@rigkit/cli 0.2.6 → 0.2.8

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.
@@ -1,7 +1,8 @@
1
- import chalk from "chalk";
2
- import { createLogUpdate } from "log-update";
3
- import logSymbols from "log-symbols";
4
- import ora from "ora";
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";
5
6
 
6
7
  export type RunPresenter = {
7
8
  render(event: { type: string; [key: string]: unknown }): void;
@@ -10,346 +11,209 @@ export type RunPresenter = {
10
11
  close(): void;
11
12
  };
12
13
 
13
- type StepStatus = "running" | "done" | "cached" | "failed";
14
-
15
- type RunStep = {
16
- index: number;
17
- path: string;
18
- status: StepStatus;
19
- detail: string;
20
- logs: string[];
21
- };
22
-
23
14
  export function createRunPresenter(operation: string): RunPresenter | undefined {
24
- if (!canUsePresenter()) return undefined;
15
+ if (process.env.RIGKIT_RENDER === "0") return undefined;
25
16
 
26
- const log = createLogUpdate(normalizedStderr(), {
27
- defaultHeight: 24,
28
- defaultWidth: 80,
29
- });
30
- const spinner = ora({
31
- color: "cyan",
32
- discardStdin: false,
33
- isEnabled: true,
34
- spinner: "dots",
35
- stream: process.stderr,
36
- });
37
- const steps: RunStep[] = [];
38
- const stepsByPath = new Map<string, RunStep>();
39
- let totalSteps = 0;
40
17
  let workflow = "";
41
- let phase = "Starting runtime operation";
42
- let activePath: string | undefined;
43
- let finalStatus: "running" | "completed" | "failed" = "running";
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;
44
23
  let closed = false;
45
- let paused = false;
46
- let timer: ReturnType<typeof setInterval> | undefined;
47
24
 
48
- const refresh = () => {
49
- if (closed || paused) return;
50
- log(renderTimeline({
51
- activePath,
52
- finalStatus,
53
- operation,
54
- phase,
55
- spinnerFrame: spinner.frame().trimEnd(),
56
- steps,
57
- totalSteps,
58
- workflow,
59
- }));
25
+ const write = (line: string): void => {
26
+ process.stderr.write(`${line}\n`);
60
27
  };
61
28
 
62
- const startTimer = () => {
63
- timer ??= setInterval(refresh, spinner.interval);
64
- timer.unref?.();
65
- };
66
- const stopTimer = () => {
67
- if (!timer) return;
68
- clearInterval(timer);
69
- timer = undefined;
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}`;
70
36
  };
71
- startTimer();
72
- refresh();
73
37
 
74
38
  return {
75
39
  render(event) {
76
40
  if (closed) return;
77
41
  switch (event.type) {
78
- case "definition.loaded":
79
- workflow = String(event.workflow ?? "workflow");
80
- phase = `Loaded ${workflow}`;
42
+ case "definition.loaded": {
43
+ workflow = stringField(event.workflow) ?? workflow;
81
44
  break;
82
- case "plan.created":
83
- workflow = String(event.workflow ?? (workflow || "workflow"));
84
- totalSteps = numberField(event.nodeCount) ?? totalSteps;
85
- phase = `Planned ${workflow}`;
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))));
86
52
  break;
87
- case "node.cached":
88
- markStep(stepFor(steps, stepsByPath, event.nodePath), "cached", compactId(event.runId));
53
+ }
54
+ case "workflow.apply.started": {
55
+ workflow = stringField(event.workflow) ?? workflow;
56
+ write(`${accent(sym.active)} workflow ${bold(workflow || operation)}`);
89
57
  break;
90
- case "node.started":
91
- activePath = stringField(event.nodePath);
92
- markStep(stepFor(steps, stepsByPath, event.nodePath), "running", "loading");
93
- phase = `Running ${activePath ?? "step"}`;
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)}`));
94
81
  break;
82
+ }
95
83
  case "node.completed": {
96
- const step = stepFor(steps, stepsByPath, event.nodePath);
97
- markStep(step, "done", compactId(event.runId));
98
- if (activePath === step.path) activePath = undefined;
99
- phase = `Completed ${step.path}`;
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}`));
100
91
  break;
101
92
  }
102
- case "vm.created":
103
- setActiveDetail(stepsByPath, activePath, event.fromSnapshotId
104
- ? `VM ${String(event.vmId ?? "")} from ${String(event.fromSnapshotId)}`
105
- : `VM ${String(event.vmId ?? "")} created`);
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)}`));
106
98
  break;
