@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.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 (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -3,13 +3,18 @@
3
3
  * The spawned server runs in foreground mode (no subcommand) and writes
4
4
  * its own PID file at ~/.pi/dashboard/server.pid.
5
5
  */
6
- import { spawn } from "node:child_process";
6
+ import { spawnDetached, waitForReady } from "@blackbelt-technology/pi-dashboard-shared/platform/detached-spawn.js";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
7
9
  import path from "node:path";
10
+ import { createRequire } from "node:module";
8
11
  import { fileURLToPath } from "node:url";
9
12
  import type { DashboardConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
10
13
  import { resolveJitiImport } from "@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js";
14
+ import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
11
15
 
12
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const require = createRequire(import.meta.url);
13
18
 
14
19
  export interface LaunchResult {
15
20
  success: boolean;
@@ -17,11 +22,26 @@ export interface LaunchResult {
17
22
  }
18
23
 
19
24
  /**
20
- * Resolve the dashboard server CLI script path relative to this extension file.
21
- * From packages/extension/src/server-launcher.ts → packages/server/src/cli.ts
25
+ * Resolve the dashboard server CLI script path.
26
+ *
27
+ * Handles two layouts:
28
+ * 1. Monorepo dev: `<repo>/packages/extension/src/` → `<repo>/packages/server/src/cli.ts`
29
+ * 2. Installed : `<x>/node_modules/@blackbelt-technology/pi-dashboard-extension/src/`
30
+ * → `<x>/node_modules/@blackbelt-technology/pi-dashboard-server/src/cli.ts`
31
+ *
32
+ * Uses Node's module resolver (`require.resolve`) to find the server package
33
+ * and joins `src/cli.ts`. Falls back to the monorepo-relative path so existing
34
+ * dev workflows keep working even if the server package isn't resolvable (e.g.
35
+ * a pristine checkout with no node_modules yet).
22
36
  */
23
37
  export function resolveServerCliPath(): string {
24
- return path.resolve(__dirname, "..", "..", "server", "src", "cli.ts");
38
+ try {
39
+ const serverPkgJson = require.resolve("@blackbelt-technology/pi-dashboard-server/package.json");
40
+ return path.resolve(path.dirname(serverPkgJson), "src", "cli.ts");
41
+ } catch {
42
+ // Dev-repo fallback: <extension>/src/../../server/src/cli.ts
43
+ return path.resolve(__dirname, "..", "..", "server", "src", "cli.ts");
44
+ }
25
45
  }
26
46
 
27
47
  /**
@@ -43,36 +63,60 @@ export async function launchServer(config: DashboardConfig): Promise<LaunchResul
43
63
  const args = buildSpawnArgs(config);
44
64
 
45
65
  try {
46
- // Spawn server using pi's jiti TypeScript loader (resolved to absolute path).
47
- // The server writes its own PID file on startup, so
48
- // `pi-dashboard status` can detect it.
49
- const child = spawn(process.execPath, ["--import", resolveJitiImport(), cliPath, ...args], {
50
- detached: true,
51
- stdio: "ignore",
66
+ // Open the server.log in append mode so any startup error is visible.
67
+ // Matches the log location used by `pi-dashboard start`.
68
+ let logFd: number | undefined;
69
+ try {
70
+ const logDir = path.join(os.homedir(), ".pi", "dashboard");
71
+ fs.mkdirSync(logDir, { recursive: true });
72
+ const logPath = path.join(logDir, "server.log");
73
+ logFd = fs.openSync(logPath, "a");
74
+ fs.writeSync(
75
+ logFd,
76
+ `\n[${new Date().toISOString()}] bridge auto-start (parent pid ${process.pid}, port ${config.port})\n`,
77
+ );
78
+ } catch { /* if we can't open the log, spawn still works */ }
79
+
80
+ // Spawn server via the detached-spawn primitive. resolveJitiImport()
81
+ // returns a file:// URL (required on Windows for node --import).
82
+ const r = await spawnDetached({
83
+ cmd: process.execPath,
84
+ args: ["--import", resolveJitiImport(), cliPath, ...args],
52
85
  env: { ...process.env },
86
+ logFd,
53
87
  });
54
88
 
55
- child.unref();
56
-
57
- // Monitor for early exit (within 2s)
58
- const earlyExit = await new Promise<boolean>((resolve) => {
59
- const timer = setTimeout(() => {
60
- resolve(false); // No early exit — server is running
61
- }, 2000);
89
+ // Close the parent's copy of the log fd — the child has its own.
90
+ if (logFd !== undefined) {
91
+ try { fs.closeSync(logFd); } catch { /* ignore */ }
92
+ }
62
93
 
63
- child.on("exit", () => {
64
- clearTimeout(timer);
65
- resolve(true); // Exited early — failure
66
- });
94
+ if (!r.ok || !r.process) {
95
+ return { success: false, message: `Server process failed to spawn: ${r.error ?? "unknown"}` };
96
+ }
67
97
 
68
- child.on("error", () => {
69
- clearTimeout(timer);
70
- resolve(true);
71
- });
98
+ // Wait for the server to actually become available via positive
99
+ // HTTP probe. NO deadline — we rely on child-exit for failure
100
+ // detection. A timeout here only catches the pathological case
101
+ // "process alive but never ready", which is rarer than the
102
+ // false-positive case "slow cold-start mistakenly flagged as
103
+ // failure" (Fastify + jiti compile + session scan can take 15–30s
104
+ // on Windows). If the child crashes, `waitForReady` returns
105
+ // { ok: false, error: "child exited with code N" } via its
106
+ // `child` listener. If the child hangs alive-but-broken, the user
107
+ // can kill it manually — timers don't help that case anyway.
108
+ const ready = await waitForReady({
109
+ probe: async () => (await isDashboardRunning(config.port)).running,
110
+ pollIntervalMs: 300,
111
+ child: r.process,
112
+ // deadlineMs intentionally omitted — wait indefinitely.
72
113
  });
73
114
 
74
- if (earlyExit) {
75
- return { success: false, message: "Server process exited immediately" };
115
+ if (!ready.ok) {
116
+ return {
117
+ success: false,
118
+ message: `Server process failed: ${ready.error ?? "unknown"}. See ~/.pi/dashboard/server.log`,
119
+ };
76
120
  }
77
121
 
78
122
  return { success: true, message: "Server started" };
@@ -1,8 +1,19 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-server",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Dashboard server for monitoring and interacting with pi agent sessions",
5
5
  "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "engines": {
10
+ "node": ">=22.18.0"
11
+ },
12
+ "piCompatibility": {
13
+ "minimum": "0.6.7",
14
+ "recommended": "0.6.7",
15
+ "maximum": null
16
+ },
6
17
  "main": "src/cli.ts",
7
18
  "bin": {
8
19
  "pi-dashboard": "src/cli.ts"
@@ -15,7 +26,8 @@
15
26
  "postinstall": "node scripts/fix-pty-permissions.cjs"
16
27
  },
17
28
  "dependencies": {
18
- "@blackbelt-technology/pi-dashboard-shared": "*",
29
+ "@blackbelt-technology/pi-dashboard-extension": "^0.4.0",
30
+ "@blackbelt-technology/pi-dashboard-shared": "^0.4.0",
19
31
  "@fastify/compress": "^8.3.1",
20
32
  "@fastify/cookie": "^11.0.2",
21
33
  "@fastify/cors": "^11.0.0",
@@ -28,11 +40,13 @@
28
40
  "fastify": "^5.0.0",
29
41
  "jsonwebtoken": "^9.0.3",
30
42
  "node-pty": "^1.1.0",
43
+ "proper-lockfile": "^4.1.2",
31
44
  "ws": "^8.18.0"
32
45
  },
33
46
  "devDependencies": {
34
47
  "@types/diff": "^7.0.0",
35
48
  "@types/jsonwebtoken": "^9.0.9",
49
+ "@types/proper-lockfile": "^4.1.4",
36
50
  "@types/ws": "^8.18.1"
37
51
  }
38
52
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Unit tests for the in-memory bootstrap ticket queue.
3
+ *
4
+ * See change: unified-bootstrap-install.
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import { createBootstrapQueue } from "../bootstrap-queue.js";
8
+
9
+ describe("bootstrap-queue", () => {
10
+ it("enqueue returns a unique ticketId + pending result", () => {
11
+ const q = createBootstrapQueue();
12
+ const a = q.enqueue(async () => "A");
13
+ const b = q.enqueue(async () => "B");
14
+ expect(a.ticketId).not.toBe(b.ticketId);
15
+ expect(q.size()).toBe(2);
16
+ });
17
+
18
+ it("flushAll runs handlers in enqueue order and resolves results", async () => {
19
+ const q = createBootstrapQueue();
20
+ const order: string[] = [];
21
+ const a = q.enqueue(async () => {
22
+ order.push("a");
23
+ return "A";
24
+ });
25
+ const b = q.enqueue(async () => {
26
+ order.push("b");
27
+ return "B";
28
+ });
29
+ await q.flushAll();
30
+ expect(order).toEqual(["a", "b"]);
31
+ await expect(a.result).resolves.toBe("A");
32
+ await expect(b.result).resolves.toBe("B");
33
+ expect(q.size()).toBe(0);
34
+ });
35
+
36
+ it("handler exceptions reject the ticket promise", async () => {
37
+ const q = createBootstrapQueue();
38
+ const t = q.enqueue(async () => {
39
+ throw new Error("boom");
40
+ });
41
+ await q.flushAll();
42
+ await expect(t.result).rejects.toThrow("boom");
43
+ });
44
+
45
+ it("onTicketComplete fires success=true for resolved handlers", async () => {
46
+ const q = createBootstrapQueue();
47
+ const events: Array<{ ticketId: string; success: boolean; error?: string }> = [];
48
+ q.onTicketComplete((e) => events.push(e));
49
+ const t = q.enqueue(async () => 42);
50
+ await q.flushAll();
51
+ await t.result;
52
+ expect(events).toEqual([{ ticketId: t.ticketId, success: true }]);
53
+ });
54
+
55
+ it("onTicketComplete fires success=false with error message on rejection", async () => {
56
+ const q = createBootstrapQueue();
57
+ const events: Array<{ ticketId: string; success: boolean; error?: string }> = [];
58
+ q.onTicketComplete((e) => events.push(e));
59
+ const t = q.enqueue(async () => {
60
+ throw new Error("oh no");
61
+ });
62
+ await q.flushAll();
63
+ await t.result.catch(() => undefined);
64
+ expect(events).toEqual([
65
+ { ticketId: t.ticketId, success: false, error: "oh no" },
66
+ ]);
67
+ });
68
+
69
+ it("onTicketComplete returns an unsubscribe function", async () => {
70
+ const q = createBootstrapQueue();
71
+ const events: unknown[] = [];
72
+ const off = q.onTicketComplete((e) => events.push(e));
73
+ off();
74
+ q.enqueue(async () => "x");
75
+ await q.flushAll();
76
+ expect(events).toEqual([]);
77
+ });
78
+
79
+ it("clear drops pending tickets with an error result and broadcasts completion", async () => {
80
+ const q = createBootstrapQueue();
81
+ const events: Array<{ ticketId: string; success: boolean; error?: string }> = [];
82
+ q.onTicketComplete((e) => events.push(e));
83
+ const t = q.enqueue(async () => "never runs");
84
+ q.clear("server shutting down");
85
+ await t.result.catch(() => undefined);
86
+ expect(events).toHaveLength(1);
87
+ expect(events[0]).toMatchObject({
88
+ ticketId: t.ticketId,
89
+ success: false,
90
+ error: "server shutting down",
91
+ });
92
+ expect(q.size()).toBe(0);
93
+ });
94
+
95
+ it("multiple listeners all receive the completion event", async () => {
96
+ const q = createBootstrapQueue();
97
+ const a: unknown[] = [];
98
+ const b: unknown[] = [];
99
+ q.onTicketComplete((e) => a.push(e));
100
+ q.onTicketComplete((e) => b.push(e));
101
+ const t = q.enqueue(async () => "ok");
102
+ await q.flushAll();
103
+ await t.result;
104
+ expect(a).toHaveLength(1);
105
+ expect(b).toHaveLength(1);
106
+ });
107
+
108
+ it("a listener that throws does not block other listeners", async () => {
109
+ const q = createBootstrapQueue();
110
+ const seen: unknown[] = [];
111
+ q.onTicketComplete(() => {
112
+ throw new Error("listener crash");
113
+ });
114
+ q.onTicketComplete((e) => seen.push(e));
115
+ const t = q.enqueue(async () => "ok");
116
+ await q.flushAll();
117
+ await t.result;
118
+ expect(seen).toHaveLength(1);
119
+ });
120
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Route tests for `/api/bootstrap/*`.
3
+ *
4
+ * Spins up a minimal Fastify instance with the bootstrap routes wired
5
+ * to a fresh state store and a pair of spy triggers. No real network
6
+ * access, no real subprocesses.
7
+ *
8
+ * See change: unified-bootstrap-install.
9
+ */
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import Fastify, { type FastifyInstance } from "fastify";
12
+ import { createBootstrapState, type BootstrapStateStore } from "../bootstrap-state.js";
13
+ import { registerBootstrapRoutes } from "../routes/bootstrap-routes.js";
14
+
15
+ const noopGuard = async () => {
16
+ /* allow all requests in tests */
17
+ };
18
+
19
+ interface Harness {
20
+ app: FastifyInstance;
21
+ state: BootstrapStateStore;
22
+ upgradeCalls: string[];
23
+ retryCalls: string[];
24
+ }
25
+
26
+ async function makeHarness(): Promise<Harness> {
27
+ const app = Fastify({ logger: false });
28
+ const state = createBootstrapState();
29
+ const upgradeCalls: string[] = [];
30
+ const retryCalls: string[] = [];
31
+
32
+ registerBootstrapRoutes(app, {
33
+ bootstrapState: state,
34
+ networkGuard: noopGuard,
35
+ triggerUpgradePi: async (ticketId) => {
36
+ upgradeCalls.push(ticketId);
37
+ },
38
+ triggerRetry: async (ticketId) => {
39
+ retryCalls.push(ticketId);
40
+ },
41
+ });
42
+
43
+ await app.ready();
44
+ return { app, state, upgradeCalls, retryCalls };
45
+ }
46
+
47
+ describe("bootstrap-routes", () => {
48
+ let h: Harness;
49
+
50
+ beforeEach(async () => {
51
+ h = await makeHarness();
52
+ });
53
+
54
+ afterEach(async () => {
55
+ await h.app.close();
56
+ });
57
+
58
+ describe("GET /api/bootstrap/status", () => {
59
+ it("returns the current state (default ready)", async () => {
60
+ const res = await h.app.inject({ method: "GET", url: "/api/bootstrap/status" });
61
+ expect(res.statusCode).toBe(200);
62
+ expect(res.json()).toEqual({ status: "ready" });
63
+ });
64
+
65
+ it("reflects subsequent state changes", async () => {
66
+ h.state.set({ status: "installing", progress: { step: "pi" } });
67
+ const res = await h.app.inject({ method: "GET", url: "/api/bootstrap/status" });
68
+ expect(res.json()).toMatchObject({
69
+ status: "installing",
70
+ progress: { step: "pi" },
71
+ });
72
+ });
73
+ });
74
+
75
+ describe("POST /api/bootstrap/upgrade-pi", () => {
76
+ it("returns 202 with a ticketId and invokes the trigger", async () => {
77
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/upgrade-pi" });
78
+ expect(res.statusCode).toBe(202);
79
+ const body = res.json() as { ticketId: string; status: string };
80
+ expect(body.status).toBe("accepted");
81
+ expect(typeof body.ticketId).toBe("string");
82
+ expect(body.ticketId.length).toBeGreaterThan(0);
83
+ // Trigger runs async — await a microtask.
84
+ await new Promise((r) => setImmediate(r));
85
+ expect(h.upgradeCalls).toEqual([body.ticketId]);
86
+ });
87
+
88
+ it("returns 409 when an install is already in progress", async () => {
89
+ h.state.set({ status: "installing" });
90
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/upgrade-pi" });
91
+ expect(res.statusCode).toBe(409);
92
+ expect(h.upgradeCalls).toEqual([]);
93
+ });
94
+
95
+ it("is allowed when status is failed (to upgrade after a previous failure)", async () => {
96
+ h.state.set({ status: "failed", error: { message: "network" } });
97
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/upgrade-pi" });
98
+ expect(res.statusCode).toBe(202);
99
+ });
100
+ });
101
+
102
+ describe("POST /api/bootstrap/retry", () => {
103
+ it("returns 409 when status is ready", async () => {
104
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/retry" });
105
+ expect(res.statusCode).toBe(409);
106
+ expect(h.retryCalls).toEqual([]);
107
+ });
108
+
109
+ it("returns 409 when status is installing", async () => {
110
+ h.state.set({ status: "installing" });
111
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/retry" });
112
+ expect(res.statusCode).toBe(409);
113
+ expect(h.retryCalls).toEqual([]);
114
+ });
115
+
116
+ it("returns 202 when status is failed and invokes the trigger", async () => {
117
+ h.state.set({ status: "failed", error: { message: "network" } });
118
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/retry" });
119
+ expect(res.statusCode).toBe(202);
120
+ const body = res.json() as { ticketId: string };
121
+ await new Promise((r) => setImmediate(r));
122
+ expect(h.retryCalls).toEqual([body.ticketId]);
123
+ });
124
+ });
125
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Unit tests for the in-memory bootstrap state store.
3
+ *
4
+ * See change: unified-bootstrap-install.
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import { createBootstrapState } from "../bootstrap-state.js";
8
+
9
+ describe("bootstrap-state", () => {
10
+ it("defaults to status=ready", () => {
11
+ const s = createBootstrapState();
12
+ expect(s.get()).toEqual({ status: "ready" });
13
+ });
14
+
15
+ it("applies initial overrides", () => {
16
+ const s = createBootstrapState({
17
+ status: "installing",
18
+ progress: { step: "pi", output: "starting" },
19
+ });
20
+ const state = s.get();
21
+ expect(state.status).toBe("installing");
22
+ expect(state.progress).toEqual({ step: "pi", output: "starting" });
23
+ });
24
+
25
+ it("set merges partial into state", () => {
26
+ const s = createBootstrapState();
27
+ s.set({ status: "installing", progress: { step: "pi" } });
28
+ expect(s.get().status).toBe("installing");
29
+ s.set({ progress: { step: "openspec" } });
30
+ expect(s.get().progress).toEqual({ step: "openspec" });
31
+ expect(s.get().status).toBe("installing");
32
+ });
33
+
34
+ it("set with undefined explicitly clears a key", () => {
35
+ const s = createBootstrapState({ progress: { step: "pi" } });
36
+ expect(s.get().progress).toBeDefined();
37
+ s.set({ progress: undefined });
38
+ expect(s.get().progress).toBeUndefined();
39
+ });
40
+
41
+ it("notifies subscribers on set", () => {
42
+ const s = createBootstrapState();
43
+ const calls: string[] = [];
44
+ s.subscribe((st) => calls.push(st.status));
45
+ s.set({ status: "installing" });
46
+ s.set({ status: "ready" });
47
+ expect(calls).toEqual(["installing", "ready"]);
48
+ });
49
+
50
+ it("subscribe returns an unsubscribe function", () => {
51
+ const s = createBootstrapState();
52
+ const calls: string[] = [];
53
+ const off = s.subscribe((st) => calls.push(st.status));
54
+ s.set({ status: "installing" });
55
+ off();
56
+ s.set({ status: "ready" });
57
+ expect(calls).toEqual(["installing"]);
58
+ });
59
+
60
+ it("listener errors do not stop other listeners", () => {
61
+ const s = createBootstrapState();
62
+ const calls: string[] = [];
63
+ s.subscribe(() => {
64
+ throw new Error("boom");
65
+ });
66
+ s.subscribe((st) => calls.push(st.status));
67
+ s.set({ status: "installing" });
68
+ expect(calls).toEqual(["installing"]);
69
+ });
70
+
71
+ it("dispose clears all listeners", () => {
72
+ const s = createBootstrapState();
73
+ const calls: string[] = [];
74
+ s.subscribe((st) => calls.push(st.status));
75
+ s.dispose();
76
+ s.set({ status: "installing" });
77
+ expect(calls).toEqual([]);
78
+ });
79
+
80
+ it("get returns a fresh snapshot (external mutation does not affect store)", () => {
81
+ const s = createBootstrapState({ progress: { step: "pi" } });
82
+ const snap = s.get();
83
+ snap.status = "failed";
84
+ expect(s.get().status).toBe("ready");
85
+ });
86
+
87
+ describe("lastInstallPackages", () => {
88
+ it("defaults to an empty array", () => {
89
+ const s = createBootstrapState();
90
+ expect(s.getLastInstallPackages()).toEqual([]);
91
+ });
92
+
93
+ it("records and returns a fresh copy", () => {
94
+ const s = createBootstrapState();
95
+ s.setLastInstallPackages(["pi", "openspec"]);
96
+ const got = s.getLastInstallPackages();
97
+ expect(got).toEqual(["pi", "openspec"]);
98
+ // External mutation does not affect the stored value.
99
+ got.push("tsx");
100
+ expect(s.getLastInstallPackages()).toEqual(["pi", "openspec"]);
101
+ });
102
+
103
+ it("accepts a readonly input without type error", () => {
104
+ const s = createBootstrapState();
105
+ const readonlyInput: readonly string[] = ["a", "b"];
106
+ s.setLastInstallPackages(readonlyInput);
107
+ expect(s.getLastInstallPackages()).toEqual(["a", "b"]);
108
+ });
109
+
110
+ it("is independent of status broadcast (not part of snapshot)", () => {
111
+ const s = createBootstrapState();
112
+ const seen: string[] = [];
113
+ s.subscribe((st) => seen.push(st.status));
114
+ s.setLastInstallPackages(["pi"]);
115
+ // setLastInstallPackages MUST NOT trigger a listener.
116
+ expect(seen).toEqual([]);
117
+ });
118
+ });
119
+ });
@@ -102,6 +102,23 @@ describe("listDirectories", () => {
102
102
  expect(entry.path).toBe(path.join(projectRoot, entry.name));
103
103
  }
104
104
  });
105
+
106
+ it("should return the server's platform", async () => {
107
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
108
+ const result = await listDirectories(projectRoot);
109
+ expect(result.platform).toBe(process.platform);
110
+ });
111
+
112
+ it("returns parent=null at the filesystem root", async () => {
113
+ // Use whichever root is appropriate for the host: "/" on Unix, the
114
+ // process's drive root on Windows. Previously this test only
115
+ // exercised Unix; `isFilesystemRoot` covers both branches now.
116
+ const root = process.platform === "win32"
117
+ ? path.parse(process.cwd()).root // e.g., "C:\\" or "B:\\"
118
+ : "/";
119
+ const result = await listDirectories(root);
120
+ expect(result.parent).toBeNull();
121
+ });
105
122
  });
106
123
 
107
124
  describe("listDirectories with q filter", () => {
@@ -31,6 +31,17 @@ describe("parseArgs", () => {
31
31
  expect(result.subcommand).toBe("status");
32
32
  });
33
33
 
34
+ it("parses upgrade-pi subcommand (unified-bootstrap-install §8)", () => {
35
+ const result = parseArgs(["upgrade-pi"]);
36
+ expect(result.subcommand).toBe("upgrade-pi");
37
+ });
38
+
39
+ it("parses upgrade-pi with --port flag", () => {
40
+ const result = parseArgs(["upgrade-pi", "--port", "9090"]);
41
+ expect(result.subcommand).toBe("upgrade-pi");
42
+ expect(result.flags.port).toBe(9090);
43
+ });
44
+
34
45
  it("parses subcommand with flags", () => {
35
46
  const result = parseArgs(["start", "--port", "3000", "--pi-port", "4000"]);
36
47
  expect(result.subcommand).toBe("start");