windows-development-sandbox 1.23.1 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/config.example.json +0 -1
  2. package/dist/bin/agent-sandbox.js +2 -2
  3. package/dist/bin/{chunk-USTRSHIE.js → chunk-IZRGTSQ5.js} +158 -131
  4. package/dist/bin/{chunk-QFSJX2KW.js → chunk-KH4MI2U5.js} +1 -1
  5. package/dist/bin/{chunk-7YPEDAJD.js → chunk-SUYYVIDW.js} +1 -1
  6. package/dist/bin/claude-host.js +2 -2
  7. package/dist/bin/claude-sbx.js +2 -2
  8. package/dist/bin/codex-sbx.js +2 -2
  9. package/dist/bin/gemini-sbx.js +2 -2
  10. package/dist/bin/sandbox.js +2 -2
  11. package/dist/bin/sbx-hide.js +2 -2
  12. package/dist/bin/sbx-show.js +2 -2
  13. package/dist/core/config/defaults.js +0 -1
  14. package/dist/core/detect/toolchains.js +0 -6
  15. package/dist/core/schema/config.d.ts +1 -2
  16. package/dist/core/schema/toolchains.d.ts +1 -2
  17. package/dist/core/schema/toolchains.js +3 -6
  18. package/dist/guest/agents/agent-files.d.ts +11 -7
  19. package/dist/guest/agents/agent-files.js +37 -11
  20. package/dist/guest/agents/local-state.d.ts +2 -1
  21. package/dist/guest/agents/local-state.js +2 -1
  22. package/dist/guest/boot/boot-console.d.ts +28 -0
  23. package/dist/guest/boot/boot-console.js +98 -0
  24. package/dist/guest/boot/boot.d.ts +8 -1
  25. package/dist/guest/boot/boot.js +66 -42
  26. package/dist/guest/boot/graph.d.ts +9 -5
  27. package/dist/guest/boot/graph.js +8 -2
  28. package/dist/guest/boot/progress-view.d.ts +116 -0
  29. package/dist/guest/boot/progress-view.js +456 -0
  30. package/dist/guest/boot/runner.d.ts +20 -10
  31. package/dist/guest/boot/runner.js +39 -47
  32. package/dist/guest/runtime/log-line.d.ts +7 -0
  33. package/dist/guest/runtime/log-line.js +18 -0
  34. package/dist/guest/runtime/log.d.ts +17 -4
  35. package/dist/guest/runtime/log.js +40 -17
  36. package/dist/guest/runtime/process.d.ts +9 -0
  37. package/dist/guest/runtime/process.js +69 -8
  38. package/dist/guest/setup/environment.js +9 -4
  39. package/dist/guest/setup/hooks.d.ts +2 -0
  40. package/dist/guest/setup/hooks.js +4 -0
  41. package/dist/guest/setup/path-plan.d.ts +12 -0
  42. package/dist/guest/setup/path-plan.js +18 -0
  43. package/dist/guest/toolchains/native/unreal.js +4 -6
  44. package/dist/guest/toolchains/native/visual-studio.d.ts +14 -14
  45. package/dist/guest/toolchains/native/visual-studio.js +145 -174
  46. package/dist/guest/toolchains/native/vs-persist.d.ts +189 -0
  47. package/dist/guest/toolchains/native/vs-persist.js +256 -0
  48. package/dist/guest/toolchains/native/vs-request.d.ts +6 -22
  49. package/dist/guest/toolchains/native/vs-request.js +16 -46
  50. package/dist/guest/toolchains/plan.js +11 -14
  51. package/dist/host/migrate/migrations/drop-vs-install-files.d.ts +5 -0
  52. package/dist/host/migrate/migrations/drop-vs-install-files.js +34 -0
  53. package/dist/host/migrate/registry.js +7 -1
  54. package/dist/runtime/agent.js +67 -26
  55. package/dist/runtime/daemon.js +958 -716
  56. package/dist/runtime/db.js +67 -26
  57. package/dist/runtime/main.js +1792 -1038
  58. package/dist/runtime/netproxy.js +3 -6
  59. package/dist/runtime/tool.js +44 -24
  60. package/dist/runtime/view.js +4 -7
  61. package/dist/selftest/agent-paths.test.js +17 -1
  62. package/dist/selftest/agent-settings.test.js +36 -5
  63. package/dist/selftest/boot-console.test.d.ts +2 -0
  64. package/dist/selftest/boot-console.test.js +291 -0
  65. package/dist/selftest/boot-runner.test.js +10 -12
  66. package/dist/selftest/guest-boot.test.js +7 -1
  67. package/dist/selftest/index.js +2 -0
  68. package/dist/selftest/layout.test.js +4 -0
  69. package/dist/selftest/migrate.test.js +21 -0
  70. package/dist/selftest/persisted-installs.test.js +169 -32
  71. package/dist/selftest/support/engine-golden.js +2 -0
  72. package/dist/selftest/support/fixtures.d.ts +1 -2
  73. package/dist/shared/channel/schema.d.ts +3 -6
  74. package/dist/shared/pty/terminal.js +2 -1
  75. package/dist/sync/watcher.js +5 -1
  76. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  77. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  78. package/native/csharp/StorageHelper/AssemblyInfo.cs +3 -3
  79. package/package.json +1 -1
  80. package/sandbox/scripts/restore-visual-studio.ps1 +78 -0
  81. package/sandbox/scripts/save-visual-studio.ps1 +106 -0
  82. package/schemas/config.schema.json +3 -7
  83. package/schemas/sandbox-config.schema.json +3 -7
  84. package/dist/guest/toolchains/native/autosdk.d.ts +0 -3
  85. package/dist/guest/toolchains/native/autosdk.js +0 -50
