From 987b2db8342fe46d301f05c6dbc0edaacd8cddbe Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Fri, 27 Mar 2026 12:28:58 -0400 Subject: [PATCH 1/5] fix: improve ax error handling around missing commands or unknown flags --- src/commands/base-command.ts | 52 ++++++++++++---- src/commands/main.ts | 24 +++++++- src/utils/run-program.ts | 13 +++- .../error-handling/error-handling.test.ts | 59 +++++++++++++++++++ 4 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 tests/integration/commands/error-handling/error-handling.test.ts diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index b91d876f2a9..302ad779aa7 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -9,7 +9,7 @@ import { NodeFS, NoopLogger } from '@netlify/build-info/node' import { resolveConfig } from '@netlify/config' import { getGlobalConfigStore, LocalState } from '@netlify/dev-utils' import { isCI } from 'ci-info' -import { Command, Help, Option, type OptionValues } from 'commander' +import { Command, CommanderError, Help, Option, type OptionValues } from 'commander' import debug from 'debug' import { findUp } from 'find-up' import inquirer from 'inquirer' @@ -65,6 +65,9 @@ const HELP_INDENT_WIDTH = 2 /** separator width between term and description */ const HELP_SEPARATOR_WIDTH = 5 +/** Commander error codes for option-related errors */ +const OPTION_ERROR_CODES = ['commander.unknownOption', 'commander.missingArgument', 'commander.excessArguments'] + /** * A list of commands where we don't have to perform the workspace selection at. * Those commands work with the system or are not writing any config files that need to be @@ -236,12 +239,13 @@ export default class BaseCommand extends Command { accountId?: string /** - * IMPORTANT this function will be called for each command! - * Don't do anything expensive in there. + * Override Commander's createCommand to return BaseCommand instances with our setup. + * This is called by .command() to create subcommands. + * IMPORTANT: This function is called for each command! Don't do anything expensive here. */ - createCommand(name: string): BaseCommand { - const base = new BaseCommand(name) - // .addOption(new Option('--force', 'Force command to run. Bypasses prompts for certain destructive commands.')) + createCommand(name?: string): BaseCommand { + const commandName = name || '' + const base = new BaseCommand(commandName) .addOption(new Option('--silent', 'Silence CLI output').hideHelp(true)) .addOption(new Option('--cwd ').hideHelp(true)) .addOption( @@ -262,20 +266,46 @@ export default class BaseCommand extends Command { ) .option('--debug', 'Print debugging information') - // only add the `--filter` option to commands that are workspace aware - if (!COMMANDS_WITHOUT_WORKSPACE_OPTIONS.has(name)) { + if (!COMMANDS_WITHOUT_WORKSPACE_OPTIONS.has(commandName)) { base.option('--filter ', 'For monorepos, specify the name of the application to run the command in') } - return base.hook('preAction', async (_parentCommand, actionCommand) => { + base.hook('preAction', async (_parentCommand, actionCommand) => { if (actionCommand.opts()?.debug) { process.env.DEBUG = '*' } - debug(`${name}:preAction`)('start') + debug(`${commandName}:preAction`)('start') this.analytics.startTime = process.hrtime.bigint() await this.init(actionCommand as BaseCommand) - debug(`${name}:preAction`)('end') + debug(`${commandName}:preAction`)('end') }) + + // Wrap the command's action() to set exitOverride when action is registered. + // We set exitOverride here (rather than earlier) because Commander may clone + // or modify command instances during registration, so we need to set it on + // the final instance that will actually execute. + const originalAction = base.action.bind(base) + base.action = function (this: BaseCommand, fn: any) { + // Set exitOverride for option-related errors in non-interactive environments. + // In non-interactive mode, we show the full help output instead of just a + // brief error message, making it easier for users in CI/CD environments to + // understand what went wrong. + this.exitOverride((error: CommanderError) => { + const isOptionError = OPTION_ERROR_CODES.includes(error.code) + + if (isOptionError && !isInteractive()) { + log() + this.outputHelp() + log() + } + + throw error + }) + + return originalAction(fn) + } + + return base } #noBaseOptions = false diff --git a/src/commands/main.ts b/src/commands/main.ts index 2eea1a46796..187ee6dde82 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -1,6 +1,6 @@ import process from 'process' -import { Option } from 'commander' +import { Option, CommanderError } from 'commander' import envinfo from 'envinfo' import { closest } from 'fastest-levenshtein' import inquirer from 'inquirer' @@ -21,6 +21,7 @@ import { import execa from '../utils/execa.js' import getCLIPackageJson from '../utils/get-cli-package-json.js' import { didEnableCompileCache } from '../utils/nodejs-compile-cache.js' +import { isInteractive } from '../utils/scripted-commands.js' import { track, reportError } from '../utils/telemetry/index.js' import { createAgentsCommand } from './agents/index.js' @@ -57,6 +58,8 @@ import { createDatabaseCommand } from './database/index.js' const SUGGESTION_TIMEOUT = 1e4 +const OPTION_ERROR_CODES = ['commander.unknownOption', 'commander.missingArgument', 'commander.excessArguments'] + // These commands run with the --force flag in non-interactive and CI environments export const CI_FORCED_COMMANDS = { 'env:set': { options: '--force', description: 'Bypasses prompts & Force the command to run.' }, @@ -180,6 +183,14 @@ const mainCommand = async function (options, command) { 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, as prompts would block or timeout + if (!isInteractive()) { + log(`\nDid you mean ${chalk.blue(suggestion)}?`) + log() + return logAndThrowError(`Run ${NETLIFY_CYAN(`${command.name()} help`)} for a list of available commands.`) + } + const applySuggestion = await new Promise((resolve) => { const prompt = inquirer.prompt({ type: 'confirm', @@ -276,6 +287,17 @@ To ask a human for credentials: ${NETLIFY_CYAN('netlify login --request ')} write(` ${chalk.red(BANG)} See more help with --help\n`) }, }) + .exitOverride(function (this: BaseCommand, error: CommanderError) { + const isOptionError = OPTION_ERROR_CODES.includes(error.code) + + if (isOptionError && !isInteractive()) { + log() + this.outputHelp() + log() + } + + throw error + }) .action(mainCommand) program.commands.forEach((cmd) => { diff --git a/src/utils/run-program.ts b/src/utils/run-program.ts index eadbbc940be..9b950e2dfcb 100644 --- a/src/utils/run-program.ts +++ b/src/utils/run-program.ts @@ -1,8 +1,10 @@ +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 { exit } from './command-helpers.js' -// This function is used to run the program with the correct flags export const runProgram = async (program: BaseCommand, argv: string[]) => { const cmdName = argv[2] // checks if the command has a force option @@ -12,5 +14,12 @@ export const runProgram = async (program: BaseCommand, argv: string[]) => { injectForceFlagIfScripted(argv) } - await program.parseAsync(argv) + try { + await program.parseAsync(argv) + } catch (error) { + if (error instanceof CommanderError) { + exit(error.exitCode) + } + throw error + } } diff --git a/tests/integration/commands/error-handling/error-handling.test.ts b/tests/integration/commands/error-handling/error-handling.test.ts new file mode 100644 index 00000000000..108fede6523 --- /dev/null +++ b/tests/integration/commands/error-handling/error-handling.test.ts @@ -0,0 +1,59 @@ +import { describe, test, expect } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { normalize } from '../../utils/snapshots.js' + +describe('error handling', () => { + test('unknown option shows error message', async (t) => { + await expect(callCli(['status', '--invalid-option'])).rejects.toThrow() + + try { + await callCli(['status', '--invalid-option']) + } catch (error) { + const stderr = (error as { stderr: string }).stderr + const normalized = normalize(stderr) + + // In interactive mode (test environment), shows brief error + t.expect(normalized).toContain('Error: unknown option') + t.expect(normalized).toContain('See more help with --help') + } + }) + + test('unknown command shows error', async (t) => { + await expect(callCli(['statuss'])).rejects.toThrow() + + try { + await callCli(['statuss']) + } catch (error) { + const stderr = (error as { stderr: string }).stderr + const normalized = normalize(stderr) + + // Shows error with help suggestion + t.expect(normalized).toContain('Error') + t.expect(normalized).toContain('netlify help') + } + }) + + test('missing required argument shows error', async (t) => { + await expect(callCli(['sites:delete'])).rejects.toThrow() + + try { + await callCli(['sites:delete']) + } catch (error) { + const stderr = (error as { stderr: string }).stderr + const normalized = normalize(stderr) + + t.expect(normalized).toContain('Error:') + t.expect(normalized).toContain('missing required argument') + } + }) + + test('help command works correctly', async (t) => { + const helpOutput = (await callCli(['status', '--help'])) as string + const normalized = normalize(helpOutput) + + t.expect(normalized).toContain('Print status information') + t.expect(normalized).toContain('USAGE') + t.expect(normalized).toContain('OPTIONS') + }) +}) From 3f12a30dd61a0ec87330fc301d23907db45d42c0 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Fri, 27 Mar 2026 12:53:18 -0400 Subject: [PATCH 2/5] fix: show --help results in non-interactive environment to help agents --- src/commands/main.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/main.ts b/src/commands/main.ts index 187ee6dde82..e5ffbd19012 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -184,10 +184,12 @@ const mainCommand = async function (options, command) { const suggestion = closest(command.args[0], allCommands) // In non-interactive environments (CI/CD, scripts), show the suggestion - // without prompting, as prompts would block or timeout + // without prompting, and display full help for available commands if (!isInteractive()) { log(`\nDid you mean ${chalk.blue(suggestion)}?`) log() + command.outputHelp() + log() return logAndThrowError(`Run ${NETLIFY_CYAN(`${command.name()} help`)} for a list of available commands.`) } From e7d95dd4355ceac384e43aaf0a445d84094ef311 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Fri, 27 Mar 2026 13:09:02 -0400 Subject: [PATCH 3/5] fix: tests --- .../__snapshots__/didyoumean.test.ts.snap | 216 +++++++++++++++++- 1 file changed, 212 insertions(+), 4 deletions(-) diff --git a/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap b/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap index 6dbb7b3ed46..3e6a5daa255 100644 --- a/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap +++ b/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap @@ -2,20 +2,228 @@ exports[`suggests closest matching command on typo 1`] = ` "› Warning: sta is not a netlify command. -? Did you mean api (y/N) " + +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 + $ db 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 Stream 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 + +→ 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" `; exports[`suggests closest matching command on typo 2`] = ` "› Warning: opeen is not a netlify command. -? Did you mean open (y/N) " + +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 + $ db 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 Stream 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 + +→ 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" `; exports[`suggests closest matching command on typo 3`] = ` "› Warning: hel is not a netlify command. -? Did you mean dev (y/N) " + +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 + $ db 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 Stream 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 + +→ 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" `; exports[`suggests closest matching command on typo 4`] = ` "› Warning: versio is not a netlify command. -? Did you mean serve (y/N) " + +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 + $ db 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 Stream 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 + +→ 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" `; From c5cfbbbd1d31bb3f12c8441f4bcdec0b3e2432db Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Fri, 27 Mar 2026 14:45:26 -0400 Subject: [PATCH 4/5] fix: tests --- src/commands/base-command.ts | 12 +- src/commands/main.ts | 13 +- .../__snapshots__/didyoumean.test.ts.snap | 212 +----------------- .../error-handling/error-handling.test.ts | 11 +- 4 files changed, 15 insertions(+), 233 deletions(-) diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 302ad779aa7..25196e16ed3 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -34,6 +34,7 @@ import { warn, logError, } from '../utils/command-helpers.js' +import { handleOptionError, isOptionError } from '../utils/command-error-handler.js' import type { FeatureFlags } from '../utils/feature-flags.js' import { getFrameworksAPIPaths } from '../utils/frameworks-api.js' import { getSiteByName } from '../utils/get-site.js' @@ -65,9 +66,6 @@ const HELP_INDENT_WIDTH = 2 /** separator width between term and description */ const HELP_SEPARATOR_WIDTH = 5 -/** Commander error codes for option-related errors */ -const OPTION_ERROR_CODES = ['commander.unknownOption', 'commander.missingArgument', 'commander.excessArguments'] - /** * A list of commands where we don't have to perform the workspace selection at. * Those commands work with the system or are not writing any config files that need to be @@ -291,12 +289,8 @@ 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) => { - const isOptionError = OPTION_ERROR_CODES.includes(error.code) - - if (isOptionError && !isInteractive()) { - log() - this.outputHelp() - log() + if (isOptionError(error)) { + handleOptionError(this) } throw error diff --git a/src/commands/main.ts b/src/commands/main.ts index e5ffbd19012..86339c57d51 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -21,6 +21,7 @@ import { import execa from '../utils/execa.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 { isInteractive } from '../utils/scripted-commands.js' import { track, reportError } from '../utils/telemetry/index.js' @@ -58,8 +59,6 @@ import { createDatabaseCommand } from './database/index.js' const SUGGESTION_TIMEOUT = 1e4 -const OPTION_ERROR_CODES = ['commander.unknownOption', 'commander.missingArgument', 'commander.excessArguments'] - // These commands run with the --force flag in non-interactive and CI environments export const CI_FORCED_COMMANDS = { 'env:set': { options: '--force', description: 'Bypasses prompts & Force the command to run.' }, @@ -188,7 +187,7 @@ const mainCommand = async function (options, command) { if (!isInteractive()) { log(`\nDid you mean ${chalk.blue(suggestion)}?`) log() - command.outputHelp() + command.outputHelp({ error: true }) log() return logAndThrowError(`Run ${NETLIFY_CYAN(`${command.name()} help`)} for a list of available commands.`) } @@ -290,12 +289,8 @@ To ask a human for credentials: ${NETLIFY_CYAN('netlify login --request ')} }, }) .exitOverride(function (this: BaseCommand, error: CommanderError) { - const isOptionError = OPTION_ERROR_CODES.includes(error.code) - - if (isOptionError && !isInteractive()) { - log() - this.outputHelp() - log() + if (isOptionError(error)) { + handleOptionError(this) } 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 3e6a5daa255..1f24269f778 100644 --- a/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap +++ b/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap @@ -3,227 +3,23 @@ exports[`suggests closest matching command on typo 1`] = ` "› Warning: sta is not a netlify command. -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 - $ db 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 Stream 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 - -→ 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" +Did you mean api?" `; exports[`suggests closest matching command on typo 2`] = ` "› Warning: opeen is not a netlify command. -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 - $ db 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 Stream 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 - -→ 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" +Did you mean open?" `; exports[`suggests closest matching command on typo 3`] = ` "› Warning: hel is not a netlify command. -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 - $ db 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 Stream 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 - -→ 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" +Did you mean dev?" `; exports[`suggests closest matching command on typo 4`] = ` "› Warning: versio is not a netlify command. -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 - $ db 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 Stream 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 - -→ 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" +Did you mean serve?" `; diff --git a/tests/integration/commands/error-handling/error-handling.test.ts b/tests/integration/commands/error-handling/error-handling.test.ts index 108fede6523..7efeedbc4b5 100644 --- a/tests/integration/commands/error-handling/error-handling.test.ts +++ b/tests/integration/commands/error-handling/error-handling.test.ts @@ -1,14 +1,13 @@ -import { describe, test, expect } from 'vitest' +import { describe, test } from 'vitest' import { callCli } from '../../utils/call-cli.js' import { normalize } from '../../utils/snapshots.js' describe('error handling', () => { test('unknown option shows error message', async (t) => { - await expect(callCli(['status', '--invalid-option'])).rejects.toThrow() - try { await callCli(['status', '--invalid-option']) + t.expect.fail('Expected callCli to throw') } catch (error) { const stderr = (error as { stderr: string }).stderr const normalized = normalize(stderr) @@ -20,10 +19,9 @@ describe('error handling', () => { }) test('unknown command shows error', async (t) => { - await expect(callCli(['statuss'])).rejects.toThrow() - try { await callCli(['statuss']) + t.expect.fail('Expected callCli to throw') } catch (error) { const stderr = (error as { stderr: string }).stderr const normalized = normalize(stderr) @@ -35,10 +33,9 @@ describe('error handling', () => { }) test('missing required argument shows error', async (t) => { - await expect(callCli(['sites:delete'])).rejects.toThrow() - try { await callCli(['sites:delete']) + t.expect.fail('Expected callCli to throw') } catch (error) { const stderr = (error as { stderr: string }).stderr const normalized = normalize(stderr) From 7d47f8489364bcdf9fcb22a19cdcd489d449f51d Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Fri, 27 Mar 2026 14:45:36 -0400 Subject: [PATCH 5/5] fix: tests --- src/utils/command-error-handler.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/utils/command-error-handler.ts diff --git a/src/utils/command-error-handler.ts b/src/utils/command-error-handler.ts new file mode 100644 index 00000000000..7449d206634 --- /dev/null +++ b/src/utils/command-error-handler.ts @@ -0,0 +1,20 @@ +import { CommanderError, type HelpContext } from 'commander' + +import { log } from './command-helpers.js' +import { isInteractive } from './scripted-commands.js' + +const OPTION_ERROR_CODES = new Set([ + 'commander.unknownOption', + 'commander.missingArgument', + 'commander.excessArguments', +]) + +export const isOptionError = (error: CommanderError): boolean => OPTION_ERROR_CODES.has(error.code) + +export const handleOptionError = (command: { outputHelp: (context?: HelpContext) => void }): void => { + if (!isInteractive()) { + log() + command.outputHelp({ error: true }) + log() + } +}