diff --git a/docs/commands/clone.md b/docs/commands/clone.md index d67d38b62b5..6b35c87bb38 100644 --- a/docs/commands/clone.md +++ b/docs/commands/clone.md @@ -8,12 +8,17 @@ description: Clone a remote repo and link it to an existing project on Netlify # `clone` -Clone a remote repository and link it to an existing project on Netlify -Use this command when the existing Netlify project is already configured to deploy from the existing repo. +Clone a repository and link it to a Netlify project -If you specify a target directory, the repo will be cloned into that directory. By default, a directory will be created with the name of the repo. +You can clone from: +- A GitHub/GitLab repository URL or shorthand (e.g., owner/repo) +- A Netlify site name (e.g., my-site) +- A Netlify site URL (e.g., https://my-site.netlify.app) -To specify a project, use --id or --name. By default, the Netlify project to link will be automatically detected if exactly one project found is found with a matching git URL. If we cannot find such a project, you will be interactively prompted to select one. +When cloning a Netlify site that has a connected repository, the repository will be cloned from the connected source (GitHub, GitLab, etc.). + + +If you specify a target directory, the repo will be cloned into that directory. By default, a directory will be created with the name of the repo or site. **Usage** @@ -23,26 +28,30 @@ netlify clone **Arguments** -- repo - URL of the repository to clone or Github `owner/repo` (required) +- repository - Repository URL, GitHub shorthand (owner/repo), Netlify site name, or Netlify site URL - targetDir - directory in which to clone the repository - will be created if it does not exist **Flags** - `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `id` (*string*) - ID of existing Netlify project to link to -- `name` (*string*) - Name of existing Netlify project to link to +- `id` (*string*) - ID of existing Netlify project to link to (only for GitHub/GitLab repos) +- `name` (*string*) - Name of existing Netlify project to link to (only for GitHub/GitLab repos) - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in **Examples** ```bash +netlify clone my-site-name +netlify clone https://my-site.netlify.app +netlify clone https://app.netlify.com/sites/my-site netlify clone vibecoder/next-unicorn netlify clone https://github.com/vibecoder/next-unicorn.git netlify clone git@github.com:vibecoder/next-unicorn.git netlify clone vibecoder/next-unicorn ./next-unicorn-shh-secret netlify clone --id 123-123-123-123 vibecoder/next-unicorn netlify clone --name my-project-name vibecoder/next-unicorn +netlify clone my-site-name ./local-folder ``` diff --git a/docs/index.md b/docs/index.md index 2a0899110d5..e681df42f73 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,7 +56,7 @@ Claim an anonymously deployed site and link it to your account ### [clone](/commands/clone) -Clone a remote repository and link it to an existing project on Netlify +Clone a repository and link it to a Netlify project ### [completion](/commands/completion) diff --git a/src/commands/clone/clone.ts b/src/commands/clone/clone.ts index 7d09289d1c7..67402223df3 100644 --- a/src/commands/clone/clone.ts +++ b/src/commands/clone/clone.ts @@ -1,12 +1,29 @@ +import { resolve } from 'path' + +import { LocalState } from '@netlify/dev-utils' import inquirer from 'inquirer' import { normalizeRepoUrl } from '../../utils/normalize-repo-url.js' -import { chalk, logAndThrowError, log } from '../../utils/command-helpers.js' +import { chalk, logAndThrowError, log, getToken, netlifyCommand, type APIError } from '../../utils/command-helpers.js' import { runGit } from '../../utils/run-git.js' +import execa from '../../utils/execa.js' import type BaseCommand from '../base-command.js' +import { NETLIFY_GIT_HOST } from '../git-credential/git-credential.js' import { link } from '../link/link.js' import type { CloneOptionValues } from './option_values.js' import { startSpinner } from '../../lib/spinner.js' +import type { SiteInfo } from '../../utils/types.js' + +const NETLIFY_GIT_SERVICE_HOST = 'hgit.services-prod.nsvcs.net' + +const isNetlifyGitServiceUrl = (repoUrl: string): boolean => { + try { + const host = new URL(repoUrl).host + return host === NETLIFY_GIT_HOST || host === NETLIFY_GIT_SERVICE_HOST + } catch { + return false + } +} const getTargetDir = async (defaultDir: string): Promise => { const { selectedDir } = await inquirer.prompt<{ selectedDir: string }>([ @@ -29,50 +46,267 @@ const cloneRepo = async (repoUrl: string, targetDir: string, debug: boolean): Pr } } -export const clone = async ( - options: CloneOptionValues, - command: BaseCommand, - args: { repo: string; targetDir?: string }, -) => { - await command.authenticate() +// Under `npx`/`pnpx`/`npm exec`, `process.argv[1]` points into a temp cache dir that +// gets cleaned up, so a git credential helper pinned to that path breaks after the +// fact. Fall back to the resolved invocation (e.g. `npx netlify`) in that case. +export const getCredentialHelper = (): string => { + const cliCommand = netlifyCommand() + const invocation = cliCommand === 'netlify' ? `'${process.execPath}' '${resolve(process.argv[1])}'` : cliCommand + return `!${invocation} git-credential` +} - const { repoUrl, httpsUrl, repoName } = normalizeRepoUrl(args.repo) +const configureGitAuth = async (repoDir: string): Promise => { + await execa('git', ['config', `credential.https://${NETLIFY_GIT_HOST}.helper`, ''], { cwd: repoDir }) + await execa('git', ['config', '--add', `credential.https://${NETLIFY_GIT_HOST}.helper`, getCredentialHelper()], { + cwd: repoDir, + }) + await execa('git', ['config', 'http.postBuffer', '524288000'], { cwd: repoDir }) +} - const targetDir = args.targetDir ?? (await getTargetDir(`./${repoName}`)) +const cloneFromNetlifyGit = async (repoUrl: string, targetDir: string, debug: boolean): Promise => { + try { + await execa( + 'git', + [ + '-c', + `credential.https://${NETLIFY_GIT_HOST}.helper=`, + '-c', + `credential.https://${NETLIFY_GIT_HOST}.helper=${getCredentialHelper()}`, + 'clone', + repoUrl, + targetDir, + ], + { + ...(debug ? {} : { stdio: 'pipe' }), + }, + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to clone repository: ${message}`) + } +} - const cloneSpinner = startSpinner({ text: `Cloning repository to ${chalk.cyan(targetDir)}` }) +const parseNetlifySiteInput = (input: string): { isNetlifySite: true; siteName: string } | { isNetlifySite: false } => { + const netlifyAppUrlRegex = /^https?:\/\/([^.]+)\.netlify\.app\/?$/ + const netlifyAppUrlMatch = netlifyAppUrlRegex.exec(input) + if (netlifyAppUrlMatch) { + return { isNetlifySite: true, siteName: netlifyAppUrlMatch[1] } + } + + const appNetlifyUrlRegex = /^https?:\/\/app\.netlify\.com\/(?:sites|projects)\/([^/]+)\/?/ + const appNetlifyUrlMatch = appNetlifyUrlRegex.exec(input) + if (appNetlifyUrlMatch) { + return { isNetlifySite: true, siteName: appNetlifyUrlMatch[1] } + } + + if (!input.includes('/') && !input.includes(':') && !input.includes('.')) { + return { isNetlifySite: true, siteName: input } + } + + return { isNetlifySite: false } +} + +// FIXME(serhalp): This suffers from the same egregious performance problem as `link`/`init`. +// We should fix it rather than keep spreading it. +const lookupSiteByName = async (api: BaseCommand['netlify']['api'], siteName: string): Promise => { try { - await cloneRepo(repoUrl, targetDir, options.debug ?? false) + const sites = await api.listSites({ name: siteName, filter: 'all' }) + const site = sites.find((s) => s.name === siteName) + return site ? (site as SiteInfo) : null } catch (error) { - return logAndThrowError(error) + if ((error as APIError).status === 404) { + return null + } + throw error } - cloneSpinner.success(`Cloned repository to ${chalk.cyan(targetDir)}`) +} - command.workingDir = targetDir +export const finalizeClone = async ( + options: CloneOptionValues, + command: BaseCommand, + workingDir: string, + linkOverrides: { id?: string; name?: string; gitRemoteUrl?: string }, +): Promise => { + command.workingDir = workingDir // TODO(serhalp): This shouldn't be necessary but `getPathInProject` does not take // `command.workingDir` into account. Carefully fix this and remove this line. - process.chdir(targetDir) + process.chdir(workingDir) + + // `command.netlify.repositoryRoot`/`state` were resolved from the pre-clone working + // directory before this command's action ran, so `link()` below would otherwise read + // and write against the original directory instead of the freshly cloned one. + command.netlify.repositoryRoot = workingDir + command.netlify.state = new LocalState(workingDir) const { id, name, ...globalOptions } = options - const linkOptions = { - ...globalOptions, - id, - name, - // Use the normalized HTTPS URL as the canonical git URL for linking to ensure - // we have a consistent URL format for looking up projects. - gitRemoteUrl: httpsUrl, - } - await link(linkOptions, command) + await link({ ...globalOptions, ...linkOverrides }, command) +} +const logCloneSuccess = ( + targetDir: string, + { credentialsConfigured = false, devCommand }: { credentialsConfigured?: boolean; devCommand?: string } = {}, +): void => { log() log(chalk.green('✔ Your project is ready to go!')) log(`→ Next, enter your project directory using ${chalk.cyanBright(`cd ${targetDir}`)}`) log() log(`→ You can now run other ${chalk.cyanBright('netlify')} CLI commands in this directory`) + if (credentialsConfigured) { + log(`Git is configured to use your Netlify credentials for this repository.`) + } log(`→ To build and deploy your project: ${chalk.cyanBright('netlify deploy')}`) - if (command.netlify.config.dev?.command) { - log(`→ To run your dev server: ${chalk.cyanBright(command.netlify.config.dev.command)}`) + if (devCommand) { + log(`→ To run your dev server: ${chalk.cyanBright(devCommand)}`) } log(`→ To see all available commands: ${chalk.cyanBright('netlify help')}`) log() } + +const cloneFromNetlifyGitService = async ( + options: CloneOptionValues, + command: BaseCommand, + args: { repo: string; targetDir?: string }, + siteInfo: SiteInfo, +): Promise => { + const [token] = await getToken() + if (!token) { + return logAndThrowError( + `No authentication token found. Run ${chalk.cyanBright('netlify login')} to authenticate first.`, + ) + } + + const accountSlug = siteInfo.account_slug + const siteSlug = siteInfo.name + + if (!accountSlug || !siteSlug) { + return logAndThrowError('Could not determine account or site slug from the site.') + } + + const repoUrl = `https://${NETLIFY_GIT_HOST}/${accountSlug}/${siteSlug}.git` + const targetDir = args.targetDir ?? (await getTargetDir(`./${siteSlug}`)) + const resolvedTargetDir = resolve(targetDir) + + log(`Remote: ${chalk.dim(repoUrl)}`) + + const cloneSpinner = startSpinner({ text: `Cloning repository to ${chalk.cyan(targetDir)}` }) + + try { + await cloneFromNetlifyGit(repoUrl, resolvedTargetDir, options.debug ?? false) + } catch (error) { + cloneSpinner.error() + return logAndThrowError(error) + } + + cloneSpinner.success(`Cloned repository to ${chalk.cyan(targetDir)}`) + + const configSpinner = startSpinner({ text: 'Configuring git credentials' }) + + try { + await configureGitAuth(resolvedTargetDir) + } catch (error) { + configSpinner.error() + return logAndThrowError(error) + } + + configSpinner.success('Configured git credentials') + + await finalizeClone(options, command, resolvedTargetDir, { id: siteInfo.id }) + logCloneSuccess(targetDir, { credentialsConfigured: true }) +} + +const isInsideGitRepo = async (): Promise => { + const { exitCode } = await execa('git', ['rev-parse', '--is-inside-work-tree'], { reject: false }) + return exitCode === 0 +} + +export const clone = async ( + options: CloneOptionValues, + command: BaseCommand, + args: { repo: string; targetDir?: string }, +) => { + await command.authenticate() + + const { api, site } = command.netlify + + if (site.id) { + return logAndThrowError( + `This directory is already linked to a Netlify project. Run ${chalk.cyanBright('netlify clone')} from outside any existing linked project directory.`, + ) + } + + if (await isInsideGitRepo()) { + return logAndThrowError( + `This directory is already inside a git repository. Run ${chalk.cyanBright('netlify clone')} from outside any existing git repository.`, + ) + } + + const parsedInput = parseNetlifySiteInput(args.repo) + + if (parsedInput.isNetlifySite) { + const siteSpinner = startSpinner({ text: `Looking up site ${chalk.cyan(parsedInput.siteName)}...` }) + + const siteInfo = await lookupSiteByName(api, parsedInput.siteName) + + if (!siteInfo) { + siteSpinner.error() + return logAndThrowError(`Could not find a Netlify site named "${parsedInput.siteName}"`) + } + + siteSpinner.success(`Found site ${chalk.cyan(siteInfo.name)}`) + + const connectedRepoUrl = siteInfo.build_settings?.repo_url + + if (connectedRepoUrl && isNetlifyGitServiceUrl(connectedRepoUrl)) { + log(`Site is connected to Netlify's managed git service.`) + log(`Cloning from Netlify's managed git service...`) + log() + + return cloneFromNetlifyGitService(options, command, args, siteInfo) + } + + if (connectedRepoUrl) { + log(`Site has a connected repository: ${chalk.dim(connectedRepoUrl)}`) + log(`Cloning from the connected repository...`) + log() + + const { repoUrl, repoName } = normalizeRepoUrl(connectedRepoUrl) + const targetDir = args.targetDir ?? (await getTargetDir(`./${repoName}`)) + + const cloneSpinner = startSpinner({ text: `Cloning repository to ${chalk.cyan(targetDir)}` }) + try { + await cloneRepo(repoUrl, targetDir, options.debug ?? false) + } catch (error) { + cloneSpinner.error() + return logAndThrowError(error) + } + cloneSpinner.success(`Cloned repository to ${chalk.cyan(targetDir)}`) + + await finalizeClone(options, command, targetDir, { id: siteInfo.id, gitRemoteUrl: connectedRepoUrl }) + logCloneSuccess(targetDir) + } else { + log(`Site does not have a connected repository.`) + log(`Cloning from Netlify's managed git service...`) + log() + + return cloneFromNetlifyGitService(options, command, args, siteInfo) + } + } else { + const { repoUrl, httpsUrl, repoName } = normalizeRepoUrl(args.repo) + + const targetDir = args.targetDir ?? (await getTargetDir(`./${repoName}`)) + + const cloneSpinner = startSpinner({ text: `Cloning repository to ${chalk.cyan(targetDir)}` }) + try { + await cloneRepo(repoUrl, targetDir, options.debug ?? false) + } catch (error) { + cloneSpinner.error() + return logAndThrowError(error) + } + cloneSpinner.success(`Cloned repository to ${chalk.cyan(targetDir)}`) + + // Use the normalized HTTPS URL as the canonical git URL for linking to ensure + // we have a consistent URL format for looking up projects. + await finalizeClone(options, command, targetDir, { id: options.id, name: options.name, gitRemoteUrl: httpsUrl }) + logCloneSuccess(targetDir, { devCommand: command.netlify.config.dev?.command }) + } +} diff --git a/src/commands/clone/index.ts b/src/commands/clone/index.ts index a18159db1f3..1b42b7680e1 100644 --- a/src/commands/clone/index.ts +++ b/src/commands/clone/index.ts @@ -6,25 +6,36 @@ import type { CloneOptionValues } from './option_values.js' export const createCloneCommand = (program: BaseCommand) => program .command('clone') + // TODO(serhalp): When making this feature public, add this line: + // When cloning a Netlify site without a connected repository, the repository will be cloned from Netlify's managed git service with automatic credential configuration. .description( - `Clone a remote repository and link it to an existing project on Netlify -Use this command when the existing Netlify project is already configured to deploy from the existing repo. + `Clone a repository and link it to a Netlify project -If you specify a target directory, the repo will be cloned into that directory. By default, a directory will be created with the name of the repo. +You can clone from: +- A GitHub/GitLab repository URL or shorthand (e.g., owner/repo) +- A Netlify site name (e.g., my-site) +- A Netlify site URL (e.g., https://my-site.netlify.app) -To specify a project, use --id or --name. By default, the Netlify project to link will be automatically detected if exactly one project found is found with a matching git URL. If we cannot find such a project, you will be interactively prompted to select one.`, +When cloning a Netlify site that has a connected repository, the repository will be cloned from the connected source (GitHub, GitLab, etc.). + + +If you specify a target directory, the repo will be cloned into that directory. By default, a directory will be created with the name of the repo or site.`, ) - .argument('', 'URL of the repository to clone or Github `owner/repo` (required)') + .argument('', 'Repository URL, GitHub shorthand (owner/repo), Netlify site name, or Netlify site URL') .argument('[targetDir]', 'directory in which to clone the repository - will be created if it does not exist') - .option('--id ', 'ID of existing Netlify project to link to') - .option('--name ', 'Name of existing Netlify project to link to') + .option('--id ', 'ID of existing Netlify project to link to (only for GitHub/GitLab repos)') + .option('--name ', 'Name of existing Netlify project to link to (only for GitHub/GitLab repos)') .addExamples([ + 'netlify clone my-site-name', + 'netlify clone https://my-site.netlify.app', + 'netlify clone https://app.netlify.com/sites/my-site', 'netlify clone vibecoder/next-unicorn', 'netlify clone https://github.com/vibecoder/next-unicorn.git', 'netlify clone git@github.com:vibecoder/next-unicorn.git', 'netlify clone vibecoder/next-unicorn ./next-unicorn-shh-secret', 'netlify clone --id 123-123-123-123 vibecoder/next-unicorn', 'netlify clone --name my-project-name vibecoder/next-unicorn', + 'netlify clone my-site-name ./local-folder', ]) .addHelpText('after', () => { const docsUrl = 'https://docs.netlify.com/cli/get-started/#link-and-unlink-sites' diff --git a/src/commands/git-credential/git-credential.ts b/src/commands/git-credential/git-credential.ts new file mode 100644 index 00000000000..1e29db49c98 --- /dev/null +++ b/src/commands/git-credential/git-credential.ts @@ -0,0 +1,52 @@ +import process from 'process' +import readline from 'readline' +import type { Readable, Writable } from 'stream' + +import { getToken } from '../../utils/command-helpers.js' +import type { GitCredentialOptionValues } from './option_values.js' + +export const NETLIFY_GIT_HOST = 'git.netlify.com' + +export const parseGitCredentialInput = async (input: Readable): Promise>> => { + const rl = readline.createInterface({ + input, + terminal: false, + }) + + const data: Record = {} + + for await (const line of rl) { + if (line === '') break + const [key, ...valueParts] = line.split('=') + if (key) { + data[key] = valueParts.join('=') + } + } + + return data +} + +export const writeCredentials = (output: Writable, token: string): void => { + output.write(`username=x-access-token\n`) + output.write(`password=${token}\n`) +} + +export const gitCredential = async (operation: string, _options: GitCredentialOptionValues): Promise => { + if (operation !== 'get') { + return + } + + const input = await parseGitCredentialInput(process.stdin) + + if (input.host?.split(':')[0] !== NETLIFY_GIT_HOST) { + return + } + + const [token] = await getToken() + + if (!token) { + return + } + + writeCredentials(process.stdout, token) +} diff --git a/src/commands/git-credential/index.ts b/src/commands/git-credential/index.ts new file mode 100644 index 00000000000..927de66539a --- /dev/null +++ b/src/commands/git-credential/index.ts @@ -0,0 +1,12 @@ +import type BaseCommand from '../base-command.js' +import type { GitCredentialOptionValues } from './option_values.js' + +export const createGitCredentialCommand = (program: BaseCommand) => + program + .command('git-credential', { hidden: true }) + .description('Git credential helper for Netlify authentication (used internally by git)') + .argument('', 'Git credential operation (get, store, erase)') + .action(async (operation: string, options: GitCredentialOptionValues) => { + const { gitCredential } = await import('./git-credential.js') + await gitCredential(operation, options) + }) diff --git a/src/commands/git-credential/option_values.ts b/src/commands/git-credential/option_values.ts new file mode 100644 index 00000000000..9c2157adace --- /dev/null +++ b/src/commands/git-credential/option_values.ts @@ -0,0 +1,3 @@ +import type { BaseOptionValues } from '../base-command.js' + +export type GitCredentialOptionValues = BaseOptionValues diff --git a/src/commands/main.ts b/src/commands/main.ts index 2b406b8d43d..0eef5a1433e 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -39,6 +39,7 @@ import { createDevCommand } from './dev/index.js' import { createDevExecCommand } from './dev-exec/index.js' import { createEnvCommand } from './env/index.js' import { createFunctionsCommand } from './functions/index.js' +import { createGitCredentialCommand } from './git-credential/index.js' import { createInitCommand } from './init/index.js' import { createLinkCommand } from './link/index.js' import { createLoginCommand } from './login/index.js' @@ -280,6 +281,7 @@ export const createMainCommand = (): BaseCommand => { createLogsCommand(program) createDatabaseCommand(program) createAgentsCommand(program) + createGitCredentialCommand(program) program.setAnalyticsPayload({ didEnableCompileCache }) diff --git a/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap b/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap index cb3354e7036..c80463ac37e 100644 --- a/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap +++ b/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap @@ -20,8 +20,7 @@ COMMANDS $ blobs Manage objects in Netlify Blobs $ build Build on your local machine $ claim Claim an anonymously deployed site and link it to your account - $ clone Clone a remote repository and link it to an existing project - on Netlify + $ clone Clone a repository and link it to a Netlify project $ completion Generate shell completion script $ create Create a new Netlify project using an AI agent $ database Provision a production ready Postgres database with a single @@ -82,8 +81,7 @@ COMMANDS $ blobs Manage objects in Netlify Blobs $ build Build on your local machine $ claim Claim an anonymously deployed site and link it to your account - $ clone Clone a remote repository and link it to an existing project - on Netlify + $ clone Clone a repository and link it to a Netlify project $ completion Generate shell completion script $ create Create a new Netlify project using an AI agent $ database Provision a production ready Postgres database with a single @@ -144,8 +142,7 @@ COMMANDS $ blobs Manage objects in Netlify Blobs $ build Build on your local machine $ claim Claim an anonymously deployed site and link it to your account - $ clone Clone a remote repository and link it to an existing project - on Netlify + $ clone Clone a repository and link it to a Netlify project $ completion Generate shell completion script $ create Create a new Netlify project using an AI agent $ database Provision a production ready Postgres database with a single @@ -206,8 +203,7 @@ COMMANDS $ blobs Manage objects in Netlify Blobs $ build Build on your local machine $ claim Claim an anonymously deployed site and link it to your account - $ clone Clone a remote repository and link it to an existing project - on Netlify + $ clone Clone a repository and link it to a Netlify project $ completion Generate shell completion script $ create Create a new Netlify project using an AI agent $ database Provision a production ready Postgres database with a single diff --git a/tests/integration/commands/help/__snapshots__/help.test.ts.snap b/tests/integration/commands/help/__snapshots__/help.test.ts.snap index 123ff40b050..92159f088b1 100644 --- a/tests/integration/commands/help/__snapshots__/help.test.ts.snap +++ b/tests/integration/commands/help/__snapshots__/help.test.ts.snap @@ -15,8 +15,7 @@ COMMANDS $ blobs Manage objects in Netlify Blobs $ build Build on your local machine $ claim Claim an anonymously deployed site and link it to your account - $ clone Clone a remote repository and link it to an existing project - on Netlify + $ clone Clone a repository and link it to a Netlify project $ completion Generate shell completion script $ create Create a new Netlify project using an AI agent $ database Provision a production ready Postgres database with a single diff --git a/tests/unit/commands/clone/clone.test.ts b/tests/unit/commands/clone/clone.test.ts new file mode 100644 index 00000000000..c68ffe12981 --- /dev/null +++ b/tests/unit/commands/clone/clone.test.ts @@ -0,0 +1,137 @@ +import { resolve } from 'path' + +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' + +const { mockAuthenticate, mockListSites, mockExeca, mockLink, MockLocalState } = vi.hoisted(() => ({ + mockAuthenticate: vi.fn(), + mockListSites: vi.fn(), + mockExeca: vi.fn(), + mockLink: vi.fn(), + MockLocalState: vi.fn(), +})) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + logAndThrowError: (message: unknown): never => { + throw message instanceof Error ? message : new Error(String(message)) + }, +})) + +vi.mock('../../../../src/utils/execa.js', () => ({ + default: mockExeca, +})) + +vi.mock('../../../../src/commands/link/link.js', () => ({ + link: mockLink, +})) + +vi.mock('@netlify/dev-utils', () => ({ + LocalState: MockLocalState, +})) + +import { clone, finalizeClone, getCredentialHelper } from '../../../../src/commands/clone/clone.js' + +function createMockCommand(overrides: { siteId?: string } = {}) { + return { + authenticate: mockAuthenticate, + netlify: { + api: { listSites: mockListSites }, + site: { id: overrides.siteId }, + }, + } as unknown as Parameters[1] +} + +describe('clone command', () => { + describe('clone', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticate.mockResolvedValue(undefined) + mockExeca.mockResolvedValue({ exitCode: 0 }) + }) + + it('aborts with an actionable error when the current directory is already linked to a project', async () => { + const command = createMockCommand({ siteId: 'existing-site-id' }) + + await expect(clone({}, command, { repo: 'owner/repo' })).rejects.toThrow(/already linked to a Netlify project/) + + expect(mockAuthenticate).toHaveBeenCalledOnce() + expect(mockExeca).not.toHaveBeenCalled() + expect(mockListSites).not.toHaveBeenCalled() + }) + + it('aborts with an actionable error when the current directory is already inside a git repository', async () => { + const command = createMockCommand() + mockExeca.mockResolvedValue({ exitCode: 0 }) + + await expect(clone({}, command, { repo: 'owner/repo' })).rejects.toThrow(/already inside a git repository/) + + expect(mockExeca).toHaveBeenCalledWith('git', ['rev-parse', '--is-inside-work-tree'], { reject: false }) + expect(mockListSites).not.toHaveBeenCalled() + }) + }) + + describe('finalizeClone', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLink.mockResolvedValue(undefined) + MockLocalState.mockImplementation((cwd: string) => ({ cwd })) + }) + + it('re-resolves repositoryRoot and state for the cloned directory before linking, instead of the pre-clone one', async () => { + const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => {}) + const command = { + workingDir: '/original/dir', + netlify: { + repositoryRoot: '/original/dir', + state: { cwd: '/original/dir' }, + }, + } as unknown as Parameters[1] + + await finalizeClone({}, command, '/cloned/dir', { id: 'site-id' }) + + expect(command.workingDir).toBe('/cloned/dir') + expect(chdirSpy).toHaveBeenCalledWith('/cloned/dir') + expect(command.netlify.repositoryRoot).toBe('/cloned/dir') + expect(MockLocalState).toHaveBeenCalledWith('/cloned/dir') + expect(command.netlify.state).toEqual({ cwd: '/cloned/dir' }) + expect(mockLink).toHaveBeenCalledWith({ id: 'site-id' }, command) + + chdirSpy.mockRestore() + }) + }) + + describe('getCredentialHelper', () => { + const originalArgv1 = process.argv[1] + + beforeEach(() => { + vi.stubEnv('npm_lifecycle_event', undefined) + vi.stubEnv('npm_config_user_agent', undefined) + vi.stubEnv('npm_command', undefined) + process.argv[1] = '/some/path/to/bin/run.js' + }) + + afterEach(() => { + vi.unstubAllEnvs() + process.argv[1] = originalArgv1 + }) + + it('pins the resolved node + script path when invoked directly', () => { + expect(getCredentialHelper()).toBe( + `!'${process.execPath}' '${resolve('/some/path/to/bin/run.js')}' git-credential`, + ) + }) + + it('falls back to `npx netlify` when invoked via npx, since argv[1] points into a temp cache dir', () => { + vi.stubEnv('npm_lifecycle_event', 'npx') + + expect(getCredentialHelper()).toBe('!npx netlify git-credential') + }) + + it('falls back to `pnpm exec netlify` when invoked via pnpm exec', () => { + vi.stubEnv('npm_config_user_agent', 'pnpm/8.0.0 npm/? node/v18.0.0') + vi.stubEnv('npm_command', 'exec') + + expect(getCredentialHelper()).toBe('!pnpm exec netlify git-credential') + }) + }) +}) diff --git a/tests/unit/commands/git-credential/git-credential.test.ts b/tests/unit/commands/git-credential/git-credential.test.ts new file mode 100644 index 00000000000..fd360a7f085 --- /dev/null +++ b/tests/unit/commands/git-credential/git-credential.test.ts @@ -0,0 +1,88 @@ +import { Readable, Writable } from 'stream' +import { describe, expect, it } from 'vitest' + +import { + parseGitCredentialInput, + writeCredentials, + NETLIFY_GIT_HOST, +} from '../../../../src/commands/git-credential/git-credential.js' + +describe('git-credential command', () => { + describe('parseGitCredentialInput', () => { + it('parses git credential input format', async () => { + const input = new Readable({ + read() { + this.push('protocol=https\n') + this.push('host=git.netlify.com\n') + this.push('path=/test/repo.git\n') + this.push('\n') + this.push(null) + }, + }) + + const result = await parseGitCredentialInput(input) + + expect(result).toEqual({ + protocol: 'https', + host: 'git.netlify.com', + path: '/test/repo.git', + }) + }) + + it('handles values with equals signs', async () => { + const input = new Readable({ + read() { + this.push('protocol=https\n') + this.push('host=example.com\n') + this.push('username=test=user\n') + this.push('\n') + this.push(null) + }, + }) + + const result = await parseGitCredentialInput(input) + + expect(result.username).toBe('test=user') + }) + + it('stops at empty line', async () => { + const input = new Readable({ + read() { + this.push('protocol=https\n') + this.push('\n') + this.push('host=should-not-be-included\n') + this.push(null) + }, + }) + + const result = await parseGitCredentialInput(input) + + expect(result).toEqual({ + protocol: 'https', + }) + expect(result.host).toBeUndefined() + }) + }) + + describe('writeCredentials', () => { + it('writes credentials in git credential format', () => { + const output: string[] = [] + const mockOutput = new Writable({ + write(chunk: Buffer, _encoding, callback) { + output.push(chunk.toString()) + callback() + }, + }) + + writeCredentials(mockOutput, 'my-test-token') + + expect(output.join('')).toBe('username=x-access-token\npassword=my-test-token\n') + }) + }) + + describe('NETLIFY_GIT_HOST', () => { + it('is the correct host', () => { + expect(NETLIFY_GIT_HOST).toBe('git.netlify.com') + }) + }) +})