@aexol/spectral 0.9.234 → 0.9.240

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.
Files changed (68) hide show
  1. package/README.md +47 -0
  2. package/dist/agent/index.d.ts +32 -5
  3. package/dist/agent/index.d.ts.map +1 -1
  4. package/dist/agent/index.js +120 -22
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +18 -4
  8. package/dist/commands/desktop.d.ts +167 -0
  9. package/dist/commands/desktop.d.ts.map +1 -0
  10. package/dist/commands/desktop.js +540 -0
  11. package/dist/commands/serve.d.ts +9 -1
  12. package/dist/commands/serve.d.ts.map +1 -1
  13. package/dist/commands/serve.js +78 -1
  14. package/dist/commands/update.d.ts +85 -5
  15. package/dist/commands/update.d.ts.map +1 -1
  16. package/dist/commands/update.js +288 -58
  17. package/dist/extensions/desktop-control/macos.d.ts.map +1 -1
  18. package/dist/extensions/desktop-control/macos.js +2 -2
  19. package/dist/mcp/server-manager.d.ts.map +1 -1
  20. package/dist/mcp/server-manager.js +6 -0
  21. package/dist/memory/tools/receive-agent-observations.d.ts.map +1 -1
  22. package/dist/memory/tools/receive-agent-observations.js +12 -2
  23. package/dist/relay/dispatcher.d.ts.map +1 -1
  24. package/dist/relay/dispatcher.js +30 -2
  25. package/dist/sdk/coding-agent/core/extensions/types.d.ts +8 -0
  26. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  27. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  28. package/dist/sdk/coding-agent/core/system-prompt.js +5 -0
  29. package/dist/server/agent-bridge/tps-metrics.d.ts +34 -0
  30. package/dist/server/agent-bridge/tps-metrics.d.ts.map +1 -1
  31. package/dist/server/agent-bridge/tps-metrics.js +22 -0
  32. package/dist/server/agent-bridge.d.ts +9 -0
  33. package/dist/server/agent-bridge.d.ts.map +1 -1
  34. package/dist/server/agent-bridge.js +83 -3
  35. package/dist/server/handlers/paths-list.d.ts +1 -1
  36. package/dist/server/handlers/paths-list.js +1 -1
  37. package/dist/server/inter-agent-broker.d.ts +12 -0
  38. package/dist/server/inter-agent-broker.d.ts.map +1 -1
  39. package/dist/server/inter-agent-broker.js +1 -0
  40. package/dist/server/preset-chain.d.ts +151 -0
  41. package/dist/server/preset-chain.d.ts.map +1 -0
  42. package/dist/server/preset-chain.js +250 -0
  43. package/dist/server/session-stream.d.ts +55 -1
  44. package/dist/server/session-stream.d.ts.map +1 -1
  45. package/dist/server/session-stream.js +203 -24
  46. package/dist/server/storage/rows.d.ts +2 -0
  47. package/dist/server/storage/rows.d.ts.map +1 -1
  48. package/dist/server/storage/schema.d.ts +2 -2
  49. package/dist/server/storage/schema.d.ts.map +1 -1
  50. package/dist/server/storage/schema.js +2 -1
  51. package/dist/server/storage.d.ts +7 -0
  52. package/dist/server/storage.d.ts.map +1 -1
  53. package/dist/server/storage.js +142 -9
  54. package/dist/server/wire.d.ts +71 -0
  55. package/dist/server/wire.d.ts.map +1 -1
  56. package/dist/utils/augment-path.d.ts +51 -0
  57. package/dist/utils/augment-path.d.ts.map +1 -0
  58. package/dist/utils/augment-path.js +234 -0
  59. package/dist/utils/open-browser.d.ts +55 -0
  60. package/dist/utils/open-browser.d.ts.map +1 -0
  61. package/dist/utils/open-browser.js +163 -0
  62. package/dist/utils/serve-state.d.ts +264 -0
  63. package/dist/utils/serve-state.d.ts.map +1 -0
  64. package/dist/utils/serve-state.js +563 -0
  65. package/dist/utils/standalone.d.ts +14 -0
  66. package/dist/utils/standalone.d.ts.map +1 -0
  67. package/dist/utils/standalone.js +23 -0
  68. package/package.json +1 -3