99
+ }
107
100
  case "command.started": {
108
- const step = stepFor(steps, stepsByPath, event.nodePath ?? activePath);
109
- step.status = "running";
110
- activePath = step.path;
111
- step.detail = String(event.command ?? event.commandName ?? "command");
112
- phase = `Command ${String(event.commandName ?? "command")}`;
101
+ const command = stringField(event.command) ?? stringField(event.commandName);
102
+ if (command) write(indent(` ${dim(`$ ${trim(command)}`)}`));
113
103
  break;
114
104
  }
115
105
  case "command.output": {
116
- const step = stepFor(steps, stepsByPath, event.nodePath ?? activePath);
117
- activePath = step.path;
118
- appendLog(step.logs, `[${String(event.stream ?? "log")}] ${String(event.data ?? "")}`);
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
+ }
119
112
  break;
120
113
  }
121
- case "command.completed":
122
- setActiveDetail(
123
- stepsByPath,
124
- stringField(event.nodePath) ?? activePath,
125
- `Command ${String(event.commandName ?? "")} exited ${String(event.exitCode ?? 0)}`,
126
- );
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
+ }
127
120
  break;
121
+ }
128
122
  case "log.output": {
129
- const step = stepFor(steps, stepsByPath, event.nodePath ?? activePath);
130
- activePath = step.status === "running" ? step.path : activePath;
131
- appendLog(step.logs, `[${String(event.label ?? event.stream ?? "log")}] ${String(event.data ?? "")}`);
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
+ }
132
136
  break;
133
137
  }
134
- case "interaction.awaiting_user":
135
- phase = `Waiting for ${String(event.title ?? "user interaction")}`;
136
- setActiveDetail(stepsByPath, stringField(event.nodePath) ?? activePath, String(event.url ?? ""));
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)}`));
137
143
  break;
138
- case "interaction.completed":
139
- phase = `Completed ${String(event.title ?? "interaction")}`;
144
+ }
145
+ case "interaction.completed": {
146
+ const label = stringField(event.title) ?? stringField(event.label) ?? "interaction";
147
+ write(indent(`${ok(sym.ok)} ${dim(`${label} completed`)}`));
140
148
  break;
141
- case "artifact.created":
142
- setActiveDetail(
143
- stepsByPath,
144
- stringField(event.nodePath) ?? activePath,
145
- `Created ${String(event.kind ?? "artifact")}`,
146
- );
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}`)}`));
147
155
  break;
148
- case "workspace.ready":
149
- phase = `Workspace ${String(event.workspaceId ?? "ready")} ready`;
156
+ }
157
+ case "workspace.create.started": {
158
+ const name = stringField(event.workspaceName) ?? "workspace";
159
+ write(`${accent(sym.active)} creating workspace ${bold(name)}`);
150
160
  break;
151
- case "run.completed":
152
- finalStatus = "completed";
153
- phase = "Completed";
154
- collapseActiveStep(stepsByPath, activePath);
155
- activePath = undefined;
161
+ }
162
+ case "workspace.ready": {
163
+ const id = stringField(event.workspaceId) ?? "workspace";
164
+ write(`${ok(sym.ok)} ${bold(id)} ${dim("ready")}`);
156
165
  break;
157
- case "run.failed":
158
- finalStatus = "failed";
159
- phase = isRecord(event.error) && typeof event.error.message === "string"
160
- ? event.error.message
161
- : "Run failed";
162
- activePath = failActiveStep(steps, stepsByPath, activePath, phase);
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)}`);
163
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
+ }
164
195
  }
165
- refresh();
166
196
  },
