redkite 0.1.11 → 0.1.13
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 +76 -1
- package/dist/build.d.ts +3 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +14 -7
- package/dist/build.js.map +1 -1
- package/dist/cli/agent.d.ts +5 -1
- package/dist/cli/agent.d.ts.map +1 -1
- package/dist/cli/agent.js +7 -7
- package/dist/cli/agent.js.map +1 -1
- package/dist/cli/crash.d.ts +46 -0
- package/dist/cli/crash.d.ts.map +1 -0
- package/dist/cli/crash.js +204 -0
- package/dist/cli/crash.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +50 -15
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/log.d.ts +1 -0
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +4 -3
- package/dist/cli/log.js.map +1 -1
- package/dist/cli/screen.d.ts +3 -0
- package/dist/cli/screen.d.ts.map +1 -1
- package/dist/cli/screen.js +32 -6
- package/dist/cli/screen.js.map +1 -1
- package/dist/cli/viewer.d.ts.map +1 -1
- package/dist/cli/viewer.js +5 -5
- package/dist/cli/viewer.js.map +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +72 -6
- package/dist/deploy.js.map +1 -1
- package/dist/docker.d.ts +1 -0
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +5 -0
- package/dist/docker.js.map +1 -1
- package/dist/health.d.ts +2 -2
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +17 -9
- package/dist/health.js.map +1 -1
- package/dist/log.d.ts +3 -0
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +13 -0
- package/dist/log.js.map +1 -1
- package/dist/source.d.ts +2 -0
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +20 -0
- package/dist/source.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +23 -11
- package/src/cli/agent.ts +8 -8
- package/src/cli/crash.ts +287 -0
- package/src/cli/index.ts +59 -17
- package/src/cli/log.ts +4 -3
- package/src/cli/screen.ts +45 -4
- package/src/cli/viewer.ts +5 -4
- package/src/deploy.ts +88 -6
- package/src/docker.ts +6 -0
- package/src/health.ts +23 -16
- package/src/log.ts +19 -1
- package/src/source.ts +24 -0
- package/src/types.ts +9 -0
package/src/cli/agent.ts
CHANGED
|
@@ -21,14 +21,14 @@ function overSsh(repo: string) {
|
|
|
21
21
|
return !/^(https?|git|file):\/\//.test(repo);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
// ssh-add may ask for a passphrase, and it cannot ask through a screen
|
|
26
|
-
// something else is drawing
|
|
27
|
-
export function requireAgent(warn: (message: string) => void) {
|
|
28
|
-
const existing = process.env["SSH_AUTH_SOCK"];
|
|
29
|
-
if (existing) return existing;
|
|
24
|
+
export const AGENT_STARTED = "no SSH agent was running, so one was started";
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
// Runs before the view opens, because ssh-add may ask for a passphrase and it
|
|
27
|
+
// cannot ask through a screen something else is drawing. Whether one was started
|
|
28
|
+
// is answered rather than said, so the step that opens the connection says it
|
|
29
|
+
export function requireAgent() {
|
|
30
|
+
const existing = process.env["SSH_AUTH_SOCK"];
|
|
31
|
+
if (existing) return { socket: existing, started: false };
|
|
32
32
|
|
|
33
33
|
const output = execFileSync("ssh-agent", ["-s"], { encoding: "utf8" });
|
|
34
34
|
const socket = output.match(/SSH_AUTH_SOCK=([^;]+);/)?.[1];
|
|
@@ -51,5 +51,5 @@ export function requireAgent(warn: (message: string) => void) {
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
return socket;
|
|
54
|
+
return { socket, started: true };
|
|
55
55
|
}
|
package/src/cli/crash.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { untagged, type Task } from "../log.js";
|
|
5
|
+
import type { Deployment } from "../types.js";
|
|
6
|
+
import { elapsed } from "./screen.js";
|
|
7
|
+
import { causes } from "./log.js";
|
|
8
|
+
import type { Viewer } from "./viewer.js";
|
|
9
|
+
|
|
10
|
+
// What a failed run said, kept whole. The view trims a step to its last lines
|
|
11
|
+
// and the alternate screen takes the rest with it, so the only complete record
|
|
12
|
+
// of a run that went wrong is one written down while it happened.
|
|
13
|
+
|
|
14
|
+
// Named rather than read from TMPDIR: the path is the one a person is told to
|
|
15
|
+
// look in, and a runner that points TMPDIR somewhere else would hide it
|
|
16
|
+
const ROOT = "/tmp";
|
|
17
|
+
|
|
18
|
+
type Kind = "info" | "warn" | "fail" | "done" | "plan" | "step" | "command";
|
|
19
|
+
|
|
20
|
+
type Entry = { at: number; kind: Kind; text: string };
|
|
21
|
+
|
|
22
|
+
type Said = { at: number; kind: "detail" | "line"; text: string };
|
|
23
|
+
|
|
24
|
+
type StepRecord = {
|
|
25
|
+
label: string;
|
|
26
|
+
started: number;
|
|
27
|
+
ended?: number;
|
|
28
|
+
state: "running" | "done" | "failed";
|
|
29
|
+
note?: string;
|
|
30
|
+
said: Said[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type Transcript = {
|
|
34
|
+
started: number;
|
|
35
|
+
entries: Entry[];
|
|
36
|
+
steps: StepRecord[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type Crash = {
|
|
40
|
+
project: string;
|
|
41
|
+
environment: string;
|
|
42
|
+
command: string;
|
|
43
|
+
version: string;
|
|
44
|
+
argv: string[];
|
|
45
|
+
outcome: string;
|
|
46
|
+
at: number;
|
|
47
|
+
error?: unknown;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Every call reaches the wrapped log unchanged, so the view behaves as it did.
|
|
51
|
+
// The transcript is a copy kept beside it, never a replacement for it
|
|
52
|
+
export function recording(log: Viewer, now: () => number = Date.now) {
|
|
53
|
+
const transcript: Transcript = { started: now(), entries: [], steps: [] };
|
|
54
|
+
const note = (kind: Kind, text: string) => transcript.entries.push({ at: now(), kind, text });
|
|
55
|
+
|
|
56
|
+
const step = (label: string): Task => {
|
|
57
|
+
const record: StepRecord = { label, started: now(), state: "running", said: [] };
|
|
58
|
+
const task = log.step(label);
|
|
59
|
+
|
|
60
|
+
transcript.steps.push(record);
|
|
61
|
+
note("step", `${label} started`);
|
|
62
|
+
|
|
63
|
+
const settle = (state: "done" | "failed", message?: string) => {
|
|
64
|
+
record.state = state;
|
|
65
|
+
record.ended = now();
|
|
66
|
+
record.note = message;
|
|
67
|
+
|
|
68
|
+
const said = message ? `: ${message}` : "";
|
|
69
|
+
note("step", `${label} ${state}${said} (${elapsed(record.ended, record.started)})`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
detail: (message) => {
|
|
74
|
+
record.said.push({ at: now(), kind: "detail", text: message });
|
|
75
|
+
task.detail(message);
|
|
76
|
+
},
|
|
77
|
+
line: (message) => {
|
|
78
|
+
record.said.push({ at: now(), kind: "line", text: message });
|
|
79
|
+
task.line(message);
|
|
80
|
+
},
|
|
81
|
+
done: (message) => {
|
|
82
|
+
settle("done", message);
|
|
83
|
+
task.done(message);
|
|
84
|
+
},
|
|
85
|
+
fail: (message) => {
|
|
86
|
+
settle("failed", message);
|
|
87
|
+
task.fail(message);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const recorded: Viewer = Object.assign(
|
|
93
|
+
(message: string) => {
|
|
94
|
+
note("info", message);
|
|
95
|
+
log(message);
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
plan: (points: string[]) => {
|
|
99
|
+
note("plan", points.join(", "));
|
|
100
|
+
log.plan?.(points);
|
|
101
|
+
},
|
|
102
|
+
warn: (message: string) => {
|
|
103
|
+
note("warn", message);
|
|
104
|
+
log.warn(message);
|
|
105
|
+
},
|
|
106
|
+
fail: (message: string) => {
|
|
107
|
+
note("fail", message);
|
|
108
|
+
log.fail(message);
|
|
109
|
+
},
|
|
110
|
+
done: (message: string) => {
|
|
111
|
+
note("done", message);
|
|
112
|
+
log.done(message);
|
|
113
|
+
},
|
|
114
|
+
step,
|
|
115
|
+
close: () => log.close(),
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// A host command is only shown with --verbose, and is exactly what a crash log
|
|
120
|
+
// is read for, so it is kept whether it was shown or not
|
|
121
|
+
const command = (text: string) => note("command", text);
|
|
122
|
+
|
|
123
|
+
return { log: recorded, transcript, command };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// One directory per failed run, under the environment it failed in. Named from
|
|
127
|
+
// the command line, so both segments are made safe to be a path first
|
|
128
|
+
export function crashDirectory(project: string, environment: string, at: Date, root = ROOT) {
|
|
129
|
+
const moment = at.toISOString().replaceAll(":", "-");
|
|
130
|
+
return join(root, slug(project), slug(environment), `crash-${moment}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The run's own log, and one file per step. A build's thousands of lines would
|
|
134
|
+
// bury everything else in a single file, and a step's file is what gets opened
|
|
135
|
+
// first. Numbered in the order they started, which also keeps two steps that
|
|
136
|
+
// share a label from sharing a file
|
|
137
|
+
export function crashFiles(transcript: Transcript, crash: Crash): Record<string, string> {
|
|
138
|
+
const width = Math.max(2, String(transcript.steps.length).length);
|
|
139
|
+
|
|
140
|
+
const named = transcript.steps.map((step, index) => ({
|
|
141
|
+
step,
|
|
142
|
+
file: `${pad(index + 1, width)}-${slug(step.label)}.log`,
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"run.log": runLog(transcript, crash, named),
|
|
147
|
+
...Object.fromEntries(
|
|
148
|
+
named.map(({ step, file }) => [file, stepLog(transcript, crash, step)]),
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Nothing is written for a deployment that turned it off. Otherwise private to
|
|
154
|
+
// whoever ran it: /tmp is shared by everyone on the machine, and a build's output
|
|
155
|
+
// can say more than it meant to
|
|
156
|
+
export async function dumpCrash(
|
|
157
|
+
config: Deployment,
|
|
158
|
+
transcript: Transcript,
|
|
159
|
+
crash: Crash,
|
|
160
|
+
root = ROOT,
|
|
161
|
+
) {
|
|
162
|
+
if (config.options?.crashLog === false) return undefined;
|
|
163
|
+
|
|
164
|
+
const directory = crashDirectory(config.project, crash.environment, new Date(crash.at), root);
|
|
165
|
+
await mkdir(dirname(directory), { recursive: true, mode: 0o700 });
|
|
166
|
+
|
|
167
|
+
// Not recursive: a directory already there belongs to another run, and nothing
|
|
168
|
+
// of this one is written into it
|
|
169
|
+
await mkdir(directory, { mode: 0o700 });
|
|
170
|
+
|
|
171
|
+
for (const [name, contents] of Object.entries(crashFiles(transcript, crash))) {
|
|
172
|
+
await writeFile(join(directory, name), contents, { mode: 0o600, flag: "wx" });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return directory;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const MARK: Record<Kind, string> = {
|
|
179
|
+
info: "",
|
|
180
|
+
warn: "warn ",
|
|
181
|
+
fail: "fail ",
|
|
182
|
+
done: "done ",
|
|
183
|
+
plan: "plan ",
|
|
184
|
+
step: "step ",
|
|
185
|
+
command: "",
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
type Named = { step: StepRecord; file: string };
|
|
189
|
+
|
|
190
|
+
function runLog(transcript: Transcript, crash: Crash, named: Named[]) {
|
|
191
|
+
const since = sinceStart(transcript);
|
|
192
|
+
|
|
193
|
+
const index = named.map(({ step, file }) => {
|
|
194
|
+
const took = elapsed(step.ended ?? crash.at, step.started);
|
|
195
|
+
return `${file.padEnd(40)} ${step.state.padEnd(8)} ${took}`;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return lines([
|
|
199
|
+
"redkite crash log",
|
|
200
|
+
"",
|
|
201
|
+
`project ${crash.project}`,
|
|
202
|
+
`environment ${crash.environment}`,
|
|
203
|
+
`command ${crash.command}`,
|
|
204
|
+
`outcome ${crash.outcome}`,
|
|
205
|
+
`version ${crash.version}`,
|
|
206
|
+
`argv ${crash.argv.join(" ")}`,
|
|
207
|
+
`started ${new Date(transcript.started).toISOString()}`,
|
|
208
|
+
`ended ${new Date(crash.at).toISOString()}`,
|
|
209
|
+
"",
|
|
210
|
+
"== error",
|
|
211
|
+
...errorLines(crash.error),
|
|
212
|
+
"",
|
|
213
|
+
"== steps, each in its own file beside this one",
|
|
214
|
+
...(index.length > 0 ? index : ["none started"]),
|
|
215
|
+
"",
|
|
216
|
+
"== log",
|
|
217
|
+
...transcript.entries.map(
|
|
218
|
+
(entry) => `${since(entry.at)} ${MARK[entry.kind]}${indent(entry.text)}`,
|
|
219
|
+
),
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Stamped from the start of the run, the same as run.log, so a line here can be
|
|
224
|
+
// found among what the run said around it
|
|
225
|
+
function stepLog(transcript: Transcript, crash: Crash, step: StepRecord) {
|
|
226
|
+
const since = sinceStart(transcript);
|
|
227
|
+
const note = step.note ? `: ${step.note}` : "";
|
|
228
|
+
|
|
229
|
+
return lines([
|
|
230
|
+
`step ${step.label}`,
|
|
231
|
+
`state ${step.state}${indent(note)}`,
|
|
232
|
+
`started ${new Date(step.started).toISOString()}`,
|
|
233
|
+
`ended ${step.ended ? new Date(step.ended).toISOString() : "still running when the run ended"}`,
|
|
234
|
+
`took ${elapsed(step.ended ?? crash.at, step.started)}`,
|
|
235
|
+
"",
|
|
236
|
+
...step.said.map(
|
|
237
|
+
(said) => `${since(said.at)} ${said.kind === "detail" ? "· " : "| "}${indent(said.text)}`,
|
|
238
|
+
),
|
|
239
|
+
]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Whole, where the terminal gets the tail: the stack of every error in the
|
|
243
|
+
// chain, since the step that threw is usually two causes down
|
|
244
|
+
function errorLines(error: unknown) {
|
|
245
|
+
if (error === undefined) return ["none thrown"];
|
|
246
|
+
if (!(error instanceof Error)) return [String(error)];
|
|
247
|
+
|
|
248
|
+
return causes(error).flatMap((link, index) => [
|
|
249
|
+
...(index > 0 ? ["", "caused by"] : []),
|
|
250
|
+
link.stack ?? link.message,
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sinceStart(transcript: Transcript) {
|
|
255
|
+
return (at: number) => `+${clock(at - transcript.started)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function lines(rows: string[]) {
|
|
259
|
+
return `${rows.join("\n")}\n`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Continuation lines line up under the text rather than under the timestamp. A
|
|
263
|
+
// file cannot draw a tag, so it gets the bracketed word
|
|
264
|
+
function indent(text: string) {
|
|
265
|
+
return untagged(text).replaceAll("\n", "\n ");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function slug(text: string) {
|
|
269
|
+
const slugged = text
|
|
270
|
+
.toLowerCase()
|
|
271
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
272
|
+
.replace(/^-+|-+$/g, "");
|
|
273
|
+
|
|
274
|
+
return slugged || "unnamed";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function clock(ms: number) {
|
|
278
|
+
const minutes = Math.floor(ms / 60_000);
|
|
279
|
+
const seconds = Math.floor(ms / 1000) % 60;
|
|
280
|
+
const millis = ms % 1000;
|
|
281
|
+
|
|
282
|
+
return `${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(millis, 3)}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function pad(value: number, width: number) {
|
|
286
|
+
return String(value).padStart(width, "0");
|
|
287
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -14,9 +14,10 @@ import { sshHost } from "../sshHost.js";
|
|
|
14
14
|
import { topologyFor, type Topology } from "../topology.js";
|
|
15
15
|
import type { Deployment, DeployHost } from "../types.js";
|
|
16
16
|
|
|
17
|
-
import { needsAgent, requireAgent } from "./agent.js";
|
|
17
|
+
import { AGENT_STARTED, needsAgent, requireAgent } from "./agent.js";
|
|
18
18
|
import { loadConfig, projectRoot } from "./config.js";
|
|
19
19
|
import { loadDotenv } from "./dotenv.js";
|
|
20
|
+
import { dumpCrash, recording } from "./crash.js";
|
|
20
21
|
import { createLog, describeFailure } from "./log.js";
|
|
21
22
|
|
|
22
23
|
// One command that reads the config and does everything under it: the agent,
|
|
@@ -42,7 +43,7 @@ of the project, and everything below it is derived.
|
|
|
42
43
|
|
|
43
44
|
// Wraps a host rather than living inside one, so both implementations are
|
|
44
45
|
// measured the same way and neither knows it is being timed
|
|
45
|
-
function measured(host: Host,
|
|
46
|
+
function measured(host: Host, say?: (line: string) => void) {
|
|
46
47
|
const totals = { commands: 0, commandMs: 0, files: 0 };
|
|
47
48
|
|
|
48
49
|
const wrapped: Host = {
|
|
@@ -61,7 +62,7 @@ function measured(host: Host, log?: Log) {
|
|
|
61
62
|
|
|
62
63
|
// The exit code matters: several of these are allowed to fail, and a
|
|
63
64
|
// deploy reading verbose output is one where somebody wants to know which
|
|
64
|
-
|
|
65
|
+
say?.(` $ ${command} ${Date.now() - started}ms exit ${result.code}`);
|
|
65
66
|
return result;
|
|
66
67
|
},
|
|
67
68
|
|
|
@@ -180,7 +181,9 @@ async function recover(
|
|
|
180
181
|
const deployHost = environmentOf(config, environment)?.host;
|
|
181
182
|
|
|
182
183
|
const say = (message = "") => process.stdout.write(`${message}\n`);
|
|
183
|
-
|
|
184
|
+
// Printed rather than drawn: a recovery has no view, and its connection
|
|
185
|
+
// step is silent
|
|
186
|
+
if (needsAgent(config, environment) && requireAgent().started) say(AGENT_STARTED);
|
|
184
187
|
|
|
185
188
|
const host = await hostFor(deployHost, silent);
|
|
186
189
|
|
|
@@ -484,14 +487,26 @@ export function buildingHere(config: Deployment, environment: string): Deploymen
|
|
|
484
487
|
|
|
485
488
|
// Without a bastion the containers are on this machine, and every command the
|
|
486
489
|
// deploy issues is one this process can run itself
|
|
487
|
-
async function hostFor(
|
|
488
|
-
|
|
490
|
+
async function hostFor(
|
|
491
|
+
host: DeployHost | undefined,
|
|
492
|
+
log: Log,
|
|
493
|
+
signal?: AbortSignal,
|
|
494
|
+
startedAgent = false,
|
|
495
|
+
) {
|
|
496
|
+
if (!host?.bastion) {
|
|
497
|
+
// No connection to say it in. A run on this machine that clones over ssh
|
|
498
|
+
// still started one, and that is still worth knowing
|
|
499
|
+
if (startedAgent) log.warn(AGENT_STARTED);
|
|
500
|
+
return await localHost({ signal });
|
|
501
|
+
}
|
|
489
502
|
|
|
490
503
|
const task = log.step(`Opening a connection to ${host.bastion}`);
|
|
504
|
+
if (startedAgent) task.detail(AGENT_STARTED);
|
|
491
505
|
|
|
492
506
|
try {
|
|
493
507
|
const opened = await sshHost(host.bastion, { signal, hostKeys: host.hostKeys });
|
|
494
|
-
|
|
508
|
+
// Kept on the row once the connection is open, not only while it opens
|
|
509
|
+
task.done(startedAgent ? AGENT_STARTED : undefined);
|
|
495
510
|
return opened;
|
|
496
511
|
} catch (error) {
|
|
497
512
|
task.fail(`Could not reach ${host.bastion}`);
|
|
@@ -560,18 +575,43 @@ async function run(
|
|
|
560
575
|
// The host clones the repositories over this, so it has to exist before the
|
|
561
576
|
// connection that forwards it is opened. Before the view too: ssh-add asks
|
|
562
577
|
// for a passphrase on the terminal, and by then the view owns it
|
|
563
|
-
|
|
564
|
-
requireAgent((message) => process.stderr.write(`${message}\n`));
|
|
565
|
-
}
|
|
578
|
+
const agent = needsAgent(config, environment) ? requireAgent() : undefined;
|
|
566
579
|
|
|
567
580
|
// Whatever is in flight is killed, the pipeline unwinds through its own
|
|
568
581
|
// failure path, and the finally below removes the scratch directory
|
|
569
582
|
const stopping = new AbortController();
|
|
570
583
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
584
|
+
// Everything the run says still reaches the view. The recording is what a
|
|
585
|
+
// crash log is written from, since the view keeps only each step's tail
|
|
586
|
+
const recorder = recording(
|
|
587
|
+
createLog({
|
|
588
|
+
...options,
|
|
589
|
+
onQuit: () => stop(),
|
|
590
|
+
}),
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const log = recorder.log;
|
|
594
|
+
|
|
595
|
+
// Before the view closes, so where the log went is among what it writes out.
|
|
596
|
+
// A log that cannot be written must not replace the failure it was recording
|
|
597
|
+
const dumped = async (outcome: string, error?: unknown) => {
|
|
598
|
+
try {
|
|
599
|
+
const path = await dumpCrash(config, recorder.transcript, {
|
|
600
|
+
project: config.project,
|
|
601
|
+
environment,
|
|
602
|
+
command: kind,
|
|
603
|
+
version: await version(),
|
|
604
|
+
argv: process.argv.slice(2),
|
|
605
|
+
outcome,
|
|
606
|
+
at: Date.now(),
|
|
607
|
+
error,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
if (path) log.warn(`Crash logs written to ${path}`);
|
|
611
|
+
} catch (failure) {
|
|
612
|
+
log.warn(`Could not write the crash log: ${describeFailure(failure).split("\n")[0]}`);
|
|
613
|
+
}
|
|
614
|
+
};
|
|
575
615
|
|
|
576
616
|
// Read by the wait below, so a press during it hardens what that is sending
|
|
577
617
|
let hardest: "TERM" | "KILL" = "TERM";
|
|
@@ -601,8 +641,8 @@ async function run(
|
|
|
601
641
|
|
|
602
642
|
try {
|
|
603
643
|
const meter = measured(
|
|
604
|
-
await hostFor(deployHost, log, stopping.signal),
|
|
605
|
-
options.verbose ? log :
|
|
644
|
+
await hostFor(deployHost, log, stopping.signal, agent?.started),
|
|
645
|
+
options.verbose ? log : recorder.command,
|
|
606
646
|
);
|
|
607
647
|
|
|
608
648
|
host = meter.host;
|
|
@@ -633,15 +673,17 @@ async function run(
|
|
|
633
673
|
|
|
634
674
|
log.fail(`Reverted ${result.reverted.join(", ")}`);
|
|
635
675
|
process.exitCode = 1;
|
|
676
|
+
await dumped("reverted");
|
|
636
677
|
} catch (error) {
|
|
637
678
|
// Whatever the command in flight said about being killed is noise: what
|
|
638
|
-
// happened is that somebody asked for it to stop
|
|
679
|
+
// happened is that somebody asked for it to stop, which is not a crash
|
|
639
680
|
if (stopping.signal.aborted) {
|
|
640
681
|
log.fail("Stopped");
|
|
641
682
|
process.exitCode = 130;
|
|
642
683
|
} else {
|
|
643
684
|
log.fail(describeFailure(error));
|
|
644
685
|
process.exitCode = 1;
|
|
686
|
+
await dumped("failed", error);
|
|
645
687
|
}
|
|
646
688
|
} finally {
|
|
647
689
|
// Nothing leaves while the build is still running. The signal a press
|
package/src/cli/log.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Task } from "../log.js";
|
|
2
2
|
|
|
3
|
-
import { elapsed as took } from "./screen.js";
|
|
3
|
+
import { paintTags, elapsed as took } from "./screen.js";
|
|
4
4
|
import { createViewer, type Viewer } from "./viewer.js";
|
|
5
5
|
|
|
6
6
|
// What a deploy prints. The engine's own trace is a build graph with span ids
|
|
@@ -48,7 +48,8 @@ function plainLog(lines: boolean) {
|
|
|
48
48
|
const write = (stream: NodeJS.WriteStream, message: string, colour?: number) => {
|
|
49
49
|
const painted = colours(stream);
|
|
50
50
|
const stamp = painted ? paint(elapsed(), STAMP) : elapsed();
|
|
51
|
-
|
|
51
|
+
// A tag is drawn where there is colour and bracketed where there is not
|
|
52
|
+
const body = paintTags(message, painted, colour);
|
|
52
53
|
const line = `${stamp} ${body}\n`;
|
|
53
54
|
|
|
54
55
|
// Both streams share one terminal, so the block has to come down for a
|
|
@@ -146,7 +147,7 @@ export function describeFailure(error: unknown) {
|
|
|
146
147
|
|
|
147
148
|
const DEPTH = 8;
|
|
148
149
|
|
|
149
|
-
function causes(error: Error) {
|
|
150
|
+
export function causes(error: Error) {
|
|
150
151
|
const chain: Error[] = [];
|
|
151
152
|
let current: unknown = error;
|
|
152
153
|
|
package/src/cli/screen.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TAGS, untagged } from "../log.js";
|
|
2
|
+
|
|
1
3
|
// The deploy as a list of collapsibles. A step is a title and the lines it
|
|
2
4
|
// produced; the one running is open, the ones finished are shut, and the person
|
|
3
5
|
// watching moves between them. The model and the renderer are pure, so what the
|
|
@@ -199,14 +201,53 @@ const RUNNING = 93;
|
|
|
199
201
|
const DONE = 32;
|
|
200
202
|
const FAILED = 91;
|
|
201
203
|
const WARN = 33;
|
|
204
|
+
// A tag is a blue label with rounded ends: the ends in blue on the terminal's own
|
|
205
|
+
// ground, and the word white on blue between them
|
|
206
|
+
const TAG_EDGE = 34;
|
|
207
|
+
const TAG_BODY = "97;44";
|
|
208
|
+
|
|
209
|
+
type Colour = number | string;
|
|
202
210
|
|
|
203
|
-
type Piece = { text: string; colour?:
|
|
211
|
+
type Piece = { text: string; colour?: Colour };
|
|
204
212
|
|
|
205
|
-
function paint(text: string, colour:
|
|
213
|
+
function paint(text: string, colour: Colour | undefined, on: boolean) {
|
|
206
214
|
if (!on || colour === undefined || text === "") return text;
|
|
207
215
|
return `\u001b[${colour}m${text}\u001b[0m`;
|
|
208
216
|
}
|
|
209
217
|
|
|
218
|
+
// Text that may carry a tag, as pieces, so the row is measured before any escape
|
|
219
|
+
// is added. The rounded ends are a column each, the same as the brackets drawn
|
|
220
|
+
// in their place without colour, so a row is the same width either way
|
|
221
|
+
function tagged(text: string, colour: Colour | undefined, on: boolean): Piece[] {
|
|
222
|
+
if (!on) return [{ text: untagged(text), colour }];
|
|
223
|
+
|
|
224
|
+
const pieces: Piece[] = [];
|
|
225
|
+
let at = 0;
|
|
226
|
+
|
|
227
|
+
for (const match of text.matchAll(TAGS)) {
|
|
228
|
+
if (match.index > at) pieces.push({ text: text.slice(at, match.index), colour });
|
|
229
|
+
|
|
230
|
+
pieces.push(
|
|
231
|
+
{ text: "\u25d6", colour: TAG_EDGE },
|
|
232
|
+
{ text: match[1] ?? "", colour: TAG_BODY },
|
|
233
|
+
{ text: "\u25d7", colour: TAG_EDGE },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
at = match.index + match[0].length;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (at < text.length) pieces.push({ text: text.slice(at), colour });
|
|
240
|
+
return pieces;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// For what is written as a whole line rather than drawn into a row: the summary
|
|
244
|
+
// left on the screen, and the plain log. The line's own colour resumes after
|
|
245
|
+
export function paintTags(text: string, on: boolean, colour?: Colour) {
|
|
246
|
+
return tagged(text, colour, on)
|
|
247
|
+
.map((piece) => paint(piece.text, piece.colour, on))
|
|
248
|
+
.join("");
|
|
249
|
+
}
|
|
250
|
+
|
|
210
251
|
function widthOf(pieces: Piece[]) {
|
|
211
252
|
return pieces.reduce((total, piece) => total + piece.text.length, 0);
|
|
212
253
|
}
|
|
@@ -362,7 +403,7 @@ function said(model: Model, message: Message, colour: boolean) {
|
|
|
362
403
|
[
|
|
363
404
|
{ text: gutter(model, message.at), colour: DIM },
|
|
364
405
|
{ text: " " },
|
|
365
|
-
|
|
406
|
+
...tagged(message.text, WARN, colour),
|
|
366
407
|
],
|
|
367
408
|
Math.max(24, model.columns),
|
|
368
409
|
colour,
|
|
@@ -485,7 +526,7 @@ function title(model: Model, step: Step, index: number, colour: boolean) {
|
|
|
485
526
|
{ text: glyph, colour: stateColour(step) },
|
|
486
527
|
{ text: " " },
|
|
487
528
|
{ text: step.label, colour: labelColour(step, selected) },
|
|
488
|
-
|
|
529
|
+
...tagged(said, DIM, colour),
|
|
489
530
|
{ text: quiet(model, step), colour: WARN },
|
|
490
531
|
];
|
|
491
532
|
|
package/src/cli/viewer.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
elapsed,
|
|
6
6
|
emptyModel,
|
|
7
7
|
keysOf,
|
|
8
|
+
paintTags,
|
|
8
9
|
render,
|
|
9
10
|
type Model,
|
|
10
11
|
type Step,
|
|
@@ -113,7 +114,7 @@ export function createViewer(
|
|
|
113
114
|
stream.write(ALTERNATE_OFF);
|
|
114
115
|
// The alternate screen takes every frame with it, so the run has to be
|
|
115
116
|
// written again on the screen the person keeps
|
|
116
|
-
for (const line of summary(model)) stream.write(`${line}\n`);
|
|
117
|
+
for (const line of summary(model, colour)) stream.write(`${line}\n`);
|
|
117
118
|
};
|
|
118
119
|
|
|
119
120
|
// What a second press means is not the view's to decide: the same key
|
|
@@ -191,18 +192,18 @@ export function createViewer(
|
|
|
191
192
|
|
|
192
193
|
// One line per step and whatever was said outside them, which is the record a
|
|
193
194
|
// person scrolls back to after the deploy is over
|
|
194
|
-
function summary(model: Model): string[] {
|
|
195
|
+
function summary(model: Model, colour: boolean): string[] {
|
|
195
196
|
const rows = model.steps.map((step) => {
|
|
196
197
|
const glyph = step.state === "done" ? "✔" : step.state === "failed" ? "✘" : "·";
|
|
197
198
|
const said = step.note ? `: ${step.note}` : "";
|
|
198
199
|
const took = elapsed(step.ended ?? model.now, step.started);
|
|
199
200
|
|
|
200
|
-
return `${glyph} ${step.label}${said} (${took})
|
|
201
|
+
return paintTags(`${glyph} ${step.label}${said} (${took})`, colour);
|
|
201
202
|
});
|
|
202
203
|
|
|
203
204
|
return [
|
|
204
205
|
...rows,
|
|
205
|
-
...model.messages.map((message) => message.text),
|
|
206
|
+
...model.messages.map((message) => paintTags(message.text, colour)),
|
|
206
207
|
...whatFailed(model),
|
|
207
208
|
];
|
|
208
209
|
}
|