@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm tool module — Recipe-based API for the npm CLI.
|
|
3
|
+
*
|
|
4
|
+
* Covers the subset of npm operations the dashboard actually invokes:
|
|
5
|
+
* - `npm root -g` (resolve the global node_modules path)
|
|
6
|
+
* - `npm outdated` (check for updates, local or global)
|
|
7
|
+
* - `npm install` (install a package, local or global)
|
|
8
|
+
* - `npm view <pkg> version` (read upstream version)
|
|
9
|
+
*
|
|
10
|
+
* See change: platform-command-executor.
|
|
11
|
+
*/
|
|
12
|
+
import { run, unwrap, type Recipe, type Result } from "./runner.js";
|
|
13
|
+
|
|
14
|
+
const NPM_TIMEOUT_FAST = 10_000;
|
|
15
|
+
const NPM_TIMEOUT_INSTALL = 120_000;
|
|
16
|
+
|
|
17
|
+
interface WithCwd {
|
|
18
|
+
cwd?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Recipes ─────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `npm root -g` — returns the absolute path to the global node_modules.
|
|
25
|
+
* Cached by callers (it's stable per Node install).
|
|
26
|
+
*/
|
|
27
|
+
export const NPM_ROOT_GLOBAL: Recipe<Record<string, never>, string> = {
|
|
28
|
+
argv: () => ["npm", "root", "-g"],
|
|
29
|
+
parse: (out) => out.trim(),
|
|
30
|
+
timeout: NPM_TIMEOUT_FAST,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `npm outdated <pkg> --json` (or without `<pkg>` for project-wide).
|
|
35
|
+
* npm exits 1 when updates are available and 0 when up-to-date — we tolerate
|
|
36
|
+
* exit 1 so callers see the JSON body either way.
|
|
37
|
+
*/
|
|
38
|
+
export const NPM_OUTDATED: Recipe<WithCwd & { pkg?: string }, unknown | null> = {
|
|
39
|
+
argv: ({ pkg }) => pkg === undefined
|
|
40
|
+
? ["npm", "outdated", "--json"]
|
|
41
|
+
: ["npm", "outdated", pkg, "--json"],
|
|
42
|
+
parse: (out) => {
|
|
43
|
+
const trimmed = out.trim();
|
|
44
|
+
if (!trimmed) return null;
|
|
45
|
+
try { return JSON.parse(trimmed); } catch { return null; }
|
|
46
|
+
},
|
|
47
|
+
timeout: NPM_TIMEOUT_FAST,
|
|
48
|
+
tolerate: [1],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `npm outdated -g <pkg> --json`. Same exit-1 tolerance.
|
|
53
|
+
*/
|
|
54
|
+
export const NPM_OUTDATED_GLOBAL: Recipe<{ pkg?: string }, unknown | null> = {
|
|
55
|
+
argv: ({ pkg }) => pkg === undefined
|
|
56
|
+
? ["npm", "outdated", "-g", "--json"]
|
|
57
|
+
: ["npm", "outdated", "-g", pkg, "--json"],
|
|
58
|
+
parse: (out) => {
|
|
59
|
+
const trimmed = out.trim();
|
|
60
|
+
if (!trimmed) return null;
|
|
61
|
+
try { return JSON.parse(trimmed); } catch { return null; }
|
|
62
|
+
},
|
|
63
|
+
timeout: NPM_TIMEOUT_FAST,
|
|
64
|
+
tolerate: [1],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `npm install <pkg>@<version>` — local install. Long timeout.
|
|
69
|
+
*/
|
|
70
|
+
export const NPM_INSTALL: Recipe<WithCwd & { pkg: string; version?: string }, string> = {
|
|
71
|
+
argv: ({ pkg, version }) => ["npm", "install", version ? `${pkg}@${version}` : pkg],
|
|
72
|
+
parse: (out) => out,
|
|
73
|
+
timeout: NPM_TIMEOUT_INSTALL,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `npm install -g <pkg>@<version>` — global install.
|
|
78
|
+
*/
|
|
79
|
+
export const NPM_INSTALL_GLOBAL: Recipe<{ pkg: string; version?: string }, string> = {
|
|
80
|
+
argv: ({ pkg, version }) => ["npm", "install", "-g", version ? `${pkg}@${version}` : pkg],
|
|
81
|
+
parse: (out) => out,
|
|
82
|
+
timeout: NPM_TIMEOUT_INSTALL,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `npm view <pkg> version` — the single-value shorthand for "latest version".
|
|
87
|
+
*/
|
|
88
|
+
export const NPM_VIEW_VERSION: Recipe<{ pkg: string }, string> = {
|
|
89
|
+
argv: ({ pkg }) => ["npm", "view", pkg, "version"],
|
|
90
|
+
parse: (out) => out.trim(),
|
|
91
|
+
timeout: NPM_TIMEOUT_FAST,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const NPM_RECIPES = {
|
|
95
|
+
NPM_ROOT_GLOBAL,
|
|
96
|
+
NPM_OUTDATED,
|
|
97
|
+
NPM_OUTDATED_GLOBAL,
|
|
98
|
+
NPM_INSTALL,
|
|
99
|
+
NPM_INSTALL_GLOBAL,
|
|
100
|
+
NPM_VIEW_VERSION,
|
|
101
|
+
} as const;
|
|
102
|
+
|
|
103
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `npm root -g`. Returns a `Result` for explicit error handling; use
|
|
107
|
+
* `rootGlobalOr` for best-effort semantics.
|
|
108
|
+
*
|
|
109
|
+
* Previous versions cached the result in a module-level variable. That
|
|
110
|
+
* cache is now owned by `ToolRegistry` (the runner consults the
|
|
111
|
+
* registry for every resolved binary including `npm` itself). Cache
|
|
112
|
+
* invalidation flows through `registry.rescan()`.
|
|
113
|
+
*
|
|
114
|
+
* See change: consolidate-tool-resolution.
|
|
115
|
+
*/
|
|
116
|
+
export function rootGlobal(): Result<string> {
|
|
117
|
+
return run(NPM_ROOT_GLOBAL, {}, {});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Test-only no-op kept for backward compatibility with existing test
|
|
122
|
+
* suites. The `cachedGlobalRoot` variable no longer exists.
|
|
123
|
+
*/
|
|
124
|
+
export function _resetNpmRootCache(): void { /* no-op */ }
|
|
125
|
+
|
|
126
|
+
export function outdated(input: WithCwd & { pkg?: string }): Result<unknown | null> {
|
|
127
|
+
return run(NPM_OUTDATED, input, { cwd: input.cwd });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function outdatedGlobal(input: { pkg?: string } = {}): Result<unknown | null> {
|
|
131
|
+
return run(NPM_OUTDATED_GLOBAL, input, {});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function install(input: WithCwd & { pkg: string; version?: string }): Result<string> {
|
|
135
|
+
return run(NPM_INSTALL, input, { cwd: input.cwd });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function installGlobal(input: { pkg: string; version?: string }): Result<string> {
|
|
139
|
+
return run(NPM_INSTALL_GLOBAL, input, {});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function viewVersion(input: { pkg: string }): Result<string> {
|
|
143
|
+
return run(NPM_VIEW_VERSION, input, {});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Best-effort variants ────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export function rootGlobalOr(fallback = ""): string {
|
|
149
|
+
return unwrap(rootGlobal(), fallback);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function outdatedOr(input: WithCwd & { pkg?: string }, fallback: unknown | null = null): unknown | null {
|
|
153
|
+
return unwrap(outdated(input), fallback);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function outdatedGlobalOr(input: { pkg?: string } = {}, fallback: unknown | null = null): unknown | null {
|
|
157
|
+
return unwrap(outdatedGlobal(input), fallback);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function viewVersionOr(input: { pkg: string }, fallback = ""): string {
|
|
161
|
+
return unwrap(viewVersion(input), fallback);
|
|
162
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenSpec tool module — Recipe-based API for the openspec CLI.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the ad-hoc spawnSync/execFile calls in `openspec-poller.ts`
|
|
5
|
+
* with typed Recipes executed through the runner. The higher-level
|
|
6
|
+
* `pollOpenSpec` / `pollOpenSpecAsync` functions remain in
|
|
7
|
+
* `openspec-poller.ts` (they aggregate list + per-change status into
|
|
8
|
+
* the dashboard's OpenSpecData shape) and now use these primitives.
|
|
9
|
+
*
|
|
10
|
+
* See change: platform-command-executor.
|
|
11
|
+
*/
|
|
12
|
+
import { run, unwrap, type Recipe, type Result } from "./runner.js";
|
|
13
|
+
|
|
14
|
+
const OPENSPEC_TIMEOUT = 10_000;
|
|
15
|
+
|
|
16
|
+
interface WithCwd {
|
|
17
|
+
cwd: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Parse JSON from stdout; returns null on parse failure. */
|
|
21
|
+
function parseJsonOrNull(out: string): unknown | null {
|
|
22
|
+
const trimmed = out.trim();
|
|
23
|
+
if (!trimmed) return null;
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(trimmed);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Recipes ─────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export const OPENSPEC_LIST: Recipe<WithCwd, unknown | null> = {
|
|
34
|
+
argv: () => ["openspec", "list", "--json"],
|
|
35
|
+
parse: parseJsonOrNull,
|
|
36
|
+
timeout: OPENSPEC_TIMEOUT,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const OPENSPEC_STATUS: Recipe<WithCwd & { change: string }, unknown | null> = {
|
|
40
|
+
argv: ({ change }) => ["openspec", "status", "--change", change, "--json"],
|
|
41
|
+
parse: parseJsonOrNull,
|
|
42
|
+
timeout: OPENSPEC_TIMEOUT,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `openspec archive --completed` — bulk-archives all completed changes.
|
|
47
|
+
* Stdout is human-readable (not JSON); callers typically don't parse it,
|
|
48
|
+
* they just await success/failure.
|
|
49
|
+
*/
|
|
50
|
+
export const OPENSPEC_ARCHIVE_COMPLETED: Recipe<WithCwd, string> = {
|
|
51
|
+
argv: () => ["openspec", "archive", "--completed"],
|
|
52
|
+
parse: (out) => out,
|
|
53
|
+
// Archive operations can be slow when many changes are processed.
|
|
54
|
+
timeout: 30_000,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const OPENSPEC_RECIPES = {
|
|
58
|
+
OPENSPEC_LIST,
|
|
59
|
+
OPENSPEC_STATUS,
|
|
60
|
+
OPENSPEC_ARCHIVE_COMPLETED,
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** Run `openspec list --json` and return the parsed JSON, or null on failure. */
|
|
66
|
+
export function list(input: WithCwd): Result<unknown | null> {
|
|
67
|
+
return run(OPENSPEC_LIST, input, { cwd: input.cwd });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Run `openspec status --change <name> --json` and return parsed JSON or null. */
|
|
71
|
+
export function status(input: WithCwd & { change: string }): Result<unknown | null> {
|
|
72
|
+
return run(OPENSPEC_STATUS, input, { cwd: input.cwd });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Run `openspec archive --completed`. Returns raw stdout on success. */
|
|
76
|
+
export function archiveCompleted(input: WithCwd): Result<string> {
|
|
77
|
+
return run(OPENSPEC_ARCHIVE_COMPLETED, input, { cwd: input.cwd });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Best-effort variants (mirror the pattern established in git.ts) ─────────
|
|
81
|
+
|
|
82
|
+
export function listOr(input: WithCwd, fallback: unknown | null = null): unknown | null {
|
|
83
|
+
return unwrap(list(input), fallback);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function statusOr(
|
|
87
|
+
input: WithCwd & { change: string },
|
|
88
|
+
fallback: unknown | null = null,
|
|
89
|
+
): unknown | null {
|
|
90
|
+
return unwrap(status(input), fallback);
|
|
91
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-aware filesystem path primitives.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard uses paths in three places that need OS-correct behaviour:
|
|
5
|
+
* 1. Pin/unpin directory storage (server-side).
|
|
6
|
+
* 2. Session grouping — matching a session's `cwd` against pinned entries.
|
|
7
|
+
* 3. Path picker UI — parsing user-typed input.
|
|
8
|
+
*
|
|
9
|
+
* This module is the single source of truth. All exported helpers that
|
|
10
|
+
* depend on OS conventions take a trailing `platform: NodeJS.Platform`
|
|
11
|
+
* parameter defaulting to `process.platform` — tests pass it explicitly
|
|
12
|
+
* to exercise both Windows and Unix branches without mutating
|
|
13
|
+
* `process.platform`.
|
|
14
|
+
*
|
|
15
|
+
* ISOMORPHIC: implemented with string operations only (no `node:path`)
|
|
16
|
+
* so the module loads in the browser. The client imports `normalizePath`
|
|
17
|
+
* and `parsePathInput` directly; using `node:path` would have forced
|
|
18
|
+
* Vite to externalize the import and crash the SPA at load time.
|
|
19
|
+
*
|
|
20
|
+
* Windows specifics:
|
|
21
|
+
* - Each drive letter (A:, B:, …, Z:) is a distinct filesystem root.
|
|
22
|
+
* `samePath` NEVER merges different drives.
|
|
23
|
+
* - Drive letters are case-insensitive (`B:\` == `b:\`).
|
|
24
|
+
* - Path components are case-insensitive on NTFS (default) and HFS+.
|
|
25
|
+
* - UNC paths (`\\server\share`) are distinct from drive-letter paths.
|
|
26
|
+
* - Bare drive-relative input (`B:`, `B:Dev`) is defensively treated
|
|
27
|
+
* as drive-root-plus-partial, NOT as the B-drive's current directory
|
|
28
|
+
* (which is cwd-dependent and useless in a pin dialog).
|
|
29
|
+
*
|
|
30
|
+
* See change: platform-path-normalization.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** True if input is a Windows drive-letter form (`B:`, `B:Dev`) without separator. */
|
|
36
|
+
function isDriveLetterForm(value: string): boolean {
|
|
37
|
+
return /^[A-Za-z]:(?![\\/])/.test(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Extract the `B:` prefix from `B:Dev`, else null. */
|
|
41
|
+
function driveLetterPrefix(value: string): string | null {
|
|
42
|
+
const m = value.match(/^([A-Za-z]:)(?![\\/])/);
|
|
43
|
+
return m ? m[1] : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Detect the root portion of a path. Returns "" when no root. */
|
|
47
|
+
function getRoot(p: string, platform: NodeJS.Platform): string {
|
|
48
|
+
if (platform === "win32") {
|
|
49
|
+
// UNC: \\server\share (captures up to the share name, no trailing sep)
|
|
50
|
+
const unc = p.match(/^(?:\\\\|\/\/)([^\\/]+)[\\/]([^\\/]+)(?:[\\/]|$)/);
|
|
51
|
+
if (unc) return `\\\\${unc[1]}\\${unc[2]}\\`;
|
|
52
|
+
// Drive root: "C:\" or "C:/"
|
|
53
|
+
const drive = p.match(/^([A-Za-z]:)[\\/]/);
|
|
54
|
+
if (drive) return `${drive[1]}\\`;
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
// POSIX
|
|
58
|
+
return p.startsWith("/") ? "/" : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Split a path into segments, collapsing `.` and `..`. Operates on a
|
|
63
|
+
* rootless remainder; caller is responsible for re-prepending the root.
|
|
64
|
+
*/
|
|
65
|
+
function normalizeSegments(rest: string, sep: string): string[] {
|
|
66
|
+
const split = rest.split(/[\\/]+/).filter((s) => s.length > 0);
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
for (const seg of split) {
|
|
69
|
+
if (seg === ".") continue;
|
|
70
|
+
if (seg === "..") {
|
|
71
|
+
if (out.length > 0 && out[out.length - 1] !== "..") out.pop();
|
|
72
|
+
// Rootless `..` that can't be resolved stays (we only call this with
|
|
73
|
+
// rootful paths via getRoot, so this arm is mostly defensive).
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
out.push(seg);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Canonicalize a path to the OS-native form:
|
|
85
|
+
* - Separators match the OS (`\\` on win32, `/` elsewhere).
|
|
86
|
+
* - Redundant separators collapsed.
|
|
87
|
+
* - `.` and `..` segments resolved.
|
|
88
|
+
* - Trailing separator removed EXCEPT for roots.
|
|
89
|
+
* - Original case preserved (NO lowercasing).
|
|
90
|
+
*
|
|
91
|
+
* Windows subtleties:
|
|
92
|
+
* - Bare drive-letter input (`B:`, `B:Dev`) is treated defensively as
|
|
93
|
+
* drive-rooted (`B:\` / `B:\Dev`), NOT as cwd-relative on that drive
|
|
94
|
+
* (which would be useless for a pin dialog — the dashboard's
|
|
95
|
+
* `process.cwd()` has no relationship to what the user typed).
|
|
96
|
+
* - UNC paths are preserved as-is (with the `\\server\share\` root).
|
|
97
|
+
*/
|
|
98
|
+
export function normalizePath(
|
|
99
|
+
p: string,
|
|
100
|
+
platform: NodeJS.Platform = process.platform,
|
|
101
|
+
): string {
|
|
102
|
+
if (!p) return p;
|
|
103
|
+
|
|
104
|
+
if (platform === "win32") {
|
|
105
|
+
// Handle drive-relative forms defensively.
|
|
106
|
+
if (isDriveLetterForm(p)) {
|
|
107
|
+
const prefix = driveLetterPrefix(p)!; // "B:"
|
|
108
|
+
const rest = p.slice(prefix.length);
|
|
109
|
+
if (!rest) return prefix + "\\"; // bare "B:" → "B:\\"
|
|
110
|
+
// "B:Dev" → normalize as if it were "B:\\Dev"
|
|
111
|
+
return normalizePath(prefix + "\\" + rest, "win32");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const root = getRoot(p, "win32");
|
|
115
|
+
if (root) {
|
|
116
|
+
const rest = p.slice(root.length);
|
|
117
|
+
const segments = normalizeSegments(rest, "\\");
|
|
118
|
+
if (segments.length === 0) return root;
|
|
119
|
+
// Drive root: "C:\" → segments joined with \ after root (no extra sep).
|
|
120
|
+
// UNC root: "\\server\share\" → same pattern.
|
|
121
|
+
return root + segments.join("\\");
|
|
122
|
+
}
|
|
123
|
+
// No root detected — relative path. Normalize separators + segments,
|
|
124
|
+
// leave without a leading root.
|
|
125
|
+
const segments = normalizeSegments(p, "\\");
|
|
126
|
+
return segments.join("\\");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// POSIX
|
|
130
|
+
const root = getRoot(p, platform);
|
|
131
|
+
if (root) {
|
|
132
|
+
const segments = normalizeSegments(p.slice(root.length), "/");
|
|
133
|
+
if (segments.length === 0) return root;
|
|
134
|
+
return root + segments.join("/");
|
|
135
|
+
}
|
|
136
|
+
const segments = normalizeSegments(p, "/");
|
|
137
|
+
return segments.join("/");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Filesystem-level equality.
|
|
142
|
+
* - win32/darwin: case-insensitive (Windows NTFS + macOS HFS+ defaults).
|
|
143
|
+
* - linux: case-sensitive.
|
|
144
|
+
*
|
|
145
|
+
* Runs both inputs through `normalizePath` first so separator and
|
|
146
|
+
* trailing-separator drift is tolerated uniformly. Cross-drive safety
|
|
147
|
+
* on Windows is automatic — the drive letter is preserved and compared.
|
|
148
|
+
*/
|
|
149
|
+
export function samePath(
|
|
150
|
+
a: string,
|
|
151
|
+
b: string,
|
|
152
|
+
platform: NodeJS.Platform = process.platform,
|
|
153
|
+
): boolean {
|
|
154
|
+
if (!a || !b) return a === b;
|
|
155
|
+
const na = normalizePath(a, platform);
|
|
156
|
+
const nb = normalizePath(b, platform);
|
|
157
|
+
if (platform === "linux") return na === nb;
|
|
158
|
+
return na.toLowerCase() === nb.toLowerCase();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Parse user-typed path input into `{ parent, partial }`:
|
|
163
|
+
* - `parent` is the directory to browse.
|
|
164
|
+
* - `partial` is the in-progress filter / typed segment after `parent`.
|
|
165
|
+
*
|
|
166
|
+
* Handles Windows drive-letter roots, UNC roots, Unix roots, mixed
|
|
167
|
+
* separators, and trailing separators.
|
|
168
|
+
*/
|
|
169
|
+
export function parsePathInput(
|
|
170
|
+
value: string,
|
|
171
|
+
platform: NodeJS.Platform = process.platform,
|
|
172
|
+
): { parent: string; partial: string } {
|
|
173
|
+
if (!value) return { parent: platform === "win32" ? "" : "/", partial: "" };
|
|
174
|
+
|
|
175
|
+
if (platform === "win32") {
|
|
176
|
+
// Bare drive letter "B:" → drive root.
|
|
177
|
+
if (/^[A-Za-z]:$/.test(value)) {
|
|
178
|
+
return { parent: value[0] + ":\\", partial: "" };
|
|
179
|
+
}
|
|
180
|
+
// Drive-relative "B:Dev" → drive root + partial.
|
|
181
|
+
if (isDriveLetterForm(value)) {
|
|
182
|
+
const prefix = driveLetterPrefix(value)!;
|
|
183
|
+
return { parent: prefix + "\\", partial: value.slice(prefix.length) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const lastBackslash = value.lastIndexOf("\\");
|
|
187
|
+
const lastForward = value.lastIndexOf("/");
|
|
188
|
+
const lastSep = Math.max(lastBackslash, lastForward);
|
|
189
|
+
|
|
190
|
+
if (lastSep < 0) {
|
|
191
|
+
// No separator — treat whole input as partial.
|
|
192
|
+
return { parent: "", partial: value };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (lastSep === value.length - 1) {
|
|
196
|
+
// Ends with separator.
|
|
197
|
+
const parent = value.slice(0, lastSep);
|
|
198
|
+
if (/^[A-Za-z]:$/.test(parent)) return { parent: parent + "\\", partial: "" };
|
|
199
|
+
return { parent: normalizePath(parent, "win32"), partial: "" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const parent = value.slice(0, lastSep);
|
|
203
|
+
const partial = value.slice(lastSep + 1);
|
|
204
|
+
const normalizedParent = /^[A-Za-z]:$/.test(parent)
|
|
205
|
+
? parent + "\\"
|
|
206
|
+
: normalizePath(parent, "win32");
|
|
207
|
+
return { parent: normalizedParent, partial };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// POSIX
|
|
211
|
+
if (value === "/") return { parent: "/", partial: "" };
|
|
212
|
+
if (value.endsWith("/")) {
|
|
213
|
+
const parent = value.slice(0, -1) || "/";
|
|
214
|
+
return { parent, partial: "" };
|
|
215
|
+
}
|
|
216
|
+
const lastSep = value.lastIndexOf("/");
|
|
217
|
+
if (lastSep < 0) return { parent: "/", partial: value };
|
|
218
|
+
const parent = value.slice(0, lastSep) || "/";
|
|
219
|
+
const partial = value.slice(lastSep + 1);
|
|
220
|
+
return { parent, partial };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Append the OS-native separator to a path if not already terminated. */
|
|
224
|
+
export function withTrailingSep(
|
|
225
|
+
p: string,
|
|
226
|
+
platform: NodeJS.Platform = process.platform,
|
|
227
|
+
): string {
|
|
228
|
+
if (!p) return p;
|
|
229
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
230
|
+
if (p.endsWith("\\") || p.endsWith("/")) return p;
|
|
231
|
+
return p + sep;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Join two path segments with the OS-native separator. */
|
|
235
|
+
export function joinForDisplay(
|
|
236
|
+
parent: string,
|
|
237
|
+
child: string,
|
|
238
|
+
platform: NodeJS.Platform = process.platform,
|
|
239
|
+
): string {
|
|
240
|
+
if (!parent) return child;
|
|
241
|
+
if (!child) return parent;
|
|
242
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
243
|
+
const parentTrimmed = parent.replace(/[\\/]+$/, "");
|
|
244
|
+
const childTrimmed = child.replace(/^[\\/]+/, "");
|
|
245
|
+
// Preserve root's trailing sep — `C:\` + `Users` → `C:\Users`, not `C:Users`.
|
|
246
|
+
if (platform === "win32" && /^[A-Za-z]:$/.test(parentTrimmed)) {
|
|
247
|
+
return parentTrimmed + "\\" + childTrimmed;
|
|
248
|
+
}
|
|
249
|
+
if (parentTrimmed === "") return sep + childTrimmed; // POSIX root case
|
|
250
|
+
return parentTrimmed + sep + childTrimmed;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* True iff `resolved` is a filesystem root on `platform`. Used by
|
|
255
|
+
* server-side `browse.ts` to compute `parent = null` uniformly
|
|
256
|
+
* (replacing the Unix-only `resolved === "/"` check).
|
|
257
|
+
*/
|
|
258
|
+
export function isFilesystemRoot(
|
|
259
|
+
resolved: string,
|
|
260
|
+
platform: NodeJS.Platform = process.platform,
|
|
261
|
+
): boolean {
|
|
262
|
+
if (!resolved) return false;
|
|
263
|
+
if (platform === "win32") {
|
|
264
|
+
// Drive-letter root: "C:\" (also accept forward slash form)
|
|
265
|
+
if (/^[A-Za-z]:[\\/]$/.test(resolved)) return true;
|
|
266
|
+
// UNC root: "\\server\share" with optional trailing sep
|
|
267
|
+
if (/^\\\\[^\\]+\\[^\\]+\\?$/.test(resolved)) return true;
|
|
268
|
+
// Bare separator as "current drive root" — Node's path.dirname("/")
|
|
269
|
+
// returns "/" even on Windows, and listDirectories("/") is a valid
|
|
270
|
+
// call for "root of the current drive". Treat it as a root so the
|
|
271
|
+
// picker doesn't show a useless `..` entry.
|
|
272
|
+
if (resolved === "/" || resolved === "\\") return true;
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
return resolved === "/";
|
|
276
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process identification primitives — find PIDs by command-line marker,
|
|
3
|
+
* check if a PID looks like a pi-related process.
|
|
4
|
+
*
|
|
5
|
+
* Every OS-dependent helper accepts injectable `platform` and `exec`
|
|
6
|
+
* parameters, defaulting to `process.platform` and a safe `execSync`.
|
|
7
|
+
* Tests exercise both branches without mutating `process.platform`.
|
|
8
|
+
*
|
|
9
|
+
* Windows branches are intentional stubs today: there is no cheap,
|
|
10
|
+
* format-stable cross-command way to inspect a PID's command line
|
|
11
|
+
* (tasklist /V is slow and locale-dependent). Windows pi-ness is
|
|
12
|
+
* verified via `headlessPidRegistry` at the server level, which tracks
|
|
13
|
+
* PID → session identity directly at spawn time. Future work can
|
|
14
|
+
* extend these Windows branches with WMIC / PowerShell probing in
|
|
15
|
+
* ONE place (here) instead of the three scattered inline checks in
|
|
16
|
+
* session-action-handler.ts.
|
|
17
|
+
*
|
|
18
|
+
* See change: consolidate-windows-spawn-and-platform-handlers.
|
|
19
|
+
*/
|
|
20
|
+
import { execSync } from "./exec.js";
|
|
21
|
+
|
|
22
|
+
type ExecFn = (cmd: string, opts: { encoding: "utf-8"; timeout?: number; stdio?: any }) => string;
|
|
23
|
+
|
|
24
|
+
export interface ProcessIdentifyOpts {
|
|
25
|
+
/** Override platform (defaults to process.platform). */
|
|
26
|
+
platform?: NodeJS.Platform;
|
|
27
|
+
/** Override execSync (for tests). */
|
|
28
|
+
exec?: ExecFn;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function defaultExec(cmd: string, opts: { encoding: "utf-8"; timeout?: number; stdio?: any }): string {
|
|
32
|
+
return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Pattern matcher ─────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Returns true iff the given command-line string references pi or node. */
|
|
38
|
+
export function isPiCommandLine(commandLine: string): boolean {
|
|
39
|
+
return /\bpi\b|\bnode\b/.test(commandLine);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── findPidByMarker ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Find PIDs whose command line contains `marker`. Unix uses ps|grep;
|
|
46
|
+
* Windows returns `[]` (command-line lookup is delegated to
|
|
47
|
+
* headlessPidRegistry at the server level).
|
|
48
|
+
*
|
|
49
|
+
* Never throws. Returns `[]` on any error.
|
|
50
|
+
*/
|
|
51
|
+
export function findPidByMarker(marker: string, opts: ProcessIdentifyOpts = {}): number[] {
|
|
52
|
+
const platform = opts.platform ?? process.platform;
|
|
53
|
+
if (platform === "win32") return [];
|
|
54
|
+
|
|
55
|
+
const exec = opts.exec ?? defaultExec;
|
|
56
|
+
// Additional sentinels help distinguish pi headless spawns from other
|
|
57
|
+
// processes that happen to contain the session ID in an env var or
|
|
58
|
+
// unrelated argument. The canonical sentinels match the Unix headless
|
|
59
|
+
// wrapper strings.
|
|
60
|
+
const sentinels = ["sleep 2147483647", "tail -f /dev/null"];
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const out = exec(
|
|
64
|
+
`ps -eo pid,command | grep ${shellQuote(marker)} | grep -v grep`,
|
|
65
|
+
{ encoding: "utf-8", timeout: 3000 },
|
|
66
|
+
).trim();
|
|
67
|
+
if (!out) return [];
|
|
68
|
+
|
|
69
|
+
const pids: number[] = [];
|
|
70
|
+
for (const line of out.split("\n")) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (!trimmed) continue;
|
|
73
|
+
// Must also contain one of the pi headless sentinels, else it's
|
|
74
|
+
// probably a grep/editor/tail-of-log matching the session id.
|
|
75
|
+
const hasSentinel = sentinels.some((s) => trimmed.includes(s));
|
|
76
|
+
if (!hasSentinel) continue;
|
|
77
|
+
const pidStr = trimmed.split(/\s+/, 1)[0];
|
|
78
|
+
const pid = parseInt(pidStr, 10);
|
|
79
|
+
if (pid > 0) pids.push(pid);
|
|
80
|
+
}
|
|
81
|
+
return pids;
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── isProcessLikePi ────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if a PID belongs to a pi/node process. Safety check before
|
|
91
|
+
* SIGKILL on Unix; no-op on Windows where pi-ness is tracked by
|
|
92
|
+
* the PID registry at spawn time.
|
|
93
|
+
*
|
|
94
|
+
* Unix behaviour:
|
|
95
|
+
* - macOS: `ps -p <pid> -o command=`
|
|
96
|
+
* - Linux: `/proc/<pid>/cmdline` with `ps` fallback via `cat`
|
|
97
|
+
*
|
|
98
|
+
* Returns `false` if the process has already exited (command fails).
|
|
99
|
+
* Returns `true` on Windows unconditionally.
|
|
100
|
+
*/
|
|
101
|
+
export function isProcessLikePi(pid: number, opts: ProcessIdentifyOpts = {}): boolean {
|
|
102
|
+
const platform = opts.platform ?? process.platform;
|
|
103
|
+
if (platform === "win32") return true;
|
|
104
|
+
|
|
105
|
+
const exec = opts.exec ?? defaultExec;
|
|
106
|
+
const cmd = platform === "darwin"
|
|
107
|
+
? `ps -p ${pid} -o command=`
|
|
108
|
+
: `cat /proc/${pid}/cmdline 2>/dev/null || ps -p ${pid} -o command=`;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const output = exec(cmd, { encoding: "utf-8", timeout: 2000 }).trim();
|
|
112
|
+
return isPiCommandLine(output);
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function shellQuote(s: string): string {
|
|
121
|
+
// Strict allow-list: if the marker is purely [A-Za-z0-9._-], leave it alone;
|
|
122
|
+
// otherwise single-quote it safely. Session IDs are UUIDs or similar and
|
|
123
|
+
// fall into the allow-list in practice, so this is almost always a no-op.
|
|
124
|
+
if (/^[A-Za-z0-9._-]+$/.test(s)) return `"${s}"`;
|
|
125
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
126
|
+
}
|