diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index ab4972e0..95a426e1 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -164,6 +164,12 @@ A flag brief states what the flag does; when the flag exists for a distinct situ A group card may declare a workflow: the ordered commands of that group's common path, each with a short purpose column. The engine renders it as a `Workflow` section, before any examples, with each step `$`-prefixed and copy-pastable. Declare a workflow only where a real multi-command path exists; a group of independent commands has no workflow. +### Groups With Their Own Command + +A path can be a command and a group at once: `prisma dev ` runs the app, and `prisma dev url` and `prisma dev stop` are commands beneath it. `prisma dev --help` shows the `dev` command's card, with the commands beneath it in a `Commands` section after its description. The group's brief is still its row on the parent card. The group's description and workflow do not appear on that card, so the command's description defines any term the rows beneath it rely on. + +When the word after the path names a command beneath it, that command runs, so `prisma dev url` never runs `dev` with an entry named `url`; `prisma dev -- url` does. Give the command a positional whose values cannot be spelled like those commands' names, such as a file path. + ## Flags Shared flag rules: diff --git a/packages/cli-engine/README.md b/packages/cli-engine/README.md index e3ce61cb..7b782a71 100644 --- a/packages/cli-engine/README.md +++ b/packages/cli-engine/README.md @@ -10,6 +10,20 @@ 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. +## Mounting commands + +`createCli` takes commands by path. Every path with more than one word needs its group declared in `groups`, and the group's `brief` is its row on the parent's help card. A path can be a command and a group at once: + +```ts +createCli({ + groups: { dev: { brief: "Run the app locally. Start it, print URLs, stop it" } }, + commands: { dev, "dev url": devUrl, "dev stop": devStop }, + // ... +}); +``` + +`dev module.ts` runs `dev` with the positional `module.ts`, and `dev url gateway` runs `dev url`. When the word after `dev` names a command beneath it, that command runs, so `dev url` never reaches `dev` as a positional. To pass such a word as the positional, put `--` before it: `dev -- url` runs `dev` with `url`. A retired path declared as a redirect, such as `dev up`, wins over the positional the same way. `dev --help` shows the `dev` command's help and lists `url` and `stop`. + ## 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. diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index bc3124dd..2d73958a 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.7.0", + "version": "0.8.0", "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { diff --git a/packages/cli-engine/src/cli.ts b/packages/cli-engine/src/cli.ts index 5b44239b..5555e119 100644 --- a/packages/cli-engine/src/cli.ts +++ b/packages/cli-engine/src/cli.ts @@ -32,8 +32,10 @@ export interface Cli { } /** - * Shell-side construction. Group help is declared with the mount. - * Collisions, unknown groups, reserved-flag violations, grammar + * Shell-side construction. Group help is declared with the mount. A + * path may be both a command and a group (`dev` beside `dev url`); a + * child's name wins over the command's positional. Redirects onto + * mounted paths, unknown groups, reserved-flag violations, grammar * violations, and foreign-section references fail construction (build * time, not run time). */ diff --git a/packages/cli-engine/src/execution/command-tree.ts b/packages/cli-engine/src/execution/command-tree.ts index 7f276484..b41c4684 100644 --- a/packages/cli-engine/src/execution/command-tree.ts +++ b/packages/cli-engine/src/execution/command-tree.ts @@ -132,45 +132,62 @@ export interface CommandTreeEntry { } export interface CommandTreeNode { + /** The command mounted at this group's own path, such as `dev` beside + * `dev url`. It runs when the next argument names no child. */ + ownCommand: CommandTreeEntry | undefined; readonly commands: Map; readonly children: Map; } function emptyNode(): CommandTreeNode { - return { commands: new Map(), children: new Map() }; + return { ownCommand: undefined, commands: new Map(), children: new Map() }; } +function childNode(node: CommandTreeNode, segment: string): CommandTreeNode { + let child = node.children.get(segment); + if (child === undefined) { + child = emptyNode(); + node.children.set(segment, child); + } + return child; +} + +/** Every path some other mounted path extends: `dev` when `dev url` is + * mounted. */ +function groupPathsOf(paths: readonly string[]): ReadonlySet { + const groups = new Set(); + for (const path of paths) { + const segments = path.split(" "); + for (let depth = 1; depth < segments.length; depth += 1) { + groups.add(segments.slice(0, depth).join(" ")); + } + } + return groups; +} + +/** A path that is also a group keeps its command on the group's node, + * so the node's children and its own command meet in one place. */ function insertCommand( root: CommandTreeNode, path: string, def: AnyCommand, docsBaseUrl: string | undefined, + isGroup: boolean, ): void { const segments = path.split(" "); if (segments.some((segment) => segment.length === 0)) { throw constructionError(`invalid command path '${path}'`); } - let node = root; - for (const segment of segments.slice(0, -1)) { - if (node.commands.has(segment)) { - throw constructionError( - `command path '${path}' collides with a command mounted at a prefix of it`, - ); - } - let child = node.children.get(segment); - if (child === undefined) { - child = emptyNode(); - node.children.set(segment, child); - } - node = child; - } + const entry = { def, id: segments.join("."), docsBaseUrl }; + const parent = segments + .slice(0, -1) + .reduce((node, segment) => childNode(node, segment), root); const leaf = segments[segments.length - 1]; - if (node.commands.has(leaf) || node.children.has(leaf)) { - throw constructionError( - `command path '${path}' collides with another mounted command`, - ); + if (isGroup) { + childNode(parent, leaf).ownCommand = entry; + } else { + parent.commands.set(leaf, entry); } - node.commands.set(leaf, { def, id: segments.join("."), docsBaseUrl }); } /** A command belongs to the family whose commands record contains it @@ -253,6 +270,7 @@ export function buildCommandTree(spec: EngineSpec): CommandTreeNode { } validateDocsBaseUrls(spec); validateConfigSectionNames(spec); + const groupPaths = groupPathsOf(paths); const root = emptyNode(); for (const path of paths) { const def = spec.commands[path]; @@ -271,7 +289,13 @@ export function buildCommandTree(spec: EngineSpec): CommandTreeNode { ); } } - insertCommand(root, path, def, commandFamilyOf(spec, def)?.docsBaseUrl); + insertCommand( + root, + path, + def, + commandFamilyOf(spec, def)?.docsBaseUrl, + groupPaths.has(path), + ); } return root; } diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 8de363c7..aaa03dad 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -457,6 +457,18 @@ export class EngineImpl implements Engine { ); return 0; } + const retired = this.retiredPathBeneathCommand(argv); + if (retired !== undefined) { + unsubscribe(); + const exitCode = settleCommandMoved( + this.spec, + invocation, + retired, + retired.from.replaceAll(" ", "."), + ); + this.fireOnSettled(invocation, exitCode, startedAtMs); + return exitCode; + } const stricliArgv = this.takeStatementFlags(invocation, argv, startedAtMs); if (stricliArgv === undefined) { unsubscribe(); @@ -537,6 +549,24 @@ export class EngineImpl implements Engine { return extraction.argv; } + /** A retired path beneath a mounted command, such as `dev up` beside + * `dev`. The parser would hand `up` to `dev` as a positional, so the + * redirect is matched first: a retired child's name wins over the + * positional, as a live child's name does. */ + private retiredPathBeneathCommand( + argv: readonly string[], + ): CommandRedirect | undefined { + const routed = routedCommand(this.tree, argv); + if (routed === undefined) { + return undefined; + } + const redirect = matchVerbRedirect(this.redirects, attemptedPath(argv)); + return redirect !== undefined && + redirect.from.split(" ").length > routed.id.split(".").length + ? redirect + : undefined; + } + /** 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. */ diff --git a/packages/cli-engine/src/execution/help.ts b/packages/cli-engine/src/execution/help.ts index d1aac3e2..225677e1 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -86,10 +86,16 @@ function helpPath(argv: readonly string[]): readonly string[] { type HelpTarget = | { readonly kind: "node"; readonly node: CommandTreeNode } - | { readonly kind: "leaf"; readonly entry: CommandTreeEntry }; + | { + readonly kind: "command"; + readonly entry: CommandTreeEntry; + /** Set when the command's path is also a group. */ + readonly subcommands: CommandTreeNode | undefined; + }; /** Walks as far as the segments stay recognized; help for `project - * frobnicate` is project's help, not a dead end. */ + * frobnicate` is project's help, not a dead end. A group with its own + * command answers with that command's help. */ function resolveTarget( root: CommandTreeNode, segments: readonly string[], @@ -99,7 +105,10 @@ function resolveTarget( for (const segment of segments) { const entry = node.commands.get(segment); if (entry !== undefined) { - return { target: { kind: "leaf", entry }, path: [...path, segment] }; + return { + target: { kind: "command", entry, subcommands: undefined }, + path: [...path, segment], + }; } const child = node.children.get(segment); if (child === undefined) { @@ -108,14 +117,21 @@ function resolveTarget( node = child; path.push(segment); } + if (node.ownCommand !== undefined) { + return { + target: { kind: "command", entry: node.ownCommand, subcommands: node }, + path, + }; + } return { target: { kind: "node", node }, path }; } /** A BARE group invocation (`prisma-cli project`, or no argv at all) * is a help request; anything carrying flags or extra tokens is not — * `cli --unknown` and `cli project --frobnicate` must reach routing - * and usage validation, not exit 0 with a help card. A bare leaf is a - * command run and is left alone. The format-selection flags do not + * and usage validation, not exit 0 with a help card. A bare leaf, or + * a bare group with its own command, is a command run and is left + * alone. The format-selection flags do not * count: `cli project --format markdown` asks for the group's help in * Markdown. */ export function bareGroupInvocation( @@ -196,8 +212,8 @@ export function helpCard( argv: readonly string[], ): HelpCard { const { target, path } = resolveTarget(root, helpPath(argv)); - return target.kind === "leaf" - ? leafCard(spec, target.entry, path) + return target.kind === "command" + ? leafCard(spec, target.entry, target.subcommands, path) : nodeCard(spec, target.node, path); } @@ -234,6 +250,7 @@ function nodeCard( function leafCard( spec: EngineSpec, entry: CommandTreeEntry, + subcommands: CommandTreeNode | undefined, path: readonly string[], ): HelpCard { const def = entry.def; @@ -255,7 +272,8 @@ function leafCard( tagline: def.help.summary, usage, description: def.help.description, - commands: [], + commands: + subcommands === undefined ? [] : nodeRows(spec, subcommands, path), workflow: [], arguments: Object.values>(def.args.positionals) .map((spec) => positionalRuntime(spec)) @@ -311,6 +329,9 @@ function renderHelpTerminal(card: HelpCard, paint: Paint): string { lines.push(rail(paint)); proseLines(card.description, paint, lines); } + if (card.kind === "leaf") { + rowSection("Commands", card.commands, paint, lines); + } workflowLines(card.workflow, paint, lines); rowSection("Arguments", card.arguments, paint, lines); rowSection("Options", card.options, paint, lines); diff --git a/packages/cli-engine/src/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index f4db2e5e..f8410a68 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -33,7 +33,11 @@ import { writeSections, } from "./rendering"; import { emitFrame } from "./reporting"; -import { resolveExample, usageErrorCode } from "./stricli-adapter"; +import { + OWN_COMMAND_ROUTE, + resolveExample, + usageErrorCode, +} from "./stricli-adapter"; function undocumentedExitCode( def: AnyCommand, @@ -491,7 +495,8 @@ export function commandSegments( spec: EngineSpec, prefix: readonly string[], ): readonly string[] { - return prefix[0] === spec.name ? prefix.slice(1) : prefix; + const path = prefix[0] === spec.name ? prefix.slice(1) : prefix; + return path.filter((segment) => segment !== OWN_COMMAND_ROUTE); } /** Maps stricli's own settlement (parse/route failures, framework bugs) diff --git a/packages/cli-engine/src/execution/statement-flags.ts b/packages/cli-engine/src/execution/statement-flags.ts index a3762fb7..8d90740f 100644 --- a/packages/cli-engine/src/execution/statement-flags.ts +++ b/packages/cli-engine/src/execution/statement-flags.ts @@ -46,8 +46,9 @@ export interface StatementFlagValue { } /** The command argv routes to, found the way the parser routes: the - * leading words, group by group, until one names a command. Like the - * parser, a kebab-case word matches a camelCase name. */ + * leading words, group by group, until one names a command. A group + * with its own command routes to it at the first word that names no + * child. Like the parser, a kebab-case word matches a camelCase name. */ export function routedCommand( tree: CommandTreeNode, argv: readonly string[], @@ -62,11 +63,11 @@ export function routedCommand( const child = node.children.get(token) ?? node.children.get(camelCase(token)); if (child === undefined) { - return undefined; + return node.ownCommand; } node = child; } - return undefined; + return node.ownCommand; } function statementFlagIn( diff --git a/packages/cli-engine/src/execution/stricli-adapter.ts b/packages/cli-engine/src/execution/stricli-adapter.ts index 8fb440d1..3b5104e0 100644 --- a/packages/cli-engine/src/execution/stricli-adapter.ts +++ b/packages/cli-engine/src/execution/stricli-adapter.ts @@ -239,6 +239,12 @@ function toStricliCommand( }); } +/** The route a group's own command sits under, hidden from listings. + * A child's name is one path segment and never contains a space, so no + * child can take this name. A user can still type it, so it is dropped + * from the path the parser reports. */ +export const OWN_COMMAND_ROUTE = ""; + export function buildRoutes( spec: EngineSpec, node: CommandTreeNode, @@ -246,6 +252,13 @@ export function buildRoutes( runEntry: RunEntry, ): StricliRouteMap { const routes: Record = {}; + if (node.ownCommand !== undefined) { + routes[OWN_COMMAND_ROUTE] = toStricliCommand( + node.ownCommand, + spec.name, + runEntry, + ); + } for (const [name, entry] of node.commands) { routes[name] = toStricliCommand(entry, spec.name, runEntry); } @@ -260,7 +273,14 @@ export function buildRoutes( brief: spec.groups[groupPath].brief, fullDescription: spec.groups[groupPath].description, }; - return buildRouteMap({ routes, docs }); + if (node.ownCommand === undefined) { + return buildRouteMap({ routes, docs }); + } + return buildRouteMap({ + routes, + defaultCommand: OWN_COMMAND_ROUTE, + docs: { ...docs, hideRoute: { [OWN_COMMAND_ROUTE]: true } }, + }); } export function capturingText(state: RunState): ApplicationText { diff --git a/packages/cli-engine/tests/command-groups.test.ts b/packages/cli-engine/tests/command-groups.test.ts new file mode 100644 index 00000000..197e1ac4 --- /dev/null +++ b/packages/cli-engine/tests/command-groups.test.ts @@ -0,0 +1,352 @@ +/** + * A path that is both a command and a group: `dev` runs with an entry, + * and `dev url` and `dev stop` are its children. A child's name wins + * over a positional spelled the same. + */ +import { + defineCommand, + defineCommandFamily, + flag, + type MountedTree, + positional, + type RedirectSpec, +} 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 presenting(ran: string, data: Record) { + return { ran, ...data }; +} + +const dev = defineCommand({ + help: { + summary: "Run the app locally", + description: "Starts the entry module and watches it for changes.", + examples: ["dev module.ts"], + }, + args: { + flags: { port: flag.number({ brief: "Port to listen on" }) }, + positionals: { + entry: positional.optionalString({ + brief: "The module to run", + placeholder: "entry", + }), + }, + }, + statements: { delete: { arity: 1, brief: "Delete a resource: --delete X" } }, + handler: async (args, ctx) => { + const data = presenting("dev", { + entry: args.positionals.entry, + port: args.flags.port, + deleted: ctx.statements.take("delete").map((answer) => answer.text), + }); + return ok( + ctx.present( + { data }, + { + human: () => [], + stdout: () => [], + json: () => data, + next: () => [], + }, + ), + ); + }, +}); + +const devUrl = defineCommand({ + help: { summary: "Print the local URL of a running service" }, + args: { + positionals: { + service: positional.optionalString({ + brief: "The service to print", + placeholder: "service", + }), + }, + }, + handler: async (args, ctx) => { + const data = presenting("dev url", { service: args.positionals.service }); + return ok( + ctx.present( + { data }, + { + human: () => [], + stdout: () => [], + json: () => data, + next: () => [], + }, + ), + ); + }, +}); + +const devStop = defineCommand({ + help: { summary: "Stop the running app" }, + handler: async (_args, ctx) => { + const data = presenting("dev stop", {}); + return ok( + ctx.present( + { data }, + { + human: () => [], + stdout: () => [], + json: () => data, + next: () => [], + }, + ), + ); + }, +}); + +const DEV_GROUP = { + dev: { brief: "Run the app locally. Start it, print URLs, stop it" }, +}; + +function devCli( + commands: MountedTree = { + dev, + "dev url": devUrl, + "dev stop": devStop, + }, + redirects: readonly RedirectSpec[] = [], +): TestCli { + return createTestCli({ + commandFamilies: [defineCommandFamily({ commands, redirects })], + commands, + groups: DEV_GROUP, + }); +} + +const TERMINAL = { isTty: { stdout: true } }; + +function lastEnvelope(result: Awaited>) { + const last = result.json[result.json.length - 1]; + if (last.kind !== "result") { + throw new Error("expected a result frame"); + } + return last.envelope; +} + +describe("routing", () => { + test("the path's own command runs with a positional", async () => { + const result = await devCli().run(["dev", "module.ts", "--json"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + ran: "dev", + entry: "module.ts", + port: undefined, + deleted: [], + }); + expect(lastEnvelope(result).commandId).toBe("dev"); + }); + + test("the path's own command runs with no arguments instead of printing help", async () => { + const result = await devCli().run(["dev"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toMatchObject({ ran: "dev" }); + }); + + test("the path's own command runs with only flags", async () => { + const result = await devCli().run(["dev", "--port", "3000", "--json"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toMatchObject({ ran: "dev", port: 3000 }); + }); + + test("a child runs", async () => { + const result = await devCli().run(["dev", "url", "gateway", "--json"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + ran: "dev url", + service: "gateway", + }); + expect(lastEnvelope(result).commandId).toBe("dev.url"); + }); + + test("a child's name wins over a positional spelled the same", async () => { + const result = await devCli().run(["dev", "url", "--json"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toEqual({ + ran: "dev url", + service: undefined, + }); + }); + + test("routing does not depend on the order the paths were mounted in", async () => { + const result = await devCli({ + "dev url": devUrl, + "dev stop": devStop, + dev, + }).run(["dev", "module.ts", "--json"]); + + expect(result.presented?.data).toMatchObject({ + ran: "dev", + entry: "module.ts", + }); + }); + + test("the path's own command takes its statement flags", async () => { + const result = await devCli().run([ + "dev", + "module.ts", + "--delete", + "Legacy", + "--json", + ]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toMatchObject({ deleted: ["Legacy"] }); + }); + + test("an unknown flag on the path's own command is a usage error for that command", async () => { + const result = await devCli().run(["dev", "--frobnicate", "--json"]); + + expect(result.exitCode).toBe(2); + const envelope = lastEnvelope(result); + expect(envelope.commandId).toBe("dev"); + expect(!envelope.ok && envelope.error.code).toBe("CLI.INVALID_ARGUMENTS"); + }); + + test("a retired flag on the path's own command names its replacement", async () => { + const result = await devCli(undefined, [ + { from: "dev", flag: "listen", replacement: "dev --port 3000" }, + ]).run(["dev", "--listen", "3000", "--json"]); + + expect(result.exitCode).toBe(2); + const envelope = lastEnvelope(result); + expect(!envelope.ok && envelope.error.code).toBe("CLI.COMMAND_MOVED"); + }); + + test("a retired child's name wins over the positional, as a live child's does", async () => { + const cli = devCli(undefined, [{ from: "dev up", replacement: "dev" }]); + + const retired = await cli.run(["dev", "up", "--json"]); + expect(retired.exitCode).toBe(2); + const envelope = lastEnvelope(retired); + expect(envelope.commandId).toBe("dev.up"); + expect(!envelope.ok && envelope.error.code).toBe("CLI.COMMAND_MOVED"); + + const live = await cli.run(["dev", "module.ts", "--json"]); + expect(live.presented?.data).toMatchObject({ + ran: "dev", + entry: "module.ts", + }); + }); + + test("-- passes a word spelled like a child to the path's own command", async () => { + const result = await devCli().run(["dev", "--json", "--", "url"]); + + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toMatchObject({ ran: "dev", entry: "url" }); + }); + + test("the route the own command sits under never appears in the command id", async () => { + const result = await devCli().run([ + "dev", + "", + "--frobnicate", + "--json", + ]); + + expect(result.exitCode).toBe(2); + expect(lastEnvelope(result).commandId).toBe("dev"); + }); +}); + +describe("help", () => { + test("--help on the path shows its command's usage and lists the children", async () => { + const result = await devCli().run( + ["dev", "--help", "--no-color"], + TERMINAL, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe( + "prisma-test dev → Run the app locally\n\n│ Usage\n│ $ prisma-test dev [options] [entry]\n│\n│ Starts the entry module and watches it for changes.\n│\n│ Commands\n│ url [service] Print the local URL of a running service\n│ stop Stop the running app\n│\n│ Arguments\n│ entry The module to run (optional)\n│\n│ Options\n│ --port Port to listen on\n│ --delete ... Delete a resource: --delete X\n│\n│ Global options also apply: --format, --json, --log-level, --verbose,\n│ --quiet, --yes, --confirm, --interactive, --color, --config. Run\n│ 'prisma-test --help' for details.\n│\n│ Examples\n│ $ prisma-test dev module.ts\n\n", + ); + }); + + test("--help after a positional shows the path's own help", async () => { + const withPositional = await devCli().run( + ["dev", "module.ts", "--help", "--no-color"], + TERMINAL, + ); + const bare = await devCli().run(["dev", "--help", "--no-color"], TERMINAL); + + expect(withPositional.stdout).toBe(bare.stdout); + }); + + test("--help on a child shows the child's help", async () => { + const result = await devCli().run( + ["dev", "url", "--help", "--no-color"], + TERMINAL, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "prisma-test dev url → Print the local URL of a running service", + ); + expect(result.stdout).toContain( + "$ prisma-test dev url [options] [service]", + ); + }); + + test("Markdown help lists the children under the usage", async () => { + const result = await devCli().run([ + "dev", + "--help", + "--format", + "markdown", + ]); + + expect(result.stdout).toContain( + "## Usage\n\n```bash\nprisma-test dev [options] [entry]\n```", + ); + expect(result.stdout).toContain( + "## Commands\n\n| Command | Description |\n| --- | --- |\n| `url [service]` | Print the local URL of a running service |\n| `stop` | Stop the running app |", + ); + }); + + test("the parent card lists the path once, with its group brief", async () => { + const result = await devCli().run(["--help", "--no-color"], TERMINAL); + + expect(result.stdout).toContain( + "│ dev Run the app locally. Start it, print URLs, stop it\n", + ); + expect(result.stdout.match(/│ {2}dev /g)).toHaveLength(1); + }); +}); + +describe("construction", () => { + test("the path still needs a group declaration", () => { + expect(() => + createTestCli({ commands: { dev, "dev url": devUrl } }), + ).toThrow("unknown group 'dev'"); + }); + + test("the path's own command is still validated", () => { + const offender = defineCommand({ + help: { summary: "Offender" }, + args: { flags: { json: flag.boolean({ brief: "mine" }) } }, + handler: null as never, + }); + + expect(() => + createTestCli({ + commands: { dev: offender, "dev url": devUrl }, + groups: DEV_GROUP, + }), + ).toThrow("command 'dev' declares reserved flag 'json'"); + }); + + test("a verb redirect onto the path collides with its command", () => { + expect(() => + devCli(undefined, [{ from: "dev", replacement: "run" }]), + ).toThrow("redirect 'dev' collides with a mounted command"); + }); +}); diff --git a/packages/cli-engine/tests/engine.test.ts b/packages/cli-engine/tests/engine.test.ts index 79ea2429..936a553c 100644 --- a/packages/cli-engine/tests/engine.test.ts +++ b/packages/cli-engine/tests/engine.test.ts @@ -207,15 +207,6 @@ describe("construction validation", () => { ).toThrow("unknown group 'auth'"); }); - test("a command mount colliding with a group prefix fails construction", () => { - expect(() => - createTestCli({ - commands: { auth: command, "auth whoami": command }, - groups: { auth: { brief: "Authentication" } }, - }), - ).toThrow("collides"); - }); - test("reserved shared-family flag names fail construction", () => { const offender = defineCommand({ help: { summary: "Offender" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 9ba854be..26269292 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.7.0", + "@prisma/cli-engine": "workspace:0.8.0", "@prisma/composer-cli": "0.30.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 a4f64251..0d56163a 100644 --- a/packages/cli/scripts/conformance.ts +++ b/packages/cli/scripts/conformance.ts @@ -112,19 +112,19 @@ async function tarball(): Promise { 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", + familyPin: "0.7.0", + shellPin: "0.8.0", + reason: "engine 0.8.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", + "composer-cli releases peering 0.8.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", + familyPin: "0.7.0", + shellPin: "0.8.0", + reason: "engine 0.8.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", + "orm-toolchain releases peering 0.8.0 and the follow-up bump PR pins that release", }, ], channel: CHANNEL, diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 184a6479..9dd0823c 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.7.0", + "@prisma/cli-engine": "workspace:0.8.0", "@prisma/composer-cli": "0.30.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 dcbf365e..51bc874b 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.7.0 + specifier: workspace:0.8.0 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.30.0 @@ -210,7 +210,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.7.0 + specifier: workspace:0.8.0 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.30.0