netlify-cli 27.14.3 → 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
@@ -78,14 +78,15 @@ export const createLogsCommand = (program) => {
78
78
  'functions',
79
79
  'edge-functions',
80
80
  'deploy',
81
+ 'server',
81
82
  ]))
82
83
  .addOption(new Option('--function <name...>', 'Filter to specific functions by name'))
83
84
  .addOption(new Option('--edge-function <name...>', 'Filter to specific edge functions by name or path'))
84
85
  .addOption(new Option('--since <time>', 'Start of the historical log window. Accepts a duration (e.g. 10m, 1h, 24h) or an ISO 8601 timestamp. Defaults to 10m'))
85
86
  .addOption(new Option('--until <time>', 'End of the historical log window. Accepts a duration or an ISO 8601 timestamp (defaults to now)'))
86
87
  .addOption(new Option('-f, --follow', 'Stream logs in real time instead of showing historical logs'))
87
- .addOption(new Option('-u, --url <url>', 'Show logs for the deploy behind the given URL. Supports deploy permalinks and branch subdomains. Can be used with all sources.'))
88
- .addOption(new Option('-d, --deploy-id <deploy-id>', 'Show logs for a specific deploy by ID, including failed builds. Can be used with all sources.'))
88
+ .addOption(new Option('-u, --url <url>', 'Show logs for the deploy behind the given URL. Supports deploy permalinks and branch subdomains. Can be used with all sources except server.'))
89
+ .addOption(new Option('-d, --deploy-id <deploy-id>', 'Show logs for a specific deploy by ID, including failed builds. Can be used with all sources except server.'))
89
90
  .addOption(new Option('-l, --level <levels...>', `Log levels to include. Choices are:${CLI_LOG_LEVEL_CHOICES_STRING}`))
90
91
  .addOption(new Option('--json', 'Output logs as JSON Lines'))
