@gleapai/kai-bridge 0.2.9 → 0.5.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/package.json +1 -1
- package/runner/acp-runner.mjs +69 -10
- package/runner/lib/acp/mapper.mjs +31 -1
- package/runner/lib/contract.mjs +21 -2
- package/runner/personas/claude/kai-verifier.md +84 -0
- package/runner/personas/codex/kai-verifier.md +84 -0
- package/runner/tools/verify-mcp.mjs +442 -0
- package/src/api.mjs +102 -1
- package/src/companions.mjs +101 -0
- package/src/daemon.mjs +1502 -147
- package/src/deps.mjs +113 -0
- package/src/executor.mjs +16 -4
- package/src/ports.mjs +134 -0
- package/src/preview-errors.mjs +283 -0
- package/src/preview-login.mjs +610 -0
- package/src/preview.mjs +777 -106
- package/src/selfupdate.mjs +26 -0
- package/src/service.mjs +11 -0
- package/src/setup.mjs +30 -2
- package/src/verify.mjs +387 -0
- package/src/workspace.mjs +60 -1
package/src/preview.mjs
CHANGED
|
@@ -3,16 +3,28 @@
|
|
|
3
3
|
// Each repo may commit a `.gleap/dev.yaml`:
|
|
4
4
|
//
|
|
5
5
|
// services:
|
|
6
|
-
// api: { cwd: Server, run: "npm run dev", port: 9000, health: /health
|
|
7
|
-
//
|
|
6
|
+
// api: { cwd: Server, run: "npm run dev", port: 9000, health: /health, kind: api, openapi: docs/openapi.json,
|
|
7
|
+
// reload: restart, requires: [{ name: mongodb, port: 27017 }] }
|
|
8
|
+
// web: { cwd: Frontend, run: "npm start", port: 3000, env: { API_URL: "http://localhost:9000" }, protocol: http }
|
|
8
9
|
// preview: web
|
|
10
|
+
// companions: [github.com/owner/api]
|
|
11
|
+
// verify: { readOnly: true, endpoints: [/api/v1/tickets], loginVia: github.com/owner/web }
|
|
12
|
+
// external: [https://cdn.example.com]
|
|
13
|
+
// auth: { storageState: <command>, loginCheck: { path, selector },
|
|
14
|
+
// apiToken: { from: localStorage, origin: web, key: token, header: Authorization, prefix: "Bearer " } }
|
|
9
15
|
//
|
|
10
16
|
// Worktree mode: every service gets a FREE port (the user's own dev server
|
|
11
17
|
// may already own the declared one) — `PORT` is set and `${port:<name>}`
|
|
12
18
|
// placeholders in `env`/`run` are substituted, so `API_URL: http://localhost:${port:api}`
|
|
13
19
|
// follows the re-assignment. Local mode: a service whose declared port is
|
|
14
|
-
// already listening is adopted as "your running dev server"
|
|
15
|
-
//
|
|
20
|
+
// already listening is adopted as "your running dev server" — but ONLY when
|
|
21
|
+
// the listener's working directory is inside the repo (ports.mjs); anything
|
|
22
|
+
// else on that port is `port_busy`, with pid / command / cwd for the card.
|
|
23
|
+
//
|
|
24
|
+
// Every failure is a `PreviewError` (preview-errors.mjs) with a code the
|
|
25
|
+
// dashboard turns into a diagnosis: dependency_missing, port_busy,
|
|
26
|
+
// deps_failed, env_missing, service_crashed, … — classified from the whole
|
|
27
|
+
// log tail before anything is truncated.
|
|
16
28
|
//
|
|
17
29
|
// Logs go to `~/.kai/logs/services/<session>/<service>.log` so Kai can
|
|
18
30
|
// `tail` them; the timeline only gets one line per start/stop/failure.
|
|
@@ -21,13 +33,55 @@
|
|
|
21
33
|
// later tier (needs relay infra).
|
|
22
34
|
|
|
23
35
|
import { spawn } from "node:child_process";
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
24
37
|
import { createServer, connect } from "node:net";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
38
|
+
import { request as httpsRequest } from "node:https";
|
|
39
|
+
import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
40
|
+
import { createRequire } from "node:module";
|
|
41
|
+
import { homedir, networkInterfaces } from "node:os";
|
|
42
|
+
import { dirname, join, resolve, basename, posix as posixPath, win32 as win32Path } from "node:path";
|
|
43
|
+
import { fileURLToPath } from "node:url";
|
|
28
44
|
import YAML from "yaml";
|
|
29
45
|
|
|
46
|
+
import { PreviewError, classifyLogTail, detectMonorepo, logTailSummary, missingEnvKeys, stripOpenFlag } from "./preview-errors.mjs";
|
|
47
|
+
import { canAdoptPort, describeBusyPort, describeListener as lsofDescribeListener } from "./ports.mjs";
|
|
48
|
+
import { copyPrimaryEnvFiles } from "./workspace.mjs";
|
|
49
|
+
|
|
50
|
+
const DEFAULT_RUNNER_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "runner");
|
|
51
|
+
import { installCommandFor } from "./deps.mjs";
|
|
52
|
+
|
|
30
53
|
export const DEV_CONFIG_PATHS = [".gleap/dev.yaml", ".gleap/dev.yml"];
|
|
54
|
+
export const SERVICE_KINDS = new Set(["web", "api"]);
|
|
55
|
+
export const RELOAD_MODES = new Set(["hot", "restart"]);
|
|
56
|
+
export const PROTOCOLS = new Set(["http", "https"]);
|
|
57
|
+
export const DEPS_INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
58
|
+
export const DEPS_MARKER = ".kai-installed";
|
|
59
|
+
/** Progress ticks during long waits (install, readiness) — the booting heartbeat. */
|
|
60
|
+
export const PROGRESS_TICK_MS = 30_000;
|
|
61
|
+
/** Post-ready settle before the re-probe (env override for tests: KAI_PREVIEW_SETTLE_MS). Read at construction time. */
|
|
62
|
+
const DEFAULT_SETTLE_MS = Symbol("default");
|
|
63
|
+
const defaultSettleMs = () => Math.max(0, Number(process.env.KAI_PREVIEW_SETTLE_MS ?? 3_000) || 0);
|
|
64
|
+
const KNOWN_TOP_KEYS = new Set(["services", "preview", "companions", "auth", "verify", "external"]);
|
|
65
|
+
const KNOWN_SERVICE_KEYS = new Set(["name", "cwd", "run", "port", "health", "env", "readyTimeoutMs", "kind", "openapi", "protocol", "reload", "requires"]);
|
|
66
|
+
const KNOWN_AUTH_KEYS = new Set(["storageState", "loginCheck", "apiToken"]);
|
|
67
|
+
|
|
68
|
+
const EMPTY_CONFIG = () => ({ services: {}, preview: null, companions: [], auth: { storageState: null, loginCheck: null, apiToken: null }, verify: { readOnly: null, endpoints: [], loginVia: null }, external: [], unknownKeys: [], error: null });
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wrap a service/install command so it runs with the daemon's own node
|
|
72
|
+
* first on PATH. Under launchd the daemon's PATH is frozen at install
|
|
73
|
+
* time (service.mjs), so we run through a login shell to pick up
|
|
74
|
+
* nvm/pnpm shims — but on macOS a login shell runs `path_helper`, which
|
|
75
|
+
* moves `/usr/local/bin` ahead of every inherited entry and never
|
|
76
|
+
* sources `.zshrc` (where nvm lives). A stale `/usr/local/bin/node`
|
|
77
|
+
* then wins over the nvm node the daemon itself runs on, and Vite
|
|
78
|
+
* refuses to start. Prepending inside the command string survives that
|
|
79
|
+
* reordering; prepending on `env.PATH` does not.
|
|
80
|
+
*/
|
|
81
|
+
export function withDaemonNode(cmd, nodeBin = dirname(process.execPath)) {
|
|
82
|
+
const quoted = `'${nodeBin.replace(/'/g, `'\\''`)}'`;
|
|
83
|
+
return `export PATH=${quoted}:"$PATH"; ${cmd}`;
|
|
84
|
+
}
|
|
31
85
|
|
|
32
86
|
export function readDevConfig(repoRoot) {
|
|
33
87
|
for (const rel of DEV_CONFIG_PATHS) {
|
|
@@ -37,14 +91,31 @@ export function readDevConfig(repoRoot) {
|
|
|
37
91
|
const parsed = YAML.parse(readFileSync(p, "utf8")) || {};
|
|
38
92
|
return normalizeDevConfig(parsed);
|
|
39
93
|
} catch (err) {
|
|
40
|
-
return {
|
|
94
|
+
return { ...EMPTY_CONFIG(), error: `${rel}: ${err?.message || err}` };
|
|
41
95
|
}
|
|
42
96
|
}
|
|
43
97
|
return null;
|
|
44
98
|
}
|
|
45
99
|
|
|
100
|
+
function normalizeOriginList(list) {
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const entry of Array.isArray(list) ? list : typeof list === "string" ? [list] : []) {
|
|
103
|
+
try {
|
|
104
|
+
const u = new URL(String(entry).trim());
|
|
105
|
+
if (u.protocol === "http:" || u.protocol === "https:") out.push(u.origin.toLowerCase());
|
|
106
|
+
} catch {
|
|
107
|
+
/* junk */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return [...new Set(out)];
|
|
111
|
+
}
|
|
112
|
+
|
|
46
113
|
export function normalizeDevConfig(raw) {
|
|
47
114
|
const services = {};
|
|
115
|
+
const unknownKeys = [];
|
|
116
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
117
|
+
for (const k of Object.keys(raw)) if (!KNOWN_TOP_KEYS.has(k)) unknownKeys.push(k);
|
|
118
|
+
}
|
|
48
119
|
// Accept both shapes: `services: {api: {...}}` and the list form
|
|
49
120
|
// `services: [- name: api ...]` people naturally write in YAML —
|
|
50
121
|
// Object.entries on an array yields index keys, which used to name
|
|
@@ -54,6 +125,16 @@ export function normalizeDevConfig(raw) {
|
|
|
54
125
|
: raw?.services || {};
|
|
55
126
|
for (const [name, s] of Object.entries(rawServices)) {
|
|
56
127
|
if (!s || typeof s !== "object" || !s.run) continue;
|
|
128
|
+
for (const k of Object.keys(s)) if (!KNOWN_SERVICE_KEYS.has(k)) unknownKeys.push(`services.${name}.${k}`);
|
|
129
|
+
const kind = typeof s.kind === "string" && SERVICE_KINDS.has(s.kind.toLowerCase()) ? s.kind.toLowerCase() : null;
|
|
130
|
+
const protocol = typeof s.protocol === "string" && PROTOCOLS.has(s.protocol.toLowerCase()) ? s.protocol.toLowerCase() : "http";
|
|
131
|
+
const reload = typeof s.reload === "string" && RELOAD_MODES.has(s.reload.toLowerCase()) ? s.reload.toLowerCase() : null;
|
|
132
|
+
const requires = [];
|
|
133
|
+
for (const r of Array.isArray(s.requires) ? s.requires : []) {
|
|
134
|
+
const port = Number(r?.port);
|
|
135
|
+
if (!Number.isInteger(port) || port <= 0) continue;
|
|
136
|
+
requires.push({ name: typeof r?.name === "string" && r.name.trim() ? r.name.trim() : `service on :${port}`, port });
|
|
137
|
+
}
|
|
57
138
|
services[name] = {
|
|
58
139
|
name,
|
|
59
140
|
cwd: typeof s.cwd === "string" ? s.cwd : ".",
|
|
@@ -62,9 +143,45 @@ export function normalizeDevConfig(raw) {
|
|
|
62
143
|
health: typeof s.health === "string" ? s.health : null,
|
|
63
144
|
env: s.env && typeof s.env === "object" ? Object.fromEntries(Object.entries(s.env).map(([k, v]) => [k, String(v)])) : {},
|
|
64
145
|
readyTimeoutMs: Number.isFinite(Number(s.readyTimeoutMs)) ? Number(s.readyTimeoutMs) : 90_000,
|
|
146
|
+
kind, // null = auto (probe content-type after boot)
|
|
147
|
+
openapi: typeof s.openapi === "string" && s.openapi.trim() ? s.openapi.trim() : null,
|
|
148
|
+
protocol,
|
|
149
|
+
reload, // null = default for the kind (hot for web, restart for api)
|
|
150
|
+
requires,
|
|
65
151
|
};
|
|
66
152
|
}
|
|
67
153
|
const preview = typeof raw?.preview === "string" && services[raw.preview] ? raw.preview : Object.keys(services)[0] || null;
|
|
154
|
+
// Optional sign-in hooks for Verify (0.4.0):
|
|
155
|
+
// auth:
|
|
156
|
+
// storageState: "node scripts/test-login.mjs" # prints Playwright storageState JSON (or a path to it)
|
|
157
|
+
// loginCheck: { path: /account, selector: "[data-testid=user-menu]" }
|
|
158
|
+
// apiToken: { from: localStorage, origin: web, key: token, header: Authorization, prefix: "Bearer " }
|
|
159
|
+
// `storageState` replaces the user's saved sign-in record; `loginCheck`
|
|
160
|
+
// replaces the probe's login-wall heuristic (selector visible = signed in);
|
|
161
|
+
// `apiToken` says where the app keeps its API credential so the daemon can
|
|
162
|
+
// hand `http_request` an auth header derived from the injected sign-in.
|
|
163
|
+
const rawAuth = raw?.auth && typeof raw.auth === "object" ? raw.auth : {};
|
|
164
|
+
for (const k of Object.keys(rawAuth)) if (!KNOWN_AUTH_KEYS.has(k)) unknownKeys.push(`auth.${k}`);
|
|
165
|
+
const loginCheck =
|
|
166
|
+
rawAuth.loginCheck && typeof rawAuth.loginCheck === "object" && typeof rawAuth.loginCheck.selector === "string" && rawAuth.loginCheck.selector.trim()
|
|
167
|
+
? { path: typeof rawAuth.loginCheck.path === "string" && rawAuth.loginCheck.path.trim() ? rawAuth.loginCheck.path.trim() : "/", selector: rawAuth.loginCheck.selector.trim() }
|
|
168
|
+
: null;
|
|
169
|
+
const rawToken = rawAuth.apiToken && typeof rawAuth.apiToken === "object" ? rawAuth.apiToken : null;
|
|
170
|
+
const apiToken =
|
|
171
|
+
rawToken && typeof rawToken.key === "string" && rawToken.key.trim()
|
|
172
|
+
? {
|
|
173
|
+
from: rawToken.from === "cookie" ? "cookie" : "localStorage",
|
|
174
|
+
origin: typeof rawToken.origin === "string" && rawToken.origin.trim() ? rawToken.origin.trim() : preview,
|
|
175
|
+
key: rawToken.key.trim(),
|
|
176
|
+
header: typeof rawToken.header === "string" && /^[A-Za-z0-9-]+$/.test(rawToken.header.trim()) ? rawToken.header.trim() : "Authorization",
|
|
177
|
+
prefix: typeof rawToken.prefix === "string" ? rawToken.prefix : "Bearer ",
|
|
178
|
+
}
|
|
179
|
+
: null;
|
|
180
|
+
const auth = {
|
|
181
|
+
storageState: typeof rawAuth.storageState === "string" && rawAuth.storageState.trim() ? rawAuth.storageState.trim() : null,
|
|
182
|
+
loginCheck,
|
|
183
|
+
apiToken,
|
|
184
|
+
};
|
|
68
185
|
// Companion repos this preview needs running alongside (an API for a
|
|
69
186
|
// frontend, etc). Entries are repo keys as reported by the device scan
|
|
70
187
|
// ("github.com/owner/name") — strings, or {repo, optional} objects.
|
|
@@ -75,14 +192,30 @@ export function normalizeDevConfig(raw) {
|
|
|
75
192
|
if (!repo) continue;
|
|
76
193
|
companions.push({ repo: repo.toLowerCase().trim(), optional: !!(entry && typeof entry === "object" && entry.optional) });
|
|
77
194
|
}
|
|
78
|
-
|
|
195
|
+
// Verify policy: `readOnly` null = default (true when the run exercises an
|
|
196
|
+
// API service — a shared database must not be written by a tester).
|
|
197
|
+
const rawVerify = raw?.verify && typeof raw.verify === "object" ? raw.verify : {};
|
|
198
|
+
const verify = {
|
|
199
|
+
readOnly: typeof rawVerify.readOnly === "boolean" ? rawVerify.readOnly : null,
|
|
200
|
+
endpoints: (Array.isArray(rawVerify.endpoints) ? rawVerify.endpoints : []).map((e) => String(e).trim()).filter(Boolean).slice(0, 50),
|
|
201
|
+
loginVia: typeof rawVerify.loginVia === "string" && rawVerify.loginVia.trim() ? rawVerify.loginVia.trim().toLowerCase() : null,
|
|
202
|
+
};
|
|
203
|
+
return { services, preview, companions, auth, verify, external: normalizeOriginList(raw?.external), unknownKeys, error: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The reload mode a service actually uses (`hot` unless declared / api). */
|
|
207
|
+
export function effectiveReload(svc, kind = svc?.kind) {
|
|
208
|
+
if (svc?.reload) return svc.reload;
|
|
209
|
+
return kind === "api" ? "restart" : "hot";
|
|
79
210
|
}
|
|
80
211
|
|
|
81
212
|
/**
|
|
82
213
|
* No-config fallback: infer a single dev service from the repo's root
|
|
83
214
|
* package.json. `.gleap/dev.yaml` stays the durable override — callers
|
|
84
215
|
* check `readDevConfig` first. Returns null when nothing runnable is
|
|
85
|
-
* found (no package.json, or no dev/start/serve script)
|
|
216
|
+
* found (no package.json, or no dev/start/serve script), and
|
|
217
|
+
* `{ needsConfig: true, hint }` for a workspace root (turbo / nx / lerna /
|
|
218
|
+
* pnpm -r / yarn workspaces) where guessing a command would be wrong.
|
|
86
219
|
*/
|
|
87
220
|
export function detectDevConfig(repoRoot) {
|
|
88
221
|
const pkgPath = join(repoRoot, "package.json");
|
|
@@ -93,6 +226,8 @@ export function detectDevConfig(repoRoot) {
|
|
|
93
226
|
} catch {
|
|
94
227
|
return null;
|
|
95
228
|
}
|
|
229
|
+
const mono = detectMonorepo(repoRoot, pkg, { exists: existsSync });
|
|
230
|
+
if (mono) return { needsConfig: true, hint: mono.hint };
|
|
96
231
|
const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
|
|
97
232
|
const script = ["dev", "start", "serve"].find((s) => typeof scripts[s] === "string" && scripts[s].trim());
|
|
98
233
|
if (!script) return null;
|
|
@@ -153,6 +288,14 @@ export function getFreePort() {
|
|
|
153
288
|
});
|
|
154
289
|
}
|
|
155
290
|
|
|
291
|
+
/** First port in [port, port + tries) nobody is listening on, else null. */
|
|
292
|
+
export async function findFreePortNear(port, tries = 10) {
|
|
293
|
+
for (let p = port; p < port + tries && p <= 65535; p++) {
|
|
294
|
+
if (!(await isPortListening(p))) return p;
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
156
299
|
export function lanAddress() {
|
|
157
300
|
for (const list of Object.values(networkInterfaces())) {
|
|
158
301
|
for (const i of list || []) if (i.family === "IPv4" && !i.internal) return i.address;
|
|
@@ -165,56 +308,148 @@ export function substitutePorts(value, ports) {
|
|
|
165
308
|
return String(value).replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? ""));
|
|
166
309
|
}
|
|
167
310
|
|
|
168
|
-
|
|
311
|
+
/** GET `url` accepting self-signed certificates (dev https); resolves `{ status, contentType }` or null. */
|
|
312
|
+
export function fetchInsecure(url, { timeoutMs = 2_000 } = {}) {
|
|
313
|
+
return new Promise((resolveP) => {
|
|
314
|
+
if (!/^https:/i.test(url)) {
|
|
315
|
+
fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: "manual" })
|
|
316
|
+
.then((res) => resolveP({ status: res.status, contentType: res.headers.get("content-type") || "" }))
|
|
317
|
+
.catch(() => resolveP(null));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
let done = false;
|
|
321
|
+
const finish = (v) => {
|
|
322
|
+
if (done) return;
|
|
323
|
+
done = true;
|
|
324
|
+
resolveP(v);
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
const req = httpsRequest(url, { method: "GET", rejectUnauthorized: false, timeout: timeoutMs }, (res) => {
|
|
328
|
+
finish({ status: res.statusCode ?? 0, contentType: String(res.headers["content-type"] || "") });
|
|
329
|
+
res.resume();
|
|
330
|
+
});
|
|
331
|
+
req.on("timeout", () => {
|
|
332
|
+
req.destroy();
|
|
333
|
+
finish(null);
|
|
334
|
+
});
|
|
335
|
+
req.on("error", () => finish(null));
|
|
336
|
+
req.end();
|
|
337
|
+
} catch {
|
|
338
|
+
finish(null);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Content-type ≠ text/html → an API (no UI to click through). Web when unknown. */
|
|
344
|
+
export function kindFromContentType(contentType) {
|
|
345
|
+
const ct = String(contentType || "").toLowerCase();
|
|
346
|
+
if (!ct) return "web";
|
|
347
|
+
return ct.includes("text/html") ? "web" : "api";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function waitForReady({ port, health, timeoutMs, protocol = "http", onTick = () => {}, tickMs = PROGRESS_TICK_MS }) {
|
|
169
351
|
const deadline = Date.now() + timeoutMs;
|
|
352
|
+
const startedAt = Date.now();
|
|
353
|
+
let nextTick = startedAt + tickMs;
|
|
170
354
|
while (Date.now() < deadline) {
|
|
171
355
|
if (health) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (res.ok || (res.status >= 300 && res.status < 500)) return true;
|
|
177
|
-
} catch {
|
|
178
|
-
/* not yet */
|
|
179
|
-
}
|
|
356
|
+
// `localhost`, not 127.0.0.1 — the server may be IPv6-only (see
|
|
357
|
+
// isPortListening); Node's fetch tries both families.
|
|
358
|
+
const res = await fetchInsecure(`${protocol}://localhost:${port}${health.startsWith("/") ? health : `/${health}`}`, { timeoutMs: 2000 });
|
|
359
|
+
if (res && (res.status < 300 || (res.status >= 300 && res.status < 500))) return true;
|
|
180
360
|
} else if (await isPortListening(port)) {
|
|
181
361
|
return true;
|
|
182
362
|
}
|
|
363
|
+
if (Date.now() >= nextTick) {
|
|
364
|
+
onTick(Math.round((Date.now() - startedAt) / 1000));
|
|
365
|
+
nextTick = Date.now() + tickMs;
|
|
366
|
+
}
|
|
183
367
|
await new Promise((r) => setTimeout(r, 1000));
|
|
184
368
|
}
|
|
185
369
|
return false;
|
|
186
370
|
}
|
|
187
371
|
|
|
372
|
+
function lockfileHash(cwd) {
|
|
373
|
+
for (const name of ["pnpm-lock.yaml", "yarn.lock", "package-lock.json", "bun.lockb", "bun.lock"]) {
|
|
374
|
+
const p = join(cwd, name);
|
|
375
|
+
if (!existsSync(p)) continue;
|
|
376
|
+
try {
|
|
377
|
+
return createHash("sha1").update(readFileSync(p)).digest("hex");
|
|
378
|
+
} catch {
|
|
379
|
+
/* unreadable lockfile — treat as none */
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return "none";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Runs `cmd` through the user's login shell (darwin) or the platform shell, capturing output into `fd`. */
|
|
386
|
+
function spawnShell(cmd, { cwd, env, fd, detached = false }) {
|
|
387
|
+
if (process.platform !== "win32") cmd = withDaemonNode(cmd);
|
|
388
|
+
if (process.platform === "darwin") {
|
|
389
|
+
// Under launchd the daemon's PATH is frozen at install time
|
|
390
|
+
// (service.mjs), so nvm/pnpm shims don't resolve with a bare
|
|
391
|
+
// `shell: true`. A login shell rebuilds the user's real PATH.
|
|
392
|
+
return spawn("/bin/zsh", ["-lc", cmd], { cwd, env, stdio: ["ignore", fd, fd], detached });
|
|
393
|
+
}
|
|
394
|
+
// Windows has no process groups to kill and `detached` would open a
|
|
395
|
+
// console window — taskkill /T does the tree.
|
|
396
|
+
return spawn(cmd, { cwd, env, shell: true, stdio: ["ignore", fd, fd], detached: process.platform !== "win32" && detached, windowsHide: true });
|
|
397
|
+
}
|
|
398
|
+
|
|
188
399
|
/**
|
|
189
400
|
* Runs the services of one repo binding for the lifetime of a turn (or
|
|
190
401
|
* longer — the daemon keeps them per session until the session ends).
|
|
191
402
|
*/
|
|
192
403
|
export class ServiceRunner {
|
|
193
|
-
|
|
404
|
+
/**
|
|
405
|
+
* `preferredPort({ repoKey, service })` → a port to TRY before falling
|
|
406
|
+
* back to a random free one (the daemon injects a stable hash of
|
|
407
|
+
* repo + service into 43000-43999, so a saved sign-in's origins keep
|
|
408
|
+
* matching across sessions). Only used when the port is actually free.
|
|
409
|
+
* `describeListener(port)` (ports.mjs by default) decides adoption;
|
|
410
|
+
* `onServiceExit({ name, code, repoRoot, repoKey, logPath, error, errorCode, detail })`
|
|
411
|
+
* fires when a service that had become ready dies on its own;
|
|
412
|
+
* `onProcess(name, pid, "add" | "remove")` lets the daemon persist pids.
|
|
413
|
+
*/
|
|
414
|
+
constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir() } = {}) {
|
|
415
|
+
if (settleMs === DEFAULT_SETTLE_MS) settleMs = defaultSettleMs();
|
|
194
416
|
this.kaiHome = kaiHome;
|
|
195
417
|
this.sessionId = sessionId;
|
|
196
418
|
this.log = log;
|
|
197
419
|
this.onStatus = onStatus;
|
|
420
|
+
this.preferredPort = typeof preferredPort === "function" ? preferredPort : null;
|
|
421
|
+
this.describeListener = typeof describeListener === "function" ? describeListener : (port) => lsofDescribeListener(port);
|
|
422
|
+
this.settleMs = Math.max(0, Number(settleMs) || 0);
|
|
423
|
+
this.onServiceExit = onServiceExit;
|
|
424
|
+
this.onProcess = onProcess;
|
|
425
|
+
this.home = home;
|
|
198
426
|
this.processes = new Map(); // name → child
|
|
199
427
|
this.ports = {}; // name → port
|
|
200
428
|
this.adopted = new Set();
|
|
429
|
+
this.adoptedCwd = new Map();
|
|
430
|
+
this.stopping = new Set(); // names being stopped on purpose (no crash report)
|
|
431
|
+
this.serviceRoots = new Map(); // service name → repo root
|
|
432
|
+
this.repoKeys = new Map(); // service name → repo key
|
|
433
|
+
this.meta = new Map(); // service name → { svc, repoRoot, repoKey, mode, cwd, logPath, kind, protocol, ready, envSource }
|
|
434
|
+
this.registered = new Map(); // repoRoot → [svc] (renamed, in config order)
|
|
201
435
|
this.logDir = join(kaiHome, "logs", "services", String(sessionId).replace(/[^\w.-]/g, "_"));
|
|
202
436
|
}
|
|
203
437
|
|
|
204
438
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
439
|
+
* Register a repo's services and assign their ports WITHOUT booting —
|
|
440
|
+
* called for every config (session repos + companions) before any boot,
|
|
441
|
+
* so `${port:x}` cross-references resolve regardless of boot order.
|
|
442
|
+
* Idempotent per repo root. Throws `PreviewError` (`port_busy`) when a
|
|
443
|
+
* declared port is held by a process that is not this repo's.
|
|
207
444
|
*/
|
|
208
|
-
async
|
|
209
|
-
|
|
210
|
-
const out = [];
|
|
445
|
+
async assignPorts(repoRoot, config, { mode = "worktree", repoKey = null, envSource = null } = {}) {
|
|
446
|
+
if (this.registered.has(repoRoot)) return this.registered.get(repoRoot);
|
|
211
447
|
// Same service name from a DIFFERENT repo is a collision, not a
|
|
212
448
|
// reuse: silently sharing the port map entry made the second repo
|
|
213
449
|
// ride the first one's port and never boot. Uniquify with the repo
|
|
214
450
|
// folder as prefix and say so — committed configs should pick
|
|
215
451
|
// collision-safe names, but the runtime must not break when they
|
|
216
452
|
// don't.
|
|
217
|
-
this.serviceRoots ??= new Map();
|
|
218
453
|
const services = Object.values(config.services).map((svc) => {
|
|
219
454
|
const owner = this.serviceRoots.get(svc.name);
|
|
220
455
|
if (owner && owner !== repoRoot) {
|
|
@@ -223,102 +458,309 @@ export class ServiceRunner {
|
|
|
223
458
|
svc = { ...svc, name: unique };
|
|
224
459
|
}
|
|
225
460
|
this.serviceRoots.set(svc.name, repoRoot);
|
|
461
|
+
if (repoKey) this.repoKeys.set(svc.name, repoKey);
|
|
226
462
|
return svc;
|
|
227
463
|
});
|
|
228
|
-
const previewName =
|
|
229
|
-
? services.find((s) => s.name.endsWith(`-${config.preview}`))?.name ?? config.preview
|
|
230
|
-
: config.preview;
|
|
231
|
-
// Assign ports first so cross-references resolve.
|
|
464
|
+
const previewName =
|
|
465
|
+
config.preview && !services.some((s) => s.name === config.preview) ? (services.find((s) => s.name.endsWith(`-${config.preview}`))?.name ?? config.preview) : config.preview;
|
|
232
466
|
for (const svc of services) {
|
|
467
|
+
if (!this.meta.has(svc.name)) {
|
|
468
|
+
this.meta.set(svc.name, { svc, repoRoot, repoKey, mode, cwd: resolve(repoRoot, svc.cwd), logPath: join(this.logDir, `${svc.name}.log`), kind: svc.kind, protocol: svc.protocol, ready: false, envSource, previewName });
|
|
469
|
+
}
|
|
233
470
|
if (this.ports[svc.name]) continue;
|
|
234
471
|
const declared = svc.port;
|
|
235
|
-
if (
|
|
236
|
-
this.ports[svc.name] = declared;
|
|
237
|
-
this.adopted.add(svc.name);
|
|
238
|
-
} else if (declared && !(await isPortListening(declared))) {
|
|
472
|
+
if (declared && !(await isPortListening(declared))) {
|
|
239
473
|
this.ports[svc.name] = declared;
|
|
240
|
-
} else {
|
|
241
|
-
this.ports[svc.name] = await getFreePort();
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
for (const svc of services) {
|
|
245
|
-
const port = this.ports[svc.name];
|
|
246
|
-
const logPath = join(this.logDir, `${svc.name}.log`);
|
|
247
|
-
if (this.adopted.has(svc.name)) {
|
|
248
|
-
out.push({ name: svc.name, port, url: `http://localhost:${port}`, logPath: null, adopted: true, ready: true });
|
|
249
|
-
this.onStatus(`Using your running ${svc.name} on :${port}`);
|
|
250
474
|
continue;
|
|
251
475
|
}
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
476
|
+
if (declared) {
|
|
477
|
+
// Someone listens on the declared port. Ours (cwd inside the repo)
|
|
478
|
+
// in local mode → adopt. Anything else → busy: local mode cannot
|
|
479
|
+
// move (the app is wired to that port), worktree mode can — unless
|
|
480
|
+
// the run command hard-codes the number.
|
|
481
|
+
const listener = await this.describeListener(declared).catch(() => null);
|
|
482
|
+
if (mode === "local" && canAdoptPort({ listener, repoRoot })) {
|
|
483
|
+
this.ports[svc.name] = declared;
|
|
484
|
+
this.adopted.add(svc.name);
|
|
485
|
+
// Remember WHICH checkout serves it (may be a sibling worktree) so
|
|
486
|
+
// branch/commit/dirty describe the code actually running.
|
|
487
|
+
if (listener?.cwd) this.adoptedCwd.set(svc.name, listener.cwd);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const busy = describeBusyPort(declared, listener, { home: this.home });
|
|
491
|
+
const hardCoded = new RegExp(`(^|[^0-9])${declared}([^0-9]|$)`).test(`${svc.run} ${Object.values(svc.env).join(" ")}`);
|
|
492
|
+
if (mode === "local" || hardCoded) {
|
|
493
|
+
throw new PreviewError(`${svc.name}: ${busy.message}${mode === "local" ? "" : ` The run command pins that port, so it cannot be moved.`}`, {
|
|
494
|
+
code: "port_busy",
|
|
495
|
+
repo: repoKey,
|
|
496
|
+
service: svc.name,
|
|
497
|
+
detail: busy.detail,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
255
500
|
}
|
|
256
|
-
|
|
257
|
-
const env = {
|
|
258
|
-
...process.env,
|
|
259
|
-
PORT: String(port),
|
|
260
|
-
...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports)])),
|
|
261
|
-
KAI_SESSION_ID: String(this.sessionId),
|
|
262
|
-
BROWSER: "none",
|
|
263
|
-
};
|
|
264
|
-
const fd = openSync(logPath, "a");
|
|
265
|
-
// Fresh worktrees have no node_modules — running the dev command
|
|
266
|
-
// straight into `command not found` was the #1 preview failure.
|
|
267
|
-
await this.ensureDeps(cwd, { env, fd, logPath });
|
|
268
|
-
const cmd = substitutePorts(svc.run, this.ports);
|
|
269
|
-
// Under launchd the daemon's PATH is frozen at install time
|
|
270
|
-
// (service.mjs), so nvm/pnpm shims don't resolve with a bare
|
|
271
|
-
// `shell: true`. A login shell rebuilds the user's real PATH.
|
|
272
|
-
const child =
|
|
273
|
-
process.platform === "darwin"
|
|
274
|
-
? spawn("/bin/zsh", ["-lc", cmd], { cwd, env, stdio: ["ignore", fd, fd], detached: true })
|
|
275
|
-
: spawn(cmd, { cwd, env, shell: true, stdio: ["ignore", fd, fd], detached: true });
|
|
276
|
-
child.on("exit", (code) => {
|
|
277
|
-
this.processes.delete(svc.name);
|
|
278
|
-
this.log("info", "service.exit", { name: svc.name, code });
|
|
279
|
-
if (code && code !== 0) this.onStatus(`${svc.name} exited with code ${code} — see ${logPath}`);
|
|
280
|
-
});
|
|
281
|
-
this.processes.set(svc.name, child);
|
|
282
|
-
this.onStatus(`Starting ${svc.name} (${svc.run}) on :${port}`);
|
|
283
|
-
// Bail as soon as the process dies (command not found, crash on
|
|
284
|
-
// boot) instead of polling a dead port for the full timeout.
|
|
285
|
-
const ready = await Promise.race([
|
|
286
|
-
waitForReady({ port, health: svc.health, timeoutMs: svc.readyTimeoutMs }),
|
|
287
|
-
new Promise((resolveP) => child.once("exit", () => setTimeout(() => resolveP(false), 300))),
|
|
288
|
-
]);
|
|
289
|
-
this.onStatus(ready ? `${svc.name} ready on http://localhost:${port}` : `${svc.name} did not become ready within ${Math.round(svc.readyTimeoutMs / 1000)}s — see ${logPath}`);
|
|
290
|
-
out.push({ name: svc.name, port, url: `http://localhost:${port}`, logPath, adopted: false, ready });
|
|
501
|
+
this.ports[svc.name] = (declared ? await findFreePortNear(declared) : await this.stablePort(repoKey, svc.name)) ?? (await getFreePort());
|
|
291
502
|
}
|
|
503
|
+
this.registered.set(repoRoot, services);
|
|
504
|
+
this.previewNames ??= new Map();
|
|
505
|
+
this.previewNames.set(repoRoot, previewName);
|
|
506
|
+
return services;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Start (or adopt) every service in `config` relative to `repoRoot`.
|
|
511
|
+
* Returns `{ services: [{name, port, url, logPath, adopted, ready, kind, protocol}], preview: {name, url, lanUrl} | null }`.
|
|
512
|
+
* Throws `PreviewError` for a service that cannot boot; a service that
|
|
513
|
+
* boots but never becomes ready is returned with `ready: false` plus the
|
|
514
|
+
* classified `error` / `errorCode` / `errorDetail` from its log.
|
|
515
|
+
*/
|
|
516
|
+
async start(repoRoot, config, { mode = "worktree", repoKey = null, envSource = null } = {}) {
|
|
517
|
+
mkdirSync(this.logDir, { recursive: true });
|
|
518
|
+
const services = await this.assignPorts(repoRoot, config, { mode, repoKey, envSource });
|
|
519
|
+
const out = [];
|
|
520
|
+
for (const svc of services) out.push(await this.bootService(svc.name));
|
|
521
|
+
const previewName = this.previewNames?.get(repoRoot) ?? null;
|
|
292
522
|
const previewSvc = previewName ? out.find((s) => s.name === previewName) : null;
|
|
293
523
|
const lan = lanAddress();
|
|
294
524
|
return {
|
|
295
525
|
services: out,
|
|
296
|
-
preview: previewSvc
|
|
526
|
+
preview: previewSvc
|
|
527
|
+
? { name: previewSvc.name, url: previewSvc.url, lanUrl: lan ? `${previewSvc.protocol}://${lan}:${previewSvc.port}` : null, adopted: !!previewSvc.adopted, kind: previewSvc.kind, protocol: previewSvc.protocol, ...(previewSvc.adopted && previewSvc.cwd ? { adoptedCwd: previewSvc.cwd } : {}) }
|
|
528
|
+
: null,
|
|
297
529
|
};
|
|
298
530
|
}
|
|
299
531
|
|
|
532
|
+
describeService(name) {
|
|
533
|
+
const m = this.meta.get(name);
|
|
534
|
+
const port = this.ports[name];
|
|
535
|
+
const url = `${m?.protocol ?? "http"}://localhost:${port}`;
|
|
536
|
+
const adopted = this.adopted.has(name);
|
|
537
|
+
return { name, port, url, logPath: adopted ? null : m?.logPath ?? null, adopted, ready: !!m?.ready, kind: m?.kind ?? "web", protocol: m?.protocol ?? "http", repoKey: m?.repoKey ?? null, repoRoot: m?.repoRoot ?? null, cwd: (adopted ? this.adoptedCwd.get(name) : null) ?? m?.cwd ?? null };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Boot one registered service (adopted / already running → described only). */
|
|
541
|
+
async bootService(name) {
|
|
542
|
+
const m = this.meta.get(name);
|
|
543
|
+
if (!m) throw new PreviewError(`Unknown service ${name}`, { code: "other", service: name });
|
|
544
|
+
const { svc, repoRoot, repoKey, cwd, logPath } = m;
|
|
545
|
+
const port = this.ports[name];
|
|
546
|
+
if (this.adopted.has(name)) {
|
|
547
|
+
m.ready = true;
|
|
548
|
+
if (!m.kindProbed) await this.probeKind(m, port);
|
|
549
|
+
this.onStatus(`Using your running ${name} on :${port}`);
|
|
550
|
+
return this.describeService(name);
|
|
551
|
+
}
|
|
552
|
+
if (this.processes.has(name)) return this.describeService(name);
|
|
553
|
+
if (!existsSync(cwd)) {
|
|
554
|
+
throw new PreviewError(`${name}: the service directory ${svc.cwd} does not exist in ${repoRoot}.`, { code: "no_dev_config", repo: repoKey, service: name, detail: { cwd } });
|
|
555
|
+
}
|
|
556
|
+
// Gitignored env files never reach a fresh worktree — copy the primary
|
|
557
|
+
// checkout's PER SERVICE CWD (a Server/ under a monorepo root has its own
|
|
558
|
+
// .env). Never overwrites existing files.
|
|
559
|
+
if (m.envSource && resolve(m.envSource) !== resolve(repoRoot)) {
|
|
560
|
+
const copied = copyPrimaryEnvFiles(resolve(m.envSource, svc.cwd), cwd);
|
|
561
|
+
if (copied.length) this.log("info", "preview.env.copied", { cwd, copied });
|
|
562
|
+
}
|
|
563
|
+
// Declared infrastructure (Mongo, Redis, …) is checked BEFORE the boot:
|
|
564
|
+
// instant and precise, instead of a 90 s readiness timeout ending in a
|
|
565
|
+
// log-tail guess.
|
|
566
|
+
for (const req of svc.requires || []) {
|
|
567
|
+
if (!(await isPortListening(req.port))) {
|
|
568
|
+
throw new PreviewError(`${name} needs ${req.name} on :${req.port}, but nothing is listening there. Start it, then retry.`, {
|
|
569
|
+
code: "dependency_missing",
|
|
570
|
+
repo: repoKey,
|
|
571
|
+
service: name,
|
|
572
|
+
detail: { dependency: req.name, port: req.port },
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const env = {
|
|
577
|
+
...process.env,
|
|
578
|
+
PORT: String(port),
|
|
579
|
+
...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports)])),
|
|
580
|
+
KAI_SESSION_ID: String(this.sessionId),
|
|
581
|
+
BROWSER: "none",
|
|
582
|
+
};
|
|
583
|
+
const fd = openSync(logPath, "a");
|
|
584
|
+
// Fresh worktrees have no node_modules — running the dev command
|
|
585
|
+
// straight into `command not found` was the #1 preview failure. The
|
|
586
|
+
// install is authoritative: when it fails, the dev command never runs.
|
|
587
|
+
await this.ensureDeps(cwd, { env, fd, logPath, service: name, repoKey });
|
|
588
|
+
const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports));
|
|
589
|
+
const child = spawnShell(cmd, { cwd, env, fd, detached: true });
|
|
590
|
+
m.ready = false;
|
|
591
|
+
child.on("exit", (code) => {
|
|
592
|
+
this.processes.delete(name);
|
|
593
|
+
this.onProcess(name, child.pid, "remove");
|
|
594
|
+
this.log("info", "service.exit", { name, code });
|
|
595
|
+
const deliberate = this.stopping.has(name);
|
|
596
|
+
this.stopping.delete(name);
|
|
597
|
+
if (deliberate) return;
|
|
598
|
+
if (code && code !== 0) this.onStatus(`${name} exited with code ${code} — see ${logPath}`);
|
|
599
|
+
if (!m.ready) return; // boot failure — reported by the boot path below
|
|
600
|
+
// A service that was READY died on its own: the preview is broken now,
|
|
601
|
+
// not at the next click. The daemon posts service_crashed.
|
|
602
|
+
m.ready = false;
|
|
603
|
+
const classified = classifyLogTail(this.readLog(logPath));
|
|
604
|
+
void Promise.resolve(
|
|
605
|
+
this.onServiceExit({
|
|
606
|
+
name,
|
|
607
|
+
code,
|
|
608
|
+
repoRoot,
|
|
609
|
+
repoKey,
|
|
610
|
+
logPath,
|
|
611
|
+
error: classified?.line ?? `${name} exited with code ${code ?? "?"}`,
|
|
612
|
+
errorCode: classified?.code ?? "service_crashed",
|
|
613
|
+
detail: classified?.detail ?? null,
|
|
614
|
+
}),
|
|
615
|
+
).catch(() => {});
|
|
616
|
+
});
|
|
617
|
+
this.processes.set(name, child);
|
|
618
|
+
this.onProcess(name, child.pid, "add");
|
|
619
|
+
this.onStatus(`Starting ${name} (${cmd}) on :${port}`);
|
|
620
|
+
// Bail as soon as the process dies (command not found, crash on
|
|
621
|
+
// boot) instead of polling a dead port for the full timeout.
|
|
622
|
+
let ready = await Promise.race([
|
|
623
|
+
waitForReady({ port, health: svc.health, timeoutMs: svc.readyTimeoutMs, protocol: svc.protocol, onTick: (s) => this.onStatus(`Still starting ${name} (${s}s)…`) }),
|
|
624
|
+
new Promise((resolveP) => child.once("exit", () => setTimeout(() => resolveP(false), 300))),
|
|
625
|
+
]);
|
|
626
|
+
if (ready && this.settleMs > 0) {
|
|
627
|
+
// Many dev servers open the port, then crash on their first real
|
|
628
|
+
// request (a missing env var, a DB refusing). Give them a moment and
|
|
629
|
+
// look again before calling the preview "running".
|
|
630
|
+
await new Promise((r) => setTimeout(r, this.settleMs));
|
|
631
|
+
const alive = this.processes.has(name) && (await isPortListening(port));
|
|
632
|
+
if (!alive) ready = false;
|
|
633
|
+
}
|
|
634
|
+
m.ready = ready;
|
|
635
|
+
if (!ready) {
|
|
636
|
+
const tail = this.readLog(logPath);
|
|
637
|
+
const classified = classifyLogTail(tail);
|
|
638
|
+
const envKeys = classified ? [] : missingEnvKeys(cwd, { exists: existsSync, read: (p) => readFileSync(p, "utf8"), env });
|
|
639
|
+
const errorCode = classified?.code ?? (envKeys.length ? "env_missing" : "service_crashed");
|
|
640
|
+
const error = classified?.line ?? (envKeys.length ? `${name} did not start — ${envKeys.length} key${envKeys.length === 1 ? "" : "s"} from .env.example missing: ${envKeys.join(", ")}` : logTailSummary(tail) || `${name} did not become ready within ${Math.round(svc.readyTimeoutMs / 1000)}s`);
|
|
641
|
+
this.onStatus(`${name} did not become ready — ${error} (see ${logPath})`);
|
|
642
|
+
return { ...this.describeService(name), ready: false, error, errorCode, errorDetail: classified?.detail ?? (envKeys.length ? { envKeys } : null) };
|
|
643
|
+
}
|
|
644
|
+
this.onStatus(`${name} ready on ${svc.protocol}://localhost:${port}`);
|
|
645
|
+
await this.probeKind(m, port);
|
|
646
|
+
return this.describeService(name);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** `kind` auto-detection: the root's content-type (once per service). */
|
|
650
|
+
async probeKind(m, port) {
|
|
651
|
+
m.kindProbed = true;
|
|
652
|
+
if (m.kind) return;
|
|
653
|
+
// An HTML error page (Express' default 404) is not a UI: only a 2xx
|
|
654
|
+
// decides; otherwise the health path gets a say before we default to web.
|
|
655
|
+
const root = await fetchInsecure(`${m.protocol}://localhost:${port}/`, { timeoutMs: 3000 });
|
|
656
|
+
if (root && root.status < 300) {
|
|
657
|
+
m.kind = kindFromContentType(root.contentType);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const health = m.svc?.health;
|
|
661
|
+
if (health) {
|
|
662
|
+
const res = await fetchInsecure(`${m.protocol}://localhost:${port}${health.startsWith("/") ? health : `/${health}`}`, { timeoutMs: 3000 });
|
|
663
|
+
if (res && res.status < 300) {
|
|
664
|
+
m.kind = kindFromContentType(res.contentType);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// A UI always serves something at "/"; a 404 there (whatever its
|
|
669
|
+
// content-type — Express renders HTML) is the signature of an API.
|
|
670
|
+
if (root && root.status === 404) {
|
|
671
|
+
m.kind = "api";
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
m.kind = root && root.contentType && !String(root.contentType).toLowerCase().includes("text/html") ? "api" : "web";
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
readLog(logPath) {
|
|
678
|
+
try {
|
|
679
|
+
return readFileSync(logPath, "utf8").slice(-20_000);
|
|
680
|
+
} catch {
|
|
681
|
+
return "";
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The resolver's preferred port when it is valid and free; null otherwise. */
|
|
686
|
+
async stablePort(repoKey, service) {
|
|
687
|
+
if (!this.preferredPort || !repoKey) return null;
|
|
688
|
+
let port = null;
|
|
689
|
+
try {
|
|
690
|
+
port = await this.preferredPort({ repoKey, service });
|
|
691
|
+
} catch {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) return null;
|
|
695
|
+
if (Object.values(this.ports).includes(port)) return null;
|
|
696
|
+
return (await isPortListening(port)) ? null : port;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** `[{ name, url, repoKey, kind, protocol, openapi, repoRoot, adopted }]` for every service this runner owns — the login flows' origin table. */
|
|
700
|
+
describeServices() {
|
|
701
|
+
return Object.entries(this.ports).map(([name, port]) => {
|
|
702
|
+
const m = this.meta.get(name);
|
|
703
|
+
return { name, url: `${m?.protocol ?? "http"}://localhost:${port}`, repoKey: this.repoKeys.get(name) ?? null, kind: m?.kind ?? "web", protocol: m?.protocol ?? "http", openapi: m?.svc?.openapi ?? null, repoRoot: m?.repoRoot ?? null, adopted: this.adopted.has(name), cwd: m?.cwd ?? null };
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Every service of a repo root (names). */
|
|
708
|
+
servicesOf(repoRoot) {
|
|
709
|
+
return (this.registered.get(repoRoot) || []).map((s) => s.name);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Any service on https? → the browser needs `--ignore-https-errors`. */
|
|
713
|
+
usesHttps() {
|
|
714
|
+
return [...this.meta.values()].some((m) => m.protocol === "https");
|
|
715
|
+
}
|
|
716
|
+
|
|
300
717
|
/**
|
|
301
718
|
* Install JS dependencies when the service dir has a package.json but
|
|
302
|
-
* no node_modules (fresh worktree)
|
|
303
|
-
*
|
|
719
|
+
* no node_modules (fresh worktree) or the lockfile changed since our
|
|
720
|
+
* last install (marker `node_modules/.kai-installed`). Package manager
|
|
721
|
+
* by lockfile, preflighted (`command -v`); up to 10 minutes; output to
|
|
722
|
+
* the service log. Authoritative: a failed install throws
|
|
723
|
+
* `PreviewError(deps_failed)` and the dev command never runs.
|
|
304
724
|
*/
|
|
305
|
-
async ensureDeps(cwd, { env, fd, logPath }) {
|
|
306
|
-
if (!existsSync(join(cwd, "package.json"))
|
|
307
|
-
const
|
|
308
|
-
const
|
|
725
|
+
async ensureDeps(cwd, { env, fd, logPath, service = null, repoKey = null }) {
|
|
726
|
+
if (!existsSync(join(cwd, "package.json"))) return { installed: false };
|
|
727
|
+
const marker = join(cwd, "node_modules", DEPS_MARKER);
|
|
728
|
+
const hash = lockfileHash(cwd);
|
|
729
|
+
if (existsSync(join(cwd, "node_modules"))) {
|
|
730
|
+
// The user's own install (no marker) is theirs — never re-run it under
|
|
731
|
+
// them. Our own install is redone only when the lockfile changed.
|
|
732
|
+
if (!existsSync(marker)) return { installed: false };
|
|
733
|
+
let previous = "";
|
|
734
|
+
try {
|
|
735
|
+
previous = readFileSync(marker, "utf8").trim();
|
|
736
|
+
} catch {
|
|
737
|
+
/* unreadable marker → reinstall */
|
|
738
|
+
}
|
|
739
|
+
if (previous === hash) return { installed: false };
|
|
740
|
+
}
|
|
741
|
+
const pm = existsSync(join(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(cwd, "yarn.lock")) ? "yarn" : existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock")) ? "bun" : "npm";
|
|
742
|
+
if (!(await this.commandExists(pm, env))) {
|
|
743
|
+
throw new PreviewError(`${pm} is not installed on this machine (needed to install ${service ?? basename(cwd)}'s dependencies).`, {
|
|
744
|
+
code: "deps_failed",
|
|
745
|
+
repo: repoKey,
|
|
746
|
+
service,
|
|
747
|
+
detail: { command: pm, cwd },
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
const installCmd = installCommandFor(cwd);
|
|
309
751
|
this.onStatus(`Installing dependencies (${installCmd}) — first preview in this workspace…`);
|
|
310
752
|
this.log("info", "service.install", { cwd, pm });
|
|
753
|
+
const startedAt = Date.now();
|
|
754
|
+
const tick = setInterval(() => this.onStatus(`Still installing dependencies (${Math.round((Date.now() - startedAt) / 1000)}s)…`), PROGRESS_TICK_MS);
|
|
755
|
+
tick.unref?.();
|
|
311
756
|
const code = await new Promise((resolveP) => {
|
|
312
|
-
const child =
|
|
313
|
-
process.platform === "darwin"
|
|
314
|
-
? spawn("/bin/zsh", ["-lc", installCmd], { cwd, env, stdio: ["ignore", fd, fd] })
|
|
315
|
-
: spawn(installCmd, { cwd, env, shell: true, stdio: ["ignore", fd, fd] });
|
|
757
|
+
const child = spawnShell(installCmd, { cwd, env, fd });
|
|
316
758
|
const timer = setTimeout(() => {
|
|
317
759
|
try {
|
|
318
760
|
child.kill("SIGTERM");
|
|
319
761
|
} catch {}
|
|
320
|
-
resolveP(
|
|
321
|
-
},
|
|
762
|
+
resolveP("timeout");
|
|
763
|
+
}, DEPS_INSTALL_TIMEOUT_MS);
|
|
322
764
|
child.on("exit", (c) => {
|
|
323
765
|
clearTimeout(timer);
|
|
324
766
|
resolveP(c ?? -1);
|
|
@@ -328,11 +770,44 @@ export class ServiceRunner {
|
|
|
328
770
|
resolveP(-1);
|
|
329
771
|
});
|
|
330
772
|
});
|
|
331
|
-
|
|
773
|
+
clearInterval(tick);
|
|
774
|
+
if (code !== 0) {
|
|
775
|
+
const classified = classifyLogTail(this.readLog(logPath));
|
|
776
|
+
const why = code === "timeout" ? `timed out after ${Math.round(DEPS_INSTALL_TIMEOUT_MS / 60_000)} minutes` : `exited with code ${code}`;
|
|
777
|
+
throw new PreviewError(`${installCmd} ${why}${classified?.line ? ` — ${classified.line}` : ""} (see ${logPath})`, {
|
|
778
|
+
code: "deps_failed",
|
|
779
|
+
repo: repoKey,
|
|
780
|
+
service,
|
|
781
|
+
detail: { command: installCmd, cwd, ...(classified?.detail || {}) },
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
mkdirSync(join(cwd, "node_modules"), { recursive: true });
|
|
786
|
+
writeFileSync(marker, `${hash}\n`);
|
|
787
|
+
} catch {
|
|
788
|
+
/* marker is an optimisation */
|
|
789
|
+
}
|
|
790
|
+
return { installed: true };
|
|
332
791
|
}
|
|
333
792
|
|
|
334
|
-
|
|
335
|
-
|
|
793
|
+
/** `command -v <pm>` through the same shell the services use (login shell on macOS). */
|
|
794
|
+
commandExists(cmd, env) {
|
|
795
|
+
return new Promise((resolveP) => {
|
|
796
|
+
const probe = process.platform === "win32" ? spawn("where", [cmd], { env, stdio: "ignore", windowsHide: true }) : process.platform === "darwin" ? spawn("/bin/zsh", ["-lc", `command -v ${cmd}`], { env, stdio: "ignore" }) : spawn("sh", ["-c", `command -v ${cmd}`], { env, stdio: "ignore" });
|
|
797
|
+
probe.on("exit", (c) => resolveP(c === 0));
|
|
798
|
+
probe.on("error", () => resolveP(false));
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Kill one service's process tree; resolves when the process is gone (bounded). */
|
|
803
|
+
async stopService(name, { timeoutMs = 5_000 } = {}) {
|
|
804
|
+
const child = this.processes.get(name);
|
|
805
|
+
if (!child) return;
|
|
806
|
+
this.stopping.add(name);
|
|
807
|
+
const gone = new Promise((r) => child.once("exit", r));
|
|
808
|
+
if (process.platform === "win32") {
|
|
809
|
+
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
|
|
810
|
+
} else {
|
|
336
811
|
try {
|
|
337
812
|
process.kill(-child.pid, "SIGTERM");
|
|
338
813
|
} catch {
|
|
@@ -342,9 +817,60 @@ export class ServiceRunner {
|
|
|
342
817
|
/* gone */
|
|
343
818
|
}
|
|
344
819
|
}
|
|
820
|
+
}
|
|
821
|
+
this.log("info", "service.stop", { name });
|
|
822
|
+
await Promise.race([gone, new Promise((r) => setTimeout(r, timeoutMs))]);
|
|
823
|
+
if (this.processes.has(name)) {
|
|
824
|
+
try {
|
|
825
|
+
process.platform === "win32" ? child.kill() : process.kill(-child.pid, "SIGKILL");
|
|
826
|
+
} catch {
|
|
827
|
+
/* gone */
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
stopAll() {
|
|
833
|
+
for (const [name, child] of this.processes) {
|
|
834
|
+
this.stopping.add(name);
|
|
835
|
+
if (process.platform === "win32") {
|
|
836
|
+
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
|
|
837
|
+
} else {
|
|
838
|
+
try {
|
|
839
|
+
process.kill(-child.pid, "SIGTERM");
|
|
840
|
+
} catch {
|
|
841
|
+
try {
|
|
842
|
+
child.kill("SIGTERM");
|
|
843
|
+
} catch {
|
|
844
|
+
/* gone */
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
345
848
|
this.log("info", "service.stop", { name });
|
|
346
849
|
}
|
|
347
850
|
this.processes.clear();
|
|
851
|
+
for (const m of this.meta.values()) m.ready = false;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Restart the services of one repo root on their SAME ports (the fix
|
|
856
|
+
* changed code a `reload: restart` service does not hot-reload). Adopted
|
|
857
|
+
* services are the user's own dev server and are never touched — they
|
|
858
|
+
* come back as `{ adopted: true, restarted: false }`. Returns the
|
|
859
|
+
* re-described services.
|
|
860
|
+
*/
|
|
861
|
+
async restart(repoRoot, { only = null } = {}) {
|
|
862
|
+
const names = this.servicesOf(repoRoot).filter((n) => !only || only.includes(n));
|
|
863
|
+
const out = [];
|
|
864
|
+
for (const name of names) {
|
|
865
|
+
if (this.adopted.has(name)) {
|
|
866
|
+
out.push({ ...this.describeService(name), restarted: false });
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
await this.stopService(name);
|
|
870
|
+
const started = await this.bootService(name);
|
|
871
|
+
out.push({ ...started, restarted: true });
|
|
872
|
+
}
|
|
873
|
+
return out;
|
|
348
874
|
}
|
|
349
875
|
|
|
350
876
|
/** Prompt section telling the agent what is running + how to verify. */
|
|
@@ -361,12 +887,157 @@ export class ServiceRunner {
|
|
|
361
887
|
}
|
|
362
888
|
}
|
|
363
889
|
|
|
364
|
-
/**
|
|
365
|
-
export function
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
890
|
+
/** Where the bundled Playwright MCP + its Playwright live (npm may hoist or nest them). */
|
|
891
|
+
export function playwrightPaths(runnerDir) {
|
|
892
|
+
const mcpCli = join(runnerDir, "..", "node_modules", "@playwright", "mcp", "cli.js");
|
|
893
|
+
let playwrightCli = null;
|
|
894
|
+
let bundledChromium = null;
|
|
895
|
+
try {
|
|
896
|
+
// `playwright` exports no `./cli.js` subpath — resolve the (exported)
|
|
897
|
+
// package.json and take cli.js from the same directory.
|
|
898
|
+
playwrightCli = join(dirname(createRequire(mcpCli).resolve("playwright/package.json")), "cli.js");
|
|
899
|
+
// playwright-core's public `executablePath()` is where the bundled
|
|
900
|
+
// build lives whether or not it is installed — the presence check
|
|
901
|
+
// for ensurePreviewBrowser.
|
|
902
|
+
bundledChromium = createRequire(playwrightCli)("playwright-core").chromium.executablePath();
|
|
903
|
+
} catch {
|
|
904
|
+
/* MCP not installed (tests, broken install) — callers treat null as "unknown" */
|
|
905
|
+
}
|
|
906
|
+
return { mcpCli, playwrightCli, bundledChromium };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Pure: is a system Chrome available for `--browser chrome`? Playwright's
|
|
911
|
+
* `chrome` channel launches the user's own installation — no download,
|
|
912
|
+
* and the pages look like what the developer sees. Injected `exists` /
|
|
913
|
+
* `env` keep this testable per platform.
|
|
914
|
+
*/
|
|
915
|
+
export function resolvePreviewBrowser({ platform = process.platform, exists = existsSync, env = process.env } = {}) {
|
|
916
|
+
// Path flavour follows the TARGET platform, so the table is testable
|
|
917
|
+
// for every OS from any OS.
|
|
918
|
+
const p = platform === "win32" ? win32Path : posixPath;
|
|
919
|
+
const candidates = [];
|
|
920
|
+
if (platform === "darwin") {
|
|
921
|
+
candidates.push("/Applications/Google Chrome.app", p.join(env.HOME || homedir(), "Applications", "Google Chrome.app"));
|
|
922
|
+
} else if (platform === "win32") {
|
|
923
|
+
for (const root of [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA]) {
|
|
924
|
+
if (root) candidates.push(p.join(root, "Google", "Chrome", "Application", "chrome.exe"));
|
|
925
|
+
}
|
|
926
|
+
} else {
|
|
927
|
+
for (const dir of String(env.PATH || "").split(p.delimiter).filter(Boolean)) {
|
|
928
|
+
candidates.push(p.join(dir, "google-chrome"), p.join(dir, "google-chrome-stable"));
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return candidates.some((p) => exists(p)) ? "chrome" : null;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** The exact command a user runs by hand when the automatic browser install fails (shown on the `no_browser` card). */
|
|
935
|
+
export function browserInstallCommand({ playwrightCli, withDeps = false } = {}) {
|
|
936
|
+
const cli = playwrightCli || "node_modules/@playwright/mcp/node_modules/playwright/cli.js";
|
|
937
|
+
return `${withDeps ? "sudo " : ""}node ${cli} install${withDeps ? " --with-deps" : ""} chromium`;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Make sure SOME browser can launch before a verify turn: system Chrome
|
|
942
|
+
* wins; otherwise the bundled Chromium must be in Playwright's cache —
|
|
943
|
+
* and nothing installs it today (the `@playwright/mcp` dependency ships
|
|
944
|
+
* no browser), so download it once (`playwright install chromium`, 5-min
|
|
945
|
+
* cap, output to the daemon log). On Linux with passwordless sudo the
|
|
946
|
+
* install adds `--with-deps` (the shared libraries Chromium needs).
|
|
947
|
+
* Resolves `{ browser: "chrome" | "chromium" | null, installed, ok,
|
|
948
|
+
* error?, command? }` — `command` is what to run by hand when it failed.
|
|
949
|
+
* Never throws. `install` / `canSudo` are injectable for tests.
|
|
950
|
+
*/
|
|
951
|
+
export async function ensurePreviewBrowser({ runnerDir, log = () => {}, exists = existsSync, platform = process.platform, env = process.env, install, canSudo } = {}) {
|
|
952
|
+
if (resolvePreviewBrowser({ platform, exists, env })) return { browser: "chrome", installed: false, ok: true };
|
|
953
|
+
const paths = playwrightPaths(runnerDir ?? DEFAULT_RUNNER_DIR);
|
|
954
|
+
if (paths.bundledChromium && exists(paths.bundledChromium)) return { browser: "chromium", installed: false, ok: true };
|
|
955
|
+
if (!paths.playwrightCli) {
|
|
956
|
+
return { browser: null, installed: false, ok: false, error: "Playwright is not installed next to kai-bridge — reinstall @gleapai/kai-bridge.", command: "npm install -g @gleapai/kai-bridge" };
|
|
957
|
+
}
|
|
958
|
+
const withDeps = platform === "linux" && (await (canSudo ?? defaultCanSudo)());
|
|
959
|
+
const installArgs = ["install", ...(withDeps ? ["--with-deps"] : []), "chromium"];
|
|
960
|
+
const command = browserInstallCommand({ playwrightCli: paths.playwrightCli, withDeps });
|
|
961
|
+
log("info", "browser.install.start", { cli: paths.playwrightCli, withDeps });
|
|
962
|
+
const runInstall =
|
|
963
|
+
install ??
|
|
964
|
+
((cli) =>
|
|
965
|
+
new Promise((resolveP) => {
|
|
966
|
+
const child = withDeps
|
|
967
|
+
? spawn("sudo", ["-n", process.execPath, cli, ...installArgs], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } })
|
|
968
|
+
: spawn(process.execPath, [cli, ...installArgs], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } });
|
|
969
|
+
let tail = "";
|
|
970
|
+
const onData = (d) => {
|
|
971
|
+
const line = String(d).trim();
|
|
972
|
+
if (!line) return;
|
|
973
|
+
tail = `${tail}\n${line}`.slice(-2000);
|
|
974
|
+
log("info", "browser.install", { line: line.slice(0, 300) });
|
|
975
|
+
};
|
|
976
|
+
child.stdout.on("data", onData);
|
|
977
|
+
child.stderr.on("data", onData);
|
|
978
|
+
const timer = setTimeout(() => {
|
|
979
|
+
try {
|
|
980
|
+
child.kill("SIGTERM");
|
|
981
|
+
} catch {}
|
|
982
|
+
resolveP({ ok: false, error: "playwright install chromium timed out after 5 minutes" });
|
|
983
|
+
}, 5 * 60_000);
|
|
984
|
+
child.on("error", (err) => {
|
|
985
|
+
clearTimeout(timer);
|
|
986
|
+
resolveP({ ok: false, error: err.message });
|
|
987
|
+
});
|
|
988
|
+
child.on("exit", (code) => {
|
|
989
|
+
clearTimeout(timer);
|
|
990
|
+
resolveP(code === 0 ? { ok: true } : { ok: false, error: `playwright install chromium exited ${code}${tail ? ` — ${tail.trim().split("\n").at(-1)}` : ""}` });
|
|
991
|
+
});
|
|
992
|
+
}));
|
|
993
|
+
const res = await runInstall(paths.playwrightCli, { withDeps, args: installArgs });
|
|
994
|
+
log(res.ok ? "info" : "warn", "browser.install.done", { ok: res.ok, error: res.error });
|
|
995
|
+
return { browser: res.ok ? "chromium" : null, installed: res.ok, ok: !!res.ok, ...(res.ok ? {} : { error: res.error, command }) };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function defaultCanSudo() {
|
|
999
|
+
return new Promise((resolveP) => {
|
|
1000
|
+
try {
|
|
1001
|
+
const child = spawn("sudo", ["-n", "true"], { stdio: "ignore" });
|
|
1002
|
+
child.on("exit", (c) => resolveP(c === 0));
|
|
1003
|
+
child.on("error", () => resolveP(false));
|
|
1004
|
+
} catch {
|
|
1005
|
+
resolveP(false);
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* The Playwright MCP server registration for the turn (stdio, headless,
|
|
1012
|
+
* always `--isolated`: the profile lives in memory). Verify turns add
|
|
1013
|
+
* `outputDir` (recordings + screenshots land there for the host to
|
|
1014
|
+
* upload), `caps` (`devtools` = video/tracing, `testing` = browser_verify_*,
|
|
1015
|
+
* `storage` = browser_storage_state for the final sign-in refresh), an
|
|
1016
|
+
* optional `secretsFile` (dotenv; the MCP substitutes secret NAMES typed
|
|
1017
|
+
* into fields and masks the values), and — when a saved sign-in is
|
|
1018
|
+
* injected — `storageStateFile` (re-read by the MCP on every new context,
|
|
1019
|
+
* so it must outlive the turn) plus `allowedOrigins` (every other request
|
|
1020
|
+
* is aborted: the injected cookies can only ever be sent to the preview).
|
|
1021
|
+
* `ignoreHttpsErrors` for `protocol: https` dev servers (self-signed).
|
|
1022
|
+
* `disabledTools` rides on the entry for the runner (Claude
|
|
1023
|
+
* `disallowedTools`, Codex `disabled_tools`) — `browser_set_storage_state`
|
|
1024
|
+
* is off so the agent can never load a state file of its choosing.
|
|
1025
|
+
*/
|
|
1026
|
+
export function previewMcpServer(runnerDir, { outputDir, caps, secretsFile, browser, storageStateFile, allowedOrigins, disabledTools, ignoreHttpsErrors } = {}) {
|
|
1027
|
+
const args = [playwrightPaths(runnerDir).mcpCli, "--headless", "--isolated"];
|
|
1028
|
+
args.push("--viewport-size", "1280x800");
|
|
1029
|
+
const capList = Array.isArray(caps) ? caps : typeof caps === "string" && caps ? caps.split(",") : [];
|
|
1030
|
+
if (capList.length > 0) args.push(`--caps=${capList.map((c) => String(c).trim()).filter(Boolean).join(",")}`);
|
|
1031
|
+
if (outputDir) args.push("--output-dir", outputDir);
|
|
1032
|
+
if (secretsFile) args.push("--secrets", secretsFile);
|
|
1033
|
+
if (storageStateFile) args.push("--storage-state", storageStateFile);
|
|
1034
|
+
const origins = (Array.isArray(allowedOrigins) ? allowedOrigins : []).map((o) => String(o).trim()).filter(Boolean);
|
|
1035
|
+
if (origins.length > 0) args.push("--allowed-origins", [...new Set(origins)].join(";"));
|
|
1036
|
+
if (ignoreHttpsErrors) args.push("--ignore-https-errors");
|
|
1037
|
+
const channel = browser === undefined ? resolvePreviewBrowser() : browser;
|
|
1038
|
+
if (channel) args.push("--browser", channel);
|
|
1039
|
+
const entry = { id: "gleap_preview", name: "gleap_preview", command: process.execPath, args };
|
|
1040
|
+
const disabled = (Array.isArray(disabledTools) ? disabledTools : []).map((t) => String(t).trim()).filter(Boolean);
|
|
1041
|
+
if (disabled.length > 0) entry.disabledTools = disabled;
|
|
1042
|
+
return entry;
|
|
372
1043
|
}
|