@rigkit/cli 0.0.0-canary-20260518T014918-c5bc0c2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { appendFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createRunLogger } from "./run-logger.ts";
6
+
7
+ describe("createRunLogger", () => {
8
+ test("writes a run.start envelope, every appended event, and a run.end envelope", () => {
9
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-run-logger-"));
10
+ try {
11
+ const logger = createRunLogger({ projectDir, operation: "plan" });
12
+ expect(logger).toBeDefined();
13
+ logger!.append({ type: "node.started", nodePath: "build" });
14
+ logger!.append({ type: "node.completed", nodePath: "build" });
15
+ logger!.finish({ status: "completed", result: { ok: true } });
16
+ logger!.close();
17
+
18
+ const lines = readFileSync(logger!.path, "utf8").trim().split("\n");
19
+ expect(lines.length).toBe(4);
20
+ const entries = lines.map((line) => JSON.parse(line));
21
+ expect(entries[0]).toMatchObject({ type: "run.start", operation: "plan" });
22
+ expect(entries[1]).toMatchObject({ type: "node.started", nodePath: "build" });
23
+ expect(entries[2]).toMatchObject({ type: "node.completed", nodePath: "build" });
24
+ expect(entries[3]).toMatchObject({ type: "run.end", status: "completed", result: { ok: true } });
25
+
26
+ const logFiles = readdirSync(join(projectDir, ".rigkit", "logs"));
27
+ expect(logFiles.length).toBe(1);
28
+ } finally {
29
+ rmSync(projectDir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test("captures failure details on the run.end envelope", () => {
34
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-run-logger-"));
35
+ try {
36
+ const logger = createRunLogger({ projectDir, operation: "apply" });
37
+ logger!.append({ type: "run.failed", error: { message: "boom" } });
38
+ logger!.finish({ status: "failed", error: new Error("boom") });
39
+ logger!.close();
40
+
41
+ const entries = readFileSync(logger!.path, "utf8").trim().split("\n").map((line) => JSON.parse(line));
42
+ expect(entries.at(-1)).toMatchObject({
43
+ type: "run.end",
44
+ status: "failed",
45
+ error: { message: "boom" },
46
+ });
47
+ } finally {
48
+ rmSync(projectDir, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ test("splices daemon stderr written during the run into the failure log", () => {
53
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-run-logger-"));
54
+ const daemonStderrPath = join(projectDir, "daemon.log");
55
+ // Pre-existing daemon output that predates the run — we must NOT splice it.
56
+ writeFileSync(daemonStderrPath, "earlier daemon noise we should ignore\n");
57
+
58
+ try {
59
+ const logger = createRunLogger({ projectDir, operation: "apply", daemonStderrPath });
60
+ // Daemon writes a real stack trace mid-run.
61
+ appendFileSync(daemonStderrPath, "Error: connect ECONNREFUSED 127.0.0.1:443\n at fetch (engine.ts:42)\n");
62
+ logger!.finish({ status: "failed", error: new Error("INTERNAL_ERROR: Internal server error") });
63
+ logger!.close();
64
+
65
+ const entries = readFileSync(logger!.path, "utf8").trim().split("\n").map((line) => JSON.parse(line));
66
+ const stderr = entries.filter((entry) => entry.type === "daemon.stderr").map((entry) => entry.data);
67
+ expect(stderr).toEqual([
68
+ "Error: connect ECONNREFUSED 127.0.0.1:443",
69
+ " at fetch (engine.ts:42)",
70
+ ]);
71
+ expect(stderr.some((line) => line.includes("earlier daemon noise"))).toBe(false);
72
+ } finally {
73
+ rmSync(projectDir, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ test("does not splice daemon stderr on success", () => {
78
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-run-logger-"));
79
+ const daemonStderrPath = join(projectDir, "daemon.log");
80
+ try {
81
+ const logger = createRunLogger({ projectDir, operation: "apply", daemonStderrPath });
82
+ appendFileSync(daemonStderrPath, "noise during a successful run\n");
83
+ logger!.finish({ status: "completed" });
84
+ logger!.close();
85
+
86
+ const entries = readFileSync(logger!.path, "utf8").trim().split("\n").map((line) => JSON.parse(line));
87
+ expect(entries.find((entry) => entry.type === "daemon.stderr")).toBeUndefined();
88
+ } finally {
89
+ rmSync(projectDir, { recursive: true, force: true });
90
+ }
91
+ });
92
+ });
@@ -0,0 +1,203 @@
1
+ // Persists every runtime event for a single run to `.rigkit/logs/`. The
2
+ // presenter on stderr stays terse; this file is the unfiltered transcript you
3
+ // grep when something goes wrong. NDJSON, one event per line, plus a couple of
4
+ // envelope records (run.start / run.end) so logs stand on their own.
5
+ //
6
+ // On failure we also splice in the daemon's stderr emitted during this run's
7
+ // time window. The daemon's "INTERNAL_ERROR: Internal server error" event
8
+ // carries no stack — the real trace is in its stderr. We capture that to
9
+ // `runtimeLogPath` (in runtime-client) and tail it here so the run log is
10
+ // self-contained.
11
+
12
+ import {
13
+ appendFileSync,
14
+ closeSync,
15
+ mkdirSync,
16
+ openSync,
17
+ readSync,
18
+ readdirSync,
19
+ statSync,
20
+ unlinkSync,
21
+ } from "node:fs";
22
+ import { join } from "node:path";
23
+
24
+ const LOG_DIR_NAME = "logs";
25
+ const MAX_LOG_FILES = 50;
26
+
27
+ export type RunLogger = {
28
+ append(event: unknown): void;
29
+ finish(outcome: { status: "completed" | "failed"; error?: unknown; result?: unknown }): void;
30
+ close(): void;
31
+ path: string;
32
+ };
33
+
34
+ export function createRunLogger(input: {
35
+ projectDir: string;
36
+ operation: string;
37
+ runtimeStateDir?: string;
38
+ // Daemon stderr file. When the run fails we splice everything written here
39
+ // during this run's time window into the run log.
40
+ daemonStderrPath?: string;
41
+ }): RunLogger | undefined {
42
+ const logDir = resolveLogDir(input);
43
+ if (!logDir) return undefined;
44
+
45
+ try {
46
+ mkdirSync(logDir, { recursive: true });
47
+ } catch {
48
+ return undefined;
49
+ }
50
+
51
+ const path = join(logDir, `${fileTimestamp()}-${slugify(input.operation)}.log`);
52
+
53
+ let fd: number;
54
+ try {
55
+ fd = openSync(path, "a");
56
+ } catch {
57
+ return undefined;
58
+ }
59
+
60
+ rotate(logDir);
61
+
62
+ const startedAt = new Date().toISOString();
63
+ // Snapshot the daemon log's current size so we can read only what this run
64
+ // appended later. We don't want to dump the daemon's entire history.
65
+ const daemonOffset = input.daemonStderrPath ? safeFileSize(input.daemonStderrPath) : 0;
66
+ let closed = false;
67
+
68
+ const writeLine = (value: unknown): void => {
69
+ if (closed) return;
70
+ try {
71
+ appendFileSync(fd, `${JSON.stringify(value)}\n`);
72
+ } catch {
73
+ // best-effort: a logging failure should never break the run
74
+ }
75
+ };
76
+
77
+ writeLine({
78
+ ts: startedAt,
79
+ type: "run.start",
80
+ operation: input.operation,
81
+ cwd: process.cwd(),
82
+ });
83
+
84
+ return {
85
+ path,
86
+ append(event) {
87
+ writeLine({ ts: new Date().toISOString(), ...(toRecord(event) ?? { value: event }) });
88
+ },
89
+ finish(outcome) {
90
+ if (outcome.status === "failed" && input.daemonStderrPath) {
91
+ spliceDaemonStderr(input.daemonStderrPath, daemonOffset, writeLine);
92
+ }
93
+ writeLine({
94
+ ts: new Date().toISOString(),
95
+ type: "run.end",
96
+ status: outcome.status,
97
+ durationMs: Date.now() - Date.parse(startedAt),
98
+ ...(outcome.error !== undefined ? { error: toErrorRecord(outcome.error) } : {}),
99
+ ...(outcome.result !== undefined ? { result: outcome.result } : {}),
100
+ });
101
+ },
102
+ close() {
103
+ if (closed) return;
104
+ closed = true;
105
+ try {
106
+ closeSync(fd);
107
+ } catch {
108
+ // best-effort
109
+ }
110
+ },
111
+ };
112
+ }
113
+
114
+ function spliceDaemonStderr(
115
+ path: string,
116
+ offset: number,
117
+ write: (value: unknown) => void,
118
+ ): void {
119
+ let fd: number | undefined;
120
+ try {
121
+ const size = safeFileSize(path);
122
+ if (size <= offset) return;
123
+ fd = openSync(path, "r");
124
+ const length = size - offset;
125
+ const buffer = Buffer.alloc(length);
126
+ readSync(fd, buffer, 0, length, offset);
127
+ const text = buffer.toString("utf8");
128
+ for (const line of text.split("\n")) {
129
+ if (!line) continue;
130
+ write({ ts: new Date().toISOString(), type: "daemon.stderr", data: line });
131
+ }
132
+ } catch {
133
+ // best-effort: failure to splice should never break the run log
134
+ } finally {
135
+ if (fd !== undefined) {
136
+ try {
137
+ closeSync(fd);
138
+ } catch {
139
+ // best-effort
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ function safeFileSize(path: string): number {
146
+ try {
147
+ return statSync(path).size;
148
+ } catch {
149
+ return 0;
150
+ }
151
+ }
152
+
153
+ function resolveLogDir(input: { projectDir: string; runtimeStateDir?: string }): string | undefined {
154
+ if (input.runtimeStateDir) return join(input.runtimeStateDir, LOG_DIR_NAME);
155
+ if (input.projectDir) return join(input.projectDir, ".rigkit", LOG_DIR_NAME);
156
+ return undefined;
157
+ }
158
+
159
+ function rotate(logDir: string): void {
160
+ try {
161
+ const entries = readdirSync(logDir)
162
+ .filter((name) => name.endsWith(".log"))
163
+ .map((name) => {
164
+ const filePath = join(logDir, name);
165
+ return { path: filePath, mtime: statSync(filePath).mtimeMs };
166
+ })
167
+ .sort((a, b) => b.mtime - a.mtime);
168
+
169
+ for (const stale of entries.slice(MAX_LOG_FILES)) {
170
+ try {
171
+ unlinkSync(stale.path);
172
+ } catch {
173
+ // best-effort
174
+ }
175
+ }
176
+ } catch {
177
+ // best-effort
178
+ }
179
+ }
180
+
181
+ function fileTimestamp(): string {
182
+ return new Date().toISOString().replace(/[:.]/g, "-");
183
+ }
184
+
185
+ function slugify(value: string): string {
186
+ const cleaned = value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
187
+ const slug = cleaned.slice(0, 40);
188
+ return slug || "run";
189
+ }
190
+
191
+ function toRecord(value: unknown): Record<string, unknown> | undefined {
192
+ return value && typeof value === "object" && !Array.isArray(value)
193
+ ? (value as Record<string, unknown>)
194
+ : undefined;
195
+ }
196
+
197
+ function toErrorRecord(value: unknown): Record<string, unknown> {
198
+ if (value instanceof Error) {
199
+ return { name: value.name, message: value.message, stack: value.stack };
200
+ }
201
+ const record = toRecord(value);
202
+ return record ?? { message: String(value) };
203
+ }
@@ -0,0 +1,250 @@
1
+ // Append-only run presenter. Each runtime event prints zero or one line to
2
+ // stderr. No spinners, no re-renders. Looks the same in a TTY, a CI log, and an
3
+ // LLM transcript — and it pipes cleanly.
4
+
5
+ import { accent, bold, clip, dim, err, ok, sym, termWidth, warn } from "./ui.ts";
6
+
7
+ export type RunPresenter = {
8
+ render(event: { type: string; [key: string]: unknown }): void;
9
+ pause(): void;
10
+ resume(): void;
11
+ close(): void;
12
+ };
13
+
14
+ export function createRunPresenter(operation: string): RunPresenter | undefined {
15
+ if (process.env.RIGKIT_RENDER === "0") return undefined;
16
+
17
+ let workflow = "";
18
+ let totalNodes = 0;
19
+ let cachedNodes = 0;
20
+ const seenNodes = new Set<string>();
21
+ const completedNodes = new Set<string>();
22
+ let lastNodePath: string | undefined;
23
+ let closed = false;
24
+
25
+ const write = (line: string): void => {
26
+ process.stderr.write(`${line}\n`);
27
+ };
28
+
29
+ const indent = (line: string): string => ` ${line}`;
30
+
31
+ const trim = (value: string): string => clip(value, Math.max(40, termWidth() - 4));
32
+
33
+ const headerLine = (): string => {
34
+ const left = workflow ? `${bold(operation)} ${dim(workflow)}` : bold(operation);
35
+ return `${accent(sym.active)} ${left}`;
36
+ };
37
+
38
+ return {
39
+ render(event) {
40
+ if (closed) return;
41
+ switch (event.type) {
42
+ case "definition.loaded": {
43
+ workflow = stringField(event.workflow) ?? workflow;
44
+ break;
45
+ }
46
+ case "plan.created": {
47
+ workflow = stringField(event.workflow) ?? workflow;
48
+ totalNodes = numberField(event.nodeCount) ?? totalNodes;
49
+ cachedNodes = numberField(event.cachedNodeCount) ?? cachedNodes;
50
+ write(headerLine());
51
+ write(indent(dim(planSummary(totalNodes, cachedNodes))));
52
+ break;
53
+ }
54
+ case "workflow.apply.started": {
55
+ workflow = stringField(event.workflow) ?? workflow;
56
+ write(`${accent(sym.active)} workflow ${bold(workflow || operation)}`);
57
+ break;
58
+ }
59
+ case "workflow.apply.completed": {
60
+ const name = stringField(event.workflow) ?? workflow;
61
+ totalNodes = numberField(event.nodeCount) ?? totalNodes;
62
+ cachedNodes = numberField(event.cachedNodeCount) ?? cachedNodes;
63
+ const summary = totalNodes > 0 ? ` ${dim(planSummary(totalNodes, cachedNodes))}` : "";
64
+ write(`${ok(sym.ok)} ${bold(name || "workflow")} ${dim("prepared")}${summary}`);
65
+ break;
66
+ }
67
+ case "node.cached": {
68
+ const path = stringField(event.nodePath);
69
+ if (!path || completedNodes.has(path)) break;
70
+ completedNodes.add(path);
71
+ seenNodes.add(path);
72
+ write(indent(`${dim(sym.ok)} ${dim(path)} ${dim("cached")}`));
73
+ break;
74
+ }
75
+ case "node.started": {
76
+ const path = stringField(event.nodePath);
77
+ if (!path || seenNodes.has(path)) break;
78
+ seenNodes.add(path);
79
+ lastNodePath = path;
80
+ write(indent(`${accent(sym.active)} ${bold(path)}`));
81
+ break;
82
+ }
83
+ case "node.completed": {
84
+ const path = stringField(event.nodePath);
85
+ if (!path || completedNodes.has(path)) break;
86
+ completedNodes.add(path);
87
+ if (lastNodePath === path) lastNodePath = undefined;
88
+ const detail = compactId(event.runId);
89
+ const tail = detail ? ` ${dim(detail)}` : "";
90
+ write(indent(`${ok(sym.ok)} ${path}${tail}`));
91
+ break;
92
+ }
93
+ case "vm.created": {
94
+ const vmId = stringField(event.vmId);
95
+ const from = stringField(event.fromSnapshotId);
96
+ const label = from ? `vm ${vmId ?? ""} from ${from}` : `vm ${vmId ?? "ready"}`;
97
+ write(indent(` ${dim(label)}`));
98
+ break;
99
+ }
100
+ case "command.started": {
101
+ const command = stringField(event.command) ?? stringField(event.commandName);
102
+ if (command) write(indent(` ${dim(`$ ${trim(command)}`)}`));
103
+ break;
104
+ }
105
+ case "command.output": {
106
+ const data = stringField(event.data);
107
+ if (!data) break;
108
+ for (const line of data.replace(/\r/g, "").split("\n")) {
109
+ if (!line) continue;
110
+ write(indent(` ${dim(trim(line))}`));
111
+ }
112
+ break;
113
+ }
114
+ case "command.completed": {
115
+ const exitCode = numberField(event.exitCode) ?? 0;
116
+ if (exitCode !== 0) {
117
+ const name = stringField(event.commandName) ?? "command";
118
+ write(indent(` ${err(`${name} exited ${exitCode}`)}`));
119
+ }
120
+ break;
121
+ }
122
+ case "log.output": {
123
+ const data = stringField(event.data);
124
+ if (!data) break;
125
+ const stream = stringField(event.stream);
126
+ // console.debug is intentionally silent at the terminal — it lives
127
+ // in the run log file only. Surface it live with RIGKIT_DEBUG=1.
128
+ if (stream === "debug" && process.env.RIGKIT_DEBUG !== "1") break;
129
+ const label = stringField(event.label);
130
+ const { icon, style } = logStreamPresentation(stream);
131
+ for (const line of data.replace(/\r/g, "").split("\n")) {
132
+ if (!line) continue;
133
+ const prefix = label ? `${dim(`[${label}]`)} ` : "";
134
+ write(indent(` ${icon} ${prefix}${style(trim(line))}`));
135
+ }
136
+ break;
137
+ }
138
+ case "interaction.awaiting_user": {
139
+ const label = stringField(event.title) ?? stringField(event.label) ?? "user interaction";
140
+ const url = stringField(event.url);
141
+ write(indent(`${accent(sym.arrow)} waiting on ${bold(label)}`));
142
+ if (url) write(indent(` ${dim(url)}`));
143
+ break;
144
+ }
145
+ case "interaction.completed": {
146
+ const label = stringField(event.title) ?? stringField(event.label) ?? "interaction";
147
+ write(indent(`${ok(sym.ok)} ${dim(`${label} completed`)}`));
148
+ break;
149
+ }
150
+ case "artifact.created": {
151
+ const kind = stringField(event.kind) ?? "artifact";
152
+ const provider = stringField(event.providerId);
153
+ const label = provider ? `${provider}:${kind}` : kind;
154
+ write(indent(` ${dim(`+ ${label}`)}`));
155
+ break;
156
+ }
157
+ case "workspace.create.started": {
158
+ const name = stringField(event.workspaceName) ?? "workspace";
159
+ write(`${accent(sym.active)} creating workspace ${bold(name)}`);
160
+ break;
161
+ }
162
+ case "workspace.ready": {
163
+ const id = stringField(event.workspaceId) ?? "workspace";
164
+ write(`${ok(sym.ok)} ${bold(id)} ${dim("ready")}`);
165
+ break;
166
+ }
167
+ case "workspace.remove.started": {
168
+ const name = stringField(event.workspaceName) ?? "workspace";
169
+ write(`${accent(sym.active)} removing workspace ${bold(name)}`);
170
+ break;
171
+ }
172
+ case "workspace.remove.completed": {
173
+ const name = stringField(event.workspaceName) ?? "workspace";
174
+ write(`${ok(sym.ok)} removed ${bold(name)}`);
175
+ break;
176
+ }
177
+ case "workspace.operation.started": {
178
+ const name = stringField(event.workspaceName) ?? "workspace";
179
+ const op = stringField(event.operationId) ?? "operation";
180
+ write(`${accent(sym.active)} running ${bold(op)} on ${bold(name)}`);
181
+ break;
182
+ }
183
+ case "workspace.operation.completed": {
184
+ const name = stringField(event.workspaceName) ?? "workspace";
185
+ const op = stringField(event.operationId) ?? "operation";
186
+ write(`${ok(sym.ok)} ran ${bold(op)} on ${bold(name)}`);
187
+ break;
188
+ }
189
+ case "run.completed":
190
+ case "run.failed": {
191
+ // Run terminal events are owned by the CLI's failure renderer so the
192
+ // structured failure block stays in one place.
193
+ break;
194
+ }
195
+ }
196
+ },
197
+ pause() {
198
+ // No-op — output is already append-only.
199
+ },
200
+ resume() {
201
+ // No-op — output is already append-only.
202
+ },
203
+ close() {
204
+ closed = true;
205
+ },
206
+ };
207
+ }
208
+
209
+ function planSummary(total: number, cached: number): string {
210
+ if (total === 0) return "no nodes";
211
+ if (cached === 0) return `${total} ${noun("node", total)}`;
212
+ return `${cached}/${total} cached`;
213
+ }
214
+
215
+ function noun(singular: string, count: number): string {
216
+ return count === 1 ? singular : `${singular}s`;
217
+ }
218
+
219
+ function compactId(value: unknown): string {
220
+ return typeof value === "string" && value.length > 0 ? value.slice(0, 8) : "";
221
+ }
222
+
223
+ function stringField(value: unknown): string | undefined {
224
+ return typeof value === "string" && value.length > 0 ? value : undefined;
225
+ }
226
+
227
+ function numberField(value: unknown): number | undefined {
228
+ return typeof value === "number" ? value : undefined;
229
+ }
230
+
231
+ function logStreamPresentation(stream: string | undefined): {
232
+ icon: string;
233
+ style: (text: string) => string;
234
+ } {
235
+ switch (stream) {
236
+ case "warn":
237
+ return { icon: warn("!"), style: warn };
238
+ case "stderr":
239
+ case "error":
240
+ return { icon: err(sym.err), style: err };
241
+ case "debug":
242
+ return { icon: dim("·"), style: (text: string) => dim(`${dim("debug")} ${text}`) };
243
+ case "stdout":
244
+ case "info":
245
+ case "log":
246
+ default:
247
+ return { icon: dim(sym.dot), style: dim };
248
+ }
249
+ }
250
+
package/src/ui.ts ADDED
@@ -0,0 +1,159 @@
1
+ // Minimal design system for the rig CLI.
2
+ // Mirrors apps/website/src/components/CliShowcase.tsx: bold black for the thing
3
+ // that matters, dim for muted, blue for prompts and active items, green for ok,
4
+ // red for fail. No spinners, no log-update, no boxes. Append-only output that
5
+ // looks the same in a TTY, a CI log, or an LLM transcript.
6
+
7
+ import chalk from "chalk";
8
+
9
+ const HEX_ACCENT = "#2d4df5";
10
+ const HEX_OK = "#1f8b4c";
11
+ const HEX_WARN = "#a36b00";
12
+ const HEX_ERR = "#b91c1c";
13
+
14
+ export const accent = (text: string) => chalk.hex(HEX_ACCENT)(text);
15
+ export const ok = (text: string) => chalk.hex(HEX_OK)(text);
16
+ export const warn = (text: string) => chalk.hex(HEX_WARN)(text);
17
+ export const err = (text: string) => chalk.hex(HEX_ERR)(text);
18
+ export const dim = (text: string) => chalk.dim(text);
19
+ export const bold = (text: string) => chalk.bold(text);
20
+
21
+ export const sym = {
22
+ prompt: "$",
23
+ arrow: "›",
24
+ active: "▸",
25
+ ok: "✓",
26
+ err: "✗",
27
+ dot: "·",
28
+ ellipsis: "…",
29
+ } as const;
30
+
31
+ export function title(text: string): string {
32
+ return bold(text);
33
+ }
34
+
35
+ export function heading(text: string): string {
36
+ return bold(text);
37
+ }
38
+
39
+ export function muted(text: string): string {
40
+ return dim(text);
41
+ }
42
+
43
+ export function prompt(command: string): string {
44
+ return `${accent(sym.prompt)} ${command}`;
45
+ }
46
+
47
+ export function hint(text: string): string {
48
+ return `${dim(sym.arrow)} ${text}`;
49
+ }
50
+
51
+ export type FileStatus = "created" | "updated" | "kept" | "failed" | "pinned";
52
+
53
+ const STATUS_WORD_WIDTH = "created".length;
54
+
55
+ export function fileStatus(kind: FileStatus, label: string): string {
56
+ switch (kind) {
57
+ case "created":
58
+ return `${ok("+")} ${bold(pad("created", STATUS_WORD_WIDTH))} ${label}`;
59
+ case "updated":
60
+ return `${warn("~")} ${bold(pad("updated", STATUS_WORD_WIDTH))} ${label}`;
61
+ case "kept":
62
+ return `${dim(sym.dot)} ${dim(pad("kept", STATUS_WORD_WIDTH))} ${dim(label)}`;
63
+ case "pinned":
64
+ return `${accent("·")} ${bold(pad("pinned", STATUS_WORD_WIDTH))} ${label}`;
65
+ case "failed":
66
+ return `${err(sym.err)} ${err(pad("failed", STATUS_WORD_WIDTH))} ${label}`;
67
+ }
68
+ }
69
+
70
+ export type StepKind = "active" | "ok" | "cached" | "err";
71
+
72
+ export function stepLine(kind: StepKind, label: string, detail?: string): string {
73
+ const tail = detail ? ` ${dim(detail)}` : "";
74
+ switch (kind) {
75
+ case "active":
76
+ return `${accent(sym.active)} ${bold(label)}${tail}`;
77
+ case "ok":
78
+ return `${ok(sym.ok)} ${label}${tail}`;
79
+ case "cached":
80
+ return `${dim(sym.ok)} ${dim(label)}${tail ? ` ${dim("cached")}` : ` ${dim("cached")}`}`;
81
+ case "err":
82
+ return `${err(sym.err)} ${bold(label)}${tail}`;
83
+ }
84
+ }
85
+
86
+ type Cell = { text: string; style?: (text: string) => string };
87
+ type Row = Array<string | Cell>;
88
+
89
+ function toCell(value: string | Cell): Cell {
90
+ return typeof value === "string" ? { text: value } : value;
91
+ }
92
+
93
+ export type ColumnsOptions = {
94
+ // Bold + underline the header row. Default true.
95
+ emphasizeHeader?: boolean;
96
+ // Two-space indent in front of each row. Default true.
97
+ indent?: boolean;
98
+ };
99
+
100
+ // Replacement for the old ASCII table. Aligned columns, bold-underlined header,
101
+ // no dashed separator. Cells may carry their own style.
102
+ export function columns(headers: string[], rows: Row[], options: ColumnsOptions = {}): string {
103
+ const indent = options.indent === false ? "" : " ";
104
+ const emphasizeHeader = options.emphasizeHeader !== false;
105
+ const widths = headers.map((header, index) =>
106
+ Math.max(
107
+ header.length,
108
+ ...rows.map((row) => toCell(row[index] ?? "").text.length),
109
+ ),
110
+ );
111
+
112
+ const renderRow = (cells: Cell[], styleFallback?: (text: string) => string): string => {
113
+ const last = cells.length - 1;
114
+ return indent + cells
115
+ .map((cell, index) => {
116
+ const width = widths[index] ?? cell.text.length;
117
+ const style = cell.style ?? styleFallback;
118
+ const styled = style ? style(cell.text) : cell.text;
119
+ if (index === last) return styled;
120
+ const padding = " ".repeat(Math.max(0, width - cell.text.length));
121
+ return `${styled}${padding} `;
122
+ })
123
+ .join("")
124
+ .replace(/\s+$/u, "");
125
+ };
126
+
127
+ const headerCells = headers.map((text) => ({ text }));
128
+ const lines: string[] = [];
129
+ lines.push(renderRow(headerCells, emphasizeHeader ? (s) => chalk.bold.underline(s) : undefined));
130
+ for (const row of rows) {
131
+ lines.push(renderRow(row.map(toCell)));
132
+ }
133
+ return lines.join("\n");
134
+ }
135
+
136
+ // "key value" pairs aligned by the longest key. Keys are bold.
137
+ export function kvList(pairs: Array<[string, string]>, options: { indent?: boolean } = {}): string {
138
+ const indent = options.indent === false ? "" : " ";
139
+ const width = pairs.reduce((max, [key]) => Math.max(max, key.length), 0);
140
+ return pairs
141
+ .map(([key, value]) => `${indent}${bold(pad(key, width))} ${value}`)
142
+ .join("\n");
143
+ }
144
+
145
+ export function section(headingText: string, body: string): string {
146
+ return `${heading(headingText)}\n${body}`;
147
+ }
148
+
149
+ export function pad(text: string, width: number): string {
150
+ return text.length >= width ? text : text + " ".repeat(width - text.length);
151
+ }
152
+
153
+ export function termWidth(stream: NodeJS.WriteStream = process.stderr): number {
154
+ return stream.columns || 80;
155
+ }
156
+
157
+ export function clip(value: string, max: number): string {
158
+ return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}${sym.ellipsis}` : value;
159
+ }