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
Binary file added docs/assets/cli-help/horizontal-paint.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/cli-help/init-paint.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions docs/product/cli-style-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,31 @@ Recommended symbols:
- Human-facing paths should usually be shown relative to the current working directory.
- Structured output should use the literal machine-meaningful value.
- Banners are reserved for first-run experiences such as `auth login`.
- Explicit root help (`prisma --help` or `prisma -h`) may place a compact ASCII
rendition of the Prisma brand mark and wordmark to the right of the command list. Show it
only in human TTY output when the terminal has room for the text, a four-column
gap, and the full mark. When there is no room beside the text, place the same horizontal lockup
above the help with a blank line below it. Omit it when even the mark does not
fit, for unknown widths, pipes, JSON, Markdown, and group or command help. Respect the
normal color settings. Use Node’s `util.styleText` with cyan, bright red,
and yellow for the three bands, matching the supported Node 22 runtime.
The exact shades follow the terminal palette; fall back to
monochrome with `NO_COLOR` or `--no-color`. The ASCII Prisma wordmark uses bold with the terminal’s default foreground.
Reset inherited terminal styling around each artwork row so help and init
render the wordmark consistently.
- `prisma init` displays the same horizontal lockup above its status output on
stderr, after argument and configuration validation. Omit it for JSON, Markdown, quiet
mode, non-TTY output, or a terminal too narrow to fit it. Bare `prisma`, group
help, and other commands do not display the logo.
- In an interactive color terminal, reveal the symbol once by painting cyan,
then red, then yellow, top to bottom within each band (200ms per band, 600ms
total). The wordmark and help text remain stationary. Print the rest of the
help after the reveal, and restore the cursor if interrupted. Use the final
static logo in CI, dumb terminals, `--no-interactive`, quiet mode,
`NO_COLOR` / `--no-color`, or when `PRISMA_REDUCED_MOTION=1`. Skip animation
when the logo cannot fit in the visible terminal height. If the terminal
resizes during animation, stop cursor rewrites and print the current static
layout below the partial frame.
- Outside those flows, focus on status, context, result, and next steps.

