diff --git a/docs/assets/cli-help/horizontal-paint.gif b/docs/assets/cli-help/horizontal-paint.gif new file mode 100644 index 00000000..62786229 Binary files /dev/null and b/docs/assets/cli-help/horizontal-paint.gif differ diff --git a/docs/assets/cli-help/init-paint.gif b/docs/assets/cli-help/init-paint.gif new file mode 100644 index 00000000..b0a43ed5 Binary files /dev/null and b/docs/assets/cli-help/init-paint.gif differ diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index ba11f880..866bf34e 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -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, 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, 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. diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 47537fd1..2e2bd4fa 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.3.0", + "version": "0.3.1", "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..489ffcfa 100644 --- a/packages/cli-engine/src/cli.ts +++ b/packages/cli-engine/src/cli.ts @@ -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"; @@ -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[]; diff --git a/packages/cli-engine/src/execution/artwork.ts b/packages/cli-engine/src/execution/artwork.ts new file mode 100644 index 00000000..6ae4a295 --- /dev/null +++ b/packages/cli-engine/src/execution/artwork.ts @@ -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 { + 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; + signal: AbortSignal; +}): Promise { + 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)) + ); +} diff --git a/packages/cli-engine/src/execution/engine.ts b/packages/cli-engine/src/execution/engine.ts index 5491496f..c72fcbf4 100644 --- a/packages/cli-engine/src/execution/engine.ts +++ b/packages/cli-engine/src/execution/engine.ts @@ -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"; @@ -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 { @@ -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"; @@ -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. */ @@ -382,23 +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 === "human" ? runtime.stdout : runtime.stderr; - renderHelp( - this.spec, - this.tree, - argv, - preParseColorEnabled( + try { + await runHelp( + this.spec, + this.tree, argv, runtime, - format === "human" ? "stdout" : "stderr", - ), - stream, - ); - return 0; + format, + this.delay, + controller.signal, + ); + } finally { + unsubscribe(); + } + if (state.deliveredSignal === "SIGTERM") return 143; + return controller.signal.aborted ? 130 : 0; } const stricliProcess = { /** stricli writes only help text here. In json mode stdout carries @@ -565,6 +567,9 @@ export class EngineImpl implements Engine { ): Promise { 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; diff --git a/packages/cli-engine/src/execution/help.ts b/packages/cli-engine/src/execution/help.ts index b4b45337..947a9eb6 100644 --- a/packages/cli-engine/src/execution/help.ts +++ b/packages/cli-engine/src/execution/help.ts @@ -13,6 +13,8 @@ import { positionalRuntime, } from "../args"; import type { AnyCommand, WorkflowStep } from "../commands"; +import type { Runtime } from "../runtime"; +import { addArtwork, canAnimateArtwork, writeArtworkFrames } from "./artwork"; import type { CommandTreeEntry, CommandTreeNode } from "./command-tree"; import type { EngineSpec } from "./engine"; import { makePaint, type Paint, textWidth } from "./palette"; @@ -130,7 +132,9 @@ export function renderHelp( argv: readonly string[], colorEnabled: boolean, out: HelpWriter, -): void { + columns?: number, + progress = 1, +): number { const paint = makePaint(colorEnabled); const { target, path } = resolveTarget(root, helpPath(argv)); const lines: string[] = []; @@ -139,7 +143,22 @@ export function renderHelp( } else { renderNodeHelp(spec, target.node, path, paint, lines); } + let prefixRows = 0; + if ( + helpFlagGiven(argv) && + helpPath(argv).length === 0 && + target.kind === "node" + ) { + prefixRows = addArtwork( + lines, + spec.help?.artwork, + columns, + colorEnabled, + progress, + ); + } out.write(`${lines.join("\n")}\n`); + return prefixRows; } /** `prisma-cli project → Manage and inspect your Prisma projects` */ @@ -564,3 +583,43 @@ function renderLeafHelp( docsLine(entry.docsBaseUrl, paint, lines); lines.push(""); } + +/** Animate only the visible logo prefix, so long help never needs a full redraw. */ +export async function runHelp( + spec: EngineSpec, + root: CommandTreeNode, + argv: readonly string[], + runtime: Runtime, + format: string, + delay: (ms: number, signal: AbortSignal) => Promise, + signal: AbortSignal, +): Promise { + const channel = format === "human" ? "stdout" : "stderr"; + const out = runtime[channel]; + const columns = + format === "human" && runtime.isTty.stdout ? out.columns : undefined; + const color = preParseColorEnabled(argv, runtime, channel); + await writeArtworkFrames({ + out, + animate: columns !== undefined && canAnimateArtwork(runtime, argv, color), + delay, + signal, + render: (progress) => { + let text = ""; + const rows = renderHelp( + spec, + root, + argv, + color, + { + write: (value) => { + text = value; + }, + }, + columns === undefined ? undefined : out.columns, + progress, + ); + return { text, rows }; + }, + }); +} diff --git a/packages/cli-engine/src/help-artwork.ts b/packages/cli-engine/src/help-artwork.ts new file mode 100644 index 00000000..08f03bfd --- /dev/null +++ b/packages/cli-engine/src/help-artwork.ts @@ -0,0 +1,61 @@ +import { styleText } from "node:util"; + +export type HelpArtworkLine = + | string + | readonly { + readonly text: string; + readonly color?: "cyan" | "redBright" | "yellow"; + }[]; + +export function renderArtworkLine( + line: HelpArtworkLine, + colorEnabled: boolean, +): string { + if (typeof line === "string") return line; + return line + .map(({ text, color }) => + colorEnabled && color !== undefined + ? styleText(color, text, { validateStream: false }) + : text, + ) + .join(""); +} + +/** Paint each color band in first-appearance order, preserving every cell. */ +export function revealArtwork( + lines: readonly HelpArtworkLine[] | undefined, + progress: number, +): readonly HelpArtworkLine[] | undefined { + if (lines === undefined || progress >= 1) return lines; + const totals = new Map(); + for (const line of lines) { + if (typeof line === "string") continue; + for (const { text, color } of line) { + if (color === undefined) continue; + const key = color; + totals.set(key, (totals.get(key) ?? 0) + text.replace(/ /g, "").length); + } + } + const budgets = new Map( + [...totals].map(([key, total], index) => [ + key, + Math.floor( + total * Math.max(0, Math.min(1, progress * totals.size - index)), + ), + ]), + ); + return lines.map((line) => + typeof line === "string" + ? line + : line.map((span) => { + if (span.color === undefined) return span; + const key = span.color; + const text = span.text.replace(/[^ ]/g, (character) => { + const remaining = budgets.get(key) ?? 0; + budgets.set(key, remaining - 1); + return remaining > 0 ? character : " "; + }); + return { ...span, text }; + }), + ); +} diff --git a/packages/cli-engine/src/runtime.ts b/packages/cli-engine/src/runtime.ts index fbc06ac6..016325a2 100644 --- a/packages/cli-engine/src/runtime.ts +++ b/packages/cli-engine/src/runtime.ts @@ -12,6 +12,7 @@ export interface OutputStream { * The engine reads it at render time rather than caching it, so a * terminal resized mid-run is respected by the next thing drawn. */ readonly columns?: number; + readonly rows?: number; } /** @@ -165,11 +166,17 @@ export interface HostProcess { readonly platform: string; readonly arch: string; cwd(): string; - readonly stdout: { write(text: string): unknown; isTTY?: boolean }; + readonly stdout: { + write(text: string): unknown; + isTTY?: boolean; + columns?: number; + rows?: number; + }; readonly stderr: { write(text: string): unknown; isTTY?: boolean; columns?: number; + rows?: number; }; readonly stdin: { isTTY?: boolean; diff --git a/packages/cli-engine/src/testing.ts b/packages/cli-engine/src/testing.ts index fe4dd8e9..befdb14e 100644 --- a/packages/cli-engine/src/testing.ts +++ b/packages/cli-engine/src/testing.ts @@ -4,6 +4,7 @@ import { CONFIG_FILE_NAME } from "./config-loader"; import type { Credential } from "./credential-manager"; import type { EngineEvent, StreamEvent } from "./events"; import { buildEngine } from "./execution/engine"; +import type { HelpArtworkLine } from "./help-artwork"; import { InMemoryCredentialManager, type SessionRecord, @@ -133,7 +134,8 @@ export interface TestCli { readonly isTty?: { stdin?: boolean; stdout?: boolean; stderr?: boolean }; /** Terminal width, as the stream would report it. Absent means * not a terminal, which is what ui.width reads as unbounded. */ - readonly columns?: { stderr?: number }; + readonly columns?: { stdout?: number; stderr?: number }; + readonly rows?: { stdout?: number; stderr?: number }; readonly env?: Readonly>; /** Overrides the CLI-level seed, so one harness can assert both * sides of the CI branch. Absent leaves the engine to detect CI @@ -220,6 +222,8 @@ export function createTestCli(spec: { /** Words for the root help card, exactly as `createCli` takes them. */ readonly help?: { readonly tagline?: string; + readonly artwork?: readonly HelpArtworkLine[]; + readonly artworkCommands?: readonly string[]; readonly description?: string; readonly workflow?: readonly WorkflowStep[]; readonly examples?: readonly string[]; @@ -365,12 +369,15 @@ export function createTestCli(spec: { write: (text) => { stdoutText += text; }, + columns: opts?.columns?.stdout, + rows: opts?.rows?.stdout, }, stderr: { write: (text) => { stderrText += text; }, columns: opts?.columns?.stderr, + rows: opts?.rows?.stderr, }, stdin: inputStreamFromString(opts?.stdin ?? ""), cwd: opts?.cwd ?? "/", diff --git a/packages/cli-engine/tests/help-artwork.test.ts b/packages/cli-engine/tests/help-artwork.test.ts new file mode 100644 index 00000000..fd2b6ef0 --- /dev/null +++ b/packages/cli-engine/tests/help-artwork.test.ts @@ -0,0 +1,207 @@ +import { stripVTControlCharacters } from "node:util"; +import { describe, expect, test, vi } from "vitest"; +import { defineCommand } from "../src/commands"; +import { addArtwork, writeArtworkFrames } from "../src/execution/artwork"; +import { ok } from "../src/protocol"; +import { createTestCli } from "../src/testing"; + +const CURSOR_UP = /\[\d+A/; + +const artwork = [ + [ + { text: "CC", color: "cyan" }, + { text: "RR", color: "redBright" }, + { text: "YY", color: "yellow" }, + { text: " Prisma" }, + ], +] as const; +const initRun = vi.fn(); +const commands = { + init: defineCommand({ + help: { summary: "Initialize" }, + args: { flags: {}, positionals: {} }, + handler: async (_args, ctx) => { + initRun(); + return ok( + ctx.present( + { data: null }, + { + human: () => [ + { kind: "summary", status: "ok", text: "Initialized" }, + ], + stdout: () => [], + json: () => null, + next: () => [], + }, + ), + ); + }, + }), + example: defineCommand({ + help: { summary: "Example" }, + args: { flags: {}, positionals: {} }, + handler: async () => { + throw new Error("Help must not execute commands"); + }, + }), +}; +const terminal = { + isTty: { stdout: true }, + columns: { stdout: 200 }, + rows: { stdout: 30 }, + env: { TERM: "xterm-256color" }, +}; + +describe("help artwork painting", () => { + test("restores the cursor after displaying animated help", async () => { + const cli = createTestCli({ commands, help: { artwork } }); + const result = await cli.run(["--help"], terminal); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("\u001b[?25l"); + expect(result.stdout.endsWith("\u001b[?25h")).toBe(true); + }); + + test.each([ + { env: { PRISMA_REDUCED_MOTION: "1" } }, + { env: { NO_COLOR: "1" } }, + { env: { TERM: "dumb" } }, + { isCI: true }, + { isTty: { stdout: false } }, + { rows: { stdout: 3 } }, + ])("keeps output static with %j", async (options) => { + const delay = vi.fn(async (_ms: number, _signal: AbortSignal) => {}); + const cli = createTestCli({ commands, help: { artwork }, delay }); + const result = await cli.run(["--help"], { ...terminal, ...options }); + expect(delay).not.toHaveBeenCalled(); + expect(result.stdout).not.toContain("\u001b[?25l"); + }); + + test.each([ + ["SIGINT", 130], + ["SIGTERM", 143], + ] as const)("%s finishes the logo and restores the cursor", async (signal, exitCode) => { + const controller = new AbortController(); + const cli = createTestCli({ + commands, + help: { artwork }, + delay: async () => { + controller.abort(signal); + }, + }); + const result = await cli.run(["--help"], { + ...terminal, + abort: controller.signal, + }); + expect(result.exitCode).toBe(exitCode); + expect(result.stdout.endsWith("\u001b[?25h")).toBe(true); + }); +}); + +const initTerminal = { + ...terminal, + isTty: { stdin: true, stdout: true, stderr: true }, + columns: { stdout: 80, stderr: 80 }, + rows: { stdout: 24, stderr: 24 }, +}; +const initHelp = { artwork, artworkCommands: ["init"] }; + +test("init paints on stderr before the command result", async () => { + const cli = createTestCli({ commands, help: initHelp }); + const result = await cli.run(["init"], initTerminal); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("\u001b[?25l"); + const plain = stripVTControlCharacters(result.stderr); + expect(plain).toContain("CCRRYY Prisma"); + expect(plain.indexOf("CCRRYY Prisma")).toBeLessThan( + plain.indexOf("Initialized"), + ); +}); + +test.each([ + [], + ["example", "--help"], + ["init", "--help"], + ["init", "--json"], + ["init", "--quiet"], + ["init", "--unknown"], +])("omits artwork for %j", async (...argv) => { + const cli = createTestCli({ commands, help: initHelp }); + const result = await cli.run(argv, initTerminal); + expect(stripVTControlCharacters(result.stdout + result.stderr)).not.toContain( + "CCRRYY Prisma", + ); + expect(result.stdout + result.stderr).not.toContain("\u001b[?25l"); +}); + +test("interrupting the init intro prevents the handler from running", async () => { + initRun.mockClear(); + const controller = new AbortController(); + const cli = createTestCli({ + commands, + help: initHelp, + delay: async () => controller.abort("SIGTERM"), + }); + const result = await cli.run(["init"], { + ...initTerminal, + abort: controller.signal, + }); + expect(result.exitCode).toBe(143); + expect(initRun).not.toHaveBeenCalled(); + expect(result.stderr).toContain("\u001b[?25h"); +}); + +test("help and init isolate the same wordmark styling from surrounding output", async () => { + const cli = createTestCli({ commands, help: initHelp }); + const options = { ...initTerminal, env: { PRISMA_REDUCED_MOTION: "1" } }; + const help = await cli.run(["--help"], options); + const init = await cli.run(["init"], options); + const helpRow = help.stdout + .split("\n") + .find((line) => line.includes(" Prisma")); + const helpLogo = helpRow?.slice(helpRow.indexOf("\u001b[0m")); + expect(helpLogo).toBeDefined(); + expect(helpLogo?.startsWith("\u001b[0m\u001b[1m")).toBe(true); + expect(helpLogo?.endsWith(" Prisma\u001b[22m\u001b[0m")).toBe(true); + expect(init.stderr).toContain(helpLogo); +}); + +test.each([ + "columns", + "rows", +] as const)("stops cursor rewrites when terminal %s change", async (dimension) => { + const size = { columns: 80, rows: 24 }; + const writes: string[] = []; + const out = { + get columns() { + return size.columns; + }, + get rows() { + return size.rows; + }, + write: (text: string) => { + writes.push(text); + }, + }; + const delay = vi.fn(async () => { + size[dimension] = 5; + }); + await writeArtworkFrames({ + out, + animate: true, + delay, + signal: new AbortController().signal, + render: (progress) => { + const lines = ["Help", "", "Commands"]; + const rows = addArtwork(lines, artwork, out.columns, true, progress); + return { text: `${lines.join("\n")}\n`, rows }; + }, + }); + expect(delay).toHaveBeenCalledTimes(1); + expect(writes.slice(1).join("")).not.toMatch(CURSOR_UP); + expect(writes.slice(1).join("")).toContain("Help"); + expect(writes.at(-1)).toBe("\u001b[?25h"); + expect(writes.slice(1).join("").includes("Prisma")).toBe( + dimension === "rows", + ); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ef50342..7d9d7337 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.3.0", + "@prisma/cli-engine": "workspace:0.3.1", "@prisma/composer-cli": "0.17.0", "@prisma/compute-sdk": "0.42.0", "@prisma/management-api-sdk": "1.69.0", diff --git a/packages/cli/src/cli-artwork.ts b/packages/cli/src/cli-artwork.ts new file mode 100644 index 00000000..0ea8ed73 --- /dev/null +++ b/packages/cli/src/cli-artwork.ts @@ -0,0 +1,62 @@ +// ASCII adaptation of https://www.prisma.io/brand-kit. +const symbol = [ + [ + { text: " ////////", color: "cyan" }, + { text: " //////", color: "redBright" }, + ], + [ + { text: " ////////", color: "cyan" }, + { text: " ////////", color: "redBright" }, + ], + [ + { text: "////////", color: "cyan" }, + { text: " //////////", color: "redBright" }, + ], + [ + { text: "//////", color: "cyan" }, + { text: " ////////////", color: "redBright" }, + ], + [ + { text: "////", color: "cyan" }, + { text: " ////////////", color: "redBright" }, + { text: " ", color: "yellow" }, + ], + [ + { text: "//", color: "cyan" }, + { text: " ////////////", color: "redBright" }, + { text: " //", color: "yellow" }, + ], + [ + { text: " ////////////", color: "redBright" }, + { text: " ////", color: "yellow" }, + ], + [ + { text: "////////////", color: "redBright" }, + { text: " //////", color: "yellow" }, + ], + [ + { text: "//////////", color: "redBright" }, + { text: " ////////", color: "yellow" }, + ], + [ + { text: "////////", color: "redBright" }, + { text: " //////// ", color: "yellow" }, + ], + [ + { text: "//////", color: "redBright" }, + { text: " //////// ", color: "yellow" }, + ], +] as const; + +const wordmark = [ + " ____ _", + "| _ \\ _ __(_)___ _ __ ___ __ _", + "| |_) | '__| / __| '_ ` _ \\ / _` |", + "| __/| | | \\__ \\ | | | | | (_| |", + "|_| |_| |_|___/_| |_| |_|\\__,_|", +] as const; + +export const horizontalArtwork = symbol.map((row, index) => { + const word = wordmark[index - 3]; + return word === undefined ? row : [...row, { text: ` ${word}` }]; +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e48d9eee..d370078f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,6 +9,7 @@ import { } from "@prisma/cli-engine"; import { createComposerFamily } from "@prisma/composer-cli/family"; import { ormCommandFamily as ormToolchainFamily } from "@prisma/orm-toolchain/cli"; +import { horizontalArtwork } from "./cli-artwork"; import { CLI_DOCS_URL, CLI_NAME, DOCS_ERRORS_BASE_URL } from "./cli-name"; import { authLoginCommand } from "./commands/auth/login"; import { authLogoutCommand } from "./commands/auth/logout"; @@ -460,6 +461,8 @@ export function buildCli(): Cli { commands: mountedCommands, help: { tagline: "The Prisma Developer Platform, from your terminal", + artwork: horizontalArtwork, + artworkCommands: ["init"], description: "Deploy your app with isolated infrastructure for every branch: a Project groups one product, and each of its Branches maps to a Git branch with its own services, databases, and buckets. The production branch serves live traffic; every other branch is a preview.", workflow: [ diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 1c0b6b42..96f2ed86 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -114,6 +114,12 @@ export async function assembleRuntime(proc: HostProcess): Promise { write: (text) => { proc.stdout.write(text); }, + get columns() { + return proc.stdout.columns; + }, + get rows() { + return proc.stdout.rows; + }, }, stderr: { write: (text) => { @@ -122,6 +128,9 @@ export async function assembleRuntime(proc: HostProcess): Promise { get columns() { return proc.stderr.columns; }, + get rows() { + return proc.stderr.rows; + }, }, stdin, cwd: proc.cwd(), diff --git a/packages/prisma/package.json b/packages/prisma/package.json index eeebbc73..4ad630cd 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.3.0", + "@prisma/cli-engine": "workspace:0.3.1", "@prisma/composer-cli": "0.17.0", "@prisma/compute-sdk": "0.42.0", "@prisma/management-api-sdk": "1.69.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d620e90..b8d856af 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.3.0 + specifier: workspace:0.3.1 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.17.0 @@ -207,7 +207,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.3.0 + specifier: workspace:0.3.1 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.17.0 diff --git a/skills/prisma-platform-core-concepts/SKILL.md b/skills/prisma-platform-core-concepts/SKILL.md index 0adcffd1..341f4ae8 100644 --- a/skills/prisma-platform-core-concepts/SKILL.md +++ b/skills/prisma-platform-core-concepts/SKILL.md @@ -2,7 +2,7 @@ name: prisma-platform-core-concepts metadata: library: "prisma" - library_version: "8.0.0-rc.12" + library_version: "8.0.0-rc.13" version: 2026.9.1 description: >- Use when hosting, deploying, or operating an app on the Prisma Platform: