@blackbelt-technology/pi-agent-dashboard 0.2.9 → 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 +64 -8
- package/README.md +308 -101
- package/docs/architecture.md +515 -16
- package/package.json +14 -7
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -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/ask-user-tool.ts +289 -20
- package/packages/extension/src/bridge.ts +107 -6
- package/packages/extension/src/command-handler.ts +34 -39
- 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/prompt-expander.ts +25 -4
- 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 +17 -2
- package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
- package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -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 +246 -10
- package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -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__/cors.test.ts +34 -2
- 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-manager-pid-registry.test.ts +168 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
- package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +29 -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__/fix-pty-permissions.test.ts +59 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
- package/packages/server/src/__tests__/git-operations.test.ts +9 -7
- package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
- 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__/openspec-tasks-parser.test.ts +178 -0
- package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
- package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
- package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
- 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-auth-routes.test.ts +13 -3
- 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__/recommended-routes.test.ts +389 -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__/session-file-dedup.test.ts +10 -10
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
- package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
- package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
- package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
- 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 +103 -6
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
- 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 +108 -9
- package/packages/server/src/browser-gateway.ts +16 -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 +39 -5
- package/packages/server/src/editor-pid-registry.ts +199 -0
- package/packages/server/src/editor-registry.ts +22 -25
- package/packages/server/src/fix-pty-permissions.ts +44 -0
- package/packages/server/src/git-operations.ts +1 -1
- package/packages/server/src/headless-pid-registry.ts +16 -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/npm-search-proxy.ts +71 -0
- package/packages/server/src/openspec-tasks.ts +158 -0
- package/packages/server/src/package-manager-wrapper.ts +225 -34
- package/packages/server/src/pi-core-checker.ts +290 -0
- package/packages/server/src/pi-core-updater.ts +172 -0
- package/packages/server/src/pi-gateway.ts +7 -0
- 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/file-routes.ts +30 -3
- package/packages/server/src/routes/openspec-routes.ts +107 -1
- package/packages/server/src/routes/pi-core-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +12 -10
- package/packages/server/src/routes/provider-routes.ts +55 -2
- package/packages/server/src/routes/recommended-routes.ts +225 -0
- package/packages/server/src/routes/system-routes.ts +30 -34
- 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 +363 -26
- 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 +65 -20
- package/packages/server/src/test-env-guard.ts +26 -0
- package/packages/server/src/test-support/test-server.ts +63 -0
- package/packages/server/src/tunnel.ts +172 -34
- 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 +59 -3
- 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__/openspec-poller.test.ts +44 -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 +156 -0
- 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__/source-matching.test.ts +143 -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 +93 -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 +71 -49
- 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 +196 -0
- package/packages/shared/src/resolve-jiti.ts +62 -3
- package/packages/shared/src/rest-api.ts +97 -0
- package/packages/shared/src/semaphore.ts +83 -0
- package/packages/shared/src/source-matching.ts +126 -0
- package/packages/shared/src/test-support/setup-home.ts +74 -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
- package/packages/shared/src/types.ts +7 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-spawn mechanism selection.
|
|
3
|
+
*
|
|
4
|
+
* The user expresses preference via a two-valued config type
|
|
5
|
+
* (`SpawnStrategy` = "tmux" | "headless"). The dashboard internally
|
|
6
|
+
* decides WHICH actual mechanism to use given the OS and what's
|
|
7
|
+
* available on this host. This module is the single source of truth
|
|
8
|
+
* for that decision.
|
|
9
|
+
*
|
|
10
|
+
* Mechanisms:
|
|
11
|
+
* • "tmux" — Unix terminal multiplexer (Linux, macOS)
|
|
12
|
+
* • "wt" — Windows Terminal new-tab (Win10/11)
|
|
13
|
+
* • "wsl-tmux" — WSL-hosted tmux (Windows, niche)
|
|
14
|
+
* • "headless" — RPC-mode pi, no TTY, bridge over WebSocket
|
|
15
|
+
*
|
|
16
|
+
* `selectMechanism` is pure: no I/O, no subprocess calls. Availability
|
|
17
|
+
* is determined by the caller (typically via `ToolRegistry.resolve`)
|
|
18
|
+
* and passed in. This keeps the decision trivially testable.
|
|
19
|
+
*
|
|
20
|
+
* See change: consolidate-windows-spawn-and-platform-handlers.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type SpawnMechanism = "tmux" | "wt" | "wsl-tmux" | "headless";
|
|
24
|
+
|
|
25
|
+
/** User-visible config value (from `SpawnStrategy` in shared/config.ts). */
|
|
26
|
+
export type UserSpawnStrategy = "tmux" | "headless";
|
|
27
|
+
|
|
28
|
+
export interface SpawnMechanismContext {
|
|
29
|
+
platform: NodeJS.Platform;
|
|
30
|
+
userStrategy: UserSpawnStrategy;
|
|
31
|
+
electronMode: boolean;
|
|
32
|
+
available: {
|
|
33
|
+
tmux: boolean;
|
|
34
|
+
wt: boolean;
|
|
35
|
+
wslTmux: boolean;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Select one spawn mechanism for this platform given the user's
|
|
41
|
+
* preference, the electron-mode flag, and tool availability.
|
|
42
|
+
*
|
|
43
|
+
* Rules (in order):
|
|
44
|
+
* 1. electronMode forces "headless".
|
|
45
|
+
* 2. userStrategy "headless" forces "headless".
|
|
46
|
+
* 3. Unix (linux/darwin): tmux if available, else headless.
|
|
47
|
+
* 4. Windows: wt > wsl-tmux > headless.
|
|
48
|
+
* 5. Any other platform falls back to headless.
|
|
49
|
+
*/
|
|
50
|
+
export function selectMechanism(ctx: SpawnMechanismContext): SpawnMechanism {
|
|
51
|
+
if (ctx.electronMode) return "headless";
|
|
52
|
+
if (ctx.userStrategy === "headless") return "headless";
|
|
53
|
+
|
|
54
|
+
if (ctx.platform === "linux" || ctx.platform === "darwin") {
|
|
55
|
+
return ctx.available.tmux ? "tmux" : "headless";
|
|
56
|
+
}
|
|
57
|
+
if (ctx.platform === "win32") {
|
|
58
|
+
if (ctx.available.wt) return "wt";
|
|
59
|
+
if (ctx.available.wslTmux) return "wsl-tmux";
|
|
60
|
+
return "headless";
|
|
61
|
+
}
|
|
62
|
+
return "headless";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Windows Terminal argv builder ───────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export interface WtArgsOptions {
|
|
68
|
+
/** Absolute cwd for the new tab. Spaces / parens / quotes are safe in argv form. */
|
|
69
|
+
cwd: string;
|
|
70
|
+
/** Tab title, typically the basename of cwd. */
|
|
71
|
+
title: string;
|
|
72
|
+
/**
|
|
73
|
+
* Pre-resolved pi argv: typically [node.exe, cli.js, --mode?, rpc?, --fork?, file?].
|
|
74
|
+
* Interactive wt sessions OMIT --mode rpc so pi runs its TUI.
|
|
75
|
+
*/
|
|
76
|
+
piArgv: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build argv (NOT a shell string) to invoke Windows Terminal so it opens
|
|
81
|
+
* a new tab in the existing WT window and runs `piArgv` there.
|
|
82
|
+
*
|
|
83
|
+
* Design notes:
|
|
84
|
+
* • argv form — passed to spawn with shell:false, so wt re-parses it
|
|
85
|
+
* internally. No need to escape spaces, semicolons, or quotes in cwd.
|
|
86
|
+
* • `-w 0` reuses the most-recently-used WT window; new tab, not new
|
|
87
|
+
* window. Matches tmux `new-window` semantics.
|
|
88
|
+
* • No `-p <profile>` — respect the user's default WT profile
|
|
89
|
+
* (cmd / pwsh / WSL).
|
|
90
|
+
* • `--` sentinel before piArgv so any `-` or `/` prefix in piArgv
|
|
91
|
+
* can't be misparsed as a wt option.
|
|
92
|
+
*/
|
|
93
|
+
export function buildWtArgs(opts: WtArgsOptions): string[] {
|
|
94
|
+
return [
|
|
95
|
+
"-w", "0",
|
|
96
|
+
"new-tab",
|
|
97
|
+
"-d", opts.cwd,
|
|
98
|
+
"--title", opts.title,
|
|
99
|
+
"--",
|
|
100
|
+
...opts.piArgv,
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Shared helper: append session/fork flags uniformly ─────────────────────
|
|
105
|
+
|
|
106
|
+
export interface SessionFlags {
|
|
107
|
+
sessionFile?: string;
|
|
108
|
+
mode?: "continue" | "fork";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Return `["--session", file]` or `["--fork", file]` or `[]`.
|
|
113
|
+
* Every mechanism MUST use this to append flags; dropping them silently
|
|
114
|
+
* is the exact bug that motivated this change (B1, B2).
|
|
115
|
+
*/
|
|
116
|
+
export function sessionFlagsToArgv(flags: SessionFlags): string[] {
|
|
117
|
+
if (flags.sessionFile && flags.mode === "continue") {
|
|
118
|
+
return ["--session", flags.sessionFile];
|
|
119
|
+
}
|
|
120
|
+
if (flags.sessionFile && flags.mode === "fork") {
|
|
121
|
+
return ["--fork", flags.sessionFile];
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subprocess adapter — strategy pattern for OS-aware subprocess invocation.
|
|
3
|
+
*
|
|
4
|
+
* The adapter is the single point of entry for spawning any subprocess
|
|
5
|
+
* from dashboard code or from libraries we wrap. It dispatches to a
|
|
6
|
+
* platform-specific implementation:
|
|
7
|
+
*
|
|
8
|
+
* - Windows: `.cmd`/`.bat` shims go through explicit `cmd.exe /d /s /c`
|
|
9
|
+
* invocation with `windowsHide: true` and `shell: false` (the only
|
|
10
|
+
* reliable way to avoid Node issue #21825's flashing console).
|
|
11
|
+
* Native `.exe`s spawn directly.
|
|
12
|
+
* - Unix: direct spawn, no shell, no special cases.
|
|
13
|
+
*
|
|
14
|
+
* Why an adapter instead of a global monkey-patch?
|
|
15
|
+
*
|
|
16
|
+
* - Explicit dependency injection. Callers (and tests) know exactly
|
|
17
|
+
* which spawn implementation they get.
|
|
18
|
+
* - Isolated — third-party code that needs this behaviour gets it via
|
|
19
|
+
* a thin subclass that consumes the adapter (see
|
|
20
|
+
* `createSafePackageManagerClass` in
|
|
21
|
+
* `packages/server/src/package-manager-wrapper.ts`). No cross-
|
|
22
|
+
* cutting global state.
|
|
23
|
+
* - Testable: fake adapter => assert argv without spawning real
|
|
24
|
+
* subprocesses.
|
|
25
|
+
*
|
|
26
|
+
* See change: consolidate-windows-spawn-and-platform-handlers.
|
|
27
|
+
*/
|
|
28
|
+
import type {
|
|
29
|
+
ChildProcess,
|
|
30
|
+
SpawnOptions,
|
|
31
|
+
SpawnSyncOptions,
|
|
32
|
+
SpawnSyncReturns,
|
|
33
|
+
} from "node:child_process";
|
|
34
|
+
import {
|
|
35
|
+
spawn as safeSpawn,
|
|
36
|
+
spawnSync as safeSpawnSync,
|
|
37
|
+
buildSafeArgv,
|
|
38
|
+
} from "./exec.js";
|
|
39
|
+
|
|
40
|
+
// ── Interface ──────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cross-platform subprocess adapter. Implementations guarantee:
|
|
44
|
+
* - `windowsHide: true` on Windows, always.
|
|
45
|
+
* - No `shell: true` ever — `.cmd` shims are invoked via explicit
|
|
46
|
+
* `cmd.exe /d /s /c` argv.
|
|
47
|
+
* - Arg arrays are passed verbatim, no shell-escaping surprises.
|
|
48
|
+
*/
|
|
49
|
+
export interface SubprocessAdapter {
|
|
50
|
+
/** Async spawn. Returns the live ChildProcess. */
|
|
51
|
+
spawn(command: string, args?: readonly string[], options?: SpawnOptions): ChildProcess;
|
|
52
|
+
|
|
53
|
+
/** Synchronous spawn. Blocks until completion. */
|
|
54
|
+
spawnSync<T extends string | Buffer = Buffer>(
|
|
55
|
+
command: string,
|
|
56
|
+
args?: readonly string[],
|
|
57
|
+
options?: SpawnSyncOptions,
|
|
58
|
+
): SpawnSyncReturns<T>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Windows implementation ─────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
class WindowsSubprocessAdapter implements SubprocessAdapter {
|
|
64
|
+
spawn(command: string, args: readonly string[] = [], options?: SpawnOptions): ChildProcess {
|
|
65
|
+
const { argv, spawnOptions } = buildSafeArgv(command, args, "win32");
|
|
66
|
+
return safeSpawn(argv[0], argv.slice(1), { ...(options ?? {}), ...spawnOptions });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
spawnSync<T extends string | Buffer = Buffer>(
|
|
70
|
+
command: string,
|
|
71
|
+
args: readonly string[] = [],
|
|
72
|
+
options?: SpawnSyncOptions,
|
|
73
|
+
): SpawnSyncReturns<T> {
|
|
74
|
+
const { argv, spawnOptions } = buildSafeArgv(command, args, "win32");
|
|
75
|
+
return safeSpawnSync<T>(argv[0], argv.slice(1), { ...(options ?? {}), ...spawnOptions });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Unix implementation ────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
class UnixSubprocessAdapter implements SubprocessAdapter {
|
|
82
|
+
spawn(command: string, args: readonly string[] = [], options?: SpawnOptions): ChildProcess {
|
|
83
|
+
return safeSpawn(command, args, { ...(options ?? {}), shell: false });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
spawnSync<T extends string | Buffer = Buffer>(
|
|
87
|
+
command: string,
|
|
88
|
+
args: readonly string[] = [],
|
|
89
|
+
options?: SpawnSyncOptions,
|
|
90
|
+
): SpawnSyncReturns<T> {
|
|
91
|
+
return safeSpawnSync<T>(command, args, { ...(options ?? {}), shell: false });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Factory ────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Return the appropriate adapter for the given platform. Default:
|
|
99
|
+
* `process.platform`. Tests pass explicit values without mutating the
|
|
100
|
+
* global.
|
|
101
|
+
*/
|
|
102
|
+
export function createSubprocessAdapter(
|
|
103
|
+
platform: NodeJS.Platform = process.platform,
|
|
104
|
+
): SubprocessAdapter {
|
|
105
|
+
if (platform === "win32") return new WindowsSubprocessAdapter();
|
|
106
|
+
return new UnixSubprocessAdapter();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Process-wide default adapter. Constructed lazily on first access.
|
|
111
|
+
* Callers that want a different strategy (e.g. tests injecting a fake)
|
|
112
|
+
* pass the adapter explicitly to their constructor instead of using
|
|
113
|
+
* this singleton.
|
|
114
|
+
*/
|
|
115
|
+
let defaultAdapter: SubprocessAdapter | null = null;
|
|
116
|
+
export function getDefaultSubprocessAdapter(): SubprocessAdapter {
|
|
117
|
+
if (!defaultAdapter) defaultAdapter = createSubprocessAdapter();
|
|
118
|
+
return defaultAdapter;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Test-only: drop the cached default adapter. */
|
|
122
|
+
export function _resetDefaultSubprocessAdapter(): void {
|
|
123
|
+
defaultAdapter = null;
|
|
124
|
+
}
|