basepad 0.0.0-stage → 0.0.0
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/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/catalogue/mailpit.js +82 -0
- package/dist/catalogue/postgres.js +632 -0
- package/dist/catalogue/redis.js +135 -0
- package/dist/catalogue.js +13 -0
- package/dist/claude-hooks.js +33 -0
- package/dist/cli.js +182 -0
- package/dist/commands.js +989 -0
- package/dist/config.js +217 -0
- package/dist/env-files.js +123 -0
- package/dist/forward.js +53 -0
- package/dist/hosts.js +79 -0
- package/dist/index.js +11 -0
- package/dist/init.js +88 -0
- package/dist/internal/exec.js +58 -0
- package/dist/internal/ports.js +63 -0
- package/dist/internal/processes.js +42 -0
- package/dist/internal/service-user.js +53 -0
- package/dist/internal/table.js +7 -0
- package/dist/launcher.js +53 -0
- package/dist/layout.js +234 -0
- package/dist/mission-control/act.js +91 -0
- package/dist/mission-control/feed.js +254 -0
- package/dist/mission-control/frame.js +799 -0
- package/dist/mission-control/launch.js +362 -0
- package/dist/mission-control/model.js +325 -0
- package/dist/mission-control/run.js +211 -0
- package/dist/mission-control/work.js +162 -0
- package/dist/setup.js +280 -0
- package/dist/supervisor.js +477 -0
- package/dist/tls.js +99 -0
- package/dist/tools.js +32 -0
- package/dist/versions.js +19 -0
- package/dist/worktrees.js +186 -0
- package/package.json +50 -5
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { Box, render, Text } from "ink";
|
|
2
|
+
import { createElement as h } from "react";
|
|
3
|
+
import { palette } from "./frame.js";
|
|
4
|
+
const withArt = ({ cols, rows, color }) => color === "rgb" && cols >= ART_COLS && (rows ?? Number.POSITIVE_INFINITY) >= MIN_ART_ROWS + 2;
|
|
5
|
+
/** The rows the art takes in `rows` with its padding: the whole sky where it fits, less above. */
|
|
6
|
+
const artRows = (rows) => Math.min(ART_ROWS, rows - 2);
|
|
7
|
+
export function launchFrame(input) {
|
|
8
|
+
const c = palette(input.color);
|
|
9
|
+
const seconds = input.elapsed === undefined ? undefined : Math.floor(input.elapsed);
|
|
10
|
+
const clock = seconds === undefined
|
|
11
|
+
? undefined
|
|
12
|
+
: `T+${[seconds / 3600, (seconds / 60) % 60, seconds % 60]
|
|
13
|
+
.map((n) => String(Math.floor(n)).padStart(2, "0"))
|
|
14
|
+
.join(":")}`;
|
|
15
|
+
return beside(withArt(input)
|
|
16
|
+
? pixels(scene(input).slice(-2 * artRows(input.rows ?? Number.POSITIVE_INFINITY)), c)
|
|
17
|
+
: [], checklist({
|
|
18
|
+
...input,
|
|
19
|
+
lamp: "GO",
|
|
20
|
+
status: launchStatus(input, c),
|
|
21
|
+
...(clock === undefined ? {} : { clock }),
|
|
22
|
+
}, c));
|
|
23
|
+
}
|
|
24
|
+
/** The art, if any, with the checklist to its right. */
|
|
25
|
+
function beside(art, list) {
|
|
26
|
+
return h(Box, { flexDirection: "row", gap: 4, paddingX: 1, paddingY: 1 }, ...(art.length > 0 ? [h(Box, { key: "art", flexDirection: "column" }, ...art)] : []), list);
|
|
27
|
+
}
|
|
28
|
+
function launchStatus({ checks, lift }, c) {
|
|
29
|
+
const failed = checks.find((check) => check.state === "nogo");
|
|
30
|
+
if (failed !== undefined)
|
|
31
|
+
return [`NO-GO · ${failed.name}`, c.bad];
|
|
32
|
+
if (checks.every((check) => check.state === "go"))
|
|
33
|
+
return lift > 0 ? ["LIFT-OFF", c.ok] : ["ALL SYSTEMS GO", c.ok];
|
|
34
|
+
return ["PRE-LAUNCH CHECKS", c.warn];
|
|
35
|
+
}
|
|
36
|
+
// ── the checklist ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
/** The art needs this many columns beside the checklist, this many rows, and a colour per pixel. */
|
|
38
|
+
const ART_COLS = 70;
|
|
39
|
+
const ART_ROWS = 18;
|
|
40
|
+
/** The least it shows: the rocket, its tower and pad, with the sky above cut. */
|
|
41
|
+
const MIN_ART_ROWS = 13;
|
|
42
|
+
const WIDTH = 34;
|
|
43
|
+
function checklist(list, c) {
|
|
44
|
+
const rule = h(Text, { color: c.dim }, "─".repeat(WIDTH));
|
|
45
|
+
// Dark type on a lit lamp; without colour, the terminal's own.
|
|
46
|
+
const onLamp = c.keyBg === undefined ? undefined : "#1a1b26";
|
|
47
|
+
const lit = (text, bg) => h(Text, { color: onLamp, backgroundColor: bg, bold: true }, ` ${text} `);
|
|
48
|
+
const rows = list.checks.map((check) => {
|
|
49
|
+
const on = check.state !== "waiting";
|
|
50
|
+
const lamp = check.state === "go"
|
|
51
|
+
? lit(list.lamp, c.ok)
|
|
52
|
+
: check.state === "nogo"
|
|
53
|
+
? lit("NO-GO", c.bad)
|
|
54
|
+
: check.state === "active"
|
|
55
|
+
? h(Text, { color: c.warn, bold: true }, " ·· ")
|
|
56
|
+
: h(Text, null, "");
|
|
57
|
+
return h(Text, { key: check.name }, h(Text, { color: on ? c.text : c.dim }, check.name.padEnd(WIDTH - 8, on ? "." : " ")), lamp);
|
|
58
|
+
});
|
|
59
|
+
const [said, tone] = list.status;
|
|
60
|
+
return h(Box, { key: "checklist", flexDirection: "column", justifyContent: "center" }, h(Text, null, h(Text, { color: c.text, bold: true }, "BASEPAD"), h(Text, { color: c.dim }, " │ "), h(Text, { color: c.mute }, list.title)), h(Text, { color: c.dim }, list.subtitle.toUpperCase()), rule, ...rows, rule, h(Text, null, ...(list.clock === undefined
|
|
61
|
+
? []
|
|
62
|
+
: [h(Text, { key: "clock", color: c.text, bold: true }, list.clock.padEnd(14))]), h(Text, { color: tone, bold: true }, said)), ...(list.note === undefined ? [] : [h(Text, { key: "note", color: c.mute }, list.note)]));
|
|
63
|
+
}
|
|
64
|
+
// ── the rocket ────────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
/** A Saturn-style rocket, a character per pixel: each letter a colour (`colours`), `.` none. */
|
|
66
|
+
const ROCKET = [
|
|
67
|
+
"....S....",
|
|
68
|
+
"....S....",
|
|
69
|
+
"...SwS...",
|
|
70
|
+
"...WWw...",
|
|
71
|
+
"..WWWWw..",
|
|
72
|
+
"..DDDDD..",
|
|
73
|
+
"..WWWWw..",
|
|
74
|
+
"..WKWWw..",
|
|
75
|
+
"..DDDDD..",
|
|
76
|
+
"..WWWWw..",
|
|
77
|
+
"..WWWKw..",
|
|
78
|
+
"..WWWWw..",
|
|
79
|
+
"..DDDDD..",
|
|
80
|
+
"..BBBBB..",
|
|
81
|
+
"..WWWWw..",
|
|
82
|
+
"..WKKWw..",
|
|
83
|
+
"..WKKWw..",
|
|
84
|
+
"..WWWWw..",
|
|
85
|
+
"..WWWKK..",
|
|
86
|
+
".DWWWKKD.",
|
|
87
|
+
"DDWWWWwDD",
|
|
88
|
+
"D.SSSSS.D",
|
|
89
|
+
];
|
|
90
|
+
const FLAME = [
|
|
91
|
+
["...LLL...", "...YLY...", "....Y...."],
|
|
92
|
+
["...LLL...", "...LYL...", "...YOY...", "....O...."],
|
|
93
|
+
];
|
|
94
|
+
const PLUME = [
|
|
95
|
+
["..LLLLL..", "..YLLLY..", "...YYY...", "...OYO...", "....O....", "....O...."],
|
|
96
|
+
["..LLLLL..", "..LLLLL..", "...YLY...", "...YOY...", "...O.O...", "....O...."],
|
|
97
|
+
];
|
|
98
|
+
const WIDE = 26;
|
|
99
|
+
const TALL = 36;
|
|
100
|
+
const ROCKET_X = 12;
|
|
101
|
+
const PAD = TALL - 3;
|
|
102
|
+
const ROCKET_TOP = PAD - ROCKET.length - 1;
|
|
103
|
+
const STARS = [
|
|
104
|
+
[1, 2],
|
|
105
|
+
[22, 5],
|
|
106
|
+
[8, 1],
|
|
107
|
+
[24, 14],
|
|
108
|
+
[17, 0],
|
|
109
|
+
];
|
|
110
|
+
/** A blank sky, `put` to draw a pixel into it, and `stamp` to draw rows of them. */
|
|
111
|
+
function canvas() {
|
|
112
|
+
const px = Array.from({ length: TALL }, () => Array(WIDE).fill("."));
|
|
113
|
+
const put = (x, y, ch) => {
|
|
114
|
+
const row = px[y];
|
|
115
|
+
if (ch !== "." && row !== undefined && x >= 0 && x < WIDE)
|
|
116
|
+
row[x] = ch;
|
|
117
|
+
};
|
|
118
|
+
const stamp = (rows, x, y) => {
|
|
119
|
+
for (const [dy, row] of rows.entries())
|
|
120
|
+
for (const [dx, ch] of [...row].entries())
|
|
121
|
+
put(x + dx, y + dy, ch);
|
|
122
|
+
};
|
|
123
|
+
return { rows: () => px.map((row) => row.join("")), put, stamp };
|
|
124
|
+
}
|
|
125
|
+
const TOWER_TOP = ROCKET_TOP - 2;
|
|
126
|
+
/** Where the tower's beacon blinks. */
|
|
127
|
+
const BEACON = [5, TOWER_TOP - 2];
|
|
128
|
+
/** The stars, the tower (its beacon dark, its arms away from the rocket) and the pad. */
|
|
129
|
+
function ground(put, armsIn) {
|
|
130
|
+
for (const [x, y] of STARS)
|
|
131
|
+
put(x, y, "*");
|
|
132
|
+
// The umbilical tower: two legs and a lattice.
|
|
133
|
+
for (let y = TOWER_TOP; y < PAD; y++) {
|
|
134
|
+
put(3, y, "T");
|
|
135
|
+
put(7, y, "T");
|
|
136
|
+
put(4 + ((y - TOWER_TOP) % 3), y, "t");
|
|
137
|
+
}
|
|
138
|
+
put(5, TOWER_TOP - 1, "T");
|
|
139
|
+
put(...BEACON, "t");
|
|
140
|
+
// Its arms hold the rocket until it is cleared to go.
|
|
141
|
+
for (const y of [ROCKET_TOP + 4, ROCKET_TOP + 10, ROCKET_TOP + 16])
|
|
142
|
+
for (let x = 8; x < (armsIn ? ROCKET_X + 2 : 10); x++)
|
|
143
|
+
put(x, y, "T");
|
|
144
|
+
for (let x = 0; x < WIDE; x++)
|
|
145
|
+
put(x, PAD, "D");
|
|
146
|
+
for (let x = 1; x < WIDE - 1; x++)
|
|
147
|
+
put(x, PAD + 1, x > 10 && x < 22 ? "K" : "D");
|
|
148
|
+
}
|
|
149
|
+
/** The pad as pixels, a row of letters each: the tower, the rocket risen `lift`, its flame. */
|
|
150
|
+
function scene({ checks, tick, lift }) {
|
|
151
|
+
const { rows, put, stamp } = canvas();
|
|
152
|
+
const go = checks.every((check) => check.state === "go");
|
|
153
|
+
ground(put, !go);
|
|
154
|
+
if (tick % 10 < 5)
|
|
155
|
+
put(...BEACON, "R");
|
|
156
|
+
const top = ROCKET_TOP - lift;
|
|
157
|
+
stamp(ROCKET, ROCKET_X, top);
|
|
158
|
+
if (go)
|
|
159
|
+
stamp((lift > 0 ? PLUME : FLAME)[tick % 2], ROCKET_X, top + ROCKET.length);
|
|
160
|
+
if (lift > 0)
|
|
161
|
+
for (let x = 0; x < WIDE; x++)
|
|
162
|
+
if ((x + tick) % 3 !== 0)
|
|
163
|
+
put(x, PAD - 1, "S");
|
|
164
|
+
return rows();
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The art in layers, for drawing it elsewhere (the README's rocket, `scripts/rocket-svg.ts`): the
|
|
168
|
+
* ground with the tower's arms away, the rocket and its flames as stamped at `x`, `top`, and each
|
|
169
|
+
* letter's colour.
|
|
170
|
+
*/
|
|
171
|
+
export function rocketArt() {
|
|
172
|
+
const { rows, put } = canvas();
|
|
173
|
+
ground(put, false);
|
|
174
|
+
return {
|
|
175
|
+
wide: WIDE,
|
|
176
|
+
tall: TALL,
|
|
177
|
+
ground: rows(),
|
|
178
|
+
beacon: BEACON,
|
|
179
|
+
rocket: ROCKET,
|
|
180
|
+
flames: FLAME,
|
|
181
|
+
plumes: PLUME,
|
|
182
|
+
x: ROCKET_X,
|
|
183
|
+
top: ROCKET_TOP,
|
|
184
|
+
colours: colours(palette("rgb")),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function colours(c) {
|
|
188
|
+
return {
|
|
189
|
+
W: c.text,
|
|
190
|
+
w: c.soft,
|
|
191
|
+
S: c.mute,
|
|
192
|
+
B: c.accent,
|
|
193
|
+
Y: c.warn,
|
|
194
|
+
R: c.bad,
|
|
195
|
+
"*": c.dim,
|
|
196
|
+
T: c.dim,
|
|
197
|
+
D: "#414868",
|
|
198
|
+
K: "#24283b",
|
|
199
|
+
t: "#3b4261",
|
|
200
|
+
L: "#fff1c1",
|
|
201
|
+
O: "#ff9e64",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** Two pixel rows to a line of text: `▀` in the top one's colour, on the bottom one's. */
|
|
205
|
+
function pixels(rows, c) {
|
|
206
|
+
const colour = colours(c);
|
|
207
|
+
const out = [];
|
|
208
|
+
for (let y = 0; y < rows.length; y += 2) {
|
|
209
|
+
const top = [...rows[y]];
|
|
210
|
+
const bottom = [...(rows[y + 1] ?? "")];
|
|
211
|
+
out.push(h(Text, { key: y }, ...top.map((t, x) => {
|
|
212
|
+
const b = bottom[x] ?? ".";
|
|
213
|
+
if (t === "." && b === ".")
|
|
214
|
+
return h(Text, { key: x }, " ");
|
|
215
|
+
if (t === ".")
|
|
216
|
+
return h(Text, { key: x, color: colour[b] }, "▄");
|
|
217
|
+
if (b === ".")
|
|
218
|
+
return h(Text, { key: x, color: colour[t] }, "▀");
|
|
219
|
+
return h(Text, { key: x, color: colour[t], backgroundColor: colour[b] }, "▀");
|
|
220
|
+
})));
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
export function landingFrame(input) {
|
|
225
|
+
const c = palette(input.color);
|
|
226
|
+
const art = input.color === "rgb" && input.cols >= ART_COLS;
|
|
227
|
+
const lamp = input.state === "stopped" ? "go" : input.state === "failed" ? "nogo" : "active";
|
|
228
|
+
const status = input.state === "stopped"
|
|
229
|
+
? ["SPLASHDOWN · all stopped", c.ok]
|
|
230
|
+
: input.state === "failed"
|
|
231
|
+
? ["NO-GO · not everything stopped", c.bad]
|
|
232
|
+
: ["DESCENT", c.warn];
|
|
233
|
+
const kept = input.kept ?? [];
|
|
234
|
+
const names = kept.map((key) => `\`${key}\``).join(", ");
|
|
235
|
+
const note = input.state !== "stopped"
|
|
236
|
+
? undefined
|
|
237
|
+
: kept.length === 0
|
|
238
|
+
? "`basepad up` relaunches."
|
|
239
|
+
: `${names} ${kept.length > 1 ? "run" : "runs"} on main's and keep${kept.length > 1 ? "" : "s"} running. \`basepad up\` relaunches.`;
|
|
240
|
+
return beside(art ? pixels(capsule(input), c) : [], checklist({
|
|
241
|
+
...input,
|
|
242
|
+
subtitle: `${input.project} · ${input.environment}`,
|
|
243
|
+
title: "SPLASHDOWN",
|
|
244
|
+
lamp: "SAFE",
|
|
245
|
+
checks: input.parts.map((name) => ({ name, state: lamp })),
|
|
246
|
+
status,
|
|
247
|
+
...(note === undefined ? {} : { note }),
|
|
248
|
+
}, c));
|
|
249
|
+
}
|
|
250
|
+
/** The capsule's parachute, its gores red and white. */
|
|
251
|
+
const CHUTE = [
|
|
252
|
+
"......RRWWRR......",
|
|
253
|
+
"...RRWWRRWWRRWW...",
|
|
254
|
+
"..WWRRWWRRWWRRWW..",
|
|
255
|
+
".RRWWRRWWRRWWRRWW.",
|
|
256
|
+
"RRWWRRWWRRWWRRWWRR",
|
|
257
|
+
"t.......t........t",
|
|
258
|
+
];
|
|
259
|
+
const CAPSULE = ["...W...", "..WWw..", ".WWWWw.", "WWWWWww", "DDDDDDD"];
|
|
260
|
+
/** The sky and sea as pixels: the capsule under its chute `drop` down, swaying, then splashed. */
|
|
261
|
+
function capsule({ tick, drop, state }) {
|
|
262
|
+
const px = Array.from({ length: TALL }, () => Array(WIDE).fill("."));
|
|
263
|
+
const put = (x, y, ch) => {
|
|
264
|
+
const row = px[y];
|
|
265
|
+
if (ch !== "." && row !== undefined && x >= 0 && x < WIDE)
|
|
266
|
+
row[x] = ch;
|
|
267
|
+
};
|
|
268
|
+
const stamp = (rows, x, y) => {
|
|
269
|
+
for (const [dy, row] of rows.entries())
|
|
270
|
+
for (const [dx, ch] of [...row].entries())
|
|
271
|
+
put(x + dx, y + dy, ch);
|
|
272
|
+
};
|
|
273
|
+
for (const [x, y] of STARS)
|
|
274
|
+
put(x, y, "*");
|
|
275
|
+
const sea = TALL - 4;
|
|
276
|
+
const down = state === "stopped";
|
|
277
|
+
// The lowest it comes before landing, and where it floats after.
|
|
278
|
+
const lowest = sea - CAPSULE.length - CHUTE.length - 7;
|
|
279
|
+
const y = down ? sea - CAPSULE.length - CHUTE.length - 5 : Math.min(lowest, drop);
|
|
280
|
+
const sway = down ? 0 : tick % 20 < 10 ? 0 : 1;
|
|
281
|
+
const x = 4 + sway;
|
|
282
|
+
if (!down) {
|
|
283
|
+
stamp(CHUTE, x, y);
|
|
284
|
+
// Shroud lines from the chute's edge and middle to the capsule's top.
|
|
285
|
+
for (let i = 1; i <= 5; i++) {
|
|
286
|
+
const row = y + CHUTE.length - 1 + i;
|
|
287
|
+
put(x + 1 + Math.round((i * 7) / 5), row, "t");
|
|
288
|
+
put(x + 16 - Math.round((i * 6) / 5), row, "t");
|
|
289
|
+
put(x + 9, row, "t");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const capsuleY = down ? sea - CAPSULE.length + 1 : y + CHUTE.length + 5;
|
|
293
|
+
stamp(CAPSULE, x + 6, capsuleY);
|
|
294
|
+
// The sea, its waves rolling with the beat; a splash once it is down.
|
|
295
|
+
for (let row = sea; row < TALL; row++)
|
|
296
|
+
for (let col = 0; col < WIDE; col++)
|
|
297
|
+
put(col, row, (col + tick + row) % 4 === 0 ? "w" : "B");
|
|
298
|
+
if (down)
|
|
299
|
+
for (const dx of [-1, 7, -2, 8])
|
|
300
|
+
put(x + 6 + dx, sea - 1 - (Math.abs(dx) % 2), "w");
|
|
301
|
+
return px.map((row) => row.join(""));
|
|
302
|
+
}
|
|
303
|
+
// ── the live view ─────────────────────────────────────────────────────────────────────────────
|
|
304
|
+
/** A beat of the animation. */
|
|
305
|
+
const BEAT_MS = 100;
|
|
306
|
+
/** How long the splashdown shows before `down` returns. */
|
|
307
|
+
const SPLASH_MS = 300;
|
|
308
|
+
/** Draws the landing on the terminal, in place, until it lands or fails. */
|
|
309
|
+
export function landing(options) {
|
|
310
|
+
let tick = 0;
|
|
311
|
+
let state = "stopping";
|
|
312
|
+
const draw = () => landingFrame({
|
|
313
|
+
...options,
|
|
314
|
+
state,
|
|
315
|
+
tick,
|
|
316
|
+
drop: Math.floor(tick / 2),
|
|
317
|
+
cols: process.stdout.columns || Number(process.env.COLUMNS) || 80,
|
|
318
|
+
});
|
|
319
|
+
const app = render(draw(), { exitOnCtrlC: true, patchConsole: false });
|
|
320
|
+
const timer = setInterval(() => {
|
|
321
|
+
tick++;
|
|
322
|
+
app.rerender(draw());
|
|
323
|
+
}, BEAT_MS);
|
|
324
|
+
const close = (then) => {
|
|
325
|
+
clearInterval(timer);
|
|
326
|
+
state = then;
|
|
327
|
+
app.rerender(draw());
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
async land() {
|
|
331
|
+
close("stopped");
|
|
332
|
+
await new Promise((resolve) => setTimeout(resolve, SPLASH_MS));
|
|
333
|
+
app.unmount();
|
|
334
|
+
},
|
|
335
|
+
fail() {
|
|
336
|
+
close("failed");
|
|
337
|
+
app.unmount();
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** How long setup's lift-off plays. */
|
|
342
|
+
const SETUP_LIFT_OFF_MS = 1500;
|
|
343
|
+
/**
|
|
344
|
+
* Plays the lift-off on the terminal, in place, then leaves the empty pad: setup's last moment,
|
|
345
|
+
* once every step is done.
|
|
346
|
+
*/
|
|
347
|
+
export async function liftOff(input) {
|
|
348
|
+
let tick = 0;
|
|
349
|
+
const draw = () => launchFrame({ ...input, tick, lift: Math.floor(tick ** 2 / 4) });
|
|
350
|
+
const app = render(draw(), { exitOnCtrlC: true, patchConsole: false });
|
|
351
|
+
await new Promise((done) => {
|
|
352
|
+
const timer = setInterval(() => {
|
|
353
|
+
tick++;
|
|
354
|
+
app.rerender(draw());
|
|
355
|
+
if (tick * BEAT_MS >= SETUP_LIFT_OFF_MS) {
|
|
356
|
+
clearInterval(timer);
|
|
357
|
+
done();
|
|
358
|
+
}
|
|
359
|
+
}, BEAT_MS);
|
|
360
|
+
});
|
|
361
|
+
app.unmount();
|
|
362
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mission control's model: what it shows (a snapshot of the project's environments and their logs,
|
|
3
|
+
* read from the supervisor) and what the developer chose to look at (the view), with the pure
|
|
4
|
+
* function that turns a key press into the next view.
|
|
5
|
+
*/
|
|
6
|
+
import { ofDaemon } from "../layout.js";
|
|
7
|
+
/** The view mission control opens on: the environment `basepad up` ran in. */
|
|
8
|
+
export function initialView(selected) {
|
|
9
|
+
return { selected, quit: false };
|
|
10
|
+
}
|
|
11
|
+
/** The selected environment: the one the view names, else the first. */
|
|
12
|
+
export function selectedOf(snapshot, view) {
|
|
13
|
+
return (snapshot.environments.find((env) => env.namespace === view.selected) ?? snapshot.environments[0]);
|
|
14
|
+
}
|
|
15
|
+
/** An environment's apps, then its services: the order the view lists and steps through them. */
|
|
16
|
+
export function partsOf(env) {
|
|
17
|
+
return env === undefined ? [] : [...env.apps, ...env.services];
|
|
18
|
+
}
|
|
19
|
+
const DOING = {
|
|
20
|
+
start: "Starting",
|
|
21
|
+
stop: "Stopping",
|
|
22
|
+
restart: "Restarting",
|
|
23
|
+
};
|
|
24
|
+
/** What space does: stops what runs or is on its way, else starts it. */
|
|
25
|
+
function toggleOf(env, part) {
|
|
26
|
+
const states = part === undefined ? env.apps.map((app) => app.state) : [part.state];
|
|
27
|
+
return states.some((state) => state === "running" || state === "starting") ? "stop" : "start";
|
|
28
|
+
}
|
|
29
|
+
/** The parts whose lines the stream shows: the focused part, or all the environment's. */
|
|
30
|
+
export function streamIds(snapshot, view) {
|
|
31
|
+
const env = selectedOf(snapshot, view);
|
|
32
|
+
const parts = partsOf(env);
|
|
33
|
+
const focused = parts.find((part) => part.id === view.focus);
|
|
34
|
+
return focused === undefined ? parts.map((part) => part.id) : [focused.id];
|
|
35
|
+
}
|
|
36
|
+
/** Every line the stream can show, scrolled all the way back, in arrival order. */
|
|
37
|
+
export function streamLines(snapshot, view) {
|
|
38
|
+
return streamIds(snapshot, view)
|
|
39
|
+
.flatMap((id) => (snapshot.logs.get(id) ?? []).filter(printed))
|
|
40
|
+
.sort((a, b) => a.seq - b.seq);
|
|
41
|
+
}
|
|
42
|
+
/** A web page `enter` can open: an app's URL, or a service's page such as an inbox. */
|
|
43
|
+
const isPage = (address) => /^https?:\/\//.test(address);
|
|
44
|
+
/** Whether a line says anything: the blank ones an app prints are left out of the stream. */
|
|
45
|
+
export const printed = (line) => line.text.trim() !== "";
|
|
46
|
+
/** The index of the last line that arrived no later than `seq`; -1 when none did. */
|
|
47
|
+
function indexOf(lines, seq) {
|
|
48
|
+
let i = lines.length - 1;
|
|
49
|
+
while (i >= 0 && lines[i].seq > seq)
|
|
50
|
+
i--;
|
|
51
|
+
return i;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The text a press types into a field: a character, or a paste without its control characters (a
|
|
55
|
+
* newline it was copied with, a tab), since a field is one line and a paste never acts; a named
|
|
56
|
+
* key types none.
|
|
57
|
+
*/
|
|
58
|
+
const textOf = (key) => typeof key !== "string"
|
|
59
|
+
? [...key.paste].filter((c) => c >= " " && c !== "\u007f").join("")
|
|
60
|
+
: [...key].length === 1
|
|
61
|
+
? key
|
|
62
|
+
: undefined;
|
|
63
|
+
/**
|
|
64
|
+
* What a key press does. `page` is how many log lines the stream shows: a page up or down moves
|
|
65
|
+
* by that many. A paste is text in a text field (the search, a worktree's suffix) and no key
|
|
66
|
+
* anywhere else, so it never starts or stops anything.
|
|
67
|
+
*/
|
|
68
|
+
export function reduce(previous, key, snapshot, page = 10) {
|
|
69
|
+
const { notice: _, ...view } = previous;
|
|
70
|
+
if (view.confirm !== undefined)
|
|
71
|
+
return confirming(view, view.confirm, key, snapshot);
|
|
72
|
+
if (view.search?.typing)
|
|
73
|
+
return { view: typing(view, view.search.query, key, snapshot) };
|
|
74
|
+
if (typeof key !== "string")
|
|
75
|
+
return { view: previous };
|
|
76
|
+
const envs = snapshot.environments;
|
|
77
|
+
const env = selectedOf(snapshot, view);
|
|
78
|
+
const at = env === undefined ? 0 : envs.indexOf(env);
|
|
79
|
+
// Environments go round, by tab or arrow; a number picks one outright.
|
|
80
|
+
const select = (i) => {
|
|
81
|
+
const next = envs[i];
|
|
82
|
+
if (next === undefined)
|
|
83
|
+
return view;
|
|
84
|
+
return { selected: next.namespace, quit: false, ...(view.help ? { help: true } : {}) };
|
|
85
|
+
};
|
|
86
|
+
const turn = (by) => select((at + by + envs.length) % Math.max(1, envs.length));
|
|
87
|
+
// Focus steps from "all" (none) down through each part in order, and stops at the last.
|
|
88
|
+
const step = (by) => {
|
|
89
|
+
const ids = [undefined, ...partsOf(env).map((part) => part.id)];
|
|
90
|
+
const now = Math.max(0, ids.indexOf(view.focus));
|
|
91
|
+
const focus = ids[Math.max(0, Math.min(ids.length - 1, now + by))];
|
|
92
|
+
const { focus: _, until: __, ...rest } = view;
|
|
93
|
+
return focus === undefined ? rest : { ...rest, focus };
|
|
94
|
+
};
|
|
95
|
+
// Scrolling pauses the stream at a line, by its arrival, so new lines do not move it.
|
|
96
|
+
const scroll = (by) => {
|
|
97
|
+
const lines = streamLines(snapshot, view);
|
|
98
|
+
const { until: _, ...live } = view;
|
|
99
|
+
const last = lines.length - 1;
|
|
100
|
+
if (by === "live" || lines.length <= page)
|
|
101
|
+
return live;
|
|
102
|
+
const now = view.until === undefined ? last : Math.max(page - 1, indexOf(lines, view.until));
|
|
103
|
+
const at = by === "oldest" ? page - 1 : Math.max(page - 1, now + by);
|
|
104
|
+
return at >= last ? live : { ...view, until: lines[at].seq };
|
|
105
|
+
};
|
|
106
|
+
const focused = partsOf(env).find((part) => part.id === view.focus);
|
|
107
|
+
// One key never stops a whole environment: `space` takes a selected part, else asks for one.
|
|
108
|
+
if (key === "space" && focused === undefined) {
|
|
109
|
+
const notice = "Select an app or service first (↓); `basepad down` stops them all.";
|
|
110
|
+
return { view: { ...view, notice } };
|
|
111
|
+
}
|
|
112
|
+
const kind = key === "r"
|
|
113
|
+
? "restart"
|
|
114
|
+
: key === "space" && env !== undefined
|
|
115
|
+
? toggleOf(env, focused)
|
|
116
|
+
: undefined;
|
|
117
|
+
if (kind !== undefined) {
|
|
118
|
+
const intent = env === undefined ? undefined : actionOn(env, focused, kind);
|
|
119
|
+
if (intent === undefined)
|
|
120
|
+
return { view: { ...view, notice: `Nothing here to ${kind}.` } };
|
|
121
|
+
return { view: { ...view, notice: `${DOING[kind]} ${intent.label}…` }, intent };
|
|
122
|
+
}
|
|
123
|
+
if (/^[1-9]$/.test(key))
|
|
124
|
+
return { view: select(Number(key) - 1) };
|
|
125
|
+
// Open and copy take the selected part, else the environment's first app with an address.
|
|
126
|
+
const target = focused ?? env?.apps.find((app) => app.address !== undefined);
|
|
127
|
+
const noAddress = `${target?.name} has no domain, so no address to open or copy.`;
|
|
128
|
+
switch (key) {
|
|
129
|
+
case "return":
|
|
130
|
+
if (target === undefined)
|
|
131
|
+
return { view: { ...view, notice: "Nothing here to open." } };
|
|
132
|
+
if (target.address === undefined)
|
|
133
|
+
return { view: { ...view, notice: noAddress } };
|
|
134
|
+
if (!isPage(target.address)) {
|
|
135
|
+
const notice = `${target.name} is a service, with no page to open; c copies its address.`;
|
|
136
|
+
return { view: { ...view, notice } };
|
|
137
|
+
}
|
|
138
|
+
return { view, intent: { kind: "open", url: target.address } };
|
|
139
|
+
case "c":
|
|
140
|
+
if (target === undefined)
|
|
141
|
+
return { view: { ...view, notice: "Nothing here to copy." } };
|
|
142
|
+
if (target.address === undefined)
|
|
143
|
+
return { view: { ...view, notice: noAddress } };
|
|
144
|
+
return { view, intent: { kind: "copy", text: target.address } };
|
|
145
|
+
case "q":
|
|
146
|
+
case "ctrl-c":
|
|
147
|
+
return { view: { ...view, quit: true } };
|
|
148
|
+
case "up":
|
|
149
|
+
case "k":
|
|
150
|
+
return { view: step(-1) };
|
|
151
|
+
case "down":
|
|
152
|
+
case "j":
|
|
153
|
+
return { view: step(1) };
|
|
154
|
+
case "tab":
|
|
155
|
+
case "right":
|
|
156
|
+
case "l":
|
|
157
|
+
return { view: turn(1) };
|
|
158
|
+
case "shift-tab":
|
|
159
|
+
case "left":
|
|
160
|
+
case "h":
|
|
161
|
+
return { view: turn(-1) };
|
|
162
|
+
case "?": {
|
|
163
|
+
const { help, ...rest } = view;
|
|
164
|
+
return { view: help ? rest : { ...rest, help: true } };
|
|
165
|
+
}
|
|
166
|
+
case "pageup":
|
|
167
|
+
return { view: scroll(-page) };
|
|
168
|
+
case "pagedown":
|
|
169
|
+
return { view: scroll(page) };
|
|
170
|
+
case "home":
|
|
171
|
+
case "g":
|
|
172
|
+
return { view: scroll("oldest") };
|
|
173
|
+
case "end":
|
|
174
|
+
case "G":
|
|
175
|
+
return { view: scroll("live") };
|
|
176
|
+
case "a":
|
|
177
|
+
if (snapshot.work?.some((w) => w.kind === "add")) {
|
|
178
|
+
const notice = "A worktree is being added; a adds another once it is made.";
|
|
179
|
+
return { view: { ...view, notice } };
|
|
180
|
+
}
|
|
181
|
+
return { view, intent: { kind: "add" } };
|
|
182
|
+
case "d": {
|
|
183
|
+
if (env === undefined || env === envs[0]) {
|
|
184
|
+
return {
|
|
185
|
+
view: { ...view, notice: "Main is not a worktree: only a worktree is removed here." },
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (snapshot.work?.some((w) => w.namespace === env.namespace)) {
|
|
189
|
+
return { view: { ...view, notice: `${env.label} is being removed.` } };
|
|
190
|
+
}
|
|
191
|
+
const confirm = { namespace: env.namespace, suffix: env.label, step: "remove" };
|
|
192
|
+
return { view: { ...view, confirm } };
|
|
193
|
+
}
|
|
194
|
+
case "/":
|
|
195
|
+
return { view: { ...view, search: { query: "", typing: true } } };
|
|
196
|
+
case "n":
|
|
197
|
+
case "N":
|
|
198
|
+
return {
|
|
199
|
+
view: view.search === undefined ? view : find(view, key === "n" ? -1 : 1, snapshot),
|
|
200
|
+
};
|
|
201
|
+
case "escape": {
|
|
202
|
+
// One layer at a time: the keys, then the search or the pause, then the focus.
|
|
203
|
+
if (view.help) {
|
|
204
|
+
const { help: _, ...rest } = view;
|
|
205
|
+
return { view: rest };
|
|
206
|
+
}
|
|
207
|
+
const { until, search, ...rest } = view;
|
|
208
|
+
if (until !== undefined || search !== undefined)
|
|
209
|
+
return { view: rest };
|
|
210
|
+
const { focus: _, ...all } = rest;
|
|
211
|
+
return { view: all };
|
|
212
|
+
}
|
|
213
|
+
default:
|
|
214
|
+
return { view };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* A key while a worktree's removal waits for the developer's word. Only `y` goes on, and for a
|
|
219
|
+
* worktree with uncommitted changes only its name typed out; any other key keeps it.
|
|
220
|
+
*/
|
|
221
|
+
function confirming(view, confirm, key, snapshot) {
|
|
222
|
+
const { confirm: _, ...rest } = view;
|
|
223
|
+
const { namespace, suffix } = confirm;
|
|
224
|
+
const env = snapshot.environments.find((e) => e.namespace === namespace);
|
|
225
|
+
if (key === "ctrl-c")
|
|
226
|
+
return { view: { ...rest, quit: true } };
|
|
227
|
+
if (env === undefined)
|
|
228
|
+
return { view: rest };
|
|
229
|
+
const removal = (force) => ({
|
|
230
|
+
view: rest,
|
|
231
|
+
intent: { kind: "remove", suffix, namespace, force },
|
|
232
|
+
});
|
|
233
|
+
const kept = (why = "") => ({ view: { ...rest, notice: `Kept ${suffix}${why}.` } });
|
|
234
|
+
if (confirm.step === "remove") {
|
|
235
|
+
if (typeof key !== "string")
|
|
236
|
+
return { view };
|
|
237
|
+
if (key !== "y")
|
|
238
|
+
return kept();
|
|
239
|
+
if (!env.changes)
|
|
240
|
+
return removal(false);
|
|
241
|
+
return { view: { ...rest, confirm: { ...confirm, step: "discard", typed: "" } } };
|
|
242
|
+
}
|
|
243
|
+
const typed = confirm.typed ?? "";
|
|
244
|
+
switch (key) {
|
|
245
|
+
case "return":
|
|
246
|
+
return typed === suffix ? removal(true) : kept(`: the name typed was not ${suffix}`);
|
|
247
|
+
case "escape":
|
|
248
|
+
return kept();
|
|
249
|
+
case "backspace":
|
|
250
|
+
return {
|
|
251
|
+
view: { ...view, confirm: { ...confirm, typed: [...typed].slice(0, -1).join("") } },
|
|
252
|
+
};
|
|
253
|
+
default: {
|
|
254
|
+
// A named key (an arrow, page up) is not text.
|
|
255
|
+
const text = textOf(key);
|
|
256
|
+
return text === undefined
|
|
257
|
+
? { view }
|
|
258
|
+
: { view: { ...view, confirm: { ...confirm, typed: typed + text } } };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** A key while a search is typed: text, or `return` to search, or `escape` to cancel. */
|
|
263
|
+
function typing(view, query, key, snapshot) {
|
|
264
|
+
const { search: _, ...rest } = view;
|
|
265
|
+
switch (key) {
|
|
266
|
+
case "ctrl-c":
|
|
267
|
+
return { ...view, quit: true };
|
|
268
|
+
case "escape":
|
|
269
|
+
return rest;
|
|
270
|
+
case "backspace":
|
|
271
|
+
return { ...view, search: { query: [...query].slice(0, -1).join(""), typing: true } };
|
|
272
|
+
case "return": {
|
|
273
|
+
if (query === "")
|
|
274
|
+
return rest;
|
|
275
|
+
const { until: __, ...live } = rest;
|
|
276
|
+
const found = find({ ...live, search: { query, typing: false } }, -1, snapshot);
|
|
277
|
+
return found.until === undefined ? { ...live, notice: `No line matches “${query}”.` } : found;
|
|
278
|
+
}
|
|
279
|
+
default: {
|
|
280
|
+
// A named key (an arrow, page up) is not text.
|
|
281
|
+
const text = textOf(key);
|
|
282
|
+
return text === undefined ? view : { ...view, search: { query: query + text, typing: true } };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The view at the next line that matches the search, ignoring case: older (`-1`) or newer (`1`)
|
|
288
|
+
* than the one shown, or the newest when live.
|
|
289
|
+
*/
|
|
290
|
+
function find(view, by, snapshot) {
|
|
291
|
+
const query = view.search?.query.toLowerCase() ?? "";
|
|
292
|
+
const lines = streamLines(snapshot, view);
|
|
293
|
+
const from = view.until === undefined ? lines.length : indexOf(lines, view.until);
|
|
294
|
+
for (let i = from + by; i >= 0 && i < lines.length; i += by) {
|
|
295
|
+
const line = lines[i];
|
|
296
|
+
if (line.text.toLowerCase().includes(query))
|
|
297
|
+
return { ...view, until: line.seq };
|
|
298
|
+
}
|
|
299
|
+
const which = by < 0 ? "older" : "newer";
|
|
300
|
+
return { ...view, notice: `No ${which} line matches “${view.search?.query}”.` };
|
|
301
|
+
}
|
|
302
|
+
/** A start, stop or restart of one part, or of the whole environment when none is given. */
|
|
303
|
+
function actionOn(env, part, kind) {
|
|
304
|
+
if (part !== undefined) {
|
|
305
|
+
const isApp = env.apps.includes(part);
|
|
306
|
+
return {
|
|
307
|
+
kind,
|
|
308
|
+
namespace: ofDaemon(part.id).namespace,
|
|
309
|
+
apps: isApp ? [part.name] : [],
|
|
310
|
+
services: isApp ? [] : [part.name],
|
|
311
|
+
label: part.name,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// A worktree's services on main's servers are main's: only main's own actions reach them.
|
|
315
|
+
const services = env.services.filter((s) => ofDaemon(s.id).namespace === env.namespace);
|
|
316
|
+
if (env.apps.length + services.length === 0)
|
|
317
|
+
return undefined;
|
|
318
|
+
return {
|
|
319
|
+
kind,
|
|
320
|
+
namespace: env.namespace,
|
|
321
|
+
apps: env.apps.map((a) => a.name),
|
|
322
|
+
services: services.map((s) => s.name),
|
|
323
|
+
label: env.label,
|
|
324
|
+
};
|
|
325
|
+
}
|