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

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 (216) hide show
  1. package/AGENTS.md +87 -114
  2. package/README.md +408 -430
  3. package/docs/architecture.md +465 -12
  4. package/package.json +10 -5
  5. package/packages/extension/package.json +14 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  14. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  15. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  16. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  17. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  18. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  19. package/packages/extension/src/ask-user-tool.ts +5 -4
  20. package/packages/extension/src/bridge.ts +171 -17
  21. package/packages/extension/src/dev-build.ts +1 -1
  22. package/packages/extension/src/git-info.ts +9 -19
  23. package/packages/extension/src/multiselect-list.ts +146 -0
  24. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  25. package/packages/extension/src/pi-env.d.ts +1 -0
  26. package/packages/extension/src/process-scanner.ts +72 -38
  27. package/packages/extension/src/provider-register.ts +304 -16
  28. package/packages/extension/src/server-auto-start.ts +27 -1
  29. package/packages/extension/src/server-launcher.ts +83 -27
  30. package/packages/server/package.json +16 -2
  31. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  32. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  33. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  34. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  35. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  36. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  37. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  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-registry.test.ts +28 -15
  41. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  42. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  43. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  44. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  45. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  46. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  47. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  48. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  49. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  51. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  52. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  53. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  54. package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
  55. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  56. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  57. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  58. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  59. package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
  60. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  61. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  62. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  63. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  64. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  65. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  66. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  67. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  68. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  69. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  70. package/packages/server/src/bootstrap-queue.ts +130 -0
  71. package/packages/server/src/bootstrap-state.ts +131 -0
  72. package/packages/server/src/browse.ts +8 -3
  73. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  74. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  75. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  76. package/packages/server/src/cli.ts +310 -39
  77. package/packages/server/src/config-api.ts +16 -0
  78. package/packages/server/src/directory-service.ts +270 -39
  79. package/packages/server/src/editor-detection.ts +12 -9
  80. package/packages/server/src/editor-manager.ts +19 -4
  81. package/packages/server/src/editor-pid-registry.ts +9 -8
  82. package/packages/server/src/editor-registry.ts +22 -25
  83. package/packages/server/src/git-operations.ts +1 -1
  84. package/packages/server/src/headless-pid-registry.ts +7 -20
  85. package/packages/server/src/home-lock-release.ts +72 -0
  86. package/packages/server/src/home-lock.ts +389 -0
  87. package/packages/server/src/node-guard.ts +52 -0
  88. package/packages/server/src/package-manager-wrapper.ts +207 -47
  89. package/packages/server/src/pi-core-checker.ts +1 -1
  90. package/packages/server/src/pi-core-updater.ts +7 -1
  91. package/packages/server/src/pi-resource-scanner.ts +5 -8
  92. package/packages/server/src/pi-version-skew.ts +207 -0
  93. package/packages/server/src/preferences-store.ts +17 -3
  94. package/packages/server/src/process-manager.ts +403 -222
  95. package/packages/server/src/provider-probe.ts +234 -0
  96. package/packages/server/src/restart-helper.ts +141 -0
  97. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  98. package/packages/server/src/routes/openspec-routes.ts +25 -1
  99. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  100. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  101. package/packages/server/src/routes/provider-routes.ts +43 -0
  102. package/packages/server/src/routes/recommended-routes.ts +10 -12
  103. package/packages/server/src/routes/system-routes.ts +20 -33
  104. package/packages/server/src/routes/tool-routes.ts +153 -0
  105. package/packages/server/src/server-pid.ts +5 -9
  106. package/packages/server/src/server.ts +211 -10
  107. package/packages/server/src/session-api.ts +77 -8
  108. package/packages/server/src/session-bootstrap.ts +17 -3
  109. package/packages/server/src/session-diff.ts +21 -21
  110. package/packages/server/src/terminal-manager.ts +61 -20
  111. package/packages/server/src/tunnel.ts +42 -28
  112. package/packages/shared/package.json +10 -3
  113. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  114. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  115. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  116. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  117. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  118. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  129. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  130. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  131. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  132. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  133. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  134. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  135. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  137. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  138. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  139. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  140. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  141. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  142. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  143. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  144. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  145. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  146. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  147. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  148. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  149. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  150. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  151. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  152. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  153. package/packages/shared/src/__tests__/config.test.ts +56 -0
  154. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  155. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  156. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  157. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  158. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  159. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  160. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  161. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  162. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  163. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  164. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  165. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  166. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  167. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  168. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  169. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  170. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  171. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  172. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  173. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  174. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  175. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  176. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  177. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  178. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  179. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  180. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  181. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  182. package/packages/shared/src/bootstrap-install.ts +212 -0
  183. package/packages/shared/src/bridge-register.ts +87 -20
  184. package/packages/shared/src/browser-protocol.ts +71 -1
  185. package/packages/shared/src/config.ts +87 -15
  186. package/packages/shared/src/managed-paths.ts +31 -4
  187. package/packages/shared/src/openspec-poller.ts +63 -46
  188. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  189. package/packages/shared/src/platform/commands.ts +100 -0
  190. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  191. package/packages/shared/src/platform/exec.ts +220 -0
  192. package/packages/shared/src/platform/git.ts +155 -0
  193. package/packages/shared/src/platform/index.ts +16 -0
  194. package/packages/shared/src/platform/node-spawn.ts +154 -0
  195. package/packages/shared/src/platform/npm.ts +162 -0
  196. package/packages/shared/src/platform/openspec.ts +91 -0
  197. package/packages/shared/src/platform/paths.ts +276 -0
  198. package/packages/shared/src/platform/process-identify.ts +126 -0
  199. package/packages/shared/src/platform/process-scan.ts +94 -0
  200. package/packages/shared/src/platform/process.ts +168 -0
  201. package/packages/shared/src/platform/runner.ts +369 -0
  202. package/packages/shared/src/platform/shell.ts +44 -0
  203. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  204. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  205. package/packages/shared/src/protocol.ts +23 -0
  206. package/packages/shared/src/recommended-extensions.ts +18 -2
  207. package/packages/shared/src/resolve-jiti.ts +62 -3
  208. package/packages/shared/src/rest-api.ts +26 -0
  209. package/packages/shared/src/semaphore.ts +83 -0
  210. package/packages/shared/src/state-replay.ts +9 -0
  211. package/packages/shared/src/tool-registry/definitions.ts +434 -0
  212. package/packages/shared/src/tool-registry/index.ts +56 -0
  213. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  214. package/packages/shared/src/tool-registry/registry.ts +262 -0
  215. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  216. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Repo-level invariant: `process.platform === "<os>"` branches (and the
3
+ * inverse `!==` form) MUST NOT appear outside the canonical platform
4
+ * primitive locations. All OS-specific behaviour lives under
5
+ * `packages/shared/src/platform/**` (and `packages/electron/src/platform/**`
6
+ * for Electron-specific primitives) plus a small documented allowlist.
7
+ *
8
+ * If this test fails, either:
9
+ * (a) Move the platform-aware logic into a platform/* primitive that
10
+ * takes an optional `platform: NodeJS.Platform` parameter, OR
11
+ * (b) Add an opt-out marker `// platform-branch-ok` on the same line
12
+ * for a genuine, localised OS probe (e.g. a top-level env fingerprint).
13
+ *
14
+ * See change: consolidate-windows-spawn-and-platform-handlers.
15
+ */
16
+ import { describe, it, expect } from "vitest";
17
+ import fs from "node:fs/promises";
18
+ import path from "node:path";
19
+ import url from "node:url";
20
+
21
+ /**
22
+ * Files / directory-prefixes where platform branches are allowed.
23
+ *
24
+ * Each entry is a repo-relative path using forward slashes. Entries
25
+ * ending in `/` match any file under that directory (prefix match);
26
+ * entries without a trailing slash must match exactly.
27
+ *
28
+ * Every entry has a one-line reason and a follow-up owner.
29
+ */
30
+ const ALLOWLIST: readonly string[] = [
31
+ // Canonical platform primitives — the whole POINT is platform branching.
32
+ "packages/shared/src/platform/",
33
+ // Electron-specific platform primitives.
34
+ "packages/electron/src/platform/",
35
+
36
+ // ── Seed allowlist (documented follow-ups, out of scope for this change)
37
+
38
+ // Extension's pgid/ps scanner — platform-aware but uses `_platform` test
39
+ // hooks rather than shared primitives; consolidating into
40
+ // shared/platform/process-scan.ts is a separate change.
41
+ "packages/extension/src/process-scanner.ts",
42
+
43
+ // Electron dependency detection predates the tool-registry; migration
44
+ // to ToolRegistry.resolve is a separate change.
45
+ "packages/electron/src/lib/dependency-detector.ts",
46
+
47
+ // Electron top-level bootstrap: process.platform printed in log output,
48
+ // legitimate observability use.
49
+ "packages/electron/src/main.ts",
50
+
51
+ // Electron doctor: reports process.platform to the user.
52
+ "packages/electron/src/lib/doctor.ts",
53
+
54
+ // Electron forge config: build-time darwin special-case.
55
+ "packages/electron/forge.config.ts",
56
+
57
+ // Server process-manager: one domain branch in spawnHeadless picking
58
+ // Unix "sh -c tail -f" wrapper vs Windows direct node.exe spawn.
59
+ // The wrapper is genuinely Unix-only (sh+tail); splitting the headless
60
+ // mechanism into two is tracked as a follow-up.
61
+ "packages/server/src/process-manager.ts",
62
+
63
+ // Server editor registry: selects per-OS process patterns from a data
64
+ // table. Genuine data-lookup branching, benign.
65
+ "packages/server/src/editor-registry.ts",
66
+
67
+ // Server tunnel: surfaces process.platform in a response body.
68
+ "packages/server/src/tunnel.ts",
69
+
70
+ // Server browse: returns process.platform in BrowseResult for the
71
+ // client path-picker (protocol surface).
72
+ "packages/server/src/browse.ts",
73
+
74
+ // Client session-grouping: reads process.platform in a comment-only
75
+ // doc reference and uses inferPlatform heuristic; no actual branch.
76
+ "packages/client/src/lib/session-grouping.ts",
77
+
78
+ // ── Follow-up: migrate to electron/src/platform/ per deferred
79
+ // consolidate-platform-handlers (18→13 file refactor).
80
+
81
+ // App menu: darwin detection for role:appMenu (Electron convention).
82
+ "packages/electron/src/lib/app-menu.ts",
83
+ // Bundled node: win32 binary name suffix; data-lookup branch.
84
+ "packages/electron/src/lib/bundled-node.ts",
85
+ // Server lifecycle: win32 managed-tsx.cmd + which/where probes.
86
+ "packages/electron/src/lib/server-lifecycle.ts",
87
+ // Tray icon: platform-specific asset selection; will move to
88
+ // electron/src/platform/tray-icon.ts in deferred consolidation.
89
+ "packages/electron/src/lib/tray.ts",
90
+
91
+ // Server editor PID registry: per-OS process pattern matching for
92
+ // orphan detection on boot. Genuine data-table branching.
93
+ "packages/server/src/editor-pid-registry.ts",
94
+ // Electron dependency installer: Windows npm is npm.cmd (batch wrapper);
95
+ // spawn('npm') without .cmd extension fails ENOENT on Windows. The branch
96
+ // routes around this by preferring bundled node+npm-cli.js on Windows.
97
+ // Follow-up: migrate to a platform/exec npm-resolver primitive.
98
+ "packages/electron/src/lib/dependency-installer.ts",
99
+ // fix-pty-permissions: Windows short-circuit (no chmod needed).
100
+ "packages/server/src/fix-pty-permissions.ts",
101
+ // package-manager-wrapper: comment-only reference; no runtime branch.
102
+ "packages/server/src/package-manager-wrapper.ts",
103
+ // terminal-manager: win32 branch for node-pty spawnOptions; will move
104
+ // to platform/terminal in deferred consolidation.
105
+ "packages/server/src/terminal-manager.ts",
106
+ ];
107
+
108
+ const PLATFORM_BRANCH_RE = /process\.platform\s*(===|!==)\s*["'](win32|linux|darwin)["']/;
109
+
110
+ const OPT_OUT_MARKER = "platform-branch-ok";
111
+
112
+ async function* walk(dir: string): AsyncGenerator<string> {
113
+ const entries = await fs.readdir(dir, { withFileTypes: true });
114
+ for (const entry of entries) {
115
+ const full = path.join(dir, entry.name);
116
+ if (entry.isDirectory()) {
117
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
118
+ yield* walk(full);
119
+ } else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
120
+ yield full;
121
+ }
122
+ }
123
+ }
124
+
125
+ /** Check if a repo-relative normalised path is covered by the allowlist. */
126
+ function isAllowed(relPath: string, allow: readonly string[]): boolean {
127
+ for (const entry of allow) {
128
+ if (entry.endsWith("/")) {
129
+ if (relPath.startsWith(entry)) return true;
130
+ } else {
131
+ if (relPath === entry) return true;
132
+ }
133
+ }
134
+ return false;
135
+ }
136
+
137
+ describe("no direct process.platform branches outside platform/**", () => {
138
+ it("only allowlisted files contain process.platform === \"<os>\" branches", async () => {
139
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
140
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
141
+ const packagesDir = path.resolve(repoRoot, "packages");
142
+
143
+ const violations: Array<{ file: string; line: number; text: string }> = [];
144
+
145
+ for (const pkg of await fs.readdir(packagesDir, { withFileTypes: true })) {
146
+ if (!pkg.isDirectory()) continue;
147
+ const srcDir = path.join(packagesDir, pkg.name, "src");
148
+ try { await fs.access(srcDir); } catch { continue; }
149
+
150
+ for await (const file of walk(srcDir)) {
151
+ const relPath = path.relative(repoRoot, file).replace(/\\/g, "/");
152
+ if (isAllowed(relPath, ALLOWLIST)) continue;
153
+
154
+ const content = await fs.readFile(file, "utf-8");
155
+ const lines = content.split(/\r?\n/);
156
+ lines.forEach((line, idx) => {
157
+ if (!PLATFORM_BRANCH_RE.test(line)) return;
158
+ if (line.includes(OPT_OUT_MARKER)) return;
159
+ violations.push({ file: relPath, line: idx + 1, text: line.trim() });
160
+ });
161
+ }
162
+ }
163
+
164
+ if (violations.length > 0) {
165
+ const msg =
166
+ `Direct process.platform branches found outside the allowlist.\n` +
167
+ `Move the logic into a platform/* primitive or add a ` +
168
+ `\`// ${OPT_OUT_MARKER}\` comment on the line with a justification.\n\n` +
169
+ `Offenders (${violations.length}):\n` +
170
+ violations.map((v) => ` ${v.file}:${v.line} ${v.text}`).join("\n");
171
+ expect(violations, msg).toEqual([]);
172
+ }
173
+ });
174
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Repo-level invariant: `process.kill(...)` MUST NOT be called directly
3
+ * outside `packages/shared/src/platform/`. All termination / liveness must
4
+ * go through the platform helpers (`isProcessAlive`, `killProcess`,
5
+ * `killPidWithGroup`) so that Windows tree-kill (taskkill /F /T /PID) and
6
+ * POSIX process-group semantics are applied uniformly.
7
+ *
8
+ * If this test fails, migrate the offending file's call to:
9
+ * import { isProcessAlive, killProcess, killPidWithGroup }
10
+ * from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
11
+ *
12
+ * See change: route-kill-paths-through-platform.
13
+ */
14
+ import { describe, it, expect } from "vitest";
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import url from "node:url";
18
+
19
+ /** Files or directories allowed to call `process.kill(...)` directly. */
20
+ const ALLOWLIST_DIRS: readonly string[] = [
21
+ "packages/shared/src/platform",
22
+ ];
23
+
24
+ /**
25
+ * Regex catches any textual reference to `process.kill(...)`. We match on
26
+ * whole-word `process` to avoid flagging `childProcess.kill(...)`, which
27
+ * is the `ChildProcess#kill()` instance method, not the global
28
+ * `process.kill`. Calls on `ChildProcess` instances are banned separately
29
+ * via code review / type-guided refactors, not this lint.
30
+ */
31
+ const PROCESS_KILL_RE = /(?:^|[^.\w])process\.kill\s*\(/;
32
+
33
+ /**
34
+ * Per-line opt-out marker. Use for embedded scripts that run in a
35
+ * separate Node process (e.g. the `node -e` orchestrator string in
36
+ * restart-helper.ts):
37
+ * const orchestrator = `process.kill(pid, 0);` // ban:process-kill-ok
38
+ */
39
+ const OPT_OUT_MARKER = "ban:process-kill-ok";
40
+
41
+ /** Recursively walk a directory, yielding all .ts / .tsx files. */
42
+ async function* walk(dir: string): AsyncGenerator<string> {
43
+ const entries = await fs.readdir(dir, { withFileTypes: true });
44
+ for (const entry of entries) {
45
+ const full = path.join(dir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
48
+ yield* walk(full);
49
+ } else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
50
+ yield full;
51
+ }
52
+ }
53
+ }
54
+
55
+ describe("no direct process.kill outside packages/shared/src/platform/", () => {
56
+ it("only allowlisted paths call process.kill directly", async () => {
57
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
58
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
59
+ const packagesDir = path.resolve(repoRoot, "packages");
60
+
61
+ const violations: Array<{ file: string; line: number; text: string }> = [];
62
+ const allowPrefixes = ALLOWLIST_DIRS.map((p) =>
63
+ path.resolve(repoRoot, p).replace(/\\/g, "/") + "/",
64
+ );
65
+
66
+ for (const pkg of await fs.readdir(packagesDir, { withFileTypes: true })) {
67
+ if (!pkg.isDirectory()) continue;
68
+ const srcDir = path.join(packagesDir, pkg.name, "src");
69
+ try {
70
+ await fs.access(srcDir);
71
+ } catch {
72
+ continue;
73
+ }
74
+ for await (const file of walk(srcDir)) {
75
+ const normalized = file.replace(/\\/g, "/");
76
+ if (allowPrefixes.some((prefix) => normalized.startsWith(prefix))) continue;
77
+
78
+ const content = await fs.readFile(file, "utf-8");
79
+ const lines = content.split(/\r?\n/);
80
+ lines.forEach((line, idx) => {
81
+ if (!PROCESS_KILL_RE.test(line)) return;
82
+ if (line.includes(OPT_OUT_MARKER)) return;
83
+ violations.push({
84
+ file: path.relative(repoRoot, file),
85
+ line: idx + 1,
86
+ text: line.trim(),
87
+ });
88
+ });
89
+ }
90
+ }
91
+
92
+ if (violations.length > 0) {
93
+ const msg =
94
+ `Direct process.kill(...) calls found outside packages/shared/src/platform/.\n` +
95
+ `Migrate each to a platform helper:\n` +
96
+ ` import { isProcessAlive, killProcess, killPidWithGroup }\n` +
97
+ ` from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";\n\n` +
98
+ `Offenders (${violations.length}):\n` +
99
+ violations
100
+ .map((v) => ` ${v.file}:${v.line} ${v.text}`)
101
+ .join("\n");
102
+ expect(violations, msg).toEqual([]);
103
+ }
104
+ });
105
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Repo-level invariant: build-time scripts (CI workflows, Dockerfiles,
3
+ * shell scripts, root-level CJS helpers) MUST NOT hardcode
4
+ * `node_modules/electron` or `node_modules/node-pty` paths. Instead, they
5
+ * MUST resolve through the tool registry — either via the shared shell
6
+ * wrapper at `packages/shared/bin/pi-dashboard-resolve-tool.cjs`, or
7
+ * (for postinstall paths that run before the shared package is built)
8
+ * via `require.resolve("<pkg>/package.json")` matching the registry's
9
+ * `bare-import` strategy semantics.
10
+ *
11
+ * This invariant exists because npm workspace hoisting moves these
12
+ * packages between `packages/<workspace>/node_modules/<pkg>/` (nested)
13
+ * and `<repoRoot>/node_modules/<pkg>/` (hoisted) depending on the
14
+ * workspaces config and npm version. The v0.4.0 release crisis was
15
+ * caused exactly by this: `cd packages/electron/node_modules/electron`
16
+ * stopped working after `f51e352` switched workspace cross-refs to
17
+ * plain semver.
18
+ *
19
+ * If this test fails, replace the offending substring with one of:
20
+ *
21
+ * # Shell / YAML / Dockerfile (build-time, has access to repo source):
22
+ * ELECTRON_DIR=$(node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron)
23
+ * cd "$ELECTRON_DIR" && ...
24
+ *
25
+ * # CJS root postinstall (runs DURING npm install — must inline):
26
+ * const ptyPkg = require.resolve("node-pty/package.json");
27
+ * const prebuildsDir = path.join(path.dirname(ptyPkg), "prebuilds");
28
+ *
29
+ * See change: register-build-time-tools.
30
+ */
31
+ import { describe, expect, it } from "vitest";
32
+ import fs from "node:fs";
33
+ import path from "node:path";
34
+ import url from "node:url";
35
+
36
+ /** Banned substrings (after comment-stripping). */
37
+ const PATTERNS: readonly { re: RegExp; suggestion: string }[] = [
38
+ {
39
+ re: /node_modules\/electron(?:\/|\b)/,
40
+ suggestion:
41
+ "Use `node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron`",
42
+ },
43
+ {
44
+ re: /node_modules\/node-pty(?:\/|\b)/,
45
+ suggestion:
46
+ 'Use `require.resolve("node-pty/package.json")` (mirrors the registry\'s bare-import strategy)',
47
+ },
48
+ ];
49
+
50
+ /**
51
+ * Files explicitly allowed to contain the banned substrings. Each entry
52
+ * is a repo-relative path matched exactly. Add an entry only when the
53
+ * substring appears as a non-path token (e.g. an argument to
54
+ * `require.resolve`, a comment quoting an example, or this lint file
55
+ * itself). Document the reason inline.
56
+ */
57
+ const ALLOWLIST: readonly string[] = [
58
+ // The lint file itself contains every banned substring as test data.
59
+ "packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts",
60
+ // Root postinstall — uses `require.resolve("node-pty/package.json")`,
61
+ // which contains "node-pty" as an argument string but not as a
62
+ // hardcoded path. Allowlisted because it must run before the shared
63
+ // package is unpacked. See file header for full reasoning.
64
+ "scripts/fix-pty-permissions.cjs",
65
+ // Sister postinstall script (workspace-scoped) — same rationale.
66
+ "packages/server/scripts/fix-pty-permissions.cjs",
67
+ ];
68
+
69
+ /**
70
+ * Repo-relative file list to scan.
71
+ *
72
+ * The scope is intentionally narrow: only the build-time sites that the
73
+ * `register-build-time-tools` change migrated, plus the postinstall
74
+ * scripts that mirror the registry's `bare-import` semantics. Bundle /
75
+ * Docker entrypoint scripts (`bundle-server.sh`, `docker-make.sh`,
76
+ * `test-electron-install-inner.sh`, etc.) are NOT in scope: those
77
+ * operate on a known WORKDIR with deterministic node_modules layout
78
+ * inside the build image and are not affected by host-side hoisting.
79
+ */
80
+ const SCAN_FILES: readonly string[] = [
81
+ ".github/workflows/publish.yml",
82
+ ".github/workflows/ci.yml",
83
+ "packages/electron/scripts/Dockerfile.build",
84
+ "scripts/fix-pty-permissions.cjs",
85
+ "packages/server/scripts/fix-pty-permissions.cjs",
86
+ ];
87
+
88
+ interface Violation {
89
+ file: string;
90
+ line: number;
91
+ col: number;
92
+ text: string;
93
+ suggestion: string;
94
+ }
95
+
96
+ /**
97
+ * Strip a single line's trailing comment for YAML / shell / JS-style
98
+ * line comments. Preserves substring matches inside strings as actual
99
+ * content (we don't try to parse string literals — keeping it simple).
100
+ *
101
+ * Specifically:
102
+ * - `# ...` (YAML, shell): everything from a `#` not preceded by a
103
+ * non-space alphanumeric is dropped. Matches GitHub Actions /
104
+ * bash conventions.
105
+ * - `// ...` (JS): everything from `//` to end of line is dropped.
106
+ *
107
+ * This is intentionally simple. False positives only matter if a banned
108
+ * pattern appears INSIDE a string literal (which would still be the
109
+ * bug we want to catch); false negatives only matter for inline
110
+ * comments (`echo foo # comment node_modules/electron`), which we
111
+ * exclude correctly.
112
+ */
113
+ function stripLineComment(line: string): string {
114
+ // JS-style first.
115
+ const jsIdx = line.indexOf("//");
116
+ if (jsIdx >= 0) line = line.slice(0, jsIdx);
117
+ // Shell/YAML `#` — only when preceded by whitespace or start-of-line.
118
+ const hashMatch = line.match(/(^|\s)#/);
119
+ if (hashMatch && typeof hashMatch.index === "number") {
120
+ line = line.slice(0, hashMatch.index);
121
+ }
122
+ return line;
123
+ }
124
+
125
+ describe("no hardcoded node_modules/<dep> paths in build-time files", () => {
126
+ it("only allowlisted files reference node_modules/electron or node_modules/node-pty", () => {
127
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
128
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
129
+
130
+ const violations: Violation[] = [];
131
+ const allowSet = new Set(
132
+ ALLOWLIST.map((p) => path.resolve(repoRoot, p).replace(/\\/g, "/")),
133
+ );
134
+
135
+ for (const rel of SCAN_FILES) {
136
+ const file = path.resolve(repoRoot, rel);
137
+ if (!fs.existsSync(file)) continue;
138
+ const normalized = file.replace(/\\/g, "/");
139
+ if (allowSet.has(normalized)) continue;
140
+
141
+ const content = fs.readFileSync(file, "utf-8");
142
+ const lines = content.split(/\r?\n/);
143
+
144
+ lines.forEach((rawLine, idx) => {
145
+ const stripped = stripLineComment(rawLine);
146
+ for (const { re, suggestion } of PATTERNS) {
147
+ const m = stripped.match(re);
148
+ if (!m) continue;
149
+ const col = rawLine.indexOf(m[0]);
150
+ violations.push({
151
+ file: path.relative(repoRoot, file),
152
+ line: idx + 1,
153
+ col: col >= 0 ? col + 1 : 1,
154
+ text: rawLine.trim(),
155
+ suggestion,
156
+ });
157
+ }
158
+ });
159
+ }
160
+
161
+ if (violations.length > 0) {
162
+ const msg =
163
+ `Hardcoded \`node_modules/<dep>\` path(s) found in build-time files.\n` +
164
+ `These break under npm workspace hoisting changes (see v0.4.0 release crisis).\n` +
165
+ `Use the tool registry instead. See change: register-build-time-tools.\n\n` +
166
+ `Offenders (${violations.length}):\n` +
167
+ violations
168
+ .map(
169
+ (v) =>
170
+ ` ${v.file}:${v.line}:${v.col} ${v.text}\n → ${v.suggestion}`,
171
+ )
172
+ .join("\n");
173
+ expect(violations, msg).toEqual([]);
174
+ }
175
+ });
176
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Repo-level invariant: any source file that passes an argv to Node
3
+ * with `--import` or `--loader` MUST wrap the following positions
4
+ * (loader and entry script) in `file://` URLs via `toFileUrl(...)` or
5
+ * `pathToFileURL(...).href`. Raw OS paths on Windows drives whose
6
+ * letter collides with URL-scheme parsing (e.g. `B:\`) crash Node with
7
+ * `ERR_UNSUPPORTED_ESM_URL_SCHEME`.
8
+ *
9
+ * If this test fails, migrate the offending file to use
10
+ * `spawnNodeScript` or wrap the entry/loader with `toFileUrl` from
11
+ * `@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js`.
12
+ *
13
+ * See change: fix-windows-entry-script-url.
14
+ */
15
+ import { describe, it, expect } from "vitest";
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ import url from "node:url";
19
+
20
+ /** Files allowed to reference --import / --loader with raw identifiers. */
21
+ const ALLOWLIST: readonly string[] = [
22
+ "packages/shared/src/platform/node-spawn.ts",
23
+ // resolve-jiti.ts returns a file:// URL to callers; it does not itself
24
+ // build a `["--import", X, Y]` argv. Allowlisted as the documented
25
+ // source of loader URLs referenced in server spawn call sites.
26
+ "packages/shared/src/resolve-jiti.ts",
27
+ ];
28
+
29
+ /** Per-line opt-out for intentional usages (e.g. comment examples). */
30
+ const OPT_OUT_MARKER = "ban:raw-node-import-ok";
31
+
32
+ /**
33
+ * Detect argv arrays containing `"--import"` or `"--loader"` followed by
34
+ * a bare identifier (not a string literal and not a wrapped call).
35
+ *
36
+ * We match the argv-literal shape:
37
+ * ["--import", X, Y]
38
+ * args: ["--import", X, Y, ...]
39
+ *
40
+ * Then check that both X and Y are either:
41
+ * - a string literal starting with "file:" (already a URL)
42
+ * - a call expression to toFileUrl(...) or pathToFileURL(...).href
43
+ * - the identifier resolveJitiImport() / resolveJitiFromAnchor() (which
44
+ * are documented to return file:// URLs — allowlisted by name)
45
+ *
46
+ * Anything else is flagged.
47
+ */
48
+ const IMPORT_ARGV_RE =
49
+ /["']--(?:import|loader)["']\s*,\s*([^,\]]+?)\s*,\s*([^,\]]+?)(?:\s*,|\s*\])/g;
50
+
51
+ const URL_LOOKING_RE =
52
+ /^(?:["']file:|toFileUrl\s*\(|pathToFileURL\s*\([^)]*\)\s*\.href|resolveJitiImport\s*\(|resolveJitiFromAnchor\s*\()/;
53
+
54
+ /** Recursively walk a directory, yielding .ts / .tsx files. */
55
+ async function* walk(dir: string): AsyncGenerator<string> {
56
+ const entries = await fs.readdir(dir, { withFileTypes: true });
57
+ for (const entry of entries) {
58
+ const full = path.join(dir, entry.name);
59
+ if (entry.isDirectory()) {
60
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
61
+ yield* walk(full);
62
+ } else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
63
+ yield full;
64
+ }
65
+ }
66
+ }
67
+
68
+ describe("no raw paths passed to node --import / --loader", () => {
69
+ it("only URL-wrapped or allowlisted argv positions follow --import / --loader", async () => {
70
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
71
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
72
+ const packagesDir = path.resolve(repoRoot, "packages");
73
+
74
+ const allowSet = new Set(
75
+ ALLOWLIST.map((p) => path.resolve(repoRoot, p).replace(/\\/g, "/")),
76
+ );
77
+
78
+ const violations: Array<{ file: string; line: number; text: string }> = [];
79
+
80
+ for (const pkg of await fs.readdir(packagesDir, { withFileTypes: true })) {
81
+ if (!pkg.isDirectory()) continue;
82
+ const srcDir = path.join(packagesDir, pkg.name, "src");
83
+ try {
84
+ await fs.access(srcDir);
85
+ } catch {
86
+ continue;
87
+ }
88
+ for await (const file of walk(srcDir)) {
89
+ const normalized = file.replace(/\\/g, "/");
90
+ if (allowSet.has(normalized)) continue;
91
+
92
+ const content = await fs.readFile(file, "utf-8");
93
+ const lines = content.split(/\r?\n/);
94
+
95
+ // Walk each line and check for the argv pattern. Track byte
96
+ // offsets so we can compute line numbers for multi-line matches.
97
+ let offset = 0;
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const line = lines[i]!;
100
+ // Fast path: only inspect lines that mention --import or --loader.
101
+ if (!line.includes("--import") && !line.includes("--loader")) {
102
+ offset += line.length + 1;
103
+ continue;
104
+ }
105
+ if (line.includes(OPT_OUT_MARKER)) {
106
+ offset += line.length + 1;
107
+ continue;
108
+ }
109
+ // Check the current line alone (we allow argv to be on one line;
110
+ // multi-line argv arrays are a rare style and would still trip
111
+ // the quick search above).
112
+ IMPORT_ARGV_RE.lastIndex = 0;
113
+ let m: RegExpExecArray | null;
114
+ while ((m = IMPORT_ARGV_RE.exec(line)) !== null) {
115
+ const loaderArg = m[1]!.trim();
116
+ const entryArg = m[2]!.trim();
117
+ const loaderOk = URL_LOOKING_RE.test(loaderArg);
118
+ const entryOk = URL_LOOKING_RE.test(entryArg);
119
+ if (!loaderOk || !entryOk) {
120
+ violations.push({
121
+ file: path.relative(repoRoot, file),
122
+ line: i + 1,
123
+ text: line.trim(),
124
+ });
125
+ }
126
+ }
127
+ offset += line.length + 1;
128
+ }
129
+ }
130
+ }
131
+
132
+ if (violations.length > 0) {
133
+ const msg =
134
+ `Raw filesystem paths passed to node --import / --loader found.\n` +
135
+ `Migrate each call site to use spawnNodeScript() or wrap the\n` +
136
+ `loader/entry with toFileUrl(...) from:\n` +
137
+ ` import { toFileUrl, spawnNodeScript } from\n` +
138
+ ` "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";\n\n` +
139
+ `Offenders (${violations.length}):\n` +
140
+ violations
141
+ .map((v) => ` ${v.file}:${v.line} ${v.text}`)
142
+ .join("\n");
143
+ expect(violations, msg).toEqual([]);
144
+ }
145
+ });
146
+ });