@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.
- package/AGENTS.md +87 -114
- package/README.md +408 -430
- package/docs/architecture.md +465 -12
- package/package.json +10 -5
- package/packages/extension/package.json +14 -4
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/git-info.test.ts +67 -55
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
- 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/ask-user-tool.ts +5 -4
- package/packages/extension/src/bridge.ts +171 -17
- package/packages/extension/src/dev-build.ts +1 -1
- package/packages/extension/src/git-info.ts +9 -19
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +43 -0
- 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 +83 -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__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
- 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 +237 -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 +111 -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 +310 -39
- 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 +207 -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 +141 -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__/no-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -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__/resolve-tool-cli.test.ts +105 -0
- 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__/state-replay-entry-id.test.ts +69 -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 +16 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -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/protocol.ts +23 -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/state-replay.ts +9 -0
- package/packages/shared/src/tool-registry/definitions.ts +434 -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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform process enumeration primitives: is-process-running,
|
|
3
|
+
* ps/tasklist pattern-matching, elapsed-time parsing.
|
|
4
|
+
*
|
|
5
|
+
* Every OS-dependent helper accepts injectable `platform` and `exec`
|
|
6
|
+
* parameters (defaulting to `process.platform` and `execSync`), so tests
|
|
7
|
+
* can exercise both branches without mutating the global `process.platform`.
|
|
8
|
+
* See change: consolidate-platform-handlers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "./exec.js";
|
|
12
|
+
|
|
13
|
+
type ExecFn = (cmd: string, opts: { encoding: "utf-8"; stdio?: any }) => string;
|
|
14
|
+
|
|
15
|
+
export interface ProcessScanOpts {
|
|
16
|
+
/** Override platform (defaults to process.platform). */
|
|
17
|
+
platform?: NodeJS.Platform;
|
|
18
|
+
/** Override execSync (for tests). */
|
|
19
|
+
exec?: ExecFn;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function defaultExec(cmd: string, opts: { encoding: "utf-8"; stdio?: any }): string {
|
|
23
|
+
return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Elapsed-time parsing (pure, platform-agnostic) ──────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse `ps -o etime=` format into milliseconds. Handles:
|
|
30
|
+
* - `mm:ss` (e.g. "02:15" → 135000)
|
|
31
|
+
* - `hh:mm:ss` (e.g. "01:30:00" → 5400000)
|
|
32
|
+
* - `dd-hh:mm:ss` (e.g. "2-03:00:00" → 183600000)
|
|
33
|
+
*
|
|
34
|
+
* Returns 0 for empty or unparseable input.
|
|
35
|
+
*/
|
|
36
|
+
export function parseEtime(etime: string): number {
|
|
37
|
+
const trimmed = etime.trim();
|
|
38
|
+
if (!trimmed) return 0;
|
|
39
|
+
|
|
40
|
+
let days = 0;
|
|
41
|
+
let rest = trimmed;
|
|
42
|
+
|
|
43
|
+
const dashIdx = rest.indexOf("-");
|
|
44
|
+
if (dashIdx !== -1) {
|
|
45
|
+
days = parseInt(rest.slice(0, dashIdx), 10);
|
|
46
|
+
if (isNaN(days)) return 0;
|
|
47
|
+
rest = rest.slice(dashIdx + 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parts = rest.split(":").map((p) => parseInt(p, 10));
|
|
51
|
+
if (parts.some(isNaN)) return 0;
|
|
52
|
+
|
|
53
|
+
let hours = 0, minutes = 0, seconds = 0;
|
|
54
|
+
if (parts.length === 3) {
|
|
55
|
+
[hours, minutes, seconds] = parts;
|
|
56
|
+
} else if (parts.length === 2) {
|
|
57
|
+
[minutes, seconds] = parts;
|
|
58
|
+
} else {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return ((days * 86400) + (hours * 3600) + (minutes * 60) + seconds) * 1000;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Process-running check ───────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check whether a process matching `pattern` is currently running.
|
|
69
|
+
* - win32: `tasklist /FI "IMAGENAME eq <pattern>" /NH` — pattern is the
|
|
70
|
+
* executable image name (e.g. "Code.exe"). Returns true if the
|
|
71
|
+
* output contains the pattern.
|
|
72
|
+
* - unix: `pgrep -f "<pattern>"` — pattern is any substring of the
|
|
73
|
+
* command-line (e.g. "/Applications/Zed.app"). Returns true if
|
|
74
|
+
* pgrep exits with code 0 (at least one match).
|
|
75
|
+
*
|
|
76
|
+
* Best-effort: any failure returns `false`.
|
|
77
|
+
*/
|
|
78
|
+
export function isProcessRunning(pattern: string, opts: ProcessScanOpts = {}): boolean {
|
|
79
|
+
const platform = opts.platform ?? process.platform;
|
|
80
|
+
const exec = opts.exec ?? defaultExec;
|
|
81
|
+
try {
|
|
82
|
+
if (platform === "win32") {
|
|
83
|
+
const result = exec(`tasklist /FI "IMAGENAME eq ${pattern}" /NH`, {
|
|
84
|
+
encoding: "utf-8",
|
|
85
|
+
stdio: "pipe",
|
|
86
|
+
});
|
|
87
|
+
return String(result).includes(pattern);
|
|
88
|
+
}
|
|
89
|
+
exec(`pgrep -f "${pattern}"`, { encoding: "utf-8", stdio: "pipe" });
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform process primitives: port cleanup, kill, liveness, group-kill.
|
|
3
|
+
*
|
|
4
|
+
* Every OS-dependent helper takes an optional `platform` parameter
|
|
5
|
+
* (defaulting to `process.platform`) so tests can exercise both branches
|
|
6
|
+
* without mutating the global `process.platform`. See change:
|
|
7
|
+
* consolidate-platform-handlers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "./exec.js";
|
|
11
|
+
|
|
12
|
+
export type ExecFn = (cmd: string, opts: { encoding: "utf-8" }) => string;
|
|
13
|
+
export type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
|
|
14
|
+
|
|
15
|
+
export interface ProcessOpts {
|
|
16
|
+
/** Override platform (defaults to process.platform). */
|
|
17
|
+
platform?: NodeJS.Platform;
|
|
18
|
+
/** Override execSync (for tests). */
|
|
19
|
+
exec?: ExecFn;
|
|
20
|
+
/** Override process.kill (for tests). */
|
|
21
|
+
kill?: KillFn;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function defaultExec(cmd: string, opts: { encoding: "utf-8" }): string {
|
|
25
|
+
// Always suppress the cmd.exe window flash on Windows. The primitives that
|
|
26
|
+
// use this (findPortHolders via netstat, killProcess via taskkill) don't
|
|
27
|
+
// need user visibility.
|
|
28
|
+
return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function defaultKill(pid: number, signal: NodeJS.Signals | number): void {
|
|
32
|
+
process.kill(pid, signal);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Port-holder detection ────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse `netstat -ano -p tcp` output for PIDs listening on a port (Windows).
|
|
39
|
+
* Pure function, exported for testing.
|
|
40
|
+
*
|
|
41
|
+
* Example input line:
|
|
42
|
+
* " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345"
|
|
43
|
+
*/
|
|
44
|
+
export function parseNetstatListeners(output: string, port: number, selfPid: number): number[] {
|
|
45
|
+
const pids: number[] = [];
|
|
46
|
+
const portSuffix = `:${port}`;
|
|
47
|
+
for (const line of output.split(/\r?\n/)) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (!trimmed || !/^\s*TCP/i.test(line)) continue;
|
|
50
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
51
|
+
const cols = trimmed.split(/\s+/);
|
|
52
|
+
if (cols.length < 5) continue;
|
|
53
|
+
const local = cols[1];
|
|
54
|
+
if (!local.endsWith(portSuffix)) continue;
|
|
55
|
+
const pid = Number.parseInt(cols[cols.length - 1], 10);
|
|
56
|
+
if (Number.isFinite(pid) && pid > 0 && pid !== selfPid) pids.push(pid);
|
|
57
|
+
}
|
|
58
|
+
return pids;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find PIDs holding a TCP port. Cross-platform:
|
|
63
|
+
* - win32: `netstat -ano -p tcp` → parse LISTENING rows
|
|
64
|
+
* - unix: `lsof -t -i :<port> -sTCP:LISTEN`
|
|
65
|
+
*
|
|
66
|
+
* Best-effort: any failure returns []. Excludes the current process PID.
|
|
67
|
+
*/
|
|
68
|
+
export function findPortHolders(port: number, opts: ProcessOpts = {}): number[] {
|
|
69
|
+
const platform = opts.platform ?? process.platform;
|
|
70
|
+
const exec = opts.exec ?? defaultExec;
|
|
71
|
+
try {
|
|
72
|
+
if (platform === "win32") {
|
|
73
|
+
const output = exec("netstat -ano -p tcp", { encoding: "utf-8" });
|
|
74
|
+
return parseNetstatListeners(String(output), port, process.pid);
|
|
75
|
+
}
|
|
76
|
+
const output = exec(`lsof -t -i :${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" });
|
|
77
|
+
return String(output).trim().split("\n").map(Number).filter((n) => n > 0 && n !== process.pid);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Liveness ─────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check whether a PID is alive. Cross-platform via `process.kill(pid, 0)`.
|
|
87
|
+
*/
|
|
88
|
+
export function isProcessAlive(pid: number, opts: { kill?: KillFn } = {}): boolean {
|
|
89
|
+
const kill = opts.kill ?? defaultKill;
|
|
90
|
+
try {
|
|
91
|
+
kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Termination ──────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export interface KillProcessResult {
|
|
101
|
+
ok: boolean;
|
|
102
|
+
forced: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Terminate a process, cross-platform:
|
|
107
|
+
* - win32: `taskkill /F /T /PID <pid>` (tree kill, immediate)
|
|
108
|
+
* - unix: SIGTERM → wait up to `timeoutMs` → SIGKILL if still alive
|
|
109
|
+
*
|
|
110
|
+
* Returns `{ ok, forced }`. `ok` is true if the process was terminated (or
|
|
111
|
+
* was already dead); `forced` is true if SIGKILL was needed on Unix.
|
|
112
|
+
*/
|
|
113
|
+
export async function killProcess(
|
|
114
|
+
pid: number,
|
|
115
|
+
opts: ProcessOpts & { timeoutMs?: number } = {},
|
|
116
|
+
): Promise<KillProcessResult> {
|
|
117
|
+
const platform = opts.platform ?? process.platform;
|
|
118
|
+
const exec = opts.exec ?? defaultExec;
|
|
119
|
+
const kill = opts.kill ?? defaultKill;
|
|
120
|
+
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
121
|
+
|
|
122
|
+
if (!isProcessAlive(pid, { kill })) return { ok: false, forced: false };
|
|
123
|
+
|
|
124
|
+
if (platform === "win32") {
|
|
125
|
+
try {
|
|
126
|
+
exec(`taskkill /F /T /PID ${pid}`, { encoding: "utf-8" });
|
|
127
|
+
return { ok: true, forced: false };
|
|
128
|
+
} catch {
|
|
129
|
+
return { ok: false, forced: false };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
kill(pid, "SIGTERM");
|
|
135
|
+
} catch {
|
|
136
|
+
return { ok: false, forced: false };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const deadline = Date.now() + timeoutMs;
|
|
140
|
+
while (Date.now() < deadline) {
|
|
141
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
142
|
+
if (!isProcessAlive(pid, { kill })) return { ok: true, forced: false };
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
kill(pid, "SIGKILL");
|
|
146
|
+
} catch {
|
|
147
|
+
/* already dead */
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, forced: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Process-group kill (for detached children) ───────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Signal a process, targeting the process group on Unix (negative PID) and
|
|
156
|
+
* the PID directly on Windows. Used for detached children spawned with their
|
|
157
|
+
* own process group.
|
|
158
|
+
*/
|
|
159
|
+
export function killPidWithGroup(
|
|
160
|
+
pid: number,
|
|
161
|
+
signal: NodeJS.Signals,
|
|
162
|
+
opts: ProcessOpts = {},
|
|
163
|
+
): void {
|
|
164
|
+
const platform = opts.platform ?? process.platform;
|
|
165
|
+
const kill = opts.kill ?? defaultKill;
|
|
166
|
+
const target = platform === "win32" ? pid : -pid;
|
|
167
|
+
kill(target, signal);
|
|
168
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe runner — the engine that executes structured subprocess Recipes.
|
|
3
|
+
*
|
|
4
|
+
* A Recipe is pure data: it describes *what* to run (argv from input),
|
|
5
|
+
* *how to parse* the stdout, and policy (timeout, tolerated exit codes).
|
|
6
|
+
* The runner owns *how to spawn*: binary resolution via `ToolResolver`,
|
|
7
|
+
* always-safe defaults (`windowsHide: true`, no shell interpolation),
|
|
8
|
+
* timeout enforcement, and uniform error normalization to `Result<T>`.
|
|
9
|
+
*
|
|
10
|
+
* Tool modules (`platform/git.ts`, `platform/openspec.ts`, `platform/npm.ts`)
|
|
11
|
+
* declare Recipes and call `run()`. They never touch `child_process`,
|
|
12
|
+
* `process.platform`, or `windowsHide`.
|
|
13
|
+
*
|
|
14
|
+
* See change: platform-command-executor.
|
|
15
|
+
*/
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { spawnSync, spawn, buildSafeArgv } from "./exec.js";
|
|
19
|
+
import { ToolResolver } from "./binary-lookup.js";
|
|
20
|
+
// The tool registry publishes itself on a well-known `globalThis` symbol
|
|
21
|
+
// when `getDefaultRegistry()` is first called from any consumer. The
|
|
22
|
+
// runner reads that global to avoid a load-order cycle (tool-registry
|
|
23
|
+
// → platform/npm.ts → this file) that would otherwise trip Node's
|
|
24
|
+
// ESM/CJS translator with ERR_INTERNAL_ASSERTION on certain boots.
|
|
25
|
+
// See change: consolidate-tool-resolution.
|
|
26
|
+
|
|
27
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** A Recipe is a pure-data description of a subprocess operation. */
|
|
30
|
+
export interface Recipe<Input, Output> {
|
|
31
|
+
/** Build the command + args from the typed input. First element is the command name. */
|
|
32
|
+
argv: (input: Input) => readonly string[];
|
|
33
|
+
/** Parse stdout (and optionally the input) into the typed result. */
|
|
34
|
+
parse: (stdout: string, input: Input) => Output;
|
|
35
|
+
/** Per-recipe timeout override (default: 5000ms). */
|
|
36
|
+
timeout?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Exit codes to treat as "empty success" instead of an error. Useful for
|
|
39
|
+
* commands like `git diff` that exit 1 when there's no diff.
|
|
40
|
+
*/
|
|
41
|
+
tolerate?: readonly number[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Context passed to `run()` alongside the input. */
|
|
45
|
+
export interface RunCtx {
|
|
46
|
+
/** Working directory for the spawn. */
|
|
47
|
+
cwd?: string;
|
|
48
|
+
/** Environment variables (merged over process.env). */
|
|
49
|
+
env?: NodeJS.ProcessEnv;
|
|
50
|
+
/** Override timeout for this call (takes precedence over recipe.timeout). */
|
|
51
|
+
timeout?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Discriminated error type surfaced by `run()`. */
|
|
55
|
+
export type ExecError =
|
|
56
|
+
| { kind: "not-found"; binary: string }
|
|
57
|
+
| { kind: "timeout"; timeoutMs: number; binary: string }
|
|
58
|
+
| { kind: "exit"; code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }
|
|
59
|
+
| { kind: "spawn-failure"; message: string };
|
|
60
|
+
|
|
61
|
+
/** Typed Result — no thrown exceptions for the 4 error kinds above. */
|
|
62
|
+
export type Result<T> = { ok: true; value: T } | { ok: false; error: ExecError };
|
|
63
|
+
|
|
64
|
+
// ── Resolver cache ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Low-level ToolResolver kept as the fallback for unregistered binary
|
|
68
|
+
* names. Registered names flow through the shared `ToolRegistry` so
|
|
69
|
+
* user overrides apply uniformly to every Recipe.
|
|
70
|
+
* See change: consolidate-tool-resolution.
|
|
71
|
+
*/
|
|
72
|
+
const sharedResolver = new ToolResolver({
|
|
73
|
+
processExecPath: process.execPath,
|
|
74
|
+
useLoginShell: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Test-only hook: invalidate the registry cache. Preserved as a thin
|
|
79
|
+
* shim over `registry.rescan()` so existing test suites keep working
|
|
80
|
+
* after migrating away from the runner's private `resolverCache`.
|
|
81
|
+
*/
|
|
82
|
+
export function resetResolverCache(): void {
|
|
83
|
+
try {
|
|
84
|
+
const reg = tryGetRegistry();
|
|
85
|
+
if (reg) reg.rescan();
|
|
86
|
+
} catch { /* isolated tests */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Lazy registry accessor via `globalThis` symbol. The tool-registry
|
|
90
|
+
// module writes itself there inside `getDefaultRegistry()`. Returns
|
|
91
|
+
// `null` until some consumer (e.g. the server's `/api/tools` route or
|
|
92
|
+
// the package-manager wrapper) constructs the registry; the runner
|
|
93
|
+
// then falls back to `ToolResolver.which()` for that single call.
|
|
94
|
+
interface LazyRegistry {
|
|
95
|
+
has(n: string): boolean;
|
|
96
|
+
resolve(n: string): { ok: boolean; path: string | null };
|
|
97
|
+
resolveExecutor(n: string): { ok: boolean; argv: string[] };
|
|
98
|
+
rescan(): void;
|
|
99
|
+
}
|
|
100
|
+
const GLOBAL_REGISTRY_KEY = Symbol.for("pi-dashboard.tool-registry");
|
|
101
|
+
function tryGetRegistry(): LazyRegistry | null {
|
|
102
|
+
const reg = (globalThis as unknown as { [k: symbol]: LazyRegistry | undefined })[GLOBAL_REGISTRY_KEY];
|
|
103
|
+
return reg ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Is the argv[0] already a filesystem path (absolute or relative)? Then the
|
|
108
|
+
* caller supplied the binary directly and we should not try to resolve it
|
|
109
|
+
* via PATH/where/which — just use it as-is.
|
|
110
|
+
*/
|
|
111
|
+
function isPathLike(cmd: string): boolean {
|
|
112
|
+
if (path.isAbsolute(cmd)) return true;
|
|
113
|
+
if (cmd.startsWith("./") || cmd.startsWith("../")) return true;
|
|
114
|
+
if (cmd.startsWith(".\\") || cmd.startsWith("..\\")) return true;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a binary name to an absolute path.
|
|
120
|
+
*
|
|
121
|
+
* Strategy:
|
|
122
|
+
* 1. Path-like argv (absolute / relative) → use as-is if it exists.
|
|
123
|
+
* 2. Name is registered in `ToolRegistry` → delegate to the registry
|
|
124
|
+
* so overrides, managed strategies, and diagnostics apply
|
|
125
|
+
* uniformly. The registry has its own per-instance cache; the
|
|
126
|
+
* runner no longer maintains a private `resolverCache`.
|
|
127
|
+
* 3. Name is not registered → fall back to `ToolResolver.which` for
|
|
128
|
+
* ad-hoc binaries (zrok, code-server, custom tools) that the
|
|
129
|
+
* dashboard hasn't formally declared.
|
|
130
|
+
*
|
|
131
|
+
* Imported lazily from `../tool-registry/index.js` to keep the runner
|
|
132
|
+
* usable at module-init time even if the registry hasn't finished
|
|
133
|
+
* loading its overrides yet.
|
|
134
|
+
*/
|
|
135
|
+
function resolveBinary(name: string): string | null {
|
|
136
|
+
if (isPathLike(name)) {
|
|
137
|
+
if (existsSync(name)) return name;
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
// Registered tools flow through the registry (overrides + diagnostics).
|
|
141
|
+
// The `tool-registry` module imports this file transitively via
|
|
142
|
+
// `platform/npm.ts`; the cycle is benign at function-call time because
|
|
143
|
+
// every module has finished evaluating by the time `resolveBinary` is
|
|
144
|
+
// first invoked (it's called only from inside `run()`).
|
|
145
|
+
const registry = tryGetRegistry();
|
|
146
|
+
if (registry && registry.has(name)) {
|
|
147
|
+
const resolved = registry.resolve(name);
|
|
148
|
+
return resolved.ok ? resolved.path : null;
|
|
149
|
+
}
|
|
150
|
+
return sharedResolver.which(name);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve a Recipe's argv[0] to a spawn-ready argv via the tool
|
|
155
|
+
* registry's `resolveExecutor`. This is the path that lets `npm`,
|
|
156
|
+
* `openspec`, `pi` all resolve to `[node.exe, <script>.js]` on
|
|
157
|
+
* Windows — bypassing `.cmd` shims and the console-flash chain.
|
|
158
|
+
*
|
|
159
|
+
* Returns `null` when the binary is unknown AND not on PATH.
|
|
160
|
+
*
|
|
161
|
+
* Non-registered names fall back to `ToolResolver.which()` (single
|
|
162
|
+
* path, no executor wrapping). Path-like names (absolute/relative
|
|
163
|
+
* paths) are trusted as-is.
|
|
164
|
+
*/
|
|
165
|
+
function resolveExecutorArgv(name: string, recipeArgs: readonly string[]): string[] | null {
|
|
166
|
+
if (isPathLike(name)) {
|
|
167
|
+
if (existsSync(name)) return [name, ...recipeArgs];
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const registry = tryGetRegistry();
|
|
171
|
+
if (registry && registry.has(name)) {
|
|
172
|
+
const exec = registry.resolveExecutor(name);
|
|
173
|
+
if (exec.ok && exec.argv.length > 0) {
|
|
174
|
+
return [...exec.argv, ...recipeArgs];
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const p = sharedResolver.which(name);
|
|
179
|
+
return p ? [p, ...recipeArgs] : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── The engine ──────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Execute a Recipe against a typed input. Returns a `Result<Output>`.
|
|
188
|
+
* Never throws for recognized error conditions (not-found / timeout /
|
|
189
|
+
* exit / spawn-failure) — surfaces them as typed errors instead.
|
|
190
|
+
*/
|
|
191
|
+
export function run<Input, Output>(
|
|
192
|
+
recipe: Recipe<Input, Output>,
|
|
193
|
+
input: Input,
|
|
194
|
+
ctx: RunCtx = {},
|
|
195
|
+
): Result<Output> {
|
|
196
|
+
const argv = recipe.argv(input);
|
|
197
|
+
if (argv.length === 0) {
|
|
198
|
+
return { ok: false, error: { kind: "spawn-failure", message: "Recipe produced empty argv" } };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const [rawCmd, ...recipeArgs] = argv;
|
|
202
|
+
const execArgv = resolveExecutorArgv(rawCmd, recipeArgs);
|
|
203
|
+
if (!execArgv) {
|
|
204
|
+
return { ok: false, error: { kind: "not-found", binary: rawCmd } };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const timeout = ctx.timeout ?? recipe.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
208
|
+
|
|
209
|
+
// Route every command through `buildSafeArgv` — the canonical
|
|
210
|
+
// Windows-safe subprocess invocation. `execArgv` is already
|
|
211
|
+
// `[node.exe, <script>.js, ...args]` for executor-kind tools, so
|
|
212
|
+
// buildSafeArgv sees node.exe (.exe → direct spawn) and returns
|
|
213
|
+
// the argv unchanged. For non-executor tools resolving to `.cmd`,
|
|
214
|
+
// buildSafeArgv wraps in `cmd.exe /d /s /c`.
|
|
215
|
+
//
|
|
216
|
+
// See change: consolidate-windows-spawn-and-platform-handlers.
|
|
217
|
+
const [execCmd, ...execArgs] = execArgv;
|
|
218
|
+
const { argv: safeArgv, spawnOptions } = buildSafeArgv(execCmd, execArgs);
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const result = spawnSync(safeArgv[0], safeArgv.slice(1), {
|
|
222
|
+
cwd: ctx.cwd,
|
|
223
|
+
env: ctx.env ? { ...process.env, ...ctx.env } : undefined,
|
|
224
|
+
encoding: "utf-8",
|
|
225
|
+
timeout,
|
|
226
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
227
|
+
...spawnOptions, // shell: false, windowsHide: true
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// spawnSync error path: either it set .error (e.g. spawn failure) or
|
|
231
|
+
// it timed out (in which case signal === "SIGTERM" on Node >= 15).
|
|
232
|
+
if (result.error) {
|
|
233
|
+
const err = result.error as NodeJS.ErrnoException;
|
|
234
|
+
if (err.code === "ETIMEDOUT" || err.message?.includes("ETIMEDOUT")) {
|
|
235
|
+
return { ok: false, error: { kind: "timeout", timeoutMs: timeout, binary: rawCmd } };
|
|
236
|
+
}
|
|
237
|
+
return { ok: false, error: { kind: "spawn-failure", message: err.message } };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Node's spawnSync signals timeout by setting signal = SIGTERM and status = null.
|
|
241
|
+
if (result.status === null && result.signal) {
|
|
242
|
+
return { ok: false, error: { kind: "timeout", timeoutMs: timeout, binary: rawCmd } };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
|
|
246
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : String(result.stderr ?? "");
|
|
247
|
+
|
|
248
|
+
const status = result.status;
|
|
249
|
+
const tolerated = status !== 0 && recipe.tolerate?.includes(status ?? -1);
|
|
250
|
+
if (status === 0 || tolerated) {
|
|
251
|
+
return { ok: true, value: recipe.parse(stdout, input) };
|
|
252
|
+
}
|
|
253
|
+
return { ok: false, error: { kind: "exit", code: status, signal: result.signal, stdout, stderr } };
|
|
254
|
+
} catch (err) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
error: { kind: "spawn-failure", message: err instanceof Error ? err.message : String(err) },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Async sibling of `run()`. Same Recipe contract, same binary
|
|
264
|
+
* resolution, same `.cmd`/shell handling, same error normalization
|
|
265
|
+
* — but spawns via `platform/exec.ts`'s wrapped `spawn` (with stdout
|
|
266
|
+
* captured to a Promise) instead of `spawnSync`, so callers can run
|
|
267
|
+
* many recipes concurrently without blocking the event loop.
|
|
268
|
+
*
|
|
269
|
+
* Use this from server code paths that iterate over many inputs (e.g.
|
|
270
|
+
* `openspec status --change <name>` across ~20 changes). The sync
|
|
271
|
+
* `run()` is fine for one-off calls or for callers that must stay
|
|
272
|
+
* sync (the bridge extension's sync hooks).
|
|
273
|
+
*
|
|
274
|
+
* `windowsHide: true` comes from the shared `spawn` wrapper — the
|
|
275
|
+
* same invariant the sync runner relies on. Do not re-introduce a
|
|
276
|
+
* bare `child_process.spawn` elsewhere.
|
|
277
|
+
*
|
|
278
|
+
* See change: consolidate-tool-resolution (async runner follow-up).
|
|
279
|
+
*/
|
|
280
|
+
export function runAsync<Input, Output>(
|
|
281
|
+
recipe: Recipe<Input, Output>,
|
|
282
|
+
input: Input,
|
|
283
|
+
ctx: RunCtx = {},
|
|
284
|
+
): Promise<Result<Output>> {
|
|
285
|
+
const argv = recipe.argv(input);
|
|
286
|
+
if (argv.length === 0) {
|
|
287
|
+
return Promise.resolve({ ok: false, error: { kind: "spawn-failure", message: "Recipe produced empty argv" } });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const [rawCmd, ...recipeArgs] = argv;
|
|
291
|
+
const execArgv = resolveExecutorArgv(rawCmd, recipeArgs);
|
|
292
|
+
if (!execArgv) {
|
|
293
|
+
return Promise.resolve({ ok: false, error: { kind: "not-found", binary: rawCmd } });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const timeout = ctx.timeout ?? recipe.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
297
|
+
|
|
298
|
+
// Executor-kind tools resolve to `[node.exe, script.js, ...]` on
|
|
299
|
+
// Windows so buildSafeArgv's `.cmd` wrapping is a no-op here — pure
|
|
300
|
+
// node.exe spawn, no cmd.exe in the chain.
|
|
301
|
+
const [execCmd, ...execArgs] = execArgv;
|
|
302
|
+
const { argv: safeArgv, spawnOptions } = buildSafeArgv(execCmd, execArgs);
|
|
303
|
+
|
|
304
|
+
return new Promise<Result<Output>>((resolve) => {
|
|
305
|
+
let stdout = "";
|
|
306
|
+
let stderr = "";
|
|
307
|
+
let settled = false;
|
|
308
|
+
const settle = (r: Result<Output>) => {
|
|
309
|
+
if (settled) return;
|
|
310
|
+
settled = true;
|
|
311
|
+
resolve(r);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
let child: import("node:child_process").ChildProcess;
|
|
315
|
+
try {
|
|
316
|
+
child = spawn(safeArgv[0], safeArgv.slice(1), {
|
|
317
|
+
cwd: ctx.cwd,
|
|
318
|
+
env: ctx.env ? { ...process.env, ...ctx.env } : undefined,
|
|
319
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
320
|
+
...spawnOptions, // shell: false, windowsHide: true
|
|
321
|
+
});
|
|
322
|
+
} catch (err) {
|
|
323
|
+
settle({ ok: false, error: { kind: "spawn-failure", message: err instanceof Error ? err.message : String(err) } });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const timer = setTimeout(() => {
|
|
328
|
+
try { child.kill("SIGTERM"); } catch { /* ignore */ }
|
|
329
|
+
settle({ ok: false, error: { kind: "timeout", timeoutMs: timeout, binary: rawCmd } });
|
|
330
|
+
}, timeout);
|
|
331
|
+
|
|
332
|
+
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf-8"); });
|
|
333
|
+
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf-8"); });
|
|
334
|
+
|
|
335
|
+
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
if (err.code === "ETIMEDOUT" || err.message?.includes("ETIMEDOUT")) {
|
|
338
|
+
settle({ ok: false, error: { kind: "timeout", timeoutMs: timeout, binary: rawCmd } });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
settle({ ok: false, error: { kind: "spawn-failure", message: err.message } });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
const tolerated = code !== 0 && code !== null && recipe.tolerate?.includes(code);
|
|
347
|
+
if (code === 0 || tolerated) {
|
|
348
|
+
try {
|
|
349
|
+
settle({ ok: true, value: recipe.parse(stdout, input) });
|
|
350
|
+
} catch (err) {
|
|
351
|
+
settle({ ok: false, error: { kind: "spawn-failure", message: err instanceof Error ? err.message : String(err) } });
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
settle({ ok: false, error: { kind: "exit", code, signal, stdout, stderr } });
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get the value or a fallback. Use when the caller doesn't care about the
|
|
363
|
+
* error discriminant (best-effort operations).
|
|
364
|
+
*
|
|
365
|
+
* const branch = unwrap(git.currentBranch({ cwd }), null);
|
|
366
|
+
*/
|
|
367
|
+
export function unwrap<T>(result: Result<T>, fallback: T): T {
|
|
368
|
+
return result.ok ? result.value : fallback;
|
|
369
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform shell and terminal-environment primitives.
|
|
3
|
+
*
|
|
4
|
+
* `detectShell` and `getTerminalEnvHints` accept an injectable `platform`
|
|
5
|
+
* and `env` parameters (defaulting to `process.platform` and `process.env`)
|
|
6
|
+
* so tests can exercise both branches without global mutation.
|
|
7
|
+
* See change: consolidate-platform-handlers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ShellOpts {
|
|
11
|
+
/** Override platform (defaults to process.platform). */
|
|
12
|
+
platform?: NodeJS.Platform;
|
|
13
|
+
/** Override env (defaults to process.env). */
|
|
14
|
+
env?: NodeJS.ProcessEnv;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Detect the appropriate shell for the current platform:
|
|
19
|
+
* - win32: `%COMSPEC%` if set, else `"powershell.exe"`
|
|
20
|
+
* - unix: `$SHELL` if set, else `"/bin/bash"`
|
|
21
|
+
*/
|
|
22
|
+
export function detectShell(opts: ShellOpts = {}): string {
|
|
23
|
+
const platform = opts.platform ?? process.platform;
|
|
24
|
+
const env = opts.env ?? process.env;
|
|
25
|
+
if (platform === "win32") {
|
|
26
|
+
return env.COMSPEC || "powershell.exe";
|
|
27
|
+
}
|
|
28
|
+
return env.SHELL || "/bin/bash";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extra environment variables to set when spawning a PTY, per platform.
|
|
33
|
+
* Currently only Windows sets `TERM=cygwin` (when not already set) so that
|
|
34
|
+
* curses/readline-style apps render correctly in node-pty on Windows.
|
|
35
|
+
*/
|
|
36
|
+
export function getTerminalEnvHints(opts: ShellOpts = {}): Record<string, string> {
|
|
37
|
+
const platform = opts.platform ?? process.platform;
|
|
38
|
+
const env = opts.env ?? process.env;
|
|
39
|
+
const hints: Record<string, string> = {};
|
|
40
|
+
if (platform === "win32" && !env.TERM) {
|
|
41
|
+
hints.TERM = "cygwin";
|
|
42
|
+
}
|
|
43
|
+
return hints;
|
|
44
|
+
}
|