@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.
- package/LICENSE +75 -21
- package/README.md +17 -0
- package/dashboard/dashboard-data.mjs +103 -0
- package/dashboard/dashboard.html +504 -171
- package/dashboard/lib/broadcast-share.mjs +251 -0
- package/dashboard/lib/guards.d.mts +10 -0
- package/dashboard/lib/guards.mjs +15 -2
- package/dashboard/lib/open-browser.d.mts +31 -0
- package/dashboard/lib/open-browser.mjs +298 -0
- package/dashboard/lib/semantic-model.mjs +215 -9
- package/dashboard/open.html +213 -0
- package/dashboard/serve.mjs +6 -2
- package/dashboard/start-broadcast.mjs +73 -30
- package/dashboard/start.mjs +21 -25
- package/dist/index.js +1417 -416
- package/package.json +4 -2
|
@@ -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 };
|
package/dashboard/lib/guards.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|