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/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..4e80a36ef05 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -2,8 +2,8 @@ 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 getRepoData from '../../utils/get-repo-data.js' +import { type APIError, chalk, exit, log, logAndThrowError, netlifyCommand, warn } from '../../utils/command-helpers.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,6 +38,87 @@ const logExistingAndExit = ({ siteInfo }: { siteInfo: SiteInfo }): never => { return exit() } +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() + 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() + 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`, + )}, then ${chalk.cyanBright.bold(`${netlifyCommand()} init`)}.`, + ) + 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)) { + return undefined + } + if (siteInfo.name) { + return siteInfo + } + try { + return (await api.getSite({ siteId: siteInfo.id })) as unknown as SiteInfo + } catch (error_) { + const { status, message } = error_ as APIError + 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 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(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() + } + return undefined + } +} + /** * Creates and new site and exits the process */ @@ -245,7 +326,6 @@ export const init = async ( }) const { repositoryRoot, state } = command.netlify - const { siteInfo: existingSiteInfo } = command.netlify // Check logged in status await command.authenticate() @@ -257,14 +337,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, repoError: repoData }) + } // TODO(ndhoule): Custom error messaage here return await handleNoGitRemoteAndExit({ command, @@ -275,7 +358,7 @@ export const init = async ( }) } - const siteInfo = isEmpty(existingSiteInfo) ? await createOrLinkSiteToRepo(command) : existingSiteInfo + const siteInfo = linkedSiteInfo ?? (await createOrLinkSiteToRepo(command)) log() diff --git a/src/utils/get-repo-data.ts b/src/utils/get-repo-data.ts index 9cd415a484d..9cf0511300a 100644 --- a/src/utils/get-repo-data.ts +++ b/src/utils/get-repo-data.ts @@ -18,21 +18,40 @@ export interface RepoData { httpsUrl: string } +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, workingDir, }: { remoteName?: string workingDir: string -}): Promise => { +}): 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 RepoDataLookupError('No Git remote found', 'no-remote') + } + 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 RepoDataLookupError('No Git remote found', 'no-remote') } const baseGitPath = dirname(gitDirectory) @@ -51,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', ) } @@ -60,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() @@ -76,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 3b731b53509..c3faf296fe5 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,270 @@ 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 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 + 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', + }) + 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).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 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' } }, + ] + 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 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: [] }, + { 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.toBe('deleted_site_id') + }, + true, + ) + }) + }) + + 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( + [deletedSiteRoute, ...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).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') + }, + true, + ) + }) + }) + + 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).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') + }, + 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, + ) + }) + }) }) 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', }) }) })