Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions docs/commands/clone.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@
# `clone`

<!-- AUTO-GENERATED-CONTENT:START (GENERATE_COMMANDS_DOCS) -->
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.

Check warning on line 20 in docs/commands/clone.md

View workflow job for this annotation

GitHub Actions / lint-docs

[vale] reported by reviewdog 🐶 [smart-marks.smartApostrophes] Use a smart apostrophe (’) instead of a straight single quote mark in 'Netlify's' Raw Output: {"message": "[smart-marks.smartApostrophes] Use a smart apostrophe (’) instead of a straight single quote mark in 'Netlify's'", "location": {"path": "docs/commands/clone.md", "range": {"start": {"line": 20, "column": 96}}}, "severity": "WARNING"}

Check warning on line 20 in docs/commands/clone.md

View workflow job for this annotation

GitHub Actions / lint-docs

[vale] reported by reviewdog 🐶 [base.spelling] Spellcheck: did you really mean 'Netlify's'? Raw Output: {"message": "[base.spelling] Spellcheck: did you really mean 'Netlify's'?", "location": {"path": "docs/commands/clone.md", "range": {"start": {"line": 20, "column": 96}}}, "severity": "WARNING"}

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**

Expand All @@ -23,26 +29,26 @@

**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
```


Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
254 changes: 226 additions & 28 deletions src/commands/clone/clone.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
const { selectedDir } = await inquirer.prompt<{ selectedDir: string }>([
Expand All @@ -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<void> => {
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 })
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const targetDir = args.targetDir ?? (await getTargetDir(`./${repoName}`))
const cloneFromNetlifyGit = async (repoUrl: string, targetDir: string, debug: boolean): Promise<void> => {
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<SiteInfo | null> => {
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)}`)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<void> => {
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<void> => {
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 })
}
}
28 changes: 17 additions & 11 deletions src/commands/clone/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<repo>', 'URL of the repository to clone or Github `owner/repo` (required)')
.argument('<repository>', '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>', 'ID of existing Netlify project to link to')
.option('--name <name>', 'Name of existing Netlify project to link to')
.option('--id <id>', 'ID of existing Netlify project to link to (only for GitHub/GitLab repos)')
.option('--name <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'
Expand Down
Loading
Loading