From a403e1a5696d5f0aa502aea90723307d651e3199 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Fri, 2 Oct 2026 12:36:44 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(cli):=20agent=20contract=20=E2=80=94?= =?UTF-8?q?=20exit=20codes,=20error=20envelope,=20confirmation=20without?= =?UTF-8?q?=20a=20terminal,=20dry=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step [1] of docs/tasks/agent-native-cli.md. - src/lib/exit-codes.ts: the documented exit-code table (0–9), identical with and without --json. No oclif import, so the MCP server can share it. - src/lib/cli-error.ts: CliError + toEnvelope, one shape for every failure (SDK errors, parser errors, JSON input, prompts, bugs): code, message, status, exit, fieldErrors, url, traceId, hint, docs. No oclif import. - BaseCommand.catch prints the envelope (stdout with --json, stderr as text) and exits with the documented code; oclif's own JSON error path (22k-line parse-context dump) never runs. Unknown commands go through a command_not_found hook and get the same envelope. - confirmOrFail: destructive commands (delete, stop, regenerate, block, disable, --many updates) ask in a terminal; without a terminal and without --yes they exit 3 and send nothing; "no" at the prompt exits 9. The old `isTTY` check let `norbix users delete x --- README.md | 51 +++-- package.json | 4 + src/base.ts | 279 ++++++++++++++++++++--- src/commands/api.ts | 5 + src/commands/apikeys/regenerate.ts | 12 +- src/commands/configure.ts | 13 +- src/commands/db/delete.ts | 15 +- src/commands/db/update.ts | 8 +- src/commands/email/delete.ts | 10 +- src/commands/email/stop.ts | 10 +- src/commands/env/delete.ts | 13 +- src/commands/files/delete.ts | 10 +- src/commands/hub.ts | 5 + src/commands/login.ts | 49 +++- src/commands/module.ts | 13 +- src/commands/payments/disable.ts | 10 +- src/commands/push/campaign/delete.ts | 10 +- src/commands/push/delete.ts | 10 +- src/commands/push/disable.ts | 9 +- src/commands/push/integration/delete.ts | 10 +- src/commands/push/integration/disable.ts | 10 +- src/commands/push/stop.ts | 10 +- src/commands/scheduler/delete.ts | 10 +- src/commands/scheduler/disable.ts | 10 +- src/commands/sms/delete.ts | 10 +- src/commands/sms/stop.ts | 10 +- src/commands/users/block.ts | 10 +- src/commands/users/delete.ts | 10 +- src/commands/webhooks/disable.ts | 10 +- src/commands/webhooks/remove.ts | 13 +- src/commands/webhooks/secret.ts | 11 +- src/hooks/command-not-found.ts | 29 +++ src/hooks/init.ts | 10 + src/lib/cli-error.ts | 279 +++++++++++++++++++++++ src/lib/color.ts | 19 ++ src/lib/exit-codes.ts | 118 ++++++++++ src/lib/json.ts | 7 +- src/lib/namespace-command.ts | 20 +- test/files.test.ts | 14 +- tests/push-routes.test.ts | 2 +- tests/push.test.ts | 4 +- 41 files changed, 932 insertions(+), 230 deletions(-) create mode 100644 src/hooks/command-not-found.ts create mode 100644 src/hooks/init.ts create mode 100644 src/lib/cli-error.ts create mode 100644 src/lib/color.ts create mode 100644 src/lib/exit-codes.ts diff --git a/README.md b/README.md index f7f5b4c..4205449 100644 --- a/README.md +++ b/README.md @@ -126,28 +126,47 @@ pipe into `jq`: norbix db find orders --json | jq '.list.items[] | ._id' ``` -## Errors +## Errors and exit codes -A failed call prints one line: the error code, the message, and the HTTP -status. +A failed call prints the message, the details the server gave (code, HTTP +status, URL, trace id, field errors) and what to do next — on **stderr**, so +stdout stays clean for data: ```sh -$ norbix files info nbin_1 a/b.txt - › Error: CM-ERRORS-FILES-016: File not found: "a/b.txt" does not exist in - › Local (nbin_1). (HTTP 404) +$ norbix files info a/b.txt +Error: File not found: "a/b.txt" does not exist in Local (nbin_1). + code: CM-ERRORS-FILES-016 + status: 404 + url: https://nb-eu-germany.api.norbix.ai/v2/files/... +Hint: Check the id, the --env and the --project. List items first (e.g. `norbix files list --json`). +Docs: norbix files info --help ``` -The code and the message are the gateway's own — they come out of -`responseStatus.errors[]`, where the gateway puts them. Before this, every -failure read "Request failed with status 404" with no code. +With `--json` the same information is one JSON document on stdout: +`{"error": {"code", "message", "status", "exit", "fieldErrors", "url", "traceId", "hint", "docs"}}`. -Exit codes do not change: `1` for a failed call, `2` for a check that ran and -came back negative (`norbix files integrations test`). +The exit code tells a script or an agent what went wrong, with or without +`--json`: -**A refusal the gateway answers with HTTP 200** and -`responseStatus.isSuccess = false` now exits non-zero as well, wherever it did -not before — `norbix files publish` / `unpublish` were the two commands that -used to treat it as a success. +| code | meaning | +| --- | --- | +| 0 | success | +| 1 | unexpected / internal error | +| 2 | usage: bad flags or arguments, invalid JSON input, unknown command, missing config | +| 3 | confirmation required — a destructive command ran without a terminal and without `--yes` | +| 4 | not authenticated / auth rejected (401, 403, no key) | +| 5 | not found (404) | +| 6 | validation rejected by the server (400 / 422) | +| 7 | network, timeout, endpoint unreachable | +| 8 | rate limited or server error (429, 5xx) | +| 9 | cancelled by you at a prompt | + +Destructive commands (`delete`, `stop`, `regenerate`, `block`, `disable`, +`--many` updates) ask for confirmation in a terminal. In a script, in CI or +under a coding agent there is nobody to ask, so they **exit 3 and send +nothing** unless you pass `--yes`. Preview any of them with `--dry-run`: +the request is built with your real auth, region and project, printed, and +not sent. ## Versioning @@ -191,7 +210,7 @@ Or pass `--integration ` (env var: `NORBIX_FILES_INTEGRATION_ID`). | `norbix files sign [--expires ]` | Get a temporary web address for the file that anyone with the link can open. | | `norbix files upload [remote]` | Upload a file. Without `remote` it keeps its own name in the root folder. | | `norbix files download [local]` | Download a file. Without `local` it keeps its own name in the current folder. | -| `norbix files delete [--yes]` | Delete one file. Asks first unless you pass `--yes`. | +| `norbix files delete [--yes]` | Delete one file. Asks first unless you pass `--yes`; without a terminal it exits 3 unless `--yes`. | | `norbix files publish [--folder]` | Give the file — or the whole folder — a link anyone can open, and print it. | | `norbix files unpublish [--folder]` | Take that link away again. | | `norbix files integrations test ` | Check that the storage behind an integration really works. See below. | diff --git a/package.json b/package.json index 4c1de79..f93a914 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,10 @@ "webhooks": { "description": "Inspect the webhook integration and its destinations" } + }, + "hooks": { + "init": "./dist/hooks/init.js", + "command_not_found": "./dist/hooks/command-not-found.js" } }, "publishConfig": { diff --git a/src/base.ts b/src/base.ts index 322ec25..911f048 100644 --- a/src/base.ts +++ b/src/base.ts @@ -1,6 +1,8 @@ -import {Norbix, NorbixError} from '@norbix.ai/ts' -import {Command, Flags} from '@oclif/core' +import {Norbix} from '@norbix.ai/ts' +import {Command, Flags, ux} from '@oclif/core' +import {CliError, formatErrorText, toEnvelope, usageError} from './lib/cli-error.js' +import {EXIT} from './lib/exit-codes.js' import { DEFAULT_API_URL, DEFAULT_HUB_URL, @@ -11,6 +13,14 @@ import { } from './lib/profiles.js' import {readStore, type StoredConfig} from './lib/store.js' +/** One link of the SDK's transport middleware chain. */ +type NorbixMiddleware = (ctx: { + url: string + init: RequestInit + attempt: number + next: () => Promise +}) => Promise + /** Stands in for a projectId the SDK constructor insists on; never sent. */ const NO_PROJECT_PLACEHOLDER = 'no-project' @@ -33,6 +43,8 @@ export interface GlobalFlags { 'api-key'?: string account?: string profile?: string + 'dry-run'?: boolean + yes?: boolean } export interface ResolvedContext { @@ -54,6 +66,31 @@ export interface ResolvedContext { stored: StoredConfig } +/** The HTTP request a dry run would have sent (Authorization redacted). */ +export interface DryRunRequest { + method: string + url: string + headers: Record + body?: unknown +} + +/** What `--dry-run` prints: the SDK call and the HTTP request behind it. */ +export interface DryRunReport { + dryRun: true + /** `api.membership.deleteUser` — or a local action like `config.set`. */ + method: string + request: unknown + http?: DryRunRequest +} + +/** Thrown by the dry-run middleware instead of sending; caught in `catch`. */ +class DryRunStop extends Error { + constructor(readonly http: DryRunRequest) { + super('dry run') + this.name = 'DryRunStop' + } +} + /** * Base class for every Norbix CLI command. * @@ -69,6 +106,9 @@ export interface ResolvedContext { * * Default endpoints are https://api.norbix.ai and https://hub.norbix.ai; * a profile can override them (self-hosted, localhost, custom domain). + * + * Agent contract (docs/agent-contract.md): never hang, never act silently, + * one JSON document on stdout with --json, documented exit codes. */ export abstract class BaseCommand extends Command { static enableJsonFlag = true @@ -106,10 +146,91 @@ export abstract class BaseCommand extends Command { }), } + /** Flags of a command that changes something but needs no confirmation (create, update, archive). */ + static dryRunFlags = { + 'dry-run': Flags.boolean({ + description: 'Resolve context and print the request that would be sent; send nothing', + default: false, + }), + } + + /** Flags of a destructive command (delete, stop, regenerate, block, disable): confirmation + dry run. */ + static mutatingFlags = { + yes: Flags.boolean({ + char: 'y', + description: 'Skip the confirmation prompt (required in non-interactive shells)', + default: false, + }), + ...BaseCommand.dryRunFlags, + } + + /** The SDK call captured by a dry run, filled by the client proxy. */ + private dryRunCall?: {method: string; request: unknown} + protected readStore(): StoredConfig { return readStore(this.config.configDir) } + /** The id of this command as the user types it: `users delete`. */ + protected get commandName(): string { + return (this.id ?? '').replaceAll(':', ' ') + } + + /** + * True only when a human can answer a prompt: both stdout and stdin are a + * terminal, not CI, not --json. Anything else is an agent or a script. + */ + protected isInteractive(): boolean { + return Boolean(process.stdout.isTTY && process.stdin.isTTY) && !process.env.CI && !this.jsonEnabled() + } + + /** + * Ask before a destructive call. `--yes` skips it; a dry run needs none; + * a non-interactive shell without `--yes` fails with exit 3 and sends + * nothing; "no" at the prompt exits 9. + */ + protected async confirmOrFail(message: string, flags: {yes?: boolean; 'dry-run'?: boolean}): Promise { + if (flags.yes || flags['dry-run']) return + if (!this.isInteractive()) { + throw new CliError({ + exit: EXIT.CONFIRMATION_REQUIRED, + code: 'CONFIRMATION_REQUIRED', + message: `Confirmation required: ${message}`, + hint: 'Re-run with --yes. Preview first with --dry-run.', + docs: `norbix ${this.commandName} --help`, + }) + } + + const {confirm} = await import('@inquirer/prompts') + if (!(await confirm({message, default: false}))) { + throw new CliError({exit: EXIT.CANCELLED, code: 'CANCELLED', message: 'Cancelled.'}) + } + } + + /** + * Report a dry run of a LOCAL action (config write, file write) — SDK calls + * are captured automatically by `client()`. Prints the report and returns + * it, so the command can `return this.dryRun(...)`. + */ + protected dryRun(call: {method: string; request: unknown; http?: DryRunRequest}): DryRunReport { + const report: DryRunReport = {dryRun: true, method: call.method, request: call.request} + if (call.http) report.http = call.http + this.printDryRun(report) + return report + } + + private printDryRun(report: DryRunReport): void { + if (this.jsonEnabled()) return // oclif prints the returned value + const lines = [`Dry run — nothing was sent.`, `Would call: ${report.method}`] + if (report.http) { + lines.push(` ${report.http.method} ${report.http.url}`) + if (report.http.body !== undefined) lines.push(` body: ${JSON.stringify(report.http.body)}`) + } + + lines.push(`Request: ${JSON.stringify(report.request, null, 2)}`) + this.log(lines.join('\n')) + } + protected resolveContext(flags: GlobalFlags): ResolvedContext { const profiles = readProfiles() const legacy = this.readStore() @@ -123,9 +244,10 @@ export abstract class BaseCommand extends Command { prof = profiles[explicitProfile] ?? {} profileName = explicitProfile if (!profiles[explicitProfile]) { - this.error( - `Profile "${explicitProfile}" not found in ~/.norbix/config.\n` + - `Run \`norbix configure --profile ${explicitProfile}\` to create it, or \`norbix profiles\` to list existing ones.`, + throw usageError( + `Profile "${explicitProfile}" not found in ~/.norbix/config.`, + `Run \`norbix configure --profile ${explicitProfile}\` to create it, or \`norbix profiles\` to list existing ones.`, + 'norbix profiles --help', ) } } else { @@ -195,10 +317,11 @@ export abstract class BaseCommand extends Command { */ protected assertEndpoints(ctx: ResolvedContext): void { if (ctx.usesDefaultEndpoints && !ctx.region) { - this.error( - 'Region is required when using the default norbix.ai endpoints.\n' + - 'Set it with `norbix configure` (region field), pass --region (e.g. nb-eu-germany),\n' + + throw usageError( + 'Region is required when using the default norbix.ai endpoints.', + 'Set it with `norbix configure` (region field), pass --region (e.g. nb-eu-germany), ' + 'or set custom api_url / hub_url in the profile for self-hosted installations.', + 'norbix configure --help', ) } } @@ -211,6 +334,11 @@ export abstract class BaseCommand extends Command { * `requireProject: false` — the call does not need a project (a signed link carries it). * The SDK constructor still demands a projectId, so a placeholder is passed and the * project headers are taken off the wire again: the gateway never sees a fake project. + * + * With `--dry-run` the client is real up to the socket: auth, region and + * project are resolved exactly as for a live call, the SDK builds the + * request, and a middleware stops it just before `fetch`. `catch` then + * prints the request and exits 0. */ protected client( flags: GlobalFlags, @@ -220,21 +348,30 @@ export abstract class BaseCommand extends Command { this.assertEndpoints(ctx) const noProject = !ctx.projectId && opts.requireProject === false if (!ctx.projectId && !noProject) { - this.error( - 'No project ID configured.\nRun `norbix configure` (or `norbix login`), or pass --project.', + throw usageError( + 'No project ID configured.', + 'Run `norbix configure` (or `norbix login`), pass --project , or set NORBIX_PROJECT_ID.', + 'norbix configure --help', ) } if (opts.requireAuth !== false && !ctx.apiKey && !ctx.bearerToken) { - this.error( - 'Not authenticated.\nRun `norbix login` (browser/user session) or `norbix configure` (API key profile), or pass --api-key.', - ) + throw new CliError({ + exit: EXIT.AUTH, + code: 'UNAUTHENTICATED', + message: 'Not authenticated.', + hint: 'Run `norbix login` (browser/user session) or `norbix configure` (API key profile), pass --api-key, or set NORBIX_API_KEY.', + docs: 'norbix login --help', + }) } - return new Norbix( + const middleware: NorbixMiddleware[] = noProject ? [dropProjectHeaders] : [] + if (flags['dry-run']) middleware.push(dryRunMiddleware) + + const client = new Norbix( { projectId: noProject ? NO_PROJECT_PLACEHOLDER : ctx.projectId, - middleware: noProject ? [dropProjectHeaders] : [], + middleware, accountId: ctx.accountId, apiKey: ctx.apiKey, bearerToken: ctx.bearerToken, @@ -243,10 +380,42 @@ export abstract class BaseCommand extends Command { // Always explicit: CLI defaults are api/hub.norbix.ai (the SDK's own // defaults still point at .dev — tracked as an SDK bug). baseUrl: {api: ctx.apiUrl, hub: ctx.hubUrl}, + // A dry run stops in the middleware; the SDK must not retry it. + ...(flags['dry-run'] ? {retry: {maxRetries: 0}} : {}), }, // The CLI already resolved env vars itself — don't let the SDK re-read them. {envSource: {}}, ) + + return flags['dry-run'] ? this.recordingClient(client) : client + } + + /** Wrap `client.api.*` / `client.hub.*` so a dry run knows which SDK method was called. */ + private recordingClient(client: Norbix): Norbix { + const record = (target: 'api' | 'hub') => + new Proxy(client[target] as unknown as Record, { + get: (namespace, moduleName: string) => { + const mod = namespace[moduleName] + if (typeof mod !== 'object' || mod === null) return mod + return new Proxy(mod as Record, { + get: (m, methodName: string) => { + const fn = m[methodName] + if (typeof fn !== 'function') return fn + return (request: unknown = {}, ...rest: unknown[]) => { + this.dryRunCall = {method: `${target}.${moduleName}.${methodName}`, request} + return (fn as (...a: unknown[]) => unknown)(request, ...rest) + } + }, + }) + }, + }) + + return new Proxy(client, { + get: (c, prop: string | symbol) => { + if (prop === 'api' || prop === 'hub') return record(prop) + return (c as unknown as Record)[prop] + }, + }) } /** Pretty-print a result unless --json is active (oclif prints the return value then). */ @@ -256,27 +425,73 @@ export abstract class BaseCommand extends Command { } } + /** Plain JSON on stdout: no theme, no ANSI codes, one document. */ + protected logJson(json: unknown): void { + ux.stdout(JSON.stringify(json, null, 2)) + } + /** - * One line for a failed call: `: (HTTP )`. - * - * The code comes first because it is the part a reader can search for and a - * script can match on. It is the gateway's own code — the SDK reads it out - * of `responseStatus.errors[]`, where the gateway puts it (10b-files slice - * ERRORS, issue #66). The exit codes do not change. + * Every failure ends here and becomes the one error envelope + * (docs/agent-contract.md): `{"error": {...}}` on stdout with --json, + * `Error: ... / Hint: ...` on stderr otherwise. The exit code is the same + * in both modes. A dry run is "caught" here too: it is the middleware + * stopping the request, and exits 0. */ protected async catch(error: Error & {exitCode?: number}): Promise { - if (error instanceof NorbixError) { - const {code, status} = error as {code?: string; status?: number} - const prefix = code ? `${code}: ` : '' - // The SDK's last-resort message already ends in "(HTTP )" — - // do not say it twice. - const suffix = - status && !error.message.includes(`(HTTP ${status})`) ? ` (HTTP ${status})` : '' - const hint = - status === 401 ? '\nYour session may have expired. Run `norbix login` again.' : '' - return this.error(`${prefix}${error.message}${suffix}${hint}`) + // The SDK wraps whatever its middleware throws into a network error, so + // the dry-run stop arrives as `raw` of that wrapper. + const stop = error instanceof DryRunStop ? error : dryRunStopOf(error) + if (stop) { + const report: DryRunReport = { + dryRun: true, + method: this.dryRunCall?.method ?? 'unknown', + request: this.dryRunCall?.request ?? {}, + http: stop.http, + } + if (this.jsonEnabled()) this.logJson(report) + else this.printDryRun(report) + return report + } + + // `this.exit(n)` — already decided, nothing to print. + if ((error as {code?: string}).code === 'EEXIT') throw error + + const envelope = toEnvelope(error, {command: this.commandName}) + if (this.jsonEnabled()) { + this.logJson({error: envelope}) + } else { + process.stderr.write(formatErrorText(envelope) + '\n') } - return super.catch(error) + // Rethrow the original error with the exit code attached: oclif's + // handler then exits with it and prints nothing more (the envelope was + // the output), and a test still sees the real error. + const marked = error as Error & {oclif?: {exit?: number}; skipOclifErrorHandling?: boolean} + marked.oclif = {...marked.oclif, exit: envelope.exit} + marked.skipOclifErrorHandling = true + throw marked } } + +function dryRunStopOf(error: unknown): DryRunStop | undefined { + const raw = (error as {raw?: unknown} | undefined)?.raw + return raw instanceof DryRunStop ? raw : undefined +} + +/** Stops the SDK just before `fetch` and hands the request back to `catch`. */ +async function dryRunMiddleware(ctx: {url: string; init: RequestInit; next: () => Promise}): Promise { + const headers: Record = {} + new Headers(ctx.init.headers).forEach((value, key) => { + headers[key] = key.toLowerCase() === 'authorization' ? 'Bearer ***' : value + }) + let body: unknown + if (typeof ctx.init.body === 'string') { + try { + body = JSON.parse(ctx.init.body) + } catch { + body = ctx.init.body + } + } + + throw new DryRunStop({method: ctx.init.method ?? 'GET', url: ctx.url, headers, body}) +} diff --git a/src/commands/api.ts b/src/commands/api.ts index 8d23645..4bbcf1b 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -1,3 +1,4 @@ +import {BaseCommand} from '../base.js' import {NamespaceCommand} from '../lib/namespace-command.js' export default class ApiNs extends NamespaceCommand { @@ -11,6 +12,10 @@ use \`norbix raw\`.` static strict = false + static flags = { + ...BaseCommand.mutatingFlags, + } + static examples = [ '<%= config.bin %> api # list modules', '<%= config.bin %> api database # list database methods', diff --git a/src/commands/apikeys/regenerate.ts b/src/commands/apikeys/regenerate.ts index 3dec5d1..7412c54 100644 --- a/src/commands/apikeys/regenerate.ts +++ b/src/commands/apikeys/regenerate.ts @@ -1,5 +1,3 @@ -import {confirm} from '@inquirer/prompts' -import {Flags} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -9,20 +7,14 @@ export default class ApikeysRegenerate extends BaseCommand { static examples = ['<%= config.bin %> apikeys regenerate --yes'] static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {flags} = await this.parse(ApikeysRegenerate) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({ - message: 'Regenerate API keys? Every service using the old keys will lose access.', - default: false, - }) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail('Regenerate API keys? Every service using the old keys will lose access.', flags) const res = await client.api.apikeys.regenerateApiKeys({environment: flags.env}) this.print(res) diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 087ea97..8c7dc97 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -1,7 +1,7 @@ -import {input, password as passwordPrompt} from '@inquirer/prompts' import {Flags} from '@oclif/core' import {BaseCommand} from '../base.js' +import {usageError} from '../lib/cli-error.js' import { DEFAULT_API_URL, DEFAULT_HUB_URL, @@ -35,6 +35,17 @@ environment. Use --profile to create or edit a named profile; without it the const name = flags.profile ?? 'default' const existing = readProfiles()[name] ?? {} + if (!this.isInteractive()) { + throw usageError( + 'configure is interactive and this shell is not.', + `Write the profile without prompts: norbix login --api-key --project --region --profile ${name}; ` + + 'or set NORBIX_API_KEY / NORBIX_PROJECT_ID / NORBIX_REGION in the environment.', + 'norbix login --help', + ) + } + + const {input, password: passwordPrompt} = await import('@inquirer/prompts') + this.log(`Configuring profile [${name}] in ${PROFILES_PATH}`) // Same interaction style as `aws configure`: show the current value diff --git a/src/commands/db/delete.ts b/src/commands/db/delete.ts index b4dc978..2c11370 100644 --- a/src/commands/db/delete.ts +++ b/src/commands/db/delete.ts @@ -1,4 +1,3 @@ -import {confirm} from '@inquirer/prompts' import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -8,7 +7,8 @@ export default class DbDelete extends BaseCommand { static description = 'Delete records: one by --id, or many by --filter with --many' static examples = [ - '<%= config.bin %> db delete orders --id 66b2f0a1...', + '<%= config.bin %> db delete orders --id 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run', + '<%= config.bin %> db delete orders --id 66b2f0a1c3d4e5f6a7b8c9d0 --yes', `<%= config.bin %> db delete orders --filter '{"status":"cancelled"}' --many --yes`, ] @@ -20,7 +20,7 @@ export default class DbDelete extends BaseCommand { id: Flags.string({description: 'Record ID (single delete)', exclusive: ['many', 'filter']}), filter: Flags.string({char: 'f', description: 'JSON filter (with --many)', dependsOn: ['many']}), many: Flags.boolean({description: 'Delete every record matching --filter', default: false}), - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { @@ -30,13 +30,7 @@ export default class DbDelete extends BaseCommand { if (flags.many) { if (!flags.filter) this.error('--many requires --filter.') const filter = await readJsonInput(flags.filter, 'filter') - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({ - message: `Delete ALL records in "${args.collection}" matching ${filter}?`, - default: false, - }) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Delete ALL records in "${args.collection}" matching ${filter}?`, flags) const res = await client.api.database.deleteMany({collectionName: args.collection, filter}) this.print(res) @@ -44,6 +38,7 @@ export default class DbDelete extends BaseCommand { } if (!flags.id) this.error('Pass --id for a single delete, or --filter with --many.') + await this.confirmOrFail(`Delete record ${flags.id} from "${args.collection}"?`, flags) const res = await client.api.database.deleteOne({collectionName: args.collection, id: flags.id}) this.print(res) return res diff --git a/src/commands/db/update.ts b/src/commands/db/update.ts index 705ab94..9e2806c 100644 --- a/src/commands/db/update.ts +++ b/src/commands/db/update.ts @@ -8,7 +8,8 @@ export default class DbUpdate extends BaseCommand { static examples = [ `<%= config.bin %> db update orders --id 66b2f0a1... --update '{"$set":{"status":"shipped"}}'`, - `<%= config.bin %> db update orders --filter '{"status":"new"}' --update '{"$set":{"status":"queued"}}' --many`, + `<%= config.bin %> db update orders --filter '{"status":"new"}' --update '{"$set":{"status":"queued"}}' --many --dry-run`, + `<%= config.bin %> db update orders --filter '{"status":"new"}' --update '{"$set":{"status":"queued"}}' --many --yes`, ] static args = { @@ -20,6 +21,7 @@ export default class DbUpdate extends BaseCommand { filter: Flags.string({char: 'f', description: 'JSON filter (with --many)', dependsOn: ['many']}), update: Flags.string({char: 'u', required: true, description: 'JSON update (e.g. {"$set":{...}}) or `-` for stdin'}), many: Flags.boolean({description: 'Update every record matching --filter', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { @@ -29,9 +31,11 @@ export default class DbUpdate extends BaseCommand { if (flags.many) { if (!flags.filter) this.error('--many requires --filter.') + const filter = await readJsonInput(flags.filter, 'filter') + await this.confirmOrFail(`Update ALL records in "${args.collection}" matching ${filter}?`, flags) const res = await client.api.database.updateMany({ collectionName: args.collection, - filter: await readJsonInput(flags.filter, 'filter'), + filter, update, }) this.print(res) diff --git a/src/commands/email/delete.ts b/src/commands/email/delete.ts index 7d4078e..a219dad 100644 --- a/src/commands/email/delete.ts +++ b/src/commands/email/delete.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,17 +12,14 @@ export default class EmailDelete extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(EmailDelete) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Delete email template ${args.id}?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Delete email template ${args.id}?`, flags) const res = await client.hub.notifications.deleteEmailTemplate({id: args.id}) this.print(`Template ${args.id} deleted.`) diff --git a/src/commands/email/stop.ts b/src/commands/email/stop.ts index cb18f2d..98e22d2 100644 --- a/src/commands/email/stop.ts +++ b/src/commands/email/stop.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,17 +12,14 @@ export default class EmailStop extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(EmailStop) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Stop email campaign ${args.id}?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Stop email campaign ${args.id}?`, flags) // stop* methods are newer than SDK 1.2.0 — guard so older SDKs fail nicely. const n = client.hub.notifications as unknown as { diff --git a/src/commands/env/delete.ts b/src/commands/env/delete.ts index edc12b6..328ea55 100644 --- a/src/commands/env/delete.ts +++ b/src/commands/env/delete.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,20 +12,14 @@ export default class EnvDelete extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(EnvDelete) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({ - message: `Delete environment "${args.name}" and everything inside it?`, - default: false, - }) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Delete environment "${args.name}" and everything inside it?`, flags) const res = await client.hub.environments.delete({environmentName: args.name}) this.print(`Environment ${args.name} deleted.`) diff --git a/src/commands/files/delete.ts b/src/commands/files/delete.ts index 103f715..8550690 100644 --- a/src/commands/files/delete.ts +++ b/src/commands/files/delete.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' import {integrationFlag, resolveIntegration} from '../../lib/files.js' @@ -15,7 +14,7 @@ export default class FilesDelete extends BaseCommand { static flags = { integration: integrationFlag, - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { @@ -26,10 +25,7 @@ export default class FilesDelete extends BaseCommand { this.error('No files integration ID. Pass --integration or run `norbix config set filesIntegrationId `.') } - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Delete file "${args.remote}"?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Delete file "${args.remote}"?`, flags) const res = await client.api.files.deleteFileApi({ filesIntegrationId: integration, diff --git a/src/commands/hub.ts b/src/commands/hub.ts index 5864902..3f12f36 100644 --- a/src/commands/hub.ts +++ b/src/commands/hub.ts @@ -1,3 +1,4 @@ +import {BaseCommand} from '../base.js' import {NamespaceCommand} from '../lib/namespace-command.js' export default class Hub extends NamespaceCommand { @@ -12,6 +13,10 @@ positional id) means one item. Destructive verbs ask for confirmation static strict = false + static flags = { + ...BaseCommand.mutatingFlags, + } + static examples = [ '<%= config.bin %> hub # list modules', '<%= config.bin %> hub database # list database methods', diff --git a/src/commands/login.ts b/src/commands/login.ts index fcd17f2..b049e1c 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,8 +1,8 @@ -import {input, password as passwordPrompt} from '@inquirer/prompts' import {Norbix} from '@norbix.ai/ts' import {Flags} from '@oclif/core' import {BaseCommand} from '../base.js' +import {usageError} from '../lib/cli-error.js' import { DeviceFlowUnsupportedError, openBrowser, @@ -24,12 +24,17 @@ support browser login yet, the CLI falls back to user + password. (same as \`norbix configure\`). Commands run with --profile skip the session on purpose and use that -profile's API key.` +profile's API key. + +Non-interactive shells (scripts, CI, coding agents) cannot answer prompts: +pass --api-key (with --project), or --user with --password, or set +NORBIX_API_KEY in the environment. Without one of these the command exits 2.` static examples = [ '<%= config.bin %> login', '<%= config.bin %> login --user alice@example.com', - '<%= config.bin %> login --api-key nbk_... --project 5f1a... --profile ci', + '<%= config.bin %> login --api-key nbk_live_7hK2abc --project 66b2f0a1c3d4e5f6a7b8c9d0 --region nb-eu-germany --profile ci', + '<%= config.bin %> login --user alice@example.com --password "$NORBIX_PASSWORD" --project 66b2f0a1c3d4e5f6a7b8c9d0', ] static flags = { @@ -43,10 +48,19 @@ profile's API key.` const profName = flags.profile ?? 'default' const existing = profiles[profName] ?? {} + // No terminal to answer a prompt: only the flag-driven modes can work. + if (!this.isInteractive() && !flags['api-key'] && !flags.password) { + throw usageError( + 'login needs a terminal to ask for credentials, and this shell is not interactive.', + 'Non-interactive options: --api-key --project (saves a profile); ' + + '--user --password (password login); or set NORBIX_API_KEY and NORBIX_PROJECT_ID in the environment and skip login.', + 'norbix login --help', + ) + } + // Mode 1: API key → saved as a profile (long-lived identity). if (flags['api-key']) { - const projectId = - flags.project ?? existing.project_id ?? (await input({message: 'Project ID:', required: true})) + const projectId = flags.project ?? existing.project_id ?? (await this.ask('Project ID:')) writeProfile(profName, { ...existing, api_key: flags['api-key'], @@ -76,6 +90,21 @@ profile's API key.` return this.passwordLogin(flags, existing) } + /** Prompt for a value — or, with no terminal, fail with the flag to pass instead. */ + private async ask(message: string, secret = false): Promise { + if (!this.isInteractive()) { + const flag = message.startsWith('Project') ? '--project' : message.startsWith('User') ? '--user' : '--password' + throw usageError( + `login needs "${message.replace(/:$/, '')}" and cannot prompt for it in a non-interactive shell.`, + `Pass ${flag} on the command line.`, + 'norbix login --help', + ) + } + + const prompts = await import('@inquirer/prompts') + return secret ? prompts.password({message, mask: '*'}) : prompts.input({message, required: true}) + } + private async browserLogin( hubUrl: string, projectId: string | undefined, @@ -84,7 +113,8 @@ profile's API key.` const start = await startDeviceFlow(hubUrl, projectId) this.log(`First, copy your one-time code: ${start.userCode}`) - if (process.stdout.isTTY) { + if (this.isInteractive()) { + const {input} = await import('@inquirer/prompts') await input({message: 'Press ENTER to open the browser...'}) openBrowser(start.verificationUriComplete ?? start.verificationUri) } @@ -124,11 +154,10 @@ profile's API key.` }, existing: {project_id?: string; account_id?: string; env?: string; region?: string}, ): Promise { - const projectId = - flags.project ?? existing.project_id ?? (await input({message: 'Project ID:', required: true})) + const projectId = flags.project ?? existing.project_id ?? (await this.ask('Project ID:')) const region = flags.region ?? existing.region - const userName = flags.user ?? (await input({message: 'User name (email):', required: true})) - const pwd = flags.password ?? (await passwordPrompt({message: 'Password:', mask: '*'})) + const userName = flags.user ?? (await this.ask('User name (email):')) + const pwd = flags.password ?? (await this.ask('Password:', true)) const ctx = this.resolveContext({...flags, project: projectId, region}) const client = new Norbix( diff --git a/src/commands/module.ts b/src/commands/module.ts index 113aae6..c4c3ca8 100644 --- a/src/commands/module.ts +++ b/src/commands/module.ts @@ -1,6 +1,5 @@ -import {confirm} from '@inquirer/prompts' import {Norbix} from '@norbix.ai/ts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../base.js' @@ -35,19 +34,15 @@ export default class Module extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt (disable only)', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(Module) const client = this.client(flags) - if (args.action === 'disable' && !flags.yes && process.stdout.isTTY) { - const ok = await confirm({ - message: `Disable the "${args.name}" module for this project?`, - default: false, - }) - if (!ok) return this.print('Cancelled.') + if (args.action === 'disable') { + await this.confirmOrFail(`Disable the "${args.name}" module for this project?`, flags) } const res = await MODULES[args.name][args.action as 'enable' | 'disable'](client) diff --git a/src/commands/payments/disable.ts b/src/commands/payments/disable.ts index a0a635d..8c2f1a3 100644 --- a/src/commands/payments/disable.ts +++ b/src/commands/payments/disable.ts @@ -5,15 +5,23 @@ import {BaseCommand} from '../../base.js' export default class PaymentsDisable extends BaseCommand { static description = 'Disable a payment trigger' - static examples = ['<%= config.bin %> payments disable 66b2f0a1...'] + static examples = [ + '<%= config.bin %> payments disable 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run', + '<%= config.bin %> payments disable 66b2f0a1c3d4e5f6a7b8c9d0 --yes', + ] static args = { id: Args.string({required: true, description: 'Trigger ID'}), } + static flags = { + ...BaseCommand.mutatingFlags, + } + async run(): Promise { const {args, flags} = await this.parse(PaymentsDisable) const client = this.client(flags) + await this.confirmOrFail(`Disable payment trigger ${args.id}?`, flags) const res = await client.hub.payments.disablePaymentsTrigger({triggerId: args.id}) this.print(`Trigger ${args.id} disabled.`) diff --git a/src/commands/push/campaign/delete.ts b/src/commands/push/campaign/delete.ts index 8a501c8..093bb94 100644 --- a/src/commands/push/campaign/delete.ts +++ b/src/commands/push/campaign/delete.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../../base.js' @@ -13,17 +12,14 @@ export default class PushCampaignDelete extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(PushCampaignDelete) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Delete push campaign ${args.id}?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Delete push campaign ${args.id}?`, flags) // The route is `/campaigns/{Id}` but the generated request type is empty, // so the id goes in as `Id` — the transport fills tokens by exact name. diff --git a/src/commands/push/delete.ts b/src/commands/push/delete.ts index 8a5ce7f..dbc93d9 100644 --- a/src/commands/push/delete.ts +++ b/src/commands/push/delete.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,17 +12,14 @@ export default class PushDelete extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(PushDelete) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Delete push template ${args.id}?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Delete push template ${args.id}?`, flags) // The route token is `{Id}` and the transport fills tokens by exact name, // so the request must carry `Id`. The generated type calls the field `id`, diff --git a/src/commands/push/disable.ts b/src/commands/push/disable.ts index d01523b..69835aa 100644 --- a/src/commands/push/disable.ts +++ b/src/commands/push/disable.ts @@ -1,5 +1,3 @@ -import {confirm} from '@inquirer/prompts' -import {Flags} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -11,17 +9,14 @@ Run \`push disable-dependencies\` first to see what still depends on push.` static examples = ['<%= config.bin %> push disable --yes'] static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {flags} = await this.parse(PushDisable) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: 'Turn off push for this project?', default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail('Turn off push for this project?', flags) const res = await client.hub.notifications.disablePush({}) diff --git a/src/commands/push/integration/delete.ts b/src/commands/push/integration/delete.ts index 855e4fa..ff8d1dd 100644 --- a/src/commands/push/integration/delete.ts +++ b/src/commands/push/integration/delete.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../../base.js' @@ -13,17 +12,14 @@ export default class PushIntegrationDelete extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(PushIntegrationDelete) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Delete push integration ${args.id}?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Delete push integration ${args.id}?`, flags) // The generated request type calls this field `id`, but the route token is // `{Id}` and the transport fills tokens by exact name — passing `id` diff --git a/src/commands/push/integration/disable.ts b/src/commands/push/integration/disable.ts index 4394491..1df997d 100644 --- a/src/commands/push/integration/disable.ts +++ b/src/commands/push/integration/disable.ts @@ -5,15 +5,23 @@ import {BaseCommand} from '../../../base.js' export default class PushIntegrationDisable extends BaseCommand { static description = 'Turn a push integration off' - static examples = ['<%= config.bin %> push integration disable 66b2f0a1...'] + static examples = [ + '<%= config.bin %> push integration disable 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run', + '<%= config.bin %> push integration disable 66b2f0a1c3d4e5f6a7b8c9d0 --yes', + ] static args = { id: Args.string({required: true, description: 'Integration ID'}), } + static flags = { + ...BaseCommand.mutatingFlags, + } + async run(): Promise { const {args, flags} = await this.parse(PushIntegrationDisable) const client = this.client(flags) + await this.confirmOrFail(`Turn off push integration ${args.id}?`, flags) // The generated request type calls this field `id`, but the route token is // `{Id}` and the transport fills tokens by exact name — passing `id` diff --git a/src/commands/push/stop.ts b/src/commands/push/stop.ts index 784c144..ecbcf82 100644 --- a/src/commands/push/stop.ts +++ b/src/commands/push/stop.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,17 +12,14 @@ export default class PushStop extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(PushStop) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Stop push campaign ${args.id}?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Stop push campaign ${args.id}?`, flags) // The route is `/campaigns/{Id}/stop` and the transport fills tokens by // exact name, but the generated request type has no field at all — so the diff --git a/src/commands/scheduler/delete.ts b/src/commands/scheduler/delete.ts index 05b9ca7..db463a7 100644 --- a/src/commands/scheduler/delete.ts +++ b/src/commands/scheduler/delete.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,17 +12,14 @@ export default class SchedulerDelete extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(SchedulerDelete) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Delete scheduler task ${args.id}?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Delete scheduler task ${args.id}?`, flags) const res = await client.hub.scheduler.deleteSchedulerTask({id: args.id}) this.print(`Task ${args.id} deleted.`) diff --git a/src/commands/scheduler/disable.ts b/src/commands/scheduler/disable.ts index 9b94178..9c8aec3 100644 --- a/src/commands/scheduler/disable.ts +++ b/src/commands/scheduler/disable.ts @@ -5,15 +5,23 @@ import {BaseCommand} from '../../base.js' export default class SchedulerDisable extends BaseCommand { static description = 'Disable a scheduler task' - static examples = ['<%= config.bin %> scheduler disable 66b2f0a1...'] + static examples = [ + '<%= config.bin %> scheduler disable 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run', + '<%= config.bin %> scheduler disable 66b2f0a1c3d4e5f6a7b8c9d0 --yes', + ] static args = { id: Args.string({required: true, description: 'Task ID'}), } + static flags = { + ...BaseCommand.mutatingFlags, + } + async run(): Promise { const {args, flags} = await this.parse(SchedulerDisable) const client = this.client(flags) + await this.confirmOrFail(`Disable scheduler task ${args.id}?`, flags) const res = await client.hub.scheduler.disableSchedulerTask({id: args.id}) this.print(`Task ${args.id} disabled.`) diff --git a/src/commands/sms/delete.ts b/src/commands/sms/delete.ts index e6f3077..de2be30 100644 --- a/src/commands/sms/delete.ts +++ b/src/commands/sms/delete.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,17 +12,14 @@ export default class SmsDelete extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(SmsDelete) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Delete SMS template ${args.id}?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Delete SMS template ${args.id}?`, flags) const res = await client.hub.notifications.deleteSmsTemplate({id: args.id}) this.print(`Template ${args.id} deleted.`) diff --git a/src/commands/sms/stop.ts b/src/commands/sms/stop.ts index 23698cb..ace4ce7 100644 --- a/src/commands/sms/stop.ts +++ b/src/commands/sms/stop.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,17 +12,14 @@ export default class SmsStop extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(SmsStop) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Stop SMS campaign ${args.id}?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Stop SMS campaign ${args.id}?`, flags) const n = client.hub.notifications as unknown as { stopSmsCampaign?: (r: {id: string}) => Promise diff --git a/src/commands/users/block.ts b/src/commands/users/block.ts index 1ca628e..e1953c3 100644 --- a/src/commands/users/block.ts +++ b/src/commands/users/block.ts @@ -5,15 +5,23 @@ import {BaseCommand} from '../../base.js' export default class UsersBlock extends BaseCommand { static description = 'Block a user (they can no longer log in)' - static examples = ['<%= config.bin %> users block 66b2f0a1...'] + static examples = [ + '<%= config.bin %> users block 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run', + '<%= config.bin %> users block 66b2f0a1c3d4e5f6a7b8c9d0 --yes', + ] static args = { id: Args.string({required: true, description: 'User ID'}), } + static flags = { + ...BaseCommand.mutatingFlags, + } + async run(): Promise { const {args, flags} = await this.parse(UsersBlock) const client = this.client(flags) + await this.confirmOrFail(`Block user ${args.id}? They can no longer log in.`, flags) const res = await client.api.membership.blockUser({id: args.id}) this.print(`User ${args.id} blocked.`) diff --git a/src/commands/users/delete.ts b/src/commands/users/delete.ts index 06892bd..9e74461 100644 --- a/src/commands/users/delete.ts +++ b/src/commands/users/delete.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,17 +12,14 @@ export default class UsersDelete extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(UsersDelete) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({message: `Delete user ${args.id}?`, default: false}) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Delete user ${args.id}?`, flags) const res = await client.api.membership.deleteUser({id: args.id}) this.print(`User ${args.id} deleted.`) diff --git a/src/commands/webhooks/disable.ts b/src/commands/webhooks/disable.ts index 9503b13..7e24615 100644 --- a/src/commands/webhooks/disable.ts +++ b/src/commands/webhooks/disable.ts @@ -5,15 +5,23 @@ import {BaseCommand} from '../../base.js' export default class WebhooksDisable extends BaseCommand { static description = 'Disable a webhook destination' - static examples = ['<%= config.bin %> webhooks disable 66b2f0a1...'] + static examples = [ + '<%= config.bin %> webhooks disable 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run', + '<%= config.bin %> webhooks disable 66b2f0a1c3d4e5f6a7b8c9d0 --yes', + ] static args = { destinationId: Args.string({required: true, description: 'Destination ID'}), } + static flags = { + ...BaseCommand.mutatingFlags, + } + async run(): Promise { const {args, flags} = await this.parse(WebhooksDisable) const client = this.client(flags) + await this.confirmOrFail(`Disable webhook destination ${args.destinationId}?`, flags) const res = await client.hub.webhooks.disableWebhookDestination({ destinationId: args.destinationId, diff --git a/src/commands/webhooks/remove.ts b/src/commands/webhooks/remove.ts index 3266ba5..ffe3168 100644 --- a/src/commands/webhooks/remove.ts +++ b/src/commands/webhooks/remove.ts @@ -1,5 +1,4 @@ -import {confirm} from '@inquirer/prompts' -import {Args, Flags} from '@oclif/core' +import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,20 +12,14 @@ export default class WebhooksRemove extends BaseCommand { } static flags = { - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { const {args, flags} = await this.parse(WebhooksRemove) const client = this.client(flags) - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({ - message: `Remove webhook destination ${args.destinationId}?`, - default: false, - }) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail(`Remove webhook destination ${args.destinationId}?`, flags) const res = await client.hub.webhooks.removeWebhookDestination({ destinationId: args.destinationId, diff --git a/src/commands/webhooks/secret.ts b/src/commands/webhooks/secret.ts index 59e1822..bd939b1 100644 --- a/src/commands/webhooks/secret.ts +++ b/src/commands/webhooks/secret.ts @@ -1,4 +1,3 @@ -import {confirm} from '@inquirer/prompts' import {Flags} from '@oclif/core' import {BaseCommand} from '../../base.js' @@ -13,7 +12,7 @@ export default class WebhooksSecret extends BaseCommand { static flags = { rotate: Flags.boolean({description: 'Rotate the secret (old secret stops working)', default: false}), - yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}), + ...BaseCommand.mutatingFlags, } async run(): Promise { @@ -21,13 +20,7 @@ export default class WebhooksSecret extends BaseCommand { const client = this.client(flags) if (flags.rotate) { - if (!flags.yes && process.stdout.isTTY) { - const ok = await confirm({ - message: 'Rotate the webhook secret? Consumers still verifying with the old secret will fail.', - default: false, - }) - if (!ok) return this.print('Cancelled.') - } + await this.confirmOrFail('Rotate the webhook secret? Consumers still verifying with the old secret will fail.', flags) const res = await client.hub.webhooks.rotateWebhookIntegrationSecret() this.print(res) diff --git a/src/hooks/command-not-found.ts b/src/hooks/command-not-found.ts new file mode 100644 index 0000000..c842124 --- /dev/null +++ b/src/hooks/command-not-found.ts @@ -0,0 +1,29 @@ +import type {Hook} from '@oclif/core' + +import {EXIT} from '../lib/exit-codes.js' +import {formatErrorText, type ErrorEnvelope} from '../lib/cli-error.js' + +/** + * Unknown command: same envelope as every other error, so `--json` callers + * never get oclif's own text. Exit 2 (usage). + */ +const hook: Hook<'command_not_found'> = async function (opts) { + const id = opts.id.replaceAll(':', ' ') + const envelope: ErrorEnvelope = { + code: 'UNKNOWN_COMMAND', + message: `Unknown command "${id}".`, + exit: EXIT.USAGE, + hint: 'List commands with `norbix --help` or `norbix schema --json`. Any SDK endpoint is reachable with `norbix hub ...` / `norbix api ...`.', + docs: 'norbix --help', + } + + if (opts.argv?.includes('--json')) { + process.stdout.write(JSON.stringify({error: envelope}, null, 2) + '\n') + } else { + process.stderr.write(formatErrorText(envelope) + '\n') + } + + this.exit(EXIT.USAGE) +} + +export default hook diff --git a/src/hooks/init.ts b/src/hooks/init.ts new file mode 100644 index 0000000..6ec807d --- /dev/null +++ b/src/hooks/init.ts @@ -0,0 +1,10 @@ +import type {Hook} from '@oclif/core' + +import {disableColorIfNeeded} from '../lib/color.js' + +/** Runs before every command: no ANSI codes with --json, NO_COLOR or a pipe. */ +const hook: Hook<'init'> = async function (opts) { + disableColorIfNeeded(opts.argv) +} + +export default hook diff --git a/src/lib/cli-error.ts b/src/lib/cli-error.ts new file mode 100644 index 0000000..a4bf04c --- /dev/null +++ b/src/lib/cli-error.ts @@ -0,0 +1,279 @@ +import {EXIT, codeForExit, exitForStatus, type ExitCode} from './exit-codes.js' + +/** + * One error shape for the whole CLI: `{"error": {...}}` on stdout in JSON + * mode, `Error: ... / Hint: ...` on stderr in text mode. Every failure + * (SDK, parser, JSON input, prompt, bug) goes through `toEnvelope`. + * + * No oclif import here on purpose: the MCP server shares this envelope. + */ + +export interface ErrorEnvelope { + /** Machine code: the SDK / gateway code when there is one, else derived. */ + code: string + message: string + /** HTTP status when the server answered. */ + status?: number + exit: ExitCode + /** Field → messages, only when the server named fields. */ + fieldErrors?: Record + /** The URL that was called, when known. */ + url?: string + /** Server trace / correlation id, when the server returned one. */ + traceId?: string + /** What to do next. */ + hint?: string + /** Where to read more: a URL or `norbix --help`. */ + docs?: string +} + +export interface CliErrorInit { + exit: ExitCode + code?: string + message: string + hint?: string + docs?: string + status?: number + fieldErrors?: Record + url?: string + traceId?: string +} + +/** An error the CLI raises itself, already carrying its exit code and hint. */ +export class CliError extends Error { + readonly exit: ExitCode + readonly code: string + readonly hint?: string + readonly docs?: string + readonly status?: number + readonly fieldErrors?: Record + readonly url?: string + readonly traceId?: string + + constructor(init: CliErrorInit) { + super(init.message) + this.name = 'CliError' + this.exit = init.exit + this.code = init.code ?? codeForExit(init.exit) + this.hint = init.hint + this.docs = init.docs + this.status = init.status + this.fieldErrors = init.fieldErrors + this.url = init.url + this.traceId = init.traceId + } +} + +/** Shortcut for the most common CLI-raised error: a usage problem (exit 2). */ +export function usageError(message: string, hint?: string, docs?: string): CliError { + return new CliError({exit: EXIT.USAGE, code: 'USAGE_ERROR', message, hint, docs}) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +/** `responseStatus` block of a gateway body, whatever the casing of the key. */ +function responseStatusOf(raw: unknown): Record | undefined { + if (!isRecord(raw)) return undefined + for (const key of Object.keys(raw)) { + if (key.toLowerCase() === 'responsestatus' && isRecord(raw[key])) return raw[key] as Record + } + + return undefined +} + +/** Trace id from wherever the gateway puts it (top level or responseStatus/meta). */ +export function traceIdOf(raw: unknown): string | undefined { + const rs = responseStatusOf(raw) + const meta = isRecord(rs?.meta) ? rs.meta : undefined + const top = isRecord(raw) ? raw : undefined + return ( + text(top?.traceId) ?? + text(top?.correlationId) ?? + text(rs?.traceId) ?? + text(rs?.correlationId) ?? + text(meta?.traceId) ?? + text(meta?.correlationId) + ) +} + +/** + * Field errors as `{field: [messages]}` from both shapes the gateway uses: + * an `errors` array of `{fieldName, message}` items (ServiceStack) or an + * `errors` object keyed by field. + */ +export function fieldErrorsOf(raw: unknown, items?: Array<{fieldName?: string; message?: string; errorCode?: string}>): Record | undefined { + const out: Record = {} + for (const item of items ?? []) { + if (!item.fieldName) continue + ;(out[item.fieldName] ??= []).push(item.message ?? item.errorCode ?? 'invalid') + } + + const source = responseStatusOf(raw) ?? (isRecord(raw) ? raw : undefined) + const errors = source?.errors + if (isRecord(errors)) { + for (const [field, value] of Object.entries(errors)) { + const messages = Array.isArray(value) ? value.map(String) : [String(value)] + out[field] = [...(out[field] ?? []), ...messages] + } + } + + return Object.keys(out).length > 0 ? out : undefined +} + +interface NorbixErrorLike { + name: string + message: string + status?: number + code?: string + fieldErrors?: Array<{fieldName?: string; message?: string; errorCode?: string}> + raw?: unknown + url?: string +} + +/** Duck-typed: the SDK's NorbixError family, without importing the SDK here. */ +function isNorbixError(err: unknown): err is NorbixErrorLike { + return err instanceof Error && typeof (err as {status?: unknown}).status === 'number' && err.name.startsWith('Norbix') +} + +const SDK_LOCAL_CODES: Record = { + NORBIX_NOT_AUTHENTICATED: EXIT.AUTH, + NORBIX_ACCOUNT_SCOPE_REQUIRED: EXIT.USAGE, + NORBIX_MISSING_PATH_PARAM: EXIT.USAGE, +} + +function hintForExit(exit: ExitCode, env: {status?: number; url?: string}, command?: string): string { + const help = command ? `norbix ${command} --help` : 'norbix --help' + switch (exit) { + case EXIT.AUTH: + return env.status === 403 + ? 'The key is valid but has no permission for this call. Use a key with the right permissions, or --profile .' + : 'Not authenticated or the session expired. Run `norbix login`, pass --api-key, or use --profile .' + case EXIT.NOT_FOUND: + return 'Check the id, the --env and the --project. List items first (e.g. `norbix users list --json`).' + case EXIT.VALIDATION: + return `The server rejected the request. fieldErrors names the fields; see ${help}.` + case EXIT.NETWORK: + return `Could not reach ${env.url ?? 'the endpoint'}. Check --region, api_url / hub_url in the profile, and connectivity.` + case EXIT.SERVER: + return 'Retry with backoff. If it keeps failing, report it with the traceId.' + case EXIT.USAGE: + return `Run ${help} or \`norbix schema ${command ?? ''} --json\`.`.replace(' ', ' ') + default: + return 'Unexpected error. Re-run with --json for details and report it with the traceId.' + } +} + +/** Map any thrown value to the one error envelope. */ +export function toEnvelope(err: unknown, ctx: {command?: string} = {}): ErrorEnvelope { + const docs = ctx.command ? `norbix ${ctx.command} --help` : 'norbix --help' + + if (err instanceof CliError) { + return compact({ + code: err.code, + message: err.message, + status: err.status, + exit: err.exit, + fieldErrors: err.fieldErrors, + url: err.url, + traceId: err.traceId, + hint: err.hint ?? hintForExit(err.exit, {status: err.status, url: err.url}, ctx.command), + docs: err.docs ?? docs, + }) + } + + if (isNorbixError(err)) { + const isNetwork = err.name === 'NorbixNetworkError' || err.name === 'NorbixTimeoutError' + const exit: ExitCode = isNetwork + ? EXIT.NETWORK + : (err.code && SDK_LOCAL_CODES[err.code]) || exitForStatus(err.status === 0 ? undefined : err.status) + // The SDK reads `errorCode`; some answers carry `code` instead. + const bodyCode = isRecord(err.raw) ? (text(err.raw.code) ?? text(err.raw.errorCode)) : undefined + const code = err.code ?? bodyCode ?? (isNetwork && err.name === 'NorbixTimeoutError' ? 'TIMEOUT' : codeForExit(exit)) + return compact({ + code, + message: err.message, + status: err.status && err.status > 0 ? err.status : undefined, + exit, + fieldErrors: fieldErrorsOf(err.raw, err.fieldErrors), + url: err.url, + traceId: traceIdOf(err.raw), + hint: hintForExit(exit, {status: err.status, url: err.url}, ctx.command), + docs, + }) + } + + if (err instanceof Error) { + const anyErr = err as Error & {oclif?: {exit?: number}; code?: string; parse?: unknown} + // Ctrl+C at an @inquirer prompt. + if (err.name === 'ExitPromptError') { + return compact({code: 'CANCELLED', message: 'Cancelled.', exit: EXIT.CANCELLED}) + } + + // oclif parser errors (missing arg, unknown flag, bad option) and every + // `this.error(...)` call: usage, exit 2. + if (anyErr.parse !== undefined || anyErr.oclif?.exit === 2 || /Error$/.test(err.name) && anyErr.oclif !== undefined) { + return compact({ + code: 'USAGE_ERROR', + message: cleanParserMessage(err.message), + exit: EXIT.USAGE, + hint: hintForExit(EXIT.USAGE, {}, ctx.command), + docs, + }) + } + + return compact({ + code: 'INTERNAL_ERROR', + message: err.message || String(err), + exit: EXIT.INTERNAL, + hint: hintForExit(EXIT.INTERNAL, {}, ctx.command), + docs, + }) + } + + return compact({ + code: 'INTERNAL_ERROR', + message: String(err), + exit: EXIT.INTERNAL, + hint: hintForExit(EXIT.INTERNAL, {}, ctx.command), + docs, + }) +} + +/** oclif's parser wraps its message in boilerplate the envelope already covers. */ +function cleanParserMessage(message: string): string { + return message + .replace(/^The following errors? occurred:\s*/, '') + .replace(/\s*See more help with --help\s*$/, '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join('\n') +} + +/** Text rendering for stderr (no ANSI codes). */ +export function formatErrorText(env: ErrorEnvelope): string { + const lines = [`Error: ${env.message}`] + const details: string[] = [] + if (env.code && env.code !== 'INTERNAL_ERROR' && env.code !== 'USAGE_ERROR') details.push(`code: ${env.code}`) + if (env.status !== undefined) details.push(`status: ${env.status}`) + if (env.url) details.push(`url: ${env.url}`) + if (env.traceId) details.push(`traceId: ${env.traceId}`) + if (env.fieldErrors) { + for (const [field, messages] of Object.entries(env.fieldErrors)) details.push(`${field}: ${messages.join('; ')}`) + } + + for (const d of details) lines.push(` ${d}`) + if (env.hint) lines.push(`Hint: ${env.hint}`) + if (env.docs) lines.push(`Docs: ${env.docs}`) + return lines.join('\n') +} + +function compact(env: ErrorEnvelope): ErrorEnvelope { + return Object.fromEntries(Object.entries(env).filter(([, v]) => v !== undefined)) as unknown as ErrorEnvelope +} diff --git a/src/lib/color.ts b/src/lib/color.ts new file mode 100644 index 0000000..c768f09 --- /dev/null +++ b/src/lib/color.ts @@ -0,0 +1,19 @@ +/** + * The CLI uses no colour library. This check exists so that stays true in + * the places that could colour (oclif's own help / error theme): no ANSI + * codes with --json, NO_COLOR, or when stdout is not a terminal. + */ +export function colorEnabled(argv: string[] = process.argv.slice(2)): boolean { + if (argv.includes('--json')) return false + if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false + if (process.env.FORCE_COLOR === '0') return false + return Boolean(process.stdout.isTTY) +} + +/** Tell oclif (and anything else that honours the convention) to stay plain. */ +export function disableColorIfNeeded(argv?: string[]): void { + if (!colorEnabled(argv)) { + process.env.FORCE_COLOR = '0' + process.env.NO_COLOR ??= '1' + } +} diff --git a/src/lib/exit-codes.ts b/src/lib/exit-codes.ts new file mode 100644 index 0000000..0e98f0c --- /dev/null +++ b/src/lib/exit-codes.ts @@ -0,0 +1,118 @@ +/** + * Exit codes of the Norbix CLI — the agent contract (docs/agent-contract.md). + * + * No oclif import here on purpose: the MCP server shares this table. + * Codes are identical with and without `--json`. + */ + +export const EXIT = { + OK: 0, + /** Unexpected / internal error — file an issue, include `traceId`. */ + INTERNAL: 1, + /** Usage: bad flags/args, invalid JSON input, unknown command, missing config. */ + USAGE: 2, + /** Confirmation required: non-interactive shell and no `--yes`. */ + CONFIRMATION_REQUIRED: 3, + /** Not authenticated / auth rejected (401, 403, no key). */ + AUTH: 4, + /** Not found (404). */ + NOT_FOUND: 5, + /** Validation rejected by the server (400/422, or a 200 with isSuccess=false). */ + VALIDATION: 6, + /** Network / timeout / endpoint unreachable. */ + NETWORK: 7, + /** Rate limited / server error (429, 5xx). */ + SERVER: 8, + /** Cancelled by the user at a prompt. */ + CANCELLED: 9, +} as const + +export type ExitCode = (typeof EXIT)[keyof typeof EXIT] + +export interface ExitCodeInfo { + code: ExitCode + name: keyof typeof EXIT + meaning: string + hint: string +} + +/** The documented table, in order — `norbix schema --json` prints it. */ +export const EXIT_CODES: ExitCodeInfo[] = [ + {code: EXIT.OK, name: 'OK', meaning: 'success', hint: ''}, + {code: EXIT.INTERNAL, name: 'INTERNAL', meaning: 'unexpected / internal error', hint: 'file an issue, include traceId'}, + { + code: EXIT.USAGE, + name: 'USAGE', + meaning: 'usage: bad flags/args, invalid JSON input, unknown command, missing config', + hint: 'norbix --help / norbix schema --json', + }, + { + code: EXIT.CONFIRMATION_REQUIRED, + name: 'CONFIRMATION_REQUIRED', + meaning: 'confirmation required (non-interactive, no --yes)', + hint: 're-run with --yes after a --dry-run', + }, + { + code: EXIT.AUTH, + name: 'AUTH', + meaning: 'not authenticated / auth rejected (401, 403, no key)', + hint: 'norbix login, --api-key, --profile', + }, + {code: EXIT.NOT_FOUND, name: 'NOT_FOUND', meaning: 'not found (404)', hint: 'check id / env / project'}, + { + code: EXIT.VALIDATION, + name: 'VALIDATION', + meaning: 'validation rejected by server (400/422)', + hint: 'fieldErrors lists the fields', + }, + { + code: EXIT.NETWORK, + name: 'NETWORK', + meaning: 'network / timeout / endpoint unreachable', + hint: 'shows the URL; check --region, api_url, connectivity', + }, + { + code: EXIT.SERVER, + name: 'SERVER', + meaning: 'rate limited / server error (429, 5xx)', + hint: 'retry with backoff; include traceId', + }, + {code: EXIT.CANCELLED, name: 'CANCELLED', meaning: 'cancelled by user at a prompt', hint: ''}, +] + +/** Exit code for an HTTP status the server answered with. */ +export function exitForStatus(status: number | undefined): ExitCode { + if (status === undefined) return EXIT.INTERNAL + if (status === 401 || status === 403) return EXIT.AUTH + if (status === 404) return EXIT.NOT_FOUND + if (status === 400 || status === 422) return EXIT.VALIDATION + if (status === 429 || (status >= 500 && status <= 599)) return EXIT.SERVER + // A business refusal comes back as HTTP 200 with isSuccess=false. + if (status === 200) return EXIT.VALIDATION + if (status >= 400) return EXIT.VALIDATION + return EXIT.INTERNAL +} + +/** Default machine code for an exit code when the server gave none. */ +export function codeForExit(exit: ExitCode): string { + switch (exit) { + case EXIT.USAGE: + return 'USAGE_ERROR' + case EXIT.CONFIRMATION_REQUIRED: + return 'CONFIRMATION_REQUIRED' + case EXIT.AUTH: + return 'UNAUTHENTICATED' + case EXIT.NOT_FOUND: + return 'NOT_FOUND' + case EXIT.VALIDATION: + return 'VALIDATION_FAILED' + case EXIT.NETWORK: + return 'NETWORK_ERROR' + case EXIT.SERVER: + return 'SERVER_ERROR' + case EXIT.CANCELLED: + return 'CANCELLED' + default: + return 'INTERNAL_ERROR' + } +} diff --git a/src/lib/json.ts b/src/lib/json.ts index 3d20701..fa6ee5a 100644 --- a/src/lib/json.ts +++ b/src/lib/json.ts @@ -1,3 +1,5 @@ +import {usageError} from './cli-error.js' + /** Helpers for JSON flags: accept inline JSON or `-` to read from stdin. */ export async function readJsonInput(value: string, flagName: string): Promise { @@ -7,7 +9,10 @@ export async function readJsonInput(value: string, flagName: string): Promise)[moduleName] const res = await liveModule[method](request) this.print(res) diff --git a/test/files.test.ts b/test/files.test.ts index 720c86f..3f703de 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -487,9 +487,12 @@ describe('norbix files integrations test', () => { it('turns a 403 into the usual error line: code, message, HTTP status', async () => { answer({responseStatus: {errorCode: 'Forbidden', message: 'Missing permission files:create'}}, 403) - const {error} = await runCommand(['files', 'integrations', 'test', INTEGRATION, ...auth]) + const {error, stderr} = await runCommand(['files', 'integrations', 'test', INTEGRATION, ...auth]) - expect(error?.message).toBe('Forbidden: Missing permission files:create (HTTP 403)') + expect(error).toMatchObject({message: 'Missing permission files:create', code: 'Forbidden', status: 403}) + expect(stderr).toContain('Error: Missing permission files:create') + expect(stderr).toContain('code: Forbidden') + expect(stderr).toContain('status: 403') }) /** @@ -511,9 +514,12 @@ describe('norbix files integrations test', () => { 404, ) - const {error} = await runCommand(['files', 'integrations', 'test', INTEGRATION, ...auth]) + const {error, stderr} = await runCommand(['files', 'integrations', 'test', INTEGRATION, ...auth]) - expect(error?.message).toBe('CM-ERRORS-FILES-004: Files integration was not found. (HTTP 404)') + expect(error).toMatchObject({message: 'Files integration was not found.', code: 'CM-ERRORS-FILES-004', status: 404}) + expect(stderr).toContain('Error: Files integration was not found.') + expect(stderr).toContain('code: CM-ERRORS-FILES-004') + expect(stderr).toContain('status: 404') }) it('falls back to a plain line when a 500 body is not JSON', async () => { diff --git a/tests/push-routes.test.ts b/tests/push-routes.test.ts index f851cd4..5b52ef7 100644 --- a/tests/push-routes.test.ts +++ b/tests/push-routes.test.ts @@ -65,7 +65,7 @@ const routes: Array<[string[], string, string]> = [ [['integrations'], 'GET', `${P}/integrations`], [['integration', ID], 'GET', `${P}/integrations/${ID}`], [['integration', 'enable', ID], 'PUT', `${P}/integrations/${ID}/enable`], - [['integration', 'disable', ID], 'PUT', `${P}/integrations/${ID}/disable`], + [['integration', 'disable', ID, '--yes'], 'PUT', `${P}/integrations/${ID}/disable`], [['integration', 'default', ID], 'PUT', `${P}/integrations/${ID}/default`], [['integration', 'delete', ID, '--yes'], 'DELETE', `${P}/integrations/${ID}`], [['integration', 'test', '--integration', ID, '--token', 'dGVzdA==', '--family', 'Ios'], 'POST', `${P}/integrations/test`], diff --git a/tests/push.test.ts b/tests/push.test.ts index 066333f..c95b8f3 100644 --- a/tests/push.test.ts +++ b/tests/push.test.ts @@ -113,7 +113,7 @@ describe('push preview — signed link, no login, no project', () => { it('today the installed SDK still refuses a call with no token', async () => { const {error} = await runOclif(['push', 'preview', '--hash', 'abc.def', '--region', 'nb-eu-germany']) - expect(error?.message).toMatch(/NORBIX_NOT_AUTHENTICATED/) + expect((error as {code?: string} | undefined)?.code).toBe('NORBIX_NOT_AUTHENTICATED') expect(sent).toHaveLength(0) }) }) @@ -142,7 +142,7 @@ describe('push integration state changes', () => { // throws NORBIX_MISSING_PATH_PARAM at runtime, which is why it is pinned. const cases = [ {command: PushIntegrationEnable, method: 'enablePushIntegration', argv: [ID]}, - {command: PushIntegrationDisable, method: 'disablePushIntegration', argv: [ID]}, + {command: PushIntegrationDisable, method: 'disablePushIntegration', argv: [ID, '--yes']}, {command: PushIntegrationDefault, method: 'setPushIntegrationAsDefault', argv: [ID]}, {command: PushIntegrationDelete, method: 'deletePushIntegration', argv: [ID, '--yes']}, ] From e68cf87a65cde92701ff7d1cb806c377e4b78b61 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Fri, 2 Oct 2026 12:36:45 +0300 Subject: [PATCH 2/4] =?UTF-8?q?test(cli):=20agent-mode=20tests=20=E2=80=94?= =?UTF-8?q?=20exit=20codes,=20envelope,=20dry=20run,=20destructive=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step [1] of docs/tasks/agent-native-cli.md. The built CLI runs as a child process (pipes everywhere, like an agent sees it) against a fake gateway on a free port. Covers: every destructive command exits 3 without --yes and sends nothing, runs with --yes; --dry-run prints the call and sends nothing, and still fails on a missing region or key; the JSON envelope and exit code for 404, 400 with fieldErrors, 401, 500, network, usage, unknown command, bad JSON input, unknown profile and login without a terminal — the same exit code with and without --json, stdout one JSON document, stderr empty; no ANSI codes. Plus unit tests for toEnvelope. Co-Authored-By: Claude Fable 5.1 --- tests/_cli.ts | 155 ++++++++++++++++++++++ tests/agent-mode.test.ts | 271 +++++++++++++++++++++++++++++++++++++++ tests/cli-error.test.ts | 142 ++++++++++++++++++++ 3 files changed, 568 insertions(+) create mode 100644 tests/_cli.ts create mode 100644 tests/agent-mode.test.ts create mode 100644 tests/cli-error.test.ts diff --git a/tests/_cli.ts b/tests/_cli.ts new file mode 100644 index 0000000..279e529 --- /dev/null +++ b/tests/_cli.ts @@ -0,0 +1,155 @@ +import {execFile} from 'node:child_process' +import {mkdirSync, mkdtempSync, writeFileSync} from 'node:fs' +import http from 'node:http' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {fileURLToPath} from 'node:url' + +/** + * Test seam for the agent contract: the BUILT CLI is run as a child process + * (stdin, stdout and stderr are pipes — exactly what a coding agent or a + * script sees) against a fake gateway on a free local port. Nothing else is + * contacted. Exit codes, stdout/stderr separation and the JSON envelope are + * observed from the outside, as an agent would observe them. + */ + +export const CLI = fileURLToPath(new URL('../bin/run.js', import.meta.url)) + +export interface Hit { + method: string + url: string + body: string +} + +export interface FakeGateway { + port: number + hits: Hit[] + close: () => Promise +} + +/** A gateway that answers by path: missing→404, bad→400, unauth→401, boom→500, else 200 {ok:true}. */ +export async function startFakeGateway(): Promise { + const hits: Hit[] = [] + const server = http.createServer((req, res) => { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + hits.push({method: req.method ?? '', url: req.url ?? '', body}) + res.setHeader('content-type', 'application/json') + const url = req.url ?? '' + if (url.includes('missing')) { + res.statusCode = 404 + return res.end('{"message":"User not found","code":"USER_NOT_FOUND","traceId":"t-1"}') + } + + if (url.includes('bad')) { + res.statusCode = 400 + return res.end('{"message":"Validation failed","errors":{"email":["is required"]}}') + } + + if (url.includes('unauth')) { + res.statusCode = 401 + return res.end('{"message":"bad key"}') + } + + if (url.includes('boom')) { + res.statusCode = 500 + return res.end('{"responseStatus":{"errorCode":"CM-ERRORS-INTERNAL","message":"boom","meta":{"correlationId":"c-9"}}}') + } + + res.end('{"ok":true}') + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + return { + port, + hits, + close: () => new Promise((resolve) => server.close(() => resolve())), + } +} + +/** + * A HOME with four profiles: + * x → the fake gateway + * y → a closed port (network error) + * noauth → the fake gateway, no api key + * z → default norbix.ai endpoints, no region (context error) + */ +export function makeHome(port: number): string { + const home = mkdtempSync(join(tmpdir(), 'norbix-agent-')) + mkdirSync(join(home, '.norbix'), {recursive: true}) + writeFileSync( + join(home, '.norbix', 'config'), + [ + '[x]', + 'api_key=k', + 'project_id=p', + `api_url=http://127.0.0.1:${port}`, + `hub_url=http://127.0.0.1:${port}`, + '[y]', + 'api_key=k', + 'project_id=p', + 'api_url=http://127.0.0.1:1', + 'hub_url=http://127.0.0.1:1', + '[noauth]', + 'project_id=p', + `api_url=http://127.0.0.1:${port}`, + `hub_url=http://127.0.0.1:${port}`, + '[z]', + 'api_key=k', + 'project_id=p', + '', + ].join('\n'), + ) + return home +} + +export interface CliResult { + code: number + stdout: string + stderr: string +} + +/** Run the built CLI with pipes for every stream (non-interactive). */ +export function cli(home: string, args: string[], env: Record = {}): Promise { + return new Promise((resolve) => { + const child = execFile( + process.execPath, + [CLI, ...args], + { + env: {...process.env, HOME: home, USERPROFILE: home, CI: undefined, NO_COLOR: undefined, FORCE_COLOR: undefined, ...env}, + cwd: home, + maxBuffer: 10 * 1024 * 1024, + }, + (error, stdout, stderr) => { + const code = error && typeof (error as {code?: unknown}).code === 'number' ? ((error as {code: number}).code) : 0 + resolve({code, stdout: String(stdout), stderr: String(stderr)}) + }, + ) + child.stdin?.end() + }) +} + +/** Run jobs with a small concurrency so a loaded machine still finishes. */ +export async function pool(items: T[], limit: number, job: (item: T) => Promise): Promise { + const results: R[] = Array.from({length: items.length}) + let next = 0 + const workers = Array.from({length: Math.min(limit, items.length)}, async () => { + while (next < items.length) { + const i = next++ + results[i] = await job(items[i]) + } + }) + await Promise.all(workers) + return results +} + +/** stdout must be exactly one JSON document. */ +export function parseSingleJson(stdout: string): unknown { + const parsed = JSON.parse(stdout) as unknown + // JSON.parse would already throw on two documents; also refuse leading text. + if (!/^\s*[{[]/.test(stdout)) throw new Error(`stdout is not a JSON document: ${stdout.slice(0, 80)}`) + return parsed +} diff --git a/tests/agent-mode.test.ts b/tests/agent-mode.test.ts new file mode 100644 index 0000000..2e7aec9 --- /dev/null +++ b/tests/agent-mode.test.ts @@ -0,0 +1,271 @@ +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest' + +import {cli, makeHome, parseSingleJson, pool, startFakeGateway, type FakeGateway} from './_cli.js' + +/** + * The agent contract (docs/agent-contract.md), observed from outside the + * process: exit codes, one JSON document on stdout, nothing sent without + * --yes, dry runs that send nothing. + */ + +let gateway: FakeGateway +let home: string + +beforeAll(async () => { + gateway = await startFakeGateway() + home = makeHome(gateway.port) +}) + +afterAll(async () => { + await gateway.close() +}) + +beforeEach(() => { + gateway.hits.length = 0 +}) + +/** Every destructive command, with the arguments it needs — without --yes. */ +const DESTRUCTIVE: string[][] = [ + ['users', 'delete', 'abc123'], + ['users', 'block', 'abc123'], + ['sms', 'delete', 'abc123'], + ['sms', 'stop', 'abc123'], + ['email', 'delete', 'abc123'], + ['email', 'stop', 'abc123'], + ['push', 'delete', 'abc123'], + ['push', 'stop', 'abc123'], + ['push', 'disable'], + ['push', 'campaign', 'delete', 'abc123'], + ['push', 'integration', 'delete', 'abc123'], + ['push', 'integration', 'disable', 'abc123'], + ['files', 'delete', 'invoices/2026/invoice.pdf', '--integration', 'fi_1'], + ['scheduler', 'delete', 'abc123'], + ['scheduler', 'disable', 'abc123'], + ['env', 'delete', 'STAGING'], + ['apikeys', 'regenerate'], + ['webhooks', 'remove', 'abc123'], + ['webhooks', 'disable', 'abc123'], + ['webhooks', 'secret', '--rotate'], + ['payments', 'disable', 'abc123'], + ['module', 'disable', 'sms'], + ['db', 'delete', 'orders', '--id', 'abc123'], + ['db', 'delete', 'orders', '--many', '--filter', '{}'], + ['db', 'update', 'orders', '--many', '--filter', '{}', '--update', '{"$set":{"a":1}}'], + ['api', 'membership', 'user', 'delete', 'abc123'], + ['hub', 'scheduler', 'task', 'delete', 'abc123'], +] + +describe('destructive commands in a non-interactive shell', () => { + it('every one exits 3 without --yes and sends nothing', async () => { + const results = await pool(DESTRUCTIVE, 4, async (argv) => ({argv, ...(await cli(home, [...argv, '--profile', 'x']))})) + const wrong = results.filter((r) => r.code !== 3 || !/--yes/.test(r.stderr) || r.stdout !== '') + expect(wrong.map((r) => `${r.argv.join(' ')} → exit ${r.code}: ${r.stderr.slice(0, 120)}`)).toEqual([]) + expect(gateway.hits).toEqual([]) + }) + + it('every one exits 3 with --json too, with the envelope on stdout', async () => { + const results = await pool(DESTRUCTIVE, 4, async (argv) => ({argv, ...(await cli(home, [...argv, '--profile', 'x', '--json']))})) + for (const r of results) { + expect(r.code, r.argv.join(' ')).toBe(3) + expect(r.stderr, r.argv.join(' ')).toBe('') + const doc = parseSingleJson(r.stdout) as {error: {code: string; exit: number; hint: string}} + expect(doc.error.code).toBe('CONFIRMATION_REQUIRED') + expect(doc.error.exit).toBe(3) + expect(doc.error.hint).toMatch(/--yes/) + } + + expect(gateway.hits).toEqual([]) + }) + + it('every one runs with --yes and reaches the gateway', async () => { + const results = await pool(DESTRUCTIVE, 4, async (argv) => ({argv, ...(await cli(home, [...argv, '--profile', 'x', '--yes']))})) + const wrong = results.filter((r) => r.code !== 0) + expect(wrong.map((r) => `${r.argv.join(' ')} → exit ${r.code}: ${r.stderr.slice(0, 160)}`)).toEqual([]) + expect(gateway.hits.length).toBe(DESTRUCTIVE.length) + }) +}) + +describe('--dry-run', () => { + it('prints the SDK call and the HTTP request, sends nothing, exits 0 (json)', async () => { + const r = await cli(home, ['users', 'delete', 'abc123', '--profile', 'x', '--dry-run', '--json']) + expect(r.code).toBe(0) + expect(r.stderr).toBe('') + const doc = parseSingleJson(r.stdout) as {dryRun: boolean; method: string; request: unknown; http: {method: string; url: string; headers: Record}} + expect(doc.dryRun).toBe(true) + expect(doc.method).toBe('api.membership.deleteUser') + expect(doc.request).toEqual({id: 'abc123'}) + expect(doc.http.method).toBe('DELETE') + expect(doc.http.url).toMatch(/\/v2\/membership\/auth\?id=abc123$/) + expect(doc.http.headers.authorization).toBe('Bearer ***') + expect(gateway.hits).toEqual([]) + }) + + it('prints the SDK call in text mode too', async () => { + const r = await cli(home, ['db', 'update', 'orders', '--many', '--filter', '{}', '--update', '{"$set":{"a":1}}', '--profile', 'x', '--dry-run']) + expect(r.code).toBe(0) + expect(r.stdout).toContain('api.database.updateMany') + expect(r.stdout).toContain('PUT ') + expect(r.stdout).toContain('"collectionName": "orders"') + expect(gateway.hits).toEqual([]) + }) + + it('works for the plain-word hub/api commands', async () => { + const r = await cli(home, ['hub', 'scheduler', 'task', 'delete', 'abc123', '--profile', 'x', '--dry-run', '--json']) + expect(r.code).toBe(0) + const doc = parseSingleJson(r.stdout) as {method: string; request: unknown} + expect(doc.method).toBe('hub.scheduler.deleteSchedulerTask') + expect(doc.request).toEqual({id: 'abc123'}) + expect(gateway.hits).toEqual([]) + }) + + it('still resolves the context: a missing region fails', async () => { + const r = await cli(home, ['users', 'delete', 'abc123', '--profile', 'z', '--dry-run', '--json']) + expect(r.code).toBe(2) + const doc = parseSingleJson(r.stdout) as {error: {message: string}} + expect(doc.error.message).toMatch(/Region is required/) + }) + + it('still resolves the context: missing auth fails with exit 4', async () => { + const r = await cli(home, ['users', 'delete', 'abc123', '--profile', 'noauth', '--dry-run', '--json']) + expect(r.code).toBe(4) + const doc = parseSingleJson(r.stdout) as {error: {code: string}} + expect(doc.error.code).toBe('UNAUTHENTICATED') + }) +}) + +interface Case { + name: string + argv: string[] + exit: number + code: string + check?: (error: Record) => void +} + +const CASES: Case[] = [ + { + name: '404 not found', + argv: ['users', 'get', 'missing', '--profile', 'x'], + exit: 5, + code: 'USER_NOT_FOUND', + check: (e) => { + expect(e.status).toBe(404) + expect(e.traceId).toBe('t-1') + expect(e.url).toMatch(/\/v2\/membership\/auth\/missing$/) + }, + }, + { + name: '400 with field errors', + argv: ['users', 'get', 'bad', '--profile', 'x'], + exit: 6, + code: 'NORBIX_VALIDATION_ERROR', + check: (e) => { + expect(e.status).toBe(400) + expect(e.fieldErrors).toEqual({email: ['is required']}) + }, + }, + { + name: '401 rejected', + argv: ['users', 'get', 'unauth', '--profile', 'x'], + exit: 4, + code: 'NORBIX_AUTH_ERROR', + check: (e) => expect(e.status).toBe(401), + }, + { + name: '500 server error with a trace id', + argv: ['users', 'get', 'boom', '--profile', 'x'], + exit: 8, + code: 'CM-ERRORS-INTERNAL', + check: (e) => { + expect(e.status).toBe(500) + expect(e.traceId).toBe('c-9') + }, + }, + { + name: 'network failure', + argv: ['users', 'list', '--profile', 'y'], + exit: 7, + code: 'NORBIX_NETWORK_ERROR', + check: (e) => expect(e.url).toMatch(/^http:\/\/127\.0\.0\.1:1\//), + }, + { + name: 'usage error: missing required flag', + argv: ['db', 'update', 'orders', '--profile', 'x'], + exit: 2, + code: 'USAGE_ERROR', + check: (e) => expect(e.message).toMatch(/Missing required flag update/), + }, + { + name: 'unknown command', + argv: ['nothing'], + exit: 2, + code: 'UNKNOWN_COMMAND', + }, + { + name: 'bad --filter JSON', + argv: ['db', 'find', 'orders', '--filter', '{bad', '--profile', 'x'], + exit: 2, + code: 'USAGE_ERROR', + check: (e) => expect(e.message).toMatch(/--filter is not valid JSON/), + }, + { + name: 'unknown profile', + argv: ['users', 'list', '--profile', 'nope'], + exit: 2, + code: 'USAGE_ERROR', + }, + { + name: 'login without a terminal', + argv: ['login', '--user', 'alice@example.com'], + exit: 2, + code: 'USAGE_ERROR', + check: (e) => expect(e.hint).toMatch(/--api-key/), + }, +] + +describe('error envelope', () => { + for (const c of CASES) { + it(`${c.name}: --json gives one document, exit ${c.exit}`, async () => { + const r = await cli(home, [...c.argv, '--json']) + expect(r.code).toBe(c.exit) + expect(r.stderr).toBe('') + const doc = parseSingleJson(r.stdout) as {error: Record} + expect(doc.error.code).toBe(c.code) + expect(doc.error.exit).toBe(c.exit) + expect(typeof doc.error.message).toBe('string') + expect(typeof doc.error.hint).toBe('string') + expect(typeof doc.error.docs).toBe('string') + c.check?.(doc.error) + }) + + it(`${c.name}: same exit code without --json, text on stderr only`, async () => { + const r = await cli(home, c.argv) + expect(r.code).toBe(c.exit) + expect(r.stdout).toBe('') + expect(r.stderr).toMatch(/^Error: /) + expect(r.stderr).toMatch(/\nHint: /) + expect(r.stderr).not.toMatch(/\u001B\[/) + expect(r.stderr).not.toMatch(/at .*\.js:\d+/) + }) + } + + it('the usage envelope stays short (no parse-context dump)', async () => { + const r = await cli(home, ['db', 'update', 'orders', '--profile', 'x', '--json']) + expect(r.stdout.split('\n').length).toBeLessThan(30) + }) +}) + +describe('plain output', () => { + it('success with --json is one JSON document and no ANSI codes', async () => { + const r = await cli(home, ['users', 'get', 'ok1', '--profile', 'x', '--json']) + expect(r.code).toBe(0) + expect(r.stderr).toBe('') + expect(parseSingleJson(r.stdout)).toEqual({ok: true}) + expect(r.stdout).not.toMatch(/\u001B\[/) + }) + + it('a destructive command with --yes prints its text line on stdout', async () => { + const r = await cli(home, ['users', 'delete', 'abc123', '--profile', 'x', '--yes']) + expect(r.code).toBe(0) + expect(r.stdout.trim()).toBe('User abc123 deleted.') + }) +}) diff --git a/tests/cli-error.test.ts b/tests/cli-error.test.ts new file mode 100644 index 0000000..9d3e359 --- /dev/null +++ b/tests/cli-error.test.ts @@ -0,0 +1,142 @@ +import {describe, expect, it} from 'vitest' + +import {CliError, fieldErrorsOf, formatErrorText, toEnvelope, traceIdOf, usageError} from '../src/lib/cli-error.js' +import {EXIT, EXIT_CODES, exitForStatus} from '../src/lib/exit-codes.js' + +/** A stand-in for the SDK's NorbixError family (duck-typed by name + status). */ +function sdkError(name: string, init: {message: string; status: number; code?: string; raw?: unknown; url?: string; fieldErrors?: unknown[]}): Error { + const err = Object.assign(new Error(init.message), init) + err.name = name + return err +} + +describe('exit codes', () => { + it('maps HTTP statuses to the documented codes', () => { + expect(exitForStatus(401)).toBe(EXIT.AUTH) + expect(exitForStatus(403)).toBe(EXIT.AUTH) + expect(exitForStatus(404)).toBe(EXIT.NOT_FOUND) + expect(exitForStatus(400)).toBe(EXIT.VALIDATION) + expect(exitForStatus(422)).toBe(EXIT.VALIDATION) + expect(exitForStatus(429)).toBe(EXIT.SERVER) + expect(exitForStatus(503)).toBe(EXIT.SERVER) + expect(exitForStatus(200)).toBe(EXIT.VALIDATION) + }) + + it('documents every code once, 0 to 9', () => { + expect(EXIT_CODES.map((c) => c.code)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + }) +}) + +describe('toEnvelope', () => { + it('passes a CliError through with its hint and docs', () => { + const env = toEnvelope(new CliError({exit: EXIT.CONFIRMATION_REQUIRED, code: 'CONFIRMATION_REQUIRED', message: 'Confirmation required: x', hint: 'Re-run with --yes.'}), {command: 'users delete'}) + expect(env).toEqual({ + code: 'CONFIRMATION_REQUIRED', + message: 'Confirmation required: x', + exit: 3, + hint: 'Re-run with --yes.', + docs: 'norbix users delete --help', + }) + }) + + it('maps a 404 from the SDK, keeping the body code and trace id', () => { + const env = toEnvelope(sdkError('NorbixError', {message: 'User not found', status: 404, raw: {code: 'USER_NOT_FOUND', traceId: 't-1'}, url: 'http://x/u/1'}), {command: 'users get'}) + expect(env.exit).toBe(5) + expect(env.code).toBe('USER_NOT_FOUND') + expect(env.status).toBe(404) + expect(env.traceId).toBe('t-1') + expect(env.url).toBe('http://x/u/1') + expect(env.hint).toMatch(/Check the id/) + }) + + it('maps a network error to exit 7 without a status', () => { + const env = toEnvelope(sdkError('NorbixNetworkError', {message: 'fetch failed', status: 0, code: 'NORBIX_NETWORK_ERROR', url: 'http://localhost:1/v2/x'})) + expect(env.exit).toBe(7) + expect(env.status).toBeUndefined() + expect(env.hint).toContain('http://localhost:1/v2/x') + }) + + it('maps the SDK’s local "not authenticated" to exit 4', () => { + const env = toEnvelope(sdkError('NorbixError', {message: 'Norbix is not authenticated.', status: 0, code: 'NORBIX_NOT_AUTHENTICATED'})) + expect(env.exit).toBe(4) + }) + + it('maps a 403 to exit 4 with the permission hint', () => { + const env = toEnvelope(sdkError('NorbixAuthError', {message: 'Forbidden', status: 403})) + expect(env.exit).toBe(4) + expect(env.hint).toMatch(/permission/) + }) + + it('maps a 500 to exit 8', () => { + expect(toEnvelope(sdkError('NorbixError', {message: 'boom', status: 500})).exit).toBe(8) + }) + + it('cleans the oclif parser message and exits 2', () => { + const err = Object.assign(new Error('The following error occurred:\n Missing required flag update\nSee more help with --help'), {oclif: {exit: 2}, parse: {}}) + const env = toEnvelope(err, {command: 'db update'}) + expect(env).toMatchObject({code: 'USAGE_ERROR', exit: 2, message: 'Missing required flag update', docs: 'norbix db update --help'}) + }) + + it('treats Ctrl+C at a prompt as cancelled, exit 9', () => { + const err = new Error('User force closed the prompt') + err.name = 'ExitPromptError' + expect(toEnvelope(err)).toEqual({code: 'CANCELLED', message: 'Cancelled.', exit: 9}) + }) + + it('maps an unknown error to exit 1', () => { + const env = toEnvelope(new TypeError('x is not a function')) + expect(env).toMatchObject({code: 'INTERNAL_ERROR', exit: 1, message: 'x is not a function'}) + }) + + it('usageError is exit 2', () => { + expect(toEnvelope(usageError('bad', 'do this')).exit).toBe(2) + }) +}) + +describe('field errors and trace ids', () => { + it('reads both the item list and the object map', () => { + expect(fieldErrorsOf({errors: {email: ['is required']}}, [{fieldName: 'name', message: 'too short'}])).toEqual({ + name: ['too short'], + email: ['is required'], + }) + }) + + it('is absent when nothing names a field', () => { + expect(fieldErrorsOf({message: 'x'}, [{message: 'no field'}])).toBeUndefined() + }) + + it('finds the correlation id inside responseStatus.meta', () => { + expect(traceIdOf({responseStatus: {meta: {correlationId: 'c-9'}}})).toBe('c-9') + expect(traceIdOf({correlationId: 'top'})).toBe('top') + expect(traceIdOf('text')).toBeUndefined() + }) +}) + +describe('formatErrorText', () => { + it('renders message, details, hint and docs with no ANSI codes', () => { + const text = formatErrorText({ + code: 'USER_NOT_FOUND', + message: 'User not found', + status: 404, + exit: 5, + url: 'http://x/u/1', + traceId: 't-1', + fieldErrors: {email: ['is required']}, + hint: 'Check the id.', + docs: 'norbix users get --help', + }) + expect(text).toBe( + [ + 'Error: User not found', + ' code: USER_NOT_FOUND', + ' status: 404', + ' url: http://x/u/1', + ' traceId: t-1', + ' email: is required', + 'Hint: Check the id.', + 'Docs: norbix users get --help', + ].join('\n'), + ) + expect(text).not.toMatch(/\u001B\[/) + }) +}) From d237297fca7279c0a4d178e6a3822101990697e0 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Fri, 2 Oct 2026 12:36:46 +0300 Subject: [PATCH 3/4] docs(cli): task file for the agent-native CLI work The handover (target behaviour, verified findings, specification, PR order, reproduction harness, acceptance tests) plus the work log kept while implementing it. Co-Authored-By: Claude Fable 5.1 --- docs/tasks/agent-native-cli.md | 428 +++++++++++++++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 docs/tasks/agent-native-cli.md diff --git a/docs/tasks/agent-native-cli.md b/docs/tasks/agent-native-cli.md new file mode 100644 index 0000000..0063219 --- /dev/null +++ b/docs/tasks/agent-native-cli.md @@ -0,0 +1,428 @@ +# Agent-native CLI — hardening + Claude Code integration via CLI + +Status: open · Branch: create your own worktree from `main` (suggested name `feat/agent-native-cli`) +Related, in parallel (do NOT touch): MCP server work for Norbix. This task is the **CLI path**: +a frontier coding agent (Claude Code, Cursor, OpenCode, Codex) is told to use `norbix` from a shell. + +## 0. Why this exists + +Norbix is about to launch "works with frontier models". One integration path is MCP; the other is +this CLI. Agents already know how to run shell commands, so a CLI that behaves predictably in a +non-interactive shell is zero-setup integration. Today the CLI is good for humans but has gaps that +make it unsafe or noisy for agents. This document lists the verified gaps, the target behaviour, and +the integration deliverables. The human experience (text output, prompts in a terminal) must stay. + +Everything in §2 was verified on 2026-10-02 against the current `main` by running the built CLI +against a local fake HTTP server (script in §6). Re-run the script before and after your changes. + +## 1. Target behaviour ("the agent contract") + +These are the rules the CLI must satisfy. Put a copy of this section into `docs/agent-contract.md` +and link it from README; `norbix schema` (§3.4) must print its URL/path too. + +1. **Never hang, never act silently.** In a non-interactive shell (stdout or stdin not a TTY, or + `CI` set), any command that needs a confirmation and did not get `--yes` MUST exit non-zero + (code 3) with a one-line stderr message that names the flag. It must not send the request. + In a terminal without `--yes`, ask as today. +2. **`--json` means stdout is exactly one JSON document, always.** Success: the result. Failure: + `{"error": {...}}` with the envelope in §3.2. No text on stdout in JSON mode, ever. Progress and + warnings go to stderr. Exit code is the same with and without `--json`. +3. **Errors carry everything the SDK knows** (status, code, fieldErrors, url) plus a `hint` + (what to do next) and `docs` (URL or `norbix --help`). +4. **Documented exit codes** (see §3.1). Agents branch on them. +5. **`--dry-run` on every mutating command.** It resolves auth/endpoints/project exactly as a real + call would (so a dry run that would fail for real also fails), prints the request that would be + sent, sends nothing, exits 0. +6. **Discoverable without reading source.** `norbix schema [command] --json` returns the full + command surface (args, flags, types, required, env var, default, examples, destructive: bool, + supportsDryRun: bool). `--help` on every command includes at least one example with real-looking + values and, for mutating commands, the `--dry-run` / `--yes` pattern. +7. **Cheap to run.** Startup under 200 ms for `--version`; heavy modules loaded lazily. +8. **No ANSI codes when `--json`, `NO_COLOR`, or non-TTY.** (No colour lib today — add the check + now so it stays true.) + +## 2. Verified findings (with reproductions) + +### 2.1 Destructive commands run with no confirmation when there is no terminal — CRITICAL + +Pattern in ~15 commands (`users/delete.ts`, `sms/delete.ts`, `sms/stop.ts`, `push/delete.ts`, +`push/stop.ts`, `push/integration/delete.ts`, `email/*`, `files/delete.ts`, `scheduler/delete.ts`, +`env/delete.ts`, `apikeys/regenerate.ts`, `module.ts`, `db/delete.ts --many`, +`lib/namespace-command.ts` for `hub`/`api`): + +```ts +if (!flags.yes && process.stdout.isTTY) { + const ok = await confirm({message: `Delete user ${args.id}?`, default: false}) + if (!ok) return this.print('Cancelled.') +} +const res = await client.api.membership.deleteUser({id: args.id}) // reached when !isTTY +``` + +Observed (` --help` / `norbix schema --json` | +| 3 | confirmation required (non-interactive, no `--yes`) | re-run with `--yes` after `--dry-run` | +| 4 | not authenticated / auth rejected (401, 403, no key) | `norbix login`, `--api-key`, `--profile` | +| 5 | not found (404) | check id / env / project | +| 6 | validation rejected by server (400/422) | `fieldErrors` lists fields | +| 7 | network / timeout / endpoint unreachable | shows URL; check `--region`, `api_url`, connectivity | +| 8 | rate limited / server error (429/5xx) | retry with backoff; include `traceId` | +| 9 | cancelled by user at a prompt | | + +Exit codes must be identical with and without `--json`. + +### 3.2 JSON error envelope (stdout, one document) + +```json +{ + "error": { + "code": "NOT_FOUND", // SDK code if present, else derived from status / category + "message": "User not found", + "status": 404, // HTTP status when applicable + "exit": 5, + "fieldErrors": {"email": ["is required"]}, // only when present + "url": "https://nb-eu-germany.api.norbix.ai/v2/membership/auth/abc", // when known + "traceId": "…", // when the server returns one (check NorbixError.raw) + "hint": "Check the id, or list users with: norbix users list --json", + "docs": "norbix users get --help" + } +} +``` +Implement in `BaseCommand.catch`: map every error (NorbixError family, oclif parse errors, JSON +input errors, unknown command, generic Error) to `{exit, code, message, hint, ...}`; in JSON mode +`logJson` this object and `this.exit(exit)`; in text mode print `Error: ` + details + +`Hint: …` to stderr. Never let oclif's default JSON error path run (it is what dumps 22k lines). +For unknown commands hook `bin/run.js` / a `command_not_found` hook so JSON mode still emits the +envelope. + +### 3.3 Confirmation + dry-run helpers (`src/base.ts`) + +```ts +static mutatingFlags = { + yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt (required in non-interactive shells)'}), + 'dry-run': Flags.boolean({description: 'Resolve context and print the request that would be sent; send nothing'}), +} + +protected isInteractive(): boolean { + return Boolean(process.stdout.isTTY && process.stdin.isTTY) && !process.env.CI && !this.jsonEnabled() +} + +protected async confirmOrFail(message: string, flags: {yes?: boolean}): Promise { + if (flags.yes) return + if (!this.isInteractive()) { + throw new CliError({exit: 3, code: 'CONFIRMATION_REQUIRED', + message: `Confirmation required: ${message}`, + hint: 'Re-run with --yes. Preview first with --dry-run.'}) + } + const {confirm} = await import('@inquirer/prompts') // lazy + if (!(await confirm({message, default: false}))) { + throw new CliError({exit: 9, code: 'CANCELLED', message: 'Cancelled.'}) + } +} + +/** Call AFTER this.client(flags) so auth/region/project are validated. */ +protected dryRun(flags, call: {method: string; http?: string; request: unknown}): never-ish +``` +Apply to every mutating command (list in §2.1 plus `db delete --id`, `db update --id/--many`, +`db insert*`, `db replace`, `files upload/publish/unpublish`, `users block/invite/roles/policies`, +`webhooks *`, `scheduler enable/disable`, `payments enable/disable`, `*/archive`, `*/unarchive`, +`*/clone`, `config set/unset`, `env use`). Read-only commands do not get these flags. +Decide `destructive` per command (delete/stop/regenerate/block/disable = destructive → confirmation; +create/update/archive = mutating → `--dry-run` but no confirmation unless `--many`). + +### 3.4 `norbix schema [command] --json` + +New command `src/commands/schema.ts`. Source: `this.config.commands` (the oclif manifest). Output, +trimmed and stable: +```json +{ "cli": "norbix", "version": "x.y.z", "contract": "docs/agent-contract.md", + "exitCodes": {...}, + "commands": [{ "id": "users delete", "description": "...", "destructive": true, "supportsDryRun": true, + "args": [{"name":"id","required":true,"description":"User ID"}], + "flags": [{"name":"yes","char":"y","type":"boolean","description":"..."}, + {"name":"project","type":"string","env":"NORBIX_PROJECT_ID","global":true}], + "examples": ["norbix users delete 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run", "… --yes --json"] }], + "dynamic": { "hub": "norbix hub --help | norbix hub --json lists methods", "api": "…" } } +``` +Without `--json` print a compact table. For `hub`/`api`, extend scope help to list, per method, the +request field names + types (from the SDK `.d.ts` or a generated map — check what `@norbix.ai/ts` +exposes at runtime; if nothing, generate `src/generated/request-fields.json` in `scripts/`). + +### 3.5 `hub` / `api` parser fixes (`src/lib/dispatch.ts`) + +- Add `--body ''` (or `-`) for the whole request object; when present, individual + `--field` flags are rejected with exit 2 (no silent merge). +- Add `--field:str value` / `--field=@str:value` escape (or document that `--body` is the way to + pass exact types) — pick one, document it in scope help and `schema.dynamic`. +- Boolean flag followed by a value: only treat the next token as a value if the field is not known + to be boolean (needs the request-field map from §3.4) — otherwise document and keep. +- `--dry-run` must run after `this.client(flags)`. + +### 3.6 `login` + +Guard every prompt with `isInteractive()`. Non-interactive without `--password`/`--api-key` → exit +2 with hint listing the three non-interactive options (`--api-key`, `--user --password`, +`NORBIX_API_KEY` env). Remove the stack trace / unsettled-await warning. + +### 3.7 Startup + +`npm run build` also runs `oclif manifest` (keep `postpack` cleanup or commit the manifest — pick +one and document). Lazy-import `@inquirer/prompts` everywhere (only §3.3 helper and `login` need +it). Target: `norbix --version` < 200 ms; report before/after numbers. + +### 3.8 Claude Code / agent integration deliverables (the "AI integration via CLI" part) + +1. `docs/agent-contract.md` — §1 + §3.1 + §3.2, written for an agent to read once. +2. `docs/AGENTS.md` (also referenced from README "Use with AI coding agents"): the recipe an agent + follows — auth with `--profile`/`NORBIX_*` env; always `--json`; discover with + `norbix schema --json` then `norbix --help`; mutate with `--dry-run` → inspect → `--yes`; + branch on exit codes; prefer named commands, fall back to `hub`/`api` with `--body`. +3. `norbix ai init` (new command, name open — alternatives `norbix agent setup`): writes, into the + current project, a Claude Code skill `.claude/skills/norbix/SKILL.md` (frontmatter name + + description "Use when the task touches the Norbix backend…", body = short recipe + link to + `norbix schema --json`) and optionally appends a 10-line block to `CLAUDE.md` / `AGENTS.md` + (`--target claude|agents|cursor|all`, `--dry-run` shows the files it would write, never + overwrites an existing file without `--force`). Keep the skill body short; the CLI's own + `--help` and `schema` are the source of truth, not the skill file. +4. README section "Use with Claude Code / AI agents" (5–10 lines + `norbix ai init`). +5. Make sure the hub/portal docs team can link `docs/AGENTS.md` (ask Domantas where it will live; + do not block on it). + +## 4. Non-goals + +- No MCP code in this branch (separate parallel work). Where the MCP server and the CLI should + share things (exit-code table, error envelope, schema JSON), put them in `src/lib/` with no oclif + imports so MCP can import them later, and say so in the final report. +- Do not remove or change human text output beyond what §1 requires. Do not add colour. +- No new runtime dependencies without saying why. + +## 5. Order of work / PR split + +PR 1 — Safety + errors (merge first, smallest reviewable unit): + §3.1, §3.2, §3.3 applied to all destructive commands, §3.6, "Cancelled" → exit 9, tests (§7). +PR 2 — Discovery + dry-run everywhere: §3.4 `schema`, `--dry-run` on all mutating commands, + `--help` examples with realistic values, §3.5. +PR 3 — Startup + agent integration: §3.7, §3.8 (docs, `ai init`, README). +Each PR: conventional commit titles (`feat(cli): …`, `fix(cli): …`; breaking → `feat!:`), CI green, +`COVERAGE.md` updated if it lists commands. + +## 6. Reproduction harness (use before and after) + +```bash +# fake server +cat > /tmp/mock.mjs <<'EOS' +import http from 'node:http' +http.createServer((req,res)=>{ console.error('MOCK',req.method,req.url); res.setHeader('content-type','application/json') + if (req.url.includes('missing')) { res.statusCode=404; return res.end('{"message":"User not found","code":"USER_NOT_FOUND","traceId":"t-1"}') } + if (req.url.includes('bad')) { res.statusCode=400; return res.end('{"message":"Validation failed","errors":{"email":["is required"]}}') } + if (req.url.includes('unauth')) { res.statusCode=401; return res.end('{"message":"bad key"}') } + res.end('{"ok":true}') }).listen(8799) +EOS +node /tmp/mock.mjs & export HOME=/tmp/h; mkdir -p $HOME/.norbix +printf '[x]\napi_key=k\nproject_id=p\napi_url=http://localhost:8799\nhub_url=http://localhost:8799\n[y]\napi_key=k\nproject_id=p\napi_url=http://localhost:1\nhub_url=http://localhost:1\n' > $HOME/.norbix/config +run(){ node bin/run.js "$@" /tmp/o 2>/tmp/e; echo "exit=$? stdout_lines=$(wc -l Date: Sat, 3 Oct 2026 16:59:48 +0300 Subject: [PATCH 4/4] test(agent-mode): give the spawn-every-destructive-command tests 120 s Each of the three tests spawns the CLI once per destructive command, four at a time. Windows runners take ~3 s per spawn, so they ran past vitest's 20 s default, and the timed-out pool kept sending requests into the next test (the --dry-run test then saw DELETE/PUT hits it never made). Co-Authored-By: Claude Opus 5.5 --- tests/agent-mode.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/agent-mode.test.ts b/tests/agent-mode.test.ts index 2e7aec9..a894601 100644 --- a/tests/agent-mode.test.ts +++ b/tests/agent-mode.test.ts @@ -56,12 +56,17 @@ const DESTRUCTIVE: string[][] = [ ] describe('destructive commands in a non-interactive shell', () => { + // Each test spawns the CLI once per command (DESTRUCTIVE.length processes, + // 4 at a time). Windows runners need ~3 s per spawn, so the default 20 s + // runs out — and a timed-out pool keeps sending into the next test. + const SPAWN_ALL_TIMEOUT = 120_000 + it('every one exits 3 without --yes and sends nothing', async () => { const results = await pool(DESTRUCTIVE, 4, async (argv) => ({argv, ...(await cli(home, [...argv, '--profile', 'x']))})) const wrong = results.filter((r) => r.code !== 3 || !/--yes/.test(r.stderr) || r.stdout !== '') expect(wrong.map((r) => `${r.argv.join(' ')} → exit ${r.code}: ${r.stderr.slice(0, 120)}`)).toEqual([]) expect(gateway.hits).toEqual([]) - }) + }, SPAWN_ALL_TIMEOUT) it('every one exits 3 with --json too, with the envelope on stdout', async () => { const results = await pool(DESTRUCTIVE, 4, async (argv) => ({argv, ...(await cli(home, [...argv, '--profile', 'x', '--json']))})) @@ -75,14 +80,14 @@ describe('destructive commands in a non-interactive shell', () => { } expect(gateway.hits).toEqual([]) - }) + }, SPAWN_ALL_TIMEOUT) it('every one runs with --yes and reaches the gateway', async () => { const results = await pool(DESTRUCTIVE, 4, async (argv) => ({argv, ...(await cli(home, [...argv, '--profile', 'x', '--yes']))})) const wrong = results.filter((r) => r.code !== 0) expect(wrong.map((r) => `${r.argv.join(' ')} → exit ${r.code}: ${r.stderr.slice(0, 160)}`)).toEqual([]) expect(gateway.hits.length).toBe(DESTRUCTIVE.length) - }) + }, SPAWN_ALL_TIMEOUT) }) describe('--dry-run', () => {