@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/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
- // web: { cwd: Frontend, run: "npm start", port: 3000, env: { API_URL: "http://localhost:9000" } }
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" instead of
15
- // started twice.
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 { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
26
- import { networkInterfaces } from "node:os";
27
- import { join, resolve, basename } from "node:path";
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 { services: {}, preview: null, companions: [], error: `${rel}: ${err?.message || err}` };
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
- return { services, preview, companions, error: null };
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
- async function waitForReady({ port, health, timeoutMs }) {
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
- try {
173
- // `localhost`, not 127.0.0.1 — the server may be IPv6-only (see
174
- // isPortListening); Node's fetch tries both families.
175
- const res = await fetch(`http://localhost:${port}${health.startsWith("/") ? health : `/${health}`}`, { signal: AbortSignal.timeout(2000) });
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
- constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {} }) {
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
- * Start (or adopt) every service in `config` relative to `repoRoot`.
206
- * Returns `{ services: [{name, port, url, logPath, adopted, ready}], preview: {name, url, lanUrl} | null }`.
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 start(repoRoot, config, { mode = "worktree" } = {}) {
209
- mkdirSync(this.logDir, { recursive: true });
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 = config.preview && !services.some((s) => s.name === config.preview)
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 (mode === "local" && declared && (await isPortListening(declared))) {
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 (this.processes.has(svc.name)) {
253
- out.push({ name: svc.name, port, url: `http://localhost:${port}`, logPath, adopted: false, ready: true });
254
- continue;
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
- const cwd = resolve(repoRoot, svc.cwd);
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 ? { name: previewSvc.name, url: previewSvc.url, lanUrl: lan ? `http://${lan}:${previewSvc.port}` : null, adopted: !!previewSvc.adopted } : null,
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). Package manager by lockfile; up
303
- * to 5 minutes; output goes to the service log. No-op otherwise.
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")) || existsSync(join(cwd, "node_modules"))) return;
307
- const pm = existsSync(join(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(cwd, "yarn.lock")) ? "yarn" : "npm";
308
- const installCmd = `${pm} install`;
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(-1);
321
- }, 5 * 60_000);
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
- if (code !== 0) this.onStatus(`${installCmd} exited with code ${code} — see ${logPath}`);
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
- stopAll() {
335
- for (const [name, child] of this.processes) {
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
- /** The Playwright MCP server registration for the turn (stdio, headless). */
365
- export function previewMcpServer(runnerDir) {
366
- return {
367
- id: "gleap_preview",
368
- name: "gleap_preview",
369
- command: process.execPath,
370
- args: [join(runnerDir, "..", "node_modules", "@playwright", "mcp", "cli.js"), "--headless", "--isolated"],
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
  }