@gleapai/kai-bridge 0.6.0 → 0.9.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
@@ -1,3 +1,4 @@
1
+ import { previewPorts } from './preview-ports.mjs';
1
2
  // Preview tier A — run the app's real dev servers next to the session.
2
3
  //
3
4
  // Each repo may commit a `.gleap/dev.yaml`:
@@ -304,8 +305,11 @@ export function lanAddress() {
304
305
  }
305
306
 
306
307
  /** `${port:api}` → assigned port of service `api`. */
307
- export function substitutePorts(value, ports) {
308
- return String(value).replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? ""));
308
+ export function substitutePorts(value, ports, urls = {}) {
309
+ return String(value).replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? "")).replace(/\$\{url:([\w-]+)\}/g, (_, name) => {
310
+ if (!urls[name]) throw new Error(`Service URL is not available for ${name}.`);
311
+ return urls[name];
312
+ });
309
313
  }
310
314
 
311
315
  /** GET `url` accepting self-signed certificates (dev https); resolves `{ status, contentType }` or null. */
@@ -393,7 +397,8 @@ function spawnShell(cmd, { cwd, env, fd, detached = false }) {
393
397
  }
394
398
  // Windows has no process groups to kill and `detached` would open a
395
399
  // 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 });
400
+ const child = spawn(cmd, { cwd, env, shell: true, stdio: ["ignore", fd, fd], detached: process.platform !== "win32" && detached, windowsHide: true });
401
+ return child;
397
402
  }
398
403
 
399
404
  /**
@@ -415,6 +420,7 @@ export class ServiceRunner {
415
420
  if (settleMs === DEFAULT_SETTLE_MS) settleMs = defaultSettleMs();
416
421
  this.kaiHome = kaiHome;
417
422
  this.sessionId = sessionId;
423
+ this.serviceUrls = {};
418
424
  this.log = log;
419
425
  this.onStatus = onStatus;
420
426
  this.preferredPort = typeof preferredPort === "function" ? preferredPort : null;
@@ -469,8 +475,10 @@ export class ServiceRunner {
469
475
  }
470
476
  if (this.ports[svc.name]) continue;
471
477
  const declared = svc.port;
478
+ const owner = `${this.sessionId}/${repoRoot}/${svc.name}`;
479
+ const pinned = !!declared && (mode === 'local' || new RegExp(`(^|[^0-9])${declared}([^0-9]|$)`).test(`${svc.run} ${Object.values(svc.env).join(' ')}`));
472
480
  if (declared && !(await isPortListening(declared))) {
473
- this.ports[svc.name] = declared;
481
+ this.ports[svc.name] = await previewPorts.reserve(owner, declared, pinned, isPortListening);
474
482
  continue;
475
483
  }
476
484
  if (declared) {
@@ -498,7 +506,11 @@ export class ServiceRunner {
498
506
  });
499
507
  }
500
508
  }
501
- this.ports[svc.name] = (declared ? await findFreePortNear(declared) : await this.stablePort(repoKey, svc.name)) ?? (await getFreePort());
509
+ const preferred = (declared ? await findFreePortNear(declared) : await this.stablePort(repoKey, svc.name)) ?? (await getFreePort());
510
+ this.ports[svc.name] = await previewPorts.reserve(owner, preferred, false, isPortListening);
511
+ }
512
+ for (const svc of services) {
513
+ this.serviceUrls[svc.name] = `${svc.protocol || 'http'}://localhost:${this.ports[svc.name]}`;
502
514
  }
503
515
  this.registered.set(repoRoot, services);
504
516
  this.previewNames ??= new Map();
@@ -576,7 +588,7 @@ export class ServiceRunner {
576
588
  const env = {
577
589
  ...process.env,
578
590
  PORT: String(port),
579
- ...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports)])),
591
+ ...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports, this.serviceUrls)])),
580
592
  KAI_SESSION_ID: String(this.sessionId),
581
593
  BROWSER: "none",
582
594
  };
@@ -585,10 +597,12 @@ export class ServiceRunner {
585
597
  // straight into `command not found` was the #1 preview failure. The
586
598
  // install is authoritative: when it fails, the dev command never runs.
587
599
  await this.ensureDeps(cwd, { env, fd, logPath, service: name, repoKey });
588
- const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports));
600
+ const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports, this.serviceUrls));
589
601
  const child = spawnShell(cmd, { cwd, env, fd, detached: true });
590
602
  m.ready = false;
591
603
  child.on("exit", (code) => {
604
+ // An old shell can exit after a replacement has been admitted.
605
+ if (this.processes.get(name) !== child) return;
592
606
  this.processes.delete(name);
593
607
  this.onProcess(name, child.pid, "remove");
594
608
  this.log("info", "service.exit", { name, code });
@@ -804,7 +818,11 @@ export class ServiceRunner {
804
818
  const child = this.processes.get(name);
805
819
  if (!child) return;
806
820
  this.stopping.add(name);
807
- const gone = new Promise((r) => child.once("exit", r));
821
+ const port = this.ports[name];
822
+ let timer;
823
+ const gone = child.exitCode !== null || child.signalCode !== null
824
+ ? Promise.resolve()
825
+ : new Promise((r) => child.once("exit", r));
808
826
  if (process.platform === "win32") {
809
827
  spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
810
828
  } else {
@@ -819,17 +837,29 @@ export class ServiceRunner {
819
837
  }
820
838
  }
821
839
  this.log("info", "service.stop", { name });
822
- await Promise.race([gone, new Promise((r) => setTimeout(r, timeoutMs))]);
823
- if (this.processes.has(name)) {
840
+ await Promise.race([gone, new Promise((r) => { timer = setTimeout(r, timeoutMs); })]);
841
+ clearTimeout(timer);
842
+ // On Linux the shell may exit before its children close the listener.
843
+ // Starting the replacement now can mistake that old listener for readiness
844
+ // and then crash with EADDRINUSE. Wait for the owned service's port too.
845
+ const deadline = Date.now() + timeoutMs;
846
+ while (port && await isPortListening(port) && Date.now() < deadline) {
847
+ await new Promise(r => setTimeout(r, 50));
848
+ }
849
+ if (this.processes.get(name) === child || (port && await isPortListening(port))) {
824
850
  try {
825
851
  process.platform === "win32" ? child.kill() : process.kill(-child.pid, "SIGKILL");
826
852
  } catch {
827
853
  /* gone */