167
197
  pause() {
168
- if (closed || paused) return;
169
- log(renderTimeline({
170
- activePath,
171
- finalStatus,
172
- operation,
173
- phase,
174
- spinnerFrame: spinner.frame().trimEnd(),
175
- steps,
176
- totalSteps,
177
- workflow,
178
- }));
179
- paused = true;
180
- stopTimer();
181
- log.done();
198
+ // No-op output is already append-only.
182
199
  },
183
200
  resume() {
184
- if (closed || !paused) return;
185
- paused = false;
186
- startTimer();
187
- refresh();
201
+ // No-op output is already append-only.
188
202
  },
189
203
  close() {
190
- if (closed) return;
191
- if (paused) {
192
- paused = false;
193
- }
194
- refresh();
195
204
  closed = true;
196
- stopTimer();
197
- log.done();
198
205
  },
199
206
  };
200
207
  }
201
208
 
202
- function normalizedStderr(): NodeJS.WriteStream {
203
- const stream = Object.create(process.stderr) as NodeJS.WriteStream;
204
- Object.defineProperty(stream, "columns", {
205
- get: () => process.stderr.columns || 80,
206
- });
207
- Object.defineProperty(stream, "rows", {
208
- get: () => process.stderr.rows || 24,
209
- });
210
- stream.write = process.stderr.write.bind(process.stderr) as typeof process.stderr.write;
211
- return stream;
212
- }
213
-
214
- function canUsePresenter(): boolean {
215
- return Boolean(
216
- process.env.RIGKIT_RENDER !== "0" &&
217
- process.stderr.isTTY,
218
- );
219
- }
220
-
221
- function renderTimeline(input: {
222
- activePath: string | undefined;
223
- finalStatus: "running" | "completed" | "failed";
224
- operation: string;
225
- phase: string;
226
- spinnerFrame: string;
227
- steps: RunStep[];
228
- totalSteps: number;
229
- workflow: string;
230
- }): string {
231
- const done = input.steps.filter((step) => step.status === "done" || step.status === "cached").length;
232
- const total = input.totalSteps || input.steps.length;
233
- const progress = total > 0 ? `${done}/${total} steps` : `${input.steps.length} steps`;
234
- const title = input.workflow ? `${input.operation} ${input.workflow}` : input.operation;
235
- const lines = [
236
- `${statusPrefix(input.finalStatus, input.spinnerFrame)} ${chalk.bold(title)} ${chalk.dim(input.phase)} ${chalk.dim(`(${progress})`)}`,
237
- "",
238
- ...timelineLines(input),
239
- ];
240
- return lines.join("\n");
241
- }
242
-
243
- function timelineLines(input: {
244
- activePath: string | undefined;
245
- spinnerFrame: string;
246
- steps: RunStep[];
247
- }): string[] {
248
- const visible = input.steps.length > 8 ? input.steps.slice(-8) : input.steps;
249
- const lines: string[] = [];
250
- if (visible.length < input.steps.length) {
251
- lines.push(chalk.dim(`... ${input.steps.length - visible.length} earlier steps`));
252
- }
253
-
254
- const active = input.activePath ? input.steps.find((step) => step.path === input.activePath) : undefined;
255
- for (const step of visible) {
256
- lines.push(stepLine(step, input.spinnerFrame));
257
- if (step.path === active?.path && (step.status === "running" || step.status === "failed")) {
258
- if (step.detail) lines.push(chalk.dim(` ${clip(step.detail, 100)}`));
259
- for (const line of step.logs.slice(-4)) lines.push(chalk.dim(` ${clip(line, 100)}`));
260
- }
261
- }
262
-
263
- return lines.length > 0 ? lines : [chalk.dim("waiting for runtime events")];
264
- }
265
-
266
- function stepLine(step: RunStep, spinnerFrame: string): string {
267
- const index = String(step.index).padStart(2, " ");
268
- switch (step.status) {
269
- case "running":
270
- return `${chalk.dim(index)} ${spinnerFrame} ${chalk.bold(step.path)}`;
271
- case "cached":
272
- return `${chalk.dim(index)} ${logSymbols.success} ${chalk.dim(step.path)}${step.detail ? chalk.dim(` cached ${step.detail}`) : ""}`;
273
- case "done":
274
- return `${chalk.dim(index)} ${logSymbols.success} ${chalk.dim(step.path)}${step.detail ? chalk.dim(` ${step.detail}`) : ""}`;
275
- case "failed":
276
- return `${chalk.dim(index)} ${logSymbols.error} ${chalk.bold(step.path)}`;
277
- }
278
- }
279
-
280
- function statusPrefix(status: "running" | "completed" | "failed", spinnerFrame: string): string {
281
- if (status === "completed") return logSymbols.success;
282
- if (status === "failed") return logSymbols.error;
283
- return spinnerFrame;
284
- }
285
-
286
- function stepFor(
287
- steps: RunStep[],
288
- stepsByPath: Map<string, RunStep>,
289
- rawPath: unknown,
290
- ): RunStep {
291
- const path = typeof rawPath === "string" && rawPath.length > 0 ? rawPath : "runtime";
292
- const existing = stepsByPath.get(path);
293
- if (existing) return existing;
294
- const step: RunStep = {
295
- index: steps.length + 1,
296
- path,
297
- status: "running",
298
- detail: "loading",
299
- logs: [],
300
- };
301
- steps.push(step);
302
- stepsByPath.set(path, step);
303
- return step;
304
- }
305
-
306
- function markStep(step: RunStep, status: StepStatus, detail: string): void {
307
- step.status = status;
308
- step.detail = detail;
309
- if (status === "done" || status === "cached") {
310
- step.logs = [];
311
- }
312
- }
313
-
314
- function setActiveDetail(stepsByPath: Map<string, RunStep>, path: string | undefined, detail: string): void {
315
- if (!path) return;
316
- const step = stepsByPath.get(path);
317
- if (!step) return;
318
- step.detail = detail;
319
- }
320
-
321
- function collapseActiveStep(stepsByPath: Map<string, RunStep>, path: string | undefined): void {
322
- if (!path) return;
323
- const step = stepsByPath.get(path);
324
- if (step?.status === "running") markStep(step, "done", "completed");
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`;
325
213
  }
326
214
 
327
- function failActiveStep(
328
- steps: RunStep[],
329
- stepsByPath: Map<string, RunStep>,
330
- path: string | undefined,
331
- message: string,
332
- ): string | undefined {
333
- const step = path ? stepsByPath.get(path) : lastStepToFail(steps);
334
- if (!step) return undefined;
335
- markStep(step, "failed", message);
336
- return step.path;
337
- }
338
-
339
- function lastStepToFail(steps: RunStep[]): RunStep | undefined {
340
- for (let index = steps.length - 1; index >= 0; index -= 1) {
341
- const step = steps[index]!;
342
- if (step.status === "running") return step;
343
- }
344
- return steps[steps.length - 1];
345
- }
346
-
347
- function appendLog(lines: string[], value: string): void {
348
- for (const line of value.replace(/\r/g, "").split("\n")) {
349
- if (line.length === 0) continue;
350
- lines.push(line);
351
- }
352
- if (lines.length > 200) lines.splice(0, lines.length - 200);
215
+ function noun(singular: string, count: number): string {
216
+ return count === 1 ? singular : `${singular}s`;
353
217
  }
354
218
 
355
219
  function compactId(value: unknown): string {
@@ -357,17 +221,30 @@ function compactId(value: unknown): string {
357
221
  }
358
222
 
359
223
  function stringField(value: unknown): string | undefined {
360
- return typeof value === "string" ? value : undefined;
224
+ return typeof value === "string" && value.length > 0 ? value : undefined;
361
225
  }
362
226
 
363
227
  function numberField(value: unknown): number | undefined {
364
228
  return typeof value === "number" ? value : undefined;
365
229
  }
366
230
 
367
- function isRecord(value: unknown): value is Record<string, unknown> {
368
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
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
+ }
369
249
  }
370
250
 
371
- function clip(value: string, max: number): string {
372
- return value.length > max ? `${value.slice(0, Math.max(0, max - 3))}...` : value;
373
- }