@cabane/cli 0.1.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.
Files changed (3) hide show
  1. package/README.md +182 -0
  2. package/dist/cli.js +1939 -0
  3. package/package.json +50 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1939 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/bridge.ts
7
+ import { existsSync as existsSync2, readFileSync as readFileSync2, rmSync } from "fs";
8
+ import { join as join2 } from "path";
9
+
10
+ // src/log.ts
11
+ var BOLD = "\x1B[1m";
12
+ var RED = "\x1B[31m";
13
+ var GREEN = "\x1B[32m";
14
+ var YELLOW = "\x1B[33m";
15
+ var RESET = "\x1B[0m";
16
+ var useColor = () => process.stderr.isTTY === true && !process.env.NO_COLOR;
17
+ var paint = (code, s) => useColor() ? `${code}${s}${RESET}` : s;
18
+ function step(msg) {
19
+ process.stderr.write(`${paint(BOLD, "\u203A")} ${msg}
20
+ `);
21
+ }
22
+ function info(msg) {
23
+ process.stderr.write(` ${msg}
24
+ `);
25
+ }
26
+ function ok(msg) {
27
+ process.stderr.write(`${paint(GREEN, "\u2713")} ${msg}
28
+ `);
29
+ }
30
+ function warn(msg) {
31
+ process.stderr.write(`${paint(YELLOW, "\u26A0")} ${msg}
32
+ `);
33
+ }
34
+ function error(msg) {
35
+ process.stderr.write(`${paint(RED, "\u2717")} ${msg}
36
+ `);
37
+ }
38
+
39
+ // src/paths.ts
40
+ import { existsSync, readFileSync } from "fs";
41
+ import { homedir } from "os";
42
+ import { join } from "path";
43
+ var SELFHOST_DIRNAME = ".cabane-selfhost";
44
+ var LEGACY_DIRNAME = ".cabane";
45
+ function classifyHome(dir) {
46
+ if (existsSync(join(dir, "app"))) return "selfhost";
47
+ let cfg = null;
48
+ try {
49
+ cfg = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
50
+ } catch {
51
+ cfg = null;
52
+ }
53
+ if (cfg) {
54
+ if (typeof cfg.distBase === "string" || typeof cfg.pgPort === "number") return "selfhost";
55
+ if (typeof cfg.baseUrl === "string" || typeof cfg.deviceToken === "string") return "bridge";
56
+ }
57
+ return "empty";
58
+ }
59
+ function defaultRoot(home = homedir()) {
60
+ const selfhost = join(home, SELFHOST_DIRNAME);
61
+ if (existsSync(selfhost)) return selfhost;
62
+ const legacy = join(home, LEGACY_DIRNAME);
63
+ if (classifyHome(legacy) === "selfhost") return legacy;
64
+ return selfhost;
65
+ }
66
+ function cabaneHome() {
67
+ return process.env.CABANE_HOME && process.env.CABANE_HOME.length > 0 ? process.env.CABANE_HOME : defaultRoot();
68
+ }
69
+ function resolvePaths(root = cabaneHome(), dataDir) {
70
+ const data = dataDir && dataDir.length > 0 ? dataDir : join(root, "data");
71
+ return {
72
+ root,
73
+ configFile: join(root, "config.json"),
74
+ appsDir: join(root, "app"),
75
+ dataDir: data,
76
+ pgDataDir: join(data, "pg"),
77
+ storageRoot: join(data, "storage"),
78
+ bridgeHome: join(root, "bridge"),
79
+ logsDir: join(root, "logs"),
80
+ serverLog: join(root, "logs", "server.log"),
81
+ bridgeLog: join(root, "logs", "bridge.log"),
82
+ runDir: join(root, "run"),
83
+ supervisorPidFile: join(root, "run", "supervisor.pid"),
84
+ stateFile: join(root, "run", "state.json")
85
+ };
86
+ }
87
+ function assertNotForeignBridge(root = cabaneHome()) {
88
+ if (classifyHome(root) !== "bridge") return;
89
+ throw new Error(
90
+ `${root} holds a standalone @cabane/bridge config (a device pairing), not a Cabane self-host install \u2014 refusing to write here and overwrite it.
91
+ Self-host defaults to ~/.cabane-selfhost; unset CABANE_HOME, or point it at a different directory.`
92
+ );
93
+ }
94
+ function appDirFor(paths, version) {
95
+ return join(paths.appsDir, version);
96
+ }
97
+
98
+ // src/proc.ts
99
+ import { spawn } from "child_process";
100
+ function run(cmd, args, opts = {}) {
101
+ const { allowFail, ...spawnOpts } = opts;
102
+ return new Promise((resolve2, reject) => {
103
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...spawnOpts });
104
+ let stdout = "";
105
+ let stderr = "";
106
+ child.stdout?.on("data", (d) => stdout += d.toString());
107
+ child.stderr?.on("data", (d) => stderr += d.toString());
108
+ child.on("error", reject);
109
+ child.on("close", (code) => {
110
+ const result = { code: code ?? 0, stdout, stderr };
111
+ if (code === 0 || allowFail) resolve2(result);
112
+ else
113
+ reject(
114
+ new Error(`${cmd} ${args.join(" ")} exited ${code}
115
+ ${stderr.trim() || stdout.trim()}`)
116
+ );
117
+ });
118
+ });
119
+ }
120
+ async function waitUntil(check, { timeoutMs, intervalMs = 500 }) {
121
+ const deadline = Date.now() + timeoutMs;
122
+ while (Date.now() < deadline) {
123
+ try {
124
+ if (await check()) return true;
125
+ } catch {
126
+ }
127
+ await new Promise((r) => setTimeout(r, intervalMs));
128
+ }
129
+ return false;
130
+ }
131
+ function pidAlive(pid) {
132
+ try {
133
+ process.kill(pid, 0);
134
+ return true;
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+ async function portInUse(port) {
140
+ const { createServer } = await import("net");
141
+ return new Promise((resolve2) => {
142
+ const srv = createServer();
143
+ srv.once("error", (err) => resolve2(err.code === "EADDRINUSE"));
144
+ srv.listen(port, "127.0.0.1", () => srv.close(() => resolve2(false)));
145
+ });
146
+ }
147
+
148
+ // src/commands/bridge.ts
149
+ var STATE_DIRS = ["cursors", "dispatched", "completed", "resume-attempts"];
150
+ async function bridgeReset(opts = {}) {
151
+ const paths = resolvePaths();
152
+ const bridgeCabane = join2(paths.bridgeHome, ".cabane");
153
+ const running = bridgeRunning(paths.stateFile);
154
+ if (running && !opts.force) {
155
+ warn("the bridge is running \u2014 a reset only takes effect on the next boot.");
156
+ info("stop it first (`cabane down`), or re-run with `--force` to clear anyway.");
157
+ return;
158
+ }
159
+ let cleared = 0;
160
+ for (const dir of STATE_DIRS) {
161
+ const target = join2(bridgeCabane, dir);
162
+ if (existsSync2(target)) {
163
+ rmSync(target, { recursive: true, force: true });
164
+ cleared += 1;
165
+ }
166
+ }
167
+ if (cleared === 0) {
168
+ ok("bridge resume state already clean \u2014 nothing to reset.");
169
+ } else {
170
+ ok(`cleared bridge resume state (${cleared} of ${STATE_DIRS.length} stores).`);
171
+ info(
172
+ "the bridge will start fresh (no interrupted turn to replay) on the next `cabane up`."
173
+ );
174
+ }
175
+ }
176
+ function bridgeRunning(stateFile) {
177
+ try {
178
+ const state = JSON.parse(readFileSync2(stateFile, "utf8"));
179
+ return state.bridgePid ? pidAlive(state.bridgePid) : false;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ // src/commands/doctor.ts
186
+ import { writeSync } from "fs";
187
+ import { homedir as homedir2 } from "os";
188
+
189
+ // src/config.ts
190
+ import { mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
191
+ import { dirname } from "path";
192
+ var DEFAULT_DIST_BASE = "https://app.cabane.ai";
193
+ var DEFAULT_PORT = 3e3;
194
+ var DEFAULT_PG_PORT = 55432;
195
+ function defaultConfig() {
196
+ return {
197
+ distBase: DEFAULT_DIST_BASE,
198
+ port: DEFAULT_PORT,
199
+ pgPort: DEFAULT_PG_PORT
200
+ };
201
+ }
202
+ function loadConfig(root) {
203
+ const file = resolvePaths(root).configFile;
204
+ try {
205
+ const parsed = JSON.parse(readFileSync3(file, "utf8"));
206
+ return { ...defaultConfig(), ...parsed };
207
+ } catch {
208
+ return defaultConfig();
209
+ }
210
+ }
211
+ function saveConfig(config, root) {
212
+ const file = resolvePaths(root).configFile;
213
+ mkdirSync(dirname(file), { recursive: true });
214
+ writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
215
+ }
216
+ function updateConfig(mutate, root) {
217
+ const config = loadConfig(root);
218
+ mutate(config);
219
+ saveConfig(config, root);
220
+ return config;
221
+ }
222
+ function pathsFor(config, root) {
223
+ return resolvePaths(root, config.dataDir);
224
+ }
225
+
226
+ // src/doctor/checks.ts
227
+ import { createRequire } from "module";
228
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
229
+ import { mkdtempSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
230
+ import { statfs } from "fs/promises";
231
+ import { dirname as dirname2, join as join3 } from "path";
232
+ import { pathToFileURL } from "url";
233
+
234
+ // src/doctor/types.ts
235
+ var pass = (id, name, detail) => ({
236
+ id,
237
+ name,
238
+ status: "pass",
239
+ detail
240
+ });
241
+ var warn2 = (id, name, detail, fix) => ({
242
+ id,
243
+ name,
244
+ status: "warn",
245
+ detail,
246
+ ...fix ? { fix } : {}
247
+ });
248
+ var fail = (id, name, detail, fix) => ({
249
+ id,
250
+ name,
251
+ status: "fail",
252
+ detail,
253
+ ...fix ? { fix } : {}
254
+ });
255
+ var skip = (id, name, detail) => ({
256
+ id,
257
+ name,
258
+ status: "skip",
259
+ detail
260
+ });
261
+
262
+ // src/doctor/checks.ts
263
+ var NODE_FLOOR_FALLBACK = "22";
264
+ function parseMajor(v) {
265
+ const m = /^v?(\d+)/.exec(v.trim());
266
+ return m ? Number(m[1]) : NaN;
267
+ }
268
+ function evaluateNode(current, floor) {
269
+ const cur = parseMajor(current);
270
+ const min = parseMajor(floor);
271
+ if (Number.isNaN(cur) || Number.isNaN(min)) {
272
+ return warn2("node", "Node", `could not compare v${current} against floor "${floor}"`);
273
+ }
274
+ if (cur >= min) return pass("node", "Node", `v${current} (\u2265 ${min} required)`);
275
+ return fail(
276
+ "node",
277
+ "Node",
278
+ `v${current} is below the required Node ${min}`,
279
+ `Install Node ${min}+ (e.g. \`nvm install ${min}\`) and re-run.`
280
+ );
281
+ }
282
+ function checkNode(ctx) {
283
+ const floor = installedNodeFloor(ctx) ?? NODE_FLOOR_FALLBACK;
284
+ return evaluateNode(ctx.sys.nodeVersion, floor);
285
+ }
286
+ async function checkPort(ctx) {
287
+ const port = ctx.config.port;
288
+ if (!await portInUse(port)) return pass("port", "Port", `:${port} is free`);
289
+ const ours = ownSupervisorPid(ctx.paths);
290
+ if (ours) {
291
+ return pass("port", "Port", `:${port} in use by a running cabane (supervisor pid ${ours})`);
292
+ }
293
+ return fail(
294
+ "port",
295
+ "Port",
296
+ `:${port} is occupied by another process`,
297
+ `Free port ${port}, or install on another with \`cabane install --port <n>\`, then retry.`
298
+ );
299
+ }
300
+ function ownSupervisorPid(paths) {
301
+ try {
302
+ const pid = Number(readFileSync4(paths.supervisorPidFile, "utf8").trim());
303
+ return pid > 0 && pidAlive(pid) ? pid : null;
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+ var GiB = 1024 ** 3;
309
+ var DISK_WARN_BYTES = 2 * GiB;
310
+ var DISK_FAIL_BYTES = 500 * 1024 ** 2;
311
+ function evaluateDisk(freeBytes, where) {
312
+ const gb = (freeBytes / GiB).toFixed(1);
313
+ if (freeBytes < DISK_FAIL_BYTES) {
314
+ return fail(
315
+ "disk",
316
+ "Disk",
317
+ `${gb} GB free on ${where} \u2014 critically low`,
318
+ "Free up disk space; Cabane needs room for the Postgres data dir + file storage."
319
+ );
320
+ }
321
+ if (freeBytes < DISK_WARN_BYTES) {
322
+ return warn2(
323
+ "disk",
324
+ "Disk",
325
+ `${gb} GB free on ${where} \u2014 low`,
326
+ "Consider freeing space; Postgres and uploads grow over time."
327
+ );
328
+ }
329
+ return pass("disk", "Disk", `${gb} GB free on ${where}`);
330
+ }
331
+ async function checkDisk(ctx) {
332
+ const probe = nearestExisting(ctx.paths.dataDir);
333
+ const st = await statfs(probe);
334
+ const free = st.bavail * st.bsize;
335
+ return evaluateDisk(free, probe);
336
+ }
337
+ function checkStorage(ctx) {
338
+ const root = ctx.paths.storageRoot;
339
+ const target = existsSync3(root) ? root : nearestExisting(root);
340
+ if (!isWritableDir(target)) {
341
+ return fail(
342
+ "storage",
343
+ "Storage root",
344
+ `${root} is not writable (${target} is read-only)`,
345
+ `Make ${target} writable, or point Cabane elsewhere with \`CABANE_HOME=<dir>\`.`
346
+ );
347
+ }
348
+ const note = existsSync3(root) ? "" : " (will be created on first `cabane up`)";
349
+ return pass("storage", "Storage root", `${root} \u2014 writable${note}`);
350
+ }
351
+ async function resolvePgBinaries(platform, arch) {
352
+ const pkg = pgPlatformPackage(platform, arch);
353
+ if (!pkg) return null;
354
+ try {
355
+ const cliRequire = createRequire(import.meta.url);
356
+ let entry;
357
+ try {
358
+ const epRequire = createRequire(cliRequire.resolve("embedded-postgres"));
359
+ entry = epRequire.resolve(pkg);
360
+ } catch {
361
+ entry = cliRequire.resolve(pkg);
362
+ }
363
+ const mod = await import(pathToFileURL(entry).href);
364
+ if (!mod.postgres || !mod.initdb || !mod.pg_ctl) return null;
365
+ return { postgres: mod.postgres, initdb: mod.initdb, pg_ctl: mod.pg_ctl };
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+ function pgPlatformPackage(platform, arch) {
371
+ const map = {
372
+ darwin: { arm64: "darwin-arm64", x64: "darwin-x64" },
373
+ linux: {
374
+ arm64: "linux-arm64",
375
+ arm: "linux-arm",
376
+ ia32: "linux-ia32",
377
+ ppc64: "linux-ppc64",
378
+ x64: "linux-x64"
379
+ },
380
+ win32: { x64: "windows-x64" }
381
+ };
382
+ const slug = map[platform]?.[arch];
383
+ return slug ? `@embedded-postgres/${slug}` : null;
384
+ }
385
+ async function checkPostgres(ctx) {
386
+ const { platform, arch } = ctx.sys;
387
+ const bin = await resolvePgBinaries(platform, arch);
388
+ if (!bin) {
389
+ return fail(
390
+ "postgres",
391
+ "Embedded Postgres",
392
+ `no Postgres binaries for ${platform}-${arch}`,
393
+ "Reinstall the CLI so the platform Postgres package is present (`npm i -g @cabane/cli`)."
394
+ );
395
+ }
396
+ const dataDir = ctx.paths.pgDataDir;
397
+ if (!existsSync3(join3(dataDir, "PG_VERSION"))) {
398
+ return pass(
399
+ "postgres",
400
+ "Embedded Postgres",
401
+ `binaries present; data dir uninitialised \u2014 first \`cabane up\` will init it (${dataDir})`
402
+ );
403
+ }
404
+ const stale = stalePostmaster(dataDir);
405
+ if (stale) {
406
+ return warn2(
407
+ "postgres",
408
+ "Embedded Postgres",
409
+ `data dir initialised but holds a stale postmaster.pid (pid ${stale}, not running) \u2014 ${dataDir}`,
410
+ "Harmless \u2014 Postgres clears it on the next `cabane up`; remove data/pg/postmaster.pid if start still refuses."
411
+ );
412
+ }
413
+ return pass("postgres", "Embedded Postgres", `binaries present; data dir healthy \u2014 ${dataDir}`);
414
+ }
415
+ function stalePostmaster(dataDir) {
416
+ try {
417
+ const pid = Number(
418
+ readFileSync4(join3(dataDir, "postmaster.pid"), "utf8").split("\n")[0]?.trim()
419
+ );
420
+ return pid > 0 && !pidAlive(pid) ? pid : null;
421
+ } catch {
422
+ return null;
423
+ }
424
+ }
425
+ async function checkGatekeeper(ctx) {
426
+ const { platform, arch } = ctx.sys;
427
+ if (platform !== "darwin") return skip("gatekeeper", "macOS Gatekeeper", "not macOS \u2014 n/a");
428
+ const bin = await resolvePgBinaries(platform, arch);
429
+ if (!bin) return skip("gatekeeper", "macOS Gatekeeper", "Postgres binaries not found \u2014 n/a");
430
+ if (await hasQuarantine(bin.postgres)) {
431
+ return fail(
432
+ "gatekeeper",
433
+ "macOS Gatekeeper",
434
+ "the Postgres binaries are quarantined \u2014 Gatekeeper will block them from running",
435
+ `Clear it: \`xattr -dr com.apple.quarantine "${dirname2(dirname2(bin.postgres))}"\`, then retry.`
436
+ );
437
+ }
438
+ return pass("gatekeeper", "macOS Gatekeeper", "Postgres binaries are not quarantined");
439
+ }
440
+ async function hasQuarantine(file) {
441
+ try {
442
+ const res = await run("xattr", ["-p", "com.apple.quarantine", file], { allowFail: true });
443
+ return res.code === 0;
444
+ } catch {
445
+ return false;
446
+ }
447
+ }
448
+ var CREDENTIAL_SOURCE = {
449
+ subscription: "Claude subscription login (~/.claude)",
450
+ "env-api-key": "ANTHROPIC_API_KEY (env or `cabane set-env`)",
451
+ "env-auth-token": "ANTHROPIC_AUTH_TOKEN (env or `cabane set-env`)",
452
+ "settings-api-key": "ANTHROPIC_API_KEY in Claude settings.json"
453
+ };
454
+ function evaluateExecutor(p) {
455
+ const name = "Executor (Claude Code)";
456
+ if (!p.binaryFound) {
457
+ return fail(
458
+ "executor",
459
+ name,
460
+ `\`${p.binaryName}\` is not on PATH \u2014 the house bridge has no executor to run`,
461
+ "Install Claude Code (or your chosen executor) and make sure it is on PATH."
462
+ );
463
+ }
464
+ const apiKeyOnly = p.credential === "env-api-key" || p.credential === "env-auth-token" || p.credential === "settings-api-key";
465
+ if (p.forceLoginMethod === "claudeai" && apiKeyOnly) {
466
+ return fail(
467
+ "executor",
468
+ name,
469
+ "a Claude Code managed policy forces subscription login (`forceLoginMethod: claudeai`), which refuses your API key",
470
+ "Log in with the required method (`claude` \u2192 /login), or remove the managed-settings force-login policy."
471
+ );
472
+ }
473
+ if (!p.credential) {
474
+ return fail(
475
+ "executor",
476
+ name,
477
+ "no executor credential is reachable by the house bridge",
478
+ "Log in to Claude Code, or persist a key with `cabane set-env ANTHROPIC_API_KEY=\u2026`, or export it before `cabane up`."
479
+ );
480
+ }
481
+ return pass(
482
+ "executor",
483
+ name,
484
+ `\`${p.binaryName}\` present; credential reachable via ${CREDENTIAL_SOURCE[p.credential]} (reachability only \u2014 not verified against the provider)`
485
+ );
486
+ }
487
+ function checkExecutor(ctx) {
488
+ return evaluateExecutor(probeExecutor(ctx));
489
+ }
490
+ function probeExecutor(ctx) {
491
+ const { env, homeDir } = ctx.sys;
492
+ const cred = (key) => {
493
+ const v = env[key] ?? ctx.config.bridgeEnv?.[key];
494
+ return typeof v === "string" && v.length > 0;
495
+ };
496
+ const configDir = env.CLAUDE_CONFIG_DIR ?? join3(homeDir, ".claude");
497
+ let credential = null;
498
+ if (cred("ANTHROPIC_API_KEY")) credential = "env-api-key";
499
+ else if (cred("ANTHROPIC_AUTH_TOKEN")) credential = "env-auth-token";
500
+ else if (existsSync3(join3(configDir, ".credentials.json"))) credential = "subscription";
501
+ else if (settingsHasApiKey(join3(configDir, "settings.json"))) credential = "settings-api-key";
502
+ return {
503
+ binaryName: "claude",
504
+ binaryFound: onPath("claude", env),
505
+ forceLoginMethod: readForceLoginMethod(ctx.sys),
506
+ credential
507
+ };
508
+ }
509
+ function settingsHasApiKey(file) {
510
+ try {
511
+ const s = JSON.parse(readFileSync4(file, "utf8"));
512
+ return Boolean(s.env?.ANTHROPIC_API_KEY || s.apiKeyHelper);
513
+ } catch {
514
+ return false;
515
+ }
516
+ }
517
+ function managedSettingsPath(sys) {
518
+ switch (sys.platform) {
519
+ case "darwin":
520
+ return "/Library/Application Support/ClaudeCode/managed-settings.json";
521
+ case "linux":
522
+ return "/etc/claude-code/managed-settings.json";
523
+ case "win32":
524
+ return join3(sys.env.PROGRAMDATA ?? "C:\\ProgramData", "ClaudeCode", "managed-settings.json");
525
+ default:
526
+ return null;
527
+ }
528
+ }
529
+ function readForceLoginMethod(sys) {
530
+ const path = managedSettingsPath(sys);
531
+ if (!path) return null;
532
+ try {
533
+ const s = JSON.parse(readFileSync4(path, "utf8"));
534
+ return s.forceLoginMethod ?? null;
535
+ } catch {
536
+ return null;
537
+ }
538
+ }
539
+ function onPath(bin, env) {
540
+ const dirs = (env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
541
+ const names = process.platform === "win32" ? [bin, `${bin}.exe`, `${bin}.cmd`] : [bin];
542
+ for (const dir of dirs) {
543
+ if (!dir) continue;
544
+ for (const name of names) if (existsSync3(join3(dir, name))) return true;
545
+ }
546
+ return false;
547
+ }
548
+ function evaluateVersions(input) {
549
+ const name = "Bridge/server version";
550
+ const { serverVersion, bridgeVersion } = input;
551
+ if (!serverVersion) {
552
+ return skip("versions", name, "no server artifact installed yet \u2014 run `cabane install`");
553
+ }
554
+ if (!bridgeVersion) {
555
+ return warn2(
556
+ "versions",
557
+ name,
558
+ `the installed artifact ships no bundled bridge to check against (server ${serverVersion})`,
559
+ "Reinstall the server artifact \u2014 a current build bundles its own bridge (`cabane install`)."
560
+ );
561
+ }
562
+ if (bridgeVersion === serverVersion) {
563
+ return pass("versions", name, `bundled bridge and server both ${serverVersion} (lockstep)`);
564
+ }
565
+ return fail(
566
+ "versions",
567
+ name,
568
+ `bundled bridge ${bridgeVersion} \u2260 server ${serverVersion} \u2014 a version skew (they must ship from one build)`,
569
+ "Reinstall a single matching artifact with `cabane install` so the bundled bridge and server are the same version."
570
+ );
571
+ }
572
+ function checkVersions(ctx) {
573
+ return evaluateVersions({
574
+ serverVersion: ctx.config.currentVersion ?? null,
575
+ bridgeVersion: readBundledBridgeVersion(ctx)
576
+ });
577
+ }
578
+ function readBundledBridgeVersion(ctx) {
579
+ const version = ctx.config.currentVersion;
580
+ if (!version) return null;
581
+ try {
582
+ const pkg = JSON.parse(
583
+ readFileSync4(join3(appDirFor(ctx.paths, version), "bridge", "package.json"), "utf8")
584
+ );
585
+ return pkg.version ?? null;
586
+ } catch {
587
+ return null;
588
+ }
589
+ }
590
+ function installedNodeFloor(ctx) {
591
+ const version = ctx.config.currentVersion;
592
+ if (!version) return null;
593
+ try {
594
+ const manifest = JSON.parse(
595
+ readFileSync4(join3(appDirFor(ctx.paths, version), "manifest.json"), "utf8")
596
+ );
597
+ return manifest.nodeFloor ?? null;
598
+ } catch {
599
+ return null;
600
+ }
601
+ }
602
+ function nearestExisting(path) {
603
+ let cur = path;
604
+ while (!existsSync3(cur)) {
605
+ const parent = dirname2(cur);
606
+ if (parent === cur) break;
607
+ cur = parent;
608
+ }
609
+ return cur;
610
+ }
611
+ function isWritableDir(dir) {
612
+ try {
613
+ const probe = mkdtempSync(join3(dir, ".cabane-doctor-"));
614
+ writeFileSync2(join3(probe, "w"), "");
615
+ rmSync2(probe, { recursive: true, force: true });
616
+ return true;
617
+ } catch {
618
+ return false;
619
+ }
620
+ }
621
+
622
+ // src/doctor/report.ts
623
+ function summarize(results) {
624
+ const count = (s) => results.filter((r) => r.status === s).length;
625
+ const failed = count("fail");
626
+ return {
627
+ results,
628
+ passed: count("pass"),
629
+ warned: count("warn"),
630
+ failed,
631
+ skipped: count("skip"),
632
+ ok: failed === 0
633
+ };
634
+ }
635
+ var STATUS_GLYPH = {
636
+ pass: "\u2713",
637
+ warn: "\u26A0",
638
+ fail: "\u2717",
639
+ skip: "\u2013"
640
+ };
641
+ var NAME_WIDTH = 22;
642
+ function reportLines(summary) {
643
+ const lines = ["cabane doctor", ""];
644
+ for (const r of summary.results) {
645
+ lines.push(`${STATUS_GLYPH[r.status]} ${r.name.padEnd(NAME_WIDTH)} ${r.detail}`);
646
+ if (r.fix) lines.push(` ${" ".repeat(NAME_WIDTH)} \u21B3 fix: ${r.fix}`);
647
+ }
648
+ lines.push("");
649
+ lines.push(rollup(summary));
650
+ return lines;
651
+ }
652
+ function rollup(s) {
653
+ const parts = [`${s.results.length} checks`, `${s.passed} passed`];
654
+ if (s.warned) parts.push(`${s.warned} warn`);
655
+ if (s.failed) parts.push(`${s.failed} failed`);
656
+ if (s.skipped) parts.push(`${s.skipped} skipped`);
657
+ const verdict = s.ok ? "all clear" : "problems found";
658
+ return `${parts.join(" \xB7 ")} \u2014 ${verdict}`;
659
+ }
660
+
661
+ // src/commands/doctor.ts
662
+ var CHECKS = [
663
+ { run: checkNode },
664
+ { run: checkExecutor },
665
+ { run: checkPort },
666
+ { run: checkPostgres },
667
+ { run: checkStorage },
668
+ { run: checkVersions },
669
+ { run: checkDisk },
670
+ { run: checkGatekeeper }
671
+ ];
672
+ async function doctor() {
673
+ const config = loadConfig();
674
+ const ctx = {
675
+ paths: pathsFor(config),
676
+ config,
677
+ sys: {
678
+ platform: process.platform,
679
+ arch: process.arch,
680
+ nodeVersion: process.versions.node,
681
+ env: process.env,
682
+ homeDir: homedir2()
683
+ }
684
+ };
685
+ const results = [];
686
+ for (const check of CHECKS) {
687
+ try {
688
+ results.push(await check.run(ctx));
689
+ } catch (err) {
690
+ results.push(
691
+ fail("unknown", "Check errored", err instanceof Error ? err.message : String(err))
692
+ );
693
+ }
694
+ }
695
+ const summary = summarize(results);
696
+ const text = reportLines(summary).map(colorize).join("\n") + "\n";
697
+ try {
698
+ writeSync(1, text);
699
+ } catch {
700
+ process.stdout.write(text);
701
+ }
702
+ process.exit(summary.ok ? 0 : 1);
703
+ }
704
+ var COLOR = {
705
+ [STATUS_GLYPH.pass]: "[32m",
706
+ // green
707
+ [STATUS_GLYPH.warn]: "[33m",
708
+ // yellow
709
+ [STATUS_GLYPH.fail]: "[31m",
710
+ // red
711
+ [STATUS_GLYPH.skip]: "[2m"
712
+ // dim
713
+ };
714
+ var RESET2 = "[0m";
715
+ function colorize(line) {
716
+ if (process.stdout.isTTY !== true || process.env.NO_COLOR) return line;
717
+ const glyph = line[0];
718
+ const code = glyph ? COLOR[glyph] : void 0;
719
+ return code ? `${code}${glyph}${RESET2}${line.slice(1)}` : line;
720
+ }
721
+
722
+ // src/commands/down.ts
723
+ import { readFileSync as readFileSync5, rmSync as rmSync3 } from "fs";
724
+ import { join as join4 } from "path";
725
+ async function down() {
726
+ const config = loadConfig();
727
+ const paths = pathsFor(config);
728
+ const state = readState(paths.stateFile);
729
+ const supervisorPid = readPid(paths.supervisorPidFile) ?? state.supervisorPid;
730
+ const port = state.port ?? config.port;
731
+ if (supervisorPid && pidAlive(supervisorPid)) {
732
+ step(`stopping supervisor (pid ${supervisorPid})\u2026`);
733
+ await killPid(supervisorPid);
734
+ } else {
735
+ info("no live supervisor \u2014 reaping any orphaned children.");
736
+ }
737
+ for (const pid of [
738
+ state.bridgePid,
739
+ state.serverPid,
740
+ readPid(join4(paths.pgDataDir, "postmaster.pid"))
741
+ ]) {
742
+ if (pid && pidAlive(pid)) await killPid(pid);
743
+ }
744
+ const free = await waitUntil(async () => !await portInUse(port), {
745
+ timeoutMs: 15e3,
746
+ intervalMs: 300
747
+ });
748
+ rmSync3(paths.supervisorPidFile, { force: true });
749
+ rmSync3(paths.stateFile, { force: true });
750
+ if (free) ok("stopped");
751
+ else warn(`stopped, but port ${port} still looks busy \u2014 check \`cabane status\`.`);
752
+ }
753
+ async function killPid(pid) {
754
+ try {
755
+ process.kill(pid, "SIGTERM");
756
+ } catch {
757
+ return;
758
+ }
759
+ const gone = await waitUntil(async () => !pidAlive(pid), { timeoutMs: 8e3, intervalMs: 250 });
760
+ if (!gone) {
761
+ try {
762
+ process.kill(pid, "SIGKILL");
763
+ } catch {
764
+ }
765
+ }
766
+ }
767
+ function readState(file) {
768
+ try {
769
+ return JSON.parse(readFileSync5(file, "utf8"));
770
+ } catch {
771
+ return {};
772
+ }
773
+ }
774
+ function readPid(file) {
775
+ try {
776
+ const pid = Number(readFileSync5(file, "utf8").trim());
777
+ return pid > 0 ? pid : null;
778
+ } catch {
779
+ return null;
780
+ }
781
+ }
782
+
783
+ // src/commands/init.ts
784
+ import { existsSync as existsSync5 } from "fs";
785
+ import { homedir as homedir3 } from "os";
786
+ import { isAbsolute, join as join5, resolve } from "path";
787
+
788
+ // src/artifact.ts
789
+ import { execFileSync } from "child_process";
790
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2 } from "fs";
791
+ function readTarballManifest(tarball) {
792
+ const raw = execFileSync("tar", ["-xzOf", tarball, "./manifest.json"], {
793
+ maxBuffer: 16 * 1024 * 1024
794
+ });
795
+ return JSON.parse(raw.toString("utf8"));
796
+ }
797
+ function unpackArtifact(tarball, destDir) {
798
+ mkdirSync2(destDir, { recursive: true });
799
+ execFileSync("tar", ["-xzf", tarball, "-C", destDir], { stdio: "inherit" });
800
+ }
801
+ async function npmInstall(appDir) {
802
+ await run("npm", ["install", "--omit=dev", "--legacy-peer-deps", "--no-audit", "--no-fund"], {
803
+ cwd: appDir,
804
+ stdio: ["ignore", "inherit", "inherit"]
805
+ });
806
+ }
807
+ function isInstalled(appDir) {
808
+ return existsSync4(appDir) && existsSync4(`${appDir}/node_modules`) && existsSync4(`${appDir}/server.js`);
809
+ }
810
+
811
+ // src/prompt.ts
812
+ import { createInterface } from "readline";
813
+ function isInteractive() {
814
+ return process.stdin.isTTY === true;
815
+ }
816
+ async function promptLine(question) {
817
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
818
+ try {
819
+ const answer = await new Promise((resolve2) => rl.question(question, resolve2));
820
+ return answer.trim();
821
+ } finally {
822
+ rl.close();
823
+ }
824
+ }
825
+ async function promptSecret(question) {
826
+ if (!isInteractive()) return promptLine(question);
827
+ const rl = createInterface({
828
+ input: process.stdin,
829
+ output: process.stderr,
830
+ terminal: true
831
+ });
832
+ let muted = false;
833
+ rl._writeToOutput = (s) => {
834
+ if (!muted) process.stderr.write(s);
835
+ };
836
+ try {
837
+ const answer = await new Promise((resolve2) => {
838
+ rl.question(question, resolve2);
839
+ muted = true;
840
+ });
841
+ process.stderr.write("\n");
842
+ return answer.trim();
843
+ } finally {
844
+ rl.close();
845
+ }
846
+ }
847
+
848
+ // src/commands/init.ts
849
+ var EXECUTOR_KEY = "ANTHROPIC_API_KEY";
850
+ async function init() {
851
+ if (!isInteractive()) {
852
+ throw new Error(
853
+ "`cabane init` is an interactive wizard \u2014 run it in a terminal, or configure non-interactively with `cabane install --port \u2026` + `cabane set-env KEY=VALUE`."
854
+ );
855
+ }
856
+ assertNotForeignBridge();
857
+ const root = cabaneHome();
858
+ const config = loadConfig();
859
+ step("Configure your self-hosted Cabane");
860
+ info(`home: ${root} (config + installed app)`);
861
+ info("");
862
+ const currentDataDir = pathsFor(config, root).dataDir;
863
+ const dataAnswer = await promptLine(
864
+ `Where should your workspace data live? [${currentDataDir}]
865
+ (uploaded files \u2192 <dir>/storage, database \u2192 <dir>/pg)
866
+ > `
867
+ );
868
+ const dataDir = dataAnswer ? expandPath(dataAnswer, root) : currentDataDir;
869
+ const defaultDataDir = resolvePaths(root).dataDir;
870
+ config.dataDir = dataDir === defaultDataDir ? void 0 : dataDir;
871
+ const oldPg = pathsFor(loadConfig(), root).pgDataDir;
872
+ const newPg = join5(dataDir, "pg");
873
+ if (oldPg !== newPg && existsSync5(join5(oldPg, "PG_VERSION"))) {
874
+ warn(`existing database at ${oldPg} will NOT move \u2014 a fresh one initializes at ${newPg}.`);
875
+ info(
876
+ "to keep your current data, copy that directory over, or leave the location unchanged."
877
+ );
878
+ }
879
+ config.port = await promptPort(config.port || DEFAULT_PORT);
880
+ const urlAnswer = await promptLine(
881
+ `Public URL the box is reachable at? [${config.publicUrl ?? `http://localhost:${config.port}`}]
882
+ (blank = serve on localhost only)
883
+ > `
884
+ );
885
+ if (urlAnswer) {
886
+ if (!/^https?:\/\//.test(urlAnswer)) {
887
+ throw new Error(`public URL must start with http:// or https:// \u2014 got: ${urlAnswer}`);
888
+ }
889
+ config.publicUrl = urlAnswer.replace(/\/$/, "");
890
+ }
891
+ info("");
892
+ info(
893
+ `Executor API key \u2014 for API-key billing (stored as ${EXECUTOR_KEY}). Leave blank if your`
894
+ );
895
+ info("executor is already logged in (e.g. a Claude subscription under ~/.claude).");
896
+ const key = await promptSecret(`${EXECUTOR_KEY} (hidden, blank to skip): `);
897
+ if (key) {
898
+ config.bridgeEnv = { ...config.bridgeEnv ?? {}, [EXECUTOR_KEY]: key };
899
+ }
900
+ saveConfig(config, root);
901
+ const paths = pathsFor(config, root);
902
+ info("");
903
+ ok("configuration saved");
904
+ info(` data ${paths.dataDir}`);
905
+ info(` storage ${paths.storageRoot} (your uploaded files)`);
906
+ info(` database ${paths.pgDataDir}`);
907
+ info(` port ${config.port}`);
908
+ info(` url ${config.publicUrl ?? `http://localhost:${config.port}`}`);
909
+ info(
910
+ ` ${EXECUTOR_KEY} ${config.bridgeEnv?.[EXECUTOR_KEY] ? mask(config.bridgeEnv[EXECUTOR_KEY]) : "(not set \u2014 using on-disk executor auth)"}`
911
+ );
912
+ info("");
913
+ const installed = config.currentVersion && isInstalled(appDirFor(paths, config.currentVersion));
914
+ if (installed) {
915
+ info("next: `cabane up` (no flags needed), then sign up and `cabane setup-agent`.");
916
+ } else {
917
+ info("next: `cabane install` to fetch the server, then `cabane up` (no flags needed).");
918
+ }
919
+ }
920
+ function expandPath(input, root) {
921
+ let p = input;
922
+ if (p === "~") p = homedir3();
923
+ else if (p.startsWith("~/")) p = join5(homedir3(), p.slice(2));
924
+ return isAbsolute(p) ? p : resolve(root, p);
925
+ }
926
+ async function promptPort(current) {
927
+ for (let attempt = 0; attempt < 5; attempt++) {
928
+ const answer = await promptLine(`Port the server listens on? [${current}]
929
+ > `);
930
+ if (!answer) return current;
931
+ const n = Number(answer);
932
+ if (Number.isInteger(n) && n > 0 && n <= 65535) return n;
933
+ warn(`invalid port: ${answer} \u2014 enter a number between 1 and 65535.`);
934
+ }
935
+ throw new Error("no valid port after several tries \u2014 re-run `cabane init`.");
936
+ }
937
+ function mask(value) {
938
+ if (value.length <= 8) return "\u2022\u2022\u2022\u2022";
939
+ return `${value.slice(0, 4)}\u2026${value.slice(-4)}`;
940
+ }
941
+
942
+ // src/commands/install.ts
943
+ import { randomBytes } from "crypto";
944
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, rmSync as rmSync4 } from "fs";
945
+ import { tmpdir } from "os";
946
+ import { join as join6 } from "path";
947
+
948
+ // src/dist.ts
949
+ import { createHash } from "crypto";
950
+ import { createWriteStream } from "fs";
951
+ import { Readable } from "stream";
952
+ import { pipeline } from "stream/promises";
953
+ var bearer = (token) => ({ authorization: `Bearer ${token}` });
954
+ async function fetchManifest(distBase, token, version) {
955
+ const q = version && version.length > 0 ? `?version=${encodeURIComponent(version)}` : "";
956
+ const res = await fetch(`${distBase.replace(/\/$/, "")}/api/dist/manifest${q}`, {
957
+ headers: bearer(token)
958
+ });
959
+ if (res.status === 404) return null;
960
+ if (res.status === 401) {
961
+ throw new Error(
962
+ "distribution token rejected (401) \u2014 check the token passed to `cabane install`"
963
+ );
964
+ }
965
+ if (!res.ok) {
966
+ throw new Error(`GET /api/dist/manifest \u2192 ${res.status}: ${(await res.text()).slice(0, 200)}`);
967
+ }
968
+ return await res.json();
969
+ }
970
+ async function downloadArtifact(distBase, token, manifest, destPath) {
971
+ const q = `?version=${encodeURIComponent(manifest.version)}`;
972
+ const res = await fetch(`${distBase.replace(/\/$/, "")}/api/dist/artifact${q}`, {
973
+ headers: bearer(token),
974
+ // fetch follows the 302 to the presigned URL on its own; the redirect target
975
+ // is unauthenticated (the signature is the gate) so the bearer header is
976
+ // simply ignored there.
977
+ redirect: "follow"
978
+ });
979
+ if (!res.ok || !res.body) {
980
+ throw new Error(`GET /api/dist/artifact \u2192 ${res.status}: ${(await res.text()).slice(0, 200)}`);
981
+ }
982
+ const hash = createHash("sha256");
983
+ const source = Readable.fromWeb(res.body);
984
+ source.on("data", (chunk) => hash.update(chunk));
985
+ await pipeline(source, createWriteStream(destPath));
986
+ const digest = hash.digest("hex");
987
+ if (digest !== manifest.sha256) {
988
+ throw new Error(
989
+ `downloaded artifact sha256 mismatch: got ${digest}, manifest says ${manifest.sha256}`
990
+ );
991
+ }
992
+ return digest;
993
+ }
994
+
995
+ // src/commands/install.ts
996
+ async function install(opts) {
997
+ assertNotForeignBridge();
998
+ const config = loadConfig();
999
+ const paths = pathsFor(config);
1000
+ const dataExisted = existsSync6(paths.dataDir);
1001
+ if (opts.distBase) config.distBase = opts.distBase.replace(/\/$/, "");
1002
+ if (opts.port) config.port = opts.port;
1003
+ mkdirSync3(paths.appsDir, { recursive: true });
1004
+ let tarball;
1005
+ let version;
1006
+ let cleanup = null;
1007
+ if (opts.from) {
1008
+ if (!existsSync6(opts.from)) throw new Error(`--from tarball not found: ${opts.from}`);
1009
+ tarball = opts.from;
1010
+ version = readTarballManifest(tarball).version;
1011
+ step(`installing from local tarball \u2192 version ${version}`);
1012
+ } else {
1013
+ const token = await resolveToken(opts, config);
1014
+ config.distToken = token;
1015
+ step(`fetching artifact from ${config.distBase}`);
1016
+ const manifest = await fetchManifest(config.distBase, token, opts.version);
1017
+ if (!manifest) {
1018
+ throw new Error(
1019
+ `no artifact available at ${config.distBase}` + (opts.version ? ` for version ${opts.version}` : "") + " \u2014 nothing published, or the version is unknown."
1020
+ );
1021
+ }
1022
+ version = manifest.version;
1023
+ info(`resolved ${version} (${(manifest.bytes / 1024 / 1024).toFixed(1)} MB)`);
1024
+ const tmp = join6(tmpdir(), `cabane-artifact-${Date.now().toString(36)}.tgz`);
1025
+ await downloadArtifact(config.distBase, token, manifest, tmp);
1026
+ ok("downloaded + sha256 verified");
1027
+ tarball = tmp;
1028
+ cleanup = () => rmSync4(tmp, { force: true });
1029
+ }
1030
+ try {
1031
+ const appDir = appDirFor(paths, version);
1032
+ if (isInstalled(appDir) && !opts.force) {
1033
+ info(`version ${version} already installed \u2014 reusing (pass --force to reinstall)`);
1034
+ } else {
1035
+ rmSync4(appDir, { recursive: true, force: true });
1036
+ step(`unpacking \u2192 ${appDir}`);
1037
+ unpackArtifact(tarball, appDir);
1038
+ step("npm install --omit=dev (runtime deps)\u2026");
1039
+ await npmInstall(appDir);
1040
+ ok("runtime deps installed");
1041
+ }
1042
+ persistInstall(config, version);
1043
+ ok(`installed ${version}`);
1044
+ if (dataExisted) {
1045
+ ok(`your data is preserved \u2014 ${paths.dataDir} (pg + storage) was untouched`);
1046
+ } else {
1047
+ info(`data will live at ${paths.dataDir} (pg + storage) \u2014 preserved across upgrades`);
1048
+ }
1049
+ info("next: cabane up");
1050
+ } finally {
1051
+ cleanup?.();
1052
+ }
1053
+ }
1054
+ async function resolveToken(opts, config) {
1055
+ if (opts.token) return opts.token.trim();
1056
+ if (config.distToken) return config.distToken;
1057
+ if (isInteractive()) {
1058
+ const t = await promptSecret("Paste your Cabane access token (cabdist_\u2026): ");
1059
+ if (t) return t;
1060
+ }
1061
+ throw new Error(
1062
+ "no distribution token. Pass --token <cabdist_\u2026>, or --from <tarball> to install a local build."
1063
+ );
1064
+ }
1065
+ function persistInstall(config, version) {
1066
+ if (!config.visionSecret) config.visionSecret = randomBytes(32).toString("hex");
1067
+ if (config.currentVersion && config.currentVersion !== version) {
1068
+ config.previousVersion = config.currentVersion;
1069
+ }
1070
+ config.currentVersion = version;
1071
+ saveConfig(config);
1072
+ }
1073
+
1074
+ // src/commands/logs.ts
1075
+ import { existsSync as existsSync7 } from "fs";
1076
+ async function logs(opts) {
1077
+ const paths = resolvePaths();
1078
+ const n = String(opts.lines ?? 80);
1079
+ const targets = [];
1080
+ if (opts.server || !opts.bridge) targets.push(paths.serverLog);
1081
+ if (opts.bridge || !opts.server) targets.push(paths.bridgeLog);
1082
+ const present = targets.filter((t) => existsSync7(t));
1083
+ if (present.length === 0) {
1084
+ info("no logs yet \u2014 start the stack with `cabane up`.");
1085
+ return;
1086
+ }
1087
+ const args = opts.follow ? ["-n", n, "-F", ...present] : ["-n", n, ...present];
1088
+ await run("tail", args, { stdio: ["ignore", "inherit", "inherit"] });
1089
+ }
1090
+
1091
+ // src/commands/set-env.ts
1092
+ var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1093
+ function mask2(value) {
1094
+ if (value.length <= 8) return "\u2022\u2022\u2022\u2022";
1095
+ return `${value.slice(0, 4)}\u2026${value.slice(-4)}`;
1096
+ }
1097
+ function printList() {
1098
+ const env = loadConfig().bridgeEnv ?? {};
1099
+ const keys = Object.keys(env);
1100
+ if (keys.length === 0) {
1101
+ info("no persisted bridge env. Add one: `cabane set-env ANTHROPIC_API_KEY=\u2026`");
1102
+ return;
1103
+ }
1104
+ step("persisted bridge env:");
1105
+ for (const key of keys) info(`${key}=${mask2(env[key])}`);
1106
+ }
1107
+ async function setEnv(pairs, opts) {
1108
+ if (opts.list || pairs.length === 0 && !opts.unset?.length) {
1109
+ printList();
1110
+ return;
1111
+ }
1112
+ assertNotForeignBridge();
1113
+ if (opts.unset?.length) {
1114
+ updateConfig((config) => {
1115
+ if (!config.bridgeEnv) return;
1116
+ for (const key of opts.unset) delete config.bridgeEnv[key];
1117
+ if (Object.keys(config.bridgeEnv).length === 0) delete config.bridgeEnv;
1118
+ });
1119
+ ok(`unset ${opts.unset.join(", ")}`);
1120
+ }
1121
+ const parsed = {};
1122
+ for (const pair of pairs) {
1123
+ const eq = pair.indexOf("=");
1124
+ if (eq < 0) {
1125
+ const key2 = pair;
1126
+ if (!KEY_RE.test(key2)) throw new Error(`invalid env var name: ${key2}`);
1127
+ if (!isInteractive()) {
1128
+ throw new Error(
1129
+ `no value for ${key2} and not a TTY \u2014 pass ${key2}=VALUE, or run interactively to be prompted.`
1130
+ );
1131
+ }
1132
+ const value = await promptSecret(`Value for ${key2} (hidden): `);
1133
+ if (!value) throw new Error(`no value entered for ${key2}.`);
1134
+ parsed[key2] = value;
1135
+ continue;
1136
+ }
1137
+ if (eq === 0) throw new Error(`expected KEY=VALUE, got: ${pair}`);
1138
+ const key = pair.slice(0, eq);
1139
+ if (!KEY_RE.test(key)) throw new Error(`invalid env var name: ${key}`);
1140
+ parsed[key] = pair.slice(eq + 1);
1141
+ }
1142
+ if (Object.keys(parsed).length > 0) {
1143
+ updateConfig((config) => {
1144
+ config.bridgeEnv = { ...config.bridgeEnv ?? {}, ...parsed };
1145
+ });
1146
+ for (const key of Object.keys(parsed)) ok(`set ${key}=${mask2(parsed[key])}`);
1147
+ info("restart to apply: `cabane down && cabane up --daemon`");
1148
+ }
1149
+ }
1150
+
1151
+ // src/commands/setup-agent.ts
1152
+ import { existsSync as existsSync10 } from "fs";
1153
+
1154
+ // src/api.ts
1155
+ var ApiError = class extends Error {
1156
+ constructor(message, status2, body) {
1157
+ super(message);
1158
+ this.status = status2;
1159
+ this.body = body;
1160
+ this.name = "ApiError";
1161
+ }
1162
+ status;
1163
+ body;
1164
+ };
1165
+ function makeClient(base, token) {
1166
+ const trimmed = base.replace(/\/$/, "");
1167
+ const call = async (method, path, body) => {
1168
+ const res = await fetch(`${trimmed}${path}`, {
1169
+ method,
1170
+ headers: {
1171
+ authorization: `Bearer ${token}`,
1172
+ ...body !== void 0 ? { "content-type": "application/json" } : {}
1173
+ },
1174
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
1175
+ });
1176
+ const text = await res.text();
1177
+ let json = null;
1178
+ try {
1179
+ json = text ? JSON.parse(text) : null;
1180
+ } catch {
1181
+ json = text;
1182
+ }
1183
+ if (!res.ok) {
1184
+ const detail = json && typeof json === "object" ? JSON.stringify(json) : String(json).slice(0, 300);
1185
+ throw new ApiError(`${method} ${path} \u2192 ${res.status}: ${detail}`, res.status, json);
1186
+ }
1187
+ return json;
1188
+ };
1189
+ return {
1190
+ base: trimmed,
1191
+ get: (p) => call("GET", p),
1192
+ post: (p, b) => call("POST", p, b),
1193
+ put: (p, b) => call("PUT", p, b),
1194
+ del: (p) => call("DELETE", p)
1195
+ };
1196
+ }
1197
+
1198
+ // src/env-compose.ts
1199
+ import { join as join7 } from "path";
1200
+ function composeServerEnv(opts) {
1201
+ const dataDir = join7(opts.appDir, "data");
1202
+ return {
1203
+ NODE_ENV: "production",
1204
+ PORT: String(opts.port),
1205
+ DATABASE_URL: opts.databaseUrl,
1206
+ STORAGE_BACKEND: "filesystem",
1207
+ STORAGE_ROOT: opts.storageRoot,
1208
+ WEB_DIST: "web",
1209
+ PUBLIC_BASE_URL: opts.publicBaseUrl?.replace(/\/$/, "") || `http://localhost:${opts.port}`,
1210
+ VISION_URL_SIGNING_SECRET: opts.visionSecret,
1211
+ // Runtime file-read overrides → the artifact's own data/ tree.
1212
+ CABANE_API_ROOT: join7(dataDir, "api"),
1213
+ CABANE_SKILLS_DIR: join7(dataDir, "skills"),
1214
+ CABANE_DOCS_CONTENT_DIR: join7(dataDir, "docs-content"),
1215
+ CABANE_WELCOME_DIR: join7(dataDir, "welcome"),
1216
+ ...opts.houseOwnerUserId ? { CABANE_HOUSE_OWNER_USER_ID: opts.houseOwnerUserId } : {},
1217
+ ...opts.appVersion ? { CABANE_APP_VERSION: opts.appVersion } : {}
1218
+ };
1219
+ }
1220
+ function composeBridgeEnv(opts) {
1221
+ return {
1222
+ ...opts.persistedEnv,
1223
+ ...opts.parentEnv,
1224
+ CABANE_BRIDGE_HOME: opts.bridgeHome,
1225
+ CLAUDE_CONFIG_DIR: opts.claudeConfigDir,
1226
+ BRIDGE_NO_OPEN: "1",
1227
+ CABANE_BRIDGE_CLASS: "house"
1228
+ };
1229
+ }
1230
+ function localBaseUrl(port) {
1231
+ return `http://localhost:${port}`;
1232
+ }
1233
+ function embeddedDatabaseUrl(port) {
1234
+ return `postgres://cabane:cabane@127.0.0.1:${port}/cabane`;
1235
+ }
1236
+
1237
+ // src/supervisor/house-bridge.ts
1238
+ import { spawn as spawn2 } from "child_process";
1239
+ import { mkdirSync as mkdirSync4, openSync, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync3 } from "fs";
1240
+ import { dirname as dirname3, join as join8 } from "path";
1241
+ var PAIRING_VERSION = 1;
1242
+ function resolveBridgeBin(appDir) {
1243
+ const pkgPath = join8(appDir, "bridge", "package.json");
1244
+ const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
1245
+ const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["cabane-bridge"];
1246
+ if (!rel) {
1247
+ throw new Error(
1248
+ `the installed artifact's bridge (${pkgPath}) exposes no cabane-bridge bin \u2014 reinstall.`
1249
+ );
1250
+ }
1251
+ return join8(dirname3(pkgPath), rel);
1252
+ }
1253
+ function encodePairingString(input) {
1254
+ const payload = {
1255
+ v: PAIRING_VERSION,
1256
+ baseUrl: input.baseUrl,
1257
+ deviceToken: input.deviceToken,
1258
+ ...input.deviceId ? { deviceId: input.deviceId } : {},
1259
+ ...input.deviceLabel ? { deviceLabel: input.deviceLabel } : {}
1260
+ };
1261
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
1262
+ }
1263
+ async function pairBridge(opts) {
1264
+ mkdirSync4(opts.bridgeHome, { recursive: true });
1265
+ const pairingString = encodePairingString(opts);
1266
+ const tmp = join8(opts.bridgeHome, `.pairing-${process.pid}.txt`);
1267
+ writeFileSync3(tmp, pairingString, { mode: 384 });
1268
+ try {
1269
+ await run("node", [resolveBridgeBin(opts.appDir), "pair", "--file", tmp], {
1270
+ env: {
1271
+ PATH: process.env.PATH ?? "",
1272
+ ...process.env.HOME ? { HOME: process.env.HOME } : {},
1273
+ CABANE_BRIDGE_HOME: opts.bridgeHome
1274
+ }
1275
+ });
1276
+ } finally {
1277
+ rmSync5(tmp, { force: true });
1278
+ }
1279
+ ok("bridge paired");
1280
+ }
1281
+ function bridgeConfigPath(bridgeHome) {
1282
+ return join8(bridgeHome, ".cabane", "config.json");
1283
+ }
1284
+ function readBridgeConfig(bridgeHome) {
1285
+ try {
1286
+ return JSON.parse(readFileSync6(bridgeConfigPath(bridgeHome), "utf8"));
1287
+ } catch {
1288
+ return null;
1289
+ }
1290
+ }
1291
+ var BridgeChild = class {
1292
+ constructor(appDir, bridgeHome, claudeConfigDir, logFile, persistedEnv) {
1293
+ this.appDir = appDir;
1294
+ this.bridgeHome = bridgeHome;
1295
+ this.claudeConfigDir = claudeConfigDir;
1296
+ this.logFile = logFile;
1297
+ this.persistedEnv = persistedEnv;
1298
+ }
1299
+ appDir;
1300
+ bridgeHome;
1301
+ claudeConfigDir;
1302
+ logFile;
1303
+ persistedEnv;
1304
+ child = null;
1305
+ /** Spawn `cabane-bridge start --no-open` with the forwarded shell env under the
1306
+ * house-class overrides (CT480). */
1307
+ start() {
1308
+ const out = openSync(this.logFile, "a");
1309
+ this.child = spawn2("node", [resolveBridgeBin(this.appDir), "start", "--no-open"], {
1310
+ env: composeBridgeEnv({
1311
+ parentEnv: process.env,
1312
+ bridgeHome: this.bridgeHome,
1313
+ claudeConfigDir: this.claudeConfigDir,
1314
+ persistedEnv: this.persistedEnv
1315
+ }),
1316
+ stdio: ["ignore", out, out]
1317
+ });
1318
+ this.child.on("exit", (code, signal) => {
1319
+ if (code && code !== 0)
1320
+ warn(`bridge exited (code ${code}${signal ? `, ${signal}` : ""})`);
1321
+ this.child = null;
1322
+ });
1323
+ ok("house bridge started");
1324
+ }
1325
+ get running() {
1326
+ return this.child !== null;
1327
+ }
1328
+ get pid() {
1329
+ return this.child?.pid;
1330
+ }
1331
+ async stop() {
1332
+ const child = this.child;
1333
+ if (!child) return;
1334
+ this.child = null;
1335
+ await new Promise((resolve2) => {
1336
+ const kill9 = setTimeout(() => child.kill("SIGKILL"), 5e3);
1337
+ child.once("exit", () => {
1338
+ clearTimeout(kill9);
1339
+ resolve2();
1340
+ });
1341
+ child.kill("SIGTERM");
1342
+ });
1343
+ }
1344
+ };
1345
+
1346
+ // src/commands/up.ts
1347
+ import { spawn as spawn4 } from "child_process";
1348
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, openSync as openSync3, readFileSync as readFileSync7, rmSync as rmSync6, writeFileSync as writeFileSync4 } from "fs";
1349
+ import { join as join10 } from "path";
1350
+
1351
+ // src/supervisor/postgres.ts
1352
+ import { existsSync as existsSync8 } from "fs";
1353
+ import { join as join9 } from "path";
1354
+ import EmbeddedPostgres from "embedded-postgres";
1355
+ var EmbeddedPg = class {
1356
+ constructor(dataDir, port) {
1357
+ this.dataDir = dataDir;
1358
+ this.port = port;
1359
+ this.databaseUrl = embeddedDatabaseUrl(port);
1360
+ }
1361
+ dataDir;
1362
+ port;
1363
+ pg = null;
1364
+ databaseUrl;
1365
+ make() {
1366
+ return new EmbeddedPostgres({
1367
+ databaseDir: this.dataDir,
1368
+ user: "cabane",
1369
+ password: "cabane",
1370
+ port: this.port,
1371
+ persistent: true,
1372
+ // keep the data dir across restarts (the laptop reality)
1373
+ onLog: () => {
1374
+ },
1375
+ // quiet — pg writes its own logs under the data dir
1376
+ onError: (err) => warn(`[pg] ${err instanceof Error ? err.message : String(err)}`)
1377
+ });
1378
+ }
1379
+ initialised() {
1380
+ return existsSync8(join9(this.dataDir, "PG_VERSION"));
1381
+ }
1382
+ /** Init (first run) + start the postmaster + ensure the app database exists. */
1383
+ async start() {
1384
+ this.pg = this.make();
1385
+ if (!this.initialised()) {
1386
+ info("initialising Postgres data dir (first run)\u2026");
1387
+ await this.pg.initialise();
1388
+ }
1389
+ await this.pg.start();
1390
+ try {
1391
+ await this.pg.createDatabase("cabane");
1392
+ } catch {
1393
+ }
1394
+ ok(`Postgres up on 127.0.0.1:${this.port}`);
1395
+ }
1396
+ /** Clean shutdown (pg_ctl stop) — no orphaned postmaster. Idempotent. */
1397
+ async stop() {
1398
+ const pg = this.pg ?? this.make();
1399
+ try {
1400
+ await pg.stop();
1401
+ } catch {
1402
+ }
1403
+ this.pg = null;
1404
+ }
1405
+ };
1406
+
1407
+ // src/supervisor/server.ts
1408
+ import { openSync as openSync2 } from "fs";
1409
+ import { spawn as spawn3 } from "child_process";
1410
+ var ServerChild = class {
1411
+ constructor(appDir, env, port, logFile) {
1412
+ this.appDir = appDir;
1413
+ this.env = env;
1414
+ this.port = port;
1415
+ this.logFile = logFile;
1416
+ }
1417
+ appDir;
1418
+ env;
1419
+ port;
1420
+ logFile;
1421
+ child = null;
1422
+ childEnv() {
1423
+ return {
1424
+ PATH: process.env.PATH ?? "",
1425
+ HOME: process.env.HOME ?? "",
1426
+ ...process.env.TZ ? { TZ: process.env.TZ } : {},
1427
+ ...this.env
1428
+ };
1429
+ }
1430
+ /** Apply the shipped migrations (idempotent). Runs the artifact's standalone
1431
+ * `migrate.mjs` against DATABASE_URL. */
1432
+ async migrate() {
1433
+ info("applying migrations\u2026");
1434
+ await run("node", ["migrate.mjs"], {
1435
+ cwd: this.appDir,
1436
+ env: { PATH: process.env.PATH ?? "", DATABASE_URL: this.env.DATABASE_URL },
1437
+ stdio: ["ignore", "inherit", "inherit"]
1438
+ });
1439
+ }
1440
+ /** Spawn `node server.js` and wait for /healthz. */
1441
+ async start() {
1442
+ const out = openSync2(this.logFile, "a");
1443
+ this.child = spawn3("node", ["server.js"], {
1444
+ cwd: this.appDir,
1445
+ env: this.childEnv(),
1446
+ stdio: ["ignore", out, out]
1447
+ });
1448
+ this.child.on("exit", (code, signal) => {
1449
+ if (code && code !== 0)
1450
+ warn(`server exited (code ${code}${signal ? `, ${signal}` : ""})`);
1451
+ this.child = null;
1452
+ });
1453
+ const healthy3 = await waitUntil(() => this.healthy(), { timeoutMs: 45e3, intervalMs: 500 });
1454
+ if (!healthy3) {
1455
+ throw new Error(
1456
+ `server did not become healthy on :${this.port} within 45s \u2014 see ${this.logFile}`
1457
+ );
1458
+ }
1459
+ ok(`server up on http://localhost:${this.port}`);
1460
+ }
1461
+ async healthy() {
1462
+ try {
1463
+ const res = await fetch(`http://127.0.0.1:${this.port}/healthz`);
1464
+ return res.ok;
1465
+ } catch {
1466
+ return false;
1467
+ }
1468
+ }
1469
+ get pid() {
1470
+ return this.child?.pid;
1471
+ }
1472
+ /** SIGTERM, then SIGKILL after a grace period. */
1473
+ async stop() {
1474
+ const child = this.child;
1475
+ if (!child) return;
1476
+ this.child = null;
1477
+ await new Promise((resolve2) => {
1478
+ const kill9 = setTimeout(() => child.kill("SIGKILL"), 5e3);
1479
+ child.once("exit", () => {
1480
+ clearTimeout(kill9);
1481
+ resolve2();
1482
+ });
1483
+ child.kill("SIGTERM");
1484
+ });
1485
+ }
1486
+ };
1487
+
1488
+ // src/commands/up.ts
1489
+ async function up(opts) {
1490
+ const config = loadConfig();
1491
+ const paths = pathsFor(config);
1492
+ if (opts.port) config.port = opts.port;
1493
+ if (!config.currentVersion) {
1494
+ throw new Error("nothing installed yet \u2014 run `cabane install` first.");
1495
+ }
1496
+ const appDir = appDirFor(paths, config.currentVersion);
1497
+ if (!isInstalled(appDir)) {
1498
+ throw new Error(`install ${config.currentVersion} looks incomplete at ${appDir} \u2014 reinstall.`);
1499
+ }
1500
+ if (!config.visionSecret) {
1501
+ throw new Error("config has no vision secret \u2014 reinstall to generate one.");
1502
+ }
1503
+ if (await portInUse(config.port)) {
1504
+ throw new Error(
1505
+ `port ${config.port} is already in use. If cabane is running, use \`cabane down\`; otherwise a stale process is holding it \u2014 free it and retry.`
1506
+ );
1507
+ }
1508
+ if (opts.daemon) {
1509
+ await launchDaemon(paths, config);
1510
+ return;
1511
+ }
1512
+ await new Supervisor(paths, appDir, config.port).run();
1513
+ }
1514
+ async function launchDaemon(paths, config) {
1515
+ if (existsSync9(paths.supervisorPidFile)) {
1516
+ const pid = Number(readFileSync7(paths.supervisorPidFile, "utf8").trim());
1517
+ if (pid && pidAlive(pid)) {
1518
+ warn(`already running (supervisor pid ${pid}). Use \`cabane down\` to stop it.`);
1519
+ return;
1520
+ }
1521
+ }
1522
+ mkdirSync5(paths.logsDir, { recursive: true });
1523
+ const entry = process.argv[1];
1524
+ if (!entry) throw new Error("cannot locate the cabane CLI entry to run in the background.");
1525
+ const out = openSync3(paths.serverLog, "a");
1526
+ const child = spawn4(process.execPath, [entry, "up", "--port", String(config.port)], {
1527
+ detached: true,
1528
+ stdio: ["ignore", out, out]
1529
+ });
1530
+ child.unref();
1531
+ step("starting in the background\u2026");
1532
+ const healthy3 = await waitUntil(
1533
+ async () => {
1534
+ try {
1535
+ const res = await fetch(`http://127.0.0.1:${config.port}/healthz`);
1536
+ return res.ok;
1537
+ } catch {
1538
+ return false;
1539
+ }
1540
+ },
1541
+ { timeoutMs: 6e4, intervalMs: 1e3 }
1542
+ );
1543
+ if (!healthy3) {
1544
+ throw new Error(`background start did not come healthy within 60s \u2014 see ${paths.serverLog}`);
1545
+ }
1546
+ ok(`running in the background on http://localhost:${config.port}`);
1547
+ for (const line of daemonFooter(config)) info(line);
1548
+ }
1549
+ function daemonFooter(config) {
1550
+ const lines = [];
1551
+ if (!config.houseOwnerUserId) {
1552
+ lines.push("next: open the URL, sign up, then `cabane setup-agent`");
1553
+ }
1554
+ lines.push("logs: cabane logs \xB7 stop: cabane down");
1555
+ return lines;
1556
+ }
1557
+ var Supervisor = class {
1558
+ constructor(paths, appDir, port) {
1559
+ this.paths = paths;
1560
+ this.appDir = appDir;
1561
+ this.port = port;
1562
+ this.pg = new EmbeddedPg(paths.pgDataDir, loadConfig().pgPort);
1563
+ }
1564
+ paths;
1565
+ appDir;
1566
+ port;
1567
+ pg;
1568
+ server = null;
1569
+ bridge = null;
1570
+ shuttingDown = false;
1571
+ buildServer() {
1572
+ const config = loadConfig();
1573
+ const env = composeServerEnv({
1574
+ appDir: this.appDir,
1575
+ port: this.port,
1576
+ databaseUrl: this.pg.databaseUrl,
1577
+ storageRoot: this.paths.storageRoot,
1578
+ visionSecret: config.visionSecret,
1579
+ ...config.publicUrl ? { publicBaseUrl: config.publicUrl } : {},
1580
+ ...config.houseOwnerUserId ? { houseOwnerUserId: config.houseOwnerUserId } : {},
1581
+ // CT484: the artifact's build version → the server reports it on the bridge
1582
+ // heartbeat so a bridge/server skew is detectable. Read from the installed
1583
+ // tree's package.json (stamped at build); best-effort.
1584
+ ...(() => {
1585
+ const v = readAppVersion(this.appDir);
1586
+ return v ? { appVersion: v } : {};
1587
+ })()
1588
+ });
1589
+ return new ServerChild(this.appDir, env, this.port, this.paths.serverLog);
1590
+ }
1591
+ /** Start the bridge child iff it is configured (registered + paired) and not
1592
+ * already running. */
1593
+ ensureBridge() {
1594
+ if (this.bridge?.running) return;
1595
+ const config = loadConfig();
1596
+ const paired = readBridgeConfig(this.paths.bridgeHome)?.deviceId;
1597
+ if (!config.houseDeviceId || !paired) return;
1598
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR ?? `${process.env.HOME}/.claude`;
1599
+ this.bridge = new BridgeChild(
1600
+ this.appDir,
1601
+ this.paths.bridgeHome,
1602
+ claudeConfigDir,
1603
+ this.paths.bridgeLog,
1604
+ config.bridgeEnv
1605
+ );
1606
+ try {
1607
+ this.bridge.start();
1608
+ } catch (err) {
1609
+ this.bridge = null;
1610
+ warn(`house bridge could not start: ${err instanceof Error ? err.message : String(err)}`);
1611
+ info(
1612
+ "the server is up; agents will not answer until this is resolved (try `cabane update`)."
1613
+ );
1614
+ }
1615
+ }
1616
+ /** Publish the supervisor pid as early as possible so `status` / `down` /
1617
+ * `setup-agent` can find us the instant `up --daemon` returns (the daemon
1618
+ * launcher only waits on /healthz, which comes up before the children are all
1619
+ * wired). */
1620
+ writePid() {
1621
+ mkdirSync5(this.paths.runDir, { recursive: true });
1622
+ writeFileSync4(this.paths.supervisorPidFile, `${process.pid}
1623
+ `);
1624
+ }
1625
+ writeState() {
1626
+ mkdirSync5(this.paths.runDir, { recursive: true });
1627
+ writeFileSync4(this.paths.supervisorPidFile, `${process.pid}
1628
+ `);
1629
+ writeFileSync4(
1630
+ this.paths.stateFile,
1631
+ JSON.stringify(
1632
+ {
1633
+ supervisorPid: process.pid,
1634
+ serverPid: this.server?.pid ?? null,
1635
+ bridgePid: this.bridge?.pid ?? null,
1636
+ port: this.port,
1637
+ // Bumped on every (re)write — a completed reload advances it, which is
1638
+ // how `setup-agent` knows a restart it requested actually finished
1639
+ // (rather than trusting /healthz, which the still-up old server answers).
1640
+ updatedAt: Date.now()
1641
+ },
1642
+ null,
1643
+ 2
1644
+ ) + "\n"
1645
+ );
1646
+ }
1647
+ async run() {
1648
+ mkdirSync5(this.paths.logsDir, { recursive: true });
1649
+ mkdirSync5(this.paths.storageRoot, { recursive: true });
1650
+ this.writePid();
1651
+ step("starting embedded Postgres\u2026");
1652
+ await this.pg.start();
1653
+ this.server = this.buildServer();
1654
+ step("starting server\u2026");
1655
+ await this.server.migrate();
1656
+ await this.server.start();
1657
+ this.ensureBridge();
1658
+ this.writeState();
1659
+ process.on("SIGINT", () => void this.teardown("SIGINT"));
1660
+ process.on("SIGTERM", () => void this.teardown("SIGTERM"));
1661
+ process.on("exit", () => {
1662
+ for (const pid of [this.bridge?.pid, this.server?.pid]) {
1663
+ if (pid) {
1664
+ try {
1665
+ process.kill(pid, "SIGKILL");
1666
+ } catch {
1667
+ }
1668
+ }
1669
+ }
1670
+ });
1671
+ process.on(
1672
+ "uncaughtException",
1673
+ (err) => warn(`ignoring uncaught error: ${err instanceof Error ? err.message : String(err)}`)
1674
+ );
1675
+ process.on(
1676
+ "unhandledRejection",
1677
+ (err) => warn(`ignoring unhandled rejection: ${err instanceof Error ? err.message : String(err)}`)
1678
+ );
1679
+ ok("cabane is up \u2014 Ctrl-C to stop");
1680
+ await new Promise(() => {
1681
+ });
1682
+ }
1683
+ async teardown(signal) {
1684
+ if (this.shuttingDown) return;
1685
+ this.shuttingDown = true;
1686
+ step(`${signal} \u2014 stopping children\u2026`);
1687
+ await this.bridge?.stop();
1688
+ await this.server?.stop();
1689
+ await this.pg.stop();
1690
+ rmSync6(this.paths.supervisorPidFile, { force: true });
1691
+ rmSync6(this.paths.stateFile, { force: true });
1692
+ ok("stopped cleanly");
1693
+ process.exit(0);
1694
+ }
1695
+ };
1696
+ function readAppVersion(appDir) {
1697
+ try {
1698
+ const pkg = JSON.parse(readFileSync7(join10(appDir, "package.json"), "utf8"));
1699
+ return pkg.version ?? null;
1700
+ } catch {
1701
+ return null;
1702
+ }
1703
+ }
1704
+
1705
+ // src/commands/setup-agent.ts
1706
+ var DEFAULT_AGENT_MODEL = "anthropic/claude-opus-4-8";
1707
+ var DEVICE_LABEL = "cabane-house-bridge";
1708
+ async function setupAgent(opts) {
1709
+ const paths = resolvePaths();
1710
+ const config = loadConfig();
1711
+ const base = localBaseUrl(config.port);
1712
+ const model = opts.model ?? DEFAULT_AGENT_MODEL;
1713
+ if (!await healthy(config.port)) {
1714
+ throw new Error(`server is not up on ${base} \u2014 run \`cabane up\` and sign up first.`);
1715
+ }
1716
+ if (!existsSync10(paths.supervisorPidFile)) {
1717
+ throw new Error("no running supervisor found \u2014 start the stack with `cabane up` first.");
1718
+ }
1719
+ const restart = async () => {
1720
+ await down();
1721
+ await up({ daemon: true, port: config.port });
1722
+ };
1723
+ const token = await resolvePat(opts, base);
1724
+ const client = makeClient(base, token);
1725
+ const me = await client.get("/api/auth/me");
1726
+ const ownerId = me.user.id;
1727
+ ok(`owner resolved (${ownerId})`);
1728
+ if (config.houseOwnerUserId !== ownerId) {
1729
+ config.houseOwnerUserId = ownerId;
1730
+ saveConfig(config);
1731
+ step("applying ops identity to the server (restart)\u2026");
1732
+ await restart();
1733
+ }
1734
+ const appDir = appDirFor(paths, config.currentVersion ?? "");
1735
+ const deviceId = await ensureHouseDevice(client, config, appDir, paths.bridgeHome, base);
1736
+ await assignDefaultAgent(client, deviceId, model);
1737
+ step("starting the house bridge + assigning agents (restart)\u2026");
1738
+ await restart();
1739
+ const beating = await waitForHeartbeat(client, deviceId);
1740
+ if (beating) ok("house bridge heartbeating \u2014 agents are live");
1741
+ else warn("no bridge heartbeat yet \u2014 check `cabane logs`; it may need a minute");
1742
+ ok("setup complete");
1743
+ info("message your agent in the app \u2014 it should answer.");
1744
+ }
1745
+ async function healthy(port) {
1746
+ try {
1747
+ return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok;
1748
+ } catch {
1749
+ return false;
1750
+ }
1751
+ }
1752
+ async function resolvePat(opts, base) {
1753
+ if (opts.token) return opts.token.trim();
1754
+ if (isInteractive()) {
1755
+ info(`mint a token in the app: ${base} \u2192 Settings \u2192 Developer \u2192 tokens`);
1756
+ const t = await promptSecret("Paste a Cabane access token (cab_\u2026): ");
1757
+ if (t) return t;
1758
+ }
1759
+ throw new Error("no access token. Pass --token <cab_\u2026> (mint it in Settings \u2192 Developer).");
1760
+ }
1761
+ async function ensureHouseDevice(client, config, appDir, bridgeHome, base) {
1762
+ step("registering the house device\u2026");
1763
+ let deviceId;
1764
+ let deviceToken;
1765
+ try {
1766
+ const res = await client.post(
1767
+ "/api/bridge-devices",
1768
+ { label: DEVICE_LABEL, class: "house" }
1769
+ );
1770
+ deviceId = res.device.id;
1771
+ deviceToken = res.deviceToken;
1772
+ } catch (err) {
1773
+ if (err instanceof ApiError && err.status === 409 && config.houseDeviceId) {
1774
+ info(`reusing already-registered house device ${config.houseDeviceId}`);
1775
+ return config.houseDeviceId;
1776
+ }
1777
+ throw err;
1778
+ }
1779
+ config.houseDeviceId = deviceId;
1780
+ saveConfig(config);
1781
+ await pairBridge({
1782
+ appDir,
1783
+ bridgeHome,
1784
+ baseUrl: base,
1785
+ deviceToken,
1786
+ deviceId,
1787
+ deviceLabel: DEVICE_LABEL
1788
+ });
1789
+ return deviceId;
1790
+ }
1791
+ async function assignDefaultAgent(client, deviceId, model) {
1792
+ const { workspaces } = await client.get("/api/workspaces");
1793
+ const workspace = workspaces.find((w) => w.role === "owner") ?? workspaces[0];
1794
+ if (!workspace) throw new Error("the account owns no workspace \u2014 create one in the app first.");
1795
+ const wsPath = `/api/workspaces/${workspace.id}`;
1796
+ const { agents } = await client.get(
1797
+ `${wsPath}/agents`
1798
+ );
1799
+ const agent = workspace.defaultAgentId && agents.find((a) => a.id === workspace.defaultAgentId) || agents.find((a) => a.username === "cabane") || agents[0];
1800
+ if (!agent) throw new Error("the workspace has no agent to assign.");
1801
+ const { runConfig } = await client.get(
1802
+ `${wsPath}/agents/${agent.id}/run-config`
1803
+ );
1804
+ if (!runConfig?.model) {
1805
+ info(`setting @${agent.username} model \u2192 ${model}`);
1806
+ await client.put(`${wsPath}/agents/${agent.id}/run-config`, { ...runConfig ?? {}, model });
1807
+ }
1808
+ info(`assigning @${agent.username} \u2192 the house bridge`);
1809
+ await client.put(`${wsPath}/agents/${agent.id}/executor`, { executor: "bridge", deviceId });
1810
+ }
1811
+ async function waitForHeartbeat(client, deviceId) {
1812
+ return waitUntil(
1813
+ async () => {
1814
+ const { devices } = await client.get(
1815
+ "/api/bridge-devices"
1816
+ );
1817
+ return Boolean(devices.find((d) => d.id === deviceId)?.lastSeenAt);
1818
+ },
1819
+ { timeoutMs: 9e4, intervalMs: 3e3 }
1820
+ );
1821
+ }
1822
+
1823
+ // src/commands/status.ts
1824
+ import { existsSync as existsSync11, readFileSync as readFileSync8 } from "fs";
1825
+ import { join as join11 } from "path";
1826
+ function readBridgeCrash(bridgeHome) {
1827
+ try {
1828
+ const path = join11(bridgeHome, ".cabane", "last-error.json");
1829
+ if (!existsSync11(path)) return null;
1830
+ return JSON.parse(readFileSync8(path, "utf8"));
1831
+ } catch {
1832
+ return null;
1833
+ }
1834
+ }
1835
+ async function status() {
1836
+ const config = loadConfig();
1837
+ const paths = pathsFor(config);
1838
+ const state = readState2(paths.stateFile);
1839
+ const supervisorPid = readSupervisorPid(paths.supervisorPidFile) ?? state.supervisorPid;
1840
+ const supervisorUp = supervisorPid ? pidAlive(supervisorPid) : false;
1841
+ const serverUp = await healthy2(config.port);
1842
+ const bridgeUp = state.bridgePid ? pidAlive(state.bridgePid) : false;
1843
+ const line = (label, value) => {
1844
+ process.stdout.write(` ${label.padEnd(12)}${value}
1845
+ `);
1846
+ };
1847
+ process.stdout.write("cabane\n");
1848
+ line("version", config.currentVersion ?? "(not installed)");
1849
+ if (config.previousVersion) line("previous", config.previousVersion);
1850
+ line("port", String(config.port));
1851
+ line("home", paths.root);
1852
+ line("data", `${paths.dataDir} (pg + storage \xB7 kept across upgrades)`);
1853
+ line("supervisor", supervisorUp ? `up (pid ${supervisorPid})` : "down");
1854
+ line("server", serverUp ? `up \xB7 http://localhost:${config.port}` : "down");
1855
+ const crash = bridgeUp ? null : readBridgeCrash(paths.bridgeHome);
1856
+ line(
1857
+ "bridge",
1858
+ config.houseDeviceId ? bridgeUp ? "up" : crash ? `crashed \u2014 ${crash.reason}` : "configured \xB7 down" : "not set up (cabane setup-agent)"
1859
+ );
1860
+ if (!bridgeUp && crash) {
1861
+ info("see why: cabane logs --bridge");
1862
+ }
1863
+ if (!supervisorUp) info("start it: cabane up");
1864
+ }
1865
+ function readState2(file) {
1866
+ try {
1867
+ return JSON.parse(readFileSync8(file, "utf8"));
1868
+ } catch {
1869
+ return {};
1870
+ }
1871
+ }
1872
+ function readSupervisorPid(file) {
1873
+ try {
1874
+ const pid = Number(readFileSync8(file, "utf8").trim());
1875
+ return pid > 0 ? pid : null;
1876
+ } catch {
1877
+ return null;
1878
+ }
1879
+ }
1880
+ async function healthy2(port) {
1881
+ try {
1882
+ return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok;
1883
+ } catch {
1884
+ return false;
1885
+ }
1886
+ }
1887
+
1888
+ // src/commands/update.ts
1889
+ async function update(opts) {
1890
+ const config = loadConfig();
1891
+ const distBase = (opts.distBase ?? config.distBase).replace(/\/$/, "");
1892
+ const token = opts.token ?? config.distToken;
1893
+ if (!token)
1894
+ throw new Error("no distribution token stored \u2014 run `cabane install --token \u2026` first.");
1895
+ if (!config.currentVersion) throw new Error("nothing installed \u2014 run `cabane install` first.");
1896
+ step(`checking ${distBase} for updates\u2026`);
1897
+ const manifest = await fetchManifest(distBase, token, opts.version);
1898
+ if (!manifest) throw new Error("no artifact available from the distribution endpoint.");
1899
+ const target = manifest.version;
1900
+ if (target === config.currentVersion && !opts.version) {
1901
+ ok(`already up to date (${config.currentVersion})`);
1902
+ return;
1903
+ }
1904
+ if (target === config.currentVersion) {
1905
+ info(`version ${target} already installed`);
1906
+ } else {
1907
+ info(`updating ${config.currentVersion} \u2192 ${target}`);
1908
+ }
1909
+ await install({ token, distBase, version: target });
1910
+ ok(`installed ${target} (previous kept for rollback)`);
1911
+ info("restart to run it: cabane down && cabane up");
1912
+ }
1913
+
1914
+ // src/cli.ts
1915
+ var program = new Command();
1916
+ program.name("cabane").description("Run a self-hosted Cabane on your own machine \u2014 no docker, no repo.").version("0.1.0");
1917
+ var parsePort = (raw) => {
1918
+ const n = Number(raw);
1919
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) throw new Error(`invalid port: ${raw}`);
1920
+ return n;
1921
+ };
1922
+ program.command("init").description("interactive first-run wizard: data location, port, and the executor credential").action(init);
1923
+ program.command("install").description("install the server artifact (token \u2192 gated download, or --from a local tarball)").option("--token <token>", "distribution token (cabdist_\u2026) \u2014 prompted if omitted").option("--from <tarball>", "install from a local artifact tarball instead of downloading").option("--dist-base <url>", "distribution origin to fetch from (default https://app.cabane.ai)").option("--version <version>", "install a specific published version (default: latest)").option("--port <port>", "local port the server will listen on (default 3000)", parsePort).option("--force", "reinstall even if this version is already present").action(install);
1924
+ program.command("up").description("start + supervise the stack (Postgres, server, house bridge)").option("--daemon", "run in the background (terminal returns; stop with `cabane down`)").option("--port <port>", "override the configured port", parsePort).action(up);
1925
+ program.command("setup-agent").description("after browser signup: register the house bridge + put your agent on it").option("--token <token>", "a Cabane access token (cab_\u2026) \u2014 prompted if omitted").option("--model <model>", "model the agent runs (default anthropic/claude-opus-4-8)").action(setupAgent);
1926
+ program.command("update").description("install a newer published artifact (keeps the previous for rollback)").option("--token <token>", "distribution token (cabdist_\u2026) \u2014 defaults to the stored one").option("--dist-base <url>", "distribution origin to fetch from").option("--version <version>", "install a specific version instead of latest").action(update);
1927
+ program.command("set-env [pairs...]").description(
1928
+ "persist credentials forwarded to the house bridge \u2014 `KEY=VALUE`, or a bare `KEY` to be prompted for the value (kept off shell history)"
1929
+ ).option("--unset <keys...>", "remove one or more persisted keys").option("--list", "list persisted keys (values masked)").action(setEnv);
1930
+ program.command("down").description("stop a running stack").action(down);
1931
+ var bridge = program.command("bridge").description("house-bridge maintenance");
1932
+ bridge.command("reset").description("clear the house bridge\u2019s resume state (cursors + dispatched/completed dedupe)").option("--force", "clear even while the bridge is running (takes effect next boot)").action(bridgeReset);
1933
+ program.command("status").description("show what is installed + running").action(status);
1934
+ program.command("doctor").description("preflight: diagnose common self-host failure classes (runs with the stack down)").action(doctor);
1935
+ program.command("logs").description("tail the server + bridge logs").option("-n, --lines <n>", "how many lines to show (default 80)", (v) => Number(v)).option("-f, --follow", "follow the logs (Ctrl-C to stop)").option("--server", "only the server log").option("--bridge", "only the bridge log").action(logs);
1936
+ program.parseAsync(process.argv).catch((err) => {
1937
+ error(err instanceof Error ? err.message : String(err));
1938
+ process.exitCode = 1;
1939
+ });