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