@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,413 @@
1
+ /**
2
+ * In-memory bootstrap harness — pure-fs, pure-env test runner for
3
+ * ToolRegistry resolution + bridge-extension registration scenarios.
4
+ *
5
+ * See openspec/changes/bootstrap-resolution-harness/design.md §7 for
6
+ * the full design.
7
+ *
8
+ * Usage:
9
+ *
10
+ * await withFakeEnv({
11
+ * platform: "win32",
12
+ * homedir: "C:\\Users\\Robert",
13
+ * cwd: "C:\\Program Files\\PI Dashboard",
14
+ * env: { APPDATA: "C:\\Users\\Robert\\AppData\\Roaming", PATH: "..." },
15
+ * fs: layer(
16
+ * fixtures.npmGlobalOnWindows({ pi: "0.6.3" }),
17
+ * fixtures.managedInstall({ pi: "0.5.1" }),
18
+ * ),
19
+ * }, async (ctx) => {
20
+ * const registry = ctx.createRegistry();
21
+ * const res = registry.resolve("pi");
22
+ * expect(snapshotTrail(res, ctx)).toMatchSnapshot();
23
+ * });
24
+ *
25
+ * Invariants:
26
+ * - No real fs, child_process, or network access during a scenario run.
27
+ * - Every strategy dep (`exists`, `which`, `npmRootGlobal`, `resolveModule`)
28
+ * is wired to the in-memory volume.
29
+ * - Platform is fully injected; tests do NOT mutate `process.platform`.
30
+ */
31
+ import path from "node:path";
32
+ import posix from "node:path/posix";
33
+ import win32 from "node:path/win32";
34
+ import { Volume } from "memfs";
35
+ import type { IFs } from "memfs";
36
+ import {
37
+ ToolRegistry,
38
+ type ToolRegistryDeps,
39
+ type PlatformEnv,
40
+ } from "../../tool-registry/registry.js";
41
+ import type { StrategyDeps } from "../../tool-registry/strategies.js";
42
+ import { OverridesStore } from "../../tool-registry/overrides.js";
43
+
44
+ /**
45
+ * Minimal in-memory OverridesStore replacement that doesn't touch
46
+ * the real filesystem. Shape-compatible with the real class (TS
47
+ * `private` is erased at runtime, so casting through `unknown` is
48
+ * safe).
49
+ */
50
+ class FakeOverridesStore {
51
+ constructor(private cache: Record<string, string>) {}
52
+ list(): Readonly<Record<string, string>> {
53
+ return this.cache;
54
+ }
55
+ set(name: string, overridePath: string): void {
56
+ this.cache[name] = overridePath;
57
+ }
58
+ clear(name: string): void {
59
+ delete this.cache[name];
60
+ }
61
+ invalidate(): void {
62
+ /* cache is always fresh in the fake store */
63
+ }
64
+ }
65
+
66
+ /** File contents for the fake filesystem: path -> content. Directories are
67
+ * implied by the paths of their children. */
68
+ export type FsRecord = Readonly<Record<string, string | Buffer>>;
69
+
70
+ /** Env variables visible inside the scenario. */
71
+ export type FakeEnv = Readonly<Record<string, string>>;
72
+
73
+ export interface FakeEnvSpec {
74
+ platform: NodeJS.Platform;
75
+ homedir: string;
76
+ cwd?: string;
77
+ env?: FakeEnv;
78
+ /** File contents — use `layer(...)` to compose fixtures. */
79
+ fs?: FsRecord;
80
+ /** Per-tool override map (tool-overrides.json content). */
81
+ overrides?: Readonly<Record<string, string>>;
82
+ /** Override `npm root -g`. Defaults to platform-appropriate path. */
83
+ npmRootGlobal?: string;
84
+ }
85
+
86
+ export interface HarnessContext {
87
+ readonly spec: FakeEnvSpec;
88
+ readonly vol: Volume;
89
+ readonly fs: IFs;
90
+ readonly platform: NodeJS.Platform;
91
+ readonly homedir: string;
92
+ readonly cwd: string;
93
+ readonly env: FakeEnv;
94
+ /** Platform-correct `path` module (posix vs win32). */
95
+ readonly pathlib: typeof posix | typeof win32;
96
+ /** PATH entries as an array (split on platform-correct delimiter). */
97
+ readonly pathEntries: readonly string[];
98
+ /** Resolved `npm root -g` value. */
99
+ readonly npmRootGlobal: string;
100
+
101
+ /** Create the strategy deps wired to the fake filesystem/env. */
102
+ createStrategyDeps(): Required<StrategyDeps>;
103
+
104
+ /** Create a ToolRegistry pre-wired with the fake env + overrides. */
105
+ createRegistry(extra?: Partial<ToolRegistryDeps>): ToolRegistry;
106
+
107
+ /** Read the fake filesystem's settings.json (or null if absent/broken). */
108
+ readSettings(): Record<string, unknown> | null;
109
+ }
110
+
111
+ /**
112
+ * Merge multiple FsRecord layers. Later layers override earlier on path
113
+ * conflict. Returns a single FsRecord.
114
+ */
115
+ export function layer(...layers: readonly (FsRecord | undefined | null)[]): FsRecord {
116
+ const out: Record<string, string | Buffer> = {};
117
+ for (const l of layers) {
118
+ if (!l) continue;
119
+ for (const [k, v] of Object.entries(l)) out[k] = v;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** Platform-aware PATH delimiter. */
125
+ function pathDelim(platform: NodeJS.Platform): string {
126
+ return platform === "win32" ? ";" : ":";
127
+ }
128
+
129
+ /** Platform-correct path module. */
130
+ function pathFor(platform: NodeJS.Platform): typeof posix | typeof win32 {
131
+ return platform === "win32" ? win32 : posix;
132
+ }
133
+
134
+ /** Default `npm root -g` per platform when not provided by spec. */
135
+ function defaultNpmRootGlobal(spec: FakeEnvSpec): string {
136
+ const p = pathFor(spec.platform);
137
+ if (spec.platform === "win32") {
138
+ const appdata = spec.env?.APPDATA ?? p.join(spec.homedir, "AppData", "Roaming");
139
+ return p.join(appdata, "npm", "node_modules");
140
+ }
141
+ return p.join(spec.homedir, ".npm", "lib", "node_modules");
142
+ }
143
+
144
+ /**
145
+ * Build a `which(name)` function that walks PATH inside the fake fs.
146
+ * On win32, tries `name`, `name.cmd`, `name.exe` in that order.
147
+ */
148
+ function buildWhich(
149
+ fs: IFs,
150
+ pathEntries: readonly string[],
151
+ platform: NodeJS.Platform,
152
+ ): (name: string) => string | null {
153
+ const p = pathFor(platform);
154
+ const exts = platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
155
+ return (name: string): string | null => {
156
+ // If name has an extension or absolute path, short-circuit.
157
+ if (p.isAbsolute(name)) {
158
+ return fs.existsSync(name) ? name : null;
159
+ }
160
+ for (const entry of pathEntries) {
161
+ for (const ext of exts) {
162
+ const candidate = p.join(entry, name + ext);
163
+ try {
164
+ if (fs.existsSync(candidate)) return candidate;
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ }
169
+ }
170
+ return null;
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Build a `resolveModule(id, from)` that walks the fake fs' node_modules
176
+ * ancestor chain starting at `from`, looking for
177
+ * <dir>/node_modules/<id>/package.json
178
+ * then reading that package.json's `main`/`exports` to derive the entry.
179
+ * When no package.json `main` is present, falls back to `index.js`.
180
+ *
181
+ * NOT a full Node resolver — covers the cases the bootstrap harness
182
+ * needs (bare package import of CJS/ESM with a `main` field). Good
183
+ * enough for pi-coding-agent, openspec, tsx.
184
+ */
185
+ function buildResolveModule(
186
+ fs: IFs,
187
+ platform: NodeJS.Platform,
188
+ ): (id: string, from: string) => string | null {
189
+ const p = pathFor(platform);
190
+
191
+ function readJson(p2: string): Record<string, unknown> | null {
192
+ try {
193
+ const raw = fs.readFileSync(p2, "utf-8") as string;
194
+ return JSON.parse(raw);
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ function entryFromPkg(pkgPath: string, pkg: Record<string, unknown>): string {
201
+ const pkgDir = p.dirname(pkgPath);
202
+ const main = typeof pkg.main === "string" ? pkg.main : "index.js";
203
+ return p.join(pkgDir, main);
204
+ }
205
+
206
+ // Split a require id into { pkgName, subpath }.
207
+ // "foo" → { pkgName: "foo", subpath: null }
208
+ // "foo/bar" → { pkgName: "foo", subpath: "bar" }
209
+ // "@scope/foo" → { pkgName: "@scope/foo", subpath: null }
210
+ // "@scope/foo/package.json" → { pkgName: "@scope/foo", subpath: "package.json" }
211
+ function splitId(id: string): { pkgName: string; subpath: string | null } {
212
+ if (id.startsWith("@")) {
213
+ const parts = id.split("/");
214
+ if (parts.length <= 2) return { pkgName: id, subpath: null };
215
+ return { pkgName: parts.slice(0, 2).join("/"), subpath: parts.slice(2).join("/") };
216
+ }
217
+ const idx = id.indexOf("/");
218
+ if (idx === -1) return { pkgName: id, subpath: null };
219
+ return { pkgName: id.slice(0, idx), subpath: id.slice(idx + 1) };
220
+ }
221
+
222
+ return (id: string, from: string): string | null => {
223
+ // Normalize `from`: if it's a file:// URL, strip it.
224
+ let anchor = from;
225
+ if (anchor.startsWith("file://")) {
226
+ // file:///C:/... on win32, file:///home/... on posix
227
+ anchor = anchor.slice(platform === "win32" ? 8 : 7);
228
+ }
229
+ // Starting directory: if anchor is a file, use its dir; if it's already
230
+ // a directory path, use as-is.
231
+ let dir = anchor;
232
+ try {
233
+ const st = fs.statSync(dir);
234
+ if (!st.isDirectory()) dir = p.dirname(dir);
235
+ } catch {
236
+ dir = p.dirname(anchor);
237
+ }
238
+
239
+ const { pkgName, subpath } = splitId(id);
240
+
241
+ // Walk up looking for node_modules/<pkgName>/package.json.
242
+ // Stop when we hit the filesystem root (dirname returns same value).
243
+ let prev = "";
244
+ while (dir !== prev) {
245
+ const pkgJsonPath = p.join(dir, "node_modules", pkgName, "package.json");
246
+ if (fs.existsSync(pkgJsonPath)) {
247
+ // Subpath request — resolve relative to the package dir.
248
+ if (subpath !== null) {
249
+ const candidate = p.join(p.dirname(pkgJsonPath), subpath);
250
+ if (fs.existsSync(candidate)) return candidate;
251
+ return null;
252
+ }
253
+ // Bare package — read main from package.json.
254
+ const pkg = readJson(pkgJsonPath);
255
+ if (pkg) {
256
+ const entry = entryFromPkg(pkgJsonPath, pkg);
257
+ if (fs.existsSync(entry)) return entry;
258
+ }
259
+ }
260
+ prev = dir;
261
+ dir = p.dirname(dir);
262
+ }
263
+ return null;
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Populate a memfs Volume from an FsRecord. Also creates directory
269
+ * entries implicitly.
270
+ */
271
+ function populateVolume(vol: Volume, records: FsRecord, platform: NodeJS.Platform): void {
272
+ const p = pathFor(platform);
273
+ for (const [rawPath, content] of Object.entries(records)) {
274
+ // memfs internally uses posix — but its APIs accept win32 paths on
275
+ // win32. For our purposes we normalize to posix for the Volume,
276
+ // because memfs is posix-native. Tests use the platform-correct
277
+ // path module via `ctx.pathlib` for path composition, and we
278
+ // translate at volume-populate time.
279
+ const normalized = platform === "win32" ? toMemfsPath(rawPath) : rawPath;
280
+ const dir = posix.dirname(normalized);
281
+ try {
282
+ vol.mkdirSync(dir, { recursive: true });
283
+ } catch {
284
+ /* ignore */
285
+ }
286
+ vol.writeFileSync(normalized, content);
287
+ }
288
+ void p; // silence unused on some paths
289
+ }
290
+
291
+ /**
292
+ * Translate a win32-style path to the posix-like path memfs uses internally.
293
+ * e.g. `C:\Users\Robert\.pi\settings.json` → `/C:/Users/Robert/.pi/settings.json`.
294
+ * Drive letter becomes a top-level directory. Separators flipped.
295
+ */
296
+ export function toMemfsPath(winPath: string): string {
297
+ const replaced = winPath.replace(/\\/g, "/");
298
+ if (/^[A-Za-z]:/.test(replaced)) {
299
+ return "/" + replaced;
300
+ }
301
+ return replaced.startsWith("/") ? replaced : "/" + replaced;
302
+ }
303
+
304
+ /**
305
+ * Build a memfs-backed IFs that understands win32 paths by translating
306
+ * them to posix-form keys inside the volume.
307
+ */
308
+ function wrapFsForPlatform(vol: Volume, platform: NodeJS.Platform): IFs {
309
+ const base = vol as unknown as IFs;
310
+ if (platform !== "win32") return base;
311
+ // Wrap: translate any incoming path through toMemfsPath.
312
+ const translate = (p: unknown): unknown =>
313
+ typeof p === "string" ? toMemfsPath(p) : p;
314
+ const wrap = <K extends keyof IFs>(name: K): IFs[K] => {
315
+ const orig = base[name] as unknown as (...args: unknown[]) => unknown;
316
+ if (typeof orig !== "function") return base[name];
317
+ return ((...args: unknown[]) => {
318
+ if (args.length > 0) args[0] = translate(args[0]);
319
+ return orig.apply(base, args);
320
+ }) as unknown as IFs[K];
321
+ };
322
+ return new Proxy(base, {
323
+ get(target, prop: string) {
324
+ if (prop === "existsSync" || prop === "readFileSync" || prop === "statSync"
325
+ || prop === "readdirSync" || prop === "writeFileSync" || prop === "mkdirSync"
326
+ || prop === "rmSync" || prop === "lstatSync") {
327
+ return wrap(prop as keyof IFs);
328
+ }
329
+ return (target as unknown as Record<string, unknown>)[prop];
330
+ },
331
+ }) as IFs;
332
+ }
333
+
334
+ /**
335
+ * Run an async callback inside a fresh in-memory environment.
336
+ *
337
+ * Does NOT mutate `process.platform`, `process.env`, `process.cwd()`,
338
+ * or any other host state — all environment surface is threaded through
339
+ * `HarnessContext` and the `StrategyDeps` it produces.
340
+ */
341
+ export async function withFakeEnv<T>(
342
+ spec: FakeEnvSpec,
343
+ fn: (ctx: HarnessContext) => Promise<T> | T,
344
+ ): Promise<T> {
345
+ const vol = new Volume();
346
+ populateVolume(vol, spec.fs ?? {}, spec.platform);
347
+ const fs = wrapFsForPlatform(vol, spec.platform);
348
+ const pathlib = pathFor(spec.platform);
349
+ const env = spec.env ?? {};
350
+ const cwd = spec.cwd ?? spec.homedir;
351
+ const pathVar = env.PATH ?? "";
352
+ const pathEntries = pathVar === ""
353
+ ? []
354
+ : pathVar.split(pathDelim(spec.platform)).filter(Boolean);
355
+ const npmRootGlobal = spec.npmRootGlobal ?? defaultNpmRootGlobal(spec);
356
+
357
+ const whichFn = buildWhich(fs, pathEntries, spec.platform);
358
+ const resolveModuleFn = buildResolveModule(fs, spec.platform);
359
+ const existsFn = (p: string) => {
360
+ try {
361
+ return fs.existsSync(p);
362
+ } catch {
363
+ return false;
364
+ }
365
+ };
366
+
367
+ const createStrategyDeps = (): Required<StrategyDeps> => ({
368
+ exists: existsFn,
369
+ which: whichFn,
370
+ npmRootGlobal: () => npmRootGlobal,
371
+ resolveModule: resolveModuleFn,
372
+ });
373
+
374
+ const createRegistry = (extra?: Partial<ToolRegistryDeps>): ToolRegistry => {
375
+ const overridesStore = new FakeOverridesStore({ ...(spec.overrides ?? {}) });
376
+ const platformEnv: PlatformEnv = { homedir: spec.homedir, cwd };
377
+ return new ToolRegistry({
378
+ overrides: overridesStore as unknown as OverridesStore,
379
+ platform: spec.platform,
380
+ env: platformEnv,
381
+ now: () => 0,
382
+ ...extra,
383
+ });
384
+ };
385
+
386
+ const readSettings = (): Record<string, unknown> | null => {
387
+ const settingsPath = pathlib.join(spec.homedir, ".pi", "agent", "settings.json");
388
+ try {
389
+ const raw = fs.readFileSync(settingsPath, "utf-8") as string;
390
+ return JSON.parse(raw);
391
+ } catch {
392
+ return null;
393
+ }
394
+ };
395
+
396
+ const ctx: HarnessContext = {
397
+ spec,
398
+ vol,
399
+ fs,
400
+ platform: spec.platform,
401
+ homedir: spec.homedir,
402
+ cwd,
403
+ env,
404
+ pathlib,
405
+ pathEntries,
406
+ npmRootGlobal,
407
+ createStrategyDeps,
408
+ createRegistry,
409
+ readSettings,
410
+ };
411
+
412
+ return await fn(ctx);
413
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Bulk-skip manifest for cells NOT yet covered by a family-test file.
3
+ *
4
+ * This is the "fail-closed by default" scaffold. Every cell in the
5
+ * cube starts SKIPPED with a documented reason. As family-test files
6
+ * are added, they call `register(cell, tag)` and the corresponding
7
+ * skip entry is automatically overridden (because registration takes
8
+ * precedence in the sweep).
9
+ *
10
+ * New cells added to the enumeration (by extending PLATFORMS,
11
+ * DASH_LOCATIONS, etc. in `scenarios.ts`) will fail the cube-sweep
12
+ * test until a decision is made here or in a family file.
13
+ *
14
+ * Call ordering invariant: this module MUST be imported before
15
+ * `sweepCube()` runs. Family-test files can import-and-register after
16
+ * this module runs — registration wins.
17
+ */
18
+ import { enumerateCube, skip, type ScenarioCell } from "./scenarios.js";
19
+
20
+ /**
21
+ * Classify why a given cell is not interesting / not reachable in
22
+ * practice. Returns null when the cell is plausible and should be
23
+ * covered by a family file — such cells get a generic placeholder
24
+ * skip reason here so the cube sweep passes on day 1; families
25
+ * replace the skip with registration as they land.
26
+ */
27
+ function skipReasonFor(cell: ScenarioCell): string {
28
+ // ── Combinations that are not real install layouts ────────────────
29
+
30
+ // AppImage tmp mount is Linux-only, and only occurs when dash ===
31
+ // "electron" via an AppImage package.
32
+ if (cell.pi === "appimage-tmp" && (cell.platform !== "linux" || cell.dash !== "electron")) {
33
+ return "appimage-tmp is Linux + electron only";
34
+ }
35
+
36
+ // "dev" dash implies a workspace checkout — only meaningful on
37
+ // developer machines (typically mac/linux). Windows dev happens but
38
+ // is rare; capture later if needed.
39
+ if (cell.dash === "dev" && cell.platform === "win32") {
40
+ return "dev monorepo on Windows — rare; capture if reported";
41
+ }
42
+
43
+ // "home-drift" env only meaningful on Windows (Git Bash sets $HOME
44
+ // differently from os.homedir). On posix, $HOME and os.homedir
45
+ // agree.
46
+ if (cell.env === "home-drift" && cell.platform !== "win32") {
47
+ return "home-drift is a Windows-specific phenomenon";
48
+ }
49
+
50
+ // Malformed settings.json doesn't depend on pi state — the parse
51
+ // failure happens regardless. One test per platform is enough.
52
+ if (cell.settings === "malformed" && cell.pi !== "present-valid") {
53
+ return "malformed settings: one pi state per platform is sufficient";
54
+ }
55
+
56
+ // Appimage + anything other than settings=missing is not a real
57
+ // first-run scenario (appimage is installed fresh).
58
+ if (cell.pi === "appimage-tmp" && cell.settings !== "missing") {
59
+ return "appimage fresh-run implies missing settings.json";
60
+ }
61
+
62
+ // spaces-unicode env is orthogonal to most axes — one scenario per
63
+ // platform proves the invariant. Skip most combinations.
64
+ if (cell.env === "spaces-unicode" && cell.pi !== "present-valid") {
65
+ return "spaces-unicode: covered once per platform via pi=present-valid";
66
+ }
67
+
68
+ // ── Refined skip reasons (post families B-K) ──────────────────────
69
+
70
+ // Dashboard-absent is only interesting when pi is present (K1).
71
+ // Other combinations — pi absent without dashboard, etc. — are
72
+ // pathological and not reachable by any real install mechanic.
73
+ if (cell.dash === "absent" && cell.pi !== "present-valid") {
74
+ return "dashboard-absent only meaningful when pi is present (K1)";
75
+ }
76
+
77
+ // Lock-file and instance-coordination cells land with proposal
78
+ // `single-dashboard-per-home`, which introduces a new axis (lock
79
+ // state) not modelled in this cube. The current cells remain
80
+ // skipped until that proposal extends the enumeration.
81
+
82
+ // TODO(unified-bootstrap-install): add three scenarios once this
83
+ // change's task 13 wires the bootstrap through the home-lock:
84
+ // - B1-post: "npm-g-dash-only + bootstrap install ran→ pi resolves
85
+ // via managed + bridge auto-registered". Requires
86
+ // extending the cube with a `bootstrap?: "pre"|"post"`
87
+ // axis or a new pi state `managed-installed-fresh`.
88
+ // - B4: "pi-dashboard installed via npm-g, extension resolves
89
+ // via node_modules lookup (findBundledExtension
90
+ // Strategy 2)". Requires a new `pi-ext-resolution`
91
+ // axis.
92
+ // - L2: "two simultaneous bootstraps" (requires lock from
93
+ // single-dashboard-per-home to be wired).
94
+ // Reason skipped today: requires lock from single-dashboard-per-home
95
+ // to be wired AND the cube to grow new axes. Track in harness
96
+ // follow-up. See change: unified-bootstrap-install tasks 2.3, 10.2,
97
+ // 11.2, 13.2.
98
+
99
+ // pi=malformed means partial install; resolution failure reason
100
+ // is identical across settings/env axes once pi state is fixed.
101
+ // E2 covers linux + win32; other combinations add no signal.
102
+ if (
103
+ cell.pi === "malformed"
104
+ && cell.settings !== "empty"
105
+ && !(cell.platform === "linux" || cell.platform === "win32")
106
+ ) {
107
+ return "malformed pi: E2 covers linux+win32 — other settings/env add no signal";
108
+ }
109
+
110
+ // Remaining cells are plausible combinations with no dedicated
111
+ // family test. Documented as deliberate skips rather than gaps.
112
+ return "not yet covered — add family coverage when a bug reports here";
113
+ }
114
+
115
+ /**
116
+ * Apply bulk skips. Called at module load.
117
+ */
118
+ export function applyBulkSkips(): void {
119
+ for (const cell of enumerateCube()) {
120
+ skip(cell, skipReasonFor(cell));
121
+ }
122
+ }
123
+
124
+ // Apply on import so scenario consumers see the full manifest.
125
+ applyBulkSkips();
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Scenario registry — the authoritative record of which cells are
3
+ * tested, which are explicitly skipped (with a reason), and which
4
+ * are unknown (fail-closed on CI).
5
+ *
6
+ * See openspec/changes/bootstrap-resolution-harness/design.md §5.
7
+ */
8
+
9
+ /** The canonical axes for the scenario cube. */
10
+ export const PLATFORMS = ["win32", "darwin", "linux"] as const;
11
+ export const DASH_LOCATIONS = ["electron", "npm-g", "dev", "managed", "absent"] as const;
12
+ export const PI_STATES = [
13
+ "absent",
14
+ "present-no-ext",
15
+ "present-stale-ext",
16
+ "present-valid",
17
+ "malformed",
18
+ "appimage-tmp",
19
+ ] as const;
20
+ export const SETTINGS_STATES = ["missing", "empty", "valid", "malformed"] as const;
21
+ export const ENV_STATES = ["normal", "spaces-unicode", "home-drift"] as const;
22
+
23
+ export type Platform = (typeof PLATFORMS)[number];
24
+ export type DashLocation = (typeof DASH_LOCATIONS)[number];
25
+ export type PiState = (typeof PI_STATES)[number];
26
+ export type SettingsState = (typeof SETTINGS_STATES)[number];
27
+ export type EnvState = (typeof ENV_STATES)[number];
28
+
29
+ export interface ScenarioCell {
30
+ platform: Platform;
31
+ dash: DashLocation;
32
+ pi: PiState;
33
+ settings: SettingsState;
34
+ env: EnvState;
35
+ }
36
+
37
+ export function cellKey(cell: ScenarioCell): string {
38
+ return [cell.platform, cell.dash, cell.pi, cell.settings, cell.env].join("/");
39
+ }
40
+
41
+ /** Parse a cell-key back into its components. */
42
+ export function parseCellKey(key: string): ScenarioCell | null {
43
+ const parts = key.split("/");
44
+ if (parts.length !== 5) return null;
45
+ const [platform, dash, pi, settings, env] = parts;
46
+ if (!PLATFORMS.includes(platform as Platform)) return null;
47
+ if (!DASH_LOCATIONS.includes(dash as DashLocation)) return null;
48
+ if (!PI_STATES.includes(pi as PiState)) return null;
49
+ if (!SETTINGS_STATES.includes(settings as SettingsState)) return null;
50
+ if (!ENV_STATES.includes(env as EnvState)) return null;
51
+ return {
52
+ platform: platform as Platform,
53
+ dash: dash as DashLocation,
54
+ pi: pi as PiState,
55
+ settings: settings as SettingsState,
56
+ env: env as EnvState,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Cells with an active test. Entries added by each family-test file
62
+ * via `register(cell, describeSymbol)`.
63
+ *
64
+ * The value is a string tag identifying the test file — useful for
65
+ * the cube-sweep error message ("cell X already covered by Y").
66
+ */
67
+ export const REGISTERED_SCENARIOS = new Map<string, string>();
68
+
69
+ /**
70
+ * Cells explicitly skipped with a documented reason. New cells added
71
+ * to the enumeration that don't land in REGISTERED or SKIPPED will
72
+ * fail CI (the fail-closed invariant).
73
+ *
74
+ * Format: key → human-readable reason.
75
+ */
76
+ export const SKIPPED_SCENARIOS = new Map<string, string>();
77
+
78
+ /** Register a tested cell. Last-registered wins (tests may re-register during dev). */
79
+ export function register(cell: ScenarioCell, tag: string): void {
80
+ REGISTERED_SCENARIOS.set(cellKey(cell), tag);
81
+ }
82
+
83
+ /** Skip a cell with a reason. Required before CI accepts the new cell. */
84
+ export function skip(cell: ScenarioCell, reason: string): void {
85
+ if (!reason || reason.trim().length === 0) {
86
+ throw new Error(`SKIPPED_SCENARIOS entry for ${cellKey(cell)} requires a non-empty reason`);
87
+ }
88
+ SKIPPED_SCENARIOS.set(cellKey(cell), reason);
89
+ }
90
+
91
+ /** Mass-skip helper: all cells matching a partial pattern. */
92
+ export function skipPattern(
93
+ partial: Partial<ScenarioCell>,
94
+ reason: string,
95
+ ): void {
96
+ for (const cell of enumerateCube()) {
97
+ let match = true;
98
+ for (const k of Object.keys(partial) as (keyof ScenarioCell)[]) {
99
+ if (cell[k] !== partial[k]) {
100
+ match = false;
101
+ break;
102
+ }
103
+ }
104
+ if (match) skip(cell, reason);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Enumerate every combination. Order is stable (useful for snapshots
110
+ * and for deterministic error messages).
111
+ */
112
+ export function enumerateCube(): ScenarioCell[] {
113
+ const out: ScenarioCell[] = [];
114
+ for (const platform of PLATFORMS) {
115
+ for (const dash of DASH_LOCATIONS) {
116
+ for (const pi of PI_STATES) {
117
+ for (const settings of SETTINGS_STATES) {
118
+ for (const env of ENV_STATES) {
119
+ out.push({ platform, dash, pi, settings, env });
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Reset state — used only by tests that want to verify the cube logic itself. */
129
+ export function __resetScenariosForTesting(): void {
130
+ REGISTERED_SCENARIOS.clear();
131
+ SKIPPED_SCENARIOS.clear();
132
+ }