redkite 0.1.11 → 0.1.13

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.
Files changed (62) hide show
  1. package/README.md +76 -1
  2. package/dist/build.d.ts +3 -2
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +14 -7
  5. package/dist/build.js.map +1 -1
  6. package/dist/cli/agent.d.ts +5 -1
  7. package/dist/cli/agent.d.ts.map +1 -1
  8. package/dist/cli/agent.js +7 -7
  9. package/dist/cli/agent.js.map +1 -1
  10. package/dist/cli/crash.d.ts +46 -0
  11. package/dist/cli/crash.d.ts.map +1 -0
  12. package/dist/cli/crash.js +204 -0
  13. package/dist/cli/crash.js.map +1 -0
  14. package/dist/cli/index.d.ts.map +1 -1
  15. package/dist/cli/index.js +50 -15
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/cli/log.d.ts +1 -0
  18. package/dist/cli/log.d.ts.map +1 -1
  19. package/dist/cli/log.js +4 -3
  20. package/dist/cli/log.js.map +1 -1
  21. package/dist/cli/screen.d.ts +3 -0
  22. package/dist/cli/screen.d.ts.map +1 -1
  23. package/dist/cli/screen.js +32 -6
  24. package/dist/cli/screen.js.map +1 -1
  25. package/dist/cli/viewer.d.ts.map +1 -1
  26. package/dist/cli/viewer.js +5 -5
  27. package/dist/cli/viewer.js.map +1 -1
  28. package/dist/deploy.d.ts.map +1 -1
  29. package/dist/deploy.js +72 -6
  30. package/dist/deploy.js.map +1 -1
  31. package/dist/docker.d.ts +1 -0
  32. package/dist/docker.d.ts.map +1 -1
  33. package/dist/docker.js +5 -0
  34. package/dist/docker.js.map +1 -1
  35. package/dist/health.d.ts +2 -2
  36. package/dist/health.d.ts.map +1 -1
  37. package/dist/health.js +17 -9
  38. package/dist/health.js.map +1 -1
  39. package/dist/log.d.ts +3 -0
  40. package/dist/log.d.ts.map +1 -1
  41. package/dist/log.js +13 -0
  42. package/dist/log.js.map +1 -1
  43. package/dist/source.d.ts +2 -0
  44. package/dist/source.d.ts.map +1 -1
  45. package/dist/source.js +20 -0
  46. package/dist/source.js.map +1 -1
  47. package/dist/types.d.ts +4 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/build.ts +23 -11
  51. package/src/cli/agent.ts +8 -8
  52. package/src/cli/crash.ts +287 -0
  53. package/src/cli/index.ts +59 -17
  54. package/src/cli/log.ts +4 -3
  55. package/src/cli/screen.ts +45 -4
  56. package/src/cli/viewer.ts +5 -4
  57. package/src/deploy.ts +88 -6
  58. package/src/docker.ts +6 -0
  59. package/src/health.ts +23 -16
  60. package/src/log.ts +19 -1
  61. package/src/source.ts +24 -0
  62. package/src/types.ts +9 -0
package/src/cli/agent.ts CHANGED
@@ -21,14 +21,14 @@ function overSsh(repo: string) {
21
21
  return !/^(https?|git|file):\/\//.test(repo);
22
22
  }
23
23
 
24
- // A plain writer rather than a Log, because this runs before the view opens:
25
- // ssh-add may ask for a passphrase, and it cannot ask through a screen
26
- // something else is drawing
27
- export function requireAgent(warn: (message: string) => void) {
28
- const existing = process.env["SSH_AUTH_SOCK"];
29
- if (existing) return existing;
24
+ export const AGENT_STARTED = "no SSH agent was running, so one was started";
30
25
 
31
- warn("No SSH agent, starting one");
26
+ // Runs before the view opens, because ssh-add may ask for a passphrase and it
27
+ // cannot ask through a screen something else is drawing. Whether one was started
28
+ // is answered rather than said, so the step that opens the connection says it
29
+ export function requireAgent() {
30
+ const existing = process.env["SSH_AUTH_SOCK"];
31
+ if (existing) return { socket: existing, started: false };
32
32
 
33
33
  const output = execFileSync("ssh-agent", ["-s"], { encoding: "utf8" });
34
34
  const socket = output.match(/SSH_AUTH_SOCK=([^;]+);/)?.[1];
@@ -51,5 +51,5 @@ export function requireAgent(warn: (message: string) => void) {
51
51
  );
52
52
  }
53
53
 
54
- return socket;
54
+ return { socket, started: true };
55
55
  }
