From 5142f11e5c3bfcaf0ad472f9dc842eb22c5749cd Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:16:42 +0200 Subject: [PATCH 01/21] feat(engine): command families register statement verbs as shared flags defineCommandFamily takes statementVerbs. Each verb becomes a reserved, repeatable -- flag on every mounted command, listed in help and the telemetry snapshot. A command declaring a flag with a verb's name, or a verb that is not camelCase or is already a shared flag, fails construction. The run state keeps every verb-flag value in argv order. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/command-family.ts | 7 ++ .../src/execution/command-snapshot.ts | 12 ++- .../cli-engine/src/execution/command-tree.ts | 43 ++++++-- packages/cli-engine/src/execution/engine.ts | 19 ++++ packages/cli-engine/src/execution/help.ts | 20 ++-- .../src/execution/pre-parse-argv.ts | 2 +- .../cli-engine/src/execution/shared-flags.ts | 102 +++++++++++++++++- .../src/execution/stricli-adapter.ts | 20 ++-- .../tests/statement-flag-order.test.ts | 60 +++++++++++ 9 files changed, 259 insertions(+), 26 deletions(-) create mode 100644 packages/cli-engine/tests/statement-flag-order.test.ts diff --git a/packages/cli-engine/src/command-family.ts b/packages/cli-engine/src/command-family.ts index 0f90acb7..e84e2192 100644 --- a/packages/cli-engine/src/command-family.ts +++ b/packages/cli-engine/src/command-family.ts @@ -56,6 +56,11 @@ export interface CommandFamily { readonly docsBaseUrl: string | undefined; /** The invocations this family retired. */ readonly redirects: readonly CommandRedirect[]; + /** The verbs this family's commands answer statement prompts with. + * Each becomes a reserved, repeatable shared flag (`--`) on + * every mounted command. Optional because a family built by an + * older engine has none. */ + readonly statementVerbs?: readonly string[]; } /** A path is segments separated by whitespace, so the separator's shape @@ -78,12 +83,14 @@ export function defineCommandFamily(spec: { readonly commands: Readonly>; readonly docsBaseUrl?: string; readonly redirects?: readonly RedirectSpec[]; + readonly statementVerbs?: readonly string[]; }): CommandFamily { return Object.freeze({ configSection: spec.configSection, commands: spec.commands, docsBaseUrl: spec.docsBaseUrl, redirects: (spec.redirects ?? []).map(normalizeRedirect), + statementVerbs: [...(spec.statementVerbs ?? [])], }); } diff --git a/packages/cli-engine/src/execution/command-snapshot.ts b/packages/cli-engine/src/execution/command-snapshot.ts index 08f7dce0..2d0ec6fc 100644 --- a/packages/cli-engine/src/execution/command-snapshot.ts +++ b/packages/cli-engine/src/execution/command-snapshot.ts @@ -9,14 +9,17 @@ import { camelCase, flagRuntime, kebabCase } from "../args"; import type { AnyCommand } from "../commands"; import type { EngineCommandSnapshot } from "../run-summary"; -import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; +import { SHARED_ALIASES, sharedFlagParameters } from "./shared-flags"; -function declaredFlagKeys(def: AnyCommand): readonly string[] { +function declaredFlagKeys( + def: AnyCommand, + statementVerbs: readonly string[], +): readonly string[] { const own = Object.keys(def.args.flags); if (def.kind === "server-command") { return own; } - return [...Object.keys(SHARED_FLAG_PARAMETERS), ...own]; + return [...Object.keys(sharedFlagParameters(statementVerbs)), ...own]; } function aliasMap(def: AnyCommand): ReadonlyMap { @@ -115,10 +118,11 @@ function explicitFlagKeys( export function buildCommandSnapshot( entryId: string, def: AnyCommand, + statementVerbs: readonly string[], argv: readonly string[], positionalValues: readonly (string | undefined)[], ): EngineCommandSnapshot { - const declared = declaredFlagKeys(def); + const declared = declaredFlagKeys(def, statementVerbs); const explicit = explicitFlagKeys(def, declared, argv); return { commandPath: entryId.split("."), diff --git a/packages/cli-engine/src/execution/command-tree.ts b/packages/cli-engine/src/execution/command-tree.ts index 8785d1aa..098b328a 100644 --- a/packages/cli-engine/src/execution/command-tree.ts +++ b/packages/cli-engine/src/execution/command-tree.ts @@ -9,7 +9,11 @@ import type { AnyCommand } from "../commands"; import { reservedConfigSectionName } from "../config-loader"; import type { ConfigSection } from "../config-section"; import type { EngineSpec } from "./engine"; -import { RESERVED_ALIASES, RESERVED_FLAG_NAMES } from "./shared-flags"; +import { + RESERVED_ALIASES, + RESERVED_FLAG_NAMES, + registeredStatementVerbs, +} from "./shared-flags"; export function constructionError(message: string): Error { return new Error(`@prisma/cli-engine: ${message}`); @@ -18,11 +22,30 @@ export function constructionError(message: string): Error { const CAMEL_CASE = /^[a-z][a-zA-Z0-9]*$/; const INTEGER_LIKE = /^\d+$/; -function validateFlags(path: string, def: AnyCommand): void { +function validateStatementVerbs(statementVerbs: readonly string[]): void { + for (const verb of statementVerbs) { + if (!CAMEL_CASE.test(verb)) { + throw constructionError( + `statement verb '${verb}' must be camelCase (it transliterates to --kebab-case on the CLI)`, + ); + } + if (RESERVED_FLAG_NAMES.has(verb)) { + throw constructionError( + `statement verb '${verb}' is already a shared flag`, + ); + } + } +} + +function validateFlags( + path: string, + def: AnyCommand, + statementVerbs: readonly string[], +): void { const flags = def.args.flags; const seenAliases = new Set(); for (const [key, spec] of Object.entries(flags)) { - if (RESERVED_FLAG_NAMES.has(key)) { + if (RESERVED_FLAG_NAMES.has(key) || statementVerbs.includes(key)) { throw constructionError( `command '${path}' declares reserved flag '${key}' (the shared flag family is engine-injected)`, ); @@ -226,10 +249,12 @@ export function buildCommandTree(spec: EngineSpec): CommandTreeNode { } validateDocsBaseUrls(spec); validateConfigSectionNames(spec); + const statementVerbs = registeredStatementVerbs(spec); + validateStatementVerbs(statementVerbs); const root = emptyNode(); for (const path of paths) { const def = spec.commands[path]; - validateFlags(path, def); + validateFlags(path, def, statementVerbs); validatePositionals(path, def); validateExitCodes(path, def); validateSpawnDeclarations(path, def); @@ -282,11 +307,15 @@ function mountedAs( /** Engine-injected shared flags count: a command answers them whether * or not it declared them, so a redirect for one could never fire. */ -function acceptsFlag(def: AnyCommand, flag: string): boolean { +function acceptsFlag(spec: EngineSpec, def: AnyCommand, flag: string): boolean { if (def.args.flags[flag] !== undefined) { return true; } - return def.kind !== "server-command" && RESERVED_FLAG_NAMES.has(flag); + return ( + def.kind !== "server-command" && + (RESERVED_FLAG_NAMES.has(flag) || + registeredStatementVerbs(spec).includes(flag)) + ); } function addVerbRedirect( @@ -323,7 +352,7 @@ function addFlagRedirect( `redirect for flag '${flag}' names '${redirect.from}', which is not a mounted command`, ); } - if (acceptsFlag(def, flag)) { + if (acceptsFlag(spec, def, flag)) { throw constructionError( `redirect for flag '${flag}' on '${redirect.from}' names a flag that command still accepts`, ); diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 637795fc..8f9971db 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -63,8 +63,11 @@ import { applySharedFlags, configFlagGivenNoValueError, defaultInteractive, + registeredStatementVerbs, type SharedFlags, + type StatementFlagValue, sniffFormat, + statementFlagValues, } from "./shared-flags"; import { type DelegatedTerminal, @@ -145,6 +148,9 @@ export interface RunState { * token removes the value it matched, so one `--confirm` grants one * consent. */ confirmValues: string[]; + /** Every verb-flag value, in argv order. A statement prompt marks the + * value it consumed; one left unconsumed fails a successful run. */ + statementValues: StatementFlagValue[]; interactive: boolean; colorEnabled: boolean; /** The file `--config` named, if the run named one. */ @@ -214,6 +220,9 @@ export interface Invocation { /** Every config section name the mounted command families declare — * the closed set of top-level keys prisma.config.ts may contain. */ readonly configSections: readonly string[]; + /** Every verb the mounted command families registered for statement + * prompts. */ + readonly statementVerbs: readonly string[]; /** The engine's whole signal policy, reachable so ctx.spawn can * replay recorded signals through exactly the delivered path. */ readonly deliverSignal: (signal: "SIGINT" | "SIGTERM") => void; @@ -301,6 +310,7 @@ export class EngineImpl implements Engine { private readonly now: () => Date; private readonly delay: (ms: number, signal: AbortSignal) => Promise; private readonly configSections: readonly string[]; + private readonly statementVerbs: readonly string[]; constructor( spec: EngineSpec, @@ -311,6 +321,7 @@ export class EngineImpl implements Engine { this.now = now; this.delay = delay; this.configSections = declaredConfigSections(spec); + this.statementVerbs = registeredStatementVerbs(spec); this.tree = buildCommandTree(spec); this.root = buildRoutes( spec, @@ -336,6 +347,7 @@ export class EngineImpl implements Engine { logLevel: "info", yes: false, confirmValues: [], + statementValues: [], interactive: defaultInteractive(runtime), /** Pre-parse resolution so a run that never mounts a command — an * unknown command, a parse failure — still colours its @@ -386,6 +398,7 @@ export class EngineImpl implements Engine { state, signal: controller.signal, configSections: this.configSections, + statementVerbs: this.statementVerbs, deliverSignal, }; if (versionFlagGiven(argv)) { @@ -636,6 +649,7 @@ export class EngineImpl implements Engine { state.snapshot = buildCommandSnapshot( entry.id, entry.def, + this.statementVerbs, state.argv, values, ); @@ -650,6 +664,11 @@ export class EngineImpl implements Engine { let needsOutcome: NeedsOutcome; try { applySharedFlags(state, rawFlags as SharedFlags, invocation.runtime); + state.statementValues = statementFlagValues( + state.argv, + this.statementVerbs, + rawFlags, + ); needsOutcome = await checkNeeds(entry.def, invocation); } catch (cause) { // The child preflight can be awaiting the token endpoint when the diff --git a/packages/cli-engine/src/execution/help.ts b/packages/cli-engine/src/execution/help.ts index b09a5faa..06ad6707 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -19,7 +19,11 @@ import type { EngineSpec } from "./engine"; import { renderHelpMarkdown } from "./markdown"; import { makePaint, type Paint, textWidth } from "./palette"; import { formatFlagGiven, withoutFormatFlags } from "./pre-parse-argv"; -import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; +import { + registeredStatementVerbs, + SHARED_ALIASES, + sharedFlagParameters, +} from "./shared-flags"; import { resolveExample } from "./stricli-adapter"; const RAIL = "│"; @@ -217,7 +221,7 @@ function nodeCard( ), arguments: [], options: [], - globalOptions: atRoot ? sharedFlagRows() : [], + globalOptions: atRoot ? sharedFlagRows(spec) : [], note: atRoot ? undefined : `Run '${spec.name} ${groupPath} --help' for details on a command.`, @@ -241,7 +245,9 @@ function leafCard( ] .filter((part) => part !== "") .join(" "); - const sharedNames = Object.keys(SHARED_FLAG_PARAMETERS) + const sharedNames = Object.keys( + sharedFlagParameters(registeredStatementVerbs(spec)), + ) .map((key) => `--${kebabCase(key)}`) .join(", "); return { @@ -491,12 +497,14 @@ function flagLabel( return `${alias} --${kebab}${negated}${placeholder}${repeat}`; } -function sharedFlagRows(): readonly HelpRow[] { +function sharedFlagRows(spec: EngineSpec): readonly HelpRow[] { const aliasByKey = new Map( Object.entries(SHARED_ALIASES).map(([alias, key]) => [key, alias]), ); - const rows = Object.entries(SHARED_FLAG_PARAMETERS).map(([key, spec]) => { - const record = spec as { + const rows = Object.entries( + sharedFlagParameters(registeredStatementVerbs(spec)), + ).map(([key, parameter]) => { + const record = parameter as { brief: string; kind: string; placeholder?: string; diff --git a/packages/cli-engine/src/execution/pre-parse-argv.ts b/packages/cli-engine/src/execution/pre-parse-argv.ts index 375e13dd..537bca53 100644 --- a/packages/cli-engine/src/execution/pre-parse-argv.ts +++ b/packages/cli-engine/src/execution/pre-parse-argv.ts @@ -11,7 +11,7 @@ */ import type { Format } from "../presentation"; -function flagTokens(argv: readonly string[]): readonly string[] { +export function flagTokens(argv: readonly string[]): readonly string[] { const terminator = argv.indexOf("--"); return terminator === -1 ? argv : argv.slice(0, terminator); } diff --git a/packages/cli-engine/src/execution/shared-flags.ts b/packages/cli-engine/src/execution/shared-flags.ts index 097dc036..68b42a9f 100644 --- a/packages/cli-engine/src/execution/shared-flags.ts +++ b/packages/cli-engine/src/execution/shared-flags.ts @@ -1,10 +1,11 @@ +import { camelCase } from "../args"; import { resolveIsCI } from "../ci"; import type { Severity } from "../events"; import type { Format } from "../presentation"; import { CliStructuredError } from "../protocol"; import type { Runtime } from "../runtime"; -import type { RunState } from "./engine"; -import { formatFlagGiven } from "./pre-parse-argv"; +import type { EngineSpec, RunState } from "./engine"; +import { flagTokens, formatFlagGiven } from "./pre-parse-argv"; /** The engine-injected shared flag family. Commands cannot declare * these names or aliases; handlers never see their values. */ @@ -122,6 +123,103 @@ export function configFlagGivenNoValueError(): CliStructuredError { ); } +/** A verb flag answers statement prompts. The verbs come from the + * mounted command families, so these flags join the shared family per + * CLI rather than in SHARED_FLAG_PARAMETERS. */ +function statementVerbParameter(verb: string) { + return { + kind: "parsed", + parse: (input: string) => input, + placeholder: "subject", + variadic: true, + optional: true, + brief: `Answer the question about with ${verb} (repeatable)`, + } as const; +} + +type SharedFlagParameter = + | (typeof SHARED_FLAG_PARAMETERS)[keyof typeof SHARED_FLAG_PARAMETERS] + | ReturnType; + +export function registeredStatementVerbs(spec: EngineSpec): readonly string[] { + return [ + ...new Set( + spec.commandFamilies.flatMap( + (commandFamily) => commandFamily.statementVerbs ?? [], + ), + ), + ]; +} + +export function sharedFlagParameters( + statementVerbs: readonly string[], +): Readonly> { + return { + ...SHARED_FLAG_PARAMETERS, + ...Object.fromEntries( + statementVerbs.map((verb) => [verb, statementVerbParameter(verb)]), + ), + }; +} + +export interface StatementFlagValue { + readonly verb: string; + readonly text: string; + consumed: boolean; +} + +function verbFlagIn( + token: string, + statementVerbs: readonly string[], +): string | undefined { + if (!token.startsWith("--")) { + return undefined; + } + const equals = token.indexOf("="); + if (equals === token.length - 1) { + return undefined; + } + const name = camelCase(token.slice(2, equals === -1 ? undefined : equals)); + return statementVerbs.includes(name) ? name : undefined; +} + +function parsedValues(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +/** + * Every verb-flag value, in the order argv gave them. The parser + * groups values by flag, so argv decides only which verb comes next; + * the values themselves are the parser's. A parsed value argv could + * not place is appended rather than dropped, so it is still reported + * if nothing consumes it. + */ +export function statementFlagValues( + argv: readonly string[], + statementVerbs: readonly string[], + parsedFlags: Readonly>, +): StatementFlagValue[] { + const remaining = new Map( + statementVerbs.map((verb) => [verb, parsedValues(parsedFlags[verb])]), + ); + const ordered: StatementFlagValue[] = []; + for (const token of flagTokens(argv)) { + const verb = verbFlagIn(token, statementVerbs); + const text = verb === undefined ? undefined : remaining.get(verb)?.shift(); + if (verb !== undefined && text !== undefined) { + ordered.push({ verb, text, consumed: false }); + } + } + for (const [verb, texts] of remaining) { + for (const text of texts) { + ordered.push({ verb, text, consumed: false }); + } + } + return ordered; +} + export const SHARED_ALIASES = { v: "verbose", q: "quiet", y: "yes" } as const; export interface SharedFlags { diff --git a/packages/cli-engine/src/execution/stricli-adapter.ts b/packages/cli-engine/src/execution/stricli-adapter.ts index 8fb440d1..fbee00ec 100644 --- a/packages/cli-engine/src/execution/stricli-adapter.ts +++ b/packages/cli-engine/src/execution/stricli-adapter.ts @@ -25,7 +25,11 @@ import { import type { AnyCommand } from "../commands"; import type { CommandTreeEntry, CommandTreeNode } from "./command-tree"; import type { EngineSpec, Invocation, RunState } from "./engine"; -import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; +import { + registeredStatementVerbs, + SHARED_ALIASES, + sharedFlagParameters, +} from "./shared-flags"; export interface EngineRunContext extends StricliBaseContext { readonly invocation: Invocation; @@ -147,7 +151,10 @@ function stricliPositional( }; } -function commandParameters(def: AnyCommand): Record { +function commandParameters( + def: AnyCommand, + sharedFlags: Readonly>, +): Record { const declaredFlags: Record = {}; const aliases: Record = {}; for (const [key, spec] of Object.entries(def.args.flags)) { @@ -163,9 +170,7 @@ function commandParameters(def: AnyCommand): Record { ).map(([key, spec]) => [key, positionalRuntime(spec)] as const); const positional = stricliPositional(positionalEntries); return { - flags: injectShared - ? { ...SHARED_FLAG_PARAMETERS, ...declaredFlags } - : declaredFlags, + flags: injectShared ? { ...sharedFlags, ...declaredFlags } : declaredFlags, aliases: injectShared ? { ...SHARED_ALIASES, ...aliases } : aliases, ...(positional === undefined ? {} : { positional }), }; @@ -213,10 +218,12 @@ function commandDocs( function toStricliCommand( entry: CommandTreeEntry, cliName: string, + sharedFlags: Readonly>, runEntry: RunEntry, ): EngineRoutingTarget { const parameters = commandParameters( entry.def, + sharedFlags, ) as unknown as TypedCommandParameters< Record, readonly (string | undefined)[], @@ -246,8 +253,9 @@ export function buildRoutes( runEntry: RunEntry, ): StricliRouteMap { const routes: Record = {}; + const sharedFlags = sharedFlagParameters(registeredStatementVerbs(spec)); for (const [name, entry] of node.commands) { - routes[name] = toStricliCommand(entry, spec.name, runEntry); + routes[name] = toStricliCommand(entry, spec.name, sharedFlags, runEntry); } for (const [name, child] of node.children) { const childPath = groupPath === "" ? name : `${groupPath} ${name}`; diff --git a/packages/cli-engine/tests/statement-flag-order.test.ts b/packages/cli-engine/tests/statement-flag-order.test.ts new file mode 100644 index 00000000..ba33b87f --- /dev/null +++ b/packages/cli-engine/tests/statement-flag-order.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest"; +import { statementFlagValues } from "../src/execution/shared-flags"; + +const VERBS = ["rename", "delete", "dropColumn"]; + +function ordered(argv: readonly string[], parsed: Record) { + return statementFlagValues(argv, VERBS, parsed).map( + ({ verb, text }) => `${verb} ${text}`, + ); +} + +describe("verb-flag values keep their argv order across flags", () => { + test("values of different verbs interleave as written", () => { + expect( + ordered( + [ + "migration", + "plan", + "--rename", + "A:B", + "--delete", + "C", + "--rename=D:E", + ], + { rename: ["A:B", "D:E"], delete: ["C"] }, + ), + ).toEqual(["rename A:B", "delete C", "rename D:E"]); + }); + + test("kebab-case spellings of a camelCase verb count", () => { + expect( + ordered(["--drop-column", "User.name", "--delete", "Legacy"], { + dropColumn: ["User.name"], + delete: ["Legacy"], + }), + ).toEqual(["dropColumn User.name", "delete Legacy"]); + }); + + test("nothing after a bare -- is a flag", () => { + expect( + ordered(["--delete", "Legacy", "--", "--rename", "X:Y"], { + delete: ["Legacy"], + }), + ).toEqual(["delete Legacy"]); + }); + + test("every parsed value is kept even when argv cannot place it", () => { + expect( + ordered(["--delete=Legacy"], { delete: ["Legacy", "Other"] }), + ).toEqual(["delete Legacy", "delete Other"]); + }); + + test("every value starts unconsumed", () => { + expect( + statementFlagValues(["--delete", "Legacy"], VERBS, { + delete: ["Legacy"], + }), + ).toEqual([{ verb: "delete", text: "Legacy", consumed: false }]); + }); +}); From 61b13037739631ab6673d8e22894a2f7849cfdce Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:16:42 +0200 Subject: [PATCH 02/21] feat(engine): ctx.prompt.statement asks a consent answered with a verb A statement is answered first by a verb flag whose value names the subject (the subject, or :...), then refused with CLI.CONSENT_REQUIRED outside an interactive terminal or under --yes, then asked. ctx.prompt.statements asks several at once, and its refusal lists every question still unanswered. A run that succeeds with a verb-flag value nothing consumed fails with CLI.CONSENT_UNUSED. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/context.ts | 54 ++ .../src/execution/clack-renderer.ts | 16 + packages/cli-engine/src/execution/engine.ts | 12 + packages/cli-engine/src/execution/prompts.ts | 228 ++++++- packages/cli-engine/src/exports/index.ts | 3 + .../cli-engine/tests/clack-prompts.test.ts | 46 +- .../tests/statement-prompts.test.ts | 557 ++++++++++++++++++ 7 files changed, 913 insertions(+), 3 deletions(-) create mode 100644 packages/cli-engine/tests/statement-prompts.test.ts diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 24c19db6..f46a4c84 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -206,6 +206,32 @@ export interface BrowserWaitRequest { * writes to stderr, so an interactive json run prompts without touching * the stdout stream. */ +export interface StatementOptions { + /** What the answer is about, in the command's own vocabulary. It + * should not contain `:`, which separates it from the rest of a flag + * value. */ + readonly subject: string; + /** The verbs that may answer, in the order a refusal lists them. */ + readonly verbs: readonly V[]; + /** How a refusal writes a verb's flag value, such as + * `Legacy:`. A verb without one is written with the + * subject. */ + readonly forms?: Partial>; + /** Returns undefined to accept the answer, or a message saying why it + * is rejected. The engine never interprets the text. */ + readonly validate: (verb: V, text: string) => string | undefined; +} + +export interface StatementQuestion + extends StatementOptions { + readonly question: string; +} + +export interface StatementAnswer { + readonly verb: V; + readonly text: string; +} + export interface PromptSurface { readonly confirm: ( question: string, @@ -228,6 +254,34 @@ export interface PromptSurface { question: string, opts?: { readonly token?: string }, ) => Promise; + /** + * A consent answered with a verb and free text: what the user means + * should happen to `subject`. Like `consent`, `--yes` and Enter never + * answer it. + * + * A `--` flag whose value names the subject answers it first, + * in any session, without rendering anything: the value names the + * subject when it is the subject or starts with `:`. A + * value `validate` rejects fails the run with `CLI.PROMPT_INVALID`. + * Without such a flag a non-interactive run, or one under `--yes`, + * fails with `CLI.CONSENT_REQUIRED`; an interactive run asks, and the + * user answers ` `, or `` alone to mean the subject. + * Each verb must be registered in a command family's + * `statementVerbs`. + */ + readonly statement: ( + question: string, + opts: StatementOptions, + ) => Promise>; + /** + * Several statements asked together, answered in order. Flags answer + * what they can; a refusal names every question still unanswered at + * once, and an interactive run asks them one after another. + * `statement(question, opts)` is `statements([{ question, ...opts }])`. + */ + readonly statements: ( + questions: readonly StatementQuestion[], + ) => Promise[]>; readonly select: ( question: string, options: ReadonlyArray<{ value: T; label: string }>, diff --git a/packages/cli-engine/src/execution/clack-renderer.ts b/packages/cli-engine/src/execution/clack-renderer.ts index 2c4ff225..97648d81 100644 --- a/packages/cli-engine/src/execution/clack-renderer.ts +++ b/packages/cli-engine/src/execution/clack-renderer.ts @@ -55,6 +55,14 @@ export interface ClackRenderer { /** Type-to-confirm: anything but the token re-prompts, so the only * ways out are the exact token and cancelling. */ confirmToken(question: string, token: string): Promise; + /** Free text checked by `check`: a rejected answer shows its message + * and re-prompts, so the only ways out are an accepted answer and + * cancelling. */ + statement( + question: string, + placeholder: string, + check: (value: string) => string | undefined, + ): Promise; select( question: string, options: ReadonlyArray<{ value: T; label: string }>, @@ -102,6 +110,14 @@ export async function makeClackRenderer( ? undefined : `Type ${token} exactly, or press Ctrl-C.`, }), + statement: (question, placeholder, check) => + clack.text({ + input, + output, + message: question, + placeholder, + validate: (value) => check(value ?? ""), + }), select: ( question: string, options: ReadonlyArray<{ value: T; label: string }>, diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 8f9971db..503c6f5d 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -21,6 +21,7 @@ import type { InputStream, Runtime } from "../runtime"; import { type ChildResult, type ChildStatusSettlement, + childExitCode, isChildStatusSettlement, } from "../spawn"; import { @@ -46,6 +47,7 @@ import { } from "./help"; import { checkNeeds, type NeedsOutcome } from "./needs"; import { configFlagGivenNoValue, versionFlagGiven } from "./pre-parse-argv"; +import { unusedStatementValuesError } from "./prompts"; import { commandSegments, settleBug, @@ -602,8 +604,11 @@ export class EngineImpl implements Engine { return; } state.resolved = true; + const unused = unusedStatementValuesError(state); if (!result.ok) { settleErrored(invocation, result.failure, result.failure.diagnostics); + } else if (unused !== undefined && succeeded(state, result.value)) { + settleErrored(invocation, unused); } else if (isChildStatusSettlement(result.value)) { settleChildStatus(invocation, entry.def, result.value); } else { @@ -797,6 +802,13 @@ export class EngineImpl implements Engine { } } +function succeeded(state: RunState, value: unknown): boolean { + if (!isChildStatusSettlement(value)) { + return true; + } + return state.lastChild !== undefined && childExitCode(state.lastChild) === 0; +} + /** The path the user typed, for argv that routed to no command: the * leading tokens up to the first flag or argument escape. */ function attemptedPath(argv: readonly string[]): readonly string[] { diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index a668595e..09bf9b36 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -10,6 +10,11 @@ * interactive terminal that is the only thing that can. Cancellation * (EOF at the prompt) is a distinct structured error mapped to exit 3. * + * A statement is the second consent form: answered with a verb and free + * text. A `--` flag whose value names the subject answers it in + * every session; each value answers one statement, and a value nothing + * consumed fails an otherwise successful run. + * * Rendering is two-tier: real TTYs (isTty.stdin AND stdin.setRawMode * present, no scripted answers) render through @clack/prompts via * clack-renderer.ts; everything else uses the plain line renderer @@ -20,7 +25,12 @@ * re-prompts, the line renderer fails structurally, because a scripted * or piped answer cannot be corrected. */ -import type { PromptSurface } from "../context"; +import { kebabCase } from "../args"; +import type { + PromptSurface, + StatementAnswer, + StatementQuestion, +} from "../context"; import { CliStructuredError } from "../protocol"; import type { InputStream } from "../runtime"; import { @@ -31,6 +41,9 @@ import { import { constructionError } from "./command-tree"; import type { Invocation, RunState } from "./engine"; import { announceUrl } from "./open-url"; +import type { StatementFlagValue } from "./shared-flags"; + +const WHITESPACE = /\s/; /** How often browserWait asks whether the user has finished. */ const BROWSER_WAIT_POLL_INTERVAL_MS = 1000; @@ -46,6 +59,133 @@ function consumeConfirmValue(state: RunState, token: string): boolean { return true; } +function flagForm(verb: string, text: string): string { + return `--${kebabCase(verb)} ${text}`; +} + +function namesSubject(text: string, subject: string): boolean { + return text === subject || text.startsWith(`${subject}:`); +} + +function subjectOf(text: string): string { + const colon = text.indexOf(":"); + return colon === -1 ? text : text.slice(0, colon); +} + +/** The first unconsumed verb-flag value naming the subject, tried verb + * by verb. A value the command rejects fails the run: a wrong flag + * cannot be corrected by asking again. */ +function answerFromFlags( + state: RunState, + question: StatementQuestion, +): StatementAnswer | undefined { + for (const verb of question.verbs) { + const value = state.statementValues.find( + (candidate) => + !candidate.consumed && + candidate.verb === verb && + namesSubject(candidate.text, question.subject), + ); + if (value === undefined) { + continue; + } + const rejection = question.validate(verb, value.text); + if (rejection !== undefined) { + throw new CliStructuredError( + "CLI.PROMPT_INVALID", + `${flagForm(verb, value.text)} does not answer "${question.question}": ${rejection}`, + ); + } + value.consumed = true; + return { verb, text: value.text }; + } + return undefined; +} + +function statementUnavailable( + unanswered: readonly StatementQuestion[], + state: RunState, +): CliStructuredError { + const subjects = unanswered.map((question) => `"${question.subject}"`); + const situation = state.yes + ? "which --yes cannot give" + : "and the session is not interactive"; + const summary = + unanswered.length === 1 + ? `${subjects[0]} needs a statement, ${situation}.` + : `${unanswered.length} subjects need a statement, ${situation}: ${subjects.join(", ")}.`; + const listed = unanswered.map(({ subject, verbs }) => ({ subject, verbs })); + return new CliStructuredError("CLI.CONSENT_REQUIRED", summary, { + why: unanswered.map((question) => question.question).join("\n"), + nextActions: unanswered.flatMap((question) => + question.verbs.map((verb) => ({ + kind: "user-choice" as const, + label: `Pass ${flagForm(verb, question.forms?.[verb] ?? question.subject)}`, + })), + ), + meta: + listed.length === 1 + ? { ...listed[0], unanswered: listed } + : { unanswered: listed }, + }); +} + +type ParsedStatement = + | { readonly answer: StatementAnswer } + | { readonly problem: string }; + +/** An interactive answer: ` `, or `` alone to mean + * the subject. */ +function parseStatement( + raw: string, + question: StatementQuestion, +): ParsedStatement { + const trimmed = raw.trim(); + const space = trimmed.search(WHITESPACE); + const typedVerb = space === -1 ? trimmed : trimmed.slice(0, space); + const rest = space === -1 ? "" : trimmed.slice(space).trim(); + const verb = question.verbs.find((candidate) => candidate === typedVerb); + if (verb === undefined) { + return { + problem: `Start the answer with ${question.verbs.join(" or ")}.`, + }; + } + const text = rest === "" ? question.subject : rest; + const rejection = question.validate(verb, text); + return rejection === undefined + ? { answer: { verb, text } } + : { problem: rejection }; +} + +/** Fails a run that otherwise succeeded when a verb flag answered + * nothing: a mistyped subject must not pass silently. */ +export function unusedStatementValuesError( + state: RunState, +): CliStructuredError | undefined { + const unused: readonly StatementFlagValue[] = state.statementValues.filter( + (value) => !value.consumed, + ); + if (unused.length === 0) { + return undefined; + } + const given = unused.map((value) => flagForm(value.verb, value.text)); + const subjects = [...new Set(unused.map((value) => subjectOf(value.text)))]; + const summary = + unused.length === 1 + ? `${given[0]} was given but nothing in this run asked about ${subjects[0]}.` + : `${given.join(", ")} were given but nothing in this run asked about ${subjects.join(", ")}.`; + return new CliStructuredError("CLI.CONSENT_UNUSED", summary, { + nextActions: [ + { + kind: "user-choice", + label: + "Remove the flag, or spell the subject the way the command names it.", + }, + ], + meta: { unused: unused.map(({ verb, text }) => ({ verb, text })) }, + }); +} + function makeLineReader( stdin: InputStream, invocation: Invocation, @@ -382,6 +522,83 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { return raw; }; + const askStatement = async ( + question: StatementQuestion, + ): Promise> => { + if (useClack()) { + const raw = await renderWithClack(question.question, (r) => + r.statement(question.question, question.verbs.join(" or "), (value) => { + const parsed = parseStatement(value, question); + return "problem" in parsed ? parsed.problem : undefined; + }), + ); + const parsed = parseStatement(raw, question); + if ("problem" in parsed) { + throw promptInvalid(question.question, raw); + } + return parsed.answer; + } + const raw = await ask( + question.question, + `? ${question.question} (${question.verbs.join("/")}) `, + ); + if (typeof raw !== "string") { + throw promptInvalid(question.question, String(raw)); + } + const parsed = parseStatement(raw, question); + if ("problem" in parsed) { + throw new CliStructuredError( + "CLI.PROMPT_INVALID", + `"${raw}" is not a valid answer to "${question.question}": ${parsed.problem}`, + ); + } + return parsed.answer; + }; + + const requireRegisteredVerbs = ( + questions: readonly StatementQuestion[], + ): void => { + for (const question of questions) { + if (question.verbs.length === 0) { + throw constructionError( + `command '${state.commandId}' asked a statement about '${question.subject}' with no verbs`, + ); + } + for (const verb of question.verbs) { + if (!invocation.statementVerbs.includes(verb)) { + throw constructionError( + `command '${state.commandId}' asked a statement with verb '${verb}', which no command family registers in statementVerbs`, + ); + } + } + } + }; + + /** Flags answer first, so a refusal can name every question still + * unanswered at once; the rest are asked one after another. */ + const statements = async ( + questions: readonly StatementQuestion[], + ): Promise[]> => { + requireRegisteredVerbs(questions); + const fromFlags = questions.map((question) => + answerFromFlags(state, question), + ); + const unanswered = questions.filter( + (_question, index) => fromFlags[index] === undefined, + ); + if (unanswered.length > 0 && (state.yes || !state.interactive)) { + throw statementUnavailable(unanswered, state); + } + const answerFrom = async (index: number): Promise[]> => { + if (index === questions.length) { + return []; + } + const answer = fromFlags[index] ?? (await askStatement(questions[index])); + return [answer, ...(await answerFrom(index + 1))]; + }; + return answerFrom(0); + }; + /** A prompt writes to stderr and reads the engine's stdin — the same * terminal a live child inherited. Like ctx.present, prompting while * a child owns the terminal is a construction error. */ @@ -392,7 +609,7 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { ); } }; - const surface: PromptSurface = { + const surface: Omit = { confirm: async (question, opts) => { const fallback = opts?.default; if (state.yes || !state.interactive) { @@ -500,6 +717,13 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { opts?: { readonly default?: T }, ) => claimTerminal(() => surface.select(question, options, opts)), text: (question, opts) => claimTerminal(() => surface.text(question, opts)), + statement: async (question, opts) => { + const [answer] = await claimTerminal(() => + statements([{ question, ...opts }]), + ); + return answer; + }, + statements: (questions) => claimTerminal(() => statements(questions)), browserWait: (request) => claimTerminal(() => surface.browserWait(request)), }; } diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index c983feb5..bf80a8ec 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -74,6 +74,9 @@ export type { OpenUrlOutcome, OpenUrlRequest, PromptSurface, + StatementAnswer, + StatementOptions, + StatementQuestion, } from "../context"; export { authServiceError, diff --git a/packages/cli-engine/tests/clack-prompts.test.ts b/packages/cli-engine/tests/clack-prompts.test.ts index b9c391ed..25931353 100644 --- a/packages/cli-engine/tests/clack-prompts.test.ts +++ b/packages/cli-engine/tests/clack-prompts.test.ts @@ -8,6 +8,7 @@ import { type Block, createCli, defineCommand, + defineCommandFamily, type PromptSurface, type Runtime, } from "@prisma/cli-engine"; @@ -83,7 +84,12 @@ function promptCli(run: (prompt: PromptSurface) => Promise) { return createCli({ name: "probe", version: "0.0.0", - commandFamilies: [], + commandFamilies: [ + defineCommandFamily({ + commands: { probe }, + statementVerbs: ["rename", "delete"], + }), + ], groups: {}, commands: { probe }, }); @@ -278,6 +284,44 @@ describe("the clack tier resolves prompt values", () => { expect(result.exitCode).toBe(3); }); + test("statement: a rejected answer shows the reason and re-prompts", async () => { + const result = await runInteractive( + (prompt) => + prompt.statement("What happens to Legacy?", { + subject: "Legacy", + verbs: ["rename", "delete"], + validate: (verb, text) => + verb === "rename" && !text.startsWith("Legacy:") + ? "Write the rename as Legacy:." + : undefined, + }), + [ + ..."drop", + ENTER, + BACKSPACE, + BACKSPACE, + BACKSPACE, + BACKSPACE, + ..."rename Archive", + ENTER, + ...Array.from({ length: "Archive".length }, () => BACKSPACE), + ..."Legacy:Archive", + ENTER, + ], + ); + + expect(result.exitCode).toBe(0); + expect(answerIn(result.plainStderr)).toBe( + '{"verb":"rename","text":"Legacy:Archive"}', + ); + expect(result.plainStderr).toContain( + "Start the answer with rename or delete.", + ); + expect(result.plainStderr).toContain( + "Write the rename as Legacy:.", + ); + }); + test("a multi-step wizard reuses the one renderer and stdin iterator", async () => { const result = await runInteractive( async (prompt) => { diff --git a/packages/cli-engine/tests/statement-prompts.test.ts b/packages/cli-engine/tests/statement-prompts.test.ts new file mode 100644 index 00000000..215c3f5f --- /dev/null +++ b/packages/cli-engine/tests/statement-prompts.test.ts @@ -0,0 +1,557 @@ +/** + * prompt.statement and prompt.statements: a consent answered with a + * verb and free text. A matching verb flag answers before anything + * renders; otherwise a non-interactive run or --yes refuses with + * CLI.CONSENT_REQUIRED, and an interactive run asks. A verb-flag value + * nothing consumed fails an otherwise successful run. + */ +import { + type Block, + defineCommand, + defineCommandFamily, + flag, + type PromptSurface, +} from "@prisma/cli-engine"; +import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; +import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +const EPOCH = () => new Date(0); +const INTERACTIVE = { isTty: { stdin: true, stdout: true } }; + +function probe(ask: (prompt: PromptSurface) => Promise) { + return defineCommand({ + help: { summary: "Statement probe" }, + handler: async (_args, ctx) => { + const answer = await ask(ctx.prompt); + return ok( + ctx.present( + { data: { answer } }, + { + human: (): readonly Block[] => [ + { + kind: "summary", + status: "ok", + text: `answer=${JSON.stringify(answer)}`, + }, + ], + stdout: () => [], + json: () => ({ answer }), + next: () => [], + }, + ), + ); + }, + }); +} + +function cliWith(ask: (prompt: PromptSurface) => Promise) { + const command = probe(ask); + return createTestCli({ + commandFamilies: [ + defineCommandFamily({ + commands: { probe: command }, + statementVerbs: ["rename", "delete"], + }), + ], + commands: { probe: command }, + now: EPOCH, + }); +} + +const LEGACY_QUESTION = + 'Table "Legacy" would be dropped and its rows lost. What do you mean?'; +const USER_NAME_QUESTION = + 'Column "User.name" would be dropped. What do you mean?'; + +function renameCheck(subject: string) { + return (verb: "rename" | "delete", text: string) => + verb === "rename" && !text.startsWith(`${subject}:`) + ? `Write the rename as ${subject}:.` + : undefined; +} + +const legacyQuestion = { + question: LEGACY_QUESTION, + subject: "Legacy", + verbs: ["rename", "delete"], + forms: { rename: "Legacy:" }, + validate: renameCheck("Legacy"), +} as const; + +const userNameQuestion = { + question: USER_NAME_QUESTION, + subject: "User.name", + verbs: ["rename", "delete"], + forms: { rename: "User.name:" }, + validate: renameCheck("User.name"), +} as const; + +const askLegacy = (prompt: PromptSurface) => + prompt.statement(LEGACY_QUESTION, legacyQuestion); + +const askBoth = (prompt: PromptSurface) => + prompt.statements([legacyQuestion, userNameQuestion]); + +function errorOf(result: Awaited>) { + const last = result.json[result.json.length - 1]; + return last.kind === "result" && !last.envelope.ok + ? last.envelope.error + : undefined; +} + +describe("a verb flag answers the statement", () => { + test("--delete Legacy answers without rendering anything", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--delete", + "Legacy", + "--format", + "human", + ]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { verb: "delete", text: "Legacy" }, + }); + expect(result.stderr).toBe('✔ answer={"verb":"delete","text":"Legacy"}\n'); + }); + + test("a value that starts with the subject and a colon names it", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--rename=Legacy:Archive", + ]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { verb: "rename", text: "Legacy:Archive" }, + }); + }); + + test("the flag answers a json run on a terminal without prompting", async () => { + const result = await cliWith(askLegacy).run( + ["probe", "--delete", "Legacy", "--json"], + INTERACTIVE, + ); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { verb: "delete", text: "Legacy" }, + }); + expect(result.stderr).toBe(""); + }); + + test("the flag answers under --yes", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--yes", + "--delete", + "Legacy", + ]); + + expect(result.exitCode).toBe(0); + }); + + test("a value naming a longer subject does not answer", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--delete", + "LegacyArchive", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.code).toBe("CLI.CONSENT_REQUIRED"); + }); + + test("a value the command rejects fails the run with its message", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--rename", + "Legacy", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)).toEqual({ + code: "CLI.PROMPT_INVALID", + severity: "error", + summary: `--rename Legacy does not answer "${LEGACY_QUESTION}": Write the rename as Legacy:.`, + nextActions: [], + }); + }); +}); + +describe("without a flag, a non-interactive run refuses", () => { + test("the refusal names the subject and one flag form per verb", async () => { + const result = await cliWith(askLegacy).run(["probe", "--json"]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)).toEqual({ + code: "CLI.CONSENT_REQUIRED", + severity: "error", + summary: + '"Legacy" needs a statement, and the session is not interactive.', + why: LEGACY_QUESTION, + nextActions: [ + { kind: "user-choice", label: "Pass --rename Legacy:" }, + { kind: "user-choice", label: "Pass --delete Legacy" }, + ], + meta: { + subject: "Legacy", + verbs: ["rename", "delete"], + unanswered: [{ subject: "Legacy", verbs: ["rename", "delete"] }], + }, + }); + }); + + test("--yes does not answer it, even on a terminal", async () => { + const result = await cliWith(askLegacy).run( + ["probe", "--yes", "--json"], + INTERACTIVE, + ); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.summary).toBe( + '"Legacy" needs a statement, which --yes cannot give.', + ); + }); + + test("the human refusal prints each flag form as a next action", async () => { + const result = await cliWith(askLegacy).run(["probe", "--format", "human"]); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("[CLI.CONSENT_REQUIRED]"); + expect(result.stderr).toContain("Pass --rename Legacy:"); + expect(result.stderr).toContain("Pass --delete Legacy"); + }); +}); + +describe("an interactive run asks", () => { + test("a bare verb answers with the subject", async () => { + const result = await cliWith(askLegacy).run(["probe"], { + ...INTERACTIVE, + answers: ["delete"], + }); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { verb: "delete", text: "Legacy" }, + }); + }); + + test("a verb with text answers with that text", async () => { + const result = await cliWith(askLegacy).run(["probe"], { + ...INTERACTIVE, + answers: ["rename Legacy:Archive"], + }); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { verb: "rename", text: "Legacy:Archive" }, + }); + }); + + test("the line renderer shows the question and the verbs", async () => { + const result = await cliWith(askLegacy).run(["probe"], { + isTty: { stdin: true }, + stdin: "delete\n", + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(`? ${LEGACY_QUESTION} (rename/delete) `); + }); + + test("an unknown verb fails the line renderer", async () => { + const result = await cliWith(askLegacy).run(["probe", "--json"], { + ...INTERACTIVE, + answers: ["drop Legacy"], + }); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.summary).toBe( + `"drop Legacy" is not a valid answer to "${LEGACY_QUESTION}": Start the answer with rename or delete.`, + ); + }); + + test("an answer the command rejects fails the line renderer with its message", async () => { + const result = await cliWith(askLegacy).run(["probe", "--json"], { + ...INTERACTIVE, + answers: ["rename Archive"], + }); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)).toMatchObject({ + code: "CLI.PROMPT_INVALID", + summary: `"rename Archive" is not a valid answer to "${LEGACY_QUESTION}": Write the rename as Legacy:.`, + }); + }); +}); + +describe("prompt.statements asks several questions together", () => { + test("a flag answers one, and the refusal lists only the other", async () => { + const result = await cliWith(askBoth).run([ + "probe", + "--delete", + "Legacy", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)).toMatchObject({ + code: "CLI.CONSENT_REQUIRED", + summary: + '"User.name" needs a statement, and the session is not interactive.', + nextActions: [ + { kind: "user-choice", label: "Pass --rename User.name:" }, + { kind: "user-choice", label: "Pass --delete User.name" }, + ], + meta: { + unanswered: [{ subject: "User.name", verbs: ["rename", "delete"] }], + }, + }); + }); + + test("one refusal lists every unanswered question", async () => { + const result = await cliWith(askBoth).run(["probe", "--json"]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)).toEqual({ + code: "CLI.CONSENT_REQUIRED", + severity: "error", + summary: + '2 subjects need a statement, and the session is not interactive: "Legacy", "User.name".', + why: `${LEGACY_QUESTION}\n${USER_NAME_QUESTION}`, + nextActions: [ + { kind: "user-choice", label: "Pass --rename Legacy:" }, + { kind: "user-choice", label: "Pass --delete Legacy" }, + { kind: "user-choice", label: "Pass --rename User.name:" }, + { kind: "user-choice", label: "Pass --delete User.name" }, + ], + meta: { + unanswered: [ + { subject: "Legacy", verbs: ["rename", "delete"] }, + { subject: "User.name", verbs: ["rename", "delete"] }, + ], + }, + }); + }); + + test("flags answer every question, in any order", async () => { + const result = await cliWith(askBoth).run([ + "probe", + "--delete", + "User.name", + "--rename", + "Legacy:Archive", + ]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: [ + { verb: "rename", text: "Legacy:Archive" }, + { verb: "delete", text: "User.name" }, + ], + }); + }); + + test("an interactive run asks the unanswered questions in order", async () => { + const result = await cliWith(askBoth).run(["probe"], { + isTty: { stdin: true }, + stdin: "rename Legacy:Archive\ndelete\n", + }); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: [ + { verb: "rename", text: "Legacy:Archive" }, + { verb: "delete", text: "User.name" }, + ], + }); + expect(result.stderr.indexOf(LEGACY_QUESTION)).toBeLessThan( + result.stderr.indexOf(USER_NAME_QUESTION), + ); + }); + + test("an interactive run asks only what the flags left unanswered", async () => { + const result = await cliWith(askBoth).run(["probe", "--delete", "Legacy"], { + isTty: { stdin: true }, + stdin: "rename User.name:fullName\n", + }); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: [ + { verb: "delete", text: "Legacy" }, + { verb: "rename", text: "User.name:fullName" }, + ], + }); + expect(result.stderr).not.toContain(LEGACY_QUESTION); + }); + + test("two questions about one subject consume one value each", async () => { + const twice = (prompt: PromptSurface) => + prompt.statements([legacyQuestion, legacyQuestion]); + const result = await cliWith(twice).run([ + "probe", + "--delete", + "Legacy", + "--delete", + "Legacy", + ]); + + expect(result.exitCode).toBe(0); + }); +}); + +describe("a verb-flag value nothing consumed", () => { + test("fails a run that otherwise succeeded", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--delete", + "Legacy", + "--delete", + "Lagacy", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)).toEqual({ + code: "CLI.CONSENT_UNUSED", + severity: "error", + summary: + "--delete Lagacy was given but nothing in this run asked about Lagacy.", + nextActions: [ + { + kind: "user-choice", + label: + "Remove the flag, or spell the subject the way the command names it.", + }, + ], + meta: { unused: [{ verb: "delete", text: "Lagacy" }] }, + }); + }); + + test("fails a run that never asked", async () => { + const quiet = async () => "nothing asked"; + const result = await cliWith(quiet).run([ + "probe", + "--rename", + "Legacy:Archive", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.summary).toBe( + "--rename Legacy:Archive was given but nothing in this run asked about Legacy.", + ); + }); + + test("a value given twice for one question leaves the second unused", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--delete", + "Legacy", + "--delete", + "Legacy", + "--json", + ]); + + expect(errorOf(result)?.code).toBe("CLI.CONSENT_UNUSED"); + }); + + test("a run that failed for another reason reports that reason only", async () => { + const failing = defineCommand({ + help: { summary: "Failing probe" }, + handler: async () => + notOk(new CliStructuredError("CLI.INVALID_ARGUMENTS", "Bad input.")), + }); + const cli = createTestCli({ + commandFamilies: [ + defineCommandFamily({ + commands: { probe: failing }, + statementVerbs: ["delete"], + }), + ], + commands: { probe: failing }, + }); + const result = await cli.run(["probe", "--delete", "Legacy", "--json"]); + + expect(errorOf(result)?.code).toBe("CLI.INVALID_ARGUMENTS"); + }); +}); + +describe("verb registration", () => { + test("a statement naming an unregistered verb is a construction error", async () => { + const unregistered = (prompt: PromptSurface) => + prompt.statement("Archive it?", { + subject: "Legacy", + verbs: ["archive"], + validate: () => undefined, + }); + const result = await cliWith(unregistered).run(["probe", "--json"]); + + expect(result.exitCode).toBe(1); + expect(errorOf(result)?.summary).toContain( + "verb 'archive', which no command family registers in statementVerbs", + ); + }); + + test("a command declaring a flag with a verb's name fails construction", () => { + const command = defineCommand({ + args: { flags: { delete: flag.boolean({ brief: "Delete it" }) } }, + help: { summary: "Clashing probe" }, + handler: async () => + notOk(new CliStructuredError("CLI.INVALID_ARGUMENTS", "Unused.")), + }); + + expect(() => + createTestCli({ + commandFamilies: [ + defineCommandFamily({ + commands: { probe: command }, + statementVerbs: ["delete"], + }), + ], + commands: { probe: command }, + }), + ).toThrow( + "command 'probe' declares reserved flag 'delete' (the shared flag family is engine-injected)", + ); + }); + + test("a verb that is already a shared flag fails construction", () => { + const command = probe(async () => undefined); + + expect(() => + createTestCli({ + commandFamilies: [ + defineCommandFamily({ + commands: { probe: command }, + statementVerbs: ["confirm"], + }), + ], + commands: { probe: command }, + }), + ).toThrow("statement verb 'confirm' is already a shared flag"); + }); + + test("a verb that is not camelCase fails construction", () => { + const command = probe(async () => undefined); + + expect(() => + createTestCli({ + commandFamilies: [ + defineCommandFamily({ + commands: { probe: command }, + statementVerbs: ["drop-table"], + }), + ], + commands: { probe: command }, + }), + ).toThrow( + "statement verb 'drop-table' must be camelCase (it transliterates to --kebab-case on the CLI)", + ); + }); +}); From 1cccdccc2f2706931f115112a68ca5bd3c3cfe4a Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:16:42 +0200 Subject: [PATCH 03/21] docs: statement prompts, their verb flags, and CLI.CONSENT_UNUSED Signed-off-by: willbot Signed-off-by: Will Madden --- docs/product/cli-style-guide.md | 10 ++++++++++ docs/reference/error-reference.md | 8 +++++++- packages/cli-engine/README.md | 32 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index d50a1476..0988a47f 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -182,6 +182,7 @@ Shared global flags, defined by the engine in `SHARED_FLAG_PARAMETERS` (`package - `-q`, `--quiet` (shorthand for `--log-level error`) - `-y`, `--yes` (accept prompt defaults) - `--confirm ` (grant a consent prompt non-interactively; repeatable) +- `-- ` for each statement verb a command family registers (answer a statement prompt; repeatable) - `--interactive`, `--no-interactive` - `--color`, `--no-color` - `--config ` @@ -210,6 +211,15 @@ When a command needs confirmation and cannot prompt: - explain what needs confirmation - suggest `-y` when it is a valid bypass +### Consent + +Consent is a question `--yes` never answers and Enter never answers. It comes in two forms. + +- **A yes/no consent** (`ctx.prompt.consent`). With a token, the user types the token, or passes `--confirm `. Without a token, only an interactive terminal can grant it. +- **A statement** (`ctx.prompt.statement`). The user states what should happen to a subject with a verb: `delete`, or `rename Legacy:Archive`. The command family registers the verbs, and each verb is a flag, so `--delete Legacy` gives the same answer on the command line. A flag value answers the question only when it names the subject: it is the subject, or starts with `:`. + +Without an answer, a non-interactive run fails with `CLI.CONSENT_REQUIRED` and lists the flags that would answer every open question. A statement flag that no question used fails the run with `CLI.CONSENT_UNUSED`, because a mistyped subject must not pass silently. + ## Loading Indicators Loading indicators are for slow or remote work, not for every step. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index f96bdd11..ddc09745 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -156,6 +156,12 @@ The config file's `$prismaConfig` marker declares a version other than the one t A consent prompt was reached under `--yes` or in a non-interactive session. Consent has no default answer and `--yes` does not grant it, so there is nothing for the run to assume. When the consent declares a token, the message and next action say to pass `--confirm `, and the token travels in meta; without a token, the only path is running the command interactively. Meta: `consentToken` (only when the consent declares a token). +A statement prompt (`ctx.prompt.statement` or `ctx.prompt.statements`) raises the same code when no verb flag on the command line answers it. One error lists every question still unanswered: the summary names the subjects, `why` carries the questions, and the next actions give one flag to pass per verb, such as `--delete Legacy` or `--rename Legacy:`. Meta: `unanswered` (a list of `{ subject, verbs }`), plus `subject` and `verbs` when exactly one question is unanswered. + +### CLI.CONSENT_UNUSED + +A statement verb flag such as `--delete Legacy` was given, but no statement prompt in the run asked about that subject, so the value answered nothing. Raised when the run would otherwise have succeeded; a run that failed for another reason reports that reason only. The usual cause is a mistyped subject, or a flag given twice for one question. Exits 2. Meta: `unused` (a list of `{ verb, text }`). + ### CLI.CREDENTIALS_LOCKED The advisory lock on the stored-credentials file was held by another prisma process for longer than the wait timeout, so this run's credential mutation gave up; the fix is to wait for the other command and retry. Raised by the auth state file's lock helper in `packages/cli`. Meta: none. @@ -194,7 +200,7 @@ The user cancelled a prompt: EOF on stdin at a line-rendered prompt, a clack can ### CLI.PROMPT_INVALID -An answer could not be interpreted: not a yes/no for a confirm, not one of a select's options with no default to fall back to, or a consent token typed wrong where re-prompting is impossible (scripted answers or piped stdin — the interactive clack renderer re-prompts instead). Meta: `consentToken` (token-mismatch raise only). +An answer could not be interpreted: not a yes/no for a confirm, not one of a select's options with no default to fall back to, a consent token typed wrong where re-prompting is impossible (scripted answers or piped stdin — the interactive clack renderer re-prompts instead), or a statement answer the command rejected. A statement answer is rejected when a verb flag's value fails the command's check (re-prompting cannot correct a flag), or when a typed answer does not start with one of the verbs or fails the check where re-prompting is impossible; the summary carries the command's reason. Meta: `consentToken` (token-mismatch raise only). ### CLI.PROMPT_REQUIRED diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index e4f29223..7f15f82e 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -10,4 +10,36 @@ Every command describes its output once, as blocks, and the engine renders it in - `@prisma/cli-engine/protocol` — the wire types for machine-readable (JSON) output. - `@prisma/cli-engine/testing` — the test harness for running commands in-process. +## Statement prompts + +A command asks what the user means to happen to something with `ctx.prompt.statement`. The answer is a verb and free text, and the command validates it: + +```ts +const answer = await ctx.prompt.statement( + 'Table "Legacy" would be dropped and its rows lost. What do you mean?', + { + subject: "Legacy", + verbs: ["rename", "delete"], + forms: { rename: "Legacy:" }, + validate: (verb, text) => + verb === "rename" && !text.startsWith("Legacy:") + ? "Write the rename as Legacy:." + : undefined, + }, +); +// { verb: "delete", text: "Legacy" } or { verb: "rename", text: "Legacy:Archive" } +``` + +Each verb is a flag the command family registers with `defineCommandFamily({ ..., statementVerbs: ["rename", "delete"] })`. The engine adds `--rename` and `--delete` to every mounted command, and no command may declare a flag with those names. Asking with a verb no family registered is a construction error. + +The engine answers the question in this order: + +1. A verb-flag value that names the subject: the value is the subject, or starts with `:`. `--delete Legacy` and `--rename Legacy:Archive` both name `Legacy`. A value `validate` rejects fails the run with `CLI.PROMPT_INVALID`. +2. With no such value, a non-interactive run, or one under `--yes`, fails with `CLI.CONSENT_REQUIRED`. Its next actions give one flag to pass per verb, and its `meta` carries `subject`, `verbs` and `unanswered`. +3. Otherwise the user is asked, and answers ` `, or `` alone to mean the subject. A rejected answer is asked again on a terminal; with scripted or piped input it fails with `CLI.PROMPT_INVALID`. + +`ctx.prompt.statements([...])` asks several questions at once and returns the answers in order. Flags answer what they can, a refusal lists every question still unanswered, and an interactive run asks the rest one after another. + +Each flag value answers one question. A run that succeeds with a verb-flag value nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject is never ignored. + Part of [prisma/prisma-cli](https://github.com/prisma/prisma-cli). From 6d80e3b58c63f3c25ada77497ff3433ad858b11e Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:16:56 +0200 Subject: [PATCH 04/21] chore(engine): bump @prisma/cli-engine to 0.7.0 The product families still peer 0.6.3, so conformance records an exception for each until they release against 0.7.0. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/scripts/conformance.ts | 19 ++++++++++++++++++- packages/prisma/package.json | 2 +- pnpm-lock.yaml | 4 ++-- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 741348ea..1c9a91e5 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.6.3", + "version": "0.7.0", "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 5b038a7a..24ddd70a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.6.3", + "@prisma/cli-engine": "workspace:0.7.0", "@prisma/composer-cli": "0.28.0", "@prisma/compute-sdk": "0.43.0", "@prisma/management-api-sdk": "1.80.0", diff --git a/packages/cli/scripts/conformance.ts b/packages/cli/scripts/conformance.ts index 3403120d..a4f64251 100644 --- a/packages/cli/scripts/conformance.ts +++ b/packages/cli/scripts/conformance.ts @@ -109,7 +109,24 @@ async function tarball(): Promise { shellPackage: "@prisma/cli", enginePackage: "@prisma/cli-engine", familyPackages: ["@prisma/composer-cli", "@prisma/orm-toolchain"], - exceptions: [], + exceptions: [ + { + familyPackage: "@prisma/composer-cli", + familyPin: "0.6.3", + shellPin: "0.7.0", + reason: "engine 0.7.0 must publish before composer-cli can peer it", + removeWhen: + "composer-cli releases peering 0.7.0 and the follow-up bump PR pins that release", + }, + { + familyPackage: "@prisma/orm-toolchain", + familyPin: "0.6.3", + shellPin: "0.7.0", + reason: "engine 0.7.0 must publish before orm-toolchain can peer it", + removeWhen: + "orm-toolchain releases peering 0.7.0 and the follow-up bump PR pins that release", + }, + ], channel: CHANNEL, sandboxDir: join(WORK_DIR, "sandbox"), }, diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 5400830d..08f3677a 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -50,7 +50,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.6.3", + "@prisma/cli-engine": "workspace:0.7.0", "@prisma/composer-cli": "0.28.0", "@prisma/compute-sdk": "0.43.0", "@prisma/management-api-sdk": "1.80.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a69ffe3a..1297c39d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.6.3 + specifier: workspace:0.7.0 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.28.0 @@ -210,7 +210,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.6.3 + specifier: workspace:0.7.0 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.28.0 From 5a94bd3da9cf5b68d95ace121e1f4fb4e8129e71 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:33:21 +0200 Subject: [PATCH 05/21] refactor(engine): a command declares its statements instead of a family registering verbs defineCommand takes statements: { : { arity, brief? } }. Only that command accepts --; help lists it on the command's own card; the telemetry snapshot records its name. A statement clashing with a shared flag or the command's own flag fails construction, and a prompt naming a verb the command did not declare is a construction error. Command families no longer carry statementVerbs. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/command-family.ts | 7 - packages/cli-engine/src/commands.ts | 17 ++ packages/cli-engine/src/context.ts | 3 +- .../src/execution/command-snapshot.ts | 15 +- .../cli-engine/src/execution/command-tree.ts | 78 ++++----- packages/cli-engine/src/execution/engine.ts | 26 +-- packages/cli-engine/src/execution/help.ts | 42 +++-- packages/cli-engine/src/execution/prompts.ts | 6 +- .../cli-engine/src/execution/shared-flags.ts | 102 +----------- .../src/execution/statement-flags.ts | 81 ++++++++++ .../src/execution/stricli-adapter.ts | 26 ++- packages/cli-engine/src/exports/index.ts | 1 + .../cli-engine/tests/clack-prompts.test.ts | 9 +- .../tests/statement-declarations.test.ts | 152 ++++++++++++++++++ .../tests/statement-flag-order.test.ts | 2 +- .../tests/statement-prompts.test.ts | 100 +----------- 16 files changed, 364 insertions(+), 303 deletions(-) create mode 100644 packages/cli-engine/src/execution/statement-flags.ts create mode 100644 packages/cli-engine/tests/statement-declarations.test.ts diff --git a/packages/cli-engine/src/command-family.ts b/packages/cli-engine/src/command-family.ts index e84e2192..0f90acb7 100644 --- a/packages/cli-engine/src/command-family.ts +++ b/packages/cli-engine/src/command-family.ts @@ -56,11 +56,6 @@ export interface CommandFamily { readonly docsBaseUrl: string | undefined; /** The invocations this family retired. */ readonly redirects: readonly CommandRedirect[]; - /** The verbs this family's commands answer statement prompts with. - * Each becomes a reserved, repeatable shared flag (`--`) on - * every mounted command. Optional because a family built by an - * older engine has none. */ - readonly statementVerbs?: readonly string[]; } /** A path is segments separated by whitespace, so the separator's shape @@ -83,14 +78,12 @@ export function defineCommandFamily(spec: { readonly commands: Readonly>; readonly docsBaseUrl?: string; readonly redirects?: readonly RedirectSpec[]; - readonly statementVerbs?: readonly string[]; }): CommandFamily { return Object.freeze({ configSection: spec.configSection, commands: spec.commands, docsBaseUrl: spec.docsBaseUrl, redirects: (spec.redirects ?? []).map(normalizeRedirect), - statementVerbs: [...(spec.statementVerbs ?? [])], }); } diff --git a/packages/cli-engine/src/commands.ts b/packages/cli-engine/src/commands.ts index a5fc412e..676b0e1f 100644 --- a/packages/cli-engine/src/commands.ts +++ b/packages/cli-engine/src/commands.ts @@ -143,6 +143,18 @@ export interface SpawnDeclarations { readonly maySpawn?: boolean; } +/** + * A statement the command may ask for with ctx.prompt.statement. Its + * key is the verb, and the command alone accepts `-- ` + * (repeatable) to answer it; the handler never sees those values. + */ +export interface StatementSpec { + /** How many values follow the flag. Only 1 is supported. */ + readonly arity: 1; + /** The flag's help brief. The engine writes a generic one when absent. */ + readonly brief?: string; +} + function normalizeNeeds( spec: NeedsSpec | undefined, ): CommandNeeds { @@ -200,6 +212,9 @@ export interface CommandDefinition< */ readonly installsPackages: TInstallsPackages; + /** The statements ctx.prompt.statement may ask, by verb. */ + readonly statements: Readonly>; + /** * The handler function, referenced directly — never a dynamic import * (operator ruling, 2026-08-09). A handler that needs heavy @@ -271,6 +286,7 @@ export function defineCommand< readonly exitCodes?: Readonly>; readonly managesCredentials?: TManagesCredentials; readonly installsPackages?: TInstallsPackages; + readonly statements?: Readonly>; readonly handler: Handler< TFlags, TPositionals, @@ -298,6 +314,7 @@ export function defineCommand< false) as TManagesCredentials, maySpawn: def.maySpawn ?? false, installsPackages: (def.installsPackages ?? false) as TInstallsPackages, + statements: Object.freeze({ ...def.statements }), handler: def.handler, }); } diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index f46a4c84..4f8a4cfe 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -266,8 +266,7 @@ export interface PromptSurface { * Without such a flag a non-interactive run, or one under `--yes`, * fails with `CLI.CONSENT_REQUIRED`; an interactive run asks, and the * user answers ` `, or `` alone to mean the subject. - * Each verb must be registered in a command family's - * `statementVerbs`. + * Each verb must be one the command declares in `statements`. */ readonly statement: ( question: string, diff --git a/packages/cli-engine/src/execution/command-snapshot.ts b/packages/cli-engine/src/execution/command-snapshot.ts index 2d0ec6fc..72e82e97 100644 --- a/packages/cli-engine/src/execution/command-snapshot.ts +++ b/packages/cli-engine/src/execution/command-snapshot.ts @@ -9,17 +9,15 @@ import { camelCase, flagRuntime, kebabCase } from "../args"; import type { AnyCommand } from "../commands"; import type { EngineCommandSnapshot } from "../run-summary"; -import { SHARED_ALIASES, sharedFlagParameters } from "./shared-flags"; +import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; +import { declaredStatements } from "./statement-flags"; -function declaredFlagKeys( - def: AnyCommand, - statementVerbs: readonly string[], -): readonly string[] { - const own = Object.keys(def.args.flags); +function declaredFlagKeys(def: AnyCommand): readonly string[] { + const own = [...Object.keys(def.args.flags), ...declaredStatements(def)]; if (def.kind === "server-command") { return own; } - return [...Object.keys(sharedFlagParameters(statementVerbs)), ...own]; + return [...Object.keys(SHARED_FLAG_PARAMETERS), ...own]; } function aliasMap(def: AnyCommand): ReadonlyMap { @@ -118,11 +116,10 @@ function explicitFlagKeys( export function buildCommandSnapshot( entryId: string, def: AnyCommand, - statementVerbs: readonly string[], argv: readonly string[], positionalValues: readonly (string | undefined)[], ): EngineCommandSnapshot { - const declared = declaredFlagKeys(def, statementVerbs); + const declared = declaredFlagKeys(def); const explicit = explicitFlagKeys(def, declared, argv); return { commandPath: entryId.split("."), diff --git a/packages/cli-engine/src/execution/command-tree.ts b/packages/cli-engine/src/execution/command-tree.ts index 098b328a..a3e6ced2 100644 --- a/packages/cli-engine/src/execution/command-tree.ts +++ b/packages/cli-engine/src/execution/command-tree.ts @@ -9,11 +9,8 @@ import type { AnyCommand } from "../commands"; import { reservedConfigSectionName } from "../config-loader"; import type { ConfigSection } from "../config-section"; import type { EngineSpec } from "./engine"; -import { - RESERVED_ALIASES, - RESERVED_FLAG_NAMES, - registeredStatementVerbs, -} from "./shared-flags"; +import { RESERVED_ALIASES, RESERVED_FLAG_NAMES } from "./shared-flags"; +import { declaredStatements } from "./statement-flags"; export function constructionError(message: string): Error { return new Error(`@prisma/cli-engine: ${message}`); @@ -22,30 +19,11 @@ export function constructionError(message: string): Error { const CAMEL_CASE = /^[a-z][a-zA-Z0-9]*$/; const INTEGER_LIKE = /^\d+$/; -function validateStatementVerbs(statementVerbs: readonly string[]): void { - for (const verb of statementVerbs) { - if (!CAMEL_CASE.test(verb)) { - throw constructionError( - `statement verb '${verb}' must be camelCase (it transliterates to --kebab-case on the CLI)`, - ); - } - if (RESERVED_FLAG_NAMES.has(verb)) { - throw constructionError( - `statement verb '${verb}' is already a shared flag`, - ); - } - } -} - -function validateFlags( - path: string, - def: AnyCommand, - statementVerbs: readonly string[], -): void { +function validateFlags(path: string, def: AnyCommand): void { const flags = def.args.flags; const seenAliases = new Set(); for (const [key, spec] of Object.entries(flags)) { - if (RESERVED_FLAG_NAMES.has(key) || statementVerbs.includes(key)) { + if (RESERVED_FLAG_NAMES.has(key)) { throw constructionError( `command '${path}' declares reserved flag '${key}' (the shared flag family is engine-injected)`, ); @@ -73,6 +51,34 @@ function validateFlags( } } +function validateStatements(path: string, def: AnyCommand): void { + if (def.kind !== "result-command") { + return; + } + for (const [verb, spec] of Object.entries(def.statements)) { + if (!CAMEL_CASE.test(verb)) { + throw constructionError( + `command '${path}' statement '${verb}' must be camelCase (it transliterates to --kebab-case on the CLI)`, + ); + } + if (RESERVED_FLAG_NAMES.has(verb)) { + throw constructionError( + `command '${path}' declares statement '${verb}', which is a shared flag`, + ); + } + if (def.args.flags[verb] !== undefined) { + throw constructionError( + `command '${path}' declares both a flag and a statement named '${verb}'`, + ); + } + if (spec.arity !== 1) { + throw constructionError( + `command '${path}' statement '${verb}' declares arity ${spec.arity}; only 1 is supported`, + ); + } + } +} + function validatePositionals(path: string, def: AnyCommand): void { const entries = Object.entries>(def.args.positionals); let sawOptional = false; @@ -249,12 +255,11 @@ export function buildCommandTree(spec: EngineSpec): CommandTreeNode { } validateDocsBaseUrls(spec); validateConfigSectionNames(spec); - const statementVerbs = registeredStatementVerbs(spec); - validateStatementVerbs(statementVerbs); const root = emptyNode(); for (const path of paths) { const def = spec.commands[path]; - validateFlags(path, def, statementVerbs); + validateFlags(path, def); + validateStatements(path, def); validatePositionals(path, def); validateExitCodes(path, def); validateSpawnDeclarations(path, def); @@ -307,15 +312,14 @@ function mountedAs( /** Engine-injected shared flags count: a command answers them whether * or not it declared them, so a redirect for one could never fire. */ -function acceptsFlag(spec: EngineSpec, def: AnyCommand, flag: string): boolean { - if (def.args.flags[flag] !== undefined) { +function acceptsFlag(def: AnyCommand, flag: string): boolean { + if ( + def.args.flags[flag] !== undefined || + declaredStatements(def).includes(flag) + ) { return true; } - return ( - def.kind !== "server-command" && - (RESERVED_FLAG_NAMES.has(flag) || - registeredStatementVerbs(spec).includes(flag)) - ); + return def.kind !== "server-command" && RESERVED_FLAG_NAMES.has(flag); } function addVerbRedirect( @@ -352,7 +356,7 @@ function addFlagRedirect( `redirect for flag '${flag}' names '${redirect.from}', which is not a mounted command`, ); } - if (acceptsFlag(spec, def, flag)) { + if (acceptsFlag(def, flag)) { throw constructionError( `redirect for flag '${flag}' on '${redirect.from}' names a flag that command still accepts`, ); diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 503c6f5d..14095686 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -65,17 +65,19 @@ import { applySharedFlags, configFlagGivenNoValueError, defaultInteractive, - registeredStatementVerbs, type SharedFlags, - type StatementFlagValue, sniffFormat, - statementFlagValues, } from "./shared-flags"; import { type DelegatedTerminal, endAbandonedChild, recordSignalDuringSpawn, } from "./spawn"; +import { + declaredStatements, + type StatementFlagValue, + statementFlagValues, +} from "./statement-flags"; import { buildRoutes, capturingText, @@ -150,8 +152,11 @@ export interface RunState { * token removes the value it matched, so one `--confirm` grants one * consent. */ confirmValues: string[]; - /** Every verb-flag value, in argv order. A statement prompt marks the - * value it consumed; one left unconsumed fails a successful run. */ + /** The statement verbs the mounted command declared. */ + statementVerbs: readonly string[]; + /** Every statement-flag value, in argv order. A statement prompt + * marks the value it consumed; one left unconsumed fails a run that + * otherwise succeeded. */ statementValues: StatementFlagValue[]; interactive: boolean; colorEnabled: boolean; @@ -222,9 +227,6 @@ export interface Invocation { /** Every config section name the mounted command families declare — * the closed set of top-level keys prisma.config.ts may contain. */ readonly configSections: readonly string[]; - /** Every verb the mounted command families registered for statement - * prompts. */ - readonly statementVerbs: readonly string[]; /** The engine's whole signal policy, reachable so ctx.spawn can * replay recorded signals through exactly the delivered path. */ readonly deliverSignal: (signal: "SIGINT" | "SIGTERM") => void; @@ -312,7 +314,6 @@ export class EngineImpl implements Engine { private readonly now: () => Date; private readonly delay: (ms: number, signal: AbortSignal) => Promise; private readonly configSections: readonly string[]; - private readonly statementVerbs: readonly string[]; constructor( spec: EngineSpec, @@ -323,7 +324,6 @@ export class EngineImpl implements Engine { this.now = now; this.delay = delay; this.configSections = declaredConfigSections(spec); - this.statementVerbs = registeredStatementVerbs(spec); this.tree = buildCommandTree(spec); this.root = buildRoutes( spec, @@ -349,6 +349,7 @@ export class EngineImpl implements Engine { logLevel: "info", yes: false, confirmValues: [], + statementVerbs: [], statementValues: [], interactive: defaultInteractive(runtime), /** Pre-parse resolution so a run that never mounts a command — an @@ -400,7 +401,6 @@ export class EngineImpl implements Engine { state, signal: controller.signal, configSections: this.configSections, - statementVerbs: this.statementVerbs, deliverSignal, }; if (versionFlagGiven(argv)) { @@ -654,7 +654,6 @@ export class EngineImpl implements Engine { state.snapshot = buildCommandSnapshot( entry.id, entry.def, - this.statementVerbs, state.argv, values, ); @@ -669,9 +668,10 @@ export class EngineImpl implements Engine { let needsOutcome: NeedsOutcome; try { applySharedFlags(state, rawFlags as SharedFlags, invocation.runtime); + state.statementVerbs = declaredStatements(entry.def); state.statementValues = statementFlagValues( state.argv, - this.statementVerbs, + state.statementVerbs, rawFlags, ); needsOutcome = await checkNeeds(entry.def, invocation); diff --git a/packages/cli-engine/src/execution/help.ts b/packages/cli-engine/src/execution/help.ts index 06ad6707..c6e6a289 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -19,11 +19,8 @@ import type { EngineSpec } from "./engine"; import { renderHelpMarkdown } from "./markdown"; import { makePaint, type Paint, textWidth } from "./palette"; import { formatFlagGiven, withoutFormatFlags } from "./pre-parse-argv"; -import { - registeredStatementVerbs, - SHARED_ALIASES, - sharedFlagParameters, -} from "./shared-flags"; +import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; +import { statementFlagParameter } from "./statement-flags"; import { resolveExample } from "./stricli-adapter"; const RAIL = "│"; @@ -221,7 +218,7 @@ function nodeCard( ), arguments: [], options: [], - globalOptions: atRoot ? sharedFlagRows(spec) : [], + globalOptions: atRoot ? sharedFlagRows() : [], note: atRoot ? undefined : `Run '${spec.name} ${groupPath} --help' for details on a command.`, @@ -245,9 +242,7 @@ function leafCard( ] .filter((part) => part !== "") .join(" "); - const sharedNames = Object.keys( - sharedFlagParameters(registeredStatementVerbs(spec)), - ) + const sharedNames = Object.keys(SHARED_FLAG_PARAMETERS) .map((key) => `--${kebabCase(key)}`) .join(", "); return { @@ -497,14 +492,12 @@ function flagLabel( return `${alias} --${kebab}${negated}${placeholder}${repeat}`; } -function sharedFlagRows(spec: EngineSpec): readonly HelpRow[] { +function sharedFlagRows(): readonly HelpRow[] { const aliasByKey = new Map( Object.entries(SHARED_ALIASES).map(([alias, key]) => [key, alias]), ); - const rows = Object.entries( - sharedFlagParameters(registeredStatementVerbs(spec)), - ).map(([key, parameter]) => { - const record = parameter as { + const rows = Object.entries(SHARED_FLAG_PARAMETERS).map(([key, spec]) => { + const record = spec as { brief: string; kind: string; placeholder?: string; @@ -525,7 +518,28 @@ function sharedFlagRows(spec: EngineSpec): readonly HelpRow[] { ]; } +function statementFlagRows(def: AnyCommand): readonly HelpRow[] { + if (def.kind !== "result-command") { + return []; + } + return Object.entries(def.statements).map(([verb, spec]) => { + const parameter = statementFlagParameter(verb, spec.brief); + return { + name: flagLabel(verb, { + placeholder: parameter.placeholder, + variadic: parameter.variadic, + }), + brief: parameter.brief, + suffix: undefined, + }; + }); +} + function declaredFlagRows(def: AnyCommand): readonly HelpRow[] { + return [...ownFlagRows(def), ...statementFlagRows(def)]; +} + +function ownFlagRows(def: AnyCommand): readonly HelpRow[] { return Object.entries(def.args.flags).map(([key, spec]) => { const runtime: FlagRuntimeSpec = flagRuntime(spec); return { diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index 09bf9b36..76ba71b1 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -41,7 +41,7 @@ import { import { constructionError } from "./command-tree"; import type { Invocation, RunState } from "./engine"; import { announceUrl } from "./open-url"; -import type { StatementFlagValue } from "./shared-flags"; +import type { StatementFlagValue } from "./statement-flags"; const WHITESPACE = /\s/; @@ -565,9 +565,9 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { ); } for (const verb of question.verbs) { - if (!invocation.statementVerbs.includes(verb)) { + if (!state.statementVerbs.includes(verb)) { throw constructionError( - `command '${state.commandId}' asked a statement with verb '${verb}', which no command family registers in statementVerbs`, + `command '${state.commandId}' asked a statement with verb '${verb}', which it does not declare in statements`, ); } } diff --git a/packages/cli-engine/src/execution/shared-flags.ts b/packages/cli-engine/src/execution/shared-flags.ts index 68b42a9f..097dc036 100644 --- a/packages/cli-engine/src/execution/shared-flags.ts +++ b/packages/cli-engine/src/execution/shared-flags.ts @@ -1,11 +1,10 @@ -import { camelCase } from "../args"; import { resolveIsCI } from "../ci"; import type { Severity } from "../events"; import type { Format } from "../presentation"; import { CliStructuredError } from "../protocol"; import type { Runtime } from "../runtime"; -import type { EngineSpec, RunState } from "./engine"; -import { flagTokens, formatFlagGiven } from "./pre-parse-argv"; +import type { RunState } from "./engine"; +import { formatFlagGiven } from "./pre-parse-argv"; /** The engine-injected shared flag family. Commands cannot declare * these names or aliases; handlers never see their values. */ @@ -123,103 +122,6 @@ export function configFlagGivenNoValueError(): CliStructuredError { ); } -/** A verb flag answers statement prompts. The verbs come from the - * mounted command families, so these flags join the shared family per - * CLI rather than in SHARED_FLAG_PARAMETERS. */ -function statementVerbParameter(verb: string) { - return { - kind: "parsed", - parse: (input: string) => input, - placeholder: "subject", - variadic: true, - optional: true, - brief: `Answer the question about with ${verb} (repeatable)`, - } as const; -} - -type SharedFlagParameter = - | (typeof SHARED_FLAG_PARAMETERS)[keyof typeof SHARED_FLAG_PARAMETERS] - | ReturnType; - -export function registeredStatementVerbs(spec: EngineSpec): readonly string[] { - return [ - ...new Set( - spec.commandFamilies.flatMap( - (commandFamily) => commandFamily.statementVerbs ?? [], - ), - ), - ]; -} - -export function sharedFlagParameters( - statementVerbs: readonly string[], -): Readonly> { - return { - ...SHARED_FLAG_PARAMETERS, - ...Object.fromEntries( - statementVerbs.map((verb) => [verb, statementVerbParameter(verb)]), - ), - }; -} - -export interface StatementFlagValue { - readonly verb: string; - readonly text: string; - consumed: boolean; -} - -function verbFlagIn( - token: string, - statementVerbs: readonly string[], -): string | undefined { - if (!token.startsWith("--")) { - return undefined; - } - const equals = token.indexOf("="); - if (equals === token.length - 1) { - return undefined; - } - const name = camelCase(token.slice(2, equals === -1 ? undefined : equals)); - return statementVerbs.includes(name) ? name : undefined; -} - -function parsedValues(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; -} - -/** - * Every verb-flag value, in the order argv gave them. The parser - * groups values by flag, so argv decides only which verb comes next; - * the values themselves are the parser's. A parsed value argv could - * not place is appended rather than dropped, so it is still reported - * if nothing consumes it. - */ -export function statementFlagValues( - argv: readonly string[], - statementVerbs: readonly string[], - parsedFlags: Readonly>, -): StatementFlagValue[] { - const remaining = new Map( - statementVerbs.map((verb) => [verb, parsedValues(parsedFlags[verb])]), - ); - const ordered: StatementFlagValue[] = []; - for (const token of flagTokens(argv)) { - const verb = verbFlagIn(token, statementVerbs); - const text = verb === undefined ? undefined : remaining.get(verb)?.shift(); - if (verb !== undefined && text !== undefined) { - ordered.push({ verb, text, consumed: false }); - } - } - for (const [verb, texts] of remaining) { - for (const text of texts) { - ordered.push({ verb, text, consumed: false }); - } - } - return ordered; -} - export const SHARED_ALIASES = { v: "verbose", q: "quiet", y: "yes" } as const; export interface SharedFlags { diff --git a/packages/cli-engine/src/execution/statement-flags.ts b/packages/cli-engine/src/execution/statement-flags.ts new file mode 100644 index 00000000..586ed5d8 --- /dev/null +++ b/packages/cli-engine/src/execution/statement-flags.ts @@ -0,0 +1,81 @@ +import { camelCase } from "../args"; +import type { AnyCommand } from "../commands"; +import { flagTokens } from "./pre-parse-argv"; + +/** The statement verbs a command declared; only result commands can. */ +export function declaredStatements(def: AnyCommand): readonly string[] { + return def.kind === "result-command" ? Object.keys(def.statements) : []; +} + +/** The parser's view of a statement flag: repeatable, one value each, + * never required. */ +export function statementFlagParameter(verb: string, brief?: string) { + return { + kind: "parsed", + parse: (input: string) => input, + placeholder: "subject", + variadic: true, + optional: true, + brief: + brief ?? + `Say what happens to : ${verb}, instead of being asked (repeatable)`, + } as const; +} + +export interface StatementFlagValue { + readonly verb: string; + readonly text: string; + consumed: boolean; +} + +function verbFlagIn( + token: string, + verbs: readonly string[], +): string | undefined { + if (!token.startsWith("--")) { + return undefined; + } + const equals = token.indexOf("="); + if (equals === token.length - 1) { + return undefined; + } + const name = camelCase(token.slice(2, equals === -1 ? undefined : equals)); + return verbs.includes(name) ? name : undefined; +} + +function parsedValues(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +/** + * Every statement-flag value, in the order argv gave them. The parser + * groups values by flag, so argv decides only which verb comes next; + * the values themselves are the parser's. A parsed value argv could + * not place is appended rather than dropped, so it is still reported + * if nothing consumes it. + */ +export function statementFlagValues( + argv: readonly string[], + verbs: readonly string[], + parsedFlags: Readonly>, +): StatementFlagValue[] { + const remaining = new Map( + verbs.map((verb) => [verb, parsedValues(parsedFlags[verb])]), + ); + const ordered: StatementFlagValue[] = []; + for (const token of flagTokens(argv)) { + const verb = verbFlagIn(token, verbs); + const text = verb === undefined ? undefined : remaining.get(verb)?.shift(); + if (verb !== undefined && text !== undefined) { + ordered.push({ verb, text, consumed: false }); + } + } + for (const [verb, texts] of remaining) { + for (const text of texts) { + ordered.push({ verb, text, consumed: false }); + } + } + return ordered; +} diff --git a/packages/cli-engine/src/execution/stricli-adapter.ts b/packages/cli-engine/src/execution/stricli-adapter.ts index fbee00ec..5121d541 100644 --- a/packages/cli-engine/src/execution/stricli-adapter.ts +++ b/packages/cli-engine/src/execution/stricli-adapter.ts @@ -25,11 +25,8 @@ import { import type { AnyCommand } from "../commands"; import type { CommandTreeEntry, CommandTreeNode } from "./command-tree"; import type { EngineSpec, Invocation, RunState } from "./engine"; -import { - registeredStatementVerbs, - SHARED_ALIASES, - sharedFlagParameters, -} from "./shared-flags"; +import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; +import { statementFlagParameter } from "./statement-flags"; export interface EngineRunContext extends StricliBaseContext { readonly invocation: Invocation; @@ -151,10 +148,7 @@ function stricliPositional( }; } -function commandParameters( - def: AnyCommand, - sharedFlags: Readonly>, -): Record { +function commandParameters(def: AnyCommand): Record { const declaredFlags: Record = {}; const aliases: Record = {}; for (const [key, spec] of Object.entries(def.args.flags)) { @@ -164,13 +158,20 @@ function commandParameters( aliases[runtime.alias] = key; } } + if (def.kind === "result-command") { + for (const [verb, spec] of Object.entries(def.statements)) { + declaredFlags[verb] = statementFlagParameter(verb, spec.brief); + } + } const injectShared = def.kind !== "server-command"; const positionalEntries = Object.entries>( def.args.positionals, ).map(([key, spec]) => [key, positionalRuntime(spec)] as const); const positional = stricliPositional(positionalEntries); return { - flags: injectShared ? { ...sharedFlags, ...declaredFlags } : declaredFlags, + flags: injectShared + ? { ...SHARED_FLAG_PARAMETERS, ...declaredFlags } + : declaredFlags, aliases: injectShared ? { ...SHARED_ALIASES, ...aliases } : aliases, ...(positional === undefined ? {} : { positional }), }; @@ -218,12 +219,10 @@ function commandDocs( function toStricliCommand( entry: CommandTreeEntry, cliName: string, - sharedFlags: Readonly>, runEntry: RunEntry, ): EngineRoutingTarget { const parameters = commandParameters( entry.def, - sharedFlags, ) as unknown as TypedCommandParameters< Record, readonly (string | undefined)[], @@ -253,9 +252,8 @@ export function buildRoutes( runEntry: RunEntry, ): StricliRouteMap { const routes: Record = {}; - const sharedFlags = sharedFlagParameters(registeredStatementVerbs(spec)); for (const [name, entry] of node.commands) { - routes[name] = toStricliCommand(entry, spec.name, sharedFlags, runEntry); + routes[name] = toStricliCommand(entry, spec.name, runEntry); } for (const [name, child] of node.children) { const childPath = groupPath === "" ? name : `${groupPath} ${name}`; diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index bf80a8ec..1e6efc27 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -43,6 +43,7 @@ export { type ServerCommandDefinition, type SessionCommandDefinition, type SpawnDeclarations, + type StatementSpec, type WorkflowStep, } from "../commands"; export { diff --git a/packages/cli-engine/tests/clack-prompts.test.ts b/packages/cli-engine/tests/clack-prompts.test.ts index 25931353..e2924d22 100644 --- a/packages/cli-engine/tests/clack-prompts.test.ts +++ b/packages/cli-engine/tests/clack-prompts.test.ts @@ -8,7 +8,6 @@ import { type Block, createCli, defineCommand, - defineCommandFamily, type PromptSurface, type Runtime, } from "@prisma/cli-engine"; @@ -60,6 +59,7 @@ function keystrokeStdin( function promptCli(run: (prompt: PromptSurface) => Promise) { const probe = defineCommand({ help: { summary: "Prompt probe" }, + statements: { rename: { arity: 1 }, delete: { arity: 1 } }, handler: async (_args, ctx) => { const answer = await run(ctx.prompt); return ok( @@ -84,12 +84,7 @@ function promptCli(run: (prompt: PromptSurface) => Promise) { return createCli({ name: "probe", version: "0.0.0", - commandFamilies: [ - defineCommandFamily({ - commands: { probe }, - statementVerbs: ["rename", "delete"], - }), - ], + commandFamilies: [], groups: {}, commands: { probe }, }); diff --git a/packages/cli-engine/tests/statement-declarations.test.ts b/packages/cli-engine/tests/statement-declarations.test.ts new file mode 100644 index 00000000..4a0943bc --- /dev/null +++ b/packages/cli-engine/tests/statement-declarations.test.ts @@ -0,0 +1,152 @@ +/** + * A command declares the statements it may ask for. Only that command + * accepts their flags, its handler never sees their values, and a + * declaration that would clash with another flag fails construction. + */ +import { + defineCommand, + flag, + type PromptSurface, + type StatementSpec, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +function command( + statements: Readonly>, + ask: (prompt: PromptSurface) => Promise = async () => undefined, +) { + return defineCommand({ + help: { summary: "Statement probe" }, + args: { flags: { name: flag.string({ brief: "A name" }) } }, + statements, + handler: async (args, ctx) => { + const answer = await ask(ctx.prompt); + return ok( + ctx.present( + { data: { flags: args.flags, answer } }, + { + human: () => [], + stdout: () => [], + json: () => ({}), + next: () => [], + }, + ), + ); + }, + }); +} + +const DELETE = { delete: { arity: 1 } } as const; + +function errorOf(result: Awaited>) { + const last = result.json[result.json.length - 1]; + return last.kind === "result" && !last.envelope.ok + ? last.envelope.error + : undefined; +} + +describe("a statement flag belongs to the command that declares it", () => { + test("another command does not accept it", async () => { + const cli = createTestCli({ + commands: { probe: command(DELETE), other: command({}) }, + }); + const result = await cli.run(["other", "--delete", "Legacy", "--json"]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.code).toBe("CLI.INVALID_ARGUMENTS"); + }); + + test("the handler never sees its values", async () => { + const answerDelete = (prompt: PromptSurface) => + prompt.statement("Drop Legacy?", { + subject: "Legacy", + verbs: ["delete"], + validate: () => undefined, + }); + const cli = createTestCli({ + commands: { probe: command(DELETE, answerDelete) }, + }); + const result = await cli.run(["probe", "--delete", "Legacy"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + flags: { name: undefined }, + answer: { verb: "delete", text: "Legacy" }, + }); + }); + + test("help lists it only on the command that declares it", async () => { + const cli = createTestCli({ + commands: { + probe: command({ + delete: { arity: 1, brief: "Drop the table and its rows" }, + }), + other: command({}), + }, + }); + const declaring = await cli.run(["probe", "--help", "--format", "human"]); + const other = await cli.run(["other", "--help", "--format", "human"]); + + expect(declaring.stdout).toContain("--delete ..."); + expect(declaring.stdout).toContain("Drop the table and its rows"); + expect(other.stdout).not.toContain("--delete"); + }); + + test("a statement naming a verb the command did not declare is a construction error", async () => { + const undeclared = (prompt: PromptSurface) => + prompt.statement("Archive it?", { + subject: "Legacy", + verbs: ["archive"], + validate: () => undefined, + }); + const cli = createTestCli({ + commands: { probe: command(DELETE, undeclared) }, + }); + const result = await cli.run(["probe", "--json"]); + + expect(result.exitCode).toBe(1); + expect(errorOf(result)?.summary).toContain( + "verb 'archive', which it does not declare in statements", + ); + }); +}); + +describe("declarations that fail construction", () => { + const clashes: ReadonlyArray< + readonly [string, Record, string] + > = [ + [ + "an ordinary flag with the same name", + { name: { arity: 1 } }, + "command 'probe' declares both a flag and a statement named 'name'", + ], + [ + "a shared flag's name", + { confirm: { arity: 1 } }, + "command 'probe' declares statement 'confirm', which is a shared flag", + ], + [ + "a name that is not camelCase", + { "drop-table": { arity: 1 } }, + "command 'probe' statement 'drop-table' must be camelCase (it transliterates to --kebab-case on the CLI)", + ], + ]; + + test.each(clashes)("%s", (_case, statements, message) => { + expect(() => + createTestCli({ commands: { probe: command(statements) } }), + ).toThrow(message); + }); + + test("an arity other than 1", () => { + const pair = { arity: 2 } as unknown as StatementSpec; + + expect(() => + createTestCli({ commands: { probe: command({ rename: pair }) } }), + ).toThrow( + "command 'probe' statement 'rename' declares arity 2; only 1 is supported", + ); + }); +}); diff --git a/packages/cli-engine/tests/statement-flag-order.test.ts b/packages/cli-engine/tests/statement-flag-order.test.ts index ba33b87f..56667e33 100644 --- a/packages/cli-engine/tests/statement-flag-order.test.ts +++ b/packages/cli-engine/tests/statement-flag-order.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { statementFlagValues } from "../src/execution/shared-flags"; +import { statementFlagValues } from "../src/execution/statement-flags"; const VERBS = ["rename", "delete", "dropColumn"]; diff --git a/packages/cli-engine/tests/statement-prompts.test.ts b/packages/cli-engine/tests/statement-prompts.test.ts index 215c3f5f..8b2fcb61 100644 --- a/packages/cli-engine/tests/statement-prompts.test.ts +++ b/packages/cli-engine/tests/statement-prompts.test.ts @@ -8,8 +8,6 @@ import { type Block, defineCommand, - defineCommandFamily, - flag, type PromptSurface, } from "@prisma/cli-engine"; import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; @@ -22,6 +20,7 @@ const INTERACTIVE = { isTty: { stdin: true, stdout: true } }; function probe(ask: (prompt: PromptSurface) => Promise) { return defineCommand({ help: { summary: "Statement probe" }, + statements: { rename: { arity: 1 }, delete: { arity: 1 } }, handler: async (_args, ctx) => { const answer = await ask(ctx.prompt); return ok( @@ -46,17 +45,7 @@ function probe(ask: (prompt: PromptSurface) => Promise) { } function cliWith(ask: (prompt: PromptSurface) => Promise) { - const command = probe(ask); - return createTestCli({ - commandFamilies: [ - defineCommandFamily({ - commands: { probe: command }, - statementVerbs: ["rename", "delete"], - }), - ], - commands: { probe: command }, - now: EPOCH, - }); + return createTestCli({ commands: { probe: probe(ask) }, now: EPOCH }); } const LEGACY_QUESTION = @@ -464,94 +453,13 @@ describe("a verb-flag value nothing consumed", () => { test("a run that failed for another reason reports that reason only", async () => { const failing = defineCommand({ help: { summary: "Failing probe" }, + statements: { delete: { arity: 1 } }, handler: async () => notOk(new CliStructuredError("CLI.INVALID_ARGUMENTS", "Bad input.")), }); - const cli = createTestCli({ - commandFamilies: [ - defineCommandFamily({ - commands: { probe: failing }, - statementVerbs: ["delete"], - }), - ], - commands: { probe: failing }, - }); + const cli = createTestCli({ commands: { probe: failing } }); const result = await cli.run(["probe", "--delete", "Legacy", "--json"]); expect(errorOf(result)?.code).toBe("CLI.INVALID_ARGUMENTS"); }); }); - -describe("verb registration", () => { - test("a statement naming an unregistered verb is a construction error", async () => { - const unregistered = (prompt: PromptSurface) => - prompt.statement("Archive it?", { - subject: "Legacy", - verbs: ["archive"], - validate: () => undefined, - }); - const result = await cliWith(unregistered).run(["probe", "--json"]); - - expect(result.exitCode).toBe(1); - expect(errorOf(result)?.summary).toContain( - "verb 'archive', which no command family registers in statementVerbs", - ); - }); - - test("a command declaring a flag with a verb's name fails construction", () => { - const command = defineCommand({ - args: { flags: { delete: flag.boolean({ brief: "Delete it" }) } }, - help: { summary: "Clashing probe" }, - handler: async () => - notOk(new CliStructuredError("CLI.INVALID_ARGUMENTS", "Unused.")), - }); - - expect(() => - createTestCli({ - commandFamilies: [ - defineCommandFamily({ - commands: { probe: command }, - statementVerbs: ["delete"], - }), - ], - commands: { probe: command }, - }), - ).toThrow( - "command 'probe' declares reserved flag 'delete' (the shared flag family is engine-injected)", - ); - }); - - test("a verb that is already a shared flag fails construction", () => { - const command = probe(async () => undefined); - - expect(() => - createTestCli({ - commandFamilies: [ - defineCommandFamily({ - commands: { probe: command }, - statementVerbs: ["confirm"], - }), - ], - commands: { probe: command }, - }), - ).toThrow("statement verb 'confirm' is already a shared flag"); - }); - - test("a verb that is not camelCase fails construction", () => { - const command = probe(async () => undefined); - - expect(() => - createTestCli({ - commandFamilies: [ - defineCommandFamily({ - commands: { probe: command }, - statementVerbs: ["drop-table"], - }), - ], - commands: { probe: command }, - }), - ).toThrow( - "statement verb 'drop-table' must be camelCase (it transliterates to --kebab-case on the CLI)", - ); - }); -}); From 3651bd329f7ca4a7d4557bf27492c12a194bb210 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:41:36 +0200 Subject: [PATCH 06/21] feat(engine): a statement flag takes its arity in values, and leftovers can fail before side effects The engine takes the routed command's statement flags out of argv before the parser sees it: each -- takes the declared arity in values, in argv order. A wrong count or an empty value is CLI.INVALID_ARGUMENTS. Answers carry the values as well as the text. statements(questions, { last: true }) fails with CLI.CONSENT_UNUSED as soon as the questions are answered, before the command acts. The unused error now says when a subject was asked but already answered by another flag. Construction errors: a verb that is not one lowercase word, an arity that is not a whole number of at least 1, a server command declaring statements, and a question with an empty subject, a subject containing ':', or a verb listed twice. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/commands.ts | 22 +- packages/cli-engine/src/context.ts | 51 ++-- .../cli-engine/src/execution/command-tree.ts | 16 +- packages/cli-engine/src/execution/engine.ts | 32 +- packages/cli-engine/src/execution/help.ts | 24 +- packages/cli-engine/src/execution/prompts.ts | 162 ++++++---- .../src/execution/statement-flags.ts | 190 ++++++++---- .../src/execution/stricli-adapter.ts | 6 - packages/cli-engine/src/exports/index.ts | 1 + .../cli-engine/tests/clack-prompts.test.ts | 2 +- .../tests/statement-declarations.test.ts | 61 +++- .../tests/statement-edge-cases.test.ts | 281 ++++++++++++++++++ .../tests/statement-flag-order.test.ts | 99 +++--- .../tests/statement-prompts.test.ts | 66 +++- 14 files changed, 773 insertions(+), 240 deletions(-) create mode 100644 packages/cli-engine/tests/statement-edge-cases.test.ts diff --git a/packages/cli-engine/src/commands.ts b/packages/cli-engine/src/commands.ts index 676b0e1f..6bf9a589 100644 --- a/packages/cli-engine/src/commands.ts +++ b/packages/cli-engine/src/commands.ts @@ -145,13 +145,15 @@ export interface SpawnDeclarations { /** * A statement the command may ask for with ctx.prompt.statement. Its - * key is the verb, and the command alone accepts `-- ` - * (repeatable) to answer it; the handler never sees those values. + * key is the verb, one lowercase word, and the command alone accepts + * `--` followed by `arity` values, repeatable, to answer it. The + * handler never sees those values. */ export interface StatementSpec { - /** How many values follow the flag. Only 1 is supported. */ - readonly arity: 1; - /** The flag's help brief. The engine writes a generic one when absent. */ + /** How many argv values each occurrence of the flag takes. Giving + * another number is CLI.INVALID_ARGUMENTS. */ + readonly arity: number; + /** The flag's help brief, held to the help standard like any flag's. */ readonly brief?: string; } @@ -212,8 +214,9 @@ export interface CommandDefinition< */ readonly installsPackages: TInstallsPackages; - /** The statements ctx.prompt.statement may ask, by verb. */ - readonly statements: Readonly>; + /** The statements ctx.prompt.statement may ask, by verb. Optional + * because a command built by an older engine has none. */ + readonly statements?: Readonly>; /** * The handler function, referenced directly — never a dynamic import @@ -434,6 +437,11 @@ export function defineServerCommand< readonly needs?: NeedsSpec; readonly handler: ServerCommandDefinition["handler"]; }): ServerCommandDefinition { + if (Object.hasOwn(def, "statements")) { + throw new Error( + "@prisma/cli-engine: a server command cannot declare statements (it never prompts)", + ); + } return Object.freeze({ kind: "server-command" as const, help: normalizeHelp(def.help), diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 4f8a4cfe..2c6cfaba 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -191,25 +191,10 @@ export interface BrowserWaitRequest { readonly interval?: number; } -/** - * Prompts. Every prompt resolves to its answered value directly. - * Failures THROW engine-internal structured errors the engine catches - * and settles: cancellation exits 3; a prompt that cannot be operated - * (no default under --yes or non-interactive, an invalid answer) exits - * 2. A handler that does not catch simply propagates; one that catches - * cannot swallow the settlement — rethrow or return notOk. - * - * Every prompt except `consent` may carry a declared `default`. Under - * --yes and in non-interactive contexts (no TTY stdin, CI, - * --no-interactive — format never decides interactivity) a prompt with - * a default resolves to it; one without a default throws. The prompt UI - * writes to stderr, so an interactive json run prompts without touching - * the stdout stream. - */ export interface StatementOptions { - /** What the answer is about, in the command's own vocabulary. It - * should not contain `:`, which separates it from the rest of a flag - * value. */ + /** What the answer is about, in the command's own vocabulary. + * Non-empty and without `:`, which separates it from the rest of a + * flag value; anything else is a construction error. */ readonly subject: string; /** The verbs that may answer, in the order a refusal lists them. */ readonly verbs: readonly V[]; @@ -229,9 +214,35 @@ export interface StatementQuestion export interface StatementAnswer { readonly verb: V; + /** The values joined by one space. */ readonly text: string; + /** The verb's `arity` values: from the flag, or split from the + * typed answer on whitespace. */ + readonly values: readonly string[]; +} + +export interface StatementsOptions { + /** This is the run's final ask: values still unconsumed once the + * questions are answered fail with CLI.CONSENT_UNUSED here, before + * the command acts on the answers. */ + readonly last?: boolean; } +/** + * Prompts. Every prompt resolves to its answered value directly. + * Failures THROW engine-internal structured errors the engine catches + * and settles: cancellation exits 3; a prompt that cannot be operated + * (no default under --yes or non-interactive, an invalid answer) exits + * 2. A handler that does not catch simply propagates; one that catches + * cannot swallow the settlement — rethrow or return notOk. + * + * Every prompt except `consent` and `statement` may carry a declared `default`. Under + * --yes and in non-interactive contexts (no TTY stdin, CI, + * --no-interactive — format never decides interactivity) a prompt with + * a default resolves to it; one without a default throws. The prompt UI + * writes to stderr, so an interactive json run prompts without touching + * the stdout stream. + */ export interface PromptSurface { readonly confirm: ( question: string, @@ -266,7 +277,8 @@ export interface PromptSurface { * Without such a flag a non-interactive run, or one under `--yes`, * fails with `CLI.CONSENT_REQUIRED`; an interactive run asks, and the * user answers ` `, or `` alone to mean the subject. - * Each verb must be one the command declares in `statements`. + * Each verb must be one the command declares in `statements`; the + * flag takes that declaration's `arity` values per occurrence. */ readonly statement: ( question: string, @@ -280,6 +292,7 @@ export interface PromptSurface { */ readonly statements: ( questions: readonly StatementQuestion[], + opts?: StatementsOptions, ) => Promise[]>; readonly select: ( question: string, diff --git a/packages/cli-engine/src/execution/command-tree.ts b/packages/cli-engine/src/execution/command-tree.ts index a3e6ced2..7f276484 100644 --- a/packages/cli-engine/src/execution/command-tree.ts +++ b/packages/cli-engine/src/execution/command-tree.ts @@ -10,13 +10,14 @@ import { reservedConfigSectionName } from "../config-loader"; import type { ConfigSection } from "../config-section"; import type { EngineSpec } from "./engine"; import { RESERVED_ALIASES, RESERVED_FLAG_NAMES } from "./shared-flags"; -import { declaredStatements } from "./statement-flags"; +import { declaredStatements, statementsOf } from "./statement-flags"; export function constructionError(message: string): Error { return new Error(`@prisma/cli-engine: ${message}`); } const CAMEL_CASE = /^[a-z][a-zA-Z0-9]*$/; +const ONE_LOWERCASE_WORD = /^[a-z]+$/; const INTEGER_LIKE = /^\d+$/; function validateFlags(path: string, def: AnyCommand): void { @@ -52,13 +53,10 @@ function validateFlags(path: string, def: AnyCommand): void { } function validateStatements(path: string, def: AnyCommand): void { - if (def.kind !== "result-command") { - return; - } - for (const [verb, spec] of Object.entries(def.statements)) { - if (!CAMEL_CASE.test(verb)) { + for (const [verb, spec] of Object.entries(statementsOf(def))) { + if (!ONE_LOWERCASE_WORD.test(verb)) { throw constructionError( - `command '${path}' statement '${verb}' must be camelCase (it transliterates to --kebab-case on the CLI)`, + `command '${path}' statement '${verb}' must be one lowercase word (it is both the flag and the word typed at the prompt)`, ); } if (RESERVED_FLAG_NAMES.has(verb)) { @@ -71,9 +69,9 @@ function validateStatements(path: string, def: AnyCommand): void { `command '${path}' declares both a flag and a statement named '${verb}'`, ); } - if (spec.arity !== 1) { + if (!Number.isInteger(spec.arity) || spec.arity < 1) { throw constructionError( - `command '${path}' statement '${verb}' declares arity ${spec.arity}; only 1 is supported`, + `command '${path}' statement '${verb}' declares arity ${spec.arity}; arity is a whole number of values, at least 1`, ); } } diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 14095686..ce81d9c6 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -74,9 +74,11 @@ import { recordSignalDuringSpawn, } from "./spawn"; import { - declaredStatements, + type DeclaredStatements, + extractStatementFlags, + routedCommand, type StatementFlagValue, - statementFlagValues, + statementsOf, } from "./statement-flags"; import { buildRoutes, @@ -152,8 +154,10 @@ export interface RunState { * token removes the value it matched, so one `--confirm` grants one * consent. */ confirmValues: string[]; - /** The statement verbs the mounted command declared. */ - statementVerbs: readonly string[]; + /** The statements the command argv routes to declares. */ + statements: DeclaredStatements; + /** Every subject a statement prompt asked about, answered or not. */ + askedSubjects: Set; /** Every statement-flag value, in argv order. A statement prompt * marks the value it consumed; one left unconsumed fails a run that * otherwise succeeded. */ @@ -349,7 +353,8 @@ export class EngineImpl implements Engine { logLevel: "info", yes: false, confirmValues: [], - statementVerbs: [], + statements: {}, + askedSubjects: new Set(), statementValues: [], interactive: defaultInteractive(runtime), /** Pre-parse resolution so a run that never mounts a command — an @@ -434,6 +439,15 @@ export class EngineImpl implements Engine { ); return 0; } + const routed = routedCommand(this.tree, argv); + state.statements = routed === undefined ? {} : statementsOf(routed.def); + const extraction = extractStatementFlags(argv, state.statements); + if (!extraction.ok) { + unsubscribe(); + settleErrored(invocation, extraction.error); + return 2; + } + state.statementValues = extraction.values; const stricliProcess = { /** stricli writes only help text here. In json mode stdout carries * exactly the frame stream, so help prose goes to stderr instead. */ @@ -460,7 +474,7 @@ export class EngineImpl implements Engine { localization: { text: capturingText(state) }, }); try { - await runStricli(app, [...argv], { + await runStricli(app, [...extraction.argv], { process: stricliProcess, forCommand: (info) => { state.prefix = info.prefix; @@ -668,12 +682,6 @@ export class EngineImpl implements Engine { let needsOutcome: NeedsOutcome; try { applySharedFlags(state, rawFlags as SharedFlags, invocation.runtime); - state.statementVerbs = declaredStatements(entry.def); - state.statementValues = statementFlagValues( - state.argv, - state.statementVerbs, - rawFlags, - ); needsOutcome = await checkNeeds(entry.def, invocation); } catch (cause) { // The child preflight can be awaiting the token endpoint when the diff --git a/packages/cli-engine/src/execution/help.ts b/packages/cli-engine/src/execution/help.ts index c6e6a289..d1aac3e2 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -20,7 +20,11 @@ import { renderHelpMarkdown } from "./markdown"; import { makePaint, type Paint, textWidth } from "./palette"; import { formatFlagGiven, withoutFormatFlags } from "./pre-parse-argv"; import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; -import { statementFlagParameter } from "./statement-flags"; +import { + statementBrief, + statementPlaceholders, + statementsOf, +} from "./statement-flags"; import { resolveExample } from "./stricli-adapter"; const RAIL = "│"; @@ -519,20 +523,10 @@ function sharedFlagRows(): readonly HelpRow[] { } function statementFlagRows(def: AnyCommand): readonly HelpRow[] { - if (def.kind !== "result-command") { - return []; - } - return Object.entries(def.statements).map(([verb, spec]) => { - const parameter = statementFlagParameter(verb, spec.brief); - return { - name: flagLabel(verb, { - placeholder: parameter.placeholder, - variadic: parameter.variadic, - }), - brief: parameter.brief, - suffix: undefined, - }; - }); + return Object.entries(statementsOf(def)).map(([verb, spec]) => ({ + name: ` --${verb} ${statementPlaceholders(spec)}...`, + brief: statementBrief(verb, spec), + })); } function declaredFlagRows(def: AnyCommand): readonly HelpRow[] { diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index 76ba71b1..d5aaabfc 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -25,11 +25,11 @@ * re-prompts, the line renderer fails structurally, because a scripted * or piped answer cannot be corrected. */ -import { kebabCase } from "../args"; import type { PromptSurface, StatementAnswer, StatementQuestion, + StatementsOptions, } from "../context"; import { CliStructuredError } from "../protocol"; import type { InputStream } from "../runtime"; @@ -41,9 +41,10 @@ import { import { constructionError } from "./command-tree"; import type { Invocation, RunState } from "./engine"; import { announceUrl } from "./open-url"; -import type { StatementFlagValue } from "./statement-flags"; +import type { DeclaredStatements, StatementFlagValue } from "./statement-flags"; const WHITESPACE = /\s/; +const WHITESPACES = /\s+/; /** How often browserWait asks whether the user has finished. */ const BROWSER_WAIT_POLL_INTERVAL_MS = 1000; @@ -59,22 +60,23 @@ function consumeConfirmValue(state: RunState, token: string): boolean { return true; } -function flagForm(verb: string, text: string): string { - return `--${kebabCase(verb)} ${text}`; +function flagForm(verb: string, values: readonly string[]): string { + return `--${verb} ${values.join(" ")}`; } -function namesSubject(text: string, subject: string): boolean { - return text === subject || text.startsWith(`${subject}:`); +function namesSubject(value: string, subject: string): boolean { + return value === subject || value.startsWith(`${subject}:`); } -function subjectOf(text: string): string { - const colon = text.indexOf(":"); - return colon === -1 ? text : text.slice(0, colon); +function subjectOf(value: string): string { + const colon = value.indexOf(":"); + return colon === -1 ? value : value.slice(0, colon); } -/** The first unconsumed verb-flag value naming the subject, tried verb - * by verb. A value the command rejects fails the run: a wrong flag - * cannot be corrected by asking again. */ +/** The first unconsumed statement-flag value naming the subject, tried + * verb by verb; its first value is the one that names it. A value the + * command rejects fails the run: a wrong flag cannot be corrected by + * asking again. */ function answerFromFlags( state: RunState, question: StatementQuestion, @@ -84,20 +86,21 @@ function answerFromFlags( (candidate) => !candidate.consumed && candidate.verb === verb && - namesSubject(candidate.text, question.subject), + namesSubject(candidate.values[0], question.subject), ); if (value === undefined) { continue; } - const rejection = question.validate(verb, value.text); + const text = value.values.join(" "); + const rejection = question.validate(verb, text); if (rejection !== undefined) { throw new CliStructuredError( "CLI.PROMPT_INVALID", - `${flagForm(verb, value.text)} does not answer "${question.question}": ${rejection}`, + `${flagForm(verb, value.values)} does not answer "${question.question}": ${rejection}`, ); } value.consumed = true; - return { verb, text: value.text }; + return { verb, text, values: value.values }; } return undefined; } @@ -120,7 +123,7 @@ function statementUnavailable( nextActions: unanswered.flatMap((question) => question.verbs.map((verb) => ({ kind: "user-choice" as const, - label: `Pass ${flagForm(verb, question.forms?.[verb] ?? question.subject)}`, + label: `Pass ${flagForm(verb, [question.forms?.[verb] ?? question.subject])}`, })), ), meta: @@ -135,10 +138,12 @@ type ParsedStatement = | { readonly problem: string }; /** An interactive answer: ` `, or `` alone to mean - * the subject. */ + * the subject. A verb taking several values reads them from the text, + * separated by whitespace. */ function parseStatement( raw: string, question: StatementQuestion, + state: RunState, ): ParsedStatement { const trimmed = raw.trim(); const space = trimmed.search(WHITESPACE); @@ -151,39 +156,79 @@ function parseStatement( }; } const text = rest === "" ? question.subject : rest; + const { arity } = state.statements[verb]; + const values = arity === 1 ? [text] : text.split(WHITESPACES); + if (values.length !== arity) { + return { problem: `Give ${arity} values after ${verb}.` }; + } const rejection = question.validate(verb, text); return rejection === undefined - ? { answer: { verb, text } } + ? { answer: { verb, text, values } } : { problem: rejection }; } -/** Fails a run that otherwise succeeded when a verb flag answered - * nothing: a mistyped subject must not pass silently. */ +/** What makes a question one the command could never have meant to + * ask, if anything. */ +function malformation( + question: StatementQuestion, + declared: DeclaredStatements, +): string | undefined { + const about = `about '${question.subject}'`; + if (question.subject === "" || question.subject.includes(":")) { + return `${about}: a subject must be non-empty and contain no ':'`; + } + if (question.verbs.length === 0) { + return `${about} with no verbs`; + } + if (new Set(question.verbs).size !== question.verbs.length) { + return `${about} listing a verb twice`; + } + const undeclared = question.verbs.find( + (verb) => !Object.hasOwn(declared, verb), + ); + return undeclared === undefined + ? undefined + : `with verb '${undeclared}', which it does not declare in statements`; +} + +function unusedSentence(state: RunState, value: StatementFlagValue): string { + const given = flagForm(value.verb, value.values); + const subject = subjectOf(value.values[0]); + return state.askedSubjects.has(subject) + ? `${given} was given, but the question about ${subject} was already answered by another flag.` + : `${given} was given but nothing in this run asked about ${subject}.`; +} + +/** Fails a run when a statement flag answered nothing: a mistyped + * subject, or a second answer to one question, must not pass + * silently. */ export function unusedStatementValuesError( state: RunState, ): CliStructuredError | undefined { - const unused: readonly StatementFlagValue[] = state.statementValues.filter( - (value) => !value.consumed, - ); + const unused = state.statementValues.filter((value) => !value.consumed); if (unused.length === 0) { return undefined; } - const given = unused.map((value) => flagForm(value.verb, value.text)); - const subjects = [...new Set(unused.map((value) => subjectOf(value.text)))]; - const summary = - unused.length === 1 - ? `${given[0]} was given but nothing in this run asked about ${subjects[0]}.` - : `${given.join(", ")} were given but nothing in this run asked about ${subjects.join(", ")}.`; - return new CliStructuredError("CLI.CONSENT_UNUSED", summary, { - nextActions: [ - { - kind: "user-choice", - label: - "Remove the flag, or spell the subject the way the command names it.", + const allAsked = unused.every((value) => + state.askedSubjects.has(subjectOf(value.values[0])), + ); + return new CliStructuredError( + "CLI.CONSENT_UNUSED", + unused.map((value) => unusedSentence(state, value)).join(" "), + { + nextActions: [ + { + kind: "user-choice", + label: allAsked + ? "Give one flag per question." + : "Remove the flag, or spell the subject the way the command names it.", + }, + ], + meta: { + unused: unused.map(({ verb, values }) => ({ verb, values })), }, - ], - meta: { unused: unused.map(({ verb, text }) => ({ verb, text })) }, - }); + }, + ); } function makeLineReader( @@ -528,11 +573,11 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { if (useClack()) { const raw = await renderWithClack(question.question, (r) => r.statement(question.question, question.verbs.join(" or "), (value) => { - const parsed = parseStatement(value, question); + const parsed = parseStatement(value, question, state); return "problem" in parsed ? parsed.problem : undefined; }), ); - const parsed = parseStatement(raw, question); + const parsed = parseStatement(raw, question, state); if ("problem" in parsed) { throw promptInvalid(question.question, raw); } @@ -545,7 +590,7 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { if (typeof raw !== "string") { throw promptInvalid(question.question, String(raw)); } - const parsed = parseStatement(raw, question); + const parsed = parseStatement(raw, question, state); if ("problem" in parsed) { throw new CliStructuredError( "CLI.PROMPT_INVALID", @@ -555,22 +600,16 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { return parsed.answer; }; - const requireRegisteredVerbs = ( + const requireWellFormed = ( questions: readonly StatementQuestion[], ): void => { for (const question of questions) { - if (question.verbs.length === 0) { + const problem = malformation(question, state.statements); + if (problem !== undefined) { throw constructionError( - `command '${state.commandId}' asked a statement about '${question.subject}' with no verbs`, + `command '${state.commandId}' asked a statement ${problem}`, ); } - for (const verb of question.verbs) { - if (!state.statementVerbs.includes(verb)) { - throw constructionError( - `command '${state.commandId}' asked a statement with verb '${verb}', which it does not declare in statements`, - ); - } - } } }; @@ -578,8 +617,12 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { * unanswered at once; the rest are asked one after another. */ const statements = async ( questions: readonly StatementQuestion[], + opts: StatementsOptions | undefined, ): Promise[]> => { - requireRegisteredVerbs(questions); + requireWellFormed(questions); + for (const question of questions) { + state.askedSubjects.add(question.subject); + } const fromFlags = questions.map((question) => answerFromFlags(state, question), ); @@ -596,7 +639,13 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { const answer = fromFlags[index] ?? (await askStatement(questions[index])); return [answer, ...(await answerFrom(index + 1))]; }; - return answerFrom(0); + const answers = await answerFrom(0); + const unused = + opts?.last === true ? unusedStatementValuesError(state) : undefined; + if (unused !== undefined) { + throw unused; + } + return answers; }; /** A prompt writes to stderr and reads the engine's stdin — the same @@ -719,11 +768,12 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { text: (question, opts) => claimTerminal(() => surface.text(question, opts)), statement: async (question, opts) => { const [answer] = await claimTerminal(() => - statements([{ question, ...opts }]), + statements([{ question, ...opts }], undefined), ); return answer; }, - statements: (questions) => claimTerminal(() => statements(questions)), + statements: (questions, opts) => + claimTerminal(() => statements(questions, opts)), browserWait: (request) => claimTerminal(() => surface.browserWait(request)), }; } diff --git a/packages/cli-engine/src/execution/statement-flags.ts b/packages/cli-engine/src/execution/statement-flags.ts index 586ed5d8..0b3cdbad 100644 --- a/packages/cli-engine/src/execution/statement-flags.ts +++ b/packages/cli-engine/src/execution/statement-flags.ts @@ -1,81 +1,167 @@ -import { camelCase } from "../args"; -import type { AnyCommand } from "../commands"; -import { flagTokens } from "./pre-parse-argv"; +/** + * Statement flags: `--` followed by the verb's `arity` values, + * repeatable, accepted only by the command that declares the verb. The + * engine takes them out of argv before the parser sees it, because the + * parser gives a flag one value per occurrence and keeps no order + * across flags. Handlers never see them; ctx.prompt.statement hands + * them out. + */ +import type { AnyCommand, StatementSpec } from "../commands"; +import { CliStructuredError } from "../protocol"; +import type { CommandTreeEntry, CommandTreeNode } from "./command-tree"; + +export type DeclaredStatements = Readonly>; + +export function statementsOf(def: AnyCommand): DeclaredStatements { + return def.kind === "result-command" ? (def.statements ?? {}) : {}; +} -/** The statement verbs a command declared; only result commands can. */ export function declaredStatements(def: AnyCommand): readonly string[] { - return def.kind === "result-command" ? Object.keys(def.statements) : []; + return Object.keys(statementsOf(def)); +} + +/** How help shows the flag: `` for the first value, `` + * for each further one. */ +export function statementPlaceholders(spec: StatementSpec): string { + return [ + "", + ...Array.from({ length: spec.arity - 1 }, () => ""), + ].join(" "); } -/** The parser's view of a statement flag: repeatable, one value each, - * never required. */ -export function statementFlagParameter(verb: string, brief?: string) { - return { - kind: "parsed", - parse: (input: string) => input, - placeholder: "subject", - variadic: true, - optional: true, - brief: - brief ?? - `Say what happens to : ${verb}, instead of being asked (repeatable)`, - } as const; +export function statementBrief(verb: string, spec: StatementSpec): string { + return ( + spec.brief ?? + `Say what happens to : ${verb}, instead of being asked (repeatable)` + ); } export interface StatementFlagValue { readonly verb: string; - readonly text: string; + /** The `arity` values the flag was given, in order. */ + readonly values: readonly string[]; consumed: boolean; } -function verbFlagIn( +/** The command argv routes to, found the way the parser routes: the + * leading words, group by group, until one names a command. */ +export function routedCommand( + tree: CommandTreeNode, + argv: readonly string[], +): CommandTreeEntry | undefined { + let node = tree; + for (const token of argv) { + const entry = node.commands.get(token); + if (entry !== undefined) { + return entry; + } + const child = node.children.get(token); + if (child === undefined) { + return undefined; + } + node = child; + } + return undefined; +} + +function statementFlagIn( token: string, - verbs: readonly string[], -): string | undefined { + statements: DeclaredStatements, +): { readonly verb: string; readonly inline: string | undefined } | undefined { if (!token.startsWith("--")) { return undefined; } const equals = token.indexOf("="); - if (equals === token.length - 1) { + const verb = token.slice(2, equals === -1 ? undefined : equals); + if (!Object.hasOwn(statements, verb)) { return undefined; } - const name = camelCase(token.slice(2, equals === -1 ? undefined : equals)); - return verbs.includes(name) ? name : undefined; + return { verb, inline: equals === -1 ? undefined : token.slice(equals + 1) }; +} + +function wrongValueCount( + verb: string, + arity: number, + given: number, +): CliStructuredError { + const wanted = arity === 1 ? "a value" : `${arity} values`; + return new CliStructuredError( + "CLI.INVALID_ARGUMENTS", + `--${verb} needs ${wanted}, and was given ${given}.`, + { + nextActions: [ + { + kind: "user-choice", + label: `Pass --${verb} followed by ${wanted}.`, + }, + ], + }, + ); } -function parsedValues(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; +function emptyValue(verb: string): CliStructuredError { + return new CliStructuredError( + "CLI.INVALID_ARGUMENTS", + `--${verb} was given an empty value.`, + { + nextActions: [ + { kind: "user-choice", label: `Name what --${verb} is about.` }, + ], + }, + ); } +export type StatementExtraction = + | { + readonly ok: true; + readonly argv: readonly string[]; + readonly values: StatementFlagValue[]; + } + | { readonly ok: false; readonly error: CliStructuredError }; + /** - * Every statement-flag value, in the order argv gave them. The parser - * groups values by flag, so argv decides only which verb comes next; - * the values themselves are the parser's. A parsed value argv could - * not place is appended rather than dropped, so it is still reported - * if nothing consumes it. + * Takes the command's statement flags out of argv, keeping their + * values in the order argv gave them. A value is any following token + * that is not a flag; nothing after a bare `--` is a flag. */ -export function statementFlagValues( +export function extractStatementFlags( argv: readonly string[], - verbs: readonly string[], - parsedFlags: Readonly>, -): StatementFlagValue[] { - const remaining = new Map( - verbs.map((verb) => [verb, parsedValues(parsedFlags[verb])]), - ); - const ordered: StatementFlagValue[] = []; - for (const token of flagTokens(argv)) { - const verb = verbFlagIn(token, verbs); - const text = verb === undefined ? undefined : remaining.get(verb)?.shift(); - if (verb !== undefined && text !== undefined) { - ordered.push({ verb, text, consumed: false }); + statements: DeclaredStatements, +): StatementExtraction { + const terminator = argv.indexOf("--"); + const tokens = terminator === -1 ? argv : argv.slice(0, terminator); + const rest = terminator === -1 ? [] : argv.slice(terminator); + const kept: string[] = []; + const values: StatementFlagValue[] = []; + let index = 0; + while (index < tokens.length) { + const token = tokens[index]; + index += 1; + const flag = statementFlagIn(token, statements); + if (flag === undefined) { + kept.push(token); + continue; } - } - for (const [verb, texts] of remaining) { - for (const text of texts) { - ordered.push({ verb, text, consumed: false }); + const { arity } = statements[flag.verb]; + const given = flag.inline === undefined ? [] : [flag.inline]; + while ( + given.length < arity && + index < tokens.length && + !tokens[index].startsWith("-") + ) { + given.push(tokens[index]); + index += 1; + } + if (given.length !== arity) { + return { + ok: false, + error: wrongValueCount(flag.verb, arity, given.length), + }; + } + if (given.some((value) => value.trim() === "")) { + return { ok: false, error: emptyValue(flag.verb) }; } + values.push({ verb: flag.verb, values: given, consumed: false }); } - return ordered; + return { ok: true, argv: [...kept, ...rest], values }; } diff --git a/packages/cli-engine/src/execution/stricli-adapter.ts b/packages/cli-engine/src/execution/stricli-adapter.ts index 5121d541..8fb440d1 100644 --- a/packages/cli-engine/src/execution/stricli-adapter.ts +++ b/packages/cli-engine/src/execution/stricli-adapter.ts @@ -26,7 +26,6 @@ import type { AnyCommand } from "../commands"; import type { CommandTreeEntry, CommandTreeNode } from "./command-tree"; import type { EngineSpec, Invocation, RunState } from "./engine"; import { SHARED_ALIASES, SHARED_FLAG_PARAMETERS } from "./shared-flags"; -import { statementFlagParameter } from "./statement-flags"; export interface EngineRunContext extends StricliBaseContext { readonly invocation: Invocation; @@ -158,11 +157,6 @@ function commandParameters(def: AnyCommand): Record { aliases[runtime.alias] = key; } } - if (def.kind === "result-command") { - for (const [verb, spec] of Object.entries(def.statements)) { - declaredFlags[verb] = statementFlagParameter(verb, spec.brief); - } - } const injectShared = def.kind !== "server-command"; const positionalEntries = Object.entries>( def.args.positionals, diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index 1e6efc27..aec04920 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -78,6 +78,7 @@ export type { StatementAnswer, StatementOptions, StatementQuestion, + StatementsOptions, } from "../context"; export { authServiceError, diff --git a/packages/cli-engine/tests/clack-prompts.test.ts b/packages/cli-engine/tests/clack-prompts.test.ts index e2924d22..4eb71e85 100644 --- a/packages/cli-engine/tests/clack-prompts.test.ts +++ b/packages/cli-engine/tests/clack-prompts.test.ts @@ -307,7 +307,7 @@ describe("the clack tier resolves prompt values", () => { expect(result.exitCode).toBe(0); expect(answerIn(result.plainStderr)).toBe( - '{"verb":"rename","text":"Legacy:Archive"}', + '{"verb":"rename","text":"Legacy:Archive","values":["Legacy:Archive"]}', ); expect(result.plainStderr).toContain( "Start the answer with rename or delete.", diff --git a/packages/cli-engine/tests/statement-declarations.test.ts b/packages/cli-engine/tests/statement-declarations.test.ts index 4a0943bc..9e225fb1 100644 --- a/packages/cli-engine/tests/statement-declarations.test.ts +++ b/packages/cli-engine/tests/statement-declarations.test.ts @@ -5,6 +5,8 @@ */ import { defineCommand, + defineCommandFamily, + defineServerCommand, flag, type PromptSurface, type StatementSpec, @@ -73,7 +75,7 @@ describe("a statement flag belongs to the command that declares it", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ flags: { name: undefined }, - answer: { verb: "delete", text: "Legacy" }, + answer: { verb: "delete", text: "Legacy", values: ["Legacy"] }, }); }); @@ -89,9 +91,21 @@ describe("a statement flag belongs to the command that declares it", () => { const declaring = await cli.run(["probe", "--help", "--format", "human"]); const other = await cli.run(["other", "--help", "--format", "human"]); + const root = await cli.run(["--help", "--format", "human"]); + expect(declaring.stdout).toContain("--delete ..."); expect(declaring.stdout).toContain("Drop the table and its rows"); expect(other.stdout).not.toContain("--delete"); + expect(root.stdout).not.toContain("--delete"); + }); + + test("help shows each further value of a verb with a larger arity", async () => { + const cli = createTestCli({ + commands: { probe: command({ move: { arity: 2 } }) }, + }); + const result = await cli.run(["probe", "--help", "--format", "human"]); + + expect(result.stdout).toContain("--move ..."); }); test("a statement naming a verb the command did not declare is a construction error", async () => { @@ -128,9 +142,24 @@ describe("declarations that fail construction", () => { "command 'probe' declares statement 'confirm', which is a shared flag", ], [ - "a name that is not camelCase", + "a kebab-case name", { "drop-table": { arity: 1 } }, - "command 'probe' statement 'drop-table' must be camelCase (it transliterates to --kebab-case on the CLI)", + "command 'probe' statement 'drop-table' must be one lowercase word", + ], + [ + "a camelCase name", + { dropTable: { arity: 1 } }, + "command 'probe' statement 'dropTable' must be one lowercase word", + ], + [ + "an arity of 0", + { rename: { arity: 0 } }, + "command 'probe' statement 'rename' declares arity 0; arity is a whole number of values, at least 1", + ], + [ + "a fractional arity", + { rename: { arity: 1.5 } }, + "command 'probe' statement 'rename' declares arity 1.5", ], ]; @@ -140,13 +169,31 @@ describe("declarations that fail construction", () => { ).toThrow(message); }); - test("an arity other than 1", () => { - const pair = { arity: 2 } as unknown as StatementSpec; + test("a flag redirect naming a statement the command still accepts", () => { + const probe = command(DELETE); + const family = defineCommandFamily({ + commands: { probe }, + redirects: [ + { from: "probe", flag: "delete", replacement: "probe --drop" }, + ], + }); expect(() => - createTestCli({ commands: { probe: command({ rename: pair }) } }), + createTestCli({ commandFamilies: [family], commands: { probe } }), ).toThrow( - "command 'probe' statement 'rename' declares arity 2; only 1 is supported", + "redirect for flag 'delete' on 'probe' names a flag that command still accepts", + ); + }); + + test("a server command declaring statements", () => { + const spec = { + help: { summary: "Server probe" }, + statements: DELETE, + handler: async () => 0, + }; + + expect(() => defineServerCommand(spec)).toThrow( + "a server command cannot declare statements", ); }); }); diff --git a/packages/cli-engine/tests/statement-edge-cases.test.ts b/packages/cli-engine/tests/statement-edge-cases.test.ts new file mode 100644 index 00000000..d28a6020 --- /dev/null +++ b/packages/cli-engine/tests/statement-edge-cases.test.ts @@ -0,0 +1,281 @@ +/** + * Statement prompts at their edges: the final ask, runs that end with + * a child's status, malformed questions, verbs with several values, + * and what reaches stderr, stdout and telemetry. + */ +import { + defineCommand, + exitWithChildStatus, + type PromptSurface, + type RunSummary, + type StatementSpec, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +const VERBS = { rename: { arity: 1 }, delete: { arity: 1 } } as const; + +function probe( + ask: (prompt: PromptSurface) => Promise, + statements: Readonly> = VERBS, +) { + return defineCommand({ + help: { summary: "Statement probe" }, + statements, + handler: async (_args, ctx) => { + const answer = await ask(ctx.prompt); + return ok( + ctx.present( + { data: { answer } }, + { + human: () => [], + stdout: () => [], + json: () => ({ answer }), + next: () => [], + }, + ), + ); + }, + }); +} + +function cliWith( + ask: (prompt: PromptSurface) => Promise, + statements?: Readonly>, +) { + return createTestCli({ commands: { probe: probe(ask, statements) } }); +} + +function errorOf(result: Awaited>) { + const last = result.json[result.json.length - 1]; + return last.kind === "result" && !last.envelope.ok + ? last.envelope.error + : undefined; +} + +function legacy(subject = "Legacy") { + return { + question: `What happens to ${subject}?`, + subject, + verbs: ["rename", "delete"], + validate: () => undefined, + } as const; +} + +describe("statements(questions, { last: true })", () => { + const acted: string[] = []; + const finalAsk = async (prompt: PromptSurface) => { + const answers = await prompt.statements([legacy()], { last: true }); + acted.push("applied"); + return answers; + }; + + test("an unconsumed value fails before the command acts on the answers", async () => { + acted.length = 0; + const result = await cliWith(finalAsk).run([ + "probe", + "--delete", + "Legacy", + "--delete", + "Other", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.summary).toBe( + "--delete Other was given but nothing in this run asked about Other.", + ); + expect(acted).toEqual([]); + }); + + test("with nothing left over the command goes on", async () => { + acted.length = 0; + const result = await cliWith(finalAsk).run(["probe", "--delete", "Legacy"]); + + expect(result.exitCode).toBe(0); + expect(acted).toEqual(["applied"]); + }); +}); + +describe("a run that ends with a child's status", () => { + const spawning = defineCommand({ + help: { summary: "Spawning probe" }, + maySpawn: true, + statements: VERBS, + handler: async (_args, ctx) => { + await ctx.spawn({ command: "child" }); + return ok(exitWithChildStatus()); + }, + }); + + test("a child that exited 0 leaves the unused value to fail the run", async () => { + const cli = createTestCli({ + commands: { probe: spawning }, + spawnScript: () => ({ exitCode: 0, signal: null }), + }); + const result = await cli.run(["probe", "--delete", "X", "--json"]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.code).toBe("CLI.CONSENT_UNUSED"); + }); + + test("a child that failed reports its own status only", async () => { + const cli = createTestCli({ + commands: { probe: spawning }, + spawnScript: () => ({ exitCode: 4, signal: null }), + }); + const result = await cli.run(["probe", "--delete", "X", "--json"]); + + expect(result.exitCode).toBe(4); + expect(errorOf(result)?.code).toBe("CLI.CHILD_PROCESS_FAILED"); + }); +}); + +describe("a malformed question is a construction error", () => { + test.each([ + [ + "no verbs", + { ...legacy(), verbs: [] }, + "asked a statement about 'Legacy' with no verbs", + ], + [ + "a verb listed twice", + { ...legacy(), verbs: ["delete", "delete"] }, + "asked a statement about 'Legacy' listing a verb twice", + ], + [ + "a subject containing ':'", + legacy("Legacy:Archive"), + "a subject must be non-empty and contain no ':'", + ], + [ + "an empty subject", + legacy(""), + "a subject must be non-empty and contain no ':'", + ], + ])("%s", async (_case, question, message) => { + const result = await cliWith((prompt) => prompt.statements([question])).run( + ["probe", "--json"], + ); + + expect(result.exitCode).toBe(1); + expect(errorOf(result)?.summary).toContain(message); + }); +}); + +describe("a verb with several values", () => { + const MOVE = { move: { arity: 2 } }; + const askMove = (prompt: PromptSurface) => + prompt.statement("Where does Legacy go?", { + subject: "Legacy", + verbs: ["move"], + validate: () => undefined, + }); + + test("the flag takes that many values", async () => { + const result = await cliWith(askMove, MOVE).run([ + "probe", + "--move", + "Legacy", + "archive", + ]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { + verb: "move", + text: "Legacy archive", + values: ["Legacy", "archive"], + }, + }); + }); + + test("too few values is an argument error", async () => { + const result = await cliWith(askMove, MOVE).run([ + "probe", + "--move", + "Legacy", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)).toMatchObject({ + code: "CLI.INVALID_ARGUMENTS", + summary: "--move needs 2 values, and was given 1.", + }); + }); + + test("an interactive answer gives them separated by spaces", async () => { + const result = await cliWith(askMove, MOVE).run(["probe"], { + isTty: { stdin: true }, + answers: ["move Legacy archive"], + }); + + expect(result.presented?.data).toEqual({ + answer: { + verb: "move", + text: "Legacy archive", + values: ["Legacy", "archive"], + }, + }); + }); + + test("an interactive answer with the wrong count fails the line renderer", async () => { + const result = await cliWith(askMove, MOVE).run(["probe", "--json"], { + isTty: { stdin: true }, + answers: ["move Legacy"], + }); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)?.summary).toBe( + '"move Legacy" is not a valid answer to "Where does Legacy go?": Give 2 values after move.', + ); + }); +}); + +describe("what each channel sees", () => { + const askLegacy = (prompt: PromptSurface) => prompt.statements([legacy()]); + + test("an empty value is an argument error", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--delete", + "", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)).toMatchObject({ + code: "CLI.INVALID_ARGUMENTS", + summary: "--delete was given an empty value.", + }); + }); + + test("a json run on a terminal prompts on stderr and keeps stdout to frames", async () => { + const result = await cliWith(askLegacy).run(["probe", "--json"], { + isTty: { stdin: true }, + stdin: "delete\n", + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe("? What happens to Legacy? (rename/delete) "); + for (const line of result.stdout.trim().split("\n")) { + expect(() => JSON.parse(line)).not.toThrow(); + } + }); + + test("telemetry records the flag's name and never its value", async () => { + const summaries: RunSummary[] = []; + await cliWith(async () => undefined).run( + ["probe", "--delete", "SecretTable", "--json"], + { onSettled: (summary) => summaries.push(summary) }, + ); + + expect(summaries[0].snapshot.flags).toContainEqual({ + name: "delete", + source: "cli", + }); + expect(JSON.stringify(summaries)).not.toContain("SecretTable"); + }); +}); diff --git a/packages/cli-engine/tests/statement-flag-order.test.ts b/packages/cli-engine/tests/statement-flag-order.test.ts index 56667e33..1a189464 100644 --- a/packages/cli-engine/tests/statement-flag-order.test.ts +++ b/packages/cli-engine/tests/statement-flag-order.test.ts @@ -1,60 +1,75 @@ import { describe, expect, test } from "vitest"; -import { statementFlagValues } from "../src/execution/statement-flags"; +import { extractStatementFlags } from "../src/execution/statement-flags"; -const VERBS = ["rename", "delete", "dropColumn"]; +const STATEMENTS = { + rename: { arity: 1 }, + delete: { arity: 1 }, + move: { arity: 2 }, +}; -function ordered(argv: readonly string[], parsed: Record) { - return statementFlagValues(argv, VERBS, parsed).map( - ({ verb, text }) => `${verb} ${text}`, - ); +function extracted(argv: readonly string[]) { + const result = extractStatementFlags(argv, STATEMENTS); + return result.ok + ? { + argv: result.argv, + values: result.values.map( + ({ verb, values }) => `${verb} ${values.join(" ")}`, + ), + } + : { error: result.error.message }; } -describe("verb-flag values keep their argv order across flags", () => { +describe("statement flags come out of argv in the order given", () => { test("values of different verbs interleave as written", () => { expect( - ordered( - [ - "migration", - "plan", - "--rename", - "A:B", - "--delete", - "C", - "--rename=D:E", - ], - { rename: ["A:B", "D:E"], delete: ["C"] }, - ), - ).toEqual(["rename A:B", "delete C", "rename D:E"]); + extracted([ + "migration", + "plan", + "--rename", + "A:B", + "--delete", + "C", + "--rename=D:E", + "--json", + ]), + ).toEqual({ + argv: ["migration", "plan", "--json"], + values: ["rename A:B", "delete C", "rename D:E"], + }); }); - test("kebab-case spellings of a camelCase verb count", () => { - expect( - ordered(["--drop-column", "User.name", "--delete", "Legacy"], { - dropColumn: ["User.name"], - delete: ["Legacy"], - }), - ).toEqual(["dropColumn User.name", "delete Legacy"]); + test("a verb takes its arity in values per occurrence", () => { + expect(extracted(["--move", "A", "B", "positional"])).toEqual({ + argv: ["positional"], + values: ["move A B"], + }); }); test("nothing after a bare -- is a flag", () => { - expect( - ordered(["--delete", "Legacy", "--", "--rename", "X:Y"], { - delete: ["Legacy"], - }), - ).toEqual(["delete Legacy"]); + expect(extracted(["--delete", "Legacy", "--", "--rename", "X:Y"])).toEqual({ + argv: ["--", "--rename", "X:Y"], + values: ["delete Legacy"], + }); }); - test("every parsed value is kept even when argv cannot place it", () => { - expect( - ordered(["--delete=Legacy"], { delete: ["Legacy", "Other"] }), - ).toEqual(["delete Legacy", "delete Other"]); + test("an undeclared flag is left for the parser", () => { + expect(extracted(["--drop", "Legacy"])).toEqual({ + argv: ["--drop", "Legacy"], + values: [], + }); }); +}); - test("every value starts unconsumed", () => { - expect( - statementFlagValues(["--delete", "Legacy"], VERBS, { - delete: ["Legacy"], - }), - ).toEqual([{ verb: "delete", text: "Legacy", consumed: false }]); +describe("a wrong value count is an argument error", () => { + test.each([ + [["--delete"], "--delete needs a value, and was given 0."], + [["--delete", "--json"], "--delete needs a value, and was given 0."], + [["--move", "A"], "--move needs 2 values, and was given 1."], + [["--move", "A", "--json"], "--move needs 2 values, and was given 1."], + [["--delete", ""], "--delete was given an empty value."], + [["--delete", " "], "--delete was given an empty value."], + [["--delete="], "--delete was given an empty value."], + ])("%j", (argv, error) => { + expect(extracted(argv)).toEqual({ error }); }); }); diff --git a/packages/cli-engine/tests/statement-prompts.test.ts b/packages/cli-engine/tests/statement-prompts.test.ts index 8b2fcb61..3427aed5 100644 --- a/packages/cli-engine/tests/statement-prompts.test.ts +++ b/packages/cli-engine/tests/statement-prompts.test.ts @@ -101,9 +101,11 @@ describe("a verb flag answers the statement", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ - answer: { verb: "delete", text: "Legacy" }, + answer: { verb: "delete", text: "Legacy", values: ["Legacy"] }, }); - expect(result.stderr).toBe('✔ answer={"verb":"delete","text":"Legacy"}\n'); + expect(result.stderr).toBe( + '✔ answer={"verb":"delete","text":"Legacy","values":["Legacy"]}\n', + ); }); test("a value that starts with the subject and a colon names it", async () => { @@ -114,7 +116,11 @@ describe("a verb flag answers the statement", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ - answer: { verb: "rename", text: "Legacy:Archive" }, + answer: { + verb: "rename", + text: "Legacy:Archive", + values: ["Legacy:Archive"], + }, }); }); @@ -126,7 +132,7 @@ describe("a verb flag answers the statement", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ - answer: { verb: "delete", text: "Legacy" }, + answer: { verb: "delete", text: "Legacy", values: ["Legacy"] }, }); expect(result.stderr).toBe(""); }); @@ -226,7 +232,7 @@ describe("an interactive run asks", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ - answer: { verb: "delete", text: "Legacy" }, + answer: { verb: "delete", text: "Legacy", values: ["Legacy"] }, }); }); @@ -238,7 +244,11 @@ describe("an interactive run asks", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ - answer: { verb: "rename", text: "Legacy:Archive" }, + answer: { + verb: "rename", + text: "Legacy:Archive", + values: ["Legacy:Archive"], + }, }); }); @@ -339,8 +349,8 @@ describe("prompt.statements asks several questions together", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ answer: [ - { verb: "rename", text: "Legacy:Archive" }, - { verb: "delete", text: "User.name" }, + { verb: "rename", text: "Legacy:Archive", values: ["Legacy:Archive"] }, + { verb: "delete", text: "User.name", values: ["User.name"] }, ], }); }); @@ -354,8 +364,8 @@ describe("prompt.statements asks several questions together", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ answer: [ - { verb: "rename", text: "Legacy:Archive" }, - { verb: "delete", text: "User.name" }, + { verb: "rename", text: "Legacy:Archive", values: ["Legacy:Archive"] }, + { verb: "delete", text: "User.name", values: ["User.name"] }, ], }); expect(result.stderr.indexOf(LEGACY_QUESTION)).toBeLessThan( @@ -372,8 +382,12 @@ describe("prompt.statements asks several questions together", () => { expect(result.exitCode).toBe(0); expect(result.presented?.data).toEqual({ answer: [ - { verb: "delete", text: "Legacy" }, - { verb: "rename", text: "User.name:fullName" }, + { verb: "delete", text: "Legacy", values: ["Legacy"] }, + { + verb: "rename", + text: "User.name:fullName", + values: ["User.name:fullName"], + }, ], }); expect(result.stderr).not.toContain(LEGACY_QUESTION); @@ -418,7 +432,9 @@ describe("a verb-flag value nothing consumed", () => { "Remove the flag, or spell the subject the way the command names it.", }, ], - meta: { unused: [{ verb: "delete", text: "Lagacy" }] }, + meta: { + unused: [{ verb: "delete", values: ["Lagacy"] }], + }, }); }); @@ -447,7 +463,29 @@ describe("a verb-flag value nothing consumed", () => { "--json", ]); - expect(errorOf(result)?.code).toBe("CLI.CONSENT_UNUSED"); + expect(errorOf(result)).toMatchObject({ + code: "CLI.CONSENT_UNUSED", + summary: + "--delete Legacy was given, but the question about Legacy was already answered by another flag.", + nextActions: [ + { kind: "user-choice", label: "Give one flag per question." }, + ], + }); + }); + + test("a second verb for an answered subject is reported as already answered", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--delete", + "Legacy", + "--rename", + "Legacy:Archive", + "--json", + ]); + + expect(errorOf(result)?.summary).toBe( + "--delete Legacy was given, but the question about Legacy was already answered by another flag.", + ); }); test("a run that failed for another reason reports that reason only", async () => { From 1eaddbcedde38caecd42216e768652eb8532d519 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:42:07 +0200 Subject: [PATCH 07/21] docs: statements are declared per command, with arity, and { last: true } rejects leftovers early Signed-off-by: willbot Signed-off-by: Will Madden --- docs/product/cli-style-guide.md | 3 +-- docs/reference/error-reference.md | 6 +++--- packages/cli-engine/README.md | 29 +++++++++++++++++++++++------ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index 0988a47f..39c10377 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -182,7 +182,6 @@ Shared global flags, defined by the engine in `SHARED_FLAG_PARAMETERS` (`package - `-q`, `--quiet` (shorthand for `--log-level error`) - `-y`, `--yes` (accept prompt defaults) - `--confirm ` (grant a consent prompt non-interactively; repeatable) -- `-- ` for each statement verb a command family registers (answer a statement prompt; repeatable) - `--interactive`, `--no-interactive` - `--color`, `--no-color` - `--config ` @@ -216,7 +215,7 @@ When a command needs confirmation and cannot prompt: Consent is a question `--yes` never answers and Enter never answers. It comes in two forms. - **A yes/no consent** (`ctx.prompt.consent`). With a token, the user types the token, or passes `--confirm `. Without a token, only an interactive terminal can grant it. -- **A statement** (`ctx.prompt.statement`). The user states what should happen to a subject with a verb: `delete`, or `rename Legacy:Archive`. The command family registers the verbs, and each verb is a flag, so `--delete Legacy` gives the same answer on the command line. A flag value answers the question only when it names the subject: it is the subject, or starts with `:`. +- **A statement** (`ctx.prompt.statement`). The user states what should happen to a subject with a verb: `delete`, or `rename Legacy:Archive`. The command declares its verbs, and each verb is a flag on that command only, so `--delete Legacy` gives the same answer on the command line. A flag value answers the question only when it names the subject: it is the subject, or starts with `:`. Without an answer, a non-interactive run fails with `CLI.CONSENT_REQUIRED` and lists the flags that would answer every open question. A statement flag that no question used fails the run with `CLI.CONSENT_UNUSED`, because a mistyped subject must not pass silently. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index ddc09745..19262836 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -156,11 +156,11 @@ The config file's `$prismaConfig` marker declares a version other than the one t A consent prompt was reached under `--yes` or in a non-interactive session. Consent has no default answer and `--yes` does not grant it, so there is nothing for the run to assume. When the consent declares a token, the message and next action say to pass `--confirm `, and the token travels in meta; without a token, the only path is running the command interactively. Meta: `consentToken` (only when the consent declares a token). -A statement prompt (`ctx.prompt.statement` or `ctx.prompt.statements`) raises the same code when no verb flag on the command line answers it. One error lists every question still unanswered: the summary names the subjects, `why` carries the questions, and the next actions give one flag to pass per verb, such as `--delete Legacy` or `--rename Legacy:`. Meta: `unanswered` (a list of `{ subject, verbs }`), plus `subject` and `verbs` when exactly one question is unanswered. +A statement prompt (`ctx.prompt.statement` or `ctx.prompt.statements`) raises the same code when no statement flag on the command line answers it. One error lists every question still unanswered: the summary names the subjects, `why` carries the questions, and the next actions give one flag to pass per verb, such as `--delete Legacy` or `--rename Legacy:`. Meta: `unanswered` (a list of `{ subject, verbs }`), plus `subject` and `verbs` when exactly one question is unanswered. ### CLI.CONSENT_UNUSED -A statement verb flag such as `--delete Legacy` was given, but no statement prompt in the run asked about that subject, so the value answered nothing. Raised when the run would otherwise have succeeded; a run that failed for another reason reports that reason only. The usual cause is a mistyped subject, or a flag given twice for one question. Exits 2. Meta: `unused` (a list of `{ verb, text }`). +A statement flag such as `--delete Legacy` was given but answered nothing. The summary says why for each flag: no statement prompt in the run asked about that subject (usually a mistyped subject), or the question about it was already answered by another flag (a flag given twice, or two verbs for one subject). Raised when the handler returns from a run that would otherwise have succeeded, so a run that failed for another reason reports that reason only; or earlier, as soon as `ctx.prompt.statements(questions, { last: true })` has answered its questions, before the command acts. Exits 2. Meta: `unused` (a list of `{ verb, values }`). ### CLI.CREDENTIALS_LOCKED @@ -184,7 +184,7 @@ A bug, not a user error: a non-structured throw from a handler, an engine invari ### CLI.INVALID_ARGUMENTS -The invocation's arguments did not parse or contradict each other. Raise sites: stricli's argument-parse failure mapped at the adapter boundary (its usage text becomes summary and `why`), `--config=` given an empty value, `prisma init --skills` given `none` combined with agent names or an unknown agent name, and `prisma skills sync` given both `--disable` and `--enable`. Exits 2 as a usage error. Meta: none. +The invocation's arguments did not parse or contradict each other. Raise sites: stricli's argument-parse failure mapped at the adapter boundary (its usage text becomes summary and `why`), `--config=` given an empty value, a statement flag (such as `--delete`) given the wrong number of values for its declared arity or an empty value, `prisma init --skills` given `none` combined with agent names or an unknown agent name, and `prisma skills sync` given both `--disable` and `--enable`. Exits 2 as a usage error. Meta: none. ### CLI.MISSING_DEPENDENCY diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index 7f15f82e..144c65d2 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -12,7 +12,23 @@ Every command describes its output once, as blocks, and the engine renders it in ## Statement prompts -A command asks what the user means to happen to something with `ctx.prompt.statement`. The answer is a verb and free text, and the command validates it: +A command asks what the user means to happen to something with `ctx.prompt.statement`. The answer is a verb and free text, and the command validates it. + +The command declares the verbs it may ask with, each with its `arity` (how many values follow the flag) and a help `brief`: + +```ts +defineCommand({ + statements: { + rename: { arity: 1, brief: "Rename a table instead of dropping it: --rename Old:New" }, + delete: { arity: 1, brief: "Drop a table and its rows: --delete Table" }, + }, + // ... +}); +``` + +Only that command accepts `--rename` and `--delete`; on any other command they are unknown flags. The handler never sees their values, and help lists them on the command's own card. A verb is one lowercase word, and may not be a shared flag or one of the command's own flags. Asking with a verb the command did not declare is a construction error, and so is a question with an empty subject, a subject containing `:`, or a verb listed twice. + +Then, in the handler: ```ts const answer = await ctx.prompt.statement( @@ -27,19 +43,20 @@ const answer = await ctx.prompt.statement( : undefined, }, ); -// { verb: "delete", text: "Legacy" } or { verb: "rename", text: "Legacy:Archive" } +// { verb: "delete", text: "Legacy", values: ["Legacy"] } +// or { verb: "rename", text: "Legacy:Archive", values: ["Legacy:Archive"] } ``` -Each verb is a flag the command family registers with `defineCommandFamily({ ..., statementVerbs: ["rename", "delete"] })`. The engine adds `--rename` and `--delete` to every mounted command, and no command may declare a flag with those names. Asking with a verb no family registered is a construction error. +Each occurrence of `--` takes exactly `arity` values, in argv order across all the verbs. A wrong count or an empty value is `CLI.INVALID_ARGUMENTS`. `values` holds them; `text` is them joined by one space. The engine answers the question in this order: -1. A verb-flag value that names the subject: the value is the subject, or starts with `:`. `--delete Legacy` and `--rename Legacy:Archive` both name `Legacy`. A value `validate` rejects fails the run with `CLI.PROMPT_INVALID`. +1. A verb flag whose first value names the subject: the value is the subject, or starts with `:`. `--delete Legacy` and `--rename Legacy:Archive` both name `Legacy`. A value `validate` rejects fails the run with `CLI.PROMPT_INVALID`. 2. With no such value, a non-interactive run, or one under `--yes`, fails with `CLI.CONSENT_REQUIRED`. Its next actions give one flag to pass per verb, and its `meta` carries `subject`, `verbs` and `unanswered`. -3. Otherwise the user is asked, and answers ` `, or `` alone to mean the subject. A rejected answer is asked again on a terminal; with scripted or piped input it fails with `CLI.PROMPT_INVALID`. +3. Otherwise the user is asked, and answers ` `, or `` alone to mean the subject. A verb with an arity above 1 takes its values from the text, separated by whitespace. A rejected answer is asked again on a terminal; with scripted or piped input it fails with `CLI.PROMPT_INVALID`. `ctx.prompt.statements([...])` asks several questions at once and returns the answers in order. Flags answer what they can, a refusal lists every question still unanswered, and an interactive run asks the rest one after another. -Each flag value answers one question. A run that succeeds with a verb-flag value nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject is never ignored. +Each flag answers one question. A run that succeeds with a flag nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject or a second answer to one question is never ignored. That check runs when the handler returns, after the command has acted. A command that asks everything in one batch passes `statements(questions, { last: true })` to get the same failure right after the questions are answered, before it does anything with them. Part of [prisma/prisma-cli](https://github.com/prisma/prisma-cli). From e820a12ff43c9f2754d201c5b9bb3af4b7707c37 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:52:40 +0200 Subject: [PATCH 08/21] fix(engine): statement flags route like the parser and explain values that start with '-' A kebab-case word now finds a camelCase command when the engine looks for the command's statement flags, as the parser does. A missing first value followed by a token starting with '-' says to write --=. A statement-flag argument error names the command and fires the run summary. An interactive answer's text is its values joined by one space, the same as a flag's. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/README.md | 4 +- packages/cli-engine/src/execution/engine.ts | 24 ++++-- packages/cli-engine/src/execution/prompts.ts | 5 +- .../src/execution/statement-flags.ts | 44 +++++++---- .../tests/statement-edge-cases.test.ts | 79 +++++++++++++++++++ .../tests/statement-flag-order.test.ts | 16 +++- 6 files changed, 143 insertions(+), 29 deletions(-) diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index 144c65d2..8e8efd4b 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -47,13 +47,13 @@ const answer = await ctx.prompt.statement( // or { verb: "rename", text: "Legacy:Archive", values: ["Legacy:Archive"] } ``` -Each occurrence of `--` takes exactly `arity` values, in argv order across all the verbs. A wrong count or an empty value is `CLI.INVALID_ARGUMENTS`. `values` holds them; `text` is them joined by one space. +Each occurrence of `--` takes exactly `arity` values, in argv order across all the verbs. A wrong count or an empty value is `CLI.INVALID_ARGUMENTS`. A token that starts with `-` reads as a flag, so a first value that starts with `-` must be written `--=`. `values` holds them; `text` is them joined by one space. The engine answers the question in this order: 1. A verb flag whose first value names the subject: the value is the subject, or starts with `:`. `--delete Legacy` and `--rename Legacy:Archive` both name `Legacy`. A value `validate` rejects fails the run with `CLI.PROMPT_INVALID`. 2. With no such value, a non-interactive run, or one under `--yes`, fails with `CLI.CONSENT_REQUIRED`. Its next actions give one flag to pass per verb, and its `meta` carries `subject`, `verbs` and `unanswered`. -3. Otherwise the user is asked, and answers ` `, or `` alone to mean the subject. A verb with an arity above 1 takes its values from the text, separated by whitespace. A rejected answer is asked again on a terminal; with scripted or piped input it fails with `CLI.PROMPT_INVALID`. +3. Otherwise the user is asked, and answers ` `, or `` alone to mean the subject. A verb with an arity above 1 takes its values from the text, separated by whitespace, and `text` is them joined by one space, as for a flag. A rejected answer is asked again on a terminal; with scripted or piped input it fails with `CLI.PROMPT_INVALID`. `ctx.prompt.statements([...])` asks several questions at once and returns the answers in order. Flags answer what they can, a refusal lists every question still unanswered, and an interactive run asks the rest one after another. diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index ce81d9c6..4f3dded2 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -439,15 +439,23 @@ export class EngineImpl implements Engine { ); return 0; } + let stricliArgv = argv; const routed = routedCommand(this.tree, argv); - state.statements = routed === undefined ? {} : statementsOf(routed.def); - const extraction = extractStatementFlags(argv, state.statements); - if (!extraction.ok) { - unsubscribe(); - settleErrored(invocation, extraction.error); - return 2; + if (routed !== undefined) { + state.statements = statementsOf(routed.def); + const extraction = extractStatementFlags(argv, state.statements); + if (!extraction.ok) { + unsubscribe(); + state.commandId = routed.id; + state.docsBaseUrl = routed.docsBaseUrl; + state.snapshot = buildCommandSnapshot(routed.id, routed.def, argv, []); + settleErrored(invocation, extraction.error); + this.fireOnSettled(invocation, 2, startedAtMs); + return 2; + } + state.statementValues = extraction.values; + stricliArgv = extraction.argv; } - state.statementValues = extraction.values; const stricliProcess = { /** stricli writes only help text here. In json mode stdout carries * exactly the frame stream, so help prose goes to stderr instead. */ @@ -474,7 +482,7 @@ export class EngineImpl implements Engine { localization: { text: capturingText(state) }, }); try { - await runStricli(app, [...extraction.argv], { + await runStricli(app, [...stricliArgv], { process: stricliProcess, forCommand: (info) => { state.prefix = info.prefix; diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index d5aaabfc..6d039c88 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -155,12 +155,13 @@ function parseStatement( problem: `Start the answer with ${question.verbs.join(" or ")}.`, }; } - const text = rest === "" ? question.subject : rest; + const typed = rest === "" ? question.subject : rest; const { arity } = state.statements[verb]; - const values = arity === 1 ? [text] : text.split(WHITESPACES); + const values = arity === 1 ? [typed] : typed.split(WHITESPACES); if (values.length !== arity) { return { problem: `Give ${arity} values after ${verb}.` }; } + const text = values.join(" "); const rejection = question.validate(verb, text); return rejection === undefined ? { answer: { verb, text, values } } diff --git a/packages/cli-engine/src/execution/statement-flags.ts b/packages/cli-engine/src/execution/statement-flags.ts index 0b3cdbad..a43df699 100644 --- a/packages/cli-engine/src/execution/statement-flags.ts +++ b/packages/cli-engine/src/execution/statement-flags.ts @@ -6,6 +6,7 @@ * across flags. Handlers never see them; ctx.prompt.statement hands * them out. */ +import { camelCase } from "../args"; import type { AnyCommand, StatementSpec } from "../commands"; import { CliStructuredError } from "../protocol"; import type { CommandTreeEntry, CommandTreeNode } from "./command-tree"; @@ -44,18 +45,21 @@ export interface StatementFlagValue { } /** The command argv routes to, found the way the parser routes: the - * leading words, group by group, until one names a command. */ + * leading words, group by group, until one names a command. Like the + * parser, a kebab-case word matches a camelCase name. */ export function routedCommand( tree: CommandTreeNode, argv: readonly string[], ): CommandTreeEntry | undefined { let node = tree; for (const token of argv) { - const entry = node.commands.get(token); + const entry = + node.commands.get(token) ?? node.commands.get(camelCase(token)); if (entry !== undefined) { return entry; } - const child = node.children.get(token); + const child = + node.children.get(token) ?? node.children.get(camelCase(token)); if (child === undefined) { return undefined; } @@ -79,24 +83,32 @@ function statementFlagIn( return { verb, inline: equals === -1 ? undefined : token.slice(equals + 1) }; } +/** A first value that starts with `-` reads as a flag, so it can only + * be given inline; the error says so when that is what happened. */ function wrongValueCount( verb: string, arity: number, given: number, + next: string | undefined, ): CliStructuredError { const wanted = arity === 1 ? "a value" : `${arity} values`; - return new CliStructuredError( - "CLI.INVALID_ARGUMENTS", - `--${verb} needs ${wanted}, and was given ${given}.`, - { - nextActions: [ - { - kind: "user-choice", - label: `Pass --${verb} followed by ${wanted}.`, - }, - ], - }, - ); + const summary = `--${verb} needs ${wanted}, and was given ${given}.`; + if (given === 0 && next?.startsWith("-") === true) { + return new CliStructuredError( + "CLI.INVALID_ARGUMENTS", + `${summary} A value that starts with '-' must be written --${verb}=.`, + { + nextActions: [ + { kind: "user-choice", label: `Write --${verb}=.` }, + ], + }, + ); + } + return new CliStructuredError("CLI.INVALID_ARGUMENTS", summary, { + nextActions: [ + { kind: "user-choice", label: `Pass --${verb} followed by ${wanted}.` }, + ], + }); } function emptyValue(verb: string): CliStructuredError { @@ -155,7 +167,7 @@ export function extractStatementFlags( if (given.length !== arity) { return { ok: false, - error: wrongValueCount(flag.verb, arity, given.length), + error: wrongValueCount(flag.verb, arity, given.length, tokens[index]), }; } if (given.some((value) => value.trim() === "")) { diff --git a/packages/cli-engine/tests/statement-edge-cases.test.ts b/packages/cli-engine/tests/statement-edge-cases.test.ts index d28a6020..da410e12 100644 --- a/packages/cli-engine/tests/statement-edge-cases.test.ts +++ b/packages/cli-engine/tests/statement-edge-cases.test.ts @@ -221,6 +221,32 @@ describe("a verb with several values", () => { }); }); + test("an interactive answer's text is its values joined by one space", async () => { + const seen: string[] = []; + const askMoveRecording = (prompt: PromptSurface) => + prompt.statement("Where does Legacy go?", { + subject: "Legacy", + verbs: ["move"], + validate: (_verb, text) => { + seen.push(text); + return undefined; + }, + }); + const result = await cliWith(askMoveRecording, MOVE).run(["probe"], { + isTty: { stdin: true }, + answers: ["move Legacy archive"], + }); + + expect(seen).toEqual(["Legacy archive"]); + expect(result.presented?.data).toEqual({ + answer: { + verb: "move", + text: "Legacy archive", + values: ["Legacy", "archive"], + }, + }); + }); + test("an interactive answer with the wrong count fails the line renderer", async () => { const result = await cliWith(askMove, MOVE).run(["probe", "--json"], { isTty: { stdin: true }, @@ -252,6 +278,59 @@ describe("what each channel sees", () => { }); }); + test("an argument error names the command and fires the run summary", async () => { + const summaries: RunSummary[] = []; + const result = await cliWith(askLegacy).run( + ["probe", "--delete", "", "--json"], + { onSettled: (summary) => summaries.push(summary) }, + ); + + const last = result.json[result.json.length - 1]; + expect(last.kind === "result" && last.envelope.commandId).toBe("probe"); + expect(summaries).toMatchObject([{ commandId: "probe", exitCode: 2 }]); + }); + + test("a value starting with '-' must be written with '='", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--delete", + "-1", + "--json", + ]); + + expect(errorOf(result)).toMatchObject({ + code: "CLI.INVALID_ARGUMENTS", + summary: + "--delete needs a value, and was given 0. A value that starts with '-' must be written --delete=.", + nextActions: [{ kind: "user-choice", label: "Write --delete=." }], + }); + }); + + test("a value starting with '-' written with '=' answers", async () => { + const result = await cliWith((prompt) => + prompt.statements([legacy("-x")]), + ).run(["probe", "--rename=-x"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: [{ verb: "rename", text: "-x", values: ["-x"] }], + }); + }); + + test("a kebab-case spelling of a camelCase command still takes its statements", async () => { + const cli = createTestCli({ + commands: { + fooBar: probe((prompt) => prompt.statements([legacy()])), + }, + }); + const result = await cli.run(["foo-bar", "--delete", "Legacy"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: [{ verb: "delete", text: "Legacy", values: ["Legacy"] }], + }); + }); + test("a json run on a terminal prompts on stderr and keeps stdout to frames", async () => { const result = await cliWith(askLegacy).run(["probe", "--json"], { isTty: { stdin: true }, diff --git a/packages/cli-engine/tests/statement-flag-order.test.ts b/packages/cli-engine/tests/statement-flag-order.test.ts index 1a189464..0e28fec4 100644 --- a/packages/cli-engine/tests/statement-flag-order.test.ts +++ b/packages/cli-engine/tests/statement-flag-order.test.ts @@ -52,6 +52,13 @@ describe("statement flags come out of argv in the order given", () => { }); }); + test("a value starting with '-' can be written after '='", () => { + expect(extracted(["--rename=-x"])).toEqual({ + argv: [], + values: ["rename -x"], + }); + }); + test("an undeclared flag is left for the parser", () => { expect(extracted(["--drop", "Legacy"])).toEqual({ argv: ["--drop", "Legacy"], @@ -63,12 +70,19 @@ describe("statement flags come out of argv in the order given", () => { describe("a wrong value count is an argument error", () => { test.each([ [["--delete"], "--delete needs a value, and was given 0."], - [["--delete", "--json"], "--delete needs a value, and was given 0."], + [ + ["--delete", "--json"], + "--delete needs a value, and was given 0. A value that starts with '-' must be written --delete=.", + ], [["--move", "A"], "--move needs 2 values, and was given 1."], [["--move", "A", "--json"], "--move needs 2 values, and was given 1."], [["--delete", ""], "--delete was given an empty value."], [["--delete", " "], "--delete was given an empty value."], [["--delete="], "--delete was given an empty value."], + [ + ["--delete", "-1"], + "--delete needs a value, and was given 0. A value that starts with '-' must be written --delete=.", + ], ])("%j", (argv, error) => { expect(extracted(argv)).toEqual({ error }); }); From 25d03a964ba6d4e85bd5a399017e5210af966c7e Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:53:24 +0200 Subject: [PATCH 09/21] refactor(engine): take statement flags out of argv in their own method Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/engine.ts | 47 ++++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 4f3dded2..7a04c028 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -439,22 +439,10 @@ export class EngineImpl implements Engine { ); return 0; } - let stricliArgv = argv; - const routed = routedCommand(this.tree, argv); - if (routed !== undefined) { - state.statements = statementsOf(routed.def); - const extraction = extractStatementFlags(argv, state.statements); - if (!extraction.ok) { - unsubscribe(); - state.commandId = routed.id; - state.docsBaseUrl = routed.docsBaseUrl; - state.snapshot = buildCommandSnapshot(routed.id, routed.def, argv, []); - settleErrored(invocation, extraction.error); - this.fireOnSettled(invocation, 2, startedAtMs); - return 2; - } - state.statementValues = extraction.values; - stricliArgv = extraction.argv; + const stricliArgv = this.takeStatementFlags(invocation, argv, startedAtMs); + if (stricliArgv === undefined) { + unsubscribe(); + return 2; } const stricliProcess = { /** stricli writes only help text here. In json mode stdout carries @@ -504,6 +492,33 @@ export class EngineImpl implements Engine { return exitCode; } + /** The routed command's statement flags come out of argv before the + * parser sees it. Returns the argv left for the parser, or undefined + * when a statement flag was malformed and the run has settled. */ + private takeStatementFlags( + invocation: Invocation, + argv: readonly string[], + startedAtMs: number, + ): readonly string[] | undefined { + const state = invocation.state; + const routed = routedCommand(this.tree, argv); + if (routed === undefined) { + return argv; + } + state.statements = statementsOf(routed.def); + const extraction = extractStatementFlags(argv, state.statements); + if (!extraction.ok) { + state.commandId = routed.id; + state.docsBaseUrl = routed.docsBaseUrl; + state.snapshot = buildCommandSnapshot(routed.id, routed.def, argv, []); + settleErrored(invocation, extraction.error); + this.fireOnSettled(invocation, 2, startedAtMs); + return undefined; + } + state.statementValues = extraction.values; + return extraction.argv; + } + /** stricli routed or parsed nothing runnable. When the redirect table * claims the invocation the user typed, the run names its * replacement; otherwise it settles as the usage error it is. */ From c4d32382badf48b7c53b1f4a9d8a2bf4b692ba40 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:53:40 +0200 Subject: [PATCH 10/21] docs(adr): 0006, a consent can be a statement the command declares and the user answers with Signed-off-by: willbot Signed-off-by: Will Madden --- .../adrs/0006-consent-as-a-statement.md | 42 +++++++++++++++++++ docs/architecture/adrs/README.md | 1 + 2 files changed, 43 insertions(+) create mode 100644 docs/architecture/adrs/0006-consent-as-a-statement.md diff --git a/docs/architecture/adrs/0006-consent-as-a-statement.md b/docs/architecture/adrs/0006-consent-as-a-statement.md new file mode 100644 index 00000000..696f9521 --- /dev/null +++ b/docs/architecture/adrs/0006-consent-as-a-statement.md @@ -0,0 +1,42 @@ +# ADR 0006 - A consent can be a statement the command declares and the user answers with + +## Status + +Accepted (operator, 2026-10-07). + +## Context + +The engine's consent prompt asks the user to type a fixed token, or to pass it as `--confirm `. That fits an operation with one thing at stake and one way to agree: delete this project, restore over this database. It does not fit a command that finds several risky operations in one run and needs to know, for each one, what the user *means*. The ORM's migration planner is the first such command: a dropped table may be a rename or a deletion, and only the user can say which. A yes/no answer, or a copied token, cannot carry that meaning, and one `--confirm` cannot answer several questions in a way a reader of the script can check. + +The constraints the existing consent set still hold: nothing may be inferred, `--yes` must never grant it, a script or an agent must be able to answer up front, and a human in a terminal must be asked. + +## Decision + +A command declares the statements it may ask for, with the number of values each takes: + +```ts +defineCommand({ + statements: { rename: { arity: 1, brief: "..." }, delete: { arity: 1, brief: "..." } }, + ... +}) +``` + +The engine knows no verbs. For that command only, it parses `--` followed by `arity` values, repeatable, before the rest of argv reaches the argument parser, keeps the values in argv order, and never shows them to the handler. No other command accepts the flag, and a verb may not share a name with any flag of the command or of the engine. + +The command asks with `ctx.prompt.statement(question, { subject, verbs, validate })`, or several questions at once with `ctx.prompt.statements([...], { last })`. Each question names a subject in the command's own vocabulary and the verbs that may answer it, and `validate` decides whether an answer is acceptable; the engine never interprets the answer's text. A question is answered in this order: + +1. From the command line, by a value of one of its verbs that names the subject. A value `validate` rejects fails the run with `CLI.PROMPT_INVALID`, since a wrong flag cannot be corrected by asking again. +2. Outside an interactive terminal, or under `--yes`, by nobody: the run fails with one `CLI.CONSENT_REQUIRED` that lists every unanswered question with the flag that would answer it. +3. Interactively, by the user typing ` ` (or `` alone for the subject itself), validated the same way and asked again when rejected. + +A value nothing asked about is an error, `CLI.CONSENT_UNUSED`, at the end of a run that otherwise succeeded, or at once when the command marks its final batch with `last: true`. A statement is a consent: it has no default and `--yes` never answers it. + +`consent(question, { token })` and `--confirm` stay for commands with one thing at stake. + +## Consequences + +- A product adds a consent vocabulary by declaring it on the command that uses it, and its help text with it. The engine stays free of product words. +- The interactive form and the scripted form are one mechanism, so they cannot drift: whatever the prompt accepts, the flag accepts, and the refusal names the flag. +- Statement flags are removed from argv before the argument parser sees it, because that parser gives a flag one value per occurrence and a statement may take more. The engine routes the leading words to the command itself to know which verbs apply; that routing is tested against the parser's own. +- A typed answer or a flag value is validated by the command, so an unknown subject is the command's error, with the command's message. +- This is a minor engine release (0.7.0): a new prompt surface member, new error codes, and a new optional field on `defineCommand`. Commands built against 0.6 load unchanged. diff --git a/docs/architecture/adrs/README.md b/docs/architecture/adrs/README.md index 680155de..c128a76a 100644 --- a/docs/architecture/adrs/README.md +++ b/docs/architecture/adrs/README.md @@ -16,6 +16,7 @@ long-term architecture boundaries. | [0003](0003-structured-output-and-errors.md) | Accepted | Treat structured output and stable error codes as public contracts. | | [0004](0004-engine-version-pinning.md) | Accepted | One engine per install: product CLI packages declare the engine as an exact peer, product libraries carry no engine relationship. | | [0005](0005-config-sections-declare-their-shape.md) | Accepted | A command family declares its config section once as a schema with `path` fields; the engine derives validation, diagnostics, and path resolution from it. | +| [0006](0006-consent-as-a-statement.md) | Accepted | A command declares the statements it may ask for; the engine parses their flags for that command only and asks for them as consents a human answers by typing the statement. | ## ADR Template From 9c35666ecdd411e95091d4756fb38a2c56484f79 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:02:42 +0200 Subject: [PATCH 11/21] fix(engine): a session command declaring statements fails construction Only a result command asks statements. defineSessionCommand dropped the field silently; it now throws, as defineServerCommand does. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/commands.ts | 5 +++++ .../cli-engine/tests/statement-declarations.test.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/cli-engine/src/commands.ts b/packages/cli-engine/src/commands.ts index 6bf9a589..506f537c 100644 --- a/packages/cli-engine/src/commands.ts +++ b/packages/cli-engine/src/commands.ts @@ -381,6 +381,11 @@ export function defineSessionCommand< >["handler"]; } & SpawnDeclarations, ): SessionCommandDefinition { + if (Object.hasOwn(def, "statements")) { + throw new Error( + "@prisma/cli-engine: a session command cannot declare statements (only a result command asks them)", + ); + } return Object.freeze({ kind: "session-command" as const, help: normalizeHelp(def.help), diff --git a/packages/cli-engine/tests/statement-declarations.test.ts b/packages/cli-engine/tests/statement-declarations.test.ts index 9e225fb1..7bb21250 100644 --- a/packages/cli-engine/tests/statement-declarations.test.ts +++ b/packages/cli-engine/tests/statement-declarations.test.ts @@ -7,6 +7,7 @@ import { defineCommand, defineCommandFamily, defineServerCommand, + defineSessionCommand, flag, type PromptSurface, type StatementSpec, @@ -185,6 +186,18 @@ describe("declarations that fail construction", () => { ); }); + test("a session command declaring statements", () => { + const spec = { + help: { summary: "Session probe" }, + statements: DELETE, + handler: async () => ok(undefined), + }; + + expect(() => defineSessionCommand(spec)).toThrow( + "a session command cannot declare statements", + ); + }); + test("a server command declaring statements", () => { const spec = { help: { summary: "Server probe" }, From 245581105b1b4fc1fd8c098e2003d3b1ff00e59c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:20:34 +0200 Subject: [PATCH 12/21] feat(engine): ctx.statements.take reads a verb's values as the command's input, and a subject may contain ':' take(verb) returns that verb's unconsumed values in argv order and consumes them, so the leftover check does not report them; other verbs' values stay for the questions. An undeclared verb is a construction error. A question's subject may now contain ':'; matching is unchanged. Signed-off-by: willbot Signed-off-by: Will Madden --- .../adrs/0006-consent-as-a-statement.md | 6 +- packages/cli-engine/README.md | 4 +- packages/cli-engine/src/context.ts | 18 ++- .../src/execution/command-context.ts | 3 +- packages/cli-engine/src/execution/prompts.ts | 48 +++++- packages/cli-engine/src/exports/index.ts | 1 + .../tests/statement-edge-cases.test.ts | 11 +- .../cli-engine/tests/statement-take.test.ts | 142 ++++++++++++++++++ 8 files changed, 209 insertions(+), 24 deletions(-) create mode 100644 packages/cli-engine/tests/statement-take.test.ts diff --git a/docs/architecture/adrs/0006-consent-as-a-statement.md b/docs/architecture/adrs/0006-consent-as-a-statement.md index 696f9521..62fadda3 100644 --- a/docs/architecture/adrs/0006-consent-as-a-statement.md +++ b/docs/architecture/adrs/0006-consent-as-a-statement.md @@ -21,7 +21,7 @@ defineCommand({ }) ``` -The engine knows no verbs. For that command only, it parses `--` followed by `arity` values, repeatable, before the rest of argv reaches the argument parser, keeps the values in argv order, and never shows them to the handler. No other command accepts the flag, and a verb may not share a name with any flag of the command or of the engine. +The engine knows no verbs. For that command only, it parses `--` followed by `arity` values, repeatable, before the rest of argv reaches the argument parser, keeps the values in argv order, and keeps them out of the handler's flags. No other command accepts the flag, and a verb may not share a name with any flag of the command or of the engine. The command asks with `ctx.prompt.statement(question, { subject, verbs, validate })`, or several questions at once with `ctx.prompt.statements([...], { last })`. Each question names a subject in the command's own vocabulary and the verbs that may answer it, and `validate` decides whether an answer is acceptable; the engine never interprets the answer's text. A question is answered in this order: @@ -29,7 +29,9 @@ The command asks with `ctx.prompt.statement(question, { subject, verbs, validate 2. Outside an interactive terminal, or under `--yes`, by nobody: the run fails with one `CLI.CONSENT_REQUIRED` that lists every unanswered question with the flag that would answer it. 3. Interactively, by the user typing ` ` (or `` alone for the subject itself), validated the same way and asked again when rejected. -A value nothing asked about is an error, `CLI.CONSENT_UNUSED`, at the end of a run that otherwise succeeded, or at once when the command marks its final batch with `last: true`. A statement is a consent: it has no default and `--yes` never answers it. +A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees: a command whose work depends on some statements, as the ORM plans with its renames before it knows what still loses data, takes a verb's values in argv order with `ctx.statements.take(verb)`, which consumes them. + +A value nothing asked about or took is an error, `CLI.CONSENT_UNUSED`, at the end of a run that otherwise succeeded, or at once when the command marks its final batch with `last: true`. A statement is a consent: it has no default and `--yes` never answers it. `consent(question, { token })` and `--confirm` stay for commands with one thing at stake. diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index 8e8efd4b..0950c342 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -26,7 +26,7 @@ defineCommand({ }); ``` -Only that command accepts `--rename` and `--delete`; on any other command they are unknown flags. The handler never sees their values, and help lists them on the command's own card. A verb is one lowercase word, and may not be a shared flag or one of the command's own flags. Asking with a verb the command did not declare is a construction error, and so is a question with an empty subject, a subject containing `:`, or a verb listed twice. +Only that command accepts `--rename` and `--delete`; on any other command they are unknown flags. Their values stay out of the handler's flags, and help lists them on the command's own card. A verb is one lowercase word, and may not be a shared flag or one of the command's own flags. Asking with a verb the command did not declare is a construction error, and so is a question with an empty subject or a verb listed twice. Then, in the handler: @@ -57,6 +57,8 @@ The engine answers the question in this order: `ctx.prompt.statements([...])` asks several questions at once and returns the answers in order. Flags answer what they can, a refusal lists every question still unanswered, and an interactive run asks the rest one after another. +A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. + Each flag answers one question. A run that succeeds with a flag nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject or a second answer to one question is never ignored. That check runs when the handler returns, after the command has acted. A command that asks everything in one batch passes `statements(questions, { last: true })` to get the same failure right after the questions are answered, before it does anything with them. Part of [prisma/prisma-cli](https://github.com/prisma/prisma-cli). diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 2c6cfaba..2dc5047a 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -101,6 +101,9 @@ export interface CommandContext< /** Interactive input. */ readonly prompt: PromptSurface; + /** The command's declared statement flags, read as its own input. */ + readonly statements: StatementSurface; + /** * Shows the user a URL and, in an interactive session, opens it in * their browser. Always announces the URL on the commentary channel @@ -193,8 +196,8 @@ export interface BrowserWaitRequest { export interface StatementOptions { /** What the answer is about, in the command's own vocabulary. - * Non-empty and without `:`, which separates it from the rest of a - * flag value; anything else is a construction error. */ + * Non-empty; an empty subject is a construction error. A flag value + * names it when it equals the subject or starts with `:`. */ readonly subject: string; /** The verbs that may answer, in the order a refusal lists them. */ readonly verbs: readonly V[]; @@ -221,6 +224,17 @@ export interface StatementAnswer { readonly values: readonly string[]; } +export interface StatementSurface { + /** + * Every unconsumed value of `verb`, in argv order, consumed so the + * leftover check does not report them. For statements that are input + * to the command's work rather than answers to a question; other + * verbs' values stay for the questions. A verb the command did not + * declare is a construction error. + */ + readonly take: (verb: V) => StatementAnswer[]; +} + export interface StatementsOptions { /** This is the run's final ask: values still unconsumed once the * questions are answered fail with CLI.CONSENT_UNUSED here, before diff --git a/packages/cli-engine/src/execution/command-context.ts b/packages/cli-engine/src/execution/command-context.ts index fd7a5a39..e3f27187 100644 --- a/packages/cli-engine/src/execution/command-context.ts +++ b/packages/cli-engine/src/execution/command-context.ts @@ -22,7 +22,7 @@ import { dependencyResolvable, missingDependencyError } from "./needs"; import { announceUrl } from "./open-url"; import { makePackageOperations } from "./package-operations"; import { makePaint } from "./palette"; -import { makePromptSurface } from "./prompts"; +import { makePromptSurface, makeStatementSurface } from "./prompts"; import { reportEvent } from "./reporting"; import { makeSpawn } from "./spawn"; @@ -181,6 +181,7 @@ export function makeContext( lastChild: () => state.lastChild, report: (event) => reportEvent(invocation, event), prompt: makePromptSurface(invocation), + statements: makeStatementSurface(invocation), openUrl: (request) => announceUrl(invocation, request), signal: invocation.signal, cwd: invocation.runtime.cwd, diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index 6d039c88..1f5a4314 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -29,6 +29,7 @@ import type { PromptSurface, StatementAnswer, StatementQuestion, + StatementSurface, StatementsOptions, } from "../context"; import { CliStructuredError } from "../protocol"; @@ -175,8 +176,8 @@ function malformation( declared: DeclaredStatements, ): string | undefined { const about = `about '${question.subject}'`; - if (question.subject === "" || question.subject.includes(":")) { - return `${about}: a subject must be non-empty and contain no ':'`; + if (question.subject === "") { + return `${about}: a subject must be non-empty`; } if (question.verbs.length === 0) { return `${about} with no verbs`; @@ -192,12 +193,22 @@ function malformation( : `with verb '${undeclared}', which it does not declare in statements`; } +/** The asked subject a value names, if any. */ +function askedSubjectOf( + state: RunState, + value: StatementFlagValue, +): string | undefined { + return [...state.askedSubjects].find((subject) => + namesSubject(value.values[0], subject), + ); +} + function unusedSentence(state: RunState, value: StatementFlagValue): string { const given = flagForm(value.verb, value.values); - const subject = subjectOf(value.values[0]); - return state.askedSubjects.has(subject) - ? `${given} was given, but the question about ${subject} was already answered by another flag.` - : `${given} was given but nothing in this run asked about ${subject}.`; + const asked = askedSubjectOf(state, value); + return asked === undefined + ? `${given} was given but nothing in this run asked about ${subjectOf(value.values[0])}.` + : `${given} was given, but the question about ${asked} was already answered by another flag.`; } /** Fails a run when a statement flag answered nothing: a mistyped @@ -210,8 +221,8 @@ export function unusedStatementValuesError( if (unused.length === 0) { return undefined; } - const allAsked = unused.every((value) => - state.askedSubjects.has(subjectOf(value.values[0])), + const allAsked = unused.every( + (value) => askedSubjectOf(state, value) !== undefined, ); return new CliStructuredError( "CLI.CONSENT_UNUSED", @@ -445,6 +456,27 @@ function parseBooleanAnswer( throw promptInvalid(question, raw); } +/** ctx.statements: a verb's values taken as the command's own input, + * consumed so the leftover check does not report them. */ +export function makeStatementSurface(invocation: Invocation): StatementSurface { + const { state } = invocation; + return { + take: (verb: V): StatementAnswer[] => { + if (!Object.hasOwn(state.statements, verb)) { + throw constructionError( + `command '${state.commandId}' took verb '${verb}', which it does not declare in statements`, + ); + } + return state.statementValues + .filter((value) => !value.consumed && value.verb === verb) + .map((value) => { + value.consumed = true; + return { verb, text: value.values.join(" "), values: value.values }; + }); + }, + }; +} + export function makePromptSurface(invocation: Invocation): PromptSurface { const { runtime, hooks, state } = invocation; let readLine: (() => Promise) | undefined; diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index aec04920..054dec5b 100644 --- a/packages/cli-engine/src/exports/index.ts +++ b/packages/cli-engine/src/exports/index.ts @@ -78,6 +78,7 @@ export type { StatementAnswer, StatementOptions, StatementQuestion, + StatementSurface, StatementsOptions, } from "../context"; export { diff --git a/packages/cli-engine/tests/statement-edge-cases.test.ts b/packages/cli-engine/tests/statement-edge-cases.test.ts index da410e12..96affd8b 100644 --- a/packages/cli-engine/tests/statement-edge-cases.test.ts +++ b/packages/cli-engine/tests/statement-edge-cases.test.ts @@ -144,16 +144,7 @@ describe("a malformed question is a construction error", () => { { ...legacy(), verbs: ["delete", "delete"] }, "asked a statement about 'Legacy' listing a verb twice", ], - [ - "a subject containing ':'", - legacy("Legacy:Archive"), - "a subject must be non-empty and contain no ':'", - ], - [ - "an empty subject", - legacy(""), - "a subject must be non-empty and contain no ':'", - ], + ["an empty subject", legacy(""), "a subject must be non-empty"], ])("%s", async (_case, question, message) => { const result = await cliWith((prompt) => prompt.statements([question])).run( ["probe", "--json"], diff --git a/packages/cli-engine/tests/statement-take.test.ts b/packages/cli-engine/tests/statement-take.test.ts new file mode 100644 index 00000000..84b6e374 --- /dev/null +++ b/packages/cli-engine/tests/statement-take.test.ts @@ -0,0 +1,142 @@ +/** + * ctx.statements.take(verb): a verb's values as the command's own + * input, taken before anything is asked, in argv order. Taken values + * are consumed; other verbs' values stay for the questions. + */ +import { + type CommandContext, + defineCommand, + type StatementSpec, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +const VERBS = { rename: { arity: 1 }, delete: { arity: 1 } } as const; + +function cliWith( + run: (ctx: CommandContext) => Promise, + statements: Readonly> = VERBS, +) { + const probe = defineCommand({ + help: { summary: "Statement probe" }, + statements, + handler: async (_args, ctx) => { + const answer = await run(ctx); + return ok( + ctx.present( + { data: { answer } }, + { + human: () => [], + stdout: () => [], + json: () => ({ answer }), + next: () => [], + }, + ), + ); + }, + }); + return createTestCli({ commands: { probe } }); +} + +function errorOf(result: Awaited>) { + const last = result.json[result.json.length - 1]; + return last.kind === "result" && !last.envelope.ok + ? last.envelope.error + : undefined; +} + +describe("ctx.statements.take", () => { + test("returns one verb's values in argv order and leaves the others for the questions", async () => { + const result = await cliWith(async (ctx) => { + const renames = ctx.statements.take("rename"); + const [drop] = await ctx.prompt.statements([ + { + question: "What happens to Legacy?", + subject: "Legacy", + verbs: ["delete"], + validate: () => undefined, + }, + ]); + return { renames, drop }; + }).run([ + "probe", + "--rename", + "A:B", + "--delete", + "Legacy", + "--rename", + "C:D", + ]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { + renames: [ + { verb: "rename", text: "A:B", values: ["A:B"] }, + { verb: "rename", text: "C:D", values: ["C:D"] }, + ], + drop: { verb: "delete", text: "Legacy", values: ["Legacy"] }, + }, + }); + }); + + test("taken values are not reported unused", async () => { + const result = await cliWith(async (ctx) => + ctx.statements.take("rename"), + ).run(["probe", "--rename", "A:B"]); + + expect(result.exitCode).toBe(0); + }); + + test("a value no question or take consumed is still reported", async () => { + const result = await cliWith(async (ctx) => + ctx.statements.take("rename"), + ).run(["probe", "--rename", "A:B", "--delete", "Legacy", "--json"]); + + expect(errorOf(result)?.code).toBe("CLI.CONSENT_UNUSED"); + }); + + test("a second take of the same verb finds nothing left", async () => { + const result = await cliWith(async (ctx) => [ + ctx.statements.take("rename"), + ctx.statements.take("rename"), + ]).run(["probe", "--rename", "A:B"]); + + expect(result.presented?.data).toEqual({ + answer: [[{ verb: "rename", text: "A:B", values: ["A:B"] }], []], + }); + }); + + test("taking a verb the command did not declare is a construction error", async () => { + const result = await cliWith(async (ctx) => + ctx.statements.take("archive"), + ).run(["probe", "--json"]); + + expect(result.exitCode).toBe(1); + expect(errorOf(result)?.summary).toContain( + "took verb 'archive', which it does not declare in statements", + ); + }); +}); + +describe("a subject containing ':'", () => { + test("is answered by a value equal to it", async () => { + const result = await cliWith(async (ctx) => + ctx.prompt.statement("What happens to Legacy:Archive?", { + subject: "Legacy:Archive", + verbs: ["delete"], + validate: () => undefined, + }), + ).run(["probe", "--delete", "Legacy:Archive"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { + verb: "delete", + text: "Legacy:Archive", + values: ["Legacy:Archive"], + }, + }); + }); +}); From 949a03a059d264451825e1606e8cf0061711e7a8 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:25:27 +0200 Subject: [PATCH 13/21] fix(engine): a statement value goes to the subject it equals, else the longest subject it names Within one batch, values equal to a subject answer first; any other value answers only the question with the longest subject it names, so --delete A:B no longer answers the question about A. The unused-flag wording names the longest asked subject too. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/src/execution/prompts.ts | 46 ++++-- .../tests/statement-prefix-subjects.test.ts | 143 ++++++++++++++++++ .../tests/statement-prompts.test.ts | 4 +- 3 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 packages/cli-engine/tests/statement-prefix-subjects.test.ts diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index 1f5a4314..18b91b53 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -74,20 +74,38 @@ function subjectOf(value: string): string { return colon === -1 ? value : value.slice(0, colon); } -/** The first unconsumed statement-flag value naming the subject, tried - * verb by verb; its first value is the one that names it. A value the - * command rejects fails the run: a wrong flag cannot be corrected by - * asking again. */ +/** The longest of `subjects` that `value` names: `A:B` names both `A` + * and `A:B`, and belongs to `A:B`. */ +function longestSubjectNamed( + value: string, + subjects: Iterable, +): string | undefined { + let longest: string | undefined; + for (const subject of subjects) { + if ( + namesSubject(value, subject) && + (longest === undefined || subject.length > longest.length) + ) { + longest = subject; + } + } + return longest; +} + +/** The first unconsumed statement-flag value whose first value `fits` + * the question, tried verb by verb. A value the command rejects fails + * the run: a wrong flag cannot be corrected by asking again. */ function answerFromFlags( state: RunState, question: StatementQuestion, + fits: (first: string) => boolean, ): StatementAnswer | undefined { for (const verb of question.verbs) { const value = state.statementValues.find( (candidate) => !candidate.consumed && candidate.verb === verb && - namesSubject(candidate.values[0], question.subject), + fits(candidate.values[0]), ); if (value === undefined) { continue; @@ -198,9 +216,7 @@ function askedSubjectOf( state: RunState, value: StatementFlagValue, ): string | undefined { - return [...state.askedSubjects].find((subject) => - namesSubject(value.values[0], subject), - ); + return longestSubjectNamed(value.values[0], state.askedSubjects); } function unusedSentence(state: RunState, value: StatementFlagValue): string { @@ -656,8 +672,18 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { for (const question of questions) { state.askedSubjects.add(question.subject); } - const fromFlags = questions.map((question) => - answerFromFlags(state, question), + const subjects = questions.map((question) => question.subject); + const exact = questions.map((question) => + answerFromFlags(state, question, (first) => first === question.subject), + ); + const fromFlags = questions.map( + (question, index) => + exact[index] ?? + answerFromFlags( + state, + question, + (first) => longestSubjectNamed(first, subjects) === question.subject, + ), ); const unanswered = questions.filter( (_question, index) => fromFlags[index] === undefined, diff --git a/packages/cli-engine/tests/statement-prefix-subjects.test.ts b/packages/cli-engine/tests/statement-prefix-subjects.test.ts new file mode 100644 index 00000000..a97d3b34 --- /dev/null +++ b/packages/cli-engine/tests/statement-prefix-subjects.test.ts @@ -0,0 +1,143 @@ +/** + * Subjects that are prefixes of one another, asked in one batch: a + * value goes to the subject it equals, and otherwise to the longest + * subject it names, whatever the order of argv or of the questions. + */ +import { defineCommand } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +function question(subject: string) { + return { + question: `What happens to ${subject}?`, + subject, + verbs: ["rename", "delete"], + validate: () => undefined, + } as const; +} + +function cliAsking(subjects: readonly string[]) { + const probe = defineCommand({ + help: { summary: "Statement probe" }, + statements: { rename: { arity: 1 }, delete: { arity: 1 } }, + handler: async (_args, ctx) => { + const answer = await ctx.prompt.statements(subjects.map(question)); + return ok( + ctx.present( + { data: { answer } }, + { + human: () => [], + stdout: () => [], + json: () => ({ answer }), + next: () => [], + }, + ), + ); + }, + }); + return createTestCli({ commands: { probe } }); +} + +function errorOf(result: Awaited>) { + const last = result.json[result.json.length - 1]; + return last.kind === "result" && !last.envelope.ok + ? last.envelope.error + : undefined; +} + +function deleting(text: string) { + return { verb: "delete", text, values: [text] }; +} + +describe("subjects that prefix one another, in one batch", () => { + test("a value for A:B does not answer A", async () => { + const result = await cliAsking(["A", "A:B"]).run([ + "probe", + "--delete", + "A:B", + "--json", + ]); + + expect(errorOf(result)).toMatchObject({ + code: "CLI.CONSENT_REQUIRED", + meta: { unanswered: [{ subject: "A" }] }, + }); + }); + + test.each([ + [ + ["A", "A:B"], + ["--delete", "A:B", "--delete", "A"], + ], + [ + ["A", "A:B"], + ["--delete", "A", "--delete", "A:B"], + ], + [ + ["A:B", "A"], + ["--delete", "A:B", "--delete", "A"], + ], + [ + ["A:B", "A"], + ["--delete", "A", "--delete", "A:B"], + ], + ])("questions %j with %j each get their own value", async (subjects, flags) => { + const result = await cliAsking(subjects).run(["probe", ...flags]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: subjects.map(deleting), + }); + }); + + test("a value naming only the shorter subject still answers it", async () => { + const result = await cliAsking(["A", "A:B"]).run([ + "probe", + "--rename", + "A:Z", + "--delete", + "A:B", + ]); + + expect(result.presented?.data).toEqual({ + answer: [ + { verb: "rename", text: "A:Z", values: ["A:Z"] }, + deleting("A:B"), + ], + }); + }); + + test("an interactive run asks about A rather than answering it with A:B's value", async () => { + const result = await cliAsking(["A", "A:B"]).run( + ["probe", "--delete", "A:B"], + { isTty: { stdin: true }, stdin: "rename A:C\n" }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe("? What happens to A? (rename/delete) "); + expect(result.presented?.data).toEqual({ + answer: [ + { verb: "rename", text: "A:C", values: ["A:C"] }, + deleting("A:B"), + ], + }); + }); + + test("a leftover value is reported against the longest subject it names", async () => { + const result = await cliAsking(["A", "A:B"]).run([ + "probe", + "--delete", + "A", + "--delete", + "A:B", + "--delete", + "A:B", + "--json", + ]); + + expect(errorOf(result)?.summary).toBe( + "--delete A:B was given, but the question about A:B was already answered by another flag.", + ); + }); +}); diff --git a/packages/cli-engine/tests/statement-prompts.test.ts b/packages/cli-engine/tests/statement-prompts.test.ts index 3427aed5..3a0d3a6f 100644 --- a/packages/cli-engine/tests/statement-prompts.test.ts +++ b/packages/cli-engine/tests/statement-prompts.test.ts @@ -473,7 +473,7 @@ describe("a verb-flag value nothing consumed", () => { }); }); - test("a second verb for an answered subject is reported as already answered", async () => { + test("a value equal to the subject answers before one that only starts with it", async () => { const result = await cliWith(askLegacy).run([ "probe", "--delete", @@ -484,7 +484,7 @@ describe("a verb-flag value nothing consumed", () => { ]); expect(errorOf(result)?.summary).toBe( - "--delete Legacy was given, but the question about Legacy was already answered by another flag.", + "--rename Legacy:Archive was given, but the question about Legacy was already answered by another flag.", ); }); From 19f76cb29c501ba90f0a1ef29320edb5d1e19fea Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:25:27 +0200 Subject: [PATCH 14/21] docs(engine): prefix subjects belong in one batch; take before last, and not a verb a later question lists Also replaces the stale 'handlers never see' wording and names take in the CLI.CONSENT_UNUSED entry. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 2 +- packages/cli-engine/README.md | 4 ++-- packages/cli-engine/src/commands.ts | 3 ++- packages/cli-engine/src/context.ts | 14 +++++++++++--- .../cli-engine/src/execution/statement-flags.ts | 5 +++-- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 19262836..a267557c 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -160,7 +160,7 @@ A statement prompt (`ctx.prompt.statement` or `ctx.prompt.statements`) raises th ### CLI.CONSENT_UNUSED -A statement flag such as `--delete Legacy` was given but answered nothing. The summary says why for each flag: no statement prompt in the run asked about that subject (usually a mistyped subject), or the question about it was already answered by another flag (a flag given twice, or two verbs for one subject). Raised when the handler returns from a run that would otherwise have succeeded, so a run that failed for another reason reports that reason only; or earlier, as soon as `ctx.prompt.statements(questions, { last: true })` has answered its questions, before the command acts. Exits 2. Meta: `unused` (a list of `{ verb, values }`). +A statement flag such as `--delete Legacy` was given but answered nothing. The summary says why for each flag: no statement prompt asked about that subject and no `ctx.statements.take` took it (usually a mistyped subject), or the question about it was already answered by another flag (a flag given twice, or two verbs for one subject). Raised when the handler returns from a run that would otherwise have succeeded, so a run that failed for another reason reports that reason only; or earlier, as soon as `ctx.prompt.statements(questions, { last: true })` has answered its questions, before the command acts. Exits 2. Meta: `unused` (a list of `{ verb, values }`). ### CLI.CREDENTIALS_LOCKED diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index 0950c342..d7de8236 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -51,13 +51,13 @@ Each occurrence of `--` takes exactly `arity` values, in argv order across The engine answers the question in this order: -1. A verb flag whose first value names the subject: the value is the subject, or starts with `:`. `--delete Legacy` and `--rename Legacy:Archive` both name `Legacy`. A value `validate` rejects fails the run with `CLI.PROMPT_INVALID`. +1. A verb flag whose first value names the subject: the value is the subject, or starts with `:`. `--delete Legacy` and `--rename Legacy:Archive` both name `Legacy`. Within one batch, a value equal to a subject answers that subject first, and any other value goes to the longest subject it names, so `--delete A:B` answers the question about `A:B`, not the one about `A`. Ask questions whose subjects are prefixes of one another in one batch. A value `validate` rejects fails the run with `CLI.PROMPT_INVALID`. 2. With no such value, a non-interactive run, or one under `--yes`, fails with `CLI.CONSENT_REQUIRED`. Its next actions give one flag to pass per verb, and its `meta` carries `subject`, `verbs` and `unanswered`. 3. Otherwise the user is asked, and answers ` `, or `` alone to mean the subject. A verb with an arity above 1 takes its values from the text, separated by whitespace, and `text` is them joined by one space, as for a flag. A rejected answer is asked again on a terminal; with scripted or piped input it fails with `CLI.PROMPT_INVALID`. `ctx.prompt.statements([...])` asks several questions at once and returns the answers in order. Flags answer what they can, a refusal lists every question still unanswered, and an interactive run asks the rest one after another. -A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. +A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. Do not list a verb you take in a later question's `verbs`, and take before any `last: true` batch, which reports every value still unconsumed. Each flag answers one question. A run that succeeds with a flag nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject or a second answer to one question is never ignored. That check runs when the handler returns, after the command has acted. A command that asks everything in one batch passes `statements(questions, { last: true })` to get the same failure right after the questions are answered, before it does anything with them. diff --git a/packages/cli-engine/src/commands.ts b/packages/cli-engine/src/commands.ts index 506f537c..79d5220c 100644 --- a/packages/cli-engine/src/commands.ts +++ b/packages/cli-engine/src/commands.ts @@ -147,7 +147,8 @@ export interface SpawnDeclarations { * A statement the command may ask for with ctx.prompt.statement. Its * key is the verb, one lowercase word, and the command alone accepts * `--` followed by `arity` values, repeatable, to answer it. The - * handler never sees those values. + * values stay out of the handler's flags; the handler reads them + * through ctx.prompt.statement or ctx.statements.take. */ export interface StatementSpec { /** How many argv values each occurrence of the flag takes. Giving diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 2dc5047a..cc838ea8 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -197,7 +197,11 @@ export interface BrowserWaitRequest { export interface StatementOptions { /** What the answer is about, in the command's own vocabulary. * Non-empty; an empty subject is a construction error. A flag value - * names it when it equals the subject or starts with `:`. */ + * names it when it equals the subject or starts with `:`. + * Within one batch a value equal to a subject answers that subject + * first, and any other value goes to the longest subject it names, + * so ask questions whose subjects are prefixes of one another (`A` + * and `A:B`) in one `statements` call. */ readonly subject: string; /** The verbs that may answer, in the order a refusal lists them. */ readonly verbs: readonly V[]; @@ -230,7 +234,9 @@ export interface StatementSurface { * leftover check does not report them. For statements that are input * to the command's work rather than answers to a question; other * verbs' values stay for the questions. A verb the command did not - * declare is a construction error. + * declare is a construction error. Do not list a verb you take in a + * later question's `verbs`: the take consumes the flag that would + * answer it. */ readonly take: (verb: V) => StatementAnswer[]; } @@ -238,7 +244,9 @@ export interface StatementSurface { export interface StatementsOptions { /** This is the run's final ask: values still unconsumed once the * questions are answered fail with CLI.CONSENT_UNUSED here, before - * the command acts on the answers. */ + * the command acts on the answers. Take any verbs you take before + * the `last` batch: after it, their values have already been + * reported as unused. */ readonly last?: boolean; } diff --git a/packages/cli-engine/src/execution/statement-flags.ts b/packages/cli-engine/src/execution/statement-flags.ts index a43df699..a3762fb7 100644 --- a/packages/cli-engine/src/execution/statement-flags.ts +++ b/packages/cli-engine/src/execution/statement-flags.ts @@ -3,8 +3,9 @@ * repeatable, accepted only by the command that declares the verb. The * engine takes them out of argv before the parser sees it, because the * parser gives a flag one value per occurrence and keeps no order - * across flags. Handlers never see them; ctx.prompt.statement hands - * them out. + * across flags. The values stay out of the handler's flags; the + * handler reads them through ctx.prompt.statement or + * ctx.statements.take. */ import { camelCase } from "../args"; import type { AnyCommand, StatementSpec } from "../commands"; From 29edb2903f43c76947c103384c01c324daf80aa5 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:54:22 +0200 Subject: [PATCH 15/21] docs(engine): a question may list a verb the command took No flag value of a taken verb is left to answer the question, so the verb serves the refusal's flag form and the typed answer. Tests pin both. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/README.md | 2 +- packages/cli-engine/src/context.ts | 6 +-- .../cli-engine/tests/statement-take.test.ts | 52 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index d7de8236..52fa4ced 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -57,7 +57,7 @@ The engine answers the question in this order: `ctx.prompt.statements([...])` asks several questions at once and returns the answers in order. Flags answer what they can, a refusal lists every question still unanswered, and an interactive run asks the rest one after another. -A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. Do not list a verb you take in a later question's `verbs`, and take before any `last: true` batch, which reports every value still unconsumed. +A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. A later question may still list a taken verb: no flag value of it is left to answer the question, so the verb serves the refusal's `--` form and what a user may type. Take before any `last: true` batch, which reports every value still unconsumed. Each flag answers one question. A run that succeeds with a flag nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject or a second answer to one question is never ignored. That check runs when the handler returns, after the command has acted. A command that asks everything in one batch passes `statements(questions, { last: true })` to get the same failure right after the questions are answered, before it does anything with them. diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index cc838ea8..ab10c075 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -234,9 +234,9 @@ export interface StatementSurface { * leftover check does not report them. For statements that are input * to the command's work rather than answers to a question; other * verbs' values stay for the questions. A verb the command did not - * declare is a construction error. Do not list a verb you take in a - * later question's `verbs`: the take consumes the flag that would - * answer it. + * declare is a construction error. A later question may still list a + * taken verb: no flag value of it is left to answer the question, so + * the verb serves the refusal's flag form and the typed answer. */ readonly take: (verb: V) => StatementAnswer[]; } diff --git a/packages/cli-engine/tests/statement-take.test.ts b/packages/cli-engine/tests/statement-take.test.ts index 84b6e374..cc913cab 100644 --- a/packages/cli-engine/tests/statement-take.test.ts +++ b/packages/cli-engine/tests/statement-take.test.ts @@ -120,6 +120,58 @@ describe("ctx.statements.take", () => { }); }); +describe("a question listing a verb the command took", () => { + const takeThenAsk = async (ctx: CommandContext) => { + const renames = ctx.statements.take("rename"); + const [legacy] = await ctx.prompt.statements([ + { + question: "What happens to Legacy?", + subject: "Legacy", + verbs: ["rename", "delete"], + forms: { rename: "Legacy:" }, + validate: () => undefined, + }, + ]); + return { renames, legacy }; + }; + + test("is answered by a typed answer with that verb", async () => { + const result = await cliWith(takeThenAsk).run( + ["probe", "--rename", "Other:New"], + { isTty: { stdin: true }, answers: ["rename Legacy:Archive"] }, + ); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { + renames: [{ verb: "rename", text: "Other:New", values: ["Other:New"] }], + legacy: { + verb: "rename", + text: "Legacy:Archive", + values: ["Legacy:Archive"], + }, + }, + }); + }); + + test("is refused non-interactively with that verb's flag form, never answered by a taken value", async () => { + const result = await cliWith(takeThenAsk).run([ + "probe", + "--rename", + "Legacy:Archive", + "--json", + ]); + + expect(errorOf(result)).toMatchObject({ + code: "CLI.CONSENT_REQUIRED", + nextActions: [ + { kind: "user-choice", label: "Pass --rename Legacy:" }, + { kind: "user-choice", label: "Pass --delete Legacy" }, + ], + }); + }); +}); + describe("a subject containing ':'", () => { test("is answered by a value equal to it", async () => { const result = await cliWith(async (ctx) => From 84ce08ee85309c712e1880598c175e83db7f2e3d Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:50:19 +0200 Subject: [PATCH 16/21] feat(engine): ctx.statements.values() lists unconsumed statement values without consuming them ADR 0006 now states the subject-matching rule and why its separator is ':', records the rules added since acceptance (take, values, longest subject first, ':' in subjects, last), and says what the flag and prompt paths actually share. Signed-off-by: willbot Signed-off-by: Will Madden --- .../adrs/0006-consent-as-a-statement.md | 14 ++++- packages/cli-engine/README.md | 2 +- packages/cli-engine/src/context.ts | 6 +++ packages/cli-engine/src/execution/prompts.ts | 13 ++++- .../cli-engine/tests/statement-take.test.ts | 51 +++++++++++++++++++ 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/docs/architecture/adrs/0006-consent-as-a-statement.md b/docs/architecture/adrs/0006-consent-as-a-statement.md index 62fadda3..c3158928 100644 --- a/docs/architecture/adrs/0006-consent-as-a-statement.md +++ b/docs/architecture/adrs/0006-consent-as-a-statement.md @@ -23,7 +23,7 @@ defineCommand({ The engine knows no verbs. For that command only, it parses `--` followed by `arity` values, repeatable, before the rest of argv reaches the argument parser, keeps the values in argv order, and keeps them out of the handler's flags. No other command accepts the flag, and a verb may not share a name with any flag of the command or of the engine. -The command asks with `ctx.prompt.statement(question, { subject, verbs, validate })`, or several questions at once with `ctx.prompt.statements([...], { last })`. Each question names a subject in the command's own vocabulary and the verbs that may answer it, and `validate` decides whether an answer is acceptable; the engine never interprets the answer's text. A question is answered in this order: +The command asks with `ctx.prompt.statement(question, { subject, verbs, validate })`, or several questions at once with `ctx.prompt.statements([...], { last })`. Each question names a subject in the command's own vocabulary and the verbs that may answer it, and `validate` decides whether an answer is acceptable. The engine interprets one thing in a value: whether it names the subject, which it does when it equals the subject or starts with the subject followed by `:`. The separator is `:` because that is the subject grammar of the first consumer, the ORM, whose renames read `Old:New`; a product whose values use another separator picks subjects that no value can name by accident. Beyond that, the engine never interprets the answer's text. A question is answered in this order: 1. From the command line, by a value of one of its verbs that names the subject. A value `validate` rejects fails the run with `CLI.PROMPT_INVALID`, since a wrong flag cannot be corrected by asking again. 2. Outside an interactive terminal, or under `--yes`, by nobody: the run fails with one `CLI.CONSENT_REQUIRED` that lists every unanswered question with the flag that would answer it. @@ -35,10 +35,20 @@ A value nothing asked about or took is an error, `CLI.CONSENT_UNUSED`, at the en `consent(question, { token })` and `--confirm` stay for commands with one thing at stake. +### Rules added after acceptance + +These came from the ORM's first use and the reviews of this change, all in engine 0.7.0: + +- **`ctx.statements.take(verb)`** returns a verb's values in argv order and consumes them, for statements that are input to the command's work. A question may still list a taken verb; no value of it is left to answer the question, so the verb serves the refusal's flag form and the typed answer. Take before any `last: true` batch. +- **`ctx.statements.values()`** lists every unconsumed value in argv order without consuming any, so a command can show what it was given without spending it. +- **Longest subject first.** Within one `statements` batch, a value equal to a subject answers that subject first, and any other value goes to the question with the longest subject it names. So `A:B` answers the question about `A:B`, not the one about `A`. Questions whose subjects are prefixes of one another belong in one batch, because separate calls cannot be resolved this way. +- **`:` in a subject is allowed**, with the matching rule unchanged. +- **`last: true`** marks a batch as the run's final ask: values still unconsumed fail with `CLI.CONSENT_UNUSED` as soon as it is answered, before the command acts on the answers. + ## Consequences - A product adds a consent vocabulary by declaring it on the command that uses it, and its help text with it. The engine stays free of product words. -- The interactive form and the scripted form are one mechanism, so they cannot drift: whatever the prompt accepts, the flag accepts, and the refusal names the flag. +- The interactive form and the scripted form share one validation (`validate`) and one refusal text, which names the flag. They differ in how a value is matched to a question: a flag value must name the subject, while a typed answer is given to the question being asked. - Statement flags are removed from argv before the argument parser sees it, because that parser gives a flag one value per occurrence and a statement may take more. The engine routes the leading words to the command itself to know which verbs apply; that routing is tested against the parser's own. - A typed answer or a flag value is validated by the command, so an unknown subject is the command's error, with the command's message. - This is a minor engine release (0.7.0): a new prompt surface member, new error codes, and a new optional field on `defineCommand`. Commands built against 0.6 load unchanged. diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index 52fa4ced..c9eb73d6 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -57,7 +57,7 @@ The engine answers the question in this order: `ctx.prompt.statements([...])` asks several questions at once and returns the answers in order. Flags answer what they can, a refusal lists every question still unanswered, and an interactive run asks the rest one after another. -A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. A later question may still list a taken verb: no flag value of it is left to answer the question, so the verb serves the refusal's `--` form and what a user may type. Take before any `last: true` batch, which reports every value still unconsumed. +A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. A later question may still list a taken verb: no flag value of it is left to answer the question, so the verb serves the refusal's `--` form and what a user may type. `ctx.statements.values()` lists every unconsumed value of every verb in argv order without consuming any, for showing what a run was given, such as in a line that repeats the command. Take before any `last: true` batch, which reports every value still unconsumed. Each flag answers one question. A run that succeeds with a flag nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject or a second answer to one question is never ignored. That check runs when the handler returns, after the command has acted. A command that asks everything in one batch passes `statements(questions, { last: true })` to get the same failure right after the questions are answered, before it does anything with them. diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index ab10c075..b92ad876 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -239,6 +239,12 @@ export interface StatementSurface { * the verb serves the refusal's flag form and the typed answer. */ readonly take: (verb: V) => StatementAnswer[]; + /** + * Every unconsumed value of every verb, in argv order, without + * consuming any: for showing the statements a run was given, such as + * in a line that repeats the command. + */ + readonly values: () => StatementAnswer[]; } export interface StatementsOptions { diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index 18b91b53..58accf08 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -472,11 +472,20 @@ function parseBooleanAnswer( throw promptInvalid(question, raw); } -/** ctx.statements: a verb's values taken as the command's own input, - * consumed so the leftover check does not report them. */ +/** ctx.statements: the command's statement values read as its own + * input. `values` only reads them; `take` consumes a verb's values so + * the leftover check does not report them. */ export function makeStatementSurface(invocation: Invocation): StatementSurface { const { state } = invocation; return { + values: () => + state.statementValues + .filter((value) => !value.consumed) + .map((value) => ({ + verb: value.verb, + text: value.values.join(" "), + values: value.values, + })), take: (verb: V): StatementAnswer[] => { if (!Object.hasOwn(state.statements, verb)) { throw constructionError( diff --git a/packages/cli-engine/tests/statement-take.test.ts b/packages/cli-engine/tests/statement-take.test.ts index cc913cab..5a4ec97b 100644 --- a/packages/cli-engine/tests/statement-take.test.ts +++ b/packages/cli-engine/tests/statement-take.test.ts @@ -120,6 +120,57 @@ describe("ctx.statements.take", () => { }); }); +describe("ctx.statements.values", () => { + test("lists every unconsumed value in argv order without consuming any", async () => { + const result = await cliWith(async (ctx) => { + const listed = ctx.statements.values(); + const [legacy] = await ctx.prompt.statements([ + { + question: "What happens to Legacy?", + subject: "Legacy", + verbs: ["rename", "delete"], + validate: () => undefined, + }, + ]); + return { listed, legacy, renames: ctx.statements.take("rename") }; + }).run(["probe", "--rename", "A:B", "--delete", "Legacy"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { + listed: [ + { verb: "rename", text: "A:B", values: ["A:B"] }, + { verb: "delete", text: "Legacy", values: ["Legacy"] }, + ], + legacy: { verb: "delete", text: "Legacy", values: ["Legacy"] }, + renames: [{ verb: "rename", text: "A:B", values: ["A:B"] }], + }, + }); + }); + + test("after a take lists only what is left, and leaves it for the leftover check", async () => { + const seen: unknown[] = []; + const result = await cliWith(async (ctx) => { + ctx.statements.take("rename"); + seen.push(ctx.statements.values()); + }).run([ + "probe", + "--rename", + "A:B", + "--delete", + "Legacy", + "--rename", + "C:D", + "--json", + ]); + + expect(seen).toEqual([ + [{ verb: "delete", text: "Legacy", values: ["Legacy"] }], + ]); + expect(errorOf(result)?.code).toBe("CLI.CONSENT_UNUSED"); + }); +}); + describe("a question listing a verb the command took", () => { const takeThenAsk = async (ctx: CommandContext) => { const renames = ctx.statements.take("rename"); From 990fe242113c8fb6d3133fd79403ffd7c99124a9 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:12:40 +0200 Subject: [PATCH 17/21] fix(engine): printed statement flags are shell-safe, and the prompt shows each verb's form Every printed flag form single-quotes a value with anything but plain characters (POSIX escape for an embedded quote) and joins a value starting with '-' with '='. The prompt shows each verb with its form; a rejected answer under clack is asked again in an empty field with the reason above the question. A multi-line why is indented on every line. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/README.md | 2 +- .../src/execution/clack-renderer.ts | 21 +-- packages/cli-engine/src/execution/prompts.ts | 60 +++++--- .../cli-engine/src/execution/rendering.ts | 6 +- .../cli-engine/tests/clack-prompts.test.ts | 11 +- packages/cli-engine/tests/execution.test.ts | 2 +- .../tests/statement-edge-cases.test.ts | 2 +- .../tests/statement-flag-forms.test.ts | 133 ++++++++++++++++++ .../tests/statement-prefix-subjects.test.ts | 2 +- .../tests/statement-prompts.test.ts | 14 +- .../cli-engine/tests/statement-take.test.ts | 2 +- 11 files changed, 204 insertions(+), 51 deletions(-) create mode 100644 packages/cli-engine/tests/statement-flag-forms.test.ts diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index c9eb73d6..10a71a6c 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -52,7 +52,7 @@ Each occurrence of `--` takes exactly `arity` values, in argv order across The engine answers the question in this order: 1. A verb flag whose first value names the subject: the value is the subject, or starts with `:`. `--delete Legacy` and `--rename Legacy:Archive` both name `Legacy`. Within one batch, a value equal to a subject answers that subject first, and any other value goes to the longest subject it names, so `--delete A:B` answers the question about `A:B`, not the one about `A`. Ask questions whose subjects are prefixes of one another in one batch. A value `validate` rejects fails the run with `CLI.PROMPT_INVALID`. -2. With no such value, a non-interactive run, or one under `--yes`, fails with `CLI.CONSENT_REQUIRED`. Its next actions give one flag to pass per verb, and its `meta` carries `subject`, `verbs` and `unanswered`. +2. With no such value, a non-interactive run, or one under `--yes`, fails with `CLI.CONSENT_REQUIRED`. Its next actions give one flag to pass per verb, safe to paste into a shell: a value with anything but plain characters is single-quoted, and a value starting with `-` is written `--=`, and its `meta` carries `subject`, `verbs` and `unanswered`. 3. Otherwise the user is asked, and answers ` `, or `` alone to mean the subject. A verb with an arity above 1 takes its values from the text, separated by whitespace, and `text` is them joined by one space, as for a flag. A rejected answer is asked again on a terminal; with scripted or piped input it fails with `CLI.PROMPT_INVALID`. `ctx.prompt.statements([...])` asks several questions at once and returns the answers in order. Flags answer what they can, a refusal lists every question still unanswered, and an interactive run asks the rest one after another. diff --git a/packages/cli-engine/src/execution/clack-renderer.ts b/packages/cli-engine/src/execution/clack-renderer.ts index 97648d81..bf0c78f0 100644 --- a/packages/cli-engine/src/execution/clack-renderer.ts +++ b/packages/cli-engine/src/execution/clack-renderer.ts @@ -55,14 +55,10 @@ export interface ClackRenderer { /** Type-to-confirm: anything but the token re-prompts, so the only * ways out are the exact token and cancelling. */ confirmToken(question: string, token: string): Promise; - /** Free text checked by `check`: a rejected answer shows its message - * and re-prompts, so the only ways out are an accepted answer and - * cancelling. */ - statement( - question: string, - placeholder: string, - check: (value: string) => string | undefined, - ): Promise; + /** Free text in an empty field. The caller checks the answer and asks + * again with the reason in the message, so a rejected answer never + * stays in the field to be appended to. */ + statement(message: string): Promise; select( question: string, options: ReadonlyArray<{ value: T; label: string }>, @@ -110,14 +106,7 @@ export async function makeClackRenderer( ? undefined : `Type ${token} exactly, or press Ctrl-C.`, }), - statement: (question, placeholder, check) => - clack.text({ - input, - output, - message: question, - placeholder, - validate: (value) => check(value ?? ""), - }), + statement: (message) => clack.text({ input, output, message }), select: ( question: string, options: ReadonlyArray<{ value: T; label: string }>, diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index 58accf08..7e8a5a0d 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -45,6 +45,7 @@ import { announceUrl } from "./open-url"; import type { DeclaredStatements, StatementFlagValue } from "./statement-flags"; const WHITESPACE = /\s/; +const SHELL_PLAIN = /^[A-Za-z0-9_.:@%+/,=-]+$/; const WHITESPACES = /\s+/; /** How often browserWait asks whether the user has finished. */ @@ -61,8 +62,35 @@ function consumeConfirmValue(state: RunState, token: string): boolean { return true; } +/** A value as a POSIX shell reads it back: plain values as they are, + * anything else in single quotes, an embedded quote written `'\''`. */ +function shellQuote(value: string): string { + return SHELL_PLAIN.test(value) + ? value + : `'${value.replaceAll("'", "'\\''")}'`; +} + +/** The flag as a user would paste it. A first value starting with `-` + * would read as a flag, so it is joined with `=`. */ function flagForm(verb: string, values: readonly string[]): string { - return `--${verb} ${values.join(" ")}`; + const [first, ...rest] = values.map(shellQuote); + const head = values[0].startsWith("-") + ? `--${verb}=${first}` + : `--${verb} ${first}`; + return [head, ...rest].join(" "); +} + +/** What a question accepts, as the prompt shows it: each verb with its + * form when it has one. */ +function statementHint( + question: StatementQuestion, +): string { + return question.verbs + .map((verb) => { + const form = question.forms?.[verb]; + return form === undefined ? verb : `${verb} ${form}`; + }) + .join(" or "); } function namesSubject(value: string, subject: string): boolean { @@ -628,23 +656,23 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { const askStatement = async ( question: StatementQuestion, ): Promise> => { + const asked = `${question.question} (${statementHint(question)})`; if (useClack()) { - const raw = await renderWithClack(question.question, (r) => - r.statement(question.question, question.verbs.join(" or "), (value) => { - const parsed = parseStatement(value, question, state); - return "problem" in parsed ? parsed.problem : undefined; - }), - ); - const parsed = parseStatement(raw, question, state); - if ("problem" in parsed) { - throw promptInvalid(question.question, raw); - } - return parsed.answer; + const askWithClack = async ( + problem: string | undefined, + ): Promise> => { + const message = problem === undefined ? asked : `${problem}\n${asked}`; + const raw = await renderWithClack(question.question, (r) => + r.statement(message), + ); + const parsed = parseStatement(raw, question, state); + return "problem" in parsed + ? askWithClack(parsed.problem) + : parsed.answer; + }; + return askWithClack(undefined); } - const raw = await ask( - question.question, - `? ${question.question} (${question.verbs.join("/")}) `, - ); + const raw = await ask(question.question, `? ${asked} `); if (typeof raw !== "string") { throw promptInvalid(question.question, String(raw)); } diff --git a/packages/cli-engine/src/execution/rendering.ts b/packages/cli-engine/src/execution/rendering.ts index 3e5a9b02..7d301a81 100644 --- a/packages/cli-engine/src/execution/rendering.ts +++ b/packages/cli-engine/src/execution/rendering.ts @@ -308,7 +308,11 @@ export function writeDiagnostic( const code = paint("muted", `[${diagnostic.code}]`); stream.write(`${glyph} ${code} ${diagnostic.summary}\n`); if (diagnostic.why !== undefined) { - stream.write(` ${paint("muted", `why: ${diagnostic.why}`)}\n`); + const [first, ...rest] = String(diagnostic.why).split("\n"); + stream.write(` ${paint("muted", `why: ${first}`)}\n`); + for (const line of rest) { + stream.write(` ${paint("muted", line)}\n`); + } } for (const action of renderableNextActions(diagnostic.nextActions)) { stream.write(`${renderNextAction(action, paint)}\n`); diff --git a/packages/cli-engine/tests/clack-prompts.test.ts b/packages/cli-engine/tests/clack-prompts.test.ts index 4eb71e85..bdb021d3 100644 --- a/packages/cli-engine/tests/clack-prompts.test.ts +++ b/packages/cli-engine/tests/clack-prompts.test.ts @@ -279,12 +279,13 @@ describe("the clack tier resolves prompt values", () => { expect(result.exitCode).toBe(3); }); - test("statement: a rejected answer shows the reason and re-prompts", async () => { + test("statement: a rejected answer shows the reason and asks again with an empty field", async () => { const result = await runInteractive( (prompt) => prompt.statement("What happens to Legacy?", { subject: "Legacy", verbs: ["rename", "delete"], + forms: { rename: "Legacy:" }, validate: (verb, text) => verb === "rename" && !text.startsWith("Legacy:") ? "Write the rename as Legacy:." @@ -293,14 +294,9 @@ describe("the clack tier resolves prompt values", () => { [ ..."drop", ENTER, - BACKSPACE, - BACKSPACE, - BACKSPACE, - BACKSPACE, ..."rename Archive", ENTER, - ...Array.from({ length: "Archive".length }, () => BACKSPACE), - ..."Legacy:Archive", + ..."rename Legacy:Archive", ENTER, ], ); @@ -309,6 +305,7 @@ describe("the clack tier resolves prompt values", () => { expect(answerIn(result.plainStderr)).toBe( '{"verb":"rename","text":"Legacy:Archive","values":["Legacy:Archive"]}', ); + expect(result.plainStderr).toContain("rename Legacy: or delete"); expect(result.plainStderr).toContain( "Start the answer with rename or delete.", ); diff --git a/packages/cli-engine/tests/execution.test.ts b/packages/cli-engine/tests/execution.test.ts index f7855db6..0e5d51db 100644 --- a/packages/cli-engine/tests/execution.test.ts +++ b/packages/cli-engine/tests/execution.test.ts @@ -1067,7 +1067,7 @@ describe("parse and route failures", () => { expect(result.stderr).toBe( "✘ [CLI.INVALID_ARGUMENTS] Expected argument for name\n" + ' why: Expected "z" to be one of (a|b), did you mean "a" or "b"?\n' + - "Failed to parse \"q\" for count: expected a number, received 'q'\n", + " Failed to parse \"q\" for count: expected a number, received 'q'\n", ); }); diff --git a/packages/cli-engine/tests/statement-edge-cases.test.ts b/packages/cli-engine/tests/statement-edge-cases.test.ts index 96affd8b..7296933e 100644 --- a/packages/cli-engine/tests/statement-edge-cases.test.ts +++ b/packages/cli-engine/tests/statement-edge-cases.test.ts @@ -329,7 +329,7 @@ describe("what each channel sees", () => { }); expect(result.exitCode).toBe(0); - expect(result.stderr).toBe("? What happens to Legacy? (rename/delete) "); + expect(result.stderr).toBe("? What happens to Legacy? (rename or delete) "); for (const line of result.stdout.trim().split("\n")) { expect(() => JSON.parse(line)).not.toThrow(); } diff --git a/packages/cli-engine/tests/statement-flag-forms.test.ts b/packages/cli-engine/tests/statement-flag-forms.test.ts new file mode 100644 index 00000000..26fedd56 --- /dev/null +++ b/packages/cli-engine/tests/statement-flag-forms.test.ts @@ -0,0 +1,133 @@ +/** + * The flag forms the engine prints are safe to paste into a shell and + * accepted when pasted back: a value with anything but plain + * characters is single-quoted, and a value starting with `-` is joined + * with `=`. + */ +import { defineCommand, type PromptSurface } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +function cliAsking(subject: string) { + const ask = (prompt: PromptSurface) => + prompt.statement(`What happens to ${subject}?`, { + subject, + verbs: ["delete"], + validate: () => undefined, + }); + const probe = defineCommand({ + help: { summary: "Statement probe" }, + statements: { delete: { arity: 1 } }, + handler: async (_args, ctx) => { + const answer = await ask(ctx.prompt); + return ok( + ctx.present( + { data: { answer } }, + { + human: () => [], + stdout: () => [], + json: () => ({ answer }), + next: () => [], + }, + ), + ); + }, + }); + return createTestCli({ commands: { probe } }); +} + +function errorOf(result: Awaited>) { + const last = result.json[result.json.length - 1]; + return last.kind === "result" && !last.envelope.ok + ? last.envelope.error + : undefined; +} + +describe("printed flag forms", () => { + test.each([ + ["a plain subject", "Legacy", "Pass --delete Legacy"], + ["a subject with ':' and '.'", "User.name:x", "Pass --delete User.name:x"], + [ + "a subject a shell would run part of", + 'a b:c"d--$é; DROP TABLE Profile', + `Pass --delete 'a b:c"d--$é; DROP TABLE Profile'`, + ], + ["a subject with a single quote", "it's", `Pass --delete 'it'\\''s'`], + ["a subject starting with '-'", "-rf", "Pass --delete=-rf"], + [ + "a subject starting with '-' that needs quoting", + "-r f", + "Pass --delete='-r f'", + ], + ])("%s", async (_case, subject, label) => { + const result = await cliAsking(subject).run(["probe", "--json"]); + + expect(errorOf(result)?.nextActions).toEqual([ + { kind: "user-choice", label }, + ]); + }); + + test("the form printed for a subject starting with '-' is accepted back", async () => { + const result = await cliAsking("-rf").run(["probe", "--delete=-rf"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + answer: { verb: "delete", text: "-rf", values: ["-rf"] }, + }); + }); + + test("a leftover value is quoted in the error too", async () => { + const result = await cliAsking("Legacy").run([ + "probe", + "--delete", + "Legacy", + "--delete", + "x; rm", + "--json", + ]); + + expect(errorOf(result)?.summary).toBe( + "--delete 'x; rm' was given but nothing in this run asked about x; rm.", + ); + }); +}); + +describe("a refusal listing several questions", () => { + test("indents every line of why", async () => { + const probe = defineCommand({ + help: { summary: "Statement probe" }, + statements: { delete: { arity: 1 } }, + handler: async (_args, ctx) => { + await ctx.prompt.statements( + ["A", "B"].map((subject) => ({ + question: `What happens to ${subject}?`, + subject, + verbs: ["delete"], + validate: () => undefined, + })), + ); + return ok( + ctx.present( + { data: {} }, + { + human: () => [], + stdout: () => [], + json: () => ({}), + next: () => [], + }, + ), + ); + }, + }); + const result = await createTestCli({ commands: { probe } }).run([ + "probe", + "--format", + "human", + ]); + + expect(result.stderr).toContain( + " why: What happens to A?\n What happens to B?\n", + ); + }); +}); diff --git a/packages/cli-engine/tests/statement-prefix-subjects.test.ts b/packages/cli-engine/tests/statement-prefix-subjects.test.ts index a97d3b34..ef320388 100644 --- a/packages/cli-engine/tests/statement-prefix-subjects.test.ts +++ b/packages/cli-engine/tests/statement-prefix-subjects.test.ts @@ -115,7 +115,7 @@ describe("subjects that prefix one another, in one batch", () => { ); expect(result.exitCode).toBe(0); - expect(result.stderr).toBe("? What happens to A? (rename/delete) "); + expect(result.stderr).toBe("? What happens to A? (rename or delete) "); expect(result.presented?.data).toEqual({ answer: [ { verb: "rename", text: "A:C", values: ["A:C"] }, diff --git a/packages/cli-engine/tests/statement-prompts.test.ts b/packages/cli-engine/tests/statement-prompts.test.ts index 3a0d3a6f..7da99d90 100644 --- a/packages/cli-engine/tests/statement-prompts.test.ts +++ b/packages/cli-engine/tests/statement-prompts.test.ts @@ -190,7 +190,7 @@ describe("without a flag, a non-interactive run refuses", () => { '"Legacy" needs a statement, and the session is not interactive.', why: LEGACY_QUESTION, nextActions: [ - { kind: "user-choice", label: "Pass --rename Legacy:" }, + { kind: "user-choice", label: "Pass --rename 'Legacy:'" }, { kind: "user-choice", label: "Pass --delete Legacy" }, ], meta: { @@ -218,7 +218,7 @@ describe("without a flag, a non-interactive run refuses", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toContain("[CLI.CONSENT_REQUIRED]"); - expect(result.stderr).toContain("Pass --rename Legacy:"); + expect(result.stderr).toContain("Pass --rename 'Legacy:'"); expect(result.stderr).toContain("Pass --delete Legacy"); }); }); @@ -259,7 +259,9 @@ describe("an interactive run asks", () => { }); expect(result.exitCode).toBe(0); - expect(result.stderr).toContain(`? ${LEGACY_QUESTION} (rename/delete) `); + expect(result.stderr).toContain( + `? ${LEGACY_QUESTION} (rename Legacy: or delete) `, + ); }); test("an unknown verb fails the line renderer", async () => { @@ -303,7 +305,7 @@ describe("prompt.statements asks several questions together", () => { summary: '"User.name" needs a statement, and the session is not interactive.', nextActions: [ - { kind: "user-choice", label: "Pass --rename User.name:" }, + { kind: "user-choice", label: "Pass --rename 'User.name:'" }, { kind: "user-choice", label: "Pass --delete User.name" }, ], meta: { @@ -323,9 +325,9 @@ describe("prompt.statements asks several questions together", () => { '2 subjects need a statement, and the session is not interactive: "Legacy", "User.name".', why: `${LEGACY_QUESTION}\n${USER_NAME_QUESTION}`, nextActions: [ - { kind: "user-choice", label: "Pass --rename Legacy:" }, + { kind: "user-choice", label: "Pass --rename 'Legacy:'" }, { kind: "user-choice", label: "Pass --delete Legacy" }, - { kind: "user-choice", label: "Pass --rename User.name:" }, + { kind: "user-choice", label: "Pass --rename 'User.name:'" }, { kind: "user-choice", label: "Pass --delete User.name" }, ], meta: { diff --git a/packages/cli-engine/tests/statement-take.test.ts b/packages/cli-engine/tests/statement-take.test.ts index 5a4ec97b..0695258a 100644 --- a/packages/cli-engine/tests/statement-take.test.ts +++ b/packages/cli-engine/tests/statement-take.test.ts @@ -216,7 +216,7 @@ describe("a question listing a verb the command took", () => { expect(errorOf(result)).toMatchObject({ code: "CLI.CONSENT_REQUIRED", nextActions: [ - { kind: "user-choice", label: "Pass --rename Legacy:" }, + { kind: "user-choice", label: "Pass --rename 'Legacy:'" }, { kind: "user-choice", label: "Pass --delete Legacy" }, ], }); From c560ced5b8fea5e2b1fd74d142e4730e5da99aa9 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:16:14 +0200 Subject: [PATCH 18/21] fix(engine): quote a printed statement value that starts with '=' zsh reads a bare leading '=' as a command lookup, so such a value is now single-quoted. Also documents that only the first value of a verb with arity above 1 can be written --=, that a rejected typed answer is asked again only on a terminal (ADR 0006), and how a multi-line why is indented (error conventions). Signed-off-by: willbot Signed-off-by: Will Madden --- docs/architecture/adrs/0006-consent-as-a-statement.md | 2 +- docs/product/error-conventions.md | 2 ++ packages/cli-engine/src/commands.ts | 4 +++- packages/cli-engine/src/execution/prompts.ts | 2 +- packages/cli-engine/tests/statement-flag-forms.test.ts | 5 +++++ 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/architecture/adrs/0006-consent-as-a-statement.md b/docs/architecture/adrs/0006-consent-as-a-statement.md index c3158928..4160eaca 100644 --- a/docs/architecture/adrs/0006-consent-as-a-statement.md +++ b/docs/architecture/adrs/0006-consent-as-a-statement.md @@ -27,7 +27,7 @@ The command asks with `ctx.prompt.statement(question, { subject, verbs, validate 1. From the command line, by a value of one of its verbs that names the subject. A value `validate` rejects fails the run with `CLI.PROMPT_INVALID`, since a wrong flag cannot be corrected by asking again. 2. Outside an interactive terminal, or under `--yes`, by nobody: the run fails with one `CLI.CONSENT_REQUIRED` that lists every unanswered question with the flag that would answer it. -3. Interactively, by the user typing ` ` (or `` alone for the subject itself), validated the same way and asked again when rejected. +3. Interactively, by the user typing ` ` (or `` alone for the subject itself), validated the same way. A rejected answer is asked again on a terminal; scripted or piped input cannot be corrected, so it fails with `CLI.PROMPT_INVALID`. A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees: a command whose work depends on some statements, as the ORM plans with its renames before it knows what still loses data, takes a verb's values in argv order with `ctx.statements.take(verb)`, which consumes them. diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 1f8503dd..7ba5663e 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -94,6 +94,8 @@ Human-readable errors should follow this shape: 5. where when relevant 6. hint for `--log-level verbose` when helpful +A `why` that runs over several lines is indented on every line, each continuation line aligned under the text after `why: `. + Example: ```text diff --git a/packages/cli-engine/src/commands.ts b/packages/cli-engine/src/commands.ts index 79d5220c..7340afd3 100644 --- a/packages/cli-engine/src/commands.ts +++ b/packages/cli-engine/src/commands.ts @@ -152,7 +152,9 @@ export interface SpawnDeclarations { */ export interface StatementSpec { /** How many argv values each occurrence of the flag takes. Giving - * another number is CLI.INVALID_ARGUMENTS. */ + * another number is CLI.INVALID_ARGUMENTS. Only the first value can + * be written `--=`, so with an arity above 1 a later + * value starting with `-` cannot be passed. */ readonly arity: number; /** The flag's help brief, held to the help standard like any flag's. */ readonly brief?: string; diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index 7e8a5a0d..2e71444a 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -45,7 +45,7 @@ import { announceUrl } from "./open-url"; import type { DeclaredStatements, StatementFlagValue } from "./statement-flags"; const WHITESPACE = /\s/; -const SHELL_PLAIN = /^[A-Za-z0-9_.:@%+/,=-]+$/; +const SHELL_PLAIN = /^[A-Za-z0-9_.:@%+/,-][A-Za-z0-9_.:@%+/,=-]*$/; const WHITESPACES = /\s+/; /** How often browserWait asks whether the user has finished. */ diff --git a/packages/cli-engine/tests/statement-flag-forms.test.ts b/packages/cli-engine/tests/statement-flag-forms.test.ts index 26fedd56..64fcf2a2 100644 --- a/packages/cli-engine/tests/statement-flag-forms.test.ts +++ b/packages/cli-engine/tests/statement-flag-forms.test.ts @@ -55,6 +55,11 @@ describe("printed flag forms", () => { ], ["a subject with a single quote", "it's", `Pass --delete 'it'\\''s'`], ["a subject starting with '-'", "-rf", "Pass --delete=-rf"], + ["a subject starting with '='", "=eq", "Pass --delete '=eq'"], + ["a subject with a newline", "a\nb", "Pass --delete 'a\nb'"], + ["a subject with a backslash", "a\\b", "Pass --delete 'a\\b'"], + ["a subject with '$'", "$HOME", "Pass --delete '$HOME'"], + ["a subject with a backtick", "`id`", "Pass --delete '`id`'"], [ "a subject starting with '-' that needs quoting", "-r f", From 206c1af1a130a7c2b360b93b15e5311ef9fe71de Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:53:54 +0200 Subject: [PATCH 19/21] fix(engine): unused --confirm tokens fail the run, refusals name stray flags, and a signal cancels a waiting prompt An unconsumed --confirm token is now CLI.CONSENT_UNUSED in every command, like a statement leftover. A statement refusal lists any given statement flag that names none of its subjects and any --confirm token, and CONSENT_UNUSED lists the subjects the run asked about. Next actions read "Run the command again with -- ". A SIGINT or SIGTERM delivered while a prompt waits for input cancels the prompt (CLI.PROMPT_CANCELLED, exit 3) instead of leaving a clack prompt holding the terminal. Signed-off-by: willbot Signed-off-by: Will Madden --- .../adrs/0006-consent-as-a-statement.md | 2 + docs/product/cli-style-guide.md | 2 +- docs/reference/error-reference.md | 6 +- packages/cli-engine/README.md | 2 +- .../src/execution/clack-renderer.ts | 12 +- packages/cli-engine/src/execution/engine.ts | 15 +- packages/cli-engine/src/execution/prompts.ts | 181 ++++++++++++++---- .../tests/consent-leftovers.test.ts | 120 ++++++++++++ .../tests/interaction-affordances.test.ts | 8 +- .../cli-engine/tests/prompt-signals.test.ts | 107 +++++++++++ .../tests/statement-flag-forms.test.ts | 54 ++++-- .../tests/statement-prompts.test.ts | 50 ++++- .../cli-engine/tests/statement-take.test.ts | 10 +- 13 files changed, 494 insertions(+), 75 deletions(-) create mode 100644 packages/cli-engine/tests/consent-leftovers.test.ts create mode 100644 packages/cli-engine/tests/prompt-signals.test.ts diff --git a/docs/architecture/adrs/0006-consent-as-a-statement.md b/docs/architecture/adrs/0006-consent-as-a-statement.md index 4160eaca..81c93101 100644 --- a/docs/architecture/adrs/0006-consent-as-a-statement.md +++ b/docs/architecture/adrs/0006-consent-as-a-statement.md @@ -43,6 +43,8 @@ These came from the ORM's first use and the reviews of this change, all in engin - **`ctx.statements.values()`** lists every unconsumed value in argv order without consuming any, so a command can show what it was given without spending it. - **Longest subject first.** Within one `statements` batch, a value equal to a subject answers that subject first, and any other value goes to the question with the longest subject it names. So `A:B` answers the question about `A:B`, not the one about `A`. Questions whose subjects are prefixes of one another belong in one batch, because separate calls cannot be resolved this way. - **`:` in a subject is allowed**, with the matching rule unchanged. +- **Unused `--confirm` tokens fail the run** with `CLI.CONSENT_UNUSED`, in every command, deliberately: a token that matched no consent is the same mistake as a misspelled statement. A statement refusal also names any `--confirm` token and any statement flag that names none of its subjects, so the user learns of the mistake before answering a prompt. +- **A signal while a prompt waits cancels the prompt**, as Ctrl-C at the prompt does: `CLI.PROMPT_CANCELLED`, exit 3. - **`last: true`** marks a batch as the run's final ask: values still unconsumed fail with `CLI.CONSENT_UNUSED` as soon as it is answered, before the command acts on the answers. ## Consequences diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index 39c10377..9a8f83dd 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -217,7 +217,7 @@ Consent is a question `--yes` never answers and Enter never answers. It comes in - **A yes/no consent** (`ctx.prompt.consent`). With a token, the user types the token, or passes `--confirm `. Without a token, only an interactive terminal can grant it. - **A statement** (`ctx.prompt.statement`). The user states what should happen to a subject with a verb: `delete`, or `rename Legacy:Archive`. The command declares its verbs, and each verb is a flag on that command only, so `--delete Legacy` gives the same answer on the command line. A flag value answers the question only when it names the subject: it is the subject, or starts with `:`. -Without an answer, a non-interactive run fails with `CLI.CONSENT_REQUIRED` and lists the flags that would answer every open question. A statement flag that no question used fails the run with `CLI.CONSENT_UNUSED`, because a mistyped subject must not pass silently. +Without an answer, a non-interactive run fails with `CLI.CONSENT_REQUIRED` and lists the flags that would answer every open question. A statement flag that no question used, or a `--confirm` token that no consent asked for, fails the run with `CLI.CONSENT_UNUSED`, because a mistyped subject or token must not pass silently. ## Loading Indicators diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index a267557c..e98ceb5c 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -156,11 +156,11 @@ The config file's `$prismaConfig` marker declares a version other than the one t A consent prompt was reached under `--yes` or in a non-interactive session. Consent has no default answer and `--yes` does not grant it, so there is nothing for the run to assume. When the consent declares a token, the message and next action say to pass `--confirm `, and the token travels in meta; without a token, the only path is running the command interactively. Meta: `consentToken` (only when the consent declares a token). -A statement prompt (`ctx.prompt.statement` or `ctx.prompt.statements`) raises the same code when no statement flag on the command line answers it. One error lists every question still unanswered: the summary names the subjects, `why` carries the questions, and the next actions give one flag to pass per verb, such as `--delete Legacy` or `--rename Legacy:`. Meta: `unanswered` (a list of `{ subject, verbs }`), plus `subject` and `verbs` when exactly one question is unanswered. +A statement prompt (`ctx.prompt.statement` or `ctx.prompt.statements`) raises the same code when no statement flag on the command line answers it. One error lists every question still unanswered: the summary names the subjects, `why` carries the questions, and the next actions give one flag to pass per verb, such as `--delete Legacy` or `--rename 'Legacy:'`. `why` also lists every given statement flag that names none of the questions' subjects, and any `--confirm` token, which answers no statement, so a misspelled flag is reported before anyone answers a prompt. Meta: `unanswered` (a list of `{ subject, verbs }`), plus `subject` and `verbs` when exactly one question is unanswered; `unmatched` (a list of `{ verb, values }`) and `confirm` (the tokens) when any. ### CLI.CONSENT_UNUSED -A statement flag such as `--delete Legacy` was given but answered nothing. The summary says why for each flag: no statement prompt asked about that subject and no `ctx.statements.take` took it (usually a mistyped subject), or the question about it was already answered by another flag (a flag given twice, or two verbs for one subject). Raised when the handler returns from a run that would otherwise have succeeded, so a run that failed for another reason reports that reason only; or earlier, as soon as `ctx.prompt.statements(questions, { last: true })` has answered its questions, before the command acts. Exits 2. Meta: `unused` (a list of `{ verb, values }`). +A consent flag was given but answered nothing: a statement flag such as `--delete Legacy`, or a `--confirm` token. The summary says why for each flag: no statement prompt asked about that subject and no `ctx.statements.take` took it (usually a mistyped subject), the question about it was already answered by another flag (a flag given twice, or two verbs for one subject), or no consent in the run asked for that `--confirm` token. The `--confirm` case applies to every command, deliberately: a token that matched nothing is the same mistake as a misspelled statement. `why` lists the subjects the run asked about. Raised when the handler returns from a run that would otherwise have succeeded, so a run that failed for another reason reports that reason only; or earlier, as soon as `ctx.prompt.statements(questions, { last: true })` has answered its questions, before the command acts. Exits 2. Meta: `unused` (a list of `{ verb, values }`), `confirm` (the unused `--confirm` tokens, when any), `asked` (the subjects asked about, when any). ### CLI.CREDENTIALS_LOCKED @@ -196,7 +196,7 @@ A `ctx.packages` operation (an install, or running a package through the manager ### CLI.PROMPT_CANCELLED -The user cancelled a prompt: EOF on stdin at a line-rendered prompt, a clack cancel (Ctrl-C at the prompt UI), an abort during a browserWait poll, Ctrl-C at the `prisma auth login` paste prompt, or — via the service commands' `userCancelledError` — consent declined interactively. Settles with exit 3, the cancellation code, instead of 2. Meta: none. +The user cancelled a prompt: EOF on stdin at a line-rendered prompt, a clack cancel (Ctrl-C at the prompt UI), a SIGINT or SIGTERM delivered while a prompt waits for input, an abort during a browserWait poll, Ctrl-C at the `prisma auth login` paste prompt, or — via the service commands' `userCancelledError` — consent declined interactively. Settles with exit 3, the cancellation code, instead of 2. Meta: none. ### CLI.PROMPT_INVALID diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index 10a71a6c..916c5b39 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -59,6 +59,6 @@ The engine answers the question in this order: A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. A later question may still list a taken verb: no flag value of it is left to answer the question, so the verb serves the refusal's `--` form and what a user may type. `ctx.statements.values()` lists every unconsumed value of every verb in argv order without consuming any, for showing what a run was given, such as in a line that repeats the command. Take before any `last: true` batch, which reports every value still unconsumed. -Each flag answers one question. A run that succeeds with a flag nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject or a second answer to one question is never ignored. That check runs when the handler returns, after the command has acted. A command that asks everything in one batch passes `statements(questions, { last: true })` to get the same failure right after the questions are answered, before it does anything with them. +Each flag answers one question. A run that succeeds with a flag nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject or a second answer to one question is never ignored. The same holds for a `--confirm` token no consent asked for, in every command. A refusal also names any given statement flag that matches none of its questions, and any `--confirm` token, so the user hears of it before answering a prompt. That check runs when the handler returns, after the command has acted. A command that asks everything in one batch passes `statements(questions, { last: true })` to get the same failure right after the questions are answered, before it does anything with them. Part of [prisma/prisma-cli](https://github.com/prisma/prisma-cli). diff --git a/packages/cli-engine/src/execution/clack-renderer.ts b/packages/cli-engine/src/execution/clack-renderer.ts index bf0c78f0..8497d703 100644 --- a/packages/cli-engine/src/execution/clack-renderer.ts +++ b/packages/cli-engine/src/execution/clack-renderer.ts @@ -43,7 +43,9 @@ function toWritable(out: OutputStream): Writable { /** * Cancellation surfaces as clack's cancel symbol (isCancel), which - * covers the \x03 byte path; prompts.ts maps it to CLI.PROMPT_CANCELLED. + * covers the \x03 byte path and an aborted `signal` (a SIGINT or + * SIGTERM delivered while the prompt waits); prompts.ts maps it to + * CLI.PROMPT_CANCELLED. */ export interface ClackRenderer { confirm( @@ -75,6 +77,7 @@ export interface ClackRenderer { export async function makeClackRenderer( stdin: InputStream, stderr: OutputStream, + signal: AbortSignal, ): Promise { const clack = await import("@clack/prompts"); const input = toReadable(stdin); @@ -85,6 +88,7 @@ export async function makeClackRenderer( clack.confirm({ input, output, + signal, message: question, initialValue: initial ?? false, }), @@ -92,6 +96,7 @@ export async function makeClackRenderer( clack.confirm({ input, output, + signal, message: question, initialValue: false, }), @@ -99,6 +104,7 @@ export async function makeClackRenderer( clack.text({ input, output, + signal, message: `${question} Type ${token} to confirm.`, placeholder: token, validate: (value) => @@ -106,7 +112,7 @@ export async function makeClackRenderer( ? undefined : `Type ${token} exactly, or press Ctrl-C.`, }), - statement: (message) => clack.text({ input, output, message }), + statement: (message) => clack.text({ input, output, signal, message }), select: ( question: string, options: ReadonlyArray<{ value: T; label: string }>, @@ -115,6 +121,7 @@ export async function makeClackRenderer( clack.select({ input, output, + signal, message: question, options: options.map((option) => ({ value: option.value, @@ -126,6 +133,7 @@ export async function makeClackRenderer( clack.text({ input, output, + signal, message: question, placeholder, defaultValue: fallback, diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 7a04c028..64def665 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -47,7 +47,7 @@ import { } from "./help"; import { checkNeeds, type NeedsOutcome } from "./needs"; import { configFlagGivenNoValue, versionFlagGiven } from "./pre-parse-argv"; -import { unusedStatementValuesError } from "./prompts"; +import { unusedConsentError } from "./prompts"; import { commandSegments, settleBug, @@ -195,6 +195,11 @@ export interface RunState { * prompt's first await, so an unawaited prompt still blocks * ctx.spawn from handing the same terminal to a child. */ activePrompts: number; + /** True while a prompt waits for the user's keystrokes or line. A + * signal then cancels the prompt (promptCancel) instead of ending + * the run, as Ctrl-C at the prompt does. */ + readingPrompt: boolean; + promptCancel: AbortController; /** Set while a ctx.packages operation is in flight. It serializes the * operations against each other, and blocks ctx.spawn: a child * writing the terminal directly while the manager's output is being @@ -374,6 +379,8 @@ export class EngineImpl implements Engine { delegatedTerminal: undefined, lastChild: undefined, activePrompts: 0, + readingPrompt: false, + promptCancel: new AbortController(), packageOperationRunning: false, deliveredSignal: undefined, pendingForceExit: undefined, @@ -393,6 +400,10 @@ export class EngineImpl implements Engine { runtime.exit(signal === "SIGTERM" ? 143 : 130); return; } + if (state.readingPrompt) { + state.promptCancel.abort(signal); + return; + } state.deliveredSignal = signal; controller.abort(signal); }; @@ -641,7 +652,7 @@ export class EngineImpl implements Engine { return; } state.resolved = true; - const unused = unusedStatementValuesError(state); + const unused = unusedConsentError(state); if (!result.ok) { settleErrored(invocation, result.failure, result.failure.diagnostics); } else if (unused !== undefined && succeeded(state, result.value)) { diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index 2e71444a..2aa73594 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -32,7 +32,7 @@ import type { StatementSurface, StatementsOptions, } from "../context"; -import { CliStructuredError } from "../protocol"; +import { CliStructuredError, type NextAction } from "../protocol"; import type { InputStream } from "../runtime"; import { type ClackRenderer, @@ -152,31 +152,70 @@ function answerFromFlags( return undefined; } +/** Why a refusal could not be answered from the command line: the + * statement values that name none of the batch's subjects, and any + * `--confirm` value, which answers no statement. */ +function strayFlagLines( + state: RunState, + subjects: readonly string[], +): readonly string[] { + const unmatched = state.statementValues.filter( + (value) => + !value.consumed && + longestSubjectNamed(value.values[0], subjects) === undefined, + ); + return [ + ...unmatched.map( + (value) => + `${flagForm(value.verb, value.values)} answers no question; the questions are about ${subjects.join(", ")}.`, + ), + ...state.confirmValues.map( + (token) => + `${flagForm("confirm", [token])} answers no consent in this run; a statement flag is what answers these questions.`, + ), + ]; +} + function statementUnavailable( unanswered: readonly StatementQuestion[], + subjects: readonly string[], state: RunState, ): CliStructuredError { - const subjects = unanswered.map((question) => `"${question.subject}"`); + const quoted = unanswered.map((question) => `"${question.subject}"`); const situation = state.yes ? "which --yes cannot give" : "and the session is not interactive"; const summary = unanswered.length === 1 - ? `${subjects[0]} needs a statement, ${situation}.` - : `${unanswered.length} subjects need a statement, ${situation}: ${subjects.join(", ")}.`; + ? `${quoted[0]} needs a statement, ${situation}.` + : `${unanswered.length} subjects need a statement, ${situation}: ${quoted.join(", ")}.`; const listed = unanswered.map(({ subject, verbs }) => ({ subject, verbs })); + const unmatched = state.statementValues + .filter( + (value) => + !value.consumed && + longestSubjectNamed(value.values[0], subjects) === undefined, + ) + .map(({ verb, values }) => ({ verb, values })); return new CliStructuredError("CLI.CONSENT_REQUIRED", summary, { - why: unanswered.map((question) => question.question).join("\n"), + why: [ + ...unanswered.map((question) => question.question), + ...strayFlagLines(state, subjects), + ].join("\n"), nextActions: unanswered.flatMap((question) => question.verbs.map((verb) => ({ kind: "user-choice" as const, - label: `Pass ${flagForm(verb, [question.forms?.[verb] ?? question.subject])}`, + label: `Run the command again with ${flagForm(verb, [question.forms?.[verb] ?? question.subject])}`, })), ), - meta: - listed.length === 1 - ? { ...listed[0], unanswered: listed } - : { unanswered: listed }, + meta: { + ...(listed.length === 1 ? listed[0] : {}), + unanswered: listed, + ...(unmatched.length === 0 ? {} : { unmatched }), + ...(state.confirmValues.length === 0 + ? {} + : { confirm: [...state.confirmValues] }), + }, }); } @@ -255,36 +294,61 @@ function unusedSentence(state: RunState, value: StatementFlagValue): string { : `${given} was given, but the question about ${asked} was already answered by another flag.`; } -/** Fails a run when a statement flag answered nothing: a mistyped - * subject, or a second answer to one question, must not pass - * silently. */ -export function unusedStatementValuesError( +function unusedActions( + unused: readonly StatementFlagValue[], + state: RunState, +): NextAction[] { + const actions: NextAction[] = []; + if (unused.some((value) => askedSubjectOf(state, value) === undefined)) { + actions.push({ + kind: "user-choice", + label: + "Remove the flag, or spell the subject the way the command names it.", + }); + } + if (unused.some((value) => askedSubjectOf(state, value) !== undefined)) { + actions.push({ kind: "user-choice", label: "Give one flag per question." }); + } + if (state.confirmValues.length > 0) { + actions.push({ + kind: "user-choice", + label: "Remove the --confirm flag: nothing in this run asks for it.", + }); + } + return actions; +} + +/** Fails a run when a consent flag answered nothing: a mistyped + * subject or token, or a second answer to one question, must not + * pass silently. Covers statement values and `--confirm` tokens. */ +export function unusedConsentError( state: RunState, ): CliStructuredError | undefined { const unused = state.statementValues.filter((value) => !value.consumed); - if (unused.length === 0) { + if (unused.length === 0 && state.confirmValues.length === 0) { return undefined; } - const allAsked = unused.every( - (value) => askedSubjectOf(state, value) !== undefined, - ); - return new CliStructuredError( - "CLI.CONSENT_UNUSED", - unused.map((value) => unusedSentence(state, value)).join(" "), - { - nextActions: [ - { - kind: "user-choice", - label: allAsked - ? "Give one flag per question." - : "Remove the flag, or spell the subject the way the command names it.", - }, - ], - meta: { - unused: unused.map(({ verb, values }) => ({ verb, values })), - }, + const sentences = [ + ...unused.map((value) => unusedSentence(state, value)), + ...state.confirmValues.map( + (token) => + `${flagForm("confirm", [token])} answers no consent in this run.`, + ), + ]; + const asked = [...state.askedSubjects]; + return new CliStructuredError("CLI.CONSENT_UNUSED", sentences.join(" "), { + ...(asked.length === 0 + ? {} + : { why: `The run asked about ${asked.join(", ")}.` }), + nextActions: unusedActions(unused, state), + meta: { + unused: unused.map(({ verb, values }) => ({ verb, values })), + ...(state.confirmValues.length === 0 + ? {} + : { confirm: [...state.confirmValues] }), + ...(asked.length === 0 ? {} : { asked }), }, - ); + }); } function makeLineReader( @@ -539,6 +603,35 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { const useClack = (): boolean => hooks.answers === undefined && clackCapable(runtime); + /** Marks the prompt as waiting for input, so a signal cancels it. */ + const reading = async (wait: () => Promise): Promise => { + state.readingPrompt = true; + try { + return await wait(); + } finally { + state.readingPrompt = false; + } + }; + + /** A line read that a signal cancels: resolves undefined, which the + * caller reports as a cancelled prompt. */ + const untilCancelled = ( + line: Promise, + ): Promise => { + const signal = state.promptCancel.signal; + if (signal.aborted) { + return Promise.resolve(undefined); + } + return Promise.race([ + line, + new Promise((resolve) => + signal.addEventListener("abort", () => resolve(undefined), { + once: true, + }), + ), + ]); + }; + const renderWithClack = async ( question: string, run: (r: ClackRenderer) => Promise, @@ -550,10 +643,14 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { [Symbol.asyncIterator]: () => iterator, setRawMode: (enabled) => runtime.stdin.setRawMode?.(enabled), }; - return makeClackRenderer(stdin, runtime.stderr); + return makeClackRenderer( + stdin, + runtime.stderr, + state.promptCancel.signal, + ); })(); const r = await renderer; - const value = await run(r); + const value = await reading(() => run(r)); if (r.isCancel(value)) { throw promptCancelled(question); } @@ -577,7 +674,8 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { } runtime.stderr.write(rendered); readLine ??= makeLineReader(runtime.stdin, invocation); - const line = await readLine(); + const readNext = readLine; + const line = await reading(() => untilCancelled(readNext())); if (line === undefined) { throw promptCancelled(question); } @@ -726,7 +824,11 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { (_question, index) => fromFlags[index] === undefined, ); if (unanswered.length > 0 && (state.yes || !state.interactive)) { - throw statementUnavailable(unanswered, state); + throw statementUnavailable( + unanswered, + questions.map((question) => question.subject), + state, + ); } const answerFrom = async (index: number): Promise[]> => { if (index === questions.length) { @@ -736,8 +838,7 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { return [answer, ...(await answerFrom(index + 1))]; }; const answers = await answerFrom(0); - const unused = - opts?.last === true ? unusedStatementValuesError(state) : undefined; + const unused = opts?.last === true ? unusedConsentError(state) : undefined; if (unused !== undefined) { throw unused; } diff --git a/packages/cli-engine/tests/consent-leftovers.test.ts b/packages/cli-engine/tests/consent-leftovers.test.ts new file mode 100644 index 00000000..42fea31f --- /dev/null +++ b/packages/cli-engine/tests/consent-leftovers.test.ts @@ -0,0 +1,120 @@ +/** + * A consent flag that answered nothing fails the run: a statement value + * no question matched, or a `--confirm` token no consent asked for. + * The refusal for a statement batch reports them up front, so a + * misspelled flag is not found only after the user has answered. + */ +import { defineCommand, type PromptSurface } from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { createTestCli, type TestCli } from "@prisma/cli-engine/testing"; +import { describe, expect, test } from "vitest"; + +function cliWith(ask: (prompt: PromptSurface) => Promise) { + const probe = defineCommand({ + help: { summary: "Consent probe" }, + statements: { rename: { arity: 1 }, delete: { arity: 1 } }, + handler: async (_args, ctx) => { + const answer = await ask(ctx.prompt); + return ok( + ctx.present( + { data: { answer } }, + { + human: () => [], + stdout: () => [], + json: () => ({ answer }), + next: () => [], + }, + ), + ); + }, + }); + return createTestCli({ commands: { probe } }); +} + +function errorOf(result: Awaited>) { + const last = result.json[result.json.length - 1]; + return last.kind === "result" && !last.envelope.ok + ? last.envelope.error + : undefined; +} + +function question(subject: string) { + return { + question: `What happens to ${subject}?`, + subject, + verbs: ["rename", "delete"], + validate: () => undefined, + } as const; +} + +const askBoth = (prompt: PromptSurface) => + prompt.statements([question("Legacy"), question("User.nickname")]); + +describe("an unconsumed --confirm token", () => { + test("fails a run that otherwise succeeded", async () => { + const result = await cliWith(async () => "nothing asked").run([ + "probe", + "--confirm", + "mydb", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(errorOf(result)).toEqual({ + code: "CLI.CONSENT_UNUSED", + severity: "error", + summary: "--confirm mydb answers no consent in this run.", + nextActions: [ + { + kind: "user-choice", + label: "Remove the --confirm flag: nothing in this run asks for it.", + }, + ], + meta: { unused: [], confirm: ["mydb"] }, + }); + }); + + test("is reported with a statement refusal, which says a statement flag answers it", async () => { + const result = await cliWith(askBoth).run([ + "probe", + "--confirm", + "mydb", + "--json", + ]); + + expect(errorOf(result)).toMatchObject({ + code: "CLI.CONSENT_REQUIRED", + why: "What happens to Legacy?\nWhat happens to User.nickname?\n--confirm mydb answers no consent in this run; a statement flag is what answers these questions.", + meta: { confirm: ["mydb"] }, + }); + }); +}); + +describe("a statement value that matches no question", () => { + test("is reported with the refusal, naming the subjects asked about", async () => { + const result = await cliWith(askBoth).run([ + "probe", + "--delete", + "Legcy", + "--json", + ]); + + expect(errorOf(result)).toMatchObject({ + code: "CLI.CONSENT_REQUIRED", + why: "What happens to Legacy?\nWhat happens to User.nickname?\n--delete Legcy answers no question; the questions are about Legacy, User.nickname.", + meta: { unmatched: [{ verb: "delete", values: ["Legcy"] }] }, + }); + }); + + test("is reported as unused with the subjects the run asked about", async () => { + const result = await cliWith((prompt) => + prompt.statements([question("Legacy")]), + ).run(["probe", "--delete", "Legacy", "--delete", "Legcy", "--json"]); + + expect(errorOf(result)).toMatchObject({ + code: "CLI.CONSENT_UNUSED", + why: "The run asked about Legacy.", + meta: { asked: ["Legacy"] }, + }); + }); +}); diff --git a/packages/cli-engine/tests/interaction-affordances.test.ts b/packages/cli-engine/tests/interaction-affordances.test.ts index 4c900ef3..636424ba 100644 --- a/packages/cli-engine/tests/interaction-affordances.test.ts +++ b/packages/cli-engine/tests/interaction-affordances.test.ts @@ -114,7 +114,7 @@ describe("consent tokens", () => { expect(result.stderr).not.toContain("type prod-db to confirm"); }); - test("--confirm with a different value still prompts interactively", async () => { + test("--confirm with a different value still prompts interactively, then fails the run as unused", async () => { const cli = createTestCli({ commands: { probe: promptProbe(dropDatabase) }, now: EPOCH, @@ -124,9 +124,11 @@ describe("consent tokens", () => { stdin: "prod-db\n", }); - expect(result.exitCode).toBe(0); - expect(result.presented?.data).toEqual({ answer: true }); expect(result.stderr).toContain("type prod-db to confirm"); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "[CLI.CONSENT_UNUSED] --confirm staging-db answers no consent in this run.", + ); }); test("--confirm with the token grants the consent non-interactively", async () => { diff --git a/packages/cli-engine/tests/prompt-signals.test.ts b/packages/cli-engine/tests/prompt-signals.test.ts new file mode 100644 index 00000000..ce939595 --- /dev/null +++ b/packages/cli-engine/tests/prompt-signals.test.ts @@ -0,0 +1,107 @@ +/** + * A SIGTERM or SIGINT delivered while a prompt waits for the user + * cancels the prompt, as Ctrl-C at the prompt does: CLI.PROMPT_CANCELLED, + * exit 3. Without this a clack prompt held the terminal until SIGKILL. + */ +import { + createCli, + defineCommand, + type PromptSurface, + type Runtime, +} from "@prisma/cli-engine"; +import { ok } from "@prisma/cli-engine/protocol"; +import { describe, expect, test } from "vitest"; + +/** A terminal that never types anything and never closes. */ +function silentStdin(rawMode: boolean): Runtime["stdin"] { + return { + ...(rawMode ? { setRawMode: () => {} } : {}), + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: () => Promise.resolve({ done: true, value: undefined }), + }), + }; +} + +async function runSignalled( + ask: (prompt: PromptSurface) => Promise, + signal: "SIGINT" | "SIGTERM", + rawMode: boolean, +) { + const probe = defineCommand({ + help: { summary: "Prompt probe" }, + handler: async (_args, ctx) => { + const answer = await ask(ctx.prompt); + return ok( + ctx.present( + { data: { answer } }, + { + human: () => [], + stdout: () => [], + json: () => ({ answer }), + next: () => [], + }, + ), + ); + }, + }); + const cli = createCli({ + name: "probe", + version: "0.0.0", + commandFamilies: [], + groups: {}, + commands: { probe }, + }); + let deliver: ((signal: "SIGINT" | "SIGTERM") => void) | undefined; + let stderr = ""; + const runtime: Runtime = { + stdout: { write: () => {} }, + stderr: { + write: (text) => { + stderr += text; + if (text.includes("Proceed?")) { + setTimeout(() => deliver?.(signal), 10); + } + }, + }, + stdin: silentStdin(rawMode), + cwd: "/", + env: {}, + isTty: { stdin: true, stdout: true, stderr: true }, + exit: (code: number): never => { + throw new Error(`runtime.exit(${code})`); + }, + onSignal: (cb) => { + deliver = cb; + return () => { + deliver = undefined; + }; + }, + loadConfig: async () => ({ files: [], diagnostics: [] }), + managementApi: { baseUrl: "https://test.invalid" }, + host: { + runtime: { name: "node", version: "v22.12.0" }, + platform: "linux", + arch: "x64", + }, + }; + const exitCode = await cli.run(["probe"], runtime); + return { exitCode, stderr }; +} + +describe("a signal while a prompt waits", () => { + test.each([ + ["SIGTERM", "clack", true], + ["SIGINT", "clack", true], + ["SIGTERM", "line", false], + ] as const)("%s at a %s prompt cancels it, exit 3", async (signal, _tier, rawMode) => { + const result = await runSignalled( + (prompt) => prompt.text("Proceed?"), + signal, + rawMode, + ); + + expect(result.exitCode).toBe(3); + expect(result.stderr).toContain("CLI.PROMPT_CANCELLED"); + }); +}); diff --git a/packages/cli-engine/tests/statement-flag-forms.test.ts b/packages/cli-engine/tests/statement-flag-forms.test.ts index 64fcf2a2..61da8f8d 100644 --- a/packages/cli-engine/tests/statement-flag-forms.test.ts +++ b/packages/cli-engine/tests/statement-flag-forms.test.ts @@ -46,24 +46,56 @@ function errorOf(result: Awaited>) { describe("printed flag forms", () => { test.each([ - ["a plain subject", "Legacy", "Pass --delete Legacy"], - ["a subject with ':' and '.'", "User.name:x", "Pass --delete User.name:x"], + ["a plain subject", "Legacy", "Run the command again with --delete Legacy"], + [ + "a subject with ':' and '.'", + "User.name:x", + "Run the command again with --delete User.name:x", + ], [ "a subject a shell would run part of", 'a b:c"d--$é; DROP TABLE Profile', - `Pass --delete 'a b:c"d--$é; DROP TABLE Profile'`, + `Run the command again with --delete 'a b:c"d--$é; DROP TABLE Profile'`, + ], + [ + "a subject with a single quote", + "it's", + `Run the command again with --delete 'it'\\''s'`, + ], + [ + "a subject starting with '-'", + "-rf", + "Run the command again with --delete=-rf", + ], + [ + "a subject starting with '='", + "=eq", + "Run the command again with --delete '=eq'", + ], + [ + "a subject with a newline", + "a\nb", + "Run the command again with --delete 'a\nb'", + ], + [ + "a subject with a backslash", + "a\\b", + "Run the command again with --delete 'a\\b'", + ], + [ + "a subject with '$'", + "$HOME", + "Run the command again with --delete '$HOME'", + ], + [ + "a subject with a backtick", + "`id`", + "Run the command again with --delete '`id`'", ], - ["a subject with a single quote", "it's", `Pass --delete 'it'\\''s'`], - ["a subject starting with '-'", "-rf", "Pass --delete=-rf"], - ["a subject starting with '='", "=eq", "Pass --delete '=eq'"], - ["a subject with a newline", "a\nb", "Pass --delete 'a\nb'"], - ["a subject with a backslash", "a\\b", "Pass --delete 'a\\b'"], - ["a subject with '$'", "$HOME", "Pass --delete '$HOME'"], - ["a subject with a backtick", "`id`", "Pass --delete '`id`'"], [ "a subject starting with '-' that needs quoting", "-r f", - "Pass --delete='-r f'", + "Run the command again with --delete='-r f'", ], ])("%s", async (_case, subject, label) => { const result = await cliAsking(subject).run(["probe", "--json"]); diff --git a/packages/cli-engine/tests/statement-prompts.test.ts b/packages/cli-engine/tests/statement-prompts.test.ts index 7da99d90..185ade73 100644 --- a/packages/cli-engine/tests/statement-prompts.test.ts +++ b/packages/cli-engine/tests/statement-prompts.test.ts @@ -190,8 +190,14 @@ describe("without a flag, a non-interactive run refuses", () => { '"Legacy" needs a statement, and the session is not interactive.', why: LEGACY_QUESTION, nextActions: [ - { kind: "user-choice", label: "Pass --rename 'Legacy:'" }, - { kind: "user-choice", label: "Pass --delete Legacy" }, + { + kind: "user-choice", + label: "Run the command again with --rename 'Legacy:'", + }, + { + kind: "user-choice", + label: "Run the command again with --delete Legacy", + }, ], meta: { subject: "Legacy", @@ -218,8 +224,12 @@ describe("without a flag, a non-interactive run refuses", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toContain("[CLI.CONSENT_REQUIRED]"); - expect(result.stderr).toContain("Pass --rename 'Legacy:'"); - expect(result.stderr).toContain("Pass --delete Legacy"); + expect(result.stderr).toContain( + "Run the command again with --rename 'Legacy:'", + ); + expect(result.stderr).toContain( + "Run the command again with --delete Legacy", + ); }); }); @@ -305,8 +315,14 @@ describe("prompt.statements asks several questions together", () => { summary: '"User.name" needs a statement, and the session is not interactive.', nextActions: [ - { kind: "user-choice", label: "Pass --rename 'User.name:'" }, - { kind: "user-choice", label: "Pass --delete User.name" }, + { + kind: "user-choice", + label: "Run the command again with --rename 'User.name:'", + }, + { + kind: "user-choice", + label: "Run the command again with --delete User.name", + }, ], meta: { unanswered: [{ subject: "User.name", verbs: ["rename", "delete"] }], @@ -325,10 +341,22 @@ describe("prompt.statements asks several questions together", () => { '2 subjects need a statement, and the session is not interactive: "Legacy", "User.name".', why: `${LEGACY_QUESTION}\n${USER_NAME_QUESTION}`, nextActions: [ - { kind: "user-choice", label: "Pass --rename 'Legacy:'" }, - { kind: "user-choice", label: "Pass --delete Legacy" }, - { kind: "user-choice", label: "Pass --rename 'User.name:'" }, - { kind: "user-choice", label: "Pass --delete User.name" }, + { + kind: "user-choice", + label: "Run the command again with --rename 'Legacy:'", + }, + { + kind: "user-choice", + label: "Run the command again with --delete Legacy", + }, + { + kind: "user-choice", + label: "Run the command again with --rename 'User.name:'", + }, + { + kind: "user-choice", + label: "Run the command again with --delete User.name", + }, ], meta: { unanswered: [ @@ -427,6 +455,7 @@ describe("a verb-flag value nothing consumed", () => { severity: "error", summary: "--delete Lagacy was given but nothing in this run asked about Lagacy.", + why: "The run asked about Legacy.", nextActions: [ { kind: "user-choice", @@ -436,6 +465,7 @@ describe("a verb-flag value nothing consumed", () => { ], meta: { unused: [{ verb: "delete", values: ["Lagacy"] }], + asked: ["Legacy"], }, }); }); diff --git a/packages/cli-engine/tests/statement-take.test.ts b/packages/cli-engine/tests/statement-take.test.ts index 0695258a..3fb97a08 100644 --- a/packages/cli-engine/tests/statement-take.test.ts +++ b/packages/cli-engine/tests/statement-take.test.ts @@ -216,8 +216,14 @@ describe("a question listing a verb the command took", () => { expect(errorOf(result)).toMatchObject({ code: "CLI.CONSENT_REQUIRED", nextActions: [ - { kind: "user-choice", label: "Pass --rename 'Legacy:'" }, - { kind: "user-choice", label: "Pass --delete Legacy" }, + { + kind: "user-choice", + label: "Run the command again with --rename 'Legacy:'", + }, + { + kind: "user-choice", + label: "Run the command again with --delete Legacy", + }, ], }); }); From adb07a8358bb76eaac787935806a18248a019173 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:06:19 +0200 Subject: [PATCH 20/21] fix(engine): unused --confirm fails only statement commands, and signals at a prompt settle as signals An unconsumed --confirm is CLI.CONSENT_UNUSED only on a command that declares statements; elsewhere it stays silent until bucket key delete and project env delete consume it (prisma/prisma-cli#339). A SIGTERM that cancels a waiting prompt now settles 143; a SIGINT stays 3, the user declining. Once a signal has cancelled a prompt the next signal force-exits, and later prompts in the run are cancelled at once. The line reader removes its abort listener when the read settles. Signed-off-by: willbot Signed-off-by: Will Madden --- .../adrs/0006-consent-as-a-statement.md | 4 +- docs/product/cli-style-guide.md | 2 +- docs/reference/error-reference.md | 4 +- packages/cli-engine/README.md | 2 +- packages/cli-engine/src/context.ts | 5 ++ packages/cli-engine/src/execution/engine.ts | 15 +++-- packages/cli-engine/src/execution/prompts.ts | 34 ++++++---- .../cli-engine/src/execution/settlement.ts | 12 +++- .../tests/consent-leftovers.test.ts | 27 +++++++- .../tests/interaction-affordances.test.ts | 8 +-- .../cli-engine/tests/prompt-signals.test.ts | 67 ++++++++++++++++--- 11 files changed, 142 insertions(+), 38 deletions(-) diff --git a/docs/architecture/adrs/0006-consent-as-a-statement.md b/docs/architecture/adrs/0006-consent-as-a-statement.md index 81c93101..605c68e8 100644 --- a/docs/architecture/adrs/0006-consent-as-a-statement.md +++ b/docs/architecture/adrs/0006-consent-as-a-statement.md @@ -43,8 +43,8 @@ These came from the ORM's first use and the reviews of this change, all in engin - **`ctx.statements.values()`** lists every unconsumed value in argv order without consuming any, so a command can show what it was given without spending it. - **Longest subject first.** Within one `statements` batch, a value equal to a subject answers that subject first, and any other value goes to the question with the longest subject it names. So `A:B` answers the question about `A:B`, not the one about `A`. Questions whose subjects are prefixes of one another belong in one batch, because separate calls cannot be resolved this way. - **`:` in a subject is allowed**, with the matching rule unchanged. -- **Unused `--confirm` tokens fail the run** with `CLI.CONSENT_UNUSED`, in every command, deliberately: a token that matched no consent is the same mistake as a misspelled statement. A statement refusal also names any `--confirm` token and any statement flag that names none of its subjects, so the user learns of the mistake before answering a prompt. -- **A signal while a prompt waits cancels the prompt**, as Ctrl-C at the prompt does: `CLI.PROMPT_CANCELLED`, exit 3. +- **Unused `--confirm` tokens fail the run** with `CLI.CONSENT_UNUSED` on a command that declares statements, where such a token is certainly a mistake: it is the same mistake as a misspelled statement. On other commands a leftover `--confirm` stays silent for now, because `bucket key delete` and `project env delete` take `--confirm` and never consume it; [prisma/prisma-cli#339](https://github.com/prisma/prisma-cli/issues/339) tracks applying the rule to every command. A statement refusal also names any `--confirm` token and any statement flag that names none of its subjects, so the user learns of the mistake before answering a prompt. +- **A signal while a prompt waits cancels the prompt** with `CLI.PROMPT_CANCELLED`: exit 3 for SIGINT, as Ctrl-C at the prompt is the user declining, and 143 for SIGTERM, which ends the run as a delivered signal. Later prompts in the run are cancelled at once, and a further signal force-exits. - **`last: true`** marks a batch as the run's final ask: values still unconsumed fail with `CLI.CONSENT_UNUSED` as soon as it is answered, before the command acts on the answers. ## Consequences diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index 9a8f83dd..ab4972e0 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -217,7 +217,7 @@ Consent is a question `--yes` never answers and Enter never answers. It comes in - **A yes/no consent** (`ctx.prompt.consent`). With a token, the user types the token, or passes `--confirm `. Without a token, only an interactive terminal can grant it. - **A statement** (`ctx.prompt.statement`). The user states what should happen to a subject with a verb: `delete`, or `rename Legacy:Archive`. The command declares its verbs, and each verb is a flag on that command only, so `--delete Legacy` gives the same answer on the command line. A flag value answers the question only when it names the subject: it is the subject, or starts with `:`. -Without an answer, a non-interactive run fails with `CLI.CONSENT_REQUIRED` and lists the flags that would answer every open question. A statement flag that no question used, or a `--confirm` token that no consent asked for, fails the run with `CLI.CONSENT_UNUSED`, because a mistyped subject or token must not pass silently. +Without an answer, a non-interactive run fails with `CLI.CONSENT_REQUIRED` and lists the flags that would answer every open question. A statement flag that no question used, or, on a command that declares statements, a `--confirm` token that no consent asked for, fails the run with `CLI.CONSENT_UNUSED`, because a mistyped subject or token must not pass silently. ## Loading Indicators diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index e98ceb5c..fd43568e 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -160,7 +160,7 @@ A statement prompt (`ctx.prompt.statement` or `ctx.prompt.statements`) raises th ### CLI.CONSENT_UNUSED -A consent flag was given but answered nothing: a statement flag such as `--delete Legacy`, or a `--confirm` token. The summary says why for each flag: no statement prompt asked about that subject and no `ctx.statements.take` took it (usually a mistyped subject), the question about it was already answered by another flag (a flag given twice, or two verbs for one subject), or no consent in the run asked for that `--confirm` token. The `--confirm` case applies to every command, deliberately: a token that matched nothing is the same mistake as a misspelled statement. `why` lists the subjects the run asked about. Raised when the handler returns from a run that would otherwise have succeeded, so a run that failed for another reason reports that reason only; or earlier, as soon as `ctx.prompt.statements(questions, { last: true })` has answered its questions, before the command acts. Exits 2. Meta: `unused` (a list of `{ verb, values }`), `confirm` (the unused `--confirm` tokens, when any), `asked` (the subjects asked about, when any). +A consent flag was given but answered nothing: a statement flag such as `--delete Legacy`, or a `--confirm` token. The summary says why for each flag: no statement prompt asked about that subject and no `ctx.statements.take` took it (usually a mistyped subject), the question about it was already answered by another flag (a flag given twice, or two verbs for one subject), or no consent in the run asked for that `--confirm` token. The `--confirm` case applies only to a command that declares statements, where such a token is certainly a mistake; elsewhere a leftover `--confirm` is still ignored until [prisma/prisma-cli#339](https://github.com/prisma/prisma-cli/issues/339) lands. `why` lists the subjects the run asked about. Raised when the handler returns from a run that would otherwise have succeeded, so a run that failed for another reason reports that reason only; or earlier, as soon as `ctx.prompt.statements(questions, { last: true })` has answered its questions, before the command acts. Exits 2. Meta: `unused` (a list of `{ verb, values }`), `confirm` (the unused `--confirm` tokens, when any), `asked` (the subjects asked about, when any). ### CLI.CREDENTIALS_LOCKED @@ -196,7 +196,7 @@ A `ctx.packages` operation (an install, or running a package through the manager ### CLI.PROMPT_CANCELLED -The user cancelled a prompt: EOF on stdin at a line-rendered prompt, a clack cancel (Ctrl-C at the prompt UI), a SIGINT or SIGTERM delivered while a prompt waits for input, an abort during a browserWait poll, Ctrl-C at the `prisma auth login` paste prompt, or — via the service commands' `userCancelledError` — consent declined interactively. Settles with exit 3, the cancellation code, instead of 2. Meta: none. +The user cancelled a prompt: EOF on stdin at a line-rendered prompt, a clack cancel (Ctrl-C at the prompt UI), a SIGINT or SIGTERM delivered while a prompt waits for input, an abort during a browserWait poll, Ctrl-C at the `prisma auth login` paste prompt, or — via the service commands' `userCancelledError` — consent declined interactively. Settles with exit 3, the cancellation code, instead of 2; a SIGTERM that cancels a waiting prompt settles 143 instead, because it ends the run as a delivered signal, while a SIGINT is the user declining, like Ctrl-C. After a signal has cancelled a prompt, every later prompt in the run is cancelled at once and a further signal force-exits. Meta: none. ### CLI.PROMPT_INVALID diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index 916c5b39..360d1372 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -59,6 +59,6 @@ The engine answers the question in this order: A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. A later question may still list a taken verb: no flag value of it is left to answer the question, so the verb serves the refusal's `--` form and what a user may type. `ctx.statements.values()` lists every unconsumed value of every verb in argv order without consuming any, for showing what a run was given, such as in a line that repeats the command. Take before any `last: true` batch, which reports every value still unconsumed. -Each flag answers one question. A run that succeeds with a flag nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject or a second answer to one question is never ignored. The same holds for a `--confirm` token no consent asked for, in every command. A refusal also names any given statement flag that matches none of its questions, and any `--confirm` token, so the user hears of it before answering a prompt. That check runs when the handler returns, after the command has acted. A command that asks everything in one batch passes `statements(questions, { last: true })` to get the same failure right after the questions are answered, before it does anything with them. +Each flag answers one question. A run that succeeds with a flag nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject or a second answer to one question is never ignored. The same holds for a `--confirm` token no consent asked for, on a command that declares statements (other commands still ignore a leftover `--confirm`; see [#339](https://github.com/prisma/prisma-cli/issues/339)). A refusal also names any given statement flag that matches none of its questions, and any `--confirm` token, so the user hears of it before answering a prompt. That check runs when the handler returns, after the command has acted. A command that asks everything in one batch passes `statements(questions, { last: true })` to get the same failure right after the questions are answered, before it does anything with them. Part of [prisma/prisma-cli](https://github.com/prisma/prisma-cli). diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index b92ad876..91436c31 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -270,6 +270,11 @@ export interface StatementsOptions { * a default resolves to it; one without a default throws. The prompt UI * writes to stderr, so an interactive json run prompts without touching * the stdout stream. + * + * A SIGINT or SIGTERM delivered while a prompt waits cancels it with + * CLI.PROMPT_CANCELLED (exit 3 for SIGINT, 143 for SIGTERM). The + * cancellation holds for the rest of the run: every later prompt is + * cancelled at once, and a further signal force-exits. */ export interface PromptSurface { readonly confirm: ( diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 64def665..8de363c7 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -196,8 +196,10 @@ export interface RunState { * ctx.spawn from handing the same terminal to a child. */ activePrompts: number; /** True while a prompt waits for the user's keystrokes or line. A - * signal then cancels the prompt (promptCancel) instead of ending - * the run, as Ctrl-C at the prompt does. */ + * signal then cancels the prompt (promptCancel). A SIGINT is the + * user declining, as Ctrl-C at the prompt is, and settles 3; a + * SIGTERM also ends the run as a delivered signal, 143. Once + * promptCancel has fired, the next signal force-exits. */ readingPrompt: boolean; promptCancel: AbortController; /** Set while a ctx.packages operation is in flight. It serializes the @@ -396,13 +398,18 @@ export class EngineImpl implements Engine { recordSignalDuringSpawn(state.delegatedTerminal, signal); return; } - if (state.deliveredSignal !== undefined) { + if ( + state.deliveredSignal !== undefined || + state.promptCancel.signal.aborted + ) { runtime.exit(signal === "SIGTERM" ? 143 : 130); return; } if (state.readingPrompt) { state.promptCancel.abort(signal); - return; + if (signal === "SIGINT") { + return; + } } state.deliveredSignal = signal; controller.abort(signal); diff --git a/packages/cli-engine/src/execution/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index 2aa73594..c8c2a754 100644 --- a/packages/cli-engine/src/execution/prompts.ts +++ b/packages/cli-engine/src/execution/prompts.ts @@ -294,6 +294,14 @@ function unusedSentence(state: RunState, value: StatementFlagValue): string { : `${given} was given, but the question about ${asked} was already answered by another flag.`; } +/** `--confirm` tokens no consent consumed, reported only on a command + * that declares statements, where one is certainly a mistake. Other + * commands still take `--confirm` on paths that ask nothing; making + * their leftovers an error waits on prisma/prisma-cli#339. */ +function leftoverConfirmTokens(state: RunState): readonly string[] { + return Object.keys(state.statements).length === 0 ? [] : state.confirmValues; +} + function unusedActions( unused: readonly StatementFlagValue[], state: RunState, @@ -309,7 +317,7 @@ function unusedActions( if (unused.some((value) => askedSubjectOf(state, value) !== undefined)) { actions.push({ kind: "user-choice", label: "Give one flag per question." }); } - if (state.confirmValues.length > 0) { + if (leftoverConfirmTokens(state).length > 0) { actions.push({ kind: "user-choice", label: "Remove the --confirm flag: nothing in this run asks for it.", @@ -325,12 +333,12 @@ export function unusedConsentError( state: RunState, ): CliStructuredError | undefined { const unused = state.statementValues.filter((value) => !value.consumed); - if (unused.length === 0 && state.confirmValues.length === 0) { + if (unused.length === 0 && leftoverConfirmTokens(state).length === 0) { return undefined; } const sentences = [ ...unused.map((value) => unusedSentence(state, value)), - ...state.confirmValues.map( + ...leftoverConfirmTokens(state).map( (token) => `${flagForm("confirm", [token])} answers no consent in this run.`, ), @@ -343,9 +351,9 @@ export function unusedConsentError( nextActions: unusedActions(unused, state), meta: { unused: unused.map(({ verb, values }) => ({ verb, values })), - ...(state.confirmValues.length === 0 + ...(leftoverConfirmTokens(state).length === 0 ? {} - : { confirm: [...state.confirmValues] }), + : { confirm: [...leftoverConfirmTokens(state)] }), ...(asked.length === 0 ? {} : { asked }), }, }); @@ -622,14 +630,14 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { if (signal.aborted) { return Promise.resolve(undefined); } - return Promise.race([ - line, - new Promise((resolve) => - signal.addEventListener("abort", () => resolve(undefined), { - once: true, - }), - ), - ]); + let onAbort = (): void => {}; + const aborted = new Promise((resolve) => { + onAbort = () => resolve(undefined); + signal.addEventListener("abort", onAbort, { once: true }); + }); + return Promise.race([line, aborted]).finally(() => + signal.removeEventListener("abort", onAbort), + ); }; const renderWithClack = async ( diff --git a/packages/cli-engine/src/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index 45cfacbb..f4db2e5e 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -129,7 +129,8 @@ export function settleErrored( diagnostics: readonly Diagnostic[] = [], ): void { const state = invocation.state; - state.settledExitCode = error.code === "CLI.PROMPT_CANCELLED" ? 3 : 2; + state.settledExitCode = + error.code === "CLI.PROMPT_CANCELLED" ? cancelledExitCode(state) : 2; emitErrored(invocation, { ok: false, commandId: state.commandId, @@ -141,6 +142,15 @@ export function settleErrored( }); } +/** A cancelled prompt exits 3, the user declining, unless a SIGTERM + * cancelled it while it waited for input: that ends the run as the + * signal it is. */ +function cancelledExitCode(state: Invocation["state"]): number { + return state.promptCancel.signal.reason === "SIGTERM" + ? signalExitCode("SIGTERM") + : 3; +} + /** The conventional code for a delivered signal: 128 + its number, so * 130 for SIGINT and 143 for SIGTERM. */ function signalExitCode(signal: "SIGINT" | "SIGTERM"): number { diff --git a/packages/cli-engine/tests/consent-leftovers.test.ts b/packages/cli-engine/tests/consent-leftovers.test.ts index 42fea31f..6114450c 100644 --- a/packages/cli-engine/tests/consent-leftovers.test.ts +++ b/packages/cli-engine/tests/consent-leftovers.test.ts @@ -51,7 +51,32 @@ const askBoth = (prompt: PromptSurface) => prompt.statements([question("Legacy"), question("User.nickname")]); describe("an unconsumed --confirm token", () => { - test("fails a run that otherwise succeeded", async () => { + test("is ignored on a command that declares no statements", async () => { + const plain = defineCommand({ + help: { summary: "Plain probe" }, + handler: async (_args, ctx) => + ok( + ctx.present( + { data: {} }, + { + human: () => [], + stdout: () => [], + json: () => ({}), + next: () => [], + }, + ), + ), + }); + const result = await createTestCli({ commands: { plain } }).run([ + "plain", + "--confirm", + "mydb", + ]); + + expect(result.exitCode).toBe(0); + }); + + test("fails a run that otherwise succeeded on a command that declares statements", async () => { const result = await cliWith(async () => "nothing asked").run([ "probe", "--confirm", diff --git a/packages/cli-engine/tests/interaction-affordances.test.ts b/packages/cli-engine/tests/interaction-affordances.test.ts index 636424ba..4c900ef3 100644 --- a/packages/cli-engine/tests/interaction-affordances.test.ts +++ b/packages/cli-engine/tests/interaction-affordances.test.ts @@ -114,7 +114,7 @@ describe("consent tokens", () => { expect(result.stderr).not.toContain("type prod-db to confirm"); }); - test("--confirm with a different value still prompts interactively, then fails the run as unused", async () => { + test("--confirm with a different value still prompts interactively", async () => { const cli = createTestCli({ commands: { probe: promptProbe(dropDatabase) }, now: EPOCH, @@ -124,11 +124,9 @@ describe("consent tokens", () => { stdin: "prod-db\n", }); + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ answer: true }); expect(result.stderr).toContain("type prod-db to confirm"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( - "[CLI.CONSENT_UNUSED] --confirm staging-db answers no consent in this run.", - ); }); test("--confirm with the token grants the consent non-interactively", async () => { diff --git a/packages/cli-engine/tests/prompt-signals.test.ts b/packages/cli-engine/tests/prompt-signals.test.ts index ce939595..c59051c9 100644 --- a/packages/cli-engine/tests/prompt-signals.test.ts +++ b/packages/cli-engine/tests/prompt-signals.test.ts @@ -1,7 +1,8 @@ /** * A SIGTERM or SIGINT delivered while a prompt waits for the user - * cancels the prompt, as Ctrl-C at the prompt does: CLI.PROMPT_CANCELLED, - * exit 3. Without this a clack prompt held the terminal until SIGKILL. + * cancels the prompt with CLI.PROMPT_CANCELLED: exit 3 for SIGINT, as + * Ctrl-C at the prompt, and 143 for SIGTERM. Without this a clack + * prompt held the terminal until SIGKILL. */ import { createCli, @@ -23,10 +24,17 @@ function silentStdin(rawMode: boolean): Runtime["stdin"] { }; } +/** The first signal soon after the prompt shows, each further one a + * little later, so a handler that caught the cancel is waiting. */ +function deliveryDelays(times: number): readonly number[] { + return Array.from({ length: times }, (_unused, index) => 10 + index * 20); +} + async function runSignalled( ask: (prompt: PromptSurface) => Promise, signal: "SIGINT" | "SIGTERM", rawMode: boolean, + times = 1, ) { const probe = defineCommand({ help: { summary: "Prompt probe" }, @@ -53,6 +61,14 @@ async function runSignalled( commands: { probe }, }); let deliver: ((signal: "SIGINT" | "SIGTERM") => void) | undefined; + const exits: string[] = []; + const deliverRecording = (): void => { + try { + deliver?.(signal); + } catch (cause) { + exits.push(String(cause)); + } + }; let stderr = ""; const runtime: Runtime = { stdout: { write: () => {} }, @@ -60,7 +76,9 @@ async function runSignalled( write: (text) => { stderr += text; if (text.includes("Proceed?")) { - setTimeout(() => deliver?.(signal), 10); + for (const delay of deliveryDelays(times)) { + setTimeout(deliverRecording, delay); + } } }, }, @@ -86,21 +104,54 @@ async function runSignalled( }, }; const exitCode = await cli.run(["probe"], runtime); - return { exitCode, stderr }; + return { exitCode, stderr, exits }; } describe("a signal while a prompt waits", () => { test.each([ - ["SIGTERM", "clack", true], - ["SIGINT", "clack", true], - ["SIGTERM", "line", false], - ] as const)("%s at a %s prompt cancels it, exit 3", async (signal, _tier, rawMode) => { + ["SIGTERM", "clack", 143, true], + ["SIGINT", "clack", 3, true], + ["SIGTERM", "line", 143, false], + ["SIGINT", "line", 3, false], + ] as const)("%s at a %s prompt cancels it, exit %s", async (signal, _tier, exitCode, rawMode) => { const result = await runSignalled( (prompt) => prompt.text("Proceed?"), signal, rawMode, ); + expect(result.exitCode).toBe(exitCode); + expect(result.stderr).toContain("CLI.PROMPT_CANCELLED"); + expect(result.exits).toEqual([]); + }); + + test.each([ + ["SIGTERM", "runtime.exit(143)"], + ["SIGINT", "runtime.exit(130)"], + ] as const)("after %s cancels a prompt the handler catches, the next signal force-exits", async (signal, exit) => { + const catchAndWait = async (prompt: PromptSurface) => { + try { + return await prompt.text("Proceed?"); + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + return "caught"; + } + }; + const result = await runSignalled(catchAndWait, signal, true, 2); + + expect(result.exits).toEqual([`Error: ${exit}`]); + }); + + test("a prompt after a cancelled one is cancelled at once", async () => { + const askTwice = async (prompt: PromptSurface) => { + try { + await prompt.text("Proceed?"); + } catch { + return prompt.text("Again?"); + } + }; + const result = await runSignalled(askTwice, "SIGINT", true); + expect(result.exitCode).toBe(3); expect(result.stderr).toContain("CLI.PROMPT_CANCELLED"); }); From 1cc84f8446b85c1ebd40afefb13c6f1267183f7f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:08:39 +0200 Subject: [PATCH 21/21] test(engine): --confirm with statements, a last batch, and an interactive retry Covers a consumed token on a statement command, an unused token failing at a last batch before the command acts, and a mistyped token followed by the right one typed on a command without statements. The last option's doc and the README say to ask any token consent before the last batch. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/cli-engine/README.md | 2 +- packages/cli-engine/src/context.ts | 6 +-- .../tests/consent-leftovers.test.ts | 52 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index 360d1372..e3ce61cb 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -57,7 +57,7 @@ The engine answers the question in this order: `ctx.prompt.statements([...])` asks several questions at once and returns the answers in order. Flags answer what they can, a refusal lists every question still unanswered, and an interactive run asks the rest one after another. -A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. A later question may still list a taken verb: no flag value of it is left to answer the question, so the verb serves the refusal's `--` form and what a user may type. `ctx.statements.values()` lists every unconsumed value of every verb in argv order without consuming any, for showing what a run was given, such as in a line that repeats the command. Take before any `last: true` batch, which reports every value still unconsumed. +A statement is the command's own declared input, unlike a `--confirm` value, which a handler never sees. A command that needs some statements as input to its work, before it can know what to ask, takes them with `ctx.statements.take("rename")`: every unconsumed value of that verb, in argv order, as `{ verb, values, text }[]`. Taken values are consumed, and other verbs' values stay for the questions. A later question may still list a taken verb: no flag value of it is left to answer the question, so the verb serves the refusal's `--` form and what a user may type. `ctx.statements.values()` lists every unconsumed value of every verb in argv order without consuming any, for showing what a run was given, such as in a line that repeats the command. Take, and ask any consent with a token, before any `last: true` batch, which reports every value still unconsumed. Each flag answers one question. A run that succeeds with a flag nothing consumed fails with `CLI.CONSENT_UNUSED`, so a mistyped subject or a second answer to one question is never ignored. The same holds for a `--confirm` token no consent asked for, on a command that declares statements (other commands still ignore a leftover `--confirm`; see [#339](https://github.com/prisma/prisma-cli/issues/339)). A refusal also names any given statement flag that matches none of its questions, and any `--confirm` token, so the user hears of it before answering a prompt. That check runs when the handler returns, after the command has acted. A command that asks everything in one batch passes `statements(questions, { last: true })` to get the same failure right after the questions are answered, before it does anything with them. diff --git a/packages/cli-engine/src/context.ts b/packages/cli-engine/src/context.ts index 91436c31..357ef066 100644 --- a/packages/cli-engine/src/context.ts +++ b/packages/cli-engine/src/context.ts @@ -250,9 +250,9 @@ export interface StatementSurface { export interface StatementsOptions { /** This is the run's final ask: values still unconsumed once the * questions are answered fail with CLI.CONSENT_UNUSED here, before - * the command acts on the answers. Take any verbs you take before - * the `last` batch: after it, their values have already been - * reported as unused. */ + * the command acts on the answers. Take any verbs you take, and ask + * any consent with a token, before the `last` batch: it reports + * their flags as unused at once. */ readonly last?: boolean; } diff --git a/packages/cli-engine/tests/consent-leftovers.test.ts b/packages/cli-engine/tests/consent-leftovers.test.ts index 6114450c..931fd90f 100644 --- a/packages/cli-engine/tests/consent-leftovers.test.ts +++ b/packages/cli-engine/tests/consent-leftovers.test.ts @@ -143,3 +143,55 @@ describe("a statement value that matches no question", () => { }); }); }); + +describe("--confirm alongside statements", () => { + test("a token a consent consumed fails nothing on a statement command", async () => { + const result = await cliWith((prompt) => + prompt.consent("Drop the database?", { token: "mydb" }), + ).run(["probe", "--confirm", "mydb"]); + + expect(result.exitCode).toBe(0); + }); + + test("an unused token fails at a last batch, before the command acts", async () => { + const acted: string[] = []; + const result = await cliWith(async (prompt) => { + await prompt.statements([question("Legacy")], { last: true }); + acted.push("applied"); + }).run(["probe", "--delete", "Legacy", "--confirm", "mydb", "--json"]); + + expect(errorOf(result)?.summary).toBe( + "--confirm mydb answers no consent in this run.", + ); + expect(acted).toEqual([]); + }); + + test("on a command without statements, a mistyped token then the right one typed succeeds", async () => { + const plain = defineCommand({ + help: { summary: "Plain probe" }, + handler: async (_args, ctx) => { + const answer = await ctx.prompt.consent("Drop the database?", { + token: "prod-db", + }); + return ok( + ctx.present( + { data: { answer } }, + { + human: () => [], + stdout: () => [], + json: () => ({ answer }), + next: () => [], + }, + ), + ); + }, + }); + const result = await createTestCli({ commands: { plain } }).run( + ["plain", "--confirm", "prdo-db"], + { isTty: { stdin: true, stdout: true }, stdin: "prod-db\n" }, + ); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ answer: true }); + }); +});