windows-development-sandbox 1.23.2 → 1.25.1

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 (211) hide show
  1. package/config.example.json +5 -3
  2. package/dist/app/features/data/data.def.d.ts +4 -4
  3. package/dist/app/features/data/data.run.d.ts +1 -1
  4. package/dist/app/features/defs.d.ts +161 -6
  5. package/dist/app/features/defs.js +7 -0
  6. package/dist/app/features/projects/projects.def.d.ts +1 -1
  7. package/dist/app/features/projects/projects.run.d.ts +1 -1
  8. package/dist/app/features/unreal/index.d.ts +2 -0
  9. package/dist/app/features/unreal/index.js +3 -0
  10. package/dist/app/features/unreal/unreal.def.d.ts +236 -0
  11. package/dist/app/features/unreal/unreal.def.js +379 -0
  12. package/dist/app/features/unreal/unreal.run.d.ts +96 -0
  13. package/dist/app/features/unreal/unreal.run.js +217 -0
  14. package/dist/app/registry.js +5 -0
  15. package/dist/bin/agent-sandbox.js +2 -2
  16. package/dist/bin/{chunk-7YPEDAJD.js → chunk-3GAXLATK.js} +58 -5
  17. package/dist/bin/{chunk-USTRSHIE.js → chunk-F4PVBN23.js} +2615 -1515
  18. package/dist/bin/{chunk-QFSJX2KW.js → chunk-G3TG4ZT7.js} +1 -1
  19. package/dist/bin/claude-host.js +2 -2
  20. package/dist/bin/claude-sbx.js +2 -2
  21. package/dist/bin/codex-sbx.js +2 -2
  22. package/dist/bin/gemini-sbx.js +2 -2
  23. package/dist/bin/sandbox.js +2 -2
  24. package/dist/bin/sbx-hide.js +2 -2
  25. package/dist/bin/sbx-show.js +2 -2
  26. package/dist/cli/commands/launch/launch.js +5 -2
  27. package/dist/cli/commands/registry.js +3 -0
  28. package/dist/cli/manifest.d.ts +18 -0
  29. package/dist/cli/manifest.js +26 -0
  30. package/dist/cli/menu/coverage-exempt.js +1 -0
  31. package/dist/cli/menu/pages/sandbox-page.d.ts +3 -0
  32. package/dist/cli/menu/pages/sandbox-page.js +34 -4
  33. package/dist/cli/menu/views.js +2 -0
  34. package/dist/core/config/defaults.js +18 -5
  35. package/dist/core/detect/toolchains.js +0 -6
  36. package/dist/core/schema/config.d.ts +10 -9
  37. package/dist/core/schema/pc-installs.d.ts +3 -1
  38. package/dist/core/schema/pc-installs.js +10 -1
  39. package/dist/core/schema/toolchains.d.ts +1 -2
  40. package/dist/core/schema/toolchains.js +4 -7
  41. package/dist/guest/accounts/kinds/epic.js +18 -13
  42. package/dist/guest/agents/agent-files.d.ts +11 -7
  43. package/dist/guest/agents/agent-files.js +37 -11
  44. package/dist/guest/agents/local-state.d.ts +2 -1
  45. package/dist/guest/agents/local-state.js +2 -1
  46. package/dist/guest/apps/after-install.d.ts +1 -1
  47. package/dist/guest/apps/after-install.js +6 -1
  48. package/dist/guest/apps/app-installer.d.ts +1 -0
  49. package/dist/guest/apps/app-installer.js +13 -3
  50. package/dist/guest/apps/comet-unpack.js +3 -2
  51. package/dist/guest/apps/resolvers-gamedev.js +55 -1
  52. package/dist/guest/boot/boot-console.d.ts +28 -0
  53. package/dist/guest/boot/boot-console.js +98 -0
  54. package/dist/guest/boot/boot.d.ts +8 -1
  55. package/dist/guest/boot/boot.js +80 -43
  56. package/dist/guest/boot/graph.d.ts +9 -5
  57. package/dist/guest/boot/graph.js +8 -2
  58. package/dist/guest/boot/guide.js +24 -0
  59. package/dist/guest/boot/progress-view.d.ts +116 -0
  60. package/dist/guest/boot/progress-view.js +456 -0
  61. package/dist/guest/boot/runner.d.ts +20 -10
  62. package/dist/guest/boot/runner.js +39 -47
  63. package/dist/guest/daemon/daemon.js +3 -0
  64. package/dist/guest/runtime/log-line.d.ts +7 -0
  65. package/dist/guest/runtime/log-line.js +18 -0
  66. package/dist/guest/runtime/log.d.ts +17 -4
  67. package/dist/guest/runtime/log.js +40 -17
  68. package/dist/guest/runtime/process.d.ts +9 -0
  69. package/dist/guest/runtime/process.js +69 -8
  70. package/dist/guest/setup/addon-mcp.d.ts +7 -0
  71. package/dist/guest/setup/addon-mcp.js +19 -4
  72. package/dist/guest/setup/browser-gpu.js +14 -11
  73. package/dist/guest/setup/environment.js +9 -4
  74. package/dist/guest/setup/headroom.js +4 -0
  75. package/dist/guest/setup/hooks.d.ts +2 -0
  76. package/dist/guest/setup/hooks.js +4 -0
  77. package/dist/guest/setup/path-plan.d.ts +12 -0
  78. package/dist/guest/setup/path-plan.js +18 -0
  79. package/dist/guest/toolchains/engines/epic-launcher.d.ts +26 -0
  80. package/dist/guest/toolchains/engines/epic-launcher.js +127 -0
  81. package/dist/guest/toolchains/engines/unreal-engines.d.ts +21 -1
  82. package/dist/guest/toolchains/engines/unreal-engines.js +163 -3
  83. package/dist/guest/toolchains/engines/unreal-status.d.ts +13 -0
  84. package/dist/guest/toolchains/engines/unreal-status.js +41 -0
  85. package/dist/guest/toolchains/native/debuggers.d.ts +6 -0
  86. package/dist/guest/toolchains/native/debuggers.js +57 -0
  87. package/dist/guest/toolchains/native/mesa.js +15 -8
  88. package/dist/guest/toolchains/native/rust-extras.d.ts +7 -0
  89. package/dist/guest/toolchains/native/rust-extras.js +23 -1
  90. package/dist/guest/toolchains/native/unreal.d.ts +2 -2
  91. package/dist/guest/toolchains/native/unreal.js +96 -20
  92. package/dist/guest/toolchains/native/visual-studio.d.ts +14 -14
  93. package/dist/guest/toolchains/native/visual-studio.js +145 -174
  94. package/dist/guest/toolchains/native/vs-persist.d.ts +189 -0
  95. package/dist/guest/toolchains/native/vs-persist.js +256 -0
  96. package/dist/guest/toolchains/native/vs-request.d.ts +6 -26
  97. package/dist/guest/toolchains/native/vs-request.js +16 -50
  98. package/dist/guest/toolchains/plan.js +23 -15
  99. package/dist/guest/toolchains/tools/vscode-profile.js +2 -0
  100. package/dist/host/accounts/kinds/epic.js +11 -5
  101. package/dist/host/accounts/source.d.ts +2 -0
  102. package/dist/host/accounts/source.js +1 -0
  103. package/dist/host/migrate/migrations/drop-vs-install-files.d.ts +5 -0
  104. package/dist/host/migrate/migrations/drop-vs-install-files.js +34 -0
  105. package/dist/host/migrate/registry.js +7 -1
  106. package/dist/host/pc-installs/discover.d.ts +2 -0
  107. package/dist/host/pc-installs/discover.js +25 -5
  108. package/dist/host/pc-installs/epic.d.ts +3 -0
  109. package/dist/host/pc-installs/epic.js +33 -2
  110. package/dist/host/pc-installs/source.d.ts +1 -1
  111. package/dist/host/pc-installs/unreal-vault.d.ts +11 -0
  112. package/dist/host/pc-installs/unreal-vault.js +78 -0
  113. package/dist/host/pc-installs/unreal.d.ts +36 -0
  114. package/dist/host/pc-installs/unreal.js +145 -2
  115. package/dist/host/profile/claude-profile.d.ts +8 -0
  116. package/dist/host/profile/claude-profile.js +43 -4
  117. package/dist/host/profile/index.d.ts +1 -1
  118. package/dist/host/profile/index.js +1 -1
  119. package/dist/runtime/agent.js +148 -33
  120. package/dist/runtime/daemon.js +3146 -1546
  121. package/dist/runtime/db.js +148 -33
  122. package/dist/runtime/main.js +3982 -2049
  123. package/dist/runtime/netproxy.js +84 -13
  124. package/dist/runtime/tool.js +125 -31
  125. package/dist/runtime/view.js +85 -14
  126. package/dist/selftest/agent-paths.test.js +18 -1
  127. package/dist/selftest/agent-settings.test.js +36 -5
  128. package/dist/selftest/agents-web.test.js +12 -0
  129. package/dist/selftest/boot-console.test.d.ts +2 -0
  130. package/dist/selftest/boot-console.test.js +291 -0
  131. package/dist/selftest/boot-runner.test.js +10 -12
  132. package/dist/selftest/engines.test.js +602 -3
  133. package/dist/selftest/guest-boot.test.js +7 -1
  134. package/dist/selftest/heavy-load.test.js +3 -1
  135. package/dist/selftest/hostile-input.test.js +11 -1
  136. package/dist/selftest/index.js +4 -0
  137. package/dist/selftest/layout.test.js +4 -0
  138. package/dist/selftest/menu-screens.js +3 -1
  139. package/dist/selftest/migrate.test.js +21 -0
  140. package/dist/selftest/mixamo.test.d.ts +2 -0
  141. package/dist/selftest/mixamo.test.js +60 -0
  142. package/dist/selftest/persisted-installs.test.js +169 -33
  143. package/dist/selftest/secrets.test.js +36 -1
  144. package/dist/selftest/support/engine-golden.js +5 -0
  145. package/dist/selftest/support/fixtures.d.ts +4 -3
  146. package/dist/selftest/support/unreal-sample.d.ts +2 -0
  147. package/dist/selftest/support/unreal-sample.js +83 -0
  148. package/dist/selftest/toolchains.test.js +32 -2
  149. package/dist/shared/accounts/index.d.ts +1 -1
  150. package/dist/shared/accounts/index.js +1 -1
  151. package/dist/shared/accounts/kinds/epic.d.ts +6 -1
  152. package/dist/shared/accounts/kinds/epic.js +11 -1
  153. package/dist/shared/apps/associations.d.ts +7 -0
  154. package/dist/shared/apps/associations.js +26 -0
  155. package/dist/shared/apps/catalogue-agents-web.d.ts +4 -1
  156. package/dist/shared/apps/catalogue-agents-web.js +5 -2
  157. package/dist/shared/apps/catalogue-gamedev.js +71 -1
  158. package/dist/shared/apps/index.d.ts +2 -0
  159. package/dist/shared/apps/index.js +2 -0
  160. package/dist/shared/apps/preview.d.ts +2 -0
  161. package/dist/shared/apps/preview.js +10 -0
  162. package/dist/shared/browser-policy.d.ts +14 -0
  163. package/dist/shared/browser-policy.js +22 -0
  164. package/dist/shared/channel/schema.d.ts +3 -6
  165. package/dist/shared/io/index.d.ts +1 -0
  166. package/dist/shared/io/index.js +1 -0
  167. package/dist/shared/io/rotate-log.d.ts +2 -0
  168. package/dist/shared/io/rotate-log.js +16 -0
  169. package/dist/shared/memory/policy.d.ts +5 -0
  170. package/dist/shared/memory/policy.js +7 -0
  171. package/dist/shared/paths/pc-installs.d.ts +13 -2
  172. package/dist/shared/paths/pc-installs.js +1 -0
  173. package/dist/shared/pty/terminal.js +2 -1
  174. package/dist/shared/readiness.d.ts +0 -1
  175. package/dist/shared/readiness.js +0 -1
  176. package/dist/shared/signer.d.ts +4 -0
  177. package/dist/shared/signer.js +2 -0
  178. package/dist/shared/unreal/engines.d.ts +161 -0
  179. package/dist/shared/unreal/engines.js +308 -0
  180. package/dist/shared/unreal/guide.d.ts +19 -0
  181. package/dist/shared/unreal/guide.js +105 -0
  182. package/dist/shared/unreal/index.d.ts +7 -0
  183. package/dist/shared/unreal/index.js +8 -0
  184. package/dist/shared/unreal/launcher-files.d.ts +88 -0
  185. package/dist/shared/unreal/launcher-files.js +292 -0
  186. package/dist/shared/unreal/mixamo.d.ts +5 -0
  187. package/dist/shared/unreal/mixamo.js +16 -0
  188. package/dist/shared/unreal/room.d.ts +17 -0
  189. package/dist/shared/unreal/room.js +34 -0
  190. package/dist/shared/unreal/status.d.ts +54 -0
  191. package/dist/shared/unreal/status.js +57 -0
  192. package/dist/shared/unreal/vault.d.ts +21 -0
  193. package/dist/shared/unreal/vault.js +67 -0
  194. package/dist/shared/unreal/vs-check.d.ts +39 -0
  195. package/dist/shared/unreal/vs-check.js +79 -0
  196. package/dist/sync/watcher.js +5 -1
  197. package/dist/web/index.html +1 -1
  198. package/dist/web/{main-CCILLX2M.js → main-FNE6XEHA.js} +84 -84
  199. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  200. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  201. package/native/csharp/StorageHelper/AssemblyInfo.cs +3 -3
  202. package/package.json +1 -1
  203. package/sandbox/csharp/VsSetup.cs +210 -0
  204. package/sandbox/scripts/get-vs-setup.ps1 +93 -0
  205. package/sandbox/scripts/restore-visual-studio.ps1 +78 -0
  206. package/sandbox/scripts/save-visual-studio.ps1 +106 -0
  207. package/sandbox/scripts/set-registry-values.ps1 +35 -0
  208. package/schemas/config.schema.json +38 -9
  209. package/schemas/sandbox-config.schema.json +11 -8
  210. package/dist/guest/toolchains/native/autosdk.d.ts +0 -3
  211. package/dist/guest/toolchains/native/autosdk.js +0 -50
