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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/commands/agents/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import requiresSiteInfoWithProject from '../../utils/hooks/requires-site-info-wi
import type BaseCommand from '../base-command.js'

const agents = (_options: OptionValues, command: BaseCommand) => {
command.help()
command.helpOrRejectExtraArgs()
}

export const createAgentsCommand = (program: BaseCommand) => {
Expand Down
27 changes: 26 additions & 1 deletion src/commands/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,39 @@ 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 = {}
}
try {
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<string, unknown> } }[]
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"}'`,
)
}
Comment on lines +65 to +75

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify the type definition of methods from `@netlify/api`

# Check if `@netlify/api` has type definitions
rg -n "export.*methods" node_modules/@netlify/api --type=ts -A 5

# Also check for the type definition in .d.ts files
fd -e d.ts . node_modules/@netlify/api --exec rg -n "methods" -A 10

Repository: netlify/cli

Length of output: 245


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant section around the snippet
sed -n '1,140p' src/commands/api/api.ts | nl -ba | sed -n '40,120p'

# Find how `methods` is imported/typed in this file
rg -n "import .*methods|from ['\"]`@netlify/api`['\"]" src/commands/api/api.ts

# Check other usages of `methods` in the repo (to understand assumed runtime shape)
rg -n "\bmethods\b" src | head -n 200

Repository: netlify/cli

Length of output: 98


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant section around the snippet without `nl`
sed -n '40,120p' src/commands/api/api.ts | sed -n '1,80p'

# Find how `methods` is imported/typed in this file
rg -n "import .*methods|from ['\"]`@netlify/api`['\"]" src/commands/api/api.ts

# Check other usages of `methods` in this repo (limits output)
rg -n "\bmethods\b" src | head -n 200

Repository: netlify/cli

Length of output: 2933


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the code around the snippet and the earlier usage of `methods`
sed -n '1,120p' src/commands/api/api.ts
sed -n '120,220p' src/commands/api/api.ts

# Inspect `@netlify/api` typing for `methods` and related types
sed -n '1,120p' node_modules/@netlify/api/lib/index.d.ts

# Search for more specific types/shapes around methods/operationId/parameters.path
rg -n "operationId|parameters|path\\??" node_modules/@netlify/api/lib/index.d.ts
rg -n "interface .*method|type .*method|declare const methods" node_modules/@netlify/api/lib -S

Repository: netlify/cli

Length of output: 5169


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "\bmethods\.|methods\b.*operationId|as \{ operationId" src
rg -n "from '`@netlify/api`'" src

Repository: netlify/cli

Length of output: 2267


Guard against unchecked methods shape when building the “missing path variable” message

@netlify/api types methods as any[], so the cast to { operationId: string; parameters: { path?: Record<string, unknown> } }[] is unchecked and can fail at runtime if the element shape differs. Add runtime guards/safe access before reading operationId and parameters.path (or extract via a small helper type-guard) so this error-path doesn’t crash.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/api/api.ts` around lines 65 - 75, The current cast of methods to
a specific shape is unsafe and can throw when building the missing-path error
message; add a runtime type guard (e.g., isApiMethod(obj): obj has
operationId:string and optional parameters.path:Record<string,unknown>) and use
it to filter/map methods before calling find by apiMethodName, then safely read
parameters?.path and guard Object.keys with a default empty object; update the
block that computes apiMethods, pathVariables and requiredNames (references:
methods, apiMethods, apiMethodName, parameters.path, logAndThrowError) to use
these checks so the error path never assumes the shape.

return logAndThrowError(error_)
}
}
100 changes: 82 additions & 18 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -19,6 +20,7 @@ import pick from 'lodash/pick.js'

import { getAgent } from '../lib/http-agent.js'
import {
BANG,
NETLIFY_CYAN,
USER_AGENT,
chalk,
Expand All @@ -35,12 +37,18 @@ 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'
Expand Down Expand Up @@ -74,6 +82,7 @@ const HELP_SEPARATOR_WIDTH = 5
*/
const COMMANDS_WITHOUT_WORKSPACE_OPTIONS = new Set([
'api',
'capabilities',
'recipes',
'completion',
'status',
Expand Down Expand Up @@ -138,13 +147,14 @@ async function selectWorkspace(project: Project, filter?: string): Promise<strin
log()
log(chalk.cyan(`We've detected multiple projects inside your repository`))

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

Expand Down Expand Up @@ -183,6 +193,7 @@ export type BaseOptionValues = {
debug?: boolean
filter?: string
httpProxy?: string
nonInteractive?: boolean
silent?: string
verbose?: boolean
}
Expand Down Expand Up @@ -246,6 +257,12 @@ export default class BaseCommand extends Command {
const commandName = name || ''
const base = new BaseCommand(commandName)
.addOption(new Option('--silent', 'Silence CLI output').hideHelp(true))
.addOption(
new Option(
'--non-interactive',
'Never open prompts; fail with exit code 4 when input would be required',
).hideHelp(true),
)
.addOption(new Option('--cwd <cwd>').hideHelp(true))
.addOption(
new Option('--auth <token>', 'Netlify auth token - can be used to run this command without logging in'),
Expand Down Expand Up @@ -290,6 +307,7 @@ export default class BaseCommand extends Command {
// brief error message, making it easier for users in CI/CD environments to
// understand what went wrong.
this.exitOverride((error: CommanderError) => {
suggestUnknownOptionAlternatives(this, error)
if (isOptionError(error)) {
handleOptionError(this)
}
Expand All @@ -304,12 +322,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 */
Expand Down Expand Up @@ -353,7 +415,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
Expand All @@ -367,9 +428,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}`
Expand Down Expand Up @@ -480,12 +541,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(
'`netlify login --request <message>`',
)} as a next step.`,
return failOnNonInteractivePrompt(
'Logging in to your Netlify account',
`Authentication required. Set the ${chalk.cyanBright(
'NETLIFY_AUTH_TOKEN',
)} environment variable or pass ${chalk.cyanBright(
'--auth <token>',
)}, or use ${chalk.cyanBright('`netlify login --request <message>`')} to ask a human for credentials.`,
)
}
const accessToken = await this.expensivelyAuthenticate()
Expand Down Expand Up @@ -653,6 +715,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)

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

/**
Expand Down
Loading
Loading