@@ -0,0 +1,456 @@
1
+ // The boot's console (docs/design/parallel-boot.md, "Console output"). A terminal gets one header line and a block of
2
+ // stage rows that redraws in place; anything else (a log, CI, a dry run) gets plain lines, a stage at a time, in stage
3
+ // order, once it has finished. Warnings, the tool list and the ready line come once, at the end. Pure: the boot feeds
4
+ // it the runner's events and its own lines and gives it a screen to write to (boot-console.ts).
5
+ import { duration } from './runner.js';
6
+ /** For Windows Terminal (Cascadia has braille and check marks). */
7
+ export const RICH_GLYPHS = {
8
+ done: '✓',
9
+ failed: '✗',
10
+ warned: '!',
11
+ skipped: '-',
12
+ waiting: '·',
13
+ more: '…',
14
+ spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
15
+ };
16
+ /** For the classic console (Consolas has neither braille nor check marks). */
17
+ export const CONSOLE_GLYPHS = {
18
+ done: '√',
19
+ failed: 'x',
20
+ warned: '!',
21
+ skipped: '-',
22
+ waiting: '·',
23
+ more: '...',
24
+ spinner: ['-', '\\', '|', '/'],
25
+ };
26
+ /** How a stage row looks in each state: its glyph (a running row spins instead), colour and detail. */
27
+ const MARKS = {
28
+ waiting: { glyph: 'waiting', colour: 'dim', detail: 'waiting' },
29
+ running: { glyph: 'waiting', colour: 'cyan', detail: '' },
30
+ done: { glyph: 'done', colour: 'green', detail: '' },
31
+ warned: { glyph: 'warned', colour: 'yellow', detail: 'see the warnings' },
32
+ failed: { glyph: 'failed', colour: 'red', detail: '' },
33
+ skipped: { glyph: 'skipped', colour: 'dim', detail: 'skipped' },
34
+ };
35
+ const ANSI = {
36
+ green: '\x1b[32m',
37
+ red: '\x1b[31m',
38
+ yellow: '\x1b[33m',
39
+ cyan: '\x1b[36m',
40
+ dim: '\x1b[2m',
41
+ bold: '\x1b[1m',
42
+ };
43
+ const RESET = '\x1b[0m';
44
+ /** Moves to the start of the line `n` lines up. */
45
+ const up = (n) => '\r\x1b[' + n + 'A';
46
+ const CLEAR_LINE = '\x1b[2K';
47
+ const CLEAR_BELOW = '\x1b[J';
48
+ const HIDE_CURSOR = '\x1b[?25l';
49
+ const SHOW_CURSOR = '\x1b[?25h';
50
+ const SPIN_MS = 80;
51
+ const TIME_WIDTH = 6;
52
+ /** Warnings shown under the live block (the rest wait for the end). */
53
+ const LIVE_WARNINGS = 4;
54
+ /** The tool names column of the summary (a longer name pushes its detail along). */
55
+ const TOOL_COLUMN = 24;
56
+ /** Tasks under a second are not worth naming as "slowest". */
57
+ const SLOW_MS = 1000;
58
+ const MIN_WIDTH = 40;
59
+ class StageState {
60
+ stage;
61
+ name;
62
+ total;
63
+ began = null;
64
+ ended = null;
65
+ finished = 0;
66
+ skipped = 0;
67
+ softFailed = false;
68
+ error = null;
69
+ printed = false;
70
+ /** Task id -> what the row shows for it (its title, or the detail the boot gave it). */
71
+ running = new Map();
72
+ lines = [];
73
+ problems = [];
74
+ constructor(stage, name, total) {
75
+ this.stage = stage;
76
+ this.name = name;
77
+ this.total = total;
78
+ }
79
+ get complete() {
80
+ return this.finished >= this.total;
81
+ }
82
+ mark() {
83
+ if (this.error !== null)
84
+ return 'failed';
85
+ if (this.complete) {
86
+ if (this.total > 0 && this.skipped === this.total)
87
+ return 'skipped';
88
+ return this.softFailed ? 'warned' : 'done';
89
+ }
90
+ return this.began === null ? 'waiting' : 'running';
91
+ }
92
+ /** How long the stage has run (from its first task's start to its last task's end, or to now). */
93
+ elapsed(now) {
94
+ if (this.began === null)
95
+ return null;
96
+ return (this.ended ?? now) - this.began;
97
+ }
98
+ }
99
+ /** "Node, Python, Rust +2 (5/13)": what the toolchain scheduler runs now. */
100
+ export function stepsDetail(active, done, total) {
101
+ const shown = active.slice(0, 3).join(', ') + (active.length > 3 ? ' +' + (active.length - 3) : '');
102
+ return (shown || 'Developer tools') + (total > 0 ? ' (' + done + '/' + total + ')' : '');
103
+ }
104
+ /** "Ready in 46.8s (slowest: ...)" and, when work goes on, the line that says what still installs. */
105
+ export function readyLines(info) {
106
+ const slowest = [...info.outcomes]
107
+ .filter((o) => o.state !== 'skipped' && o.ms >= SLOW_MS)
108
+ .sort((a, b) => b.ms - a.ms)
109
+ .slice(0, 3)
110
+ .map((o) => o.title + ' ' + duration(o.ms));
111
+ const later = info.background;
112
+ const names = later.length > 3 ? later.slice(0, 3).join(', ') + ' and ' + (later.length - 3) + ' more' : later.join(', ');
113
+ return [
114
+ 'Ready in ' + duration(info.ms) + (slowest.length ? ' (slowest: ' + slowest.join(', ') + ')' : ''),
115
+ ...(later.length
116
+ ? ['Still installing in the background: ' + names + ' (a few minutes; it does not hold you up)']
117
+ : []),
118
+ ];
119
+ }
120
+ /** Word-wraps `text` to `width` columns; a word longer than that is cut. Linear in the text. */
121
+ export function wrap(text, width) {
122
+ const lines = [];
123
+ let line = '';
124
+ for (let word of text.split(' ')) {
125
+ while (word.length > width) {
126
+ if (line)
127
+ lines.push(line);
128
+ lines.push(word.slice(0, width));
129
+ word = word.slice(width);
130
+ line = '';
131
+ }
132
+ if (!line)
133
+ line = word;
134
+ else if (line.length + 1 + word.length <= width)
135
+ line += ' ' + word;
136
+ else {
137
+ lines.push(line);
138
+ line = word;
139
+ }
140
+ }
141
+ lines.push(line);
142
+ return lines;
143
+ }
144
+ /** One boot's console: fed by the runner's events and the boot's lines, drawn on a screen. */
145
+ export class ProgressView {
146
+ o;
147
+ stages;
148
+ byStage = new Map();
149
+ taskById = new Map();
150
+ nameWidth;
151
+ glyphs;
152
+ warnings = [];
153
+ notices = [];
154
+ tools = [];
155
+ /** Lines of the live block on the screen now (the cursor is just below them). */
156
+ drawn = 0;
157
+ lastFrame = '';
158
+ asideDepth = 0;
159
+ released = false;
160
+ constructor(stages, tasks, o) {
161
+ this.o = o;
162
+ this.glyphs = o.glyphs ?? RICH_GLYPHS;
163
+ this.stages = [...stages]
164
+ .sort((a, b) => a.stage - b.stage)
165
+ .map((s) => new StageState(s.stage, s.name, tasks.filter((t) => t.stage === s.stage).length));
166
+ for (const s of this.stages)
167
+ this.byStage.set(s.stage, s);
168
+ for (const t of tasks)
169
+ this.taskById.set(t.id, t);
170
+ this.nameWidth = Math.max(0, ...stages.map((s) => s.name.length));
171
+ }
172
+ /**
173
+ * Whether programs should hand their output to the log now: it would draw over the live block, or come out between
174
+ * the plain stages. Not while a hook has the terminal.
175
+ */
176
+ get capturing() {
177
+ return !this.released && this.asideDepth === 0;
178
+ }
179
+ /** The header line, then (live) the first frame. */
180
+ start(header) {
181
+ this.o.screen.write(this.paint(header, 'bold') + '\n\n');
182
+ if (this.o.live) {
183
+ this.o.screen.write(HIDE_CURSOR);
184
+ this.draw();
185
+ }
186
+ }
187
+ started(task) {
188
+ const s = this.byStage.get(task.stage);
189
+ if (!s)
190
+ return;
191
+ s.began ??= this.o.now();
192
+ s.running.set(task.id, task.title);
193
+ }
194
+ finished(task, outcome) {
195
+ const s = this.byStage.get(task.stage);
196
+ if (!s)
197
+ return;
198
+ s.running.delete(task.id);
199
+ s.finished++;
200
+ if (outcome.state === 'skipped') {
201
+ s.skipped++;
202
+ s.problems.push(this.glyphs.skipped + ' ' + task.title + ' skipped (' + (outcome.error ?? '') + ')');
203
+ }
204
+ else if (outcome.state === 'failed') {
205
+ if (task.critical) {
206
+ s.error ??= outcome.error ?? 'failed';
207
+ s.problems.push(this.glyphs.failed + ' ' + task.title + ' failed: ' + (outcome.error ?? ''));
208
+ }
209
+ else
210
+ s.softFailed = true;
211
+ }
212
+ if (s.began !== null && s.complete)
213
+ s.ended = this.o.now();
214
+ this.flushPlain();
215
+ }
216
+ /** What a running task's place on its row says instead of its title (null: its title again). */
217
+ detail(taskId, text) {
218
+ const task = this.taskById.get(taskId);
219
+ const s = task && this.byStage.get(task.stage);
220
+ if (!s?.running.has(taskId))
221
+ return;
222
+ s.running.set(taskId, text ?? task.title);
223
+ }
224
+ /** An info line: the file has it; plain output shows it under its stage (or at once, outside a stage). */
225
+ line(text, stage) {
226
+ if (this.released)
227
+ return;
228
+ if (this.asideDepth > 0) {
229
+ this.o.screen.write(text + '\n');
230
+ return;
231
+ }
232
+ if (this.o.live)
233
+ return;
234
+ const s = stage === undefined ? undefined : this.byStage.get(stage);
235
+ if (s && !s.printed)
236
+ s.lines.push(text);
237
+ else
238
+ this.o.screen.write((s ? this.indent() : '') + text + '\n');
239
+ }
240
+ warning(text) {
241
+ if (!this.warnings.includes(text))
242
+ this.warnings.push(text);
243
+ }
244
+ notice(text) {
245
+ if (!this.notices.includes(text))
246
+ this.notices.push(text);
247
+ }
248
+ /** The tool summary ("name: detail" lines), printed once at the end. */
249
+ summary(lines) {
250
+ this.tools = [...lines];
251
+ }
252
+ /** Redraws the live block (the boot calls it a few times a second). */
253
+ tick() {
254
+ if (this.o.live && !this.released && this.asideDepth === 0)
255
+ this.draw();
256
+ }
257
+ /** Runs `work`, which writes to the terminal itself (the hooks): the live block steps aside until it is done. */
258
+ aside(work) {
259
+ if (!this.o.live || this.released)
260
+ return work();
261
+ this.erase();
262
+ this.asideDepth++;
263
+ const back = () => {
264
+ this.asideDepth--;
265
+ this.tick();
266
+ };
267
+ let result;
268
+ try {
269
+ result = work();
270
+ }
271
+ catch (e) {
272
+ back();
273
+ throw e;
274
+ }
275
+ if (result instanceof Promise)
276
+ return result.finally(back);
277
+ back();
278
+ return result;
279
+ }
280
+ /**
281
+ * The boot is over (`ready`) or failed (null): the final block, warnings, tools, notices and the ready line, once.
282
+ * The console is free afterwards (the first session takes it); nothing of this view prints again.
283
+ */
284
+ release(ready) {
285
+ if (this.released)
286
+ return;
287
+ if (this.o.live)
288
+ this.erase();
289
+ this.released = true;
290
+ const width = this.o.live ? this.width() : Infinity;
291
+ // Sections, a blank line between them.
292
+ const sections = [];
293
+ if (this.warnings.length)
294
+ sections.push([
295
+ this.paint('Warnings', 'yellow'),
296
+ ...this.warnings.flatMap((w) => this.hanging(' ! ', w, width, 'yellow')),
297
+ ]);
298
+ if (this.tools.length)
299
+ sections.push([this.paint('Tools', 'bold'), ...this.toolRows(width)]);
300
+ if (this.notices.length)
301
+ sections.push(this.notices.flatMap((n) => this.hanging('', n, width)));
302
+ if (ready) {
303
+ const [first, ...rest] = readyLines(ready);
304
+ sections.push([
305
+ this.paint(first, 'green'),
306
+ ...rest.map((l) => this.paint(l, 'cyan')),
307
+ this.paint('Log: ' + ready.logPath, 'dim'),
308
+ ]);
309
+ }
310
+ const join = (parts) => parts.map((p) => p.join('\n') + '\n').join('\n');
311
+ if (this.o.live) {
312
+ // The block again, still (no spinner), where the live one was.
313
+ const rows = this.stages.map((s) => this.row(s, null));
314
+ this.o.screen.write(join([rows, ...sections]) + SHOW_CURSOR);
315
+ return;
316
+ }
317
+ // Plain: the stages not printed yet (a failed boot), then the sections after a blank line.
318
+ const left = this.stages.filter((s) => !s.printed && s.began !== null).flatMap((s) => this.stageBlock(s));
319
+ const text = (left.length ? left.join('\n') + '\n' : '') + (sections.length ? '\n' + join(sections) : '');
320
+ if (text)
321
+ this.o.screen.write(text);
322
+ }
323
+ /** The live block as it would be drawn now (no colours when `colour` is false): the tests read it. */
324
+ frame(colour = true) {
325
+ const now = this.o.now();
326
+ const rows = this.stages.map((s) => this.row(s, now, colour));
327
+ if (!this.warnings.length)
328
+ return rows;
329
+ const width = this.width();
330
+ const shown = this.warnings.slice(-LIVE_WARNINGS);
331
+ const more = this.warnings.length - shown.length;
332
+ return [
333
+ ...rows,
334
+ '',
335
+ ...shown.map((w) => this.render([{ text: ' ! ' + w, colour: 'yellow' }], width, colour)),
336
+ ...(more > 0
337
+ ? [this.render([{ text: ' + ' + more + ' more (shown when ready)', colour: 'dim' }], width, colour)]
338
+ : []),
339
+ ];
340
+ }
341
+ draw() {
342
+ const lines = this.frame();
343
+ const text = lines.map((l) => CLEAR_LINE + l + '\n').join('');
344
+ if (text === this.lastFrame && this.drawn === lines.length)
345
+ return;
346
+ this.o.screen.write((this.drawn ? up(this.drawn) : '') + text + CLEAR_BELOW);
347
+ this.lastFrame = text;
348
+ this.drawn = lines.length;
349
+ }
350
+ erase() {
351
+ if (this.drawn)
352
+ this.o.screen.write(up(this.drawn) + CLEAR_BELOW);
353
+ this.drawn = 0;
354
+ this.lastFrame = '';
355
+ }
356
+ /** Plain output: every finished stage whose earlier stages are printed, in stage order. */
357
+ flushPlain() {
358
+ if (this.o.live || this.released)
359
+ return;
360
+ for (const s of this.stages) {
361
+ if (s.printed)
362
+ continue;
363
+ if (!s.complete)
364
+ return;
365
+ this.o.screen.write(this.stageBlock(s).join('\n') + '\n');
366
+ }
367
+ }
368
+ stageBlock(s) {
369
+ s.printed = true;
370
+ const time = s.elapsed(this.o.now());
371
+ const state = s.error !== null ? ' FAILED' : !s.complete ? ' not finished' : s.mark() === 'skipped' ? ' skipped' : '';
372
+ const head = '[' + s.stage + '/' + this.stages.length + '] ' + s.name.padEnd(this.nameWidth);
373
+ return [
374
+ head + (time === null ? '' : ' ' + duration(time).padStart(TIME_WIDTH)) + state,
375
+ ...s.problems.map((p) => this.indent() + p),
376
+ ...s.lines.map((l) => this.indent() + l),
377
+ ];
378
+ }
379
+ indent() {
380
+ return ' '.repeat(('[9/' + this.stages.length + '] ').length);
381
+ }
382
+ /** One stage row; `now` null: the final, still row (no spinner). */
383
+ row(s, now, colour = true) {
384
+ const mark = s.mark();
385
+ const look = MARKS[mark];
386
+ const still = now === null;
387
+ // A stage still running when the view is released (a failed boot) shows as unfinished, without a spinner.
388
+ const spinning = mark === 'running' && !still;
389
+ const glyph = spinning
390
+ ? this.glyphs.spinner[Math.floor(now / SPIN_MS) % this.glyphs.spinner.length]
391
+ : this.glyphs[look.glyph];
392
+ const elapsed = s.elapsed(now ?? this.o.now());
393
+ const time = elapsed === null || (mark === 'running' && still) ? '' : duration(elapsed);
394
+ const detail = mark === 'running'
395
+ ? still
396
+ ? 'not finished'
397
+ : [...s.running.values()].join(', ') || 'waiting'
398
+ : mark === 'failed'
399
+ ? 'failed: ' + s.error
400
+ : mark === 'waiting' && still
401
+ ? 'not started'
402
+ : look.detail;
403
+ return this.render([
404
+ { text: ' ' },
405
+ { text: glyph, colour: mark === 'running' && still ? 'dim' : look.colour },
406
+ { text: ' ' + s.stage + ' ' },
407
+ { text: s.name.padEnd(this.nameWidth), ...(mark === 'waiting' ? { colour: 'dim' } : {}) },
408
+ { text: ' ' + time.padStart(TIME_WIDTH), colour: 'dim' },
409
+ { text: detail ? ' ' + detail : '', colour: mark === 'failed' ? 'red' : 'dim' },
410
+ ], now === null ? Infinity : this.width(), colour);
411
+ }
412
+ /** The tool summary as two aligned columns, the detail wrapped under itself. */
413
+ toolRows(width) {
414
+ const rows = this.tools.map((line) => {
415
+ const at = line.indexOf(': ');
416
+ return at > 0 ? { name: line.slice(0, at), detail: line.slice(at + 2) } : { name: line, detail: '' };
417
+ });
418
+ const column = Math.min(TOOL_COLUMN, Math.max(0, ...rows.map((r) => r.name.length)));
419
+ const lead = ' '.repeat(2 + column + 2);
420
+ return rows.flatMap((r) => {
421
+ const colour = r.name === 'PROTECTION MISSING' ? 'red' : undefined;
422
+ if (!r.detail)
423
+ return [this.paint(' ' + r.name, colour)];
424
+ // A name longer than the column gets a line of its own; the detail stays in its column.
425
+ if (r.name.length > column)
426
+ return [this.paint(' ' + r.name, colour), ...this.hanging(lead, r.detail, width, colour)];
427
+ return this.hanging(' ' + r.name.padEnd(column) + ' ', r.detail, width, colour);
428
+ });
429
+ }
430
+ /** `lead` + `text`, wrapped to `width` with the next lines indented under the text. */
431
+ hanging(lead, text, width, colour) {
432
+ const lines = width === Infinity ? [text] : wrap(text, Math.max(MIN_WIDTH / 2, width - lead.length));
433
+ return lines.map((l, i) => this.paint((i === 0 ? lead : ' '.repeat(lead.length)) + l, colour));
434
+ }
435
+ /** The segments as one line of at most `width` columns, cut with the "more" mark. */
436
+ render(segments, width, colour = true) {
437
+ const total = segments.reduce((n, s) => n + s.text.length, 0);
438
+ let room = total > width ? Math.max(0, width - this.glyphs.more.length) : Infinity;
439
+ let out = '';
440
+ for (const s of segments) {
441
+ if (room <= 0)
442
+ break;
443
+ const text = s.text.slice(0, room);
444
+ room -= text.length;
445
+ out += colour && s.colour && text.trim() ? ANSI[s.colour] + text + RESET : text;
446
+ }
447
+ return total > width ? out.trimEnd() + this.glyphs.more : out.trimEnd();
448
+ }
449
+ paint(text, colour) {
450
+ return this.o.live && colour ? ANSI[colour] + text + RESET : text;
451
+ }
452
+ /** The usable width: one column less than the terminal (a full line would wrap the cursor). */
453
+ width() {
454
+ return Math.max(MIN_WIDTH, this.o.screen.columns() - 1);
455
+ }
456
+ }
@@ -27,9 +27,22 @@ export interface RunnerStatus {
27
27
  readonly running: readonly string[];
28
28
  readonly message: string;
29
29
  }