@@ -1,10 +1,14 @@
1
1
  import type { BootTaskDef } from './runner.js';
2
2
  export declare const STAGES = 9;
3
- /** The titles of the nine stages (the host shows the short form without the brackets). */
4
- export declare function stageTitle(stage: number, who?: {
5
- vpnServer?: string;
6
- agent?: string;
7
- }): string;
3
+ /** Who the stage titles name. */
4
+ export interface StageWho {
5
+ readonly vpnServer?: string;
6
+ readonly agent?: string;
7
+ }
8
+ /** The titles of the nine stages, with a note where one helps (the host's status message). */
9
+ export declare function stageTitle(stage: number, who?: StageWho): string;
10
+ /** The short titles of the nine stages: the console's stage rows and the host's progress line. */
11
+ export declare function stageName(stage: number, who?: StageWho): string;
8
12
  /** The boot state the tasks share. */
9
13
  export type BootStateKey = 'ptyHost' | 'lan' | 'tools' | 'dbBin' | 'allowSummary' | 'vscodeSummary' | 'env' | 'toolVars' | 'summary' | 'headroom' | 'later';
10
14
  export interface BootTaskInfo {
@@ -1,13 +1,19 @@
1
1
  export const STAGES = 9;
2
- /** The titles of the nine stages (the host shows the short form without the brackets). */
2
+ /** The titles of the nine stages, with a note where one helps (the host's status message). */
3
3
  export function stageTitle(stage, who = {}) {
4
+ return stage === 3
5
+ ? stageName(stage, who) + ' (the first start downloads them once; later starts are quick)'
6
+ : stageName(stage, who);
7
+ }
8
+ /** The short titles of the nine stages: the console's stage rows and the host's progress line. */
9
+ export function stageName(stage, who = {}) {
4
10
  switch (stage) {
5
11
  case 1:
6
12
  return 'Network and session host';
7
13
  case 2:
8
14
  return 'Pre-provision hooks and desktop';
9
15
  case 3:
10
- return 'Developer tools (the first start downloads them once; later starts are quick)';
16
+ return 'Developer tools';
11
17
  case 4:
12
18
  return who.vpnServer ? 'VPN (' + who.vpnServer + ')' : 'VPN (off)';
13
19
  case 5:
@@ -7,6 +7,10 @@ import { dataDir, launchDir, logsDir, toolsDir } from '../runtime/paths.js';
7
7
  import { addonGuideLine, APPS, headroomGuideLine, RTK_GUIDE_LINE } from '../../shared/apps/index.js';
8
8
  import { installedAddon, installedRtk } from '../setup/agent-addons.js';
9
9
  import { projectFolderLines, toolLines } from './guide-lines.js';
10
+ import { sandboxScriptsDir } from '../../shared/scripts.js';
11
+ import { findUproject, unrealGuideLines } from '../../shared/unreal/index.js';
12
+ import { DISK } from '../toolchains/engines/unreal-engines.js';
13
+ import { readUnrealStatus } from '../toolchains/engines/unreal-status.js';
10
14
  const BEGIN = '<!-- agent-sandbox:begin (generated every boot, do not edit) -->';
11
15
  const END = '<!-- agent-sandbox:end -->';
12
16
  /** Drives other than C: are subst drives onto C:\Mirror\<letter> (mapped folders shown at their PC paths). */
@@ -25,6 +29,25 @@ const mirrorLine = () => {
25
29
  };
26
30
  /** Folders mapped for other projects at boot; launch folders from before mappedProjects only list the guest side. */
27
31
  const mappedFolders = () => session.mappedProjects ?? (session.roots ?? []).map((guest) => ({ host: 'a folder on the PC', guest }));
32
+ /** The Mixamo import template, when this version ships one. */
33
+ function mixamoScript() {
34
+ const file = path.join(sandboxScriptsDir(), 'templates', 'unreal', 'import_mixamo.py');
35
+ return fs.existsSync(file) ? file : null;
36
+ }
37
+ /** The Unreal lines (engine per project, build/test/package/debug commands) when the sandbox is set up for Unreal. */
38
+ function unrealLines(projects) {
39
+ const any = session.toolchains.unreal.enabled === true || projects.some((p) => p.tools?.some((t) => /^unreal/.test(t)));
40
+ if (!any)
41
+ return [];
42
+ const status = readUnrealStatus();
43
+ if (!status.updatedAt)
44
+ return [];
45
+ const folders = [workspace, ...projects.map((p) => p.guest)];
46
+ return unrealGuideLines(status, folders
47
+ .filter((f, i) => folders.findIndex((g) => g.toLowerCase() === f.toLowerCase()) === i)
48
+ .filter((f) => status.projects.some((p) => p.workspace.toLowerCase() === f.toLowerCase()))
49
+ .map((f) => ({ workspace: f, uproject: findUproject(f, DISK) })), { mixamo: { downloads: path.join(dataDir, 'downloads'), script: mixamoScript() } });
50
+ }
28
51
  /** Deliberately tiny: it is loaded into every conversation. */
29
52
  export function sandboxGuide(toolSummary, lan = 'blocked', projects = [], addons = []) {
30
53
  return [
@@ -45,6 +68,7 @@ export function sandboxGuide(toolSummary, lan = 'blocked', projects = [], addons
45
68
  '- RO: `' + launchDir + "` (this boot's scripts) · lost on close: everything else, incl. `C:\\nvm` (Node symlink)",
46
69
  ...mirrorLine(),
47
70
  ...toolLines(session.project, toolSummary, projects),
71
+ ...unrealLines(projects),
48
72
  // Agent add-ons that change how the agent works (rtk, Headroom, MCP servers, ccusage).
49
73
  ...addons,
50
74
  '- Persistent installs: `npm i -g` (per nvm version), `pip`, `cargo install`, `dotnet tool -g`, `uv tool`, `bun add -g`. Real installers must re-run each boot via a hook (ask the user).',
@@ -0,0 +1,116 @@
1
+ import { type TaskOutcome } from './runner.js';
2
+ export interface ViewStage {
3
+ readonly stage: number;
4
+ readonly name: string;
5
+ }
6
+ export interface ViewTask {
7
+ readonly id: string;
8
+ readonly stage: number;
9
+ readonly title: string;
10
+ /** Its failure fails the boot (a soft failure is a warning). */
11
+ readonly critical?: boolean;
12
+ }
13
+ /** The marks of a stage row. */
14
+ export interface Glyphs {
15
+ readonly done: string;
16
+ readonly failed: string;
17
+ readonly warned: string;
18
+ readonly skipped: string;
19
+ readonly waiting: string;
20
+ readonly more: string;
21
+ readonly spinner: readonly string[];
22
+ }
23
+ /** For Windows Terminal (Cascadia has braille and check marks). */
24
+ export declare const RICH_GLYPHS: Glyphs;
25
+ /** For the classic console (Consolas has neither braille nor check marks). */
26
+ export declare const CONSOLE_GLYPHS: Glyphs;
27
+ export interface Screen {
28
+ /** Writes text as it is (the view writes its own line ends and, when live, its cursor moves). */
29
+ write(text: string): void;
30
+ /** The terminal's width (live mode). */
31
+ columns(): number;
32
+ }
33
+ export interface ViewOptions {
34
+ /** A terminal: the stage block redraws in place. Otherwise plain, ordered lines. */
35
+ readonly live: boolean;
36
+ readonly screen: Screen;
37
+ /** Milliseconds. */
38
+ readonly now: () => number;
39
+ readonly glyphs?: Glyphs;
40
+ }
41
+ /** The end of the boot: how long it took, which tasks took longest and what goes on in the background. */
42
+ export interface ReadyInfo {
43
+ readonly ms: number;
44
+ readonly outcomes: readonly TaskOutcome[];
45
+ readonly background: readonly string[];
46
+ readonly logPath: string;
47
+ }
48
+ /** "Node, Python, Rust +2 (5/13)": what the toolchain scheduler runs now. */
49
+ export declare function stepsDetail(active: readonly string[], done: number, total: number): string;
50
+ /** "Ready in 46.8s (slowest: ...)" and, when work goes on, the line that says what still installs. */
51
+ export declare function readyLines(info: ReadyInfo): string[];
52
+ /** Word-wraps `text` to `width` columns; a word longer than that is cut. Linear in the text. */
53
+ export declare function wrap(text: string, width: number): string[];
54
+ /** One boot's console: fed by the runner's events and the boot's lines, drawn on a screen. */
55
+ export declare class ProgressView {
56
+ private readonly o;
57
+ private readonly stages;
58
+ private readonly byStage;
59
+ private readonly taskById;
60
+ private readonly nameWidth;
61
+ private readonly glyphs;
62
+ private readonly warnings;
63
+ private readonly notices;
64
+ private tools;
65
+ /** Lines of the live block on the screen now (the cursor is just below them). */
66
+ private drawn;
67
+ private lastFrame;
68
+ private asideDepth;
69
+ private released;
70
+ constructor(stages: readonly ViewStage[], tasks: readonly ViewTask[], o: ViewOptions);
71
+ /**
72
+ * Whether programs should hand their output to the log now: it would draw over the live block, or come out between
73
+ * the plain stages. Not while a hook has the terminal.
74
+ */
75
+ get capturing(): boolean;
76
+ /** The header line, then (live) the first frame. */
77
+ start(header: string): void;
78
+ started(task: ViewTask): void;
79
+ finished(task: ViewTask, outcome: TaskOutcome): void;
80
+ /** What a running task's place on its row says instead of its title (null: its title again). */
81
+ detail(taskId: string, text: string | null): void;
82
+ /** An info line: the file has it; plain output shows it under its stage (or at once, outside a stage). */
83
+ line(text: string, stage: number | undefined): void;
84
+ warning(text: string): void;
85
+ notice(text: string): void;
86
+ /** The tool summary ("name: detail" lines), printed once at the end. */
87
+ summary(lines: readonly string[]): void;
88
+ /** Redraws the live block (the boot calls it a few times a second). */
89
+ tick(): void;
90
+ /** Runs `work`, which writes to the terminal itself (the hooks): the live block steps aside until it is done. */
91
+ aside<T>(work: () => T): T;
92
+ /**
93
+ * The boot is over (`ready`) or failed (null): the final block, warnings, tools, notices and the ready line, once.
94
+ * The console is free afterwards (the first session takes it); nothing of this view prints again.
95
+ */
96
+ release(ready: ReadyInfo | null): void;
97
+ /** The live block as it would be drawn now (no colours when `colour` is false): the tests read it. */
98
+ frame(colour?: boolean): string[];
99
+ private draw;
100
+ private erase;
101
+ /** Plain output: every finished stage whose earlier stages are printed, in stage order. */
102
+ private flushPlain;
103
+ private stageBlock;
104
+ private indent;
105
+ /** One stage row; `now` null: the final, still row (no spinner). */
106
+ private row;
107
+ /** The tool summary as two aligned columns, the detail wrapped under itself. */
108
+ private toolRows;
109
+ /** `lead` + `text`, wrapped to `width` with the next lines indented under the text. */
110
+ private hanging;
111
+ /** The segments as one line of at most `width` columns, cut with the "more" mark. */
112
+ private render;
113
+ private paint;
114
+ /** The usable width: one column less than the terminal (a full line would wrap the cursor). */
115
+ private width;
116
+ }
@@ -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
+ }