@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.
- package/AGENTS.md +67 -116
- package/README.md +93 -7
- package/docs/architecture.md +408 -9
- package/package.json +6 -4
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
- package/packages/extension/src/__tests__/git-info.test.ts +67 -55
- package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
- package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
- package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
- package/packages/extension/src/bridge.ts +69 -2
- package/packages/extension/src/dev-build.ts +1 -1
- package/packages/extension/src/git-info.ts +9 -19
- package/packages/extension/src/pi-env.d.ts +1 -0
- package/packages/extension/src/process-scanner.ts +72 -38
- package/packages/extension/src/provider-register.ts +304 -16
- package/packages/extension/src/server-auto-start.ts +27 -1
- package/packages/extension/src/server-launcher.ts +71 -27
- package/packages/server/package.json +16 -2
- package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
- package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
- package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
- package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
- package/packages/server/src/__tests__/config-api.test.ts +68 -0
- package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
- package/packages/server/src/__tests__/directory-service.test.ts +234 -8
- package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
- package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
- package/packages/server/src/__tests__/extension-register.test.ts +3 -1
- package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
- package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
- package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
- package/packages/server/src/__tests__/home-lock.test.ts +308 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
- package/packages/server/src/__tests__/node-guard.test.ts +85 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
- package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
- package/packages/server/src/__tests__/process-manager.test.ts +45 -18
- package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
- package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
- package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
- package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
- package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
- package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
- package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
- package/packages/server/src/__tests__/tunnel.test.ts +13 -7
- package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
- package/packages/server/src/bootstrap-queue.ts +130 -0
- package/packages/server/src/bootstrap-state.ts +131 -0
- package/packages/server/src/browse.ts +8 -3
- package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
- package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
- package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
- package/packages/server/src/cli.ts +256 -32
- package/packages/server/src/config-api.ts +16 -0
- package/packages/server/src/directory-service.ts +270 -39
- package/packages/server/src/editor-detection.ts +12 -9
- package/packages/server/src/editor-manager.ts +19 -4
- package/packages/server/src/editor-pid-registry.ts +9 -8
- package/packages/server/src/editor-registry.ts +22 -25
- package/packages/server/src/git-operations.ts +1 -1
- package/packages/server/src/headless-pid-registry.ts +7 -20
- package/packages/server/src/home-lock-release.ts +72 -0
- package/packages/server/src/home-lock.ts +389 -0
- package/packages/server/src/node-guard.ts +52 -0
- package/packages/server/src/package-manager-wrapper.ts +207 -47
- package/packages/server/src/pi-core-checker.ts +1 -1
- package/packages/server/src/pi-core-updater.ts +7 -1
- package/packages/server/src/pi-resource-scanner.ts +5 -8
- package/packages/server/src/pi-version-skew.ts +196 -0
- package/packages/server/src/preferences-store.ts +17 -3
- package/packages/server/src/process-manager.ts +403 -222
- package/packages/server/src/provider-probe.ts +234 -0
- package/packages/server/src/restart-helper.ts +130 -0
- package/packages/server/src/routes/bootstrap-routes.ts +88 -0
- package/packages/server/src/routes/openspec-routes.ts +25 -1
- package/packages/server/src/routes/pi-core-routes.ts +24 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -8
- package/packages/server/src/routes/provider-routes.ts +43 -0
- package/packages/server/src/routes/recommended-routes.ts +10 -12
- package/packages/server/src/routes/system-routes.ts +20 -33
- package/packages/server/src/routes/tool-routes.ts +153 -0
- package/packages/server/src/server-pid.ts +5 -9
- package/packages/server/src/server.ts +211 -10
- package/packages/server/src/session-api.ts +77 -8
- package/packages/server/src/session-bootstrap.ts +17 -3
- package/packages/server/src/session-diff.ts +21 -21
- package/packages/server/src/terminal-manager.ts +61 -20
- package/packages/server/src/tunnel.ts +42 -28
- package/packages/shared/package.json +10 -3
- package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
- package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
- package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
- package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
- package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
- package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
- package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
- package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
- package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
- package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
- package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
- package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
- package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
- package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
- package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
- package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
- package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
- package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
- package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
- package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
- package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
- package/packages/shared/src/__tests__/config.test.ts +56 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
- package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
- package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
- package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
- package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
- package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
- package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
- package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
- package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
- package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
- package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
- package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
- package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
- package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
- package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
- package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
- package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
- package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
- package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
- package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
- package/packages/shared/src/bootstrap-install.ts +212 -0
- package/packages/shared/src/bridge-register.ts +87 -20
- package/packages/shared/src/browser-protocol.ts +71 -1
- package/packages/shared/src/config.ts +87 -15
- package/packages/shared/src/managed-paths.ts +31 -4
- package/packages/shared/src/openspec-poller.ts +63 -46
- package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
- package/packages/shared/src/platform/commands.ts +100 -0
- package/packages/shared/src/platform/detached-spawn.ts +305 -0
- package/packages/shared/src/platform/exec.ts +220 -0
- package/packages/shared/src/platform/git.ts +155 -0
- package/packages/shared/src/platform/index.ts +15 -0
- package/packages/shared/src/platform/npm.ts +162 -0
- package/packages/shared/src/platform/openspec.ts +91 -0
- package/packages/shared/src/platform/paths.ts +276 -0
- package/packages/shared/src/platform/process-identify.ts +126 -0
- package/packages/shared/src/platform/process-scan.ts +94 -0
- package/packages/shared/src/platform/process.ts +168 -0
- package/packages/shared/src/platform/runner.ts +369 -0
- package/packages/shared/src/platform/shell.ts +44 -0
- package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
- package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
- package/packages/shared/src/recommended-extensions.ts +18 -2
- package/packages/shared/src/resolve-jiti.ts +62 -3
- package/packages/shared/src/rest-api.ts +26 -0
- package/packages/shared/src/semaphore.ts +83 -0
- package/packages/shared/src/tool-registry/definitions.ts +342 -0
- package/packages/shared/src/tool-registry/index.ts +56 -0
- package/packages/shared/src/tool-registry/overrides.ts +118 -0
- package/packages/shared/src/tool-registry/registry.ts +262 -0
- package/packages/shared/src/tool-registry/strategies.ts +198 -0
- package/packages/shared/src/tool-registry/types.ts +180 -0
|
@@ -46,22 +46,45 @@ describe("shared bridge-register", () => {
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
it("returns null when extension dir does not exist", () => {
|
|
49
|
-
|
|
49
|
+
// Disable Strategy 2 (node-resolver fallback) so we test Strategy 1 in isolation.
|
|
50
|
+
expect(findBundledExtension(tmpDir, { resolvePackage: () => null })).toBeNull();
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
it("returns null when no package.json in extension dir", () => {
|
|
53
54
|
const extDir = path.join(tmpDir, "packages", "extension");
|
|
54
55
|
fs.mkdirSync(extDir, { recursive: true });
|
|
55
56
|
// No package.json
|
|
56
|
-
expect(findBundledExtension(tmpDir)).toBeNull();
|
|
57
|
+
expect(findBundledExtension(tmpDir, { resolvePackage: () => null })).toBeNull();
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
it("returns null for AppImage temp mount paths", () => {
|
|
60
|
-
// We can't easily create a /tmp/.mount_ path, but we can verify
|
|
61
|
-
// the function handles it via the string check
|
|
62
61
|
const mockBase = "/tmp/.mount_PI1234/resources/server";
|
|
63
|
-
//
|
|
64
|
-
|
|
62
|
+
// Even with a resolvable node-modules extension, if that resolved path is
|
|
63
|
+
// itself under /tmp/.mount_* it must be rejected (tested separately below).
|
|
64
|
+
expect(findBundledExtension(mockBase, { resolvePackage: () => null })).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("Strategy 2 — falls back to require.resolve when baseDir has no packages/extension", () => {
|
|
68
|
+
// Simulate `npm i -g pi-dashboard` layout: baseDir is the server
|
|
69
|
+
// package root and contains no `packages/extension/`, but the
|
|
70
|
+
// extension is resolvable as a runtime dep via node_modules.
|
|
71
|
+
const fakeExtDir = path.join(tmpDir, "fake-node_modules", "pi-dashboard-extension");
|
|
72
|
+
fs.mkdirSync(fakeExtDir, { recursive: true });
|
|
73
|
+
fs.writeFileSync(path.join(fakeExtDir, "package.json"), '{"name":"fake"}');
|
|
74
|
+
const resolved = findBundledExtension(tmpDir, {
|
|
75
|
+
resolvePackage: () => path.join(fakeExtDir, "package.json"),
|
|
76
|
+
});
|
|
77
|
+
expect(resolved).toBe(fakeExtDir);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("Strategy 2 — rejects AppImage-mount paths even when resolvable", () => {
|
|
81
|
+
// A /tmp/.mount_* path must be rejected regardless of which strategy
|
|
82
|
+
// surfaced it.
|
|
83
|
+
const appImageExtDir = "/tmp/.mount_PI1234/node_modules/@blackbelt-technology/pi-dashboard-extension";
|
|
84
|
+
const resolved = findBundledExtension(tmpDir, {
|
|
85
|
+
resolvePackage: () => path.join(appImageExtDir, "package.json"),
|
|
86
|
+
});
|
|
87
|
+
expect(resolved).toBeNull();
|
|
65
88
|
});
|
|
66
89
|
});
|
|
67
90
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { loadConfig, DEFAULT_OPENSPEC_POLL } from "../config.js";
|
|
6
|
+
|
|
7
|
+
describe("loadConfig — openspec poll block", () => {
|
|
8
|
+
let testDir: string;
|
|
9
|
+
let configFile: string;
|
|
10
|
+
let origHome: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
testDir = path.join(os.tmpdir(), `test-config-openspec-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
14
|
+
fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
|
|
15
|
+
configFile = path.join(testDir, ".pi", "dashboard", "config.json");
|
|
16
|
+
origHome = process.env.HOME!;
|
|
17
|
+
process.env.HOME = testDir;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
process.env.HOME = origHome;
|
|
22
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("applies all defaults when openspec block is missing", () => {
|
|
26
|
+
fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
|
|
27
|
+
const cfg = loadConfig();
|
|
28
|
+
expect(cfg.openspec).toEqual(DEFAULT_OPENSPEC_POLL);
|
|
29
|
+
expect(cfg.openspec.pollIntervalSeconds).toBe(30);
|
|
30
|
+
expect(cfg.openspec.maxConcurrentSpawns).toBe(3);
|
|
31
|
+
expect(cfg.openspec.changeDetection).toBe("mtime");
|
|
32
|
+
expect(cfg.openspec.jitterSeconds).toBe(5);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("accepts valid values", () => {
|
|
36
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
37
|
+
openspec: { pollIntervalSeconds: 60, maxConcurrentSpawns: 5, changeDetection: "always", jitterSeconds: 10 },
|
|
38
|
+
}));
|
|
39
|
+
const cfg = loadConfig();
|
|
40
|
+
expect(cfg.openspec.pollIntervalSeconds).toBe(60);
|
|
41
|
+
expect(cfg.openspec.maxConcurrentSpawns).toBe(5);
|
|
42
|
+
expect(cfg.openspec.changeDetection).toBe("always");
|
|
43
|
+
expect(cfg.openspec.jitterSeconds).toBe(10);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("clamps pollIntervalSeconds below the minimum (5)", () => {
|
|
47
|
+
fs.writeFileSync(configFile, JSON.stringify({ openspec: { pollIntervalSeconds: 1 } }));
|
|
48
|
+
expect(loadConfig().openspec.pollIntervalSeconds).toBe(5);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("clamps pollIntervalSeconds above the maximum (3600)", () => {
|
|
52
|
+
fs.writeFileSync(configFile, JSON.stringify({ openspec: { pollIntervalSeconds: 999_999 } }));
|
|
53
|
+
expect(loadConfig().openspec.pollIntervalSeconds).toBe(3600);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("clamps maxConcurrentSpawns to [1, 16]", () => {
|
|
57
|
+
fs.writeFileSync(configFile, JSON.stringify({ openspec: { maxConcurrentSpawns: 0 } }));
|
|
58
|
+
expect(loadConfig().openspec.maxConcurrentSpawns).toBe(1);
|
|
59
|
+
|
|
60
|
+
fs.writeFileSync(configFile, JSON.stringify({ openspec: { maxConcurrentSpawns: 100 } }));
|
|
61
|
+
expect(loadConfig().openspec.maxConcurrentSpawns).toBe(16);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("clamps jitterSeconds to [0, 60]", () => {
|
|
65
|
+
fs.writeFileSync(configFile, JSON.stringify({ openspec: { jitterSeconds: -5 } }));
|
|
66
|
+
expect(loadConfig().openspec.jitterSeconds).toBe(0);
|
|
67
|
+
|
|
68
|
+
fs.writeFileSync(configFile, JSON.stringify({ openspec: { jitterSeconds: 120 } }));
|
|
69
|
+
expect(loadConfig().openspec.jitterSeconds).toBe(60);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("falls back to 'mtime' when changeDetection is unknown", () => {
|
|
73
|
+
fs.writeFileSync(configFile, JSON.stringify({ openspec: { changeDetection: "bogus" } }));
|
|
74
|
+
expect(loadConfig().openspec.changeDetection).toBe("mtime");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("coerces non-number values to defaults", () => {
|
|
78
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
79
|
+
openspec: { pollIntervalSeconds: "thirty", maxConcurrentSpawns: null, jitterSeconds: undefined },
|
|
80
|
+
}));
|
|
81
|
+
const cfg = loadConfig();
|
|
82
|
+
expect(cfg.openspec.pollIntervalSeconds).toBe(30);
|
|
83
|
+
expect(cfg.openspec.maxConcurrentSpawns).toBe(3);
|
|
84
|
+
expect(cfg.openspec.jitterSeconds).toBe(5);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("ignores unknown keys in the openspec block", () => {
|
|
88
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
89
|
+
openspec: { pollIntervalSeconds: 45, nonsenseField: "ignored", another: 42 },
|
|
90
|
+
}));
|
|
91
|
+
const cfg = loadConfig();
|
|
92
|
+
expect(cfg.openspec.pollIntervalSeconds).toBe(45);
|
|
93
|
+
expect((cfg.openspec as any).nonsenseField).toBeUndefined();
|
|
94
|
+
expect((cfg.openspec as any).another).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("is stable through round-trip (load → stringify → load)", () => {
|
|
98
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
99
|
+
openspec: { pollIntervalSeconds: 90, maxConcurrentSpawns: 4, changeDetection: "always", jitterSeconds: 12 },
|
|
100
|
+
}));
|
|
101
|
+
const first = loadConfig();
|
|
102
|
+
fs.writeFileSync(configFile, JSON.stringify(first));
|
|
103
|
+
const second = loadConfig();
|
|
104
|
+
expect(second.openspec).toEqual(first.openspec);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -304,6 +304,62 @@ describe("loadConfig", () => {
|
|
|
304
304
|
const config = loadConfig();
|
|
305
305
|
expect(config.electronMode).toBe(false);
|
|
306
306
|
});
|
|
307
|
+
|
|
308
|
+
// ── fix-trusted-networks-no-oauth regression tests ──────────────────
|
|
309
|
+
// These assert that auth.bypassHosts and auth.bypassUrls are honored
|
|
310
|
+
// at load time EVEN WHEN auth.providers is empty or absent. Before the
|
|
311
|
+
// fix, parseAuthConfig returned undefined whenever providers was empty,
|
|
312
|
+
// nuking bypassHosts before the resolvedTrustedNetworks merge could
|
|
313
|
+
// read it. See openspec/changes/fix-trusted-networks-no-oauth/.
|
|
314
|
+
|
|
315
|
+
it("should honor auth.bypassHosts when providers is {} (task 1.1)", () => {
|
|
316
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
317
|
+
auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
|
|
318
|
+
}));
|
|
319
|
+
const config = loadConfig();
|
|
320
|
+
expect(config.auth).toBeDefined();
|
|
321
|
+
expect(config.auth!.bypassHosts).toEqual(["192.168.1.0/24"]);
|
|
322
|
+
expect(config.resolvedTrustedNetworks).toContain("192.168.1.0/24");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("should honor auth.bypassHosts when no providers key at all (task 1.2)", () => {
|
|
326
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
327
|
+
auth: { bypassHosts: ["10.0.0.0/8"] },
|
|
328
|
+
}));
|
|
329
|
+
const config = loadConfig();
|
|
330
|
+
expect(config.auth).toBeDefined();
|
|
331
|
+
expect(config.auth!.bypassHosts).toEqual(["10.0.0.0/8"]);
|
|
332
|
+
expect(config.resolvedTrustedNetworks).toContain("10.0.0.0/8");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("should honor auth.bypassUrls when providers is {} (task 1.3)", () => {
|
|
336
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
337
|
+
auth: { providers: {}, bypassUrls: ["/webhooks/"] },
|
|
338
|
+
}));
|
|
339
|
+
const config = loadConfig();
|
|
340
|
+
expect(config.auth).toBeDefined();
|
|
341
|
+
expect(config.auth!.bypassUrls).toEqual(["/webhooks/"]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("should return auth undefined when providers={} and all bypass arrays are empty (task 1.4 boundary)", () => {
|
|
345
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
346
|
+
auth: { providers: {}, bypassHosts: [], bypassUrls: [] },
|
|
347
|
+
}));
|
|
348
|
+
const config = loadConfig();
|
|
349
|
+
// Truly empty auth → still undefined (boundary preserved)
|
|
350
|
+
expect(config.auth).toBeUndefined();
|
|
351
|
+
expect(config.resolvedTrustedNetworks).toEqual([]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("should merge top-level trustedNetworks with bypassHosts when no providers", () => {
|
|
355
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
356
|
+
trustedNetworks: ["192.168.1.0/24"],
|
|
357
|
+
auth: { providers: {}, bypassHosts: ["10.0.0.0/8"] },
|
|
358
|
+
}));
|
|
359
|
+
const config = loadConfig();
|
|
360
|
+
expect(config.resolvedTrustedNetworks).toContain("192.168.1.0/24");
|
|
361
|
+
expect(config.resolvedTrustedNetworks).toContain("10.0.0.0/8");
|
|
362
|
+
});
|
|
307
363
|
});
|
|
308
364
|
|
|
309
365
|
describe("ensureConfig", () => {
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for platform/detached-spawn.ts primitives.
|
|
3
|
+
*
|
|
4
|
+
* Uses real `node -e` subprocess fixtures (no mocking) so we can exercise
|
|
5
|
+
* the actual Node spawn path with libuv's detached semantics on whatever
|
|
6
|
+
* OS the test runs on.
|
|
7
|
+
*
|
|
8
|
+
* All platform-dependent helpers take an explicit `platform` argument so
|
|
9
|
+
* tests can exercise both branches. We never mutate `process.platform`
|
|
10
|
+
* and never `vi.mock`.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "vitest";
|
|
13
|
+
import { openSync, closeSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { spawnDetached, waitForNoCrash, waitForReady } from "../platform/detached-spawn.js";
|
|
17
|
+
|
|
18
|
+
function tmpLog(): string {
|
|
19
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "dspawn-"));
|
|
20
|
+
return path.join(dir, "out.log");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("spawnDetached", () => {
|
|
24
|
+
it("spawns a real detached child with correct defaults", async () => {
|
|
25
|
+
const r = await spawnDetached({
|
|
26
|
+
cmd: process.execPath,
|
|
27
|
+
args: ["-e", "setTimeout(() => process.exit(0), 300)"],
|
|
28
|
+
});
|
|
29
|
+
expect(r.ok).toBe(true);
|
|
30
|
+
expect(r.pid).toBeTypeOf("number");
|
|
31
|
+
expect(r.process).toBeDefined();
|
|
32
|
+
// clean up
|
|
33
|
+
await new Promise((res) => r.process!.once("exit", res));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns ok:false with error when cmd does not exist", async () => {
|
|
37
|
+
const r = await spawnDetached({
|
|
38
|
+
cmd: "/definitely/not/a/real/binary/nope.exe",
|
|
39
|
+
args: [],
|
|
40
|
+
});
|
|
41
|
+
expect(r.ok).toBe(false);
|
|
42
|
+
expect(r.error).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("redirects stderr to logFd when provided", async () => {
|
|
46
|
+
const logPath = tmpLog();
|
|
47
|
+
const fd = openSync(logPath, "a");
|
|
48
|
+
try {
|
|
49
|
+
const r = await spawnDetached({
|
|
50
|
+
cmd: process.execPath,
|
|
51
|
+
args: ["-e", "process.stderr.write('BOOM'); setTimeout(() => process.exit(0), 100)"],
|
|
52
|
+
logFd: fd,
|
|
53
|
+
});
|
|
54
|
+
expect(r.ok).toBe(true);
|
|
55
|
+
await new Promise((res) => r.process!.once("exit", res));
|
|
56
|
+
} finally {
|
|
57
|
+
try { closeSync(fd); } catch { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
const content = readFileSync(logPath, "utf-8");
|
|
60
|
+
expect(content).toContain("BOOM");
|
|
61
|
+
rmSync(path.dirname(logPath), { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("does not keep parent event loop alive (unref)", async () => {
|
|
65
|
+
// Can only check behaviour indirectly: the returned pid/process exist
|
|
66
|
+
// and the child is running detached. Lifecycle survival is covered by
|
|
67
|
+
// Node's own libuv tests; we assert we didn't throw.
|
|
68
|
+
const r = await spawnDetached({
|
|
69
|
+
cmd: process.execPath,
|
|
70
|
+
args: ["-e", "setTimeout(() => process.exit(0), 100)"],
|
|
71
|
+
});
|
|
72
|
+
expect(r.ok).toBe(true);
|
|
73
|
+
await new Promise((res) => r.process!.once("exit", res));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── detach option ────────────────────────────────────────────────────────
|
|
77
|
+
//
|
|
78
|
+
// Behaviour of `detach` can't be directly observed at the Node level
|
|
79
|
+
// (libuv's UV_PROCESS_DETACHED flag + setpgid/JobObject are internal).
|
|
80
|
+
// These tests verify the OPTION is accepted and does not break spawn;
|
|
81
|
+
// lifecycle semantics are exercised in the integration smoke tests
|
|
82
|
+
// (phase 2.10 manual Windows check).
|
|
83
|
+
|
|
84
|
+
it("accepts detach: true (default behaviour, unchanged)", async () => {
|
|
85
|
+
const r = await spawnDetached({
|
|
86
|
+
cmd: process.execPath,
|
|
87
|
+
args: ["-e", "setTimeout(() => process.exit(0), 100)"],
|
|
88
|
+
detach: true,
|
|
89
|
+
});
|
|
90
|
+
expect(r.ok).toBe(true);
|
|
91
|
+
await new Promise((res) => r.process!.once("exit", res));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("accepts detach: false without breaking spawn", async () => {
|
|
95
|
+
const r = await spawnDetached({
|
|
96
|
+
cmd: process.execPath,
|
|
97
|
+
args: ["-e", "setTimeout(() => process.exit(0), 100)"],
|
|
98
|
+
detach: false,
|
|
99
|
+
});
|
|
100
|
+
expect(r.ok).toBe(true);
|
|
101
|
+
await new Promise((res) => r.process!.once("exit", res));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("accepts detach: undefined (implicit default)", async () => {
|
|
105
|
+
const r = await spawnDetached({
|
|
106
|
+
cmd: process.execPath,
|
|
107
|
+
args: ["-e", "setTimeout(() => process.exit(0), 100)"],
|
|
108
|
+
// detach is deliberately omitted
|
|
109
|
+
});
|
|
110
|
+
expect(r.ok).toBe(true);
|
|
111
|
+
await new Promise((res) => r.process!.once("exit", res));
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("waitForNoCrash", () => {
|
|
116
|
+
it("returns ok:true when child outlives the window", async () => {
|
|
117
|
+
const r = await spawnDetached({
|
|
118
|
+
cmd: process.execPath,
|
|
119
|
+
args: ["-e", "setTimeout(() => process.exit(0), 1000)"],
|
|
120
|
+
});
|
|
121
|
+
expect(r.ok).toBe(true);
|
|
122
|
+
const gate = await waitForNoCrash({ child: r.process!, windowMs: 150 });
|
|
123
|
+
expect(gate.ok).toBe(true);
|
|
124
|
+
await new Promise((res) => r.process!.once("exit", res));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns ok:false with exitCode when child exits early", async () => {
|
|
128
|
+
const r = await spawnDetached({
|
|
129
|
+
cmd: process.execPath,
|
|
130
|
+
args: ["-e", "process.exit(7)"],
|
|
131
|
+
});
|
|
132
|
+
expect(r.ok).toBe(true);
|
|
133
|
+
const gate = await waitForNoCrash({ child: r.process!, windowMs: 1000 });
|
|
134
|
+
expect(gate.ok).toBe(false);
|
|
135
|
+
expect(gate.exitCode).toBe(7);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("respects a small windowMs and does not hang on live children", async () => {
|
|
139
|
+
const r = await spawnDetached({
|
|
140
|
+
cmd: process.execPath,
|
|
141
|
+
args: ["-e", "setTimeout(() => process.exit(0), 5000)"],
|
|
142
|
+
});
|
|
143
|
+
expect(r.ok).toBe(true);
|
|
144
|
+
const start = Date.now();
|
|
145
|
+
const gate = await waitForNoCrash({ child: r.process!, windowMs: 100 });
|
|
146
|
+
const elapsed = Date.now() - start;
|
|
147
|
+
expect(gate.ok).toBe(true);
|
|
148
|
+
expect(elapsed).toBeLessThan(500);
|
|
149
|
+
r.process!.kill();
|
|
150
|
+
await new Promise((res) => r.process!.once("exit", res));
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("waitForReady", () => {
|
|
155
|
+
it("returns ok:true when probe succeeds", async () => {
|
|
156
|
+
const r = await waitForReady({
|
|
157
|
+
probe: async () => true,
|
|
158
|
+
deadlineMs: 1000,
|
|
159
|
+
pollIntervalMs: 50,
|
|
160
|
+
});
|
|
161
|
+
expect(r.ok).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("returns ok:false with 'timeout' when probe never succeeds", async () => {
|
|
165
|
+
const r = await waitForReady({
|
|
166
|
+
probe: async () => false,
|
|
167
|
+
deadlineMs: 200,
|
|
168
|
+
pollIntervalMs: 50,
|
|
169
|
+
});
|
|
170
|
+
expect(r.ok).toBe(false);
|
|
171
|
+
expect(r.error).toBe("timeout");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("short-circuits on child error event", async () => {
|
|
175
|
+
// Spawn a nonexistent path via spawnDetached — triggers error event.
|
|
176
|
+
const bad = await spawnDetached({
|
|
177
|
+
cmd: "/does/not/exist/XYZQQ",
|
|
178
|
+
args: [],
|
|
179
|
+
});
|
|
180
|
+
// bad.process may or may not exist depending on how Node surfaced the
|
|
181
|
+
// error. If it does, we can observe the short-circuit; if not, skip
|
|
182
|
+
// this specific assertion. Either way, waitForReady must not hang.
|
|
183
|
+
if (bad.process) {
|
|
184
|
+
const start = Date.now();
|
|
185
|
+
const r = await waitForReady({
|
|
186
|
+
probe: async () => false,
|
|
187
|
+
deadlineMs: 5000,
|
|
188
|
+
pollIntervalMs: 500,
|
|
189
|
+
child: bad.process,
|
|
190
|
+
});
|
|
191
|
+
const elapsed = Date.now() - start;
|
|
192
|
+
expect(r.ok).toBe(false);
|
|
193
|
+
expect(elapsed).toBeLessThan(5000);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("waits indefinitely when deadlineMs is undefined (succeeds eventually)", async () => {
|
|
198
|
+
let calls = 0;
|
|
199
|
+
const start = Date.now();
|
|
200
|
+
const r = await waitForReady({
|
|
201
|
+
probe: async () => ++calls >= 5,
|
|
202
|
+
// deadlineMs intentionally omitted
|
|
203
|
+
pollIntervalMs: 30,
|
|
204
|
+
});
|
|
205
|
+
const elapsed = Date.now() - start;
|
|
206
|
+
expect(r.ok).toBe(true);
|
|
207
|
+
expect(calls).toBeGreaterThanOrEqual(5);
|
|
208
|
+
// ~5 polls at 30ms interval ≈ 120–180ms. Just ensure we're not
|
|
209
|
+
// short-circuiting suspiciously fast or hanging absurdly long.
|
|
210
|
+
expect(elapsed).toBeLessThan(2000);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("waits indefinitely until child crashes (no deadline, child-exit wins)", async () => {
|
|
214
|
+
// Spawn a short-lived child that exits non-zero after ~200ms.
|
|
215
|
+
const bad = await spawnDetached({
|
|
216
|
+
cmd: process.execPath,
|
|
217
|
+
args: ["-e", "setTimeout(() => process.exit(1), 200)"],
|
|
218
|
+
});
|
|
219
|
+
expect(bad.ok).toBe(true);
|
|
220
|
+
const start = Date.now();
|
|
221
|
+
const r = await waitForReady({
|
|
222
|
+
probe: async () => false, // never ready
|
|
223
|
+
// deadlineMs intentionally omitted — relies on child-exit
|
|
224
|
+
pollIntervalMs: 50,
|
|
225
|
+
child: bad.process!,
|
|
226
|
+
});
|
|
227
|
+
const elapsed = Date.now() - start;
|
|
228
|
+
expect(r.ok).toBe(false);
|
|
229
|
+
expect(r.error).toMatch(/child exited/);
|
|
230
|
+
expect(elapsed).toBeLessThan(2000); // short-circuited, not stuck
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("polls at pollIntervalMs until probe flips", async () => {
|
|
234
|
+
let calls = 0;
|
|
235
|
+
const r = await waitForReady({
|
|
236
|
+
probe: async () => ++calls >= 3,
|
|
237
|
+
deadlineMs: 2000,
|
|
238
|
+
pollIntervalMs: 50,
|
|
239
|
+
});
|
|
240
|
+
expect(r.ok).toBe(true);
|
|
241
|
+
expect(calls).toBeGreaterThanOrEqual(3);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `managed-paths.ts` getters.
|
|
3
|
+
*
|
|
4
|
+
* The constants (MANAGED_DIR, MANAGED_BIN, PI_SETTINGS_PATH) reflect
|
|
5
|
+
* the live environment at module-load time — those are covered by
|
|
6
|
+
* implicit use throughout the codebase. These tests pin the
|
|
7
|
+
* getter-with-override path used by the bootstrap harness and by
|
|
8
|
+
* future tests/proposals that need HOME injection.
|
|
9
|
+
*/
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
MANAGED_BIN,
|
|
15
|
+
MANAGED_DIR,
|
|
16
|
+
PI_SETTINGS_PATH,
|
|
17
|
+
getManagedBin,
|
|
18
|
+
getManagedDir,
|
|
19
|
+
getPiSettingsPath,
|
|
20
|
+
} from "../managed-paths.js";
|
|
21
|
+
|
|
22
|
+
describe("managed-paths getters", () => {
|
|
23
|
+
it("getManagedDir() with no arg matches live MANAGED_DIR", () => {
|
|
24
|
+
expect(getManagedDir()).toBe(MANAGED_DIR);
|
|
25
|
+
expect(getManagedDir()).toBe(path.join(os.homedir(), ".pi-dashboard"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("getManagedBin() with no arg matches live MANAGED_BIN", () => {
|
|
29
|
+
expect(getManagedBin()).toBe(MANAGED_BIN);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("getPiSettingsPath() with no arg matches live PI_SETTINGS_PATH", () => {
|
|
33
|
+
expect(getPiSettingsPath()).toBe(PI_SETTINGS_PATH);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("getManagedDir({ homedir }) uses the override", () => {
|
|
37
|
+
expect(getManagedDir({ homedir: "/fake/home" })).toBe(
|
|
38
|
+
path.join("/fake/home", ".pi-dashboard"),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("getManagedBin({ homedir }) composes from override", () => {
|
|
43
|
+
expect(getManagedBin({ homedir: "/fake/home" })).toBe(
|
|
44
|
+
path.join("/fake/home", ".pi-dashboard", "node_modules", ".bin"),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("getPiSettingsPath({ homedir }) uses the override", () => {
|
|
49
|
+
expect(getPiSettingsPath({ homedir: "/fake/home" })).toBe(
|
|
50
|
+
path.join("/fake/home", ".pi", "agent", "settings.json"),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("override-less getManagedDir and live MANAGED_DIR constant are in sync", () => {
|
|
55
|
+
// Sanity: if someone accidentally drifts the constant from the
|
|
56
|
+
// getter default, this catches it.
|
|
57
|
+
expect(getManagedDir()).toEqual(MANAGED_DIR);
|
|
58
|
+
expect(getManagedBin()).toEqual(MANAGED_BIN);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-level invariant: `node:child_process` MUST NOT be imported directly
|
|
3
|
+
* outside `packages/shared/src/platform/exec.ts` (and, once added,
|
|
4
|
+
* `packages/shared/src/platform/runner.ts`). All subprocess execution goes
|
|
5
|
+
* through the safe wrappers so `windowsHide: true` and other defaults are
|
|
6
|
+
* uniform.
|
|
7
|
+
*
|
|
8
|
+
* If this test fails, migrate the offending file's import to:
|
|
9
|
+
* import { ... } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
|
|
10
|
+
*
|
|
11
|
+
* See change: platform-command-executor.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import url from "node:url";
|
|
17
|
+
|
|
18
|
+
/** Files allowed to import from node:child_process directly. */
|
|
19
|
+
const ALLOWLIST: readonly string[] = [
|
|
20
|
+
"packages/shared/src/platform/exec.ts",
|
|
21
|
+
"packages/shared/src/platform/runner.ts",
|
|
22
|
+
// Platform primitives that legitimately own the raw child_process
|
|
23
|
+
// APIs (Windows detached-spawn + cross-platform subprocess adapter).
|
|
24
|
+
// See change: consolidate-windows-spawn-and-platform-handlers.
|
|
25
|
+
"packages/shared/src/platform/detached-spawn.ts",
|
|
26
|
+
"packages/shared/src/platform/subprocess-adapter.ts",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Regex catches any textual reference to the `node:child_process` module:
|
|
31
|
+
* - import X from "node:child_process"
|
|
32
|
+
* - import { X } from 'node:child_process'
|
|
33
|
+
* - require("node:child_process")
|
|
34
|
+
* - const X = await import("node:child_process")
|
|
35
|
+
*
|
|
36
|
+
* We intentionally match the `node:` prefix strictly — this codebase uses
|
|
37
|
+
* ESM node-protocol imports everywhere, and the bare `child_process`
|
|
38
|
+
* alias is already absent.
|
|
39
|
+
*/
|
|
40
|
+
const CHILD_PROCESS_IMPORT_RE = /(?:from\s+|require\s*\(\s*|import\s*\(\s*)["']node:child_process["']/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Per-line opt-out marker. Use for embedded scripts (e.g. `node -e` orchestrators
|
|
44
|
+
* or Electron renderer bootstrap strings) where the `require("node:child_process")`
|
|
45
|
+
* is source text that runs in a separate Node process, not an import by the
|
|
46
|
+
* host module. Add this comment on the same line as the allowed usage:
|
|
47
|
+
* const { spawn } = require("node:child_process"); // ban:child_process-ok
|
|
48
|
+
*/
|
|
49
|
+
const OPT_OUT_MARKER = "ban:child_process-ok";
|
|
50
|
+
|
|
51
|
+
/** Recursively walk a directory, yielding all .ts / .tsx files. */
|
|
52
|
+
async function* walk(dir: string): AsyncGenerator<string> {
|
|
53
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const full = path.join(dir, entry.name);
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
// Skip nested node_modules, dist, and test directories
|
|
58
|
+
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
|
|
59
|
+
yield* walk(full);
|
|
60
|
+
} else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
|
|
61
|
+
yield full;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("no direct node:child_process imports outside platform/exec.ts", () => {
|
|
67
|
+
it("only allowlisted files import node:child_process", async () => {
|
|
68
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
69
|
+
const repoRoot = path.resolve(here, "..", "..", "..", "..");
|
|
70
|
+
const packagesDir = path.resolve(repoRoot, "packages");
|
|
71
|
+
|
|
72
|
+
const violations: Array<{ file: string; line: number; text: string }> = [];
|
|
73
|
+
const allowSet = new Set(
|
|
74
|
+
ALLOWLIST.map((p) => path.resolve(repoRoot, p).replace(/\\/g, "/")),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
for (const pkg of await fs.readdir(packagesDir, { withFileTypes: true })) {
|
|
78
|
+
if (!pkg.isDirectory()) continue;
|
|
79
|
+
const srcDir = path.join(packagesDir, pkg.name, "src");
|
|
80
|
+
try {
|
|
81
|
+
await fs.access(srcDir);
|
|
82
|
+
} catch {
|
|
83
|
+
continue; // package has no src/
|
|
84
|
+
}
|
|
85
|
+
for await (const file of walk(srcDir)) {
|
|
86
|
+
const normalized = file.replace(/\\/g, "/");
|
|
87
|
+
if (allowSet.has(normalized)) continue;
|
|
88
|
+
|
|
89
|
+
const content = await fs.readFile(file, "utf-8");
|
|
90
|
+
const lines = content.split(/\r?\n/);
|
|
91
|
+
lines.forEach((line, idx) => {
|
|
92
|
+
if (!CHILD_PROCESS_IMPORT_RE.test(line)) return;
|
|
93
|
+
if (line.includes(OPT_OUT_MARKER)) return;
|
|
94
|
+
violations.push({ file: path.relative(repoRoot, file), line: idx + 1, text: line.trim() });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (violations.length > 0) {
|
|
100
|
+
const msg =
|
|
101
|
+
`Direct node:child_process imports found outside the allowlist.\n` +
|
|
102
|
+
`Migrate each to:\n` +
|
|
103
|
+
` import { ... } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";\n\n` +
|
|
104
|
+
`Offenders (${violations.length}):\n` +
|
|
105
|
+
violations
|
|
106
|
+
.map((v) => ` ${v.file}:${v.line} ${v.text}`)
|
|
107
|
+
.join("\n");
|
|
108
|
+
// Use a plain expect to surface the full diff in the test output.
|
|
109
|
+
expect(violations, msg).toEqual([]);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|