Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/commands/agents/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
45 changes: 44 additions & 1 deletion src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ 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'
import { deepMerge, pick } from '../utils/object-utilities.js'

import { getAgent } from '../lib/http-agent.js'
import {
BANG,
NETLIFY_CYAN,
USER_AGENT,
chalk,
Expand All @@ -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'
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 */
Expand Down
2 changes: 1 addition & 1 deletion src/commands/blobs/blobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import BaseCommand from '../base-command.js'
* The blobs command
*/
const blobs = (_options: OptionValues, command: BaseCommand) => {
command.help()
command.helpOrRejectExtraArgs()
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/commands/completion/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
}
2 changes: 1 addition & 1 deletion src/commands/env/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/functions/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
23 changes: 14 additions & 9 deletions src/commands/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ 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'
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'

Expand Down Expand Up @@ -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) => {
Expand All @@ -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' })
Expand Down Expand Up @@ -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 <msg>')}

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 }),
)}
Expand All @@ -312,6 +316,7 @@ To ask a human for credentials: ${NETLIFY_CYAN('netlify login --request <msg>')}
},
})
.exitOverride(function (this: BaseCommand, error: CommanderError) {
suggestUnknownOptionAlternatives(this, error)
if (isOptionError(error)) {
handleOptionError(this)
}
Expand Down
1 change: 1 addition & 0 deletions src/commands/open/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
2 changes: 1 addition & 1 deletion src/commands/sites/sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/teams/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
103 changes: 101 additions & 2 deletions src/utils/command-error-handler.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -9,6 +10,104 @@ 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',
])

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<string, string[]>()
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 <command> --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 => {
Expand Down
25 changes: 25 additions & 0 deletions src/utils/exit-codes.ts
Original file line number Diff line number Diff line change
@@ -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]
5 changes: 5 additions & 0 deletions src/utils/run-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down
Loading
Loading