@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,272 @@
1
+ /**
2
+ * Family L — Instance coordination (per-HOME advisory lock).
3
+ *
4
+ * Covers the scenarios enumerated in
5
+ * `openspec/changes/single-dashboard-per-home/design.md §10`:
6
+ *
7
+ * L1 no prior dashboard → acquire
8
+ * L2 healthy dashboard same port → attach
9
+ * L3 healthy dashboard diff port → attach via metadata URL
10
+ * L4 stale lock (PID dead) → steal + start
11
+ * L5 stale PID + port free → steal + clean + start
12
+ * L6 stale PID + port taken by → identity mismatch error
13
+ * unrelated process
14
+ * L7 mDNS disabled → lock still works (same as L2)
15
+ * L9 multi-user → separate HOMEs, separate locks
16
+ * L10 HOME symlink → realpath canonicalization
17
+ * L11 identity mismatch → error, no attach, no start
18
+ * L12 corrupt metadata → treat as stale, steal
19
+ *
20
+ * L8 (concurrent launch) and L13 (permission denied) live in integration
21
+ * tests (`concurrent-launch.test.ts`, `crash-recovery.test.ts`) because
22
+ * they require real processes / real filesystems.
23
+ *
24
+ * Note: this family does NOT use the cube enumeration. The 5-axis cube
25
+ * does not model lock state; adding it would 4x the cell count. Family L
26
+ * is registered as a separate enumeration here (design decision: the
27
+ * simpler option from design §Precondition item 2b).
28
+ */
29
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
30
+ import fs from "node:fs";
31
+ import os from "node:os";
32
+ import path from "node:path";
33
+ import {
34
+ acquireOrAttach,
35
+ readMetadata,
36
+ writeMetadataAtomic,
37
+ canonicalHomedir,
38
+ getLockPath,
39
+ InstanceLockMismatchError,
40
+ type LockMetadata,
41
+ } from "../../../../../server/src/home-lock.js";
42
+
43
+ let tmpHome: string;
44
+ let lockPath: string;
45
+ let metaPath: string;
46
+
47
+ beforeEach(() => {
48
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-family-l-"));
49
+ lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock");
50
+ metaPath = `${lockPath}.meta.json`;
51
+ });
52
+
53
+ afterEach(() => {
54
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
55
+ });
56
+
57
+ const baseCfg = (over: Partial<Parameters<typeof acquireOrAttach>[0]> = {}) => ({
58
+ httpPort: 8000,
59
+ piPort: 9999,
60
+ version: "0.0.0-test",
61
+ hooks: {
62
+ lockPath, metaPath, staleMs: 500,
63
+ probeHealth: async () => ({ running: false }),
64
+ isProcessAlive: () => false,
65
+ ...(over.hooks ?? {}),
66
+ },
67
+ ...over,
68
+ });
69
+
70
+ describe("Family L — instance coordination", () => {
71
+ it("L1 — no prior dashboard: acquires cleanly", async () => {
72
+ const r = await acquireOrAttach(baseCfg());
73
+ expect(r.mode).toBe("acquired");
74
+ if (r.mode === "acquired") await r.release();
75
+ });
76
+
77
+ it("L2 — healthy dashboard same port: attaches", async () => {
78
+ const first = await acquireOrAttach(baseCfg({ identity: "live-1" }));
79
+ expect(first.mode).toBe("acquired");
80
+
81
+ const second = await acquireOrAttach(baseCfg({
82
+ hooks: {
83
+ lockPath, metaPath, staleMs: 500,
84
+ isProcessAlive: () => true,
85
+ probeHealth: async () => ({ running: true, identity: "live-1", pid: process.pid }),
86
+ },
87
+ }));
88
+ expect(second.mode).toBe("attach");
89
+ if (first.mode === "acquired") await first.release();
90
+ });
91
+
92
+ it("L3 — healthy dashboard on different port: attaches via metadata URL", async () => {
93
+ const first = await acquireOrAttach(baseCfg({ identity: "live-3", httpPort: 8765 }));
94
+ expect(first.mode).toBe("acquired");
95
+
96
+ // New caller asks for port 9001, but lock meta says live on 8765.
97
+ const second = await acquireOrAttach(baseCfg({
98
+ httpPort: 9001,
99
+ hooks: {
100
+ lockPath, metaPath, staleMs: 500,
101
+ isProcessAlive: () => true,
102
+ // Probe only returns alive for the correct port (8765).
103
+ probeHealth: async (port) =>
104
+ port === 8765
105
+ ? { running: true, identity: "live-3", pid: process.pid }
106
+ : { running: false },
107
+ },
108
+ }));
109
+ expect(second.mode).toBe("attach");
110
+ if (second.mode === "attach") {
111
+ expect(second.meta.httpPort).toBe(8765);
112
+ expect(second.meta.url).toContain("8765");
113
+ }
114
+ if (first.mode === "acquired") await first.release();
115
+ });
116
+
117
+ it("L4 — stale lock, PID dead: steals + starts", async () => {
118
+ const first = await acquireOrAttach(baseCfg({ identity: "dead-holder" }));
119
+ expect(first.mode).toBe("acquired");
120
+ // Don't release — simulate crash.
121
+ await new Promise(r => setTimeout(r, 50));
122
+
123
+ const second = await acquireOrAttach(baseCfg({
124
+ hooks: {
125
+ lockPath, metaPath, staleMs: 1,
126
+ isProcessAlive: () => false,
127
+ probeHealth: async () => ({ running: false }),
128
+ },
129
+ }));
130
+ expect(second.mode).toBe("acquired");
131
+ if (second.mode === "acquired") await second.release();
132
+ });
133
+
134
+ it("L5 — stale PID + port free: clean steal", async () => {
135
+ // Write stale metadata manually, with no active lockfile yet.
136
+ fs.mkdirSync(path.dirname(metaPath), { recursive: true });
137
+ const staleMeta: LockMetadata = {
138
+ pid: 1, ppid: 0, httpPort: 8000, piPort: 9999,
139
+ startedAt: 0, identity: "ghost", version: "0", url: "http://localhost:8000", hostname: "h",
140
+ };
141
+ writeMetadataAtomic(staleMeta, metaPath);
142
+
143
+ const r = await acquireOrAttach(baseCfg());
144
+ expect(r.mode).toBe("acquired");
145
+ if (r.mode === "acquired") {
146
+ expect(r.meta.identity).not.toBe("ghost");
147
+ await r.release();
148
+ }
149
+ });
150
+
151
+ it("L6 — stale PID + port taken by unrelated process: identity mismatch", async () => {
152
+ const first = await acquireOrAttach(baseCfg({ identity: "legit" }));
153
+ expect(first.mode).toBe("acquired");
154
+
155
+ // Simulate: lock is alive-ish, but health returns a different identity
156
+ // (port commandeered by something else with same pid reuse).
157
+ await expect(
158
+ acquireOrAttach(baseCfg({
159
+ hooks: {
160
+ lockPath, metaPath, staleMs: 500,
161
+ isProcessAlive: () => true,
162
+ probeHealth: async () => ({ running: true, identity: "hostile-squatter" }),
163
+ },
164
+ })),
165
+ ).rejects.toBeInstanceOf(InstanceLockMismatchError);
166
+
167
+ if (first.mode === "acquired") await first.release();
168
+ });
169
+
170
+ it("L7 — mDNS disabled: lock path unaffected (parity with L2)", async () => {
171
+ // mDNS is orthogonal to the lock; exercise the same L2 flow to document
172
+ // that lock acquisition does NOT depend on mDNS discovery.
173
+ const first = await acquireOrAttach(baseCfg({ identity: "no-mdns" }));
174
+ expect(first.mode).toBe("acquired");
175
+
176
+ const second = await acquireOrAttach(baseCfg({
177
+ hooks: {
178
+ lockPath, metaPath, staleMs: 500,
179
+ isProcessAlive: () => true,
180
+ probeHealth: async () => ({ running: true, identity: "no-mdns", pid: process.pid }),
181
+ },
182
+ }));
183
+ expect(second.mode).toBe("attach");
184
+ if (first.mode === "acquired") await first.release();
185
+ });
186
+
187
+ it("L9 — multi-user: two HOMEs, two locks, no interference", async () => {
188
+ const homeA = fs.mkdtempSync(path.join(os.tmpdir(), "pi-user-a-"));
189
+ const homeB = fs.mkdtempSync(path.join(os.tmpdir(), "pi-user-b-"));
190
+ try {
191
+ const lockA = path.join(homeA, ".pi", "dashboard", "server.lock");
192
+ const metaA = `${lockA}.meta.json`;
193
+ const lockB = path.join(homeB, ".pi", "dashboard", "server.lock");
194
+ const metaB = `${lockB}.meta.json`;
195
+
196
+ const a = await acquireOrAttach({
197
+ httpPort: 8000, piPort: 9999, version: "t",
198
+ hooks: { lockPath: lockA, metaPath: metaA, staleMs: 500 },
199
+ });
200
+ const b = await acquireOrAttach({
201
+ httpPort: 8001, piPort: 9998, version: "t",
202
+ hooks: { lockPath: lockB, metaPath: metaB, staleMs: 500 },
203
+ });
204
+ expect(a.mode).toBe("acquired");
205
+ expect(b.mode).toBe("acquired");
206
+ if (a.mode === "acquired") await a.release();
207
+ if (b.mode === "acquired") await b.release();
208
+ } finally {
209
+ fs.rmSync(homeA, { recursive: true, force: true });
210
+ fs.rmSync(homeB, { recursive: true, force: true });
211
+ }
212
+ });
213
+
214
+ it("L10 — HOME symlink: realpath canonicalizes to the same lock", async () => {
215
+ const real = fs.mkdtempSync(path.join(os.tmpdir(), "pi-real-home-"));
216
+ const link = path.join(os.tmpdir(), `pi-link-home-${Date.now()}-${Math.random()}`);
217
+ fs.symlinkSync(real, link);
218
+ try {
219
+ // Both paths resolve via realpath → same canonical HOME.
220
+ expect(fs.realpathSync(link)).toBe(fs.realpathSync(real));
221
+ // canonicalHomedir() uses os.homedir() so we can't mock without
222
+ // globals; the invariant is tested via fs.realpathSync equivalence.
223
+ expect(typeof canonicalHomedir()).toBe("string");
224
+ } finally {
225
+ try { fs.unlinkSync(link); } catch { /* ignore */ }
226
+ fs.rmSync(real, { recursive: true, force: true });
227
+ }
228
+ });
229
+
230
+ it("L11 — identity mismatch: throws, no attach, no start", async () => {
231
+ const first = await acquireOrAttach(baseCfg({ identity: "me" }));
232
+ expect(first.mode).toBe("acquired");
233
+
234
+ await expect(
235
+ acquireOrAttach(baseCfg({
236
+ hooks: {
237
+ lockPath, metaPath, staleMs: 500,
238
+ isProcessAlive: () => true,
239
+ probeHealth: async () => ({ running: true, identity: "not-me", pid: 99999 }),
240
+ },
241
+ })),
242
+ ).rejects.toBeInstanceOf(InstanceLockMismatchError);
243
+
244
+ // Verify no new metadata has been written with a different identity
245
+ const meta = readMetadata(metaPath);
246
+ expect(meta?.identity).toBe("me");
247
+
248
+ if (first.mode === "acquired") await first.release();
249
+ });
250
+
251
+ it("L12 — corrupt metadata: treated as stale, steal", async () => {
252
+ const first = await acquireOrAttach(baseCfg({ identity: "intact" }));
253
+ expect(first.mode).toBe("acquired");
254
+
255
+ // Corrupt the metadata file.
256
+ fs.writeFileSync(metaPath, "{broken json");
257
+ await new Promise(r => setTimeout(r, 50));
258
+
259
+ const second = await acquireOrAttach(baseCfg({
260
+ hooks: {
261
+ lockPath, metaPath, staleMs: 1,
262
+ isProcessAlive: () => false,
263
+ probeHealth: async () => ({ running: false }),
264
+ },
265
+ }));
266
+ expect(second.mode).toBe("acquired");
267
+ if (second.mode === "acquired") {
268
+ expect(second.meta.identity).not.toBe("intact");
269
+ await second.release();
270
+ }
271
+ });
272
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Fixture: dev monorepo layout (what developers see running from source).
3
+ *
4
+ * <root>/
5
+ * node_modules/
6
+ * @mariozechner/pi-coding-agent/dist/cli.js
7
+ * openspec/dist/cli.js
8
+ * tsx/dist/cli.mjs
9
+ * packages/
10
+ * shared/ server/ extension/ electron/
11
+ *
12
+ * Used for Family C scenarios (bare-import resolves pi via workspace
13
+ * node_modules).
14
+ */
15
+ import posix from "node:path/posix";
16
+ import win32 from "node:path/win32";
17
+ import type { FsRecord } from "../harness.js";
18
+ import { openspecPackageJson, piPackageJson, type PiVersionSpec } from "./pi-versions.js";
19
+
20
+ export interface DevMonorepoSpec {
21
+ root: string;
22
+ platform: NodeJS.Platform;
23
+ pi?: PiVersionSpec;
24
+ openspec?: string;
25
+ }
26
+
27
+ export function devMonorepo(spec: DevMonorepoSpec): FsRecord {
28
+ const p = spec.platform === "win32" ? win32 : posix;
29
+ const nodeModules = p.join(spec.root, "node_modules");
30
+ const out: Record<string, string> = {};
31
+
32
+ // Root package.json (workspace)
33
+ out[p.join(spec.root, "package.json")] = JSON.stringify({
34
+ name: "pi-agent-dashboard-root",
35
+ private: true,
36
+ workspaces: ["packages/*"],
37
+ });
38
+
39
+ // Workspace packages
40
+ for (const pkg of ["shared", "server", "extension", "electron", "client"]) {
41
+ out[p.join(spec.root, "packages", pkg, "package.json")] = JSON.stringify({
42
+ name: `@blackbelt-technology/pi-dashboard-${pkg}`,
43
+ version: "0.4.0",
44
+ });
45
+ }
46
+
47
+ // Hoisted deps
48
+ const piDir = p.join(nodeModules, "@mariozechner", "pi-coding-agent");
49
+ out[p.join(piDir, "package.json")] = piPackageJson(spec.pi);
50
+ out[p.join(piDir, "dist", "cli.js")] = "#!/usr/bin/env node";
51
+
52
+ const osDir = p.join(nodeModules, "openspec");
53
+ out[p.join(osDir, "package.json")] = openspecPackageJson(spec.openspec ?? "0.4.1");
54
+ out[p.join(osDir, "dist", "cli.js")] = "#!/usr/bin/env node";
55
+ out[p.join(osDir, "dist", "index.js")] = "module.exports = {};";
56
+
57
+ return out;
58
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Fixture: Electron packaged resources layout.
3
+ *
4
+ * macOS: /Applications/PI Dashboard.app/Contents/Resources/server/...
5
+ * Linux: /usr/lib/pi-dashboard/resources/server/...
6
+ * Windows: C:\Program Files\PI Dashboard\resources\server\...
7
+ * AppImage: /tmp/.mount_PIxxxx/resources/server/... (temp, unstable)
8
+ *
9
+ * The bundled extension lives alongside at
10
+ * `<resourcesPath>/server/packages/extension/`.
11
+ */
12
+ import posix from "node:path/posix";
13
+ import win32 from "node:path/win32";
14
+ import type { FsRecord } from "../harness.js";
15
+
16
+ export interface ElectronLayoutSpec {
17
+ platform: NodeJS.Platform;
18
+ /** If true, simulate AppImage temp-mount path. */
19
+ appimage?: boolean;
20
+ }
21
+
22
+ function resourcesRoot(spec: ElectronLayoutSpec): string {
23
+ if (spec.appimage) {
24
+ return "/tmp/.mount_PIxxxx/resources";
25
+ }
26
+ switch (spec.platform) {
27
+ case "darwin":
28
+ return "/Applications/PI Dashboard.app/Contents/Resources";
29
+ case "win32":
30
+ return "C:\\Program Files\\PI Dashboard\\resources";
31
+ default:
32
+ return "/usr/lib/pi-dashboard/resources";
33
+ }
34
+ }
35
+
36
+ export function electronPackaged(spec: ElectronLayoutSpec): FsRecord {
37
+ const p = spec.platform === "win32" ? win32 : posix;
38
+ const resources = resourcesRoot(spec);
39
+ const serverDir = p.join(resources, "server");
40
+ const extensionDir = p.join(serverDir, "packages", "extension");
41
+ const out: Record<string, string> = {};
42
+
43
+ // Bundled server package.json
44
+ out[p.join(serverDir, "package.json")] = JSON.stringify({
45
+ name: "@blackbelt-technology/pi-agent-dashboard-root",
46
+ version: "0.4.0",
47
+ private: true,
48
+ });
49
+
50
+ // Bundled server CLI source
51
+ out[p.join(serverDir, "packages", "server", "package.json")] = JSON.stringify({
52
+ name: "@blackbelt-technology/pi-dashboard-server",
53
+ version: "0.4.0",
54
+ });
55
+ out[p.join(serverDir, "packages", "server", "src", "cli.ts")] = "// cli";
56
+
57
+ // Bundled bridge extension
58
+ out[p.join(extensionDir, "package.json")] = JSON.stringify({
59
+ name: "@blackbelt-technology/pi-dashboard-extension",
60
+ version: "0.4.0",
61
+ });
62
+ out[p.join(extensionDir, "src", "bridge.ts")] = "// bridge";
63
+
64
+ // Bundled Node.js (minimal)
65
+ const nodeBin = spec.platform === "win32"
66
+ ? p.join(resources, "node", "bin", "node.exe")
67
+ : p.join(resources, "node", "bin", "node");
68
+ out[nodeBin] = "\x7fELF"; // binary-ish marker
69
+
70
+ return out;
71
+ }
72
+
73
+ /** Returns the resolved `resourcesPath` + extension path for assertions. */
74
+ export function electronPaths(spec: ElectronLayoutSpec): {
75
+ resources: string;
76
+ serverDir: string;
77
+ extensionDir: string;
78
+ } {
79
+ const p = spec.platform === "win32" ? win32 : posix;
80
+ const resources = resourcesRoot(spec);
81
+ const serverDir = p.join(resources, "server");
82
+ const extensionDir = p.join(serverDir, "packages", "extension");
83
+ return { resources, serverDir, extensionDir };
84
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Barrel export for bootstrap fixtures. Import as `import * as fixtures`.
3
+ */
4
+ export * from "./pi-versions.js";
5
+ export * from "./managed-install.js";
6
+ export * from "./npm-global-layout.js";
7
+ export * from "./electron-layout.js";
8
+ export * from "./dev-monorepo.js";
9
+ export * from "./settings-json.js";
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Fixture: managed install at `<homedir>/.pi-dashboard/`.
3
+ *
4
+ * Produces the fs layout a user gets after running the Electron wizard
5
+ * or proposal-2's CLI first-run bootstrap: node_modules/@mariozechner/
6
+ * pi-coding-agent + node_modules/.bin shims.
7
+ */
8
+ import posix from "node:path/posix";
9
+ import win32 from "node:path/win32";
10
+ import type { FsRecord } from "../harness.js";
11
+ import { openspecPackageJson, piPackageJson, type PiVersionSpec } from "./pi-versions.js";
12
+
13
+ export interface ManagedInstallSpec {
14
+ homedir: string;
15
+ platform: NodeJS.Platform;
16
+ pi?: PiVersionSpec | false;
17
+ openspec?: string | false;
18
+ tsx?: string | false;
19
+ /**
20
+ * If `true`, write just the package.json for pi — no `dist/cli.js`.
21
+ * Simulates an install that was interrupted mid-extract (scenario E2).
22
+ */
23
+ piPartial?: boolean;
24
+ }
25
+
26
+ export function managedInstall(spec: ManagedInstallSpec): FsRecord {
27
+ const p = spec.platform === "win32" ? win32 : posix;
28
+ const out: Record<string, string> = {};
29
+ const managedDir = p.join(spec.homedir, ".pi-dashboard");
30
+ const nodeModules = p.join(managedDir, "node_modules");
31
+ const binDir = p.join(nodeModules, ".bin");
32
+
33
+ out[p.join(managedDir, "package.json")] = JSON.stringify({
34
+ name: "pi-dashboard-managed",
35
+ private: true,
36
+ type: "module",
37
+ });
38
+
39
+ if (spec.pi !== false) {
40
+ const piSpec = spec.pi ?? {};
41
+ const piDir = p.join(nodeModules, "@mariozechner", "pi-coding-agent");
42
+ out[p.join(piDir, "package.json")] = piPackageJson(piSpec);
43
+ if (!spec.piPartial) {
44
+ out[p.join(piDir, "dist", "cli.js")] = "#!/usr/bin/env node\n// pi cli stub";
45
+ // bin shim
46
+ if (spec.platform === "win32") {
47
+ out[p.join(binDir, "pi.cmd")] = "@node %~dp0\\..\\@mariozechner\\pi-coding-agent\\dist\\cli.js %*";
48
+ } else {
49
+ out[p.join(binDir, "pi")] = "#!/bin/sh\nexec node ../@mariozechner/pi-coding-agent/dist/cli.js \"$@\"";
50
+ }
51
+ }
52
+ }
53
+
54
+ if (spec.openspec !== false) {
55
+ const v = spec.openspec ?? "0.4.1";
56
+ const dir = p.join(nodeModules, "openspec");
57
+ out[p.join(dir, "package.json")] = openspecPackageJson(v);
58
+ out[p.join(dir, "dist", "cli.js")] = "#!/usr/bin/env node";
59
+ out[p.join(dir, "dist", "index.js")] = "module.exports = {};";
60
+ if (spec.platform === "win32") {
61
+ out[p.join(binDir, "openspec.cmd")] = "@node %~dp0\\..\\openspec\\dist\\cli.js %*";
62
+ } else {
63
+ out[p.join(binDir, "openspec")] = "#!/bin/sh\nexec node ../openspec/dist/cli.js \"$@\"";
64
+ }
65
+ }
66
+
67
+ if (spec.tsx !== false) {
68
+ const v = spec.tsx ?? "4.20.0";
69
+ const dir = p.join(nodeModules, "tsx");
70
+ out[p.join(dir, "package.json")] = JSON.stringify({
71
+ name: "tsx",
72
+ version: v,
73
+ main: "dist/cli.mjs",
74
+ bin: { tsx: "dist/cli.mjs" },
75
+ });
76
+ out[p.join(dir, "dist", "cli.mjs")] = "#!/usr/bin/env node";
77
+ if (spec.platform === "win32") {
78
+ out[p.join(binDir, "tsx.cmd")] = "@node %~dp0\\..\\tsx\\dist\\cli.mjs %*";
79
+ } else {
80
+ out[p.join(binDir, "tsx")] = "#!/bin/sh\nexec node ../tsx/dist/cli.mjs \"$@\"";
81
+ }
82
+ }
83
+
84
+ return out;
85
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Fixture: global npm install layout.
3
+ *
4
+ * Posix: /usr/lib/node_modules/<pkg>/...
5
+ * Windows: %APPDATA%\Roaming\npm\node_modules\<pkg>\...
6
+ * with pi.cmd / openspec.cmd shims in %APPDATA%\Roaming\npm\
7
+ *
8
+ * For the "%ProgramFiles%\nodejs" variant on Windows, see
9
+ * `npmGlobalWindowsProgramFiles` — npm is installed there when users pick
10
+ * "Add to PATH" during Node.js installer.
11
+ */
12
+ import posix from "node:path/posix";
13
+ import win32 from "node:path/win32";
14
+ import type { FsRecord } from "../harness.js";
15
+ import { openspecPackageJson, piPackageJson, type PiVersionSpec } from "./pi-versions.js";
16
+
17
+ interface PosixSpec {
18
+ pi?: PiVersionSpec | false;
19
+ openspec?: string | false;
20
+ /** Install root; defaults to `/usr/lib/node_modules`. */
21
+ root?: string;
22
+ binDir?: string;
23
+ }
24
+
25
+ interface WindowsSpec {
26
+ pi?: PiVersionSpec | false;
27
+ openspec?: string | false;
28
+ dashboard?: boolean;
29
+ /** Where %APPDATA%\Roaming\npm lives. */
30
+ npmDir?: string;
31
+ }
32
+
33
+ /**
34
+ * Unix global npm install. Root defaults to `/usr/lib/node_modules`,
35
+ * binaries at `/usr/local/bin`.
36
+ */
37
+ export function npmGlobalUnix(spec: PosixSpec = {}): FsRecord {
38
+ const p = posix;
39
+ const root = spec.root ?? "/usr/lib/node_modules";
40
+ const binDir = spec.binDir ?? "/usr/local/bin";
41
+ const out: Record<string, string> = {};
42
+
43
+ if (spec.pi !== false) {
44
+ const piSpec = spec.pi ?? {};
45
+ const piDir = p.join(root, "@mariozechner", "pi-coding-agent");
46
+ out[p.join(piDir, "package.json")] = piPackageJson(piSpec);
47
+ out[p.join(piDir, "dist", "cli.js")] = "#!/usr/bin/env node";
48
+ out[p.join(binDir, "pi")] = "#!/bin/sh\nexec node ...";
49
+ }
50
+
51
+ if (spec.openspec !== false) {
52
+ // openspec tool definition uses package `@fission-ai/openspec`
53
+ // with entry `bin/openspec.js` (see definitions.ts).
54
+ const dir = p.join(root, "@fission-ai", "openspec");
55
+ out[p.join(dir, "package.json")] = JSON.stringify({
56
+ name: "@fission-ai/openspec",
57
+ version: spec.openspec ?? "0.4.1",
58
+ bin: { openspec: "bin/openspec.js" },
59
+ });
60
+ out[p.join(dir, "bin", "openspec.js")] = "#!/usr/bin/env node";
61
+ out[p.join(binDir, "openspec")] = "#!/bin/sh\nexec node ...";
62
+ }
63
+
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Windows AppData\Roaming\npm layout — the default for MSI installs of
69
+ * Node.js. `npmDir` overrides the location.
70
+ */
71
+ export function npmGlobalWindowsAppData(
72
+ homedir: string,
73
+ spec: WindowsSpec = {},
74
+ ): FsRecord {
75
+ const p = win32;
76
+ const npmDir = spec.npmDir ?? p.join(homedir, "AppData", "Roaming", "npm");
77
+ const nodeModules = p.join(npmDir, "node_modules");
78
+ const out: Record<string, string> = {};
79
+
80
+ if (spec.pi !== false) {
81
+ const piSpec = spec.pi ?? {};
82
+ const piDir = p.join(nodeModules, "@mariozechner", "pi-coding-agent");
83
+ out[p.join(piDir, "package.json")] = piPackageJson(piSpec);
84
+ out[p.join(piDir, "dist", "cli.js")] = "#!/usr/bin/env node";
85
+ out[p.join(npmDir, "pi.cmd")] = "@node %~dp0\\node_modules\\@mariozechner\\pi-coding-agent\\dist\\cli.js %*";
86
+ }
87
+
88
+ if (spec.openspec !== false) {
89
+ const dir = p.join(nodeModules, "@fission-ai", "openspec");
90
+ out[p.join(dir, "package.json")] = JSON.stringify({
91
+ name: "@fission-ai/openspec",
92
+ version: spec.openspec ?? "0.4.1",
93
+ bin: { openspec: "bin/openspec.js" },
94
+ });
95
+ out[p.join(dir, "bin", "openspec.js")] = "#!/usr/bin/env node";
96
+ out[p.join(npmDir, "openspec.cmd")] = "@node %~dp0\\node_modules\\@fission-ai\\openspec\\bin\\openspec.js %*";
97
+ }
98
+
99
+ if (spec.dashboard) {
100
+ const dir = p.join(nodeModules, "@blackbelt-technology", "pi-agent-dashboard");
101
+ out[p.join(dir, "package.json")] = JSON.stringify({
102
+ name: "@blackbelt-technology/pi-agent-dashboard",
103
+ version: "0.4.0",
104
+ bin: { "pi-dashboard": "packages/server/dist/cli.js" },
105
+ });
106
+ out[p.join(dir, "packages", "server", "dist", "cli.js")] = "#!/usr/bin/env node";
107
+ out[p.join(npmDir, "pi-dashboard.cmd")] = "@node %~dp0\\node_modules\\@blackbelt-technology\\pi-agent-dashboard\\packages\\server\\dist\\cli.js %*";
108
+ }
109
+
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * Windows %ProgramFiles%\nodejs\node_modules layout — picked when Node
115
+ * installer chose "install as system tool."
116
+ */
117
+ export function npmGlobalWindowsProgramFiles(spec: WindowsSpec = {}): FsRecord {
118
+ return npmGlobalWindowsAppData("C:\\Program Files\\nodejs_", {
119
+ ...spec,
120
+ npmDir: "C:\\Program Files\\nodejs",
121
+ });
122
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Fixture: stamp a specific version into a pi-coding-agent package.json.
3
+ * Returns the package.json JSON string ready for FsRecord insertion.
4
+ */
5
+ export interface PiVersionSpec {
6
+ name?: string;
7
+ version?: string;
8
+ bin?: Record<string, string>;
9
+ main?: string;
10
+ }
11
+
12
+ export function piPackageJson(spec: PiVersionSpec = {}): string {
13
+ return JSON.stringify(
14
+ {
15
+ name: spec.name ?? "@mariozechner/pi-coding-agent",
16
+ version: spec.version ?? "0.6.3",
17
+ main: spec.main ?? "dist/cli.js",
18
+ bin: spec.bin ?? { pi: "dist/cli.js" },
19
+ },
20
+ null,
21
+ 2,
22
+ );
23
+ }
24
+
25
+ export function openspecPackageJson(version = "0.4.1"): string {
26
+ return JSON.stringify(
27
+ {
28
+ name: "openspec",
29
+ version,
30
+ main: "dist/index.js",
31
+ bin: { openspec: "dist/cli.js" },
32
+ },
33
+ null,
34
+ 2,
35
+ );
36
+ }