30
+ /** What the console's progress view hears (foreground tasks only: the background ones are file-only). */
31
+ export interface RunnerObserver {
32
+ started(task: BootTaskDef): void;
33
+ /** Also for a task that was skipped (it never started). */
34
+ finished(task: BootTaskDef, outcome: TaskOutcome): void;
35
+ }
36
+ /** Who logs while a task runs. */
37
+ export interface TaskContext {
38
+ readonly stage: number;
39
+ readonly title: string;
40
+ readonly quiet: boolean;
41
+ }
30
42
  export interface RunnerOptions {
43
+ /** A line for the log file only: when each task started and finished (the console shows the progress view). */
44
+ trace(line: string): void;
31
45
  /** `quiet`: file only (background tasks). */
32
- say(line: string, quiet?: boolean): void;
33
46
  warn(line: string, quiet?: boolean): void;
34
47
  /** Milliseconds. */
35
48
  now(): number;
@@ -39,13 +52,12 @@ export interface RunnerOptions {
39
52
  stages: number;
40
53
  stageTitle(stage: number): string;
41
54
  status?(status: RunnerStatus): void;
42
- /** Runs a task's work so that its log lines carry the task (`prefix()` is empty while it runs alone). */
43
- inContext?<T>(ctx: {
44
- prefix: () => string;
45
- quiet: boolean;
46
- }, work: () => T): T;
55
+ observer?: RunnerObserver;
56
+ /** Runs a task's work so that its log lines carry the task (its stage and title, and whether they stay quiet). */
57
+ inContext?<T>(ctx: TaskContext, work: () => T): T;
47
58
  }
48
- export declare const seconds: (ms: number) => string;
59
+ /** "4.6s" under a minute, "7m02s" from there. */
60
+ export declare function duration(ms: number): string;
49
61
  /** Dependencies must exist, ids be unique, the graph acyclic and no foreground task wait for a background one. */
50
62
  export declare function checkGraph(defs: readonly BootTaskDef[]): void;
51
63
  export declare class BootRunner<Id extends string> {
@@ -55,7 +67,6 @@ export declare class BootRunner<Id extends string> {
55
67
  private readonly byId;
56
68
  private readonly state;
57
69
  private readonly outcomes;
58
- private readonly announced;
59
70
  private readonly running;
60
71
  constructor(defs: readonly BootTaskDef<Id>[], impl: Readonly<Record<Id, TaskRun>>, o: RunnerOptions);
61
72
  /** Every outcome so far, in declaration order. */
@@ -68,10 +79,9 @@ export declare class BootRunner<Id extends string> {
68
79
  private isFinished;
69
80
  /** The task cannot have run properly: skipped, or failed with a critical failure. */
70
81
  private isBlocked;
82
+ private tag;
71
83
  private skip;
72
84
  private begin;
73
85
  private finish;
74
86
  private report;
75
87
  }
76
- /** "Boot ready in 31.8s. Slowest: Headroom 28.1s, Developer tools 7.0s. In the background: Visual Studio (...)". */
77
- export declare function bootSummary(totalMs: number, outcomes: readonly TaskOutcome[], background: readonly string[], logPath: string): string;
@@ -1,9 +1,15 @@
1
1
  // Runs the boot's tasks: each starts when the tasks it comes after are finished, a few at a time, in declaration order.
2
- // Pure (no runtime imports): the boot injects printing, the clock, the log context and the status.
2
+ // Pure (no runtime imports): the boot injects the log, the clock, the log context, the status and the progress view.
3
3
  const PARALLEL = 4;
4
- const CYAN = '\x1b[36m';
5
- const RESET = '\x1b[0m';
6
- export const seconds = (ms) => (ms / 1000).toFixed(1) + 's';
4
+ const MINUTE = 60000;
5
+ /** "4.6s" under a minute, "7m02s" from there. */
6
+ export function duration(ms) {
7
+ // 59.96 s would round to "60.0s": from there it is "1m00s".
8
+ if (ms < MINUTE - 50)
9
+ return (Math.max(0, ms) / 1000).toFixed(1) + 's';
10
+ const s = Math.round(ms / 1000);
11
+ return Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's';
12
+ }
7
13
  /** Dependencies must exist, ids be unique, the graph acyclic and no foreground task wait for a background one. */
8
14
  export function checkGraph(defs) {
9
15
  const byId = new Map();
@@ -44,7 +50,6 @@ export class BootRunner {
44
50
  byId = new Map();
45
51
  state = new Map();
46
52
  outcomes = new Map();
47
- announced = new Set();
48
53
  running = new Set();
49
54
  constructor(defs, impl, o) {
50
55
  this.defs = defs;
@@ -84,13 +89,12 @@ export class BootRunner {
84
89
  this.state.set(d.id, 'running');
85
90
  this.running.add(d.id);
86
91
  const began = this.o.now();
87
- if (!quiet)
88
- this.begin(d);
89
- const prefix = () => (this.running.size > 1 && !d.exclusive ? d.title : '');
92
+ this.begin(d, quiet);
93
+ const ctx = { stage: d.stage, title: d.title, quiet };
90
94
  const work = async () => {
91
95
  await this.impl[d.id]();
92
96
  };
93
- const call = () => (this.o.inContext ? this.o.inContext({ prefix, quiet }, work) : work());
97
+ const call = () => (this.o.inContext ? this.o.inContext(ctx, work) : work());
94
98
  // A synchronous throw from a task is a failure too: the call starts inside a promise.
95
99
  void Promise.resolve()
96
100
  .then(call)
@@ -115,7 +119,7 @@ export class BootRunner {
115
119
  for (const d of pending()) {
116
120
  const bad = d.after.find((a) => this.isBlocked(a));
117
121
  if (bad) {
118
- this.skip(d, this.byId.get(bad).title + ' failed');
122
+ this.skip(d, this.byId.get(bad).title + ' failed', quiet);
119
123
  again = true;
120
124
  }
121
125
  }
@@ -147,43 +151,44 @@ export class BootRunner {
147
151
  const s = this.state.get(id);
148
152
  return s === 'skipped' || (s === 'failed' && this.byId.get(id).critical);
149
153
  }
150
- skip(d, why) {
154
+ tag(d) {
155
+ return (d.background ? '[background] ' : '[' + d.stage + '/' + this.o.stages + '] ') + d.title;
156
+ }
157
+ skip(d, why, quiet) {
151
158
  this.state.set(d.id, 'skipped');
152
- this.outcomes.set(d.id, { id: d.id, title: d.title, state: 'skipped', ms: 0, error: why });
153
- this.o.say('[' + d.stage + '/' + this.o.stages + '] ' + d.title + ': skipped (' + why + ')');
159
+ const outcome = { id: d.id, title: d.title, state: 'skipped', ms: 0, error: why };
160
+ this.outcomes.set(d.id, outcome);
161
+ this.o.trace(this.tag(d) + ': skipped (' + why + ')');
162
+ if (!quiet)
163
+ this.o.observer?.finished(d, outcome);
154
164
  }
155
- begin(d) {
156
- if (!this.announced.has(d.stage)) {
157
- this.announced.add(d.stage);
158
- this.o.say('\n' + CYAN + '[' + d.stage + '/' + this.o.stages + '] ' + this.o.stageTitle(d.stage) + RESET);
159
- }
160
- this.o.say('[' + d.stage + '/' + this.o.stages + '] ' + d.title + ': started');
165
+ begin(d, quiet) {
166
+ this.o.trace(this.tag(d) + ': started');
167
+ if (quiet)
168
+ return;
169
+ this.o.observer?.started(d);
161
170
  this.report(d.stage);
162
171
  }
163
172
  finish(d, began, quiet, error) {
164
173
  const ms = this.o.now() - began;
165
174
  const message = error === undefined ? undefined : error instanceof Error ? error.message : String(error);
166
- this.state.set(d.id, message === undefined ? 'done' : 'failed');
167
- this.outcomes.set(d.id, {
175
+ const outcome = {
168
176
  id: d.id,
169
177
  title: d.title,
170
178
  state: message === undefined ? 'done' : 'failed',
171
179
  ms,
172
180
  ...(message === undefined ? {} : { error: message }),
173
- });
174
- const tag = '[' + d.stage + '/' + this.o.stages + '] ' + d.title;
175
- if (message === undefined) {
176
- if (!quiet)
177
- this.o.say(tag + ': done in ' + seconds(ms));
178
- }
179
- else if (d.critical) {
180
- this.o.warn(tag + ': FAILED in ' + seconds(ms) + ': ' + message, quiet);
181
- }
182
- else {
181
+ };
182
+ this.state.set(d.id, outcome.state);
183
+ this.outcomes.set(d.id, outcome);
184
+ this.o.trace(this.tag(d) + (message === undefined ? ': done in ' : ': FAILED in ') + duration(ms));
185
+ // A soft failure is a warning and the boot goes on; a critical one stops the boot, which shows that error.
186
+ if (message !== undefined && !d.critical)
183
187
  this.o.warn(d.title + ' failed (continuing): ' + message, quiet);
184
- if (!quiet)
185
- this.o.say(tag + ': FAILED in ' + seconds(ms) + ': ' + message);
186
- }
188
+ if (message !== undefined && d.critical)
189
+ this.o.trace(this.tag(d) + ': ' + message);
190
+ if (!quiet)
191
+ this.o.observer?.finished(d, outcome);
187
192
  }
188
193
  report(stageHint) {
189
194
  if (!this.o.status)
@@ -200,16 +205,3 @@ export class BootRunner {
200
205
  });
201
206
  }
202
207
  }
203
- /** "Boot ready in 31.8s. Slowest: Headroom 28.1s, Developer tools 7.0s. In the background: Visual Studio (...)". */
204
- export function bootSummary(totalMs, outcomes, background, logPath) {
205
- const slowest = [...outcomes]
206
- .filter((o) => o.state !== 'skipped')
207
- .sort((a, b) => b.ms - a.ms)
208
- .slice(0, 3)
209
- .map((o) => o.title + ' ' + seconds(o.ms));
210
- return ('Boot ready in ' +
211
- seconds(totalMs) +
212
- '.' +
213
- (slowest.length ? ' Slowest: ' + slowest.join(', ') + '.' : '') +
214
- (background.length ? ' In the background: ' + background.join(', ') + ' (installing, see ' + logPath + ').' : ''));
215
- }
@@ -0,0 +1,7 @@
1
+ /** Text without its terminal colour and cursor sequences. */
2
+ export declare function plainText(text: string): string;
3
+ /**
4
+ * The file lines for one message: each line of it with an ISO timestamp (UTC, zero-padded:
5
+ * `2026-11-04T12:07:27.930Z`) and the task that logged it, if any; blank lines are dropped.
6
+ */
7
+ export declare function fileLines(message: string, at?: Date, task?: string): string[];