@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,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,108 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/commands.ts.
3
+ * Platform behavior exercised via injected `platform` + `exec` / `asyncExec`.
4
+ * See change: consolidate-platform-handlers.
5
+ */
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import { openBrowser, isVirtualMachine } from "../platform/commands.js";
8
+
9
+ describe("openBrowser", () => {
10
+ it("uses `open` on macOS", () => {
11
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(null));
12
+ openBrowser("https://example.com", { platform: "darwin", asyncExec });
13
+ expect(asyncExec).toHaveBeenCalledOnce();
14
+ expect(asyncExec.mock.calls[0][0]).toMatch(/^open\s+"https:\/\/example\.com"$/);
15
+ });
16
+
17
+ it("uses `start` on Windows", () => {
18
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(null));
19
+ openBrowser("https://example.com", { platform: "win32", asyncExec });
20
+ expect(asyncExec.mock.calls[0][0]).toMatch(/^start\s+""\s+"https:\/\/example\.com"$/);
21
+ });
22
+
23
+ it("uses `xdg-open` on Linux", () => {
24
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(null));
25
+ openBrowser("https://example.com", { platform: "linux", asyncExec });
26
+ expect(asyncExec.mock.calls[0][0]).toMatch(/^xdg-open\s+"https:\/\/example\.com"$/);
27
+ });
28
+
29
+ it("escapes URLs via JSON.stringify (quotes, newlines, backslashes)", () => {
30
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(null));
31
+ openBrowser('https://example.com/?q="escaped"', { platform: "linux", asyncExec });
32
+ // JSON.stringify converts " → \"
33
+ expect(asyncExec.mock.calls[0][0]).toContain('\\"escaped\\"');
34
+ });
35
+
36
+ it("invokes onError callback when async exec fails", () => {
37
+ const err = new Error("nope");
38
+ const onError = vi.fn();
39
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(err));
40
+ openBrowser("https://example.com", { platform: "linux", asyncExec, onError });
41
+ expect(onError).toHaveBeenCalledWith(err);
42
+ });
43
+
44
+ it("does not throw when onError is absent", () => {
45
+ const err = new Error("nope");
46
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(err));
47
+ expect(() =>
48
+ openBrowser("https://example.com", { platform: "linux", asyncExec }),
49
+ ).not.toThrow();
50
+ });
51
+ });
52
+
53
+ describe("isVirtualMachine", () => {
54
+ it("detects VMware via sysctl on macOS", () => {
55
+ const exec = vi.fn().mockReturnValue("VMware7,1\n");
56
+ expect(isVirtualMachine({ platform: "darwin", exec })).toBe(true);
57
+ });
58
+
59
+ it("detects VirtualBox via sysctl on macOS", () => {
60
+ const exec = vi.fn().mockReturnValue("VirtualBox6,0\n");
61
+ expect(isVirtualMachine({ platform: "darwin", exec })).toBe(true);
62
+ });
63
+
64
+ it("returns false on physical macOS hardware", () => {
65
+ const exec = vi.fn().mockReturnValue("MacBookPro18,3\n");
66
+ expect(isVirtualMachine({ platform: "darwin", exec })).toBe(false);
67
+ });
68
+
69
+ it("detects VM via systemd-detect-virt on Linux", () => {
70
+ const exec = vi.fn().mockReturnValue("kvm\n");
71
+ expect(isVirtualMachine({ platform: "linux", exec })).toBe(true);
72
+ });
73
+
74
+ it("returns false on bare-metal Linux", () => {
75
+ const exec = vi.fn().mockReturnValue("none\n");
76
+ expect(isVirtualMachine({ platform: "linux", exec })).toBe(false);
77
+ });
78
+
79
+ it("detects VMware via wmic on Windows", () => {
80
+ const exec = vi.fn().mockImplementation((cmd: string) => {
81
+ if (cmd.includes("bios")) return "SerialNumber\nVMware-42 AA BB\n";
82
+ return "";
83
+ });
84
+ expect(isVirtualMachine({ platform: "win32", exec })).toBe(true);
85
+ });
86
+
87
+ it("detects Hyper-V via wmic computersystem on Windows", () => {
88
+ const exec = vi.fn().mockImplementation((cmd: string) => {
89
+ if (cmd.includes("bios")) throw new Error("no serial");
90
+ if (cmd.includes("computersystem")) return "Manufacturer Model\nMicrosoft Corporation Virtual Machine\n";
91
+ return "";
92
+ });
93
+ expect(isVirtualMachine({ platform: "win32", exec })).toBe(true);
94
+ });
95
+
96
+ it("returns false on physical Windows when no VM markers found", () => {
97
+ const exec = vi.fn().mockReturnValue("SerialNumber\nR90ABCDE\n");
98
+ expect(isVirtualMachine({ platform: "win32", exec })).toBe(false);
99
+ });
100
+
101
+ it("returns false when exec throws unexpectedly", () => {
102
+ const exec = vi.fn().mockImplementation(() => {
103
+ throw new Error("boom");
104
+ });
105
+ expect(isVirtualMachine({ platform: "darwin", exec })).toBe(false);
106
+ expect(isVirtualMachine({ platform: "linux", exec })).toBe(false);
107
+ });
108
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/exec.ts — the thin wrapper over
3
+ * node:child_process that sets `windowsHide: true` by default.
4
+ *
5
+ * These tests assert the *option passthrough* contract; they do not spawn
6
+ * real subprocesses. The wrapper is a few lines per function — its only
7
+ * job is to forward arguments with `windowsHide: true` layered on top.
8
+ *
9
+ * See change: platform-command-executor.
10
+ */
11
+ import { describe, it, expect } from "vitest";
12
+ import { execSync, spawn, spawnSync, exec, execFile } from "../platform/exec.js";
13
+
14
+ describe("platform/exec wrappers", () => {
15
+ // ── execSync ────────────────────────────────────────────────────────────
16
+ // Real invocation: we pick commands that exit 0 on every OS (node itself).
17
+
18
+ it("execSync exits 0 for `node --version`", () => {
19
+ const out = execSync(`"${process.execPath}" --version`, { encoding: "utf-8" });
20
+ expect(String(out).trim()).toMatch(/^v\d+\.\d+\.\d+/);
21
+ });
22
+
23
+ // ── spawnSync ───────────────────────────────────────────────────────────
24
+
25
+ it("spawnSync runs `node --version` and captures stdout", () => {
26
+ const result = spawnSync(process.execPath, ["--version"], { encoding: "utf-8" });
27
+ expect(result.status).toBe(0);
28
+ expect(String(result.stdout).trim()).toMatch(/^v\d+\.\d+\.\d+/);
29
+ });
30
+
31
+ it("spawnSync accepts undefined args and defaults to []", () => {
32
+ // Should not throw — wrapper must coerce undefined args to []
33
+ const result = spawnSync(process.execPath, undefined, {
34
+ encoding: "utf-8",
35
+ input: "process.stdout.write('ok')",
36
+ });
37
+ // May or may not work depending on shell, but the call itself must not throw.
38
+ expect(typeof result).toBe("object");
39
+ });
40
+
41
+ // ── windowsHide default ─────────────────────────────────────────────────
42
+
43
+ // The key invariant: wrappers set windowsHide: true when caller omits it.
44
+ // We verify this by inspecting the spawn metadata (spawnargs / opts).
45
+ // Node doesn't expose the final options object, so we check by spawning
46
+ // with a non-overridden call and verifying it completes successfully
47
+ // (a misconfigured windowsHide would not change functional behavior,
48
+ // so the real assertion is in D10 below via source inspection).
49
+
50
+ it("spawn returns a ChildProcess object", async () => {
51
+ const child = spawn(process.execPath, ["--version"]);
52
+ expect(child.pid).toBeGreaterThan(0);
53
+ await new Promise<void>((resolve) => {
54
+ child.on("exit", () => resolve());
55
+ });
56
+ });
57
+
58
+ // ── exec (callback form) ────────────────────────────────────────────────
59
+
60
+ it("exec(cmd, cb) invokes callback with stdout", async () => {
61
+ const out = await new Promise<string>((resolve, reject) => {
62
+ exec(`"${process.execPath}" --version`, (err, stdout) => {
63
+ if (err) reject(err);
64
+ else resolve(String(stdout));
65
+ });
66
+ });
67
+ expect(out.trim()).toMatch(/^v\d+\.\d+\.\d+/);
68
+ });
69
+
70
+ // ── execFile ────────────────────────────────────────────────────────────
71
+
72
+ it("execFile(file, args, cb) works", async () => {
73
+ const out = await new Promise<string>((resolve, reject) => {
74
+ execFile(process.execPath, ["--version"], (err, stdout) => {
75
+ if (err) reject(err);
76
+ else resolve(String(stdout));
77
+ });
78
+ });
79
+ expect(out.trim()).toMatch(/^v\d+\.\d+\.\d+/);
80
+ });
81
+ });
82
+
83
+ describe("platform/exec — windowsHide default (source-level assertion)", () => {
84
+ // Since Node doesn't expose the spawn options after the call, we verify
85
+ // the windowsHide default by reading the wrapper source and asserting
86
+ // that every public export merges `windowsHide: true` into its options.
87
+ //
88
+ // This catches refactors that accidentally drop the default.
89
+
90
+ it("exec.ts source sets windowsHide: true by default", async () => {
91
+ const fs = await import("node:fs/promises");
92
+ const path = await import("node:path");
93
+ const url = await import("node:url");
94
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
95
+ const src = await fs.readFile(path.resolve(here, "../platform/exec.ts"), "utf-8");
96
+
97
+ // Must define a withHide helper and apply it uniformly.
98
+ expect(src).toMatch(/windowsHide\??:\s*boolean/);
99
+ expect(src).toMatch(/windowsHide:\s*hide/);
100
+ // Default must be true (not false) when caller omits it.
101
+ expect(src).toMatch(/opts\?\.windowsHide\s*\?\?\s*true/);
102
+ });
103
+ });