diff --git a/docs/commands/capabilities.md b/docs/commands/capabilities.md new file mode 100644 index 00000000000..5e75602dc06 --- /dev/null +++ b/docs/commands/capabilities.md @@ -0,0 +1,35 @@ +--- +title: Netlify CLI capabilities command +sidebar: + label: capabilities +--- + +# `capabilities` + +The `capabilities` command prints a machine-readable JSON manifest describing the CLI itself: every command and its flags, which commands support `--json`, the exit-code dictionary, relevant environment variables, and config file locations. It is intended for scripts and AI agents that need to discover the CLI's surface without scraping `--help` output. + + +Print a machine-readable manifest of every command, its flags, exit codes, env vars, and config files +Intended for scripts and AI agents. Output is always JSON on stdout. + +**Usage** + +```bash +netlify capabilities +``` + +**Flags** + +- `json` (*boolean*) - Output capabilities as JSON (the default; this command always outputs JSON) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify capabilities +netlify capabilities --json +``` + + + diff --git a/docs/index.md b/docs/index.md index 2a0899110d5..23c31e4771f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,6 +50,10 @@ Manage objects in Netlify Blobs Build on your local machine +### [capabilities](/commands/capabilities) + +Print a machine-readable manifest of every command, its flags, exit codes, env vars, and config files + ### [claim](/commands/claim) Claim an anonymously deployed site and link it to your account diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index b139a133119..ccf8b1b1711 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -5,7 +5,7 @@ import requiresSiteInfoWithProject from '../../utils/hooks/requires-site-info-wi import type BaseCommand from '../base-command.js' const agents = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } export const createAgentsCommand = (program: BaseCommand) => { diff --git a/src/commands/api/api.ts b/src/commands/api/api.ts index 370c3a3730c..8696cee9fc1 100644 --- a/src/commands/api/api.ts +++ b/src/commands/api/api.ts @@ -40,7 +40,21 @@ export const apiCommand = async (apiMethodName: string, options: OptionValues, c let payload if (options.data) { - payload = typeof options.data === 'string' ? JSON.parse(options.data) : options.data + if (typeof options.data === 'string') { + try { + payload = JSON.parse(options.data) + } catch { + const received = options.data.length > 80 ? `${options.data.slice(0, 80)}…` : options.data + return logAndThrowError( + `Invalid JSON provided to the ${chalk.cyanBright('--data')} flag. +Received: ${received} +The --data flag expects a JSON object of API parameters, e.g. --data '{"site_id":"123456"}'. +Note: key=value pairs are not accepted; use JSON syntax instead.`, + ) + } + } else { + payload = options.data + } } else { payload = {} } @@ -48,6 +62,17 @@ export const apiCommand = async (apiMethodName: string, options: OptionValues, c const apiResponse = await apiMethod(payload) logJson(apiResponse) } catch (error_) { + if (error_ instanceof Error && error_.message.includes('Missing required path variable')) { + const apiMethods = methods as { operationId: string; parameters: { path?: Record } }[] + const pathVariables = apiMethods.find((method) => method.operationId === apiMethodName)?.parameters.path ?? {} + const requiredNames = Object.keys(pathVariables).join(', ') + return logAndThrowError( + `${error_.message} +The ${chalk.cyanBright('--data')} flag must include the path variable(s) required by ${apiMethodName}${ + requiredNames ? `: ${requiredNames}` : '' + }, e.g. --data '{"site_id":"123456"}'`, + ) + } return logAndThrowError(error_) } } diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index d3b40a27b6e..26705ccd51b 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -11,6 +11,7 @@ import { getGlobalConfigStore, LocalState } from '@netlify/dev-utils' import { isCI } from 'ci-info' import { Command, CommanderError, Help, Option, type OptionValues } from 'commander' import debug from 'debug' +import { closest, distance } from 'fastest-levenshtein' import { findUp } from 'find-up' import inquirer from 'inquirer' import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt' @@ -19,6 +20,7 @@ import pick from 'lodash/pick.js' import { getAgent } from '../lib/http-agent.js' import { + BANG, NETLIFY_CYAN, USER_AGENT, chalk, @@ -35,12 +37,14 @@ 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 { guardGlobalConfigFile, guardLocalStateFile } from '../utils/config-guard.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' 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, track } from '../utils/telemetry/index.js' import type { NetlifyOptions } from './types.js' import type { CachedConfig } from '../lib/build.js' @@ -74,6 +78,7 @@ const HELP_SEPARATOR_WIDTH = 5 */ const COMMANDS_WITHOUT_WORKSPACE_OPTIONS = new Set([ 'api', + 'capabilities', 'recipes', 'completion', 'status', @@ -138,13 +143,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.`, ) } @@ -183,6 +189,7 @@ export type BaseOptionValues = { debug?: boolean filter?: string httpProxy?: string + nonInteractive?: boolean silent?: string verbose?: boolean } @@ -246,6 +253,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'), @@ -290,6 +303,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) } @@ -304,12 +318,56 @@ export default class BaseCommand extends Command { } #noBaseOptions = false + + get noBaseOptions(): boolean { + return this.#noBaseOptions + } + /** don't show help options on command overview (mostly used on top commands like `addons` where options only apply on children) */ noHelpOptions() { this.#noBaseOptions = true 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 */ @@ -353,7 +411,6 @@ export default class BaseCommand extends Command { /** override the longestOptionTermLength to react on hide options flag */ help.longestOptionTermLength = (command: BaseCommand, helper: Help): number => - // @ts-expect-error TS(2551) FIXME: Property 'noBaseOptions' does not exist on type 'C... Remove this comment to see the full error message (command.noBaseOptions === false && helper.visibleOptions(command).reduce((max, option) => Math.max(max, helper.optionTerm(option).length), 0)) || 0 @@ -367,9 +424,9 @@ export default class BaseCommand extends Command { const bang = isCommand ? `${HELP_$} ` : '' if (description) { - const pad = termWidth + HELP_SEPARATOR_WIDTH - const fullText = `${bang}${term.padEnd(pad - (isCommand ? 2 : 0))}${chalk.grey(description)}` - return helper.wrap(fullText, helpWidth - HELP_INDENT_WIDTH, pad) + const pad = Math.max(termWidth + HELP_SEPARATOR_WIDTH - (isCommand ? 2 : 0), term.length + 2) + const fullText = `${bang}${term.padEnd(pad)}${chalk.grey(description)}` + return helper.wrap(fullText, helpWidth - HELP_INDENT_WIDTH, pad + (isCommand ? 2 : 0)) } return `${bang}${term}` @@ -480,12 +537,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() @@ -653,6 +711,8 @@ export default class BaseCommand extends Command { // ================================================== // Retrieve Site id and build state from the state.json // ================================================== + guardGlobalConfigFile() + await guardLocalStateFile(this.workingDir) const state = new LocalState(this.workingDir) const [token] = await getToken(flags.auth) @@ -884,4 +944,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/blobs/blobs.ts b/src/commands/blobs/blobs.ts index 62b77fd203c..08ab2cefb05 100644 --- a/src/commands/blobs/blobs.ts +++ b/src/commands/blobs/blobs.ts @@ -8,7 +8,7 @@ import BaseCommand from '../base-command.js' * The blobs command */ const blobs = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } /** diff --git a/src/commands/capabilities/capabilities.ts b/src/commands/capabilities/capabilities.ts new file mode 100644 index 00000000000..44a23f35f5f --- /dev/null +++ b/src/commands/capabilities/capabilities.ts @@ -0,0 +1,147 @@ +import type { Command, Option, OptionValues } from 'commander' + +import { getPathInHome } from '../../lib/settings.js' +import { exit } from '../../utils/command-helpers.js' +import { EXIT_CODES } from '../../utils/exit-codes.js' +import getCLIPackageJson from '../../utils/get-cli-package-json.js' +import type BaseCommand from '../base-command.js' + +const CONTRACT_VERSION = '1' + +const GLOBAL_FLAG_THRESHOLD = 0.8 + +const EXIT_CODE_DESCRIPTIONS: Record = { + [String(EXIT_CODES.SUCCESS)]: 'success', + [String(EXIT_CODES.GENERAL_ERROR)]: 'general error', + [String(EXIT_CODES.USAGE_ERROR)]: 'usage error', + [String(EXIT_CODES.NON_INTERACTIVE_PROMPT)]: 'non-interactive prompt blocked', +} + +const ENV_VARS = [ + { name: 'CI', description: 'When set, forces non-interactive mode (no prompts)' }, + { name: 'CONTEXT', description: 'Deploy context used when resolving environment variables (e.g. dev, production)' }, + { name: 'NETLIFY_AUTH_TOKEN', description: 'Auth token (alternative to netlify login)' }, + { name: 'NETLIFY_SITE_ID', description: 'Default site id (alternative to netlify link)' }, + { name: 'NO_COLOR', description: 'Disable colors in output' }, +] + +interface FlagManifest { + name: string + type: 'boolean' | 'string' + description: string +} + +interface CommandManifest { + name: string + description: string + flags: FlagManifest[] + json_output: boolean + mutates: null +} + +// Deliberately not localeCompare: the manifest must be byte-for-byte identical regardless of the user's locale/ICU +const byName = (left: { name: string }, right: { name: string }) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0 + +const toFlagManifest = (option: Option): FlagManifest => ({ + name: option.long ?? option.flags, + type: option.required || option.optional ? 'string' : 'boolean', + description: option.description, +}) + +const collectCommands = (root: Command): Command[] => { + const collected: Command[] = [] + const walk = (commands: readonly Command[]) => { + commands.forEach((cmd) => { + collected.push(cmd) + walk(cmd.commands) + }) + } + walk(root.commands) + return collected +} + +const getFullCommandName = (cmd: Command): string => { + const names: string[] = [] + let current = cmd + while (current.parent !== null) { + names.unshift(current.name()) + current = current.parent + } + return names.join(' ') +} + +const toCommandManifest = (cmd: Command): CommandManifest => ({ + name: getFullCommandName(cmd), + description: cmd.description(), + flags: cmd.options.map(toFlagManifest).sort(byName), + json_output: cmd.options.some((option) => option.long === '--json'), + mutates: null, +}) + +const dedupeByName = (manifests: CommandManifest[]): CommandManifest[] => { + const seen = new Map() + manifests.forEach((manifest) => { + if (!seen.has(manifest.name)) { + seen.set(manifest.name, manifest) + } + }) + return [...seen.values()] +} + +const getGlobalFlags = (commands: Command[]): FlagManifest[] => { + const occurrences = new Map() + commands.forEach((cmd) => { + cmd.options.forEach((option) => { + if (!option.long) return + const seen = occurrences.get(option.long) + if (seen) { + seen.count += 1 + } else { + occurrences.set(option.long, { count: 1, option }) + } + }) + }) + + const globalFlags = [...occurrences.values()] + .filter(({ count }) => count >= commands.length * GLOBAL_FLAG_THRESHOLD) + .map(({ option }) => toFlagManifest(option)) + + globalFlags.push({ name: '--help', type: 'boolean', description: 'Display help for command' }) + + return globalFlags.sort(byName) +} + +export const buildCapabilitiesManifest = async (program: Command) => { + const { version } = await getCLIPackageJson() + const commands = collectCommands(program) + + return { + contract_version: CONTRACT_VERSION, + cli_version: version, + commands: dedupeByName(commands.map(toCommandManifest)).sort(byName), + global_flags: getGlobalFlags(commands), + exit_codes: EXIT_CODE_DESCRIPTIONS, + env_vars: ENV_VARS, + config_files: [ + { path: 'netlify.toml', scope: 'project' }, + { path: '.netlify/state.json', scope: 'project-state' }, + { path: getPathInHome(['config.json']), scope: 'global' }, + ], + } +} + +export const capabilities = async (_options: OptionValues, command: BaseCommand) => { + let root: Command = command + while (root.parent) { + root = root.parent + } + const manifest = await buildCapabilitiesManifest(root) + process.stdout.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EPIPE') { + exit(EXIT_CODES.SUCCESS) + } + throw error + }) + process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`) +} diff --git a/src/commands/capabilities/index.ts b/src/commands/capabilities/index.ts new file mode 100644 index 00000000000..80355d5de96 --- /dev/null +++ b/src/commands/capabilities/index.ts @@ -0,0 +1,17 @@ +import { OptionValues } from 'commander' + +import BaseCommand from '../base-command.js' + +export const createCapabilitiesCommand = (program: BaseCommand) => { + program + .command('capabilities') + .description( + 'Print a machine-readable manifest of every command, its flags, exit codes, env vars, and config files\nIntended for scripts and AI agents. Output is always JSON on stdout.', + ) + .option('--json', 'Output capabilities as JSON (the default; this command always outputs JSON)') + .addExamples(['netlify capabilities', 'netlify capabilities --json']) + .action(async (options: OptionValues, command: BaseCommand) => { + const { capabilities } = await import('./capabilities.js') + await capabilities(options, command) + }) +} diff --git a/src/commands/completion/index.ts b/src/commands/completion/index.ts index 367e1823cc5..3d286bf970e 100644 --- a/src/commands/completion/index.ts +++ b/src/commands/completion/index.ts @@ -27,6 +27,6 @@ export const createCompletionCommand = (program: BaseCommand) => { .description('Generate shell completion script\nRun this command to see instructions for your shell.') .addExamples(['netlify completion:install']) .action((_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() }) } diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 742f7f198fe..86d6d7b9407 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -37,6 +37,7 @@ import { getToken, log, logJson, + prettyJsonRenderOptions, warn, type APIError, } from '../../utils/command-helpers.js' @@ -898,7 +899,7 @@ const printResults = ({ }), ) - log(prettyjson.render(msgData)) + log(prettyjson.render(msgData, prettyJsonRenderOptions())) if (!deployToProduction) { log() @@ -957,11 +958,14 @@ const prepAndRunDeploy = async ({ log('') log( - prettyjson.render({ - 'Deploy path': deployFolder, - 'Functions path': functionsFolder, - 'Configuration path': configPath, - }), + prettyjson.render( + { + 'Deploy path': deployFolder, + 'Functions path': functionsFolder, + 'Configuration path': configPath, + }, + prettyJsonRenderOptions(), + ), ) log() diff --git a/src/commands/env/env-clone.ts b/src/commands/env/env-clone.ts index 50b661aa141..c1d5ccc0997 100644 --- a/src/commands/env/env-clone.ts +++ b/src/commands/env/env-clone.ts @@ -3,6 +3,7 @@ import { OptionValues } from 'commander' import { chalk, log, logAndThrowError } from '../../utils/command-helpers.js' import { promptEnvCloneOverwrite } from '../../utils/prompts/env-clone-prompt.js' import BaseCommand from '../base-command.js' +import { failNotLinked } from './utils.js' // @ts-expect-error TS(7006) FIXME: Parameter 'api' implicitly has an 'any' type. const safeGetSite = async (api, siteId) => { @@ -60,10 +61,10 @@ export const envClone = async (options: OptionValues, command: BaseCommand) => { const { force } = options if (!site.id && !options.from) { - log( + return failNotLinked( + options, 'Please include the source project ID as the `--from` option, or run `netlify link` to link this folder to a Netlify project', ) - return false } const sourceId = options.from || site.id diff --git a/src/commands/env/env-get.ts b/src/commands/env/env-get.ts index 4ca9e8e179b..9afb02a2e7f 100644 --- a/src/commands/env/env-get.ts +++ b/src/commands/env/env-get.ts @@ -3,16 +3,15 @@ import { OptionValues } from 'commander' import { chalk, log, logJson } from '../../utils/command-helpers.js' import { SUPPORTED_CONTEXTS, getEnvelopeEnv } from '../../utils/env/index.js' import BaseCommand from '../base-command.js' -import { getSiteInfo } from './utils.js' +import { failNotLinked, getEnvSiteId, getSiteInfo } from './utils.js' export const envGet = async (name: string, options: OptionValues, command: BaseCommand) => { const { context, scope } = options - const { api, cachedConfig, site } = command.netlify - const siteId = site.id + const { api, cachedConfig } = command.netlify + const siteId = getEnvSiteId(options, command) if (!siteId) { - log('No project id found, please run inside a project folder or `netlify link`') - return false + return failNotLinked(options) } const siteInfo = await getSiteInfo(api, siteId, cachedConfig) diff --git a/src/commands/env/env-import.ts b/src/commands/env/env-import.ts index 1a14da1d555..7c190a472d4 100644 --- a/src/commands/env/env-import.ts +++ b/src/commands/env/env-import.ts @@ -7,7 +7,7 @@ import dotenv from 'dotenv' import { exit, log, logJson } from '../../utils/command-helpers.js' import { translateFromEnvelopeToMongo, translateFromMongoToEnvelope } from '../../utils/env/index.js' import BaseCommand from '../base-command.js' -import { getSiteInfo } from './utils.js' +import { failNotLinked, getEnvSiteId, getSiteInfo } from './utils.js' /** * Saves the imported env in the Envelope service @@ -51,12 +51,11 @@ const importDotEnv = async ({ api, importedEnv, options, siteInfo }) => { } export const envImport = async (fileName: string, options: OptionValues, command: BaseCommand) => { - const { api, cachedConfig, site } = command.netlify - const siteId = site.id + const { api, cachedConfig } = command.netlify + const siteId = getEnvSiteId(options, command) if (!siteId) { - log('No project id found, please run inside a project folder or `netlify link`') - return false + return failNotLinked(options) } const siteInfo = await getSiteInfo(api, siteId, cachedConfig) diff --git a/src/commands/env/env-list.ts b/src/commands/env/env-list.ts index b96412b36f7..1390beba397 100644 --- a/src/commands/env/env-list.ts +++ b/src/commands/env/env-list.ts @@ -9,7 +9,7 @@ import { chalk, log, logJson } from '../../utils/command-helpers.js' import { SUPPORTED_CONTEXTS, getEnvelopeEnv, getHumanReadableScopes } from '../../utils/env/index.js' import type BaseCommand from '../base-command.js' import { EnvironmentVariables } from '../../utils/types.js' -import { getSiteInfo } from './utils.js' +import { failNotLinked, getEnvSiteId, getSiteInfo } from './utils.js' const MASK_LENGTH = 50 const MASK = '*'.repeat(MASK_LENGTH) @@ -43,12 +43,11 @@ const getTable = ({ export const envList = async (options: OptionValues, command: BaseCommand) => { const { context, scope } = options - const { api, cachedConfig, site } = command.netlify - const siteId = site.id + const { api, cachedConfig } = command.netlify + const siteId = getEnvSiteId(options, command) if (!siteId) { - log('No project id found, please run inside a project folder or `netlify link`') - return false + return failNotLinked(options) } const siteInfo = await getSiteInfo(api, siteId, cachedConfig) diff --git a/src/commands/env/env-set.ts b/src/commands/env/env-set.ts index d1eac1b1e84..b105d969752 100644 --- a/src/commands/env/env-set.ts +++ b/src/commands/env/env-set.ts @@ -4,7 +4,7 @@ import { chalk, logAndThrowError, log, logJson } from '../../utils/command-helpe import { SUPPORTED_CONTEXTS, ALL_ENVELOPE_SCOPES, translateFromEnvelopeToMongo } from '../../utils/env/index.js' import { promptOverwriteEnvVariable } from '../../utils/prompts/env-set-prompts.js' import BaseCommand from '../base-command.js' -import { getSiteInfo } from './utils.js' +import { failNotLinked, getEnvSiteId, getSiteInfo } from './utils.js' /** * Updates the env for a site configured with Envelope with a new key/value pair @@ -110,11 +110,10 @@ const setInEnvelope = async ({ api, context, force, key, scope, secret, siteInfo export const envSet = async (key: string, value: string, options: OptionValues, command: BaseCommand) => { const { context, force, scope, secret } = options - const { api, cachedConfig, site } = command.netlify - const siteId = site.id + const { api, cachedConfig } = command.netlify + const siteId = getEnvSiteId(options, command) if (!siteId) { - log('No project id found, please run inside a project folder or `netlify link`') - return false + return failNotLinked(options) } const siteInfo = await getSiteInfo(api, siteId, cachedConfig) diff --git a/src/commands/env/env-unset.ts b/src/commands/env/env-unset.ts index 3e3baebf8e5..325d30c1f99 100644 --- a/src/commands/env/env-unset.ts +++ b/src/commands/env/env-unset.ts @@ -4,7 +4,7 @@ import { chalk, log, logJson } from '../../utils/command-helpers.js' import { SUPPORTED_CONTEXTS, translateFromEnvelopeToMongo } from '../../utils/env/index.js' import { promptOverwriteEnvVariable } from '../../utils/prompts/env-unset-prompts.js' import BaseCommand from '../base-command.js' -import { getSiteInfo } from './utils.js' +import { failNotLinked, getEnvSiteId, getSiteInfo } from './utils.js' /** * Deletes a given key from the env of a site configured with Envelope * @returns {Promise} @@ -70,12 +70,11 @@ const unsetInEnvelope = async ({ api, context, force, key, siteInfo }) => { export const envUnset = async (key: string, options: OptionValues, command: BaseCommand) => { const { context, force } = options - const { api, cachedConfig, site } = command.netlify - const siteId = site.id + const { api, cachedConfig } = command.netlify + const siteId = getEnvSiteId(options, command) if (!siteId) { - log('No project id found, please run inside a project folder or `netlify link`') - return false + return failNotLinked(options) } const siteInfo = await getSiteInfo(api, siteId, cachedConfig) diff --git a/src/commands/env/env.ts b/src/commands/env/env.ts index 5f733196241..ca015c65119 100644 --- a/src/commands/env/env.ts +++ b/src/commands/env/env.ts @@ -5,7 +5,7 @@ import { normalizeContext } from '../../utils/env/index.js' import BaseCommand from '../base-command.js' const env = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } export const createEnvCommand = (program: BaseCommand) => { diff --git a/src/commands/env/utils.ts b/src/commands/env/utils.ts index bae690a5581..6512afbe9e4 100644 --- a/src/commands/env/utils.ts +++ b/src/commands/env/utils.ts @@ -1,7 +1,10 @@ import type { NetlifyAPI } from '@netlify/api' +import type { OptionValues } from 'commander' import type { CachedConfig } from '../../lib/build.js' +import { exit, logJson } from '../../utils/command-helpers.js' import type { SiteInfo } from '../../utils/types.js' +import type BaseCommand from '../base-command.js' export const getSiteInfo = async (api: NetlifyAPI, siteId: string, cachedConfig: CachedConfig): Promise => { const { siteInfo: cachedSiteInfo } = cachedConfig @@ -10,3 +13,18 @@ export const getSiteInfo = async (api: NetlifyAPI, siteId: string, cachedConfig: } return cachedSiteInfo } + +export const getEnvSiteId = (options: OptionValues, command: BaseCommand): string | undefined => + options.site ? command.netlify.siteInfo.id : command.netlify.site.id + +export const failNotLinked = ( + options: OptionValues, + message = 'No project id found, please run inside a project folder or `netlify link`', +): never => { + if (options.json) { + logJson({ error: { code: 'NOT_LINKED', message, fix: 'netlify link' } }) + } else { + process.stderr.write(`${message}\n`) + } + return exit(1) +} diff --git a/src/commands/functions/functions.ts b/src/commands/functions/functions.ts index e151f704126..53ea75d8b70 100644 --- a/src/commands/functions/functions.ts +++ b/src/commands/functions/functions.ts @@ -6,7 +6,7 @@ import requiresSiteInfo from '../../utils/hooks/requires-site-info.js' import type BaseCommand from '../base-command.js' const functions = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } export const createFunctionsCommand = (program: BaseCommand) => { diff --git a/src/commands/link/link.ts b/src/commands/link/link.ts index 97c45c7f62f..5ff7d3b6715 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..9073af0acb4 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -15,13 +15,14 @@ import { log, NETLIFY_CYAN, USER_AGENT, - warn, logError, } from '../utils/command-helpers.js' +import { guardGlobalConfigFile } from '../utils/config-guard.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' @@ -31,6 +32,7 @@ import BaseCommand from './base-command.js' import { createClaimCommand } from './claim/index.js' import { createBlobsCommand } from './blobs/blobs.js' import { createBuildCommand } from './build/index.js' +import { createCapabilitiesCommand } from './capabilities/index.js' import { createCloneCommand } from './clone/index.js' import { createCreateCommand } from './create/index.js' import { createCompletionCommand } from './completion/index.js' @@ -155,6 +157,7 @@ ${USER_AGENT} */ // @ts-expect-error TS(7006) FIXME: Parameter 'options' implicitly has an 'any' type. const mainCommand = async function (options, command) { + guardGlobalConfigFile() const globalConfig = await getGlobalConfigStore() if (options.telemetryDisable) { @@ -197,21 +200,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 +242,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' }) @@ -253,6 +258,7 @@ export const createMainCommand = (): BaseCommand => { createApiCommand(program) createBlobsCommand(program) createBuildCommand(program) + createCapabilitiesCommand(program) createClaimCommand(program) createCompletionCommand(program) createDeployCommand(program) @@ -299,6 +305,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 (see ${NETLIFY_CYAN("'netlify capabilities --json'")}) + → For more help with the CLI, visit ${NETLIFY_CYAN( terminalLink(cliDocsEntrypointUrl, cliDocsEntrypointUrl, { fallback: false }), )} @@ -312,6 +320,7 @@ To ask a human for credentials: ${NETLIFY_CYAN('netlify login --request ')} }, }) .exitOverride(function (this: BaseCommand, error: CommanderError) { + suggestUnknownOptionAlternatives(this, error) if (isOptionError(error)) { handleOptionError(this) } diff --git a/src/commands/open/index.ts b/src/commands/open/index.ts index 2fcdfe0134b..ea60d514b71 100644 --- a/src/commands/open/index.ts +++ b/src/commands/open/index.ts @@ -31,6 +31,7 @@ export const createOpenCommand = (program: BaseCommand) => { .option('--admin', 'Open Netlify project') .addExamples(['netlify open --site', 'netlify open --admin', 'netlify open:admin', 'netlify open:site']) .action(async (options: OptionValues, command: BaseCommand) => { + command.rejectSpaceFormSubcommand() const { open } = await import('./open.js') await open(options, command) }) diff --git a/src/commands/sites/sites-create.ts b/src/commands/sites/sites-create.ts index 69e4352560f..e0c9698742e 100644 --- a/src/commands/sites/sites-create.ts +++ b/src/commands/sites/sites-create.ts @@ -3,7 +3,15 @@ import inquirer from 'inquirer' import pick from 'lodash/pick.js' import prettyjson from 'prettyjson' -import { chalk, logAndThrowError, log, logJson, warn, type APIError } from '../../utils/command-helpers.js' +import { + chalk, + logAndThrowError, + log, + logJson, + prettyJsonRenderOptions, + warn, + type APIError, +} from '../../utils/command-helpers.js' import getRepoData from '../../utils/get-repo-data.js' import { configureRepo } from '../../utils/init/config.js' import { isInteractive } from '../../utils/scripted-commands.js' @@ -158,11 +166,14 @@ export const sitesCreate = async (options: OptionValues, command: BaseCommand) = const siteUrl = site.ssl_url || site.url log( - prettyjson.render({ - 'Admin URL': site.admin_url, - URL: siteUrl, - 'Project ID': site.id, - }), + prettyjson.render( + { + 'Admin URL': site.admin_url, + URL: siteUrl, + 'Project ID': site.id, + }, + prettyJsonRenderOptions(), + ), ) track('sites_created', { diff --git a/src/commands/sites/sites.ts b/src/commands/sites/sites.ts index 7c7dcc6c0d0..51c01a73224 100644 --- a/src/commands/sites/sites.ts +++ b/src/commands/sites/sites.ts @@ -3,7 +3,7 @@ import BaseCommand from '../base-command.js' import { validateSiteName } from '../../utils/validation.js' const sites = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } export const createSitesCreateCommand = (program: BaseCommand) => { diff --git a/src/commands/status/status-hooks.ts b/src/commands/status/status-hooks.ts index 1034634ebd1..a1b5cd32914 100644 --- a/src/commands/status/status-hooks.ts +++ b/src/commands/status/status-hooks.ts @@ -1,7 +1,7 @@ import type { OptionValues } from 'commander' import prettyjson from 'prettyjson' -import { log } from '../../utils/command-helpers.js' +import { log, prettyJsonRenderOptions } from '../../utils/command-helpers.js' import type BaseCommand from '../base-command.js' interface StatusHook { @@ -40,5 +40,5 @@ export const statusHooks = async (_options: OptionValues, command: BaseCommand): log(`─────────────────┐ Project Hook Status │ ─────────────────┘`) - log(prettyjson.render(data)) + log(prettyjson.render(data, prettyJsonRenderOptions())) } diff --git a/src/commands/status/status.ts b/src/commands/status/status.ts index ed44723aaad..7eb1d17732d 100644 --- a/src/commands/status/status.ts +++ b/src/commands/status/status.ts @@ -9,18 +9,38 @@ import { getToken, log, logJson, + prettyJsonRenderOptions, warn, type APIError, } from '../../utils/command-helpers.js' import { isInteractive } from '../../utils/scripted-commands.js' import type BaseCommand from '../base-command.js' +// TODO(R-006): centralize these in the shared exit-code/error-code dictionary in src/utils/command-helpers.ts. +export const STATUS_ERROR_CODES = { + NOT_LOGGED_IN: 'NOT_LOGGED_IN', + NOT_LINKED: 'NOT_LINKED', +} as const + export const status = async (options: OptionValues, command: BaseCommand) => { const { accounts, api, globalConfig, site, siteInfo } = command.netlify const currentUserId = globalConfig.get('userId') as string | undefined const [accessToken] = await getToken() if (!accessToken) { + if (options.json) { + logJson({ + loggedIn: false, + linked: false, + account: null, + siteData: null, + error: { + code: STATUS_ERROR_CODES.NOT_LOGGED_IN, + fix: 'netlify login or NETLIFY_AUTH_TOKEN', + }, + }) + return exit(1) + } log(`Not logged in. Please log in to see project status.`) log() if (!isInteractive()) { @@ -43,6 +63,18 @@ export const status = async (options: OptionValues, command: BaseCommand) => { user = await api.getCurrentUser() } catch (error_) { if ((error_ as APIError).status === 401) { + if (options.json) { + logJson({ + loggedIn: false, + linked: false, + account: null, + siteData: null, + error: { + code: STATUS_ERROR_CODES.NOT_LOGGED_IN, + fix: 'netlify login or NETLIFY_AUTH_TOKEN', + }, + }) + } return logAndThrowError( 'Your session has expired. Please try to re-authenticate by running `netlify logout` and `netlify login`.', ) @@ -67,9 +99,21 @@ export const status = async (options: OptionValues, command: BaseCommand) => { // another lib. (clean as unknown as >(obj: T) => Partial)(accountData) - log(prettyjson.render(cleanAccountData)) + log(prettyjson.render(cleanAccountData, prettyJsonRenderOptions())) if (!siteId) { + if (options.json) { + logJson({ + loggedIn: true, + linked: false, + account: cleanAccountData, + siteData: null, + error: { + code: STATUS_ERROR_CODES.NOT_LINKED, + fix: 'netlify link', + }, + }) + } warn('Did you run `netlify link` yet?') return logAndThrowError(`You don't appear to be in a folder that is linked to a project`) } @@ -85,6 +129,9 @@ export const status = async (options: OptionValues, command: BaseCommand) => { 'site-url': siteInfo.ssl_url || siteInfo.url, 'site-id': siteInfo.id, }, + loggedIn: true, + linked: true, + error: null, }) } @@ -92,13 +139,16 @@ export const status = async (options: OptionValues, command: BaseCommand) => { Netlify Project Info │ ────────────────────┘`) log( - prettyjson.render({ - 'Current project': siteInfo.name, - 'Netlify TOML': site.configPath, - 'Admin URL': chalk.magentaBright(siteInfo.admin_url), - 'Project URL': chalk.cyanBright(siteInfo.ssl_url || siteInfo.url), - 'Project Id': chalk.yellowBright(siteInfo.id), - }), + prettyjson.render( + { + 'Current project': siteInfo.name, + 'Netlify TOML': site.configPath, + 'Admin URL': chalk.magentaBright(siteInfo.admin_url), + 'Project URL': chalk.cyanBright(siteInfo.ssl_url || siteInfo.url), + 'Project Id': chalk.yellowBright(siteInfo.id), + }, + prettyJsonRenderOptions(), + ), ) log() } diff --git a/src/commands/teams/teams.ts b/src/commands/teams/teams.ts index 82e2384f153..18e3647918d 100644 --- a/src/commands/teams/teams.ts +++ b/src/commands/teams/teams.ts @@ -3,7 +3,7 @@ import type { OptionValues } from 'commander' import type BaseCommand from '../base-command.js' const teams = (_options: OptionValues, command: BaseCommand) => { - command.help() + command.helpOrRejectExtraArgs() } export const createTeamsCommand = (program: BaseCommand) => { diff --git a/src/commands/watch/watch.ts b/src/commands/watch/watch.ts index da36642468b..1cfe9115baa 100644 --- a/src/commands/watch/watch.ts +++ b/src/commands/watch/watch.ts @@ -3,7 +3,7 @@ import pWaitFor from 'p-wait-for' import prettyjson from 'prettyjson' import { type Spinner, startSpinner, stopSpinner } from '../../lib/spinner.js' -import { chalk, logAndThrowError, log } from '../../utils/command-helpers.js' +import { chalk, logAndThrowError, log, prettyJsonRenderOptions } from '../../utils/command-helpers.js' import type BaseCommand from '../base-command.js' import { init } from '../init/init.js' @@ -98,10 +98,13 @@ export const watch = async (_options: unknown, command: BaseCommand) => { log() log(message) log( - prettyjson.render({ - URL: siteData.ssl_url || siteData.url, - Admin: siteData.admin_url, - }), + prettyjson.render( + { + URL: siteData.ssl_url || siteData.url, + Admin: siteData.admin_url, + }, + prettyJsonRenderOptions(), + ), ) console.timeEnd('Deploy time') } catch (error_) { diff --git a/src/utils/command-error-handler.ts b/src/utils/command-error-handler.ts index 7449d206634..006e83e5796 100644 --- a/src/utils/command-error-handler.ts +++ b/src/utils/command-error-handler.ts @@ -1,6 +1,7 @@ -import { CommanderError, type HelpContext } from 'commander' +import { CommanderError, type Command, type HelpContext } from 'commander' +import { distance } from 'fastest-levenshtein' -import { log } from './command-helpers.js' +import { BANG, chalk, log } from './command-helpers.js' import { isInteractive } from './scripted-commands.js' const OPTION_ERROR_CODES = new Set([ @@ -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() + for (const subcommand of command.commands) { + // @ts-expect-error TS(2551) FIXME: Property '_hidden' does not exist on type 'Command'. + if (subcommand._hidden) continue + for (const option of subcommand.options) { + if (option.hidden || !option.long) continue + const owners = flagOwners.get(option.long) ?? [] + if (!owners.includes(subcommand.name())) { + owners.push(subcommand.name()) + } + flagOwners.set(option.long, owners) + } + } + const candidates = [...flagOwners.keys()] + .filter(isCloseTo(unknownFlag)) + .sort(byClosestTo(unknownFlag)) + .slice(0, MAX_FLAG_SUGGESTIONS) + for (const flag of candidates) { + const owners = (flagOwners.get(flag) ?? []).sort((ownerA, ownerB) => ownerA.localeCompare(ownerB)) + const shownOwners = owners.slice(0, MAX_OWNING_COMMANDS).join(', ') + const ellipsis = owners.length > MAX_OWNING_COMMANDS ? ', ...' : '' + lines.push(`'${flag}' is a flag of: ${shownOwners}${ellipsis} (run 'netlify --help')`) + } + } + + return lines +} + +export const suggestUnknownOptionAlternatives = (command: Command, error: CommanderError): void => { + if (error.code !== 'commander.unknownOption') { + return + } + for (const line of getUnknownOptionSuggestions(command, error.message)) { + process.stderr.write(` ${chalk.red(BANG)} ${line}\n`) + } +} + export const isOptionError = (error: CommanderError): boolean => OPTION_ERROR_CODES.has(error.code) export const handleOptionError = (command: { outputHelp: (context?: HelpContext) => void }): void => { diff --git a/src/utils/command-helpers.ts b/src/utils/command-helpers.ts index 0775c79f230..2878571789e 100644 --- a/src/utils/command-helpers.ts +++ b/src/utils/command-helpers.ts @@ -33,7 +33,15 @@ const safeChalk = function (noColors: boolean) { return new Chalk() } -export const chalk = safeChalk(argv.includes('--json')) +/** Honors the NO_COLOR convention (https://no-color.org): any non-empty value disables colors */ +export const shouldDisableColors = (env: NodeJS.ProcessEnv = process.env): boolean => Boolean(env.NO_COLOR) + +export const chalk = safeChalk(argv.includes('--json') || shouldDisableColors()) + +/** Options for `prettyjson.render()` so human-formatted output honors NO_COLOR and --json like `chalk` does */ +export const prettyJsonRenderOptions = (): { noColor: boolean } => ({ + noColor: argv.includes('--json') || shouldDisableColors(), +}) export type ChalkInstance = ChalkInstancePrimitiveType diff --git a/src/utils/config-guard.ts b/src/utils/config-guard.ts new file mode 100644 index 00000000000..e90aad30a10 --- /dev/null +++ b/src/utils/config-guard.ts @@ -0,0 +1,64 @@ +import { copyFileSync, existsSync, readFileSync, statSync } from 'fs' +import { join } from 'path' + +import { findUp } from 'find-up' + +import { getPathInHome } from '../lib/settings.js' + +import { BANG, chalk } from './command-helpers.js' + +const STATE_RELATIVE_PATH = join('.netlify', 'state.json') + +const writeCorruptFileWarning = (filePath: string, backupPath: string, recoveryHint: string) => { + const bang = chalk.yellow(BANG) + process.stderr.write(` ${bang} Warning: ${filePath} contains malformed JSON and will be reset.\n`) + process.stderr.write(` ${bang} A backup of the corrupt file was saved to ${backupPath}.\n`) + process.stderr.write(` ${bang} Repair and restore the backup, or ${recoveryHint}.\n`) +} + +/** + * Detects an existing-but-unparseable JSON config file before the underlying store + * silently resets it, copies it to `.corrupt.` and warns on stderr. + * Returns the backup path when a corrupt file was found, `undefined` otherwise. + */ +export const backUpCorruptJsonFile = (filePath: string, recoveryHint: string): string | undefined => { + let raw: string + try { + raw = readFileSync(filePath, 'utf8') + } catch { + return undefined + } + if (raw.trim() === '') { + return undefined + } + try { + JSON.parse(raw) + return undefined + } catch { + const backupPath = `${filePath}.corrupt.${String(Math.round(statSync(filePath).mtimeMs))}` + if (!existsSync(backupPath)) { + copyFileSync(filePath, backupPath) + } + writeCorruptFileWarning(filePath, backupPath, recoveryHint) + return backupPath + } +} + +/** + * Guards the project-local `.netlify/state.json` (resolved with the same find-up + * semantics as `LocalState` in `@netlify/dev-utils`). + */ +export const guardLocalStateFile = async (workingDir: string): Promise => { + const statePath = (await findUp(STATE_RELATIVE_PATH, { cwd: workingDir })) ?? join(workingDir, STATE_RELATIVE_PATH) + return backUpCorruptJsonFile(statePath, `delete it and re-run ${chalk.cyanBright('netlify link')}`) +} + +/** + * Guards the global config store file (`~/.config/netlify/config.json` or platform + * equivalent) which holds auth tokens, before `getGlobalConfigStore` resets it. + */ +export const guardGlobalConfigFile = (): string | undefined => + backUpCorruptJsonFile( + getPathInHome(['config.json']), + `delete it and re-run ${chalk.cyanBright('netlify login')} to restore your auth tokens`, + ) diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts new file mode 100644 index 00000000000..c088f01b7a3 --- /dev/null +++ b/src/utils/exit-codes.ts @@ -0,0 +1,26 @@ +/** + * 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 and in the + * machine-readable `netlify capabilities --json` manifest. + */ +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..07d45076130 100644 --- a/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap +++ b/tests/integration/commands/didyoumean/__snapshots__/didyoumean.test.ts.snap @@ -3,23 +3,267 @@ 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 + $ capabilities Print a machine-readable manifest of every command, its + flags, exit codes, env vars, and config files + $ 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 (see 'netlify capabilities --json') + +→ 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 + $ capabilities Print a machine-readable manifest of every command, its + flags, exit codes, env vars, and config files + $ 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 (see 'netlify capabilities --json') + +→ 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 + $ capabilities Print a machine-readable manifest of every command, its + flags, exit codes, env vars, and config files + $ 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 (see 'netlify capabilities --json') + +→ 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 + $ capabilities Print a machine-readable manifest of every command, its + flags, exit codes, env vars, and config files + $ 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 (see 'netlify capabilities --json') + +→ 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/env/__snapshots__/env.test.ts.snap b/tests/integration/commands/env/__snapshots__/env.test.ts.snap index 41231145f9b..280e6535901 100644 --- a/tests/integration/commands/env/__snapshots__/env.test.ts.snap +++ b/tests/integration/commands/env/__snapshots__/env.test.ts.snap @@ -1,7 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`commands/env > env:clone > should exit if the folder is not linked to a project, and --from is not provided 1`] = `"Please include the source project ID as the \`--from\` option, or run \`netlify link\` to link this folder to a Netlify project"`; - exports[`commands/env > env:clone > should return success message 1`] = ` "Successfully cloned environment variables from site-name to site-name-a Changes will require a redeploy to take effect on any deployed versions of your project." diff --git a/tests/integration/commands/env/env.test.ts b/tests/integration/commands/env/env.test.ts index c8bd08f3ef0..aaba3724197 100644 --- a/tests/integration/commands/env/env.test.ts +++ b/tests/integration/commands/env/env.test.ts @@ -442,19 +442,22 @@ describe('commands/env', () => { }) }) - test('should exit if the folder is not linked to a project, and --from is not provided', async (t) => { + test('should exit non-zero if the folder is not linked to a project, and --from is not provided', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.build() - const cliResponse = (await callCli(['env:clone', '--to', 'site_id_a', '--force'], { + const error = (await callCli(['env:clone', '--to', 'site_id_a', '--force'], { cwd: builder.directory, extendEnv: false, env: { PATH: process.env.PATH, }, - })) as string + }).catch((error_: unknown) => error_)) as { exitCode: number; message: string } - t.expect(normalize(cliResponse)).toMatchSnapshot() + t.expect(error.exitCode).toBe(1) + t.expect(error.message).toContain( + 'Please include the source project ID as the `--from` option, or run `netlify link`', + ) }) }) diff --git a/tests/integration/commands/help/__snapshots__/help.test.ts.snap b/tests/integration/commands/help/__snapshots__/help.test.ts.snap index 37710c6c977..f9bf49fbb92 100644 --- a/tests/integration/commands/help/__snapshots__/help.test.ts.snap +++ b/tests/integration/commands/help/__snapshots__/help.test.ts.snap @@ -10,43 +10,50 @@ 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 + $ agents Manage Netlify AI agent tasks + $ api Run any Netlify API method + $ blobs Manage objects in Netlify Blobs + $ build Build on your local machine + $ capabilities Print a machine-readable manifest of every command, its + flags, exit codes, env vars, and config files + $ 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 (see 'netlify capabilities --json') + → 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/commands/api/api.test.ts b/tests/unit/commands/api/api.test.ts new file mode 100644 index 00000000000..1750305fb29 --- /dev/null +++ b/tests/unit/commands/api/api.test.ts @@ -0,0 +1,66 @@ +import type { OptionValues } from 'commander' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +vi.mock('../../../../src/utils/telemetry/report-error.js', () => ({ + reportError: vi.fn().mockResolvedValue(undefined), +})) + +import { apiCommand } from '../../../../src/commands/api/api.js' +import type BaseCommand from '../../../../src/commands/base-command.js' + +const getSite = vi.fn() + +const command = { netlify: { api: { getSite } } } as unknown as BaseCommand + +const runApi = async (data: string) => apiCommand('getSite', { data } as OptionValues, command) + +const captureError = async (data: string): Promise => { + try { + await runApi(data) + } catch (error) { + return error as Error + } + throw new Error('expected apiCommand to throw') +} + +describe('apiCommand --data parsing', () => { + beforeEach(() => { + getSite.mockReset() + }) + + test('rejects key=value input with an error naming --data', async () => { + await expect(runApi('site_id=123')).rejects.toThrowError(/--data/) + expect(getSite).not.toHaveBeenCalled() + }) + + test('error echoes the offending input and a concrete JSON example', async () => { + const error = await captureError('site_id=123') + expect(error.message).toContain('site_id=123') + expect(error.message).toContain(`--data '{"site_id":"123456"}'`) + expect(error.message).toContain('key=value') + expect(error.message).not.toContain('SyntaxError') + }) + + test('truncates long offending input in the error message', async () => { + const longInput = `site_id=${'9'.repeat(200)}` + const error = await captureError(longInput) + expect(error.message).not.toContain(longInput) + expect(error.message).toContain(longInput.slice(0, 80)) + }) + + test('still accepts a valid JSON object string', async () => { + getSite.mockResolvedValue({ id: '123456' }) + + await runApi('{"site_id":"123456"}') + + expect(getSite).toHaveBeenCalledWith({ site_id: '123456' }) + }) + + test('names --data and the required variable when the API reports a missing path variable', async () => { + getSite.mockRejectedValue(new Error("Missing required path variable 'site_id'")) + + const error = await captureError('{"other":"value"}') + expect(error.message).toContain('--data') + expect(error.message).toContain('site_id') + }) +}) diff --git a/tests/unit/commands/capabilities/capabilities.test.ts b/tests/unit/commands/capabilities/capabilities.test.ts new file mode 100644 index 00000000000..91a41309b87 --- /dev/null +++ b/tests/unit/commands/capabilities/capabilities.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'vitest' + +import { createMainCommand } from '../../../../src/commands/main.js' +import { buildCapabilitiesManifest } from '../../../../src/commands/capabilities/capabilities.js' + +describe('capabilities', () => { + test('manifest serializes to valid JSON with the versioned envelope', async () => { + const manifest = await buildCapabilitiesManifest(createMainCommand()) + + const parsed = JSON.parse(JSON.stringify(manifest, null, 2)) as Record + + expect(parsed.contract_version).toBe('1') + expect(typeof parsed.cli_version).toBe('string') + expect(Array.isArray(parsed.commands)).toBe(true) + expect(Array.isArray(parsed.global_flags)).toBe(true) + expect(Array.isArray(parsed.env_vars)).toBe(true) + expect(Array.isArray(parsed.config_files)).toBe(true) + expect((parsed.exit_codes as Record)['0']).toBe('success') + }) + + test('contains env:list and the capabilities command itself', async () => { + const manifest = await buildCapabilitiesManifest(createMainCommand()) + const names = manifest.commands.map((command) => command.name) + + expect(names).toContain('env:list') + expect(names).toContain('capabilities') + + const envList = manifest.commands.find((command) => command.name === 'env:list') + expect(envList?.json_output).toBe(true) + }) + + test('every command object has a name and a flags array', async () => { + const manifest = await buildCapabilitiesManifest(createMainCommand()) + + expect(manifest.commands.length).toBeGreaterThan(40) + manifest.commands.forEach((command) => { + expect(typeof command.name).toBe('string') + expect(command.name.length).toBeGreaterThan(0) + expect(Array.isArray(command.flags)).toBe(true) + command.flags.forEach((flag) => { + expect(typeof flag.name).toBe('string') + expect(['boolean', 'string']).toContain(flag.type) + }) + expect(command.mutates).toBeNull() + }) + }) + + test('output is deterministic across two invocations', async () => { + const first = JSON.stringify(await buildCapabilitiesManifest(createMainCommand()), null, 2) + const second = JSON.stringify(await buildCapabilitiesManifest(createMainCommand()), null, 2) + + expect(second).toBe(first) + }) + + test('commands and flags are sorted alphabetically (locale-independent code unit order)', async () => { + const byCodeUnit = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0) + const manifest = await buildCapabilitiesManifest(createMainCommand()) + + const names = manifest.commands.map((command) => command.name) + expect(names).toEqual([...names].sort(byCodeUnit)) + + manifest.commands.forEach((command) => { + const flagNames = command.flags.map((flag) => flag.name) + expect(flagNames).toEqual([...flagNames].sort(byCodeUnit)) + }) + }) + + test('global flags include --filter, --auth, --debug, and --help', async () => { + const manifest = await buildCapabilitiesManifest(createMainCommand()) + const globalFlagNames = manifest.global_flags.map((flag) => flag.name) + + expect(globalFlagNames).toContain('--filter') + expect(globalFlagNames).toContain('--auth') + expect(globalFlagNames).toContain('--debug') + expect(globalFlagNames).toContain('--help') + }) +}) diff --git a/tests/unit/commands/env/env-get.test.ts b/tests/unit/commands/env/env-get.test.ts new file mode 100644 index 00000000000..fd69372235b --- /dev/null +++ b/tests/unit/commands/env/env-get.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test, vi, beforeEach, afterEach, type MockInstance } from 'vitest' + +const { mockGetEnvelopeEnv, jsonMessages, logMessages } = vi.hoisted(() => ({ + mockGetEnvelopeEnv: vi.fn(), + jsonMessages: [] as unknown[], + logMessages: [] as string[], +})) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + log: (...args: string[]) => { + logMessages.push(args.join(' ')) + }, + logJson: (message: unknown) => { + jsonMessages.push(message) + }, + exit: (code = 0): never => { + throw new Error(`process.exit(${String(code)})`) + }, +})) + +vi.mock('../../../../src/utils/env/index.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/env/index.js')), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + getEnvelopeEnv: (...args: unknown[]) => mockGetEnvelopeEnv(...args), +})) + +import { envGet } from '../../../../src/commands/env/env-get.js' +import type BaseCommand from '../../../../src/commands/base-command.js' + +const createMockCommand = ({ linkedSiteId, flagSiteId }: { linkedSiteId?: string; flagSiteId?: string } = {}) => { + const api = { getSite: vi.fn().mockResolvedValue({ id: flagSiteId, name: 'flag-site' }) } + const command = { + netlify: { + api, + cachedConfig: { env: {}, siteInfo: { id: linkedSiteId } }, + site: { id: linkedSiteId }, + siteInfo: { id: flagSiteId }, + }, + } as unknown as BaseCommand + return { api, command } +} + +describe('envGet', () => { + let stderrSpy: MockInstance + + beforeEach(() => { + jsonMessages.length = 0 + logMessages.length = 0 + vi.clearAllMocks() + mockGetEnvelopeEnv.mockResolvedValue({ FOO: { value: 'bar' } }) + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + stderrSpy.mockRestore() + }) + + test('prints a NOT_LINKED JSON envelope and exits non-zero when unlinked with --json', async () => { + const { command } = createMockCommand() + + await expect(envGet('FOO', { json: true }, command)).rejects.toThrowError('process.exit(1)') + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toMatchObject({ error: { code: 'NOT_LINKED', fix: 'netlify link' } }) + expect(mockGetEnvelopeEnv).not.toHaveBeenCalled() + }) + + test('prints prose to stderr and exits non-zero when unlinked without --json', async () => { + const { command } = createMockCommand() + + await expect(envGet('FOO', {}, command)).rejects.toThrowError('process.exit(1)') + + expect(stderrSpy).toHaveBeenCalledWith( + 'No project id found, please run inside a project folder or `netlify link`\n', + ) + expect(jsonMessages).toHaveLength(0) + }) + + test('returns the variable for the linked site without --site', async () => { + const { command } = createMockCommand({ linkedSiteId: 'linked-id' }) + + await envGet('FOO', { json: true, context: 'dev', scope: 'any' }, command) + + expect(jsonMessages).toEqual([{ FOO: 'bar' }]) + }) + + test('honors --site by using the resolved siteInfo instead of the linked state', async () => { + const { api, command } = createMockCommand({ flagSiteId: 'flag-site-id' }) + + await envGet('FOO', { json: true, context: 'dev', scope: 'any', site: 'flag-site-id' }, command) + + expect(api.getSite).toHaveBeenCalledWith({ siteId: 'flag-site-id' }) + const [envelopeArgs] = mockGetEnvelopeEnv.mock.calls[0] as [{ siteInfo: { id: string } }] + expect(envelopeArgs.siteInfo.id).toBe('flag-site-id') + expect(jsonMessages).toEqual([{ FOO: 'bar' }]) + }) + + test('prefers --site over the linked site id', async () => { + const { api, command } = createMockCommand({ linkedSiteId: 'linked-id', flagSiteId: 'flag-site-id' }) + + await envGet('FOO', { json: true, context: 'dev', scope: 'any', site: 'flag-site-id' }, command) + + expect(api.getSite).toHaveBeenCalledWith({ siteId: 'flag-site-id' }) + const [envelopeArgs] = mockGetEnvelopeEnv.mock.calls[0] as [{ siteInfo: { id: string } }] + expect(envelopeArgs.siteInfo.id).toBe('flag-site-id') + }) +}) diff --git a/tests/unit/commands/env/utils.test.ts b/tests/unit/commands/env/utils.test.ts new file mode 100644 index 00000000000..87b3d8d82bb --- /dev/null +++ b/tests/unit/commands/env/utils.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test, vi, beforeEach, afterEach, type MockInstance } from 'vitest' + +const { jsonMessages } = vi.hoisted(() => ({ jsonMessages: [] as unknown[] })) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + logJson: (message: unknown) => { + jsonMessages.push(message) + }, + exit: (code = 0): never => { + throw new Error(`process.exit(${String(code)})`) + }, +})) + +import { failNotLinked, getEnvSiteId } from '../../../../src/commands/env/utils.js' +import type BaseCommand from '../../../../src/commands/base-command.js' + +const createMockCommand = ({ linkedSiteId, flagSiteId }: { linkedSiteId?: string; flagSiteId?: string }) => + ({ + netlify: { + site: { id: linkedSiteId }, + siteInfo: { id: flagSiteId }, + }, + } as unknown as BaseCommand) + +describe('getEnvSiteId', () => { + test('returns the linked site id when --site is not passed', () => { + const command = createMockCommand({ linkedSiteId: 'linked-id' }) + + expect(getEnvSiteId({}, command)).toBe('linked-id') + }) + + test('returns the resolved siteInfo id when --site is passed', () => { + const command = createMockCommand({ linkedSiteId: 'linked-id', flagSiteId: 'flag-id' }) + + expect(getEnvSiteId({ site: 'my-project' }, command)).toBe('flag-id') + }) + + test('returns undefined when unlinked and --site is not passed', () => { + const command = createMockCommand({}) + + expect(getEnvSiteId({}, command)).toBeUndefined() + }) +}) + +describe('failNotLinked', () => { + let stderrSpy: MockInstance + + beforeEach(() => { + jsonMessages.length = 0 + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + stderrSpy.mockRestore() + }) + + test('prints a NOT_LINKED JSON envelope to stdout and exits non-zero with --json', () => { + expect(() => failNotLinked({ json: true })).toThrowError('process.exit(1)') + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ + error: { + code: 'NOT_LINKED', + message: 'No project id found, please run inside a project folder or `netlify link`', + fix: 'netlify link', + }, + }) + expect(stderrSpy).not.toHaveBeenCalled() + }) + + test('prints prose to stderr and exits non-zero without --json', () => { + expect(() => failNotLinked({})).toThrowError('process.exit(1)') + + expect(jsonMessages).toHaveLength(0) + expect(stderrSpy).toHaveBeenCalledWith( + 'No project id found, please run inside a project folder or `netlify link`\n', + ) + }) + + test('uses the provided message override', () => { + expect(() => failNotLinked({}, 'custom message')).toThrowError('process.exit(1)') + + expect(stderrSpy).toHaveBeenCalledWith('custom message\n') + }) +}) diff --git a/tests/unit/commands/help-format.test.ts b/tests/unit/commands/help-format.test.ts new file mode 100644 index 00000000000..d6c0fe89041 --- /dev/null +++ b/tests/unit/commands/help-format.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'vitest' + +import BaseCommand from '../../../src/commands/base-command.js' + +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string): string => text.replace(/\[[0-9;]*m/g, '') + +describe('help formatting without a TTY', () => { + test('separates a short flag from its description with at least two spaces', () => { + const program = new BaseCommand('netlify') + const build = program + .command('build') + .description('Build on your local machine') + .option('--dry', 'Dry run: show instructions without running them', false) + .option('--context ', 'Specify a deploy context') + + const helpText = stripAnsi(build.helpInformation()) + + expect(helpText).toMatch(/--dry {2,}Dry run: show instructions/) + expect(helpText).not.toMatch(/--dryDry/) + }) + + test('every OPTIONS row keeps at least two spaces between term and description', () => { + const program = new BaseCommand('netlify') + const api = program + .command('api') + .description('Run any Netlify API method') + .option('-d, --data ', 'Data to use') + .option('--list', 'List out available API methods', false) + + const helpText = stripAnsi(api.helpInformation()) + const optionsSection = helpText.split('OPTIONS')[1]?.split('\n\n')[0] ?? '' + const rows = optionsSection.split('\n').filter((line) => /^\s+-/.test(line)) + + expect(rows.length).toBeGreaterThan(0) + for (const row of rows) { + expect(row).toMatch(/^ {2}\S.* {2,}\S/) + } + + expect(helpText).not.toMatch(/Data/) + }) + + test('option terms contribute to the help column width', () => { + const program = new BaseCommand('netlify') + const cmd = program + .command('example') + .description('Example command') + .option('--a-really-long-option-name ', 'Long option') + .option('--dry', 'Short option') + + const helpText = stripAnsi(cmd.helpInformation()) + const longRow = helpText.split('\n').find((line) => line.includes('--a-really-long-option-name')) + + expect(longRow).toBeDefined() + expect(longRow).toMatch(/ {2,}Long option/) + }) + + test('noHelpOptions exposes noBaseOptions and hides the OPTIONS section', () => { + const program = new BaseCommand('netlify') + + expect(program.noBaseOptions).toBe(false) + program.noHelpOptions() + expect(program.noBaseOptions).toBe(true) + }) +}) diff --git a/tests/unit/commands/main-suggestions.test.ts b/tests/unit/commands/main-suggestions.test.ts new file mode 100644 index 00000000000..9270861b540 --- /dev/null +++ b/tests/unit/commands/main-suggestions.test.ts @@ -0,0 +1,168 @@ +import { Command } from 'commander' +import { afterEach, describe, expect, test, vi } from 'vitest' + +import BaseCommand from '../../../src/commands/base-command.js' +import { getUnknownOptionSuggestions } from '../../../src/utils/command-error-handler.js' + +const getSubcommand = (program: Command, name: string): Command => { + const found = program.commands.find((cmd) => cmd.name() === name) + if (found === undefined) { + throw new Error(`missing subcommand ${name}`) + } + return found +} + +const buildRootCommand = () => { + const program = new Command('netlify') + program.option('--telemetry-disable', 'Disable telemetry') + program.command('env:list').option('--json', 'Output environment variables as JSON') + program.command('sites:list').option('--json', 'Output project data as JSON') + program.command('status').option('--json', 'Output status information as JSON') + program.command('deploy').option('--json', 'Output deployment data as JSON') + program + .command('agents:create') + .option('-a, --agent ', 'agent type (claude, codex, gemini)') + .option('--json', 'output result as JSON') + return program +} + +describe('getUnknownOptionSuggestions', () => { + test('suggests close flags from the current command when commander gave no suggestion', () => { + const program = buildRootCommand() + const subcommand = getSubcommand(program, 'env:list') + + const lines = getUnknownOptionSuggestions(subcommand, "error: unknown option '--jsno'") + + expect(lines).toContain("Did you mean '--json'?") + }) + + test('does not duplicate a suggestion commander already made', () => { + const program = buildRootCommand() + const subcommand = getSubcommand(program, 'env:list') + + const lines = getUnknownOptionSuggestions(subcommand, "error: unknown option '--jsno'\n(Did you mean --json?)") + + expect(lines.filter((line) => line.startsWith('Did you mean'))).toHaveLength(0) + }) + + test('at the root, names the owning commands of close subcommand flags (capped at 3 with ellipsis)', () => { + const program = buildRootCommand() + + const lines = getUnknownOptionSuggestions(program, "error: unknown option '--jsno'") + + expect(lines).toContain( + "'--json' is a flag of: agents:create, deploy, env:list, ... (run 'netlify --help')", + ) + }) + + test('at the root, suggests typoed flags that only exist on subcommands', () => { + const program = buildRootCommand() + + const lines = getUnknownOptionSuggestions(program, "error: unknown option '--aegnt'") + + expect(lines.some((line) => line.includes("'--agent' is a flag of: agents:create"))).toBe(true) + }) + + test('stays silent when no flag is within edit distance 2', () => { + const program = buildRootCommand() + + expect(getUnknownOptionSuggestions(program, "error: unknown option '--zzzzzzzzz'")).toEqual([]) + }) + + test('skips the cross-command index when the error came from a dispatched subcommand', () => { + const program = buildRootCommand() + program.args = ['env:list', '--jsno'] + + const lines = getUnknownOptionSuggestions(program, "error: unknown option '--jsno'") + + expect(lines.filter((line) => line.includes('is a flag of'))).toHaveLength(0) + }) + + test('returns nothing for non unknown-option messages', () => { + const program = buildRootCommand() + + expect(getUnknownOptionSuggestions(program, "error: missing required argument 'name'")).toEqual([]) + }) +}) + +describe('namespace parent commands reject space-form subcommands', () => { + const stderrChunks: string[] = [] + + const buildSitesCommand = () => { + const program = new BaseCommand('netlify') + program.command('sites:create').description('Create an empty project') + program.command('sites:delete').description('Delete a project') + program.command('sites:list').description('List all projects') + return program.command('sites').description('Handle various project operations') + } + + const mockProcess = () => { + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrChunks.push(String(chunk)) + return true + }) + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`exit(${String(code ?? 0)})`) + }) + } + + afterEach(() => { + stderrChunks.length = 0 + vi.restoreAllMocks() + }) + + test('errors with usage exit code 2 and a colon-form did-you-mean for a known subcommand', () => { + const sites = buildSitesCommand() + sites.args = ['delete', 'my-site-id'] + mockProcess() + + expect(() => { + sites.rejectSpaceFormSubcommand() + }).toThrow('exit(2)') + + const stderr = stderrChunks.join('') + expect(stderr).toContain("'netlify sites delete' is not a command") + expect(stderr).toContain("Did you mean 'netlify sites:delete my-site-id'?") + expect(stderr).toContain("Run 'netlify sites --help'") + }) + + test('suggests the closest colon-form subcommand for a near-miss', () => { + const sites = buildSitesCommand() + sites.args = ['delte', 'my-site-id'] + mockProcess() + + expect(() => { + sites.rejectSpaceFormSubcommand() + }).toThrow('exit(2)') + + expect(stderrChunks.join('')).toContain('sites:delete') + }) + + test('is a no-op when no positional arguments were given', () => { + const sites = buildSitesCommand() + sites.args = [] + mockProcess() + + expect(() => { + sites.rejectSpaceFormSubcommand() + }).not.toThrow() + expect(stderrChunks).toHaveLength(0) + }) + + test('helpOrRejectExtraArgs still prints help for bare invocations', () => { + const sites = buildSitesCommand() + sites.args = [] + const stdoutChunks: string[] = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdoutChunks.push(String(chunk)) + return true + }) + mockProcess() + + expect(() => { + sites.helpOrRejectExtraArgs() + }).toThrow('exit(0)') + expect(stdoutChunks.join('')).toContain('sites:delete') + expect(stderrChunks.join('')).toBe('') + }) +}) diff --git a/tests/unit/commands/status/status.test.ts b/tests/unit/commands/status/status.test.ts new file mode 100644 index 00000000000..9f9b9d0fe2b --- /dev/null +++ b/tests/unit/commands/status/status.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest' + +const { mockGetToken, mockGetCurrentUser, logMessages, jsonMessages, exitCalls } = vi.hoisted(() => { + const mockGetToken = vi.fn() + const mockGetCurrentUser = vi.fn() + const logMessages: string[] = [] + const jsonMessages: unknown[] = [] + const exitCalls: number[] = [] + return { mockGetToken, mockGetCurrentUser, logMessages, jsonMessages, exitCalls } +}) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + getToken: (...args: unknown[]) => mockGetToken(...args) as unknown, + log: (...args: string[]) => { + logMessages.push(args.join(' ')) + }, + logJson: (message: unknown) => { + jsonMessages.push(message) + }, + warn: (message: string) => { + logMessages.push(message) + }, + logAndThrowError: (message: unknown): never => { + throw message instanceof Error ? message : new Error(String(message)) + }, + exit: (code = 0): never => { + exitCalls.push(code) + throw new Error(`exit(${String(code)})`) + }, +})) + +import { status, STATUS_ERROR_CODES } from '../../../../src/commands/status/status.js' + +function createMockCommand(overrides: { siteId?: string } = {}) { + const { siteId } = overrides + + return { + netlify: { + accounts: [{ name: 'My Team' }], + api: { + getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args) as unknown, + }, + globalConfig: { + get: vi.fn().mockReturnValue(undefined), + }, + site: { id: siteId, configPath: '/project/netlify.toml' }, + siteInfo: { + id: siteId, + name: 'my-site', + admin_url: 'https://app.netlify.com/sites/my-site', + ssl_url: 'https://my-site.netlify.app', + url: 'http://my-site.netlify.app', + }, + }, + } as unknown as Parameters[1] +} + +describe('status', () => { + beforeEach(() => { + logMessages.length = 0 + jsonMessages.length = 0 + exitCalls.length = 0 + vi.clearAllMocks() + mockGetToken.mockResolvedValue(['fake-token', 'config']) + mockGetCurrentUser.mockResolvedValue({ full_name: 'Test User', email: 'test@example.com' }) + }) + + describe('not logged in', () => { + beforeEach(() => { + mockGetToken.mockResolvedValue([null, 'not found']) + }) + + test('with --json emits a NOT_LOGGED_IN error envelope and exits non-zero', async () => { + await expect(status({ json: true }, createMockCommand())).rejects.toThrow('exit(1)') + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ + loggedIn: false, + linked: false, + account: null, + siteData: null, + error: { + code: STATUS_ERROR_CODES.NOT_LOGGED_IN, + fix: 'netlify login or NETLIFY_AUTH_TOKEN', + }, + }) + expect(exitCalls).toEqual([1]) + }) + + test('without --json keeps existing behavior: human message and exit 0', async () => { + await expect(status({}, createMockCommand())).rejects.toThrow('exit(0)') + + expect(jsonMessages).toHaveLength(0) + expect(exitCalls).toEqual([0]) + expect(logMessages.join('\n')).toContain('Not logged in') + }) + }) + + describe('logged in but not linked', () => { + test('with --json emits a NOT_LINKED error envelope including account data', async () => { + await expect(status({ json: true }, createMockCommand())).rejects.toThrow( + "You don't appear to be in a folder that is linked to a project", + ) + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ + loggedIn: true, + linked: false, + account: { + Name: 'Test User', + Email: 'test@example.com', + Teams: ['My Team'], + }, + siteData: null, + error: { + code: STATUS_ERROR_CODES.NOT_LINKED, + fix: 'netlify link', + }, + }) + }) + + test('without --json keeps existing behavior: warns and throws without JSON output', async () => { + await expect(status({}, createMockCommand())).rejects.toThrow( + "You don't appear to be in a folder that is linked to a project", + ) + + expect(jsonMessages).toHaveLength(0) + expect(logMessages.join('\n')).toContain('Did you run `netlify link` yet?') + }) + }) + + describe('logged in and linked', () => { + test('with --json keeps existing keys and adds loggedIn, linked, and error', async () => { + await status({ json: true }, createMockCommand({ siteId: 'site-123' })) + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ + account: { + Name: 'Test User', + Email: 'test@example.com', + Teams: ['My Team'], + }, + siteData: { + 'site-name': 'my-site', + 'config-path': '/project/netlify.toml', + 'admin-url': 'https://app.netlify.com/sites/my-site', + 'site-url': 'https://my-site.netlify.app', + 'site-id': 'site-123', + }, + loggedIn: true, + linked: true, + error: null, + }) + expect(exitCalls).toHaveLength(0) + }) + + test('without --json does not emit JSON', async () => { + await status({}, createMockCommand({ siteId: 'site-123' })) + + expect(jsonMessages).toHaveLength(0) + }) + }) + + describe('expired session', () => { + beforeEach(() => { + mockGetCurrentUser.mockRejectedValue(Object.assign(new Error('Unauthorized'), { status: 401 })) + }) + + test('with --json emits a NOT_LOGGED_IN error envelope before throwing', async () => { + await expect(status({ json: true }, createMockCommand())).rejects.toThrow('Your session has expired') + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toMatchObject({ + loggedIn: false, + error: { code: STATUS_ERROR_CODES.NOT_LOGGED_IN }, + }) + }) + + test('without --json keeps existing behavior: throws without JSON output', async () => { + await expect(status({}, createMockCommand())).rejects.toThrow('Your session has expired') + + expect(jsonMessages).toHaveLength(0) + }) + }) +}) diff --git a/tests/unit/utils/config-guard.test.ts b/tests/unit/utils/config-guard.test.ts new file mode 100644 index 00000000000..e74a8bc5d6c --- /dev/null +++ b/tests/unit/utils/config-guard.test.ts @@ -0,0 +1,92 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { backUpCorruptJsonFile, guardLocalStateFile } from '../../../src/utils/config-guard.js' + +const mockStderr = () => vi.spyOn(process.stderr, 'write').mockReturnValue(true) + +describe('config corruption guard', () => { + let dir: string + let stderrSpy: ReturnType + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'netlify-config-guard-')) + stderrSpy = mockStderr() + }) + + afterEach(() => { + stderrSpy.mockRestore() + rmSync(dir, { recursive: true, force: true }) + }) + + const stderrOutput = () => stderrSpy.mock.calls.map(([chunk]) => String(chunk)).join('') + + test('returns undefined for a missing file', () => { + expect(backUpCorruptJsonFile(join(dir, 'missing.json'), 'fix it')).toBeUndefined() + expect(stderrSpy).not.toHaveBeenCalled() + }) + + test('returns undefined for valid JSON and writes no backup', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, '{"siteId":"123"}') + expect(backUpCorruptJsonFile(filePath, 'fix it')).toBeUndefined() + expect(stderrSpy).not.toHaveBeenCalled() + }) + + test('returns undefined for an empty file', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, '') + expect(backUpCorruptJsonFile(filePath, 'fix it')).toBeUndefined() + }) + + test('backs up a corrupt file and warns on stderr naming both paths', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, '{ totally not json') + + const backupPath = backUpCorruptJsonFile(filePath, 'run netlify link') + + expect(backupPath).toMatch(/state\.json\.corrupt\.\d+$/) + expect(existsSync(backupPath ?? '')).toBe(true) + expect(readFileSync(backupPath ?? '', 'utf8')).toBe('{ totally not json') + + const output = stderrOutput() + expect(output).toContain(filePath) + expect(output).toContain(backupPath) + expect(output).toContain('run netlify link') + }) + + test('does not overwrite an existing backup for the same mtime', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, 'garbage') + + const first = backUpCorruptJsonFile(filePath, 'fix it') + const second = backUpCorruptJsonFile(filePath, 'fix it') + + expect(first).toBe(second) + expect(readFileSync(first ?? '', 'utf8')).toBe('garbage') + }) + + test('guardLocalStateFile resolves .netlify/state.json from the working directory', async () => { + const projectDir = join(dir, 'project') + writeFileSync(join(dir, 'unrelated.txt'), '', { flag: 'w' }) + const stateDir = join(projectDir, '.netlify') + const { mkdirSync } = await import('fs') + mkdirSync(stateDir, { recursive: true }) + writeFileSync(join(stateDir, 'state.json'), 'not json at all') + + const backupPath = await guardLocalStateFile(projectDir) + + expect(backupPath).toMatch(/state\.json\.corrupt\.\d+$/) + expect(existsSync(backupPath ?? '')).toBe(true) + }) + + test('guardLocalStateFile is a no-op when no state file exists', async () => { + const projectDir = join(dir, 'empty-project') + const { mkdirSync } = await import('fs') + mkdirSync(projectDir, { recursive: true }) + await expect(guardLocalStateFile(projectDir)).resolves.toBeUndefined() + }) +}) 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/no-color.test.ts b/tests/unit/utils/no-color.test.ts new file mode 100644 index 00000000000..f42fd5c2a5a --- /dev/null +++ b/tests/unit/utils/no-color.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { shouldDisableColors } from '../../../src/utils/command-helpers.js' + +describe('NO_COLOR support', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + + test('shouldDisableColors is true for any non-empty NO_COLOR value', () => { + expect(shouldDisableColors({ NO_COLOR: '1' })).toBe(true) + expect(shouldDisableColors({ NO_COLOR: 'true' })).toBe(true) + expect(shouldDisableColors({ NO_COLOR: '0' })).toBe(true) + }) + + test('shouldDisableColors is false when NO_COLOR is unset or empty', () => { + expect(shouldDisableColors({})).toBe(false) + expect(shouldDisableColors({ NO_COLOR: '' })).toBe(false) + }) + + test('chalk is initialized colorless when NO_COLOR is set', async () => { + vi.stubEnv('NO_COLOR', '1') + const { chalk } = await import('../../../src/utils/command-helpers.js') + expect(chalk.level).toBe(0) + expect(chalk.red('plain')).toBe('plain') + }) +}) 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 .') + }) })