redkite 0.1.9 → 0.1.10
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 +131 -11
- 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 +11 -5
- 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.map +1 -1
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +12 -2
- 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 +1 -6
- package/dist/steps.d.ts.map +1 -1
- package/dist/steps.js +3 -3
- package/dist/steps.js.map +1 -1
- package/dist/types.d.ts +7 -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 +11 -5
- package/src/cli/screen.ts +193 -43
- package/src/cli/viewer.ts +41 -4
- package/src/config.ts +11 -0
- package/src/deploy.ts +14 -2
- 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 +7 -7
- package/src/types.ts +19 -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,3 +1,4 @@
|
|
|
1
|
+
import { PROXY } from "./services/proxy.js";
|
|
1
2
|
import { assertSteps } from "./pipeline.js";
|
|
2
3
|
import { pluginSteps } from "./plugin.js";
|
|
3
4
|
import type { Deployment, Environment } from "./types.js";
|
|
@@ -100,6 +101,16 @@ function assertUniqueNames(config: Deployment) {
|
|
|
100
101
|
const names = [...config.apps, ...config.services].map((item) => item.name);
|
|
101
102
|
const duplicate = names.find((name, i) => names.indexOf(name) !== i);
|
|
102
103
|
if (duplicate) throw new Error(`Duplicate name in deployment: ${duplicate}`);
|
|
104
|
+
|
|
105
|
+
// The proxy is derived rather than listed, and it already has this name. A
|
|
106
|
+
// service claiming it is a second container on the first one's name, which
|
|
107
|
+
// nothing downstream can tell apart
|
|
108
|
+
if (!names.includes(PROXY)) return;
|
|
109
|
+
|
|
110
|
+
throw new Error(
|
|
111
|
+
`${PROXY} is the derived proxy, so a service cannot be called that. ` +
|
|
112
|
+
"What it runs and what goes in its server block is proxy: nginx({ … })",
|
|
113
|
+
);
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
// Two apps on the same route means one of them is unreachable, and which one
|
package/src/deploy.ts
CHANGED
|
@@ -349,14 +349,26 @@ async function buildAll(
|
|
|
349
349
|
const task = log.step(`Building ${app.name}`);
|
|
350
350
|
|
|
351
351
|
try {
|
|
352
|
+
// Said before the reads rather than after them. Every ref is a call to
|
|
353
|
+
// the vault, and a step that has not spoken yet looks like one that is
|
|
354
|
+
// not doing anything
|
|
355
|
+
task.detail("reading its environment");
|
|
356
|
+
const env = await readEnv(app.secrets, context.secrets);
|
|
357
|
+
|
|
358
|
+
task.detail("reading the files it ships with");
|
|
359
|
+
const files = await resolveFiles(app, context.secrets);
|
|
360
|
+
|
|
352
361
|
const buildContext: BuildContext = {
|
|
353
362
|
host: here ?? context.host,
|
|
354
363
|
docker: here ? new Docker(here) : context.docker,
|
|
355
364
|
deliver: here && { host: context.host, docker: context.docker },
|
|
356
|
-
env
|
|
357
|
-
files
|
|
365
|
+
env,
|
|
366
|
+
files,
|
|
358
367
|
branch: environment.branch,
|
|
359
368
|
environment: context.environment,
|
|
369
|
+
// Forwarded to the deploy host by the connection, so what this
|
|
370
|
+
// process holds is what the build there can reach
|
|
371
|
+
agent: Boolean(process.env["SSH_AUTH_SOCK"]),
|
|
360
372
|
detail: task.detail,
|
|
361
373
|
output: task.line,
|
|
362
374
|
};
|
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(" ");
|
package/src/dockerfile.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
|
|
1
3
|
import { appRoot, destinationFor, mountFor, rootedAt } from "./layout.js";
|
|
2
4
|
import type { BuildSpec, CarryPath } from "./types.js";
|
|
3
5
|
|
|
@@ -20,6 +22,10 @@ export type DockerfileContext = {
|
|
|
20
22
|
envSecret: string;
|
|
21
23
|
// Container path to secret id, for credentials that have to be files
|
|
22
24
|
fileSecrets: Record<string, string>;
|
|
25
|
+
// Whether an agent is there to forward. A dependency fetched over ssh during
|
|
26
|
+
// the install needs one, and asking for a mount nothing is behind fails the
|
|
27
|
+
// build outright
|
|
28
|
+
agent?: boolean;
|
|
23
29
|
// Where the app sits in the repository, when it is not the repository. The
|
|
24
30
|
// build steps and the shipped command run there, and every /app path the
|
|
25
31
|
// spec names is read against it
|
|
@@ -73,7 +79,18 @@ function builderStage(spec: BuildSpec, context: DockerfileContext) {
|
|
|
73
79
|
lines.push(`ENV NODE_ENV=${context.environment}`);
|
|
74
80
|
lines.push("ENV NEXT_TELEMETRY_DISABLED=1");
|
|
75
81
|
|
|
76
|
-
|
|
82
|
+
// accept-new rather than no, the same as the checkout on the host. Without a
|
|
83
|
+
// known_hosts of its own a build has nothing to compare against, and a first
|
|
84
|
+
// sight it refuses is a dependency it cannot fetch
|
|
85
|
+
if (context.agent) {
|
|
86
|
+
lines.push(`ENV GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new"`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The agent reaches the install and the steps, which is where a manifest's
|
|
90
|
+
// git dependencies are resolved
|
|
91
|
+
const agent = context.agent ? "--mount=type=ssh " : "";
|
|
92
|
+
|
|
93
|
+
if (spec.dependencies) lines.push(...dependencyLayer(spec, `${agent}${mounts}`));
|
|
77
94
|
|
|
78
95
|
// Submodules are already in the context: the checkout on the host resolved
|
|
79
96
|
// them, so nothing in the build needs an agent or a .git directory
|
|
@@ -93,7 +110,7 @@ function builderStage(spec: BuildSpec, context: DockerfileContext) {
|
|
|
93
110
|
// written exactly as it would be typed. Quoting it made the whole command one
|
|
94
111
|
// word, and the shell went looking for a program by that name
|
|
95
112
|
for (const step of spec.steps) {
|
|
96
|
-
lines.push(`RUN ${env}${mounts}${step}`);
|
|
113
|
+
lines.push(`RUN ${env}${agent}${mounts}${step}`);
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
return lines;
|
|
@@ -134,9 +151,12 @@ function runtimeStage(spec: BuildSpec, context: DockerfileContext) {
|
|
|
134
151
|
lines.push(optional ? copyOrSkip(from, to, BUILDER_STAGE) : copy(from, to, BUILDER_STAGE));
|
|
135
152
|
}
|
|
136
153
|
|
|
154
|
+
// The directory first: cp does not make one, and a credential belongs
|
|
155
|
+
// somewhere of its own more often than beside the code
|
|
137
156
|
for (const [path, secret] of Object.entries(context.fileSecrets)) {
|
|
138
157
|
lines.push(
|
|
139
|
-
`RUN --mount=type=secret,id=${secret}
|
|
158
|
+
`RUN --mount=type=secret,id=${secret} mkdir -p ${dirname(path)} && ` +
|
|
159
|
+
`cp /run/secrets/${secret} ${path}`,
|
|
140
160
|
);
|
|
141
161
|
}
|
|
142
162
|
|
package/src/environment.ts
CHANGED
|
@@ -14,7 +14,7 @@ export async function envFileFor(app: AppSpec, context: Context) {
|
|
|
14
14
|
if (!app.secrets) return undefined;
|
|
15
15
|
|
|
16
16
|
const contents = await readEnv(app.secrets, context.secrets);
|
|
17
|
-
return await context.host.write(`apps/${app.name}/env`, contents);
|
|
17
|
+
return await context.host.write(`apps/${app.name}/env`, dockerEnv(contents));
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
// The flag, or nothing at all for an app that names no secrets
|
|
@@ -22,3 +22,73 @@ export async function envFlags(app: AppSpec, context: Context) {
|
|
|
22
22
|
const path = await envFileFor(app, context);
|
|
23
23
|
return path ? [`--env-file ${path}`] : [];
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
// A vault holds dotenv, and docker's env file is not dotenv: a quote is part of
|
|
27
|
+
// the value there, so DATABASE_URL="postgres://..." reaches the process with the
|
|
28
|
+
// quote still on it and every url parser rejects the scheme
|
|
29
|
+
export function dockerEnv(contents: string) {
|
|
30
|
+
const lines = Object.entries(parseEnv(contents)).map(([key, value]) => {
|
|
31
|
+
// The format is one variable per line, so there is no spelling of this that
|
|
32
|
+
// docker would read back. Better to name the variable than to truncate it
|
|
33
|
+
if (value.includes("\n")) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`${key} spans more than one line, which a docker env file cannot carry. ` +
|
|
36
|
+
"Pass it as a file secret instead",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `${key}=${value}`;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Enough of the format to carry a credential: KEY=value, quotes stripped,
|
|
47
|
+
// blank lines and comments skipped. A quoted value may run past the end of its
|
|
48
|
+
// line, because a key or a certificate is written the way it was generated
|
|
49
|
+
export function parseEnv(contents: string) {
|
|
50
|
+
const values: Record<string, string> = {};
|
|
51
|
+
const lines = contents.split("\n");
|
|
52
|
+
|
|
53
|
+
for (let at = 0; at < lines.length; at += 1) {
|
|
54
|
+
const text = (lines[at] ?? "").trim();
|
|
55
|
+
if (!text || text.startsWith("#")) continue;
|
|
56
|
+
|
|
57
|
+
const split = text.indexOf("=");
|
|
58
|
+
if (split < 1) continue;
|
|
59
|
+
|
|
60
|
+
const key = text.slice(0, split).replace(/^export\s+/, "").trim();
|
|
61
|
+
const start = text.slice(split + 1).trim();
|
|
62
|
+
const edge = start[0];
|
|
63
|
+
|
|
64
|
+
if (edge !== '"' && edge !== "'") {
|
|
65
|
+
values[key] = start;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (start.length > 1 && start.endsWith(edge)) {
|
|
70
|
+
values[key] = start.slice(1, -1);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The quote never closed on its own line. Take whole lines until one closes
|
|
75
|
+
// it, so a pem arrives whole rather than as its first line
|
|
76
|
+
const held = [start.slice(1)];
|
|
77
|
+
|
|
78
|
+
while (at + 1 < lines.length) {
|
|
79
|
+
at += 1;
|
|
80
|
+
const next = (lines[at] ?? "").trimEnd();
|
|
81
|
+
|
|
82
|
+
if (next.endsWith(edge)) {
|
|
83
|
+
held.push(next.slice(0, -1));
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
held.push(next);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
values[key] = held.join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return values;
|
|
94
|
+
}
|