@gleapai/kai-bridge 0.9.1 → 0.10.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/README.md +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/runner/acp-runner.mjs +7 -51
- package/runner/lib/acp/mapper.mjs +1 -31
- package/runner/lib/contract.mjs +1 -16
- package/scripts/postinstall.mjs +7 -0
- package/src/api.mjs +16 -102
- package/src/companions.mjs +3 -2
- package/src/daemon.mjs +607 -1054
- package/src/executor.mjs +2 -2
- package/src/gateway.mjs +217 -0
- package/src/harnesses.mjs +15 -2
- package/src/playwright-patch.mjs +84 -0
- package/src/preview-errors.mjs +0 -29
- package/src/preview.mjs +291 -101
- package/src/service.mjs +0 -11
- package/src/setup.mjs +5 -5
- package/src/tunnel-binary.mjs +109 -0
- package/src/tunnel.mjs +227 -0
- package/src/workspace.mjs +1 -1
- package/runner/personas/claude/kai-verifier.md +0 -84
- package/runner/personas/codex/kai-verifier.md +0 -84
- package/runner/tools/verify-mcp.mjs +0 -442
- package/src/preview-login.mjs +0 -610
- package/src/verify.mjs +0 -387
package/src/preview.mjs
CHANGED
|
@@ -9,10 +9,7 @@ import { previewPorts } from './preview-ports.mjs';
|
|
|
9
9
|
// web: { cwd: Frontend, run: "npm start", port: 3000, env: { API_URL: "http://localhost:9000" }, protocol: http }
|
|
10
10
|
// preview: web
|
|
11
11
|
// companions: [github.com/owner/api]
|
|
12
|
-
// verify: { readOnly: true, endpoints: [/api/v1/tickets], loginVia: github.com/owner/web }
|
|
13
12
|
// external: [https://cdn.example.com]
|
|
14
|
-
// auth: { storageState: <command>, loginCheck: { path, selector },
|
|
15
|
-
// apiToken: { from: localStorage, origin: web, key: token, header: Authorization, prefix: "Bearer " } }
|
|
16
13
|
//
|
|
17
14
|
// Worktree mode: every service gets a FREE port (the user's own dev server
|
|
18
15
|
// may already own the declared one) — `PORT` is set and `${port:<name>}`
|
|
@@ -36,6 +33,8 @@ import { previewPorts } from './preview-ports.mjs';
|
|
|
36
33
|
import { spawn } from "node:child_process";
|
|
37
34
|
import { createHash } from "node:crypto";
|
|
38
35
|
import { createServer, connect } from "node:net";
|
|
36
|
+
|
|
37
|
+
import { Gateway } from "./gateway.mjs";
|
|
39
38
|
import { request as httpsRequest } from "node:https";
|
|
40
39
|
import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
41
40
|
import { createRequire } from "node:module";
|
|
@@ -62,11 +61,36 @@ export const PROGRESS_TICK_MS = 30_000;
|
|
|
62
61
|
/** Post-ready settle before the re-probe (env override for tests: KAI_PREVIEW_SETTLE_MS). Read at construction time. */
|
|
63
62
|
const DEFAULT_SETTLE_MS = Symbol("default");
|
|
64
63
|
const defaultSettleMs = () => Math.max(0, Number(process.env.KAI_PREVIEW_SETTLE_MS ?? 3_000) || 0);
|
|
65
|
-
const KNOWN_TOP_KEYS = new Set(["services", "preview", "companions", "
|
|
64
|
+
const KNOWN_TOP_KEYS = new Set(["services", "preview", "companions", "external", "reverseCompanions"]);
|
|
66
65
|
const KNOWN_SERVICE_KEYS = new Set(["name", "cwd", "run", "port", "health", "env", "readyTimeoutMs", "kind", "openapi", "protocol", "reload", "requires"]);
|
|
67
|
-
|
|
66
|
+
/** Stable preview ports: hash(repoKey + service) into this window. */
|
|
67
|
+
export const STABLE_PORT_MIN = 43000;
|
|
68
|
+
export const STABLE_PORT_SPAN = 1000;
|
|
69
|
+
|
|
70
|
+
const EMPTY_CONFIG = () => ({ services: {}, preview: null, companions: [], reverseCompanions: true, external: [], unknownKeys: [], error: null });
|
|
71
|
+
|
|
72
|
+
/** Deterministic port in [43000, 43999] for a repo's service — the same across sessions. */
|
|
73
|
+
export function preferredStablePort(repoKey, serviceName) {
|
|
74
|
+
const h = createHash("sha1").update(`${String(repoKey || "").toLowerCase()} ${String(serviceName || "")}`).digest();
|
|
75
|
+
return STABLE_PORT_MIN + (h.readUInt32BE(0) % STABLE_PORT_SPAN);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The `playwright` the bundled MCP ships — `null` when it is not installed. */
|
|
79
|
+
export function loadPlaywright(runnerDir) {
|
|
80
|
+
try {
|
|
81
|
+
const req = createRequire(join(runnerDir, "..", "node_modules", "@playwright", "mcp", "package.json"));
|
|
82
|
+
return req("playwright");
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
68
87
|
|
|
69
|
-
|
|
88
|
+
/** Launch options for the warm-up browser: `chrome` when the user's Chrome exists (no download), else the bundled build. */
|
|
89
|
+
export function launchOptionsFor({ headless = true, browser = resolvePreviewBrowser() } = {}) {
|
|
90
|
+
const opts = { headless: !!headless };
|
|
91
|
+
if (browser) opts.channel = browser;
|
|
92
|
+
return opts;
|
|
93
|
+
}
|
|
70
94
|
|
|
71
95
|
/**
|
|
72
96
|
* Wrap a service/install command so it runs with the daemon's own node
|
|
@@ -152,37 +176,6 @@ export function normalizeDevConfig(raw) {
|
|
|
152
176
|
};
|
|
153
177
|
}
|
|
154
178
|
const preview = typeof raw?.preview === "string" && services[raw.preview] ? raw.preview : Object.keys(services)[0] || null;
|
|
155
|
-
// Optional sign-in hooks for Verify (0.4.0):
|
|
156
|
-
// auth:
|
|
157
|
-
// storageState: "node scripts/test-login.mjs" # prints Playwright storageState JSON (or a path to it)
|
|
158
|
-
// loginCheck: { path: /account, selector: "[data-testid=user-menu]" }
|
|
159
|
-
// apiToken: { from: localStorage, origin: web, key: token, header: Authorization, prefix: "Bearer " }
|
|
160
|
-
// `storageState` replaces the user's saved sign-in record; `loginCheck`
|
|
161
|
-
// replaces the probe's login-wall heuristic (selector visible = signed in);
|
|
162
|
-
// `apiToken` says where the app keeps its API credential so the daemon can
|
|
163
|
-
// hand `http_request` an auth header derived from the injected sign-in.
|
|
164
|
-
const rawAuth = raw?.auth && typeof raw.auth === "object" ? raw.auth : {};
|
|
165
|
-
for (const k of Object.keys(rawAuth)) if (!KNOWN_AUTH_KEYS.has(k)) unknownKeys.push(`auth.${k}`);
|
|
166
|
-
const loginCheck =
|
|
167
|
-
rawAuth.loginCheck && typeof rawAuth.loginCheck === "object" && typeof rawAuth.loginCheck.selector === "string" && rawAuth.loginCheck.selector.trim()
|
|
168
|
-
? { path: typeof rawAuth.loginCheck.path === "string" && rawAuth.loginCheck.path.trim() ? rawAuth.loginCheck.path.trim() : "/", selector: rawAuth.loginCheck.selector.trim() }
|
|
169
|
-
: null;
|
|
170
|
-
const rawToken = rawAuth.apiToken && typeof rawAuth.apiToken === "object" ? rawAuth.apiToken : null;
|
|
171
|
-
const apiToken =
|
|
172
|
-
rawToken && typeof rawToken.key === "string" && rawToken.key.trim()
|
|
173
|
-
? {
|
|
174
|
-
from: rawToken.from === "cookie" ? "cookie" : "localStorage",
|
|
175
|
-
origin: typeof rawToken.origin === "string" && rawToken.origin.trim() ? rawToken.origin.trim() : preview,
|
|
176
|
-
key: rawToken.key.trim(),
|
|
177
|
-
header: typeof rawToken.header === "string" && /^[A-Za-z0-9-]+$/.test(rawToken.header.trim()) ? rawToken.header.trim() : "Authorization",
|
|
178
|
-
prefix: typeof rawToken.prefix === "string" ? rawToken.prefix : "Bearer ",
|
|
179
|
-
}
|
|
180
|
-
: null;
|
|
181
|
-
const auth = {
|
|
182
|
-
storageState: typeof rawAuth.storageState === "string" && rawAuth.storageState.trim() ? rawAuth.storageState.trim() : null,
|
|
183
|
-
loginCheck,
|
|
184
|
-
apiToken,
|
|
185
|
-
};
|
|
186
179
|
// Companion repos this preview needs running alongside (an API for a
|
|
187
180
|
// frontend, etc). Entries are repo keys as reported by the device scan
|
|
188
181
|
// ("github.com/owner/name") — strings, or {repo, optional} objects.
|
|
@@ -193,15 +186,9 @@ export function normalizeDevConfig(raw) {
|
|
|
193
186
|
if (!repo) continue;
|
|
194
187
|
companions.push({ repo: repo.toLowerCase().trim(), optional: !!(entry && typeof entry === "object" && entry.optional) });
|
|
195
188
|
}
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
const verify = {
|
|
200
|
-
readOnly: typeof rawVerify.readOnly === "boolean" ? rawVerify.readOnly : null,
|
|
201
|
-
endpoints: (Array.isArray(rawVerify.endpoints) ? rawVerify.endpoints : []).map((e) => String(e).trim()).filter(Boolean).slice(0, 50),
|
|
202
|
-
loginVia: typeof rawVerify.loginVia === "string" && rawVerify.loginVia.trim() ? rawVerify.loginVia.trim().toLowerCase() : null,
|
|
203
|
-
};
|
|
204
|
-
return { services, preview, companions, auth, verify, external: normalizeOriginList(raw?.external), unknownKeys, error: null };
|
|
189
|
+
// `reverseCompanions: false` keeps this repo out of previews of the repos it
|
|
190
|
+
// names as companions (a frontend normally boots alongside its API's ticket).
|
|
191
|
+
return { services, preview, companions, reverseCompanions: raw?.reverseCompanions !== false, external: normalizeOriginList(raw?.external), unknownKeys, error: null };
|
|
205
192
|
}
|
|
206
193
|
|
|
207
194
|
/** The reload mode a service actually uses (`hot` unless declared / api). */
|
|
@@ -304,14 +291,25 @@ export function lanAddress() {
|
|
|
304
291
|
return null;
|
|
305
292
|
}
|
|
306
293
|
|
|
307
|
-
/**
|
|
294
|
+
/**
|
|
295
|
+
* `${port:api}` → assigned port of service `api`, `${url:api}` → its URL.
|
|
296
|
+
* Compatibility for configs written before public links: a browser-facing
|
|
297
|
+
* `http://localhost:${port:api}` becomes the public URL when `api` has one
|
|
298
|
+
* (`127.0.0.1:${port:…}` is the server-to-server form and stays local).
|
|
299
|
+
*/
|
|
308
300
|
export function substitutePorts(value, ports, urls = {}) {
|
|
309
|
-
return String(value)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
301
|
+
return String(value)
|
|
302
|
+
.replace(/https?:\/\/localhost:\$\{port:([\w-]+)\}/g, (match, name) => (/^https:\/\//.test(urls[name] || "") ? urls[name] : match))
|
|
303
|
+
.replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? ""))
|
|
304
|
+
.replace(/\$\{url:([\w-]+)\}/g, (_, name) => {
|
|
305
|
+
if (!urls[name]) throw new Error(`Service URL is not available for ${name}.`);
|
|
306
|
+
return urls[name];
|
|
307
|
+
});
|
|
313
308
|
}
|
|
314
309
|
|
|
310
|
+
/** Does a service bake another service's address into its env or command (and so must restart when addresses change)? */
|
|
311
|
+
export const bakesServiceUrl = (svc) => /\$\{url:|https?:\/\/localhost:\$\{port:/.test(`${svc?.run ?? ""} ${Object.values(svc?.env || {}).join(" ")}`);
|
|
312
|
+
|
|
315
313
|
/** GET `url` accepting self-signed certificates (dev https); resolves `{ status, contentType }` or null. */
|
|
316
314
|
export function fetchInsecure(url, { timeoutMs = 2_000 } = {}) {
|
|
317
315
|
return new Promise((resolveP) => {
|
|
@@ -351,11 +349,13 @@ export function kindFromContentType(contentType) {
|
|
|
351
349
|
return ct.includes("text/html") ? "web" : "api";
|
|
352
350
|
}
|
|
353
351
|
|
|
354
|
-
async function waitForReady({ port, health, timeoutMs, protocol = "http", onTick = () => {}, tickMs = PROGRESS_TICK_MS }) {
|
|
352
|
+
async function waitForReady({ port, health, timeoutMs, protocol = "http", onTick = () => {}, tickMs = PROGRESS_TICK_MS, isCancelled = () => false }) {
|
|
355
353
|
const deadline = Date.now() + timeoutMs;
|
|
356
354
|
const startedAt = Date.now();
|
|
357
355
|
let nextTick = startedAt + tickMs;
|
|
358
356
|
while (Date.now() < deadline) {
|
|
357
|
+
// The process is gone — stop polling (and ticking "Still starting…") for the full timeout.
|
|
358
|
+
if (isCancelled()) return false;
|
|
359
359
|
if (health) {
|
|
360
360
|
// `localhost`, not 127.0.0.1 — the server may be IPv6-only (see
|
|
361
361
|
// isPortListening); Node's fetch tries both families.
|
|
@@ -405,6 +405,24 @@ function spawnShell(cmd, { cwd, env, fd, detached = false }) {
|
|
|
405
405
|
* Runs the services of one repo binding for the lifetime of a turn (or
|
|
406
406
|
* longer — the daemon keeps them per session until the session ends).
|
|
407
407
|
*/
|
|
408
|
+
/** End a dev server's whole process group (Windows: its tree); never throws. */
|
|
409
|
+
export function killTree(pid, signal = "SIGTERM") {
|
|
410
|
+
if (!Number.isInteger(pid)) return;
|
|
411
|
+
if (process.platform === "win32") {
|
|
412
|
+
spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
process.kill(-pid, signal);
|
|
417
|
+
} catch {
|
|
418
|
+
try {
|
|
419
|
+
process.kill(pid, signal);
|
|
420
|
+
} catch {
|
|
421
|
+
/* gone */
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
408
426
|
export class ServiceRunner {
|
|
409
427
|
/**
|
|
410
428
|
* `preferredPort({ repoKey, service })` → a port to TRY before falling
|
|
@@ -416,8 +434,14 @@ export class ServiceRunner {
|
|
|
416
434
|
* fires when a service that had become ready dies on its own;
|
|
417
435
|
* `onProcess(name, pid, "add" | "remove", { port })` lets the daemon persist pids.
|
|
418
436
|
*/
|
|
419
|
-
constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir() } = {}) {
|
|
437
|
+
constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir(), gateway = true } = {}) {
|
|
420
438
|
if (settleMs === DEFAULT_SETTLE_MS) settleMs = defaultSettleMs();
|
|
439
|
+
// Sign-in gateway (gateway.mjs) in front of every bridge-started http
|
|
440
|
+
// service: the public port is the gateway's, the app runs on `appPort`.
|
|
441
|
+
this.gatewayEnabled = gateway !== false;
|
|
442
|
+
this.gateways = new Map(); // service name → Gateway
|
|
443
|
+
this.resumedPids = new Map(); // service name → pid of a dev server re-adopted from a previous daemon life
|
|
444
|
+
this.warmed = false;
|
|
421
445
|
this.kaiHome = kaiHome;
|
|
422
446
|
this.sessionId = sessionId;
|
|
423
447
|
this.serviceUrls = {};
|
|
@@ -448,7 +472,7 @@ export class ServiceRunner {
|
|
|
448
472
|
* Idempotent per repo root. Throws `PreviewError` (`port_busy`) when a
|
|
449
473
|
* declared port is held by a process that is not this repo's.
|
|
450
474
|
*/
|
|
451
|
-
async assignPorts(repoRoot, config, { mode = "worktree", repoKey = null, envSource = null } = {}) {
|
|
475
|
+
async assignPorts(repoRoot, config, { mode = "worktree", repoKey = null, envSource = null, publicHosts = null } = {}) {
|
|
452
476
|
if (this.registered.has(repoRoot)) return this.registered.get(repoRoot);
|
|
453
477
|
// Same service name from a DIFFERENT repo is a collision, not a
|
|
454
478
|
// reuse: silently sharing the port map entry made the second repo
|
|
@@ -477,6 +501,7 @@ export class ServiceRunner {
|
|
|
477
501
|
const declared = svc.port;
|
|
478
502
|
const owner = `${this.sessionId}/${repoRoot}/${svc.name}`;
|
|
479
503
|
const pinned = !!declared && (mode === 'local' || new RegExp(`(^|[^0-9])${declared}([^0-9]|$)`).test(`${svc.run} ${Object.values(svc.env).join(' ')}`));
|
|
504
|
+
this.meta.get(svc.name).pinned = pinned;
|
|
480
505
|
if (declared && !(await isPortListening(declared))) {
|
|
481
506
|
this.ports[svc.name] = await previewPorts.reserve(owner, declared, pinned, isPortListening);
|
|
482
507
|
continue;
|
|
@@ -509,8 +534,13 @@ export class ServiceRunner {
|
|
|
509
534
|
const preferred = (declared ? await findFreePortNear(declared) : await this.stablePort(repoKey, svc.name)) ?? (await getFreePort());
|
|
510
535
|
this.ports[svc.name] = await previewPorts.reserve(owner, preferred, false, isPortListening);
|
|
511
536
|
}
|
|
537
|
+
// Public mode: `${url:x}` resolves to the service's public address (the
|
|
538
|
+
// browser bundle bakes it in), `${port:x}` stays the local port.
|
|
539
|
+
this.publicHosts ??= {};
|
|
512
540
|
for (const svc of services) {
|
|
513
|
-
|
|
541
|
+
const hostname = publicHosts?.[svc.name] ?? null;
|
|
542
|
+
if (hostname) this.publicHosts[svc.name] = hostname;
|
|
543
|
+
this.serviceUrls[svc.name] = hostname ? `https://${hostname}` : `${svc.protocol || 'http'}://localhost:${this.ports[svc.name]}`;
|
|
514
544
|
}
|
|
515
545
|
this.registered.set(repoRoot, services);
|
|
516
546
|
this.previewNames ??= new Map();
|
|
@@ -536,7 +566,7 @@ export class ServiceRunner {
|
|
|
536
566
|
return {
|
|
537
567
|
services: out,
|
|
538
568
|
preview: previewSvc
|
|
539
|
-
? { 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 } : {}) }
|
|
569
|
+
? { name: previewSvc.name, url: previewSvc.url, lanUrl: lan ? `${previewSvc.protocol}://${lan}:${previewSvc.port}` : null, ...(previewSvc.publicUrl ? { publicUrl: previewSvc.publicUrl } : {}), adopted: !!previewSvc.adopted, kind: previewSvc.kind, protocol: previewSvc.protocol, ...(previewSvc.adopted && previewSvc.cwd ? { adoptedCwd: previewSvc.cwd } : {}) }
|
|
540
570
|
: null,
|
|
541
571
|
};
|
|
542
572
|
}
|
|
@@ -546,7 +576,25 @@ export class ServiceRunner {
|
|
|
546
576
|
const port = this.ports[name];
|
|
547
577
|
const url = `${m?.protocol ?? "http"}://localhost:${port}`;
|
|
548
578
|
const adopted = this.adopted.has(name);
|
|
549
|
-
|
|
579
|
+
const hostname = this.publicHosts?.[name] ?? null;
|
|
580
|
+
return { name, port, url, ...(hostname ? { publicUrl: `https://${hostname}` } : {}), 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, fronted: this.gateways.has(name), appPort: m?.appPort ?? null };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Can a gateway front this service? Not adopted, not pinned to its port, plain http, and switched on. */
|
|
584
|
+
canFront(name) {
|
|
585
|
+
const m = this.meta.get(name);
|
|
586
|
+
return !!m && this.gatewayEnabled && !this.adopted.has(name) && !m.pinned && m.mode !== "local" && (m.protocol ?? "http") === "http";
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
gatewayFor(name) {
|
|
590
|
+
return this.gateways.get(name) ?? null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async closeGateway(name) {
|
|
594
|
+
const gw = this.gateways.get(name);
|
|
595
|
+
if (!gw) return;
|
|
596
|
+
this.gateways.delete(name);
|
|
597
|
+
await gw.close().catch(() => {});
|
|
550
598
|
}
|
|
551
599
|
|
|
552
600
|
/** Boot one registered service (adopted / already running → described only). */
|
|
@@ -561,7 +609,7 @@ export class ServiceRunner {
|
|
|
561
609
|
this.onStatus(`Using your running ${name} on :${port}`);
|
|
562
610
|
return this.describeService(name);
|
|
563
611
|
}
|
|
564
|
-
if (this.processes.has(name)) return this.describeService(name);
|
|
612
|
+
if (this.processes.has(name) || this.resumedPids.has(name)) return this.describeService(name);
|
|
565
613
|
if (!existsSync(cwd)) {
|
|
566
614
|
throw new PreviewError(`${name}: the service directory ${svc.cwd} does not exist in ${repoRoot}.`, { code: "no_dev_config", repo: repoKey, service: name, detail: { cwd } });
|
|
567
615
|
}
|
|
@@ -585,9 +633,25 @@ export class ServiceRunner {
|
|
|
585
633
|
});
|
|
586
634
|
}
|
|
587
635
|
}
|
|
636
|
+
// Gateway first, so the public port answers ("starting…") while the app
|
|
637
|
+
// boots and every later request — the user's browser, the agent's
|
|
638
|
+
// browser tools — reaches the app through it.
|
|
639
|
+
let appPort = port;
|
|
640
|
+
if (this.canFront(name)) {
|
|
641
|
+
await this.closeGateway(name);
|
|
642
|
+
appPort = await getFreePort();
|
|
643
|
+
const gw = new Gateway({ listenPort: port, upstreamPort: appPort, upstreamProtocol: svc.protocol, name, log: this.log });
|
|
644
|
+
try {
|
|
645
|
+
await gw.listen();
|
|
646
|
+
} catch (err) {
|
|
647
|
+
throw new PreviewError(`${name}: could not open the preview port ${port} for the gateway (${err?.message || err}).`, { code: "port_busy", repo: repoKey, service: name, detail: { port } });
|
|
648
|
+
}
|
|
649
|
+
this.gateways.set(name, gw);
|
|
650
|
+
}
|
|
651
|
+
m.appPort = appPort === port ? null : appPort;
|
|
588
652
|
const env = {
|
|
589
653
|
...process.env,
|
|
590
|
-
PORT: String(
|
|
654
|
+
PORT: String(appPort),
|
|
591
655
|
...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports, this.serviceUrls)])),
|
|
592
656
|
KAI_SESSION_ID: String(this.sessionId),
|
|
593
657
|
BROWSER: "none",
|
|
@@ -600,6 +664,7 @@ export class ServiceRunner {
|
|
|
600
664
|
const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports, this.serviceUrls));
|
|
601
665
|
const child = spawnShell(cmd, { cwd, env, fd, detached: true });
|
|
602
666
|
m.ready = false;
|
|
667
|
+
let stoppedByUs = false;
|
|
603
668
|
child.on("exit", (code) => {
|
|
604
669
|
// An old shell can exit after a replacement has been admitted.
|
|
605
670
|
if (this.processes.get(name) !== child) return;
|
|
@@ -608,6 +673,7 @@ export class ServiceRunner {
|
|
|
608
673
|
this.log("info", "service.exit", { name, code });
|
|
609
674
|
const deliberate = this.stopping.has(name);
|
|
610
675
|
this.stopping.delete(name);
|
|
676
|
+
stoppedByUs = deliberate;
|
|
611
677
|
if (deliberate) return;
|
|
612
678
|
if (code && code !== 0) this.onStatus(`${name} exited with code ${code} — see ${logPath}`);
|
|
613
679
|
if (!m.ready) return; // boot failure — reported by the boot path below
|
|
@@ -634,7 +700,7 @@ export class ServiceRunner {
|
|
|
634
700
|
// Bail as soon as the process dies (command not found, crash on
|
|
635
701
|
// boot) instead of polling a dead port for the full timeout.
|
|
636
702
|
let ready = await Promise.race([
|
|
637
|
-
waitForReady({ port, health: svc.health, timeoutMs: svc.readyTimeoutMs, protocol: svc.protocol, onTick: (s) => this.onStatus(`Still starting ${name} (${s}s)…`) }),
|
|
703
|
+
waitForReady({ port: appPort, health: svc.health, timeoutMs: svc.readyTimeoutMs, protocol: svc.protocol, onTick: (s) => this.onStatus(`Still starting ${name} (${s}s)…`), isCancelled: () => this.processes.get(name) !== child }),
|
|
638
704
|
new Promise((resolveP) => child.once("exit", () => setTimeout(() => resolveP(false), 300))),
|
|
639
705
|
]);
|
|
640
706
|
if (ready && this.settleMs > 0) {
|
|
@@ -642,19 +708,24 @@ export class ServiceRunner {
|
|
|
642
708
|
// request (a missing env var, a DB refusing). Give them a moment and
|
|
643
709
|
// look again before calling the preview "running".
|
|
644
710
|
await new Promise((r) => setTimeout(r, this.settleMs));
|
|
645
|
-
const alive = this.processes.has(name) && (await isPortListening(
|
|
711
|
+
const alive = this.processes.has(name) && (await isPortListening(appPort));
|
|
646
712
|
if (!alive) ready = false;
|
|
647
713
|
}
|
|
648
714
|
m.ready = ready;
|
|
649
715
|
if (!ready) {
|
|
650
716
|
const tail = this.readLog(logPath);
|
|
651
717
|
const classified = classifyLogTail(tail);
|
|
652
|
-
|
|
718
|
+
// The `.env.example` guess only when it can be the cause: not for a
|
|
719
|
+
// service the bridge itself stopped mid-boot, and not for one that
|
|
720
|
+
// already came up with this very env earlier in this preview (a
|
|
721
|
+
// dashboard with 25 optional keys missing boots fine — 2026-09-21).
|
|
722
|
+
const envKeys = classified || stoppedByUs || m.everReady ? [] : missingEnvKeys(cwd, { exists: existsSync, read: (p) => readFileSync(p, "utf8"), env });
|
|
653
723
|
const errorCode = classified?.code ?? (envKeys.length ? "env_missing" : "service_crashed");
|
|
654
|
-
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`);
|
|
724
|
+
const error = classified?.line ?? (envKeys.length ? `${name} did not start — ${envKeys.length} key${envKeys.length === 1 ? "" : "s"} from .env.example missing: ${envKeys.join(", ")}` : (stoppedByUs ? `${name} was stopped while it was starting` : logTailSummary(tail)) || `${name} did not become ready within ${Math.round(svc.readyTimeoutMs / 1000)}s`);
|
|
655
725
|
this.onStatus(`${name} did not become ready — ${error} (see ${logPath})`);
|
|
656
726
|
return { ...this.describeService(name), ready: false, error, errorCode, errorDetail: classified?.detail ?? (envKeys.length ? { envKeys } : null) };
|
|
657
727
|
}
|
|
728
|
+
m.everReady = true;
|
|
658
729
|
this.onStatus(`${name} ready on ${svc.protocol}://localhost:${port}`);
|
|
659
730
|
await this.probeKind(m, port);
|
|
660
731
|
return this.describeService(name);
|
|
@@ -710,7 +781,7 @@ export class ServiceRunner {
|
|
|
710
781
|
return (await isPortListening(port)) ? null : port;
|
|
711
782
|
}
|
|
712
783
|
|
|
713
|
-
/** `[{ name, url, repoKey, kind, protocol, openapi, repoRoot, adopted }]` for every service this runner owns
|
|
784
|
+
/** `[{ name, url, repoKey, kind, protocol, openapi, repoRoot, adopted }]` for every service this runner owns. */
|
|
714
785
|
describeServices() {
|
|
715
786
|
return Object.entries(this.ports).map(([name, port]) => {
|
|
716
787
|
const m = this.meta.get(name);
|
|
@@ -723,11 +794,6 @@ export class ServiceRunner {
|
|
|
723
794
|
return (this.registered.get(repoRoot) || []).map((s) => s.name);
|
|
724
795
|
}
|
|
725
796
|
|
|
726
|
-
/** Any service on https? → the browser needs `--ignore-https-errors`. */
|
|
727
|
-
usesHttps() {
|
|
728
|
-
return [...this.meta.values()].some((m) => m.protocol === "https");
|
|
729
|
-
}
|
|
730
|
-
|
|
731
797
|
/**
|
|
732
798
|
* Install JS dependencies when the service dir has a package.json but
|
|
733
799
|
* no node_modules (fresh worktree) or the lockfile changed since our
|
|
@@ -815,10 +881,11 @@ export class ServiceRunner {
|
|
|
815
881
|
|
|
816
882
|
/** Kill one service's process tree; resolves when the process is gone (bounded). */
|
|
817
883
|
async stopService(name, { timeoutMs = 5_000 } = {}) {
|
|
884
|
+
await this.closeGateway(name);
|
|
818
885
|
const child = this.processes.get(name);
|
|
819
|
-
if (!child) return;
|
|
886
|
+
if (!child) return this.stopResumed(name, { timeoutMs });
|
|
820
887
|
this.stopping.add(name);
|
|
821
|
-
const port = this.ports[name];
|
|
888
|
+
const port = this.meta.get(name)?.appPort ?? this.ports[name];
|
|
822
889
|
let timer;
|
|
823
890
|
const gone = child.exitCode !== null || child.signalCode !== null
|
|
824
891
|
? Promise.resolve()
|
|
@@ -856,10 +923,114 @@ export class ServiceRunner {
|
|
|
856
923
|
const forceDeadline = Date.now() + 1000;
|
|
857
924
|
while (port && await isPortListening(port) && Date.now() < forceDeadline) await new Promise(r => setTimeout(r, 50));
|
|
858
925
|
if (port && await isPortListening(port)) throw new PreviewError(`${name} did not release port ${port}; retry after checking the service.`, { code: 'port_busy', service: name });
|
|
926
|
+
// The stop may have won on the 5 s timer, before the exit handler ran:
|
|
927
|
+
// leave nothing behind that lets the next boot mistake it for running.
|
|
928
|
+
if (this.processes.get(name) === child) {
|
|
929
|
+
this.processes.delete(name);
|
|
930
|
+
this.onProcess(name, child.pid, "remove");
|
|
931
|
+
}
|
|
932
|
+
this.stopping.delete(name);
|
|
933
|
+
const m = this.meta.get(name);
|
|
934
|
+
if (m) m.ready = false;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** End a dev server re-adopted from a previous daemon life (pid only, no ChildProcess). */
|
|
938
|
+
async stopResumed(name, { timeoutMs = 5_000 } = {}) {
|
|
939
|
+
const pid = this.resumedPids.get(name);
|
|
940
|
+
if (!pid) return;
|
|
941
|
+
this.resumedPids.delete(name);
|
|
942
|
+
const port = this.meta.get(name)?.appPort ?? this.ports[name];
|
|
943
|
+
killTree(pid, "SIGTERM");
|
|
944
|
+
this.log("info", "service.stop", { name, pid, resumed: true });
|
|
945
|
+
const deadline = Date.now() + timeoutMs;
|
|
946
|
+
while (port && (await isPortListening(port)) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
|
|
947
|
+
if (port && (await isPortListening(port))) killTree(pid, "SIGKILL");
|
|
948
|
+
const m = this.meta.get(name);
|
|
949
|
+
if (m) m.ready = false;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Let the dev servers OUTLIVE this daemon: close the in-process gateways
|
|
954
|
+
* (the next daemon re-binds the public ports) and forget the processes
|
|
955
|
+
* without ending them. `snapshot()` is what the next daemon resumes from.
|
|
956
|
+
*/
|
|
957
|
+
detach() {
|
|
958
|
+
for (const gw of this.gateways.values()) void gw.close().catch(() => {});
|
|
959
|
+
this.gateways.clear();
|
|
960
|
+
for (const child of this.processes.values()) {
|
|
961
|
+
child.removeAllListeners("exit");
|
|
962
|
+
child.unref?.();
|
|
963
|
+
}
|
|
964
|
+
this.processes.clear();
|
|
965
|
+
this.resumedPids.clear();
|
|
966
|
+
previewPorts.releaseSession(this.sessionId);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Everything the next daemon needs to re-adopt these services (JSON-safe). */
|
|
970
|
+
snapshot() {
|
|
971
|
+
const services = [];
|
|
972
|
+
for (const [name, port] of Object.entries(this.ports)) {
|
|
973
|
+
const m = this.meta.get(name);
|
|
974
|
+
if (!m) continue;
|
|
975
|
+
const adopted = this.adopted.has(name);
|
|
976
|
+
const pid = adopted ? null : this.processes.get(name)?.pid ?? this.resumedPids.get(name) ?? null;
|
|
977
|
+
if (!adopted && !pid) continue;
|
|
978
|
+
services.push({ name, pid, port, appPort: m.appPort ?? null, protocol: m.protocol ?? "http", kind: m.kind ?? "web", repoKey: m.repoKey ?? null, repoRoot: m.repoRoot, mode: m.mode, cwd: m.cwd, logPath: m.logPath, adopted, envSource: m.envSource ?? null, svc: m.svc, previewName: m.previewName ?? null, ready: !!m.ready });
|
|
979
|
+
}
|
|
980
|
+
return { services, previewNames: [...(this.previewNames ?? new Map())], warmed: !!this.warmed };
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Re-adopt the services of a snapshot whose processes are still alive
|
|
985
|
+
* and listening: registers them exactly as `assignPorts` + `bootService`
|
|
986
|
+
* would, re-opens the gateways on their public ports. Returns the
|
|
987
|
+
* services that could NOT be resumed (dead pid, port silent) — the
|
|
988
|
+
* caller decides whether a partial resume is acceptable.
|
|
989
|
+
*/
|
|
990
|
+
async resume(snapshot, { kill = process.kill } = {}) {
|
|
991
|
+
const failed = [];
|
|
992
|
+
for (const svc of snapshot?.services ?? []) {
|
|
993
|
+
const alive = svc.adopted ? true : (() => { try { kill(svc.pid, 0); return true; } catch { return false; } })();
|
|
994
|
+
const listening = await isPortListening(svc.appPort ?? svc.port);
|
|
995
|
+
if (!alive || !listening) {
|
|
996
|
+
failed.push({ name: svc.name, pid: svc.pid, alive, listening });
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
this.meta.set(svc.name, { svc: svc.svc, repoRoot: svc.repoRoot, repoKey: svc.repoKey, mode: svc.mode, cwd: svc.cwd, logPath: svc.logPath, kind: svc.kind, protocol: svc.protocol, ready: true, envSource: svc.envSource, previewName: svc.previewName, appPort: svc.appPort ?? null, pinned: false, kindProbed: true });
|
|
1000
|
+
this.ports[svc.name] = svc.port;
|
|
1001
|
+
this.serviceUrls[svc.name] = `${svc.protocol}://localhost:${svc.port}`;
|
|
1002
|
+
this.serviceRoots.set(svc.name, svc.repoRoot);
|
|
1003
|
+
if (svc.repoKey) this.repoKeys.set(svc.name, svc.repoKey);
|
|
1004
|
+
const list = this.registered.get(svc.repoRoot) ?? [];
|
|
1005
|
+
if (!list.some((x) => x.name === svc.name)) list.push(svc.svc);
|
|
1006
|
+
this.registered.set(svc.repoRoot, list);
|
|
1007
|
+
if (svc.adopted) this.adopted.add(svc.name);
|
|
1008
|
+
else this.resumedPids.set(svc.name, svc.pid);
|
|
1009
|
+
await previewPorts.reserve(`${this.sessionId}/${svc.repoRoot}/${svc.name}`, svc.port, true, async () => false).catch(() => {});
|
|
1010
|
+
if (svc.appPort && this.gatewayEnabled) {
|
|
1011
|
+
const gw = new Gateway({ listenPort: svc.port, upstreamPort: svc.appPort, upstreamProtocol: svc.protocol, name: svc.name, log: this.log });
|
|
1012
|
+
try {
|
|
1013
|
+
await gw.listen();
|
|
1014
|
+
this.gateways.set(svc.name, gw);
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
failed.push({ name: svc.name, pid: svc.pid, alive, listening, gateway: err?.message });
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
this.previewNames = new Map(snapshot?.previewNames ?? []);
|
|
1021
|
+
this.warmed = !!snapshot?.warmed;
|
|
1022
|
+
return failed;
|
|
859
1023
|
}
|
|
860
1024
|
|
|
861
1025
|
stopAll() {
|
|
862
1026
|
previewPorts.releaseSession(this.sessionId);
|
|
1027
|
+
for (const gw of this.gateways.values()) void gw.close().catch(() => {});
|
|
1028
|
+
this.gateways.clear();
|
|
1029
|
+
for (const [name, pid] of this.resumedPids) {
|
|
1030
|
+
killTree(pid, "SIGTERM");
|
|
1031
|
+
this.log("info", "service.stop", { name, pid, resumed: true });
|
|
1032
|
+
}
|
|
1033
|
+
this.resumedPids.clear();
|
|
863
1034
|
for (const [name, child] of this.processes) {
|
|
864
1035
|
this.stopping.add(name);
|
|
865
1036
|
if (process.platform === "win32") {
|
|
@@ -903,6 +1074,36 @@ export class ServiceRunner {
|
|
|
903
1074
|
return out;
|
|
904
1075
|
}
|
|
905
1076
|
|
|
1077
|
+
/**
|
|
1078
|
+
* Flip a LIVE runner between local and public addresses without a full
|
|
1079
|
+
* re-boot. `${url:x}` resolves to the public origin from now on (or back
|
|
1080
|
+
* to localhost with `null`); only the services whose env / run command
|
|
1081
|
+
* reference a `${url:…}` restart — a browser bundle bakes the origin in
|
|
1082
|
+
* at boot — on their SAME ports. An API, a worker, an adopted dev server
|
|
1083
|
+
* keep running. Returns the names that were restarted.
|
|
1084
|
+
*/
|
|
1085
|
+
async setPublicHosts(publicHosts) {
|
|
1086
|
+
this.publicHosts = { ...(publicHosts || {}) };
|
|
1087
|
+
for (const [name, m] of this.meta) {
|
|
1088
|
+
const hostname = this.publicHosts[name];
|
|
1089
|
+
this.serviceUrls[name] = hostname ? `https://${hostname}` : `${m.protocol ?? "http"}://localhost:${this.ports[name]}`;
|
|
1090
|
+
}
|
|
1091
|
+
const restarted = [];
|
|
1092
|
+
for (const [name, m] of this.meta) {
|
|
1093
|
+
if (this.adopted.has(name) || !bakesServiceUrl(m.svc)) continue;
|
|
1094
|
+
this.log("info", "service.readdress", { name, publicUrl: this.serviceUrls[name] });
|
|
1095
|
+
await this.stopService(name);
|
|
1096
|
+
const started = await this.bootService(name);
|
|
1097
|
+
// Ready AND actually ours: a boot that returned early on a stale process entry is not a restart.
|
|
1098
|
+
if (!started.ready || !(this.processes.has(name) || this.resumedPids.has(name))) {
|
|
1099
|
+
throw new PreviewError(`${name} did not come back${started.error ? ` — ${started.error}` : ""}`, { code: started.errorCode || "service_crashed", repo: m.repoKey, service: name, detail: { ...(started.errorDetail || {}), logPath: started.logPath } });
|
|
1100
|
+
}
|
|
1101
|
+
restarted.push(name);
|
|
1102
|
+
}
|
|
1103
|
+
if (restarted.length) this.warmed = false; // a restarted dev server compiles again
|
|
1104
|
+
return restarted;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
906
1107
|
/** Prompt section telling the agent what is running + how to verify. */
|
|
907
1108
|
describeForAgent(started) {
|
|
908
1109
|
if (!started || started.services.length === 0) return "";
|
|
@@ -968,7 +1169,7 @@ export function browserInstallCommand({ playwrightCli, withDeps = false } = {})
|
|
|
968
1169
|
}
|
|
969
1170
|
|
|
970
1171
|
/**
|
|
971
|
-
* Make sure SOME browser can launch
|
|
1172
|
+
* Make sure SOME browser can launch for the preview (warm-up, browser MCP): system Chrome
|
|
972
1173
|
* wins; otherwise the bundled Chromium must be in Playwright's cache —
|
|
973
1174
|
* and nothing installs it today (the `@playwright/mcp` dependency ships
|
|
974
1175
|
* no browser), so download it once (`playwright install chromium`, 5-min
|
|
@@ -1038,36 +1239,25 @@ function defaultCanSudo() {
|
|
|
1038
1239
|
}
|
|
1039
1240
|
|
|
1040
1241
|
/**
|
|
1041
|
-
* The Playwright MCP server registration for
|
|
1042
|
-
* always `--isolated`: the profile lives in
|
|
1043
|
-
*
|
|
1044
|
-
* upload), `caps` (`devtools` = video/tracing, `testing` = browser_verify_*,
|
|
1045
|
-
* `storage` = browser_storage_state for the final sign-in refresh), an
|
|
1046
|
-
* optional `secretsFile` (dotenv; the MCP substitutes secret NAMES typed
|
|
1047
|
-
* into fields and masks the values), and — when a saved sign-in is
|
|
1048
|
-
* injected — `storageStateFile` (re-read by the MCP on every new context,
|
|
1049
|
-
* so it must outlive the turn) plus `allowedOrigins` (every other request
|
|
1050
|
-
* is aborted: the injected cookies can only ever be sent to the preview).
|
|
1051
|
-
* `ignoreHttpsErrors` for `protocol: https` dev servers (self-signed).
|
|
1052
|
-
* `disabledTools` rides on the entry for the runner (Claude
|
|
1053
|
-
* `disallowedTools`, Codex `disabled_tools`) — `browser_set_storage_state`
|
|
1054
|
-
* is off so the agent can never load a state file of its choosing.
|
|
1242
|
+
* The Playwright MCP server registration for a coding turn with a live
|
|
1243
|
+
* preview (stdio, headless, always `--isolated`: the profile lives in
|
|
1244
|
+
* memory and is thrown away with the turn).
|
|
1055
1245
|
*/
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1246
|
+
/** Playwright MCP per-call ceilings (see the args below). */
|
|
1247
|
+
export const MCP_NAVIGATION_TIMEOUT_MS = 240_000;
|
|
1248
|
+
export const MCP_ACTION_TIMEOUT_MS = 45_000;
|
|
1249
|
+
|
|
1250
|
+
export function previewMcpServer(runnerDir, { browser } = {}) {
|
|
1251
|
+
const args = [playwrightPaths(runnerDir).mcpCli, "--headless", "--isolated", "--viewport-size", "1280x800"];
|
|
1252
|
+
// A dev build of a big app (thousands of unbundled modules, measured
|
|
1253
|
+
// 2026-09-15 on the Gleap dashboard: DOMContentLoaded 50-90 s in a fresh
|
|
1254
|
+
// profile) blows through the MCP's defaults — 60 s per navigation and 5 s
|
|
1255
|
+
// per action (that 5 s is also the screenshot ceiling: "waiting for fonts
|
|
1256
|
+
// to load"). The agent then burns its run on timeouts instead of
|
|
1257
|
+
// testing. Give both room; a fast app never notices.
|
|
1258
|
+
args.push("--timeout-navigation", String(MCP_NAVIGATION_TIMEOUT_MS));
|
|
1259
|
+
args.push("--timeout-action", String(MCP_ACTION_TIMEOUT_MS));
|
|
1067
1260
|
const channel = browser === undefined ? resolvePreviewBrowser() : browser;
|
|
1068
1261
|
if (channel) args.push("--browser", channel);
|
|
1069
|
-
|
|
1070
|
-
const disabled = (Array.isArray(disabledTools) ? disabledTools : []).map((t) => String(t).trim()).filter(Boolean);
|
|
1071
|
-
if (disabled.length > 0) entry.disabledTools = disabled;
|
|
1072
|
-
return entry;
|
|
1262
|
+
return { id: "gleap_preview", name: "gleap_preview", command: process.execPath, args };
|
|
1073
1263
|
}
|
package/src/service.mjs
CHANGED
|
@@ -60,13 +60,6 @@ ${envXml}
|
|
|
60
60
|
`;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** The desktop-session variables a headed browser needs (Linux only). */
|
|
64
|
-
export function displayEnv(env = process.env) {
|
|
65
|
-
const out = {};
|
|
66
|
-
for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]) if (env[k]) out[k] = env[k];
|
|
67
|
-
return out;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
63
|
export function renderSystemdUnit({ program, args, env = {} }) {
|
|
71
64
|
const q = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
|
|
72
65
|
return `[Unit]
|
|
@@ -102,10 +95,6 @@ export function install({ binPath, logDir, env = {} }) {
|
|
|
102
95
|
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
|
103
96
|
HOME: homedir(),
|
|
104
97
|
...(process.env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK } : {}),
|
|
105
|
-
// Linux: a systemd --user unit starts without the desktop session's
|
|
106
|
-
// display — the headed sign-in window (preview-login.mjs) needs it.
|
|
107
|
-
// Captured at install time, like PATH.
|
|
108
|
-
...(os === "linux" ? displayEnv(process.env) : {}),
|
|
109
98
|
...env,
|
|
110
99
|
};
|
|
111
100
|
if (os === "darwin") {
|