From 3ad2bcadb7c548b806cbd48caa642335ce8f251b Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 11:52:39 -0700 Subject: [PATCH 01/11] feat(env): JSON error envelope + non-zero exit when unlinked; honor documented --site flag Agent-ergonomics audit R-002 + R-010 (pass 1). - env:* with --json in an unlinked dir now emits {"error":{"code":"NOT_LINKED",...}} on stdout and exits 1 (was: exit 0, empty output) - prose errors go to stderr - --site now resolves via the existing base-command siteInfo path (was: silently ignored) --- src/commands/env/env-clone.ts | 5 +- src/commands/env/env-get.ts | 9 +- src/commands/env/env-import.ts | 9 +- src/commands/env/env-list.ts | 9 +- src/commands/env/env-set.ts | 9 +- src/commands/env/env-unset.ts | 9 +- src/commands/env/utils.ts | 18 ++++ tests/unit/commands/env/env-get.test.ts | 108 ++++++++++++++++++++++++ tests/unit/commands/env/utils.test.ts | 86 +++++++++++++++++++ 9 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 tests/unit/commands/env/env-get.test.ts create mode 100644 tests/unit/commands/env/utils.test.ts 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/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/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..f0f1e66ef2f --- /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') + }) +}) From f6caa7b32f1451cd3da475c3d47c1ee7f22a73d4 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 11:52:39 -0700 Subject: [PATCH 02/11] feat(status): --json always emits a JSON object for every auth/link state Agent-ergonomics audit R-004 (pass 1). - unlinked / logged-out / expired-token paths emit JSON envelopes with NOT_LINKED / NOT_LOGGED_IN codes (was: exit 1 with zero stdout) - non-JSON mode semantics unchanged; happy-path JSON keys additive only --- src/commands/status/status.ts | 46 ++++++ tests/unit/commands/status/status.test.ts | 186 ++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 tests/unit/commands/status/status.test.ts diff --git a/src/commands/status/status.ts b/src/commands/status/status.ts index ed44723aaad..0e396cb3334 100644 --- a/src/commands/status/status.ts +++ b/src/commands/status/status.ts @@ -15,12 +15,31 @@ import { 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 +62,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`.', ) @@ -70,6 +101,18 @@ export const status = async (options: OptionValues, command: BaseCommand) => { log(prettyjson.render(cleanAccountData)) 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 +128,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, }) } diff --git a/tests/unit/commands/status/status.test.ts b/tests/unit/commands/status/status.test.ts new file mode 100644 index 00000000000..9240eb8ed79 --- /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), + 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), + }, + 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) + }) + }) +}) From 1bd80051f6be0f6f259921c6f69c4ebbf4c58d9b Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 11:52:39 -0700 Subject: [PATCH 03/11] fix(api): teach instead of leaking SyntaxError on malformed --data Agent-ergonomics audit R-011 (pass 1). - JSON.parse failures name --data, echo truncated input, show a concrete example, note key=value is not accepted - missing-path-variable errors list the method's required variables --- src/commands/api/api.ts | 27 +++++++++++- tests/unit/commands/api/api.test.ts | 66 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 tests/unit/commands/api/api.test.ts 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/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') + }) +}) From e5480bab23be822f02f0b90b05074a356b51557e Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 12:07:29 -0700 Subject: [PATCH 04/11] feat(cli): space-form subcommand guard, flag did-you-mean, fix glued piped --help Agent-ergonomics audit R-001 + R-005 + R-008 (pass 1). - R-001: namespace parents (sites/env/functions/blobs/teams/agents/completion/open) now exit 1 with a colon-form suggestion on stderr instead of printing parent help with exit 0 ('netlify sites delete x' no longer fake-succeeds) - R-005: unknown flags get levenshtein did-you-mean (distance<=2); root-level errors name owning commands; unknown-command suggestions moved stdout->stderr - R-008: help formatter pads term/description with real whitespace in non-TTY output (was ANSI-color-only separator producing '--dryDry run:') --- src/commands/agents/agents.ts | 2 +- src/commands/base-command.ts | 60 ++++++- src/commands/blobs/blobs.ts | 2 +- src/commands/completion/index.ts | 2 +- src/commands/env/env.ts | 2 +- src/commands/functions/functions.ts | 2 +- src/commands/main.ts | 18 +- src/commands/open/index.ts | 1 + src/commands/sites/sites.ts | 2 +- src/commands/teams/teams.ts | 2 +- src/utils/command-error-handler.ts | 84 +++++++++- tests/unit/commands/help-format.test.ts | 65 ++++++++ tests/unit/commands/main-suggestions.test.ts | 166 +++++++++++++++++++ 13 files changed, 387 insertions(+), 21 deletions(-) create mode 100644 tests/unit/commands/help-format.test.ts create mode 100644 tests/unit/commands/main-suggestions.test.ts 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/base-command.ts b/src/commands/base-command.ts index d3b40a27b6e..55277653814 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,7 +37,11 @@ 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 type { FeatureFlags } from '../utils/feature-flags.js' import { getFrameworksAPIPaths } from '../utils/frameworks-api.js' import { getSiteByName } from '../utils/get-site.js' @@ -290,6 +296,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 +311,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(1) + } + + /** + * 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 +404,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 +417,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}` 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/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/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/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/main.ts b/src/commands/main.ts index f7167573f08..de2b090c35c 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -15,13 +15,16 @@ import { log, NETLIFY_CYAN, USER_AGENT, - warn, logError, } from '../utils/command-helpers.js' import execa from '../utils/execa.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' @@ -197,19 +200,19 @@ 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) } @@ -312,6 +315,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.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/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/utils/command-error-handler.ts b/src/utils/command-error-handler.ts index 7449d206634..d78c59a5e21 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,85 @@ const OPTION_ERROR_CODES = new Set([ 'commander.excessArguments', ]) +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[] = [] + + 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/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..308b201b5ef --- /dev/null +++ b/tests/unit/commands/main-suggestions.test.ts @@ -0,0 +1,166 @@ +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 exit 1 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(1)') + + 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(1)') + + 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('') + }) +}) From 8b223e15abbb41776a44e55da75ef2f1fdd2bcf8 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 12:33:11 -0700 Subject: [PATCH 05/11] feat(cli): exit-code dictionary, NO_COLOR, --non-interactive, state corruption guard, capabilities command Agent-ergonomics audit R-003 + R-006 + R-007/R-009 + R-014 + R-027 (pass 1). - R-006: src/utils/exit-codes.ts documents 0/1/2/4; unknown command/option paths now exit 2; dictionary in root help epilogue - R-014: NO_COLOR (no-color.org) disables colors regardless of TTY - R-027: global --non-interactive fails would-be prompts with exit 4 naming the missing flag/env var (extends CI=true gate) - R-007/R-009: corrupt .netlify/state.json / global config backed up to .corrupt. with stderr warning before dev-utils would silently reset it - R-003: netlify capabilities [--json] emits a deterministic machine-readable manifest (71 commands, flags, json support, exit codes, env vars, config paths) --- src/commands/base-command.ts | 41 +++--- src/commands/capabilities/capabilities.ts | 118 ++++++++++++++++++ src/commands/capabilities/index.ts | 17 +++ src/commands/link/link.ts | 9 +- src/commands/main.ts | 11 +- src/utils/command-helpers.ts | 5 +- src/utils/config-guard.ts | 64 ++++++++++ src/utils/exit-codes.ts | 26 ++++ src/utils/run-program.ts | 6 + src/utils/scripted-commands.ts | 28 ++++- .../commands/dev/dev-miscellaneous.test.ts | 4 +- .../capabilities/capabilities.test.ts | 76 +++++++++++ tests/unit/commands/main-suggestions.test.ts | 6 +- tests/unit/commands/status/status.test.ts | 4 +- tests/unit/utils/config-guard.test.ts | 92 ++++++++++++++ tests/unit/utils/exit-codes.test.ts | 19 +++ tests/unit/utils/no-color.test.ts | 28 +++++ tests/unit/utils/scripted-commands.test.ts | 54 ++++++++ 18 files changed, 578 insertions(+), 30 deletions(-) create mode 100644 src/commands/capabilities/capabilities.ts create mode 100644 src/commands/capabilities/index.ts create mode 100644 src/utils/config-guard.ts create mode 100644 src/utils/exit-codes.ts create mode 100644 tests/unit/commands/capabilities/capabilities.test.ts create mode 100644 tests/unit/utils/config-guard.test.ts create mode 100644 tests/unit/utils/exit-codes.test.ts create mode 100644 tests/unit/utils/no-color.test.ts diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 55277653814..26dac9f5ed8 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -42,11 +42,13 @@ import { 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' @@ -144,13 +146,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.`, ) } @@ -189,6 +192,7 @@ export type BaseOptionValues = { debug?: boolean filter?: string httpProxy?: string + nonInteractive?: boolean silent?: string verbose?: boolean } @@ -252,6 +256,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'), @@ -349,7 +359,7 @@ export default class BaseCommand extends Command { ) } process.stderr.write(` ${bang} Run 'netlify ${this.name()} --help' to see available subcommands.\n`) - exit(1) + exit(EXIT_CODES.USAGE_ERROR) } /** @@ -530,12 +540,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 `', - )} 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 ', + )}, or use ${chalk.cyanBright('`netlify login --request `')} to ask a human for credentials.`, ) } const accessToken = await this.expensivelyAuthenticate() @@ -703,6 +714,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) @@ -934,4 +947,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/capabilities/capabilities.ts b/src/commands/capabilities/capabilities.ts new file mode 100644 index 00000000000..1a9475c906b --- /dev/null +++ b/src/commands/capabilities/capabilities.ts @@ -0,0 +1,118 @@ +import type { Command, Option, OptionValues } from 'commander' + +import { getPathInHome } from '../../lib/settings.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 +} + +const byName = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name) + +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 toCommandManifest = (cmd: Command): CommandManifest => ({ + name: cmd.name(), + description: cmd.description(), + flags: cmd.options.map(toFlagManifest).sort(byName), + json_output: cmd.options.some((option) => option.long === '--json'), + mutates: null, +}) + +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: 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.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/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 de2b090c35c..7faf3b42e34 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -17,7 +17,9 @@ import { USER_AGENT, 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 { @@ -34,6 +36,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' @@ -158,6 +161,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) { @@ -214,7 +218,7 @@ const mainCommand = async function (options, command) { command.outputHelp({ error: true }) 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) => { @@ -240,7 +244,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' }) @@ -256,6 +260,7 @@ export const createMainCommand = (): BaseCommand => { createApiCommand(program) createBlobsCommand(program) createBuildCommand(program) + createCapabilitiesCommand(program) createClaimCommand(program) createCompletionCommand(program) createDeployCommand(program) @@ -302,6 +307,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 }), )} diff --git a/src/utils/command-helpers.ts b/src/utils/command-helpers.ts index 0775c79f230..10444e82837 100644 --- a/src/utils/command-helpers.ts +++ b/src/utils/command-helpers.ts @@ -33,7 +33,10 @@ 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()) 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..31b8e392a17 100644 --- a/src/utils/run-program.ts +++ b/src/utils/run-program.ts @@ -4,6 +4,9 @@ import { injectForceFlagIfScripted } from './scripted-commands.js' import { BaseCommand } from '../commands/index.js' import { CI_FORCED_COMMANDS } from '../commands/main.js' import { exit } from './command-helpers.js' +import { EXIT_CODES } from './exit-codes.js' + +const USAGE_ERROR_CODES = new Set(['commander.unknownCommand', 'commander.unknownOption']) export const runProgram = async (program: BaseCommand, argv: string[]) => { const cmdName = argv[2] @@ -18,6 +21,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/unit/commands/capabilities/capabilities.test.ts b/tests/unit/commands/capabilities/capabilities.test.ts new file mode 100644 index 00000000000..9fb850abca4 --- /dev/null +++ b/tests/unit/commands/capabilities/capabilities.test.ts @@ -0,0 +1,76 @@ +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', async () => { + const manifest = await buildCapabilitiesManifest(createMainCommand()) + + const names = manifest.commands.map((command) => command.name) + expect(names).toEqual([...names].sort((left, right) => left.localeCompare(right))) + + manifest.commands.forEach((command) => { + const flagNames = command.flags.map((flag) => flag.name) + expect(flagNames).toEqual([...flagNames].sort((left, right) => left.localeCompare(right))) + }) + }) + + 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/main-suggestions.test.ts b/tests/unit/commands/main-suggestions.test.ts index 308b201b5ef..5638c2f941b 100644 --- a/tests/unit/commands/main-suggestions.test.ts +++ b/tests/unit/commands/main-suggestions.test.ts @@ -109,14 +109,14 @@ describe('namespace parent commands reject space-form subcommands', () => { vi.restoreAllMocks() }) - test('errors with exit 1 and a colon-form did-you-mean for a known subcommand', () => { + 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(1)') + }).toThrow('exit(2)') const stderr = stderrChunks.join('') expect(stderr).toContain("'netlify sites delete' is not a command") @@ -131,7 +131,7 @@ describe('namespace parent commands reject space-form subcommands', () => { expect(() => { sites.rejectSpaceFormSubcommand() - }).toThrow('exit(1)') + }).toThrow('exit(2)') expect(stderrChunks.join('')).toContain('sites:delete') }) diff --git a/tests/unit/commands/status/status.test.ts b/tests/unit/commands/status/status.test.ts index 9240eb8ed79..9f9b9d0fe2b 100644 --- a/tests/unit/commands/status/status.test.ts +++ b/tests/unit/commands/status/status.test.ts @@ -11,7 +11,7 @@ const { 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), + getToken: (...args: unknown[]) => mockGetToken(...args) as unknown, log: (...args: string[]) => { logMessages.push(args.join(' ')) }, @@ -39,7 +39,7 @@ function createMockCommand(overrides: { siteId?: string } = {}) { netlify: { accounts: [{ name: 'My Team' }], api: { - getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args), + getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args) as unknown, }, globalConfig: { get: vi.fn().mockReturnValue(undefined), 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 .') + }) }) From 16858e8191479401ac2d3a4e6df2f084ba2899bf Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 13:00:34 -0700 Subject: [PATCH 06/11] =?UTF-8?q?fix(cli):=20fresh-eyes=20round=201=20?= =?UTF-8?q?=E2=80=94=20NO=5FCOLOR=20for=20prettyjson=20sites,=20all=20usag?= =?UTF-8?q?e=20errors=20exit=202,=20capabilities=20full-path=20names,=20co?= =?UTF-8?q?lon-form=20hints=20keep=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-ergonomics audit phase 7 round 1. - prettyjson render sites (status, status:hooks, deploy, sites:create, watch) honor NO_COLOR/--json - USAGE_ERROR_CODES covers missingArgument/optionMissingArgument/invalidArgument/etc -> exit 2 - capabilities manifest uses full command paths (database status), deduped, deterministic - space-form suggestions preserve trailing flags ('netlify env list --json' -> suggests 'netlify env:list --json') --- src/commands/capabilities/capabilities.ts | 24 +++++++++++++++++++++-- src/commands/deploy/deploy.ts | 16 +++++++++------ src/commands/sites/sites-create.ts | 23 ++++++++++++++++------ src/commands/status/status-hooks.ts | 4 ++-- src/commands/status/status.ts | 20 +++++++++++-------- src/commands/watch/watch.ts | 13 +++++++----- src/utils/command-error-handler.ts | 19 ++++++++++++++++++ src/utils/command-helpers.ts | 5 +++++ src/utils/run-program.ts | 3 +-- 9 files changed, 96 insertions(+), 31 deletions(-) diff --git a/src/commands/capabilities/capabilities.ts b/src/commands/capabilities/capabilities.ts index 1a9475c906b..38366981f70 100644 --- a/src/commands/capabilities/capabilities.ts +++ b/src/commands/capabilities/capabilities.ts @@ -58,14 +58,34 @@ const collectCommands = (root: Command): Command[] => { 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: cmd.name(), + 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) => { @@ -96,7 +116,7 @@ export const buildCapabilitiesManifest = async (program: Command) => { return { contract_version: CONTRACT_VERSION, cli_version: version, - commands: commands.map(toCommandManifest).sort(byName), + commands: dedupeByName(commands.map(toCommandManifest)).sort(byName), global_flags: getGlobalFlags(commands), exit_codes: EXIT_CODE_DESCRIPTIONS, env_vars: ENV_VARS, 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/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/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 0e396cb3334..7eb1d17732d 100644 --- a/src/commands/status/status.ts +++ b/src/commands/status/status.ts @@ -9,6 +9,7 @@ import { getToken, log, logJson, + prettyJsonRenderOptions, warn, type APIError, } from '../../utils/command-helpers.js' @@ -98,7 +99,7 @@ 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) { @@ -138,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/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 d78c59a5e21..006e83e5796 100644 --- a/src/utils/command-error-handler.ts +++ b/src/utils/command-error-handler.ts @@ -10,6 +10,16 @@ const OPTION_ERROR_CODES = new Set([ 'commander.excessArguments', ]) +/** Every Commander error code caused by bad user input; these exit with `EXIT_CODES.USAGE_ERROR` */ +export const USAGE_ERROR_CODES = new Set([ + ...OPTION_ERROR_CODES, + 'commander.unknownCommand', + 'commander.optionMissingArgument', + 'commander.missingMandatoryOptionValue', + 'commander.invalidArgument', + 'commander.conflictingOption', +]) + const UNKNOWN_OPTION_PATTERN = /unknown option '([^']+)'/ const MAX_FLAG_EDIT_DISTANCE = 2 const MAX_FLAG_SUGGESTIONS = 3 @@ -35,6 +45,15 @@ export const getUnknownOptionSuggestions = (command: Command, errorMessage: stri 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) diff --git a/src/utils/command-helpers.ts b/src/utils/command-helpers.ts index 10444e82837..2878571789e 100644 --- a/src/utils/command-helpers.ts +++ b/src/utils/command-helpers.ts @@ -38,6 +38,11 @@ export const shouldDisableColors = (env: NodeJS.ProcessEnv = process.env): boole 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/run-program.ts b/src/utils/run-program.ts index 31b8e392a17..1b05f34e931 100644 --- a/src/utils/run-program.ts +++ b/src/utils/run-program.ts @@ -3,11 +3,10 @@ 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' -const USAGE_ERROR_CODES = new Set(['commander.unknownCommand', 'commander.unknownOption']) - export const runProgram = async (program: BaseCommand, argv: string[]) => { const cmdName = argv[2] // checks if the command has a force option From 14b3fddfa33db52e2f9bcdd9f05e47d0fcf4b01b Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 13:14:58 -0700 Subject: [PATCH 07/11] fix(capabilities): exit 0 on EPIPE instead of crashing when consumers stop reading early Agent-ergonomics audit phase 7 round 2. 'netlify capabilities | head' hit the uncaughtException crash banner; stdout error listener now treats EPIPE as success. --- src/commands/capabilities/capabilities.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/commands/capabilities/capabilities.ts b/src/commands/capabilities/capabilities.ts index 38366981f70..123ce23b2f1 100644 --- a/src/commands/capabilities/capabilities.ts +++ b/src/commands/capabilities/capabilities.ts @@ -1,6 +1,7 @@ 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' @@ -134,5 +135,11 @@ export const capabilities = async (_options: OptionValues, command: BaseCommand) 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`) } From 94f7132369e577041e8d0b64cc93dcb7f274d363 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 13:24:52 -0700 Subject: [PATCH 08/11] fix(capabilities): locale-independent manifest ordering Agent-ergonomics audit phase 7 round 3. localeCompare collation varies with LANG/LC_ALL; contract-versioned manifest now sorts by code units and the determinism test asserts the same comparator. --- src/commands/capabilities/capabilities.ts | 4 +++- tests/unit/commands/capabilities/capabilities.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/commands/capabilities/capabilities.ts b/src/commands/capabilities/capabilities.ts index 123ce23b2f1..44a23f35f5f 100644 --- a/src/commands/capabilities/capabilities.ts +++ b/src/commands/capabilities/capabilities.ts @@ -39,7 +39,9 @@ interface CommandManifest { mutates: null } -const byName = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name) +// 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, diff --git a/tests/unit/commands/capabilities/capabilities.test.ts b/tests/unit/commands/capabilities/capabilities.test.ts index 9fb850abca4..91a41309b87 100644 --- a/tests/unit/commands/capabilities/capabilities.test.ts +++ b/tests/unit/commands/capabilities/capabilities.test.ts @@ -52,15 +52,16 @@ describe('capabilities', () => { expect(second).toBe(first) }) - test('commands and flags are sorted alphabetically', async () => { + 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((left, right) => left.localeCompare(right))) + expect(names).toEqual([...names].sort(byCodeUnit)) manifest.commands.forEach((command) => { const flagNames = command.flags.map((flag) => flag.name) - expect(flagNames).toEqual([...flagNames].sort((left, right) => left.localeCompare(right))) + expect(flagNames).toEqual([...flagNames].sort(byCodeUnit)) }) }) From a5097fc12d2551d4ecec198a8692f6c8f9f045b3 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 13:40:21 -0700 Subject: [PATCH 09/11] fix(capabilities): exempt from monorepo workspace resolution Agent-ergonomics audit phase 7 round 5. capabilities needs no project context; in a multi-package workspace under CI/--non-interactive the preAction workspace prompt blocked it with exit 4 and empty stdout. --- src/commands/base-command.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 26dac9f5ed8..838bddaf9c1 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -82,6 +82,7 @@ const HELP_SEPARATOR_WIDTH = 5 */ const COMMANDS_WITHOUT_WORKSPACE_OPTIONS = new Set([ 'api', + 'capabilities', 'recipes', 'completion', 'status', From 800ea49c62a1290ae488fce6961131fdbbee7688 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 16:17:19 -0700 Subject: [PATCH 10/11] chore: prettier formatting + generated docs for capabilities command --- docs/commands/capabilities.md | 35 ++++++++++++++++++++ docs/index.md | 4 +++ src/commands/base-command.ts | 12 +++---- src/commands/main.ts | 10 +++--- tests/unit/commands/env/utils.test.ts | 2 +- tests/unit/commands/main-suggestions.test.ts | 4 ++- 6 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 docs/commands/capabilities.md 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/base-command.ts b/src/commands/base-command.ts index 838bddaf9c1..26705ccd51b 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -37,11 +37,7 @@ import { warn, logError, } from '../utils/command-helpers.js' -import { - handleOptionError, - isOptionError, - suggestUnknownOptionAlternatives, -} 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' @@ -545,9 +541,9 @@ export default class BaseCommand extends Command { '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 `')} to ask a human for credentials.`, + )} environment variable or pass ${chalk.cyanBright('--auth ')}, or use ${chalk.cyanBright( + '`netlify login --request `', + )} to ask a human for credentials.`, ) } const accessToken = await this.expensivelyAuthenticate() diff --git a/src/commands/main.ts b/src/commands/main.ts index 7faf3b42e34..9073af0acb4 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -22,11 +22,7 @@ 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, - suggestUnknownOptionAlternatives, -} 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' @@ -204,7 +200,9 @@ const mainCommand = async function (options, command) { command.help() } - process.stderr.write(` ${chalk.yellow(BANG)} Warning: ${chalk.yellow(command.args[0])} is not a ${command.name()} command.\n`) + 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()) diff --git a/tests/unit/commands/env/utils.test.ts b/tests/unit/commands/env/utils.test.ts index f0f1e66ef2f..87b3d8d82bb 100644 --- a/tests/unit/commands/env/utils.test.ts +++ b/tests/unit/commands/env/utils.test.ts @@ -21,7 +21,7 @@ const createMockCommand = ({ linkedSiteId, flagSiteId }: { linkedSiteId?: string site: { id: linkedSiteId }, siteInfo: { id: flagSiteId }, }, - }) as unknown as BaseCommand + } as unknown as BaseCommand) describe('getEnvSiteId', () => { test('returns the linked site id when --site is not passed', () => { diff --git a/tests/unit/commands/main-suggestions.test.ts b/tests/unit/commands/main-suggestions.test.ts index 5638c2f941b..9270861b540 100644 --- a/tests/unit/commands/main-suggestions.test.ts +++ b/tests/unit/commands/main-suggestions.test.ts @@ -50,7 +50,9 @@ describe('getUnknownOptionSuggestions', () => { const lines = getUnknownOptionSuggestions(program, "error: unknown option '--jsno'") - expect(lines).toContain("'--json' is a flag of: agents:create, deploy, env:list, ... (run 'netlify --help')") + 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', () => { From ecc0437418a67a1c361ee364d9d66a08e7230158 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Thu, 11 Jun 2026 16:27:25 -0700 Subject: [PATCH 11/11] test: update integration tests for intentional behavior changes - env:clone unlinked now exits 1 with teaching error (was exit 0 + snapshot) - didyoumean suggestions moved to stderr; assert reason.stderr - help snapshot includes the new exit-codes epilogue --- .../__snapshots__/didyoumean.test.ts.snap | 252 +++++++++++++++++- .../commands/didyoumean/didyoumean.test.ts | 4 +- .../env/__snapshots__/env.test.ts.snap | 2 - tests/integration/commands/env/env.test.ts | 11 +- .../help/__snapshots__/help.test.ts.snap | 73 ++--- 5 files changed, 297 insertions(+), 45 deletions(-) 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"