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..605c68e8 --- /dev/null +++ b/docs/architecture/adrs/0006-consent-as-a-statement.md @@ -0,0 +1,56 @@ +# 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 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 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. +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. + +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. + +### 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. +- **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 + +- 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 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/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 diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index d50a1476..ab4972e0 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -210,6 +210,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 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, 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 Loading indicators are for slow or remote work, not for every step. 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/docs/reference/error-reference.md b/docs/reference/error-reference.md index f96bdd11..fd43568e 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 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 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 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. @@ -178,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 @@ -190,11 +196,11 @@ 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; 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 -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..e3ce61cb 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -10,4 +10,55 @@ 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. + +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. 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: + +```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", values: ["Legacy"] } +// 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`. 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`. 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, 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. + +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. + Part of [prisma/prisma-cli](https://github.com/prisma/prisma-cli). 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-engine/src/commands.ts b/packages/cli-engine/src/commands.ts index a5fc412e..7340afd3 100644 --- a/packages/cli-engine/src/commands.ts +++ b/packages/cli-engine/src/commands.ts @@ -143,6 +143,23 @@ export interface SpawnDeclarations { readonly maySpawn?: boolean; } +/** + * 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 + * 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 + * 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; +} + function normalizeNeeds( spec: NeedsSpec | undefined, ): CommandNeeds { @@ -200,6 +217,10 @@ export interface CommandDefinition< */ readonly installsPackages: TInstallsPackages; + /** 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 * (operator ruling, 2026-08-09). A handler that needs heavy @@ -271,6 +292,7 @@ export function defineCommand< readonly exitCodes?: Readonly>; readonly managesCredentials?: TManagesCredentials; readonly installsPackages?: TInstallsPackages; + readonly statements?: Readonly>; readonly handler: Handler< TFlags, TPositionals, @@ -298,6 +320,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, }); } @@ -361,6 +384,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), @@ -417,6 +445,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 24c19db6..357ef066 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 @@ -191,6 +194,68 @@ export interface BrowserWaitRequest { readonly interval?: number; } +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 `:`. + * 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[]; + /** 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; + /** 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 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. 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[]; + /** + * 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 { + /** 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, and ask + * any consent with a token, before the `last` batch: it reports + * their flags as unused at once. */ + readonly last?: boolean; +} + /** * Prompts. Every prompt resolves to its answered value directly. * Failures THROW engine-internal structured errors the engine catches @@ -199,12 +264,17 @@ export interface BrowserWaitRequest { * 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 + * 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. + * + * 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: ( @@ -228,6 +298,35 @@ 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 one the command declares in `statements`; the + * flag takes that declaration's `arity` values per occurrence. + */ + 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[], + opts?: StatementsOptions, + ) => 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..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( @@ -55,6 +57,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 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 }>, @@ -71,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); @@ -81,6 +88,7 @@ export async function makeClackRenderer( clack.confirm({ input, output, + signal, message: question, initialValue: initial ?? false, }), @@ -88,6 +96,7 @@ export async function makeClackRenderer( clack.confirm({ input, output, + signal, message: question, initialValue: false, }), @@ -95,6 +104,7 @@ export async function makeClackRenderer( clack.text({ input, output, + signal, message: `${question} Type ${token} to confirm.`, placeholder: token, validate: (value) => @@ -102,6 +112,7 @@ export async function makeClackRenderer( ? undefined : `Type ${token} exactly, or press Ctrl-C.`, }), + statement: (message) => clack.text({ input, output, signal, message }), select: ( question: string, options: ReadonlyArray<{ value: T; label: string }>, @@ -110,6 +121,7 @@ export async function makeClackRenderer( clack.select({ input, output, + signal, message: question, options: options.map((option) => ({ value: option.value, @@ -121,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/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/command-snapshot.ts b/packages/cli-engine/src/execution/command-snapshot.ts index 08f7dce0..72e82e97 100644 --- a/packages/cli-engine/src/execution/command-snapshot.ts +++ b/packages/cli-engine/src/execution/command-snapshot.ts @@ -10,9 +10,10 @@ 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 { declaredStatements } from "./statement-flags"; function declaredFlagKeys(def: AnyCommand): readonly string[] { - const own = Object.keys(def.args.flags); + const own = [...Object.keys(def.args.flags), ...declaredStatements(def)]; if (def.kind === "server-command") { return own; } diff --git a/packages/cli-engine/src/execution/command-tree.ts b/packages/cli-engine/src/execution/command-tree.ts index 8785d1aa..7f276484 100644 --- a/packages/cli-engine/src/execution/command-tree.ts +++ b/packages/cli-engine/src/execution/command-tree.ts @@ -10,12 +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, 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 { @@ -50,6 +52,31 @@ function validateFlags(path: string, def: AnyCommand): void { } } +function validateStatements(path: string, def: AnyCommand): void { + for (const [verb, spec] of Object.entries(statementsOf(def))) { + if (!ONE_LOWERCASE_WORD.test(verb)) { + throw constructionError( + `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)) { + 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 (!Number.isInteger(spec.arity) || spec.arity < 1) { + throw constructionError( + `command '${path}' statement '${verb}' declares arity ${spec.arity}; arity is a whole number of values, at least 1`, + ); + } + } +} + function validatePositionals(path: string, def: AnyCommand): void { const entries = Object.entries>(def.args.positionals); let sawOptional = false; @@ -230,6 +257,7 @@ export function buildCommandTree(spec: EngineSpec): CommandTreeNode { for (const path of paths) { const def = spec.commands[path]; validateFlags(path, def); + validateStatements(path, def); validatePositionals(path, def); validateExitCodes(path, def); validateSpawnDeclarations(path, def); @@ -283,7 +311,10 @@ 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 { - if (def.args.flags[flag] !== undefined) { + if ( + def.args.flags[flag] !== undefined || + declaredStatements(def).includes(flag) + ) { return true; } return def.kind !== "server-command" && RESERVED_FLAG_NAMES.has(flag); diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 637795fc..8de363c7 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 { unusedConsentError } from "./prompts"; import { commandSegments, settleBug, @@ -71,6 +73,13 @@ import { endAbandonedChild, recordSignalDuringSpawn, } from "./spawn"; +import { + type DeclaredStatements, + extractStatementFlags, + routedCommand, + type StatementFlagValue, + statementsOf, +} from "./statement-flags"; import { buildRoutes, capturingText, @@ -145,6 +154,14 @@ export interface RunState { * token removes the value it matched, so one `--confirm` grants one * consent. */ confirmValues: 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. */ + statementValues: StatementFlagValue[]; interactive: boolean; colorEnabled: boolean; /** The file `--config` named, if the run named one. */ @@ -178,6 +195,13 @@ 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). 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 * operations against each other, and blocks ctx.spawn: a child * writing the terminal directly while the manager's output is being @@ -336,6 +360,9 @@ export class EngineImpl implements Engine { logLevel: "info", yes: false, confirmValues: [], + statements: {}, + askedSubjects: new Set(), + statementValues: [], interactive: defaultInteractive(runtime), /** Pre-parse resolution so a run that never mounts a command — an * unknown command, a parse failure — still colours its @@ -354,6 +381,8 @@ export class EngineImpl implements Engine { delegatedTerminal: undefined, lastChild: undefined, activePrompts: 0, + readingPrompt: false, + promptCancel: new AbortController(), packageOperationRunning: false, deliveredSignal: undefined, pendingForceExit: undefined, @@ -369,10 +398,19 @@ 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); + if (signal === "SIGINT") { + return; + } + } state.deliveredSignal = signal; controller.abort(signal); }; @@ -419,6 +457,11 @@ export class EngineImpl implements Engine { ); return 0; } + 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 * exactly the frame stream, so help prose goes to stderr instead. */ @@ -445,7 +488,7 @@ export class EngineImpl implements Engine { localization: { text: capturingText(state) }, }); try { - await runStricli(app, [...argv], { + await runStricli(app, [...stricliArgv], { process: stricliProcess, forCommand: (info) => { state.prefix = info.prefix; @@ -467,6 +510,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. */ @@ -589,8 +659,11 @@ export class EngineImpl implements Engine { return; } state.resolved = true; + const unused = unusedConsentError(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 { @@ -778,6 +851,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/help.ts b/packages/cli-engine/src/execution/help.ts index b09a5faa..d1aac3e2 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -20,6 +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 { + statementBrief, + statementPlaceholders, + statementsOf, +} from "./statement-flags"; import { resolveExample } from "./stricli-adapter"; const RAIL = "│"; @@ -517,7 +522,18 @@ function sharedFlagRows(): readonly HelpRow[] { ]; } +function statementFlagRows(def: AnyCommand): readonly HelpRow[] { + return Object.entries(statementsOf(def)).map(([verb, spec]) => ({ + name: ` --${verb} ${statementPlaceholders(spec)}...`, + brief: statementBrief(verb, spec), + })); +} + 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/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/prompts.ts b/packages/cli-engine/src/execution/prompts.ts index a668595e..c8c2a754 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,8 +25,14 @@ * re-prompts, the line renderer fails structurally, because a scripted * or piped answer cannot be corrected. */ -import type { PromptSurface } from "../context"; -import { CliStructuredError } from "../protocol"; +import type { + PromptSurface, + StatementAnswer, + StatementQuestion, + StatementSurface, + StatementsOptions, +} from "../context"; +import { CliStructuredError, type NextAction } from "../protocol"; import type { InputStream } from "../runtime"; import { type ClackRenderer, @@ -31,6 +42,11 @@ import { import { constructionError } from "./command-tree"; import type { Invocation, RunState } from "./engine"; import { announceUrl } from "./open-url"; +import type { DeclaredStatements, StatementFlagValue } from "./statement-flags"; + +const WHITESPACE = /\s/; +const SHELL_PLAIN = /^[A-Za-z0-9_.:@%+/,-][A-Za-z0-9_.:@%+/,=-]*$/; +const WHITESPACES = /\s+/; /** How often browserWait asks whether the user has finished. */ const BROWSER_WAIT_POLL_INTERVAL_MS = 1000; @@ -46,6 +62,303 @@ 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 { + 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 { + return value === subject || value.startsWith(`${subject}:`); +} + +function subjectOf(value: string): string { + const colon = value.indexOf(":"); + return colon === -1 ? value : value.slice(0, colon); +} + +/** 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 && + fits(candidate.values[0]), + ); + if (value === undefined) { + continue; + } + const text = value.values.join(" "); + const rejection = question.validate(verb, text); + if (rejection !== undefined) { + throw new CliStructuredError( + "CLI.PROMPT_INVALID", + `${flagForm(verb, value.values)} does not answer "${question.question}": ${rejection}`, + ); + } + value.consumed = true; + return { verb, text, values: value.values }; + } + 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 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 + ? `${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), + ...strayFlagLines(state, subjects), + ].join("\n"), + nextActions: unanswered.flatMap((question) => + question.verbs.map((verb) => ({ + kind: "user-choice" as const, + label: `Run the command again with ${flagForm(verb, [question.forms?.[verb] ?? question.subject])}`, + })), + ), + meta: { + ...(listed.length === 1 ? listed[0] : {}), + unanswered: listed, + ...(unmatched.length === 0 ? {} : { unmatched }), + ...(state.confirmValues.length === 0 + ? {} + : { confirm: [...state.confirmValues] }), + }, + }); +} + +type ParsedStatement = + | { readonly answer: StatementAnswer } + | { readonly problem: string }; + +/** An interactive answer: ` `, or `` alone to mean + * 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); + 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 typed = rest === "" ? question.subject : rest; + const { arity } = state.statements[verb]; + 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 } } + : { problem: rejection }; +} + +/** 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 === "") { + return `${about}: a subject must be non-empty`; + } + 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`; +} + +/** The asked subject a value names, if any. */ +function askedSubjectOf( + state: RunState, + value: StatementFlagValue, +): string | undefined { + return longestSubjectNamed(value.values[0], state.askedSubjects); +} + +function unusedSentence(state: RunState, value: StatementFlagValue): string { + const given = flagForm(value.verb, value.values); + 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.`; +} + +/** `--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, +): 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 (leftoverConfirmTokens(state).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 && leftoverConfirmTokens(state).length === 0) { + return undefined; + } + const sentences = [ + ...unused.map((value) => unusedSentence(state, value)), + ...leftoverConfirmTokens(state).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 })), + ...(leftoverConfirmTokens(state).length === 0 + ? {} + : { confirm: [...leftoverConfirmTokens(state)] }), + ...(asked.length === 0 ? {} : { asked }), + }, + }); +} + function makeLineReader( stdin: InputStream, invocation: Invocation, @@ -259,6 +572,36 @@ function parseBooleanAnswer( throw promptInvalid(question, raw); } +/** 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( + `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; @@ -268,6 +611,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); + } + 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 ( question: string, run: (r: ClackRenderer) => Promise, @@ -279,10 +651,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); } @@ -306,7 +682,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); } @@ -382,6 +759,100 @@ export function makePromptSurface(invocation: Invocation): PromptSurface { return raw; }; + const askStatement = async ( + question: StatementQuestion, + ): Promise> => { + const asked = `${question.question} (${statementHint(question)})`; + if (useClack()) { + 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, `? ${asked} `); + if (typeof raw !== "string") { + throw promptInvalid(question.question, String(raw)); + } + const parsed = parseStatement(raw, question, state); + 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 requireWellFormed = ( + questions: readonly StatementQuestion[], + ): void => { + for (const question of questions) { + const problem = malformation(question, state.statements); + if (problem !== undefined) { + throw constructionError( + `command '${state.commandId}' asked a statement ${problem}`, + ); + } + } + }; + + /** 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[], + opts: StatementsOptions | undefined, + ): Promise[]> => { + requireWellFormed(questions); + for (const question of questions) { + state.askedSubjects.add(question.subject); + } + 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, + ); + if (unanswered.length > 0 && (state.yes || !state.interactive)) { + throw statementUnavailable( + unanswered, + questions.map((question) => question.subject), + 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))]; + }; + const answers = await answerFrom(0); + const unused = opts?.last === true ? unusedConsentError(state) : undefined; + if (unused !== undefined) { + throw unused; + } + return answers; + }; + /** 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 +863,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 +971,14 @@ 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 }], undefined), + ); + return answer; + }, + statements: (questions, opts) => + claimTerminal(() => statements(questions, opts)), browserWait: (request) => claimTerminal(() => surface.browserWait(request)), }; } 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/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/src/execution/statement-flags.ts b/packages/cli-engine/src/execution/statement-flags.ts new file mode 100644 index 00000000..a3762fb7 --- /dev/null +++ b/packages/cli-engine/src/execution/statement-flags.ts @@ -0,0 +1,180 @@ +/** + * 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. 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"; +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 ?? {}) : {}; +} + +export function declaredStatements(def: AnyCommand): readonly string[] { + 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(" "); +} + +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; + /** The `arity` values the flag was given, in order. */ + readonly values: readonly string[]; + consumed: boolean; +} + +/** The command argv routes to, found the way the parser routes: the + * 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) ?? node.commands.get(camelCase(token)); + if (entry !== undefined) { + return entry; + } + const child = + node.children.get(token) ?? node.children.get(camelCase(token)); + if (child === undefined) { + return undefined; + } + node = child; + } + return undefined; +} + +function statementFlagIn( + token: string, + statements: DeclaredStatements, +): { readonly verb: string; readonly inline: string | undefined } | undefined { + if (!token.startsWith("--")) { + return undefined; + } + const equals = token.indexOf("="); + const verb = token.slice(2, equals === -1 ? undefined : equals); + if (!Object.hasOwn(statements, verb)) { + return undefined; + } + 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`; + 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 { + 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 }; + +/** + * 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 extractStatementFlags( + argv: readonly string[], + 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; + } + 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, tokens[index]), + }; + } + if (given.some((value) => value.trim() === "")) { + return { ok: false, error: emptyValue(flag.verb) }; + } + values.push({ verb: flag.verb, values: given, consumed: false }); + } + return { ok: true, argv: [...kept, ...rest], values }; +} diff --git a/packages/cli-engine/src/exports/index.ts b/packages/cli-engine/src/exports/index.ts index c983feb5..054dec5b 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 { @@ -74,6 +75,11 @@ export type { OpenUrlOutcome, OpenUrlRequest, PromptSurface, + StatementAnswer, + StatementOptions, + StatementQuestion, + StatementSurface, + 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 b9c391ed..bdb021d3 100644 --- a/packages/cli-engine/tests/clack-prompts.test.ts +++ b/packages/cli-engine/tests/clack-prompts.test.ts @@ -59,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( @@ -278,6 +279,41 @@ describe("the clack tier resolves prompt values", () => { expect(result.exitCode).toBe(3); }); + 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:." + : undefined, + }), + [ + ..."drop", + ENTER, + ..."rename Archive", + ENTER, + ..."rename Legacy:Archive", + ENTER, + ], + ); + + expect(result.exitCode).toBe(0); + 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.", + ); + 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/consent-leftovers.test.ts b/packages/cli-engine/tests/consent-leftovers.test.ts new file mode 100644 index 00000000..931fd90f --- /dev/null +++ b/packages/cli-engine/tests/consent-leftovers.test.ts @@ -0,0 +1,197 @@ +/** + * 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("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", + "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"] }, + }); + }); +}); + +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 }); + }); +}); 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/prompt-signals.test.ts b/packages/cli-engine/tests/prompt-signals.test.ts new file mode 100644 index 00000000..c59051c9 --- /dev/null +++ b/packages/cli-engine/tests/prompt-signals.test.ts @@ -0,0 +1,158 @@ +/** + * A SIGTERM or SIGINT delivered while a prompt waits for the user + * 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, + 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 }), + }), + }; +} + +/** 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" }, + 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; + const exits: string[] = []; + const deliverRecording = (): void => { + try { + deliver?.(signal); + } catch (cause) { + exits.push(String(cause)); + } + }; + let stderr = ""; + const runtime: Runtime = { + stdout: { write: () => {} }, + stderr: { + write: (text) => { + stderr += text; + if (text.includes("Proceed?")) { + for (const delay of deliveryDelays(times)) { + setTimeout(deliverRecording, delay); + } + } + }, + }, + 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, exits }; +} + +describe("a signal while a prompt waits", () => { + test.each([ + ["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"); + }); +}); 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..7bb21250 --- /dev/null +++ b/packages/cli-engine/tests/statement-declarations.test.ts @@ -0,0 +1,212 @@ +/** + * 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, + defineCommandFamily, + defineServerCommand, + defineSessionCommand, + 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", values: ["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"]); + + 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 () => { + 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 kebab-case name", + { "drop-table": { arity: 1 } }, + "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", + ], + ]; + + test.each(clashes)("%s", (_case, statements, message) => { + expect(() => + createTestCli({ commands: { probe: command(statements) } }), + ).toThrow(message); + }); + + 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({ commandFamilies: [family], commands: { probe } }), + ).toThrow( + "redirect for flag 'delete' on 'probe' names a flag that command still accepts", + ); + }); + + 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" }, + 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..7296933e --- /dev/null +++ b/packages/cli-engine/tests/statement-edge-cases.test.ts @@ -0,0 +1,351 @@ +/** + * 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", + ], + ["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"], + ); + + 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'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 }, + 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("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 }, + stdin: "delete\n", + }); + + expect(result.exitCode).toBe(0); + 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(); + } + }); + + 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-forms.test.ts b/packages/cli-engine/tests/statement-flag-forms.test.ts new file mode 100644 index 00000000..61da8f8d --- /dev/null +++ b/packages/cli-engine/tests/statement-flag-forms.test.ts @@ -0,0 +1,170 @@ +/** + * 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", "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', + `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 starting with '-' that needs quoting", + "-r f", + "Run the command again with --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-flag-order.test.ts b/packages/cli-engine/tests/statement-flag-order.test.ts new file mode 100644 index 00000000..0e28fec4 --- /dev/null +++ b/packages/cli-engine/tests/statement-flag-order.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "vitest"; +import { extractStatementFlags } from "../src/execution/statement-flags"; + +const STATEMENTS = { + rename: { arity: 1 }, + delete: { arity: 1 }, + move: { arity: 2 }, +}; + +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("statement flags come out of argv in the order given", () => { + test("values of different verbs interleave as written", () => { + expect( + 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("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(extracted(["--delete", "Legacy", "--", "--rename", "X:Y"])).toEqual({ + argv: ["--", "--rename", "X:Y"], + values: ["delete Legacy"], + }); + }); + + 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"], + values: [], + }); + }); +}); + +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. 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 }); + }); +}); 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..ef320388 --- /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 or 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 new file mode 100644 index 00000000..185ade73 --- /dev/null +++ b/packages/cli-engine/tests/statement-prompts.test.ts @@ -0,0 +1,535 @@ +/** + * 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, + 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" }, + statements: { rename: { arity: 1 }, delete: { arity: 1 } }, + 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) { + return createTestCli({ commands: { probe: probe(ask) }, 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", values: ["Legacy"] }, + }); + 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 () => { + 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", + values: ["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", values: ["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: "Run the command again with --rename 'Legacy:'", + }, + { + kind: "user-choice", + label: "Run the command again with --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( + "Run the command again with --rename 'Legacy:'", + ); + expect(result.stderr).toContain( + "Run the command again with --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", values: ["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", + values: ["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 Legacy: or 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: "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"] }], + }, + }); + }); + + 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: "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: [ + { 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", values: ["Legacy:Archive"] }, + { verb: "delete", text: "User.name", values: ["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", values: ["Legacy:Archive"] }, + { verb: "delete", text: "User.name", values: ["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", values: ["Legacy"] }, + { + verb: "rename", + text: "User.name:fullName", + values: ["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.", + why: "The run asked about Legacy.", + nextActions: [ + { + kind: "user-choice", + label: + "Remove the flag, or spell the subject the way the command names it.", + }, + ], + meta: { + unused: [{ verb: "delete", values: ["Lagacy"] }], + asked: ["Legacy"], + }, + }); + }); + + 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)).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 value equal to the subject answers before one that only starts with it", async () => { + const result = await cliWith(askLegacy).run([ + "probe", + "--delete", + "Legacy", + "--rename", + "Legacy:Archive", + "--json", + ]); + + expect(errorOf(result)?.summary).toBe( + "--rename Legacy:Archive 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 () => { + const failing = defineCommand({ + help: { summary: "Failing probe" }, + statements: { delete: { arity: 1 } }, + handler: async () => + notOk(new CliStructuredError("CLI.INVALID_ARGUMENTS", "Bad input.")), + }); + const cli = createTestCli({ commands: { probe: failing } }); + const result = await cli.run(["probe", "--delete", "Legacy", "--json"]); + + expect(errorOf(result)?.code).toBe("CLI.INVALID_ARGUMENTS"); + }); +}); 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..3fb97a08 --- /dev/null +++ b/packages/cli-engine/tests/statement-take.test.ts @@ -0,0 +1,251 @@ +/** + * 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("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"); + 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: "Run the command again with --rename 'Legacy:'", + }, + { + kind: "user-choice", + label: "Run the command again with --delete Legacy", + }, + ], + }); + }); +}); + +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"], + }, + }); + }); +}); 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