netlify-cli 27.15.0 → 27.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,7 @@ import hashFiles from '../../utils/deploy/hash-files.js';
35
35
  import { deployFileNormalizer, getEdgeFunctionsDistPathIfExists } from '../../utils/deploy/process-files.js';
36
36
  import { link } from '../link/link.js';
37
37
  import { sitesCreate } from '../sites/sites-create.js';
38
+ import { remoteDeploy } from './remote-deploy.js';
38
39
  import boxen from 'boxen';
39
40
  import terminalLink from 'terminal-link';
40
41
  import { anyEdgeFunctionsDirectoryExists } from '../../lib/edge-functions/get-directories.js';
@@ -559,7 +560,7 @@ const bundleEdgeFunctions = async (options, command) => {
559
560
  phase: 'stop',
560
561
  });
561
562
  };
562
- const printResults = ({ deployToProduction, uploadSourceZip, json, results, runBuildCommand, }) => {
563
+ const printResults = ({ deployToProduction, uploadSourceZip, json, results, prodCommand, }) => {
563
564
  const msgData = {
564
565
  'Build logs': terminalLink(results.logsUrl, results.logsUrl, { fallback: false }),
565
566
  'Function logs': terminalLink(results.functionLogsUrl, results.functionLogsUrl, { fallback: false }),
@@ -585,6 +586,9 @@ const printResults = ({ deployToProduction, uploadSourceZip, json, results, runB
585
586
  if (uploadSourceZip) {
586
587
  jsonData.source_zip_filename = results.sourceZipFileName;
587
588
  }
589
+ if (results.commitSha) {
590
+ jsonData.commit_sha = results.commitSha;
591
+ }
588
592
  logJson(jsonData);
589
593
  exit(0);
590
594
  }
@@ -602,7 +606,7 @@ const printResults = ({ deployToProduction, uploadSourceZip, json, results, runB
602
606
  if (!deployToProduction) {
603
607
  log();
604
608
  log('If everything looks good on your draft URL, deploy it to your main project URL with the --prod flag:');
605
- log(`netlify deploy${runBuildCommand ? '' : ' --no-build'} --prod`);
609
+ log(prodCommand);
606
610
  log();
607
611
  }
608
612
  }
@@ -626,7 +630,7 @@ const printResults = ({ deployToProduction, uploadSourceZip, json, results, runB
626
630
  if (!deployToProduction) {
627
631
  log();
628
632
  log('If everything looks good on your draft URL, deploy it to your main project URL with the --prod flag:');
629
- log(chalk.cyanBright.bold(`netlify deploy${runBuildCommand ? '' : ' --no-build'} --prod`));
633
+ log(chalk.cyanBright.bold(prodCommand));
630
634
  log();
631
635
  }
632
636
  }
@@ -787,6 +791,9 @@ const ensureSiteExists = async (options, command, site, siteInfo) => {
787
791
  if (hasSiteData) {
788
792
  return siteInfo;
789
793
  }
794
+ if (options.remote) {
795
+ return logAndThrowError('--remote needs an existing Netlify project connected to a git repository. Run netlify link to link this directory to one, or pass --site.');
796
+ }
790
797
  if (options.createSite) {
791
798
  return createSiteWithFlags(options, command, site);
792
799
  }
@@ -940,7 +947,13 @@ export const deploy = async (options, command) => {
940
947
  const { workingDir } = command;
941
948
  const { api, site, siteInfo } = command.netlify;
942
949
  const alias = options.alias || options.branch;
943
- command.setAnalyticsPayload({ open: options.open, prod: options.prod, json: options.json, alias: Boolean(alias) });
950
+ command.setAnalyticsPayload({
951
+ open: options.open,
952
+ prod: options.prod,
953
+ json: options.json,
954
+ alias: Boolean(alias),
955
+ remote: Boolean(options.remote),
956
+ });
944
957
  if (options.allowAnonymous) {
945
958
  const [token] = await getToken(options.auth);
946
959
  if (token) {
@@ -969,7 +982,19 @@ export const deploy = async (options, command) => {
969
982
  }
970
983
  const deployToProduction = !options.draft && (options.prod || (options.prodIfUnlocked && !(siteData.published_deploy?.locked ?? false)));
971
984
  let results = {};
972
- if (options.build) {
985
+ let prodCommand = `netlify deploy${options.build ? '' : ' --no-build'} --prod`;
986
+ if (options.remote) {
987
+ results = await remoteDeploy({
988
+ api,
989
+ workingDir,
990
+ options,
991
+ siteData,
992
+ deployToProduction,
993
+ prepareProductionDeploy: () => prepareProductionDeploy({ siteData, api, options, command }),
994
+ });
995
+ prodCommand = 'netlify deploy --remote --prod';
996
+ }
997
+ else if (options.build) {
973
998
  const deployMetadata = await createDeploy({ alias, api, command, deployToProduction, options, siteData, siteId });
974
999
  const deployId = deployMetadata.id;
975
1000
  const skewProtectionToken = deployMetadata.skew_protection_token;
@@ -1037,7 +1062,7 @@ export const deploy = async (options, command) => {
1037
1062
  });
1038
1063
  }
1039
1064
  printResults({
1040
- runBuildCommand: options.build,
1065
+ prodCommand,
1041
1066
  json: options.json,
1042
1067
  results,
1043
1068
  deployToProduction,
@@ -4,15 +4,25 @@ import terminalLink from 'terminal-link';
4
4
  import { normalizeContext } from '../../utils/env/index.js';
5
5
  import { findDuplicateKey, mergeDeployEnvVars, parseDeployEnvVar } from '../../utils/env/deploy-env-vars.js';
6
6
  import { chalk, logAndThrowError, warn } from '../../utils/command-helpers.js';
7
+ const BUILD_RELATED_OPTIONS = [
8
+ 'build',
9
+ 'context',
10
+ 'dir',
11
+ 'env',
12
+ 'functions',
13
+ 'secretEnv',
14
+ 'skipFunctionsCache',
15
+ 'uploadSourceZip',
16
+ ];
7
17
  export const createDeployCommand = (program) => program
8
18
  .command('deploy')
9
19
  .description(`Deploy your project to Netlify
10
20
 
11
- Builds and deploys your project to Netlify. Creates a draft deploy by default.
21
+ Builds your project locally, or on Netlify with --remote, and deploys it to Netlify. Creates a draft deploy by default.
12
22
  Use --prod to deploy directly to your live site.
13
23
 
14
24
  The deploy command will:
15
- - Build your project (unless --no-build is specified)
25
+ - Build your project locally (unless --no-build or --remote is specified)
16
26
  - Upload static files, functions, and edge functions
17
27
  - Process redirects and headers from netlify.toml or _redirects/_headers files
18
28
  - Provide deploy and function logs URLs
@@ -38,6 +48,7 @@ For detailed configuration options, see the Netlify documentation.`)
38
48
  .option('--json', 'Output deployment data as JSON')
39
49
  .option('--timeout <number>', 'Timeout to wait for deployment to finish', (value) => Number.parseInt(value))
40
50
  .addOption(new Option('--trigger', 'Trigger a new build of your project on Netlify without uploading local files').conflicts('build'))
51
+ .addOption(new Option('--remote', 'Build your project on Netlify instead of locally. Useful when building with native packages, on devices with limited resources or missing toolchains. Build logs stream in real time and are available on Netlify.').conflicts([...BUILD_RELATED_OPTIONS, 'alias', 'allowAnonymous', 'branch', 'createSite', 'siteName', 'trigger']))
41
52
  .addOption(new Option('--build', 'Do not use - this is now the default. Will be removed in future versions.')
42
53
  .default(true)
43
54
  .hideHelp(true))
@@ -66,6 +77,8 @@ For detailed configuration options, see the Netlify documentation.`)
66
77
  'netlify deploy --message "A message with an $ENV_VAR"',
67
78
  'netlify deploy --auth $NETLIFY_AUTH_TOKEN',
68
79
  'netlify deploy --trigger',
80
+ 'netlify deploy --remote # Build on Netlify and deploy a draft',
81
+ 'netlify deploy --remote --prod',
69
82
  'netlify deploy --context deploy-preview',
70
83
  'netlify deploy --env "NODE_ENV=production" --env "API_URL=https://api.example.com"',
71
84
  'netlify deploy --env "NODE_ENV=production" --secret-env "DATABASE_PASSWORD=$DB_PASSWORD"',
@@ -17,6 +17,7 @@ export type DeployOptionValues = BaseOptionValues & {
17
17
  open: boolean;
18
18
  prod: boolean;
19
19
  prodIfUnlocked: boolean;
20
+ remote?: boolean;
20
21
  secretEnv?: DeployEnvironmentVariable[];
21
22
  site?: string;
22
23
  siteName?: string;
@@ -0,0 +1,8 @@
1
+ import type { NetlifyAPI } from '@netlify/api';
2
+ export declare const followDeploy: (api: NetlifyAPI, { siteId, deployId, timeoutMs, onLogLine, }: {
3
+ siteId: string;
4
+ deployId: string;
5
+ timeoutMs: number;
6
+ onLogLine?: (line: string) => void;
7
+ }) => Promise<import("../../../utils/deploy/util.js").ReadyDeploy>;
8
+ //# sourceMappingURL=follow.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { streamDeploy } from '../../logs/sources/deploy.js';
2
+ import { waitForDeploy } from '../../../utils/deploy/util.js';
3
+ // The log stream is best-effort; the deploy's state decides success.
4
+ export const followDeploy = async (api, { siteId, deployId, timeoutMs, onLogLine, }) => {
5
+ const stopStreaming = onLogLine
6
+ ? streamDeploy(siteId, deployId, api.accessToken, (entry) => {
7
+ onLogLine(entry.message);
8
+ }, () => undefined)
9
+ : undefined;
10
+ try {
11
+ return await waitForDeploy(api, deployId, siteId, timeoutMs);
12
+ }
13
+ finally {
14
+ stopStreaming?.();
15
+ }
16
+ };
17
+ //# sourceMappingURL=follow.js.map
@@ -0,0 +1,27 @@
1
+ export interface GitCommand {
2
+ args: string[];
3
+ cwd: string;
4
+ exitCode: number;
5
+ stderr: string;
6
+ }
7
+ export type GitCommandObserver = (command: GitCommand) => void;
8
+ export interface GitContext {
9
+ cwd: string;
10
+ onCommand?: GitCommandObserver;
11
+ }
12
+ interface Remote {
13
+ name: string;
14
+ url: string;
15
+ }
16
+ export declare const getRemotes: (ctx: GitContext) => Promise<Remote[]>;
17
+ export declare const getHead: (ctx: GitContext) => Promise<string>;
18
+ export declare const getGitVersion: (ctx: GitContext) => Promise<string>;
19
+ export declare const getCurrentBranch: (ctx: GitContext) => Promise<string>;
20
+ export declare const getUpstream: (ctx: GitContext) => Promise<string>;
21
+ export declare const getWorkTreeStatus: (ctx: GitContext) => Promise<{
22
+ hasChanges: boolean;
23
+ untracked: string[];
24
+ }>;
25
+ export declare const getRemoteBranchesContaining: (ctx: GitContext, sha: string) => Promise<string[]>;
26
+ export {};
27
+ //# sourceMappingURL=git.d.ts.map
@@ -0,0 +1,61 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import semver from 'semver';
4
+ const execFileAsync = promisify(execFile);
5
+ // `code` is the exit code once git ran, or a string like ENOENT when it couldn't start.
6
+ const isFailedCommand = (error) => error instanceof Error && 'code' in error && typeof error.code === 'number';
7
+ const run = async (ctx, args) => {
8
+ let result;
9
+ try {
10
+ // GIT_OPTIONAL_LOCKS keeps read-only commands like `git status` from refreshing the user's index.
11
+ const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
12
+ const { stdout, stderr } = await execFileAsync('git', args, { cwd: ctx.cwd, env });
13
+ result = { exitCode: 0, stdout, stderr };
14
+ }
15
+ catch (error) {
16
+ if (!isFailedCommand(error)) {
17
+ throw new Error(`Couldn't run git: ${String(error)}`, { cause: error });
18
+ }
19
+ result = { exitCode: error.code, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
20
+ }
21
+ ctx.onCommand?.({ args, cwd: ctx.cwd, exitCode: result.exitCode, stderr: result.stderr.trimEnd() });
22
+ return result;
23
+ };
24
+ const git = async (ctx, args) => {
25
+ const { exitCode, stdout, stderr } = await run(ctx, args);
26
+ if (exitCode !== 0) {
27
+ throw new Error(`git ${args[0]} failed: ${stderr.trimEnd() || stdout}`);
28
+ }
29
+ return stdout.trim();
30
+ };
31
+ const tryGit = async (ctx, args) => {
32
+ const { exitCode, stdout } = await run(ctx, args);
33
+ return exitCode === 0 ? stdout.trim() : '';
34
+ };
35
+ export const getRemotes = async (ctx) => {
36
+ const names = (await tryGit(ctx, ['remote'])).split('\n').filter(Boolean);
37
+ return Promise.all(names.map(async (name) => ({ name, url: await git(ctx, ['remote', 'get-url', name]) })));
38
+ };
39
+ export const getHead = (ctx) => tryGit(ctx, ['rev-parse', '--verify', 'HEAD']);
40
+ export const getGitVersion = async (ctx) => {
41
+ const output = await tryGit(ctx, ['version']).catch(() => '');
42
+ return semver.coerce(output)?.version ?? '';
43
+ };
44
+ // Full ref names, since short ones become ambiguous when a local branch is named like a remote one
45
+ // (e.g. `origin/main`).
46
+ const stripRefPrefix = (ref, prefix) => (ref.startsWith(prefix) ? ref.slice(prefix.length) : '');
47
+ export const getCurrentBranch = async (ctx) => stripRefPrefix(await tryGit(ctx, ['symbolic-ref', '--quiet', 'HEAD']), 'refs/heads/');
48
+ export const getUpstream = async (ctx) => stripRefPrefix(await tryGit(ctx, ['rev-parse', '--symbolic-full-name', '@{upstream}']), 'refs/remotes/');
49
+ export const getWorkTreeStatus = async (ctx) => {
50
+ const lines = (await git(ctx, ['status', '--porcelain', '--untracked-files=normal'])).split('\n').filter(Boolean);
51
+ return {
52
+ hasChanges: lines.some((line) => !line.startsWith('?? ')),
53
+ untracked: lines.filter((line) => line.startsWith('?? ')).map((line) => line.slice(3)),
54
+ };
55
+ };
56
+ const listRemoteBranches = async (ctx, filter) => {
57
+ const output = await tryGit(ctx, ['branch', '--remotes', ...filter, '--format=%(refname:lstrip=2)']);
58
+ return output.split('\n').filter((name) => name !== '' && !name.endsWith('/HEAD'));
59
+ };
60
+ export const getRemoteBranchesContaining = (ctx, sha) => listRemoteBranches(ctx, ['--contains', sha]);
61
+ //# sourceMappingURL=git.js.map
@@ -0,0 +1,25 @@
1
+ import type { SiteInfo } from '../../../utils/types.js';
2
+ import { type GitCommandObserver } from './git.js';
3
+ export type RemoteSource = {
4
+ kind: 'external';
5
+ repoUrl: string;
6
+ head: string;
7
+ branch: string;
8
+ untracked: string[];
9
+ };
10
+ export interface SourceCommit {
11
+ sha: string;
12
+ branch?: string;
13
+ }
14
+ /** A problem the user can fix, as opposed to an unexpected failure. */
15
+ export declare class RemoteBuildError extends Error {
16
+ readonly name = "RemoteBuildError";
17
+ }
18
+ export declare const resolveRemoteSource: ({ siteInfo, workingDir, onCommand, production, }: {
19
+ siteInfo: SiteInfo;
20
+ workingDir: string;
21
+ onCommand?: GitCommandObserver;
22
+ production?: boolean;
23
+ }) => Promise<RemoteSource>;
24
+ export declare const prepareSourceCommit: (source: RemoteSource) => Promise<SourceCommit>;
25
+ //# sourceMappingURL=source.d.ts.map
@@ -0,0 +1,88 @@
1
+ import semver from 'semver';
2
+ import { isNetlifyGitUrl, isSameRepository } from '../../../utils/netlify-git.js';
3
+ import { getCurrentBranch, getGitVersion, getHead, getRemotes, getUpstream, getRemoteBranchesContaining, getWorkTreeStatus, } from './git.js';
4
+ /** A problem the user can fix, as opposed to an unexpected failure. */
5
+ export class RemoteBuildError extends Error {
6
+ name = 'RemoteBuildError';
7
+ }
8
+ const describeGitProblem = (version) => {
9
+ if (!version) {
10
+ return "--remote uses git, which isn't installed or isn't on your PATH. Install git, then deploy again.";
11
+ }
12
+ // 2.13 added `git branch --format`.
13
+ if (semver.lt(version, '2.13.0')) {
14
+ return `--remote needs git 2.13 or later, but you have ${version}. Update git, then deploy again.`;
15
+ }
16
+ return undefined;
17
+ };
18
+ const inspectClone = async (ctx, repoUrl, productionBranch) => {
19
+ const head = await getHead(ctx);
20
+ if (!head) {
21
+ return {
22
+ pushed: false,
23
+ reason: `This directory isn't a clone of ${repoUrl}, which Netlify builds this project from. Run this from a clone of it.`,
24
+ };
25
+ }
26
+ // Another remote having the commit doesn't mean the provider can build it.
27
+ const providerRemotes = (await getRemotes(ctx))
28
+ .filter(({ url }) => isSameRepository(url, repoUrl))
29
+ .map(({ name }) => name);
30
+ if (providerRemotes.length === 0) {
31
+ return {
32
+ pushed: false,
33
+ reason: `This repository has no remote for ${repoUrl}, which Netlify builds this project from. Run this from a clone of it, or add a remote for it.`,
34
+ };
35
+ }
36
+ const { hasChanges, untracked } = await getWorkTreeStatus(ctx);
37
+ if (hasChanges) {
38
+ return {
39
+ pushed: false,
40
+ reason: `You have uncommitted changes. Netlify builds this project from ${repoUrl}, so commit them and push them to ${productionBranch} or another branch, then deploy again.`,
41
+ };
42
+ }
43
+ const remoteOf = (ref) => providerRemotes.find((remote) => ref.startsWith(`${remote}/`));
44
+ const pushedTo = (await getRemoteBranchesContaining(ctx, head)).filter((ref) => remoteOf(ref) !== undefined);
45
+ if (pushedTo.length === 0) {
46
+ return {
47
+ pushed: false,
48
+ reason: `Your checked-out commit isn't on ${repoUrl} as of your last fetch. Push it to ${productionBranch} or another branch (or fetch, if you already pushed it), then deploy again.`,
49
+ };
50
+ }
51
+ // Only a branch the user evidently pushed this commit to, never a guess among other branches that
52
+ // happen to contain it.
53
+ const currentBranch = await getCurrentBranch(ctx);
54
+ const candidates = [
55
+ await getUpstream(ctx),
56
+ ...(currentBranch ? providerRemotes.map((remote) => `${remote}/${currentBranch}`) : []),
57
+ ];
58
+ const ref = candidates.find((candidate) => pushedTo.includes(candidate));
59
+ return { pushed: true, head, branch: ref?.slice((remoteOf(ref) ?? '').length + 1), untracked };
60
+ };
61
+ export const resolveRemoteSource = async ({ siteInfo, workingDir, onCommand, production = false, }) => {
62
+ const settings = siteInfo.build_settings;
63
+ if (!settings?.repo_url) {
64
+ throw new RemoteBuildError("This project isn't connected to a git repository, so Netlify can't build it. Connect one in the project's build settings, or run netlify deploy without --remote to build locally instead.");
65
+ }
66
+ const productionBranch = settings.repo_branch;
67
+ const ctx = { cwd: workingDir, onCommand };
68
+ if (settings.provider !== 'netlify-git' && !isNetlifyGitUrl(settings.repo_url)) {
69
+ const gitProblem = describeGitProblem(await getGitVersion(ctx));
70
+ if (gitProblem) {
71
+ throw new RemoteBuildError(gitProblem);
72
+ }
73
+ const repoUrl = settings.repo_url;
74
+ const clone = await inspectClone(ctx, repoUrl, productionBranch);
75
+ if (clone.pushed) {
76
+ // Any pushed commit can go to production: the build fetches it by SHA, not from the branch.
77
+ const branch = production ? productionBranch : clone.branch;
78
+ if (!branch) {
79
+ throw new RemoteBuildError(`Couldn't tell which branch to build ${clone.head.slice(0, 7)} as: it isn't on your checked-out branch's upstream or a branch of the same name on ${repoUrl}. Check out the branch you pushed it to, then deploy again.`);
80
+ }
81
+ return { kind: 'external', repoUrl, head: clone.head, branch, untracked: clone.untracked };
82
+ }
83
+ throw new RemoteBuildError(clone.reason);
84
+ }
85
+ throw new RemoteBuildError("Remote builds aren't supported yet for projects on Netlify Git. Run netlify deploy without --remote to build locally instead.");
86
+ };
87
+ export const prepareSourceCommit = (source) => Promise.resolve({ sha: source.head, branch: source.branch });
88
+ //# sourceMappingURL=source.js.map
@@ -0,0 +1,19 @@
1
+ import type { NetlifyAPI } from '@netlify/api';
2
+ import { type DeployUrls } from '../../utils/deploy/deploy-urls.js';
3
+ import type { SiteInfo } from '../../utils/types.js';
4
+ import type { DeployOptionValues } from './option_values.js';
5
+ export interface RemoteDeployResults extends DeployUrls {
6
+ siteId: string;
7
+ siteName: string;
8
+ deployId: string;
9
+ commitSha: string;
10
+ }
11
+ export declare const remoteDeploy: ({ api, workingDir, options, siteData, deployToProduction, prepareProductionDeploy, }: {
12
+ api: NetlifyAPI;
13
+ workingDir: string;
14
+ options: DeployOptionValues;
15
+ siteData: SiteInfo;
16
+ deployToProduction: boolean;
17
+ prepareProductionDeploy: () => Promise<void>;
18
+ }) => Promise<RemoteDeployResults>;
19
+ //# sourceMappingURL=remote-deploy.d.ts.map
@@ -0,0 +1,126 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { TimeoutError } from 'p-wait-for';
3
+ import terminalLink from 'terminal-link';
4
+ import { startSpinner } from '../../lib/spinner.js';
5
+ import { chalk, exit, log, logAndThrowError, NETLIFYDEVLOG, warn } from '../../utils/command-helpers.js';
6
+ import { DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js';
7
+ import { getDeploySource } from '../../utils/deploy/deploy-source.js';
8
+ import { getDeployUrls } from '../../utils/deploy/deploy-urls.js';
9
+ import { prepareSourceCommit, RemoteBuildError, resolveRemoteSource, } from './remote/source.js';
10
+ import { followDeploy } from './remote/follow.js';
11
+ import { DeployStateError } from '../../utils/deploy/util.js';
12
+ import { isInteractive } from '../../utils/scripted-commands.js';
13
+ const describeSource = (source) => `Checking your commit is on ${source.repoUrl}`;
14
+ const describeMode = (source) => `external (${source.repoUrl}), nothing is pushed`;
15
+ const describeUntracked = (files, sha, repoUrl) => {
16
+ const shown = files.slice(0, 5).join(', ');
17
+ const more = files.length > 5 ? ` and ${String(files.length - 5)} more` : '';
18
+ return `${String(files.length)} untracked ${files.length === 1 ? 'file' : 'files'} won't be in this deploy, since Netlify builds ${sha} from ${repoUrl}: ${shown}${more}`;
19
+ };
20
+ const logGitCommand = (debugLog) => ({ args, cwd, exitCode, stderr }) => {
21
+ debugLog(`git ${args.join(' ')} (in ${cwd}) exited ${String(exitCode)}`);
22
+ if (exitCode !== 0 && stderr) {
23
+ debugLog(stderr);
24
+ }
25
+ };
26
+ export const remoteDeploy = async ({ api, workingDir, options, siteData, deployToProduction, prepareProductionDeploy, }) => {
27
+ const quiet = Boolean(options.json || options.silent);
28
+ const siteId = siteData.id;
29
+ const timeoutMs = options.timeout ? options.timeout * 1000 : DEFAULT_DEPLOY_TIMEOUT;
30
+ const debugLog = options.debug
31
+ ? (message) => {
32
+ log(`${NETLIFYDEVLOG} ${chalk.dim(message)}`);
33
+ }
34
+ : undefined;
35
+ const onCommand = debugLog && logGitCommand(debugLog);
36
+ let source;
37
+ let commit;
38
+ // Debug output would interleave with the spinner's redraws.
39
+ const spinner = debugLog ? undefined : startSpinner({ text: 'Preparing your source for a remote build' });
40
+ try {
41
+ const { provider, repo_url: repoUrl, repo_branch: productionBranch } = siteData.build_settings ?? {};
42
+ debugLog?.(`Project repository: ${repoUrl ?? '(none)'} (${provider ?? 'no provider'}), production branch ${productionBranch ?? '(none)'}`);
43
+ source = await resolveRemoteSource({
44
+ siteInfo: siteData,
45
+ workingDir,
46
+ onCommand,
47
+ production: deployToProduction,
48
+ });
49
+ debugLog?.(`Mode: ${describeMode(source)}`);
50
+ spinner?.update({ text: describeSource(source) });
51
+ commit = await prepareSourceCommit(source);
52
+ }
53
+ catch (error) {
54
+ spinner?.error();
55
+ if (error instanceof RemoteBuildError) {
56
+ return logAndThrowError(error.message);
57
+ }
58
+ throw error;
59
+ }
60
+ const shortSha = commit.sha.slice(0, 7);
61
+ const foundText = `Found ${shortSha} on ${source.repoUrl}`;
62
+ if (spinner) {
63
+ spinner.success({ text: foundText });
64
+ }
65
+ else {
66
+ log(`${chalk.green('✔')} ${foundText}`);
67
+ }
68
+ if (source.untracked.length !== 0) {
69
+ warn(describeUntracked(source.untracked, shortSha, source.repoUrl));
70
+ if (isInteractive() && !quiet && !(await confirm({ message: 'Deploy anyway?', default: true }))) {
71
+ exit(0);
72
+ }
73
+ }
74
+ // After the checks above, so we never unlock production and then bail.
75
+ if (deployToProduction) {
76
+ await prepareProductionDeploy();
77
+ }
78
+ debugLog?.(`Requesting a ${deployToProduction ? 'production' : 'draft'} build of ${commit.sha} on branch ${commit.branch ?? '(none)'}`);
79
+ let build;
80
+ try {
81
+ build = (await api.createSiteBuild({
82
+ siteId,
83
+ branch: commit.branch,
84
+ title: options.message,
85
+ // @ts-expect-error FIXME(@netlify/api): `createSiteBuild` doesn't declare `commit_sha`, `draft` or `deploy_source`; the client drops undeclared query params, so they go in the body
86
+ body: { commit_sha: commit.sha, draft: !deployToProduction, deploy_source: getDeploySource() },
87
+ }));
88
+ }
89
+ catch (error) {
90
+ const { json, message } = error;
91
+ return logAndThrowError(`Couldn't start a build of ${commit.sha}: ${json?.message ?? json?.error ?? message}`);
92
+ }
93
+ debugLog?.(`Build ${build.id}, deploy ${build.deploy_id}`);
94
+ const { logsUrl } = getDeployUrls({ id: build.deploy_id, admin_url: siteData.admin_url }, { deployToProduction });
95
+ log(`${NETLIFYDEVLOG} Building ${shortSha} on Netlify (${terminalLink('↗ View deploy logs', logsUrl)})`);
96
+ log('');
97
+ try {
98
+ const deploy = await followDeploy(api, {
99
+ siteId,
100
+ deployId: build.deploy_id,
101
+ timeoutMs,
102
+ onLogLine: quiet
103
+ ? undefined
104
+ : (line) => {
105
+ log(`${chalk.dim('│')} ${line.trimEnd()}`);
106
+ },
107
+ });
108
+ return {
109
+ siteId,
110
+ siteName: siteData.name,
111
+ deployId: build.deploy_id,
112
+ commitSha: commit.sha,
113
+ ...getDeployUrls(deploy, { deployToProduction }),
114
+ };
115
+ }
116
+ catch (error) {
117
+ if (error instanceof DeployStateError) {
118
+ return logAndThrowError(`Deploy failed: ${error.message}\n\nDeploy logs: ${logsUrl}`);
119
+ }
120
+ if (error instanceof TimeoutError) {
121
+ return logAndThrowError(`Timed out after ${String(timeoutMs / 1000)} seconds waiting for the deploy to finish. It may still be running on Netlify; to wait longer, pass a larger --timeout.\n\nDeploy logs: ${logsUrl}`);
122
+ }
123
+ return logAndThrowError(`${error.message}\n\nThe build may still be running on Netlify. Deploy logs: ${logsUrl}`);
124
+ }
125
+ };
126
+ //# sourceMappingURL=remote-deploy.js.map
@@ -125,7 +125,7 @@ const prepareServer = async ({ aiGatewayContext, command, config, configPath, de
125
125
  debug,
126
126
  distImportMapPath: join(projectDir, distImportMapPath),
127
127
  featureFlags,
128
- formatExportTypeError: (name) => `${NETLIFYDEVERR} ${chalk.red('Failed')} to load Edge Function ${chalk.yellow(name)}. The file does not seem to have a function as the default export.`,
128
+ formatExportTypeError: (name) => `${NETLIFYDEVERR} ${chalk.red('Failed')} to load Edge Function ${chalk.yellow(name)}. The file does not seem to have a function, or an object with a \`fetch\` method, as the default export.`,
129
129
  formatImportError: (name) => `${NETLIFYDEVERR} ${chalk.red('Failed')} to run Edge Function ${chalk.yellow(name)}:`,
130
130
  inspectSettings,
131
131
  port,
@@ -1,5 +1,11 @@
1
1
  import { Buffer } from 'buffer';
2
2
  import type { Readable } from 'stream';
3
+ export declare class StreamBodyTooBigError extends Error {
4
+ constructor();
5
+ }
6
+ export declare class StreamTimeoutError extends Error {
7
+ constructor();
8
+ }
3
9
  declare const createStreamPromise: (stream: Readable, timeoutSeconds: number, bytesLimit?: number) => Promise<Buffer>;
4
10
  export default createStreamPromise;
5
11
  //# sourceMappingURL=create-stream-promise.d.ts.map
@@ -2,6 +2,18 @@ import { Buffer } from 'buffer';
2
2
  const SEC_TO_MILLISEC = 1e3;
3
3
  // 6 MiB
4
4
  const DEFAULT_BYTES_LIMIT = 6e6;
5
+ export class StreamBodyTooBigError extends Error {
6
+ constructor() {
7
+ super('Stream body too big');
8
+ this.name = 'StreamBodyTooBigError';
9
+ }
10
+ }
11
+ export class StreamTimeoutError extends Error {
12
+ constructor() {
13
+ super('Request timed out waiting for body');
14
+ this.name = 'StreamTimeoutError';
15
+ }
16
+ }
5
17
  const createStreamPromise = function (stream, timeoutSeconds, bytesLimit = DEFAULT_BYTES_LIMIT) {
6
18
  return new Promise(function streamPromiseFunc(resolve, reject) {
7
19
  let data = [];
@@ -10,7 +22,7 @@ const createStreamPromise = function (stream, timeoutSeconds, bytesLimit = DEFAU
10
22
  if (timeoutSeconds != null && Number.isFinite(timeoutSeconds)) {
11
23
  timeoutId = setTimeout(() => {
12
24
  data = null;
13
- reject(new Error('Request timed out waiting for body'));
25
+ reject(new StreamTimeoutError());
14
26
  }, timeoutSeconds * SEC_TO_MILLISEC);
15
27
  }
16
28
  stream.on('data', function onData(chunk) {
@@ -21,7 +33,7 @@ const createStreamPromise = function (stream, timeoutSeconds, bytesLimit = DEFAU
21
33
  dataLength += chunk.length;
22
34
  if (dataLength > bytesLimit) {
23
35
  data = null;
24
- reject(new Error('Stream body too big'));
36
+ reject(new StreamBodyTooBigError());
25
37
  }
26
38
  else {
27
39
  data.push(chunk);
@@ -3,5 +3,6 @@ export interface DeploySourceFields {
3
3
  agent_runner_id?: string;
4
4
  agent_runner_session_id?: string;
5
5
  }
6
+ export declare const getDeploySource: () => string;
6
7
  export declare const getDeploySourceFields: () => DeploySourceFields;
7
8
  //# sourceMappingURL=deploy-source.d.ts.map
@@ -1,8 +1,9 @@
1
+ export const getDeploySource = () => process.env.NETLIFY_DEPLOY_SOURCE || 'cli';
1
2
  export const getDeploySourceFields = () => {
2
3
  const agentRunnerId = process.env.NETLIFY_AGENT_RUNNER_ID;
3
4
  const agentRunnerSessionId = process.env.NETLIFY_AGENT_RUNNER_SESSION_ID;
4
5
  return {
5
- deploy_source: process.env.NETLIFY_DEPLOY_SOURCE || 'cli',
6
+ deploy_source: getDeploySource(),
6
7
  ...(agentRunnerId ? { agent_runner_id: agentRunnerId } : {}),
7
8
  ...(agentRunnerSessionId ? { agent_runner_session_id: agentRunnerSessionId } : {}),
8
9
  };
@@ -1,3 +1,4 @@
1
1
  export declare const NETLIFY_GIT_HOST = "git.netlify.com";
2
2
  export declare const isNetlifyGitUrl: (repoUrl: string) => boolean;
3
+ export declare const isSameRepository: (a: string, b: string) => boolean;
3
4
  //# sourceMappingURL=netlify-git.d.ts.map
@@ -9,4 +9,31 @@ export const isNetlifyGitUrl = (repoUrl) => {
9
9
  return false;
10
10
  }
11
11
  };
12
+ // Reduces `https://host/owner/repo.git`, `ssh://git@host/owner/repo` and `git@host:owner/repo.git` to
13
+ // `host/owner/repo`, ignoring credentials, case and a trailing `.git`. Local paths have no host.
14
+ const getRepositoryKey = (repoUrl) => {
15
+ const parse = () => {
16
+ if (repoUrl.startsWith('/')) {
17
+ return { host: '', path: repoUrl };
18
+ }
19
+ const scpLike = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(repoUrl);
20
+ if (scpLike) {
21
+ return { host: scpLike[1], path: scpLike[2] };
22
+ }
23
+ try {
24
+ const { hostname, pathname } = new URL(repoUrl);
25
+ return { host: hostname, path: pathname };
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ };
31
+ const parsed = parse();
32
+ const path = parsed?.path.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
33
+ return parsed && path ? `${parsed.host}/${path}`.toLowerCase() : undefined;
34
+ };
35
+ export const isSameRepository = (a, b) => {
36
+ const keyA = getRepositoryKey(a);
37
+ return keyA !== undefined && keyA === getRepositoryKey(b);
38
+ };
12
39
  //# sourceMappingURL=netlify-git.js.map
@@ -28,8 +28,8 @@ import { fileExistsAsync, isFileAsync } from '../lib/fs.js';
28
28
  import { getFormHandler } from '../lib/functions/form-submissions-handler.js';
29
29
  import { DEFAULT_FUNCTION_URL_EXPRESSION } from '../lib/functions/registry.js';
30
30
  import { initializeProxy as initializeImageProxy, isImageRequest } from '../lib/images/proxy.js';
31
- import { NETLIFYDEVLOG, NETLIFYDEVWARN, chalk, log, logError, warn, } from './command-helpers.js';
32
- import createStreamPromise from './create-stream-promise.js';
31
+ import { NETLIFYDEVERR, NETLIFYDEVLOG, NETLIFYDEVWARN, chalk, log, logError, warn, } from './command-helpers.js';
32
+ import createStreamPromise, { StreamBodyTooBigError, StreamTimeoutError } from './create-stream-promise.js';
33
33
  import { NFFunctionName, NFFunctionRoute, NFRequestID, headersForPath, parseHeaders } from './headers.js';
34
34
  import { generateRequestID } from './request-id.js';
35
35
  import { createRewriter, onChanges } from './rules-proxy.js';
@@ -350,6 +350,13 @@ const initializeProxy = async function ({ config, configPath, distDir, env, host
350
350
  return;
351
351
  }
352
352
  }
353
+ if (res instanceof http.ServerResponse && res.headersSent) {
354
+ if (!res.writableEnded) {
355
+ log(NETLIFYDEVWARN, `Could not finish proxying ${req.url ?? ''}: ${err.message}`);
356
+ res.destroy();
357
+ }
358
+ return;
359
+ }
353
360
  if (res instanceof http.ServerResponse) {
354
361
  res.writeHead(500, {
355
362
  'Content-Type': 'text/plain',
@@ -547,9 +554,27 @@ const initializeProxy = async function ({ config, configPath, distDir, env, host
547
554
  };
548
555
  return handlers;
549
556
  };
557
+ const rejectRequestBody = (req, res, error) => {
558
+ if (!(error instanceof StreamBodyTooBigError || error instanceof StreamTimeoutError)) {
559
+ res.destroy();
560
+ return;
561
+ }
562
+ const status = error instanceof StreamBodyTooBigError ? 413 : 408;
563
+ log(NETLIFYDEVWARN, `Responded to ${req.method ?? ''} ${req.url ?? ''} with ${status.toString()}: ${error.message}`);
564
+ res.writeHead(status);
565
+ res.end(http.STATUS_CODES[status]);
566
+ };
550
567
  const onRequest = async ({ api, edgeFunctionsProxy, env, functionsRegistry, functionsServer, imageProxy, proxy, rewriter, serverHandler, settings, siteInfo, }, req, res) => {
551
- req.originalBody =
552
- req.method && ['GET', 'OPTIONS', 'HEAD'].includes(req.method) ? null : await createStreamPromise(req, BYTES_LIMIT);
568
+ try {
569
+ req.originalBody =
570
+ req.method && ['GET', 'OPTIONS', 'HEAD'].includes(req.method)
571
+ ? null
572
+ : await createStreamPromise(req, REQUEST_BODY_TIMEOUT_SECONDS);
573
+ }
574
+ catch (error) {
575
+ rejectRequestBody(req, res, error);
576
+ return;
577
+ }
553
578
  if (isImageRequest(req)) {
554
579
  return imageProxy(req, res);
555
580
  }
@@ -740,7 +765,7 @@ export const startProxy = async function ({ accountId, aiGatewayContext, api, bl
740
765
  jwtRoleClaim: settings.jwtRolePath,
741
766
  projectDir,
742
767
  });
743
- const onRequestWithOptions = onRequest.bind(undefined, {
768
+ const requestOptions = {
744
769
  proxy,
745
770
  rewriter,
746
771
  serverHandler,
@@ -752,7 +777,19 @@ export const startProxy = async function ({ accountId, aiGatewayContext, api, bl
752
777
  siteInfo,
753
778
  env,
754
779
  api,
755
- });
780
+ };
781
+ const onRequestWithOptions = (req, res) => {
782
+ onRequest(requestOptions, req, res).catch((error) => {
783
+ console.error(NETLIFYDEVERR, 'Failed to handle request to', req.url, error);
784
+ if (res.headersSent) {
785
+ res.destroy();
786
+ }
787
+ else {
788
+ res.writeHead(500);
789
+ res.end('Could not proxy request.');
790
+ }
791
+ });
792
+ };
756
793
  const primaryServer = settings.https
757
794
  ? https.createServer({ cert: settings.https.cert, key: settings.https.key }, onRequestWithOptions)
758
795
  : http.createServer(onRequestWithOptions);
@@ -797,5 +834,5 @@ export const startProxy = async function ({ accountId, aiGatewayContext, api, bl
797
834
  await Promise.all(eventQueue);
798
835
  return getProxyUrl(settings);
799
836
  };
800
- const BYTES_LIMIT = 30;
837
+ const REQUEST_BODY_TIMEOUT_SECONDS = 30;
801
838
  //# sourceMappingURL=proxy.js.map
@@ -1,2 +1,9 @@
1
- export const SOCKETEER_URL = process.env.NETLIFY_SOCKETEER_URL ?? 'wss://socketeer.services.netlify.com';
1
+ const getSocketeerUrl = () => {
2
+ // An empty value means unset, as with NETLIFY_API_URL.
3
+ if (process.env.NETLIFY_SOCKETEER_URL) {
4
+ return process.env.NETLIFY_SOCKETEER_URL;
5
+ }
6
+ return 'wss://socketeer.services.netlify.com';
7
+ };
8
+ export const SOCKETEER_URL = getSocketeerUrl();
2
9
  //# sourceMappingURL=socketeer.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "netlify-cli",
3
- "version": "27.15.0",
3
+ "version": "27.16.0",
4
4
  "description": "Netlify command line tool",
5
5
  "keywords": [
6
6
  "api",
@@ -55,20 +55,20 @@
55
55
  "@fastify/static": "^10.0.0",
56
56
  "@inquirer/prompts": "^8.7.2",
57
57
  "@netlify/ai": "^1.0.2",
58
- "@netlify/api": "^15.1.3",
58
+ "@netlify/api": "^15.1.4",
59
59
  "@netlify/blobs": "^11.1.6",
60
- "@netlify/build": "^37.7.0",
61
- "@netlify/build-info": "^11.3.0",
60
+ "@netlify/build": "^37.7.1",
61
+ "@netlify/build-info": "^11.3.1",
62
62
  "@netlify/config": "^25.5.0",
63
63
  "@netlify/dev": "^5.1.10",
64
64
  "@netlify/dev-utils": "^6.0.4",
65
- "@netlify/edge-bundler": "^16.1.3",
65
+ "@netlify/edge-bundler": "^16.2.0",
66
66
  "@netlify/edge-functions": "^4.0.3",
67
67
  "@netlify/edge-functions-bootstrap": "^3.2.0",
68
68
  "@netlify/headers-parser": "^10.1.1",
69
69
  "@netlify/images": "^2.0.5",
70
70
  "@netlify/local-functions-proxy": "^2.0.3",
71
- "@netlify/redirect-matcher": "^0.4.2",
71
+ "@netlify/redirect-matcher": "^0.7.0",
72
72
  "@netlify/redirect-parser": "^16.1.1",
73
73
  "@netlify/server-dev": "^0.1.4",
74
74
  "@netlify/zip-it-and-ship-it": "^16.6.0",
@@ -149,7 +149,7 @@
149
149
  },
150
150
  "devDependencies": {
151
151
  "@babel/preset-react": "^7.27.1",
152
- "@bugsnag/js": "^8.6.0",
152
+ "@bugsnag/js": "^9.0.0",
153
153
  "@eslint/compat": "^1.4.1",
154
154
  "@eslint/js": "^9.36.0",
155
155
  "@netlify/functions": "^6.0.3",