redkite 0.1.6

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 (179) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +819 -0
  3. package/bin/redkite.mjs +12 -0
  4. package/dist/build.d.ts +27 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +132 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/checks.d.ts +4 -0
  9. package/dist/checks.d.ts.map +1 -0
  10. package/dist/checks.js +49 -0
  11. package/dist/checks.js.map +1 -0
  12. package/dist/cli/agent.d.ts +2 -0
  13. package/dist/cli/agent.d.ts.map +1 -0
  14. package/dist/cli/agent.js +24 -0
  15. package/dist/cli/agent.js.map +1 -0
  16. package/dist/cli/config.d.ts +5 -0
  17. package/dist/cli/config.d.ts.map +1 -0
  18. package/dist/cli/config.js +217 -0
  19. package/dist/cli/config.js.map +1 -0
  20. package/dist/cli/index.d.ts +11 -0
  21. package/dist/cli/index.d.ts.map +1 -0
  22. package/dist/cli/index.js +438 -0
  23. package/dist/cli/index.js.map +1 -0
  24. package/dist/cli/log.d.ts +9 -0
  25. package/dist/cli/log.d.ts.map +1 -0
  26. package/dist/cli/log.js +128 -0
  27. package/dist/cli/log.js.map +1 -0
  28. package/dist/cli/screen.d.ts +34 -0
  29. package/dist/cli/screen.d.ts.map +1 -0
  30. package/dist/cli/screen.js +298 -0
  31. package/dist/cli/screen.js.map +1 -0
  32. package/dist/cli/viewer.d.ts +9 -0
  33. package/dist/cli/viewer.d.ts.map +1 -0
  34. package/dist/cli/viewer.js +139 -0
  35. package/dist/cli/viewer.js.map +1 -0
  36. package/dist/config.d.ts +7 -0
  37. package/dist/config.d.ts.map +1 -0
  38. package/dist/config.js +79 -0
  39. package/dist/config.js.map +1 -0
  40. package/dist/deploy.d.ts +19 -0
  41. package/dist/deploy.d.ts.map +1 -0
  42. package/dist/deploy.js +275 -0
  43. package/dist/deploy.js.map +1 -0
  44. package/dist/docker.d.ts +96 -0
  45. package/dist/docker.d.ts.map +1 -0
  46. package/dist/docker.js +325 -0
  47. package/dist/docker.js.map +1 -0
  48. package/dist/dockerfile.d.ts +14 -0
  49. package/dist/dockerfile.d.ts.map +1 -0
  50. package/dist/dockerfile.js +160 -0
  51. package/dist/dockerfile.js.map +1 -0
  52. package/dist/health.d.ts +13 -0
  53. package/dist/health.d.ts.map +1 -0
  54. package/dist/health.js +54 -0
  55. package/dist/health.js.map +1 -0
  56. package/dist/host.d.ts +21 -0
  57. package/dist/host.d.ts.map +1 -0
  58. package/dist/host.js +27 -0
  59. package/dist/host.js.map +1 -0
  60. package/dist/index.d.ts +26 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +26 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/layout.d.ts +5 -0
  65. package/dist/layout.d.ts.map +1 -0
  66. package/dist/layout.js +45 -0
  67. package/dist/layout.js.map +1 -0
  68. package/dist/localHost.d.ts +7 -0
  69. package/dist/localHost.d.ts.map +1 -0
  70. package/dist/localHost.js +32 -0
  71. package/dist/localHost.js.map +1 -0
  72. package/dist/log.d.ts +14 -0
  73. package/dist/log.d.ts.map +1 -0
  74. package/dist/log.js +15 -0
  75. package/dist/log.js.map +1 -0
  76. package/dist/nginx.d.ts +4 -0
  77. package/dist/nginx.d.ts.map +1 -0
  78. package/dist/nginx.js +53 -0
  79. package/dist/nginx.js.map +1 -0
  80. package/dist/pipeline.d.ts +103 -0
  81. package/dist/pipeline.d.ts.map +1 -0
  82. package/dist/pipeline.js +142 -0
  83. package/dist/pipeline.js.map +1 -0
  84. package/dist/presets/index.d.ts +3 -0
  85. package/dist/presets/index.d.ts.map +1 -0
  86. package/dist/presets/index.js +3 -0
  87. package/dist/presets/index.js.map +1 -0
  88. package/dist/presets/nextApp.d.ts +15 -0
  89. package/dist/presets/nextApp.d.ts.map +1 -0
  90. package/dist/presets/nextApp.js +53 -0
  91. package/dist/presets/nextApp.js.map +1 -0
  92. package/dist/presets/nodeApp.d.ts +19 -0
  93. package/dist/presets/nodeApp.d.ts.map +1 -0
  94. package/dist/presets/nodeApp.js +29 -0
  95. package/dist/presets/nodeApp.js.map +1 -0
  96. package/dist/secrets/bitwarden.d.ts +3 -0
  97. package/dist/secrets/bitwarden.d.ts.map +1 -0
  98. package/dist/secrets/bitwarden.js +6 -0
  99. package/dist/secrets/bitwarden.js.map +1 -0
  100. package/dist/secrets/refs.d.ts +9 -0
  101. package/dist/secrets/refs.d.ts.map +1 -0
  102. package/dist/secrets/refs.js +20 -0
  103. package/dist/secrets/refs.js.map +1 -0
  104. package/dist/secrets/store.d.ts +9 -0
  105. package/dist/secrets/store.d.ts.map +1 -0
  106. package/dist/secrets/store.js +104 -0
  107. package/dist/secrets/store.js.map +1 -0
  108. package/dist/services/ensure.d.ts +18 -0
  109. package/dist/services/ensure.d.ts.map +1 -0
  110. package/dist/services/ensure.js +93 -0
  111. package/dist/services/ensure.js.map +1 -0
  112. package/dist/services/index.d.ts +19 -0
  113. package/dist/services/index.d.ts.map +1 -0
  114. package/dist/services/index.js +27 -0
  115. package/dist/services/index.js.map +1 -0
  116. package/dist/services/planned.d.ts +20 -0
  117. package/dist/services/planned.d.ts.map +1 -0
  118. package/dist/services/planned.js +77 -0
  119. package/dist/services/planned.js.map +1 -0
  120. package/dist/shell.d.ts +11 -0
  121. package/dist/shell.d.ts.map +1 -0
  122. package/dist/shell.js +65 -0
  123. package/dist/shell.js.map +1 -0
  124. package/dist/source.d.ts +16 -0
  125. package/dist/source.d.ts.map +1 -0
  126. package/dist/source.js +108 -0
  127. package/dist/source.js.map +1 -0
  128. package/dist/sshHost.d.ts +15 -0
  129. package/dist/sshHost.d.ts.map +1 -0
  130. package/dist/sshHost.js +91 -0
  131. package/dist/sshHost.js.map +1 -0
  132. package/dist/steps.d.ts +27 -0
  133. package/dist/steps.d.ts.map +1 -0
  134. package/dist/steps.js +69 -0
  135. package/dist/steps.js.map +1 -0
  136. package/dist/topology.d.ts +40 -0
  137. package/dist/topology.d.ts.map +1 -0
  138. package/dist/topology.js +113 -0
  139. package/dist/topology.js.map +1 -0
  140. package/dist/types.d.ts +100 -0
  141. package/dist/types.d.ts.map +1 -0
  142. package/dist/types.js +2 -0
  143. package/dist/types.js.map +1 -0
  144. package/package.json +64 -0
  145. package/src/build.ts +236 -0
  146. package/src/checks.ts +65 -0
  147. package/src/cli/agent.ts +28 -0
  148. package/src/cli/config.ts +275 -0
  149. package/src/cli/index.ts +559 -0
  150. package/src/cli/log.ts +168 -0
  151. package/src/cli/screen.ts +409 -0
  152. package/src/cli/viewer.ts +192 -0
  153. package/src/config.ts +90 -0
  154. package/src/deploy.ts +409 -0
  155. package/src/docker.ts +433 -0
  156. package/src/dockerfile.ts +228 -0
  157. package/src/health.ts +83 -0
  158. package/src/host.ts +54 -0
  159. package/src/index.ts +25 -0
  160. package/src/layout.ts +41 -0
  161. package/src/localHost.ts +50 -0
  162. package/src/log.ts +38 -0
  163. package/src/nginx.ts +63 -0
  164. package/src/pipeline.ts +335 -0
  165. package/src/presets/index.ts +2 -0
  166. package/src/presets/nextApp.ts +70 -0
  167. package/src/presets/nodeApp.ts +46 -0
  168. package/src/secrets/bitwarden.ts +7 -0
  169. package/src/secrets/refs.ts +32 -0
  170. package/src/secrets/store.ts +147 -0
  171. package/src/services/ensure.ts +160 -0
  172. package/src/services/index.ts +48 -0
  173. package/src/services/planned.ts +119 -0
  174. package/src/shell.ts +95 -0
  175. package/src/source.ts +171 -0
  176. package/src/sshHost.ts +145 -0
  177. package/src/steps.ts +101 -0
  178. package/src/topology.ts +199 -0
  179. package/src/types.ts +243 -0
