From 472e5ee7c92116a3afae2dab223922371e5334e1 Mon Sep 17 00:00:00 2001 From: Sarah Etter Date: Fri, 13 Feb 2026 09:55:51 -0500 Subject: [PATCH 1/7] feat: fix handling of gitlab repos --- package-lock.json | 1 + src/utils/init/config-manual.ts | 4 +- tests/unit/utils/get-repo-data.test.ts | 120 +++++++++++++ tests/unit/utils/init/config-manual.test.ts | 179 ++++++++++++++++++++ 4 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 tests/unit/utils/get-repo-data.test.ts create mode 100644 tests/unit/utils/init/config-manual.test.ts diff --git a/package-lock.json b/package-lock.json index 850fc986971..2b8d045d074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5813,6 +5813,7 @@ }, "node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": { "version": "1.1.0", + "extraneous": true, "inBundle": true, "license": "MIT" }, diff --git a/src/utils/init/config-manual.ts b/src/utils/init/config-manual.ts index 7b8c6779a3e..90ff5477fee 100644 --- a/src/utils/init/config-manual.ts +++ b/src/utils/init/config-manual.ts @@ -88,8 +88,8 @@ export default async function configManual({ const repoPath = await getRepoPath({ repoData }) const repo = { - provider: 'manual', - repo_path: repoPath, + provider: repoData.provider ?? 'manual', + repo_path: repoData.repo ?? repoPath, repo_branch: repoData.branch, allowed_branches: [repoData.branch], deploy_key_id: deployKey.id, diff --git a/tests/unit/utils/get-repo-data.test.ts b/tests/unit/utils/get-repo-data.test.ts new file mode 100644 index 00000000000..feaa1c8fb36 --- /dev/null +++ b/tests/unit/utils/get-repo-data.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RepoData } from '../../../src/utils/get-repo-data.js' + +vi.mock('../../../src/utils/command-helpers.js', () => ({ + log: vi.fn(), +})) + +describe('getRepoData', () => { + describe('RepoData structure for different Git providers', () => { + it('should construct correct httpsUrl for GitHub SSH URLs', () => { + const mockRepoData: RepoData = { + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'git@github.com:ownername/test.git', + branch: 'main', + provider: 'github', + httpsUrl: 'https://github.com/ownername/test', + } + + expect(mockRepoData.httpsUrl).toBe('https://github.com/ownername/test') + expect(mockRepoData.provider).toBe('github') + expect(mockRepoData.repo).toBe('ownername/test') + }) + + it('should construct correct httpsUrl for GitLab SSH URLs', () => { + const mockRepoData: RepoData = { + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'git@gitlab.com:ownername/test.git', + branch: 'main', + provider: 'gitlab', + httpsUrl: 'https://gitlab.com/ownername/test', + } + + expect(mockRepoData.httpsUrl).toBe('https://gitlab.com/ownername/test') + expect(mockRepoData.provider).toBe('gitlab') + expect(mockRepoData.repo).toBe('ownername/test') + }) + + it('should construct correct httpsUrl for GitHub HTTPS URLs', () => { + const mockRepoData: RepoData = { + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'https://github.com/ownername/test.git', + branch: 'main', + provider: 'github', + httpsUrl: 'https://github.com/ownername/test', + } + + expect(mockRepoData.httpsUrl).toBe('https://github.com/ownername/test') + expect(mockRepoData.provider).toBe('github') + expect(mockRepoData.repo).toBe('ownername/test') + }) + + it('should construct correct httpsUrl for GitLab HTTPS URLs', () => { + const mockRepoData: RepoData = { + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'https://gitlab.com/ownername/test.git', + branch: 'main', + provider: 'gitlab', + httpsUrl: 'https://gitlab.com/ownername/test', + } + + expect(mockRepoData.httpsUrl).toBe('https://gitlab.com/ownername/test') + expect(mockRepoData.provider).toBe('gitlab') + expect(mockRepoData.repo).toBe('ownername/test') + }) + + it('should use host as provider for unknown Git hosts', () => { + const mockRepoData: RepoData = { + name: 'test', + owner: 'user', + repo: 'user/test', + url: 'git@custom-git.example.com:user/test.git', + branch: 'main', + provider: 'custom-git.example.com', + httpsUrl: 'https://custom-git.example.com/user/test', + } + + expect(mockRepoData.httpsUrl).toBe('https://custom-git.example.com/user/test') + expect(mockRepoData.provider).toBe('custom-git.example.com') + expect(mockRepoData.repo).toBe('user/test') + }) + }) + + describe('provider field mapping', () => { + it('should map github.com to "github" provider', () => { + const mockRepoData: RepoData = { + name: 'test', + owner: 'user', + repo: 'user/test', + url: 'git@github.com:user/test.git', + branch: 'main', + provider: 'github', + httpsUrl: 'https://github.com/user/test', + } + + expect(mockRepoData.provider).toBe('github') + }) + + it('should map gitlab.com to "gitlab" provider', () => { + const mockRepoData: RepoData = { + name: 'test', + owner: 'user', + repo: 'user/test', + url: 'git@gitlab.com:user/test.git', + branch: 'main', + provider: 'gitlab', + httpsUrl: 'https://gitlab.com/user/test', + } + + expect(mockRepoData.provider).toBe('gitlab') + }) + }) +}) diff --git a/tests/unit/utils/init/config-manual.test.ts b/tests/unit/utils/init/config-manual.test.ts new file mode 100644 index 00000000000..099486385de --- /dev/null +++ b/tests/unit/utils/init/config-manual.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach, type Mock } from 'vitest' +import type { RepoData } from '../../../../src/utils/get-repo-data.js' +import type { NetlifyAPI } from '@netlify/api' + +const mockPrompt = vi.fn() +const mockLog = vi.fn() +const mockExit = vi.fn() +const mockCreateDeployKey = vi.fn() +const mockGetBuildSettings = vi.fn() +const mockSaveNetlifyToml = vi.fn() +const mockSetupSite = vi.fn() + +vi.mock('inquirer', () => ({ + default: { + prompt: mockPrompt, + }, +})) + +vi.mock('../../../../src/utils/command-helpers.js', () => ({ + log: mockLog, + exit: mockExit, +})) + +vi.mock('../../../../src/utils/init/utils.js', () => ({ + createDeployKey: mockCreateDeployKey, + getBuildSettings: mockGetBuildSettings, + saveNetlifyToml: mockSaveNetlifyToml, + setupSite: mockSetupSite, +})) + +describe('config-manual', () => { + let mockApi: Partial + let mockCommand: any + + beforeEach(() => { + vi.clearAllMocks() + + mockApi = {} + mockCommand = { + netlify: { + api: mockApi, + cachedConfig: { configPath: '/test/netlify.toml' }, + config: { plugins: [] }, + repositoryRoot: '/test', + }, + } + + mockPrompt.mockResolvedValue({ + sshKeyAdded: true, + repoPath: 'git@gitlab.com:test/repo.git', + deployHookAdded: true, + }) + + mockCreateDeployKey.mockResolvedValue({ id: 'key-123', public_key: 'ssh-rsa test' }) + mockGetBuildSettings.mockResolvedValue({ + baseDir: '', + buildCmd: 'npm run build', + buildDir: 'dist', + functionsDir: 'functions', + pluginsToInstall: [], + }) + mockSaveNetlifyToml.mockResolvedValue(undefined) + mockSetupSite.mockResolvedValue({ deploy_hook: 'https://api.netlify.com/hooks/test' }) + }) + + describe('GitLab repository configuration', () => { + it('should use provider from repoData for GitLab repos', async () => { + const configManual = (await import('../../../../src/utils/init/config-manual.js')).default + + const repoData: RepoData = { + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'git@gitlab.com:ownername/test.git', + branch: 'main', + provider: 'gitlab', + httpsUrl: 'https://gitlab.com/ownername/test', + } + + await configManual({ + command: mockCommand, + repoData, + siteId: 'site-123', + }) + + expect(mockSetupSite).toHaveBeenCalledWith( + expect.objectContaining({ + repo: expect.objectContaining({ + provider: 'gitlab', + repo_path: 'ownername/test', + }), + }), + ) + }) + + it('should use repo path (owner/name format) instead of SSH URL for GitLab', async () => { + const configManual = (await import('../../../../src/utils/init/config-manual.js')).default + + const repoData: RepoData = { + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'git@gitlab.com:ownername/test.git', + branch: 'main', + provider: 'gitlab', + httpsUrl: 'https://gitlab.com/ownername/test', + } + + await configManual({ + command: mockCommand, + repoData, + siteId: 'site-123', + }) + + const setupSiteCall = (mockSetupSite as Mock).mock.calls[0][0] + expect(setupSiteCall.repo.repo_path).toBe('ownername/test') + expect(setupSiteCall.repo.repo_path).not.toBe('git@gitlab.com:ownername/test.git') + }) + + it('should fallback to manual provider when provider is null', async () => { + const configManual = (await import('../../../../src/utils/init/config-manual.js')).default + + const repoData: RepoData = { + name: 'test', + owner: 'user', + repo: 'user/test', + url: 'git@custom.com:user/test.git', + branch: 'main', + provider: null, + httpsUrl: 'https://custom.com/user/test', + } + + await configManual({ + command: mockCommand, + repoData, + siteId: 'site-123', + }) + + expect(mockSetupSite).toHaveBeenCalledWith( + expect.objectContaining({ + repo: expect.objectContaining({ + provider: 'manual', + }), + }), + ) + }) + }) + + describe('GitHub repository configuration', () => { + it('should use provider from repoData for GitHub repos', async () => { + const configManual = (await import('../../../../src/utils/init/config-manual.js')).default + + const repoData: RepoData = { + name: 'test', + owner: 'user', + repo: 'user/test', + url: 'git@github.com:user/test.git', + branch: 'main', + provider: 'github', + httpsUrl: 'https://github.com/user/test', + } + + await configManual({ + command: mockCommand, + repoData, + siteId: 'site-123', + }) + + expect(mockSetupSite).toHaveBeenCalledWith( + expect.objectContaining({ + repo: expect.objectContaining({ + provider: 'github', + repo_path: 'user/test', + }), + }), + ) + }) + }) +}) From a2b718198f7bbf955f7114fd80836f881e94f968 Mon Sep 17 00:00:00 2001 From: Sarah Etter Date: Fri, 13 Feb 2026 10:09:42 -0500 Subject: [PATCH 2/7] fix: types --- tests/unit/utils/init/config-manual.test.ts | 56 ++++++++------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/tests/unit/utils/init/config-manual.test.ts b/tests/unit/utils/init/config-manual.test.ts index 099486385de..0cdc807d28f 100644 --- a/tests/unit/utils/init/config-manual.test.ts +++ b/tests/unit/utils/init/config-manual.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it, vi, beforeEach, type Mock } from 'vitest' +import { describe, expect, it, vi, beforeEach } from 'vitest' import type { RepoData } from '../../../../src/utils/get-repo-data.js' import type { NetlifyAPI } from '@netlify/api' +import type BaseCommand from '../../../../src/commands/base-command.js' const mockPrompt = vi.fn() const mockLog = vi.fn() @@ -30,7 +31,7 @@ vi.mock('../../../../src/utils/init/utils.js', () => ({ describe('config-manual', () => { let mockApi: Partial - let mockCommand: any + let mockCommand: Pick beforeEach(() => { vi.clearAllMocks() @@ -38,11 +39,11 @@ describe('config-manual', () => { mockApi = {} mockCommand = { netlify: { - api: mockApi, - cachedConfig: { configPath: '/test/netlify.toml' }, - config: { plugins: [] }, + api: mockApi as NetlifyAPI, + cachedConfig: { configPath: '/test/netlify.toml' } as BaseCommand['netlify']['cachedConfig'], + config: { plugins: [] } as BaseCommand['netlify']['config'], repositoryRoot: '/test', - }, + } as BaseCommand['netlify'], } mockPrompt.mockResolvedValue({ @@ -78,19 +79,14 @@ describe('config-manual', () => { } await configManual({ - command: mockCommand, + command: mockCommand as BaseCommand, repoData, siteId: 'site-123', }) - expect(mockSetupSite).toHaveBeenCalledWith( - expect.objectContaining({ - repo: expect.objectContaining({ - provider: 'gitlab', - repo_path: 'ownername/test', - }), - }), - ) + const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string; repo_path: string } } + expect(setupCall.repo.provider).toBe('gitlab') + expect(setupCall.repo.repo_path).toBe('ownername/test') }) it('should use repo path (owner/name format) instead of SSH URL for GitLab', async () => { @@ -107,12 +103,14 @@ describe('config-manual', () => { } await configManual({ - command: mockCommand, + command: mockCommand as BaseCommand, repoData, siteId: 'site-123', }) - const setupSiteCall = (mockSetupSite as Mock).mock.calls[0][0] + const setupSiteCall = mockSetupSite.mock.calls[0][0] as { + repo: { repo_path: string } + } expect(setupSiteCall.repo.repo_path).toBe('ownername/test') expect(setupSiteCall.repo.repo_path).not.toBe('git@gitlab.com:ownername/test.git') }) @@ -131,18 +129,13 @@ describe('config-manual', () => { } await configManual({ - command: mockCommand, + command: mockCommand as BaseCommand, repoData, siteId: 'site-123', }) - expect(mockSetupSite).toHaveBeenCalledWith( - expect.objectContaining({ - repo: expect.objectContaining({ - provider: 'manual', - }), - }), - ) + const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string } } + expect(setupCall.repo.provider).toBe('manual') }) }) @@ -161,19 +154,14 @@ describe('config-manual', () => { } await configManual({ - command: mockCommand, + command: mockCommand as BaseCommand, repoData, siteId: 'site-123', }) - expect(mockSetupSite).toHaveBeenCalledWith( - expect.objectContaining({ - repo: expect.objectContaining({ - provider: 'github', - repo_path: 'user/test', - }), - }), - ) + const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string; repo_path: string } } + expect(setupCall.repo.provider).toBe('github') + expect(setupCall.repo.repo_path).toBe('user/test') }) }) }) From bcddb6224a516ca9d5f02ece3bb54cd40a4caced Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 18 Feb 2026 11:49:37 -0500 Subject: [PATCH 3/7] chore: improve reliability of deploy integration tests (#7951) * ci: create integration test sites in testing account This [mechanism already exists](https://github.com/netlify/cli/blob/b80b98f85929803fc35a08458c9327dc7ef63de0/tests/integration/utils/create-live-test-site.ts#L22-L36) but wasn't being used here, so it was falling back to the first account the user has access to that is returned by the accounts API. The `netlify-integration-testing` account is properly configured to host our various integration test sites, e.g. to avoid being rate limited. * ci: try DEBUG_TESTS=1 * Revert "ci: try DEBUG_TESTS=1" This reverts commit 2d186bf27ed33cf84310a1f6aa9a63b90b41bae5. * chore: bump deploy integration test timeout and concurrency --- .github/workflows/integration-tests.yml | 1 + tests/integration/commands/deploy/deploy.test.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 3045479171a..9cb43bbc6eb 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -69,6 +69,7 @@ jobs: # We set a flag so we can skip tests that access Netlify API NETLIFY_TEST_DISABLE_LIVE: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} + NETLIFY_TEST_ACCOUNT_SLUG: 'netlify-integration-testing' NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} # NETLIFY_TEST_GITHUB_TOKEN is used to avoid reaching GitHub API limits in exec-fetcher.js NETLIFY_TEST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index bb7bb5e9086..87e8e22b026 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'url' import { load } from 'cheerio' import execa from 'execa' import fetch from 'node-fetch' -import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest' import { callCli } from '../../utils/call-cli.js' import { createLiveTestSite, generateSiteName } from '../../utils/create-live-test-site.js' @@ -86,7 +86,13 @@ const context: { account: unknown; siteId: string } = { account: undefined, } -describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('commands/deploy', () => { +const disableLiveTests = process.env.NETLIFY_TEST_DISABLE_LIVE === 'true' + +// Running multiple entire build + deploy cycles concurrently results in a lot of network requests that may +// cause resource contention anyway, so lower the default concurrency from 5 to 3. +vi.setConfig({ maxConcurrency: 3 }) + +describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_000 }, () => { beforeAll(async () => { const { account, siteId } = await createLiveTestSite(SITE_NAME) context.siteId = siteId From 7fda9dd4f2ddef02601ec37fb4b0242875a1653d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Thu, 19 Feb 2026 11:44:46 +0000 Subject: [PATCH 4/7] feat: begin integrating `@netlify/dev` (#7950) * feat: begin integrating `@netlify/dev` * chore: add deps * fix(deps): bump netlify packages to dedupe with @netlify/dev * chore: update deps * chore: fix lint issue * chore: add debug logging * chore: add comment Co-authored-by: Philippe Serhal * chore: cap site name length in tests * chore: update snapshot --------- Co-authored-by: Philippe Serhal --- package-lock.json | 455 ++++++++++-------- package.json | 13 +- src/commands/blobs/blobs-set.ts | 1 + src/commands/dev/dev.ts | 19 +- src/commands/dev/programmatic-netlify-dev.ts | 62 +++ .../framework-detection.test.ts.snap | 4 +- .../dev/dev.programmatic-netlify-dev.test.ts | 69 +++ tests/integration/utils/site-builder.ts | 19 +- 8 files changed, 436 insertions(+), 206 deletions(-) create mode 100644 src/commands/dev/programmatic-netlify-dev.ts create mode 100644 tests/integration/commands/dev/dev.programmatic-netlify-dev.test.ts diff --git a/package-lock.json b/package-lock.json index 53c6c6f0aaf..3aad0cba7a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,18 +11,19 @@ "license": "MIT", "dependencies": { "@fastify/static": "9.0.0", - "@netlify/ai": "0.3.4", + "@netlify/ai": "0.3.8", "@netlify/api": "14.0.14", - "@netlify/blobs": "10.1.0", + "@netlify/blobs": "10.7.0", "@netlify/build": "35.7.1", "@netlify/build-info": "10.3.0", "@netlify/config": "24.4.0", - "@netlify/dev-utils": "4.3.2", + "@netlify/dev": "4.11.2", + "@netlify/dev-utils": "4.3.3", "@netlify/edge-bundler": "14.9.8", "@netlify/edge-functions": "3.0.3", "@netlify/edge-functions-bootstrap": "2.17.1", "@netlify/headers-parser": "9.0.2", - "@netlify/images": "1.2.5", + "@netlify/images": "1.3.3", "@netlify/local-functions-proxy": "2.0.3", "@netlify/redirect-parser": "15.0.3", "@netlify/zip-it-and-ship-it": "14.3.2", @@ -115,8 +116,8 @@ "@bugsnag/js": "8.6.0", "@eslint/compat": "1.4.1", "@eslint/js": "9.36.0", - "@netlify/functions": "5.1.0", - "@netlify/types": "2.2.0", + "@netlify/functions": "5.1.2", + "@netlify/types": "2.3.0", "@sindresorhus/slugify": "3.0.0", "@tsconfig/node18": "18.2.4", "@tsconfig/recommended": "1.0.13", @@ -2478,17 +2479,14 @@ } }, "node_modules/@netlify/ai": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@netlify/ai/-/ai-0.3.4.tgz", - "integrity": "sha512-mV0RtkO5dOwbuqRn/Sn0aHIV4j6sw8B4F16WCx0GYBRcJ9IbBkzvuEzW0IDUbNE6hxu9FFs5WRDASDJpgDY1ZQ==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@netlify/ai/-/ai-0.3.8.tgz", + "integrity": "sha512-qz8XDb/82UzsUMKn+sB84V3ZGqeNQOvGwNo840nHIV9saJwLPTd+FOqSUoKUIxZphNA7kQ0uGeadSUkJzDz7og==", "dependencies": { - "@netlify/api": "^14.0.11" + "@netlify/api": "^14.0.14" }, "engines": { "node": ">=20.6.1" - }, - "peerDependencies": { - "@netlify/api": ">=14.0.11" } }, "node_modules/@netlify/api": { @@ -2539,84 +2537,19 @@ "integrity": "sha512-4wMPu9iN3/HL97QblBsBay3E1etIciR84izI3U+4iALY+JHCrI+a2jO0qbAZ/nxKoegypYEaiiqWXylm+/zfrw==" }, "node_modules/@netlify/blobs": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.1.0.tgz", - "integrity": "sha512-dFpqDc6/x5LEu9L7kblCQu00CFEchH8J42jmQoXPuhKoE7avajzeLTbVKA8Olk3S/c2m9ejegrgbhL8NRA2Jyw==", + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.7.0.tgz", + "integrity": "sha512-wuiaKRbRLG/L049yR+7/p7xSKa4jx6JRBnweRYwP6mMYn9D+x/wccPgsxEMtKqthmow6frs7ZSrNYTt9U3yUdQ==", "license": "MIT", "dependencies": { - "@netlify/dev-utils": "4.3.0", - "@netlify/runtime-utils": "2.2.0" + "@netlify/dev-utils": "4.3.3", + "@netlify/otel": "^5.1.1", + "@netlify/runtime-utils": "2.3.0" }, "engines": { "node": "^14.16.0 || >=16.0.0" } }, - "node_modules/@netlify/blobs/node_modules/@netlify/dev-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.0.tgz", - "integrity": "sha512-vZAL8pMuj3yPQlmHSgyaA/UQFxc6pZgU0LucFJ1+IPWGJtIzBXHRvuR4acpoP72HtyQPUHJ42s7U9GaaSGVNHg==", - "license": "MIT", - "dependencies": { - "@whatwg-node/server": "^0.10.0", - "ansis": "^4.1.0", - "chokidar": "^4.0.1", - "decache": "^4.6.2", - "dettle": "^1.0.5", - "dot-prop": "9.0.0", - "empathic": "^2.0.0", - "env-paths": "^3.0.0", - "image-size": "^2.0.2", - "js-image-generator": "^1.0.4", - "parse-gitignore": "^2.0.0", - "semver": "^7.7.2", - "tmp-promise": "^3.0.3", - "uuid": "^11.1.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || >=20" - } - }, - "node_modules/@netlify/blobs/node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@netlify/blobs/node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@netlify/blobs/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/@netlify/build": { "version": "35.7.1", "resolved": "https://registry.npmjs.org/@netlify/build/-/build-35.7.1.tgz", @@ -2746,68 +2679,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@netlify/build/node_modules/@netlify/blobs": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.6.0.tgz", - "integrity": "sha512-orUfaNjUg0SDCRt/Zhtl1v3nCjYWb1NVqKwbB92lqpJWpHRZezxFViOoUoxv5UgHaXtjxgLitE24lL3hUm1bmg==", - "license": "MIT", - "dependencies": { - "@netlify/dev-utils": "4.3.3", - "@netlify/otel": "^5.1.1", - "@netlify/runtime-utils": "2.3.0" - }, - "engines": { - "node": "^14.16.0 || >=16.0.0" - } - }, - "node_modules/@netlify/build/node_modules/@netlify/dev-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.3.tgz", - "integrity": "sha512-qziF8R9kf7mRNgSpmUH96O0aV1ZiwK4c9ZecFQbDSQuYhgy9GY1WTjiQF0oQnohjTjWNtXhrU39LAeXWNLaBJg==", - "license": "MIT", - "dependencies": { - "@whatwg-node/server": "^0.10.0", - "ansis": "^4.1.0", - "chokidar": "^4.0.1", - "decache": "^4.6.2", - "dettle": "^1.0.5", - "dot-prop": "9.0.0", - "empathic": "^2.0.0", - "env-paths": "^3.0.0", - "image-size": "^2.0.2", - "js-image-generator": "^1.0.4", - "parse-gitignore": "^2.0.0", - "semver": "^7.7.2", - "tmp-promise": "^3.0.3", - "uuid": "^13.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || >=20" - } - }, - "node_modules/@netlify/build/node_modules/@netlify/dev-utils/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/@netlify/build/node_modules/@netlify/runtime-utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", - "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", - "license": "MIT", - "engines": { - "node": "^18.14.0 || >=20" - } - }, "node_modules/@netlify/build/node_modules/@sindresorhus/slugify": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", @@ -2824,33 +2695,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@netlify/build/node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@netlify/build/node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@netlify/build/node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -3007,6 +2851,18 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/@netlify/cache": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@netlify/cache/-/cache-3.3.5.tgz", + "integrity": "sha512-u4wx2se/wRvLsU/sQlT5ruofEwMjo5kg6ybEdQLuIswH6+6+9BCFF8VX4ByBP3MZJl3/pxExmcPiFqo0TBP3tg==", + "license": "MIT", + "dependencies": { + "@netlify/runtime-utils": "2.3.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, "node_modules/@netlify/cache-utils": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@netlify/cache-utils/-/cache-utils-6.0.4.tgz", @@ -3231,10 +3087,64 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@netlify/db-dev": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@netlify/db-dev/-/db-dev-0.2.0.tgz", + "integrity": "sha512-EDRRU26K3RrVf0yKvV+i2ShaNM3Ow4M0/EKAejD6WJDFoJ+ML/NVRGdsX41vWNvCb9VVaRVAIeXEUwgzXR7CUw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@electric-sql/pglite": "^0.3.15", + "pg-gateway": "0.3.0-beta.4" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/db-dev/node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true + }, + "node_modules/@netlify/dev": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@netlify/dev/-/dev-4.11.2.tgz", + "integrity": "sha512-quIKbuG7xD3yiWExuZA1Xl/b3Wc+/V3QjrNT4XX5m+dA/D54J61dABf1IGwQYNZHKiF5rVL7D3AwHNAojVQzuw==", + "license": "MIT", + "dependencies": { + "@netlify/ai": "^0.3.8", + "@netlify/blobs": "10.7.0", + "@netlify/config": "^24.4.0", + "@netlify/dev-utils": "4.3.3", + "@netlify/edge-functions-dev": "1.0.11", + "@netlify/functions-dev": "1.1.12", + "@netlify/headers": "2.1.3", + "@netlify/images": "1.3.3", + "@netlify/redirects": "3.1.5", + "@netlify/runtime": "4.1.15", + "@netlify/static": "3.1.3", + "ulid": "^3.0.0" + }, + "engines": { + "node": ">=20.6.1" + }, + "peerDependencies": { + "@netlify/db-dev": "0.2.0" + }, + "peerDependenciesMeta": { + "@netlify/db-dev": { + "optional": true + } + } + }, "node_modules/@netlify/dev-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.2.tgz", - "integrity": "sha512-Nl6c5UVLbpOwvzVaT6fJycdkc3EswqFoI9c2hZ3WUUX+kQ2ojdrkFMuKcPERaGXYxrhy/uGk1CURAflG8YC2RA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.3.tgz", + "integrity": "sha512-qziF8R9kf7mRNgSpmUH96O0aV1ZiwK4c9ZecFQbDSQuYhgy9GY1WTjiQF0oQnohjTjWNtXhrU39LAeXWNLaBJg==", "license": "MIT", "dependencies": { "@whatwg-node/server": "^0.10.0", @@ -3981,28 +3891,94 @@ "integrity": "sha512-KyNJbDhK1rC5wEeI7bXPgfl8QvADMHqNy2nwNJG60EHVRXTF0zxFnOpt/p0m2C512gcMXRrKZxaOZQ032RHVbw==", "license": "MIT" }, - "node_modules/@netlify/edge-functions/node_modules/@netlify/types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.3.0.tgz", - "integrity": "sha512-5gxMWh/S7wr0uHKSTbMv4bjWmWSpwpeLYvErWeVNAPll5/QNFo9aWimMAUuh8ReLY3/fg92XAroVVu7+z27Snw==", + "node_modules/@netlify/edge-functions-dev": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions-dev/-/edge-functions-dev-1.0.11.tgz", + "integrity": "sha512-ASybM6fkopOKHorwI1TwsbBlF+eZQCMmlddWtSpyWDunKc4P7i/FEkzdTinNQvcLX7RIGOwjrxanTMEOen/Zvg==", "license": "MIT", + "dependencies": { + "@netlify/dev-utils": "4.3.3", + "@netlify/edge-bundler": "^14.9.8", + "@netlify/edge-functions": "3.0.3", + "@netlify/edge-functions-bootstrap": "2.16.0", + "@netlify/runtime-utils": "2.3.0", + "get-port": "^7.1.0" + }, "engines": { - "node": "^18.14.0 || >=20" + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/edge-functions-dev/node_modules/@netlify/edge-functions-bootstrap": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions-bootstrap/-/edge-functions-bootstrap-2.16.0.tgz", + "integrity": "sha512-v8QQihSbBHj3JxtJsHoepXALpNumD9M7egHoc8z62FYl5it34dWczkaJoFFopEyhiBVKi4K/n0ZYpdzwfujd6g==", + "license": "MIT" + }, + "node_modules/@netlify/edge-functions-dev/node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@netlify/functions": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-5.1.0.tgz", - "integrity": "sha512-LZtiQtf/QzPHIeNDZuIBxx04kmU7lCipWqZ26ejX7mYSB3yj2wvpZfF49kD8B8FoKTydSvgFmBpIcCO5FvpEXA==", - "dev": true, + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-5.1.2.tgz", + "integrity": "sha512-tpPiLSkQatuexH8AdAZ8RlALvT7ixOE9VhvpkzQGNvihcms8hzmvUDuSxQa7UneTj/sHsdirnXmnJ+nmf+Nx/w==", "license": "MIT", "dependencies": { - "@netlify/types": "2.2.0" + "@netlify/types": "2.3.0" }, "engines": { "node": ">=18.0.0" } }, + "node_modules/@netlify/functions-dev": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@netlify/functions-dev/-/functions-dev-1.1.12.tgz", + "integrity": "sha512-rWFCthzhKiuM62yPd2upDY9+y/Ww5ahEKA8+E8WS8dgQKg+5/G/Yka6zfPNpkkFM3/oNi0R7LffWn2DMxrTMPQ==", + "license": "MIT", + "dependencies": { + "@netlify/blobs": "10.7.0", + "@netlify/dev-utils": "4.3.3", + "@netlify/functions": "5.1.2", + "@netlify/zip-it-and-ship-it": "^14.3.2", + "cron-parser": "^4.9.0", + "decache": "^4.6.2", + "extract-zip": "^2.0.1", + "is-stream": "^4.0.1", + "jwt-decode": "^4.0.0", + "lambda-local": "^2.2.0", + "read-package-up": "^11.0.0", + "semver": "^7.6.3", + "source-map-support": "^0.5.21" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/functions-dev/node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@netlify/functions-utils": { "version": "6.2.22", "resolved": "https://registry.npmjs.org/@netlify/functions-utils/-/functions-utils-6.2.22.tgz", @@ -4134,6 +4110,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@netlify/headers": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@netlify/headers/-/headers-2.1.3.tgz", + "integrity": "sha512-jVjhHokAQLGI5SJA2nj8OWeNQ7ASV4m0n4aiR4PHrhM8ot385V2BbUGkSpC28M92uqP0l1cbAQaSoSOU4re8iQ==", + "license": "MIT", + "dependencies": { + "@netlify/headers-parser": "^9.0.2" + }, + "engines": { + "node": ">=20.6.1" + } + }, "node_modules/@netlify/headers-parser": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/@netlify/headers-parser/-/headers-parser-9.0.2.tgz", @@ -4151,9 +4139,9 @@ } }, "node_modules/@netlify/images": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@netlify/images/-/images-1.2.5.tgz", - "integrity": "sha512-kTcM86Zpzne46RDQJO5o0rDEryYbBpRk7+8NaWLYP6ChM13MdLYwk9nLYyh4APWB2Zx9JBvBJO3Q/lKiF20zXg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@netlify/images/-/images-1.3.3.tgz", + "integrity": "sha512-1X3fUmacCLMlPIqyeV5tdo6Wbf9aBSWobgr4DyRvg9zDV9jbKqgdN3BNbcUXmVaqfN+0iiv0k9p02mcRV3OyOw==", "license": "MIT", "dependencies": { "ipx": "^3.1.1" @@ -4432,6 +4420,22 @@ "node": ">=18.14.0" } }, + "node_modules/@netlify/redirects": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@netlify/redirects/-/redirects-3.1.5.tgz", + "integrity": "sha512-yU4YBqRYoqPobg/u96QI07IuevAc8+tVLAcnty6/vBJAlo5d7E72r+U6dez48EPGIJHY5hEQK4jT0m9SmKg8mg==", + "license": "MIT", + "dependencies": { + "@netlify/dev-utils": "4.3.3", + "@netlify/redirect-parser": "^15.0.3", + "cookie": "^1.0.2", + "jsonwebtoken": "9.0.3", + "netlify-redirector": "^0.5.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, "node_modules/@netlify/run-utils": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@netlify/run-utils/-/run-utils-6.0.2.tgz", @@ -4545,10 +4549,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@netlify/runtime": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/@netlify/runtime/-/runtime-4.1.15.tgz", + "integrity": "sha512-drX5NYNnqAMKnYsStRT8Q1ruNqd68QdMGdakdMtMb/aTaAtPCIug66BPP98YSWvgv9r7O5eO4NX/Ma7UkMVwvQ==", + "license": "MIT", + "dependencies": { + "@netlify/blobs": "^10.7.0", + "@netlify/cache": "3.3.5", + "@netlify/runtime-utils": "2.3.0", + "@netlify/types": "2.3.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, "node_modules/@netlify/runtime-utils": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.2.0.tgz", - "integrity": "sha512-K3kWIxIMucibzQsATU2xw2JI+OpS9PZfPW/a+81gmeLC8tLv5YAxTVT0NFY/3imk1kcOJb9g7658jPLqDJaiAw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", + "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", "license": "MIT", "engines": { "node": "^18.14.0 || >=20" @@ -4563,11 +4582,47 @@ "node": ">=18.0.0" } }, + "node_modules/@netlify/static": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@netlify/static/-/static-3.1.3.tgz", + "integrity": "sha512-88VG2jwWY1eOT/IiMbkrak7qyo+t7om0v731i63JiCDfXjCEp+yFPNr9L4v8S6wcCmgnkGQ6Sr5roF1sEtp6+Q==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/static/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@netlify/static/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@netlify/types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.2.0.tgz", - "integrity": "sha512-XOWlZ2wPpdRKkAOcQbjIf/Qz7L4RjcSVINVNQ9p3F6U8V6KSEOsB3fPrc6Ly8EOeJioHUepRPuzHzJE/7V5EsA==", - "dev": true, + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.3.0.tgz", + "integrity": "sha512-5gxMWh/S7wr0uHKSTbMv4bjWmWSpwpeLYvErWeVNAPll5/QNFo9aWimMAUuh8ReLY3/fg92XAroVVu7+z27Snw==", "license": "MIT", "engines": { "node": "^18.14.0 || >=20" @@ -16965,6 +17020,14 @@ "dev": true, "license": "MIT" }, + "node_modules/pg-gateway": { + "version": "0.3.0-beta.4", + "resolved": "https://registry.npmjs.org/pg-gateway/-/pg-gateway-0.3.0-beta.4.tgz", + "integrity": "sha512-CTjsM7Z+0Nx2/dyZ6r8zRsc3f9FScoD5UAOlfUx1Fdv/JOIWvRbF7gou6l6vP+uypXQVoYPgw8xZDXgMGvBa4Q==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 4476e3adb2c..b2c95d02913 100644 --- a/package.json +++ b/package.json @@ -58,18 +58,19 @@ }, "dependencies": { "@fastify/static": "9.0.0", - "@netlify/ai": "0.3.4", + "@netlify/ai": "0.3.8", "@netlify/api": "14.0.14", - "@netlify/blobs": "10.1.0", + "@netlify/blobs": "10.7.0", "@netlify/build": "35.7.1", "@netlify/build-info": "10.3.0", "@netlify/config": "24.4.0", - "@netlify/dev-utils": "4.3.2", + "@netlify/dev": "4.11.2", + "@netlify/dev-utils": "4.3.3", "@netlify/edge-bundler": "14.9.8", "@netlify/edge-functions": "3.0.3", "@netlify/edge-functions-bootstrap": "2.17.1", "@netlify/headers-parser": "9.0.2", - "@netlify/images": "1.2.5", + "@netlify/images": "1.3.3", "@netlify/local-functions-proxy": "2.0.3", "@netlify/redirect-parser": "15.0.3", "@netlify/zip-it-and-ship-it": "14.3.2", @@ -158,8 +159,8 @@ "@bugsnag/js": "8.6.0", "@eslint/compat": "1.4.1", "@eslint/js": "9.36.0", - "@netlify/functions": "5.1.0", - "@netlify/types": "2.2.0", + "@netlify/functions": "5.1.2", + "@netlify/types": "2.3.0", "@sindresorhus/slugify": "3.0.0", "@tsconfig/node18": "18.2.4", "@tsconfig/recommended": "1.0.13", diff --git a/src/commands/blobs/blobs-set.ts b/src/commands/blobs/blobs-set.ts index 6ecc4d94e1d..9378007c9b0 100644 --- a/src/commands/blobs/blobs-set.ts +++ b/src/commands/blobs/blobs-set.ts @@ -60,6 +60,7 @@ export const blobsSet = async ( if (force === undefined) { const existingValue = await store.get(key) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (existingValue) { await promptBlobSetOverwrite(key, storeName) } diff --git a/src/commands/dev/dev.ts b/src/commands/dev/dev.ts index e92b994689e..13d71ff7b71 100644 --- a/src/commands/dev/dev.ts +++ b/src/commands/dev/dev.ts @@ -21,7 +21,13 @@ import { import detectServerSettings, { getConfigWithPlugins } from '../../utils/detect-server-settings.js' import { parseAIGatewayContext, setupAIGateway } from '@netlify/ai/bootstrap' -import { UNLINKED_SITE_MOCK_ID, getDotEnvVariables, getSiteInformation, injectEnvVariables } from '../../utils/dev.js' +import { + UNLINKED_SITE_MOCK_ID, + getDotEnvVariables, + getSiteInformation, + injectEnvVariables, + processOnExit, +} from '../../utils/dev.js' import { getEnvelopeEnv } from '../../utils/env/index.js' import { ensureNetlifyIgnore } from '../../utils/gitignore.js' import { getLiveTunnelSlug, startLiveTunnel } from '../../utils/live-tunnel.js' @@ -35,6 +41,7 @@ import { getBaseOptionValues } from '../base-command.js' import type { NetlifySite } from '../types.js' import type { DevConfig } from './types.js' +import { startNetlifyDev as startProgrammaticNetlifyDev } from './programmatic-netlify-dev.js' import { doesProjectRequireLinkedSite } from '../../lib/extensions.js' const handleLiveTunnel = async ({ @@ -174,6 +181,16 @@ export const dev = async (options: OptionValues, command: BaseCommand) => { injectEnvVariables(env) + const programmaticNetlifyDev = await startProgrammaticNetlifyDev({ + projectRoot: command.workingDir, + apiToken: api.accessToken ?? undefined, + env, + }) + + if (programmaticNetlifyDev) { + processOnExit(() => programmaticNetlifyDev.stop()) + } + await promptEditorHelper({ chalk, config, log, NETLIFYDEVLOG, repositoryRoot, state }) let settings: ServerSettings diff --git a/src/commands/dev/programmatic-netlify-dev.ts b/src/commands/dev/programmatic-netlify-dev.ts new file mode 100644 index 00000000000..deab3d1cfd5 --- /dev/null +++ b/src/commands/dev/programmatic-netlify-dev.ts @@ -0,0 +1,62 @@ +import process from 'process' + +import { NetlifyDev } from '@netlify/dev' + +import { NETLIFYDEVWARN, log } from '../../utils/command-helpers.js' +import type { EnvironmentVariables } from '../../utils/types.js' + +interface StartNetlifyDevOptions { + projectRoot: string + apiToken: string | undefined + env: EnvironmentVariables +} + +/** + * Much of the core of local dev emulation of the Netlify platform was extracted + * (duplicated) to https://github.com/netlify/primitives. This is a shim that + * gradually enables *some* of this extracted functionality while falling back + * to the legacy copy in this codebase for the rest. + * + * TODO: Hook this up to the request chain and fall through to the existing handler. + * TODO: `@netlify/images` follows a different pattern (it is used directly). + * Move that here. + */ +export const startNetlifyDev = async ({ + apiToken, + env, + projectRoot, +}: StartNetlifyDevOptions): Promise => { + if (process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED !== '1') { + return + } + + const netlifyDev = new NetlifyDev({ + projectRoot, + apiToken, + ...(process.env.NETLIFY_API_URL && { apiURL: process.env.NETLIFY_API_URL }), + + aiGateway: { enabled: false }, + blobs: { enabled: false }, + edgeFunctions: { enabled: false }, + environmentVariables: { enabled: false }, + functions: { enabled: false }, + geolocation: { enabled: false }, + headers: { enabled: false }, + images: { enabled: false }, + redirects: { enabled: false }, + staticFiles: { enabled: false }, + serverAddress: null, + }) + + try { + await netlifyDev.start() + } catch (error) { + log(`${NETLIFYDEVWARN} Failed to start @netlify/dev: ${error instanceof Error ? error.message : String(error)}`) + } + + if (process.env.NETLIFY_DB_URL) { + env.NETLIFY_DB_URL = { sources: ['internal'], value: process.env.NETLIFY_DB_URL } + } + + return netlifyDev +} diff --git a/tests/integration/__snapshots__/framework-detection.test.ts.snap b/tests/integration/__snapshots__/framework-detection.test.ts.snap index 6a86a5e7cbe..829c8d25346 100644 --- a/tests/integration/__snapshots__/framework-detection.test.ts.snap +++ b/tests/integration/__snapshots__/framework-detection.test.ts.snap @@ -152,7 +152,7 @@ exports[`frameworks/framework-detection > should use static server when framewor ⬥ Unable to determine public folder to serve files from. Using current working directory ⬥ Setup a netlify.toml file with a [dev] section to specify your dev server settings. ⬥ See docs at: https://docs.netlify.com/cli/local-development/#project-detection -⬥ Running static server from \\"should-use-static-server-when-framework-is-set-to-static\\" +⬥ Running static server from \\"should-use-static-server-when-framework-i-cabde4ea\\" ⬥ Setting up local dev server ⬥ Static server listening to @@ -168,7 +168,7 @@ exports[`frameworks/framework-detection > should warn if using static server and "⬥ Using simple static server because '--dir' flag was specified ⬥ Ignoring 'targetPort' setting since using a simple static server. ⬥ Use --staticServerPort or [dev.staticServerPort] to configure the static server port -⬥ Running static server from \\"should-warn-if-using-static-server-and-target-port-is-configured/public\\" +⬥ Running static server from \\"should-warn-if-using-static-server-and-ta-45f6af30/public\\" ⬥ Setting up local dev server ⬥ Static server listening to diff --git a/tests/integration/commands/dev/dev.programmatic-netlify-dev.test.ts b/tests/integration/commands/dev/dev.programmatic-netlify-dev.test.ts new file mode 100644 index 00000000000..304c4fcef13 --- /dev/null +++ b/tests/integration/commands/dev/dev.programmatic-netlify-dev.test.ts @@ -0,0 +1,69 @@ +import fetch from 'node-fetch' +import { describe, test } from 'vitest' + +import { withDevServer } from '../../utils/dev-server.js' +import { withSiteBuilder } from '../../utils/site-builder.js' + +describe('@netlify/dev integration', () => { + test('Makes DB available to functions when EXPERIMENTAL_NETLIFY_DB_ENABLED is set', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder + .withPackageJson({ + packageJson: { + dependencies: { '@netlify/db': '0.1.0', '@netlify/db-dev': '0.2.0' }, + }, + }) + .withCommand({ command: ['npm', 'install'] }) + .withContentFile({ + path: 'netlify/functions/db-test.mjs', + content: ` + import { getDatabase } from "@netlify/db"; + + export default async () => { + try { + const { sql } = getDatabase(); + const rows = await sql\`SELECT 1 + 1 AS sum\`; + return Response.json({ sum: rows[0].sum }); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } + }; + + export const config = { path: "/db-test" }; + `, + }) + + await builder.build() + + await withDevServer({ cwd: builder.directory, env: { EXPERIMENTAL_NETLIFY_DB_ENABLED: '1' } }, async (server) => { + const response = await fetch(`${server.url}/db-test`) + const body = await response.text() + console.log(body) + t.expect(body).toEqual(JSON.stringify({ sum: 2 })) + }) + }) + }) + + test('Does not set NETLIFY_DB_URL when EXPERIMENTAL_NETLIFY_DB_ENABLED is not set', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withFunction({ + path: 'db-url.mjs', + pathPrefix: 'netlify/functions', + runtimeAPIVersion: 2, + config: { path: '/db-url' }, + handler: () => Response.json({ url: process.env.NETLIFY_DB_URL ?? '' }), + }) + + await builder.build() + + await withDevServer({ cwd: builder.directory }, async (server) => { + const response = await fetch(`${server.url}/db-url`) + const body = await response.text() + console.log(body) + + t.expect(response.status).toBe(200) + t.expect(body).toEqual(JSON.stringify({ url: '' })) + }) + }) + }) +}) diff --git a/tests/integration/utils/site-builder.ts b/tests/integration/utils/site-builder.ts index a8cca356e01..2c81e52ecd6 100644 --- a/tests/integration/utils/site-builder.ts +++ b/tests/integration/utils/site-builder.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto' import { copyFile, mkdir, rm, unlink, writeFile } from 'fs/promises' import os from 'os' import path from 'path' @@ -317,13 +318,29 @@ export class SiteBuilder { } } +// Windows has a MAX_PATH limit of 260 characters. Since test directories +// include the temp dir, process version, PID, a UUID, and the site name, +// long test names can push nested file paths over this limit. We cap the +// site name and append a hash to avoid collisions. +const MAX_SITE_NAME_LENGTH = 50 + +const truncateSiteName = (siteName: string): string => { + if (siteName.length <= MAX_SITE_NAME_LENGTH) { + return siteName + } + + const hash = createHash('sha256').update(siteName).digest('hex').slice(0, 8) + + return `${siteName.slice(0, MAX_SITE_NAME_LENGTH - 9)}-${hash}` +} + export const createSiteBuilder = ({ siteName }: { siteName: string }) => { const directory = path.join( tempDirectory, `netlify-cli-tests-${process.version}`, `${process.pid}`, uuidv4(), - siteName, + truncateSiteName(siteName), ) return new SiteBuilder(directory).ensureDirectoryExists(directory) From ac45c4dc556a5843321230bd82f4485d36d3fa09 Mon Sep 17 00:00:00 2001 From: Sarah Etter Date: Thu, 19 Feb 2026 12:33:25 -0500 Subject: [PATCH 5/7] feat!: remove `sites:create-template` command (#7946) * feat: deprecate sites:create-template * chore: format * chore: lint * docs: build docs * fix: claude says this is how we fix the PR title lint issue * fix: lol now claude says this will work * remove this * remove unneeded issuePrefixes that claude added * fix: remove a bit more dead code * fix: remove sites:create-template placeholder cmd --------- Co-authored-by: Philippe Serhal --- commitlint.config.js | 11 +- docs/commands/sites.md | 35 --- docs/index.md | 1 - src/commands/sites/sites-create-template.ts | 294 ------------------ src/commands/sites/sites.ts | 26 -- src/utils/command-helpers.ts | 23 -- src/utils/sites/create-template.ts | 72 ----- src/utils/sites/utils.ts | 79 ----- src/utils/types.ts | 14 - .../sites/sites-create-template.test.ts | 249 --------------- .../integration/commands/sites/sites.test.ts | 128 +------- 11 files changed, 11 insertions(+), 921 deletions(-) delete mode 100644 src/commands/sites/sites-create-template.ts delete mode 100644 src/utils/sites/create-template.ts delete mode 100644 src/utils/sites/utils.ts delete mode 100644 tests/integration/commands/sites/sites-create-template.test.ts diff --git a/commitlint.config.js b/commitlint.config.js index 7c4ff4d9841..ce5afa95b4b 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1 +1,10 @@ -export default { extends: ['@commitlint/config-conventional'] } +export default { + extends: ['@commitlint/config-conventional'], + parserPreset: { + parserOpts: { + headerPattern: /^(\w+)(?:\(([^)]*)\))?(!)?:\s(.+)$/, + breakingHeaderPattern: /^(\w+)(?:\(([^)]*)\))?(!)?:\s(.+)$/, + headerCorrespondence: ['type', 'scope', 'breaking', 'subject'], + }, + }, +} diff --git a/docs/commands/sites.md b/docs/commands/sites.md index d11c84a135c..94ef29bcad2 100644 --- a/docs/commands/sites.md +++ b/docs/commands/sites.md @@ -26,7 +26,6 @@ netlify sites | Subcommand | description | |:--------------------------- |:-----| | [`sites:create`](/commands/sites#sitescreate) | Create an empty project (advanced) | -| [`sites:create-template`](/commands/sites#sitescreate-template) | (Beta) Create a project from a starter template | | [`sites:delete`](/commands/sites#sitesdelete) | Delete a project | | [`sites:list`](/commands/sites#siteslist) | List all projects you have access to | @@ -61,40 +60,6 @@ netlify sites:create - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `with-ci` (*boolean*) - initialize CI hooks during project creation ---- -## `sites:create-template` - -(Beta) Create a project from a starter template -Create a project from a starter template. - -**Usage** - -```bash -netlify sites:create-template -``` - -**Arguments** - -- repository - repository to use as starter template - -**Flags** - -- `account-slug` (*string*) - account slug to create the project under -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `name` (*string*) - name of project -- `url` (*string*) - template url -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `with-ci` (*boolean*) - initialize CI hooks during project creation - -**Examples** - -```bash -netlify sites:create-template -netlify sites:create-template nextjs-blog-theme -netlify sites:create-template my-github-profile/my-template -``` - --- ## `sites:delete` diff --git a/docs/index.md b/docs/index.md index ee86844d08a..a306d1b873d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -165,7 +165,6 @@ Handle various project operations | Subcommand | description | |:--------------------------- |:-----| | [`sites:create`](/commands/sites#sitescreate) | Create an empty project (advanced) | -| [`sites:create-template`](/commands/sites#sitescreate-template) | (Beta) Create a project from a starter template | | [`sites:delete`](/commands/sites#sitesdelete) | Delete a project | | [`sites:list`](/commands/sites#siteslist) | List all projects you have access to | diff --git a/src/commands/sites/sites-create-template.ts b/src/commands/sites/sites-create-template.ts deleted file mode 100644 index 87057e9ea19..00000000000 --- a/src/commands/sites/sites-create-template.ts +++ /dev/null @@ -1,294 +0,0 @@ -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import type { OptionValues } from 'commander' -import inquirer from 'inquirer' -import pick from 'lodash/pick.js' -import { render } from 'prettyjson' -import { v4 as uuid } from 'uuid' - -import { - chalk, - logAndThrowError, - getTerminalLink, - log, - logJson, - warn, - type APIError, - GitHubAPIError, - type GitHubRepoResponse, -} from '../../utils/command-helpers.js' -import execa from '../../utils/execa.js' -import getRepoData from '../../utils/get-repo-data.js' -import { getGitHubToken } from '../../utils/init/config-github.js' -import { configureRepo } from '../../utils/init/config.js' -import { deployedSiteExists, getGitHubLink, getTemplateName } from '../../utils/sites/create-template.js' -import { callLinkSite, createRepo, validateTemplate } from '../../utils/sites/utils.js' -import { track } from '../../utils/telemetry/index.js' -import type { SiteInfo } from '../../utils/types.js' -import type BaseCommand from '../base-command.js' - -import { getSiteNameInput } from './sites-create.js' - -export const sitesCreateTemplate = async (repository: string, options: OptionValues, command: BaseCommand) => { - const { accounts, api } = command.netlify - await command.authenticate() - - const { globalConfig } = command.netlify - const ghToken = await getGitHubToken({ globalConfig }) - const templateName = await getTemplateName({ ghToken, options, repository }) - const { exists, isTemplate } = await validateTemplate({ templateName, ghToken }) - if (!exists) { - const githubLink = getGitHubLink({ options, templateName }) - return logAndThrowError( - `Could not find template ${chalk.bold(templateName)}. Please verify it exists and you can ${getTerminalLink( - 'access to it on GitHub', - githubLink, - )}`, - ) - } - if (!isTemplate) { - const githubLink = getGitHubLink({ options, templateName }) - return logAndThrowError(`${getTerminalLink(chalk.bold(templateName), githubLink)} is not a valid GitHub template`) - } - - let { accountSlug } = options - - if (!accountSlug) { - const { accountSlug: accountSlugInput } = await inquirer.prompt([ - { - type: 'list', - name: 'accountSlug', - message: 'Team:', - choices: accounts.map((account) => ({ - value: account.slug, - name: account.name, - })), - }, - ]) - accountSlug = accountSlugInput - } - - const { name: nameFlag } = options - let site: SiteInfo - let repoResp: Awaited> - - // Allow the user to reenter project name if selected one isn't available - const inputSiteName = async (name?: string, hasExistingRepo?: boolean): Promise<[SiteInfo, GitHubRepoResponse]> => { - const { name: inputName } = await getSiteNameInput(name) - - const siteName = inputName.trim() - - if (siteName && (await deployedSiteExists(siteName))) { - log('A project with that name already exists') - return inputSiteName() - } - - try { - const sites = await api.listSites({ name: siteName, filter: 'all' }) - const siteFoundByName = sites.find((filteredSite) => filteredSite.name === siteName) - if (siteFoundByName) { - log('A project with that name already exists on your account') - return inputSiteName() - } - } catch (error_) { - return logAndThrowError(error_) - } - - if (!hasExistingRepo) { - try { - // Create new repo from template - let gitHubInputName = siteName || templateName - repoResp = await createRepo(templateName, ghToken, gitHubInputName) - if (repoResp.errors && repoResp.errors[0].includes('Name already exists on this account')) { - if (gitHubInputName === templateName) { - gitHubInputName += `-${uuid().split('-')[0]}` - repoResp = await createRepo(templateName, ghToken, gitHubInputName) - } else { - warn(`It seems you have already created a repository with the name ${gitHubInputName}.`) - return inputSiteName() - } - } - if (!repoResp.id) { - throw new GitHubAPIError((repoResp as GitHubAPIError).status, (repoResp as GitHubAPIError).message) - } - hasExistingRepo = true - } catch (error_) { - if ((error_ as GitHubAPIError).status === '404') { - return logAndThrowError( - `Could not create repository: ${ - (error_ as GitHubAPIError).message - }. Ensure that your GitHub personal access token grants permission to create repositories`, - ) - } else { - return logAndThrowError( - `Something went wrong trying to create the repository. We're getting the following error: '${ - (error_ as GitHubAPIError).message - }'. You can try to re-run this command again or open an issue in our repository: https://github.com/netlify/cli/issues`, - ) - } - } - } - - try { - // FIXME(serhalp): `id` and `name` should be required in `netlify` package type - site = (await api.createSiteInTeam({ - accountSlug, - body: { - repo: { - provider: 'github', - // @ts-expect-error -- FIXME(serhalp): Supposedly this is does not exist. Investigate. - repo: repoResp.full_name, - // FIXME(serhalp): Supposedly this should be `public_repo`. Investigate. - private: repoResp.private, - // FIXME(serhalp): Supposedly this should be `repo_branch`. Investigate. - branch: repoResp.default_branch, - }, - name: siteName, - }, - })) as unknown as SiteInfo - } catch (error_) { - if ((error_ as APIError).status === 422) { - log(`createSiteInTeam error: ${(error_ as APIError).status}: ${(error_ as APIError).message}`) - log('Cannot create a project with that name. Project name may already exist. Please try a new name.') - return inputSiteName(undefined, hasExistingRepo) - } - return logAndThrowError(`createSiteInTeam error: ${(error_ as APIError).status}: ${(error_ as APIError).message}`) - } - return [site, repoResp] - } - - ;[site, repoResp] = await inputSiteName(nameFlag) - - log() - log(chalk.greenBright.bold.underline(`Project Created`)) - log() - - const siteUrl = site.ssl_url || site.url - log( - render({ - 'Admin URL': site.admin_url, - URL: siteUrl, - 'Project ID': site.id, - 'Repo URL': site.build_settings?.repo_url ?? '', - }), - ) - - track('sites_createdFromTemplate', { - siteId: site.id, - adminUrl: site.admin_url, - siteUrl, - }) - - const { cloneConfirm } = await inquirer.prompt({ - type: 'confirm', - name: 'cloneConfirm', - message: `Do you want to clone the repository to your local machine?`, - default: true, - }) - if (cloneConfirm) { - log() - - if (repoResp.clone_url) { - await execa('git', ['clone', repoResp.clone_url, `${repoResp.name}`]) - } - - log(`🚀 Repository cloned successfully. You can find it under the ${chalk.magenta(repoResp.name)} folder`) - - const { linkConfirm } = await inquirer.prompt({ - type: 'confirm', - name: 'linkConfirm', - message: `Do you want to link the cloned directory to the project?`, - default: true, - }) - - if (linkConfirm) { - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - - const cliPath = path.resolve(__dirname, '../../../bin/run.js') - - let stdout - // TODO(serhalp): Why is this condition here? We've asked the user multiple prompts, but we already knew we had - // invalid repo data. Move upstream. - if (repoResp.name) { - stdout = await callLinkSite(cliPath, repoResp.name, '\n') - } else { - return logAndThrowError('Failed to fetch the repo') - } - - const linkedSiteUrlRegex = /Project url:\s+(\S+)/ - const lineMatch = linkedSiteUrlRegex.exec(stdout) - const urlMatch = lineMatch ? lineMatch[1] : undefined - if (urlMatch) { - log(`\nDirectory ${chalk.cyanBright(repoResp.name)} linked to project ${chalk.cyanBright(urlMatch)}\n`) - log( - `${chalk.cyanBright.bold('cd', repoResp.name)} to use other netlify cli commands in the cloned directory.\n`, - ) - } else { - const linkedSiteMatch = /Project already linked to\s+(\S+)/.exec(stdout) - const linkedSiteNameMatch = linkedSiteMatch ? linkedSiteMatch[1] : undefined - if (linkedSiteNameMatch) { - log(`\nThis directory appears to be linked to ${chalk.cyanBright(linkedSiteNameMatch)}`) - log('This can happen if you cloned the template into a subdirectory of an existing Netlify project.') - log( - `You may need to move the ${chalk.cyanBright( - repoResp.name, - )} directory out of its parent directory and then re-run the ${chalk.cyanBright( - 'link', - )} command manually\n`, - ) - } else { - log('A problem occurred linking the project') - log('You can try again manually by running:') - log(chalk.cyanBright(`cd ${repoResp.name} && netlify link\n`)) - } - } - } else { - log('To link the cloned directory manually, run:') - log(chalk.cyanBright(`cd ${repoResp.name} && netlify link\n`)) - } - } - - if (options.withCi) { - log('Configuring CI') - const repoData = await getRepoData({ workingDir: command.workingDir }) - - if ('error' in repoData) { - return logAndThrowError('Failed to get repo data') - } - - await configureRepo({ command, siteId: site.id, repoData, manual: options.manual }) - } - - if (options.json) { - logJson( - pick(site, [ - 'id', - 'state', - 'plan', - 'name', - 'custom_domain', - 'domain_aliases', - 'url', - 'ssl_url', - 'admin_url', - 'screenshot_url', - 'created_at', - 'updated_at', - 'user_id', - 'ssl', - 'force_ssl', - 'managed_dns', - 'deploy_url', - 'account_name', - 'account_slug', - 'git_provider', - 'deploy_hook', - 'capabilities', - 'id_domain', - ]), - ) - } - - return site -} diff --git a/src/commands/sites/sites.ts b/src/commands/sites/sites.ts index bf6913cfc3c..6b61bc6cf6a 100644 --- a/src/commands/sites/sites.ts +++ b/src/commands/sites/sites.ts @@ -1,5 +1,4 @@ import { OptionValues, InvalidArgumentError } from 'commander' - import BaseCommand from '../base-command.js' const MAX_SITE_NAME_LENGTH = 63 @@ -18,30 +17,6 @@ const sites = (_options: OptionValues, command: BaseCommand) => { command.help() } -export const createSitesFromTemplateCommand = (program: BaseCommand) => { - program - .command('sites:create-template') - .description( - `(Beta) Create a project from a starter template -Create a project from a starter template.`, - ) - .option('-n, --name [name]', 'name of project') - .option('-u, --url [url]', 'template url') - .option('-a, --account-slug [slug]', 'account slug to create the project under') - .option('-c, --with-ci', 'initialize CI hooks during project creation') - .argument('[repository]', 'repository to use as starter template') - .addHelpText('after', `(Beta) Create a project from starter template.`) - .addExamples([ - 'netlify sites:create-template', - 'netlify sites:create-template nextjs-blog-theme', - 'netlify sites:create-template my-github-profile/my-template', - ]) - .action(async (repository: string, options: OptionValues, command: BaseCommand) => { - const { sitesCreateTemplate } = await import('./sites-create-template.js') - await sitesCreateTemplate(repository, options, command) - }) -} - export const createSitesCreateCommand = (program: BaseCommand) => { program .command('sites:create') @@ -66,7 +41,6 @@ Create a blank project that isn't associated with any git remote. Will link the export const createSitesCommand = (program: BaseCommand) => { createSitesCreateCommand(program) - createSitesFromTemplateCommand(program) program .command('sites:list') diff --git a/src/utils/command-helpers.ts b/src/utils/command-helpers.ts index 6397cddaf86..73807a0fccc 100644 --- a/src/utils/command-helpers.ts +++ b/src/utils/command-helpers.ts @@ -241,29 +241,6 @@ export interface APIError extends Error { message: string } -export class GitHubAPIError extends Error { - status: string - - constructor(status: string, message: string) { - super(message) - this.status = status - this.name = 'GitHubAPIError' - } -} - -export interface GitHubRepoResponse { - status?: string - message?: string - id?: number - name?: string - clone_url?: string - full_name?: string - private?: boolean - default_branch?: string - errors?: string[] - is_template?: boolean -} - export const checkFileForLine = (filename: string, line: string) => { let filecontent = '' try { diff --git a/src/utils/sites/create-template.ts b/src/utils/sites/create-template.ts deleted file mode 100644 index bb8cc98bae1..00000000000 --- a/src/utils/sites/create-template.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { OptionValues } from 'commander' -import inquirer from 'inquirer' -import parseGitHubUrl from 'parse-github-url' - -import { log } from '../command-helpers.js' -import { Template, GitHubRepo } from '../types.js' - -import { getTemplatesFromGitHub } from './utils.js' - -export const fetchTemplates = async (token: string): Promise => { - const templatesFromGitHubOrg: GitHubRepo[] = await getTemplatesFromGitHub(token) - - return ( - templatesFromGitHubOrg - // adding this filter because the react-based-templates has multiple templates in one repo so doesn't work for this command - .filter((repo: GitHubRepo) => !repo.archived && !repo.disabled && repo.name !== 'react-based-templates') - .map((template: GitHubRepo) => ({ - name: template.name, - sourceCodeUrl: template.html_url, - slug: template.full_name, - })) - ) -} - -export const getTemplateName = async ({ - ghToken, - options, - repository, -}: { - ghToken: string - options: OptionValues - repository: string -}) => { - if (repository) { - const parsedUrl = parseGitHubUrl(repository) - return parsedUrl?.repo || `netlify-templates/${repository}` - } - - if (options.url) { - const urlFromOptions = new URL(options.url) - return urlFromOptions.pathname.slice(1) - } - - const templates = await fetchTemplates(ghToken) - - log(`Choose one of our starter templates. Netlify will create a new repo for this template in your GitHub account.`) - - const { templateName } = await inquirer.prompt([ - { - type: 'list', - name: 'templateName', - message: 'Template:', - choices: templates.map((template) => ({ - value: template.slug, - name: template.name, - })), - }, - ]) - - return templateName -} - -export const deployedSiteExists = async (name: string): Promise => { - const resp = await fetch(`https://${name}.netlify.app`, { - method: 'GET', - }) - - return resp.status === 200 -} - -export const getGitHubLink = ({ options, templateName }: { options: OptionValues; templateName: string }): string => - (options.url as string | undefined) || `https://github.com/${templateName}` diff --git a/src/utils/sites/utils.ts b/src/utils/sites/utils.ts deleted file mode 100644 index b60111a4b8a..00000000000 --- a/src/utils/sites/utils.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fetch from 'node-fetch' -import execa from 'execa' - -import { GitHubRepoResponse, logAndThrowError } from '../command-helpers.js' -import { GitHubRepo } from '../types.js' - -export const getTemplatesFromGitHub = async (token: string): Promise => { - const getPublicGitHubReposFromOrg = new URL(`https://api.github.com/orgs/netlify-templates/repos`) - // GitHub returns 30 by default and we want to avoid our limit - // due to our archived repositories at any given time - const REPOS_PER_PAGE = 70 - - getPublicGitHubReposFromOrg.searchParams.set('type', 'public') - getPublicGitHubReposFromOrg.searchParams.set('sort', 'full_name') - // @ts-expect-error TS(2345) FIXME: Argument of type 'number' is not assignable to par... Remove this comment to see the full error message - getPublicGitHubReposFromOrg.searchParams.set('per_page', REPOS_PER_PAGE) - - let allTemplates: GitHubRepo[] = [] - try { - const templates = await fetch(getPublicGitHubReposFromOrg, { - method: 'GET', - headers: { - Authorization: `token ${token}`, - }, - }) - allTemplates = (await templates.json()) as GitHubRepo[] - } catch (error_) { - return logAndThrowError(error_) - } - return allTemplates -} - -export const validateTemplate = async ({ ghToken, templateName }: { ghToken: string; templateName: string }) => { - const response = await fetch(`https://api.github.com/repos/${templateName}`, { - headers: { - Authorization: `token ${ghToken}`, - }, - }) - - if (response.status === 404) { - return { exists: false } - } - - if (!response.ok) { - throw new Error(`Error fetching template ${templateName}: ${await response.text()}`) - } - - const data = (await response.json()) as GitHubRepoResponse - - return { exists: true, isTemplate: data.is_template } -} - -export const createRepo = async ( - templateName: string, - ghToken: string, - siteName: string, -): Promise => { - const resp = await fetch(`https://api.github.com/repos/${templateName}/generate`, { - method: 'POST', - headers: { - Authorization: `token ${ghToken}`, - }, - body: JSON.stringify({ - name: siteName, - }), - }) - - const data = await resp.json() - - return data as GitHubRepoResponse -} - -export const callLinkSite = async (cliPath: string, repoName: string, input: string) => { - const { stdout } = await execa(cliPath, ['link'], { - input, - cwd: repoName, - }) - return stdout -} diff --git a/src/utils/types.ts b/src/utils/types.ts index f2c33e4b541..ec299154b6f 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -204,20 +204,6 @@ export type MinimalAccount = { members_count: number } -export interface GitHubRepo { - name: string - html_url: string - full_name: string - archived: boolean - disabled: boolean -} - -export interface Template { - name: string - sourceCodeUrl: string - slug: string -} - type EnvironmentVariableScope = 'builds' | 'functions' | 'runtime' | 'post_processing' export type EnvironmentVariableSource = 'account' | 'addons' | 'configFile' | 'general' | 'internal' | 'ui' diff --git a/tests/integration/commands/sites/sites-create-template.test.ts b/tests/integration/commands/sites/sites-create-template.test.ts deleted file mode 100644 index ccbc2105a34..00000000000 --- a/tests/integration/commands/sites/sites-create-template.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import process from 'process' - -import inquirer from 'inquirer' -import { beforeEach, afterEach, describe, expect, test, vi, afterAll } from 'vitest' - -import BaseCommand from '../../../../src/commands/base-command.js' -import { createSitesFromTemplateCommand } from '../../../../src/commands/sites/sites.js' -import { deployedSiteExists, fetchTemplates, getTemplateName } from '../../../../src/utils/sites/create-template.js' -import { - getTemplatesFromGitHub, - validateTemplate, - createRepo, - callLinkSite, -} from '../../../../src/utils/sites/utils.js' -import { getEnvironmentVariables, withMockApi } from '../../utils/mock-api.js' -import { chalk } from '../../../../src/utils/command-helpers.js' - -vi.mock('../../../../src/utils/init/config-github.ts') -vi.mock('../../../../src/utils/sites/utils.ts') -vi.mock('../../../../src/utils/sites/create-template.ts') -vi.mock('inquirer') - -inquirer.registerPrompt = vi.fn() -inquirer.prompt.registerPrompt = vi.fn() - -const siteInfo = { - admin_url: 'https://app.netlify.com/projects/site-name/overview', - ssl_url: 'https://site-name.netlify.app/', - id: 'site_id', - name: 'site-name', - build_settings: { repo_url: 'https://github.com/owner/repo' }, -} - -const routes = [ - { - path: 'accounts', - response: [{ slug: 'test-account' }], - }, - { - path: 'sites', - response: [{ name: 'test-name' }], - }, - { - path: 'test-account/sites', - response: siteInfo, - method: 'POST' as const, - }, -] - -const OLD_ENV = process.env - -describe('sites:create-template', () => { - beforeEach(async () => { - inquirer.prompt = Object.assign( - vi - .fn() - .mockImplementationOnce(() => Promise.resolve({ accountSlug: 'test-account' })) - .mockImplementationOnce(() => Promise.resolve({ name: 'test-name' })) - .mockImplementationOnce(() => Promise.resolve({ cloneConfirm: true })) - .mockImplementationOnce(() => Promise.resolve({ linkConfirm: true })), - { - prompts: inquirer.prompt?.prompts || {}, - registerPrompt: inquirer.prompt?.registerPrompt || vi.fn(), - restoreDefaultPrompts: inquirer.prompt?.restoreDefaultPrompts || vi.fn(), - }, - ) - - vi.mocked(fetchTemplates).mockResolvedValue([ - { - name: 'mockTemplateName', - sourceCodeUrl: 'mockUrl', - slug: 'mockSlug', - }, - ]) - vi.mocked(getTemplatesFromGitHub).mockResolvedValue([ - { - name: 'mock-name', - html_url: 'mock-url', - full_name: 'mock-full-name', - archived: false, - disabled: false, - }, - ]) - vi.mocked(getTemplateName).mockResolvedValue('mockTemplateName') - vi.mocked(validateTemplate).mockResolvedValue({ - exists: true, - isTemplate: true, - }) - vi.mocked(createRepo).mockResolvedValue({ - id: 1, - full_name: 'mockName', - private: true, - default_branch: 'mockBranch', - name: 'repoName', - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - afterAll(() => { - vi.resetModules() - vi.restoreAllMocks() - - Object.defineProperty(process, 'env', { - value: OLD_ENV, - }) - }) - - test('it should ask for a new project name if project with that name already exists on a globally deployed project', async (t) => { - const stdoutwriteSpy = vi.spyOn(process.stdout, 'write') - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - vi.mocked(deployedSiteExists).mockResolvedValue(true) - - createSitesFromTemplateCommand(program) - - await program.parseAsync([ - '', - '', - 'sites:create-template', - '--account-slug', - 'test-account', - '--name', - 'test-name', - ]) - }) - expect(stdoutwriteSpy).toHaveBeenCalledWith('A project with that name already exists\n') - }) - - test('it should ask for a new project name if project with that name already exists on account', async (t) => { - const stdoutwriteSpy = vi.spyOn(process.stdout, 'write') - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - vi.mocked(deployedSiteExists).mockResolvedValue(false) - - createSitesFromTemplateCommand(program) - - await program.parseAsync([ - '', - '', - 'sites:create-template', - '--account-slug', - 'test-account', - '--name', - 'test-name', - ]) - }) - expect(stdoutwriteSpy).toHaveBeenCalledWith('A project with that name already exists on your account\n') - }) - - test('it should automatically link to the project when the user clones the template repo', async (t) => { - const mockSuccessfulLinkOutput = ` - Directory Linked - - Admin url: https://app.netlify.com/projects/site-name - Project url: https://site-name.netlify.app - - You can now run other \`netlify\` cli commands in this directory - ` - vi.mocked(callLinkSite).mockImplementationOnce(() => Promise.resolve(mockSuccessfulLinkOutput)) - - const autoLinkRoutes = [ - { - path: 'accounts', - response: [{ slug: 'test-account' }], - }, - { - path: 'sites', - response: [{ name: 'test-name-unique' }], - }, - { - path: 'test-account/sites', - response: siteInfo, - method: 'POST' as const, - }, - ] - - const stdoutwriteSpy = vi.spyOn(process.stdout, 'write') - await withMockApi(autoLinkRoutes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - vi.mocked(deployedSiteExists).mockResolvedValue(false) - - createSitesFromTemplateCommand(program) - - await program.parseAsync(['', '', 'sites:create-template']) - }) - - expect(stdoutwriteSpy).toHaveBeenCalledWith( - `\nDirectory ${chalk.cyanBright('repoName')} linked to project ${chalk.cyanBright( - 'https://site-name.netlify.app', - )}\n\n`, - ) - }) - - test('it should output instructions if a project is already linked', async (t) => { - const mockUnsuccessfulLinkOutput = ` - Project already linked to \"site-name\" - Admin url: https://app.netlify.com/projects/site-name - - To unlink this project, run: netlify unlink - ` - - vi.mocked(callLinkSite).mockImplementationOnce(() => Promise.resolve(mockUnsuccessfulLinkOutput)) - - const autoLinkRoutes = [ - { - path: 'accounts', - response: [{ slug: 'test-account' }], - }, - { - path: 'sites', - response: [{ name: 'test-name-unique' }], - }, - { - path: 'test-account/sites', - response: siteInfo, - method: 'POST' as const, - }, - ] - - const stdoutwriteSpy = vi.spyOn(process.stdout, 'write') - await withMockApi(autoLinkRoutes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - vi.mocked(deployedSiteExists).mockResolvedValue(false) - - createSitesFromTemplateCommand(program) - - await program.parseAsync(['', '', 'sites:create-template']) - }) - - expect(stdoutwriteSpy).toHaveBeenCalledWith( - `\nThis directory appears to be linked to ${chalk.cyanBright(`"site-name"`)}\n`, - ) - }) -}) diff --git a/tests/integration/commands/sites/sites.test.ts b/tests/integration/commands/sites/sites.test.ts index 4afd03e174a..f3cd152b500 100644 --- a/tests/integration/commands/sites/sites.test.ts +++ b/tests/integration/commands/sites/sites.test.ts @@ -1,14 +1,10 @@ import process from 'process' import inquirer from 'inquirer' -import { render } from 'prettyjson' import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest' import BaseCommand from '../../../../src/commands/base-command.js' -import { createSitesCreateCommand, createSitesFromTemplateCommand } from '../../../../src/commands/sites/sites.js' -import { getGitHubToken } from '../../../../src/utils/init/config-github.js' -import { fetchTemplates } from '../../../../src/utils/sites/create-template.js' -import { createRepo, getTemplatesFromGitHub } from '../../../../src/utils/sites/utils.js' +import { createSitesCreateCommand } from '../../../../src/commands/sites/sites.js' import { getEnvironmentVariables, withMockApi } from '../../utils/mock-api.js' vi.mock('../../../../src/utils/command-helpers.js', async () => ({ @@ -16,47 +12,6 @@ vi.mock('../../../../src/utils/command-helpers.js', async () => ({ log: () => {}, })) -// mock the getGithubToken method with a fake token -vi.mock('../../../../src/utils/init/config-github.js', () => ({ - getGitHubToken: vi.fn().mockImplementation(() => 'my-token'), -})) - -vi.mock('../../../../src/utils/sites/utils.js', () => ({ - getTemplatesFromGitHub: vi.fn().mockImplementation(() => [ - { - name: 'next-starter', - html_url: 'http://github.com/netlify-templates/next-starter', - full_name: 'netlify-templates/next-starter', - }, - { - name: 'archived-starter', - html_url: 'https://github.com/netlify-templates/fake-repo', - full_name: 'netlify-templates/fake-repo', - archived: true, - }, - ]), - createRepo: vi.fn().mockImplementation(() => ({ - full_name: 'Next starter', - private: false, - branch: 'main', - id: 1, - })), - validateTemplate: vi.fn().mockImplementation(() => ({ - exists: true, - isTemplate: true, - })), -})) - -vi.mock('prettyjson', async () => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const realRender = (await vi.importActual('prettyjson')) as typeof import('prettyjson') - - return { - ...realRender, - render: vi.fn().mockImplementation((...args: Parameters) => realRender.render(...args)), - } -}) - vi.spyOn(inquirer, 'prompt').mockImplementation(() => Promise.resolve({ accountSlug: 'test-account' })) const siteInfo = { @@ -107,87 +62,6 @@ describe('sites command', () => { value: OLD_ENV, }) }) - describe('sites:create-template', () => { - test('basic', async () => { - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - createSitesFromTemplateCommand(program) - - await program.parseAsync(['', '', 'sites:create-template']) - }) - - expect(getGitHubToken).toHaveBeenCalledOnce() - expect(getTemplatesFromGitHub).toHaveBeenCalledOnce() - expect(createRepo).toHaveBeenCalledOnce() - expect(render).toHaveBeenCalledOnce() - expect(render).toHaveBeenCalledWith({ - 'Admin URL': siteInfo.admin_url, - URL: siteInfo.ssl_url, - 'Project ID': siteInfo.id, - 'Repo URL': siteInfo.build_settings.repo_url, - }) - }) - - test('should not fetch templates if one is passed as option', async () => { - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - createSitesFromTemplateCommand(program) - - await program.parseAsync([ - '', - '', - 'sites:create-template', - '-u', - 'http://github.com/netlify-templates/next-starter', - ]) - - expect(getTemplatesFromGitHub).not.toHaveBeenCalled() - }) - }) - - test('should throw an error if the URL option is not a valid URL', async () => { - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - createSitesFromTemplateCommand(program) - - await expect(async () => { - await program.parseAsync(['', '', 'sites:create-template', '-u', 'not-a-url']) - }).rejects.toThrowError('Invalid URL') - }) - }) - }) - - describe('fetchTemplates', () => { - test('should return an array of templates with name, source code url and slug', async () => { - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - createSitesFromTemplateCommand(program) - - const templates = await fetchTemplates('fake-token') - - expect(getTemplatesFromGitHub).toHaveBeenCalledWith('fake-token') - expect(templates).toEqual([ - { - name: 'next-starter', - sourceCodeUrl: 'http://github.com/netlify-templates/next-starter', - slug: 'netlify-templates/next-starter', - }, - ]) - }) - }) - }) describe('sites:create', () => { test('should throw error when name flag is incorrect', async () => { From 94c0146d2ed8b44e654e9b69285b1d424fa2ff67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Sat, 21 Feb 2026 11:44:57 +0000 Subject: [PATCH 6/7] chore(ci): fix flakey integration tests (#7958) --- .github/workflows/conventional-commit.yml | 7 +- .github/workflows/integration-tests.yml | 55 ++++ e2e/install.e2e.ts | 9 +- .../commands/deploy/deploy.test.ts | 276 ++++++++---------- .../utils/create-live-test-site.ts | 9 +- 5 files changed, 197 insertions(+), 159 deletions(-) diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml index c38a6479cfc..4f9ce1a1b95 100644 --- a/.github/workflows/conventional-commit.yml +++ b/.github/workflows/conventional-commit.yml @@ -7,7 +7,6 @@ jobs: lint-title: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - name: Install Dependencies - run: npm install @commitlint/config-conventional - - uses: JulienKode/pull-request-name-linter-action@v19.0.0 + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 9cb43bbc6eb..cf1cacb8405 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -11,8 +11,43 @@ on: - '!release-please--**' jobs: + setup: + name: Create test site + runs-on: ubuntu-latest + # Skip for fork PRs since they don't have access to secrets + if: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) }} + timeout-minutes: 5 + outputs: + site-id: ${{ steps.create-site.outputs.site-id }} + site-name: ${{ steps.create-site.outputs.site-name }} + steps: + - name: Create site + id: create-site + run: | + SITE_NAME="netlify-test-deploy-$(openssl rand -hex 4)" + echo "Creating test site: $SITE_NAME" + HTTP_CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$SITE_NAME\"}" \ + "https://api.netlify.com/api/v1/netlify-integration-testing/sites") + if [ "$HTTP_CODE" -ne 201 ]; then + echo "::warning::Failed to create site. HTTP $HTTP_CODE. Live tests will be skipped." + exit 0 + fi + SITE_ID=$(jq -r '.id' response.json) + echo "site-id=$SITE_ID" >> "$GITHUB_OUTPUT" + echo "site-name=$SITE_NAME" >> "$GITHUB_OUTPUT" + echo "Created site $SITE_NAME with ID $SITE_ID" + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + integration: name: Integration + needs: [setup] + # Run even if setup was skipped (fork PRs) or failed to create a site. + # Tests that need a live site will be skipped if NETLIFY_LIVE_TEST_SITE_ID is not set. + if: ${{ !cancelled() }} runs-on: ${{ matrix.os }} timeout-minutes: 40 strategy: @@ -71,6 +106,9 @@ jobs: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} NETLIFY_TEST_ACCOUNT_SLUG: 'netlify-integration-testing' NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + # Site created by the setup job, shared across all matrix jobs + NETLIFY_LIVE_TEST_SITE_ID: ${{ needs.setup.outputs.site-id }} + NETLIFY_LIVE_TEST_SITE_NAME: ${{ needs.setup.outputs.site-name }} # NETLIFY_TEST_GITHUB_TOKEN is used to avoid reaching GitHub API limits in exec-fetcher.js NETLIFY_TEST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Changes the polling interval used by the file watcher @@ -106,3 +144,20 @@ jobs: with: flags: ${{ steps.test-coverage-flags.outputs.os }},${{ steps.test-coverage-flags.outputs.node }} token: ${{ secrets.CODECOV_TOKEN }} + + cleanup: + name: Delete test site + needs: [setup, integration] + if: ${{ always() && needs.setup.outputs.site-id }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Delete site + run: | + echo "Deleting test site ${{ needs.setup.outputs.site-id }}" + curl -sf -X DELETE \ + -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \ + "https://api.netlify.com/api/v1/sites/${{ needs.setup.outputs.site-id }}" + echo "Deleted successfully" + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} diff --git a/e2e/install.e2e.ts b/e2e/install.e2e.ts index 2ea2bd978c8..1269455995a 100644 --- a/e2e/install.e2e.ts +++ b/e2e/install.e2e.ts @@ -69,6 +69,9 @@ const itWithMockNpmRegistry = it.extend<{ registry: { address: string; cwd: stri npmjs: { url: 'https://registry.npmjs.org/', maxage: '1d', + timeout: '60s', + max_fails: 5, + fail_timeout: '5m', cache: true, }, }, @@ -145,7 +148,7 @@ const itWithMockNpmRegistry = it.extend<{ registry: { address: string; cwd: stri }) } - await fs.rm(publishWorkspace, { force: true, recursive: true }) + await fs.rm(publishWorkspace, { force: true, recursive: true, maxRetries: 3, retryDelay: 1000 }) const testWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), tempdirPrefix)) await use({ @@ -159,8 +162,8 @@ const itWithMockNpmRegistry = it.extend<{ registry: { address: string; cwd: stri // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression server.closeAllConnections(), ]) - await fs.rm(testWorkspace, { force: true, recursive: true }) - await fs.rm(verdaccioStorageDir, { force: true, recursive: true }) + await fs.rm(testWorkspace, { force: true, recursive: true, maxRetries: 3, retryDelay: 1000 }) + await fs.rm(verdaccioStorageDir, { force: true, recursive: true, maxRetries: 3, retryDelay: 1000 }) }, }) diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index 87e8e22b026..373388ea6f0 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -29,18 +29,16 @@ const validateContent = async ({ pathname?: string | undefined siteUrl: string }) => { - const response = await fetch(`${siteUrl}${pathname}`, { headers }) + const url = `${siteUrl}${pathname}` + const response = await fetch(url, { headers }) const body = await response.text() + const requestId = response.headers.get('x-nf-request-id') ?? '' if (content === undefined) { expect(response.status).toBe(404) return } - expect(response.status, `status should be 200. request id: ${response.headers.get('x-nf-request-id') ?? ''}`).toBe( - 200, - ) - expect(body, `body should be as expected. request id: ${response.headers.get('x-nf-request-id') ?? ''}`).toEqual( - content, - ) + expect(response.status, `status should be 200. url: ${url} request id: ${requestId}`).toBe(200) + expect(body, `body should be as expected. url: ${url} request id: ${requestId}`).toEqual(content) } type Deploy = { @@ -57,6 +55,15 @@ type Deploy = { logs: string function_logs: string edge_function_logs: string + source_zip_filename?: string +} + +const parseDeploy = (output: string): Deploy => { + try { + return JSON.parse(output) + } catch { + throw new Error(`Failed to parse deploy output as JSON. Raw output:\n${output}`) + } } const validateDeploy = async ({ @@ -81,12 +88,15 @@ const validateDeploy = async ({ await validateContent({ siteUrl: deploy.deploy_url, path: '', content }) } -const context: { account: unknown; siteId: string } = { +const context: { account: unknown; siteId: string; siteName: string } = { siteId: '', + siteName: '', account: undefined, } -const disableLiveTests = process.env.NETLIFY_TEST_DISABLE_LIVE === 'true' +const disableLiveTests = + process.env.NETLIFY_TEST_DISABLE_LIVE === 'true' || + (process.env.CI === 'true' && !process.env.NETLIFY_LIVE_TEST_SITE_ID) // Running multiple entire build + deploy cycles concurrently results in a lot of network requests that may // cause resource contention anyway, so lower the default concurrency from 5 to 3. @@ -94,16 +104,26 @@ vi.setConfig({ maxConcurrency: 3 }) describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_000 }, () => { beforeAll(async () => { - const { account, siteId } = await createLiveTestSite(SITE_NAME) - context.siteId = siteId - context.account = account + // In CI, a shared site is created once by the setup job and passed via env var. + // Locally, we create (and later delete) a site per test run. + if (process.env.NETLIFY_LIVE_TEST_SITE_ID) { + context.siteId = process.env.NETLIFY_LIVE_TEST_SITE_ID + context.siteName = process.env.NETLIFY_LIVE_TEST_SITE_NAME ?? '' + } else { + const { account, siteId } = await createLiveTestSite(SITE_NAME) + context.siteId = siteId + context.siteName = SITE_NAME + context.account = account + } }) - afterAll(async () => { - const { siteId } = context - console.log(`deleting test site "${SITE_NAME}". ${siteId}`) - await callCli(['sites:delete', siteId, '--force']) - }) + if (!process.env.NETLIFY_LIVE_TEST_SITE_ID) { + afterAll(async () => { + const { siteId } = context + + await callCli(['sites:delete', siteId, '--force']) + }) + } test('should deploy project when dir flag is passed', async (t) => { await withSiteBuilder(t, async (builder) => { @@ -118,9 +138,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) }) }) @@ -140,11 +160,11 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 await builder.build() - const deploy = await callCli(['deploy', '--json', '--no-build', '--site', SITE_NAME], { + const deploy = await callCli(['deploy', '--json', '--no-build', '--site', context.siteName], { cwd: builder.directory, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) }) }) @@ -167,9 +187,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) }) }) @@ -214,16 +234,14 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 if (shouldRunBuildBeforeDeploy) { await callCli(['build'], options) } - const deploy = await callCli(['deploy', '--json', '--no-build'], options).then((output: string) => - JSON.parse(output), - ) + const deploy = await callCli(['deploy', '--json', '--no-build'], options).then(parseDeploy) // give edge functions manifest a couple ticks to propagate await pause(500) await validateDeploy({ deploy, - siteName: SITE_NAME, + siteName: context.siteName, content: 'Edge Function works', contentMessage: 'Edge function did not execute correctly or was not deployed correctly', }) @@ -264,16 +282,14 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 if (shouldRunBuildBeforeDeploy) { await callCli(['build', '--cwd', pathPrefix], options) } - const deploy = await callCli(['deploy', '--json', '--no-build', '--cwd', pathPrefix], options).then( - (output: string) => JSON.parse(output), - ) + const deploy = await callCli(['deploy', '--json', '--no-build', '--cwd', pathPrefix], options).then(parseDeploy) // give edge functions manifest a couple ticks to propagate await pause(500) await validateDeploy({ deploy, - siteName: SITE_NAME, + siteName: context.siteName, content: 'Edge Function works', contentMessage: 'Edge function did not execute correctly or was not deployed correctly', }) @@ -312,16 +328,14 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 if (shouldRunBuildBeforeDeploy) { await callCli(['build'], options) } - const deploy = await callCli(['deploy', '--json', '--no-build'], options).then((output: string) => - JSON.parse(output), - ) + const deploy = await callCli(['deploy', '--json', '--no-build'], options).then(parseDeploy) // give edge functions manifest a couple ticks to propagate await pause(500) await validateDeploy({ deploy, - siteName: SITE_NAME, + siteName: context.siteName, content: 'Edge Function works', contentMessage: 'Edge function did not execute correctly or was not deployed correctly', }) @@ -359,16 +373,14 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 } // skipping running build here, because it cleans up frameworks API directories - const deploy = await callCli(['deploy', '--json', '--no-build'], options).then((output: string) => - JSON.parse(output), - ) + const deploy = await callCli(['deploy', '--json', '--no-build'], options).then(parseDeploy) // give edge functions manifest a couple ticks to propagate await pause(500) await validateDeploy({ deploy, - siteName: SITE_NAME, + siteName: context.siteName, content: 'Edge Function works', contentMessage: 'Edge function did not execute correctly or was not deployed correctly', }) @@ -567,17 +579,20 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) - expect(deploy).toHaveProperty('logs', `https://app.netlify.com/projects/${SITE_NAME}/deploys/${deploy.deploy_id}`) + await validateDeploy({ deploy, siteName: context.siteName, content }) + expect(deploy).toHaveProperty( + 'logs', + `https://app.netlify.com/projects/${context.siteName}/deploys/${deploy.deploy_id}`, + ) expect(deploy).toHaveProperty( 'function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/functions?scope=deploy:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/functions?scope=deploy:${deploy.deploy_id}`, ) expect(deploy).toHaveProperty( 'edge_function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, ) }) }) @@ -594,14 +609,20 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public', '--prod'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) - expect(deploy).toHaveProperty('logs', `https://app.netlify.com/projects/${SITE_NAME}/deploys/${deploy.deploy_id}`) - expect(deploy).toHaveProperty('function_logs', `https://app.netlify.com/projects/${SITE_NAME}/logs/functions`) + await validateDeploy({ deploy, siteName: context.siteName, content }) + expect(deploy).toHaveProperty( + 'logs', + `https://app.netlify.com/projects/${context.siteName}/deploys/${deploy.deploy_id}`, + ) + expect(deploy).toHaveProperty( + 'function_logs', + `https://app.netlify.com/projects/${context.siteName}/logs/functions`, + ) expect(deploy).toHaveProperty( 'edge_function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/edge-functions`, + `https://app.netlify.com/projects/${context.siteName}/logs/edge-functions`, ) }) }) @@ -720,9 +741,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content: 'index' }) + await validateDeploy({ deploy, siteName: context.siteName, content: 'index' }) await validateContent({ siteUrl: deploy.deploy_url, content: undefined, @@ -765,9 +786,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content: 'index' }) + await validateDeploy({ deploy, siteName: context.siteName, content: 'index' }) await validateContent({ siteUrl: deploy.deploy_url, content: undefined, @@ -800,9 +821,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content: 'index' }) + await validateDeploy({ deploy, siteName: context.siteName, content: 'index' }) await validateContent({ siteUrl: deploy.deploy_url, content: '{}', @@ -860,14 +881,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const { deploy_url: deployUrl } = await callCli(['deploy', '--json'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/.netlify/functions/hello`) t.expect(await response.text()).toEqual('Hello') @@ -970,14 +987,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const { deploy_url: deployUrl } = await callCli(['deploy', '--json'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) // Add retry logic for fetching deployed functions const fetchWithRetry = async (url: string, maxRetries = 5) => { @@ -1031,14 +1044,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const { deploy_url: deployUrl } = await callCli(['deploy', '--json'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/.netlify/functions/func-1`).then((res) => res.text()) t.expect(response).toEqual('Internal') @@ -1089,29 +1098,24 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const deploy = (await callCli( - ['deploy', '--json'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const deploy = await callCli(['deploy', '--json'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) - const fullDeploy = (await callCli( + const fullDeploy = await callCli( ['api', 'getDeploy', '--data', JSON.stringify({ deploy_id: deploy.deploy_id })], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, }, - true, - )) as unknown as Deploy + ).then(parseDeploy) const redirectsMessage = fullDeploy.summary.messages.find(({ title }) => title === '3 redirect rules processed') t.expect(redirectsMessage).toBeDefined() t.expect(redirectsMessage!.description).toEqual('All redirect rules deployed without errors.') - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) const [pluginRedirectResponse, _redirectsResponse, netlifyTomResponse] = await Promise.all([ fetch(`${deploy.deploy_url}/other-api/hello`).then((res) => res.text()), @@ -1174,14 +1178,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json', '--no-build'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const { deploy_url: deployUrl } = await callCli(['deploy', '--json', '--no-build'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/.netlify/functions/bundled-function-1`).then((res) => res.text()) expect(response).toEqual('Pre-bundled') }) @@ -1233,14 +1233,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json', '--no-build', '--skip-functions-cache'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const { deploy_url: deployUrl } = await callCli(['deploy', '--json', '--no-build', '--skip-functions-cache'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/.netlify/functions/bundled-function-1`).then((res) => res.text()) t.expect(response).toEqual('Bundled at deployment') @@ -1294,14 +1290,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json', '--no-build'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as { deploy_url: string } + const { deploy_url: deployUrl } = await callCli(['deploy', '--json', '--no-build'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/.netlify/functions/bundled-function-1`).then((res) => res.text()) t.expect(response).toEqual('Bundled at deployment') @@ -1353,14 +1345,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 .build() await execa.command('npm install', { cwd: builder.directory }) - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json', '--no-build'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as { deploy_url: string } + const { deploy_url: deployUrl } = await callCli(['deploy', '--json', '--no-build'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/read-blob`).then((res) => res.text()) t.expect(response).toEqual('hello from the blob') @@ -1374,14 +1362,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 timeout: 300_000, }, async ({ fixture }) => { - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], - { - cwd: fixture.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as { deploy_url: string } + const { deploy_url: deployUrl } = await callCli(['deploy', '--json'], { + cwd: fixture.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const html = await fetch(deployUrl).then((res) => res.text()) const $ = load(html) @@ -1422,16 +1406,16 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public', '--draft'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) expect(deploy).toHaveProperty( 'function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/functions?scope=deploy:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/functions?scope=deploy:${deploy.deploy_id}`, ) expect(deploy).toHaveProperty( 'edge_function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, ) }) }) @@ -1486,16 +1470,16 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, }, - ).then((output: string) => JSON.parse(output)) + ).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) expect(deploy).toHaveProperty( 'function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/functions?scope=deploy:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/functions?scope=deploy:${deploy.deploy_id}`, ) expect(deploy).toHaveProperty( 'edge_function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, ) expect(deploy.deploy_url).toContain('test-branch--') }) @@ -1515,9 +1499,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public', '--upload-source-zip'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) expect(deploy).toHaveProperty('source_zip_filename') expect(typeof deploy.source_zip_filename).toBe('string') expect(deploy.source_zip_filename).toMatch(/\.zip$/) @@ -1550,9 +1534,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--dir', 'public', '--upload-source-zip'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) expect(deploy).toHaveProperty('source_zip_filename') expect(typeof deploy.source_zip_filename).toBe('string') expect(deploy.source_zip_filename).toMatch(/\.zip$/) @@ -1614,9 +1598,7 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 } await callCli(['build'], options) - const deploy = (await callCli(['deploy', '--json', '--no-build'], options).then((output: string) => - JSON.parse(output), - )) as Deploy + const deploy = await callCli(['deploy', '--json', '--no-build'], options).then(parseDeploy) await pause(500) diff --git a/tests/integration/utils/create-live-test-site.ts b/tests/integration/utils/create-live-test-site.ts index 4bb58b24486..c1e92b9c588 100644 --- a/tests/integration/utils/create-live-test-site.ts +++ b/tests/integration/utils/create-live-test-site.ts @@ -18,8 +18,8 @@ const listAccounts = async () => { } export const createLiveTestSite = async function (siteName: string) { - console.log(`Creating new project for tests: ${siteName}`) const accounts = await listAccounts() + if (!Array.isArray(accounts) || accounts.length <= 0) { throw new Error(`Can't find suitable account to create a project`) } @@ -33,12 +33,12 @@ export const createLiveTestSite = async function (siteName: string) { ) } const accountSlug = account.slug - console.log(`Using account ${accountSlug} to create project: ${siteName}`) + const cliResponse = (await callCli(['sites:create', '--name', siteName, '--account-slug', accountSlug])) as string const isProjectCreated = cliResponse.includes('Project Created') if (!isProjectCreated) { - throw new Error(`Failed creating project: ${cliResponse}`) + throw new Error(`Failed creating project. CLI response:\n${cliResponse}`) } const { default: stripAnsi } = await import('strip-ansi') @@ -46,9 +46,8 @@ export const createLiveTestSite = async function (siteName: string) { const matches = /Project ID:\s+([a-zA-Z\d-]+)/m.exec(stripAnsi(cliResponse)) if (matches && Object.prototype.hasOwnProperty.call(matches, 1) && matches[1]) { const [, siteId] = matches - console.log(`Done creating project ${siteName} for account '${accountSlug}'. Project Id: ${siteId}`) return { siteId, account } } - throw new Error(`Failed creating project: ${cliResponse}`) + throw new Error(`Failed to extract project ID from CLI response:\n${cliResponse}`) } From 6adbd47301ad5328a2b5d0faacaab2e20d032e79 Mon Sep 17 00:00:00 2001 From: Sarah Etter Date: Mon, 23 Feb 2026 13:11:55 -0500 Subject: [PATCH 7/7] fix: remove dead path --- src/utils/init/config-manual.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/init/config-manual.ts b/src/utils/init/config-manual.ts index 90ff5477fee..366f2300c46 100644 --- a/src/utils/init/config-manual.ts +++ b/src/utils/init/config-manual.ts @@ -86,10 +86,10 @@ export default async function configManual({ const deployKey = await createDeployKey({ api }) await addDeployKey(deployKey) - const repoPath = await getRepoPath({ repoData }) + const repoPath = repoData.repo ?? (await getRepoPath({ repoData })) const repo = { provider: repoData.provider ?? 'manual', - repo_path: repoData.repo ?? repoPath, + repo_path: repoPath, repo_branch: repoData.branch, allowed_branches: [repoData.branch], deploy_key_id: deployKey.id,