@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform process enumeration primitives: is-process-running,
|
|
3
|
+
* ps/tasklist pattern-matching, elapsed-time parsing.
|
|
4
|
+
*
|
|
5
|
+
* Every OS-dependent helper accepts injectable `platform` and `exec`
|
|
6
|
+
* parameters (defaulting to `process.platform` and `execSync`), so tests
|
|
7
|
+
* can exercise both branches without mutating the global `process.platform`.
|
|
8
|
+
* See change: consolidate-platform-handlers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "./exec.js";
|
|
12
|
+
|
|
13
|
+
type ExecFn = (cmd: string, opts: { encoding: "utf-8"; stdio?: any }) => string;
|
|
14
|
+
|
|
15
|
+
export interface ProcessScanOpts {
|
|
16
|
+
/** Override platform (defaults to process.platform). */
|
|
17
|
+
platform?: NodeJS.Platform;
|
|
18
|
+
/** Override execSync (for tests). */
|
|
19
|
+
exec?: ExecFn;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function defaultExec(cmd: string, opts: { encoding: "utf-8"; stdio?: any }): string {
|
|
23
|
+
return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Elapsed-time parsing (pure, platform-agnostic) ──────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse `ps -o etime=` format into milliseconds. Handles:
|
|
30
|
+
* - `mm:ss` (e.g. "02:15" → 135000)
|
|
31
|
+
* - `hh:mm:ss` (e.g. "01:30:00" → 5400000)
|
|
32
|
+
* - `dd-hh:mm:ss` (e.g. "2-03:00:00" → 183600000)
|
|
33
|
+
*
|
|
34
|
+
* Returns 0 for empty or unparseable input.
|
|
35
|
+
*/
|
|
36
|
+
export function parseEtime(etime: string): number {
|
|
37
|
+
const trimmed = etime.trim();
|
|
38
|
+
if (!trimmed) return 0;
|
|
39
|
+
|
|
40
|
+
let days = 0;
|
|
41
|
+
let rest = trimmed;
|
|
42
|
+
|
|
43
|
+
const dashIdx = rest.indexOf("-");
|
|
44
|
+
if (dashIdx !== -1) {
|
|
45
|
+
days = parseInt(rest.slice(0, dashIdx), 10);
|
|
46
|
+
if (isNaN(days)) return 0;
|
|
47
|
+
rest = rest.slice(dashIdx + 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parts = rest.split(":").map((p) => parseInt(p, 10));
|
|
51
|
+
if (parts.some(isNaN)) return 0;
|
|
52
|
+
|
|
53
|
+
let hours = 0, minutes = 0, seconds = 0;
|
|
54
|
+
if (parts.length === 3) {
|
|
55
|
+
[hours, minutes, seconds] = parts;
|
|
56
|
+
} else if (parts.length === 2) {
|
|
57
|
+
[minutes, seconds] = parts;
|
|
58
|
+
} else {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return ((days * 86400) + (hours * 3600) + (minutes * 60) + seconds) * 1000;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Process-running check ───────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check whether a process matching `pattern` is currently running.
|
|
69
|
+
* - win32: `tasklist /FI "IMAGENAME eq <pattern>" /NH` — pattern is the
|
|
70
|
+
* executable image name (e.g. "Code.exe"). Returns true if the
|
|
71
|
+
* output contains the pattern.
|
|
72
|
+
* - unix: `pgrep -f "<pattern>"` — pattern is any substring of the
|
|
73
|
+
* command-line (e.g. "/Applications/Zed.app"). Returns true if
|
|
74
|
+
* pgrep exits with code 0 (at least one match).
|
|
75
|
+
*
|
|
76
|
+
* Best-effort: any failure returns `false`.
|
|
77
|
+
*/
|
|
78
|
+
export function isProcessRunning(pattern: string, opts: ProcessScanOpts = {}): boolean {
|
|
79
|
+
const platform = opts.platform ?? process.platform;
|
|
80
|
+
const exec = opts.exec ?? defaultExec;
|
|
81
|
+
try {
|
|
82
|
+
if (platform === "win32") {
|
|
83
|
+
const result = exec(`tasklist /FI "IMAGENAME eq ${pattern}" /NH`, {
|
|
84
|
+
encoding: "utf-8",
|
|
85
|
+
stdio: "pipe",
|
|
86
|
+
});
|
|
87
|
+
return String(result).includes(pattern);
|
|
88
|
+
}
|
|
89
|
+
exec(`pgrep -f "${pattern}"`, { encoding: "utf-8", stdio: "pipe" });
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform process primitives: port cleanup, kill, liveness, group-kill.
|
|
3
|
+
*
|
|
4
|
+
* Every OS-dependent helper takes an optional `platform` parameter
|
|
5
|
+
* (defaulting to `process.platform`) so tests can exercise both branches
|
|
6
|
+
* without mutating the global `process.platform`. See change:
|
|
7
|
+
* consolidate-platform-handlers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "./exec.js";
|
|
11
|
+
|
|
12
|
+
export type ExecFn = (cmd: string, opts: { encoding: "utf-8" }) => string;
|
|
13
|
+
export type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
|
|
14
|
+
|
|
15
|
+
export interface ProcessOpts {
|
|
16
|
+
/** Override platform (defaults to process.platform). */
|
|
17
|
+
platform?: NodeJS.Platform;
|
|
18
|
+
/** Override execSync (for tests). */
|
|
19
|
+
exec?: ExecFn;
|
|
20
|
+
/** Override process.kill (for tests). */
|
|
21
|
+
kill?: KillFn;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function defaultExec(cmd: string, opts: { encoding: "utf-8" }): string {
|
|
25
|
+
// Always suppress the cmd.exe window flash on Windows. The primitives that
|
|
26
|
+
// use this (findPortHolders via netstat, killProcess via taskkill) don't
|
|
27
|
+
// need user visibility.
|
|
28
|
+
return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function defaultKill(pid: number, signal: NodeJS.Signals | number): void {
|
|
32
|
+
process.kill(pid, signal);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Port-holder detection ────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse `netstat -ano -p tcp` output for PIDs listening on a port (Windows).
|
|
39
|
+
* Pure function, exported for testing.
|
|
40
|
+
*
|
|
41
|
+
* Example input line:
|
|
42
|
+
* " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345"
|
|
43
|
+
*/
|
|
44
|
+
export function parseNetstatListeners(output: string, port: number, selfPid: number): number[] {
|
|
45
|
+
const pids: number[] = [];
|
|
46
|
+
const portSuffix = `:${port}`;
|
|
47
|
+
for (const line of output.split(/\r?\n/)) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (!trimmed || !/^\s*TCP/i.test(line)) continue;
|
|
50
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
51
|
+
const cols = trimmed.split(/\s+/);
|
|
52
|
+
if (cols.length < 5) continue;
|
|
53
|
+
const local = cols[1];
|
|
54
|
+
if (!local.endsWith(portSuffix)) continue;
|
|
55
|
+
const pid = Number.parseInt(cols[cols.length - 1], 10);
|
|
56
|
+
if (Number.isFinite(pid) && pid > 0 && pid !== selfPid) pids.push(pid);
|
|
57
|
+
}
|
|
58
|
+
return pids;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find PIDs holding a TCP port. Cross-platform:
|
|
63
|
+
* - win32: `netstat -ano -p tcp` → parse LISTENING rows
|
|
64
|
+
* - unix: `lsof -t -i :<port> -sTCP:LISTEN`
|
|
65
|
+
*
|
|
66
|
+
* Best-effort: any failure returns []. Excludes the current process PID.
|
|
67
|
+
*/
|
|
68
|
+
export function findPortHolders(port: number, opts: ProcessOpts = {}): number[] {
|
|
69
|
+
const platform = opts.platform ?? process.platform;
|
|
70
|
+
const exec = opts.exec ?? defaultExec;
|
|
71
|
+
try {
|
|
72
|
+
if (platform === "win32") {
|
|
73
|
+
const output = exec("netstat -ano -p tcp", { encoding: "utf-8" });
|
|
74
|
+
return parseNetstatListeners(String(output), port, process.pid);
|
|
75
|
+
}
|
|
76
|
+
const output = exec(`lsof -t -i :${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" });
|
|
77
|
+
return String(output).trim().split("\n").map(Number).filter((n) => n > 0 && n !== process.pid);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Liveness ─────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check whether a PID is alive. Cross-platform via `process.kill(pid, 0)`.
|
|
87
|
+
*/
|
|
88
|
+
export function isProcessAlive(pid: number, opts: { kill?: KillFn } = {}): boolean {
|
|
89
|
+
const kill = opts.kill ?? defaultKill;
|
|
90
|
+
try {
|
|
91
|
+
kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Termination ──────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export interface KillProcessResult {
|
|
101
|
+
ok: boolean;
|
|
102
|
+
forced: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Terminate a process, cross-platform:
|
|
107
|
+
* - win32: `taskkill /F /T /PID <pid>` (tree kill, immediate)
|
|
108
|
+
* - unix: SIGTERM → wait up to `timeoutMs` → SIGKILL if still alive
|
|
109
|
+
*
|
|
110
|
+
* Returns `{ ok, forced }`. `ok` is true if the process was terminated (or
|
|
111
|
+
* was already dead); `forced` is true if SIGKILL was needed on Unix.
|
|
112
|
+
*/
|
|
113
|
+
export async function killProcess(
|
|
114
|
+
pid: number,
|
|
115
|
+
opts: ProcessOpts & { timeoutMs?: number } = {},
|
|
116
|
+
): Promise<KillProcessResult> {
|
|
117
|
+
const platform = opts.platform ?? process.platform;
|
|
118
|
+
const exec = opts.exec ?? defaultExec;
|
|
119
|
+
const kill = opts.kill ?? defaultKill;
|
|
120
|
+
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
121
|
+
|
|
122
|
+
if (!isProcessAlive(pid, { kill })) return { ok: false, forced: false };
|
|
123
|
+
|
|
124
|
+
if (platform === "win32") {
|
|
125
|
+
try {
|
|
126
|
+
exec(`taskkill /F /T /PID ${pid}`, { encoding: "utf-8" });
|
|
127
|
+
return { ok: true, forced: false };
|
|
128
|
+
} catch {
|
|
129
|
+
return { ok: false, forced: false };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
kill(pid, "SIGTERM");
|
|
135
|
+
} catch {
|
|
136
|
+
return { ok: false, forced: false };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const deadline = Date.now() + timeoutMs;
|
|
140
|
+
while (Date.now() < deadline) {
|
|
141
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
142
|
+
if (!isProcessAlive(pid, { kill })) return { ok: true, forced: false };
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
kill(pid, "SIGKILL");
|
|
146
|
+
} catch {
|
|
147
|
+
/* already dead */
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, forced: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Process-group kill (for detached children) ───────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Signal a process, targeting the process group on Unix (negative PID) and
|
|
156
|
+
* the PID directly on Windows. Used for detached children spawned with their
|
|
157
|
+
* own process group.
|
|
158
|
+
*/
|
|
159
|
+
export function killPidWithGroup(
|
|
160
|
+
pid: number,
|
|
161
|
+
signal: NodeJS.Signals,
|
|
162
|
+
opts: ProcessOpts = {},
|
|
163
|
+
): void {
|
|
164
|
+
const platform = opts.platform ?? process.platform;
|
|
165
|
+
const kill = opts.kill ?? defaultKill;
|
|
166
|
+
const target = platform === "win32" ? pid : -pid;
|
|
167
|
+
kill(target, signal);
|
|
168
|
+
}
|