@dadado/agent-kit-cli 4.8.8 → 5.0.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.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Cosmetic Mission Kit / BYO share URLs for dashboard-broadcast.
3
+ * Fragment-only payloads (never sent to Hostinger access logs).
4
+ * ADR: .cursor/memory/decisions/2026-08-11_mission-control-broadcast-url-mask.md
5
+ */
6
+
7
+ /** Live Hostinger path uses the `.html` artifact; extensionless `/mc/open` may 404 until a host alias exists. */
8
+ export const DEFAULT_SHARE_BASE = "https://missionkit.io/mc/open.html";
9
+ export const SHARE_BASE_ENV = "MISSION_CONTROL_SHARE_BASE";
10
+ export const SHARE_TTL_ENV = "MISSION_CONTROL_SHARE_TTL_SEC";
11
+ export const SHARE_SHOW_LAN_ENV = "MISSION_CONTROL_SHARE_SHOW_LAN";
12
+ export const DEFAULT_SHARE_TTL_SEC = 86_400;
13
+
14
+ /**
15
+ * True when hostname is loopback, link-local, or RFC1918 private (IPv4) / ULA (IPv6).
16
+ * Used by share-target validation (open redirect harden).
17
+ * @param {string} hostname
18
+ * @returns {boolean}
19
+ */
20
+ export function isPrivateOrLoopbackHostname(hostname) {
21
+ const host = String(hostname || "")
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/^\[|\]$/g, "");
25
+ if (!host) return false;
26
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
27
+ if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
28
+ // IPv4 dotted quad
29
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
30
+ if (m) {
31
+ const a = Number(m[1]);
32
+ const b = Number(m[2]);
33
+ const c = Number(m[3]);
34
+ const d = Number(m[4]);
35
+ if ([a, b, c, d].some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
36
+ if (a === 127) return true; // loopback
37
+ if (a === 10) return true; // 10/8
38
+ if (a === 192 && b === 168) return true; // 192.168/16
39
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
40
+ if (a === 169 && b === 254) return true; // link-local
41
+ return false;
42
+ }
43
+ // IPv6 ULA fc00::/7 and link-local fe80::/10
44
+ if (host.includes(":")) {
45
+ if (host.startsWith("fc") || host.startsWith("fd")) return true;
46
+ if (
47
+ host.startsWith("fe8") ||
48
+ host.startsWith("fe9") ||
49
+ host.startsWith("fea") ||
50
+ host.startsWith("feb")
51
+ ) {
52
+ return true;
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Validate a decoded share target URL (LAN Mission Control with optional ?token=).
60
+ * @param {string} url
61
+ * @returns {{ ok: true, url: string } | { ok: false, error: string }}
62
+ */
63
+ export function validateBroadcastShareTarget(url) {
64
+ const raw = typeof url === "string" ? url.trim() : "";
65
+ if (!raw) return { ok: false, error: "invalid-target" };
66
+ let parsed;
67
+ try {
68
+ parsed = new URL(raw);
69
+ } catch {
70
+ return { ok: false, error: "invalid-target" };
71
+ }
72
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
73
+ return { ok: false, error: "invalid-target" };
74
+ }
75
+ if (!isPrivateOrLoopbackHostname(parsed.hostname)) {
76
+ return { ok: false, error: "non-private-target" };
77
+ }
78
+ return { ok: true, url: raw };
79
+ }
80
+
81
+ /**
82
+ * Normalize and validate a share page base. Rejects non-HTTPS (except loopback http for local preview).
83
+ * @param {string} raw
84
+ * @returns {{ ok: true, base: string } | { ok: false, error: string }}
85
+ */
86
+ export function normalizeShareBase(raw) {
87
+ const trimmed = String(raw || "")
88
+ .trim()
89
+ .replace(/#.*$/, "")
90
+ .replace(/\/$/, "");
91
+ if (!trimmed) return { ok: false, error: "empty-base" };
92
+ let parsed;
93
+ try {
94
+ parsed = new URL(trimmed);
95
+ } catch {
96
+ return { ok: false, error: "invalid-base" };
97
+ }
98
+ const host = parsed.hostname.toLowerCase();
99
+ const loopback =
100
+ host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost");
101
+ if (parsed.protocol === "https:") {
102
+ return { ok: true, base: trimmed };
103
+ }
104
+ if (parsed.protocol === "http:" && loopback) {
105
+ return { ok: true, base: trimmed };
106
+ }
107
+ return { ok: false, error: "non-https-base" };
108
+ }
109
+
110
+ /**
111
+ * GET /open.html and /open are the public cosmetic share-resolver shell under broadcast bind.
112
+ * All other methods/paths keep the normal token gate.
113
+ * @param {string} method
114
+ * @param {string} pathname
115
+ * @returns {boolean}
116
+ */
117
+ export function isPublicBroadcastShareShell(method, pathname) {
118
+ return method === "GET" && (pathname === "/open.html" || pathname === "/open");
119
+ }
120
+
121
+ /**
122
+ * @param {boolean} tokenRequired
123
+ * @param {string} method
124
+ * @param {string} pathname
125
+ * @returns {boolean}
126
+ */
127
+ export function shareShellTokenRequired(tokenRequired, method, pathname) {
128
+ return Boolean(tokenRequired) && !isPublicBroadcastShareShell(method, pathname);
129
+ }
130
+
131
+ /**
132
+ * Resolve share page base URL. Empty / "0" / "off" / "false" disables masking.
133
+ * Non-HTTPS BYO bases (except loopback http) are rejected → masking off (with stderr warn when available).
134
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
135
+ * @returns {string | null}
136
+ */
137
+ export function resolveShareBase(env = process.env) {
138
+ const raw = env?.[SHARE_BASE_ENV];
139
+ if (raw === undefined || raw === null) return DEFAULT_SHARE_BASE;
140
+ const trimmed = String(raw).trim();
141
+ if (!trimmed) return null;
142
+ const lower = trimmed.toLowerCase();
143
+ if (lower === "0" || lower === "off" || lower === "false" || lower === "none") return null;
144
+ const normalized = normalizeShareBase(trimmed);
145
+ if (!normalized.ok) {
146
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
147
+ console.warn(
148
+ `[broadcast-share] ${SHARE_BASE_ENV} rejected (${normalized.error}); printing LAN URL only. Use HTTPS (or loopback http) or set off.`,
149
+ );
150
+ }
151
+ return null;
152
+ }
153
+ return normalized.base;
154
+ }
155
+
156
+ /**
157
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
158
+ * @returns {number}
159
+ */
160
+ export function resolveShareTtlSec(env = process.env) {
161
+ const raw = env?.[SHARE_TTL_ENV];
162
+ if (raw === undefined || raw === null || String(raw).trim() === "") {
163
+ return DEFAULT_SHARE_TTL_SEC;
164
+ }
165
+ const n = Number.parseInt(String(raw), 10);
166
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_SHARE_TTL_SEC;
167
+ return n;
168
+ }
169
+
170
+ /**
171
+ * When false, starter omits secondary LAN/Local URL lines (share + token only).
172
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
173
+ */
174
+ export function resolveShareShowLan(env = process.env) {
175
+ const raw = env?.[SHARE_SHOW_LAN_ENV];
176
+ if (raw === undefined || raw === null || String(raw).trim() === "") return true;
177
+ const lower = String(raw).trim().toLowerCase();
178
+ return !(lower === "0" || lower === "off" || lower === "false" || lower === "no");
179
+ }
180
+
181
+ /**
182
+ * @param {string} lanUrl full http URL including ?token=
183
+ * @param {{ ttlSec?: number, nowSec?: number }} [opts]
184
+ * @returns {string} fragment without leading '#'
185
+ */
186
+ export function encodeBroadcastSharePayload(lanUrl, opts = {}) {
187
+ const url = typeof lanUrl === "string" ? lanUrl.trim() : "";
188
+ const target = validateBroadcastShareTarget(url);
189
+ if (!target.ok) {
190
+ throw new Error(`encodeBroadcastSharePayload: ${target.error}`);
191
+ }
192
+ const ttlSec = opts.ttlSec ?? DEFAULT_SHARE_TTL_SEC;
193
+ /** @type {{ v: number, u: string, e?: number }} */
194
+ const body = { v: 1, u: target.url };
195
+ if (ttlSec > 0) {
196
+ const now = opts.nowSec ?? Math.floor(Date.now() / 1000);
197
+ body.e = now + ttlSec;
198
+ }
199
+ return `v1.${Buffer.from(JSON.stringify(body), "utf8").toString("base64url")}`;
200
+ }
201
+
202
+ /**
203
+ * @param {string} lanUrl
204
+ * @param {{ base?: string | null, ttlSec?: number, nowSec?: number }} [opts]
205
+ * @returns {string | null}
206
+ */
207
+ export function buildBroadcastShareUrl(lanUrl, opts = {}) {
208
+ const base = opts.base === undefined ? DEFAULT_SHARE_BASE : opts.base;
209
+ if (!base) return null;
210
+ const cleaned = String(base).replace(/#.*$/, "").replace(/\/$/, "");
211
+ const frag = encodeBroadcastSharePayload(lanUrl, opts);
212
+ return `${cleaned}#${frag}`;
213
+ }
214
+
215
+ /**
216
+ * @param {string} fragment hash with or without leading '#'
217
+ * @param {{ nowSec?: number }} [opts]
218
+ * @returns
219
+ * | { ok: true, url: string, expiresAt: number | null }
220
+ * | { ok: false, error: string }
221
+ */
222
+ export function decodeBroadcastShareFragment(fragment, opts = {}) {
223
+ const raw = String(fragment || "")
224
+ .replace(/^#/, "")
225
+ .trim();
226
+ if (!raw) return { ok: false, error: "missing-fragment" };
227
+ const m = /^v1\.([A-Za-z0-9_-]+)$/.exec(raw);
228
+ if (!m) return { ok: false, error: "unsupported-version" };
229
+ let parsed;
230
+ try {
231
+ const json = Buffer.from(m[1], "base64url").toString("utf8");
232
+ parsed = JSON.parse(json);
233
+ } catch {
234
+ return { ok: false, error: "invalid-payload" };
235
+ }
236
+ if (!parsed || typeof parsed !== "object" || parsed.v !== 1) {
237
+ return { ok: false, error: "unsupported-version" };
238
+ }
239
+ const url = typeof parsed.u === "string" ? parsed.u.trim() : "";
240
+ const target = validateBroadcastShareTarget(url);
241
+ if (!target.ok) return { ok: false, error: target.error };
242
+ let expiresAt = null;
243
+ if (parsed.e !== undefined && parsed.e !== null) {
244
+ const e = Number(parsed.e);
245
+ if (!Number.isFinite(e)) return { ok: false, error: "invalid-expiry" };
246
+ expiresAt = e;
247
+ const now = opts.nowSec ?? Math.floor(Date.now() / 1000);
248
+ if (now > e) return { ok: false, error: "expired" };
249
+ }
250
+ return { ok: true, url: target.url, expiresAt };
251
+ }
@@ -0,0 +1,10 @@
1
+ /** Ambient types for dashboard/lib/guards.mjs (consumed by CLI TypeScript). */
2
+
3
+ export function resolveContextConfigPath(
4
+ repoRoot: string,
5
+ fsHooks?: {
6
+ existsSync?: (path: string) => boolean;
7
+ realpathSync?: (path: string) => string;
8
+ mkdirSync?: (path: string, opts?: { recursive?: boolean }) => void;
9
+ },
10
+ ): { ok: true; path: string } | { ok: false; error: string };
@@ -675,12 +675,17 @@ export function validateConfigWriteBody(body) {
675
675
  if (!modes || typeof modes !== "object" || Array.isArray(modes)) {
676
676
  return { ok: false, error: "agentPersona.modes must be an object" };
677
677
  }
678
- /** @type {Record<string, string>} */
678
+ /** @type {Record<string, string | null>} */
679
679
  const modesPatch = {};
680
680
  for (const [mode, persona] of Object.entries(modes)) {
681
681
  if (!CONFIG_PERSONA_MODES.includes(mode)) {
682
682
  return { ok: false, error: `unknown agentPersona.modes key: ${mode}` };
683
683
  }
684
+ // null clears an existing mode override (Inherit default).
685
+ if (persona === null) {
686
+ modesPatch[mode] = null;
687
+ continue;
688
+ }
684
689
  if (typeof persona !== "string" || !CONFIG_PERSONA_IDS.includes(persona)) {
685
690
  return { ok: false, error: `agentPersona.modes.${mode} must be a builtin persona id` };
686
691
  }
@@ -767,7 +772,15 @@ export function mergeConfigAllowlist(existing, patch) {
767
772
  prev.modes && typeof prev.modes === "object" && !Array.isArray(prev.modes)
768
773
  ? { ...prev.modes }
769
774
  : {};
770
- next.modes = { ...prevModes, ...patch.agentPersona.modes };
775
+ const nextModes = { ...prevModes };
776
+ for (const [mode, persona] of Object.entries(patch.agentPersona.modes)) {
777
+ if (persona === null) {
778
+ delete nextModes[mode];
779
+ } else {
780
+ nextModes[mode] = persona;
781
+ }
782
+ }
783
+ next.modes = nextModes;
771
784
  }
772
785
  base.agentPersona = next;
773
786
  }
@@ -0,0 +1,31 @@
1
+ import type { spawn, spawnSync } from "node:child_process";
2
+ import type { readFileSync } from "node:fs";
3
+
4
+ export const OS_DEFAULT_TOKENS: Set<string>;
5
+ export function isSafePreferredBrowser(value: string): boolean;
6
+ export function shouldSkipOpen(env?: NodeJS.ProcessEnv): boolean;
7
+ export function normalizePreferredBrowser(value: unknown): string | null;
8
+ export function resolvePreferredBrowser(opts?: {
9
+ env?: NodeJS.ProcessEnv;
10
+ configValue?: unknown;
11
+ }): string | null;
12
+ export function readPreferredBrowserFromConfig(
13
+ configPath: string,
14
+ fsHooks?: { readFileSync?: typeof readFileSync },
15
+ ): unknown;
16
+ export function buildOpenBrowserCommand(opts: {
17
+ url: string;
18
+ preferred?: string | null;
19
+ platform?: NodeJS.Platform;
20
+ }): { command: string; args: string[] } | null;
21
+ export function openBrowser(
22
+ url: string,
23
+ options?: {
24
+ env?: NodeJS.ProcessEnv;
25
+ preferred?: string | null;
26
+ configValue?: unknown;
27
+ platform?: NodeJS.Platform;
28
+ spawnFn?: typeof spawn;
29
+ spawnSyncFn?: typeof spawnSync;
30
+ },
31
+ ): { opened: boolean; reason?: string; command?: string; args?: string[] };
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Shared Mission Control browser open helper.
3
+ *
4
+ * Preference resolution (highest wins):
5
+ * 1. env MISSION_CONTROL_PREFERRED_BROWSER
6
+ * 2. config missionControl.preferredBrowser (passed in by caller)
7
+ * 3. OS default handler (null preferred)
8
+ *
9
+ * Skips open when MISSION_CONTROL_NO_OPEN=1.
10
+ * Never opens more than one process per call (preferred may fall back once).
11
+ *
12
+ * Trust boundary: preferredBrowser is an app/binary *name*, not a path or
13
+ * shell expression. Values with path separators or shell metacharacters are
14
+ * rejected and treated as OS default.
15
+ *
16
+ * ADR: .cursor/memory/decisions/2026-08-11_mission-control-preferred-browser.md
17
+ */
18
+
19
+ import { spawn, spawnSync } from "node:child_process";
20
+ import { readFileSync } from "node:fs";
21
+ import { platform as osPlatform } from "node:os";
22
+
23
+ /** Sentinel values that mean "use OS default" (and slash-only Ask). */
24
+ export const OS_DEFAULT_TOKENS = new Set(["", "default", "os", "ask"]);
25
+
26
+ /**
27
+ * Reject path separators, absolute/relative path forms, and shell metacharacters.
28
+ * Allowed examples: "Google Chrome", "firefox", "msedge", "Brave Browser".
29
+ *
30
+ * @param {string} value
31
+ * @returns {boolean}
32
+ */
33
+ export function isSafePreferredBrowser(value) {
34
+ if (typeof value !== "string") return false;
35
+ const s = value.trim();
36
+ if (!s) return false;
37
+ if (/[/\\]/.test(s)) return false;
38
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional reject of C0/DEL in browser names
39
+ if (/[\0-\x1f\x7f]/.test(s)) return false;
40
+ if (/[$`;&|<>(){}[\]!*?#~"'%^=,+]/.test(s)) return false;
41
+ if (s.includes(":")) return false;
42
+ if (/^-/.test(s)) return false;
43
+ return true;
44
+ }
45
+
46
+ /**
47
+ * @param {NodeJS.ProcessEnv} [env]
48
+ * @returns {boolean}
49
+ */
50
+ export function shouldSkipOpen(env = process.env) {
51
+ return env.MISSION_CONTROL_NO_OPEN === "1";
52
+ }
53
+
54
+ /**
55
+ * @param {unknown} value
56
+ * @returns {string | null} trimmed app/binary name, or null for OS default
57
+ */
58
+ export function normalizePreferredBrowser(value) {
59
+ if (value == null) return null;
60
+ const s = String(value).trim();
61
+ if (!s || OS_DEFAULT_TOKENS.has(s.toLowerCase())) return null;
62
+ if (!isSafePreferredBrowser(s)) return null;
63
+ return s;
64
+ }
65
+
66
+ /**
67
+ * @param {{ env?: NodeJS.ProcessEnv, configValue?: unknown }} [opts]
68
+ * @returns {string | null}
69
+ */
70
+ export function resolvePreferredBrowser(opts = {}) {
71
+ const env = opts.env ?? process.env;
72
+ const fromEnv = env.MISSION_CONTROL_PREFERRED_BROWSER;
73
+ if (fromEnv != null && String(fromEnv).trim() !== "") {
74
+ return normalizePreferredBrowser(fromEnv);
75
+ }
76
+ return normalizePreferredBrowser(opts.configValue);
77
+ }
78
+
79
+ /**
80
+ * Read missionControl.preferredBrowser from a context config.json path.
81
+ * Missing/invalid file → null (OS default). Does not create the file.
82
+ *
83
+ * @param {string} configPath
84
+ * @param {{ readFileSync?: typeof readFileSync }} [fsHooks]
85
+ * @returns {unknown}
86
+ */
87
+ export function readPreferredBrowserFromConfig(configPath, fsHooks = {}) {
88
+ const read = fsHooks.readFileSync ?? readFileSync;
89
+ try {
90
+ const raw = read(configPath, "utf8");
91
+ const data = JSON.parse(raw);
92
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
93
+ const mc = data.missionControl;
94
+ if (!mc || typeof mc !== "object" || Array.isArray(mc)) return null;
95
+ return mc.preferredBrowser ?? null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Build an argv for a single open attempt (hermetic: no spawn).
103
+ *
104
+ * @param {{
105
+ * url: string,
106
+ * preferred?: string | null,
107
+ * platform?: NodeJS.Platform,
108
+ * }} opts
109
+ * @returns {{ command: string, args: string[] } | null}
110
+ */
111
+ export function buildOpenBrowserCommand(opts) {
112
+ const url = opts.url;
113
+ if (typeof url !== "string" || !url.trim()) return null;
114
+ const preferred = normalizePreferredBrowser(opts.preferred ?? null);
115
+ const os = opts.platform ?? osPlatform();
116
+
117
+ if (os === "darwin") {
118
+ if (preferred) {
119
+ return { command: "open", args: ["-a", preferred, url] };
120
+ }
121
+ return { command: "open", args: [url] };
122
+ }
123
+
124
+ if (os === "win32") {
125
+ if (preferred) {
126
+ // `start` treats the first quoted arg as window title; pass empty title.
127
+ return { command: "cmd", args: ["/c", "start", "", preferred, url] };
128
+ }
129
+ return { command: "cmd", args: ["/c", "start", "", url] };
130
+ }
131
+
132
+ // Linux / other: preferred is a binary or command name; else xdg-open.
133
+ if (preferred) {
134
+ return { command: preferred, args: [url] };
135
+ }
136
+ return { command: "xdg-open", args: [url] };
137
+ }
138
+
139
+ /**
140
+ * @param {import("node:child_process").ChildProcess | { on?: Function, unref?: Function } | null | undefined} child
141
+ */
142
+ function attachErrorSwallow(child) {
143
+ if (child && typeof child.on === "function") {
144
+ child.on("error", () => {
145
+ /* prevent unhandled 'error' (ENOENT) from killing the launcher */
146
+ });
147
+ }
148
+ if (child && typeof child.unref === "function") {
149
+ child.unref();
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Open one browser for the URL. Returns whether a process was spawned.
155
+ * When a preferred open fails, falls back once to the OS default opener.
156
+ *
157
+ * @param {string} url
158
+ * @param {{
159
+ * env?: NodeJS.ProcessEnv,
160
+ * preferred?: string | null,
161
+ * configValue?: unknown,
162
+ * platform?: NodeJS.Platform,
163
+ * spawnFn?: typeof spawn,
164
+ * spawnSyncFn?: typeof spawnSync,
165
+ * }} [options]
166
+ * @returns {{ opened: boolean, reason?: string, command?: string, args?: string[] }}
167
+ */
168
+ export function openBrowser(url, options = {}) {
169
+ const env = options.env ?? process.env;
170
+ if (shouldSkipOpen(env)) {
171
+ return { opened: false, reason: "no-open" };
172
+ }
173
+
174
+ const preferred =
175
+ options.preferred !== undefined
176
+ ? normalizePreferredBrowser(options.preferred)
177
+ : resolvePreferredBrowser({ env, configValue: options.configValue });
178
+
179
+ const platform = options.platform ?? osPlatform();
180
+ const spawnFn = options.spawnFn ?? spawn;
181
+ const spawnSyncFn = options.spawnSyncFn;
182
+
183
+ /**
184
+ * @param {{ command: string, args: string[] }} built
185
+ * @returns {{ opened: boolean, reason?: string, command: string, args: string[] }}
186
+ */
187
+ function runDetached(built) {
188
+ try {
189
+ const child = spawnFn(built.command, built.args, { detached: true, stdio: "ignore" });
190
+ attachErrorSwallow(child);
191
+ return { opened: true, command: built.command, args: built.args };
192
+ } catch {
193
+ return {
194
+ opened: false,
195
+ reason: "spawn-failed",
196
+ command: built.command,
197
+ args: built.args,
198
+ };
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Preferred open: detect failure before claiming success, then caller may fall back.
204
+ * Hermetic tests that only inject spawnFn use the detached path (throw = fail).
205
+ *
206
+ * @param {{ command: string, args: string[] }} built
207
+ * @returns {{ opened: boolean, reason?: string, command: string, args: string[] }}
208
+ */
209
+ function runPreferred(built) {
210
+ if (options.spawnFn && !spawnSyncFn) {
211
+ return runDetached(built);
212
+ }
213
+
214
+ const sync = spawnSyncFn ?? spawnSync;
215
+
216
+ if (platform !== "darwin" && platform !== "win32") {
217
+ // Long-lived browser binaries: probe PATH, then detach (do not spawnSync the app).
218
+ const probe = sync("which", [built.command], { encoding: "utf8" });
219
+ if (probe.error || (typeof probe.status === "number" && probe.status !== 0)) {
220
+ return {
221
+ opened: false,
222
+ reason: "spawn-failed",
223
+ command: built.command,
224
+ args: built.args,
225
+ };
226
+ }
227
+ return runDetached(built);
228
+ }
229
+
230
+ // darwin `open` / win32 `cmd /c start` exit quickly.
231
+ try {
232
+ const result = sync(built.command, built.args, {
233
+ encoding: "utf8",
234
+ windowsHide: true,
235
+ });
236
+ if (result.error || (typeof result.status === "number" && result.status !== 0)) {
237
+ return {
238
+ opened: false,
239
+ reason: "spawn-failed",
240
+ command: built.command,
241
+ args: built.args,
242
+ };
243
+ }
244
+ return { opened: true, command: built.command, args: built.args };
245
+ } catch {
246
+ return {
247
+ opened: false,
248
+ reason: "spawn-failed",
249
+ command: built.command,
250
+ args: built.args,
251
+ };
252
+ }
253
+ }
254
+
255
+ const built = buildOpenBrowserCommand({
256
+ url,
257
+ preferred,
258
+ platform,
259
+ });
260
+ if (!built) {
261
+ return { opened: false, reason: "invalid-url" };
262
+ }
263
+
264
+ if (!preferred) {
265
+ // Same failure detection as preferred opens (probe / spawnSync) so OS-default
266
+ // missing handlers are not reported as opened:true.
267
+ return runPreferred(built);
268
+ }
269
+
270
+ const prefResult = runPreferred(built);
271
+ if (prefResult.opened) {
272
+ return prefResult;
273
+ }
274
+
275
+ const fallback = buildOpenBrowserCommand({
276
+ url,
277
+ preferred: null,
278
+ platform,
279
+ });
280
+ if (!fallback) {
281
+ return { opened: false, reason: "invalid-url" };
282
+ }
283
+ const fb = runPreferred(fallback);
284
+ if (fb.opened) {
285
+ return {
286
+ opened: true,
287
+ command: fb.command,
288
+ args: fb.args,
289
+ reason: "preferred-fallback",
290
+ };
291
+ }
292
+ return {
293
+ opened: false,
294
+ reason: "spawn-failed",
295
+ command: built.command,
296
+ args: built.args,
297
+ };
298
+ }