From fa3159786d2c7a8beaa278bbf1edcde0376783d9 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Mon, 6 Jul 2026 10:45:26 -0700 Subject: [PATCH] feat(cli): document exit codes; usage errors exit 2 - New EXIT_CODES dictionary (0 ok, 1 error, 2 usage, 4 needs-input), surfaced in the root --help epilogue - Commander usage errors (unknown command/option, bad or missing arguments) now exit 2 instead of 1 - Unknown-command did-you-mean output moves from stdout to stderr so stdout stays clean for machine consumers --- src/commands/main.ts | 20 +- src/utils/command-error-handler.ts | 10 + src/utils/exit-codes.ts | 25 ++ src/utils/run-program.ts | 5 + .../__snapshots__/didyoumean.test.ts.snap | 232 +++++++++++++++++- .../commands/didyoumean/didyoumean.test.ts | 4 +- .../help/__snapshots__/help.test.ts.snap | 2 + tests/unit/utils/exit-codes.test.ts | 19 ++ 8 files changed, 303 insertions(+), 14 deletions(-) create mode 100644 src/utils/exit-codes.ts create mode 100644 tests/unit/utils/exit-codes.test.ts diff --git a/src/commands/main.ts b/src/commands/main.ts index f7167573f08..2b406b8d43d 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -15,10 +15,10 @@ import { log, NETLIFY_CYAN, USER_AGENT, - warn, logError, } from '../utils/command-helpers.js' import execa from '../utils/execa.js' +import { EXIT_CODES } from '../utils/exit-codes.js' import getCLIPackageJson from '../utils/get-cli-package-json.js' import { didEnableCompileCache } from '../utils/nodejs-compile-cache.js' import { handleOptionError, isOptionError } from '../utils/command-error-handler.js' @@ -197,21 +197,23 @@ const mainCommand = async function (options, command) { command.help() } - warn(`${chalk.yellow(command.args[0])} is not a ${command.name()} command.`) + process.stderr.write( + ` ${chalk.yellow(BANG)} Warning: ${chalk.yellow(command.args[0])} is not a ${command.name()} command.\n`, + ) // @ts-expect-error TS(7006) FIXME: Parameter 'cmd' implicitly has an 'any' type. const allCommands = command.commands.map((cmd) => cmd.name()) const suggestion = closest(command.args[0], allCommands) // In non-interactive environments (CI/CD, scripts), show the suggestion - // without prompting, and display full help for available commands + // without prompting, and display full help for available commands. + // Diagnostics belong on stderr so stdout stays clean for machine consumers. if (!isInteractive()) { - log(`\nDid you mean ${chalk.blue(suggestion)}?`) - log() + process.stderr.write(`\nDid you mean ${chalk.blue(suggestion)}?\n\n`) command.outputHelp({ error: true }) - log() + process.stderr.write('\n') logError(`Run ${NETLIFY_CYAN(`${command.name()} help`)} for a list of available commands.`) - exit(1) + exit(EXIT_CODES.USAGE_ERROR) } const applySuggestion = await new Promise((resolve) => { @@ -237,7 +239,7 @@ const mainCommand = async function (options, command) { if (!applySuggestion) { logError(`Run ${NETLIFY_CYAN(`${command.name()} help`)} for a list of available commands.`) - exit(1) + exit(EXIT_CODES.USAGE_ERROR) } await execa(process.argv[0], [process.argv[1], suggestion], { stdio: 'inherit' }) @@ -299,6 +301,8 @@ export const createMainCommand = (): BaseCommand => { return `To get started run: ${NETLIFY_CYAN('netlify login')} To ask a human for credentials: ${NETLIFY_CYAN('netlify login --request ')} +Exit codes: 0 ok, 1 error, 2 usage, 4 needs-input + → For more help with the CLI, visit ${NETLIFY_CYAN( terminalLink(cliDocsEntrypointUrl, cliDocsEntrypointUrl, { fallback: false }), )} diff --git a/src/utils/command-error-handler.ts b/src/utils/command-error-handler.ts index 7449d206634..75aa065bb75 100644 --- a/src/utils/command-error-handler.ts +++ b/src/utils/command-error-handler.ts @@ -9,6 +9,16 @@ const OPTION_ERROR_CODES = new Set([ 'commander.excessArguments', ]) +/** Every Commander error code caused by bad user input; these exit with `EXIT_CODES.USAGE_ERROR` */ +export const USAGE_ERROR_CODES = new Set([ + ...OPTION_ERROR_CODES, + 'commander.unknownCommand', + 'commander.optionMissingArgument', + 'commander.missingMandatoryOptionValue', + 'commander.invalidArgument', + 'commander.conflictingOption', +]) + export const isOptionError = (error: CommanderError): boolean => OPTION_ERROR_CODES.has(error.code) export const handleOptionError = (command: { outputHelp: (context?: HelpContext) => void }): void => { diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts new file mode 100644 index 00000000000..55b9749687a --- /dev/null +++ b/src/utils/exit-codes.ts @@ -0,0 +1,25 @@ +/** + * The CLI's exit code dictionary. + * + * Contract: + * - `1` is the legacy catch-all: any unclassified failure exits 1. Scripts must treat + * any non-zero exit as failure and may use the specific codes below for diagnosis. + * - New, more specific codes are only ever adopted on paths that previously failed + * (exited non-zero). A previously succeeding invocation never starts failing, and a + * previously failing invocation never starts succeeding, because a code was added here. + * - `netlify build` additionally passes through `@netlify/build` severity codes. + * + * The dictionary is surfaced in the root `netlify --help` epilogue. + */ +export const EXIT_CODES = { + /** Command completed successfully */ + SUCCESS: 0, + /** Legacy/unclassified failure (the historical catch-all) */ + GENERAL_ERROR: 1, + /** Usage error: unknown command, unknown option, or bad arguments */ + USAGE_ERROR: 2, + /** An interactive prompt was required but the session is non-interactive (CI or `--non-interactive`) */ + NON_INTERACTIVE_PROMPT: 4, +} as const + +export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES] diff --git a/src/utils/run-program.ts b/src/utils/run-program.ts index 9b950e2dfcb..1b05f34e931 100644 --- a/src/utils/run-program.ts +++ b/src/utils/run-program.ts @@ -3,7 +3,9 @@ import { CommanderError } from 'commander' import { injectForceFlagIfScripted } from './scripted-commands.js' import { BaseCommand } from '../commands/index.js' import { CI_FORCED_COMMANDS } from '../commands/main.js' +import { USAGE_ERROR_CODES } from './command-error-handler.js' import { exit } from './command-helpers.js' +import { EXIT_CODES } from './exit-codes.js' export const runProgram = async (program: BaseCommand, argv: string[]) => { const cmdName = argv[2] @@ -18,6 +20,9 @@ export const runProgram = async (program: BaseCommand, argv: string[]) => { await program.parseAsync(argv) } catch (error) { if (error instanceof CommanderError) { + if (error.exitCode !== 0 && USAGE_ERROR_CODES.has(error.code)) { + exit(EXIT_CODES.USAGE_ERROR) + } exit(error.exitCode) } throw error diff --git a/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap b/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap index 1f24269f778..cb3354e7036 100644 --- a/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap +++ b/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap @@ -3,23 +3,247 @@ exports[`suggests closest matching command on typo 1`] = ` "› Warning: sta is not a netlify command. -Did you mean api?" +Did you mean api? + + +⬥ Netlify CLI + +VERSION + netlify-cli/test-version test-os test-node-version + +USAGE + $ netlify [COMMAND] + +COMMANDS + $ agents Manage Netlify AI agent tasks + $ api Run any Netlify API method + $ blobs Manage objects in Netlify Blobs + $ build Build on your local machine + $ claim Claim an anonymously deployed site and link it to your account + $ clone Clone a remote repository and link it to an existing project + on Netlify + $ completion Generate shell completion script + $ create Create a new Netlify project using an AI agent + $ database Provision a production ready Postgres database with a single + command + $ deploy Deploy your project to Netlify + $ dev Local dev server + $ env Control environment variables for the current project + $ functions Manage netlify functions + $ init Configure continuous deployment for a new or existing project. + To create a new project without continuous deployment, use + \`netlify sites:create\` + $ link Link a local repo or project folder to an existing project on + Netlify + $ login Login to your Netlify account + $ logs View logs from your project + $ open Open settings for the project linked to the current folder + $ 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 + project then you must exit and run \`serve\` again. + $ sites Handle various project operations + $ status Print status information + $ switch Switch your active Netlify account + $ teams Handle various team operations + $ unlink Unlink a local folder from a Netlify project + $ watch Watch for project deploy to finish + +To get started run: netlify login +To ask a human for credentials: netlify login --request + +Exit codes: 0 ok, 1 error, 2 usage, 4 needs-input + +→ For more help with the CLI, visit https://developers.netlify.com/cli +→ For help with Netlify, visit https://docs.netlify.com +→ To report a CLI bug, visit https://github.com/netlify/cli/issues + + + › Error: Run netlify help for a list of available commands." `; exports[`suggests closest matching command on typo 2`] = ` "› Warning: opeen is not a netlify command. -Did you mean open?" +Did you mean open? + + +⬥ Netlify CLI + +VERSION + netlify-cli/test-version test-os test-node-version + +USAGE + $ netlify [COMMAND] + +COMMANDS + $ agents Manage Netlify AI agent tasks + $ api Run any Netlify API method + $ blobs Manage objects in Netlify Blobs + $ build Build on your local machine + $ claim Claim an anonymously deployed site and link it to your account + $ clone Clone a remote repository and link it to an existing project + on Netlify + $ completion Generate shell completion script + $ create Create a new Netlify project using an AI agent + $ database Provision a production ready Postgres database with a single + command + $ deploy Deploy your project to Netlify + $ dev Local dev server + $ env Control environment variables for the current project + $ functions Manage netlify functions + $ init Configure continuous deployment for a new or existing project. + To create a new project without continuous deployment, use + \`netlify sites:create\` + $ link Link a local repo or project folder to an existing project on + Netlify + $ login Login to your Netlify account + $ logs View logs from your project + $ open Open settings for the project linked to the current folder + $ 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 + project then you must exit and run \`serve\` again. + $ sites Handle various project operations + $ status Print status information + $ switch Switch your active Netlify account + $ teams Handle various team operations + $ unlink Unlink a local folder from a Netlify project + $ watch Watch for project deploy to finish + +To get started run: netlify login +To ask a human for credentials: netlify login --request + +Exit codes: 0 ok, 1 error, 2 usage, 4 needs-input + +→ For more help with the CLI, visit https://developers.netlify.com/cli +→ For help with Netlify, visit https://docs.netlify.com +→ To report a CLI bug, visit https://github.com/netlify/cli/issues + + + › Error: Run netlify help for a list of available commands." `; exports[`suggests closest matching command on typo 3`] = ` "› Warning: hel is not a netlify command. -Did you mean dev?" +Did you mean dev? + + +⬥ Netlify CLI + +VERSION + netlify-cli/test-version test-os test-node-version + +USAGE + $ netlify [COMMAND] + +COMMANDS + $ agents Manage Netlify AI agent tasks + $ api Run any Netlify API method + $ blobs Manage objects in Netlify Blobs + $ build Build on your local machine + $ claim Claim an anonymously deployed site and link it to your account + $ clone Clone a remote repository and link it to an existing project + on Netlify + $ completion Generate shell completion script + $ create Create a new Netlify project using an AI agent + $ database Provision a production ready Postgres database with a single + command + $ deploy Deploy your project to Netlify + $ dev Local dev server + $ env Control environment variables for the current project + $ functions Manage netlify functions + $ init Configure continuous deployment for a new or existing project. + To create a new project without continuous deployment, use + \`netlify sites:create\` + $ link Link a local repo or project folder to an existing project on + Netlify + $ login Login to your Netlify account + $ logs View logs from your project + $ open Open settings for the project linked to the current folder + $ 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 + project then you must exit and run \`serve\` again. + $ sites Handle various project operations + $ status Print status information + $ switch Switch your active Netlify account + $ teams Handle various team operations + $ unlink Unlink a local folder from a Netlify project + $ watch Watch for project deploy to finish + +To get started run: netlify login +To ask a human for credentials: netlify login --request + +Exit codes: 0 ok, 1 error, 2 usage, 4 needs-input + +→ For more help with the CLI, visit https://developers.netlify.com/cli +→ For help with Netlify, visit https://docs.netlify.com +→ To report a CLI bug, visit https://github.com/netlify/cli/issues + + + › Error: Run netlify help for a list of available commands." `; exports[`suggests closest matching command on typo 4`] = ` "› Warning: versio is not a netlify command. -Did you mean serve?" +Did you mean serve? + + +⬥ Netlify CLI + +VERSION + netlify-cli/test-version test-os test-node-version + +USAGE + $ netlify [COMMAND] + +COMMANDS + $ agents Manage Netlify AI agent tasks + $ api Run any Netlify API method + $ blobs Manage objects in Netlify Blobs + $ build Build on your local machine + $ claim Claim an anonymously deployed site and link it to your account + $ clone Clone a remote repository and link it to an existing project + on Netlify + $ completion Generate shell completion script + $ create Create a new Netlify project using an AI agent + $ database Provision a production ready Postgres database with a single + command + $ deploy Deploy your project to Netlify + $ dev Local dev server + $ env Control environment variables for the current project + $ functions Manage netlify functions + $ init Configure continuous deployment for a new or existing project. + To create a new project without continuous deployment, use + \`netlify sites:create\` + $ link Link a local repo or project folder to an existing project on + Netlify + $ login Login to your Netlify account + $ logs View logs from your project + $ open Open settings for the project linked to the current folder + $ 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 + project then you must exit and run \`serve\` again. + $ sites Handle various project operations + $ status Print status information + $ switch Switch your active Netlify account + $ teams Handle various team operations + $ unlink Unlink a local folder from a Netlify project + $ watch Watch for project deploy to finish + +To get started run: netlify login +To ask a human for credentials: netlify login --request + +Exit codes: 0 ok, 1 error, 2 usage, 4 needs-input + +→ For more help with the CLI, visit https://developers.netlify.com/cli +→ For help with Netlify, visit https://docs.netlify.com +→ To report a CLI bug, visit https://github.com/netlify/cli/issues + + + › Error: Run netlify help for a list of available commands." `; diff --git a/tests/integration/commands/didyoumean/didyoumean.test.ts b/tests/integration/commands/didyoumean/didyoumean.test.ts index 0363cb1d0d1..8cfc9a3e016 100644 --- a/tests/integration/commands/didyoumean/didyoumean.test.ts +++ b/tests/integration/commands/didyoumean/didyoumean.test.ts @@ -14,9 +14,9 @@ test('suggests closest matching command on typo', async (t) => { for (const error of errors) { t.expect(error.status).toEqual('rejected') - t.expect(error).toHaveProperty('reason.stdout', t.expect.any(String)) + t.expect(error).toHaveProperty('reason.stderr', t.expect.any(String)) t.expect( - normalize((error as { reason: { stdout: string } }).reason.stdout, { duration: true, filePath: true }), + normalize((error as { reason: { stderr: string } }).reason.stderr, { duration: true, filePath: true }), ).toMatchSnapshot() } }) diff --git a/tests/integration/commands/help/__snapshots__/help.test.ts.snap b/tests/integration/commands/help/__snapshots__/help.test.ts.snap index 37710c6c977..123ff40b050 100644 --- a/tests/integration/commands/help/__snapshots__/help.test.ts.snap +++ b/tests/integration/commands/help/__snapshots__/help.test.ts.snap @@ -47,6 +47,8 @@ COMMANDS To get started run: netlify login To ask a human for credentials: netlify login --request +Exit codes: 0 ok, 1 error, 2 usage, 4 needs-input + → For more help with the CLI, visit https://developers.netlify.com/cli → For help with Netlify, visit https://docs.netlify.com → To report a CLI bug, visit https://github.com/netlify/cli/issues" diff --git a/tests/unit/utils/exit-codes.test.ts b/tests/unit/utils/exit-codes.test.ts new file mode 100644 index 00000000000..e0401c870fc --- /dev/null +++ b/tests/unit/utils/exit-codes.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest' + +import { EXIT_CODES } from '../../../src/utils/exit-codes.js' + +describe('EXIT_CODES', () => { + test('documents the exit code dictionary', () => { + expect(EXIT_CODES).toEqual({ + SUCCESS: 0, + GENERAL_ERROR: 1, + USAGE_ERROR: 2, + NON_INTERACTIVE_PROMPT: 4, + }) + }) + + test('keeps every code distinct', () => { + const codes = Object.values(EXIT_CODES) + expect(new Set(codes).size).toBe(codes.length) + }) +})