Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/product/cli-style-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <entry>` 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:
Expand Down
14 changes: 14 additions & 0 deletions packages/cli-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-engine/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
6 changes: 4 additions & 2 deletions packages/cli-engine/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down
66 changes: 45 additions & 21 deletions packages/cli-engine/src/execution/command-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CommandTreeEntry>;
readonly children: Map<string, CommandTreeNode>;
}

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<string> {
const groups = new Set<string>();
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
Expand Down Expand Up @@ -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];
Expand All @@ -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;
}
Expand Down
30 changes: 30 additions & 0 deletions packages/cli-engine/src/execution/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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. */
Expand Down
37 changes: 29 additions & 8 deletions packages/cli-engine/src/execution/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -234,6 +250,7 @@ function nodeCard(
function leafCard(
spec: EngineSpec,
entry: CommandTreeEntry,
subcommands: CommandTreeNode | undefined,
path: readonly string[],
): HelpCard {
const def = entry.def;
Expand All @@ -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<PositionalSpec<unknown>>(def.args.positionals)
.map((spec) => positionalRuntime(spec))
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions packages/cli-engine/src/execution/settlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions packages/cli-engine/src/execution/statement-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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(
Expand Down
22 changes: 21 additions & 1 deletion packages/cli-engine/src/execution/stricli-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,13 +239,26 @@ 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 = "<own command>";

export function buildRoutes(
spec: EngineSpec,
node: CommandTreeNode,
groupPath: string,
runEntry: RunEntry,
): StricliRouteMap<EngineRunContext> {
const routes: Record<string, EngineRoutingTarget> = {};
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);
}
Expand All @@ -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 {
Expand Down
Loading
Loading