@@ -0,0 +1,287 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import { untagged, type Task } from "../log.js";
5
+ import type { Deployment } from "../types.js";
6
+ import { elapsed } from "./screen.js";
7
+ import { causes } from "./log.js";
8
+ import type { Viewer } from "./viewer.js";
9
+
10
+ // What a failed run said, kept whole. The view trims a step to its last lines
11
+ // and the alternate screen takes the rest with it, so the only complete record
12
+ // of a run that went wrong is one written down while it happened.
13
+
14
+ // Named rather than read from TMPDIR: the path is the one a person is told to
15
+ // look in, and a runner that points TMPDIR somewhere else would hide it
16
+ const ROOT = "/tmp";
17
+
18
+ type Kind = "info" | "warn" | "fail" | "done" | "plan" | "step" | "command";
19
+
20
+ type Entry = { at: number; kind: Kind; text: string };
21
+
22
+ type Said = { at: number; kind: "detail" | "line"; text: string };
23
+
24
+ type StepRecord = {
25
+ label: string;
26
+ started: number;
27
+ ended?: number;
28
+ state: "running" | "done" | "failed";
29
+ note?: string;
30
+ said: Said[];
31
+ };
32
+
33
+ export type Transcript = {
34
+ started: number;
35
+ entries: Entry[];
36
+ steps: StepRecord[];
37
+ };
38
+
39
+ export type Crash = {
40
+ project: string;
41
+ environment: string;
42
+ command: string;
43
+ version: string;
44
+ argv: string[];
45
+ outcome: string;
46
+ at: number;
47
+ error?: unknown;
48
+ };
49
+
50
+ // Every call reaches the wrapped log unchanged, so the view behaves as it did.
51
+ // The transcript is a copy kept beside it, never a replacement for it
52
+ export function recording(log: Viewer, now: () => number = Date.now) {
53
+ const transcript: Transcript = { started: now(), entries: [], steps: [] };
54
+ const note = (kind: Kind, text: string) => transcript.entries.push({ at: now(), kind, text });
55
+
56
+ const step = (label: string): Task => {
57
+ const record: StepRecord = { label, started: now(), state: "running", said: [] };
58
+ const task = log.step(label);
59
+
60
+ transcript.steps.push(record);
61
+ note("step", `${label} started`);
62
+
63
+ const settle = (state: "done" | "failed", message?: string) => {
64
+ record.state = state;
65
+ record.ended = now();
66
+ record.note = message;
67
+
68
+ const said = message ? `: ${message}` : "";
69
+ note("step", `${label} ${state}${said} (${elapsed(record.ended, record.started)})`);
70
+ };
71
+
72
+ return {
73
+ detail: (message) => {
74
+ record.said.push({ at: now(), kind: "detail", text: message });
75
+ task.detail(message);
76
+ },
77
+ line: (message) => {
78
+ record.said.push({ at: now(), kind: "line", text: message });
79
+ task.line(message);
80
+ },
81
+ done: (message) => {
82
+ settle("done", message);
83
+ task.done(message);
84
+ },
85
+ fail: (message) => {
86
+ settle("failed", message);
87
+ task.fail(message);
88
+ },
89
+ };
90
+ };
91
+
92
+ const recorded: Viewer = Object.assign(
93
+ (message: string) => {
94
+ note("info", message);
95
+ log(message);
96
+ },
97
+ {
98
+ plan: (points: string[]) => {
99
+ note("plan", points.join(", "));
100
+ log.plan?.(points);
101
+ },
102
+ warn: (message: string) => {
103
+ note("warn", message);
104
+ log.warn(message);
105
+ },
106
+ fail: (message: string) => {
107
+ note("fail", message);
108
+ log.fail(message);
109
+ },
110
+ done: (message: string) => {
111
+ note("done", message);
112
+ log.done(message);
113
+ },
114
+ step,
115
+ close: () => log.close(),
116
+ },
117
+ );
118
+
119
+ // A host command is only shown with --verbose, and is exactly what a crash log
120
+ // is read for, so it is kept whether it was shown or not
121
+ const command = (text: string) => note("command", text);
122
+
123
+ return { log: recorded, transcript, command };
124
+ }
125
+
126
+ // One directory per failed run, under the environment it failed in. Named from
127
+ // the command line, so both segments are made safe to be a path first
128
+ export function crashDirectory(project: string, environment: string, at: Date, root = ROOT) {
129
+ const moment = at.toISOString().replaceAll(":", "-");
130
+ return join(root, slug(project), slug(environment), `crash-${moment}`);
131
+ }
132
+
133
+ // The run's own log, and one file per step. A build's thousands of lines would
134
+ // bury everything else in a single file, and a step's file is what gets opened
135
+ // first. Numbered in the order they started, which also keeps two steps that
136
+ // share a label from sharing a file
137
+ export function crashFiles(transcript: Transcript, crash: Crash): Record<string, string> {
138
+ const width = Math.max(2, String(transcript.steps.length).length);
139
+
140
+ const named = transcript.steps.map((step, index) => ({
141
+ step,
142
+ file: `${pad(index + 1, width)}-${slug(step.label)}.log`,
143
+ }));
144
+
145
+ return {
146
+ "run.log": runLog(transcript, crash, named),
147
+ ...Object.fromEntries(
148
+ named.map(({ step, file }) => [file, stepLog(transcript, crash, step)]),
149
+ ),
150
+ };
151
+ }
152
+
153
+ // Nothing is written for a deployment that turned it off. Otherwise private to
154
+ // whoever ran it: /tmp is shared by everyone on the machine, and a build's output
155
+ // can say more than it meant to
156
+ export async function dumpCrash(
157
+ config: Deployment,
158
+ transcript: Transcript,
159
+ crash: Crash,
160
+ root = ROOT,
161
+ ) {
162
+ if (config.options?.crashLog === false) return undefined;
163
+
164
+ const directory = crashDirectory(config.project, crash.environment, new Date(crash.at), root);
165
+ await mkdir(dirname(directory), { recursive: true, mode: 0o700 });
166
+
167
+ // Not recursive: a directory already there belongs to another run, and nothing
168
+ // of this one is written into it
169
+ await mkdir(directory, { mode: 0o700 });
170
+
171
+ for (const [name, contents] of Object.entries(crashFiles(transcript, crash))) {
172
+ await writeFile(join(directory, name), contents, { mode: 0o600, flag: "wx" });
173
+ }
174
+
175
+ return directory;
176
+ }
177
+
178
+ const MARK: Record<Kind, string> = {
179
+ info: "",
180
+ warn: "warn ",
181
+ fail: "fail ",
182
+ done: "done ",
183
+ plan: "plan ",
184
+ step: "step ",
185
+ command: "",
186
+ };
187
+
188
+ type Named = { step: StepRecord; file: string };
189
+
190
+ function runLog(transcript: Transcript, crash: Crash, named: Named[]) {
191
+ const since = sinceStart(transcript);
192
+
193
+ const index = named.map(({ step, file }) => {
194
+ const took = elapsed(step.ended ?? crash.at, step.started);
195
+ return `${file.padEnd(40)} ${step.state.padEnd(8)} ${took}`;
196
+ });
197
+
198
+ return lines([
199
+ "redkite crash log",
200
+ "",
201
+ `project ${crash.project}`,
202
+ `environment ${crash.environment}`,
203
+ `command ${crash.command}`,
204
+ `outcome ${crash.outcome}`,
205
+ `version ${crash.version}`,
206
+ `argv ${crash.argv.join(" ")}`,
207
+ `started ${new Date(transcript.started).toISOString()}`,
208
+ `ended ${new Date(crash.at).toISOString()}`,
209
+ "",
210
+ "== error",
211
+ ...errorLines(crash.error),
212
+ "",
213
+ "== steps, each in its own file beside this one",
214
+ ...(index.length > 0 ? index : ["none started"]),
215
+ "",
216
+ "== log",
217
+ ...transcript.entries.map(
218
+ (entry) => `${since(entry.at)} ${MARK[entry.kind]}${indent(entry.text)}`,
219
+ ),
220
+ ]);
221
+ }
222
+
223
+ // Stamped from the start of the run, the same as run.log, so a line here can be
224
+ // found among what the run said around it
225
+ function stepLog(transcript: Transcript, crash: Crash, step: StepRecord) {
226
+ const since = sinceStart(transcript);
227
+ const note = step.note ? `: ${step.note}` : "";
228
+
229
+ return lines([
230
+ `step ${step.label}`,
231
+ `state ${step.state}${indent(note)}`,
232
+ `started ${new Date(step.started).toISOString()}`,
233
+ `ended ${step.ended ? new Date(step.ended).toISOString() : "still running when the run ended"}`,
234
+ `took ${elapsed(step.ended ?? crash.at, step.started)}`,
235
+ "",
236
+ ...step.said.map(
237
+ (said) => `${since(said.at)} ${said.kind === "detail" ? "· " : "| "}${indent(said.text)}`,
238
+ ),
239
+ ]);
240
+ }
241
+
242
+ // Whole, where the terminal gets the tail: the stack of every error in the
243
+ // chain, since the step that threw is usually two causes down
244
+ function errorLines(error: unknown) {
245
+ if (error === undefined) return ["none thrown"];
246
+ if (!(error instanceof Error)) return [String(error)];
247
+
248
+ return causes(error).flatMap((link, index) => [
249
+ ...(index > 0 ? ["", "caused by"] : []),
250
+ link.stack ?? link.message,
251
+ ]);
252
+ }
253
+
254
+ function sinceStart(transcript: Transcript) {
255
+ return (at: number) => `+${clock(at - transcript.started)}`;
256
+ }
257
+
258
+ function lines(rows: string[]) {
259
+ return `${rows.join("\n")}\n`;
260
+ }
261
+
262
+ // Continuation lines line up under the text rather than under the timestamp. A
263
+ // file cannot draw a tag, so it gets the bracketed word
264
+ function indent(text: string) {
265
+ return untagged(text).replaceAll("\n", "\n ");
266
+ }
267
+
268
+ function slug(text: string) {
269
+ const slugged = text
270
+ .toLowerCase()
271
+ .replace(/[^a-z0-9]+/g, "-")
272
+ .replace(/^-+|-+$/g, "");
273
+
274
+ return slugged || "unnamed";
275
+ }
276
+
277
+ function clock(ms: number) {
278
+ const minutes = Math.floor(ms / 60_000);
279
+ const seconds = Math.floor(ms / 1000) % 60;
280
+ const millis = ms % 1000;
281
+
282
+ return `${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(millis, 3)}`;
283
+ }
284
+
285
+ function pad(value: number, width: number) {
286
+ return String(value).padStart(width, "0");
287
+ }
package/src/cli/index.ts CHANGED
@@ -14,9 +14,10 @@ import { sshHost } from "../sshHost.js";
14
14
  import { topologyFor, type Topology } from "../topology.js";
