redkite 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -12
- package/dist/build.d.ts +3 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +18 -2
- package/dist/build.js.map +1 -1
- package/dist/cli/agent.js +2 -2
- package/dist/cli/agent.js.map +1 -1
- package/dist/cli/config.d.ts +1 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +82 -16
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/dotenv.d.ts +2 -0
- package/dist/cli/dotenv.d.ts.map +1 -0
- package/dist/cli/dotenv.js +30 -0
- package/dist/cli/dotenv.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +11 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/screen.d.ts +10 -3
- package/dist/cli/screen.d.ts.map +1 -1
- package/dist/cli/screen.js +153 -36
- package/dist/cli/screen.js.map +1 -1
- package/dist/cli/viewer.d.ts.map +1 -1
- package/dist/cli/viewer.js +36 -4
- package/dist/cli/viewer.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +12 -2
- package/dist/deploy.js.map +1 -1
- package/dist/docker.d.ts +1 -0
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +3 -0
- package/dist/docker.js.map +1 -1
- package/dist/dockerfile.d.ts +1 -0
- package/dist/dockerfile.d.ts.map +1 -1
- package/dist/dockerfile.js +16 -3
- package/dist/dockerfile.js.map +1 -1
- package/dist/environment.d.ts +2 -0
- package/dist/environment.d.ts.map +1 -1
- package/dist/environment.js +56 -1
- package/dist/environment.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/log.d.ts +1 -0
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +1 -0
- package/dist/log.js.map +1 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +3 -0
- package/dist/pipeline.js.map +1 -1
- package/dist/plugins/bitwarden.d.ts.map +1 -1
- package/dist/plugins/bitwarden.js +25 -6
- package/dist/plugins/bitwarden.js.map +1 -1
- package/dist/presets/nextApp.d.ts.map +1 -1
- package/dist/presets/nextApp.js +5 -4
- package/dist/presets/nextApp.js.map +1 -1
- package/dist/presets/nodeApp.d.ts.map +1 -1
- package/dist/presets/nodeApp.js +15 -6
- package/dist/presets/nodeApp.js.map +1 -1
- package/dist/secrets/manager.d.ts +9 -0
- package/dist/secrets/manager.d.ts.map +1 -0
- package/dist/secrets/manager.js +130 -0
- package/dist/secrets/manager.js.map +1 -0
- package/dist/secrets/store.d.ts.map +1 -1
- package/dist/secrets/store.js +46 -12
- package/dist/secrets/store.js.map +1 -1
- package/dist/services/ensure.js +1 -1
- package/dist/services/ensure.js.map +1 -1
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +11 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/planned.d.ts +0 -1
- package/dist/services/planned.d.ts.map +1 -1
- package/dist/services/planned.js +3 -12
- package/dist/services/planned.js.map +1 -1
- package/dist/services/proxy.d.ts +7 -0
- package/dist/services/proxy.d.ts.map +1 -0
- package/dist/services/proxy.js +103 -0
- package/dist/services/proxy.js.map +1 -0
- package/dist/source.d.ts +7 -1
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +17 -9
- package/dist/source.js.map +1 -1
- package/dist/steps.d.ts +1 -6
- package/dist/steps.d.ts.map +1 -1
- package/dist/steps.js +3 -3
- package/dist/steps.js.map +1 -1
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +22 -2
- package/src/cli/agent.ts +2 -2
- package/src/cli/config.ts +96 -15
- package/src/cli/dotenv.ts +35 -0
- package/src/cli/index.ts +11 -5
- package/src/cli/screen.ts +193 -43
- package/src/cli/viewer.ts +41 -4
- package/src/config.ts +19 -0
- package/src/deploy.ts +14 -2
- package/src/docker.ts +5 -0
- package/src/dockerfile.ts +23 -3
- package/src/environment.ts +71 -1
- package/src/index.ts +2 -1
- package/src/log.ts +4 -0
- package/src/pipeline.ts +4 -0
- package/src/plugins/bitwarden.ts +38 -11
- package/src/presets/nextApp.ts +5 -4
- package/src/presets/nodeApp.ts +15 -6
- package/src/secrets/manager.ts +172 -0
- package/src/secrets/store.ts +51 -16
- package/src/services/ensure.ts +1 -1
- package/src/services/index.ts +13 -1
- package/src/services/planned.ts +3 -13
- package/src/services/proxy.ts +126 -0
- package/src/source.ts +33 -9
- package/src/steps.ts +7 -7
- package/src/types.ts +26 -4
- package/dist/nginx.d.ts +0 -4
- package/dist/nginx.d.ts.map +0 -1
- package/dist/nginx.js +0 -53
- package/dist/nginx.js.map +0 -1
- package/src/nginx.ts +0 -63
package/src/cli/config.ts
CHANGED
|
@@ -16,8 +16,16 @@ const EXTENSIONS = ["ts", "mts", "js", "mjs"] as const;
|
|
|
16
16
|
|
|
17
17
|
const CANDIDATES = EXTENSIONS.map((extension) => `redkite.config.${extension}`);
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
|
|
19
|
+
// Anything beside the deployment that starts with redkite and is a module it
|
|
20
|
+
// could read. The .config in the middle is what people write and what the docs
|
|
21
|
+
// say, and it is optional here because a file that was meant to be an
|
|
22
|
+
// environment and is named slightly differently should be read and refused
|
|
23
|
+
// rather than passed over as though it were not there
|
|
24
|
+
const PER_ENVIRONMENT = /^redkite[.\-_](.+?)(?:\.config)?\.(?:ts|mts|cts|js|mjs|cjs)$/;
|
|
25
|
+
|
|
26
|
+
// What an environment may be called. It becomes part of an image tag, and an
|
|
27
|
+
// image tag cannot hold a capital, so this is docker's limit rather than ours
|
|
28
|
+
const ENVIRONMENT_NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
21
29
|
|
|
22
30
|
// Node strips types itself from 22.18 on, which is what lets redkite ship with
|
|
23
31
|
// no dependencies. These are the ways that can fail on a config it cannot read.
|
|
@@ -34,26 +42,46 @@ export async function loadConfig(explicit?: string): Promise<Deployment> {
|
|
|
34
42
|
const path = explicit ? resolve(explicit) : discover(process.cwd());
|
|
35
43
|
const config = defaultOf(await load(path), path) as Deployment;
|
|
36
44
|
|
|
45
|
+
const manifest = manifestOf(explicit ? dirname(path) : process.cwd());
|
|
37
46
|
const beside = await loadEnvironments(dirname(path));
|
|
38
|
-
const named = await loadNamed(manifestOf(explicit ? dirname(path) : process.cwd()));
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
// The directory package.json names is read whether or not the deployment
|
|
49
|
+
// turned out to be in it, so environments may sit together under one roof
|
|
50
|
+
// with the deployment at the root above them
|
|
51
|
+
const declared = directoryOf(manifest);
|
|
52
|
+
const under = declared && declared !== dirname(path) ? await loadEnvironments(declared) : {};
|
|
53
|
+
|
|
54
|
+
const named = await loadNamed(manifest);
|
|
55
|
+
const found = { ...beside, ...under };
|
|
56
|
+
|
|
57
|
+
for (const name of Object.keys(under)) {
|
|
41
58
|
if (!beside[name]) continue;
|
|
42
59
|
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${name} sits both beside the deployment and in ${declared}, ` +
|
|
62
|
+
"and an environment comes from one place or the other",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const name of Object.keys(named)) {
|
|
67
|
+
if (!found[name]) continue;
|
|
68
|
+
|
|
43
69
|
throw new Error(
|
|
44
70
|
`${name} is named by package.json and also sits beside the deployment. ` +
|
|
45
71
|
"An environment comes from one place or the other",
|
|
46
72
|
);
|
|
47
73
|
}
|
|
48
74
|
|
|
49
|
-
return { ...rooted(config, dirname(path)), environments: { ...
|
|
75
|
+
return { ...rooted(config, dirname(path)), environments: { ...found, ...named } };
|
|
50
76
|
}
|
|
51
77
|
|
|
52
78
|
// A source path belongs to the file that named it, not to wherever the command
|
|
53
79
|
// was run. Resolving it here is what lets a deploy from a workspace and one
|
|
54
80
|
// from the root build the same tree
|
|
55
81
|
function rooted(config: Deployment, directory: string): Deployment {
|
|
56
|
-
|
|
82
|
+
// A file that never called defineDeployment can export anything, and the
|
|
83
|
+
// message worth getting is the one topologyFor gives rather than a TypeError
|
|
84
|
+
if (!(config.apps ?? []).some((app) => app.path)) return config;
|
|
57
85
|
|
|
58
86
|
return {
|
|
59
87
|
...config,
|
|
@@ -91,23 +119,43 @@ async function loadNamed(manifest: Manifest | undefined) {
|
|
|
91
119
|
// likely to be run from a workspace inside it as from there
|
|
92
120
|
export function discover(from: string): string {
|
|
93
121
|
let directory = from;
|
|
122
|
+
// Where something addressed to redkite was seen but no deployment. Naming it
|
|
123
|
+
// is the difference between "there is nothing here" and "the file you have
|
|
124
|
+
// is an environment, and an environment is not a deployment"
|
|
125
|
+
const nearby: string[] = [];
|
|
94
126
|
|
|
95
127
|
for (;;) {
|
|
96
128
|
const declared = directoryFrom(directory);
|
|
97
129
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
130
|
+
// Naming a directory that is not there is a mistake worth stopping for.
|
|
131
|
+
// One that is there and holds no deployment is not: it may hold only the
|
|
132
|
+
// environments, and the deployment may sit at the root above them
|
|
133
|
+
if (declared && !existsSync(declared)) missing(declared, directory);
|
|
134
|
+
|
|
135
|
+
const there = declared && found(declared);
|
|
136
|
+
if (there) return there;
|
|
101
137
|
|
|
102
138
|
const here = found(directory);
|
|
103
139
|
if (here) return here;
|
|
104
140
|
|
|
141
|
+
for (const place of declared ? [declared, directory] : [directory]) {
|
|
142
|
+
for (const entry of environmentsAt(place)) nearby.push(join(place, entry));
|
|
143
|
+
}
|
|
144
|
+
|
|
105
145
|
const parent = dirname(directory);
|
|
106
146
|
if (parent === directory) break;
|
|
107
147
|
|
|
108
148
|
directory = parent;
|
|
109
149
|
}
|
|
110
150
|
|
|
151
|
+
if (nearby.length > 0) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`No deployment found, but ${nearby.join(" and ")} reads as an environment. ` +
|
|
154
|
+
"An environment says which branch and which subnet; the deployment says " +
|
|
155
|
+
"the project, the apps and the services, and is redkite.config.ts",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
111
159
|
throw new Error(
|
|
112
160
|
`No redkite.config.ts found in ${from} or any directory above it. A deployment ` +
|
|
113
161
|
'is one file at the root of the project, or wherever package.json\'s ' +
|
|
@@ -115,15 +163,35 @@ export function discover(from: string): string {
|
|
|
115
163
|
);
|
|
116
164
|
}
|
|
117
165
|
|
|
166
|
+
// The names beside a deployment that would be read as environments, for a
|
|
167
|
+
// message that can say what was there instead of what was not
|
|
168
|
+
function environmentsAt(directory: string) {
|
|
169
|
+
if (!existsSync(directory)) return [];
|
|
170
|
+
|
|
171
|
+
return readdirSync(directory)
|
|
172
|
+
.filter((entry) => !CANDIDATES.includes(entry))
|
|
173
|
+
.filter((entry) => PER_ENVIRONMENT.test(entry))
|
|
174
|
+
.sort();
|
|
175
|
+
}
|
|
176
|
+
|
|
118
177
|
// Every environment that lives in a file of its own, keyed by the name in it
|
|
119
178
|
export async function loadEnvironments(directory: string) {
|
|
120
179
|
const environments: Record<string, Environment> = {};
|
|
121
180
|
const seen: Record<string, string> = {};
|
|
122
181
|
|
|
123
182
|
for (const entry of readdirSync(directory).sort()) {
|
|
183
|
+
if (CANDIDATES.includes(entry)) continue;
|
|
184
|
+
|
|
124
185
|
const name = PER_ENVIRONMENT.exec(entry)?.[1];
|
|
125
186
|
if (!name) continue;
|
|
126
187
|
|
|
188
|
+
if (!ENVIRONMENT_NAME.test(name)) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`${join(directory, entry)} reads as the environment ${name}, which cannot ` +
|
|
191
|
+
"name one: it becomes part of an image tag, and a tag is lower case",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
127
195
|
const first = seen[name];
|
|
128
196
|
if (first) {
|
|
129
197
|
throw new Error(`${name} is defined by both ${first} and ${entry}`);
|
|
@@ -148,8 +216,7 @@ function found(directory: string) {
|
|
|
148
216
|
|
|
149
217
|
function missing(declared: string, from: string): never {
|
|
150
218
|
throw new Error(
|
|
151
|
-
`${join(from, "package.json")} points redkite at ${declared}, which
|
|
152
|
-
`redkite.config.${EXTENSIONS.join(", redkite.config.")}`,
|
|
219
|
+
`${join(from, "package.json")} points redkite at ${declared}, which is not there`,
|
|
153
220
|
);
|
|
154
221
|
}
|
|
155
222
|
|
|
@@ -176,6 +243,14 @@ function manifestAt(directory: string): Manifest | undefined {
|
|
|
176
243
|
}
|
|
177
244
|
}
|
|
178
245
|
|
|
246
|
+
// Where the project starts, which is where a .env belongs. The nearest
|
|
247
|
+
// package.json above wherever this was run, and the directory itself when
|
|
248
|
+
// there is none
|
|
249
|
+
export function projectRoot(explicit?: string) {
|
|
250
|
+
const from = explicit ? dirname(resolve(explicit)) : process.cwd();
|
|
251
|
+
return manifestOf(from)?.root ?? from;
|
|
252
|
+
}
|
|
253
|
+
|
|
179
254
|
// The nearest one above wherever this was run, which is the same walk the
|
|
180
255
|
// config itself is found by
|
|
181
256
|
function manifestOf(from: string) {
|
|
@@ -192,11 +267,17 @@ function manifestOf(from: string) {
|
|
|
192
267
|
}
|
|
193
268
|
}
|
|
194
269
|
|
|
195
|
-
// package.json says where
|
|
196
|
-
//
|
|
270
|
+
// package.json says where redkite's files live, for a repository that would
|
|
271
|
+
// rather not keep them at its root
|
|
197
272
|
function directoryFrom(directory: string) {
|
|
198
|
-
|
|
199
|
-
|
|
273
|
+
return directoryOf(manifestAt(directory));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function directoryOf(manifest: Manifest | undefined) {
|
|
277
|
+
if (!manifest) return undefined;
|
|
278
|
+
|
|
279
|
+
const declared = manifest.redkite.directory;
|
|
280
|
+
return typeof declared === "string" ? resolve(manifest.root, declared) : undefined;
|
|
200
281
|
}
|
|
201
282
|
|
|
202
283
|
function defaultOf(module: unknown, path: string) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parseEnv } from "../environment.js";
|
|
5
|
+
|
|
6
|
+
// The credentials a deploy needs are the one thing that cannot live in the
|
|
7
|
+
// config, so they live beside it. Read here rather than by a plugin, because
|
|
8
|
+
// which file answers depends on the environment being deployed and a plugin
|
|
9
|
+
// is handed neither the directory nor the name.
|
|
10
|
+
|
|
11
|
+
// Most specific first. A deploy file is for what only a deploy needs, and it
|
|
12
|
+
// wins over the one the application itself reads
|
|
13
|
+
const NAMES = (environment: string) => [
|
|
14
|
+
`.env.${environment}.deploy`,
|
|
15
|
+
`.env.${environment}`,
|
|
16
|
+
".env",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Only what is missing. Anything already in the environment was set by the
|
|
20
|
+
// person running this or by the job, and neither should be talked over
|
|
21
|
+
export function loadDotenv(directory: string, environment: string) {
|
|
22
|
+
const read: string[] = [];
|
|
23
|
+
|
|
24
|
+
for (const name of NAMES(environment)) {
|
|
25
|
+
const path = join(directory, name);
|
|
26
|
+
if (!existsSync(path)) continue;
|
|
27
|
+
|
|
28
|
+
read.push(name);
|
|
29
|
+
for (const [key, value] of Object.entries(parseEnv(readFileSync(path, "utf8")))) {
|
|
30
|
+
process.env[key] ??= value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return read;
|
|
35
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Docker } from "../docker.js";
|
|
|
4
4
|
import type { Host } from "../host.js";
|
|
5
5
|
import { localHost } from "../localHost.js";
|
|
6
6
|
import { silent, type Log } from "../log.js";
|
|
7
|
-
import {
|
|
7
|
+
import { renderProxy } from "../services/proxy.js";
|
|
8
8
|
import { addressOf, RUNS, SLOTS, type Run } from "../pipeline.js";
|
|
9
9
|
import { listRefs, type SecretStores } from "../secrets/refs.js";
|
|
10
10
|
import { pluginSteps, storeFor } from "../plugin.js";
|
|
@@ -15,7 +15,8 @@ import { topologyFor, type Topology } from "../topology.js";
|
|
|
15
15
|
import type { Deployment, DeployHost } from "../types.js";
|
|
16
16
|
|
|
17
17
|
import { needsAgent, requireAgent } from "./agent.js";
|
|
18
|
-
import { loadConfig } from "./config.js";
|
|
18
|
+
import { loadConfig, projectRoot } from "./config.js";
|
|
19
|
+
import { loadDotenv } from "./dotenv.js";
|
|
19
20
|
import { createLog, describeFailure } from "./log.js";
|
|
20
21
|
|
|
21
22
|
// One command that reads the config and does everything under it: the agent,
|
|
@@ -146,7 +147,11 @@ async function dispatch(argv: string[]) {
|
|
|
146
147
|
|
|
147
148
|
const configPath = flag(argv, "--config");
|
|
148
149
|
|
|
149
|
-
|
|
150
|
+
// Before anything reads a credential out of the environment, and only where
|
|
151
|
+
// the environment does not already say. What the job set wins over a file
|
|
152
|
+
const read = loadDotenv(projectRoot(configPath), environment);
|
|
153
|
+
|
|
154
|
+
if (command === "plan") return await plan(environment, configPath, read);
|
|
150
155
|
if (command === "rollback" || command === "down") {
|
|
151
156
|
return await recover(command, environment, configPath);
|
|
152
157
|
}
|
|
@@ -205,13 +210,14 @@ function asLog(say: (message: string) => void): Log {
|
|
|
205
210
|
return Object.assign(say, { warn: say, fail: say, done: say, step: () => silent.step("") });
|
|
206
211
|
}
|
|
207
212
|
|
|
208
|
-
async function plan(environment: string, configPath?: string) {
|
|
213
|
+
async function plan(environment: string, configPath?: string, read: string[] = []) {
|
|
209
214
|
const config = await loadConfig(configPath);
|
|
210
215
|
const topology = topologyFor(config, environment);
|
|
211
216
|
|
|
212
217
|
const say = (message = "") => process.stdout.write(`${message}\n`);
|
|
213
218
|
|
|
214
219
|
say(`# ${config.project} · ${environment}\n`);
|
|
220
|
+
if (read.length > 0) say(`read ${read.join(", ")}`);
|
|
215
221
|
say(`network ${topology.network} ${topology.cidr}`);
|
|
216
222
|
say(`branch ${topology.branch}`);
|
|
217
223
|
// A verify environment publishes nothing, because nothing in one serves
|
|
@@ -260,7 +266,7 @@ async function plan(environment: string, configPath?: string) {
|
|
|
260
266
|
if (!serves) return;
|
|
261
267
|
|
|
262
268
|
say(`\n# rendered nginx ${"-".repeat(44)}\n`);
|
|
263
|
-
say(
|
|
269
|
+
say(renderProxy(topology, config.proxy));
|
|
264
270
|
}
|
|
265
271
|
|
|
266
272
|
// What is running against what this file says should be. Services outlive a
|
package/src/cli/screen.ts
CHANGED
|
@@ -14,6 +14,9 @@ export type Step = {
|
|
|
14
14
|
note?: string;
|
|
15
15
|
detail?: string;
|
|
16
16
|
lines: string[];
|
|
17
|
+
// When it last said anything. A step that is working and one that is wedged
|
|
18
|
+
// look identical without it, which is the whole reason for the spinner
|
|
19
|
+
spoke?: number;
|
|
17
20
|
expanded: boolean;
|
|
18
21
|
// Opened by hand, so finishing does not shut it under the reader
|
|
19
22
|
held: boolean;
|
|
@@ -21,14 +24,24 @@ export type Step = {
|
|
|
21
24
|
offset: number;
|
|
22
25
|
};
|
|
23
26
|
|
|
27
|
+
// Said outside a step, carrying the moment it was said so its row stops
|
|
28
|
+
// ticking the way a finished step's does
|
|
29
|
+
export type Message = { text: string; at: number };
|
|
30
|
+
|
|
24
31
|
export type Model = {
|
|
25
32
|
steps: Step[];
|
|
26
|
-
messages:
|
|
33
|
+
messages: Message[];
|
|
27
34
|
cursor: number;
|
|
28
35
|
// The cursor follows the newest step until the reader moves it
|
|
29
36
|
following: boolean;
|
|
30
37
|
// Nothing opens on its own, including the step that is running
|
|
31
38
|
minimal: boolean;
|
|
39
|
+
// Long lines wrap rather than being cut. Off by default: one row per line is
|
|
40
|
+
// what keeps a frame countable, and this is for reading an error
|
|
41
|
+
wrapped: boolean;
|
|
42
|
+
// Every point the run will walk, known before it starts. What has not begun
|
|
43
|
+
// is drawn under what has, so the end is visible from the start
|
|
44
|
+
planned: string[];
|
|
32
45
|
rows: number;
|
|
33
46
|
columns: number;
|
|
34
47
|
started: number;
|
|
@@ -43,6 +56,7 @@ export type Key =
|
|
|
43
56
|
| "toggle"
|
|
44
57
|
| "expand"
|
|
45
58
|
| "collapse"
|
|
59
|
+
| "wrap"
|
|
46
60
|
| "quit";
|
|
47
61
|
|
|
48
62
|
export function emptyModel(rows: number, columns: number, now: number): Model {
|
|
@@ -52,6 +66,8 @@ export function emptyModel(rows: number, columns: number, now: number): Model {
|
|
|
52
66
|
cursor: 0,
|
|
53
67
|
following: true,
|
|
54
68
|
minimal: false,
|
|
69
|
+
wrapped: false,
|
|
70
|
+
planned: [],
|
|
55
71
|
rows,
|
|
56
72
|
columns,
|
|
57
73
|
started: now,
|
|
@@ -91,6 +107,7 @@ export function keyOf(data: string): Key | undefined {
|
|
|
91
107
|
if (data === "\r" || data === "\n") return "toggle";
|
|
92
108
|
if (data === "+" || data === "=") return "expand";
|
|
93
109
|
if (data === "-" || data === "_") return "collapse";
|
|
110
|
+
if (data === "w") return "wrap";
|
|
94
111
|
if (data === "q" || data === INTERRUPT) return "quit";
|
|
95
112
|
|
|
96
113
|
return undefined;
|
|
@@ -106,6 +123,7 @@ export function apply(model: Model, key: Key): Model {
|
|
|
106
123
|
}
|
|
107
124
|
|
|
108
125
|
if (key === "first") return { ...model, cursor: 0, following: false };
|
|
126
|
+
if (key === "wrap") return { ...model, wrapped: !model.wrapped };
|
|
109
127
|
|
|
110
128
|
if (key === "up") {
|
|
111
129
|
if (step?.expanded && step.offset < Math.max(0, step.lines.length - 1)) {
|
|
@@ -223,50 +241,132 @@ const FLOOR = 3;
|
|
|
223
241
|
// the view closes, and a list that only grows crowds every log off the screen
|
|
224
242
|
const MESSAGES = 6;
|
|
225
243
|
|
|
244
|
+
// Every key, inside eighty columns. One too wide is a row that wraps, and a
|
|
245
|
+
// wrapped footer costs the frame a line it counted on
|
|
226
246
|
export const HELP =
|
|
227
|
-
"\u2191\u2193 move \u00b7 enter open \u00b7 shift+\u2191 latest \u00b7
|
|
247
|
+
"\u2191\u2193 move \u00b7 enter open \u00b7 shift+\u2191 latest \u00b7 +/- all \u00b7 w wrap \u00b7 q quit";
|
|
248
|
+
|
|
249
|
+
// Turns on the frame the clock is in, so the view is a pure function of now
|
|
250
|
+
const SPINNER = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f";
|
|
251
|
+
const SPIN_MS = 80;
|
|
252
|
+
|
|
253
|
+
// How long a running step may say nothing before the frame says so. Short
|
|
254
|
+
// enough to catch a wedge, long enough that an ordinary pause is not one
|
|
255
|
+
const QUIET_MS = 8000;
|
|
228
256
|
|
|
229
257
|
export function render(model: Model, colour = false): string[] {
|
|
230
258
|
const body = Math.max(1, model.rows - FOOTER);
|
|
231
259
|
const rows: string[] = [];
|
|
232
260
|
const messages = model.messages.slice(-MESSAGES);
|
|
261
|
+
const budget = Math.max(1, body - messages.length);
|
|
262
|
+
|
|
263
|
+
// What the step being read keeps whatever else wants the room. A frame of
|
|
264
|
+
// titles with no output says nothing the last line of output would not, so
|
|
265
|
+
// the list is what gives way rather than the log
|
|
266
|
+
const focused = model.steps[model.cursor];
|
|
267
|
+
const floor = focused?.expanded ? Math.min(FLOOR, rowsOf(model, focused)) : 0;
|
|
268
|
+
|
|
269
|
+
// More than the terminal has room for, so the list itself scrolls and only
|
|
270
|
+
// the step under the cursor keeps a log
|
|
271
|
+
if (model.steps.length + floor > budget) {
|
|
272
|
+
const room = Math.max(1, budget - floor);
|
|
273
|
+
const start = Math.max(0, Math.min(model.cursor - room + 1, model.steps.length - room));
|
|
274
|
+
|
|
275
|
+
for (const entry of ordered(model, messages)) {
|
|
276
|
+
if (rows.length >= budget) break;
|
|
277
|
+
|
|
278
|
+
if (entry.kind === "message") {
|
|
279
|
+
rows.push(said(model, entry.message, colour));
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
233
282
|
|
|
234
|
-
|
|
235
|
-
// list itself is what scrolls
|
|
236
|
-
if (model.steps.length + messages.length > body) {
|
|
237
|
-
const start = Math.max(0, Math.min(model.cursor, model.steps.length - body));
|
|
283
|
+
if (entry.index < start) continue;
|
|
238
284
|
|
|
239
|
-
|
|
240
|
-
if (index
|
|
241
|
-
rows.push(title(model, step, index, colour));
|
|
242
|
-
}
|
|
285
|
+
rows.push(title(model, entry.step, entry.index, colour));
|
|
286
|
+
if (entry.index === model.cursor) rows.push(...logs(model, entry.step, floor, colour));
|
|
243
287
|
}
|
|
244
288
|
|
|
245
|
-
return finish(rows, body, colour);
|
|
289
|
+
return finish(model, rows, body, colour);
|
|
246
290
|
}
|
|
247
291
|
|
|
248
|
-
const shown = share(model,
|
|
292
|
+
const shown = share(model, budget - model.steps.length);
|
|
249
293
|
|
|
250
|
-
for (const
|
|
251
|
-
|
|
252
|
-
|
|
294
|
+
for (const entry of ordered(model, messages)) {
|
|
295
|
+
if (entry.kind === "message") {
|
|
296
|
+
rows.push(said(model, entry.message, colour));
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
rows.push(title(model, entry.step, entry.index, colour));
|
|
301
|
+
rows.push(...logs(model, entry.step, shown.get(entry.index) ?? 0, colour));
|
|
253
302
|
}
|
|
254
303
|
|
|
255
|
-
for (const
|
|
256
|
-
rows.
|
|
257
|
-
|
|
258
|
-
[
|
|
259
|
-
{ text: gutter(model), colour: DIM },
|
|
260
|
-
{ text: " " },
|
|
261
|
-
{ text: message, colour: WARN },
|
|
262
|
-
],
|
|
263
|
-
Math.max(24, model.columns),
|
|
264
|
-
colour,
|
|
265
|
-
),
|
|
266
|
-
);
|
|
304
|
+
for (const point of remaining(model)) {
|
|
305
|
+
if (rows.length >= budget) break;
|
|
306
|
+
rows.push(waiting(model, point, colour));
|
|
267
307
|
}
|
|
268
308
|
|
|
269
|
-
return finish(rows, body, colour);
|
|
309
|
+
return finish(model, rows, body, colour);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Every point the run will walk that has not started. Drawn under what has, so
|
|
313
|
+
// how much is left is visible from the first frame rather than at the end
|
|
314
|
+
function remaining(model: Model) {
|
|
315
|
+
const started = new Set(model.steps.map((step) => step.label));
|
|
316
|
+
return model.planned.filter((point) => !started.has(point));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function waiting(model: Model, point: string, colour: boolean) {
|
|
320
|
+
const head: Piece[] = [
|
|
321
|
+
{ text: " ".repeat(gutter(model).length), colour: DIM },
|
|
322
|
+
{ text: " " },
|
|
323
|
+
{ text: "\u00b7", colour: DIM },
|
|
324
|
+
{ text: " " },
|
|
325
|
+
{ text: point, colour: DIM },
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
return fit(model, head, "", colour);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
type Row =
|
|
332
|
+
| { kind: "step"; at: number; step: Step; index: number }
|
|
333
|
+
| { kind: "message"; at: number; message: Message };
|
|
334
|
+
|
|
335
|
+
// Everything the run has said, in the order it said it. Messages used to be
|
|
336
|
+
// printed after every step, which put one from the first ten seconds below a
|
|
337
|
+
// build still running half an hour later
|
|
338
|
+
function ordered(model: Model, messages: Message[]): Row[] {
|
|
339
|
+
const steps: Row[] = model.steps.map((step, index) => ({
|
|
340
|
+
kind: "step",
|
|
341
|
+
at: step.started,
|
|
342
|
+
step,
|
|
343
|
+
index,
|
|
344
|
+
}));
|
|
345
|
+
|
|
346
|
+
const said: Row[] = messages.map((message) => ({
|
|
347
|
+
kind: "message",
|
|
348
|
+
at: message.at,
|
|
349
|
+
message,
|
|
350
|
+
}));
|
|
351
|
+
|
|
352
|
+
// A step announced in the same millisecond as a message leads it: the
|
|
353
|
+
// message is usually about what the step then went and did. Everything else
|
|
354
|
+
// ties to nothing, and a stable sort leaves it where it was
|
|
355
|
+
const rank = (row: Row) => (row.kind === "step" ? 0 : 1);
|
|
356
|
+
|
|
357
|
+
return [...steps, ...said].sort((a, b) => a.at - b.at || rank(a) - rank(b));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function said(model: Model, message: Message, colour: boolean) {
|
|
361
|
+
return clipped(
|
|
362
|
+
[
|
|
363
|
+
{ text: gutter(model, message.at), colour: DIM },
|
|
364
|
+
{ text: " " },
|
|
365
|
+
{ text: message.text, colour: WARN },
|
|
366
|
+
],
|
|
367
|
+
Math.max(24, model.columns),
|
|
368
|
+
colour,
|
|
369
|
+
);
|
|
270
370
|
}
|
|
271
371
|
|
|
272
372
|
// The focused step is the one being read, so it is served first and every other
|
|
@@ -287,19 +387,19 @@ function share(model: Model, budget: number) {
|
|
|
287
387
|
// printed nothing reserves nothing, and served last the focused step used
|
|
288
388
|
// to be left with whatever the others happened not to want
|
|
289
389
|
const glances = others.reduce(
|
|
290
|
-
(total, item) => total + Math.min(GLANCE, item.step
|
|
390
|
+
(total, item) => total + Math.min(GLANCE, rowsOf(model, item.step)),
|
|
291
391
|
0,
|
|
292
392
|
);
|
|
293
393
|
|
|
294
394
|
const reserve = Math.min(glances, Math.max(0, left - FLOOR));
|
|
295
|
-
const take = Math.min(left - reserve, focused.step
|
|
395
|
+
const take = Math.min(left - reserve, rowsOf(model, focused.step));
|
|
296
396
|
|
|
297
397
|
shown.set(focused.index, take);
|
|
298
398
|
left -= take;
|
|
299
399
|
}
|
|
300
400
|
|
|
301
401
|
for (const item of others) {
|
|
302
|
-
const take = Math.min(GLANCE, item.step
|
|
402
|
+
const take = Math.min(GLANCE, rowsOf(model, item.step), Math.max(0, left));
|
|
303
403
|
|
|
304
404
|
shown.set(item.index, take);
|
|
305
405
|
left -= take;
|
|
@@ -308,6 +408,15 @@ function share(model: Model, budget: number) {
|
|
|
308
408
|
return shown;
|
|
309
409
|
}
|
|
310
410
|
|
|
411
|
+
// How many rows a step's log would fill. One per line until they are wrapped,
|
|
412
|
+
// and then as many as each line needs
|
|
413
|
+
function rowsOf(model: Model, step: Step) {
|
|
414
|
+
if (!model.wrapped) return step.lines.length;
|
|
415
|
+
|
|
416
|
+
const room = Math.max(8, model.columns - RULE.length);
|
|
417
|
+
return step.lines.reduce((total, line) => total + wrapped(line, room).length, 0);
|
|
418
|
+
}
|
|
419
|
+
|
|
311
420
|
const RULE = " \u2502 ";
|
|
312
421
|
|
|
313
422
|
// The newest lines, less whatever the reader has scrolled back past. One line
|
|
@@ -317,10 +426,26 @@ function logs(model: Model, step: Step, take: number, colour: boolean) {
|
|
|
317
426
|
|
|
318
427
|
const end = Math.max(1, step.lines.length - step.offset);
|
|
319
428
|
const room = Math.max(8, model.columns - RULE.length);
|
|
429
|
+
const lines = step.lines.slice(Math.max(0, end - take), end);
|
|
430
|
+
|
|
431
|
+
// Wrapped, one line is several rows, so what is taken is counted in rows
|
|
432
|
+
// after the wrapping rather than in lines before it
|
|
433
|
+
const rows = lines.flatMap((line) =>
|
|
434
|
+
model.wrapped ? wrapped(line, room) : [clip(line, room)],
|
|
435
|
+
);
|
|
320
436
|
|
|
321
|
-
return
|
|
322
|
-
|
|
323
|
-
|
|
437
|
+
return rows.slice(-take).map((row) => `${paint(RULE, DIM, colour)}${row}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Cut on width alone. A build's output is not prose, and breaking a path or a
|
|
441
|
+
// stack frame on a space would put half of it where nothing can find it
|
|
442
|
+
function wrapped(line: string, room: number) {
|
|
443
|
+
if (line.length <= room) return [line];
|
|
444
|
+
|
|
445
|
+
const rows: string[] = [];
|
|
446
|
+
for (let at = 0; at < line.length; at += room) rows.push(line.slice(at, at + room));
|
|
447
|
+
|
|
448
|
+
return rows;
|
|
324
449
|
}
|
|
325
450
|
|
|
326
451
|
// The ellipsis is the whole point: a line that was cut has to say so
|
|
@@ -329,36 +454,60 @@ export function clip(text: string, room: number) {
|
|
|
329
454
|
return `${text.slice(0, room - 1)}\u2026`;
|
|
330
455
|
}
|
|
331
456
|
|
|
332
|
-
|
|
457
|
+
// Padded above rather than below, so the newest row sits against the footer
|
|
458
|
+
// and the frame fills upwards the way a terminal's own output does. The footer
|
|
459
|
+
// is cut like any other row: one too wide wraps, and a wrapped row costs two
|
|
460
|
+
function finish(model: Model, rows: string[], body: number, colour: boolean) {
|
|
333
461
|
const filled = rows.slice(0, body);
|
|
334
|
-
|
|
462
|
+
const blanks = Array.from({ length: Math.max(0, body - filled.length) }, () => "");
|
|
463
|
+
const help = clip(HELP, Math.max(24, model.columns));
|
|
335
464
|
|
|
336
|
-
return [...filled, "", paint(
|
|
465
|
+
return [...blanks, ...filled, "", paint(help, DIM, colour)];
|
|
337
466
|
}
|
|
338
467
|
|
|
339
468
|
function title(model: Model, step: Step, index: number, colour: boolean) {
|
|
340
469
|
const selected = index === model.cursor;
|
|
341
|
-
const
|
|
470
|
+
const running = step.state === "running";
|
|
342
471
|
const arrow = step.expanded ? "\u25be" : "\u25b8";
|
|
343
472
|
const glyph = step.state === "done" ? "\u2714" : step.state === "failed" ? "\u2718" : arrow;
|
|
344
473
|
|
|
345
474
|
const said = step.note ? `: ${step.note}` : step.detail ? ` ${step.detail}` : "";
|
|
346
475
|
|
|
347
476
|
const head: Piece[] = [
|
|
348
|
-
|
|
477
|
+
// The run clock as this row stood: still moving while the step is, and
|
|
478
|
+
// stopped at the moment it finished
|
|
479
|
+
{ text: gutter(model, step.ended), colour: DIM },
|
|
349
480
|
{ text: " " },
|
|
350
|
-
|
|
481
|
+
// Turning while the step is working, blank when it is not. A step that has
|
|
482
|
+
// stopped saying anything still turns, which is what quiet then says
|
|
483
|
+
{ text: running ? spinner(model.now) : " ", colour: RUNNING },
|
|
351
484
|
{ text: " " },
|
|
352
485
|
{ text: glyph, colour: stateColour(step) },
|
|
353
486
|
{ text: " " },
|
|
354
487
|
{ text: step.label, colour: labelColour(step, selected) },
|
|
355
488
|
{ text: said, colour: DIM },
|
|
489
|
+
{ text: quiet(model, step), colour: WARN },
|
|
356
490
|
];
|
|
357
491
|
|
|
358
492
|
// Frozen at what it cost the moment it finished, still counting until then
|
|
359
493
|
return fit(model, head, elapsed(step.ended ?? model.now, step.started), colour);
|
|
360
494
|
}
|
|
361
495
|
|
|
496
|
+
function spinner(now: number) {
|
|
497
|
+
return SPINNER[Math.floor(now / SPIN_MS) % SPINNER.length] ?? " ";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// How long a running step has said nothing. The difference between a build
|
|
501
|
+
// that is working and one that is wedged, which nothing else on the row shows
|
|
502
|
+
function quiet(model: Model, step: Step) {
|
|
503
|
+
if (step.state !== "running") return "";
|
|
504
|
+
|
|
505
|
+
const since = model.now - (step.spoke ?? step.started);
|
|
506
|
+
if (since < QUIET_MS) return "";
|
|
507
|
+
|
|
508
|
+
return ` quiet ${elapsed(model.now, step.spoke ?? step.started)}`;
|
|
509
|
+
}
|
|
510
|
+
|
|
362
511
|
function stateColour(step: Step) {
|
|
363
512
|
if (step.state === "done") return DONE;
|
|
364
513
|
if (step.state === "failed") return FAILED;
|
|
@@ -375,9 +524,10 @@ function labelColour(step: Step, selected: boolean) {
|
|
|
375
524
|
return undefined;
|
|
376
525
|
}
|
|
377
526
|
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
527
|
+
// How far into the run this row belongs. Given a moment it stops there, which
|
|
528
|
+
// is what keeps a finished row from ticking along with the one still running
|
|
529
|
+
function gutter(model: Model, at?: number) {
|
|
530
|
+
return elapsed(at ?? model.now, model.started, true);
|
|
381
531
|
}
|
|
382
532
|
|
|
383
533
|
export function elapsed(now: number, started: number, clock = false) {
|