828
854
  }
829
855
  }
856
+ const forceDeadline = Date.now() + 1000;
857
+ while (port && await isPortListening(port) && Date.now() < forceDeadline) await new Promise(r => setTimeout(r, 50));
858
+ if (port && await isPortListening(port)) throw new PreviewError(`${name} did not release port ${port}; retry after checking the service.`, { code: 'port_busy', service: name });
830
859
  }
831
860
 
832
861
  stopAll() {
862
+ previewPorts.releaseSession(this.sessionId);
833
863
  for (const [name, child] of this.processes) {
834
864
  this.stopping.add(name);
835
865
  if (process.platform === "win32") {
package/src/profiles.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { execFileSync, spawn } from "node:child_process";
15
15
  import { cpSync, existsSync, mkdirSync } from "node:fs";
16
- import { HARNESS_IDS as REGISTRY_IDS, harnessBinary, harnessLoginCommand, probeHarnessAuth } from "./harnesses.mjs";
16
+ import { HARNESS_IDS as REGISTRY_IDS, harnessBinary, harnessLoginCommand, isHeadless, probeHarnessAuth } from "./harnesses.mjs";
17
17
  import { homedir } from "node:os";
18
18
  import { dirname, join, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
@@ -112,8 +112,8 @@ export function createManagedProfile(harness, profileId, kaiHome) {
112
112
  }
113
113
 
114
114
  /** Interactive login for a profile (opens the harness's own flow). */
115
- export function loginCommand(harness, configDir, kaiHome = process.env.KAI_HOME || join(HOME, ".kai")) {
116
- const c = harnessLoginCommand(harness, configDir, kaiHome);
115
+ export function loginCommand(harness, configDir, kaiHome = process.env.KAI_HOME || join(HOME, ".kai"), options) {
116
+ const c = harnessLoginCommand(harness, configDir, kaiHome, options);
117
117
  if (!c) return null;
118
118
  return { cmd: c.cmd, args: c.args, env: { ...process.env, ...c.env } };
119
119
  }
@@ -124,8 +124,9 @@ export function loginCommand(harness, configDir, kaiHome = process.env.KAI_HOME
124
124
  * DEVICE running the login under the profile's config dir. Returns the
125
125
  * spawned process or null when no terminal could be opened.
126
126
  */
127
- export function openLoginTerminal(harness, configDir) {
128
- const c = loginCommand(harness, configDir);
127
+ export function openLoginTerminal(harness, configDir, kaiHome) {
128
+ if (isHeadless()) return null;
129
+ const c = loginCommand(harness, configDir, kaiHome);
129
130
  if (!c) return null;
130
131
  // Ambient claude must log in with the env untouched so credentials land
131
132
  // in the keychain, where the (equally untouched) probe and turns look.
@@ -138,12 +139,15 @@ export function openLoginTerminal(harness, configDir) {
138
139
  : harness === "codex"
139
140
  ? `CODEX_HOME=${shellQuote(configDir)} `
140
141
  : "NO_OPEN_BROWSER=0 ";
141
- const line = `${envPrefix}${shellQuote(c.cmd)} login`;
142
+ const line = `${harness === 'claude' && process.platform !== 'win32' ? 'DISABLE_AUTOUPDATER=1 ' : ''}${envPrefix}${[c.cmd, ...c.args].map(shellQuote).join(' ')}`;
142
143
  if (process.platform === "darwin") {
143
144
  return spawn("osascript", ["-e", `tell application "Terminal" to activate`, "-e", `tell application "Terminal" to do script ${JSON.stringify(line)}`], { stdio: "ignore", detached: true });
144
145
  }
145
146
  if (process.platform === "win32") {
146
- return spawn("cmd", ["/c", "start", "cmd", "/k", line.replace(/^([A-Z_]+)=('[^']*') /, "set $1=$2 && ")], { stdio: "ignore", detached: true, shell: true });
147
+ // cmd uses double quotes, and profile variables belong in the child env;
148
+ // POSIX KEY='value' prefixes are not valid Windows commands.
149
+ const windowsLine = [c.cmd, ...c.args].map(value => `"${String(value).replace(/"/g, '""')}"`).join(' ');
150
+ return spawn("cmd", ["/c", "start", '""', "cmd", "/k", windowsLine], { env: { ...c.env, ...(harness === 'claude' ? { DISABLE_AUTOUPDATER: '1' } : {}) }, stdio: "ignore", detached: true, shell: true });
147
151
  }
148
152
  for (const term of ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]) {
149
153
  if (findBinary(term)) {
@@ -0,0 +1,96 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { join, resolve, dirname, isAbsolute } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { inspectCheckout, normalizeRemote } from './repos.mjs';
6
+
7
+ const locks = new Map();
8
+ export function withRepositoryLock(target, action) {
9
+ const key = resolve(target);
10
+ const prior = locks.get(key) || Promise.resolve();
11
+ const work = prior.catch(() => {}).then(action);
12
+ const tail = work.catch(() => {});
13
+ locks.set(key, tail);
14
+ void tail.then(() => { if (locks.get(key) === tail) locks.delete(key); });
15
+ return work;
16
+ }
17
+
18
+ export function locateRepository(rawPath, repoKey) {
19
+ const expanded = String(rawPath || '').trim().replace(/^~(?=$|\/)/, homedir());
20
+ if (!isAbsolute(expanded)) throw new Error('Enter an absolute folder path on this device.');
21
+ const target = resolve(expanded);
22
+ if (!existsSync(target)) throw new Error(`${target} does not exist on this machine.`);
23
+ // Validate BEFORE recording an override or adding a scan root.
24
+ if (repoKey && inspectCheckout(target)?.key !== repoKey) {
25
+ throw new Error(`${target} is not a checkout of ${repoKey}. Choose the folder for that repository.`);
26
+ }
27
+ return target;
28
+ }
29
+
30
+ export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_000 }, spawnGit = spawn) {
31
+ if (typeof remote !== 'string' || !(/^(https?|ssh):\/\/\S+$/i.test(remote) || /^[\w.-]+@[\w.-]+:\S+$/.test(remote))) {
32
+ return Promise.reject(new Error('This repository has an unsupported clone URL.'));
33
+ }
34
+ if (!root || !existsSync(root)) return Promise.reject(new Error('No workspace folder is available. Add a scan folder in device settings.'));
35
+ if (typeof name !== 'string' || !name || name === '.' || name === '..' || name.startsWith('-') || /[/\\\x00-\x1f]/.test(name)) {
36
+ return Promise.reject(new Error('Invalid repository folder name.'));
37
+ }
38
+ const target = resolve(root, name);
39
+ if (dirname(target) !== resolve(root)) return Promise.reject(new Error('Invalid repository folder name.'));
40
+ const expectedKey = repoKey || normalizeRemote(remote)?.key;
41
+ if (!expectedKey || normalizeRemote(remote)?.key !== expectedKey) return Promise.reject(new Error('The clone URL belongs to a different repository.'));
42
+ return withRepositoryLock(target, async () => {
43
+ if (existsSync(target)) {
44
+ // Another session may have just cloned the same repo. Reuse only an
45
+ // exact identity match; never touch or remove an existing folder.
46
+ if (inspectCheckout(target)?.key === expectedKey) return target;
47
+ throw Object.assign(new Error(`${target} already exists. Use Locate existing to choose the correct checkout.`), { target });
48
+ }
49
+ mkdirSync(target); // exclusive reservation; cleanup only what we created
50
+ try {
51
+ await new Promise((accept, reject) => {
52
+ const child = spawnGit('git', ['clone', '--', remote, target], {
53
+ stdio: ['ignore', 'ignore', 'pipe'],
54
+ detached: process.platform !== 'win32',
55
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo', SSH_ASKPASS: 'echo',
56
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' },
57
+ });
58
+ let stderr = '';
59
+ let timedOut = false;
60
+ let killTimer;
61
+ const signalGit = (signal) => {
62
+ try {
63
+ // SSH/helpers must exit too before the directory can be cleaned.
64
+ if (child.pid && process.platform !== 'win32') process.kill(-child.pid, signal);
65
+ else child.kill(signal);
66
+ } catch { /* already exited */ }
67
+ };
68
+ child.stderr.on('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-4000); });
69
+ const timer = setTimeout(() => {
70
+ timedOut = true;
71
+ signalGit('SIGTERM');
72
+ killTimer = setTimeout(() => signalGit('SIGKILL'), 2000);
73
+ }, timeoutMs);
74
+ const finish = (err) => {
75
+ clearTimeout(timer);
76
+ clearTimeout(killTimer);
77
+ if (err) reject(err); else accept();
78
+ };
79
+ child.on('error', finish);
80
+ // Wait for exit before deleting the partial clone/releasing the lock.
81
+ child.on('close', (code) => {
82
+ const detail = stderr.trim().split('\n').filter(Boolean).slice(-2).join(' · ')
83
+ .replace(/(https?:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, '$1');
84
+ finish(timedOut ? new Error('Cloning timed out. Check the network and try again.') : code === 0 ? null
85
+ : new Error(`Could not clone the repository${detail ? `: ${detail}` : '.'}`));
86
+ });
87
+ });
88
+ if (inspectCheckout(target)?.key !== expectedKey) throw new Error('The cloned repository does not match the requested repository.');
89
+ return target;
90
+ } catch (err) {
91
+ rmSync(target, { recursive: true, force: true });
92
+ err.target = target;
93
+ throw err;
94
+ }
95
+ });
96
+ }
package/src/service.mjs CHANGED
@@ -70,7 +70,7 @@ export function displayEnv(env = process.env) {
70
70
  export function renderSystemdUnit({ program, args, env = {} }) {
71
71
  const q = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
72
72
  return `[Unit]
73
- Description=Gleap Kai Bridge
73
+ Description=Kai Code Bridge
74
74
  Wants=network-online.target
75
75
  After=network-online.target
76
76
 
package/src/setup.mjs CHANGED
@@ -165,7 +165,7 @@ const rowLine = (h) => {
165
165
  export async function runSetup({ binPath, prompter = makePrompter() } = {}) {
166
166
  const config = loadConfig();
167
167
  out("");
168
- out("── Kai Bridge ────────────────────────────────────────────────");
168
+ out("── Kai Code Bridge ────────────────────────────────────────────────");
169
169
  out("Run Kai Code sessions on this machine — with your own Claude /");
170
170
  out("Codex / Cursor login, against your local checkouts.");
171
171
  out("");
package/src/workspace.mjs CHANGED
@@ -81,7 +81,7 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
81
81
  // Resume: the worktree from the previous turn is the session state.
82
82
  return { cwd: dir, mode, branch, base, resumed: true };
83
83
  }
84
- mkdirSync(join(kaiHome, "worktrees", repo.name), { recursive: true });
84
+ mkdirSync(dirname(dir), { recursive: true });
85
85
  git(repo.primaryPath, ["fetch", "origin", base, "--quiet"]);
86
86
  git(repo.primaryPath, ["worktree", "add", "-b", branch, dir, `origin/${base}`]);
87
87
  // A fresh worktree has no node_modules; clone the primary checkout's
@@ -170,16 +170,9 @@ export function discardChanges(cwd) {
170
170
  export function removeWorktree({ kaiHome, repo, sessionId, title }) {
171
171
  const dir = worktreePath(kaiHome, repo.name, sessionSlug(sessionId, title));
172
172
  if (!existsSync(dir)) return false;
173
- try {
174
- git(repo.primaryPath, ["worktree", "remove", "--force", dir]);
175
- } catch {
176
- rmSync(dir, { recursive: true, force: true });
177
- try {
178
- git(repo.primaryPath, ["worktree", "prune"]);
179
- } catch {
180
- /* best-effort */
181
- }
182
- }
173
+ // Never destroy dirty or unpushed work during session cleanup.
174
+ if (collectChanges(dir).files.length || git(dir, ['log', 'HEAD', '--not', '--remotes', '--oneline'])) return false;
175
+ git(repo.primaryPath, ['worktree', 'remove', dir]);
183
176
  return true;
184
177
  }
185
178