15
15
  import type { Deployment, DeployHost } from "../types.js";
16
16
 
17
- import { needsAgent, requireAgent } from "./agent.js";
17
+ import { AGENT_STARTED, needsAgent, requireAgent } from "./agent.js";
18
18
  import { loadConfig, projectRoot } from "./config.js";
19
19
  import { loadDotenv } from "./dotenv.js";
20
+ import { dumpCrash, recording } from "./crash.js";
20
21
  import { createLog, describeFailure } from "./log.js";
21
22
 
22
23
  // One command that reads the config and does everything under it: the agent,
@@ -42,7 +43,7 @@ of the project, and everything below it is derived.
42
43
 
43
44
  // Wraps a host rather than living inside one, so both implementations are
44
45
  // measured the same way and neither knows it is being timed
45
- function measured(host: Host, log?: Log) {
46
+ function measured(host: Host, say?: (line: string) => void) {
46
47
  const totals = { commands: 0, commandMs: 0, files: 0 };
47
48
 
48
49
  const wrapped: Host = {
@@ -61,7 +62,7 @@ function measured(host: Host, log?: Log) {
61
62
 
62
63
  // The exit code matters: several of these are allowed to fail, and a
63
64
  // deploy reading verbose output is one where somebody wants to know which
64
- log?.(` $ ${command} ${Date.now() - started}ms exit ${result.code}`);
65
+ say?.(` $ ${command} ${Date.now() - started}ms exit ${result.code}`);
65
66
  return result;
66
67
  },
67
68
 
@@ -180,7 +181,9 @@ async function recover(
180
181
  const deployHost = environmentOf(config, environment)?.host;
181
182
 
182
183
  const say = (message = "") => process.stdout.write(`${message}\n`);
183
- if (needsAgent(config, environment)) requireAgent(say);
184
+ // Printed rather than drawn: a recovery has no view, and its connection
185
+ // step is silent
186
+ if (needsAgent(config, environment) && requireAgent().started) say(AGENT_STARTED);
184
187
 
185
188
  const host = await hostFor(deployHost, silent);
186
189
 
@@ -484,14 +487,26 @@ export function buildingHere(config: Deployment, environment: string): Deploymen
484
487
 
485
488
  // Without a bastion the containers are on this machine, and every command the
486
489
  // deploy issues is one this process can run itself
487
- async function hostFor(host: DeployHost | undefined, log: Log, signal?: AbortSignal) {
488
- if (!host?.bastion) return await localHost({ signal });
490
+ async function hostFor(
491
+ host: DeployHost | undefined,
492
+ log: Log,
493
+ signal?: AbortSignal,
494
+ startedAgent = false,
495
+ ) {
496
+ if (!host?.bastion) {
497
+ // No connection to say it in. A run on this machine that clones over ssh
498
+ // still started one, and that is still worth knowing
499
+ if (startedAgent) log.warn(AGENT_STARTED);
500
+ return await localHost({ signal });
501
+ }
489
502
 
490
503
  const task = log.step(`Opening a connection to ${host.bastion}`);
504
+ if (startedAgent) task.detail(AGENT_STARTED);
491
505
 
492
506
  try {
493
507
  const opened = await sshHost(host.bastion, { signal, hostKeys: host.hostKeys });
494
- task.done();
508
+ // Kept on the row once the connection is open, not only while it opens
509
+ task.done(startedAgent ? AGENT_STARTED : undefined);
495
510
  return opened;
496
511
  } catch (error) {
497
512
  task.fail(`Could not reach ${host.bastion}`);
@@ -560,18 +575,43 @@ async function run(
560
575
  // The host clones the repositories over this, so it has to exist before the
561
576
  // connection that forwards it is opened. Before the view too: ssh-add asks
562
577
  // for a passphrase on the terminal, and by then the view owns it
563
- if (needsAgent(config, environment)) {
564
- requireAgent((message) => process.stderr.write(`${message}\n`));
565
- }
578
+ const agent = needsAgent(config, environment) ? requireAgent() : undefined;
566
579
 
567
580
  // Whatever is in flight is killed, the pipeline unwinds through its own
568
581
  // failure path, and the finally below removes the scratch directory
569
582
  const stopping = new AbortController();
570
583
 
571
- const log = createLog({
572
- ...options,
573
- onQuit: () => stop(),
574
- });
584
+ // Everything the run says still reaches the view. The recording is what a
585
+ // crash log is written from, since the view keeps only each step's tail
586
+ const recorder = recording(
587
+ createLog({
588
+ ...options,
589
+ onQuit: () => stop(),
590
+ }),
591
+ );
592
+
593
+ const log = recorder.log;
594
+
595
+ // Before the view closes, so where the log went is among what it writes out.
596
+ // A log that cannot be written must not replace the failure it was recording
597
+ const dumped = async (outcome: string, error?: unknown) => {
598
+ try {
599
+ const path = await dumpCrash(config, recorder.transcript, {
600
+ project: config.project,
601
+ environment,
602
+ command: kind,
603
+ version: await version(),
604
+ argv: process.argv.slice(2),
605
+ outcome,
606
+ at: Date.now(),
607
+ error,
608
+ });
609
+
610
+ if (path) log.warn(`Crash logs written to ${path}`);
611
+ } catch (failure) {
612
+ log.warn(`Could not write the crash log: ${describeFailure(failure).split("\n")[0]}`);
613
+ }
614
+ };
575
615
 
576
616
  // Read by the wait below, so a press during it hardens what that is sending
577
617
  let hardest: "TERM" | "KILL" = "TERM";
@@ -601,8 +641,8 @@ async function run(
601
641
 
602
642
  try {
603
643
  const meter = measured(
604
- await hostFor(deployHost, log, stopping.signal),
605
- options.verbose ? log : undefined,
644
+ await hostFor(deployHost, log, stopping.signal, agent?.started),
645
+ options.verbose ? log : recorder.command,
606
646
  );
607
647
 
608
648
  host = meter.host;
@@ -633,15 +673,17 @@ async function run(
633
673
 
634
674
  log.fail(`Reverted ${result.reverted.join(", ")}`);
635
675
  process.exitCode = 1;
676
+ await dumped("reverted");
636
677
  } catch (error) {
637
678
  // Whatever the command in flight said about being killed is noise: what
638
- // happened is that somebody asked for it to stop
679
+ // happened is that somebody asked for it to stop, which is not a crash
639
680
  if (stopping.signal.aborted) {
640
681
  log.fail("Stopped");
641
682
  process.exitCode = 130;
642
683
  } else {
643
684
  log.fail(describeFailure(error));
644
685
  process.exitCode = 1;
686
+ await dumped("failed", error);
645
687
  }
646
688
  } finally {
647
689
  // Nothing leaves while the build is still running. The signal a press
package/src/cli/log.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Task } from "../log.js";
2
2
 
3
- import { elapsed as took } from "./screen.js";
3
+ import { paintTags, elapsed as took } from "./screen.js";
4
4
  import { createViewer, type Viewer } from "./viewer.js";
5
5
 
6
6
  // What a deploy prints. The engine's own trace is a build graph with span ids
@@ -48,7 +48,8 @@ function plainLog(lines: boolean) {
48
48
  const write = (stream: NodeJS.WriteStream, message: string, colour?: number) => {
49
49
  const painted = colours(stream);
50
50
  const stamp = painted ? paint(elapsed(), STAMP) : elapsed();
51
- const body = painted && colour ? paint(message, colour) : message;
51
+ // A tag is drawn where there is colour and bracketed where there is not
52
+ const body = paintTags(message, painted, colour);
52
53
  const line = `${stamp} ${body}\n`;
53
54
 
54
55
  // Both streams share one terminal, so the block has to come down for a
@@ -146,7 +147,7 @@ export function describeFailure(error: unknown) {
146
147
 
147
148
  const DEPTH = 8;
148
149
 
149
- function causes(error: Error) {
150
+ export function causes(error: Error) {
150
151
  const chain: Error[] = [];
151
152
  let current: unknown = error;
152
153
 
package/src/cli/screen.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { TAGS, untagged } from "../log.js";
2
+
1
3
  // The deploy as a list of collapsibles. A step is a title and the lines it
2
4
  // produced; the one running is open, the ones finished are shut, and the person
3
5
  // watching moves between them. The model and the renderer are pure, so what the
@@ -199,14 +201,53 @@ const RUNNING = 93;
199
201
  const DONE = 32;
200
202
  const FAILED = 91;
201
203
  const WARN = 33;
204
+ // A tag is a blue label with rounded ends: the ends in blue on the terminal's own
205
+ // ground, and the word white on blue between them
206
+ const TAG_EDGE = 34;
207
+ const TAG_BODY = "97;44";
208
+
209
+ type Colour = number | string;
202
210
 
203
- type Piece = { text: string; colour?: number };
211
+ type Piece = { text: string; colour?: Colour };
204
212
 
205
- function paint(text: string, colour: number | undefined, on: boolean) {
213
+ function paint(text: string, colour: Colour | undefined, on: boolean) {
206
214
  if (!on || colour === undefined || text === "") return text;
207
215
  return `\u001b[${colour}m${text}\u001b[0m`;
208
216
  }
209
217
 
218
+ // Text that may carry a tag, as pieces, so the row is measured before any escape
219
+ // is added. The rounded ends are a column each, the same as the brackets drawn
220
+ // in their place without colour, so a row is the same width either way
221
+ function tagged(text: string, colour: Colour | undefined, on: boolean): Piece[] {
222
+ if (!on) return [{ text: untagged(text), colour }];
223
+
224
+ const pieces: Piece[] = [];
225
+ let at = 0;
226
+
227
+ for (const match of text.matchAll(TAGS)) {
228
+ if (match.index > at) pieces.push({ text: text.slice(at, match.index), colour });
229
+
230
+ pieces.push(
231
+ { text: "\u25d6", colour: TAG_EDGE },
232
+ { text: match[1] ?? "", colour: TAG_BODY },
233
+ { text: "\u25d7", colour: TAG_EDGE },
234
+ );
235
+
236
+ at = match.index + match[0].length;
237
+ }
238
+
239
+ if (at < text.length) pieces.push({ text: text.slice(at), colour });
240
+ return pieces;
241
+ }
242
+
243
+ // For what is written as a whole line rather than drawn into a row: the summary
244
+ // left on the screen, and the plain log. The line's own colour resumes after
245
+ export function paintTags(text: string, on: boolean, colour?: Colour) {
246
+ return tagged(text, colour, on)
247
+ .map((piece) => paint(piece.text, piece.colour, on))
248
+ .join("");
249
+ }
250
+
210
251
  function widthOf(pieces: Piece[]) {
211
252
  return pieces.reduce((total, piece) => total + piece.text.length, 0);
212
253
  }
@@ -362,7 +403,7 @@ function said(model: Model, message: Message, colour: boolean) {
362
403
  [
363
404
  { text: gutter(model, message.at), colour: DIM },
364
405
  { text: " " },
365
- { text: message.text, colour: WARN },
406
+ ...tagged(message.text, WARN, colour),
366
407
  ],
367
408
  Math.max(24, model.columns),
368
409
  colour,
@@ -485,7 +526,7 @@ function title(model: Model, step: Step, index: number, colour: boolean) {
485
526
  { text: glyph, colour: stateColour(step) },
486
527
  { text: " " },
487
528
  { text: step.label, colour: labelColour(step, selected) },
488
- { text: said, colour: DIM },
529
+ ...tagged(said, DIM, colour),
489
530
  { text: quiet(model, step), colour: WARN },
490
531
  ];
491
532
 
package/src/cli/viewer.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  elapsed,
6
6
  emptyModel,
7
7
  keysOf,
8
+ paintTags,
8
9
  render,
9
10
  type Model,
10
11
  type Step,
@@ -113,7 +114,7 @@ export function createViewer(
113
114
  stream.write(ALTERNATE_OFF);
114
115
  // The alternate screen takes every frame with it, so the run has to be
115
116
  // written again on the screen the person keeps
116
- for (const line of summary(model)) stream.write(`${line}\n`);
117
+ for (const line of summary(model, colour)) stream.write(`${line}\n`);
117
118
  };
118
119
 
119
120
  // What a second press means is not the view's to decide: the same key
@@ -191,18 +192,18 @@ export function createViewer(
191
192
 
192
193
  // One line per step and whatever was said outside them, which is the record a
193
194
  // person scrolls back to after the deploy is over
194
- function summary(model: Model): string[] {
195
+ function summary(model: Model, colour: boolean): string[] {
195
196
  const rows = model.steps.map((step) => {
196
197
  const glyph = step.state === "done" ? "✔" : step.state === "failed" ? "✘" : "·";
197
198
  const said = step.note ? `: ${step.note}` : "";
198
199
  const took = elapsed(step.ended ?? model.now, step.started);
199
200
 
200
- return `${glyph} ${step.label}${said} (${took})`;
201
+ return paintTags(`${glyph} ${step.label}${said} (${took})`, colour);
201
202
  });
202
203
 
203
204
  return [
204
205
  ...rows,
205
- ...model.messages.map((message) => message.text),
206
+ ...model.messages.map((message) => paintTags(message.text, colour)),
206
207
  ...whatFailed(model),
207
208
  ];
208
209
  }