From fa3159786d2c7a8beaa278bbf1edcde0376783d9 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Mon, 6 Jul 2026 10:45:26 -0700 Subject: [PATCH 1/2] 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) + }) +}) From 73b943bba572ac344e089c248f015079d062125d Mon Sep 17 00:00:00 2001 From: DavidWells Date: Mon, 6 Jul 2026 10:55:00 -0700 Subject: [PATCH 2/2] fix(cli): space-form subcommands error instead of printing help with exit 0 'netlify sites delete my-site' used to print the sites help text and exit 0 - a false success for any script. Namespace commands now exit 2 with a colon-form did-you-mean that preserves the remaining args. Typo'd flags also get did-you-mean suggestions, and root-level unknown flags name the subcommands that own them. --- src/commands/agents/agents.ts | 2 +- src/commands/base-command.ts | 45 ++++- src/commands/blobs/blobs.ts | 2 +- src/commands/completion/index.ts | 2 +- src/commands/env/env.ts | 2 +- src/commands/functions/functions.ts | 2 +- src/commands/main.ts | 3 +- src/commands/open/index.ts | 1 + src/commands/sites/sites.ts | 2 +- src/commands/teams/teams.ts | 2 +- src/utils/command-error-handler.ts | 93 +++++++++- tests/unit/commands/main-suggestions.test.ts | 168 +++++++++++++++++++ 12 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 tests/unit/commands/main-suggestions.test.ts diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index b139a133119..ccf8b1b1711 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -5,7 +5,7 @@ import requiresSiteInfoWithProject from '../../utils/hooks/requires-site-info-wi import type BaseCommand from '../base-command.js' const agents = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } export const createAgentsCommand = (program: BaseCommand) => { diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 11f10bf859b..043ca869de2 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -11,6 +11,7 @@ import { getGlobalConfigStore, LocalState } from '@netlify/dev-utils' import { isCI } from 'ci-info' import { Command, CommanderError, Help, Option, type OptionValues } from 'commander' import debug from 'debug' +import { closest, distance } from 'fastest-levenshtein' import { findUp } from 'find-up' import inquirer from 'inquirer' import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt' @@ -18,6 +19,7 @@ import { deepMerge, pick } from '../utils/object-utilities.js' import { getAgent } from '../lib/http-agent.js' import { + BANG, NETLIFY_CYAN, USER_AGENT, chalk, @@ -34,7 +36,8 @@ import { warn, logError, } from '../utils/command-helpers.js' -import { handleOptionError, isOptionError } from '../utils/command-error-handler.js' +import { handleOptionError, isOptionError, suggestUnknownOptionAlternatives } from '../utils/command-error-handler.js' +import { EXIT_CODES } from '../utils/exit-codes.js' import type { FeatureFlags } from '../utils/feature-flags.js' import { getFrameworksAPIPaths } from '../utils/frameworks-api.js' import { getSiteByName } from '../utils/get-site.js' @@ -290,6 +293,7 @@ export default class BaseCommand extends Command { // brief error message, making it easier for users in CI/CD environments to // understand what went wrong. this.exitOverride((error: CommanderError) => { + suggestUnknownOptionAlternatives(this, error) if (isOptionError(error)) { handleOptionError(this) } @@ -310,6 +314,45 @@ export default class BaseCommand extends Command { return this } + /** + * Rejects space-form subcommand invocations (e.g. `netlify sites delete`) on namespace + * commands with a colon-form did-you-mean (`netlify sites:delete`) instead of silently + * succeeding. No-op when no positional arguments were given. + */ + rejectSpaceFormSubcommand(): void { + if (this.args.length === 0) { + return + } + + const attempted = this.args[0] + const colonForm = `${this.name()}:${attempted}` + const subcommandNames = (this.parent ?? this).commands + .map((cmd) => cmd.name()) + .filter((cmdName) => cmdName.startsWith(`${this.name()}:`)) + const exactMatch = subcommandNames.find((cmdName) => cmdName === colonForm) + const nearest = exactMatch ?? (subcommandNames.length === 0 ? undefined : closest(colonForm, subcommandNames)) + + const bang = chalk.red(BANG) + process.stderr.write(` ${bang} Error: 'netlify ${this.name()} ${attempted}' is not a command.\n`) + if (nearest !== undefined && (exactMatch !== undefined || distance(colonForm, nearest) <= 3)) { + const remainingArgs = this.args.slice(1).join(' ') + process.stderr.write( + ` ${bang} Did you mean 'netlify ${nearest}${remainingArgs === '' ? '' : ` ${remainingArgs}`}'?\n`, + ) + } + process.stderr.write(` ${bang} Run 'netlify ${this.name()} --help' to see available subcommands.\n`) + exit(EXIT_CODES.USAGE_ERROR) + } + + /** + * Action for namespace-only parent commands (e.g. `sites`, `env`): shows help when + * called bare, errors with a colon-form suggestion when positional arguments are given. + */ + helpOrRejectExtraArgs(): void { + this.rejectSpaceFormSubcommand() + this.help() + } + /** The examples list for the command (used inside doc generation and help page) */ examples: string[] = [] /** Set examples for the command */ diff --git a/src/commands/blobs/blobs.ts b/src/commands/blobs/blobs.ts index 62b77fd203c..08ab2cefb05 100644 --- a/src/commands/blobs/blobs.ts +++ b/src/commands/blobs/blobs.ts @@ -8,7 +8,7 @@ import BaseCommand from '../base-command.js' * The blobs command */ const blobs = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } /** diff --git a/src/commands/completion/index.ts b/src/commands/completion/index.ts index 367e1823cc5..3d286bf970e 100644 --- a/src/commands/completion/index.ts +++ b/src/commands/completion/index.ts @@ -27,6 +27,6 @@ export const createCompletionCommand = (program: BaseCommand) => { .description('Generate shell completion script\nRun this command to see instructions for your shell.') .addExamples(['netlify completion:install']) .action((_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() }) } diff --git a/src/commands/env/env.ts b/src/commands/env/env.ts index 5f733196241..ca015c65119 100644 --- a/src/commands/env/env.ts +++ b/src/commands/env/env.ts @@ -5,7 +5,7 @@ import { normalizeContext } from '../../utils/env/index.js' import BaseCommand from '../base-command.js' const env = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } export const createEnvCommand = (program: BaseCommand) => { diff --git a/src/commands/functions/functions.ts b/src/commands/functions/functions.ts index e151f704126..53ea75d8b70 100644 --- a/src/commands/functions/functions.ts +++ b/src/commands/functions/functions.ts @@ -6,7 +6,7 @@ import requiresSiteInfo from '../../utils/hooks/requires-site-info.js' import type BaseCommand from '../base-command.js' const functions = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } export const createFunctionsCommand = (program: BaseCommand) => { diff --git a/src/commands/main.ts b/src/commands/main.ts index 2b406b8d43d..699a574e202 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -21,7 +21,7 @@ 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' +import { handleOptionError, isOptionError, suggestUnknownOptionAlternatives } from '../utils/command-error-handler.js' import { isInteractive } from '../utils/scripted-commands.js' import { track, reportError } from '../utils/telemetry/index.js' @@ -316,6 +316,7 @@ Exit codes: 0 ok, 1 error, 2 usage, 4 needs-input }, }) .exitOverride(function (this: BaseCommand, error: CommanderError) { + suggestUnknownOptionAlternatives(this, error) if (isOptionError(error)) { handleOptionError(this) } diff --git a/src/commands/open/index.ts b/src/commands/open/index.ts index 2fcdfe0134b..ea60d514b71 100644 --- a/src/commands/open/index.ts +++ b/src/commands/open/index.ts @@ -31,6 +31,7 @@ export const createOpenCommand = (program: BaseCommand) => { .option('--admin', 'Open Netlify project') .addExamples(['netlify open --site', 'netlify open --admin', 'netlify open:admin', 'netlify open:site']) .action(async (options: OptionValues, command: BaseCommand) => { + command.rejectSpaceFormSubcommand() const { open } = await import('./open.js') await open(options, command) }) diff --git a/src/commands/sites/sites.ts b/src/commands/sites/sites.ts index 7c7dcc6c0d0..51c01a73224 100644 --- a/src/commands/sites/sites.ts +++ b/src/commands/sites/sites.ts @@ -3,7 +3,7 @@ import BaseCommand from '../base-command.js' import { validateSiteName } from '../../utils/validation.js' const sites = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } export const createSitesCreateCommand = (program: BaseCommand) => { diff --git a/src/commands/teams/teams.ts b/src/commands/teams/teams.ts index 82e2384f153..18e3647918d 100644 --- a/src/commands/teams/teams.ts +++ b/src/commands/teams/teams.ts @@ -3,7 +3,7 @@ import type { OptionValues } from 'commander' import type BaseCommand from '../base-command.js' const teams = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } export const createTeamsCommand = (program: BaseCommand) => { diff --git a/src/utils/command-error-handler.ts b/src/utils/command-error-handler.ts index 75aa065bb75..006e83e5796 100644 --- a/src/utils/command-error-handler.ts +++ b/src/utils/command-error-handler.ts @@ -1,6 +1,7 @@ -import { CommanderError, type HelpContext } from 'commander' +import { CommanderError, type Command, type HelpContext } from 'commander' +import { distance } from 'fastest-levenshtein' -import { log } from './command-helpers.js' +import { BANG, chalk, log } from './command-helpers.js' import { isInteractive } from './scripted-commands.js' const OPTION_ERROR_CODES = new Set([ @@ -19,6 +20,94 @@ export const USAGE_ERROR_CODES = new Set([ 'commander.conflictingOption', ]) +const UNKNOWN_OPTION_PATTERN = /unknown option '([^']+)'/ +const MAX_FLAG_EDIT_DISTANCE = 2 +const MAX_FLAG_SUGGESTIONS = 3 +const MAX_OWNING_COMMANDS = 3 + +const stripDashes = (flag: string): string => flag.replace(/^-+/, '') + +const isCloseTo = + (target: string) => + (flag: string): boolean => + distance(stripDashes(target), stripDashes(flag)) <= MAX_FLAG_EDIT_DISTANCE + +const byClosestTo = + (target: string) => + (flagA: string, flagB: string): number => + distance(stripDashes(target), stripDashes(flagA)) - distance(stripDashes(target), stripDashes(flagB)) + +export const getUnknownOptionSuggestions = (command: Command, errorMessage: string): string[] => { + const match = UNKNOWN_OPTION_PATTERN.exec(errorMessage) + if (match === null) { + return [] + } + const unknownFlag = match[1] + const lines: string[] = [] + + const attemptedIndex = command.args.findIndex((arg) => !arg.startsWith('-')) + if (attemptedIndex !== -1 && command.parent !== null) { + const colonForm = `${command.name()}:${command.args[attemptedIndex]}` + if (command.parent.commands.some((cmd) => cmd.name() === colonForm)) { + const rest = command.args.filter((_, index) => index !== attemptedIndex).join(' ') + return [`Did you mean 'netlify ${colonForm}${rest === '' ? '' : ` ${rest}`}'?`] + } + } + + if (!errorMessage.includes('Did you mean')) { + const ownFlags = command.options + .filter((option) => !option.hidden) + .flatMap((option) => option.long ?? []) + .filter(isCloseTo(unknownFlag)) + .sort(byClosestTo(unknownFlag)) + .slice(0, MAX_FLAG_SUGGESTIONS) + if (ownFlags.length !== 0) { + lines.push(`Did you mean ${ownFlags.map((flag) => `'${flag}'`).join(' or ')}?`) + } + } + + const isRoot = command.parent === null + const errorBelongsToSubcommand = + command.args.length !== 0 && + command.commands.some((cmd) => cmd.name() === command.args[0] || cmd.aliases().includes(command.args[0])) + if (isRoot && !errorBelongsToSubcommand) { + const flagOwners = new Map() + for (const subcommand of command.commands) { + // @ts-expect-error TS(2551) FIXME: Property '_hidden' does not exist on type 'Command'. + if (subcommand._hidden) continue + for (const option of subcommand.options) { + if (option.hidden || !option.long) continue + const owners = flagOwners.get(option.long) ?? [] + if (!owners.includes(subcommand.name())) { + owners.push(subcommand.name()) + } + flagOwners.set(option.long, owners) + } + } + const candidates = [...flagOwners.keys()] + .filter(isCloseTo(unknownFlag)) + .sort(byClosestTo(unknownFlag)) + .slice(0, MAX_FLAG_SUGGESTIONS) + for (const flag of candidates) { + const owners = (flagOwners.get(flag) ?? []).sort((ownerA, ownerB) => ownerA.localeCompare(ownerB)) + const shownOwners = owners.slice(0, MAX_OWNING_COMMANDS).join(', ') + const ellipsis = owners.length > MAX_OWNING_COMMANDS ? ', ...' : '' + lines.push(`'${flag}' is a flag of: ${shownOwners}${ellipsis} (run 'netlify --help')`) + } + } + + return lines +} + +export const suggestUnknownOptionAlternatives = (command: Command, error: CommanderError): void => { + if (error.code !== 'commander.unknownOption') { + return + } + for (const line of getUnknownOptionSuggestions(command, error.message)) { + process.stderr.write(` ${chalk.red(BANG)} ${line}\n`) + } +} + export const isOptionError = (error: CommanderError): boolean => OPTION_ERROR_CODES.has(error.code) export const handleOptionError = (command: { outputHelp: (context?: HelpContext) => void }): void => { diff --git a/tests/unit/commands/main-suggestions.test.ts b/tests/unit/commands/main-suggestions.test.ts new file mode 100644 index 00000000000..9270861b540 --- /dev/null +++ b/tests/unit/commands/main-suggestions.test.ts @@ -0,0 +1,168 @@ +import { Command } from 'commander' +import { afterEach, describe, expect, test, vi } from 'vitest' + +import BaseCommand from '../../../src/commands/base-command.js' +import { getUnknownOptionSuggestions } from '../../../src/utils/command-error-handler.js' + +const getSubcommand = (program: Command, name: string): Command => { + const found = program.commands.find((cmd) => cmd.name() === name) + if (found === undefined) { + throw new Error(`missing subcommand ${name}`) + } + return found +} + +const buildRootCommand = () => { + const program = new Command('netlify') + program.option('--telemetry-disable', 'Disable telemetry') + program.command('env:list').option('--json', 'Output environment variables as JSON') + program.command('sites:list').option('--json', 'Output project data as JSON') + program.command('status').option('--json', 'Output status information as JSON') + program.command('deploy').option('--json', 'Output deployment data as JSON') + program + .command('agents:create') + .option('-a, --agent ', 'agent type (claude, codex, gemini)') + .option('--json', 'output result as JSON') + return program +} + +describe('getUnknownOptionSuggestions', () => { + test('suggests close flags from the current command when commander gave no suggestion', () => { + const program = buildRootCommand() + const subcommand = getSubcommand(program, 'env:list') + + const lines = getUnknownOptionSuggestions(subcommand, "error: unknown option '--jsno'") + + expect(lines).toContain("Did you mean '--json'?") + }) + + test('does not duplicate a suggestion commander already made', () => { + const program = buildRootCommand() + const subcommand = getSubcommand(program, 'env:list') + + const lines = getUnknownOptionSuggestions(subcommand, "error: unknown option '--jsno'\n(Did you mean --json?)") + + expect(lines.filter((line) => line.startsWith('Did you mean'))).toHaveLength(0) + }) + + test('at the root, names the owning commands of close subcommand flags (capped at 3 with ellipsis)', () => { + const program = buildRootCommand() + + const lines = getUnknownOptionSuggestions(program, "error: unknown option '--jsno'") + + expect(lines).toContain( + "'--json' is a flag of: agents:create, deploy, env:list, ... (run 'netlify --help')", + ) + }) + + test('at the root, suggests typoed flags that only exist on subcommands', () => { + const program = buildRootCommand() + + const lines = getUnknownOptionSuggestions(program, "error: unknown option '--aegnt'") + + expect(lines.some((line) => line.includes("'--agent' is a flag of: agents:create"))).toBe(true) + }) + + test('stays silent when no flag is within edit distance 2', () => { + const program = buildRootCommand() + + expect(getUnknownOptionSuggestions(program, "error: unknown option '--zzzzzzzzz'")).toEqual([]) + }) + + test('skips the cross-command index when the error came from a dispatched subcommand', () => { + const program = buildRootCommand() + program.args = ['env:list', '--jsno'] + + const lines = getUnknownOptionSuggestions(program, "error: unknown option '--jsno'") + + expect(lines.filter((line) => line.includes('is a flag of'))).toHaveLength(0) + }) + + test('returns nothing for non unknown-option messages', () => { + const program = buildRootCommand() + + expect(getUnknownOptionSuggestions(program, "error: missing required argument 'name'")).toEqual([]) + }) +}) + +describe('namespace parent commands reject space-form subcommands', () => { + const stderrChunks: string[] = [] + + const buildSitesCommand = () => { + const program = new BaseCommand('netlify') + program.command('sites:create').description('Create an empty project') + program.command('sites:delete').description('Delete a project') + program.command('sites:list').description('List all projects') + return program.command('sites').description('Handle various project operations') + } + + const mockProcess = () => { + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrChunks.push(String(chunk)) + return true + }) + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`exit(${String(code ?? 0)})`) + }) + } + + afterEach(() => { + stderrChunks.length = 0 + vi.restoreAllMocks() + }) + + test('errors with usage exit code 2 and a colon-form did-you-mean for a known subcommand', () => { + const sites = buildSitesCommand() + sites.args = ['delete', 'my-site-id'] + mockProcess() + + expect(() => { + sites.rejectSpaceFormSubcommand() + }).toThrow('exit(2)') + + const stderr = stderrChunks.join('') + expect(stderr).toContain("'netlify sites delete' is not a command") + expect(stderr).toContain("Did you mean 'netlify sites:delete my-site-id'?") + expect(stderr).toContain("Run 'netlify sites --help'") + }) + + test('suggests the closest colon-form subcommand for a near-miss', () => { + const sites = buildSitesCommand() + sites.args = ['delte', 'my-site-id'] + mockProcess() + + expect(() => { + sites.rejectSpaceFormSubcommand() + }).toThrow('exit(2)') + + expect(stderrChunks.join('')).toContain('sites:delete') + }) + + test('is a no-op when no positional arguments were given', () => { + const sites = buildSitesCommand() + sites.args = [] + mockProcess() + + expect(() => { + sites.rejectSpaceFormSubcommand() + }).not.toThrow() + expect(stderrChunks).toHaveLength(0) + }) + + test('helpOrRejectExtraArgs still prints help for bare invocations', () => { + const sites = buildSitesCommand() + sites.args = [] + const stdoutChunks: string[] = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdoutChunks.push(String(chunk)) + return true + }) + mockProcess() + + expect(() => { + sites.helpOrRejectExtraArgs() + }).toThrow('exit(0)') + expect(stdoutChunks.join('')).toContain('sites:delete') + expect(stderrChunks.join('')).toBe('') + }) +})