diff --git a/docs/commands/init.md b/docs/commands/init.md index 9aa21e7e4fe..39827d5fe4a 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -20,6 +20,7 @@ netlify init - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `force` (*boolean*) - Reinitialize CI hooks if the linked project is already configured to use CI +- `git` (*boolean*) - Use Netlify-hosted git for deploys (no external provider needed) - `git-remote-name` (*string*) - Name of Git remote to use. e.g. "origin" - `manual` (*boolean*) - Manually configure a git remote for CI - `debug` (*boolean*) - Print debugging information diff --git a/docs/commands/push.md b/docs/commands/push.md new file mode 100644 index 00000000000..ae6ee3b0500 --- /dev/null +++ b/docs/commands/push.md @@ -0,0 +1,34 @@ +--- +title: Netlify CLI push command +sidebar: + label: push +description: Push code to Netlify via git, triggering a build +--- + +# `push` + + +Push code to Netlify via git, triggering a build + +**Usage** + +```bash +netlify push +``` + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `message` (*string*) - Commit message +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify push +netlify push -m "Add contact form" +``` + + + diff --git a/docs/index.md b/docs/index.md index ee86844d08a..1edaee93156 100644 --- a/docs/index.md +++ b/docs/index.md @@ -145,6 +145,10 @@ Open settings for the project linked to the current folder | [`open:site`](/commands/open#opensite) | Opens current project url in browser | +### [push](/commands/push) + +Push code to Netlify via git, triggering a build + ### [recipes](/commands/recipes) Create and modify files in a project using pre-defined recipes diff --git a/package-lock.json b/package-lock.json index 850fc986971..4db2f62cdde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@clack/prompts": "^1.0.0", "@fastify/static": "9.0.0", "@netlify/ai": "0.3.4", "@netlify/api": "14.0.13", @@ -620,6 +621,27 @@ "resolved": "https://registry.npmjs.org/@bugsnag/safe-json-stringify/-/safe-json-stringify-6.1.0.tgz", "integrity": "sha512-ImA35rnM7bGr+J30R979FQ95BhRB4UO1KfJA0J2sVqc8nwnrS9hhE5mkTmQWMs8Vh1Da+hkLKs5jJB4JjNZp4A==" }, + "node_modules/@clack/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz", + "integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz", + "integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.0.0", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -18399,6 +18421,12 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", diff --git a/package.json b/package.json index 2a3cf5209c0..2f484f1bc7a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "typecheck:watch": "tsc --watch" }, "dependencies": { + "@clack/prompts": "^1.0.0", "@fastify/static": "9.0.0", "@netlify/ai": "0.3.4", "@netlify/api": "14.0.13", diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 5b847a8654d..d5afed576d5 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -40,6 +40,7 @@ import { type APIError, } from '../../utils/command-helpers.js' import { DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js' +import { getDeployUrls } from '../../utils/deploy/deploy-output.js' import { type DeployEvent, deploySite } from '../../utils/deploy/deploy-site.js' import { uploadSourceZip } from '../../utils/deploy/upload-source-zip.js' import { getEnvelopeEnv } from '../../utils/env/index.js' @@ -650,27 +651,13 @@ const runDeploy = async ({ return reportDeployError({ error: error as DeployError, failAndExit: logAndThrowError }) } - const siteUrl = results.deploy.ssl_url || results.deploy.url - const deployUrl = results.deploy.deploy_ssl_url || results.deploy.deploy_url - const logsUrl = `${results.deploy.admin_url}/deploys/${results.deploy.id}` - - let functionLogsUrl = `${results.deploy.admin_url}/logs/functions` - let edgeFunctionLogsUrl = `${results.deploy.admin_url}/logs/edge-functions` - - if (!deployToProduction) { - functionLogsUrl += `?scope=deploy:${deployId}` - edgeFunctionLogsUrl += `?scope=deployid:${deployId}` - } + const urls = getDeployUrls(results.deploy, { deployToProduction }) return { siteId: results.deploy.site_id, siteName: results.deploy.name, deployId: results.deployId, - siteUrl, - deployUrl, - logsUrl, - functionLogsUrl, - edgeFunctionLogsUrl, + ...urls, sourceZipFileName: uploadSourceZipResult?.sourceZipFileName, } } diff --git a/src/commands/git-credentials/git-credentials.ts b/src/commands/git-credentials/git-credentials.ts new file mode 100644 index 00000000000..e522660bb33 --- /dev/null +++ b/src/commands/git-credentials/git-credentials.ts @@ -0,0 +1,36 @@ +import process from 'process' + +import type BaseCommand from '../base-command.js' + +const readStdin = (): Promise => + new Promise((resolve) => { + let data = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', (chunk: string) => { + data += chunk + }) + process.stdin.on('end', () => { + resolve(data) + }) + // If stdin isn't being piped, resolve after a short timeout + if (process.stdin.isTTY) { + resolve(data) + } + }) + +export const gitCredentials = async (command: BaseCommand) => { + const input = await readStdin() + + // Only respond to "get" requests from the git credential protocol + if (!input.includes('protocol=') && !input.startsWith('get')) { + return + } + + const token = command.netlify.api.accessToken + if (!token) { + throw new Error('No access token found. Please run `netlify login` first.') + } + + // Output in git credential helper format + process.stdout.write(`username=x-access-token\npassword=${token}\n`) +} diff --git a/src/commands/git-credentials/index.ts b/src/commands/git-credentials/index.ts new file mode 100644 index 00000000000..befacfaa77a --- /dev/null +++ b/src/commands/git-credentials/index.ts @@ -0,0 +1,10 @@ +import BaseCommand from '../base-command.js' + +export const createGitCredentialsCommand = (program: BaseCommand) => + program + .command('git-credentials', { hidden: true }) + .description('Git credential helper for Netlify-hosted repos') + .action(async (_options, command: BaseCommand) => { + const { gitCredentials } = await import('./git-credentials.js') + await gitCredentials(command) + }) diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index ff9552ac350..18ff5ae09c3 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -10,6 +10,7 @@ export const createInitCommand = (program: BaseCommand) => 'Configure continuous deployment for a new or existing project. To create a new project without continuous deployment, use `netlify sites:create`', ) .option('-m, --manual', 'Manually configure a git remote for CI') + .option('--git', 'Use Netlify-hosted git for deploys (no external provider needed)') .option('--git-remote-name ', 'Name of Git remote to use. e.g. "origin"') .addHelpText('after', () => { const docsUrl = 'https://docs.netlify.com/cli/get-started/' diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index f43d22718ba..f569f83ef10 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -6,6 +6,7 @@ import { chalk, exit, log, netlifyCommand } from '../../utils/command-helpers.js import getRepoData from '../../utils/get-repo-data.js' import { ensureNetlifyIgnore } from '../../utils/gitignore.js' import { configureRepo } from '../../utils/init/config.js' +import { configNetlifyGit } from '../../utils/init/config-netlify-git.js' import { track } from '../../utils/telemetry/index.js' import type BaseCommand from '../base-command.js' import { link } from '../link/link.js' @@ -241,6 +242,14 @@ export const init = async ( // Add .netlify to .gitignore file await ensureNetlifyIgnore(repositoryRoot) + // Handle --git flag: use Netlify-hosted git + if (options.git) { + const siteInfo = isEmpty(existingSiteInfo) ? await createOrLinkSiteToRepo(command) : existingSiteInfo + persistState({ state, siteInfo }) + await configNetlifyGit({ command, siteId: siteInfo.id }) + return siteInfo + } + const repoUrl = getRepoUrl(existingSiteInfo) if (repoUrl && !options.force) { logExistingAndExit({ siteInfo: existingSiteInfo }) diff --git a/src/commands/main.ts b/src/commands/main.ts index cc8724ea8d4..818ffb04181 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -35,12 +35,14 @@ 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 { createGitCredentialsCommand } from './git-credentials/index.js' import { createInitCommand } from './init/index.js' import { createLinkCommand } from './link/index.js' import { createLoginCommand } from './login/index.js' import { createLogoutCommand } from './logout/index.js' import { createLogsCommand } from './logs/index.js' import { createOpenCommand } from './open/index.js' +import { createPushCommand } from './push/index.js' import { createRecipesCommand } from './recipes/index.js' import { createServeCommand } from './serve/index.js' import { createSitesCommand } from './sites/index.js' @@ -221,6 +223,7 @@ export const createMainCommand = (): BaseCommand => { createDevCommand(program) createEnvCommand(program) createFunctionsCommand(program) + createGitCredentialsCommand(program) createRecipesCommand(program) createInitCommand(program) createCloneCommand(program) @@ -228,6 +231,7 @@ export const createMainCommand = (): BaseCommand => { createLoginCommand(program) createLogoutCommand(program) createOpenCommand(program) + createPushCommand(program) createServeCommand(program) createSitesCommand(program) createStatusCommand(program) diff --git a/src/commands/push/index.ts b/src/commands/push/index.ts new file mode 100644 index 00000000000..7f68cb04f6d --- /dev/null +++ b/src/commands/push/index.ts @@ -0,0 +1,14 @@ +import type { OptionValues } from 'commander' + +import BaseCommand from '../base-command.js' + +export const createPushCommand = (program: BaseCommand) => + program + .command('push') + .description('Push code to Netlify via git, triggering a build') + .option('-m, --message ', 'Commit message') + .addExamples(['netlify push', 'netlify push -m "Add contact form"']) + .action(async (options: OptionValues, command: BaseCommand) => { + const { push } = await import('./push.js') + await push(options, command) + }) diff --git a/src/commands/push/push.ts b/src/commands/push/push.ts new file mode 100644 index 00000000000..ce7b785cddf --- /dev/null +++ b/src/commands/push/push.ts @@ -0,0 +1,283 @@ +import type { OptionValues } from 'commander' +import * as p from '@clack/prompts' +import terminalLink from 'terminal-link' + +import { DEPLOY_POLL, DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js' +import { getDeployUrls } from '../../utils/deploy/deploy-output.js' +import { chalk, logAndThrowError, NETLIFY_CYAN } from '../../utils/command-helpers.js' +import execa from '../../utils/execa.js' +import type BaseCommand from '../base-command.js' + +const DEPLOY_STATE_MESSAGES: Record = { + new: 'Build triggered', + enqueued: 'Build enqueued', + building: 'Building...', + uploading: 'Deploying...', + uploaded: 'Deploying...', + preparing: 'Preparing deploy...', + prepared: 'Preparing deploy...', + processing: 'Processing...', + processed: 'Processing...', +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const prettyLink = (url: string) => terminalLink(url, url, { fallback: false }) + +const printDeploySuccess = (deploy: { + id?: string + ssl_url?: string + url?: string + deploy_ssl_url?: string + deploy_url?: string + admin_url?: string +}) => { + const urls = getDeployUrls(deploy) + + p.log.success(`Production URL: ${prettyLink(urls.siteUrl)}`) + p.log.step(`Unique deploy URL: ${prettyLink(urls.deployUrl)}`) + p.log.step(`Build logs: ${prettyLink(urls.logsUrl)}`) + p.log.step(`Function logs: ${prettyLink(urls.functionLogsUrl)}`) + p.log.step(`Edge function logs: ${prettyLink(urls.edgeFunctionLogsUrl)}`) +} + +interface Build { + sha?: string + deploy_id?: string + done?: boolean + error?: string +} + +/** + * Fetch builds for a site, optionally filtered by commit SHA. + * Uses a direct fetch because the `sha` query param is not yet in the OpenAPI spec. + */ +const fetchBuilds = async (api: BaseCommand['netlify']['api'], siteId: string, sha?: string): Promise => { + const params = new URLSearchParams() + if (sha) { + params.set('sha', sha) + } + const qs = params.toString() + const url = `${api.basePath}/sites/${siteId}/builds${qs ? `?${qs}` : ''}` + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${api.accessToken ?? ''}`, + 'Content-Type': 'application/json', + }, + }) + if (!response.ok) { + throw new Error(`Failed to fetch builds: ${response.status.toString()}`) + } + return (await response.json()) as Build[] +} + +const waitForBuildAndDeploy = async ( + api: BaseCommand['netlify']['api'], + siteId: string, + commitSha: string, + s: ReturnType, +) => { + s.start('Waiting for build...') + + const startTime = Date.now() + + // Phase 1: Poll for a build matching our commit SHA + let deployId: string | undefined + while (Date.now() - startTime < DEFAULT_DEPLOY_TIMEOUT) { + try { + const builds = await fetchBuilds(api, siteId, commitSha) + const matchingBuild = builds.at(0) + + if (matchingBuild) { + // Check if the build itself errored before producing a deploy + if (matchingBuild.done && matchingBuild.error && !matchingBuild.deploy_id) { + s.stop('Build failed') + return logAndThrowError(`Build failed: ${matchingBuild.error}`) + } + + if (matchingBuild.deploy_id) { + deployId = matchingBuild.deploy_id + break + } + + s.message('Build triggered') + } + } catch { + // Swallow transient API errors, retry next interval + } + + await sleep(DEPLOY_POLL) + } + + if (!deployId) { + s.stop('Timed out waiting for build') + return logAndThrowError('Timed out waiting for build to start. Check the Netlify dashboard for status.') + } + + // Stop the build-polling spinner before printing the log URL + s.stop('Build started') + + // Print deploy logs URL + try { + const deploy = await api.getSiteDeploy({ siteId, deployId }) + const urls = getDeployUrls(deploy) + p.log.step(`Deploy logs streaming here: ${prettyLink(urls.logsUrl)}`) + } catch { + // Non-critical, continue polling + } + + // Phase 2: Poll deploy status until terminal state + const s2 = p.spinner() + s2.start('Building...') + + while (Date.now() - startTime < DEFAULT_DEPLOY_TIMEOUT) { + try { + const deploy = await api.getSiteDeploy({ siteId, deployId }) + + if (deploy.state === 'ready') { + s2.stop('Site is live!') + printDeploySuccess(deploy) + return + } + + if (deploy.state === 'error') { + s2.stop('Deploy failed') + return logAndThrowError(`Deploy failed: ${deploy.error_message || 'Unknown error'}`) + } + + const message = DEPLOY_STATE_MESSAGES[deploy.state ?? ''] + if (message) { + s2.message(message) + } + } catch { + // Swallow transient API errors, retry next interval + } + + await sleep(DEPLOY_POLL) + } + + s2.stop('Timed out waiting for deploy') + return logAndThrowError('Deploy timed out. Check the Netlify dashboard for status.') +} + +export const push = async (options: OptionValues, command: BaseCommand) => { + p.intro(NETLIFY_CYAN.underline('Push to Netlify')) + + await command.authenticate() + + // 1. Verify netlify remote exists + const { stdout: remotes } = await execa('git', ['remote']) + if (!remotes.includes('netlify')) { + p.cancel('No netlify remote found.') + return logAndThrowError('No netlify remote found. Run `netlify init --git` first.') + } + + const s = p.spinner() + + // 2. Check if this is a fresh repo with no commits yet + let isInitialCommit = false + try { + await execa('git', ['rev-parse', 'HEAD']) + } catch { + isInitialCommit = true + } + + // 3. Check for working tree changes (unstaged + staged) + const { stdout: status } = await execa('git', ['status', '--porcelain']) + const hasChanges = status.trim().length > 0 + + if (hasChanges) { + // Stage everything + s.start('Staging changes') + await execa('git', ['add', '.']) + + // Build a colorful diff summary: +insertions -deletions N files changed + const { stdout: diffStat } = await execa('git', ['diff', '--cached', '--shortstat'], { reject: false }) + const filesMatch = /(\d+) files? changed/.exec(diffStat) + const insertMatch = /(\d+) insertions?/.exec(diffStat) + const deleteMatch = /(\d+) deletions?/.exec(diffStat) + const parts: string[] = [] + if (insertMatch) parts.push(chalk.green(`+${insertMatch[1]}`)) + if (deleteMatch) parts.push(chalk.red(`-${deleteMatch[1]}`)) + if (filesMatch) parts.push(`${filesMatch[1]} files changed`) + const summary = parts.length > 0 ? ` (${parts.join(' ')})` : '' + s.stop(`Changes staged${summary}`) + + // Commit + const userMessage = typeof options.message === 'string' ? options.message : undefined + const message: string = + userMessage ?? (isInitialCommit ? 'Initial deploy via Netlify CLI' : `Deploy at ${new Date().toLocaleString()}`) + + const commitResult = await execa( + 'git', + ['commit', '--no-gpg-sign', '--author', 'Netlify CLI ', '-m', message], + { reject: false }, + ) + if (commitResult.exitCode !== 0 && !commitResult.stderr.includes('nothing to commit')) { + p.cancel('Commit failed') + return logAndThrowError(`Commit failed: ${commitResult.stderr}`) + } + p.log.success(userMessage ? `Committed: ${userMessage}` : 'Committed') + } else if (!isInitialCommit) { + // No local changes — check if there are unpushed commits. + const { stdout: currentBranchName } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD']) + const branch = currentBranchName.trim() + // First check if the remote tracking branch exists at all (it won't before the first push). + const { exitCode: remoteRefExists } = await execa('git', ['rev-parse', '--verify', `netlify/${branch}`], { + reject: false, + }) + if (remoteRefExists === 0) { + // Use rev-list --count for a reliable machine-readable check + const { stdout: countStr } = await execa('git', ['rev-list', '--count', `netlify/${branch}..HEAD`], { + reject: false, + }) + if (countStr.trim() === '0') { + p.log.step('Everything up to date — nothing to push') + p.outro('Already deployed!') + return + } + p.log.step(`Pushing ${countStr.trim()} unpushed commit(s)`) + } else { + p.log.step('Pushing to Netlify for the first time') + } + } + + // 4. Push the current branch + const { stdout: currentBranch } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD']) + s.start(`Pushing ${currentBranch.trim()} to Netlify`) + const pushResult = await execa('git', ['push', '--porcelain', '-u', 'netlify', currentBranch.trim()], { + reject: false, + }) + if (pushResult.exitCode !== 0) { + s.stop('Push failed') + p.cancel(pushResult.stderr || 'Push failed') + return logAndThrowError(`Push failed: ${pushResult.stderr}`) + } + + // --porcelain output format: \t:\t + // flag '=' means up-to-date (nothing pushed), ' ' or other means data was transferred + const porcelainOutput = pushResult.stdout.trim() + if (porcelainOutput.includes('=\t') || porcelainOutput.includes('[up to date]')) { + s.stop('Nothing to push') + p.outro('Already deployed!') + return + } + + s.stop('Pushed to Netlify Git') + + // 5. Wait for build and deploy + const siteId = command.netlify.site.id + if (!siteId) { + p.log.warn('No linked site found. Run `netlify link` to enable deploy tracking.') + p.outro('Build triggered! Your deploy will start shortly.') + return + } + + const { stdout: shaOutput } = await execa('git', ['rev-parse', 'HEAD']) + const commitSha = shaOutput.trim() + + const deploySpinner = p.spinner() + await waitForBuildAndDeploy(command.netlify.api, siteId, commitSha, deploySpinner) + p.outro('Deploy complete!') +} diff --git a/src/utils/deploy/deploy-output.ts b/src/utils/deploy/deploy-output.ts new file mode 100644 index 00000000000..ed3c45b0234 --- /dev/null +++ b/src/utils/deploy/deploy-output.ts @@ -0,0 +1,38 @@ +export interface DeployUrls { + siteUrl: string + deployUrl: string + logsUrl: string + functionLogsUrl: string + edgeFunctionLogsUrl: string +} + +/** + * Derive all relevant URLs from a deploy object (as returned by the Netlify API). + */ +export const getDeployUrls = ( + deploy: { + id?: string + ssl_url?: string + url?: string + deploy_ssl_url?: string + deploy_url?: string + admin_url?: string + }, + { deployToProduction = true }: { deployToProduction?: boolean } = {}, +): DeployUrls => { + const siteUrl = deploy.ssl_url || deploy.url || '' + const deployUrl = deploy.deploy_ssl_url || deploy.deploy_url || '' + const adminUrl = deploy.admin_url ?? '' + const id = deploy.id ?? '' + const logsUrl = `${adminUrl}/deploys/${id}` + + let functionLogsUrl = `${adminUrl}/logs/functions` + let edgeFunctionLogsUrl = `${adminUrl}/logs/edge-functions` + + if (!deployToProduction && id) { + functionLogsUrl += `?scope=deploy:${id}` + edgeFunctionLogsUrl += `?scope=deployid:${id}` + } + + return { siteUrl, deployUrl, logsUrl, functionLogsUrl, edgeFunctionLogsUrl } +} diff --git a/src/utils/init/config-netlify-git.ts b/src/utils/init/config-netlify-git.ts new file mode 100644 index 00000000000..2aca8c2b179 --- /dev/null +++ b/src/utils/init/config-netlify-git.ts @@ -0,0 +1,103 @@ +import path from 'path' +import process from 'process' + +import type BaseCommand from '../../commands/base-command.js' +import { chalk, log, netlifyCommand } from '../command-helpers.js' +import execa from '../execa.js' + +import { getBuildSettings, saveNetlifyToml, setupSite } from './utils.js' + +export const configNetlifyGit = async ({ command, siteId }: { command: BaseCommand; siteId: string }) => { + const { + api, + cachedConfig: { configPath }, + config, + repositoryRoot, + } = command.netlify + + // 1. Prompt for build settings + const { baseDir, buildCmd, buildDir, functionsDir, pluginsToInstall } = await getBuildSettings({ + config, + command, + }) + + // 2. Save netlify.toml + await saveNetlifyToml({ repositoryRoot, config, configPath, baseDir, buildCmd, buildDir, functionsDir }) + + // 3. Set up the site with netlify-git provider via API + const repo = { + provider: 'netlify-git', + repo_branch: 'main', + allowed_branches: ['main'], + ...(baseDir && { base: baseDir }), + ...(buildDir && { dir: buildDir }), + ...(functionsDir && { functions_dir: functionsDir }), + ...(buildCmd && { cmd: buildCmd }), + } + + const updatedSite = await setupSite({ + api, + siteId, + repo, + configPlugins: config.plugins ?? [], + pluginsToInstall, + }) + + // 4. Read the remote URL from the API response + const remoteUrl = updatedSite.build_settings?.repo_url + if (!remoteUrl) { + log(chalk.yellow('Warning: Could not determine git remote URL from API response.')) + log('You may need to configure the git remote manually.') + return + } + + // 5. Initialize git if needed + try { + await execa('git', ['rev-parse', '--git-dir']) + } catch { + await execa('git', ['init', '.']) + } + + // 6. Add netlify remote (remove existing if present) + try { + const { stdout: remotes } = await execa('git', ['remote']) + if (remotes.includes('netlify')) { + await execa('git', ['remote', 'set-url', 'netlify', remoteUrl]) + } else { + await execa('git', ['remote', 'add', 'netlify', remoteUrl]) + } + } catch { + await execa('git', ['remote', 'add', 'netlify', remoteUrl]) + } + + // 7. Set local main branch to track netlify/main if no tracking branch is set + const { stdout: trackingBranch } = await execa('git', ['config', '--get', 'branch.main.remote'], { reject: false }) + if (!trackingBranch.trim()) { + await execa('git', ['config', '--local', 'branch.main.remote', 'netlify']) + await execa('git', ['config', '--local', 'branch.main.merge', 'refs/heads/main']) + } + + // 8. Configure credential helper so git uses `netlify git-credentials` for auth. + // Git `!` credential helpers run in a non-interactive shell where bash aliases + // aren't available. For direct invocations (global install, local dev, alias, + // symlink), use the absolute node + script paths so it always resolves. For + // package runner invocations (npx, pnpx, npm exec), process.argv[1] points + // into a temp cache dir, so fall back to netlifyCommand() (e.g. "npx netlify"). + const origin = new URL(remoteUrl).origin + const cliCommand = netlifyCommand() + const credentialHelper = + cliCommand === 'netlify' ? `'${process.execPath}' '${path.resolve(process.argv[1])}'` : cliCommand + await execa('git', ['config', '--local', `credential.${origin}.helper`, `!${credentialHelper} git-credentials`]) + + // 8. Log success + log() + log(chalk.greenBright.bold.underline('Success! Netlify Git configured!')) + log() + log(`Your project is set up to deploy via Netlify-hosted git.`) + log(`Remote URL: ${chalk.cyan(remoteUrl)}`) + log() + log(`Next steps:`) + log(` ${chalk.cyanBright.bold(`${netlifyCommand()} push`)} Push your code and trigger a deploy`) + log(` ${chalk.cyanBright.bold(`${netlifyCommand()} open`)} Open the Netlify admin URL`) + log() +} diff --git a/tests/integration/commands/help/__snapshots__/help.test.ts.snap b/tests/integration/commands/help/__snapshots__/help.test.ts.snap index c564dcaa2d9..2b93cc2c877 100644 --- a/tests/integration/commands/help/__snapshots__/help.test.ts.snap +++ b/tests/integration/commands/help/__snapshots__/help.test.ts.snap @@ -31,6 +31,7 @@ COMMANDS $ login Login to your Netlify account $ logs Stream logs from your project $ open Open settings for the project linked to the current folder + $ push Push code to Netlify via git, triggering a build $ recipes Create and modify files in a project using pre-defined recipes $ serve Build the project for production and serve locally. This does not watch the code for changes, so if you need to rebuild your