From fc0c90a52bf2fbf38f113d1033945bc8db6db419 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Wed, 21 Oct 2026 01:19:39 -0400 Subject: [PATCH 1/4] fix(init): don't recreate projects linked without git Rerunning init in a folder linked to a site with no repo and no git remote created a second site. Treat it as done, and ignore a linked site that returns 404 so the normal create/link flow runs. --- src/commands/base-command.ts | 2 +- src/commands/init/init.ts | 53 +++++++-- tests/integration/commands/init/init.test.ts | 108 ++++++++++++++++++- 3 files changed, 155 insertions(+), 8 deletions(-) diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index a4c467d7fd4..bf8a9f7ed4b 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -504,7 +504,7 @@ export default class BaseCommand extends Command { return accessToken } - private async refreshAccounts() { + async refreshAccounts() { try { const accounts = (await this.netlify.api.listAccountsForUser()) as MinimalAccount[] this.netlify.accounts = accounts diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index f6130c62cde..4359148a2fb 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -2,7 +2,7 @@ import { OptionValues } from 'commander' import { confirm, select } from '@inquirer/prompts' import { isEmpty } from '../../utils/object-utilities.js' -import { chalk, exit, log, netlifyCommand } from '../../utils/command-helpers.js' +import { type APIError, chalk, exit, log, netlifyCommand, warn } from '../../utils/command-helpers.js' import getRepoData from '../../utils/get-repo-data.js' import { ensureNetlifyIgnore } from '../../utils/gitignore.js' import { configureRepo } from '../../utils/init/config.js' @@ -38,6 +38,45 @@ const logExistingAndExit = ({ siteInfo }: { siteInfo: SiteInfo }): never => { return exit() } +const logLinkedWithoutGitAndExit = ({ + siteInfo, + customizeExitMessage, +}: { + siteInfo: SiteInfo + customizeExitMessage: InitExitMessageCustomizer | undefined +}): never => { + log() + log(`This folder is already linked to the Netlify project ${chalk.cyan(siteInfo.name)}`) + log() + log(`Project Url: ${chalk.cyan(siteInfo.ssl_url || siteInfo.url)}`) + log() + const defaultExitMessage = `To deploy to this project, run ${chalk.cyanBright.bold(`${netlifyCommand()} deploy`)}.` + log(customizeExitMessage?.(LINKED_EXISTING_SITE_EXIT_CODE, defaultExitMessage) ?? defaultExitMessage) + return exit() +} + +const getLinkedSiteInfo = async (command: BaseCommand): Promise => { + const { api, siteInfo } = command.netlify + if (isEmpty(siteInfo)) { + return undefined + } + if (siteInfo.name) { + return siteInfo + } + try { + return (await api.getSite({ siteId: siteInfo.id })) as unknown as SiteInfo + } catch (error_) { + if ((error_ as APIError).status === 404) { + warn(`The Netlify project ${siteInfo.id} linked to this folder no longer exists. Continuing without it.`) + if (command.netlify.accounts.length === 0) { + await command.refreshAccounts() + } + return undefined + } + throw error_ + } +} + /** * Creates and new site and exits the process */ @@ -245,7 +284,6 @@ export const init = async ( }) const { repositoryRoot, state } = command.netlify - const { siteInfo: existingSiteInfo } = command.netlify // Check logged in status await command.authenticate() @@ -257,14 +295,17 @@ export const init = async ( await installAgentSkills(command, resetContext) } - const repoUrl = getRepoUrl(existingSiteInfo) - if (repoUrl && !options.force) { - logExistingAndExit({ siteInfo: existingSiteInfo }) + const linkedSiteInfo = await getLinkedSiteInfo(command) + if (linkedSiteInfo && getRepoUrl(linkedSiteInfo) && !options.force) { + logExistingAndExit({ siteInfo: linkedSiteInfo }) } // Look for local repo const repoData = await getRepoData({ workingDir: command.workingDir, remoteName: options.gitRemoteName }) if ('error' in repoData) { + if (linkedSiteInfo) { + return logLinkedWithoutGitAndExit({ siteInfo: linkedSiteInfo, customizeExitMessage }) + } // TODO(ndhoule): Custom error messaage here return await handleNoGitRemoteAndExit({ command, @@ -275,7 +316,7 @@ export const init = async ( }) } - const siteInfo = isEmpty(existingSiteInfo) ? await createOrLinkSiteToRepo(command) : existingSiteInfo + const siteInfo = linkedSiteInfo ?? (await createOrLinkSiteToRepo(command)) log() diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index 3b731b53509..1bd9bc1cd72 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -10,7 +10,7 @@ import toml from 'toml' import { describe, test, type TestContext } from 'vitest' import { cliPath } from '../../utils/cli-path.js' -import { CONFIRM, DOWN, answerWithValue, handleQuestions } from '../../utils/handle-questions.js' +import { CONFIRM, DOWN, NO, answerWithValue, handleQuestions } from '../../utils/handle-questions.js' import { withMockApi } from '../../utils/mock-api.js' import { withSiteBuilder } from '../../utils/site-builder.js' @@ -894,4 +894,110 @@ describe.concurrent('commands/init', () => { }) }) }) + + const NO_GIT_PROMPT = 'Do you want to create a Netlify project without a git repository?' + const manualSiteInfo = { + admin_url: 'https://app.netlify.com/projects/test-site-name/overview', + ssl_url: 'https://test-site-name.netlify.app/', + id: 'site_id', + name: 'test-site-name', + build_settings: {}, + } + const createWithoutGitQuestions = () => [ + { question: 'Yes, create and deploy project manually', answer: CONFIRM }, + { question: 'Team:', answer: CONFIRM }, + { + question: 'Project name (leave blank for a random name; you can change it later)', + answer: answerWithValue('test-site-name'), + }, + { question: 'Do you want to configure build settings?', answer: answerWithValue(NO) }, + ] + const noGitRoutes = [ + { path: 'accounts', response: [{ slug: 'test-account' }] }, + { path: 'sites', response: [] }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'sites/site_id', response: manualSiteInfo }, + { path: 'user', response: { name: 'test user', slug: 'test-user', email: 'user@test.com' } }, + { path: 'test-account/sites', method: 'POST' as const, response: { id: 'site_id', name: 'test-site-name' } }, + ] + const countSiteCreations = (requests: { method: string; path: string }[]) => + requests.filter(({ method, path: requestPath }) => method === 'POST' && requestPath.endsWith('/test-account/sites')) + .length + const runInitIn = (cwd: string, apiUrl: string, questions: { question: string; answer: string | string[] }[]) => { + const childProcess = execa(cliPath, ['init', '--manual', '--skip-agent-setup'], { + cwd, + env: { NETLIFY_API_URL: apiUrl, NETLIFY_AUTH_TOKEN: 'fake-token' }, + encoding: 'utf8', + }) + handleQuestions(childProcess, questions) + return childProcess + } + const readStateSiteId = async (cwd: string) => + (JSON.parse(await readFile(path.join(cwd, '.netlify', 'state.json'), 'utf8')) as { siteId?: string }).siteId + + test('rerunning netlify init in a folder linked without git does not create another project', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(noGitRoutes, async ({ apiUrl, requests }) => { + const first = await runInitIn(builder.directory, apiUrl, createWithoutGitQuestions()) + t.expect(first.stdout).toContain(NO_GIT_PROMPT) + t.expect(countSiteCreations(requests)).toBe(1) + await t.expect(readStateSiteId(builder.directory)).resolves.toBe('site_id') + + const second = await runInitIn(builder.directory, apiUrl, createWithoutGitQuestions()) + t.expect(second.stdout).not.toContain(NO_GIT_PROMPT) + t.expect(second.stdout).toContain('This folder is already linked to the Netlify project test-site-name') + t.expect(second.stdout).toContain('https://test-site-name.netlify.app/') + t.expect(second.stdout).toContain('deploy') + t.expect(countSiteCreations(requests)).toBe(1) + await t.expect(readStateSiteId(builder.directory)).resolves.toBe('site_id') + }) + }) + }) + + test('netlify init configures the repo when a project linked without git gets a git remote', async (t) => { + const routes = [ + ...noGitRoutes, + { path: 'deploy_keys', method: 'POST' as const, response: { public_key: 'public_key' } }, + { path: 'sites/site_id', method: 'PATCH' as const, response: { deploy_hook: 'deploy_hook' } }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.withGit().withStateFile({ siteId: 'site_id' }).build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const { stdout } = await runInitIn(builder.directory, apiUrl, manualQuestions()) + + t.expect(stdout).not.toContain(NO_GIT_PROMPT) + t.expect(stdout).not.toContain('already linked') + t.expect(countSiteCreations(requests)).toBe(0) + const patch = requests.find( + ({ method, path: requestPath }) => method === 'PATCH' && requestPath.endsWith('/sites/site_id'), + ) + t.expect(patch?.body).toMatchObject({ repo: { provider: 'github', repo_path: 'owner/repo' } }) + }) + }) + }) + + test('netlify init ignores a linked project that no longer exists', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.withStateFile({ siteId: 'deleted_site_id' }).build() + + await withMockApi( + noGitRoutes, + async ({ apiUrl, requests }) => { + const { stdout, stderr } = await runInitIn(builder.directory, apiUrl, createWithoutGitQuestions()) + + t.expect(`${stdout}${stderr}`).toContain( + 'The Netlify project deleted_site_id linked to this folder no longer exists', + ) + t.expect(stdout).toContain(NO_GIT_PROMPT) + t.expect(countSiteCreations(requests)).toBe(1) + await t.expect(readStateSiteId(builder.directory)).resolves.toBe('site_id') + }, + true, + ) + }) + }) }) From c1211a702c1b182d70b68328f55ef54fa8be2a12 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Wed, 21 Oct 2026 13:49:49 -0400 Subject: [PATCH 2/4] fix(init): stop on linked project load errors A 403 now continues like a 404. Other errors stop init with the project id and status instead of risking a duplicate create on a transient failure. The linked-folder message also says how to switch projects. --- src/commands/init/init.ts | 31 ++++++++--- tests/integration/commands/init/init.test.ts | 55 ++++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 4359148a2fb..4a374405476 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -2,7 +2,7 @@ import { OptionValues } from 'commander' import { confirm, select } from '@inquirer/prompts' import { isEmpty } from '../../utils/object-utilities.js' -import { type APIError, chalk, exit, log, netlifyCommand, warn } from '../../utils/command-helpers.js' +import { type APIError, chalk, exit, log, logAndThrowError, netlifyCommand, warn } from '../../utils/command-helpers.js' import getRepoData from '../../utils/get-repo-data.js' import { ensureNetlifyIgnore } from '../../utils/gitignore.js' import { configureRepo } from '../../utils/init/config.js' @@ -52,6 +52,11 @@ const logLinkedWithoutGitAndExit = ({ log() const defaultExitMessage = `To deploy to this project, run ${chalk.cyanBright.bold(`${netlifyCommand()} deploy`)}.` log(customizeExitMessage?.(LINKED_EXISTING_SITE_EXIT_CODE, defaultExitMessage) ?? defaultExitMessage) + log( + `To link this folder to a different project, run ${chalk.cyanBright.bold( + `${netlifyCommand()} unlink`, + )}, then ${chalk.cyanBright.bold(`${netlifyCommand()} init`)}.`, + ) return exit() } @@ -66,14 +71,24 @@ const getLinkedSiteInfo = async (command: BaseCommand): Promise> = { + 403: `You don't have access to the Netlify project ${siteInfo.id} linked to this folder. Continuing without it.`, + 404: `The Netlify project ${siteInfo.id} linked to this folder no longer exists. Continuing without it.`, } - throw error_ + const unusableLinkWarning = unusableLinkWarnings[status] + if (unusableLinkWarning === undefined) { + return logAndThrowError( + `Could not load the Netlify project ${siteInfo.id} linked to this folder (${ + status ? `status ${status.toString()}` : message + }). Try again, or run ${chalk.cyanBright.bold(`${netlifyCommand()} unlink`)} to start over.`, + ) + } + warn(unusableLinkWarning) + if (command.netlify.accounts.length === 0) { + await command.refreshAccounts() + } + return undefined } } diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index 1bd9bc1cd72..8c34b8ef63f 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -1000,4 +1000,59 @@ describe.concurrent('commands/init', () => { ) }) }) + + test('netlify init ignores a linked project the user cannot access', async (t) => { + const routes = [ + { path: 'sites/forbidden_site_id', status: 403, response: { message: 'Forbidden' } }, + ...noGitRoutes, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.withStateFile({ siteId: 'forbidden_site_id' }).build() + + await withMockApi( + routes, + async ({ apiUrl, requests }) => { + const { stdout, stderr } = await runInitIn(builder.directory, apiUrl, createWithoutGitQuestions()) + + t.expect(`${stdout}${stderr}`).toContain( + "You don't have access to the Netlify project forbidden_site_id linked to this folder", + ) + t.expect(stdout).toContain(NO_GIT_PROMPT) + t.expect(countSiteCreations(requests)).toBe(1) + await t.expect(readStateSiteId(builder.directory)).resolves.toBe('site_id') + }, + true, + ) + }) + }) + + test('netlify init stops without creating a project when the linked project cannot be loaded', async (t) => { + const routes = [ + { path: 'sites/unavailable_site_id', status: 500, response: { message: 'Internal Server Error' } }, + ...noGitRoutes, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.withStateFile({ siteId: 'unavailable_site_id' }).build() + + await withMockApi( + routes, + async ({ apiUrl, requests }) => { + const result = await runInitIn(builder.directory, apiUrl, createWithoutGitQuestions()).catch( + (error: unknown) => error as { exitCode: number; stdout: string; stderr: string }, + ) + + t.expect(result.exitCode).not.toBe(0) + t.expect(`${result.stdout}${result.stderr}`).toContain( + 'Could not load the Netlify project unavailable_site_id linked to this folder (status 500)', + ) + t.expect(result.stdout).not.toContain(NO_GIT_PROMPT) + t.expect(countSiteCreations(requests)).toBe(0) + await t.expect(readStateSiteId(builder.directory)).resolves.toBe('unavailable_site_id') + }, + true, + ) + }) + }) }) From 3a23cde366187cff937d27596a3a7f6b78763679 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Thu, 22 Oct 2026 13:19:53 -0400 Subject: [PATCH 3/4] fix(init): unlink unusable projects, report bad remotes A 403 or 404 linked project is now removed from state and memory, so link and create treat the folder as unlinked. A linked folder whose git remote can't be read reports the remote error instead of only deploy. --- src/commands/init/init.ts | 26 ++--- src/utils/get-repo-data.ts | 15 +-- tests/integration/commands/init/init.test.ts | 108 ++++++++++++++++++- 3 files changed, 127 insertions(+), 22 deletions(-) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 4a374405476..878ca1888a7 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -3,7 +3,7 @@ import { confirm, select } from '@inquirer/prompts' import { isEmpty } from '../../utils/object-utilities.js' import { type APIError, chalk, exit, log, logAndThrowError, netlifyCommand, warn } from '../../utils/command-helpers.js' -import getRepoData from '../../utils/get-repo-data.js' +import getRepoData, { NO_GIT_REMOTE_ERROR } from '../../utils/get-repo-data.js' import { ensureNetlifyIgnore } from '../../utils/gitignore.js' import { configureRepo } from '../../utils/init/config.js' import { track } from '../../utils/telemetry/index.js' @@ -38,20 +38,17 @@ const logExistingAndExit = ({ siteInfo }: { siteInfo: SiteInfo }): never => { return exit() } -const logLinkedWithoutGitAndExit = ({ - siteInfo, - customizeExitMessage, -}: { - siteInfo: SiteInfo - customizeExitMessage: InitExitMessageCustomizer | undefined -}): never => { +const logLinkedWithoutGitAndExit = ({ siteInfo, repoError }: { siteInfo: SiteInfo; repoError: string }): never => { + if (repoError !== NO_GIT_REMOTE_ERROR) { + warn(`Could not read the git remote: ${repoError}`) + log(`Fix the git remote, then run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} again to connect it.`) + } log() log(`This folder is already linked to the Netlify project ${chalk.cyan(siteInfo.name)}`) log() log(`Project Url: ${chalk.cyan(siteInfo.ssl_url || siteInfo.url)}`) log() - const defaultExitMessage = `To deploy to this project, run ${chalk.cyanBright.bold(`${netlifyCommand()} deploy`)}.` - log(customizeExitMessage?.(LINKED_EXISTING_SITE_EXIT_CODE, defaultExitMessage) ?? defaultExitMessage) + log(`To deploy to this project, run ${chalk.cyanBright.bold(`${netlifyCommand()} deploy`)}.`) log( `To link this folder to a different project, run ${chalk.cyanBright.bold( `${netlifyCommand()} unlink`, @@ -73,8 +70,8 @@ const getLinkedSiteInfo = async (command: BaseCommand): Promise> = { - 403: `You don't have access to the Netlify project ${siteInfo.id} linked to this folder. Continuing without it.`, - 404: `The Netlify project ${siteInfo.id} linked to this folder no longer exists. Continuing without it.`, + 403: `You don't have access to the Netlify project ${siteInfo.id} linked to this folder. Unlinking it and continuing.`, + 404: `The Netlify project ${siteInfo.id} linked to this folder no longer exists. Unlinking it and continuing.`, } const unusableLinkWarning = unusableLinkWarnings[status] if (unusableLinkWarning === undefined) { @@ -85,6 +82,9 @@ const getLinkedSiteInfo = async (command: BaseCommand): Promise => { try { - const [gitConfig, gitDirectory] = await Promise.all([ - util.promisify(gitconfiglocal)(workingDir), - findUp('.git', { cwd: workingDir, type: 'directory' }), - ]) + const gitDirectory = await findUp('.git', { cwd: workingDir, type: 'directory' }) + if (!gitDirectory) { + throw new Error(NO_GIT_REMOTE_ERROR) + } + const gitConfig = await util.promisify(gitconfiglocal)(workingDir) - if (!gitDirectory || !gitConfig?.remote || Object.keys(gitConfig.remote).length === 0) { - throw new Error('No Git remote found') + if (!gitConfig?.remote || Object.keys(gitConfig.remote).length === 0) { + throw new Error(NO_GIT_REMOTE_ERROR) } const baseGitPath = dirname(gitDirectory) diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index 8c34b8ef63f..fddec0afc74 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -923,8 +923,13 @@ describe.concurrent('commands/init', () => { const countSiteCreations = (requests: { method: string; path: string }[]) => requests.filter(({ method, path: requestPath }) => method === 'POST' && requestPath.endsWith('/test-account/sites')) .length - const runInitIn = (cwd: string, apiUrl: string, questions: { question: string; answer: string | string[] }[]) => { - const childProcess = execa(cliPath, ['init', '--manual', '--skip-agent-setup'], { + const runInitIn = ( + cwd: string, + apiUrl: string, + questions: { question: string; answer: string | string[] }[], + flags: string[] = [], + ) => { + const childProcess = execa(cliPath, ['init', '--manual', '--skip-agent-setup', ...flags], { cwd, env: { NETLIFY_API_URL: apiUrl, NETLIFY_AUTH_TOKEN: 'fake-token' }, encoding: 'utf8', @@ -949,13 +954,108 @@ describe.concurrent('commands/init', () => { t.expect(second.stdout).not.toContain(NO_GIT_PROMPT) t.expect(second.stdout).toContain('This folder is already linked to the Netlify project test-site-name') t.expect(second.stdout).toContain('https://test-site-name.netlify.app/') - t.expect(second.stdout).toContain('deploy') + t.expect(second.stdout).toMatch(/To deploy to this project, run (npx )?netlify deploy\./) + t.expect(second.stdout).toMatch( + /To link this folder to a different project, run (npx )?netlify unlink, then (npx )?netlify init\./, + ) + t.expect(`${second.stdout}${second.stderr}`).not.toContain('Could not read the git remote') t.expect(countSiteCreations(requests)).toBe(1) await t.expect(readStateSiteId(builder.directory)).resolves.toBe('site_id') }) }) }) + test('netlify init on a linked project reports a git remote it cannot read', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.withGit().withStateFile({ siteId: 'site_id' }).build() + + await withMockApi(noGitRoutes, async ({ apiUrl, requests }) => { + const { stdout, stderr } = await runInitIn(builder.directory, apiUrl, createWithoutGitQuestions(), [ + '--git-remote-name', + 'upstream', + ]) + + t.expect(`${stdout}${stderr}`).toContain( + 'Could not read the git remote: The specified remote "upstream" is not defined in Git repo', + ) + t.expect(stdout).toContain('This folder is already linked to the Netlify project test-site-name') + t.expect(stdout).not.toContain(NO_GIT_PROMPT) + t.expect(countSiteCreations(requests)).toBe(0) + }) + }) + }) + + test('netlify init with a git remote unlinks a deleted project before creating a new one', async (t) => { + const routes = [ + ...noGitRoutes, + { path: 'deploy_keys', method: 'POST' as const, response: { public_key: 'public_key' } }, + { path: 'sites/site_id', method: 'PATCH' as const, response: { deploy_hook: 'deploy_hook' } }, + ] + const questions = [ + { question: 'Create & configure a new project', answer: answerWithValue(DOWN) }, + { question: 'Team:', answer: CONFIRM }, + { + question: 'Project name (leave blank for a random name; you can change it later)', + answer: answerWithValue('test-site-name'), + }, + ...manualQuestions(), + ] + + await withSiteBuilder(t, async (builder) => { + await builder.withGit().withStateFile({ siteId: 'deleted_site_id' }).build() + + await withMockApi( + routes, + async ({ apiUrl, requests }) => { + const { stdout } = await runInitIn(builder.directory, apiUrl, questions) + + t.expect(stdout).not.toContain('already linked') + t.expect(countSiteCreations(requests)).toBe(1) + const patchedPaths = requests + .filter(({ method }) => method === 'PATCH') + .map(({ path: requestPath }) => requestPath) + t.expect(patchedPaths).toEqual(['/api/v1/sites/site_id']) + await t.expect(readStateSiteId(builder.directory)).resolves.toBe('site_id') + }, + true, + ) + }) + }) + + test('netlify init with a git remote treats a deleted project as unlinked when connecting an existing one', async (t) => { + const existingSiteInfo = { ...manualSiteInfo, build_settings: { repo_url: 'https://github.com/owner/repo' } } + const routes = [ + { path: 'accounts', response: [{ slug: 'test-account' }] }, + { path: 'sites', response: [existingSiteInfo] }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'sites/site_id', response: existingSiteInfo }, + ] + const questions = [ + { question: 'Connect this directory to an existing Netlify project', answer: CONFIRM }, + { question: 'How do you want to link this folder to a project?', answer: CONFIRM }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.withGit().withStateFile({ siteId: 'deleted_site_id' }).build() + + await withMockApi( + routes, + async ({ apiUrl, requests }) => { + const result = await runInitIn(builder.directory, apiUrl, questions).catch( + (error: unknown) => error as { exitCode: number; stdout: string; stderr: string }, + ) + + t.expect(result.stdout).not.toContain('already linked') + t.expect(result.stderr).toContain('No project specified. In non-interactive mode') + t.expect(countSiteCreations(requests)).toBe(0) + t.expect(requests.filter(({ method }) => method === 'PATCH')).toEqual([]) + await t.expect(readStateSiteId(builder.directory)).resolves.toBeUndefined() + }, + true, + ) + }) + }) + test('netlify init configures the repo when a project linked without git gets a git remote', async (t) => { const routes = [ ...noGitRoutes, @@ -992,6 +1092,7 @@ describe.concurrent('commands/init', () => { t.expect(`${stdout}${stderr}`).toContain( 'The Netlify project deleted_site_id linked to this folder no longer exists', ) + t.expect(stdout).not.toContain('already linked') t.expect(stdout).toContain(NO_GIT_PROMPT) t.expect(countSiteCreations(requests)).toBe(1) await t.expect(readStateSiteId(builder.directory)).resolves.toBe('site_id') @@ -1018,6 +1119,7 @@ describe.concurrent('commands/init', () => { t.expect(`${stdout}${stderr}`).toContain( "You don't have access to the Netlify project forbidden_site_id linked to this folder", ) + t.expect(stdout).not.toContain('already linked') t.expect(stdout).toContain(NO_GIT_PROMPT) t.expect(countSiteCreations(requests)).toBe(1) await t.expect(readStateSiteId(builder.directory)).resolves.toBe('site_id') From 678008840fc6cb569eb583b1dc857feddc468cd8 Mon Sep 17 00:00:00 2001 From: Domitrius Clark Date: Mon, 26 Oct 2026 11:32:57 -0400 Subject: [PATCH 4/4] fix(init): keep link until replaced, redact remotes An unusable linked project is now ignored for the run instead of deleted from state.json, so aborting keeps it, and init reports sites_unlinked. getRepoData returns a reason code and hides URL credentials in its error. --- eslint-suppressions.json | 2 +- src/commands/init/init.ts | 55 +++++++++++++++----- src/utils/get-repo-data.ts | 33 +++++++++--- tests/integration/commands/init/init.test.ts | 11 ++-- tests/unit/utils/get-repo-data.test.ts | 14 ++++- 5 files changed, 89 insertions(+), 26 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ef6e75625fd..ad6ccdd6540 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -995,7 +995,7 @@ "count": 1 }, "@typescript-eslint/restrict-template-expressions": { - "count": 3 + "count": 2 } }, "src/utils/headers.ts": { diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 878ca1888a7..4e80a36ef05 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -3,7 +3,7 @@ import { confirm, select } from '@inquirer/prompts' import { isEmpty } from '../../utils/object-utilities.js' import { type APIError, chalk, exit, log, logAndThrowError, netlifyCommand, warn } from '../../utils/command-helpers.js' -import getRepoData, { NO_GIT_REMOTE_ERROR } from '../../utils/get-repo-data.js' +import getRepoData, { type RepoDataError } from '../../utils/get-repo-data.js' import { ensureNetlifyIgnore } from '../../utils/gitignore.js' import { configureRepo } from '../../utils/init/config.js' import { track } from '../../utils/telemetry/index.js' @@ -38,9 +38,15 @@ const logExistingAndExit = ({ siteInfo }: { siteInfo: SiteInfo }): never => { return exit() } -const logLinkedWithoutGitAndExit = ({ siteInfo, repoError }: { siteInfo: SiteInfo; repoError: string }): never => { - if (repoError !== NO_GIT_REMOTE_ERROR) { - warn(`Could not read the git remote: ${repoError}`) +const logLinkedWithoutGitAndExit = ({ + siteInfo, + repoError, +}: { + siteInfo: SiteInfo + repoError: RepoDataError +}): never => { + if (repoError.reason !== 'no-remote') { + warn(`Could not read the git remote: ${repoError.error}`) log(`Fix the git remote, then run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} again to connect it.`) } log() @@ -57,6 +63,22 @@ const logLinkedWithoutGitAndExit = ({ siteInfo, repoError }: { siteInfo: SiteInf return exit() } +const ignoreLinkedSiteForThisRun = (command: BaseCommand): void => { + const { site } = command.netlify + command.siteId = undefined + command.netlify.siteInfo = {} as SiteInfo + command.netlify.site = { + root: site.root, + configPath: site.configPath, + get id(): string | undefined { + return command.siteId + }, + set id(id: string) { + site.id = id + }, + } +} + const getLinkedSiteInfo = async (command: BaseCommand): Promise => { const { api, siteInfo } = command.netlify if (isEmpty(siteInfo)) { @@ -69,22 +91,27 @@ const getLinkedSiteInfo = async (command: BaseCommand): Promise> = { - 403: `You don't have access to the Netlify project ${siteInfo.id} linked to this folder. Unlinking it and continuing.`, - 404: `The Netlify project ${siteInfo.id} linked to this folder no longer exists. Unlinking it and continuing.`, + const unusableLinks: Partial> = { + 403: { + reason: 'forbidden', + warning: `You don't have access to the Netlify project ${siteInfo.id} linked to this folder. Continuing without it.`, + }, + 404: { + reason: 'not_found', + warning: `The Netlify project ${siteInfo.id} linked to this folder no longer exists. Continuing without it.`, + }, } - const unusableLinkWarning = unusableLinkWarnings[status] - if (unusableLinkWarning === undefined) { + const unusableLink = unusableLinks[status] + if (unusableLink === undefined) { return logAndThrowError( `Could not load the Netlify project ${siteInfo.id} linked to this folder (${ status ? `status ${status.toString()}` : message }). Try again, or run ${chalk.cyanBright.bold(`${netlifyCommand()} unlink`)} to start over.`, ) } - warn(unusableLinkWarning) - command.netlify.state.delete('siteId') - command.siteId = undefined - command.netlify.siteInfo = {} as SiteInfo + warn(unusableLink.warning) + await track('sites_unlinked', { siteId: siteInfo.id, kind: 'init', reason: unusableLink.reason }) + ignoreLinkedSiteForThisRun(command) if (command.netlify.accounts.length === 0) { await command.refreshAccounts() } @@ -319,7 +346,7 @@ export const init = async ( const repoData = await getRepoData({ workingDir: command.workingDir, remoteName: options.gitRemoteName }) if ('error' in repoData) { if (linkedSiteInfo) { - return logLinkedWithoutGitAndExit({ siteInfo: linkedSiteInfo, repoError: repoData.error }) + return logLinkedWithoutGitAndExit({ siteInfo: linkedSiteInfo, repoError: repoData }) } // TODO(ndhoule): Custom error messaage here return await handleNoGitRemoteAndExit({ diff --git a/src/utils/get-repo-data.ts b/src/utils/get-repo-data.ts index 01f15dcaee3..9cf0511300a 100644 --- a/src/utils/get-repo-data.ts +++ b/src/utils/get-repo-data.ts @@ -18,7 +18,23 @@ export interface RepoData { httpsUrl: string } -export const NO_GIT_REMOTE_ERROR = 'No Git remote found' +export type RepoDataErrorReason = 'no-remote' | 'unknown-remote' | 'invalid-url' | 'unreadable' + +export interface RepoDataError { + error: string + reason: RepoDataErrorReason +} + +class RepoDataLookupError extends Error { + constructor( + message: string, + readonly reason: RepoDataErrorReason, + ) { + super(message) + } +} + +const redactCredentials = (url: string): string => url.replace(/\/\/[^/@]*@/, '//***@') const getRepoData = async ({ remoteName, @@ -26,16 +42,16 @@ const getRepoData = async ({ }: { remoteName?: string workingDir: string -}): Promise => { +}): Promise => { try { const gitDirectory = await findUp('.git', { cwd: workingDir, type: 'directory' }) if (!gitDirectory) { - throw new Error(NO_GIT_REMOTE_ERROR) + throw new RepoDataLookupError('No Git remote found', 'no-remote') } const gitConfig = await util.promisify(gitconfiglocal)(workingDir) if (!gitConfig?.remote || Object.keys(gitConfig.remote).length === 0) { - throw new Error(NO_GIT_REMOTE_ERROR) + throw new RepoDataLookupError('No Git remote found', 'no-remote') } const baseGitPath = dirname(gitDirectory) @@ -54,8 +70,9 @@ const getRepoData = async ({ !gitConfig.remote[remoteName] || Object.keys(gitConfig.remote[remoteName]).length === 0 ) { - throw new Error( + throw new RepoDataLookupError( `The specified remote "${remoteName}" is not defined in Git repo. Please use --git-remote-name flag to specify a remote.`, + 'unknown-remote', ) } @@ -63,7 +80,10 @@ const getRepoData = async ({ const parsedUrl = parseGithubUrl(url) // TODO(serhalp): Validate more aggressively? We should probably require `owner`, `repo`, `host`? if (parsedUrl == null) { - throw new Error(`The specified Git remote ${remoteName} is not a valid URL: ${url}`) + throw new RepoDataLookupError( + `The specified Git remote ${remoteName} is not a valid URL: ${redactCredentials(String(url))}`, + 'invalid-url', + ) } const { host, name, owner, repo } = parsedUrl const { branch } = gitRepoInfo() @@ -79,6 +99,7 @@ const getRepoData = async ({ } catch (error) { return { error: error instanceof Error ? error.message : (error?.toString() ?? 'Failed to get repo data'), + reason: error instanceof RepoDataLookupError ? error.reason : 'unreadable', } } } diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index fddec0afc74..c3faf296fe5 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -920,6 +920,7 @@ describe.concurrent('commands/init', () => { { path: 'user', response: { name: 'test user', slug: 'test-user', email: 'user@test.com' } }, { path: 'test-account/sites', method: 'POST' as const, response: { id: 'site_id', name: 'test-site-name' } }, ] + const deletedSiteRoute = { path: 'sites/deleted_site_id', status: 404, response: { message: 'Not Found' } } const countSiteCreations = (requests: { method: string; path: string }[]) => requests.filter(({ method, path: requestPath }) => method === 'POST' && requestPath.endsWith('/test-account/sites')) .length @@ -985,8 +986,9 @@ describe.concurrent('commands/init', () => { }) }) - test('netlify init with a git remote unlinks a deleted project before creating a new one', async (t) => { + test('netlify init with a git remote replaces a deleted project link with a new project', async (t) => { const routes = [ + deletedSiteRoute, ...noGitRoutes, { path: 'deploy_keys', method: 'POST' as const, response: { public_key: 'public_key' } }, { path: 'sites/site_id', method: 'PATCH' as const, response: { deploy_hook: 'deploy_hook' } }, @@ -1022,9 +1024,10 @@ describe.concurrent('commands/init', () => { }) }) - test('netlify init with a git remote treats a deleted project as unlinked when connecting an existing one', async (t) => { + test('netlify init keeps a deleted project link until a new project is saved', async (t) => { const existingSiteInfo = { ...manualSiteInfo, build_settings: { repo_url: 'https://github.com/owner/repo' } } const routes = [ + deletedSiteRoute, { path: 'accounts', response: [{ slug: 'test-account' }] }, { path: 'sites', response: [existingSiteInfo] }, { path: 'sites/site_id/service-instances', response: [] }, @@ -1049,7 +1052,7 @@ describe.concurrent('commands/init', () => { t.expect(result.stderr).toContain('No project specified. In non-interactive mode') t.expect(countSiteCreations(requests)).toBe(0) t.expect(requests.filter(({ method }) => method === 'PATCH')).toEqual([]) - await t.expect(readStateSiteId(builder.directory)).resolves.toBeUndefined() + await t.expect(readStateSiteId(builder.directory)).resolves.toBe('deleted_site_id') }, true, ) @@ -1085,7 +1088,7 @@ describe.concurrent('commands/init', () => { await builder.withStateFile({ siteId: 'deleted_site_id' }).build() await withMockApi( - noGitRoutes, + [deletedSiteRoute, ...noGitRoutes], async ({ apiUrl, requests }) => { const { stdout, stderr } = await runInitIn(builder.directory, apiUrl, createWithoutGitQuestions()) diff --git a/tests/unit/utils/get-repo-data.test.ts b/tests/unit/utils/get-repo-data.test.ts index 6433a1ffae1..eabe0288faa 100644 --- a/tests/unit/utils/get-repo-data.test.ts +++ b/tests/unit/utils/get-repo-data.test.ts @@ -127,7 +127,18 @@ describe('getRepoData', () => { const result = await getRepoData({ workingDir: '/test' }) - expect(result).toEqual({ error: 'No Git remote found' }) + expect(result).toEqual({ error: 'No Git remote found', reason: 'no-remote' }) + }) + + it('returns an error without credentials when the remote URL is invalid', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'https://user:s3cret@' } } }) + + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toEqual({ + error: 'The specified Git remote origin is not a valid URL: https://***@', + reason: 'invalid-url', + }) }) it('returns an error when the requested remote is not defined', async () => { @@ -138,6 +149,7 @@ describe('getRepoData', () => { expect(result).toEqual({ error: 'The specified remote "missing" is not defined in Git repo. Please use --git-remote-name flag to specify a remote.', + reason: 'unknown-remote', }) }) })