@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +67 -116
- package/README.md +93 -7
- package/docs/architecture.md +408 -9
- package/package.json +6 -4
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
- package/packages/extension/src/__tests__/git-info.test.ts +67 -55
- package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
- package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
- package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
- package/packages/extension/src/bridge.ts +69 -2
- package/packages/extension/src/dev-build.ts +1 -1
- package/packages/extension/src/git-info.ts +9 -19
- package/packages/extension/src/pi-env.d.ts +1 -0
- package/packages/extension/src/process-scanner.ts +72 -38
- package/packages/extension/src/provider-register.ts +304 -16
- package/packages/extension/src/server-auto-start.ts +27 -1
- package/packages/extension/src/server-launcher.ts +71 -27
- package/packages/server/package.json +16 -2
- package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
- package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
- package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
- package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
- package/packages/server/src/__tests__/config-api.test.ts +68 -0
- package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
- package/packages/server/src/__tests__/directory-service.test.ts +234 -8
- package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
- package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
- package/packages/server/src/__tests__/extension-register.test.ts +3 -1
- package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
- package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
- package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
- package/packages/server/src/__tests__/home-lock.test.ts +308 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
- package/packages/server/src/__tests__/node-guard.test.ts +85 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
- package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
- package/packages/server/src/__tests__/process-manager.test.ts +45 -18
- package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
- package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
- package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
- package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
- package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
- package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
- package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
- package/packages/server/src/__tests__/tunnel.test.ts +13 -7
- package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
- package/packages/server/src/bootstrap-queue.ts +130 -0
- package/packages/server/src/bootstrap-state.ts +131 -0
- package/packages/server/src/browse.ts +8 -3
- package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
- package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
- package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
- package/packages/server/src/cli.ts +256 -32
- package/packages/server/src/config-api.ts +16 -0
- package/packages/server/src/directory-service.ts +270 -39
- package/packages/server/src/editor-detection.ts +12 -9
- package/packages/server/src/editor-manager.ts +19 -4
- package/packages/server/src/editor-pid-registry.ts +9 -8
- package/packages/server/src/editor-registry.ts +22 -25
- package/packages/server/src/git-operations.ts +1 -1
- package/packages/server/src/headless-pid-registry.ts +7 -20
- package/packages/server/src/home-lock-release.ts +72 -0
- package/packages/server/src/home-lock.ts +389 -0
- package/packages/server/src/node-guard.ts +52 -0
- package/packages/server/src/package-manager-wrapper.ts +207 -47
- package/packages/server/src/pi-core-checker.ts +1 -1
- package/packages/server/src/pi-core-updater.ts +7 -1
- package/packages/server/src/pi-resource-scanner.ts +5 -8
- package/packages/server/src/pi-version-skew.ts +196 -0
- package/packages/server/src/preferences-store.ts +17 -3
- package/packages/server/src/process-manager.ts +403 -222
- package/packages/server/src/provider-probe.ts +234 -0
- package/packages/server/src/restart-helper.ts +130 -0
- package/packages/server/src/routes/bootstrap-routes.ts +88 -0
- package/packages/server/src/routes/openspec-routes.ts +25 -1
- package/packages/server/src/routes/pi-core-routes.ts +24 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -8
- package/packages/server/src/routes/provider-routes.ts +43 -0
- package/packages/server/src/routes/recommended-routes.ts +10 -12
- package/packages/server/src/routes/system-routes.ts +20 -33
- package/packages/server/src/routes/tool-routes.ts +153 -0
- package/packages/server/src/server-pid.ts +5 -9
- package/packages/server/src/server.ts +211 -10
- package/packages/server/src/session-api.ts +77 -8
- package/packages/server/src/session-bootstrap.ts +17 -3
- package/packages/server/src/session-diff.ts +21 -21
- package/packages/server/src/terminal-manager.ts +61 -20
- package/packages/server/src/tunnel.ts +42 -28
- package/packages/shared/package.json +10 -3
- package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
- package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
- package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
- package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
- package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
- package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
- package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
- package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
- package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
- package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
- package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
- package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
- package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
- package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
- package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
- package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
- package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
- package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
- package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
- package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
- package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
- package/packages/shared/src/__tests__/config.test.ts +56 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
- package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
- package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
- package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
- package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
- package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
- package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
- package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
- package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
- package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
- package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
- package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
- package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
- package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
- package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
- package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
- package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
- package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
- package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
- package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
- package/packages/shared/src/bootstrap-install.ts +212 -0
- package/packages/shared/src/bridge-register.ts +87 -20
- package/packages/shared/src/browser-protocol.ts +71 -1
- package/packages/shared/src/config.ts +87 -15
- package/packages/shared/src/managed-paths.ts +31 -4
- package/packages/shared/src/openspec-poller.ts +63 -46
- package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
- package/packages/shared/src/platform/commands.ts +100 -0
- package/packages/shared/src/platform/detached-spawn.ts +305 -0
- package/packages/shared/src/platform/exec.ts +220 -0
- package/packages/shared/src/platform/git.ts +155 -0
- package/packages/shared/src/platform/index.ts +15 -0
- package/packages/shared/src/platform/npm.ts +162 -0
- package/packages/shared/src/platform/openspec.ts +91 -0
- package/packages/shared/src/platform/paths.ts +276 -0
- package/packages/shared/src/platform/process-identify.ts +126 -0
- package/packages/shared/src/platform/process-scan.ts +94 -0
- package/packages/shared/src/platform/process.ts +168 -0
- package/packages/shared/src/platform/runner.ts +369 -0
- package/packages/shared/src/platform/shell.ts +44 -0
- package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
- package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
- package/packages/shared/src/recommended-extensions.ts +18 -2
- package/packages/shared/src/resolve-jiti.ts +62 -3
- package/packages/shared/src/rest-api.ts +26 -0
- package/packages/shared/src/semaphore.ts +83 -0
- package/packages/shared/src/tool-registry/definitions.ts +342 -0
- package/packages/shared/src/tool-registry/index.ts +56 -0
- package/packages/shared/src/tool-registry/overrides.ts +118 -0
- package/packages/shared/src/tool-registry/registry.ts +262 -0
- package/packages/shared/src/tool-registry/strategies.ts +198 -0
- package/packages/shared/src/tool-registry/types.ts +180 -0
|
@@ -125,6 +125,21 @@ export interface SpawnResultBrowserMessage {
|
|
|
125
125
|
message: string;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Emitted when a session spawn fails — either because `spawnPiSession` threw,
|
|
130
|
+
* returned `{ success: false }`, or the spawned child crashed immediately.
|
|
131
|
+
* Carries enough context for the UI to render a retryable error banner
|
|
132
|
+
* instead of leaving the user staring at a silent empty state.
|
|
133
|
+
*/
|
|
134
|
+
export interface SpawnErrorMessage {
|
|
135
|
+
type: "spawn_error";
|
|
136
|
+
cwd: string;
|
|
137
|
+
strategy: string;
|
|
138
|
+
message: string;
|
|
139
|
+
/** Up to ~2 KB tail of stderr captured from the failed child, if any. */
|
|
140
|
+
stderr?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
128
143
|
export interface SessionsReorderedMessage {
|
|
129
144
|
type: "sessions_reordered";
|
|
130
145
|
cwd: string;
|
|
@@ -226,6 +241,58 @@ export interface PiCoreUpdateCompleteMessage {
|
|
|
226
241
|
sessionsReloaded: number;
|
|
227
242
|
}
|
|
228
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Bootstrap state snapshot. Mirrors `BootstrapState` in
|
|
246
|
+
* `packages/server/src/bootstrap-state.ts` but kept as a structural
|
|
247
|
+
* subset here so the shared package doesn't take a runtime dependency
|
|
248
|
+
* on the server package.
|
|
249
|
+
*
|
|
250
|
+
* See change: unified-bootstrap-install.
|
|
251
|
+
*/
|
|
252
|
+
export interface BootstrapStateSnapshot {
|
|
253
|
+
status: "ready" | "installing" | "failed";
|
|
254
|
+
progress?: { step: string; pct?: number; output?: string };
|
|
255
|
+
error?: { message: string; stack?: string };
|
|
256
|
+
version?: { pi?: string; openspec?: string; tsx?: string };
|
|
257
|
+
compatibility?: {
|
|
258
|
+
minimum: string;
|
|
259
|
+
recommended: string;
|
|
260
|
+
maximum: string | null;
|
|
261
|
+
current?: string;
|
|
262
|
+
upgradeRecommended?: boolean;
|
|
263
|
+
upgradeDashboard?: boolean;
|
|
264
|
+
};
|
|
265
|
+
bridgeRegistrationError?: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Broadcast on every bootstrap-state transition. Browsers use this to
|
|
270
|
+
* render the first-run install banner, the upgrade-pi progress display,
|
|
271
|
+
* and version-skew hints.
|
|
272
|
+
*/
|
|
273
|
+
export interface BootstrapStatusUpdateMessage {
|
|
274
|
+
type: "bootstrap_status_update";
|
|
275
|
+
state: BootstrapStateSnapshot;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Broadcast when a queued pi-dependent operation (e.g. a session-spawn
|
|
280
|
+
* request accepted with 202 during "installing") finishes running after
|
|
281
|
+
* the bootstrap transitioned to "ready". Clients that stored a ticketId
|
|
282
|
+
* from the 202 response can correlate the outcome via this message.
|
|
283
|
+
*
|
|
284
|
+
* `success` is true when the queued handler resolved without throwing;
|
|
285
|
+
* `error` carries the thrown message string when `success` is false.
|
|
286
|
+
*
|
|
287
|
+
* See change: unified-bootstrap-install.
|
|
288
|
+
*/
|
|
289
|
+
export interface BootstrapTicketCompleteMessage {
|
|
290
|
+
type: "bootstrap_ticket_complete";
|
|
291
|
+
ticketId: string;
|
|
292
|
+
success: boolean;
|
|
293
|
+
error?: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
229
296
|
/** Sent when a package operation finishes (success or failure). */
|
|
230
297
|
export interface PackageOperationCompleteMessage {
|
|
231
298
|
type: "package_operation_complete";
|
|
@@ -255,6 +322,7 @@ export type ServerToBrowserMessage =
|
|
|
255
322
|
| SessionsListBrowserMessage
|
|
256
323
|
| ResumeResultBrowserMessage
|
|
257
324
|
| SpawnResultBrowserMessage
|
|
325
|
+
| SpawnErrorMessage
|
|
258
326
|
| SessionsReorderedMessage
|
|
259
327
|
| PinnedDirsUpdatedMessage
|
|
260
328
|
| TerminalAddedMessage
|
|
@@ -274,7 +342,9 @@ export type ServerToBrowserMessage =
|
|
|
274
342
|
| BrowserPromptRequestMessage
|
|
275
343
|
| BrowserPromptDismissMessage
|
|
276
344
|
| BrowserPromptCancelMessage
|
|
277
|
-
| ModelsRefreshedMessage
|
|
345
|
+
| ModelsRefreshedMessage
|
|
346
|
+
| BootstrapStatusUpdateMessage
|
|
347
|
+
| BootstrapTicketCompleteMessage;
|
|
278
348
|
|
|
279
349
|
// ── Browser → Server ────────────────────────────────────────────────
|
|
280
350
|
|
|
@@ -41,6 +41,24 @@ export const DEFAULT_MEMORY_LIMITS: MemoryLimitsConfig = {
|
|
|
41
41
|
maxWsBufferBytes: 4 * 1024 * 1024,
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
export interface OpenSpecPollConfig {
|
|
45
|
+
/** Poll interval in seconds. Default 30. Clamped to [5, 3600]. */
|
|
46
|
+
pollIntervalSeconds: number;
|
|
47
|
+
/** Max concurrent `openspec` CLI invocations across all dirs. Default 3. Clamped to [1, 16]. */
|
|
48
|
+
maxConcurrentSpawns: number;
|
|
49
|
+
/** `"mtime"` skips re-polling unchanged changes; `"always"` polls unconditionally. Default `"mtime"`. */
|
|
50
|
+
changeDetection: "mtime" | "always";
|
|
51
|
+
/** Max per-directory phase jitter in seconds. 0 disables jitter. Default 5. Clamped to [0, 60]. */
|
|
52
|
+
jitterSeconds: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_OPENSPEC_POLL: OpenSpecPollConfig = {
|
|
56
|
+
pollIntervalSeconds: 30,
|
|
57
|
+
maxConcurrentSpawns: 3,
|
|
58
|
+
changeDetection: "mtime",
|
|
59
|
+
jitterSeconds: 5,
|
|
60
|
+
};
|
|
61
|
+
|
|
44
62
|
export interface EditorConfig {
|
|
45
63
|
/** Override path to code-server binary */
|
|
46
64
|
binary?: string;
|
|
@@ -75,6 +93,8 @@ export interface DashboardConfig {
|
|
|
75
93
|
defaultModel: string;
|
|
76
94
|
memoryLimits: MemoryLimitsConfig;
|
|
77
95
|
editor: EditorConfig;
|
|
96
|
+
/** OpenSpec background polling behavior (interval, concurrency, change detection, jitter) */
|
|
97
|
+
openspec: OpenSpecPollConfig;
|
|
78
98
|
/** Networks trusted for full access without authentication (CIDR, wildcard, exact IP) */
|
|
79
99
|
trustedNetworks: string[];
|
|
80
100
|
/** Merged trustedNetworks + auth.bypassHosts (deduplicated). Computed at load time. */
|
|
@@ -108,6 +128,7 @@ const DEFAULTS: DashboardConfig = {
|
|
|
108
128
|
defaultModel: "",
|
|
109
129
|
memoryLimits: { ...DEFAULT_MEMORY_LIMITS },
|
|
110
130
|
editor: { ...DEFAULT_EDITOR_CONFIG },
|
|
131
|
+
openspec: { ...DEFAULT_OPENSPEC_POLL },
|
|
111
132
|
trustedNetworks: [],
|
|
112
133
|
resolvedTrustedNetworks: [],
|
|
113
134
|
cors: { allowedOrigins: [] },
|
|
@@ -117,28 +138,57 @@ const DEFAULTS: DashboardConfig = {
|
|
|
117
138
|
|
|
118
139
|
/**
|
|
119
140
|
* Parse and validate the auth config section.
|
|
120
|
-
*
|
|
141
|
+
*
|
|
142
|
+
* Returns undefined ONLY when nothing auth-relevant is configured — that is,
|
|
143
|
+
* when none of `providers`, `bypassHosts`, or `bypassUrls` has any content.
|
|
144
|
+
*
|
|
145
|
+
* When providers is empty but bypassHosts or bypassUrls is populated, this
|
|
146
|
+
* function returns a valid AuthConfig with an empty providers map. The auth
|
|
147
|
+
* plugin already no-ops in that case (providerRegistry.size === 0 → skip
|
|
148
|
+
* OAuth route + cookie plugin registration), so no OAuth flow activates
|
|
149
|
+
* accidentally. But returning an object here lets the caller populate
|
|
150
|
+
* resolvedTrustedNetworks from auth.bypassHosts — which is the entire
|
|
151
|
+
* point of allowing this shape. Before this change, parseAuthConfig
|
|
152
|
+
* returned undefined on empty-providers, which nuked auth.bypassHosts
|
|
153
|
+
* before the resolvedTrustedNetworks merge could read it, and users
|
|
154
|
+
* without OAuth lost remote network access after the UI started writing
|
|
155
|
+
* to auth.bypassHosts. See openspec/changes/fix-trusted-networks-no-oauth.
|
|
121
156
|
*/
|
|
122
157
|
function parseAuthConfig(raw: any): AuthConfig | undefined {
|
|
123
158
|
if (!raw || typeof raw !== "object") return undefined;
|
|
124
159
|
const providers = raw.providers;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
160
|
+
const hasProviders =
|
|
161
|
+
providers && typeof providers === "object" && Object.keys(providers).length > 0;
|
|
162
|
+
const hasHosts = Array.isArray(raw.bypassHosts) && raw.bypassHosts.length > 0;
|
|
163
|
+
const hasUrls = Array.isArray(raw.bypassUrls) && raw.bypassUrls.length > 0;
|
|
164
|
+
if (!hasProviders && !hasHosts && !hasUrls) return undefined;
|
|
165
|
+
|
|
166
|
+
// Validate each provider has at least clientId and clientSecret.
|
|
167
|
+
// validProviders may end up empty when providers is {} or all entries
|
|
168
|
+
// are malformed — that's fine, the caller tolerates it as long as
|
|
169
|
+
// bypassHosts or bypassUrls carries the auth-relevant content.
|
|
129
170
|
const validProviders: Record<string, AuthProviderConfig> = {};
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
171
|
+
if (hasProviders) {
|
|
172
|
+
for (const [key, value] of Object.entries(providers as Record<string, unknown>)) {
|
|
173
|
+
const p = value as any;
|
|
174
|
+
if (p && typeof p === "object" && p.clientId && p.clientSecret) {
|
|
175
|
+
validProviders[key] = {
|
|
176
|
+
clientId: p.clientId,
|
|
177
|
+
clientSecret: p.clientSecret,
|
|
178
|
+
...(p.issuerUrl ? { issuerUrl: p.issuerUrl } : {}),
|
|
179
|
+
...(p.name ? { name: p.name } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
139
182
|
}
|
|
140
183
|
}
|
|
141
|
-
|
|
184
|
+
|
|
185
|
+
// If providers was declared but all entries are malformed AND there is no
|
|
186
|
+
// bypass content, fall back to undefined — same "nothing auth-relevant"
|
|
187
|
+
// rule as the top-level gate.
|
|
188
|
+
if (Object.keys(validProviders).length === 0 && !hasHosts && !hasUrls) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
142
192
|
return {
|
|
143
193
|
secret: raw.secret ?? "",
|
|
144
194
|
providers: validProviders,
|
|
@@ -157,6 +207,27 @@ function parseEditorConfig(raw: any): EditorConfig {
|
|
|
157
207
|
};
|
|
158
208
|
}
|
|
159
209
|
|
|
210
|
+
function clampNumber(raw: any, fallback: number, min: number, max: number): number {
|
|
211
|
+
const n = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
|
|
212
|
+
if (n < min) return min;
|
|
213
|
+
if (n > max) return max;
|
|
214
|
+
return n;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseOpenSpecPollConfig(raw: any): OpenSpecPollConfig {
|
|
218
|
+
if (!raw || typeof raw !== "object") return { ...DEFAULT_OPENSPEC_POLL };
|
|
219
|
+
const changeDetection =
|
|
220
|
+
raw.changeDetection === "always" || raw.changeDetection === "mtime"
|
|
221
|
+
? raw.changeDetection
|
|
222
|
+
: DEFAULT_OPENSPEC_POLL.changeDetection;
|
|
223
|
+
return {
|
|
224
|
+
pollIntervalSeconds: clampNumber(raw.pollIntervalSeconds, DEFAULT_OPENSPEC_POLL.pollIntervalSeconds, 5, 3600),
|
|
225
|
+
maxConcurrentSpawns: clampNumber(raw.maxConcurrentSpawns, DEFAULT_OPENSPEC_POLL.maxConcurrentSpawns, 1, 16),
|
|
226
|
+
changeDetection,
|
|
227
|
+
jitterSeconds: clampNumber(raw.jitterSeconds, DEFAULT_OPENSPEC_POLL.jitterSeconds, 0, 60),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
160
231
|
function parseMemoryLimits(raw: any): MemoryLimitsConfig {
|
|
161
232
|
if (!raw || typeof raw !== "object") return { ...DEFAULT_MEMORY_LIMITS };
|
|
162
233
|
return {
|
|
@@ -217,6 +288,7 @@ export function loadConfig(): DashboardConfig {
|
|
|
217
288
|
auth: parseAuthConfig(parsed.auth),
|
|
218
289
|
memoryLimits: parseMemoryLimits(parsed.memoryLimits),
|
|
219
290
|
editor: parseEditorConfig(parsed.editor),
|
|
291
|
+
openspec: parseOpenSpecPollConfig(parsed.openspec),
|
|
220
292
|
trustedNetworks: parseTrustedNetworks(parsed.trustedNetworks),
|
|
221
293
|
resolvedTrustedNetworks: [],
|
|
222
294
|
cors: {
|
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared constants for the managed install directory (~/.pi-dashboard/).
|
|
2
|
+
* Shared constants + getters for the managed install directory (~/.pi-dashboard/).
|
|
3
3
|
* Single source of truth — all packages import from here.
|
|
4
|
+
*
|
|
5
|
+
* Constants (MANAGED_DIR, MANAGED_BIN, PI_SETTINGS_PATH) reflect the live
|
|
6
|
+
* environment at module-load time. Production code continues to use them.
|
|
7
|
+
*
|
|
8
|
+
* Getters (getManagedDir, getManagedBin, getPiSettingsPath) accept an
|
|
9
|
+
* optional `{ homedir }` override so tests (and the bootstrap harness)
|
|
10
|
+
* can reason about alternate HOME directories without mutating globals.
|
|
4
11
|
*/
|
|
5
12
|
import path from "node:path";
|
|
6
13
|
import os from "node:os";
|
|
7
14
|
|
|
15
|
+
/** Env override surface used by the getters (subset of PlatformEnv). */
|
|
16
|
+
export interface ManagedPathsEnv {
|
|
17
|
+
homedir?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Root directory for managed installs (pi, openspec, tsx). */
|
|
21
|
+
export function getManagedDir(env?: ManagedPathsEnv): string {
|
|
22
|
+
return path.join(env?.homedir ?? os.homedir(), ".pi-dashboard");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Bin directory for managed install executables. */
|
|
26
|
+
export function getManagedBin(env?: ManagedPathsEnv): string {
|
|
27
|
+
return path.join(getManagedDir(env), "node_modules", ".bin");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Path to pi's global settings file. */
|
|
31
|
+
export function getPiSettingsPath(env?: ManagedPathsEnv): string {
|
|
32
|
+
return path.join(env?.homedir ?? os.homedir(), ".pi", "agent", "settings.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
8
35
|
/** Root directory for managed installs (pi, openspec, tsx). */
|
|
9
|
-
export const MANAGED_DIR =
|
|
36
|
+
export const MANAGED_DIR = getManagedDir();
|
|
10
37
|
|
|
11
38
|
/** Bin directory for managed install executables. */
|
|
12
|
-
export const MANAGED_BIN =
|
|
39
|
+
export const MANAGED_BIN = getManagedBin();
|
|
13
40
|
|
|
14
41
|
/** Path to pi's global settings file. */
|
|
15
|
-
export const PI_SETTINGS_PATH =
|
|
42
|
+
export const PI_SETTINGS_PATH = getPiSettingsPath();
|
|
@@ -1,45 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Polls the openspec CLI to gather change data for the session's project.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* This module is a thin aggregator over `platform/openspec.ts`: it
|
|
5
|
+
* calls the Recipe-based primitives and combines `list` + per-change
|
|
6
|
+
* `status` into the dashboard's `OpenSpecData` shape.
|
|
7
|
+
*
|
|
8
|
+
* Two public flavors:
|
|
9
|
+
*
|
|
10
|
+
* - `pollOpenSpec` (sync) — for the bridge extension where async
|
|
11
|
+
* isn't practical. Uses `run()` under the hood; each call blocks
|
|
12
|
+
* the event loop for ~200-2000ms per openspec invocation.
|
|
13
|
+
*
|
|
14
|
+
* - `pollOpenSpecAsync` (async) — for the server's directory service.
|
|
15
|
+
* Routes through the runner's `runAsync()` so every spawn goes
|
|
16
|
+
* through the same binary resolution, `.cmd` shell handling, and
|
|
17
|
+
* `windowsHide: true` default as everything else. Status queries
|
|
18
|
+
* run in parallel via `Promise.all`, keeping the event loop free
|
|
19
|
+
* on Windows where openspec.cmd startup is slow (~2s per call).
|
|
20
|
+
*
|
|
21
|
+
* See change: consolidate-tool-resolution.
|
|
4
22
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
23
|
+
import { listOr, statusOr, OPENSPEC_LIST, OPENSPEC_STATUS } from "./platform/openspec.js";
|
|
24
|
+
import { runAsync, unwrap } from "./platform/runner.js";
|
|
7
25
|
import type { OpenSpecData, OpenSpecChange, OpenSpecArtifact } from "./types.js";
|
|
8
26
|
|
|
9
|
-
const execFileAsync = promisify(execFile);
|
|
10
27
|
const EMPTY_DATA: OpenSpecData = { initialized: false, changes: [] };
|
|
11
28
|
|
|
12
|
-
/** Synchronous version — only used by bridge extension where async isn't practical */
|
|
13
|
-
function runOpenSpecSync(args: string[], cwd: string): unknown | null {
|
|
14
|
-
try {
|
|
15
|
-
const result = spawnSync("openspec", args, {
|
|
16
|
-
cwd,
|
|
17
|
-
encoding: "utf-8",
|
|
18
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
19
|
-
timeout: 10_000,
|
|
20
|
-
});
|
|
21
|
-
if (result.status !== 0 || !result.stdout) return null;
|
|
22
|
-
return JSON.parse(result.stdout);
|
|
23
|
-
} catch {
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Async version — non-blocking, used by server */
|
|
29
|
-
async function runOpenSpecAsync(args: string[], cwd: string): Promise<unknown | null> {
|
|
30
|
-
try {
|
|
31
|
-
const { stdout } = await execFileAsync("openspec", args, {
|
|
32
|
-
cwd,
|
|
33
|
-
encoding: "utf-8",
|
|
34
|
-
timeout: 10_000,
|
|
35
|
-
});
|
|
36
|
-
if (!stdout) return null;
|
|
37
|
-
return JSON.parse(stdout);
|
|
38
|
-
} catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
29
|
export function buildOpenSpecData(
|
|
44
30
|
listResult: { changes?: Array<{ name: string; status: string; completedTasks: number; totalTasks: number }> } | null,
|
|
45
31
|
statusResults: Map<string, { artifacts?: Array<{ id: string; status: string }>; isComplete?: boolean } | null>,
|
|
@@ -72,30 +58,61 @@ export function buildOpenSpecData(
|
|
|
72
58
|
return { initialized: true, changes };
|
|
73
59
|
}
|
|
74
60
|
|
|
75
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Synchronous poll — blocks the event loop. Used by the bridge extension
|
|
63
|
+
* where async isn't practical (some pi extension hooks are sync).
|
|
64
|
+
*/
|
|
76
65
|
export function pollOpenSpec(cwd: string): OpenSpecData {
|
|
77
|
-
const listResult =
|
|
66
|
+
const listResult = listOr({ cwd }) as any;
|
|
78
67
|
if (!listResult || !Array.isArray(listResult.changes)) return EMPTY_DATA;
|
|
79
68
|
|
|
80
69
|
const statusResults = new Map<string, any>();
|
|
81
70
|
for (const c of listResult.changes) {
|
|
82
|
-
statusResults.set(c.name,
|
|
71
|
+
statusResults.set(c.name, statusOr({ cwd, change: c.name }));
|
|
83
72
|
}
|
|
84
73
|
return buildOpenSpecData(listResult, statusResults);
|
|
85
74
|
}
|
|
86
75
|
|
|
87
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Run `openspec list --json` for a single cwd. Exposed so callers that
|
|
78
|
+
* want their own concurrency control or mtime-gate logic can compose
|
|
79
|
+
* the list + per-change status calls themselves.
|
|
80
|
+
*/
|
|
81
|
+
export async function runOpenSpecList(cwd: string): Promise<
|
|
82
|
+
| { changes?: Array<{ name: string; status: string; completedTasks: number; totalTasks: number }> }
|
|
83
|
+
| null
|
|
84
|
+
> {
|
|
85
|
+
return unwrap(await runAsync(OPENSPEC_LIST, { cwd }, { cwd }), null) as any;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run `openspec status --change <name> --json` for a single change.
|
|
90
|
+
* Exposed for the same reason as `runOpenSpecList`.
|
|
91
|
+
*/
|
|
92
|
+
export async function runOpenSpecStatus(
|
|
93
|
+
cwd: string,
|
|
94
|
+
changeName: string,
|
|
95
|
+
): Promise<{ artifacts?: Array<{ id: string; status: string }>; isComplete?: boolean } | null> {
|
|
96
|
+
return unwrap(await runAsync(OPENSPEC_STATUS, { cwd, change: changeName }, { cwd }), null) as any;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Async poll — genuinely async. Runs per-change status queries in
|
|
101
|
+
* parallel via the shared `runAsync()`, so each spawn goes through the
|
|
102
|
+
* central binary resolution + `windowsHide: true` default.
|
|
103
|
+
*/
|
|
88
104
|
export async function pollOpenSpecAsync(cwd: string): Promise<OpenSpecData> {
|
|
89
|
-
const listResult = await
|
|
105
|
+
const listResult = unwrap(await runAsync(OPENSPEC_LIST, { cwd }, { cwd }), null) as
|
|
106
|
+
| { changes?: Array<{ name: string; status: string; completedTasks: number; totalTasks: number }> }
|
|
107
|
+
| null;
|
|
90
108
|
if (!listResult || !Array.isArray(listResult.changes)) return EMPTY_DATA;
|
|
91
109
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return [c.name, status];
|
|
110
|
+
const statusEntries = await Promise.all(
|
|
111
|
+
listResult.changes.map(async (c) => {
|
|
112
|
+
const result = await runAsync(OPENSPEC_STATUS, { cwd, change: c.name }, { cwd });
|
|
113
|
+
return [c.name, unwrap(result, null)] as const;
|
|
97
114
|
}),
|
|
98
115
|
);
|
|
99
|
-
const statusResults = new Map<string, any>(
|
|
116
|
+
const statusResults = new Map<string, any>(statusEntries);
|
|
100
117
|
return buildOpenSpecData(listResult, statusResults);
|
|
101
118
|
}
|
|
@@ -3,11 +3,31 @@
|
|
|
3
3
|
* Replaces scattered whichSync/resolvePiCommand/resolveTsxCommand implementations
|
|
4
4
|
* with a single configurable resolver.
|
|
5
5
|
*/
|
|
6
|
-
import { execSync } from "
|
|
6
|
+
import { execSync, spawnSync, buildSafeArgv } from "./exec.js";
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import os from "node:os";
|
|
10
|
-
import { MANAGED_BIN, MANAGED_DIR } from "
|
|
10
|
+
import { MANAGED_BIN, MANAGED_DIR } from "../managed-paths.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Well-known globalThis symbol for the default `ToolRegistry`.
|
|
14
|
+
*
|
|
15
|
+
* The registry publishes itself here when first constructed (see
|
|
16
|
+
* `tool-registry/index.ts::getDefaultRegistry`). Delegation avoids a
|
|
17
|
+
* static import cycle (tool-registry strategies already import from
|
|
18
|
+
* this file).
|
|
19
|
+
*
|
|
20
|
+
* See change: consolidate-windows-spawn-and-platform-handlers.
|
|
21
|
+
*/
|
|
22
|
+
const GLOBAL_REGISTRY_KEY = Symbol.for("pi-dashboard.tool-registry");
|
|
23
|
+
interface LazyRegistry {
|
|
24
|
+
has(n: string): boolean;
|
|
25
|
+
resolveExecutor(n: string): { ok: boolean; argv: string[] };
|
|
26
|
+
}
|
|
27
|
+
function tryGetRegistry(): LazyRegistry | null {
|
|
28
|
+
const reg = (globalThis as unknown as { [k: symbol]: LazyRegistry | undefined })[GLOBAL_REGISTRY_KEY];
|
|
29
|
+
return reg ?? null;
|
|
30
|
+
}
|
|
11
31
|
|
|
12
32
|
export interface ResolverContext {
|
|
13
33
|
/** Extra bin dirs to search before system PATH (e.g., bundled Node dir). */
|
|
@@ -59,25 +79,27 @@ export class ToolResolver {
|
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
/**
|
|
62
|
-
* Resolve pi as [cmd, ...prefixArgs]
|
|
63
|
-
*
|
|
82
|
+
* Resolve pi as spawn-ready argv `[cmd, ...prefixArgs]`.
|
|
83
|
+
*
|
|
84
|
+
* Fully delegates to `ToolRegistry.resolveExecutor("pi")`, which
|
|
85
|
+
* owns per-OS discovery + interpreter assembly (on Windows: find
|
|
86
|
+
* `pi-coding-agent/dist/cli.js` via managed/bare-import/npm-global
|
|
87
|
+
* and prepend `node.exe`; on Unix: find `pi` binary on PATH).
|
|
88
|
+
*
|
|
89
|
+
* Returns null when the registry is not yet constructed AND pi is
|
|
90
|
+
* not on PATH (very early boot / standalone tests). Production code
|
|
91
|
+
* always has the registry available before spawn.
|
|
64
92
|
*/
|
|
65
93
|
resolvePi(): string[] | null {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
const node = this.resolveNode();
|
|
71
|
-
if (node) return [node, piCli];
|
|
72
|
-
}
|
|
73
|
-
// Fallback to .cmd
|
|
74
|
-
const cmd = path.join(MANAGED_BIN, "pi.cmd");
|
|
75
|
-
if (existsSync(cmd)) return [cmd];
|
|
94
|
+
const registry = tryGetRegistry();
|
|
95
|
+
if (registry?.has("pi")) {
|
|
96
|
+
const exec = registry.resolveExecutor("pi");
|
|
97
|
+
if (exec.ok && exec.argv.length > 0) return exec.argv;
|
|
76
98
|
}
|
|
77
|
-
|
|
99
|
+
// No registry in this process (e.g. legacy bootstrap) — fall back
|
|
100
|
+
// to PATH lookup so the method still works for non-server callers.
|
|
78
101
|
const piPath = this.which("pi");
|
|
79
|
-
|
|
80
|
-
return null;
|
|
102
|
+
return piPath ? [piPath] : null;
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
/**
|
|
@@ -160,28 +182,106 @@ export class ToolResolver {
|
|
|
160
182
|
|
|
161
183
|
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
162
184
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Run `where|which <target>` and return ALL stdout lines (trimmed,
|
|
187
|
+
* non-empty), or `[]`.
|
|
188
|
+
*
|
|
189
|
+
* Uses `spawnSync` via `buildSafeArgv` — no shell interpretation at
|
|
190
|
+
* all. `execSync("where tmux")` used to route through cmd.exe (because
|
|
191
|
+
* execSync takes a shell command string); spawnSync with argv bypasses
|
|
192
|
+
* that entirely. Guaranteed no cmd.exe console flash.
|
|
193
|
+
*
|
|
194
|
+
* See change: consolidate-windows-spawn-and-platform-handlers.
|
|
195
|
+
*/
|
|
196
|
+
function whereAllLines(whichCmd: string, target: string): string[] {
|
|
166
197
|
try {
|
|
167
|
-
|
|
198
|
+
const { argv, spawnOptions } = buildSafeArgv(whichCmd, [target]);
|
|
199
|
+
const result = spawnSync<string>(argv[0], argv.slice(1), {
|
|
168
200
|
encoding: "utf-8",
|
|
169
201
|
stdio: ["pipe", "pipe", "pipe"],
|
|
170
|
-
|
|
202
|
+
...spawnOptions,
|
|
203
|
+
});
|
|
204
|
+
if (result.status !== 0) return [];
|
|
205
|
+
const text = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
|
|
206
|
+
return text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
171
207
|
} catch {
|
|
172
|
-
return
|
|
208
|
+
return [];
|
|
173
209
|
}
|
|
174
210
|
}
|
|
175
211
|
|
|
212
|
+
/** Extract the file extension (lower-cased, including the dot) from a path, or "". */
|
|
213
|
+
function extOf(p: string): string {
|
|
214
|
+
const slash = Math.max(p.lastIndexOf("\\"), p.lastIndexOf("/"));
|
|
215
|
+
const dot = p.lastIndexOf(".");
|
|
216
|
+
return dot > slash ? p.slice(dot).toLowerCase() : "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve a command on PATH.
|
|
221
|
+
*
|
|
222
|
+
* Unix: the first `which <name>` hit is authoritative.
|
|
223
|
+
*
|
|
224
|
+
* Windows: `where <name>` lists ALL PATH matches — a directory may contain
|
|
225
|
+
* both a bash shim (extensionless, e.g. `pi`) and a Windows-native form
|
|
226
|
+
* (`pi.cmd`). Node's `spawn()` cannot execute extensionless shims on
|
|
227
|
+
* Windows without `shell: true`, so we pick the first line whose extension
|
|
228
|
+
* is in PATHEXT. Falls back to the first line if none match (preserves
|
|
229
|
+
* whatever the user set up).
|
|
230
|
+
*
|
|
231
|
+
* Single `where` invocation — no per-extension probe loop — to keep
|
|
232
|
+
* resolution fast (especially when the command is missing entirely).
|
|
233
|
+
*/
|
|
234
|
+
function whichSync(cmd: string): string | null {
|
|
235
|
+
const isWin = process.platform === "win32";
|
|
236
|
+
if (!isWin) {
|
|
237
|
+
const lines = whereAllLines("which", cmd);
|
|
238
|
+
return lines[0] ?? null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const lines = whereAllLines("where", cmd);
|
|
242
|
+
if (lines.length === 0) return null;
|
|
243
|
+
|
|
244
|
+
// If the caller already specified an extension, trust their pick.
|
|
245
|
+
const callerHasExt = /\.[A-Za-z0-9]+$/.test(cmd);
|
|
246
|
+
if (callerHasExt) return lines[0];
|
|
247
|
+
|
|
248
|
+
// Preference order: PATHEXT (user's actual Windows search path) or a
|
|
249
|
+
// standard default. Lower-cased for case-insensitive matching.
|
|
250
|
+
const pathextRaw = process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.PS1";
|
|
251
|
+
const pathext = pathextRaw.split(";").map((e) => e.trim().toLowerCase()).filter(Boolean);
|
|
252
|
+
|
|
253
|
+
// Pick the first line whose extension matches PATHEXT, scanning by
|
|
254
|
+
// preference order (lower index = more preferred).
|
|
255
|
+
let best: string | null = null;
|
|
256
|
+
let bestRank = Infinity;
|
|
257
|
+
for (const line of lines) {
|
|
258
|
+
const rank = pathext.indexOf(extOf(line));
|
|
259
|
+
if (rank === -1) continue;
|
|
260
|
+
if (rank < bestRank) {
|
|
261
|
+
best = line;
|
|
262
|
+
bestRank = rank;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (best) return best;
|
|
266
|
+
|
|
267
|
+
// No PATHEXT-matching entry — fall back to first line (could be a bash
|
|
268
|
+
// shim). The runner layer handles the `.cmd` / `.bat` spawn-via-shell
|
|
269
|
+
// case separately; extensionless shims will still ENOENT but that's
|
|
270
|
+
// the right signal to the caller.
|
|
271
|
+
return lines[0];
|
|
272
|
+
}
|
|
273
|
+
|
|
176
274
|
/** Resolve a command via login shell (picks up nvm/volta/homebrew paths). */
|
|
177
275
|
function whichViaLoginShell(cmd: string): string | null {
|
|
178
276
|
const shell = process.env.SHELL || "/bin/zsh";
|
|
179
277
|
try {
|
|
180
|
-
const
|
|
278
|
+
const raw = execSync(`${shell} -ilc "which ${cmd}"`, {
|
|
181
279
|
encoding: "utf-8",
|
|
182
280
|
stdio: ["pipe", "pipe", "pipe"],
|
|
183
281
|
timeout: 5000,
|
|
184
|
-
|
|
282
|
+
windowsHide: true,
|
|
283
|
+
});
|
|
284
|
+
const output = (typeof raw === "string" ? raw : String(raw)).trim();
|
|
185
285
|
// Extract absolute path from potentially noisy login shell output
|
|
186
286
|
const pathLine = output.split("\n").find(l => l.trim().startsWith("/"));
|
|
187
287
|
return pathLine?.trim() || null;
|