package/src/cli/log.ts ADDED
@@ -0,0 +1,168 @@
1
+ import type { Task } from "../log.js";
2
+
3
+ import { elapsed as took } from "./screen.js";
4
+ import { createViewer, type Viewer } from "./viewer.js";
5
+
6
+ // What a deploy prints. The engine's own trace is a build graph with span ids
7
+ // and manifest resolution in it, which answers a question nobody deploying has.
8
+
9
+ const STAMP = 90;
10
+ const WARN = 33;
11
+ const FAIL = 31;
12
+ const DONE = 32;
13
+
14
+ const started = Date.now();
15
+
16
+ export type LogOptions = {
17
+ verbose?: boolean;
18
+ full?: boolean;
19
+ onQuit?: () => void;
20
+ };
21
+
22
+ // A terminal gets the step viewer. Anything else, a pipe, a CI log or --full,
23
+ // gets one line per event, because there is nothing there to redraw
24
+ export function createLog(options: LogOptions = {}): Viewer {
25
+ if (viewable(process.stdout, process.stdin, options)) {
26
+ return createViewer(process.stdout, process.stdin, { onQuit: options.onQuit });
27
+ }
28
+
29
+ // --full is the reason to print a step's own output without asking for the
30
+ // host commands beside it, which is what --verbose adds
31
+ return Object.assign(plainLog(options.verbose === true || options.full === true), {
32
+ close: () => {},
33
+ });
34
+ }
35
+
36
+ function viewable(
37
+ stream: NodeJS.WriteStream,
38
+ input: NodeJS.ReadStream,
39
+ options: LogOptions,
40
+ ) {
41
+ if (options.full || process.env["REDKITE_PLAIN"]) return false;
42
+ return stream.isTTY === true && input.isTTY === true;
43
+ }
44
+
45
+ function plainLog(lines: boolean) {
46
+ const live = { write: (line: string) => process.stdout.write(line) };
47
+
48
+ const write = (stream: NodeJS.WriteStream, message: string, colour?: number) => {
49
+ const painted = colours(stream);
50
+ const stamp = painted ? paint(elapsed(), STAMP) : elapsed();
51
+ const body = painted && colour ? paint(message, colour) : message;
52
+ const line = `${stamp} ${body}\n`;
53
+
54
+ // Both streams share one terminal, so the block has to come down for a
55
+ // failure on stderr just as it does for progress on stdout
56
+ if (stream === process.stdout) return live.write(line);
57
+
58
+ live.write("");
59
+ stream.write(line);
60
+ };
61
+
62
+ const info = (message: string) => write(process.stdout, message);
63
+
64
+ const step = (label: string): Task => {
65
+ write(process.stdout, label);
66
+ const started = Date.now();
67
+
68
+ let current: { text: string; at: number } | undefined;
69
+
70
+ // A sub-step leaves the block when the next one starts, and lands here with
71
+ // what it cost. The live view stays short without the run losing its record
72
+ const settle = () => {
73
+ if (!current) return;
74
+
75
+ write(process.stdout, ` ${label}: ${current.text} (${since(current.at)})`);
76
+ current = undefined;
77
+ };
78
+
79
+ const trace = (message: string) => {
80
+ settle();
81
+ current = { text: message, at: Date.now() };
82
+ write(process.stdout, ` ${label}: ${message}`);
83
+ };
84
+
85
+ return {
86
+ detail: trace,
87
+ // In full, never clipped: this is the view for reading what a build said
88
+ line: (message: string) => {
89
+ if (lines) write(process.stdout, ` ${label} | ${message}`);
90
+ },
91
+ done: (message?: string) => {
92
+ settle();
93
+ const suffix = message ? `: ${message}` : "";
94
+ write(process.stdout, `${label}${suffix} (${since(started)})`, DONE);
95
+ },
96
+ fail: (message: string) => {
97
+ settle();
98
+ write(process.stderr, `${message} (${since(started)})`, FAIL);
99
+ },
100
+ };
101
+ };
102
+
103
+ return Object.assign(info, {
104
+ warn: (message: string) => write(process.stdout, message, WARN),
105
+ // Failures go to stderr, so a piped log still carries the progress alone
106
+ fail: (message: string) => write(process.stderr, message, FAIL),
107
+ done: (message: string) => write(process.stdout, message, DONE),
108
+ step,
109
+ });
110
+ }
111
+
112
+ function paint(text: string, colour: number) {
113
+ return `\u001b[${colour}m${text}\u001b[0m`;
114
+ }
115
+
116
+ // A file or a pipe takes the text without the escapes, and NO_COLOR is a
117
+ // standing instruction rather than a preference to re-ask about
118
+ function colours(stream: NodeJS.WriteStream) {
119
+ if (process.env["NO_COLOR"]) return false;
120
+ return stream.isTTY === true;
121
+ }
122
+
123
+ function elapsed() {
124
+ return `[${took(Date.now(), started, true)}]`;
125
+ }
126
+
127
+ function since(at: number) {
128
+ return took(Date.now(), at);
129
+ }
130
+
131
+ const TAIL = 20;
132
+
133
+ // A failure carries the tail of whatever the command wrote, and a build writes
134
+ // a lot. The chain matters because a step wraps the command it ran
135
+ export function describeFailure(error: unknown) {
136
+ if (!(error instanceof Error)) return String(error);
137
+
138
+ // A wrapper that already quotes what it wrapped must not say it twice
139
+ const chain = causes(error).map((link) => link.message);
140
+ const said = chain.filter(
141
+ (message, index) => !chain.slice(0, index).some((earlier) => earlier.includes(message)),
142
+ );
143
+
144
+ return tail(said.join("\n"));
145
+ }
146
+
147
+ const DEPTH = 8;
148
+
149
+ function causes(error: Error) {
150
+ const chain: Error[] = [];
151
+ let current: unknown = error;
152
+
153
+ while (current instanceof Error && chain.length < DEPTH) {
154
+ chain.push(current);
155
+ current = current.cause;
156
+ }
157
+
158
+ return chain;
159
+ }
160
+
161
+ function tail(body: string) {
162
+ const lines = body.split("\n");
163
+ if (lines.length <= TAIL) return body;
164
+
165
+ return [`... ${lines.length - TAIL} earlier lines`, ...lines.slice(-TAIL)].join(
166
+ "\n",
167
+ );
168
+ }
@@ -0,0 +1,409 @@
1
+ // The deploy as a list of collapsibles. A step is a title and the lines it
2
+ // produced; the one running is open, the ones finished are shut, and the person
3
+ // watching moves between them. The model and the renderer are pure, so what the
4
+ // terminal shows is asserted on rather than driven.
5
+
6
+ export type StepState = "running" | "done" | "failed";
7
+
8
+ export type Step = {
9
+ label: string;
10
+ started: number;
11
+ ended?: number;
12
+ state: StepState;
13
+ // What the step said as it finished, and what it is doing right now
14
+ note?: string;
15
+ detail?: string;
16
+ lines: string[];
17
+ expanded: boolean;
18
+ // Opened by hand, so finishing does not shut it under the reader
19
+ held: boolean;
20
+ // Lines between the bottom of the log and the bottom of the window
21
+ offset: number;
22
+ };
23
+
24
+ export type Model = {
25
+ steps: Step[];
26
+ messages: string[];
27
+ cursor: number;
28
+ // The cursor follows the newest step until the reader moves it
29
+ following: boolean;
30
+ // Nothing opens on its own, including the step that is running
31
+ minimal: boolean;
32
+ rows: number;
33
+ columns: number;
34
+ started: number;
35
+ now: number;
36
+ };
37
+
38
+ export type Key =
39
+ | "up"
40
+ | "down"
41
+ | "latest"
42
+ | "first"
43
+ | "toggle"
44
+ | "expand"
45
+ | "collapse"
46
+ | "quit";
47
+
48
+ export function emptyModel(rows: number, columns: number, now: number): Model {
49
+ return {
50
+ steps: [],
51
+ messages: [],
52
+ cursor: 0,
53
+ following: true,
54
+ minimal: false,
55
+ rows,
56
+ columns,
57
+ started: now,
58
+ now,
59
+ };
60
+ }
61
+
62
+ const ESCAPE = "\u001b";
63
+ const INTERRUPT = "\u0003";
64
+
65
+ // A terminal delivers whatever arrived since the last read, so two arrow keys
66
+ // pressed quickly land as one chunk. Reading only the whole chunk drops both
67
+ export function keysOf(data: string): Key[] {
68
+ const keys: Key[] = [];
69
+ let rest = data;
70
+
71
+ while (rest.length > 0) {
72
+ const sequence = rest.startsWith(ESCAPE) ? CSI.exec(rest)?.[0] : undefined;
73
+ const token = sequence ?? rest.slice(0, 1);
74
+
75
+ const key = keyOf(token);
76
+ if (key) keys.push(key);
77
+
78
+ rest = rest.slice(token.length);
79
+ }
80
+
81
+ return keys;
82
+ }
83
+
84
+ const CSI = /^\u001b\[[0-9;]*[A-Za-z]/;
85
+
86
+ export function keyOf(data: string): Key | undefined {
87
+ if (data === `${ESCAPE}[A`) return "up";
88
+ if (data === `${ESCAPE}[B`) return "down";
89
+ if (data === `${ESCAPE}[1;2A`) return "latest";
90
+ if (data === `${ESCAPE}[1;2B`) return "first";
91
+ if (data === "\r" || data === "\n") return "toggle";
92
+ if (data === "+" || data === "=") return "expand";
93
+ if (data === "-" || data === "_") return "collapse";
94
+ if (data === "q" || data === INTERRUPT) return "quit";
95
+
96
+ return undefined;
97
+ }
98
+
99
+ // Moving up runs out of log before it runs out of steps: the window climbs to
100
+ // the top of what this step printed, and only then does the cursor leave it
101
+ export function apply(model: Model, key: Key): Model {
102
+ const step = model.steps[model.cursor];
103
+
104
+ if (key === "latest") {
105
+ return { ...model, cursor: Math.max(0, model.steps.length - 1), following: true };
106
+ }
107
+
108
+ if (key === "first") return { ...model, cursor: 0, following: false };
109
+
110
+ if (key === "up") {
111
+ if (step?.expanded && step.offset < Math.max(0, step.lines.length - 1)) {
112
+ return scrolled(model, step.offset + 1);
113
+ }
114
+
115
+ return { ...model, cursor: Math.max(0, model.cursor - 1), following: false };
116
+ }
117
+
118
+ if (key === "down") {
119
+ if (step?.expanded && step.offset > 0) return scrolled(model, step.offset - 1);
120
+
121
+ const last = model.steps.length - 1;
122
+ const cursor = Math.max(0, Math.min(last, model.cursor + 1));
123
+
124
+ return { ...model, cursor, following: cursor === last };
125
+ }
126
+
127
+ if (key === "collapse") {
128
+ return {
129
+ ...model,
130
+ minimal: true,
131
+ steps: model.steps.map((item) => ({ ...item, expanded: false, held: false })),
132
+ };
133
+ }
134
+
135
+ // Anywhere, so a reader who collapsed everything does not have to find the
136
+ // running step before opening it again
137
+ if (key === "expand") {
138
+ const current = model.steps.length - 1;
139
+
140
+ return {
141
+ ...model,
142
+ minimal: false,
143
+ steps: model.steps.map((item, index) =>
144
+ index === current ? { ...item, expanded: true, held: true, offset: 0 } : item,
145
+ ),
146
+ };
147
+ }
148
+
149
+ if (key === "toggle" && step) {
150
+ const opening = !step.expanded;
151
+
152
+ return {
153
+ ...model,
154
+ // Opening the running step is the same instruction as pressing plus
155
+ minimal: opening && model.cursor === model.steps.length - 1 ? false : model.minimal,
156
+ steps: model.steps.map((item, index) =>
157
+ index === model.cursor
158
+ ? { ...item, expanded: opening, held: opening, offset: 0 }
159
+ : item,
160
+ ),
161
+ };
162
+ }
163
+
164
+ return model;
165
+ }
166
+
167
+ function scrolled(model: Model, offset: number): Model {
168
+ return {
169
+ ...model,
170
+ steps: model.steps.map((item, index) =>
171
+ index === model.cursor ? { ...item, offset } : item,
172
+ ),
173
+ };
174
+ }
175
+
176
+ // Colours are put on after every width is measured, because an escape is zero
177
+ // columns wide and a row measured with one in it is a row that wraps
178
+ const DIM = 90;
179
+ const CURSOR = 96;
180
+ const RUNNING = 93;
181
+ const DONE = 32;
182
+ const FAILED = 91;
183
+ const WARN = 33;
184
+
185
+ type Piece = { text: string; colour?: number };
186
+
187
+ function paint(text: string, colour: number | undefined, on: boolean) {
188
+ if (!on || colour === undefined || text === "") return text;
189
+ return `\u001b[${colour}m${text}\u001b[0m`;
190
+ }
191
+
192
+ function widthOf(pieces: Piece[]) {
193
+ return pieces.reduce((total, piece) => total + piece.text.length, 0);
194
+ }
195
+
196
+ // Clips the row as one string would clip, then hands each surviving piece its
197
+ // colour. The ellipsis belongs to whichever piece was cut
198
+ function clipped(pieces: Piece[], room: number, on: boolean) {
199
+ if (widthOf(pieces) <= room) {
200
+ return pieces.map((piece) => paint(piece.text, piece.colour, on)).join("");
201
+ }
202
+
203
+ const kept: string[] = [];
204
+ let left = room - 1;
205
+
206
+ for (const piece of pieces) {
207
+ if (left <= 0) break;
208
+
209
+ const text = piece.text.slice(0, left);
210
+ kept.push(paint(text, piece.colour, on));
211
+ left -= text.length;
212
+ }
213
+
214
+ return `${kept.join("")}\u2026`;
215
+ }
216
+
217
+ const FOOTER = 2;
218
+ // A step that is open but not the one being read shows this much of itself
219
+ const GLANCE = 6;
220
+ // What the focused step keeps even when everything else wants the room
221
+ const FLOOR = 3;
222
+ // How many of them the frame carries. Every message is written out again when
223
+ // the view closes, and a list that only grows crowds every log off the screen
224
+ const MESSAGES = 6;
225
+
226
+ export const HELP =
227
+ "\u2191\u2193 move \u00b7 enter open \u00b7 shift+\u2191 latest \u00b7 + open \u00b7 - collapse all \u00b7 q quit";
228
+
229
+ export function render(model: Model, colour = false): string[] {
230
+ const body = Math.max(1, model.rows - FOOTER);
231
+ const rows: string[] = [];
232
+ const messages = model.messages.slice(-MESSAGES);
233
+
234
+ // More titles than the terminal has lines, so nothing can be open and the
235
+ // list itself is what scrolls
236
+ if (model.steps.length + messages.length > body) {
237
+ const start = Math.max(0, Math.min(model.cursor, model.steps.length - body));
238
+
239
+ for (const [index, step] of model.steps.entries()) {
240
+ if (index >= start && rows.length < body) {
241
+ rows.push(title(model, step, index, colour));
242
+ }
243
+ }
244
+
245
+ return finish(rows, body, colour);
246
+ }
247
+
248
+ const shown = share(model, body - model.steps.length - messages.length);
249
+
250
+ for (const [index, step] of model.steps.entries()) {
251
+ rows.push(title(model, step, index, colour));
252
+ rows.push(...logs(model, step, shown.get(index) ?? 0, colour));
253
+ }
254
+
255
+ for (const message of messages) {
256
+ rows.push(
257
+ clipped(
258
+ [
259
+ { text: gutter(model), colour: DIM },
260
+ { text: " " },
261
+ { text: message, colour: WARN },
262
+ ],
263
+ Math.max(24, model.columns),
264
+ colour,
265
+ ),
266
+ );
267
+ }
268
+
269
+ return finish(rows, body, colour);
270
+ }
271
+
272
+ // The focused step is the one being read, so it is served first and every other
273
+ // open one gets a glance out of what it did not need
274
+ function share(model: Model, budget: number) {
275
+ const shown = new Map<number, number>();
276
+ const open = model.steps
277
+ .map((step, index) => ({ step, index }))
278
+ .filter((item) => item.step.expanded);
279
+
280
+ const focused = open.find((item) => item.index === model.cursor);
281
+ const others = open.filter((item) => item.index !== model.cursor);
282
+
283
+ let left = Math.max(0, budget);
284
+
285
+ if (focused) {
286
+ // What the glances would actually cost, not what they could: a step that
287
+ // printed nothing reserves nothing, and served last the focused step used
288
+ // to be left with whatever the others happened not to want
289
+ const glances = others.reduce(
290
+ (total, item) => total + Math.min(GLANCE, item.step.lines.length),
291
+ 0,
292
+ );
293
+
294
+ const reserve = Math.min(glances, Math.max(0, left - FLOOR));
295
+ const take = Math.min(left - reserve, focused.step.lines.length);
296
+
297
+ shown.set(focused.index, take);
298
+ left -= take;
299
+ }
300
+
301
+ for (const item of others) {
302
+ const take = Math.min(GLANCE, item.step.lines.length, Math.max(0, left));
303
+
304
+ shown.set(item.index, take);
305
+ left -= take;
306
+ }
307
+
308
+ return shown;
309
+ }
310
+
311
+ const RULE = " \u2502 ";
312
+
313
+ // The newest lines, less whatever the reader has scrolled back past. One line
314
+ // each: a wrapped line would push the rows below it off a frame sized in rows
315
+ function logs(model: Model, step: Step, take: number, colour: boolean) {
316
+ if (take <= 0) return [];
317
+
318
+ const end = Math.max(1, step.lines.length - step.offset);
319
+ const room = Math.max(8, model.columns - RULE.length);
320
+
321
+ return step.lines
322
+ .slice(Math.max(0, end - take), end)
323
+ .map((line) => `${paint(RULE, DIM, colour)}${clip(line, room)}`);
324
+ }
325
+
326
+ // The ellipsis is the whole point: a line that was cut has to say so
327
+ export function clip(text: string, room: number) {
328
+ if (text.length <= room) return text;
329
+ return `${text.slice(0, room - 1)}\u2026`;
330
+ }
331
+
332
+ function finish(rows: string[], body: number, colour: boolean) {
333
+ const filled = rows.slice(0, body);
334
+ while (filled.length < body) filled.push("");
335
+
336
+ return [...filled, "", paint(HELP, DIM, colour)];
337
+ }
338
+
339
+ function title(model: Model, step: Step, index: number, colour: boolean) {
340
+ const selected = index === model.cursor;
341
+ const caret = selected ? "\u276f" : " ";
342
+ const arrow = step.expanded ? "\u25be" : "\u25b8";
343
+ const glyph = step.state === "done" ? "\u2714" : step.state === "failed" ? "\u2718" : arrow;
344
+
345
+ const said = step.note ? `: ${step.note}` : step.detail ? ` ${step.detail}` : "";
346
+
347
+ const head: Piece[] = [
348
+ { text: gutter(model), colour: DIM },
349
+ { text: " " },
350
+ { text: caret, colour: selected ? CURSOR : undefined },
351
+ { text: " " },
352
+ { text: glyph, colour: stateColour(step) },
353
+ { text: " " },
354
+ { text: step.label, colour: labelColour(step, selected) },
355
+ { text: said, colour: DIM },
356
+ ];
357
+
358
+ // Frozen at what it cost the moment it finished, still counting until then
359
+ return fit(model, head, elapsed(step.ended ?? model.now, step.started), colour);
360
+ }
361
+
362
+ function stateColour(step: Step) {
363
+ if (step.state === "done") return DONE;
364
+ if (step.state === "failed") return FAILED;
365
+ return RUNNING;
366
+ }
367
+
368
+ // A failure outranks everything: it is the row the reader is looking for. Then
369
+ // where the cursor is, then what is still running
370
+ function labelColour(step: Step, selected: boolean) {
371
+ if (step.state === "failed") return FAILED;
372
+ if (selected) return CURSOR;
373
+ if (step.state === "running") return RUNNING;
374
+
375
+ return undefined;
376
+ }
377
+
378
+ // The stamp is the whole run, and it keeps counting for every row a step adds
379
+ function gutter(model: Model) {
380
+ return `[${elapsed(model.now, model.started, true)}]`;
381
+ }
382
+
383
+ export function elapsed(now: number, started: number, clock = false) {
384
+ const seconds = Math.max(0, Math.floor((now - started) / 1000));
385
+ const minutes = Math.floor(seconds / 60);
386
+
387
+ if (clock) return `${pad(minutes)}:${pad(seconds % 60)}`;
388
+ if (seconds < 60) return `${seconds}s`;
389
+
390
+ return `${minutes}m${pad(seconds % 60)}s`;
391
+ }
392
+
393
+ function pad(value: number) {
394
+ return String(value).padStart(2, "0");
395
+ }
396
+
397
+ // The timer is pushed to the right edge, and the label is what gives way when
398
+ // there is not room for both. One space between them always, which is a row
399
+ // wider than the terminal when the label is allowed to fill the gap
400
+ function fit(model: Model, head: Piece[], timer: string, colour: boolean) {
401
+ const width = Math.max(24, model.columns);
402
+ const room = width - timer.length - 1;
403
+ const shown = Math.min(widthOf(head), room - 1);
404
+
405
+ return (
406
+ `${clipped(head, room - 1, colour)}` +
407
+ `${" ".repeat(Math.max(1, room - shown))} ${paint(timer, DIM, colour)}`
408
+ );
409
+ }