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.
Files changed (127) hide show
  1. package/README.md +161 -12
  2. package/dist/build.d.ts +3 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +18 -2
  5. package/dist/build.js.map +1 -1
  6. package/dist/cli/agent.js +2 -2
  7. package/dist/cli/agent.js.map +1 -1
  8. package/dist/cli/config.d.ts +1 -0
  9. package/dist/cli/config.d.ts.map +1 -1
  10. package/dist/cli/config.js +82 -16
  11. package/dist/cli/config.js.map +1 -1
  12. package/dist/cli/dotenv.d.ts +2 -0
  13. package/dist/cli/dotenv.d.ts.map +1 -0
  14. package/dist/cli/dotenv.js +30 -0
  15. package/dist/cli/dotenv.js.map +1 -0
  16. package/dist/cli/index.d.ts.map +1 -1
  17. package/dist/cli/index.js +11 -5
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/cli/screen.d.ts +10 -3
  20. package/dist/cli/screen.d.ts.map +1 -1
  21. package/dist/cli/screen.js +153 -36
  22. package/dist/cli/screen.js.map +1 -1
  23. package/dist/cli/viewer.d.ts.map +1 -1
  24. package/dist/cli/viewer.js +36 -4
  25. package/dist/cli/viewer.js.map +1 -1
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +12 -0
  28. package/dist/config.js.map +1 -1
  29. package/dist/deploy.d.ts.map +1 -1
  30. package/dist/deploy.js +12 -2
  31. package/dist/deploy.js.map +1 -1
  32. package/dist/docker.d.ts +1 -0
  33. package/dist/docker.d.ts.map +1 -1
  34. package/dist/docker.js +3 -0
  35. package/dist/docker.js.map +1 -1
  36. package/dist/dockerfile.d.ts +1 -0
  37. package/dist/dockerfile.d.ts.map +1 -1
  38. package/dist/dockerfile.js +16 -3
  39. package/dist/dockerfile.js.map +1 -1
  40. package/dist/environment.d.ts +2 -0
  41. package/dist/environment.d.ts.map +1 -1
  42. package/dist/environment.js +56 -1
  43. package/dist/environment.js.map +1 -1
  44. package/dist/index.d.ts +2 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/log.d.ts +1 -0
  49. package/dist/log.d.ts.map +1 -1
  50. package/dist/log.js +1 -0
  51. package/dist/log.js.map +1 -1
  52. package/dist/pipeline.d.ts.map +1 -1
  53. package/dist/pipeline.js +3 -0
  54. package/dist/pipeline.js.map +1 -1
  55. package/dist/plugins/bitwarden.d.ts.map +1 -1
  56. package/dist/plugins/bitwarden.js +25 -6
  57. package/dist/plugins/bitwarden.js.map +1 -1
  58. package/dist/presets/nextApp.d.ts.map +1 -1
  59. package/dist/presets/nextApp.js +5 -4
  60. package/dist/presets/nextApp.js.map +1 -1
  61. package/dist/presets/nodeApp.d.ts.map +1 -1
  62. package/dist/presets/nodeApp.js +15 -6
  63. package/dist/presets/nodeApp.js.map +1 -1
  64. package/dist/secrets/manager.d.ts +9 -0
  65. package/dist/secrets/manager.d.ts.map +1 -0
  66. package/dist/secrets/manager.js +130 -0
  67. package/dist/secrets/manager.js.map +1 -0
  68. package/dist/secrets/store.d.ts.map +1 -1
  69. package/dist/secrets/store.js +46 -12
  70. package/dist/secrets/store.js.map +1 -1
  71. package/dist/services/ensure.js +1 -1
  72. package/dist/services/ensure.js.map +1 -1
  73. package/dist/services/index.d.ts +2 -1
  74. package/dist/services/index.d.ts.map +1 -1
  75. package/dist/services/index.js +11 -0
  76. package/dist/services/index.js.map +1 -1
  77. package/dist/services/planned.d.ts +0 -1
  78. package/dist/services/planned.d.ts.map +1 -1
  79. package/dist/services/planned.js +3 -12
  80. package/dist/services/planned.js.map +1 -1
  81. package/dist/services/proxy.d.ts +7 -0
  82. package/dist/services/proxy.d.ts.map +1 -0
  83. package/dist/services/proxy.js +103 -0
  84. package/dist/services/proxy.js.map +1 -0
  85. package/dist/source.d.ts +7 -1
  86. package/dist/source.d.ts.map +1 -1
  87. package/dist/source.js +17 -9
  88. package/dist/source.js.map +1 -1
  89. package/dist/steps.d.ts +1 -6
  90. package/dist/steps.d.ts.map +1 -1
  91. package/dist/steps.js +3 -3
  92. package/dist/steps.js.map +1 -1
  93. package/dist/types.d.ts +10 -2
  94. package/dist/types.d.ts.map +1 -1
  95. package/package.json +1 -1
  96. package/src/build.ts +22 -2
  97. package/src/cli/agent.ts +2 -2
  98. package/src/cli/config.ts +96 -15
  99. package/src/cli/dotenv.ts +35 -0
  100. package/src/cli/index.ts +11 -5
  101. package/src/cli/screen.ts +193 -43
  102. package/src/cli/viewer.ts +41 -4
  103. package/src/config.ts +19 -0
  104. package/src/deploy.ts +14 -2
  105. package/src/docker.ts +5 -0
  106. package/src/dockerfile.ts +23 -3
  107. package/src/environment.ts +71 -1
  108. package/src/index.ts +2 -1
  109. package/src/log.ts +4 -0
  110. package/src/pipeline.ts +4 -0
  111. package/src/plugins/bitwarden.ts +38 -11
  112. package/src/presets/nextApp.ts +5 -4
  113. package/src/presets/nodeApp.ts +15 -6
  114. package/src/secrets/manager.ts +172 -0
  115. package/src/secrets/store.ts +51 -16
  116. package/src/services/ensure.ts +1 -1
  117. package/src/services/index.ts +13 -1
  118. package/src/services/planned.ts +3 -13
  119. package/src/services/proxy.ts +126 -0
  120. package/src/source.ts +33 -9
  121. package/src/steps.ts +7 -7
  122. package/src/types.ts +26 -4
  123. package/dist/nginx.d.ts +0 -4
  124. package/dist/nginx.d.ts.map +0 -1
  125. package/dist/nginx.js +0 -53
  126. package/dist/nginx.js.map +0 -1
  127. 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
