diff --git a/docs/commands/clone.md b/docs/commands/clone.md index d67d38b62b5..6970a3697de 100644 --- a/docs/commands/clone.md +++ b/docs/commands/clone.md @@ -8,12 +8,18 @@ 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.). + +When cloning a Netlify site without a connected repository, the repository will be cloned from Netlify's managed git service with automatic credential configuration. + +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 +29,26 @@ 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..b3fe0b49b25 100644 --- a/src/commands/clone/clone.ts +++ b/src/commands/clone/clone.ts @@ -1,12 +1,27 @@ +import { resolve } from 'path' + 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, 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 { + return new URL(repoUrl).host === NETLIFY_GIT_SERVICE_HOST + } catch { + return false + } +} const getTargetDir = async (defaultDir: string): Promise => { const { selectedDir } = await inquirer.prompt<{ selectedDir: string }>([ @@ -29,50 +44,233 @@ 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() +const getCredentialHelper = (): string => { + const cliPath = process.argv[1] + return `!'${cliPath}' 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 } +} + +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 - // 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) +const finalizeClone = async ( + options: CloneOptionValues, + command: BaseCommand, + workingDir: string, + linkOverrides: { id?: string; name?: string; gitRemoteUrl?: string }, +): Promise => { + command.workingDir = workingDir + process.chdir(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 }) +} + +export const clone = async ( + options: CloneOptionValues, + command: BaseCommand, + args: { repo: string; targetDir?: string }, +) => { + await command.authenticate() + + const { api } = command.netlify + 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)}`) + + 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..a1ae9d35319 100644 --- a/src/commands/clone/index.ts +++ b/src/commands/clone/index.ts @@ -7,24 +7,30 @@ export const createCloneCommand = (program: BaseCommand) => program .command('clone') .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.). + +When cloning a Netlify site without a connected repository, the repository will be cloned from Netlify's managed git service with automatic credential configuration. + +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..de473c56bc5 --- /dev/null +++ b/src/commands/git-credential/git-credential.ts @@ -0,0 +1,57 @@ +import process from 'process' +import readline from 'readline' +import type { Readable, Writable } from 'stream' + +import { getToken } from '../../utils/command-helpers.js' +import type BaseCommand from '../base-command.js' +import type { GitCredentialOptionValues } from './option_values.js' + +export const NETLIFY_GIT_HOST = 'git.netlify.app' + +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, + _command: BaseCommand, +): 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..ad5b2786d95 --- /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, command: BaseCommand) => { + const { gitCredential } = await import('./git-credential.js') + await gitCredential(operation, options, command) + }) 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/git-credential/git-credential.test.ts b/tests/unit/commands/git-credential/git-credential.test.ts new file mode 100644 index 00000000000..1629df1ec21 --- /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.app\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.app', + 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.app') + }) + }) +})