@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,195 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fs from "node:fs";
5
+ import { PiCoreChecker, CORE_PACKAGE_NAMES, _internal } from "../pi-core-checker.js";
6
+
7
+ describe("PiCoreChecker._internal.looksLikePiEcosystem", () => {
8
+ it("matches known core packages", () => {
9
+ for (const name of CORE_PACKAGE_NAMES) {
10
+ expect(_internal.looksLikePiEcosystem(name)).toBe(true);
11
+ }
12
+ });
13
+
14
+ it("matches bare pi- packages", () => {
15
+ expect(_internal.looksLikePiEcosystem("pi-web-access")).toBe(true);
16
+ expect(_internal.looksLikePiEcosystem("pi-agent-browser")).toBe(true);
17
+ });
18
+
19
+ it("matches scoped pi- packages", () => {
20
+ expect(_internal.looksLikePiEcosystem("@tintinweb/pi-subagents")).toBe(true);
21
+ expect(_internal.looksLikePiEcosystem("@benvargas/pi-claude-code-use")).toBe(true);
22
+ });
23
+
24
+ it("rejects non-pi packages", () => {
25
+ expect(_internal.looksLikePiEcosystem("react")).toBe(false);
26
+ expect(_internal.looksLikePiEcosystem("@types/node")).toBe(false);
27
+ expect(_internal.looksLikePiEcosystem("piano")).toBe(false);
28
+ expect(_internal.looksLikePiEcosystem("@scope/notpi")).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe("PiCoreChecker.getStatus", () => {
33
+ let tmpManagedDir: string;
34
+
35
+ beforeEach(() => {
36
+ tmpManagedDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-core-test-"));
37
+ });
38
+
39
+ function writeManagedPackage(managedDir: string, name: string, version: string) {
40
+ const dir = path.join(managedDir, "node_modules", name);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version }));
43
+ }
44
+
45
+ it("discovers global pi packages via npm list", async () => {
46
+ const checker = new PiCoreChecker({
47
+ npmList: async () =>
48
+ JSON.stringify({
49
+ dependencies: {
50
+ "@mariozechner/pi-coding-agent": { version: "0.67.1" },
51
+ "pi-web-access": { version: "0.10.6" },
52
+ react: { version: "19.0.0" }, // must be ignored
53
+ },
54
+ }),
55
+ fetchLatest: async (name) => {
56
+ if (name === "@mariozechner/pi-coding-agent") return "0.67.6";
57
+ if (name === "pi-web-access") return "0.10.6";
58
+ return null;
59
+ },
60
+ managedDir: tmpManagedDir,
61
+ });
62
+
63
+ const status = await checker.getStatus();
64
+
65
+ expect(status.packages.length).toBe(2);
66
+ const pi = status.packages.find((p) => p.name === "@mariozechner/pi-coding-agent")!;
67
+ expect(pi.displayName).toBe("pi (core agent)");
68
+ expect(pi.currentVersion).toBe("0.67.1");
69
+ expect(pi.latestVersion).toBe("0.67.6");
70
+ expect(pi.updateAvailable).toBe(true);
71
+ expect(pi.installSource).toBe("global");
72
+
73
+ const web = status.packages.find((p) => p.name === "pi-web-access")!;
74
+ expect(web.displayName).toBe("pi-web-access");
75
+ expect(web.updateAvailable).toBe(false);
76
+ expect(web.installSource).toBe("global");
77
+
78
+ expect(status.updatesAvailable).toBe(1);
79
+ });
80
+
81
+ it("discovers managed packages and prefers them over global duplicates", async () => {
82
+ writeManagedPackage(tmpManagedDir, "@mariozechner/pi-coding-agent", "0.67.5");
83
+
84
+ const checker = new PiCoreChecker({
85
+ npmList: async () =>
86
+ JSON.stringify({
87
+ dependencies: {
88
+ "@mariozechner/pi-coding-agent": { version: "0.67.1" },
89
+ },
90
+ }),
91
+ fetchLatest: async () => "0.67.6",
92
+ managedDir: tmpManagedDir,
93
+ });
94
+
95
+ const status = await checker.getStatus();
96
+ expect(status.packages.length).toBe(1);
97
+ expect(status.packages[0].currentVersion).toBe("0.67.5");
98
+ expect(status.packages[0].installSource).toBe("managed");
99
+ });
100
+
101
+ it("returns empty list when managed dir missing and npm list fails", async () => {
102
+ const checker = new PiCoreChecker({
103
+ npmList: async () => {
104
+ throw new Error("npm not found");
105
+ },
106
+ fetchLatest: async () => null,
107
+ managedDir: path.join(tmpManagedDir, "nonexistent"),
108
+ });
109
+ const status = await checker.getStatus();
110
+ expect(status.packages).toEqual([]);
111
+ expect(status.updatesAvailable).toBe(0);
112
+ });
113
+
114
+ it("tolerates non-zero npm list exit when stdout contains valid JSON", async () => {
115
+ const checker = new PiCoreChecker({
116
+ npmList: async () => {
117
+ const err = new Error("npm warn") as Error & { stdout: string };
118
+ err.stdout = JSON.stringify({
119
+ dependencies: { "pi-web-access": { version: "0.10.6" } },
120
+ });
121
+ throw err;
122
+ },
123
+ fetchLatest: async () => "0.10.6",
124
+ managedDir: path.join(tmpManagedDir, "nope"),
125
+ });
126
+ const status = await checker.getStatus();
127
+ expect(status.packages.length).toBe(1);
128
+ expect(status.packages[0].name).toBe("pi-web-access");
129
+ });
130
+
131
+ it("caches results within 5 minutes", async () => {
132
+ let calls = 0;
133
+ const checker = new PiCoreChecker({
134
+ npmList: async () => {
135
+ calls++;
136
+ return JSON.stringify({ dependencies: { "pi-web-access": { version: "0.10.6" } } });
137
+ },
138
+ fetchLatest: async () => "0.10.6",
139
+ managedDir: path.join(tmpManagedDir, "nope"),
140
+ });
141
+ await checker.getStatus();
142
+ await checker.getStatus();
143
+ expect(calls).toBe(1);
144
+ });
145
+
146
+ it("force-refresh invalidates the cache", async () => {
147
+ let calls = 0;
148
+ const checker = new PiCoreChecker({
149
+ npmList: async () => {
150
+ calls++;
151
+ return JSON.stringify({ dependencies: { "pi-web-access": { version: "0.10.6" } } });
152
+ },
153
+ fetchLatest: async () => "0.10.6",
154
+ managedDir: path.join(tmpManagedDir, "nope"),
155
+ });
156
+ await checker.getStatus();
157
+ await checker.getStatus(true);
158
+ expect(calls).toBe(2);
159
+ });
160
+
161
+ it("treats fetch failure as latestVersion=null, updateAvailable=false", async () => {
162
+ const checker = new PiCoreChecker({
163
+ npmList: async () =>
164
+ JSON.stringify({ dependencies: { "pi-web-access": { version: "0.10.6" } } }),
165
+ fetchLatest: async () => {
166
+ throw new Error("network down");
167
+ },
168
+ managedDir: path.join(tmpManagedDir, "nope"),
169
+ });
170
+ const status = await checker.getStatus();
171
+ expect(status.packages.length).toBe(1);
172
+ expect(status.packages[0].latestVersion).toBeNull();
173
+ expect(status.packages[0].updateAvailable).toBe(false);
174
+ });
175
+
176
+ it("sorts known core packages first", async () => {
177
+ const checker = new PiCoreChecker({
178
+ npmList: async () =>
179
+ JSON.stringify({
180
+ dependencies: {
181
+ "pi-web-access": { version: "0.10.6" },
182
+ "@mariozechner/pi-coding-agent": { version: "0.67.1" },
183
+ "pi-agent-browser": { version: "0.1.0" },
184
+ },
185
+ }),
186
+ fetchLatest: async () => null,
187
+ managedDir: path.join(tmpManagedDir, "nope"),
188
+ });
189
+ const status = await checker.getStatus();
190
+ expect(status.packages[0].name).toBe("@mariozechner/pi-coding-agent");
191
+ // remaining are alphabetical
192
+ expect(status.packages[1].name).toBe("pi-agent-browser");
193
+ expect(status.packages[2].name).toBe("pi-web-access");
194
+ });
195
+ });
@@ -0,0 +1,184 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import Fastify, { type FastifyInstance } from "fastify";
3
+ import { registerPiCoreRoutes } from "../routes/pi-core-routes.js";
4
+ import { PackageOperationBusyError } from "../package-manager-wrapper.js";
5
+ import type { PiCoreStatus, PiCorePackage } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
6
+
7
+ function makePkg(name: string, updateAvailable = false): PiCorePackage {
8
+ return {
9
+ name,
10
+ displayName: name,
11
+ currentVersion: "0.1.0",
12
+ latestVersion: updateAvailable ? "0.2.0" : "0.1.0",
13
+ updateAvailable,
14
+ installSource: "global",
15
+ };
16
+ }
17
+
18
+ describe("pi-core-routes", () => {
19
+ let app: FastifyInstance;
20
+ let checker: any;
21
+ let updater: any;
22
+ let onUpdateComplete: any;
23
+
24
+ beforeEach(async () => {
25
+ checker = {
26
+ getStatus: vi.fn<(refresh?: boolean) => Promise<PiCoreStatus>>(),
27
+ invalidate: vi.fn(),
28
+ };
29
+ updater = {
30
+ update: vi.fn(),
31
+ };
32
+ onUpdateComplete = vi.fn();
33
+ app = Fastify({ logger: false });
34
+ registerPiCoreRoutes(app, {
35
+ piCoreChecker: checker,
36
+ piCoreUpdater: updater,
37
+ onUpdateComplete,
38
+ });
39
+ await app.ready();
40
+ });
41
+
42
+ afterEach(async () => {
43
+ await app.close();
44
+ });
45
+
46
+ it("GET /api/pi-core/versions returns cached status", async () => {
47
+ const status: PiCoreStatus = {
48
+ packages: [makePkg("pi-web-access")],
49
+ updatesAvailable: 0,
50
+ lastChecked: new Date().toISOString(),
51
+ };
52
+ checker.getStatus.mockResolvedValue(status);
53
+
54
+ const res = await app.inject({ method: "GET", url: "/api/pi-core/versions" });
55
+ expect(res.statusCode).toBe(200);
56
+ const body = res.json() as any;
57
+ expect(body.success).toBe(true);
58
+ expect(body.data).toEqual(status);
59
+ expect(checker.getStatus).toHaveBeenCalledWith(false);
60
+ });
61
+
62
+ it("GET /api/pi-core/versions?refresh=true forces refresh", async () => {
63
+ checker.getStatus.mockResolvedValue({
64
+ packages: [],
65
+ updatesAvailable: 0,
66
+ lastChecked: new Date().toISOString(),
67
+ });
68
+ const res = await app.inject({ method: "GET", url: "/api/pi-core/versions?refresh=true" });
69
+ expect(res.statusCode).toBe(200);
70
+ expect(checker.getStatus).toHaveBeenCalledWith(true);
71
+ });
72
+
73
+ it("POST /api/pi-core/update with empty body updates all packages with updateAvailable", async () => {
74
+ checker.getStatus.mockResolvedValue({
75
+ packages: [makePkg("pi-web-access", true), makePkg("pi-foo", false)],
76
+ updatesAvailable: 1,
77
+ lastChecked: new Date().toISOString(),
78
+ });
79
+ updater.update.mockResolvedValue({
80
+ results: [{ name: "pi-web-access", success: true }],
81
+ sessionsReloaded: 2,
82
+ });
83
+
84
+ const res = await app.inject({ method: "POST", url: "/api/pi-core/update", payload: {} });
85
+ expect(res.statusCode).toBe(200);
86
+ const body = res.json() as any;
87
+ expect(body.success).toBe(true);
88
+ expect(body.data.results).toHaveLength(1);
89
+ expect(body.data.sessionsReloaded).toBe(2);
90
+
91
+ // Only updates the one with updateAvailable
92
+ expect(updater.update).toHaveBeenCalledTimes(1);
93
+ const arg = updater.update.mock.calls[0][0];
94
+ expect(arg).toHaveLength(1);
95
+ expect(arg[0].name).toBe("pi-web-access");
96
+
97
+ // Cache invalidated so next status reflects new versions
98
+ expect(checker.invalidate).toHaveBeenCalled();
99
+
100
+ // onUpdateComplete called so server can broadcast to browsers (badge refetch)
101
+ expect(onUpdateComplete).toHaveBeenCalledTimes(1);
102
+ expect(onUpdateComplete).toHaveBeenCalledWith({
103
+ results: [{ name: "pi-web-access", success: true }],
104
+ sessionsReloaded: 2,
105
+ });
106
+ });
107
+
108
+ it("POST /api/pi-core/update does not call onUpdateComplete on busy-error", async () => {
109
+ checker.getStatus.mockResolvedValue({
110
+ packages: [makePkg("pi-web-access", true)],
111
+ updatesAvailable: 1,
112
+ lastChecked: new Date().toISOString(),
113
+ });
114
+ updater.update.mockRejectedValue(new PackageOperationBusyError());
115
+
116
+ const res = await app.inject({ method: "POST", url: "/api/pi-core/update", payload: {} });
117
+ expect(res.statusCode).toBe(409);
118
+ expect(onUpdateComplete).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("POST /api/pi-core/update with specific packages filters to those", async () => {
122
+ checker.getStatus.mockResolvedValue({
123
+ packages: [makePkg("pi-web-access", true), makePkg("pi-foo", true)],
124
+ updatesAvailable: 2,
125
+ lastChecked: new Date().toISOString(),
126
+ });
127
+ updater.update.mockResolvedValue({ results: [{ name: "pi-foo", success: true }], sessionsReloaded: 0 });
128
+
129
+ const res = await app.inject({
130
+ method: "POST",
131
+ url: "/api/pi-core/update",
132
+ payload: { packages: ["pi-foo"] },
133
+ });
134
+ expect(res.statusCode).toBe(200);
135
+ expect(updater.update.mock.calls[0][0].map((p: PiCorePackage) => p.name)).toEqual(["pi-foo"]);
136
+ });
137
+
138
+ it("POST /api/pi-core/update rejects unknown packages with 400", async () => {
139
+ checker.getStatus.mockResolvedValue({
140
+ packages: [makePkg("pi-web-access", true)],
141
+ updatesAvailable: 1,
142
+ lastChecked: new Date().toISOString(),
143
+ });
144
+
145
+ const res = await app.inject({
146
+ method: "POST",
147
+ url: "/api/pi-core/update",
148
+ payload: { packages: ["not-a-real-package"] },
149
+ });
150
+ expect(res.statusCode).toBe(400);
151
+ const body = res.json() as any;
152
+ expect(body.success).toBe(false);
153
+ expect(body.error).toMatch(/not-a-real-package/);
154
+ });
155
+
156
+ it("POST /api/pi-core/update returns 409 when busy", async () => {
157
+ checker.getStatus.mockResolvedValue({
158
+ packages: [makePkg("pi-web-access", true)],
159
+ updatesAvailable: 1,
160
+ lastChecked: new Date().toISOString(),
161
+ });
162
+ updater.update.mockRejectedValue(new PackageOperationBusyError());
163
+
164
+ const res = await app.inject({ method: "POST", url: "/api/pi-core/update", payload: {} });
165
+ expect(res.statusCode).toBe(409);
166
+ const body = res.json() as any;
167
+ expect(body.success).toBe(false);
168
+ });
169
+
170
+ it("POST /api/pi-core/update returns empty result when nothing to update", async () => {
171
+ checker.getStatus.mockResolvedValue({
172
+ packages: [makePkg("pi-web-access", false)],
173
+ updatesAvailable: 0,
174
+ lastChecked: new Date().toISOString(),
175
+ });
176
+
177
+ const res = await app.inject({ method: "POST", url: "/api/pi-core/update", payload: {} });
178
+ expect(res.statusCode).toBe(200);
179
+ const body = res.json() as any;
180
+ expect(body.data.results).toEqual([]);
181
+ expect(body.data.sessionsReloaded).toBe(0);
182
+ expect(updater.update).not.toHaveBeenCalled();
183
+ });
184
+ });
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { PiCoreUpdater } from "../pi-core-updater.js";
3
+ import {
4
+ PackageManagerWrapper,
5
+ PackageOperationBusyError,
6
+ } from "../package-manager-wrapper.js";
7
+ import type { PiCorePackage } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
8
+
9
+ // Pi PM is mocked in other tests via vi.mock; we don't need it here because
10
+ // we never call the install/remove/update methods — only runExclusive().
11
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
12
+ DefaultPackageManager: function () {
13
+ return {};
14
+ },
15
+ SettingsManager: { create: () => ({}) },
16
+ }));
17
+
18
+ function pkg(name: string, source: "global" | "managed" = "global"): PiCorePackage {
19
+ return {
20
+ name,
21
+ displayName: name,
22
+ currentVersion: "0.1.0",
23
+ latestVersion: "0.2.0",
24
+ updateAvailable: true,
25
+ installSource: source,
26
+ };
27
+ }
28
+
29
+ describe("PiCoreUpdater", () => {
30
+ let wrapper: PackageManagerWrapper;
31
+
32
+ beforeEach(() => {
33
+ wrapper = new PackageManagerWrapper();
34
+ });
35
+
36
+ it("updates packages sequentially and emits start/output/complete events", async () => {
37
+ const events: Array<{ name: string; phase: string; message?: string }> = [];
38
+ const updater = new PiCoreUpdater({
39
+ packageManagerWrapper: wrapper,
40
+ runNpmUpdate: async (p, onOutput) => {
41
+ onOutput("added 1 package");
42
+ },
43
+ });
44
+ updater.setProgressListener((e) => events.push(e));
45
+
46
+ const out = await updater.update([pkg("pi-foo"), pkg("pi-bar")]);
47
+
48
+ expect(out.results).toEqual([
49
+ { name: "pi-foo", success: true },
50
+ { name: "pi-bar", success: true },
51
+ ]);
52
+ // Per-package: start, output, complete
53
+ const phases = events.map((e) => `${e.name}:${e.phase}`);
54
+ expect(phases).toEqual([
55
+ "pi-foo:start",
56
+ "pi-foo:output",
57
+ "pi-foo:complete",
58
+ "pi-bar:start",
59
+ "pi-bar:output",
60
+ "pi-bar:complete",
61
+ ]);
62
+ });
63
+
64
+ it("continues after a failure and reports per-package errors", async () => {
65
+ const updater = new PiCoreUpdater({
66
+ packageManagerWrapper: wrapper,
67
+ runNpmUpdate: async (p) => {
68
+ if (p.name === "pi-bad") throw new Error("npm update exited with code 1");
69
+ },
70
+ });
71
+ const events: Array<{ phase: string }> = [];
72
+ updater.setProgressListener((e) => events.push({ phase: e.phase }));
73
+
74
+ const out = await updater.update([pkg("pi-bad"), pkg("pi-good")]);
75
+
76
+ expect(out.results).toEqual([
77
+ { name: "pi-bad", success: false, error: "npm update exited with code 1" },
78
+ { name: "pi-good", success: true },
79
+ ]);
80
+ // First package emits start + error; second emits start + complete
81
+ expect(events.map((e) => e.phase)).toEqual(["start", "error", "start", "complete"]);
82
+ });
83
+
84
+ it("invokes onAllComplete only when at least one package succeeded", async () => {
85
+ const onAllComplete = vi.fn().mockResolvedValue(3);
86
+ const updater = new PiCoreUpdater({
87
+ packageManagerWrapper: wrapper,
88
+ runNpmUpdate: async () => {
89
+ /* success */
90
+ },
91
+ onAllComplete,
92
+ });
93
+
94
+ const out = await updater.update([pkg("pi-foo")]);
95
+ expect(out.sessionsReloaded).toBe(3);
96
+ expect(onAllComplete).toHaveBeenCalledTimes(1);
97
+ });
98
+
99
+ it("skips onAllComplete when all packages fail", async () => {
100
+ const onAllComplete = vi.fn().mockResolvedValue(99);
101
+ const updater = new PiCoreUpdater({
102
+ packageManagerWrapper: wrapper,
103
+ runNpmUpdate: async () => {
104
+ throw new Error("boom");
105
+ },
106
+ onAllComplete,
107
+ });
108
+
109
+ const out = await updater.update([pkg("pi-foo"), pkg("pi-bar")]);
110
+ expect(out.sessionsReloaded).toBe(0);
111
+ expect(onAllComplete).not.toHaveBeenCalled();
112
+ expect(out.results.every((r) => !r.success)).toBe(true);
113
+ });
114
+
115
+ it("returns 0 sessionsReloaded and does not throw when onAllComplete rejects", async () => {
116
+ const onAllComplete = vi.fn().mockRejectedValue(new Error("reload failed"));
117
+ const updater = new PiCoreUpdater({
118
+ packageManagerWrapper: wrapper,
119
+ runNpmUpdate: async () => {
120
+ /* success */
121
+ },
122
+ onAllComplete,
123
+ });
124
+
125
+ const out = await updater.update([pkg("pi-foo")]);
126
+ expect(out.results[0].success).toBe(true);
127
+ expect(out.sessionsReloaded).toBe(0);
128
+ });
129
+
130
+ it("acquires the shared busy-lock and throws when wrapper is already busy", async () => {
131
+ // Start a long-running runExclusive on the wrapper to simulate an
132
+ // extension operation in flight.
133
+ let release!: () => void;
134
+ const held = new Promise<void>((r) => {
135
+ release = r;
136
+ });
137
+ const locked = wrapper.runExclusive(() => held);
138
+
139
+ const updater = new PiCoreUpdater({
140
+ packageManagerWrapper: wrapper,
141
+ runNpmUpdate: async () => {
142
+ /* no-op */
143
+ },
144
+ });
145
+
146
+ await expect(updater.update([pkg("pi-foo")])).rejects.toBeInstanceOf(
147
+ PackageOperationBusyError,
148
+ );
149
+
150
+ // Release the held lock and confirm a subsequent update succeeds.
151
+ release();
152
+ await locked;
153
+
154
+ const out = await updater.update([pkg("pi-foo")]);
155
+ expect(out.results[0].success).toBe(true);
156
+ });
157
+
158
+ it("releases the busy-lock after update completes (success path)", async () => {
159
+ const updater = new PiCoreUpdater({
160
+ packageManagerWrapper: wrapper,
161
+ runNpmUpdate: async () => {
162
+ /* success */
163
+ },
164
+ });
165
+ await updater.update([pkg("pi-foo")]);
166
+ expect(wrapper.isBusy()).toBe(false);
167
+ // Second call should be immediately permitted.
168
+ const out = await updater.update([pkg("pi-bar")]);
169
+ expect(out.results[0].success).toBe(true);
170
+ });
171
+
172
+ it("releases the busy-lock even when every package fails", async () => {
173
+ const updater = new PiCoreUpdater({
174
+ packageManagerWrapper: wrapper,
175
+ runNpmUpdate: async () => {
176
+ throw new Error("nope");
177
+ },
178
+ });
179
+ await updater.update([pkg("pi-foo")]);
180
+ expect(wrapper.isBusy()).toBe(false);
181
+ });
182
+
183
+ it("passes install-source-aware args & cwd to runNpmUpdate", async () => {
184
+ const seen: Array<{ name: string; source: "global" | "managed" }> = [];
185
+ const updater = new PiCoreUpdater({
186
+ packageManagerWrapper: wrapper,
187
+ runNpmUpdate: async (p) => {
188
+ seen.push({ name: p.name, source: p.installSource });
189
+ },
190
+ });
191
+ await updater.update([pkg("pi-foo", "global"), pkg("pi-bar", "managed")]);
192
+ expect(seen).toEqual([
193
+ { name: "pi-foo", source: "global" },
194
+ { name: "pi-bar", source: "managed" },
195
+ ]);
196
+ });
197
+
198
+ it("swallows progress-listener exceptions without failing the update", async () => {
199
+ const updater = new PiCoreUpdater({
200
+ packageManagerWrapper: wrapper,
201
+ runNpmUpdate: async () => {
202
+ /* success */
203
+ },
204
+ });
205
+ updater.setProgressListener(() => {
206
+ throw new Error("listener explosion");
207
+ });
208
+ // Silence the console.error emitted by the safe-emit guard
209
+ const err = vi.spyOn(console, "error").mockImplementation(() => {});
210
+ const out = await updater.update([pkg("pi-foo")]);
211
+ expect(out.results[0].success).toBe(true);
212
+ err.mockRestore();
213
+ });
214
+ });