Human-oriented command output in TTY mode should usually start with a compact header.
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.4.0",
"version": "0.4.1",
"description": "The execution engine of the unified Prisma CLI.",
"type": "module",
"exports": {
Expand Down
3 changes: 3 additions & 0 deletions packages/cli-engine/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CommandFamily, MountedTree } from "./command-family";
import type { WorkflowStep } from "./commands";
import { buildEngine } from "./execution/engine";
import type { HelpArtworkLine } from "./help-artwork";
import type { RunSummary } from "./run-summary";
import type { Runtime } from "./runtime";
import type { TelemetryDeclaration } from "./telemetry/report";
Expand Down Expand Up @@ -56,6 +57,8 @@ export function createCli(spec: {
/** Words for the root help card; the engine formats. */
readonly help?: {
readonly tagline?: string;
readonly artwork?: readonly HelpArtworkLine[];
readonly artworkCommands?: readonly string[];
readonly description?: string;
/** The CLI's common path, rendered as a `Workflow` section. */
readonly workflow?: readonly WorkflowStep[];
Expand Down
148 changes: 148 additions & 0 deletions packages/cli-engine/src/execution/artwork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { styleText } from "node:util";
import { resolveIsCI } from "../ci";
import {
type HelpArtworkLine,
renderArtworkLine,
revealArtwork,
} from "../help-artwork";
import type { OutputStream, Runtime } from "../runtime";
import type { Invocation } from "./engine";
import { textWidth } from "./palette";

export async function runCommandArtwork(
artwork: readonly HelpArtworkLine[] | undefined,
{ runtime, state, signal, delay }: Invocation,
): Promise<void> {
if (signal.aborted) throw signal.reason;
const out = runtime.stderr;
if (
state.format !== "human" ||
state.logLevel === "error" ||
!runtime.isTty.stderr
)
return;
await writeArtworkFrames({
out,
animate:
state.interactive &&
canAnimateArtwork(runtime, state.argv, state.colorEnabled),
delay,
signal,
render: (progress) => {
const lines: string[] = [];
const rows = addArtwork(
lines,
artwork,
out.columns,
state.colorEnabled,
progress,
);
return { text: rows === 0 ? "" : `${lines.join("\n")}\n`, rows };
},
});
if (signal.aborted) throw signal.reason;
}

export function addArtwork(
lines: string[],
source: readonly HelpArtworkLine[] | undefined,
columns: number | undefined,
colorEnabled: boolean,
progress: number,
): number {
const artwork = revealArtwork(source, progress)?.map((line) =>
colorEnabled
? styleText(
"reset",
styleText("bold", renderArtworkLine(line, true), {
validateStream: false,
}),
{ validateStream: false },
)
: renderArtworkLine(line, false),
);
if (!artwork?.length || columns === undefined || !Number.isFinite(columns)) {
return 0;
}
const start = 2;
const width = Math.max(...artwork.map(textWidth));
const left = columns - width - 2;
if (
artwork.length > lines.length - start ||
lines
.slice(start, start + artwork.length)
.some((line) => textWidth(line) + 4 > left)
) {
if (columns >= width + 4) {
lines.unshift(...artwork.map((row) => ` ${row}`), "");
return artwork.length + 1;
}
return 0;
}
for (const [index, row] of artwork.entries()) {
const line = lines[start + index];
lines[start + index] = `${line}${" ".repeat(left - textWidth(line))}${row}`;
}
return start + artwork.length;
}

export async function writeArtworkFrames({
out,
render,
animate,
delay,
signal,
}: {
out: OutputStream;
render: (progress: number) => { text: string; rows: number };
animate: boolean;
delay: (ms: number, signal: AbortSignal) => Promise<void>;
signal: AbortSignal;
}): Promise<void> {
const columns = out.columns;
const rows = out.rows;
const resized = () => out.columns !== columns || out.rows !== rows;
const final = render(1);
if (!animate || final.rows === 0 || final.rows + 1 >= (out.rows ?? 24)) {
out.write(final.text);
return;
}
const frame = (progress: number): string =>
`${render(progress).text.split("\n").slice(0, final.rows).join("\n")}\n`;
try {
out.write(`\u001b[?25l${frame(0)}`);
for (let step = 1; step <= 30; step++) {
// biome-ignore lint/performance/noAwaitInLoops: Frames must be paced sequentially.
await delay(20, signal);
if (signal.aborted || resized()) break;
out.write(`\u001b[${final.rows}A\r${frame(step / 30)}`);
}
} finally {
try {
out.write(
resized()
? `\r\n${render(1).text}`
: `\u001b[${final.rows}A\r${final.text}`,
);
} finally {
out.write("\u001b[?25h");
}
}
}

export function canAnimateArtwork(
runtime: Runtime,
argv: readonly string[],
color: boolean,
): boolean {
const terminator = argv.indexOf("--");
const flags = terminator === -1 ? argv : argv.slice(0, terminator);
return (
color &&
!resolveIsCI(runtime) &&
runtime.env.TERM !== "dumb" &&
runtime.env.NO_COLOR === undefined &&
runtime.env.PRISMA_REDUCED_MOTION !== "1" &&
!flags.some((flag) => ["--no-interactive", "--quiet", "-q"].includes(flag))
);
}
42 changes: 22 additions & 20 deletions packages/cli-engine/src/execution/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { AnyCommand, WorkflowStep } from "../commands";
import type { CommandContext } from "../context";
import type { ActiveCredential } from "../credential-manager";
import type { EngineEvent, Severity, StreamEvent } from "../events";
import type { HelpArtworkLine } from "../help-artwork";
import type { ManagementApiClient } from "../management-api";
import type { Format, PresentedResult } from "../presentation";
import type { CliStructuredError, Result } from "../protocol";
Expand All @@ -27,6 +28,7 @@ import {
reportCommandStart,
type TelemetryDeclaration,
} from "../telemetry/report";
import { runCommandArtwork } from "./artwork";
import { type CommandCapabilities, makeContext } from "./command-context";
import { buildCommandSnapshot } from "./command-snapshot";
import {
Expand All @@ -42,7 +44,7 @@ import {
bareGroupInvocation,
helpFlagGiven,
preParseColorEnabled,
renderHelp,
runHelp,
} from "./help";
import { checkNeeds, type NeedsOutcome } from "./needs";
import { configFlagGivenNoValue, versionFlagGiven } from "./pre-parse-argv";
Expand Down Expand Up @@ -98,6 +100,8 @@ export interface EngineSpec {
readonly help?: {
/** One line after the binary name: what this CLI is. */
readonly tagline?: string;
readonly artwork?: readonly HelpArtworkLine[];
readonly artworkCommands?: readonly string[];
/** A sentence or two under the command list. */
readonly description?: string;
/** The CLI's common path, rendered as a `Workflow` section. */
Expand Down Expand Up @@ -382,26 +386,21 @@ export class EngineImpl implements Engine {
return 2;
}
if (helpFlagGiven(argv) || bareGroupInvocation(this.tree, argv)) {
unsubscribe();
/** Help prose follows stricli's channel rule: stdout in human
* mode, stderr in json mode so stdout stays a clean frame
* stream. Never fires telemetry, like --version. */
const stream = format === "json" ? runtime.stderr : runtime.stdout;
renderHelp(
this.spec,
this.tree,
argv,
{
try {
await runHelp(
this.spec,
this.tree,
argv,
runtime,
format,
colorEnabled: preParseColorEnabled(
argv,
runtime,
format === "json" ? "stderr" : "stdout",
),
},
stream,
);
return 0;
this.delay,
controller.signal,
);
} finally {
unsubscribe();
}
if (state.deliveredSignal === "SIGTERM") return 143;
return controller.signal.aborted ? 130 : 0;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const stricliProcess = {
/** stricli writes only help text here. In json mode stdout carries
Expand Down Expand Up @@ -568,6 +567,9 @@ export class EngineImpl implements Engine {
): Promise<void> {
const state = invocation.state;
try {
if (this.spec.help?.artworkCommands?.includes(entry.id)) {
await runCommandArtwork(this.spec.help.artwork, invocation);
}
const result = await runHandler();
if (await this.settleAbandonedChild(invocation, false)) {
return;
Expand Down
63 changes: 60 additions & 3 deletions packages/cli-engine/src/execution/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
} from "../args";
import type { AnyCommand, WorkflowStep } from "../commands";
import type { Format } from "../presentation";
import type { Runtime } from "../runtime";
import { addArtwork, canAnimateArtwork, writeArtworkFrames } from "./artwork";
import type { CommandTreeEntry, CommandTreeNode } from "./command-tree";
import type { EngineSpec } from "./engine";
import { renderHelpMarkdown } from "./markdown";
Expand Down Expand Up @@ -176,13 +178,68 @@ export function renderHelp(
argv: readonly string[],
options: { readonly format: Format; readonly colorEnabled: boolean },
out: HelpWriter,
): void {
columns?: number,
progress = 1,
): number {
const card = helpCard(spec, root, argv);
if (options.format === "markdown") {
out.write(renderHelpMarkdown(card));
return;
return 0;
}
out.write(renderHelpTerminal(card, makePaint(options.colorEnabled)));
const lines = renderHelpTerminal(card, makePaint(options.colorEnabled)).split(
"\n",
);
const rows =
options.format === "human" && card.kind === "root" && helpFlagGiven(argv)
? addArtwork(
lines,
spec.help?.artwork,
columns,
options.colorEnabled,
progress,
)
: 0;
out.write(lines.join("\n"));
return rows;
}

/** Animate only the logo prefix; Markdown and JSON help never use artwork. */
export async function runHelp(
spec: EngineSpec,
root: CommandTreeNode,
argv: readonly string[],
runtime: Runtime,
format: Format,
delay: (ms: number, signal: AbortSignal) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
const channel = format === "json" ? "stderr" : "stdout";
const out = runtime[channel];
const showArtwork = format === "human" && runtime.isTty.stdout;
const colorEnabled = preParseColorEnabled(argv, runtime, channel);
await writeArtworkFrames({
out,
animate: showArtwork && canAnimateArtwork(runtime, argv, colorEnabled),
delay,
signal,
render: (progress) => {
let text = "";
const rows = renderHelp(
spec,
root,
argv,
{ format, colorEnabled },
{
write: (value) => {
text = value;
},
},
showArtwork ? out.columns : undefined,
progress,
);
return { text, rows };
},
});
}

export function helpCard(
Expand Down
Loading
Loading