- // redkite.<environment>.config.<extension>, beside the deployment it belongs to
20
- const PER_ENVIRONMENT = /^redkite\.([a-z0-9-]+)\.config\.(ts|mts|js|mjs)$/;
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
- for (const name of Object.keys(named)) {
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: { ...beside, ...named } };
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
- if (!config.apps.some((app) => app.path)) return config;
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
- // Saying where the files are and not putting them there is a mistake worth
99
- // stopping for, rather than a reason to keep looking further up
100
- if (declared) return found(declared) ?? missing(declared, directory);
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 holds no ` +
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 the deployment files live, for a repository that
196
- // would rather not keep them at its root
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
- const declared = manifestAt(directory)?.redkite.directory;
199
- return typeof declared === "string" ? resolve(directory, declared) : undefined;
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 { renderNginx } from "../nginx.js";
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
- if (command === "plan") return await plan(environment, configPath);
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(renderNginx(topology, config.maxBodySize));
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: string[];
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 + open \u00b7 - collapse all \u00b7 q quit";
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
- // 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));
283
+ if (entry.index < start) continue;
238
284
 
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
- }
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, body - model.steps.length - messages.length);
292
+ const shown = share(model, budget - model.steps.length);
249
293
 
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));
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 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
- );
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.lines.length),
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.lines.length);
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.lines.length, Math.max(0, left));
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 step.lines
322
- .slice(Math.max(0, end - take), end)
323
- .map((line) => `${paint(RULE, DIM, colour)}${clip(line, room)}`);
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
- function finish(rows: string[], body: number, colour: boolean) {
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
- while (filled.length < body) filled.push("");
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(HELP, DIM, colour)];
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 caret = selected ? "\u276f" : " ";
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
- { text: gutter(model), colour: DIM },
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
- { text: caret, colour: selected ? CURSOR : undefined },
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
- // 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)}]`;
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) {