diff --git a/README.md b/README.md index 132abc7..963a74a 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,13 @@ Designed for AI agents as first-class users: pagination; `--idempotency-key` makes retries safe across process restarts. +Filtered list pages can be empty while more pages remain. Follow their cursor +or use `--all`. When `--all` receives a repeated or cyclic cursor, it exits with code 1 and +`CLI_PAGINATION` on stderr. It emits no truncated success payload. Error details +include the fetched page and item counts; a cyclic cursor is not offered as a +resume point. This also covers resumed lists and a comment thread's older +message slices. Empty pages with an advancing cursor continue normally. + ## Command reference The full generated reference lives in @@ -142,6 +149,10 @@ keeps `main`'s email-audit and add-image blocks for the PR that ships them). Two Contributor guide: [`AGENTS.md`](./AGENTS.md) · Release process: [`RELEASING.md`](./RELEASING.md). +Development and release checks use Bun 1.3.10, pinned in every workflow. +Command metadata is supplied by CLI composition to `docs` and `doctor`; these +handlers do not import the live registry. + ## License MIT diff --git a/docs/commands/README.md b/docs/commands/README.md index 00edb91..96d3b9b 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -85,11 +85,11 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli automations list` | read | `GET /v1/automations` | List automations (lean rows; `automations get` for the graph) | | `brew-cli automations get` | read | `GET /v1/automations/{automationId}` | Fetch one automation by id — the bare row, lean by default | | `brew-cli automations create` | write | `POST /v1/automations` | Create an automation from a graph JSON (starts unpublished) | -| `brew-cli automations update` | write | `PATCH /v1/automations/{automationId}` | Update automation metadata and/or its graph (PATCH) | +| `brew-cli automations update` | destructive | `PATCH /v1/automations/{automationId}` | Update automation metadata and/or its graph (PATCH) | | `brew-cli automations publish` | write | `PATCH /v1/automations/{automationId}` | Publish an automation — arms it for live fires; does not itself send | | `brew-cli automations unpublish` | write | `PATCH /v1/automations/{automationId}` | Unpublish an automation so new trigger fires no longer start runs | | `brew-cli automations delete` | destructive | `DELETE /v1/automations/{automationId}` | Delete an automation and its version history (cascade) | -| `brew-cli automations test` | write | `POST /v1/automations/{automationId}/test` | Start a suppression-aware TEST run (no real mail is sent) | +| `brew-cli automations test` | destructive | `POST /v1/automations/{automationId}/test` | Test an automation; testRecipient delivers real mail, omit for a dry run | | `brew-cli automations run` | destructive | `POST /v1/automations/{automationId}/run` | Run a manual-audience automation (live send; --dry-run previews) | | `brew-cli automations triggers list` | read | `GET /v1/automations/triggers` | List trigger events (their payload schemas drive fires); one trigger is `automations triggers get` | | `brew-cli automations triggers get` | read | `GET /v1/automations/triggers/{triggerEventId}` | Fetch one trigger by id — the bare row with its payload schema | @@ -1263,7 +1263,7 @@ brew-cli automations create --name "Welcome flow" --input '{"triggerEventId":"te Update automation metadata and/or its graph (PATCH) - Route: `PATCH /v1/automations/{automationId}` -- Class: write +- Class: destructive - SDK: `brew.automations.patch(...)` - Argument `automationId` — Id of the automation to update - `--name ` — New name @@ -1319,17 +1319,19 @@ brew-cli automations delete am_123 --yes ### brew-cli automations test -Start a suppression-aware TEST run (no real mail is sent) +Test an automation; testRecipient delivers real mail, omit for a dry run - Route: `POST /v1/automations/{automationId}/test` -- Class: write +- Class: destructive - SDK: `brew.automations.test(...)` - Argument `automationId` — Id of the automation to test - `--input ` — Full JSON request body, or - to read stdin (flags override it) +- `--idempotency-key ` — Idempotency-Key for safe retries (auto-generated otherwise) ```bash brew-cli automations test am_123 brew-cli automations test am_123 --input '{"userId":"u_1"}' +brew-cli automations test am_123 --input '{"testRecipient":"qa@example.com"}' --yes ``` ### brew-cli automations run diff --git a/package.json b/package.json index c8c13b7..d9fdd2b 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "vitest": "^4.1.2", "yaml": "^2.8.0" }, + "packageManager": "bun@1.3.10", "trustedDependencies": [ "@brew.new/sdk" ] diff --git a/src/bin.ts b/src/bin.ts index 7a2d859..f60459d 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -2,6 +2,7 @@ import { createInterface } from 'node:readline/promises' import { run } from './cli' import { exitCodeForSignal } from './lib/errors' import { createInterrupter, type Interrupter } from './lib/interrupt' +import { readStdinText } from './lib/stdin' import type { CliIo } from './lib/types' function realIo(interrupter: Interrupter): CliIo { @@ -13,29 +14,8 @@ function realIo(interrupter: Interrupter): CliIo { isTtyIn: process.stdin.isTTY === true, env: process.env, signal, - readStdin: async () => { - // With a SIGINT listener installed, an open stdin would keep the - // process alive after Ctrl-C: stop reading when the command aborts. - const stop = (): void => { - process.stdin.destroy() - } - signal.addEventListener('abort', stop, { once: true }) - process.stdin.setEncoding('utf8') - let data = '' - try { - for await (const chunk of process.stdin) { - data += chunk - } - } catch (error) { - if (!signal.aborted) { - throw error - } - } finally { - signal.removeEventListener('abort', stop) - } - signal.throwIfAborted() - return data - }, + readStdin: (readSignal = signal) => + readStdinText({ stream: process.stdin, signal: readSignal }), readLine: async (prompt: string) => { const rl = createInterface({ input: process.stdin, diff --git a/src/cli.ts b/src/cli.ts index c129917..323bb54 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,8 +2,8 @@ import { BrewApiError } from '@brew.new/sdk' import { Command, CommanderError } from 'commander' import { buildRetryCommand, enforceConfirmation } from './lib/confirm' import { makeContext, makeFallbackContext } from './lib/context' -import type { CommandSpec } from './lib/define-command' -import { GLOBAL_FLAGS } from './lib/define-command' +import type { CommandMetadata, CommandSpec } from './lib/define-command' +import { commandMetadata, GLOBAL_FLAGS } from './lib/define-command' import { CliApiError, CliUsageError, @@ -178,12 +178,13 @@ function commandFor( ) } -/** `--input -`, `--file -` or `--data -`: the re-run needs the same stdin. */ +/** Split or equals-form stdin flags require the same input on a re-run. */ function readsStdin(rawArgv: readonly string[]): boolean { return rawArgv.some( (token, index) => - token === '-' && - ['--input', '--file', '--data'].includes(rawArgv[index - 1] ?? '') + ['--input=-', '--file=-', '--data=-'].includes(token) || + (token === '-' && + ['--input', '--file', '--data'].includes(rawArgv[index - 1] ?? '')) ) } @@ -262,8 +263,9 @@ export function buildProgram( for (const flag of GLOBAL_FLAGS) { program.option(flag.flag, flag.summary) } + const metadata = commands.map(commandMetadata) for (const spec of commands) { - registerCommand(program, spec, io, rawArgv, onContext, onExitCode) + registerCommand(program, spec, io, rawArgv, onContext, onExitCode, metadata) } return program } @@ -274,7 +276,8 @@ function registerCommand( io: CliIo, rawArgv: readonly string[], onContext?: (ctx: CliContext) => void, - onExitCode?: (code: number) => void + onExitCode?: (code: number) => void, + metadata: readonly CommandMetadata[] = [] ): void { const parent = resolveGroup(program, spec.path.slice(0, -1)) const leafName = spec.path.at(-1) @@ -323,14 +326,25 @@ function registerCommand( }, {} ) - const ctx = makeContext({ io, flags, rawArgv, traits: traitsOf(spec) }) + const ctx = makeContext({ + io, + flags, + rawArgv, + traits: traitsOf(spec), + commands: metadata, + }) onContext?.(ctx) - const invocation = { ctx, args: positional, flags } - await enforceConfirmation(spec, invocation) - // Armed only now: time a human spends at the y/N prompt is not the - // request's. ctx.startDeadline() try { + const baseInvocation = { ctx, args: positional, flags } + const input = await spec.prepareInput?.(baseInvocation) + ctx.signal.throwIfAborted() + const invocation = { + ...baseInvocation, + ...(input === undefined ? {} : { input }), + } + await enforceConfirmation(spec, invocation) + ctx.signal.throwIfAborted() const outcome = await spec.run(invocation) if (outcome !== undefined) { printData(ctx, outcome.data, outcome.human) diff --git a/src/commands/analytics/events.ts b/src/commands/analytics/events.ts index f6d63a5..9923f9c 100644 --- a/src/commands/analytics/events.ts +++ b/src/commands/analytics/events.ts @@ -59,13 +59,16 @@ export const analyticsEventsCommand = defineCommand({ }) const analytics = ctx.client().analytics if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - analytics.events( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + analytics.events( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/api-keys/list.ts b/src/commands/api-keys/list.ts index bc2a3a3..b1c6154 100644 --- a/src/commands/api-keys/list.ts +++ b/src/commands/api-keys/list.ts @@ -41,7 +41,7 @@ export const apiKeysListCommand = defineCommand({ }, }) if (flags.all === true) { - const rows = await collectAll(ctx, (next) => page(next ?? cursor)) + const rows = await collectAll(ctx, page, { cursor }) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, human: renderApiKeys(rows), diff --git a/src/commands/api.ts b/src/commands/api.ts index 5782744..45b22ec 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -9,6 +9,7 @@ import { responseToApiError, tryParseJson, } from '../lib/raw-request' +import type { CliContext } from '../lib/types' const METHODS = ['GET', 'POST', 'PATCH', 'DELETE'] as const @@ -140,13 +141,13 @@ function parsePath(value: string | undefined): string { } async function readBody( - ctx: { readonly io: { readonly readStdin: () => Promise } }, + ctx: Pick, data: unknown ): Promise { if (typeof data !== 'string') { return } - const raw = data === '-' ? await ctx.io.readStdin() : data + const raw = data === '-' ? await ctx.io.readStdin(ctx.signal) : data try { JSON.parse(raw) } catch { diff --git a/src/commands/audiences/list.ts b/src/commands/audiences/list.ts index 462510b..dbf1ceb 100644 --- a/src/commands/audiences/list.ts +++ b/src/commands/audiences/list.ts @@ -57,13 +57,16 @@ export const audiencesListCommand = defineCommand({ }) const audiences = ctx.client().audiences if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - audiences.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + audiences.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/automations/audience-runs/list.ts b/src/commands/automations/audience-runs/list.ts index 43cca9d..a4c391b 100644 --- a/src/commands/automations/audience-runs/list.ts +++ b/src/commands/automations/audience-runs/list.ts @@ -1,5 +1,6 @@ import type { ListAudienceRunsInput } from '@brew.new/sdk' import { inputField, singleRowPage } from '../../../lib/compat' +import { shellQuote } from '../../../lib/confirm' import { defineCommand } from '../../../lib/define-command' import { asSdkInput, @@ -69,13 +70,16 @@ export const automationsAudienceRunsListCommand = defineCommand({ cursor: flagString(flags.cursor), }) if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - audienceRuns.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + audienceRuns.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, @@ -85,7 +89,13 @@ export const automationsAudienceRunsListCommand = defineCommand({ const result = await audienceRuns.list( asSdkInput(input) ) - return { data: result, human: renderAudienceRuns(result.data) } + const human = + result.data.length === 0 && + result.pagination.hasMore && + result.pagination.cursor !== null + ? `No matching audience runs on this page. Continue with --cursor ${shellQuote(result.pagination.cursor)} or --all.` + : renderAudienceRuns(result.data) + return { data: result, human } }, }) diff --git a/src/commands/automations/list.ts b/src/commands/automations/list.ts index 14d19d1..b2f7d21 100644 --- a/src/commands/automations/list.ts +++ b/src/commands/automations/list.ts @@ -45,13 +45,16 @@ export const automationsListCommand = defineCommand({ }) const automations = ctx.client().automations if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - automations.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + automations.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/automations/run.ts b/src/commands/automations/run.ts index c604272..3c30d6b 100644 --- a/src/commands/automations/run.ts +++ b/src/commands/automations/run.ts @@ -1,4 +1,5 @@ import type { RunAutomationInput } from '@brew.new/sdk' +import { enforceStdinConfirmation } from '../../lib/confirm' import { defineCommand } from '../../lib/define-command' import { asSdkInput, @@ -41,6 +42,10 @@ export const automationsRunCommand = defineCommand({ 'brew-cli automations run auto_abc --schedule-at 2026-09-01T09:00:00Z --input \'{"gradualSend":{"startingPercentage":10,"incrementPercentage":20,"interval":{"value":1,"unit":"day"},"timeZone":"America/New_York"}}\' --yes', ], confirmSummary: ({ args, flags }) => { + // Stdin is checked in run after its body and flag overrides are merged. + if (flags.input === '-') { + return + } // The gate must reflect the MERGED request — an inline --input body can // carry dryRun/scheduledAt on its own. A dry run never gates. const inline = parseInlineInput(flags.input) @@ -58,6 +63,11 @@ export const automationsRunCommand = defineCommand({ dryRun: flags.dryRun === true ? true : undefined, scheduledAt: flagString(flags.scheduleAt), }) + await enforceStdinConfirmation( + automationsRunCommand, + { ctx, args, flags }, + input + ) return { data: await ctx.client().automations.run( asSdkInput({ diff --git a/src/commands/automations/runs/list.ts b/src/commands/automations/runs/list.ts index 28ab6c6..0923d50 100644 --- a/src/commands/automations/runs/list.ts +++ b/src/commands/automations/runs/list.ts @@ -99,13 +99,16 @@ export const automationsRunsListCommand = defineCommand({ cursor: flagString(flags.cursor), }) if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - runs.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + runs.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/automations/test.ts b/src/commands/automations/test.ts index 7603c6f..d2df2ab 100644 --- a/src/commands/automations/test.ts +++ b/src/commands/automations/test.ts @@ -1,15 +1,23 @@ import type { AutomationsResource } from '@brew.new/sdk' +import { enforceStdinConfirmation } from '../../lib/confirm' import { defineCommand } from '../../lib/define-command' -import { asSdkInput, INPUT_FLAG, readJsonFlag } from '../../lib/input' +import { + asSdkInput, + IDEMPOTENCY_FLAG, + INPUT_FLAG, + readJsonFlag, + requestOptions, +} from '../../lib/input' type TestAutomationInput = Parameters[0] export const automationsTestCommand = defineCommand({ path: ['automations', 'test'], - summary: 'Start a suppression-aware TEST run (no real mail is sent)', + summary: + 'Test an automation; testRecipient delivers real mail, omit for a dry run', sdkMethod: 'automations.test', route: { method: 'POST', path: '/v1/automations/{automationId}/test' }, - commandClass: 'write', + commandClass: 'destructive', args: [ { name: 'automationId', @@ -17,26 +25,65 @@ export const automationsTestCommand = defineCommand({ isRequired: true, }, ], - flags: [INPUT_FLAG], + flags: [INPUT_FLAG, IDEMPOTENCY_FLAG], examples: [ 'brew-cli automations test am_123', `brew-cli automations test am_123 --input '{"userId":"u_1"}'`, + `brew-cli automations test am_123 --input '{"testRecipient":"qa@example.com"}' --yes`, ], + confirmSummary: ({ args, flags }) => { + if (typeof flags.input !== 'string' || flags.input === '-') { + return + } + // Stdin is checked after it is read; malformed inline JSON gets the + // normal usage error in run rather than a confirmation prompt. + let base: unknown + try { + base = JSON.parse(flags.input) + } catch { + return + } + const { testRecipient } = automationTestBody(base) + if (typeof testRecipient !== 'string' || testRecipient.trim() === '') { + return + } + return `Test automation ${args.automationId ?? ''}: each send node delivers REAL mail to ${testRecipient.trim()}. Real test deliveries count toward send usage.` + }, run: async ({ ctx, args, flags }) => { const base = await readJsonFlag(ctx, flags.input, '--input') - // A bare object is sugar for the { payload } envelope the API takes. - const body = - base === undefined - ? {} - : typeof base === 'object' && base !== null && 'payload' in base - ? (base as Record) - : { payload: base } + const body = automationTestBody(base) + await enforceStdinConfirmation( + automationsTestCommand, + { ctx, args, flags }, + body + ) const result = await ctx.client().automations.test( asSdkInput({ ...body, automationId: args.automationId ?? '', - }) + }), + requestOptions(flags) ) return { data: result } }, }) + +/** Preserve API controls; a bare trigger payload remains shorthand. */ +function automationTestBody(base: unknown): Record { + if (base === undefined) { + return {} + } + if ( + isRecord(base) && + ['payload', 'testRecipient', 'scenario'].some((key) => + Object.hasOwn(base, key) + ) + ) { + return base + } + return { payload: base } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/commands/automations/trigger-instances/list.ts b/src/commands/automations/trigger-instances/list.ts index 112ecaf..cfa0321 100644 --- a/src/commands/automations/trigger-instances/list.ts +++ b/src/commands/automations/trigger-instances/list.ts @@ -67,13 +67,16 @@ export async function listTriggerInstances( ): Promise<{ data: unknown; human: string }> { const triggerInstances = ctx.client().automations.triggerInstances if (followCursor) { - const rows = await collectAll(ctx, (cursor) => - triggerInstances.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + triggerInstances.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/automations/triggers/list.ts b/src/commands/automations/triggers/list.ts index 2fdaa9b..3ee7496 100644 --- a/src/commands/automations/triggers/list.ts +++ b/src/commands/automations/triggers/list.ts @@ -53,13 +53,16 @@ export const automationsTriggersListCommand = defineCommand({ cursor: flagString(flags.cursor), }) if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - triggers.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + triggers.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/automations/update.ts b/src/commands/automations/update.ts index 3e41be8..c8dc386 100644 --- a/src/commands/automations/update.ts +++ b/src/commands/automations/update.ts @@ -14,7 +14,7 @@ export const automationsUpdateCommand = defineCommand({ summary: 'Update automation metadata and/or its graph (PATCH)', sdkMethod: 'automations.patch', route: { method: 'PATCH', path: '/v1/automations/{automationId}' }, - commandClass: 'write', + commandClass: 'destructive', args: [ { name: 'automationId', @@ -32,7 +32,7 @@ export const automationsUpdateCommand = defineCommand({ 'brew-cli automations update am_123 --name "Welcome flow v2"', 'cat graph.json | brew-cli automations update am_123 --input -', ], - run: async ({ ctx, args, flags }) => { + prepareInput: async ({ ctx, flags }) => { const base = await readJsonFlag(ctx, flags.input, '--input') const input = mergeInput(base, { name: flagString(flags.name), @@ -42,6 +42,24 @@ export const automationsUpdateCommand = defineCommand({ if (Object.keys(input).length === 0) { throw new CliUsageError('Nothing to update — pass flags or --input.') } + return input + }, + confirmSummary: ({ args, input }) => { + if (input?.dryRun === true) return undefined + if (input?.stopInFlight === true) { + return `Unpublish automation ${args.automationId} and permanently stop every contact already mid-flow?` + } + if (input?.published === true) { + return `Publish automation ${args.automationId}? Its triggers will send real email.` + } + if (input?.paused === false) { + return `Resume automation ${args.automationId}? Held contacts will continue and receive email.` + } + return undefined + }, + run: async ({ ctx, args, input }) => { + if (!input) + throw new CliUsageError('Automation update input was not prepared.') const result = await ctx.client().automations.patch( asSdkInput({ ...input, diff --git a/src/commands/brand/get-images.ts b/src/commands/brand/get-images.ts index bda7322..289d219 100644 --- a/src/commands/brand/get-images.ts +++ b/src/commands/brand/get-images.ts @@ -100,7 +100,9 @@ export const brandGetImagesCommand = defineCommand({ ) ) if (flags.all === true) { - const rows = await collectAll(ctx, page) + const rows = await collectAll(ctx, page, { + cursor: flagString(input.cursor), + }) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, human: renderAssets(rows), diff --git a/src/commands/brands/list.ts b/src/commands/brands/list.ts index d5db117..2a6f2a3 100644 --- a/src/commands/brands/list.ts +++ b/src/commands/brands/list.ts @@ -37,13 +37,16 @@ export const brandsListCommand = defineCommand({ } const brands = ctx.client().brands if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - brands.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + brands.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/chats/list.ts b/src/commands/chats/list.ts index ac77dfd..e2e375a 100644 --- a/src/commands/chats/list.ts +++ b/src/commands/chats/list.ts @@ -33,13 +33,16 @@ export const chatsListCommand = defineCommand({ } const chats = ctx.client().chats if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - chats.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + chats.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/contacts/list.ts b/src/commands/contacts/list.ts index ba1d94c..a1433ac 100644 --- a/src/commands/contacts/list.ts +++ b/src/commands/contacts/list.ts @@ -56,21 +56,24 @@ export const contactsListCommand = defineCommand({ }) const contacts = ctx.client().contacts if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - contacts.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + contacts.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, - human: renderContacts(rows), + human: () => renderContacts(rows), } } const result = await contacts.list(asSdkInput(input)) - return { data: result, human: renderContacts(result.data) } + return { data: result, human: () => renderContacts(result.data) } }, }) diff --git a/src/commands/contacts/search.ts b/src/commands/contacts/search.ts index bc0beed..cf6982d 100644 --- a/src/commands/contacts/search.ts +++ b/src/commands/contacts/search.ts @@ -66,13 +66,16 @@ export const contactsSearchCommand = defineCommand({ refuseCountMode(input) const contacts = ctx.client().contacts if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - contacts.search( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + contacts.search( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/docs.ts b/src/commands/docs.ts index ecf4127..614e288 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -21,9 +21,9 @@ export const docsCommand = defineCommand({ }, ], examples: ['brew-cli docs', 'brew-cli docs --agent'], - run: async ({ flags }) => { + run: async ({ flags, ctx }) => { if (flags.agent === true) { - return { data: buildManifest() } + return { data: buildManifest({ commands: ctx.commands }) } } return { data: DOCS_POINTERS, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c5cd448..8b62675 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -3,7 +3,6 @@ import { maskApiKey, resolveAuth } from '../lib/client' import { defineCommand } from '../lib/define-command' import { CliApiError } from '../lib/errors' import type { CliContext } from '../lib/types' -import { ALL_COMMANDS } from '../registry' /** Discovery endpoints without a dedicated command route. */ const NON_COMMAND_OPS = new Set(['GET /v1/llms.txt']) @@ -74,7 +73,7 @@ export const doctorCommand = defineCommand({ .map((e) => `${e.method} ${e.path}`) ) const localOps = new Set( - ALL_COMMANDS.flatMap((spec) => + ctx.commands.flatMap((spec) => spec.route === undefined ? [] : [`${spec.route.method} ${spec.route.path}`] @@ -109,7 +108,7 @@ export const doctorCommand = defineCommand({ brandId: auth.brandId ?? null, surface: { checked: isSurfaceChecked, - commands: ALL_COMMANDS.length, + commands: ctx.commands.length, missingLocally, extraLocally, }, @@ -121,7 +120,7 @@ export const doctorCommand = defineCommand({ : '-- no API key (run `brew-cli login` or set BREW_API_KEY)', isSurfaceChecked ? missingLocally.length === 0 - ? `ok surface in sync (${ALL_COMMANDS.length} commands cover the live API)` + ? `ok surface in sync (${ctx.commands.length} commands cover the live API)` : `DRIFT live API has ${missingLocally.length} operation(s) this CLI lacks — update @brew.new/cli:\n${missingLocally.map((op) => ` ${op}`).join('\n')}` : '-- surface not checked (API unreachable)', ...(extraLocally.length > 0 diff --git a/src/commands/domains/list.ts b/src/commands/domains/list.ts index 826f039..4793586 100644 --- a/src/commands/domains/list.ts +++ b/src/commands/domains/list.ts @@ -45,13 +45,16 @@ export const domainsListCommand = defineCommand({ }) const domains = ctx.client().domains if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - domains.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + domains.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/emails/comments/list.ts b/src/commands/emails/comments/list.ts index a943a51..4388c35 100644 --- a/src/commands/emails/comments/list.ts +++ b/src/commands/emails/comments/list.ts @@ -8,7 +8,11 @@ import { defineCommand } from '../../../lib/define-command' import { CliUsageError } from '../../../lib/errors' import { asSdkInput, flagInt, flagString } from '../../../lib/input' import { progress, renderTable } from '../../../lib/output' -import { CURSOR_FLAG, collectAll } from '../../../lib/paginate' +import { + assertPaginationProgress, + CURSOR_FLAG, + collectAll, +} from '../../../lib/paginate' import type { CliContext } from '../../../lib/types' type CommentsPage = ListEmailCommentsResponse @@ -109,8 +113,11 @@ export const emailsCommentsListCommand = defineCommand({ } } if (isAll) { - const rows = await collectAll(ctx, (next) => - list({ ...input, ...(next === undefined ? {} : { cursor: next }) }) + const rows = await collectAll( + ctx, + (next) => + list({ ...input, ...(next === undefined ? {} : { cursor: next }) }), + { cursor } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, @@ -142,6 +149,7 @@ async function readWholeThread( let thread: Thread | undefined let messages: Message[] = [] let reads = 0 + const fetched = new Set() for (;;) { let page: CommentsPage try { @@ -174,7 +182,15 @@ async function readWholeThread( thread ??= slice messages = [...(slice.messages ?? []), ...messages] const older = slice.messagesCursor ?? null - if (older === null || older === next) { + assertPaginationProgress({ + ctx, + fetchedCursors: fetched, + cursor: next, + next: older, + rowsFetched: messages.length, + pagesFetched: reads, + }) + if (older === null) { return { ...thread, messages, messagesCursor: null } } next = older diff --git a/src/commands/emails/groups/list.ts b/src/commands/emails/groups/list.ts index a517a50..8f2b6a8 100644 --- a/src/commands/emails/groups/list.ts +++ b/src/commands/emails/groups/list.ts @@ -41,7 +41,9 @@ export const emailsGroupsListCommand = defineCommand({ }) ) if (flags.all === true) { - const rows = await collectAll(ctx, fetchPage) + const rows = await collectAll(ctx, fetchPage, { + cursor: flagString(input.cursor), + }) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, human: renderEmailGroups(rows), diff --git a/src/commands/emails/list.ts b/src/commands/emails/list.ts index 39434ce..475073d 100644 --- a/src/commands/emails/list.ts +++ b/src/commands/emails/list.ts @@ -101,13 +101,16 @@ export const emailsListCommand = defineCommand({ }) const emails = ctx.client().emails if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - emails.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + emails.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/emails/send.ts b/src/commands/emails/send.ts index baeb211..47f7d39 100644 --- a/src/commands/emails/send.ts +++ b/src/commands/emails/send.ts @@ -1,4 +1,5 @@ import type { SendEmailInput } from '@brew.new/sdk' +import { enforceStdinConfirmation } from '../../lib/confirm' import { defineCommand } from '../../lib/define-command' import { CliUsageError } from '../../lib/errors' import { @@ -44,6 +45,10 @@ export const emailsSendCommand = defineCommand({ 'brew-cli emails send eml_1 --subject "Fall sale" --domain dom_1 --audience aud_1 --schedule-at 2026-09-01T09:00:00Z --yes', ], confirmSummary: ({ args, flags }) => { + // Stdin is checked in run after its body and flag overrides are merged. + if (flags.input === '-') { + return + } // The gate must describe the MERGED send, not just the flags — an // --input body can carry test/audienceId/to/scheduledAt on its own. const inline = parseInlineInput(flags.input) @@ -55,14 +60,12 @@ export const emailsSendCommand = defineCommand({ const scheduleAt = flagString(flags.scheduleAt) ?? asString(inline?.scheduledAt) const recipients = toStringArray(flags.to) ?? asRecipients(inline?.to) - const target = - audience === undefined - ? recipients === undefined - ? flags.input === '-' - ? 'the recipients named in the --input body on stdin' - : 'its recipients' - : `${recipients.length} listed recipient(s)` - : `audience ${audience}` + let target = 'its recipients' + if (audience !== undefined) { + target = `audience ${audience}` + } else if (recipients !== undefined) { + target = `${recipients.length} listed recipient(s)` + } const timing = scheduleAt === undefined ? 'now' : `at ${scheduleAt}` return `Send email ${args.emailId ?? ''} as a REAL campaign to ${target} ${timing}. Real recipients receive it; this cannot be undone.` }, @@ -77,6 +80,11 @@ export const emailsSendCommand = defineCommand({ domainId: flagString(flags.domain), scheduledAt: flagString(flags.scheduleAt), }) + await enforceStdinConfirmation( + emailsSendCommand, + { ctx, args, flags }, + input + ) if (typeof input.subject !== 'string' || input.subject === '') { throw new CliUsageError( '--subject is required (or provide it via --input).' diff --git a/src/commands/fields/list.ts b/src/commands/fields/list.ts index 08eca62..9dfff6a 100644 --- a/src/commands/fields/list.ts +++ b/src/commands/fields/list.ts @@ -43,13 +43,16 @@ export const fieldsListCommand = defineCommand({ } const fields = ctx.client().fields if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - fields.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + fields.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/flows/list.ts b/src/commands/flows/list.ts index 0f5d38e..59c225b 100644 --- a/src/commands/flows/list.ts +++ b/src/commands/flows/list.ts @@ -107,16 +107,20 @@ export const flowsListCommand = defineCommand({ const isSemantic = 'semantic' in input && input.semantic !== undefined if (flags.all === true) { let count: FlowCount = {} - const rows = await collectAll(ctx, async (cursor) => { - const page = await flows.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) - count = readCount(page) - return page - }) + const rows = await collectAll( + ctx, + async (cursor) => { + const page = await flows.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ) + count = readCount(page) + return page + }, + { cursor: flagString(input.cursor) } + ) return { data: { data: rows, diff --git a/src/commands/insights/list.ts b/src/commands/insights/list.ts index 4f76e09..cef05a9 100644 --- a/src/commands/insights/list.ts +++ b/src/commands/insights/list.ts @@ -128,17 +128,21 @@ async function listEveryInsight( for (let attempt = 1; ; attempt += 1) { extras = undefined try { - rows = await collectAll(ctx, async (cursor) => { - const page = await insights.list( - asSdkInput( - extras === undefined - ? { ...input, ...(cursor === undefined ? {} : { cursor }) } - : { ...rest, ...(cursor === undefined ? {} : { cursor }) } + rows = await collectAll( + ctx, + async (cursor) => { + const page = await insights.list( + asSdkInput( + extras === undefined + ? { ...input, ...(cursor === undefined ? {} : { cursor }) } + : { ...rest, ...(cursor === undefined ? {} : { cursor }) } + ) ) - ) - extras ??= readExtras(page) - return page - }) + extras ??= readExtras(page) + return page + }, + { cursor: startCursor } + ) break } catch (error) { if (!isStaleCursor(error)) { diff --git a/src/commands/integrations/list.ts b/src/commands/integrations/list.ts index 9720565..48ee8d0 100644 --- a/src/commands/integrations/list.ts +++ b/src/commands/integrations/list.ts @@ -41,7 +41,7 @@ export const integrationsListCommand = defineCommand({ }, }) if (flags.all === true) { - const rows = await collectAll(ctx, (next) => page(next ?? cursor)) + const rows = await collectAll(ctx, page, { cursor }) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, human: renderIntegrations(rows), diff --git a/src/commands/login.ts b/src/commands/login.ts index 295e72f..4c7d197 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -55,7 +55,7 @@ async function readCandidateKey(ctx: CliContext): Promise { return ctx.globals.apiKey } if (!ctx.io.isTtyIn) { - const piped = (await ctx.io.readStdin()).trim() + const piped = (await ctx.io.readStdin(ctx.signal)).trim() if (piped !== '') { return piped } diff --git a/src/commands/notifications/list.ts b/src/commands/notifications/list.ts index caee62f..bd5522d 100644 --- a/src/commands/notifications/list.ts +++ b/src/commands/notifications/list.ts @@ -50,13 +50,16 @@ export const notificationsListCommand = defineCommand({ } const notifications = ctx.client().notifications if (flags.all === true) { - const rows = await collectAll(ctx, (cursor) => - notifications.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + notifications.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/sends/list.ts b/src/commands/sends/list.ts index 6743018..d04378b 100644 --- a/src/commands/sends/list.ts +++ b/src/commands/sends/list.ts @@ -98,13 +98,16 @@ export async function listSends( ): Promise<{ data: unknown; human: string }> { const sends = ctx.client().sends if (followCursor) { - const rows = await collectAll(ctx, (cursor) => - sends.list( - asSdkInput({ - ...input, - ...(cursor === undefined ? {} : { cursor }), - }) - ) + const rows = await collectAll( + ctx, + (cursor) => + sends.list( + asSdkInput({ + ...input, + ...(cursor === undefined ? {} : { cursor }), + }) + ), + { cursor: flagString(input.cursor) } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/src/commands/types.ts b/src/commands/types.ts index 7303d46..e571d13 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -1,15 +1,10 @@ import { createHash } from 'node:crypto' import { readFileSync, writeFileSync } from 'node:fs' -import type { - ListTriggersInput, - ListTriggersResponse, - Trigger, -} from '@brew.new/sdk' +import type { ListTriggersInput, ListTriggersResponse } from '@brew.new/sdk' import { defineCommand } from '../lib/define-command' import { asSdkInput } from '../lib/input' - -type TriggerRow = Trigger +import { collectAll } from '../lib/paginate' /** * `brew-cli types` — generate TypeScript payload contracts for this @@ -146,34 +141,20 @@ export const typesCommand = defineCommand({ 'brew-cli types --check', ], run: async ({ ctx, flags }) => { - // Follow the cursor to the end — a silent 100-trigger cap would make - // `--check` certify an incomplete file (integration catalogs alone - // can cross 100). The page ceiling is a runaway-loop backstop only. - const triggers: Array = [] - let cursor: string | null = null - for (let page = 0; page < 50; page++) { - const listResponse: ListTriggersResponse = await ctx + // Complete the read before writing or certifying any artifact. Shared + // pagination rejects cycles and observes the command's stop signal. + const triggers = await collectAll(ctx, async (cursor) => { + const response: ListTriggersResponse = await ctx .client() .automations.triggers.list( asSdkInput({ limit: 100, - ...(cursor === null ? {} : { cursor }), + ...(cursor === undefined ? {} : { cursor }), }) ) - triggers.push(...(listResponse.data ?? [])) - if ( - !listResponse.pagination?.hasMore || - !listResponse.pagination.cursor - ) { - cursor = null - break - } - cursor = listResponse.pagination.cursor - } - if (cursor !== null) - throw new Error( - 'Trigger pagination exceeded the safety limit; no output was written.' - ) + return { data: response.data, pagination: response.pagination } + }) + ctx.signal.throwIfAborted() const artifacts: Array = [] for (let index = 0; index < triggers.length; index += 8) { ctx.signal.throwIfAborted() @@ -201,6 +182,7 @@ export const typesCommand = defineCommand({ ) artifacts.push(...batch) } + ctx.signal.throwIfAborted() const text = buildFileText({ artifacts }) const outPath = typeof flags.out === 'string' ? flags.out : 'brew-contracts.ts' diff --git a/src/lib/confirm.ts b/src/lib/confirm.ts index 348750e..39119a2 100644 --- a/src/lib/confirm.ts +++ b/src/lib/confirm.ts @@ -10,7 +10,8 @@ import { CommandAbortedError, ConfirmationRequiredError } from './errors' */ export async function enforceConfirmation( spec: CommandSpec, - invocation: CommandInvocation + invocation: CommandInvocation, + options: { readonly confirmationArgv?: readonly string[] } = {} ): Promise { if (!spec.confirmSummary) { return @@ -18,27 +19,44 @@ export async function enforceConfirmation( const summary = spec.confirmSummary({ args: invocation.args, flags: invocation.flags, + ...(invocation.input === undefined ? {} : { input: invocation.input }), }) if (summary === undefined) { return } const { ctx } = invocation + ctx.signal.throwIfAborted() if (ctx.globals.yes) { return } if (ctx.mode === 'human' && ctx.io.isTtyIn) { - const answer = await ctx.io.readLine(`${summary}\nProceed? [y/N] `) - const normalized = answer.trim().toLowerCase() - if (normalized === 'y' || normalized === 'yes') { - return + const resumeDeadline = ctx.pauseDeadline() + try { + ctx.signal.throwIfAborted() + const answer = await ctx.io.readLine(`${summary}\nProceed? [y/N] `) + ctx.signal.throwIfAborted() + const normalized = answer.trim().toLowerCase() + if (normalized === 'y' || normalized === 'yes') { + return + } + throw new CommandAbortedError('Aborted.') + } catch (error) { + ctx.signal.throwIfAborted() + throw error + } finally { + resumeDeadline() } - throw new CommandAbortedError('Aborted.') } throw new ConfirmationRequiredError({ confirmationRequired: true, command: ['brew-cli', ...spec.path].join(' '), summary, - confirmCommand: buildConfirmCommand(ctx.rawArgv), + confirmCommand: buildConfirmCommand( + options.confirmationArgv ?? + (invocation.flags.input === '-' && invocation.input !== undefined + ? inlineStdinInputArgv(ctx.rawArgv, JSON.stringify(invocation.input)) + : ctx.rawArgv) + ), }) } @@ -46,6 +64,44 @@ export function buildConfirmCommand(rawArgv: readonly string[]): string { return ['brew-cli', ...rerunTokens(rawArgv), '--yes'].join(' ') } +/** Confirm stdin requests after parsing, carrying the merged body on a re-run. */ +export async function enforceStdinConfirmation( + spec: CommandSpec, + invocation: CommandInvocation, + input: Readonly> +): Promise { + if (invocation.flags.input !== '-') { + return + } + const serializedInput = JSON.stringify(input) + await enforceConfirmation( + spec, + { ...invocation, flags: { ...invocation.flags, input: serializedInput } }, + { + confirmationArgv: inlineStdinInputArgv( + invocation.ctx.rawArgv, + serializedInput + ), + } + ) +} + +/** Consumed JSON stdin must travel inline when confirmation reruns a command. */ +function inlineStdinInputArgv( + rawArgv: readonly string[], + input: string +): readonly string[] { + return rawArgv.map((token, index) => { + if (token === '--input=-') { + return `--input=${input}` + } + if (token === '-' && rawArgv[index - 1] === '--input') { + return input + } + return token + }) +} + /** * The command line that replays an interrupted or timed-out write: the same * argv with the idempotency key its request carried, so the API returns the diff --git a/src/lib/context.ts b/src/lib/context.ts index 183cc14..2f27c0f 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -1,5 +1,6 @@ import type { BrewClient, SdkTransport } from './client' import { buildSdkClient, resolveAuth } from './client' +import type { CommandMetadata } from './define-command' import { CliTimeoutError } from './errors' import { resolveOutputMode } from './output' import { @@ -23,6 +24,7 @@ export function makeContext(input: { readonly flags: Readonly> readonly rawArgv: readonly string[] readonly traits?: CommandTraits + readonly commands?: readonly CommandMetadata[] }): CliContext { const globals = readGlobalFlags(input.flags) const mode = resolveOutputMode({ @@ -34,12 +36,31 @@ export function makeContext(input: { defaultTimeoutMs: input.traits?.defaultTimeoutMs, }) const deadline = new AbortController() - let deadlineTimer: ReturnType | undefined + let remainingMs = budget.deadlineMs + let activeDeadline: + | { + readonly timer: ReturnType + readonly startedAt: number + readonly allowanceMs: number + } + | undefined const { signal } = anySignal([input.io.signal, deadline.signal]) const transport = createTransportState() const cache = new Map() + let stdinConsumed = false + const io: CliIo = { + ...input.io, + get isTtyIn() { + return input.io.isTtyIn && !stdinConsumed + }, + readStdin: (signal) => { + stdinConsumed = true + return input.io.readStdin(signal) + }, + } const ctx: CliContext = { - io: input.io, + commands: input.commands ?? [], + io, mode, globals, rawArgv: input.rawArgv, @@ -48,17 +69,54 @@ export function makeContext(input: { transport, traits: input.traits, startDeadline: () => { - const ms = budget.deadlineMs - if (ms === undefined || deadlineTimer !== undefined) { + const timeoutMs = budget.deadlineMs + if ( + timeoutMs === undefined || + remainingMs === undefined || + activeDeadline !== undefined || + signal.aborted + ) { + return + } + if (remainingMs <= 0) { + deadline.abort(new CliTimeoutError(timeoutMs)) return } - deadlineTimer = setTimeout(() => { - deadline.abort(new CliTimeoutError(ms)) - }, ms) + const allowanceMs = remainingMs + const startedAt = performance.now() + const timer = setTimeout(() => { + activeDeadline = undefined + remainingMs = 0 + deadline.abort(new CliTimeoutError(timeoutMs)) + }, allowanceMs) + activeDeadline = { timer, startedAt, allowanceMs } + }, + pauseDeadline: () => { + if (activeDeadline === undefined) { + return () => undefined + } + ctx.stopDeadline() + if (remainingMs === 0 && budget.deadlineMs !== undefined) { + deadline.abort(new CliTimeoutError(budget.deadlineMs)) + } + let hasResumed = false + return () => { + if (hasResumed) { + return + } + hasResumed = true + ctx.startDeadline() + } }, stopDeadline: () => { - if (deadlineTimer !== undefined) { - clearTimeout(deadlineTimer) + if (activeDeadline !== undefined) { + clearTimeout(activeDeadline.timer) + remainingMs = Math.max( + 0, + activeDeadline.allowanceMs - + (performance.now() - activeDeadline.startedAt) + ) + activeDeadline = undefined } }, client: (options?: ClientOptions) => { diff --git a/src/lib/define-command.ts b/src/lib/define-command.ts index 1a3b22d..afbefa9 100644 --- a/src/lib/define-command.ts +++ b/src/lib/define-command.ts @@ -28,13 +28,15 @@ export type CommandInvocation = { readonly ctx: CliContext readonly args: Readonly> readonly flags: Readonly> + /** Resolved once before confirmation; stdin must not be consumed twice. */ + readonly input?: Readonly> } export type CommandOutcome = { /** The payload printed to stdout (verbatim in JSON mode). */ readonly data: unknown /** Optional human rendering (tables, summaries) for TTY output. */ - readonly human?: string + readonly human?: string | (() => string) /** * Optional non-zero exit code for commands whose payload IS the result * of a check (e.g. doctor): the report prints normally, then the @@ -82,6 +84,10 @@ export type CommandSpec = { readonly flags?: readonly FlagSpec[] /** At least one realistic invocation; shown in help and the manifest. */ readonly examples: readonly string[] + /** Resolve a payload for both confirmation and execution, without API calls. */ + readonly prepareInput?: ( + invocation: CommandInvocation + ) => Promise>> /** * Destructive commands describe what is about to happen. Returning * undefined skips the gate for the given invocation (e.g. a dry run). @@ -99,6 +105,43 @@ export function defineCommand(spec: CommandSpec): CommandSpec { return spec } +/** Read-only catalog data; discovery commands never need executable handlers. */ +export type CommandMetadata = Pick< + CommandSpec, + | 'path' + | 'summary' + | 'sdkMethod' + | 'derivedFrom' + | 'route' + | 'commandClass' + | 'isCredited' + | 'isRawTransport' + | 'args' + | 'flags' + | 'examples' +> & { readonly confirmation: boolean } + +export function commandMetadata(spec: CommandSpec): CommandMetadata { + return { + path: spec.path, + summary: spec.summary, + sdkMethod: spec.sdkMethod, + ...(spec.derivedFrom !== undefined + ? { derivedFrom: spec.derivedFrom } + : {}), + ...(spec.route !== undefined ? { route: spec.route } : {}), + commandClass: spec.commandClass, + ...(spec.isCredited !== undefined ? { isCredited: spec.isCredited } : {}), + ...(spec.isRawTransport !== undefined + ? { isRawTransport: spec.isRawTransport } + : {}), + ...(spec.args !== undefined ? { args: spec.args } : {}), + ...(spec.flags !== undefined ? { flags: spec.flags } : {}), + examples: spec.examples, + confirmation: spec.confirmSummary !== undefined, + } +} + export const GLOBAL_FLAGS: readonly FlagSpec[] = [ { flag: '--json', diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 72b348c..ae9dc5c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -16,6 +16,17 @@ export const EXIT_INTERRUPTED = 130 /** 128 + SIGTERM, as a shell reports a terminated process. */ export const EXIT_TERMINATED = 143 +/** A server pagination cycle is a failed drain, never a complete list. */ +export class CliPaginationError extends Error { + readonly cursor: string + + constructor(cursor: string) { + super('Pagination returned a previously fetched cursor.') + this.name = 'CliPaginationError' + this.cursor = cursor + } +} + export type InterruptSignal = 'SIGINT' | 'SIGTERM' export function exitCodeForSignal(signal: InterruptSignal): number { @@ -262,6 +273,16 @@ export function toErrorEnvelope( } function baseEnvelope(error: unknown, context: ErrorContext): CliErrorEnvelope { + if (error instanceof CliPaginationError) { + return { + code: 'CLI_PAGINATION', + type: 'internal_error', + message: error.message, + suggestion: + 'Report the pagination failure. The list is incomplete; a cyclic cursor cannot safely resume it.', + details: { cursor: error.cursor }, + } + } if (error instanceof CliInterruptError) { return { code: 'CLI_INTERRUPTED', diff --git a/src/lib/input.ts b/src/lib/input.ts index 95bf8b5..1466f00 100644 --- a/src/lib/input.ts +++ b/src/lib/input.ts @@ -21,7 +21,7 @@ export async function readJsonFlag( if (typeof value !== 'string') { return } - const raw = value === '-' ? await ctx.io.readStdin() : value + const raw = value === '-' ? await ctx.io.readStdin(ctx.signal) : value try { return JSON.parse(raw) as unknown } catch { @@ -41,7 +41,7 @@ export async function readTextFlag( return } if (value === '-') { - return await ctx.io.readStdin() + return await ctx.io.readStdin(ctx.signal) } try { return readFileSync(value, 'utf8') @@ -146,8 +146,25 @@ export function toStringArray(value: unknown): readonly string[] | undefined { export function requestOptions( flags: Readonly> ): { idempotencyKey: string } | undefined { - const key = flagString(flags.idempotencyKey) - return key === undefined ? undefined : { idempotencyKey: key } + if (flags.idempotencyKey === undefined) return undefined + const raw = flagString(flags.idempotencyKey) + let key: string | undefined + try { + key = + raw === undefined + ? undefined + : new Headers({ 'Idempotency-Key': raw }).get('Idempotency-Key')?.trim() + } catch { + throw new CliUsageError( + '--idempotency-key must be a valid HTTP header value.' + ) + } + if (!key || key.length > 100) { + throw new CliUsageError( + '--idempotency-key must contain 1–100 characters after trimming.' + ) + } + return { idempotencyKey: key } } export const IDEMPOTENCY_FLAG = { diff --git a/src/lib/manifest.ts b/src/lib/manifest.ts index 648b54a..dd7386b 100644 --- a/src/lib/manifest.ts +++ b/src/lib/manifest.ts @@ -1,14 +1,17 @@ -import { ALL_COMMANDS } from '../registry' import { SDK_SKIP_LIST, SPEC_SKIP_LIST } from '../skip-list' import { CLI_NAME, CLI_VERSION } from '../version' -import { GLOBAL_FLAGS } from './define-command' +import { type CommandMetadata, GLOBAL_FLAGS } from './define-command' /** * The machine-readable surface contract printed by `brew-cli docs --agent`. * Additive-only: tests snapshot this shape, so removals or renames are * breaking changes by construction. */ -export function buildManifest(): Record { +export function buildManifest({ + commands, +}: { + readonly commands: readonly CommandMetadata[] +}): Record { return { name: CLI_NAME, version: CLI_VERSION, @@ -43,7 +46,7 @@ export function buildManifest(): Record { flag: flag.flag, summary: flag.summary, })), - commands: ALL_COMMANDS.map((spec) => ({ + commands: commands.map((spec) => ({ command: [CLI_NAME, ...spec.path].join(' '), summary: spec.summary, class: spec.commandClass, @@ -65,7 +68,7 @@ export function buildManifest(): Record { flag: flag.flag, summary: flag.summary, })), - confirmation: spec.confirmSummary !== undefined, + confirmation: spec.confirmation, examples: spec.examples, })), skips: { diff --git a/src/lib/output.ts b/src/lib/output.ts index f929190..1ba2329 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -1,3 +1,4 @@ +import type { CommandOutcome } from './define-command' import type { CliContext, OutputMode } from './types' export function resolveOutputMode(input: { @@ -18,13 +19,15 @@ export function resolveOutputMode(input: { export function printData( ctx: CliContext, data: unknown, - human?: string + human?: CommandOutcome['human'] ): void { if (ctx.mode === 'json') { ctx.io.stdout.write(`${JSON.stringify(data)}\n`) return } - const text = human ?? JSON.stringify(data, null, 2) + const text = + (typeof human === 'function' ? human() : human) ?? + JSON.stringify(data, null, 2) ctx.io.stdout.write(`${text}\n`) } @@ -41,9 +44,9 @@ export function renderTable( columns: ReadonlyArray<{ readonly key: string; readonly header: string }> ): string { const widths = columns.map((column) => - Math.max( - column.header.length, - ...rows.map((row) => formatCell(row[column.key]).length) + rows.reduce( + (width, row) => Math.max(width, formatCell(row[column.key]).length), + column.header.length ) ) const line = (cells: readonly string[]) => diff --git a/src/lib/paginate.ts b/src/lib/paginate.ts index fab0f95..ba1345e 100644 --- a/src/lib/paginate.ts +++ b/src/lib/paginate.ts @@ -1,3 +1,4 @@ +import { CliPaginationError } from './errors' import { progress } from './output' import type { CliContext } from './types' @@ -12,6 +13,8 @@ export type Page = { * Drains a cursor-paginated list for `--all`. The API contract keeps * `pagination.cursor` string-or-null (never omitted); a repeated cursor * aborts the loop as a defense against a server-side pagination bug. + * Pass the request's starting cursor in `options` so resumed walks also + * refuse to fetch their first page again. * * An interrupt or `--timeout` stops the drain between pages as well as * mid-page. Rows fetched so far are NOT printed — a partial list would read @@ -20,11 +23,13 @@ export type Page = { */ export async function collectAll( ctx: CliContext, - fetchPage: (cursor: string | undefined) => Promise> + fetchPage: (cursor: string | undefined) => Promise>, + options?: { readonly cursor?: string | undefined } ): Promise> { const rows: T[] = [] - let cursor: string | undefined + let cursor = options?.cursor let pageCount = 0 + const fetched = new Set() for (;;) { let page: Page try { @@ -43,7 +48,15 @@ export async function collectAll( rows.push(...page.data) pageCount += 1 const next = page.pagination?.cursor ?? null - if (next === null || next === cursor) { + assertPaginationProgress({ + ctx, + fetchedCursors: fetched, + cursor, + next, + rowsFetched: rows.length, + pagesFetched: pageCount, + }) + if (next === null) { return rows } cursor = next @@ -51,6 +64,31 @@ export async function collectAll( } } +/** Lists and nested message walks share the same cycle and error protocol. */ +export function assertPaginationProgress({ + ctx, + fetchedCursors, + cursor, + next, + rowsFetched, + pagesFetched, +}: { + readonly ctx: CliContext + readonly fetchedCursors: Set + readonly cursor: string | undefined + readonly next: string | null + readonly rowsFetched: number + readonly pagesFetched: number +}): void { + if (cursor !== undefined) { + fetchedCursors.add(cursor) + } + if (next !== null && fetchedCursors.has(next)) { + ctx.transport.setDrain({ rowsFetched, pagesFetched }) + throw new CliPaginationError(next) + } +} + /** The `--all` flag shared by paginated list commands. */ export const ALL_FLAG = { flag: '--all', diff --git a/src/lib/stdin.ts b/src/lib/stdin.ts new file mode 100644 index 0000000..416d276 --- /dev/null +++ b/src/lib/stdin.ts @@ -0,0 +1,34 @@ +import type { Readable } from 'node:stream' + +/** Read through EOF; an interrupted or timed-out command closes its input. */ +export async function readStdinText({ + stream, + signal, +}: { + readonly stream: Readable + readonly signal: AbortSignal +}): Promise { + if (signal.aborted) { + stream.destroy() + signal.throwIfAborted() + } + const stop = (): void => { + stream.destroy() + } + signal.addEventListener('abort', stop, { once: true }) + stream.setEncoding('utf8') + let text = '' + try { + for await (const chunk of stream) { + text += String(chunk) + } + } catch (error) { + if (!signal.aborted) { + throw error + } + } finally { + signal.removeEventListener('abort', stop) + } + signal.throwIfAborted() + return text +} diff --git a/src/lib/types.ts b/src/lib/types.ts index d106694..58effd3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,5 @@ import type { BrewClient } from './client' +import type { CommandMetadata } from './define-command' import type { Budget, TransportState } from './transport' /** @@ -11,7 +12,8 @@ export type CliIo = { readonly isTtyOut: boolean readonly isTtyIn: boolean readonly env: Readonly> - readonly readStdin: () => Promise + /** When supplied, the command signal closes input on interrupt or timeout. */ + readonly readStdin: (signal?: AbortSignal) => Promise readonly readLine: (prompt: string) => Promise /** * Aborts on SIGINT/SIGTERM with a `CliInterruptError` (see @@ -59,6 +61,7 @@ export type ClientOptions = { } export type CliContext = { + readonly commands: readonly CommandMetadata[] readonly io: CliIo readonly mode: OutputMode readonly globals: GlobalFlags @@ -73,7 +76,9 @@ export type CliContext = { readonly budget: Budget readonly transport: TransportState readonly traits: CommandTraits | undefined - /** Start the whole-command deadline, after any confirmation prompt. */ + /** Start or resume the whole-command deadline; human prompts pause it. */ readonly startDeadline: () => void + /** Pause an active clock; the returned callback resumes its remaining budget. */ + readonly pauseDeadline: () => () => void readonly stopDeadline: () => void } diff --git a/tests/commands/audience-run-pagination.test.ts b/tests/commands/audience-run-pagination.test.ts new file mode 100644 index 0000000..623bc3e --- /dev/null +++ b/tests/commands/audience-run-pagination.test.ts @@ -0,0 +1,107 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { server } from '../helpers/msw-server' +import { runCli } from '../helpers/run-cli' + +const API = 'https://brew.new/api' +const command = (options: { all?: boolean; ttyOut?: boolean } = {}) => + runCli( + [ + 'automations', + 'audience-runs', + 'list', + ...(options.all === false ? [] : ['--all']), + ], + { + ...(options.ttyOut === undefined ? {} : { ttyOut: options.ttyOut }), + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync( + join(tmpdir(), 'brew-cli-pagination-') + ), + BREW_API_KEY: 'brew_abcdefghijklmnopqrstuvwxyz012345', + BREW_BRAND_ID: 'kx7b3s7fapqz8mjm12ekz1kxdx87yceg', + }, + } + ) + +describe('audience history pagination', () => { + it('explains how to continue an empty filtered page on a terminal', async () => { + server.use( + http.get(`${API}/v1/automations/audience-runs`, () => + HttpResponse.json({ + data: [], + pagination: { limit: 100, cursor: 'sparse', hasMore: true }, + }) + ) + ) + const result = await command({ all: false, ttyOut: true }) + expect(result.code).toBe(0) + expect(result.stdout).toContain('No matching audience runs on this page.') + expect(result.stdout).toContain('--cursor sparse') + expect(result.stdout).toContain('--all') + expect(result.stdout).not.toContain('No audience runs found.') + }) + it('fails a stalled cursor without printing a partial list as complete', async () => { + let requests = 0 + server.use( + http.get(`${API}/v1/automations/audience-runs`, () => { + requests += 1 + return HttpResponse.json({ + data: [{ audienceRunId: 'run_1', status: 'running' }], + pagination: { limit: 100, cursor: 'stalled', hasMore: true }, + }) + }) + ) + const result = await command() + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + const envelope = JSON.parse( + result.stderr.trim().split('\n').at(-1) ?? '' + ) as { + error: { + code: string + progress: { + rowsFetched: number + pagesFetched: number + resumeCursor: string + } + } + } + expect(envelope.error).toMatchObject({ + code: 'CLI_PAGINATION', + progress: { rowsFetched: 2, pagesFetched: 2 }, + }) + expect(envelope.error.progress).not.toHaveProperty('resumeCursor') + expect(requests).toBe(2) + }) + + it('continues sparse history pages until the cursor is exhausted', async () => { + const cursors: Array = [] + server.use( + http.get(`${API}/v1/automations/audience-runs`, ({ request }) => { + const cursor = new URL(request.url).searchParams.get('cursor') + cursors.push(cursor) + return HttpResponse.json( + cursor === null + ? { + data: [], + pagination: { limit: 100, cursor: 'sparse', hasMore: true }, + } + : { + data: [{ audienceRunId: 'old_active', status: 'running' }], + pagination: { limit: 100, cursor: null, hasMore: false }, + } + ) + }) + ) + const result = await command() + expect(result.code).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ + data: [{ audienceRunId: 'old_active' }], + }) + expect(cursors).toEqual([null, 'sparse']) + }) +}) diff --git a/tests/commands/automation-update-safety.test.ts b/tests/commands/automation-update-safety.test.ts new file mode 100644 index 0000000..922845f --- /dev/null +++ b/tests/commands/automation-update-safety.test.ts @@ -0,0 +1,109 @@ +import { HttpResponse, http } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' +import { server } from '../helpers/msw-server' +import { runCli } from '../helpers/run-cli' + +const env = { BREW_API_KEY: 'brew_abcdefghijklmnopqrstuvwxyz012345' } +let bodies: unknown[] +beforeEach(() => { + bodies = [] + server.use( + http.patch( + 'https://brew.new/api/v1/automations/am_1', + async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ automationId: 'am_1' }) + } + ) + ) +}) +describe('resolved automation update confirmation', () => { + it.each([ + { published: false, stopInFlight: true }, + { published: true }, + { paused: false }, + ])('requires confirmation for %j, including stdin', async (input) => { + for (const source of [JSON.stringify(input), '-']) { + const result = await runCli( + ['automations', 'update', 'am_1', '--input', source], + { + env, + stdin: JSON.stringify(input), + } + ) + expect(result.code).toBe(4) + expect(result.json).toMatchObject({ confirmationRequired: true }) + expect(bodies).toEqual([]) + } + }) + it('returns confirmation-required after consuming interactive stdin', async () => { + const result = await runCli( + ['automations', 'update', 'am_1', '--input', '-'], + { + env, + stdin: JSON.stringify({ published: true }), + ttyIn: true, + ttyOut: true, + promptAnswer: 'yes', + } + ) + expect(result.code).toBe(4) + expect(result.json).toMatchObject({ confirmationRequired: true }) + expect(bodies).toEqual([]) + }) + it.each([['--input', '-'], ['--input=-']])( + 'preserves consumed input in confirmation reruns: %j', + async (...inputFlag) => { + const input = { published: true, name: 'Original' } + const result = await runCli( + ['automations', 'update', 'am_1', ...inputFlag, '--name', 'Resolved'], + { + env, + stdin: JSON.stringify(input), + } + ) + expect(result.code).toBe(4) + const command = (result.json as { confirmCommand: string }).confirmCommand + expect(command).toContain( + JSON.stringify({ published: true, name: 'Resolved' }) + ) + expect(command).not.toContain('--input -') + expect(command).not.toContain('--input=-') + expect(bodies).toEqual([]) + } + ) + + it('can confirm inline input interactively', async () => { + const input = { published: true } + const result = await runCli( + ['automations', 'update', 'am_1', '--input', JSON.stringify(input)], + { env, ttyIn: true, ttyOut: true, promptAnswer: 'yes' } + ) + expect(result.code).toBe(0) + expect(bodies).toEqual([input]) + }) + it('sends the exact resolved stdin payload after confirmation', async () => { + const input = { published: false, stopInFlight: true } + const result = await runCli( + ['automations', 'update', 'am_1', '--input', '-', '--yes'], + { + env, + stdin: JSON.stringify(input), + } + ) + expect(result.code).toBe(0) + expect(bodies).toEqual([input]) + }) + it.each([ + { name: 'Welcome' }, + { published: false }, + { name: 'Dry run draft', dryRun: true }, + ])('does not gate safe updates or dry runs: %j', async (input) => { + const result = await runCli( + ['automations', 'update', 'am_1', '--input', JSON.stringify(input)], + { env } + ) + expect(result.code).toBe(0) + expect(bodies).toEqual([input]) + }) +}) diff --git a/tests/commands/automations-test.test.ts b/tests/commands/automations-test.test.ts new file mode 100644 index 0000000..1852155 --- /dev/null +++ b/tests/commands/automations-test.test.ts @@ -0,0 +1,185 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { automationsTestCommand } from '../../src/commands/automations/test' +import { server } from '../helpers/msw-server' +import { runCli } from '../helpers/run-cli' + +const API = 'https://brew.new/api/v1/automations/am_1/test' +const scenario = { splits: [{ nodeId: 'split_1', branch: 'left' }] } + +function cli( + input: unknown, + options: { yes?: boolean; stdin?: boolean; idempotencyKey?: string } = {} +) { + const json = JSON.stringify(input) + return runCli( + [ + 'automations', + 'test', + 'am_1', + '--input', + options.stdin === true ? '-' : json, + ...(options.yes === true ? ['--yes'] : []), + ...(options.idempotencyKey === undefined + ? [] + : ['--idempotency-key', options.idempotencyKey]), + ], + { + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-test-')), + BREW_API_KEY: 'brew_abcdefghijklmnopqrstuvwxyz012345', + }, + ...(options.stdin === true ? { stdin: json } : {}), + extraCommands: [automationsTestCommand], + } + ) +} + +function observeRequests() { + const bodies: unknown[] = [] + server.use( + http.post(API, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json( + { automationRunIds: ['arun_1'], status: 'test_started' }, + { status: 202 } + ) + }) + ) + return bodies +} + +describe('automation test delivery boundary', () => { + it('forwards the explicit replay key as a request header', async () => { + let key: string | null | undefined + server.use( + http.post(API, ({ request }) => { + key = request.headers.get('Idempotency-Key') + return HttpResponse.json({ + automationRunIds: ['arun_1'], + status: 'test_started', + }) + }) + ) + const result = await cli({}, { idempotencyKey: 'automation-test-retry' }) + expect(result.code).toBe(0) + expect(key).toBe('automation-test-retry') + }) + + it.each([ + { testRecipient: 'qa@example.com' }, + { testRecipient: 'qa@example.com', scenario }, + { + payload: { email: 'contact@example.com' }, + testRecipient: 'qa@example.com', + scenario, + }, + ])('preserves API request fields with --yes: %j', async (input) => { + const bodies = observeRequests() + const result = await cli(input, { yes: true }) + expect(result.code).toBe(0) + expect(bodies).toEqual([input]) + }) + + it.each([false, true])( + 'requires confirmation before real test delivery (stdin=%s)', + async (stdin) => { + const bodies = observeRequests() + const result = await cli({ testRecipient: 'qa@example.com' }, { stdin }) + expect(result.code).toBe(4) + expect(result.json).toMatchObject({ confirmationRequired: true }) + expect(bodies).toEqual([]) + } + ) + + it.each([false, true])( + 'runs a scenario dry run without confirmation (stdin=%s)', + async (stdin) => { + const bodies = observeRequests() + const input = { scenario } + const result = await cli(input, { stdin }) + expect(result.code).toBe(0) + expect(bodies).toEqual([input]) + } + ) + + it('delivers an explicit stdin test request with --yes', async () => { + const bodies = observeRequests() + const input = { + testRecipient: 'qa@example.com', + payload: { userId: 'u_1' }, + } + const result = await cli(input, { stdin: true, yes: true }) + expect(result.code).toBe(0) + expect(bodies).toEqual([input]) + }) + + it.each([['--input', '-'], ['--input=-']])( + 'replays the stdin confirmation command without stdin (%j)', + async (...inputArgs) => { + const bodies = observeRequests() + const input = { + testRecipient: 'qa@example.com', + payload: { name: "Operator's example", userId: 'u_1' }, + scenario, + } + const idempotencyKey = 'stdin-test-retry' + const env = { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-test-')), + BREW_API_KEY: 'brew_abcdefghijklmnopqrstuvwxyz012345', + } + let key: string | null | undefined + server.use( + http.post(API, async ({ request }) => { + key = request.headers.get('Idempotency-Key') + bodies.push(await request.json()) + return HttpResponse.json({ + automationRunIds: ['arun_1'], + status: 'test_started', + }) + }) + ) + const unconfirmed = await runCli( + [ + 'automations', + 'test', + 'am_1', + ...inputArgs, + '--idempotency-key', + idempotencyKey, + ], + { env, stdin: JSON.stringify(input) } + ) + expect(unconfirmed.code).toBe(4) + expect(bodies).toEqual([]) + const { confirmCommand } = unconfirmed.json as { + confirmCommand: string + } + // Let the shell parse its quoting without executing the CLI or an API call. + const argv = execFileSync( + 'sh', + ['-c', `set -- ${confirmCommand}; printf '%s\\0' "$@"`], + { encoding: 'utf8' } + ) + .split('\0') + .slice(0, -1) + expect(argv[0]).toBe('brew-cli') + const confirmed = await runCli(argv.slice(1), { env }) + expect(confirmed.code).toBe(0) + expect(bodies).toEqual([input]) + expect(key).toBe(idempotencyKey) + } + ) + + it('keeps a nested testRecipient in trigger payload as a dry run', async () => { + const bodies = observeRequests() + const input = { payload: { testRecipient: 'contact@example.com' } } + const result = await cli(input) + expect(result.code).toBe(0) + expect(bodies).toEqual([input]) + }) +}) diff --git a/tests/commands/emails.test.ts b/tests/commands/emails.test.ts index cc82403..09519e4 100644 --- a/tests/commands/emails.test.ts +++ b/tests/commands/emails.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -665,7 +666,124 @@ describe('emails send', () => { }) }) +describe('stdin confirmation after reading to EOF', () => { + it('returns a replayable confirmation instead of prompting on exhausted stdin', async () => { + let dispatches = 0 + server.use( + http.post(`https://brew.new/api/v1/sends`, () => { + dispatches += 1 + return HttpResponse.json({ sendId: 'send_1' }) + }) + ) + const input = { + subject: 'Confirmed campaign', + domainId: 'dom_1', + to: 'qa@example.com', + } + const result = await runCli(['emails', 'send', 'eml_1', '--input', '-'], { + env: env(), + stdin: JSON.stringify(input), + ttyIn: true, + ttyOut: true, + promptAnswer: 'yes', + }) + expect(result.code).toBe(4) + expect(result.json).toMatchObject({ confirmationRequired: true }) + expect(dispatches).toBe(0) + expect( + (result.json as { confirmCommand: string }).confirmCommand + ).toContain('Confirmed campaign') + }) +}) + describe('emails send with --input bodies (gate awareness)', () => { + it.each([['--input', '-'], ['--input=-']])( + 'sends a stdin test request without the campaign confirmation gate (%j)', + async (...inputArgs) => { + const input = { + test: true, + subject: 'Preview', + to: 'qa@example.com', + } + const bodies: unknown[] = [] + server.use( + http.post(SENDS_URL, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ status: 'completed', recipient: input.to }) + }) + ) + const result = await runCli(['emails', 'send', 'eml_1', ...inputArgs], { + env: env(), + stdin: JSON.stringify(input), + extraCommands: EXTRA, + }) + expect(result.code).toBe(0) + expect(bodies).toEqual([{ ...input, emailId: 'eml_1' }]) + expect(result.json).toEqual({ status: 'completed', recipient: input.to }) + } + ) + + it.each([['--input', '-'], ['--input=-']])( + 'confirms the scoped stdin campaign and replays its body without stdin (%j)', + async (...inputArgs) => { + const input = { + emailId: 'eml_other', + subject: "Operator's launch", + domainId: 'dom_1', + audienceId: 'aud_9', + scheduledAt: '2099-01-01T00:00:00Z', + } + const bodies: unknown[] = [] + let key: string | null | undefined + server.use( + http.post(SENDS_URL, async ({ request }) => { + bodies.push(await request.json()) + key = request.headers.get('Idempotency-Key') + return HttpResponse.json( + { status: 'scheduled', sendId: 'snd_1' }, + { status: 202 } + ) + }) + ) + const environment = env() + const result = await runCli( + [ + 'emails', + 'send', + 'eml_1', + ...inputArgs, + '--idempotency-key', + 'send-stdin-retry', + ], + { env: environment, stdin: JSON.stringify(input), extraCommands: EXTRA } + ) + expect(result.code).toBe(4) + expect(bodies).toEqual([]) + const { summary, confirmCommand } = result.json as { + summary: string + confirmCommand: string + } + expect(summary).toContain('email eml_1') + expect(summary).toContain('audience aud_9') + expect(summary).toContain('at 2099-01-01T00:00:00Z') + const argv = execFileSync( + 'sh', + ['-c', `set -- ${confirmCommand}; printf '%s\\0' "$@"`], + { encoding: 'utf8' } + ) + .split('\0') + .slice(0, -1) + expect(argv[0]).toBe('brew-cli') + const confirmed = await runCli(argv.slice(1), { + env: environment, + extraCommands: EXTRA, + }) + expect(confirmed.code).toBe(0) + expect(bodies).toEqual([{ ...input, emailId: 'eml_1' }]) + expect(key).toBe('send-stdin-retry') + } + ) + it('skips the gate when --input carries test:true, and it reaches the wire', async () => { let body: Record | undefined server.use( diff --git a/tests/commands/pagination-cycles.test.ts b/tests/commands/pagination-cycles.test.ts new file mode 100644 index 0000000..5342b28 --- /dev/null +++ b/tests/commands/pagination-cycles.test.ts @@ -0,0 +1,157 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { server } from '../helpers/msw-server' +import { runCli } from '../helpers/run-cli' + +const API = 'https://brew.new/api' + +function cli(args: readonly string[]) { + return runCli([...args, '--all', '--quiet'], { + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-cycles-')), + BREW_API_KEY: 'brew_abcdefghijklmnopqrstuvwxyz012345', + BREW_BRAND_ID: 'kx7b3s7fapqz8mjm12ekz1kxdx87yceg', + }, + }) +} + +describe('resumed pagination', () => { + it.each([ + ['analytics', 'events'], + ['api-keys', 'list'], + ['audiences', 'list'], + ['automations', 'audience-runs', 'list'], + ['automations', 'list'], + ['automations', 'runs', 'list'], + ['automations', 'trigger-instances', 'list'], + ['automations', 'triggers', 'list'], + ['brand', 'get-images'], + ['brands', 'list'], + ['chats', 'list'], + ['contacts', 'list'], + ['contacts', 'search'], + ['domains', 'list'], + ['emails', 'comments', 'list', 'em_1'], + ['emails', 'groups', 'list'], + ['emails', 'list'], + ['fields', 'list'], + ['flows', 'list'], + ['insights', 'list'], + ['integrations', 'list'], + ['notifications', 'list'], + ['sends', 'list'], + ['contacts', 'list', '--input', '{"cursor":"repeat"}'], + ])( + 'rejects the starting cursor before refetching it: %s', + async (...args) => { + const requested: Array = [] + server.use( + http.all(`${API}/v1/*`, async ({ request }) => { + const cursor = + request.method === 'POST' + ? (((await request.json()) as { cursor?: string }).cursor ?? null) + : new URL(request.url).searchParams.get('cursor') + requested.push(cursor) + return HttpResponse.json({ + data: [], + // Bound the old walk so a regression cannot hang the test runner. + pagination: { + limit: 100, + cursor: requested.length > 3 ? null : 'repeat', + hasMore: requested.length <= 3, + }, + }) + }) + ) + + const hasInput = args.includes('--input') + const result = await cli( + hasInput ? args : [...args, '--cursor', 'repeat'] + ) + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { + code: 'CLI_PAGINATION', + progress: { rowsFetched: 0, pagesFetched: 1 }, + }, + }) + expect(requested).toEqual(['repeat']) + expect( + JSON.parse(result.stderr).error.progress.resumeCursor + ).toBeUndefined() + } + ) +}) + +describe('comment-thread pagination', () => { + it.each([ + { start: undefined, cursors: ['a', 'a'] }, + { start: undefined, cursors: ['a', 'b', 'a'] }, + { start: 'a', cursors: ['a'] }, + ])( + 'rejects cyclic message slices without printing a partial thread', + async ({ start, cursors }) => { + const requested: Array = [] + server.use( + http.get(`${API}/v1/emails/em_1/comments`, ({ request }) => { + requested.push( + new URL(request.url).searchParams.get('messagesCursor') + ) + return HttpResponse.json({ + data: [ + { + commentId: 'cmt_1', + emailId: 'em_1', + target: { kind: 'email' }, + messageCount: 3, + url: 'https://brew.new/emails/em_1?comment=cmt_1', + messages: [ + { + messageId: `message_${requested.length}`, + author: { userId: 'usr_1', name: 'Jane Doe' }, + body: 'A comment', + createdAt: '2026-10-02T09:00:00.000Z', + }, + ], + messagesCursor: cursors[requested.length - 1] ?? null, + }, + ], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }) + ) + + const result = await cli([ + 'emails', + 'comments', + 'list', + 'em_1', + '--comment-id', + 'cmt_1', + ...(start === undefined ? [] : ['--messages-cursor', start]), + ]) + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { + code: 'CLI_PAGINATION', + progress: { + rowsFetched: cursors.length, + pagesFetched: cursors.length, + }, + }, + }) + expect(requested).toEqual([start ?? null, ...cursors.slice(0, -1)]) + expect( + JSON.parse(result.stderr).error.progress.resumeCursor + ).toBeUndefined() + expect( + JSON.parse(result.stderr).error.progress.resumeWith + ).toBeUndefined() + } + ) +}) diff --git a/tests/commands/raw-misc.test.ts b/tests/commands/raw-misc.test.ts index d3258d9..557244c 100644 --- a/tests/commands/raw-misc.test.ts +++ b/tests/commands/raw-misc.test.ts @@ -304,6 +304,55 @@ describe('automations run (confirmation protocol)', () => { expect(body).toEqual({ dryRun: true }) }) + it.each([['--input', '-'], ['--input=-']])( + 'previews a complete stdin request without confirmation (%j)', + async (...inputArgs) => { + const input = { dryRun: true, scheduledAt: '2099-01-01T00:00:00Z' } + const bodies: unknown[] = [] + server.use( + http.post(`${API}/v1/automations/auto_1/run`, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ + dryRun: true, + automationId: 'auto_1', + recipientCount: 12_400, + }) + }) + ) + const result = await runCli( + ['automations', 'run', 'auto_1', ...inputArgs], + { env: env(), stdin: JSON.stringify(input), extraCommands: EXTRA } + ) + expect(result.code).toBe(0) + expect(bodies).toEqual([input]) + expect(result.json).toMatchObject({ + dryRun: true, + recipientCount: 12_400, + }) + } + ) + + it('keeps a live stdin run behind confirmation before requesting the API', async () => { + const bodies: unknown[] = [] + server.use( + http.post(`${API}/v1/automations/auto_1/run`, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ status: 'queued', audienceRunId: 'arun_1' }) + }) + ) + const result = await runCli( + ['automations', 'run', 'auto_1', '--input', '-'], + { + env: env(), + stdin: '{"scheduledAt":"2099-01-01T00:00:00Z"}', + extraCommands: EXTRA, + } + ) + expect(result.code).toBe(4) + expect(result.json).toMatchObject({ confirmationRequired: true }) + expect(bodies).toEqual([]) + }) + it('gates a live run with exit 4 when unconfirmed', async () => { const result = await runCli(['automations', 'run', 'auto_1'], { env: env(), diff --git a/tests/commands/timeouts.test.ts b/tests/commands/timeouts.test.ts index 91aa8b5..1f4b211 100644 --- a/tests/commands/timeouts.test.ts +++ b/tests/commands/timeouts.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { HttpResponse, http } from 'msw' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { server } from '../helpers/msw-server' import { runCli } from '../helpers/run-cli' @@ -48,6 +48,67 @@ function stalledBody(): Response { } describe('--timeout', () => { + it( + 'bounds open stdin before an automation test makes an API request', + STALL, + async () => { + const bodies: unknown[] = [] + server.use( + http.post(`${API}/v1/automations/am_1/test`, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ + status: 'test_started', + automationRunIds: [], + }) + }) + ) + let releaseInput: ((input: string) => void) | undefined + const readStdin = vi.fn( + (signal?: AbortSignal) => + new Promise((resolve, reject) => { + releaseInput = resolve + const abort = () => { + signal?.removeEventListener('abort', abort) + reject(signal?.reason) + } + if (signal?.aborted) { + abort() + } else { + signal?.addEventListener('abort', abort, { once: true }) + } + }) + ) + const pending = runCli( + ['automations', 'test', 'am_1', '--input', '-', '--timeout', '50ms'], + { env: env(), readStdin } + ) + let guardTimer: ReturnType | undefined + try { + const result = await Promise.race([ + pending, + new Promise((resolve) => { + guardTimer = setTimeout(() => resolve(undefined), 300) + }), + ]) + expect( + result, + 'stdin must stop when the command deadline passes' + ).toBeDefined() + if (result === undefined) { + return + } + expect(result.code).toBe(1) + expect(envelopeOf(result.stderr).code).toBe('CLI_TIMEOUT') + expect(readStdin.mock.calls[0]?.[0]?.aborted).toBe(true) + expect(bodies).toEqual([]) + } finally { + clearTimeout(guardTimer) + releaseInput?.('{}') + await pending + } + } + ) + it('bounds a request that never answers', STALL, async () => { server.use(http.get(`${API}/v1/domains`, neverAnswers)) const started = performance.now() @@ -116,6 +177,34 @@ describe('--timeout', () => { } ) + it.each([['--input', '-'], ['--input=-']])( + 'requires the same stdin to retry a timed-out automation test (%j)', + STALL, + async (...inputArgs) => { + server.use(http.post(`${API}/v1/automations/am_1/test`, neverAnswers)) + const result = await runCli( + [ + 'automations', + 'test', + 'am_1', + ...inputArgs, + '--yes', + '--idempotency-key', + 'stdin-test-retry', + '--timeout', + '200ms', + ], + { env: env(), stdin: '{"testRecipient":"qa@example.com"}' } + ) + expect(result.code).toBe(1) + const error = envelopeOf(result.stderr) + expect(error.code).toBe('CLI_TIMEOUT') + expect(error.idempotencyKey).toBe('stdin-test-retry') + expect(error.suggestion).toContain('pipe the same input to it again') + expect(error.retryCommand).toContain(inputArgs.join(' ')) + } + ) + it.each(['abc', '0', '25h'])( 'refuses --timeout %s as a usage error', async (value) => { diff --git a/tests/commands/types-contracts.test.ts b/tests/commands/types-contracts.test.ts index 53e42c1..c891ecc 100644 --- a/tests/commands/types-contracts.test.ts +++ b/tests/commands/types-contracts.test.ts @@ -2,8 +2,10 @@ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { HttpResponse, http } from 'msw' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { typesCommand } from '../../src/commands/types' +import { defineCommand } from '../../src/lib/define-command' +import { CliInterruptError } from '../../src/lib/errors' import { server } from '../helpers/msw-server' import { runCli } from '../helpers/run-cli' @@ -73,6 +75,43 @@ describe('types uses authoritative contract artifacts', () => { expect(text).toContain('export const StablePayloadTriTwoContractHash =') }) + it.each([false, true])( + 'preserves the artifact when interrupted after its final contract read (check=%s)', + async (isCheck) => { + const { out, env } = setup() + const controller = new AbortController() + const existing = '// existing authoritative contracts\n' + writeFileSync(out, existing) + const command = defineCommand({ + ...typesCommand, + path: ['types-late-interrupt'], + run: async (invocation) => { + const triggers = invocation.ctx.client().automations.triggers + const getContract = triggers.getContract.bind(triggers) + vi.spyOn(triggers, 'getContract').mockImplementation( + async (...args) => { + const response = await getContract(...args) + controller.abort(new CliInterruptError('SIGINT')) + return response + } + ) + return typesCommand.run(invocation) + }, + }) + const result = await runCli( + ['types-late-interrupt', '--out', out, ...(isCheck ? ['--check'] : [])], + { + env, + signal: controller.signal, + extraCommands: [command], + } + ) + expect(result.code).toBe(130) + expect(result.stdout).toBe('') + expect(readFileSync(out, 'utf8')).toBe(existing) + } + ) + it('leaves the existing output intact if a contract cannot be read', async () => { const { out, env } = setup() writeFileSync(out, 'keep existing output') diff --git a/tests/commands/types.test.ts b/tests/commands/types.test.ts index 607dc9c..ff46751 100644 --- a/tests/commands/types.test.ts +++ b/tests/commands/types.test.ts @@ -2,10 +2,12 @@ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { HttpResponse, http } from 'msw' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { automationsTriggersReadyCommand } from '../../src/commands/automations/triggers/ready' import { typesCommand } from '../../src/commands/types' import type { components } from '../../src/generated/openapi-types' +import { defineCommand } from '../../src/lib/define-command' +import { CliInterruptError } from '../../src/lib/errors' import { server } from '../helpers/msw-server' import { runCli } from '../helpers/run-cli' @@ -64,7 +66,8 @@ beforeEach(() => { let name = 'UserSignedUpPayload' if (id === 'tri_order_receipt') name = 'OrderReceiptPayload' if (id === 'tri_l') name = 'Payload2026Launch' - if (id === 'tri_tail') name = 'TailTriggerPayload' + if (id === 'tri_tail' || id === 'tri_page_51') + name = 'TailTriggerPayload' if (id.startsWith('tri_page1_')) name = `PageOne${Number(id.slice('tri_page1_'.length))}Payload` const content = @@ -249,6 +252,136 @@ describe('types — nested contracts', () => { }) describe('types — audit hardening', () => { + it.each([false, true])( + 'preserves the artifact when interrupted after the final read (check=%s)', + async (isCheck) => { + mockApi() + const controller = new AbortController() + const dir = mkdtempSync(join(tmpdir(), 'brew-types-')) + const out = join(dir, 'brew-contracts.ts') + const existing = '// Existing contracts must survive an interruption.\n' + writeFileSync(out, existing) + const command = defineCommand({ + ...typesCommand, + path: ['types-final-page-fixture'], + run: async (invocation) => { + const triggers = invocation.ctx.client().automations.triggers + const list = triggers.list.bind(triggers) + vi.spyOn(triggers, 'list').mockImplementation(async (...args) => { + const response = await list(...args) + controller.abort(new CliInterruptError('SIGINT')) + return response + }) + return typesCommand.run(invocation) + }, + }) + + const result = await runCli( + [ + 'types-final-page-fixture', + '--out', + out, + '--quiet', + ...(isCheck ? ['--check'] : []), + ], + { + env: env(), + signal: controller.signal, + extraCommands: [command], + } + ) + + expect(result.code).toBe(130) + expect(result.stdout).toBe('') + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { code: 'CLI_INTERRUPTED', type: 'cancelled' }, + }) + expect(readFileSync(out, 'utf8')).toBe(existing) + } + ) + + it.each([ + { cursors: ['a', 'a'], isCheck: false }, + { cursors: ['a', 'b', 'a'], isCheck: false }, + { cursors: ['a', 'a'], isCheck: true }, + { cursors: ['a', 'b', 'a'], isCheck: true }, + ])( + 'rejects cursor cycles before writing or certifying a payload contract file', + async ({ cursors, isCheck }) => { + const requested: Array = [] + server.use( + http.get(`${API}/v1/automations/triggers`, ({ request }) => { + requested.push(new URL(request.url).searchParams.get('cursor')) + return HttpResponse.json({ + data: [TRIGGER], + pagination: { + cursor: cursors[(requested.length - 1) % cursors.length], + hasMore: true, + }, + }) + }) + ) + const dir = mkdtempSync(join(tmpdir(), 'brew-types-')) + const out = join(dir, 'brew-contracts.ts') + const existing = '// Existing contracts must survive a failed read.\n' + writeFileSync(out, existing) + + const result = await runCli( + ['types', '--out', out, '--quiet', ...(isCheck ? ['--check'] : [])], + { env: env(), extraCommands: [typesCommand] } + ) + + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { + code: 'CLI_PAGINATION', + progress: { + rowsFetched: cursors.length, + pagesFetched: cursors.length, + }, + }, + }) + expect(requested).toEqual([null, ...cursors.slice(0, -1)]) + expect(readFileSync(out, 'utf8')).toBe(existing) + } + ) + + it('includes payload contracts after the former fifty-page ceiling', async () => { + let reads = 0 + server.use( + http.get(`${API}/v1/automations/triggers`, ({ request }) => { + reads += 1 + const cursor = new URL(request.url).searchParams.get('cursor') + const page = cursor === null ? 1 : Number(cursor) + return HttpResponse.json({ + data: [ + { + ...TRIGGER, + triggerEventId: `tri_page_${page}`, + title: page === 51 ? 'Tail Trigger' : `Page ${page}`, + }, + ], + pagination: { + cursor: page === 51 ? null : String(page + 1), + hasMore: page < 51, + }, + }) + }) + ) + const dir = mkdtempSync(join(tmpdir(), 'brew-types-')) + const out = join(dir, 'brew-contracts.ts') + const result = await runCli(['types', '--out', out, '--quiet'], { + env: env(), + extraCommands: [typesCommand], + }) + + expect(result.code).toBe(0) + expect(readFileSync(out, 'utf8')).toContain('TailTriggerPayload') + expect(result.json).toMatchObject({ triggers: 51 }) + expect(reads).toBe(51) + }) + it('follows the pagination cursor instead of capping at one page', async () => { const pageOne = Array.from({ length: 100 }, (_, i) => ({ ...TRIGGER, diff --git a/tests/helpers/run-cli.ts b/tests/helpers/run-cli.ts index b132dba..7fa2543 100644 --- a/tests/helpers/run-cli.ts +++ b/tests/helpers/run-cli.ts @@ -9,6 +9,8 @@ export type RunCliOptions = { readonly env?: Readonly> /** Text available on stdin (for `-` inputs and piped login). */ readonly stdin?: string + /** Controlled stdin reader for cancellation and command-deadline tests. */ + readonly readStdin?: CliIo['readStdin'] /** Answer returned by interactive prompts (readLine). */ readonly promptAnswer?: string readonly ttyOut?: boolean @@ -53,7 +55,7 @@ export async function runCli( isTtyOut: options.ttyOut ?? false, isTtyIn: options.ttyIn ?? false, env: options.env ?? {}, - readStdin: async () => options.stdin ?? '', + readStdin: options.readStdin ?? (async () => options.stdin ?? ''), readLine: async () => options.promptAnswer ?? '', ...(options.signal === undefined ? {} : { signal: options.signal }), } diff --git a/tests/lib/confirmation-deadline.test.ts b/tests/lib/confirmation-deadline.test.ts new file mode 100644 index 0000000..474810e --- /dev/null +++ b/tests/lib/confirmation-deadline.test.ts @@ -0,0 +1,170 @@ +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { automationsTestCommand } from '../../src/commands/automations/test' +import { + enforceConfirmation, + enforceStdinConfirmation, +} from '../../src/lib/confirm' +import { makeContext } from '../../src/lib/context' +import { CliInterruptError, CliTimeoutError } from '../../src/lib/errors' +import type { CliIo } from '../../src/lib/types' + +const body = { testRecipient: 'qa@example.com' } + +function interactiveContext(options: { + input: string + readLine: CliIo['readLine'] + readStdin?: CliIo['readStdin'] + signal?: AbortSignal +}) { + const flags = { input: options.input, timeout: '100ms' } + const ctx = makeContext({ + rawArgv: ['automations', 'test', 'am_1', '--input', options.input], + flags, + io: { + stdout: new PassThrough(), + stderr: new PassThrough(), + isTtyOut: true, + isTtyIn: true, + env: {}, + readStdin: options.readStdin ?? (async () => JSON.stringify(body)), + readLine: options.readLine, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }, + }) + return { ctx, invocation: { ctx, args: { automationId: 'am_1' }, flags } } +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('interactive confirmation command budget', () => { + it('pauses an active prompt and resumes only the remaining budget', async () => { + let answer: (value: string) => void = () => undefined + const { ctx, invocation } = interactiveContext({ + input: JSON.stringify(body), + readLine: () => + new Promise((resolve) => { + answer = resolve + }), + }) + ctx.startDeadline() + await vi.advanceTimersByTimeAsync(60) + const pending = enforceConfirmation(automationsTestCommand, invocation) + try { + await vi.advanceTimersByTimeAsync(1000) + expect( + ctx.signal.aborted, + 'human approval time is outside the command budget' + ).toBe(false) + answer('yes') + await pending + await vi.advanceTimersByTimeAsync(39) + expect(ctx.signal.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(ctx.signal.reason).toBeInstanceOf(CliTimeoutError) + } finally { + answer('yes') + await pending + ctx.stopDeadline() + } + }) + + it('keeps an ordinary inline prompt unarmed until the command starts', async () => { + let answer: (value: string) => void = () => undefined + const { ctx, invocation } = interactiveContext({ + input: JSON.stringify(body), + readLine: () => + new Promise((resolve) => { + answer = resolve + }), + }) + const pending = enforceConfirmation(automationsTestCommand, invocation) + try { + await vi.advanceTimersByTimeAsync(1000) + expect(ctx.signal.aborted).toBe(false) + answer('yes') + await pending + await vi.advanceTimersByTimeAsync(1000) + expect(ctx.signal.aborted).toBe(false) + ctx.startDeadline() + await vi.advanceTimersByTimeAsync(99) + expect(ctx.signal.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(ctx.signal.reason).toBeInstanceOf(CliTimeoutError) + } finally { + answer('yes') + await pending + ctx.stopDeadline() + } + }) + + it('preserves a caller interrupt during deferred approval', async () => { + const caller = new AbortController() + const reason = new CliInterruptError('SIGINT') + const { ctx, invocation } = interactiveContext({ + input: '-', + signal: caller.signal, + readLine: () => + new Promise((_resolve, reject) => { + caller.signal.addEventListener( + 'abort', + () => reject(caller.signal.reason), + { once: true } + ) + }), + }) + ctx.startDeadline() + await vi.advanceTimersByTimeAsync(60) + const pending = enforceStdinConfirmation( + automationsTestCommand, + invocation, + body + ) + const rejected = expect(pending).rejects.toBe(reason) + caller.abort(reason) + try { + await rejected + await vi.advanceTimersByTimeAsync(1000) + expect(ctx.signal.reason).toBe(reason) + } finally { + ctx.stopDeadline() + } + }) + + it('refuses an expired stdin budget before asking for approval', async () => { + const readLine = vi.fn(async () => 'yes') + const { ctx, invocation } = interactiveContext({ input: '-', readLine }) + ctx.startDeadline() + await vi.advanceTimersByTimeAsync(100) + try { + await expect( + enforceStdinConfirmation(automationsTestCommand, invocation, body) + ).rejects.toBe(ctx.signal.reason) + expect(readLine).not.toHaveBeenCalled() + } finally { + ctx.stopDeadline() + } + }) + + it('refuses a fully spent budget even when its timer has not fired yet', async () => { + const readLine = vi.fn(async () => 'yes') + const { ctx, invocation } = interactiveContext({ input: '-', readLine }) + ctx.startDeadline() + vi.spyOn(performance, 'now').mockReturnValue(100) + try { + await expect( + enforceStdinConfirmation(automationsTestCommand, invocation, body) + ).rejects.toBeInstanceOf(CliTimeoutError) + expect(readLine).not.toHaveBeenCalled() + } finally { + ctx.stopDeadline() + } + }) +}) diff --git a/tests/lib/idempotency-options.test.ts b/tests/lib/idempotency-options.test.ts new file mode 100644 index 0000000..ec8a462 --- /dev/null +++ b/tests/lib/idempotency-options.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { requestOptions } from '../../src/lib/input' + +describe('idempotency flag validation', () => { + it.each(['', ' ', '\t', 'x'.repeat(101), 'bad\nkey'])( + 'rejects unsafe key %j', + (key) => { + expect(() => requestOptions({ idempotencyKey: key })).toThrow( + '--idempotency-key' + ) + } + ) + it('normalizes the wire key and leaves missing keys to the SDK', () => { + expect(requestOptions({ idempotencyKey: ' stable ' })).toEqual({ + idempotencyKey: 'stable', + }) + expect(requestOptions({})).toBeUndefined() + }) +}) diff --git a/tests/lib/output.test.ts b/tests/lib/output.test.ts index 97c2336..9da3139 100644 --- a/tests/lib/output.test.ts +++ b/tests/lib/output.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { renderTable, resolveOutputMode } from '../../src/lib/output' +import { runCli } from '../helpers/run-cli' describe('resolveOutputMode', () => { it('is human only on a TTY without --json', () => { @@ -17,6 +18,58 @@ describe('resolveOutputMode', () => { }) describe('renderTable', () => { + it('handles exports larger than the function argument limit', () => { + const rows = Array.from({ length: 150_000 }, () => ({ name: 'test' })) + const result = renderTable(rows, [{ key: 'name', header: 'NAME' }]) + expect(result.split('\n')).toHaveLength(rows.length + 1) + }) + + it('does not evaluate human rendering in JSON mode', async () => { + const result = await runCli(['lazy-output', '--json'], { + extraCommands: [ + { + path: ['lazy-output'], + summary: 'Test lazy output', + commandClass: 'read', + sdkMethod: null, + examples: ['brew-cli lazy-output'], + run: async () => ({ + data: { ok: true }, + human: () => { + throw new Error('human output evaluated') + }, + }), + }, + ], + }) + expect(result.code).toBe(0) + expect(result.json).toEqual({ ok: true }) + }) + it('evaluates human rendering exactly once on a terminal', async () => { + let calls = 0 + const result = await runCli(['lazy-output'], { + ttyOut: true, + extraCommands: [ + { + path: ['lazy-output'], + summary: 'Test lazy output', + commandClass: 'read', + sdkMethod: null, + examples: ['brew-cli lazy-output'], + run: async () => ({ + data: { ok: true }, + human: () => { + calls += 1 + return 'Rendered table' + }, + }), + }, + ], + }) + expect(result.code).toBe(0) + expect(result.stdout).toBe('Rendered table\n') + expect(calls).toBe(1) + }) it('aligns columns and stringifies non-scalar cells', () => { const table = renderTable( [ diff --git a/tests/lib/paginate.test.ts b/tests/lib/paginate.test.ts new file mode 100644 index 0000000..8e1826e --- /dev/null +++ b/tests/lib/paginate.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { defineCommand } from '../../src/lib/define-command' +import { collectAll } from '../../src/lib/paginate' +import { runCli } from '../helpers/run-cli' + +describe('pagination protocol', () => { + it.each([ + ['a', 'a'], + ['a', 'b', 'a'], + ])( + 'fails a cyclic drain without printing partial data', + async (...cursors) => { + const requested: Array = [] + const command = defineCommand({ + path: ['pagination-fixture'], + summary: 'Fixture', + sdkMethod: null, + commandClass: 'read', + examples: ['brew-cli pagination-fixture'], + run: async ({ ctx }) => ({ + data: await collectAll(ctx, async (cursor) => { + requested.push(cursor) + if (requested.length > cursors.length) { + throw new Error('refetched a known page') + } + return { + data: [requested.length], + pagination: { cursor: cursors[requested.length - 1] ?? null }, + } + }), + }), + }) + const result = await runCli(['pagination-fixture', '--quiet'], { + extraCommands: [command], + }) + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { + code: 'CLI_PAGINATION', + progress: { + rowsFetched: cursors.length, + pagesFetched: cursors.length, + }, + }, + }) + expect(requested).toEqual([undefined, ...cursors.slice(0, -1)]) + expect( + JSON.parse(result.stderr).error.progress.resumeCursor + ).toBeUndefined() + } + ) + + it('follows empty advancing pages and stops on a terminal or absent envelope', async () => { + const requested: Array = [] + const command = defineCommand({ + path: ['pagination-fixture'], + summary: 'Fixture', + sdkMethod: null, + commandClass: 'read', + examples: ['brew-cli pagination-fixture'], + run: async ({ ctx }) => ({ + data: await collectAll(ctx, async (cursor) => { + requested.push(cursor) + return cursor === undefined + ? { data: [], pagination: { cursor: 'a' } } + : { data: [7] } + }), + }), + }) + const result = await runCli(['pagination-fixture', '--quiet'], { + extraCommands: [command], + }) + expect(result.code).toBe(0) + expect(result.json).toEqual([7]) + expect(requested).toEqual([undefined, 'a']) + }) +}) diff --git a/tests/lib/prepared-input.test.ts b/tests/lib/prepared-input.test.ts new file mode 100644 index 0000000..bd0a95d --- /dev/null +++ b/tests/lib/prepared-input.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { runCli } from '../helpers/run-cli' + +describe('prepared command input budget', () => { + it('times input preparation and refuses dispatch when it has expired', async () => { + let dispatched = false + const result = await runCli(['slow-input', '--timeout', '20ms'], { + extraCommands: [ + { + path: ['slow-input'], + summary: 'Prepare input slowly', + commandClass: 'read', + sdkMethod: null, + examples: ['brew-cli slow-input'], + prepareInput: async () => { + await new Promise((resolve) => setTimeout(resolve, 80)) + return { ready: true } + }, + run: async () => { + dispatched = true + return { data: { done: true } } + }, + }, + ], + }) + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { code: 'CLI_TIMEOUT' }, + }) + expect(dispatched).toBe(false) + }) +}) diff --git a/tests/lib/stdin.test.ts b/tests/lib/stdin.test.ts new file mode 100644 index 0000000..ffd0895 --- /dev/null +++ b/tests/lib/stdin.test.ts @@ -0,0 +1,57 @@ +import { getEventListeners } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { readStdinText } from '../../src/lib/stdin' + +describe('readStdinText', () => { + it('reads UTF-8 through EOF and removes its abort listener', async () => { + const stream = new PassThrough() + const controller = new AbortController() + const pending = readStdinText({ stream, signal: controller.signal }) + const input = Buffer.from('Hello ☕') + stream.write(input.subarray(0, 7)) + stream.end(input.subarray(7)) + + await expect(pending).resolves.toBe('Hello ☕') + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + }) + + it('closes open input on command abort and preserves the reason', async () => { + const stream = new PassThrough() + const controller = new AbortController() + const reason = new Error('Command deadline passed') + const pending = readStdinText({ stream, signal: controller.signal }) + const rejected = expect(pending).rejects.toBe(reason) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(1) + controller.abort(reason) + + await rejected + expect(stream.destroyed).toBe(true) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + }) + + it('closes pre-aborted input without attaching an abort listener', async () => { + const stream = new PassThrough() + const controller = new AbortController() + const reason = new Error('Command already interrupted') + controller.abort(reason) + + await expect( + readStdinText({ stream, signal: controller.signal }) + ).rejects.toBe(reason) + expect(stream.destroyed).toBe(true) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + }) + + it('propagates input failures and removes its abort listener', async () => { + const stream = new PassThrough() + const controller = new AbortController() + const failure = new Error('Input connection dropped') + const pending = readStdinText({ stream, signal: controller.signal }) + const rejected = expect(pending).rejects.toBe(failure) + stream.destroy(failure) + + await rejected + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + }) +}) diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index a82d60c..1fc34db 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -1,7 +1,25 @@ import { describe, expect, it } from 'vitest' +import { defineCommand } from '../src/lib/define-command' +import { buildManifest } from '../src/lib/manifest' import { runCli } from './helpers/run-cli' describe('docs --agent manifest', () => { + it('builds the manifest from supplied metadata rather than importing the handler registry', () => { + const command = defineCommand({ + path: ['fixture'], + summary: 'Fixture', + sdkMethod: null, + commandClass: 'read', + examples: ['brew-cli fixture'], + run: async () => ({ data: null }), + }) + const manifest = buildManifest({ + commands: [{ ...command, confirmation: false }], + }) + expect(manifest.commands).toEqual([ + expect.objectContaining({ command: 'brew-cli fixture' }), + ]) + }) it('prints a complete machine-readable manifest', async () => { const result = await runCli(['docs', '--agent']) expect(result.code).toBe(0)