@blackbelt-technology/pi-agent-dashboard 0.2.9 → 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 (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Tests for ToolRegistry (packages/shared/src/tool-registry/registry.ts).
3
+ *
4
+ * Covered scenarios (from `specs/tool-registry/spec.md`):
5
+ * - Resolve a registered binary
6
+ * - Resolve an unregistered name throws UnknownToolError
7
+ * - Cached Resolution is referentially equal on second resolve()
8
+ * - rescan(name) invalidates one; rescan() invalidates all
9
+ * - First strategy wins; subsequent strategies not executed
10
+ * - Failing strategies recorded in tried[] and iteration continues
11
+ * - All-fail produces ok:false with full tried[] trail
12
+ * - resolveModule: caches loaded module; throws ModuleResolutionError on fail
13
+ * - setOverride / clearOverride invalidate cached Resolution
14
+ */
15
+ import { describe, it, expect } from "vitest";
16
+ import {
17
+ ToolRegistry,
18
+ UnknownToolError,
19
+ ModuleResolutionError,
20
+ type Strategy,
21
+ type ToolDefinition,
22
+ } from "../tool-registry/index.js";
23
+ import { OverridesStore } from "../tool-registry/overrides.js";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import fs from "node:fs";
27
+
28
+ // ── Test helpers ────────────────────────────────────────────────────────────
29
+
30
+ /** Make a strategy that always returns the given path. */
31
+ function fixedOk(name: string, p: string): Strategy {
32
+ return { name, run: () => ({ ok: true, path: p }) };
33
+ }
34
+
35
+ /** Make a strategy that records its invocation for "not executed" assertions. */
36
+ function spyOk(name: string, p: string, tag: { called: boolean }): Strategy {
37
+ return {
38
+ name,
39
+ run: () => {
40
+ tag.called = true;
41
+ return { ok: true, path: p };
42
+ },
43
+ };
44
+ }
45
+
46
+ /** Make a strategy that always fails with the given reason. */
47
+ function fail(name: string, reason: string): Strategy {
48
+ return { name, run: () => ({ ok: false, reason }) };
49
+ }
50
+
51
+ /** In-memory OverridesStore backed by a tmp file (for set/clear flow). */
52
+ function tmpOverridesStore(): OverridesStore {
53
+ const fp = path.join(
54
+ os.tmpdir(),
55
+ `tool-overrides-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
56
+ );
57
+ return new OverridesStore({ filePath: fp, warn: () => {} });
58
+ }
59
+
60
+ function binaryDef(name: string, strategies: Strategy[]): ToolDefinition {
61
+ return { name, kind: "binary", strategies };
62
+ }
63
+
64
+ function moduleDef(name: string, strategies: Strategy[]): ToolDefinition {
65
+ return { name, kind: "module", strategies };
66
+ }
67
+
68
+ // ── Tests ───────────────────────────────────────────────────────────────────
69
+
70
+ describe("ToolRegistry.resolve", () => {
71
+ it("returns a Resolution object for a registered binary", () => {
72
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
73
+ r.register(binaryDef("pi", [fixedOk("where", "/usr/local/bin/pi")]));
74
+
75
+ const res = r.resolve("pi");
76
+ expect(res.name).toBe("pi");
77
+ expect(res.ok).toBe(true);
78
+ expect(res.path).toBe("/usr/local/bin/pi");
79
+ expect(res.tried).toEqual([{ strategy: "where", result: "ok" }]);
80
+ expect(typeof res.resolvedAt).toBe("number");
81
+ });
82
+
83
+ it("throws UnknownToolError for an unregistered name", () => {
84
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
85
+ expect(() => r.resolve("nope")).toThrowError(UnknownToolError);
86
+ try { r.resolve("nope"); } catch (e) {
87
+ expect((e as UnknownToolError).tool).toBe("nope");
88
+ }
89
+ });
90
+
91
+ it("returns the same cached Resolution on a second call (referentially equal)", () => {
92
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
93
+ r.register(binaryDef("pi", [fixedOk("where", "/usr/bin/pi")]));
94
+
95
+ const a = r.resolve("pi");
96
+ const b = r.resolve("pi");
97
+ expect(a).toBe(b);
98
+ });
99
+ });
100
+
101
+ describe("ToolRegistry strategy chain", () => {
102
+ it("first-successful-strategy wins and short-circuits the chain", () => {
103
+ const second = { called: false };
104
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
105
+ r.register(
106
+ binaryDef("pi", [
107
+ fixedOk("managed", "/managed/pi"),
108
+ spyOk("where", "/usr/bin/pi", second),
109
+ ]),
110
+ );
111
+
112
+ const res = r.resolve("pi");
113
+ expect(res.ok).toBe(true);
114
+ expect(res.path).toBe("/managed/pi");
115
+ expect(res.source).toBe("managed");
116
+ expect(res.tried).toEqual([{ strategy: "managed", result: "ok" }]);
117
+ expect(second.called).toBe(false);
118
+ });
119
+
120
+ it("records failing strategies in tried[] and continues", () => {
121
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
122
+ r.register(
123
+ binaryDef("pi", [
124
+ fail("override", "no override set"),
125
+ fail("managed", "missing: /bad/path"),
126
+ fixedOk("where", "/usr/bin/pi"),
127
+ ]),
128
+ );
129
+
130
+ const res = r.resolve("pi");
131
+ expect(res.ok).toBe(true);
132
+ expect(res.source).toBe("system");
133
+ expect(res.tried).toEqual([
134
+ { strategy: "override", result: "no override set" },
135
+ { strategy: "managed", result: "missing: /bad/path" },
136
+ { strategy: "where", result: "ok" },
137
+ ]);
138
+ });
139
+
140
+ it("produces ok:false with full trail when every strategy fails", () => {
141
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
142
+ r.register(
143
+ binaryDef("pi", [fail("a", "reason a"), fail("b", "reason b")]),
144
+ );
145
+
146
+ const res = r.resolve("pi");
147
+ expect(res.ok).toBe(false);
148
+ expect(res.path).toBeNull();
149
+ expect(res.source).toBeNull();
150
+ expect(res.tried).toEqual([
151
+ { strategy: "a", result: "reason a" },
152
+ { strategy: "b", result: "reason b" },
153
+ ]);
154
+ });
155
+
156
+ it("validate() demotes strategy to failure with 'invalid: <reason>'", () => {
157
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
158
+ r.register({
159
+ ...binaryDef("pi", [fixedOk("override", "/bogus"), fixedOk("where", "/usr/bin/pi")]),
160
+ validate: (p) =>
161
+ p === "/bogus" ? { ok: false, reason: "not a file" } : { ok: true },
162
+ });
163
+
164
+ const res = r.resolve("pi");
165
+ expect(res.ok).toBe(true);
166
+ expect(res.path).toBe("/usr/bin/pi");
167
+ expect(res.tried[0]).toEqual({ strategy: "override", result: "invalid: not a file" });
168
+ expect(res.tried[1]).toEqual({ strategy: "where", result: "ok" });
169
+ });
170
+ });
171
+
172
+ describe("ToolRegistry.rescan", () => {
173
+ it("rescan(name) clears just that tool's cache", () => {
174
+ let callCount = 0;
175
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
176
+ r.register(
177
+ binaryDef("pi", [
178
+ {
179
+ name: "where",
180
+ run: () => ({ ok: true, path: `/usr/bin/pi${++callCount}` }),
181
+ },
182
+ ]),
183
+ );
184
+
185
+ const first = r.resolve("pi");
186
+ expect(first.path).toBe("/usr/bin/pi1");
187
+
188
+ r.rescan("pi");
189
+ const second = r.resolve("pi");
190
+ expect(second.path).toBe("/usr/bin/pi2");
191
+ expect(second).not.toBe(first);
192
+ });
193
+
194
+ it("rescan() without arg clears everything", () => {
195
+ let a = 0, b = 0;
196
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
197
+ r.register(binaryDef("pi", [{ name: "where", run: () => ({ ok: true, path: `/pi${++a}` }) }]));
198
+ r.register(binaryDef("git", [{ name: "where", run: () => ({ ok: true, path: `/git${++b}` }) }]));
199
+
200
+ r.resolve("pi"); r.resolve("git");
201
+ r.rescan();
202
+ expect(r.resolve("pi").path).toBe("/pi2");
203
+ expect(r.resolve("git").path).toBe("/git2");
204
+ });
205
+ });
206
+
207
+ describe("ToolRegistry.resolveModule", () => {
208
+ it("caches the loaded module and returns the same reference on second call", async () => {
209
+ const fakeModule = { DefaultPackageManager: () => "dpm" };
210
+ let importCalls = 0;
211
+ const r = new ToolRegistry({
212
+ overrides: tmpOverridesStore(),
213
+ importModule: async () => {
214
+ importCalls++;
215
+ return fakeModule;
216
+ },
217
+ });
218
+ r.register(moduleDef("pi-coding-agent", [fixedOk("managed", "/managed/pi/dist/index.js")]));
219
+
220
+ const a = await r.resolveModule("pi-coding-agent");
221
+ const b = await r.resolveModule("pi-coding-agent");
222
+ expect(a.module).toBe(fakeModule);
223
+ expect(b.module).toBe(fakeModule);
224
+ expect(importCalls).toBe(1);
225
+ });
226
+
227
+ it("throws ModuleResolutionError with trail when every strategy fails", async () => {
228
+ const r = new ToolRegistry({
229
+ overrides: tmpOverridesStore(),
230
+ importModule: async () => { throw new Error("should not import"); },
231
+ });
232
+ r.register(moduleDef("pi-coding-agent", [fail("a", "nope"), fail("b", "also nope")]));
233
+
234
+ await expect(r.resolveModule("pi-coding-agent")).rejects.toBeInstanceOf(ModuleResolutionError);
235
+ try {
236
+ await r.resolveModule("pi-coding-agent");
237
+ } catch (e) {
238
+ const err = e as ModuleResolutionError;
239
+ expect(err.resolution.tried).toEqual([
240
+ { strategy: "a", result: "nope" },
241
+ { strategy: "b", result: "also nope" },
242
+ ]);
243
+ expect(err.message).toContain("a: nope");
244
+ expect(err.message).toContain("b: also nope");
245
+ }
246
+ });
247
+
248
+ it("refuses to resolve a non-module tool", async () => {
249
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
250
+ r.register(binaryDef("pi", [fixedOk("where", "/usr/bin/pi")]));
251
+ await expect(r.resolveModule("pi")).rejects.toThrow(/not kind: "module"/);
252
+ });
253
+
254
+ it("rescan(name) drops the cached module so the next call re-imports", async () => {
255
+ let importCalls = 0;
256
+ const r = new ToolRegistry({
257
+ overrides: tmpOverridesStore(),
258
+ importModule: async () => ({ n: ++importCalls }),
259
+ });
260
+ r.register(moduleDef("pi-coding-agent", [fixedOk("managed", "/x/dist/index.js")]));
261
+
262
+ const a = await r.resolveModule("pi-coding-agent");
263
+ r.rescan("pi-coding-agent");
264
+ const b = await r.resolveModule("pi-coding-agent");
265
+ expect((a.module as { n: number }).n).toBe(1);
266
+ expect((b.module as { n: number }).n).toBe(2);
267
+ });
268
+ });
269
+
270
+ describe("ToolRegistry overrides", () => {
271
+ it("setOverride invalidates cache and the next resolve() picks override source", () => {
272
+ const store = tmpOverridesStore();
273
+ const r = new ToolRegistry({ overrides: store });
274
+ r.register(
275
+ binaryDef("pi", [
276
+ {
277
+ name: "override",
278
+ run: (ctx) => ctx.overrides["pi"]
279
+ ? { ok: true, path: ctx.overrides["pi"] }
280
+ : { ok: false, reason: "no override set" },
281
+ },
282
+ fixedOk("where", "/usr/bin/pi"),
283
+ ]),
284
+ );
285
+
286
+ expect(r.resolve("pi").path).toBe("/usr/bin/pi");
287
+ r.setOverride("pi", "/custom/pi");
288
+ const next = r.resolve("pi");
289
+ expect(next.path).toBe("/custom/pi");
290
+ expect(next.source).toBe("override");
291
+ });
292
+
293
+ it("clearOverride removes the entry and falls back to next strategy", () => {
294
+ const store = tmpOverridesStore();
295
+ store.set("pi", "/custom/pi");
296
+ const r = new ToolRegistry({ overrides: store });
297
+ r.register(
298
+ binaryDef("pi", [
299
+ {
300
+ name: "override",
301
+ run: (ctx) => ctx.overrides["pi"]
302
+ ? { ok: true, path: ctx.overrides["pi"] }
303
+ : { ok: false, reason: "no override set" },
304
+ },
305
+ fixedOk("where", "/usr/bin/pi"),
306
+ ]),
307
+ );
308
+
309
+ expect(r.resolve("pi").path).toBe("/custom/pi");
310
+ r.clearOverride("pi");
311
+ expect(r.resolve("pi").path).toBe("/usr/bin/pi");
312
+ });
313
+
314
+ it("setOverride throws UnknownToolError for unregistered names", () => {
315
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
316
+ expect(() => r.setOverride("ghost", "/x")).toThrow(UnknownToolError);
317
+ });
318
+ });
319
+
320
+ describe("ToolRegistry.list", () => {
321
+ it("returns one Resolution per registered tool", () => {
322
+ const r = new ToolRegistry({ overrides: tmpOverridesStore() });
323
+ r.register(binaryDef("pi", [fixedOk("where", "/pi")]));
324
+ r.register(binaryDef("git", [fail("where", "not found")]));
325
+
326
+ const all = r.list();
327
+ expect(all.map((x) => x.name).sort()).toEqual(["git", "pi"]);
328
+ expect(all.find((x) => x.name === "pi")!.ok).toBe(true);
329
+ expect(all.find((x) => x.name === "git")!.ok).toBe(false);
330
+ });
331
+ });
332
+
333
+ // Clean up any stray tmp files the tmpOverridesStore helper might leave.
334
+ afterAll();
335
+ function afterAll() {
336
+ try {
337
+ for (const f of fs.readdirSync(os.tmpdir())) {
338
+ if (f.startsWith("tool-overrides-test-")) {
339
+ try { fs.unlinkSync(path.join(os.tmpdir(), f)); } catch {}
340
+ }
341
+ }
342
+ } catch {}
343
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Shared bootstrap installer — single entry point for installing pi,
3
+ * openspec, tsx, and recommended packages into the managed directory
4
+ * (~/.pi-dashboard/). Callable from any entry point: Electron wizard,
5
+ * `pi-dashboard` CLI first-run, `pi-dashboard upgrade-pi` subcommand,
6
+ * and the `POST /api/bootstrap/upgrade-pi` REST handler.
7
+ *
8
+ * This module is deliberately free of Electron-specific concerns
9
+ * (bundled-node, offline-bundle cacache, resourcesPath). Those remain
10
+ * in `packages/electron/src/lib/dependency-installer.ts` which now
11
+ * delegates its "install from npm registry" step to this function.
12
+ *
13
+ * See change: unified-bootstrap-install.
14
+ */
15
+ import { spawn as cpSpawn } from "./platform/exec.js";
16
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
17
+ import path from "node:path";
18
+ import { getManagedDir } from "./managed-paths.js";
19
+ import { getDefaultRegistry, type ToolRegistry } from "./tool-registry/index.js";
20
+
21
+ /**
22
+ * Per-package progress tick. Mirrors the Electron `InstallProgress`
23
+ * shape so existing wizard UI code needs no changes.
24
+ */
25
+ export interface InstallProgress {
26
+ step: string;
27
+ status: "pending" | "running" | "done" | "error";
28
+ error?: string;
29
+ /** Last line of npm output (for streaming progress). */
30
+ output?: string;
31
+ }
32
+
33
+ export type ProgressCallback = (progress: InstallProgress) => void;
34
+
35
+ export interface BootstrapInstallOptions {
36
+ /** Packages to install via `npm install <pkg>` (registry fetch). */
37
+ packages: string[];
38
+ /** Root of the managed install. Defaults to `getManagedDir()`. */
39
+ managedDir?: string;
40
+ /** Called on every progress tick (pending/running/done/error). */
41
+ progress?: ProgressCallback;
42
+ /**
43
+ * Optional override of the npm invocation. By default the function
44
+ * resolves the `npm` tool via `ToolRegistry.resolve("npm")` and
45
+ * falls back to the plain `npm` / `npm.cmd` binary on PATH. When
46
+ * Electron wants to steer the install to bundled Node + npm-cli.js,
47
+ * it passes the full argv prefix (e.g. `["<path>/node", "<path>/npm-cli.js"]`).
48
+ */
49
+ npmArgv?: string[];
50
+ /**
51
+ * Optional environment overrides merged into the child process env.
52
+ * Electron uses this to put bundled Node on PATH for postinstall
53
+ * scripts.
54
+ */
55
+ env?: NodeJS.ProcessEnv;
56
+ /**
57
+ * Inject a tool registry (tests). Defaults to `getDefaultRegistry()`.
58
+ */
59
+ registry?: ToolRegistry;
60
+ }
61
+
62
+ export interface BootstrapInstallSuccess {
63
+ ok: true;
64
+ installed: string[];
65
+ managedDir: string;
66
+ }
67
+
68
+ export interface BootstrapInstallFailure {
69
+ ok: false;
70
+ error: string;
71
+ installed: string[];
72
+ managedDir: string;
73
+ }
74
+
75
+ export type BootstrapInstallResult = BootstrapInstallSuccess | BootstrapInstallFailure;
76
+
77
+ /** Ensure the managed directory exists with a package.json. */
78
+ export function ensureManagedDir(managedDir: string): void {
79
+ mkdirSync(managedDir, { recursive: true });
80
+ const pkgPath = path.join(managedDir, "package.json");
81
+ if (!existsSync(pkgPath)) {
82
+ writeFileSync(
83
+ pkgPath,
84
+ JSON.stringify({ name: "pi-dashboard-managed", private: true, type: "module" }, null, 2),
85
+ );
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Resolve the npm invocation used for bootstrap installs.
91
+ *
92
+ * Order:
93
+ * 1. Explicit `npmArgv` override (Electron bundled-node case).
94
+ * 2. `ToolRegistry.resolve("npm")`.
95
+ * 3. Plain `npm` (Unix) or `npm.cmd` (Windows) on PATH.
96
+ *
97
+ * Returns the argv list that will have `install <packages...>` appended.
98
+ */
99
+ export function resolveNpmArgv(
100
+ opts: Pick<BootstrapInstallOptions, "npmArgv" | "registry">,
101
+ ): string[] {
102
+ if (opts.npmArgv && opts.npmArgv.length > 0) return [...opts.npmArgv];
103
+
104
+ const registry = opts.registry ?? getDefaultRegistry();
105
+ if (registry.has("npm")) {
106
+ const res = registry.resolve("npm");
107
+ if (res.ok && res.path) return [res.path];
108
+ }
109
+
110
+ // Last resort: rely on PATH. On Windows the .cmd shim is required
111
+ // because spawn doesn't auto-append extensions.
112
+ const npmBin = process.platform === "win32" ? "npm.cmd" : "npm"; // platform-branch-ok
113
+ return [npmBin];
114
+ }
115
+
116
+ /** Internal: spawn npm with a given argv + packages; stream progress. */
117
+ function runNpmOnce(
118
+ argvBase: string[],
119
+ packages: string[],
120
+ cwd: string,
121
+ env: NodeJS.ProcessEnv,
122
+ onOutput?: (line: string) => void,
123
+ ): Promise<void> {
124
+ return new Promise((resolve, reject) => {
125
+ const [cmd, ...baseArgs] = argvBase;
126
+ if (!cmd) {
127
+ reject(new Error("resolveNpmArgv returned an empty argv"));
128
+ return;
129
+ }
130
+ const args = [...baseArgs, "install", ...packages];
131
+
132
+ const child = cpSpawn(cmd, args, {
133
+ cwd,
134
+ env,
135
+ stdio: ["ignore", "pipe", "pipe"],
136
+ timeout: 300_000,
137
+ });
138
+
139
+ let tail = "";
140
+
141
+ const handleData = (data: Buffer): void => {
142
+ const text = data.toString();
143
+ tail += text;
144
+ if (tail.length > 4096) tail = tail.slice(-4096);
145
+ const lines = text.split("\n").filter((l) => l.trim());
146
+ const last = lines[lines.length - 1];
147
+ if (last && onOutput) onOutput(last.trim().substring(0, 120));
148
+ };
149
+
150
+ child.stdout?.on("data", handleData);
151
+ child.stderr?.on("data", handleData);
152
+
153
+ child.on("error", (err) => reject(new Error(err.message)));
154
+ child.on("close", (code) => {
155
+ if (code !== 0) {
156
+ reject(new Error(tail.slice(-500) || `npm install exited with code ${code}`));
157
+ } else {
158
+ resolve();
159
+ }
160
+ });
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Install the given packages into the managed directory.
166
+ *
167
+ * Per-package progress is reported via `progress`. Installation is
168
+ * sequential (not concurrent) so a failure stops the chain — matching
169
+ * the behavior of the Electron wizard today. The return value reports
170
+ * which packages completed successfully before any failure.
171
+ */
172
+ export async function bootstrapInstall(
173
+ opts: BootstrapInstallOptions,
174
+ ): Promise<BootstrapInstallResult> {
175
+ const managedDir = opts.managedDir ?? getManagedDir();
176
+ ensureManagedDir(managedDir);
177
+
178
+ const argvBase = resolveNpmArgv(opts);
179
+ const env = { ...process.env, ...(opts.env ?? {}) };
180
+
181
+ const installed: string[] = [];
182
+ for (const pkg of opts.packages) {
183
+ const step = pkg.split("/").pop() || pkg;
184
+ opts.progress?.({ step, status: "running" });
185
+ try {
186
+ await runNpmOnce(argvBase, [pkg], managedDir, env, (output) => {
187
+ opts.progress?.({ step, status: "running", output });
188
+ });
189
+ opts.progress?.({ step, status: "done" });
190
+ installed.push(pkg);
191
+ } catch (err) {
192
+ const message = err instanceof Error ? err.message : String(err);
193
+ opts.progress?.({ step, status: "error", error: message });
194
+ return { ok: false, error: message, installed, managedDir };
195
+ }
196
+ }
197
+
198
+ return { ok: true, installed, managedDir };
199
+ }
200
+
201
+ /**
202
+ * Convenience wrapper: install pi, openspec, tsx into the default
203
+ * managed directory. Used by the CLI degraded-mode first-run path.
204
+ */
205
+ export async function bootstrapInstallDefaults(
206
+ progress?: ProgressCallback,
207
+ ): Promise<BootstrapInstallResult> {
208
+ return bootstrapInstall({
209
+ packages: ["@mariozechner/pi-coding-agent", "@fission-ai/openspec", "tsx"],
210
+ progress,
211
+ });
212
+ }
@@ -10,29 +10,92 @@
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import os from "node:os";
13
+ import { createRequire } from "node:module";
13
14
 
14
15
  /**
15
- * Find the bundled extension directory relative to a base directory.
16
- * Looks for `packages/extension/` (monorepo layout) under baseDir.
17
- *
18
- * Returns null if:
19
- * - Directory not found
20
- * - No package.json in the directory
21
- * - Path is under /tmp/.mount_* (unstable AppImage mount)
16
+ * Check that a candidate path is a valid, stable extension directory.
17
+ * Returns true when the directory exists, contains a package.json, and
18
+ * is NOT under /tmp/.mount_* (unstable AppImage mount).
22
19
  */
23
- export function findBundledExtension(baseDir: string): string | null {
24
- const candidate = path.resolve(baseDir, "packages", "extension");
25
- if (!fs.existsSync(candidate) || !fs.existsSync(path.join(candidate, "package.json"))) {
26
- return null;
20
+ function isValidExtensionPath(candidate: string): boolean {
21
+ if (!fs.existsSync(candidate)) return false;
22
+ if (!fs.existsSync(path.join(candidate, "package.json"))) return false;
23
+ if (candidate.includes("/tmp/.mount_")) {
24
+ console.warn(
25
+ "[dashboard] AppImage detected — extension path is temporary, skipping registration:",
26
+ candidate,
27
+ );
28
+ return false;
27
29
  }
30
+ return true;
31
+ }
28
32
 
29
- // Reject unstable AppImage temp mount paths
30
- if (candidate.includes("/tmp/.mount_")) {
31
- console.warn("[dashboard] AppImage detected extension path is temporary, skipping registration:", candidate);
33
+ /**
34
+ * Optional dependency injection for `findBundledExtension`. Tests pass
35
+ * `{ resolvePackage: () => null }` to disable the node-resolver fallback.
36
+ */
37
+ export interface FindExtensionDeps {
38
+ /**
39
+ * Resolve `@blackbelt-technology/pi-dashboard-extension/package.json`
40
+ * via Node's module resolver. Return the absolute package.json path
41
+ * or null. Defaults to `createRequire(import.meta.url).resolve(...)`.
42
+ */
43
+ resolvePackage?: () => string | null;
44
+ }
45
+
46
+ function defaultResolvePackage(): string | null {
47
+ try {
48
+ const req = createRequire(import.meta.url);
49
+ return req.resolve("@blackbelt-technology/pi-dashboard-extension/package.json");
50
+ } catch {
32
51
  return null;
33
52
  }
53
+ }
54
+
55
+ /**
56
+ * Find the bundled extension directory.
57
+ *
58
+ * Resolution order:
59
+ * 1. Monorepo layout: `<baseDir>/packages/extension/`.
60
+ * 2. Node module resolution: `@blackbelt-technology/pi-dashboard-extension/package.json`
61
+ * via `require.resolve` from this module. Works in ANY install layout
62
+ * (flat `node_modules/`, scoped, nested, pnpm, npm-g). This is the
63
+ * canonical identity-based lookup and the only reliable strategy
64
+ * when pi-dashboard is installed via `npm i -g`.
65
+ *
66
+ * Returns null if both strategies fail, the resolved directory doesn't
67
+ * have a package.json, or the path is under /tmp/.mount_* (AppImage).
68
+ *
69
+ * See change: unified-bootstrap-install.
70
+ */
71
+ export function findBundledExtension(
72
+ baseDir: string,
73
+ deps: FindExtensionDeps = {},
74
+ ): string | null {
75
+ // Strategy 1: monorepo sibling layout.
76
+ const monorepoCandidate = path.resolve(baseDir, "packages", "extension");
77
+ if (isValidExtensionPath(monorepoCandidate)) return monorepoCandidate;
78
+
79
+ // Strategy 2: Node module resolver. This works for the `npm i -g
80
+ // pi-dashboard` layout where the extension is shipped as a runtime dep
81
+ // of pi-dashboard-server.
82
+ const resolver = deps.resolvePackage ?? defaultResolvePackage;
83
+ const extPkgJson = resolver();
84
+ if (extPkgJson) {
85
+ const extDir = path.dirname(extPkgJson);
86
+ if (isValidExtensionPath(extDir)) return extDir;
87
+ }
88
+
89
+ return null;
90
+ }
34
91
 
35
- return candidate;
92
+ /** Optional overrides for testing / multi-HOME scenarios. */
93
+ export interface BridgeRegisterOptions {
94
+ /**
95
+ * Override the HOME used to locate settings.json. When omitted,
96
+ * falls back to `$HOME || $USERPROFILE || os.homedir()` (existing behavior).
97
+ */
98
+ homedir?: string;
36
99
  }
37
100
 
38
101
  /**
@@ -44,12 +107,16 @@ export function findBundledExtension(baseDir: string): string | null {
44
107
  *
45
108
  * No-op if the path is already registered.
46
109
  */
47
- export function registerBridgeExtension(extensionPath: string): void {
110
+ export function registerBridgeExtension(
111
+ extensionPath: string,
112
+ opts: BridgeRegisterOptions = {},
113
+ ): void {
48
114
  // Compute at call time so tests can override HOME
49
- const settingsPath = path.join(
50
- process.env.HOME || process.env.USERPROFILE || os.homedir(),
51
- ".pi", "agent", "settings.json",
52
- );
115
+ const home = opts.homedir
116
+ ?? process.env.HOME
117
+ ?? process.env.USERPROFILE
118
+ ?? os.homedir();
119
+ const settingsPath = path.join(home, ".pi", "agent", "settings.json");
53
120
  const settingsDir = path.dirname(settingsPath);
54
121
  fs.mkdirSync(settingsDir, { recursive: true });
55
122