diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 11f10bf859b..381153024b7 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -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' @@ -137,13 +137,14 @@ async function selectWorkspace(project: Project, filter?: string): Promise 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 ')} or ${chalk.cyanBright( + '--cwd ', + )} to configure the project you want to work with and try again. Refer to https://ntl.fyi/configure-site for more information.`, ) } @@ -182,6 +183,7 @@ export type BaseOptionValues = { debug?: boolean filter?: string httpProxy?: string + nonInteractive?: boolean silent?: string verbose?: boolean } @@ -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 ').hideHelp(true)) .addOption( new Option('--auth ', 'Netlify auth token - can be used to run this command without logging in'), @@ -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 ')}, or use ${chalk.cyanBright( '`netlify login --request `', - )} as a next step.`, + )} to ask a human for credentials.`, ) } const accessToken = await this.expensivelyAuthenticate() @@ -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']) diff --git a/src/commands/link/link.ts b/src/commands/link/link.ts index eba5a942202..04129a90dcf 100644 --- a/src/commands/link/link.ts +++ b/src/commands/link/link.ts @@ -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' @@ -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 `)} @@ -386,7 +388,8 @@ To search for projects: ${chalk.cyanBright(`${netlifyCommand()} sites:search `)} To list all projects: - ${chalk.cyanBright(`${netlifyCommand()} sites:list`)}`) + ${chalk.cyanBright(`${netlifyCommand()} sites:list`)}`, + ) } newSiteData = await linkPrompt(command, options) 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/src/utils/scripted-commands.ts b/src/utils/scripted-commands.ts index 84e05a64a43..6cc6ac2719c 100644 --- a/src/utils/scripted-commands.ts +++ b/src/utils/scripted-commands.ts @@ -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') @@ -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) +} diff --git a/tests/integration/commands/dev/dev-miscellaneous.test.ts b/tests/integration/commands/dev/dev-miscellaneous.test.ts index 709645b353c..2296b552844 100644 --- a/tests/integration/commands/dev/dev-miscellaneous.test.ts +++ b/tests/integration/commands/dev/dev-miscellaneous.test.ts @@ -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 or --cwd 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) } 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) + }) +}) diff --git a/tests/unit/utils/scripted-commands.test.ts b/tests/unit/utils/scripted-commands.test.ts index 0b2a5fed09e..c6fff72aa4c 100644 --- a/tests/unit/utils/scripted-commands.test.ts +++ b/tests/unit/utils/scripted-commands.test.ts @@ -47,4 +47,58 @@ describe('isInteractive', () => { Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true }) } }) + + test('should return false when --non-interactive is passed', async () => { + const originalArgv = process.argv + process.argv = [...originalArgv, '--non-interactive'] + try { + const isInteractive = await loadModule() + expect(isInteractive()).toBe(false) + } finally { + process.argv = originalArgv + } + }) +}) + +describe('shouldForceFlagBeInjected', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + }) + + test('treats --non-interactive like CI and injects --force', async () => { + const { shouldForceFlagBeInjected } = await import('../../../src/utils/scripted-commands.js') + expect(shouldForceFlagBeInjected(['node', 'netlify', 'env:set', '--non-interactive'])).toBe(true) + }) + + test('does not inject --force twice', async () => { + const { shouldForceFlagBeInjected } = await import('../../../src/utils/scripted-commands.js') + expect(shouldForceFlagBeInjected(['node', 'netlify', 'env:set', '--non-interactive', '--force'])).toBe(false) + }) +}) + +describe('failOnNonInteractivePrompt', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + }) + + test('exits with code 4 and names the prompt and the remediation on stderr', async () => { + const { failOnNonInteractivePrompt } = await import('../../../src/utils/scripted-commands.js') + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${String(code ?? 0)})`) + }) as never) + + expect(() => { + failOnNonInteractivePrompt('Which project do you want to link?', 'Pass --id or --name .') + }).toThrow('process.exit(4)') + + expect(exitSpy).toHaveBeenCalledWith(4) + const output = stderrSpy.mock.calls.map(([chunk]) => String(chunk)).join('') + expect(output).toContain('non-interactive mode') + expect(output).toContain('--non-interactive') + expect(output).toContain('Which project do you want to link?') + expect(output).toContain('Pass --id or --name .') + }) })