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
33 changes: 21 additions & 12 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type { FeatureFlags } from '../utils/feature-flags.js'
import { getFrameworksAPIPaths } from '../utils/frameworks-api.js'
import { getSiteByName } from '../utils/get-site.js'
import openBrowser from '../utils/open-browser.js'
import { isInteractive } from '../utils/scripted-commands.js'
import { failOnNonInteractivePrompt, isInteractive } from '../utils/scripted-commands.js'
import { identify, reportError, setCommandForErrorReporting, track } from '../utils/telemetry/index.js'
import type { NetlifyOptions } from './types.js'
import type { CachedConfig } from '../lib/build.js'
Expand Down Expand Up @@ -137,13 +137,14 @@ async function selectWorkspace(project: Project, filter?: string): Promise<strin
log()
log(chalk.cyan(`We've detected multiple projects inside your repository`))

if (isCI) {
throw new Error(
if (isCI || !isInteractive()) {
return failOnNonInteractivePrompt(
'Select the project you want to work with',
`Projects detected: ${(project.workspace?.packages || [])
.map((pkg) => pkg.name || pkg.path)
.join(
', ',
)}. Configure the project you want to work with and try again. Refer to https://ntl.fyi/configure-site for more information.`,
.join(', ')}. Pass ${chalk.cyanBright('--filter <app>')} or ${chalk.cyanBright(
'--cwd <path>',
)} to configure the project you want to work with and try again. Refer to https://ntl.fyi/configure-site for more information.`,
)
}

Expand Down Expand Up @@ -182,6 +183,7 @@ export type BaseOptionValues = {
debug?: boolean
filter?: string
httpProxy?: string
nonInteractive?: boolean
silent?: string
verbose?: boolean
}
Expand Down Expand Up @@ -245,6 +247,12 @@ export default class BaseCommand extends Command {
const commandName = name || ''
const base = new BaseCommand(commandName)
.addOption(new Option('--silent', 'Silence CLI output').hideHelp(true))
.addOption(
new Option(
'--non-interactive',
'Never open prompts; fail with exit code 4 when input would be required',
).hideHelp(true),
)
.addOption(new Option('--cwd <cwd>').hideHelp(true))
.addOption(
new Option('--auth <token>', 'Netlify auth token - can be used to run this command without logging in'),
Expand Down Expand Up @@ -480,12 +488,13 @@ export default class BaseCommand extends Command {
return token
}
if (!isInteractive()) {
return logAndThrowError(
`Authentication required. NETLIFY_AUTH_TOKEN is not set and ${chalk.cyanBright(
'`netlify status`',
)} also informs us that you need to use ${chalk.cyanBright(
return failOnNonInteractivePrompt(
'Logging in to your Netlify account',
`Authentication required. Set the ${chalk.cyanBright(
'NETLIFY_AUTH_TOKEN',
)} environment variable or pass ${chalk.cyanBright('--auth <token>')}, or use ${chalk.cyanBright(
'`netlify login --request <message>`',
)} as a next step.`,
)} to ask a human for credentials.`,
)
}
const accessToken = await this.expensivelyAuthenticate()
Expand Down Expand Up @@ -884,4 +893,4 @@ export default class BaseCommand extends Command {
}

export const getBaseOptionValues = (options: OptionValues): BaseOptionValues =>
pick(options, ['auth', 'cwd', 'debug', 'filter', 'httpProxy', 'silent', 'verbose'])
pick(options, ['auth', 'cwd', 'debug', 'filter', 'httpProxy', 'nonInteractive', 'silent', 'verbose'])
9 changes: 6 additions & 3 deletions src/commands/link/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { startSpinner } from '../../lib/spinner.js'
import { chalk, logAndThrowError, exit, log, APIError, netlifyCommand } from '../../utils/command-helpers.js'
import { ensureNetlifyIgnore } from '../../utils/gitignore.js'
import getRepoData from '../../utils/get-repo-data.js'
import { isInteractive } from '../../utils/scripted-commands.js'
import { failOnNonInteractivePrompt, isInteractive } from '../../utils/scripted-commands.js'
import { track } from '../../utils/telemetry/index.js'
import type { SiteInfo } from '../../utils/types.js'
import BaseCommand from '../base-command.js'
Expand Down Expand Up @@ -371,7 +371,9 @@ To link by project ID:
})
} else {
if (!isInteractive()) {
return logAndThrowError(`No project specified. In non-interactive mode, you must specify how to link:
return failOnNonInteractivePrompt(
'How do you want to link this folder to a project?',
`No project specified. In non-interactive mode, you must specify how to link:

Link by project ID:
${chalk.cyanBright(`${netlifyCommand()} link --id <project-id>`)}
Expand All @@ -386,7 +388,8 @@ To search for projects:
${chalk.cyanBright(`${netlifyCommand()} sites:search <search-term>`)}

To list all projects:
${chalk.cyanBright(`${netlifyCommand()} sites:list`)}`)
${chalk.cyanBright(`${netlifyCommand()} sites:list`)}`,
)
}

newSiteData = await linkPrompt(command, options)
Expand Down
20 changes: 12 additions & 8 deletions src/commands/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
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 Down
10 changes: 10 additions & 0 deletions src/utils/command-error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
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
28 changes: 25 additions & 3 deletions src/utils/scripted-commands.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import process from 'process'
import { isCI } from 'ci-info'

import { BANG, chalk, exit } from './command-helpers.js'
import { EXIT_CODES } from './exit-codes.js'

export const NON_INTERACTIVE_FLAG = '--non-interactive'

const hasNonInteractiveFlag = (argv: string[] = process.argv): boolean => argv.includes(NON_INTERACTIVE_FLAG)

export const shouldForceFlagBeInjected = (argv: string[]): boolean => {
// Is the command run in a non-interactive shell or CI/CD environment?
const scriptedCommand = Boolean(!process.stdin.isTTY || isCI || process.env.CI)
// Is the command run in a non-interactive shell, CI/CD environment or with --non-interactive?
const scriptedCommand = Boolean(!process.stdin.isTTY || isCI || process.env.CI || hasNonInteractiveFlag(argv))

// Is the `--force` flag not already present?
const noForceFlag = !argv.includes('--force')
Expand All @@ -16,10 +23,25 @@ export const shouldForceFlagBeInjected = (argv: string[]): boolean => {
}

export const isInteractive = (): boolean =>
Boolean(process.stdin.isTTY && process.stdout.isTTY && !isCI && !process.env.CI)
Boolean(process.stdin.isTTY && process.stdout.isTTY && !isCI && !process.env.CI && !hasNonInteractiveFlag())

export const injectForceFlagIfScripted = (argv: string[]) => {
if (shouldForceFlagBeInjected(argv)) {
argv.push('--force')
}
}

/**
* Fails fast (exit code 4, `EXIT_CODES.NON_INTERACTIVE_PROMPT`) when an interactive
* prompt would have fired in a non-interactive session (CI or `--non-interactive`),
* naming the prompt and the flag/env var that would supply the answer.
*/
export const failOnNonInteractivePrompt = (promptName: string, remediation: string): never => {
const bang = chalk.red(BANG)
process.stderr.write(
` ${bang} Error: Cannot prompt for input in non-interactive mode (CI or ${NON_INTERACTIVE_FLAG}).\n`,
)
process.stderr.write(` ${bang} Prompt: ${promptName}\n`)
process.stderr.write(`${remediation}\n`)
return exit(EXIT_CODES.NON_INTERACTIVE_PROMPT)
}
4 changes: 2 additions & 2 deletions tests/integration/commands/dev/dev-miscellaneous.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1459,10 +1459,10 @@ describe.concurrent('commands/dev-miscellaneous', () => {

expect(normalize(err.stderr, { duration: true, filePath: true })).toEqual(
expect.stringContaining(
'Projects detected: package1, package2. Configure the project you want to work with and try again. Refer to https://ntl.fyi/configure-site for more information.',
'Projects detected: package1, package2. Pass --filter <app> or --cwd <path> to configure the project you want to work with and try again. Refer to https://ntl.fyi/configure-site for more information.',
),
)
expect(err.exitCode).toBe(1)
expect(err.exitCode).toBe(4)
} finally {
expect.assertions(2)
}
Expand Down
Loading
Loading