@@ -0,0 +1,234 @@
1
+ /**
2
+ * PATH augmentation for processes spectral spawns.
3
+ *
4
+ * Why this exists:
5
+ * A GUI-launched host (Dock, Spotlight, launchd, a `.app` bundle) inherits a
6
+ * minimal `PATH` — usually just `/usr/bin:/bin:/usr/sbin:/sbin`. MCP servers
7
+ * declared as `npx -y <pkg>` (and every other `npm`/`node`/`git` child
8
+ * process) then die with `Executable not found in $PATH: "npx"`. The Electron
9
+ * desktop app solved this by augmenting the PATH of the `spectral serve`
10
+ * child it spawned. That app is gone; the same logic now lives here so the
11
+ * `spectral desktop` wrapper, `spectral serve` itself, and the MCP stdio
12
+ * transports all get a usable PATH regardless of how spectral was launched.
13
+ *
14
+ * Sources merged, in priority order (first occurrence wins — shell lookup is
15
+ * left-to-right):
16
+ * 1. the current process PATH,
17
+ * 2. macOS `/etc/paths` + `/etc/paths.d/*` (what `path_helper` uses),
18
+ * 3. `getconf PATH` (the platform default),
19
+ * 4. well-known Node/npm/Homebrew/Git install directories,
20
+ * 5. the newest nvm-managed Node `bin` directory.
21
+ *
22
+ * Ported from `electron/path-resolver.ts` when the Electron app was removed, so
23
+ * behaviour is unchanged for existing users.
24
+ */
25
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join, normalize } from "node:path";
28
+ import { spawnSync } from "node:child_process";
29
+ /** Platform path-list separator (`;` on Windows, `:` elsewhere). */
30
+ function pathDelimiter() {
31
+ return process.platform === "win32" ? ";" : ":";
32
+ }
33
+ /**
34
+ * Returns the case-correct name of the PATH environment variable on this
35
+ * platform. Windows allows "Path", Unix is usually "PATH".
36
+ */
37
+ export function getPathEnvKey(env = process.env) {
38
+ return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
39
+ }
40
+ /**
41
+ * Split the content of a path-list source into individual directories.
42
+ *
43
+ * Two formats show up in practice and both must be handled: one directory per
44
+ * line (`/etc/paths`, `/etc/paths.d/*`) and a single delimiter-joined line
45
+ * (`getconf PATH` prints `/usr/bin:/bin:/usr/sbin:/sbin`). The original
46
+ * `electron/path-resolver.ts` split on newlines only, so the `getconf` output
47
+ * landed in the Set as one malformed `/usr/bin:/bin:/usr/sbin:/sbin` entry —
48
+ * harmless-but-wrong, and enough to make `augmentPath()` non-idempotent.
49
+ */
50
+ function parsePathsContent(content) {
51
+ return content
52
+ .split(/\r?\n/)
53
+ .map((line) => line.trim())
54
+ .filter((line) => line.length > 0 && !line.startsWith("#"))
55
+ .flatMap((line) => line.split(pathDelimiter()))
56
+ .map((entry) => entry.trim())
57
+ .filter((entry) => entry.length > 0);
58
+ }
59
+ function readMacEtcPaths() {
60
+ if (process.platform !== "darwin")
61
+ return [];
62
+ const paths = [];
63
+ const etcPaths = "/etc/paths";
64
+ if (existsSync(etcPaths)) {
65
+ try {
66
+ paths.push(...parsePathsContent(readFileSync(etcPaths, "utf-8")));
67
+ }
68
+ catch {
69
+ // ignore
70
+ }
71
+ }
72
+ const pathsD = "/etc/paths.d";
73
+ if (existsSync(pathsD)) {
74
+ try {
75
+ for (const entry of readdirSync(pathsD, { withFileTypes: true })) {
76
+ if (!entry.isFile())
77
+ continue;
78
+ paths.push(...parsePathsContent(readFileSync(join(pathsD, entry.name), "utf-8")));
79
+ }
80
+ }
81
+ catch {
82
+ // ignore
83
+ }
84
+ }
85
+ return paths;
86
+ }
87
+ function readGetconfPath() {
88
+ if (process.platform === "win32")
89
+ return [];
90
+ try {
91
+ const result = spawnSync("getconf", ["PATH"], { encoding: "utf-8", shell: false });
92
+ if (result.status === 0 && result.stdout) {
93
+ return parsePathsContent(result.stdout);
94
+ }
95
+ }
96
+ catch {
97
+ // ignore
98
+ }
99
+ return [];
100
+ }
101
+ function knownExtraPaths() {
102
+ const commonUnix = [
103
+ "/usr/local/bin",
104
+ "/usr/local/sbin",
105
+ "/usr/bin",
106
+ "/usr/sbin",
107
+ "/bin",
108
+ "/sbin",
109
+ "/opt/bin",
110
+ "/opt/local/bin",
111
+ ];
112
+ const home = homedir();
113
+ if (process.platform === "darwin") {
114
+ return [
115
+ ...commonUnix,
116
+ "/opt/homebrew/bin",
117
+ "/opt/homebrew/sbin",
118
+ "/usr/local/git/bin",
119
+ join(home, ".local", "bin"),
120
+ ];
121
+ }
122
+ if (process.platform === "win32") {
123
+ const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files";
124
+ const programFilesX86 = process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
125
+ return [
126
+ join(programFiles, "nodejs"),
127
+ join(programFilesX86, "nodejs"),
128
+ join(process.env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "npm"),
129
+ join(home, "AppData", "Roaming", "npm"),
130
+ join(programFiles, "Git", "cmd"),
131
+ join(programFilesX86, "Git", "cmd"),
132
+ join(programFiles, "Git", "bin"),
133
+ join(programFilesX86, "Git", "bin"),
134
+ ];
135
+ }
136
+ return [...commonUnix, "/usr/local/git/bin", join(home, ".local", "bin")];
137
+ }
138
+ function findLatestNvmNodeBin() {
139
+ const nvmDir = process.env.NVM_DIR ?? join(homedir(), ".nvm");
140
+ const versionsDir = join(nvmDir, "versions", "node");
141
+ if (!existsSync(versionsDir))
142
+ return null;
143
+ try {
144
+ const versions = readdirSync(versionsDir, { withFileTypes: true })
145
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("v"))
146
+ .map((entry) => entry.name);
147
+ if (versions.length === 0)
148
+ return null;
149
+ versions.sort(compareSemverDesc);
150
+ return join(versionsDir, versions[0], "bin");
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ function compareSemverDesc(a, b) {
157
+ const parse = (value) => value
158
+ .replace(/^v/, "")
159
+ .split(".")
160
+ .map((part) => parseInt(part, 10) || 0);
161
+ const av = parse(a);
162
+ const bv = parse(b);
163
+ const len = Math.max(av.length, bv.length);
164
+ for (let i = 0; i < len; i++) {
165
+ const an = av[i] ?? 0;
166
+ const bn = bv[i] ?? 0;
167
+ if (an !== bn)
168
+ return bn - an;
169
+ }
170
+ return b.localeCompare(a);
171
+ }
172
+ /**
173
+ * Cached "static" augmentation. `/etc/paths`, `getconf PATH`, the well-known
174
+ * install dirs and the nvm lookup do not change during the lifetime of a
175
+ * process, and `augmentPath()` is called on every MCP server connect — so the
176
+ * `spawnSync("getconf")` happens at most once per process.
177
+ */
178
+ let staticExtrasCache = null;
179
+ function staticExtraPaths() {
180
+ if (staticExtrasCache)
181
+ return staticExtrasCache;
182
+ const extras = [];
183
+ extras.push(...readMacEtcPaths());
184
+ extras.push(...readGetconfPath());
185
+ extras.push(...knownExtraPaths());
186
+ const nvmBin = findLatestNvmNodeBin();
187
+ if (nvmBin)
188
+ extras.push(nvmBin);
189
+ staticExtrasCache = extras;
190
+ return extras;
191
+ }
192
+ /** Test-only hook: forget the cached /etc/paths + getconf + nvm lookup. */
193
+ export function _resetAugmentPathCacheForTests() {
194
+ staticExtrasCache = null;
195
+ }
196
+ /**
197
+ * Build an augmented PATH string.
198
+ *
199
+ * @param base PATH to start from. Defaults to the current process PATH, so
200
+ * callers holding a custom env (e.g. the MCP stdio transport env) can
201
+ * augment that instead.
202
+ */
203
+ export function augmentPath(base) {
204
+ const current = base ?? process.env[getPathEnvKey()] ?? "";
205
+ const segments = new Set();
206
+ const add = (raw) => {
207
+ if (!raw)
208
+ return;
209
+ const trimmed = normalize(raw).replace(/[\\/]+$/, "");
210
+ if (trimmed)
211
+ segments.add(trimmed);
212
+ };
213
+ current.split(pathDelimiter()).forEach(add);
214
+ staticExtraPaths().forEach(add);
215
+ return [...segments].join(pathDelimiter());
216
+ }
217
+ /**
218
+ * Return a single-entry env patch — `{ PATH: <augmented> }` under the
219
+ * case-correct key — ready to spread into a spawn `env`.
220
+ */
221
+ export function augmentedPathEnvPatch(base, env = process.env) {
222
+ const key = getPathEnvKey(env);
223
+ return { [key]: augmentPath(base ?? env[key]) };
224
+ }
225
+ /**
226
+ * Augment `env`'s PATH in place and return it. Used at daemon start so every
227
+ * descendant (bash tool, dev-process registry, MCP transports, git) inherits a
228
+ * PATH that can actually find `node`/`npx`/`git`.
229
+ */
230
+ export function applyAugmentedPath(env = process.env) {
231
+ const key = getPathEnvKey(env);
232
+ env[key] = augmentPath(env[key]);
233
+ return env;
234
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Open a URL in the user's default browser, best-effort.
3
+ *
4
+ * Used by `spectral desktop` (the browser *is* the desktop UI now that the
5
+ * Electron shell is gone) and kept separate from `auth-helper.ts`'s inline
6
+ * variant so both the login flow and the desktop launcher can share one
7
+ * implementation.
8
+ *
9
+ * Never throws: headless boxes, containers and CI have no browser and no
10
+ * `xdg-open`. Callers print the URL themselves when `opened` is false, so a
11
+ * missing browser degrades to "copy this link" instead of a failed command.
12
+ *
13
+ * Two hard rules, because this hands a URL to a process launcher:
14
+ * - only `http(s)` URLs are accepted — anything else (`file:`, `javascript:`,
15
+ * a bare string with shell metacharacters) is refused before a process is
16
+ * spawned, and
17
+ * - the fallback launches the platform opener by argv, never through a shell,
18
+ * so no quoting or re-interpretation of the URL can occur.
19
+ */
20
+ import { spawn } from "node:child_process";
21
+ export interface OpenBrowserResult {
22
+ opened: boolean;
23
+ /** Platform command that was attempted — surfaced in failure messages. */
24
+ command: string;
25
+ error?: string;
26
+ }
27
+ export interface OpenBrowserDeps {
28
+ /** Injectable spawn (tests). */
29
+ spawn?: typeof spawn;
30
+ /** Injectable `open` module loader (tests). */
31
+ loadOpen?: () => Promise<unknown>;
32
+ /** How long to wait for a launched opener to confirm (defaults to 2 s). */
33
+ confirmTimeoutMs?: number;
34
+ }
35
+ /** How long an opener child gets to report a clean exit. */
36
+ export declare const OPEN_CONFIRM_TIMEOUT_MS = 2000;
37
+ export declare function isSafeBrowserUrl(url: string): boolean;
38
+ /** Build the shell command for the current platform (mirrors auth-helper). */
39
+ export declare function browserOpenCommand(url: string, platform?: NodeJS.Platform): string;
40
+ /**
41
+ * The same launch as argv for `spawn(..., { shell: false })`. `start` is a
42
+ * `cmd.exe` builtin, so Windows goes through `cmd /c start` with the URL as a
43
+ * separate argument — still no shell string to inject into.
44
+ */
45
+ export declare function browserOpenArgv(url: string, platform?: NodeJS.Platform): {
46
+ command: string;
47
+ args: string[];
48
+ };
49
+ /**
50
+ * Best-effort browser open. Tries the `open` npm package first (it handles
51
+ * flatpak/WSL and sandbox edge cases) but only accepts it once its child exits
52
+ * 0, then the plain platform opener by argv.
53
+ */
54
+ export declare function openBrowser(url: string, platform?: NodeJS.Platform, deps?: OpenBrowserDeps): Promise<OpenBrowserResult>;
55
+ //# sourceMappingURL=open-browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"open-browser.d.ts","sourceRoot":"","sources":["../../src/utils/open-browser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,KAAK,EAAqB,MAAM,oBAAoB,CAAC;AAE9D,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,OAAO,CAAC;IAChB,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,gCAAgC;IAChC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAClC,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAKD,4DAA4D;AAC5D,eAAO,MAAM,uBAAuB,OAAQ,CAAC;AAE7C,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAErD;AAED,8EAA8E;AAC9E,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,kBAAmB,GAAG,MAAM,CAInF;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,QAAQ,kBAAmB,GAC1B;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAIrC;AAwCD;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,MAAM,EACX,QAAQ,kBAAmB,EAC3B,IAAI,GAAE,eAAoB,GACzB,OAAO,CAAC,iBAAiB,CAAC,CA8E5B"}
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Open a URL in the user's default browser, best-effort.
3
+ *
4
+ * Used by `spectral desktop` (the browser *is* the desktop UI now that the
5
+ * Electron shell is gone) and kept separate from `auth-helper.ts`'s inline
6
+ * variant so both the login flow and the desktop launcher can share one
7
+ * implementation.
8
+ *
9
+ * Never throws: headless boxes, containers and CI have no browser and no
10
+ * `xdg-open`. Callers print the URL themselves when `opened` is false, so a
11
+ * missing browser degrades to "copy this link" instead of a failed command.
12
+ *
13
+ * Two hard rules, because this hands a URL to a process launcher:
14
+ * - only `http(s)` URLs are accepted — anything else (`file:`, `javascript:`,
15
+ * a bare string with shell metacharacters) is refused before a process is
16
+ * spawned, and
17
+ * - the fallback launches the platform opener by argv, never through a shell,
18
+ * so no quoting or re-interpretation of the URL can occur.
19
+ */
20
+ import { spawn } from "node:child_process";
21
+ /** Only plain http(s) URLs may reach an opener process. */
22
+ const SAFE_URL = /^https?:\/\/[^\s]+$/i;
23
+ /** How long an opener child gets to report a clean exit. */
24
+ export const OPEN_CONFIRM_TIMEOUT_MS = 2_000;
25
+ export function isSafeBrowserUrl(url) {
26
+ return SAFE_URL.test(url.trim());
27
+ }
28
+ /** Build the shell command for the current platform (mirrors auth-helper). */
29
+ export function browserOpenCommand(url, platform = process.platform) {
30
+ if (platform === "darwin")
31
+ return `open "${url}"`;
32
+ if (platform === "win32")
33
+ return `start "" "${url}"`;
34
+ return `xdg-open "${url}"`;
35
+ }
36
+ /**
37
+ * The same launch as argv for `spawn(..., { shell: false })`. `start` is a
38
+ * `cmd.exe` builtin, so Windows goes through `cmd /c start` with the URL as a
39
+ * separate argument — still no shell string to inject into.
40
+ */
41
+ export function browserOpenArgv(url, platform = process.platform) {
42
+ if (platform === "darwin")
43
+ return { command: "open", args: [url] };
44
+ if (platform === "win32")
45
+ return { command: "cmd.exe", args: ["/c", "start", "", url] };
46
+ return { command: "xdg-open", args: [url] };
47
+ }
48
+ /**
49
+ * Wait for a launched opener to exit cleanly.
50
+ *
51
+ * `open` resolves as soon as its child is spawned — the child is what actually
52
+ * fails on a headless box — so "it returned a process" is not proof anything
53
+ * opened. Resolves with the exit code, or null when it failed, exited non-zero,
54
+ * or never reported within `timeoutMs` (the child is killed on timeout so a
55
+ * hung opener cannot wedge the caller).
56
+ */
57
+ function waitForOpenerExit(child, timeoutMs) {
58
+ const cp = child;
59
+ if (!cp || typeof cp.once !== "function")
60
+ return Promise.resolve(null);
61
+ return new Promise((resolve) => {
62
+ let settled = false;
63
+ const finish = (code) => {
64
+ if (settled)
65
+ return;
66
+ settled = true;
67
+ clearTimeout(timer);
68
+ resolve(code);
69
+ };
70
+ const timer = setTimeout(() => {
71
+ try {
72
+ cp.kill?.();
73
+ }
74
+ catch {
75
+ // already gone
76
+ }
77
+ finish(null);
78
+ }, timeoutMs);
79
+ timer.unref?.();
80
+ cp.once("close", (code) => finish(code));
81
+ cp.once("error", () => finish(null));
82
+ });
83
+ }
84
+ /**
85
+ * Best-effort browser open. Tries the `open` npm package first (it handles
86
+ * flatpak/WSL and sandbox edge cases) but only accepts it once its child exits
87
+ * 0, then the plain platform opener by argv.
88
+ */
89
+ export async function openBrowser(url, platform = process.platform, deps = {}) {
90
+ const command = browserOpenCommand(url, platform);
91
+ if (!isSafeBrowserUrl(url)) {
92
+ return { opened: false, command, error: `refused non-http(s) url: ${url}` };
93
+ }
94
+ const confirmTimeoutMs = deps.confirmTimeoutMs ?? OPEN_CONFIRM_TIMEOUT_MS;
95
+ try {
96
+ const openModule = deps.loadOpen ? await deps.loadOpen() : await import("open");
97
+ const openFn = (openModule.default ?? openModule);
98
+ if (typeof openFn === "function") {
99
+ const child = await openFn(url);
100
+ if ((await waitForOpenerExit(child, confirmTimeoutMs)) === 0) {
101
+ return { opened: true, command };
102
+ }
103
+ // Unconfirmed or failed: fall through to the plain opener.
104
+ }
105
+ }
106
+ catch {
107
+ // fall through to the platform opener
108
+ }
109
+ const spawnFn = deps.spawn ?? spawn;
110
+ const argv = browserOpenArgv(url, platform);
111
+ return new Promise((resolve) => {
112
+ let settled = false;
113
+ // Declared up front: `finish` may run before the timer exists (a spawn that
114
+ // throws, or one that fails synchronously), and reading a `const` that has
115
+ // not been initialised yet is a TDZ crash inside a promise.
116
+ let timer;
117
+ // Set once WE gave up, so the kill's own `close` event (signal-killed, code
118
+ // null) cannot masquerade as the opener's verdict.
119
+ let gaveUp = false;
120
+ const finish = (result) => {
121
+ if (settled)
122
+ return;
123
+ settled = true;
124
+ if (timer)
125
+ clearTimeout(timer);
126
+ resolve(result);
127
+ };
128
+ let child;
129
+ try {
130
+ // detached + stdio "ignore": the opener outlives this short-lived CLI
131
+ // process and cannot keep our stdout or the terminal alive.
132
+ child = spawnFn(argv.command, argv.args, { detached: true, stdio: "ignore" });
133
+ }
134
+ catch (err) {
135
+ finish({ opened: false, command, error: err.message });
136
+ return;
137
+ }
138
+ timer = setTimeout(() => {
139
+ gaveUp = true;
140
+ try {
141
+ child.kill();
142
+ }
143
+ catch {
144
+ // already gone
145
+ }
146
+ finish({
147
+ opened: false,
148
+ command,
149
+ error: `${argv.command} did not exit within ${confirmTimeoutMs}ms`,
150
+ });
151
+ }, confirmTimeoutMs);
152
+ timer.unref?.();
153
+ child.on("error", (err) => finish({ opened: false, command, error: err.message }));
154
+ child.on("close", (code) => {
155
+ if (gaveUp)
156
+ return; // the timeout above already reported the real reason
157
+ finish(code === 0
158
+ ? { opened: true, command }
159
+ : { opened: false, command, error: `${argv.command} exited with code ${code}` });
160
+ });
161
+ child.unref();
162
+ });
163
+ }