redkite 0.1.9 → 0.1.11
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.
- package/README.md +191 -23
- package/dist/build.d.ts +1 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +7 -1
- package/dist/build.js.map +1 -1
- package/dist/cli/agent.js +2 -2
- package/dist/cli/agent.js.map +1 -1
- package/dist/cli/config.d.ts +1 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +67 -14
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/dotenv.d.ts +2 -0
- package/dist/cli/dotenv.d.ts.map +1 -0
- package/dist/cli/dotenv.js +30 -0
- package/dist/cli/dotenv.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +16 -8
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/screen.d.ts +10 -3
- package/dist/cli/screen.d.ts.map +1 -1
- package/dist/cli/screen.js +153 -36
- package/dist/cli/screen.js.map +1 -1
- package/dist/cli/viewer.d.ts.map +1 -1
- package/dist/cli/viewer.js +36 -4
- package/dist/cli/viewer.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +67 -0
- package/dist/config.js.map +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +17 -6
- package/dist/deploy.js.map +1 -1
- package/dist/docker.d.ts +1 -0
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +3 -0
- package/dist/docker.js.map +1 -1
- package/dist/dockerfile.d.ts +1 -0
- package/dist/dockerfile.d.ts.map +1 -1
- package/dist/dockerfile.js +16 -3
- package/dist/dockerfile.js.map +1 -1
- package/dist/environment.d.ts +2 -0
- package/dist/environment.d.ts.map +1 -1
- package/dist/environment.js +56 -1
- package/dist/environment.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/log.d.ts +1 -0
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +1 -0
- package/dist/log.js.map +1 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +3 -0
- package/dist/pipeline.js.map +1 -1
- package/dist/plugins/bitwarden.d.ts.map +1 -1
- package/dist/plugins/bitwarden.js +25 -6
- package/dist/plugins/bitwarden.js.map +1 -1
- package/dist/presets/nextApp.d.ts.map +1 -1
- package/dist/presets/nextApp.js +5 -4
- package/dist/presets/nextApp.js.map +1 -1
- package/dist/presets/nodeApp.d.ts.map +1 -1
- package/dist/presets/nodeApp.js +15 -6
- package/dist/presets/nodeApp.js.map +1 -1
- package/dist/secrets/manager.d.ts +9 -0
- package/dist/secrets/manager.d.ts.map +1 -0
- package/dist/secrets/manager.js +130 -0
- package/dist/secrets/manager.js.map +1 -0
- package/dist/secrets/store.d.ts.map +1 -1
- package/dist/secrets/store.js +46 -12
- package/dist/secrets/store.js.map +1 -1
- package/dist/services/ensure.js +1 -1
- package/dist/services/ensure.js.map +1 -1
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +11 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/planned.d.ts +0 -1
- package/dist/services/planned.d.ts.map +1 -1
- package/dist/services/planned.js +3 -12
- package/dist/services/planned.js.map +1 -1
- package/dist/services/proxy.d.ts +7 -0
- package/dist/services/proxy.d.ts.map +1 -0
- package/dist/services/proxy.js +103 -0
- package/dist/services/proxy.js.map +1 -0
- package/dist/source.d.ts +1 -0
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +5 -5
- package/dist/source.js.map +1 -1
- package/dist/steps.d.ts +0 -6
- package/dist/steps.d.ts.map +1 -1
- package/dist/steps.js +4 -12
- package/dist/steps.js.map +1 -1
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +10 -1
- package/src/cli/agent.ts +2 -2
- package/src/cli/config.ts +77 -13
- package/src/cli/dotenv.ts +35 -0
- package/src/cli/index.ts +16 -8
- package/src/cli/screen.ts +193 -43
- package/src/cli/viewer.ts +41 -4
- package/src/config.ts +80 -0
- package/src/deploy.ts +19 -6
- package/src/docker.ts +5 -0
- package/src/dockerfile.ts +23 -3
- package/src/environment.ts +71 -1
- package/src/index.ts +2 -1
- package/src/log.ts +4 -0
- package/src/pipeline.ts +4 -0
- package/src/plugins/bitwarden.ts +38 -11
- package/src/presets/nextApp.ts +5 -4
- package/src/presets/nodeApp.ts +15 -6
- package/src/secrets/manager.ts +172 -0
- package/src/secrets/store.ts +51 -16
- package/src/services/ensure.ts +1 -1
- package/src/services/index.ts +13 -1
- package/src/services/planned.ts +3 -13
- package/src/services/proxy.ts +126 -0
- package/src/source.ts +13 -4
- package/src/steps.ts +4 -19
- package/src/types.ts +29 -4
- package/dist/nginx.d.ts +0 -4
- package/dist/nginx.d.ts.map +0 -1
- package/dist/nginx.js +0 -53
- package/dist/nginx.js.map +0 -1
- package/src/nginx.ts +0 -63
package/src/cli/screen.ts
CHANGED
|
@@ -14,6 +14,9 @@ export type Step = {
|
|
|
14
14
|
note?: string;
|
|
15
15
|
detail?: string;
|
|
16
16
|
lines: string[];
|
|
17
|
+
// When it last said anything. A step that is working and one that is wedged
|
|
18
|
+
// look identical without it, which is the whole reason for the spinner
|
|
19
|
+
spoke?: number;
|
|
17
20
|
expanded: boolean;
|
|
18
21
|
// Opened by hand, so finishing does not shut it under the reader
|
|
19
22
|
held: boolean;
|
|
@@ -21,14 +24,24 @@ export type Step = {
|
|
|
21
24
|
offset: number;
|
|
22
25
|
};
|
|
23
26
|
|
|
27
|
+
// Said outside a step, carrying the moment it was said so its row stops
|
|
28
|
+
// ticking the way a finished step's does
|
|
29
|
+
export type Message = { text: string; at: number };
|
|
30
|
+
|
|
24
31
|
export type Model = {
|
|
25
32
|
steps: Step[];
|
|
26
|
-
messages:
|
|
33
|
+
messages: Message[];
|
|
27
34
|
cursor: number;
|
|
28
35
|
// The cursor follows the newest step until the reader moves it
|
|
29
36
|
following: boolean;
|
|
30
37
|
// Nothing opens on its own, including the step that is running
|
|
31
38
|
minimal: boolean;
|
|
39
|
+
// Long lines wrap rather than being cut. Off by default: one row per line is
|
|
40
|
+
// what keeps a frame countable, and this is for reading an error
|
|
41
|
+
wrapped: boolean;
|
|
42
|
+
// Every point the run will walk, known before it starts. What has not begun
|
|
43
|
+
// is drawn under what has, so the end is visible from the start
|
|
44
|
+
planned: string[];
|
|
32
45
|
rows: number;
|
|
33
46
|
columns: number;
|
|
34
47
|
started: number;
|
|
@@ -43,6 +56,7 @@ export type Key =
|
|
|
43
56
|
| "toggle"
|
|
44
57
|
| "expand"
|
|
45
58
|
| "collapse"
|
|
59
|
+
| "wrap"
|
|
46
60
|
| "quit";
|
|
47
61
|
|
|
48
62
|
export function emptyModel(rows: number, columns: number, now: number): Model {
|
|
@@ -52,6 +66,8 @@ export function emptyModel(rows: number, columns: number, now: number): Model {
|
|
|
52
66
|
cursor: 0,
|
|
53
67
|
following: true,
|
|
54
68
|
minimal: false,
|
|
69
|
+
wrapped: false,
|
|
70
|
+
planned: [],
|
|
55
71
|
rows,
|
|
56
72
|
columns,
|
|
57
73
|
started: now,
|
|
@@ -91,6 +107,7 @@ export function keyOf(data: string): Key | undefined {
|
|
|
91
107
|
if (data === "\r" || data === "\n") return "toggle";
|
|
92
108
|
if (data === "+" || data === "=") return "expand";
|
|
93
109
|
if (data === "-" || data === "_") return "collapse";
|
|
110
|
+
if (data === "w") return "wrap";
|
|
94
111
|
if (data === "q" || data === INTERRUPT) return "quit";
|
|
95
112
|
|
|
96
113
|
return undefined;
|
|
@@ -106,6 +123,7 @@ export function apply(model: Model, key: Key): Model {
|
|
|
106
123
|
}
|
|
107
124
|
|
|
108
125
|
if (key === "first") return { ...model, cursor: 0, following: false };
|
|
126
|
+
if (key === "wrap") return { ...model, wrapped: !model.wrapped };
|
|
109
127
|
|
|
110
128
|
if (key === "up") {
|
|
111
129
|
if (step?.expanded && step.offset < Math.max(0, step.lines.length - 1)) {
|
|
@@ -223,50 +241,132 @@ const FLOOR = 3;
|
|
|
223
241
|
// the view closes, and a list that only grows crowds every log off the screen
|
|
224
242
|
const MESSAGES = 6;
|
|
225
243
|
|
|
244
|
+
// Every key, inside eighty columns. One too wide is a row that wraps, and a
|
|
245
|
+
// wrapped footer costs the frame a line it counted on
|
|
226
246
|
export const HELP =
|
|
227
|
-
"\u2191\u2193 move \u00b7 enter open \u00b7 shift+\u2191 latest \u00b7
|
|
247
|
+
"\u2191\u2193 move \u00b7 enter open \u00b7 shift+\u2191 latest \u00b7 +/- all \u00b7 w wrap \u00b7 q quit";
|
|
248
|
+
|
|
249
|
+
// Turns on the frame the clock is in, so the view is a pure function of now
|
|
250
|
+
const SPINNER = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f";
|
|
251
|
+
const SPIN_MS = 80;
|
|
252
|
+
|
|
253
|
+
// How long a running step may say nothing before the frame says so. Short
|
|
254
|
+
// enough to catch a wedge, long enough that an ordinary pause is not one
|
|
255
|
+
const QUIET_MS = 8000;
|
|
228
256
|
|
|
229
257
|
export function render(model: Model, colour = false): string[] {
|
|
230
258
|
const body = Math.max(1, model.rows - FOOTER);
|
|
231
259
|
const rows: string[] = [];
|
|
232
260
|
const messages = model.messages.slice(-MESSAGES);
|
|
261
|
+
const budget = Math.max(1, body - messages.length);
|
|
262
|
+
|
|
263
|
+
// What the step being read keeps whatever else wants the room. A frame of
|
|
264
|
+
// titles with no output says nothing the last line of output would not, so
|
|
265
|
+
// the list is what gives way rather than the log
|
|
266
|
+
const focused = model.steps[model.cursor];
|
|
267
|
+
const floor = focused?.expanded ? Math.min(FLOOR, rowsOf(model, focused)) : 0;
|
|
268
|
+
|
|
269
|
+
// More than the terminal has room for, so the list itself scrolls and only
|
|
270
|
+
// the step under the cursor keeps a log
|
|
271
|
+
if (model.steps.length + floor > budget) {
|
|
272
|
+
const room = Math.max(1, budget - floor);
|
|
273
|
+
const start = Math.max(0, Math.min(model.cursor - room + 1, model.steps.length - room));
|
|
274
|
+
|
|
275
|
+
for (const entry of ordered(model, messages)) {
|
|
276
|
+
if (rows.length >= budget) break;
|
|
277
|
+
|
|
278
|
+
if (entry.kind === "message") {
|
|
279
|
+
rows.push(said(model, entry.message, colour));
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
233
282
|
|
|
234
|
-
|
|
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));
|
|
283
|
+
if (entry.index < start) continue;
|
|
238
284
|
|
|
239
|
-
|
|
240
|
-
if (index
|
|
241
|
-
rows.push(title(model, step, index, colour));
|
|
242
|
-
}
|
|
285
|
+
rows.push(title(model, entry.step, entry.index, colour));
|
|
286
|
+
if (entry.index === model.cursor) rows.push(...logs(model, entry.step, floor, colour));
|
|
243
287
|
}
|
|
244
288
|
|
|
245
|
-
return finish(rows, body, colour);
|
|
289
|
+
return finish(model, rows, body, colour);
|
|
246
290
|
}
|
|
247
291
|
|
|
248
|
-
const shown = share(model,
|
|
292
|
+
const shown = share(model, budget - model.steps.length);
|
|
249
293
|
|
|
250
|
-
for (const
|
|
251
|
-
|
|
252
|
-
|
|
294
|
+
for (const entry of ordered(model, messages)) {
|
|
295
|
+
if (entry.kind === "message") {
|
|
296
|
+
rows.push(said(model, entry.message, colour));
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
rows.push(title(model, entry.step, entry.index, colour));
|
|
301
|
+
rows.push(...logs(model, entry.step, shown.get(entry.index) ?? 0, colour));
|
|
253
302
|
}
|
|
254
303
|
|
|
255
|
-
for (const
|
|
256
|
-
rows.
|
|
257
|
-
|
|
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
|
-
);
|
|
304
|
+
for (const point of remaining(model)) {
|
|
305
|
+
if (rows.length >= budget) break;
|
|
306
|
+
rows.push(waiting(model, point, colour));
|
|
267
307
|
}
|
|
268
308
|
|
|
269
|
-
return finish(rows, body, colour);
|
|
309
|
+
return finish(model, rows, body, colour);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Every point the run will walk that has not started. Drawn under what has, so
|
|
313
|
+
// how much is left is visible from the first frame rather than at the end
|
|
314
|
+
function remaining(model: Model) {
|
|
315
|
+
const started = new Set(model.steps.map((step) => step.label));
|
|
316
|
+
return model.planned.filter((point) => !started.has(point));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function waiting(model: Model, point: string, colour: boolean) {
|
|
320
|
+
const head: Piece[] = [
|
|
321
|
+
{ text: " ".repeat(gutter(model).length), colour: DIM },
|
|
322
|
+
{ text: " " },
|
|
323
|
+
{ text: "\u00b7", colour: DIM },
|
|
324
|
+
{ text: " " },
|
|
325
|
+
{ text: point, colour: DIM },
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
return fit(model, head, "", colour);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
type Row =
|
|
332
|
+
| { kind: "step"; at: number; step: Step; index: number }
|
|
333
|
+
| { kind: "message"; at: number; message: Message };
|
|
334
|
+
|
|
335
|
+
// Everything the run has said, in the order it said it. Messages used to be
|
|
336
|
+
// printed after every step, which put one from the first ten seconds below a
|
|
337
|
+
// build still running half an hour later
|
|
338
|
+
function ordered(model: Model, messages: Message[]): Row[] {
|
|
339
|
+
const steps: Row[] = model.steps.map((step, index) => ({
|
|
340
|
+
kind: "step",
|
|
341
|
+
at: step.started,
|
|
342
|
+
step,
|
|
343
|
+
index,
|
|
344
|
+
}));
|
|
345
|
+
|
|
346
|
+
const said: Row[] = messages.map((message) => ({
|
|
347
|
+
kind: "message",
|
|
348
|
+
at: message.at,
|
|
349
|
+
message,
|
|
350
|
+
}));
|
|
351
|
+
|
|
352
|
+
// A step announced in the same millisecond as a message leads it: the
|
|
353
|
+
// message is usually about what the step then went and did. Everything else
|
|
354
|
+
// ties to nothing, and a stable sort leaves it where it was
|
|
355
|
+
const rank = (row: Row) => (row.kind === "step" ? 0 : 1);
|
|
356
|
+
|
|
357
|
+
return [...steps, ...said].sort((a, b) => a.at - b.at || rank(a) - rank(b));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function said(model: Model, message: Message, colour: boolean) {
|
|
361
|
+
return clipped(
|
|
362
|
+
[
|
|
363
|
+
{ text: gutter(model, message.at), colour: DIM },
|
|
364
|
+
{ text: " " },
|
|
365
|
+
{ text: message.text, colour: WARN },
|
|
366
|
+
],
|
|
367
|
+
Math.max(24, model.columns),
|
|
368
|
+
colour,
|
|
369
|
+
);
|
|
270
370
|
}
|
|
271
371
|
|
|
272
372
|
// The focused step is the one being read, so it is served first and every other
|
|
@@ -287,19 +387,19 @@ function share(model: Model, budget: number) {
|
|
|
287
387
|
// printed nothing reserves nothing, and served last the focused step used
|
|
288
388
|
// to be left with whatever the others happened not to want
|
|
289
389
|
const glances = others.reduce(
|
|
290
|
-
(total, item) => total + Math.min(GLANCE, item.step
|
|
390
|
+
(total, item) => total + Math.min(GLANCE, rowsOf(model, item.step)),
|
|
291
391
|
0,
|
|
292
392
|
);
|
|
293
393
|
|
|
294
394
|
const reserve = Math.min(glances, Math.max(0, left - FLOOR));
|
|
295
|
-
const take = Math.min(left - reserve, focused.step
|
|
395
|
+
const take = Math.min(left - reserve, rowsOf(model, focused.step));
|
|
296
396
|
|
|
297
397
|
shown.set(focused.index, take);
|
|
298
398
|
left -= take;
|
|
299
399
|
}
|
|
300
400
|
|
|
301
401
|
for (const item of others) {
|
|
302
|
-
const take = Math.min(GLANCE, item.step
|
|
402
|
+
const take = Math.min(GLANCE, rowsOf(model, item.step), Math.max(0, left));
|
|
303
403
|
|
|
304
404
|
shown.set(item.index, take);
|
|
305
405
|
left -= take;
|
|
@@ -308,6 +408,15 @@ function share(model: Model, budget: number) {
|
|
|
308
408
|
return shown;
|
|
309
409
|
}
|
|
310
410
|
|
|
411
|
+
// How many rows a step's log would fill. One per line until they are wrapped,
|
|
412
|
+
// and then as many as each line needs
|
|
413
|
+
function rowsOf(model: Model, step: Step) {
|
|
414
|
+
if (!model.wrapped) return step.lines.length;
|
|
415
|
+
|
|
416
|
+
const room = Math.max(8, model.columns - RULE.length);
|
|
417
|
+
return step.lines.reduce((total, line) => total + wrapped(line, room).length, 0);
|
|
418
|
+
}
|
|
419
|
+
|
|
311
420
|
const RULE = " \u2502 ";
|
|
312
421
|
|
|
313
422
|
// The newest lines, less whatever the reader has scrolled back past. One line
|
|
@@ -317,10 +426,26 @@ function logs(model: Model, step: Step, take: number, colour: boolean) {
|
|
|
317
426
|
|
|
318
427
|
const end = Math.max(1, step.lines.length - step.offset);
|
|
319
428
|
const room = Math.max(8, model.columns - RULE.length);
|
|
429
|
+
const lines = step.lines.slice(Math.max(0, end - take), end);
|
|
430
|
+
|
|
431
|
+
// Wrapped, one line is several rows, so what is taken is counted in rows
|
|
432
|
+
// after the wrapping rather than in lines before it
|
|
433
|
+
const rows = lines.flatMap((line) =>
|
|
434
|
+
model.wrapped ? wrapped(line, room) : [clip(line, room)],
|
|
435
|
+
);
|
|
320
436
|
|
|
321
|
-
return
|
|
322
|
-
|
|
323
|
-
|
|
437
|
+
return rows.slice(-take).map((row) => `${paint(RULE, DIM, colour)}${row}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Cut on width alone. A build's output is not prose, and breaking a path or a
|
|
441
|
+
// stack frame on a space would put half of it where nothing can find it
|
|
442
|
+
function wrapped(line: string, room: number) {
|
|
443
|
+
if (line.length <= room) return [line];
|
|
444
|
+
|
|
445
|
+
const rows: string[] = [];
|
|
446
|
+
for (let at = 0; at < line.length; at += room) rows.push(line.slice(at, at + room));
|
|
447
|
+
|
|
448
|
+
return rows;
|
|
324
449
|
}
|
|
325
450
|
|
|
326
451
|
// The ellipsis is the whole point: a line that was cut has to say so
|
|
@@ -329,36 +454,60 @@ export function clip(text: string, room: number) {
|
|
|
329
454
|
return `${text.slice(0, room - 1)}\u2026`;
|
|
330
455
|
}
|
|
331
456
|
|
|
332
|
-
|
|
457
|
+
// Padded above rather than below, so the newest row sits against the footer
|
|
458
|
+
// and the frame fills upwards the way a terminal's own output does. The footer
|
|
459
|
+
// is cut like any other row: one too wide wraps, and a wrapped row costs two
|
|
460
|
+
function finish(model: Model, rows: string[], body: number, colour: boolean) {
|
|
333
461
|
const filled = rows.slice(0, body);
|
|
334
|
-
|
|
462
|
+
const blanks = Array.from({ length: Math.max(0, body - filled.length) }, () => "");
|
|
463
|
+
const help = clip(HELP, Math.max(24, model.columns));
|
|
335
464
|
|
|
336
|
-
return [...filled, "", paint(
|
|
465
|
+
return [...blanks, ...filled, "", paint(help, DIM, colour)];
|
|
337
466
|
}
|
|
338
467
|
|
|
339
468
|
function title(model: Model, step: Step, index: number, colour: boolean) {
|
|
340
469
|
const selected = index === model.cursor;
|
|
341
|
-
const
|
|
470
|
+
const running = step.state === "running";
|
|
342
471
|
const arrow = step.expanded ? "\u25be" : "\u25b8";
|
|
343
472
|
const glyph = step.state === "done" ? "\u2714" : step.state === "failed" ? "\u2718" : arrow;
|
|
344
473
|
|
|
345
474
|
const said = step.note ? `: ${step.note}` : step.detail ? ` ${step.detail}` : "";
|
|
346
475
|
|
|
347
476
|
const head: Piece[] = [
|
|
348
|
-
|
|
477
|
+
// The run clock as this row stood: still moving while the step is, and
|
|
478
|
+
// stopped at the moment it finished
|
|
479
|
+
{ text: gutter(model, step.ended), colour: DIM },
|
|
349
480
|
{ text: " " },
|
|
350
|
-
|
|
481
|
+
// Turning while the step is working, blank when it is not. A step that has
|
|
482
|
+
// stopped saying anything still turns, which is what quiet then says
|
|
483
|
+
{ text: running ? spinner(model.now) : " ", colour: RUNNING },
|
|
351
484
|
{ text: " " },
|
|
352
485
|
{ text: glyph, colour: stateColour(step) },
|
|
353
486
|
{ text: " " },
|
|
354
487
|
{ text: step.label, colour: labelColour(step, selected) },
|
|
355
488
|
{ text: said, colour: DIM },
|
|
489
|
+
{ text: quiet(model, step), colour: WARN },
|
|
356
490
|
];
|
|
357
491
|
|
|
358
492
|
// Frozen at what it cost the moment it finished, still counting until then
|
|
359
493
|
return fit(model, head, elapsed(step.ended ?? model.now, step.started), colour);
|
|
360
494
|
}
|
|
361
495
|
|
|
496
|
+
function spinner(now: number) {
|
|
497
|
+
return SPINNER[Math.floor(now / SPIN_MS) % SPINNER.length] ?? " ";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// How long a running step has said nothing. The difference between a build
|
|
501
|
+
// that is working and one that is wedged, which nothing else on the row shows
|
|
502
|
+
function quiet(model: Model, step: Step) {
|
|
503
|
+
if (step.state !== "running") return "";
|
|
504
|
+
|
|
505
|
+
const since = model.now - (step.spoke ?? step.started);
|
|
506
|
+
if (since < QUIET_MS) return "";
|
|
507
|
+
|
|
508
|
+
return ` quiet ${elapsed(model.now, step.spoke ?? step.started)}`;
|
|
509
|
+
}
|
|
510
|
+
|
|
362
511
|
function stateColour(step: Step) {
|
|
363
512
|
if (step.state === "done") return DONE;
|
|
364
513
|
if (step.state === "failed") return FAILED;
|
|
@@ -375,9 +524,10 @@ function labelColour(step: Step, selected: boolean) {
|
|
|
375
524
|
return undefined;
|
|
376
525
|
}
|
|
377
526
|
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
527
|
+
// How far into the run this row belongs. Given a moment it stops there, which
|
|
528
|
+
// is what keeps a finished row from ticking along with the one still running
|
|
529
|
+
function gutter(model: Model, at?: number) {
|
|
530
|
+
return elapsed(at ?? model.now, model.started, true);
|
|
381
531
|
}
|
|
382
532
|
|
|
383
533
|
export function elapsed(now: number, started: number, clock = false) {
|
package/src/cli/viewer.ts
CHANGED
|
@@ -79,8 +79,13 @@ export function createViewer(
|
|
|
79
79
|
input.resume();
|
|
80
80
|
input.setEncoding("utf8");
|
|
81
81
|
|
|
82
|
+
const plan = (points: string[]) => change((current) => ({ ...current, planned: points }));
|
|
83
|
+
|
|
82
84
|
const say = (message: string) =>
|
|
83
|
-
change((current) => ({
|
|
85
|
+
change((current) => ({
|
|
86
|
+
...current,
|
|
87
|
+
messages: [...current.messages, { text: message, at: Date.now() }],
|
|
88
|
+
}));
|
|
84
89
|
|
|
85
90
|
const onKey = (data: string) => {
|
|
86
91
|
for (const key of keysOf(data)) {
|
|
@@ -159,16 +164,23 @@ export function createViewer(
|
|
|
159
164
|
expanded: item.held,
|
|
160
165
|
}));
|
|
161
166
|
|
|
167
|
+
// Both mark the step as having spoken, which is what tells a working step
|
|
168
|
+
// from a wedged one on the row itself
|
|
162
169
|
return {
|
|
163
|
-
detail: (message) => edit((item) => ({ ...item, detail: message })),
|
|
170
|
+
detail: (message) => edit((item) => ({ ...item, detail: message, spoke: Date.now() })),
|
|
164
171
|
line: (message) =>
|
|
165
|
-
edit((item) => ({
|
|
172
|
+
edit((item) => ({
|
|
173
|
+
...item,
|
|
174
|
+
spoke: Date.now(),
|
|
175
|
+
lines: [...item.lines, message].slice(-KEPT),
|
|
176
|
+
})),
|
|
166
177
|
done: (message) => settle("done", message),
|
|
167
178
|
fail: (message) => settle("failed", message),
|
|
168
179
|
};
|
|
169
180
|
};
|
|
170
181
|
|
|
171
182
|
return Object.assign(say, {
|
|
183
|
+
plan,
|
|
172
184
|
warn: say,
|
|
173
185
|
fail: say,
|
|
174
186
|
done: say,
|
|
@@ -188,5 +200,30 @@ function summary(model: Model): string[] {
|
|
|
188
200
|
return `${glyph} ${step.label}${said} (${took})`;
|
|
189
201
|
});
|
|
190
202
|
|
|
191
|
-
return [
|
|
203
|
+
return [
|
|
204
|
+
...rows,
|
|
205
|
+
...model.messages.map((message) => message.text),
|
|
206
|
+
...whatFailed(model),
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The end of what the step that failed was saying. The reason a build stopped
|
|
211
|
+
// is in its own output, and the alternate screen takes every frame with it
|
|
212
|
+
const TAIL = 20;
|
|
213
|
+
|
|
214
|
+
function whatFailed(model: Model) {
|
|
215
|
+
const rows: string[] = [];
|
|
216
|
+
|
|
217
|
+
for (const step of model.steps.filter((item) => item.state === "failed")) {
|
|
218
|
+
if (step.lines.length === 0) continue;
|
|
219
|
+
|
|
220
|
+
const kept = step.lines.slice(-TAIL);
|
|
221
|
+
const dropped = step.lines.length - kept.length;
|
|
222
|
+
|
|
223
|
+
rows.push("", `${step.label} said:`);
|
|
224
|
+
if (dropped > 0) rows.push(` ... ${dropped} earlier lines`);
|
|
225
|
+
rows.push(...kept.map((line) => ` ${line}`));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return rows;
|
|
192
229
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { PROXY } from "./services/proxy.js";
|
|
1
2
|
import { assertSteps } from "./pipeline.js";
|
|
2
3
|
import { pluginSteps } from "./plugin.js";
|
|
4
|
+
import { listRefs } from "./secrets/refs.js";
|
|
5
|
+
import type { AnyStep } from "./pipeline.js";
|
|
3
6
|
import type { Deployment, Environment } from "./types.js";
|
|
4
7
|
|
|
5
8
|
// The one selected, from the one place worth looking. A deployment that carries
|
|
@@ -9,6 +12,72 @@ export function environmentOf(config: Deployment, name: string) {
|
|
|
9
12
|
return config.environment ?? config.environments?.[name];
|
|
10
13
|
}
|
|
11
14
|
|
|
15
|
+
// The selected environment's items folded into the apps they name, after each
|
|
16
|
+
// app's own. Refs merge in order and the later key wins, so the deployment holds
|
|
17
|
+
// what every environment shares and the environment holds what differs
|
|
18
|
+
export function withEnvironment(config: Deployment, name: string): Deployment {
|
|
19
|
+
const environment = environmentOf(config, name);
|
|
20
|
+
if (!environment?.secrets && !environment?.files && !environment?.steps) return config;
|
|
21
|
+
|
|
22
|
+
const { secrets = {}, files = {}, steps, ...rest } = environment;
|
|
23
|
+
assertNamesApps(config, name, [...Object.keys(secrets), ...Object.keys(files)]);
|
|
24
|
+
|
|
25
|
+
const apps = config.apps.map((app) => {
|
|
26
|
+
const refs = secrets[app.name];
|
|
27
|
+
const paths = files[app.name];
|
|
28
|
+
if (!refs && !paths) return app;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...app,
|
|
32
|
+
secrets: refs ? [...listRefs(app.secrets), ...listRefs(refs)] : app.secrets,
|
|
33
|
+
files: paths ? { ...app.files, ...paths } : app.files,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const folded = { ...config, apps, steps: steps ? stepsWith(config, steps) : config.steps };
|
|
38
|
+
|
|
39
|
+
// What was folded in leaves the environment, so folding it again adds nothing
|
|
40
|
+
// and a caller that already did it can hand the result on to one that will
|
|
41
|
+
if (config.environment) return { ...folded, environment: rest };
|
|
42
|
+
return { ...folded, environments: { ...config.environments, [name]: rest } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A step at a point the deployment already fills replaces it for this
|
|
46
|
+
// environment, where it stood. One only the environment has leads, the way a
|
|
47
|
+
// plugin's does, so a snapshot added here still runs above the shared migration
|
|
48
|
+
function stepsWith(config: Deployment, given: AnyStep[]) {
|
|
49
|
+
// Before the replacement map, which would otherwise keep the last of two and
|
|
50
|
+
// drop the other without a word
|
|
51
|
+
assertSteps(given);
|
|
52
|
+
|
|
53
|
+
const shared = config.steps ?? [];
|
|
54
|
+
const claimed = new Set(shared.map((step) => step.point));
|
|
55
|
+
const replacing = new Map(given.map((step) => [step.point, step]));
|
|
56
|
+
|
|
57
|
+
const steps = [
|
|
58
|
+
...given.filter((step) => !claimed.has(step.point)),
|
|
59
|
+
...shared.map((step) => replacing.get(step.point) ?? step),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// A plugin fills points too, and one the environment lands on is the same
|
|
63
|
+
// collision defineDeployment refuses
|
|
64
|
+
assertSteps([...pluginSteps(config.plugins), ...steps]);
|
|
65
|
+
return steps;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// An environment file does not import the deployment, so an app name there
|
|
69
|
+
// cannot be checked when it compiles. A typo would otherwise read nothing
|
|
70
|
+
function assertNamesApps(config: Deployment, environment: string, names: string[]) {
|
|
71
|
+
const apps = new Set(config.apps.map((app) => app.name));
|
|
72
|
+
const unknown = names.filter((name) => !apps.has(name));
|
|
73
|
+
if (unknown.length === 0) return;
|
|
74
|
+
|
|
75
|
+
throw new Error(
|
|
76
|
+
`${environment} gives secrets to ${unknown.join(", ")}, which this deployment ` +
|
|
77
|
+
`has no app by that name for. Its apps are ${[...apps].join(", ")}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
12
81
|
// Identity, but it pins the type so a missing health predicate fails to
|
|
13
82
|
// compile. Environments are excluded rather than merely unused: each lives in
|
|
14
83
|
// a redkite.<name>.config.ts of its own, and a second place to put one is a
|
|
@@ -30,6 +99,7 @@ export function defineDeployment<const T extends Deployment & { environments?: n
|
|
|
30
99
|
// Identity, for the environment a redkite.<name>.config.ts holds. It pins the
|
|
31
100
|
// type the same way defineDeployment does, so a missing subnet fails to compile
|
|
32
101
|
export function defineEnvironment<const T extends Environment>(environment: T): T {
|
|
102
|
+
if (environment.steps) assertSteps(environment.steps);
|
|
33
103
|
return environment;
|
|
34
104
|
}
|
|
35
105
|
|
|
@@ -100,6 +170,16 @@ function assertUniqueNames(config: Deployment) {
|
|
|
100
170
|
const names = [...config.apps, ...config.services].map((item) => item.name);
|
|
101
171
|
const duplicate = names.find((name, i) => names.indexOf(name) !== i);
|
|
102
172
|
if (duplicate) throw new Error(`Duplicate name in deployment: ${duplicate}`);
|
|
173
|
+
|
|
174
|
+
// The proxy is derived rather than listed, and it already has this name. A
|
|
175
|
+
// service claiming it is a second container on the first one's name, which
|
|
176
|
+
// nothing downstream can tell apart
|
|
177
|
+
if (!names.includes(PROXY)) return;
|
|
178
|
+
|
|
179
|
+
throw new Error(
|
|
180
|
+
`${PROXY} is the derived proxy, so a service cannot be called that. ` +
|
|
181
|
+
"What it runs and what goes in its server block is proxy: nginx({ … })",
|
|
182
|
+
);
|
|
103
183
|
}
|
|
104
184
|
|
|
105
185
|
// Two apps on the same route means one of them is unreachable, and which one
|
package/src/deploy.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { build, type BuildContext, type BuildResult } from "./build.js";
|
|
2
2
|
import { assertCheckable, runChecks } from "./checks.js";
|
|
3
|
-
import { environmentOf } from "./config.js";
|
|
3
|
+
import { environmentOf, withEnvironment } from "./config.js";
|
|
4
4
|
import { Docker } from "./docker.js";
|
|
5
5
|
import { envFileFor } from "./environment.js";
|
|
6
6
|
import { healthcheck, type HealthDeps } from "./health.js";
|
|
@@ -68,11 +68,12 @@ export async function verify(options: DeployOptions): Promise<Finished> {
|
|
|
68
68
|
|
|
69
69
|
async function start(run: Run, options: DeployOptions): Promise<Finished> {
|
|
70
70
|
const log = options.log ?? silent;
|
|
71
|
+
const config = withEnvironment(options.config, options.environment);
|
|
71
72
|
|
|
72
73
|
const setting: Omit<Context, "task"> = {
|
|
73
|
-
config
|
|
74
|
+
config,
|
|
74
75
|
environment: options.environment,
|
|
75
|
-
topology: topologyFor(
|
|
76
|
+
topology: topologyFor(config, options.environment),
|
|
76
77
|
host: options.host,
|
|
77
78
|
docker: new Docker(options.host),
|
|
78
79
|
secrets: options.secrets,
|
|
@@ -82,7 +83,7 @@ async function start(run: Run, options: DeployOptions): Promise<Finished> {
|
|
|
82
83
|
|
|
83
84
|
// A plugin's steps lead, so a snapshot listed as a plugin runs above the
|
|
84
85
|
// migration the deployment writes after it
|
|
85
|
-
const added = [...pluginSteps(
|
|
86
|
+
const added = [...pluginSteps(config.plugins), ...(config.steps ?? [])];
|
|
86
87
|
|
|
87
88
|
return await runPipeline(run, merge(supplied(options), added), setting, options.signal);
|
|
88
89
|
}
|
|
@@ -349,14 +350,26 @@ async function buildAll(
|
|
|
349
350
|
const task = log.step(`Building ${app.name}`);
|
|
350
351
|
|
|
351
352
|
try {
|
|
353
|
+
// Said before the reads rather than after them. Every ref is a call to
|
|
354
|
+
// the vault, and a step that has not spoken yet looks like one that is
|
|
355
|
+
// not doing anything
|
|
356
|
+
task.detail("reading its environment");
|
|
357
|
+
const env = await readEnv(app.secrets, context.secrets);
|
|
358
|
+
|
|
359
|
+
task.detail("reading the files it ships with");
|
|
360
|
+
const files = await resolveFiles(app, context.secrets);
|
|
361
|
+
|
|
352
362
|
const buildContext: BuildContext = {
|
|
353
363
|
host: here ?? context.host,
|
|
354
364
|
docker: here ? new Docker(here) : context.docker,
|
|
355
365
|
deliver: here && { host: context.host, docker: context.docker },
|
|
356
|
-
env
|
|
357
|
-
files
|
|
366
|
+
env,
|
|
367
|
+
files,
|
|
358
368
|
branch: environment.branch,
|
|
359
369
|
environment: context.environment,
|
|
370
|
+
// Forwarded to the deploy host by the connection, so what this
|
|
371
|
+
// process holds is what the build there can reach
|
|
372
|
+
agent: Boolean(process.env["SSH_AUTH_SOCK"]),
|
|
360
373
|
detail: task.detail,
|
|
361
374
|
output: task.line,
|
|
362
375
|
};
|
package/src/docker.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type BuildInvocation = {
|
|
|
15
15
|
tags: string[];
|
|
16
16
|
// Secret id to a path on the host holding its contents
|
|
17
17
|
secrets?: Record<string, string>;
|
|
18
|
+
// Forwards the agent this shell holds into the build
|
|
19
|
+
ssh?: boolean;
|
|
18
20
|
// Stops at the builder stage, for the image a step before the swap runs in
|
|
19
21
|
target?: string;
|
|
20
22
|
};
|
|
@@ -174,6 +176,9 @@ class DockerImage {
|
|
|
174
176
|
...Object.entries(spec.secrets ?? {}).map(
|
|
175
177
|
([id, path]) => `--secret id=${id},src=${path}`,
|
|
176
178
|
),
|
|
179
|
+
// Forwards this shell's agent into the build, which is how a dependency
|
|
180
|
+
// fetched over ssh is authenticated
|
|
181
|
+
...(spec.ssh ? ["--ssh default"] : []),
|
|
177
182
|
...(spec.target ? [`--target ${spec.target}`] : []),
|
|
178
183
|
spec.context,
|
|
179
184
|
].join(" ");
|