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.
Files changed (128) hide show
  1. package/README.md +191 -23
  2. package/dist/build.d.ts +1 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +7 -1
  5. package/dist/build.js.map +1 -1
  6. package/dist/cli/agent.js +2 -2
  7. package/dist/cli/agent.js.map +1 -1
  8. package/dist/cli/config.d.ts +1 -0
  9. package/dist/cli/config.d.ts.map +1 -1
  10. package/dist/cli/config.js +67 -14
  11. package/dist/cli/config.js.map +1 -1
  12. package/dist/cli/dotenv.d.ts +2 -0
  13. package/dist/cli/dotenv.d.ts.map +1 -0
  14. package/dist/cli/dotenv.js +30 -0
  15. package/dist/cli/dotenv.js.map +1 -0
  16. package/dist/cli/index.d.ts.map +1 -1
  17. package/dist/cli/index.js +16 -8
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/cli/screen.d.ts +10 -3
  20. package/dist/cli/screen.d.ts.map +1 -1
  21. package/dist/cli/screen.js +153 -36
  22. package/dist/cli/screen.js.map +1 -1
  23. package/dist/cli/viewer.d.ts.map +1 -1
  24. package/dist/cli/viewer.js +36 -4
  25. package/dist/cli/viewer.js.map +1 -1
  26. package/dist/config.d.ts +1 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +67 -0
  29. package/dist/config.js.map +1 -1
  30. package/dist/deploy.d.ts.map +1 -1
  31. package/dist/deploy.js +17 -6
  32. package/dist/deploy.js.map +1 -1
  33. package/dist/docker.d.ts +1 -0
  34. package/dist/docker.d.ts.map +1 -1
  35. package/dist/docker.js +3 -0
  36. package/dist/docker.js.map +1 -1
  37. package/dist/dockerfile.d.ts +1 -0
  38. package/dist/dockerfile.d.ts.map +1 -1
  39. package/dist/dockerfile.js +16 -3
  40. package/dist/dockerfile.js.map +1 -1
  41. package/dist/environment.d.ts +2 -0
  42. package/dist/environment.d.ts.map +1 -1
  43. package/dist/environment.js +56 -1
  44. package/dist/environment.js.map +1 -1
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/log.d.ts +1 -0
  50. package/dist/log.d.ts.map +1 -1
  51. package/dist/log.js +1 -0
  52. package/dist/log.js.map +1 -1
  53. package/dist/pipeline.d.ts.map +1 -1
  54. package/dist/pipeline.js +3 -0
  55. package/dist/pipeline.js.map +1 -1
  56. package/dist/plugins/bitwarden.d.ts.map +1 -1
  57. package/dist/plugins/bitwarden.js +25 -6
  58. package/dist/plugins/bitwarden.js.map +1 -1
  59. package/dist/presets/nextApp.d.ts.map +1 -1
  60. package/dist/presets/nextApp.js +5 -4
  61. package/dist/presets/nextApp.js.map +1 -1
  62. package/dist/presets/nodeApp.d.ts.map +1 -1
  63. package/dist/presets/nodeApp.js +15 -6
  64. package/dist/presets/nodeApp.js.map +1 -1
  65. package/dist/secrets/manager.d.ts +9 -0
  66. package/dist/secrets/manager.d.ts.map +1 -0
  67. package/dist/secrets/manager.js +130 -0
  68. package/dist/secrets/manager.js.map +1 -0
  69. package/dist/secrets/store.d.ts.map +1 -1
  70. package/dist/secrets/store.js +46 -12
  71. package/dist/secrets/store.js.map +1 -1
  72. package/dist/services/ensure.js +1 -1
  73. package/dist/services/ensure.js.map +1 -1
  74. package/dist/services/index.d.ts +2 -1
  75. package/dist/services/index.d.ts.map +1 -1
  76. package/dist/services/index.js +11 -0
  77. package/dist/services/index.js.map +1 -1
  78. package/dist/services/planned.d.ts +0 -1
  79. package/dist/services/planned.d.ts.map +1 -1
  80. package/dist/services/planned.js +3 -12
  81. package/dist/services/planned.js.map +1 -1
  82. package/dist/services/proxy.d.ts +7 -0
  83. package/dist/services/proxy.d.ts.map +1 -0
  84. package/dist/services/proxy.js +103 -0
  85. package/dist/services/proxy.js.map +1 -0
  86. package/dist/source.d.ts +1 -0
  87. package/dist/source.d.ts.map +1 -1
  88. package/dist/source.js +5 -5
  89. package/dist/source.js.map +1 -1
  90. package/dist/steps.d.ts +0 -6
  91. package/dist/steps.d.ts.map +1 -1
  92. package/dist/steps.js +4 -12
  93. package/dist/steps.js.map +1 -1
  94. package/dist/types.d.ts +10 -2
  95. package/dist/types.d.ts.map +1 -1
  96. package/package.json +1 -1
  97. package/src/build.ts +10 -1
  98. package/src/cli/agent.ts +2 -2
  99. package/src/cli/config.ts +77 -13
  100. package/src/cli/dotenv.ts +35 -0
  101. package/src/cli/index.ts +16 -8
  102. package/src/cli/screen.ts +193 -43
  103. package/src/cli/viewer.ts +41 -4
  104. package/src/config.ts +80 -0
  105. package/src/deploy.ts +19 -6
  106. package/src/docker.ts +5 -0
  107. package/src/dockerfile.ts +23 -3
  108. package/src/environment.ts +71 -1
  109. package/src/index.ts +2 -1
  110. package/src/log.ts +4 -0
  111. package/src/pipeline.ts +4 -0
  112. package/src/plugins/bitwarden.ts +38 -11
  113. package/src/presets/nextApp.ts +5 -4
  114. package/src/presets/nodeApp.ts +15 -6
  115. package/src/secrets/manager.ts +172 -0
  116. package/src/secrets/store.ts +51 -16
  117. package/src/services/ensure.ts +1 -1
  118. package/src/services/index.ts +13 -1
  119. package/src/services/planned.ts +3 -13
  120. package/src/services/proxy.ts +126 -0
  121. package/src/source.ts +13 -4
  122. package/src/steps.ts +4 -19
  123. package/src/types.ts +29 -4
  124. package/dist/nginx.d.ts +0 -4
  125. package/dist/nginx.d.ts.map +0 -1
  126. package/dist/nginx.js +0 -53
  127. package/dist/nginx.js.map +0 -1
  128. 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: string[];
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 + open \u00b7 - collapse all \u00b7 q quit";
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
- // 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));
283
+ if (entry.index < start) continue;
238
284
 
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
- }
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, body - model.steps.length - messages.length);
292
+ const shown = share(model, budget - model.steps.length);
249
293
 
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));
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 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
- );
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.lines.length),
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.lines.length);
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.lines.length, Math.max(0, left));
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 step.lines
322
- .slice(Math.max(0, end - take), end)
323
- .map((line) => `${paint(RULE, DIM, colour)}${clip(line, room)}`);
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
- function finish(rows: string[], body: number, colour: boolean) {
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
- while (filled.length < body) filled.push("");
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(HELP, DIM, colour)];
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 caret = selected ? "\u276f" : " ";
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
- { text: gutter(model), colour: DIM },
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
- { text: caret, colour: selected ? CURSOR : undefined },
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
- // 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)}]`;
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) => ({ ...current, messages: [...current.messages, message] }));
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) => ({ ...item, lines: [...item.lines, message].slice(-KEPT) })),
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 [...rows, ...model.messages];
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: options.config,
74
+ config,
74
75
  environment: options.environment,
75
- topology: topologyFor(options.config, options.environment),
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(options.config.plugins), ...(options.config.steps ?? [])];
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: await readEnv(app.secrets, context.secrets),
357
- files: await resolveFiles(app, context.secrets),
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(" ");