91
92
  .addExamples([
@@ -96,6 +97,8 @@ export const createLogsCommand = (program) => {
96
97
  'netlify logs --source functions --function checkout --since 24h',
97
98
  'netlify logs --source edge-functions --since 30m',
98
99
  'netlify logs --source deploy --source functions --since 1h',
100
+ 'netlify logs --source server --since 30m',
101
+ 'netlify logs --follow --source server',
99
102
  'netlify logs --follow',
100
103
  'netlify logs --follow --source functions --source edge-functions',
101
104
  'netlify logs --json --since 1h',
@@ -4,7 +4,7 @@ export declare const DEPLOY_ID_RE: RegExp;
4
4
  export declare const SOURCE_INDICATORS: Record<LogEntry['source'], string>;
5
5
  export declare const SOURCE_LABELS: Record<LogEntry['source'], string>;
6
6
  export interface LogEntry {
7
- source: 'function' | 'edge-function' | 'deploy';
7
+ source: 'function' | 'edge-function' | 'deploy' | 'server';
8
8
  name: string;
9
9
  ts: number;
10
10
  level: string;
@@ -7,11 +7,13 @@ export const SOURCE_INDICATORS = {
7
7
  function: '𝒇',
8
8
  'edge-function': '🌐',
9
9
  deploy: '🚀',
10
+ server: '🖥️',
10
11
  };
11
12
  export const SOURCE_LABELS = {
12
13
  function: 'Function',
13
14
  'edge-function': 'Edge function',
14
15
  deploy: 'Deploy',
16
+ server: 'Server',
15
17
  };
16
18
  const DURATION_LIKE_RE = /^\d+(\.\d+)?\s*[a-z]/i;
17
19
  export const parseTimeValue = (input, now = Date.now()) => {
@@ -1,7 +1,7 @@
1
1
  import type { NetlifyAPI } from '@netlify/api';
2
2
  import type BaseCommand from '../base-command.js';
3
3
  import type { LogsOptionValues } from './option_values.js';
4
- type Source = 'functions' | 'edge-functions' | 'deploy';
4
+ type Source = 'functions' | 'edge-functions' | 'deploy' | 'server';
5
5
  export declare const logsCommand: (options: LogsOptionValues, command: BaseCommand) => Promise<undefined>;
6
6
  export declare const runFollowMode: ({ sources, client, siteId, accessToken, deployId, deployTargeted, functionNames, edgeFunctionNames, levelsToPrint, json, }: {
7
7
  sources: Source[];
@@ -1,10 +1,11 @@
1
- import { chalk, log, logAndThrowError, netlifyCommand } from '../../utils/command-helpers.js';
1
+ import { chalk, log, logAndThrowError, logError, netlifyCommand, warn } from '../../utils/command-helpers.js';
2
2
  import { createColorAssigner, DEPLOY_ID_RE, formatJsonLine, formatLogLine, parseTimeValue, resolveDeployIdFromUrl, SOURCE_INDICATORS, SOURCE_LABELS, } from './log-api.js';
3
3
  import { LOG_LEVELS_LIST, CLI_LOG_LEVEL_CHOICES_STRING } from './log-levels.js';
4
4
  import { fetchDeployHistoricalLogs, findCurrentBuildingDeploy, findLatestFinishedDeploy, findLatestReadyDeploy, isDeployFinished, streamDeploy, } from './sources/deploy.js';
5
5
  import { fetchEdgeFunctionHistoricalLogs, streamEdgeFunctions } from './sources/edge-functions.js';
6
6
  import { fetchFunctionHistoricalLogs, listFunctions, selectFunctions, streamFunctions, validateFunctionCount, } from './sources/functions.js';
7
- const VALID_SOURCES = ['functions', 'edge-functions', 'deploy'];
7
+ import { fetchServerHistoricalLogs, streamServer } from './sources/server.js';
8
+ const VALID_SOURCES = ['functions', 'edge-functions', 'deploy', 'server'];
8
9
  const DEFAULT_SINCE = '10m';
9
10
  const DEPLOY_STREAM_IDLE_CLOSE_MS = 3_000;
10
11
  const parseSources = (rawSources) => {
@@ -23,6 +24,7 @@ const SOURCE_TO_ENTRY_SOURCE = {
23
24
  functions: 'function',
24
25
  'edge-functions': 'edge-function',
25
26
  deploy: 'deploy',
27
+ server: 'server',
26
28
  };
27
29
  const humanizeTimeRange = (sinceValue, untilValue) => {
28
30
  if (!sinceValue && !untilValue) {
@@ -45,6 +47,8 @@ const printHeader = (sources, timeDescription, isFollow) => {
45
47
  return 'edge functions';
46
48
  case 'deploy':
47
49
  return 'deploy';
50
+ case 'server':
51
+ return 'server';
48
52
  }
49
53
  });
50
54
  const sourceList = sourceNames.length <= 2
@@ -122,6 +126,9 @@ export const logsCommand = async (options, command) => {
122
126
  sources = ['functions', 'edge-functions'];
123
127
  }
124
128
  }
129
+ if (sources.includes('server') && (options.deployId || options.url)) {
130
+ return logAndThrowError('Server logs cannot be filtered by deploy. Remove --deploy-id and --url, or the server source.');
131
+ }
125
132
  let deployId;
126
133
  let deployTargeted = false;
127
134
  if (options.deployId) {
@@ -265,6 +272,19 @@ const runHistoricalMode = async ({ sources, client, siteId, accessToken, deployI
265
272
  });
266
273
  allEntries.push(...entries);
267
274
  }
275
+ let serverLogsTruncated = false;
276
+ if (sources.includes('server')) {
277
+ const { entries, truncated } = await fetchServerHistoricalLogs({
278
+ apiBase: client.basePath,
279
+ siteId,
280
+ accessToken,
281
+ from,
282
+ to,
283
+ levels: levelsToPrint,
284
+ });
285
+ allEntries.push(...entries);
286
+ serverLogsTruncated = truncated;
287
+ }
268
288
  allEntries.sort((a, b) => a.ts - b.ts);
269
289
  if (allEntries.length === 0) {
270
290
  log('No logs found for the given time range.');
@@ -278,6 +298,15 @@ const runHistoricalMode = async ({ sources, client, siteId, accessToken, deployI
278
298
  const key = `${entry.source}:${entry.name}`;
279
299
  printEntry(entry, levelsToPrint, json, assignColor(key));
280
300
  }
301
+ if (serverLogsTruncated) {
302
+ const message = 'Only the most recent server logs are shown. Narrow --since/--until to see older ones.';
303
+ if (json) {
304
+ process.stderr.write(`Warning: ${message}\n`);
305
+ }
306
+ else {
307
+ warn(message);
308
+ }
309
+ }
281
310
  };
282
311
  export const runFollowMode = async ({ sources, client, siteId, accessToken, deployId, deployTargeted, functionNames, edgeFunctionNames, levelsToPrint, json, }) => {
283
312
  const assignColor = createColorAssigner();
@@ -316,5 +345,15 @@ export const runFollowMode = async ({ sources, client, siteId, accessToken, depl
316
345
  if (sources.includes('edge-functions') && deployId) {
317
346
  streamEdgeFunctions(siteId, deployId, accessToken, edgeFunctionNames, onEntry);
318
347
  }
348
+ if (sources.includes('server')) {
349
+ streamServer({
350
+ apiBase: client.basePath,
351
+ siteId,
352
+ accessToken,
353
+ levels: levelsToPrint,
354
+ onEntry,
355
+ onError: logError,
356
+ });
357
+ }
319
358
  };
320
359
  //# sourceMappingURL=logs.js.map
@@ -0,0 +1,22 @@
1
+ import type { LogEntry } from '../log-api.js';
2
+ export declare const fetchServerHistoricalLogs: (options: {
3
+ apiBase: string;
4
+ siteId: string;
5
+ accessToken: string | null | undefined;
6
+ from: number;
7
+ to: number;
8
+ levels?: string[];
9
+ }) => Promise<{
10
+ entries: LogEntry[];
11
+ truncated: boolean;
12
+ }>;
13
+ export declare const streamServer: ({ apiBase, siteId, accessToken, levels, onEntry, onError, intervalMs, }: {
14
+ apiBase: string;
15
+ siteId: string;
16
+ accessToken: string | null | undefined;
17
+ levels?: string[];
18
+ onEntry: (entry: LogEntry) => void;
19
+ onError?: (error: Error) => void;
20
+ intervalMs?: number;
21
+ }) => (() => void);
22
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1,141 @@
1
+ import { debugFetch } from '../log-api.js';
2
+ // The API serves up to 500 lines a page and stops paging 10,000 lines back.
3
+ const PER_PAGE = 500;
4
+ const MAX_PAGES = 1 + 10_000 / PER_PAGE;
5
+ // Lines are stored a few seconds after their time, so each poll looks back
6
+ // this far and drops what the previous poll already returned.
7
+ const LOOK_BACK_MS = 10_000;
8
+ const POLL_INTERVAL_MS = 2_000;
9
+ // Retrying cannot change these answers, so following stops on them.
10
+ const PERMANENT_FAILURE_STATUSES = new Set([401, 403, 404]);
11
+ // The API filters by at most one severity, and only these, though lines can
12
+ // carry other levels too.
13
+ const FILTERABLE_SEVERITIES = new Set(['error', 'warn', 'info']);
14
+ const severityFor = (levels) => levels?.length === 1 && FILTERABLE_SEVERITIES.has(levels[0]) ? levels[0] : undefined;
15
+ class ServerLogsRequestError extends Error {
16
+ status;
17
+ constructor(status, statusText) {
18
+ super(`Failed to fetch server logs: ${status.toString()} ${statusText}`);
19
+ this.status = status;
20
+ }
21
+ }
22
+ const lineKey = (line) => `${line.timestamp.toString()}|${line.actor_id}|${line.severity}|${line.body}`;
23
+ const countKeys = (lines) => {
24
+ const counts = new Map();
25
+ for (const line of lines) {
26
+ const key = lineKey(line);
27
+ counts.set(key, (counts.get(key) ?? 0) + 1);
28
+ }
29
+ return counts;
30
+ };
31
+ const toEntry = (line) => ({
32
+ source: 'server',
33
+ // The dashboard shows instances by the first 8 characters of their ID too.
34
+ name: line.actor_id ? line.actor_id.slice(0, 8) : 'server',
35
+ ts: line.timestamp,
36
+ level: line.severity ? line.severity.toUpperCase() : 'INFO',
37
+ message: line.body,
38
+ });
39
+ const fetchServerLogLines = async ({ apiBase, siteId, accessToken, from, to, levels, }) => {
40
+ // The API pages newest first and sends millisecond timestamps, though it
41
+ // orders by finer ones, so reversing is what keeps same-millisecond lines in order.
42
+ const lines = [];
43
+ const severity = severityFor(levels);
44
+ for (let page = 1; page <= MAX_PAGES; page++) {
45
+ const url = new URL(`${apiBase}/sites/${encodeURIComponent(siteId)}/observability/server/logs`);
46
+ url.searchParams.set('from_ts', String(from));
47
+ url.searchParams.set('to_ts', String(to));
48
+ url.searchParams.set('page', String(page));
49
+ url.searchParams.set('per_page', String(PER_PAGE));
50
+ if (severity) {
51
+ url.searchParams.set('severity', severity);
52
+ }
53
+ const response = await debugFetch(url.toString(), {
54
+ headers: {
55
+ Authorization: `Bearer ${accessToken ?? ''}`,
56
+ },
57
+ });
58
+ if (!response.ok) {
59
+ throw new ServerLogsRequestError(response.status, response.statusText);
60
+ }
61
+ const body = (await response.json());
62
+ lines.push(...(body.logs ?? []));
63
+ if (!body.has_next_page) {
64
+ return { lines: lines.reverse(), truncated: false };
65
+ }
66
+ }
67
+ return { lines: lines.reverse(), truncated: true };
68
+ };
69
+ export const fetchServerHistoricalLogs = async (options) => {
70
+ const { lines, truncated } = await fetchServerLogLines(options);
71
+ return { entries: lines.map(toEntry), truncated };
72
+ };
73
+ export const streamServer = ({ apiBase, siteId, accessToken, levels, onEntry, onError, intervalMs = POLL_INTERVAL_MS, }) => {
74
+ let newest = Date.now();
75
+ let seen = new Map();
76
+ let failing = false;
77
+ let truncating = false;
78
+ let stopped = false;
79
+ let timer;
80
+ const poll = async () => {
81
+ try {
82
+ const { lines, truncated } = await fetchServerLogLines({
83
+ apiBase,
84
+ siteId,
85
+ accessToken,
86
+ levels,
87
+ from: newest - LOOK_BACK_MS,
88
+ to: Date.now(),
89
+ });
90
+ if (stopped) {
91
+ return;
92
+ }
93
+ // Lines carry no ID, so identical ones are told apart by how many of them
94
+ // the previous poll returned.
95
+ const unclaimed = new Map(seen);
96
+ const fresh = lines
97
+ .filter((line) => {
98
+ const key = lineKey(line);
99
+ const count = unclaimed.get(key) ?? 0;
100
+ unclaimed.set(key, count - 1);
101
+ return count <= 0;
102
+ })
103
+ .sort((a, b) => a.timestamp - b.timestamp);
104
+ for (const line of fresh) {
105
+ onEntry(toEntry(line));
106
+ }
107
+ // The API pages newest first, so a truncated window loses its oldest lines for good.
108
+ if (truncated && !truncating) {
109
+ onError?.(new Error('Server logs are arriving faster than they can be shown, so some lines were skipped.'));
110
+ }
111
+ truncating = truncated;
112
+ // The next window starts inside this one, so only these lines can repeat.
113
+ seen = countKeys(lines);
114
+ newest = fresh.reduce((max, line) => Math.max(max, line.timestamp), newest);
115
+ failing = false;
116
+ }
117
+ catch (error) {
118
+ const failure = error instanceof Error ? error : new Error(String(error));
119
+ if (error instanceof ServerLogsRequestError && PERMANENT_FAILURE_STATUSES.has(error.status)) {
120
+ stopped = true;
121
+ onError?.(failure);
122
+ return;
123
+ }
124
+ // The next poll's window still covers what this one missed, so a run of
125
+ // failures is reported once.
126
+ if (!failing) {
127
+ failing = true;
128
+ onError?.(failure);
129
+ }
130
+ }
131
+ if (!stopped) {
132
+ timer = setTimeout(() => void poll(), intervalMs);
133
+ }
134
+ };
135
+ void poll();
136
+ return () => {
137
+ stopped = true;
138
+ clearTimeout(timer);
139
+ };
140
+ };
141
+ //# sourceMappingURL=server.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
  };
@@ -9,6 +9,7 @@ interface ServerPayload {
9
9
  routes?: ServerResult['routes'];
10
10
  excluded_routes?: ServerResult['excludedRoutes'];
11
11
  runtime_version?: ServerResult['runtimeVersion'];
12
+ scale?: ServerResult['scale'];
12
13
  }
13
14
  type HashedFunctionFile = FunctionUploadFile & {
14
15
  hash: string;
@@ -205,7 +205,7 @@ const hashServer = async (serverBundle, { concurrentHash, hashAlgorithm, statusC
205
205
  hasherCtor({ concurrentHash, hashAlgorithm }),
206
206
  manifestCollectorCtor(servers, serverShaMap, { statusCb }),
207
207
  ]);
208
- const { excludedRoutes, instanceSize, region, routes, runtimeVersion } = serverBundle;
208
+ const { excludedRoutes, instanceSize, region, routes, runtimeVersion, scale } = serverBundle;
209
209
  const server = {
210
210
  sha: Object.values(servers)[0],
211
211
  ...(region === undefined ? {} : { region }),
@@ -213,6 +213,7 @@ const hashServer = async (serverBundle, { concurrentHash, hashAlgorithm, statusC
213
213
  ...(routes === undefined ? {} : { routes }),
214
214
  ...(excludedRoutes === undefined ? {} : { excluded_routes: excludedRoutes }),
215
215
  ...(runtimeVersion === undefined ? {} : { runtime_version: runtimeVersion }),
216
+ ...(scale === undefined ? {} : { scale }),
216
217
  };
217
218
  return { server, serverShaMap };
218
219
  };
@@ -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.14.3",
3
+ "version": "27.16.0",
4
4
  "description": "Netlify command line tool",
5
5
  "keywords": [
6
6
  "api",
@@ -55,23 +55,23 @@
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.5.1",
61
- "@netlify/build-info": "^11.3.0",
62
- "@netlify/config": "^25.3.0",
60
+ "@netlify/build": "^37.7.1",
61
+ "@netlify/build-info": "^11.3.1",
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
- "@netlify/zip-it-and-ship-it": "^16.4.1",
74
+ "@netlify/zip-it-and-ship-it": "^16.6.0",
75
75
  "@opentelemetry/api": "~1.9.0",
76
76
  "@pnpm/tabtab": "^0.5.4",
77
77
  "ansi-escapes": "^7.3.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",