bertrand 0.17.0 → 0.19.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/dist/bertrand.js CHANGED
@@ -23,22 +23,509 @@ var BERTRAND_DIR = ".bertrand", paths;
23
23
  var init_paths = __esm(() => {
24
24
  paths = {
25
25
  root: join(homedir(), BERTRAND_DIR),
26
- db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
27
26
  hooks: join(homedir(), BERTRAND_DIR, "hooks"),
28
- sessions: join(homedir(), BERTRAND_DIR, "sessions")
27
+ sessions: join(homedir(), BERTRAND_DIR, "sessions"),
28
+ db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
29
+ syncEnv: join(homedir(), BERTRAND_DIR, "sync.env")
30
+ };
31
+ });
32
+
33
+ // src/lib/projects/registry.ts
34
+ import {
35
+ existsSync,
36
+ mkdirSync,
37
+ readFileSync,
38
+ readdirSync,
39
+ renameSync,
40
+ statSync,
41
+ writeFileSync
42
+ } from "fs";
43
+ import { randomBytes } from "crypto";
44
+ import { join as join2 } from "path";
45
+ function _getRegistryDir() {
46
+ return _registryDir;
47
+ }
48
+ function registryPath() {
49
+ return join2(_registryDir, "projects.json");
50
+ }
51
+ function projectsDir() {
52
+ return join2(_registryDir, "projects");
53
+ }
54
+ function readRegistry() {
55
+ const path = registryPath();
56
+ if (!existsSync(path))
57
+ return null;
58
+ try {
59
+ const raw = readFileSync(path, "utf8");
60
+ const parsed = JSON.parse(raw);
61
+ if (!isRegistry(parsed))
62
+ return null;
63
+ return parsed;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+ function writeRegistry(registry) {
69
+ const path = registryPath();
70
+ mkdirSync(_registryDir, { recursive: true });
71
+ const tmp = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}-${randomBytes(4).toString("hex")}`;
72
+ writeFileSync(tmp, JSON.stringify(registry, null, 2) + `
73
+ `);
74
+ renameSync(tmp, path);
75
+ }
76
+ function recoverFromDisk() {
77
+ const dir = projectsDir();
78
+ if (!existsSync(dir))
79
+ return null;
80
+ let entries;
81
+ try {
82
+ entries = readdirSync(dir);
83
+ } catch {
84
+ return null;
85
+ }
86
+ const projects = [];
87
+ for (const slug of entries) {
88
+ const subdir = join2(dir, slug);
89
+ let mtime;
90
+ try {
91
+ const s = statSync(subdir);
92
+ if (!s.isDirectory())
93
+ continue;
94
+ mtime = new Date(s.mtimeMs);
95
+ } catch {
96
+ continue;
97
+ }
98
+ if (!existsSync(join2(subdir, "bertrand.db")))
99
+ continue;
100
+ projects.push({
101
+ slug,
102
+ name: slug,
103
+ createdAt: mtime.toISOString(),
104
+ lastUsedAt: mtime.toISOString()
105
+ });
106
+ }
107
+ if (projects.length === 0)
108
+ return null;
109
+ projects.sort((a, b) => a.slug.localeCompare(b.slug));
110
+ const envSlug = process.env.BERTRAND_PROJECT;
111
+ const hasEnv = !!envSlug && projects.some((p) => p.slug === envSlug);
112
+ const hasDefault = projects.some((p) => p.slug === DEFAULT_PROJECT_SLUG);
113
+ const activeProjectSlug = hasEnv ? envSlug : hasDefault ? DEFAULT_PROJECT_SLUG : projects[0].slug;
114
+ return { activeProjectSlug, projects };
115
+ }
116
+ function loadRegistry() {
117
+ return readRegistry() ?? recoverFromDisk();
118
+ }
119
+ function listProjects() {
120
+ return loadRegistry()?.projects ?? [];
121
+ }
122
+ function getActiveProjectSlug() {
123
+ return loadRegistry()?.activeProjectSlug ?? DEFAULT_PROJECT_SLUG;
124
+ }
125
+ function projectExists(slug) {
126
+ return listProjects().some((p) => p.slug === slug);
127
+ }
128
+ function setActiveProjectSlug(slug) {
129
+ const registry = loadRegistry();
130
+ if (!registry) {
131
+ throw new Error(`No registry to update \u2014 create a project first (no ${registryPath()} and no ${projectsDir()}/* to recover from)`);
132
+ }
133
+ if (!registry.projects.some((p) => p.slug === slug)) {
134
+ throw new Error(`Unknown project slug "${slug}"`);
135
+ }
136
+ registry.activeProjectSlug = slug;
137
+ const idx = registry.projects.findIndex((p) => p.slug === slug);
138
+ if (idx !== -1) {
139
+ registry.projects[idx].lastUsedAt = new Date().toISOString();
140
+ }
141
+ writeRegistry(registry);
142
+ }
143
+ function registerProject(opts) {
144
+ const registry = loadRegistry() ?? {
145
+ activeProjectSlug: opts.slug,
146
+ projects: []
147
+ };
148
+ if (registry.projects.some((p) => p.slug === opts.slug)) {
149
+ throw new Error(`Project "${opts.slug}" already exists`);
150
+ }
151
+ const now = new Date().toISOString();
152
+ const entry = {
153
+ slug: opts.slug,
154
+ name: opts.name,
155
+ color: opts.color,
156
+ createdAt: now,
157
+ lastUsedAt: now
158
+ };
159
+ registry.projects.push(entry);
160
+ writeRegistry(registry);
161
+ return entry;
162
+ }
163
+ function renameProject(slug, newName) {
164
+ const registry = loadRegistry();
165
+ if (!registry) {
166
+ throw new Error(`No registry to update \u2014 create a project first`);
167
+ }
168
+ const entry = registry.projects.find((p) => p.slug === slug);
169
+ if (!entry) {
170
+ throw new Error(`Unknown project slug "${slug}"`);
171
+ }
172
+ entry.name = newName;
173
+ writeRegistry(registry);
174
+ }
175
+ function removeProject(slug) {
176
+ const registry = loadRegistry();
177
+ if (!registry)
178
+ return;
179
+ registry.projects = registry.projects.filter((p) => p.slug !== slug);
180
+ if (registry.activeProjectSlug === slug) {
181
+ const survivor = [...registry.projects].sort((a, b) => b.lastUsedAt.localeCompare(a.lastUsedAt))[0];
182
+ registry.activeProjectSlug = survivor?.slug ?? DEFAULT_PROJECT_SLUG;
183
+ }
184
+ writeRegistry(registry);
185
+ }
186
+ function isRegistry(value) {
187
+ if (typeof value !== "object" || value === null)
188
+ return false;
189
+ const r = value;
190
+ if (typeof r.activeProjectSlug !== "string")
191
+ return false;
192
+ if (!Array.isArray(r.projects))
193
+ return false;
194
+ return r.projects.every((p) => {
195
+ if (typeof p !== "object" || p === null)
196
+ return false;
197
+ const e = p;
198
+ return typeof e.slug === "string" && typeof e.name === "string" && typeof e.createdAt === "string" && typeof e.lastUsedAt === "string";
199
+ });
200
+ }
201
+ var DEFAULT_PROJECT_SLUG = "default", _registryDir;
202
+ var init_registry = __esm(() => {
203
+ init_paths();
204
+ _registryDir = paths.root;
205
+ });
206
+
207
+ // src/lib/projects/paths.ts
208
+ import { join as join3 } from "path";
209
+ function projectPaths(slug) {
210
+ const root = join3(_getRegistryDir(), "projects", slug);
211
+ return {
212
+ root,
213
+ db: join3(root, "bertrand.db"),
214
+ syncEnv: join3(root, "sync.env"),
215
+ snapshots: join3(root, "snapshots")
29
216
  };
217
+ }
218
+ var init_paths2 = __esm(() => {
219
+ init_registry();
220
+ });
221
+
222
+ // src/lib/projects/resolve.ts
223
+ function resolveActiveProject() {
224
+ if (_cached)
225
+ return _cached;
226
+ const envSlug = process.env.BERTRAND_PROJECT;
227
+ let slug;
228
+ if (envSlug && envSlug.trim()) {
229
+ slug = envSlug.trim();
230
+ } else {
231
+ slug = getActiveProjectSlug();
232
+ }
233
+ const entry = listProjects().find((p) => p.slug === slug);
234
+ const name = entry?.name ?? slug;
235
+ _cached = { slug, name, ...projectPaths(slug) };
236
+ return _cached;
237
+ }
238
+ function _resetActiveProjectCache() {
239
+ _cached = null;
240
+ }
241
+ var _cached = null;
242
+ var init_resolve = __esm(() => {
243
+ init_registry();
244
+ init_paths2();
245
+ });
246
+
247
+ // src/lib/lsof.ts
248
+ import { execFileSync } from "child_process";
249
+ function findHolders(path) {
250
+ try {
251
+ const out = execFileSync("lsof", ["-F", "pcn", path], {
252
+ encoding: "utf8",
253
+ stdio: ["ignore", "pipe", "ignore"]
254
+ });
255
+ const holders = [];
256
+ let pid = 0;
257
+ let command = "";
258
+ for (const line of out.split(`
259
+ `)) {
260
+ if (line.startsWith("p"))
261
+ pid = Number(line.slice(1));
262
+ else if (line.startsWith("c"))
263
+ command = line.slice(1);
264
+ else if (line.startsWith("n")) {
265
+ if (pid && pid !== process.pid && command) {
266
+ holders.push({ pid, command });
267
+ }
268
+ }
269
+ }
270
+ return holders;
271
+ } catch {
272
+ return [];
273
+ }
274
+ }
275
+ var init_lsof = () => {};
276
+
277
+ // src/lib/projects/migrate-layout.ts
278
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync as renameSync2 } from "fs";
279
+ import { join as join4 } from "path";
280
+ function migrateLegacyLayout() {
281
+ const root = _getRegistryDir();
282
+ const registryFile = join4(root, "projects.json");
283
+ const projectsDir2 = join4(root, "projects");
284
+ if (existsSync2(registryFile)) {
285
+ return { migrated: false, reason: "already-migrated" };
286
+ }
287
+ if (existsSync2(projectsDir2)) {
288
+ return { migrated: false, reason: "already-migrated" };
289
+ }
290
+ const legacyDb = join4(root, "bertrand.db");
291
+ const legacySyncEnv = join4(root, "sync.env");
292
+ if (!existsSync2(legacyDb) && !existsSync2(legacySyncEnv)) {
293
+ return { migrated: false, reason: "fresh-install" };
294
+ }
295
+ if (existsSync2(legacyDb)) {
296
+ const holders = findHolders(legacyDb);
297
+ if (holders.length > 0) {
298
+ return { migrated: false, reason: "db-held", holders };
299
+ }
300
+ }
301
+ const destPaths = projectPaths(DEFAULT_PROJECT_SLUG);
302
+ mkdirSync2(destPaths.root, { recursive: true });
303
+ const moved = [];
304
+ for (const suffix of ["", "-wal", "-shm"]) {
305
+ const src = legacyDb + suffix;
306
+ if (!existsSync2(src))
307
+ continue;
308
+ const dst = destPaths.db + suffix;
309
+ renameSync2(src, dst);
310
+ moved.push(`bertrand.db${suffix}`);
311
+ }
312
+ if (existsSync2(legacySyncEnv)) {
313
+ renameSync2(legacySyncEnv, destPaths.syncEnv);
314
+ moved.push("sync.env");
315
+ }
316
+ const now = new Date().toISOString();
317
+ writeRegistry({
318
+ activeProjectSlug: DEFAULT_PROJECT_SLUG,
319
+ projects: [
320
+ {
321
+ slug: DEFAULT_PROJECT_SLUG,
322
+ name: "Default",
323
+ createdAt: now,
324
+ lastUsedAt: now
325
+ }
326
+ ]
327
+ });
328
+ _resetActiveProjectCache();
329
+ return { migrated: true, moved };
330
+ }
331
+ var init_migrate_layout = __esm(() => {
332
+ init_lsof();
333
+ init_registry();
334
+ init_paths2();
335
+ init_resolve();
336
+ });
337
+
338
+ // src/sync/config.ts
339
+ import {
340
+ readFileSync as readFileSync2,
341
+ statSync as statSync2,
342
+ writeFileSync as writeFileSync2,
343
+ chmodSync,
344
+ existsSync as existsSync3
345
+ } from "fs";
346
+ function parseEnv(contents) {
347
+ const out = {};
348
+ for (const rawLine of contents.split(/\r?\n/)) {
349
+ const line = rawLine.trim();
350
+ if (!line || line.startsWith("#"))
351
+ continue;
352
+ const eq = line.indexOf("=");
353
+ if (eq === -1)
354
+ continue;
355
+ const key = line.slice(0, eq).trim();
356
+ if (!KEYS.includes(key))
357
+ continue;
358
+ let value = line.slice(eq + 1).trim();
359
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
360
+ value = value.slice(1, -1);
361
+ }
362
+ out[key] = value;
363
+ }
364
+ return out;
365
+ }
366
+ function defaultPath() {
367
+ return resolveActiveProject().syncEnv;
368
+ }
369
+ function hasSyncConfig(syncEnvPath) {
370
+ return existsSync3(syncEnvPath ?? defaultPath());
371
+ }
372
+ function loadSyncConfig(syncEnvPath) {
373
+ const path = syncEnvPath ?? defaultPath();
374
+ if (!existsSync3(path))
375
+ return null;
376
+ const mode = statSync2(path).mode & 511;
377
+ if (mode & 63) {
378
+ console.warn(`warning: ${path} was mode 0${mode.toString(8)} (should be 0600). Tightening to 0600.`);
379
+ try {
380
+ chmodSync(path, 384);
381
+ } catch (e) {
382
+ console.warn(`failed to chmod 0600: ${e instanceof Error ? e.message : String(e)}. Fix manually.`);
383
+ }
384
+ }
385
+ const env = parseEnv(readFileSync2(path, "utf8"));
386
+ if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_KEY || !env.BERTRAND_SYNC_BUCKET || !env.BERTRAND_ENCRYPTION_KEY) {
387
+ return null;
388
+ }
389
+ return {
390
+ supabaseUrl: env.SUPABASE_URL,
391
+ supabaseServiceKey: env.SUPABASE_SERVICE_KEY,
392
+ bucket: env.BERTRAND_SYNC_BUCKET,
393
+ objectKey: env.BERTRAND_SYNC_OBJECT || "bertrand.db.enc",
394
+ encryptionKey: env.BERTRAND_ENCRYPTION_KEY,
395
+ clientName: env.BERTRAND_CLIENT_NAME || `bertrand-${process.platform}-${process.pid}`
396
+ };
397
+ }
398
+ function saveSyncConfig(cfg, syncEnvPath) {
399
+ const path = syncEnvPath ?? defaultPath();
400
+ const lines = [
401
+ "# bertrand sync configuration",
402
+ "# Created by `bertrand sync onboard`. chmod 600.",
403
+ "#",
404
+ "# SUPABASE_SERVICE_KEY has read+write access to your storage bucket.",
405
+ "# Treat it like an SSH private key. Do not commit. Do not share.",
406
+ "#",
407
+ "# BERTRAND_ENCRYPTION_KEY is the AES-256-GCM key used to encrypt the DB",
408
+ "# locally before upload. Without it the uploaded blob can't be decrypted.",
409
+ "# Use the SAME key on every machine that should be able to pull this DB.",
410
+ `SUPABASE_URL=${cfg.supabaseUrl}`,
411
+ `SUPABASE_SERVICE_KEY=${cfg.supabaseServiceKey}`,
412
+ `BERTRAND_SYNC_BUCKET=${cfg.bucket}`,
413
+ `BERTRAND_SYNC_OBJECT=${cfg.objectKey}`,
414
+ `BERTRAND_ENCRYPTION_KEY=${cfg.encryptionKey}`,
415
+ `BERTRAND_CLIENT_NAME=${cfg.clientName}`,
416
+ ""
417
+ ];
418
+ writeFileSync2(path, lines.join(`
419
+ `), { mode: 384 });
420
+ chmodSync(path, 384);
421
+ }
422
+ var KEYS;
423
+ var init_config = __esm(() => {
424
+ init_resolve();
425
+ KEYS = [
426
+ "SUPABASE_URL",
427
+ "SUPABASE_SERVICE_KEY",
428
+ "BERTRAND_SYNC_BUCKET",
429
+ "BERTRAND_SYNC_OBJECT",
430
+ "BERTRAND_ENCRYPTION_KEY",
431
+ "BERTRAND_CLIENT_NAME"
432
+ ];
433
+ });
434
+
435
+ // src/lib/config.ts
436
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
437
+ import { join as join5 } from "path";
438
+ function configPath() {
439
+ return join5(_getRegistryDir(), "config.json");
440
+ }
441
+ function readConfig() {
442
+ try {
443
+ return JSON.parse(readFileSync3(configPath(), "utf-8"));
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+ function writeConfig(config) {
449
+ mkdirSync3(_getRegistryDir(), { recursive: true });
450
+ writeFileSync3(configPath(), JSON.stringify(config, null, 2) + `
451
+ `);
452
+ }
453
+ function patchConfig(patch) {
454
+ const current = readConfig() ?? {};
455
+ const next = deepMerge(current, patch);
456
+ writeConfig(next);
457
+ return next;
458
+ }
459
+ function deepMerge(base, patch) {
460
+ const out = { ...base };
461
+ for (const [k, v] of Object.entries(patch)) {
462
+ const existing = out[k];
463
+ if (isPlainObject(existing) && isPlainObject(v)) {
464
+ out[k] = deepMerge(existing, v);
465
+ } else if (v !== undefined) {
466
+ out[k] = v;
467
+ }
468
+ }
469
+ return out;
470
+ }
471
+ function isPlainObject(v) {
472
+ return typeof v === "object" && v !== null && !Array.isArray(v);
473
+ }
474
+ function isSyncEnabled() {
475
+ return readConfig()?.sync?.enabled === true;
476
+ }
477
+ var init_config2 = __esm(() => {
478
+ init_registry();
479
+ });
480
+
481
+ // src/sync/trigger.ts
482
+ import { spawn } from "child_process";
483
+ function spawnSync(op) {
484
+ if (!isSyncEnabled() || !hasSyncConfig())
485
+ return;
486
+ const bin = process.argv[1] && process.argv[1].endsWith(".ts") ? process.execPath : "bertrand";
487
+ const args = process.argv[1] && process.argv[1].endsWith(".ts") ? ["run", process.argv[1], "sync", op] : ["sync", op];
488
+ const child = spawn(bin, args, {
489
+ detached: true,
490
+ stdio: "ignore"
491
+ });
492
+ child.unref();
493
+ }
494
+ function triggerBackgroundPull() {
495
+ spawnSync("pull");
496
+ }
497
+ function triggerBackgroundPush() {
498
+ spawnSync("push");
499
+ }
500
+ var init_trigger = __esm(() => {
501
+ init_config();
502
+ init_config2();
30
503
  });
31
504
 
32
505
  // src/cli/router.ts
33
- import { existsSync } from "fs";
506
+ import { existsSync as existsSync4 } from "fs";
34
507
  function register(name, handler) {
35
508
  commands.set(name, handler);
36
509
  }
37
510
  function alias(from, to) {
38
511
  aliases.set(from, to);
39
512
  }
513
+ function migrateOrAbort() {
514
+ const result = migrateLegacyLayout();
515
+ if (result.migrated) {
516
+ const fileList = result.moved.join(", ");
517
+ console.log(`Migrated to per-project layout (moved: ${fileList}). Active project: ${DEFAULT_PROJECT_SLUG}.`);
518
+ return;
519
+ }
520
+ if (!result.migrated && result.reason === "db-held") {
521
+ const procs = result.holders.map((h) => `${h.command}(${h.pid})`).join(", ");
522
+ console.error(`Cannot migrate to per-project layout: legacy database is held by ${procs}.
523
+ ` + `Close all active bertrand sessions and try again.`);
524
+ process.exit(1);
525
+ }
526
+ }
40
527
  async function autoInitIfFirstRun() {
41
- if (existsSync(paths.db))
528
+ if (existsSync4(resolveActiveProject().db))
42
529
  return;
43
530
  const init = commands.get("init");
44
531
  if (!init)
@@ -55,8 +542,12 @@ async function autoInitIfFirstRun() {
55
542
  async function route(argv) {
56
543
  const args = argv.slice(2);
57
544
  const command = args[0];
545
+ if (!command || !HOOK_COMMANDS.has(command)) {
546
+ migrateOrAbort();
547
+ }
58
548
  if (!command) {
59
549
  await autoInitIfFirstRun();
550
+ triggerBackgroundPull();
60
551
  const handler2 = commands.get("launch");
61
552
  if (!handler2)
62
553
  throw new Error("No launch command registered");
@@ -90,15 +581,28 @@ Usage:
90
581
  bertrand log <session> View session log
91
582
  bertrand stats <session> Session statistics
92
583
  bertrand archive <name> Archive/unarchive a session
584
+ bertrand project <op> list|create|switch|current|rename|remove (see: bertrand project --help)
93
585
  bertrand update Hook-facing state writer (internal)
94
586
  bertrand serve Start dashboard HTTP server
587
+ bertrand sync <op> push|pull|status|onboard (see: bertrand sync --help)
95
588
  `.trim());
96
589
  }
97
- var commands, aliases;
590
+ var commands, aliases, HOOK_COMMANDS;
98
591
  var init_router = __esm(() => {
99
- init_paths();
592
+ init_resolve();
593
+ init_migrate_layout();
594
+ init_registry();
595
+ init_trigger();
100
596
  commands = new Map;
101
597
  aliases = new Map;
598
+ HOOK_COMMANDS = new Set([
599
+ "update",
600
+ "snapshot",
601
+ "recap-thinking",
602
+ "assistant-message",
603
+ "notify",
604
+ "badge"
605
+ ]);
102
606
  });
103
607
 
104
608
  // src/db/schema.ts
@@ -109,17 +613,17 @@ __export(exports_schema, {
109
613
  sessionStats: () => sessionStats,
110
614
  sessionLabels: () => sessionLabels,
111
615
  labels: () => labels,
112
- groups: () => groups,
113
616
  events: () => events,
114
- conversations: () => conversations
617
+ conversations: () => conversations,
618
+ categories: () => categories
115
619
  });
116
620
  import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
117
621
  import { sql } from "drizzle-orm";
118
- var groups, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
622
+ var categories, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
119
623
  var init_schema = __esm(() => {
120
- groups = sqliteTable("groups", {
624
+ categories = sqliteTable("categories", {
121
625
  id: text("id").primaryKey(),
122
- parentId: text("parent_id").references(() => groups.id, {
626
+ parentId: text("parent_id").references(() => categories.id, {
123
627
  onDelete: "cascade"
124
628
  }),
125
629
  slug: text("slug").notNull(),
@@ -129,8 +633,8 @@ var init_schema = __esm(() => {
129
633
  color: text("color"),
130
634
  createdAt: text("created_at").notNull().default(sql`(datetime('now'))`)
131
635
  }, (t) => [
132
- uniqueIndex("groups_parent_slug").on(t.parentId, t.slug),
133
- index("groups_path").on(t.path)
636
+ uniqueIndex("categories_parent_slug").on(t.parentId, t.slug),
637
+ index("categories_path").on(t.path)
134
638
  ]);
135
639
  labels = sqliteTable("labels", {
136
640
  id: text("id").primaryKey(),
@@ -140,7 +644,7 @@ var init_schema = __esm(() => {
140
644
  });
141
645
  sessions = sqliteTable("sessions", {
142
646
  id: text("id").primaryKey(),
143
- groupId: text("group_id").notNull().references(() => groups.id, { onDelete: "cascade" }),
647
+ categoryId: text("category_id").notNull().references(() => categories.id, { onDelete: "cascade" }),
144
648
  slug: text("slug").notNull(),
145
649
  name: text("name").notNull(),
146
650
  status: text("status", {
@@ -153,7 +657,7 @@ var init_schema = __esm(() => {
153
657
  createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
154
658
  updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
155
659
  }, (t) => [
156
- uniqueIndex("sessions_group_slug").on(t.groupId, t.slug),
660
+ uniqueIndex("sessions_category_slug").on(t.categoryId, t.slug),
157
661
  index("sessions_status").on(t.status),
158
662
  index("sessions_started").on(t.startedAt)
159
663
  ]);
@@ -219,25 +723,61 @@ var init_schema = __esm(() => {
219
723
  // src/db/client.ts
220
724
  import { Database } from "bun:sqlite";
221
725
  import { drizzle } from "drizzle-orm/bun-sqlite";
222
- import { mkdirSync } from "fs";
726
+ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
727
+ import { mkdirSync as mkdirSync4 } from "fs";
223
728
  import { dirname } from "path";
224
729
  function getDb() {
225
- if (_db)
226
- return _db;
227
- mkdirSync(dirname(paths.db), { recursive: true });
228
- const sqlite = new Database(paths.db);
730
+ if (_testDb)
731
+ return _testDb;
732
+ return openDb(resolveActiveProject().db);
733
+ }
734
+ function getDbForProject(slug) {
735
+ if (_testDb)
736
+ return _testDb;
737
+ return openDb(projectPaths(slug).db);
738
+ }
739
+ function invalidateDbCache(slug) {
740
+ if (!slug) {
741
+ _cache.clear();
742
+ _migrated.clear();
743
+ return;
744
+ }
745
+ const dbPath = projectPaths(slug).db;
746
+ _cache.delete(dbPath);
747
+ _migrated.delete(dbPath);
748
+ }
749
+ function openDb(dbPath) {
750
+ const cached = _cache.get(dbPath);
751
+ if (cached)
752
+ return cached;
753
+ mkdirSync4(dirname(dbPath), { recursive: true });
754
+ const sqlite = new Database(dbPath);
229
755
  sqlite.exec("PRAGMA journal_mode = WAL");
230
756
  sqlite.exec("PRAGMA foreign_keys = ON");
231
757
  sqlite.exec("PRAGMA synchronous = NORMAL");
232
758
  sqlite.exec("PRAGMA cache_size = -8000");
233
759
  sqlite.exec("PRAGMA temp_store = MEMORY");
234
- _db = drizzle(sqlite, { schema: exports_schema });
235
- return _db;
760
+ const db = drizzle(sqlite, { schema: exports_schema });
761
+ if (!_migrated.has(dbPath)) {
762
+ try {
763
+ migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
764
+ } catch (err) {
765
+ sqlite.close();
766
+ throw err;
767
+ }
768
+ _migrated.add(dbPath);
769
+ }
770
+ _cache.set(dbPath, db);
771
+ return db;
236
772
  }
237
- var _db = null;
773
+ var MIGRATIONS_FOLDER, _cache, _migrated, _testDb = null;
238
774
  var init_client = __esm(() => {
239
- init_paths();
775
+ init_resolve();
776
+ init_paths2();
240
777
  init_schema();
778
+ MIGRATIONS_FOLDER = import.meta.dir + "/migrations";
779
+ _cache = new Map;
780
+ _migrated = new Set;
241
781
  });
242
782
 
243
783
  // src/lib/id.ts
@@ -257,18 +797,18 @@ function createSession(opts) {
257
797
  function getSession(id) {
258
798
  return getDb().select().from(sessions).where(eq(sessions.id, id)).get();
259
799
  }
260
- function getSessionByGroupSlug(groupId, slug) {
261
- return getDb().select().from(sessions).where(and(eq(sessions.groupId, groupId), eq(sessions.slug, slug))).get();
800
+ function getSessionByCategorySlug(categoryId, slug) {
801
+ return getDb().select().from(sessions).where(and(eq(sessions.categoryId, categoryId), eq(sessions.slug, slug))).get();
262
802
  }
263
- function getSessionsByGroup(groupId) {
264
- return getDb().select().from(sessions).where(eq(sessions.groupId, groupId)).all();
803
+ function getSessionsByCategory(categoryId) {
804
+ return getDb().select().from(sessions).where(eq(sessions.categoryId, categoryId)).all();
265
805
  }
266
806
  function getActiveSessions() {
267
- return getDb().select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups, eq(sessions.groupId, groups.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
807
+ return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
268
808
  }
269
809
  function getAllSessions(opts) {
270
810
  const db = getDb();
271
- const query = db.select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups, eq(sessions.groupId, groups.id));
811
+ const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id));
272
812
  if (opts?.excludeArchived) {
273
813
  return query.where(inArray(sessions.status, [
274
814
  "active",
@@ -405,12 +945,23 @@ var init_conversations = __esm(() => {
405
945
 
406
946
  // src/cli/commands/update.ts
407
947
  var exports_update = {};
948
+ __export(exports_update, {
949
+ shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip
950
+ });
951
+ function shouldIgnoreStatusFlip(newStatus, sessionPid) {
952
+ if (!newStatus)
953
+ return false;
954
+ if (newStatus !== "active" && newStatus !== "waiting")
955
+ return false;
956
+ return sessionPid === null;
957
+ }
408
958
  var EVENT_STATUS_MAP;
409
959
  var init_update = __esm(() => {
410
960
  init_router();
411
961
  init_sessions();
412
962
  init_events();
413
963
  init_conversations();
964
+ init_trigger();
414
965
  EVENT_STATUS_MAP = {
415
966
  "session.waiting": "waiting",
416
967
  "session.answered": "active",
@@ -454,6 +1005,7 @@ var init_update = __esm(() => {
454
1005
  if (newStatus && newStatus === session.status) {
455
1006
  return;
456
1007
  }
1008
+ const ignoreStatusFlip = shouldIgnoreStatusFlip(newStatus, session.pid);
457
1009
  let meta;
458
1010
  if (metaJson) {
459
1011
  try {
@@ -474,17 +1026,20 @@ var init_update = __esm(() => {
474
1026
  summary: summaryArg || meta?.question || joinedAnswers || undefined,
475
1027
  meta
476
1028
  });
477
- if (newStatus) {
1029
+ if (newStatus && !ignoreStatusFlip) {
478
1030
  updateSessionStatus(sessionId, newStatus);
479
1031
  }
480
1032
  if (event === "session.waiting" && conversationId && meta?.question) {
481
1033
  updateLastQuestion(conversationId, meta.question);
482
1034
  }
1035
+ if (event === "session.end") {
1036
+ triggerBackgroundPush();
1037
+ }
483
1038
  });
484
1039
  });
485
1040
 
486
1041
  // src/lib/transcript.ts
487
- import { existsSync as existsSync2, readFileSync } from "fs";
1042
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
488
1043
  function getContextWindowSize(model) {
489
1044
  for (const [prefix, size] of Object.entries(CONTEXT_WINDOW_SIZES)) {
490
1045
  if (model.startsWith(prefix))
@@ -493,9 +1048,9 @@ function getContextWindowSize(model) {
493
1048
  return 200000;
494
1049
  }
495
1050
  function getLatestAssistantTurn(filePath) {
496
- if (!existsSync2(filePath))
1051
+ if (!existsSync5(filePath))
497
1052
  return null;
498
- const text2 = readFileSync(filePath, "utf-8");
1053
+ const text2 = readFileSync4(filePath, "utf-8");
499
1054
  const lines = text2.split(`
500
1055
  `);
501
1056
  const assistantEntries = [];
@@ -553,9 +1108,9 @@ function getLatestAssistantTurn(filePath) {
553
1108
  };
554
1109
  }
555
1110
  function getContextSnapshot(filePath) {
556
- if (!existsSync2(filePath))
1111
+ if (!existsSync5(filePath))
557
1112
  return null;
558
- const text2 = readFileSync(filePath, "utf-8");
1113
+ const text2 = readFileSync4(filePath, "utf-8");
559
1114
  const lines = text2.split(`
560
1115
  `);
561
1116
  for (let i = lines.length - 1;i >= 0; i--) {
@@ -822,8 +1377,8 @@ class NoopAdapter {
822
1377
  }
823
1378
 
824
1379
  // src/terminal/index.ts
825
- import { readFileSync as readFileSync2 } from "fs";
826
- import { join as join2 } from "path";
1380
+ import { readFileSync as readFileSync5 } from "fs";
1381
+ import { join as join6 } from "path";
827
1382
  function getTerminalAdapter() {
828
1383
  if (cachedAdapter)
829
1384
  return cachedAdapter;
@@ -842,7 +1397,7 @@ function getTerminalAdapter() {
842
1397
  }
843
1398
  function readConfigTerminal() {
844
1399
  try {
845
- const config = JSON.parse(readFileSync2(join2(paths.root, "config.json"), "utf-8"));
1400
+ const config = JSON.parse(readFileSync5(join6(paths.root, "config.json"), "utf-8"));
846
1401
  return config.terminal ?? null;
847
1402
  } catch {
848
1403
  return null;
@@ -1181,7 +1736,7 @@ function archiveAllPaused() {
1181
1736
  const archived = [];
1182
1737
  for (const row of paused) {
1183
1738
  const updated = updateSessionStatus(row.session.id, "archived");
1184
- archived.push({ session: updated, groupPath: row.groupPath });
1739
+ archived.push({ session: updated, categoryPath: row.categoryPath });
1185
1740
  }
1186
1741
  return { archived };
1187
1742
  }
@@ -1193,8 +1748,8 @@ var init_session_archive = __esm(() => {
1193
1748
 
1194
1749
  // src/server/index.ts
1195
1750
  import { execFile } from "child_process";
1196
- import { existsSync as existsSync3 } from "fs";
1197
- import { join as join3 } from "path";
1751
+ import { existsSync as existsSync6 } from "fs";
1752
+ import { join as join7 } from "path";
1198
1753
  function liveStats(sessionId) {
1199
1754
  return {
1200
1755
  sessionId,
@@ -1208,6 +1763,24 @@ function archiveResponse(result) {
1208
1763
  const meta = ARCHIVE_ERROR[result.reason] ?? { status: 400, message: "Operation failed" };
1209
1764
  return Response.json({ error: meta.message, reason: result.reason }, { status: meta.status });
1210
1765
  }
1766
+ async function handleSwitchProject(req) {
1767
+ let body;
1768
+ try {
1769
+ body = await req.json();
1770
+ } catch {
1771
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
1772
+ }
1773
+ const slug = body.slug;
1774
+ if (typeof slug !== "string") {
1775
+ return Response.json({ error: "slug must be a string" }, { status: 400 });
1776
+ }
1777
+ if (!projectExists(slug)) {
1778
+ return Response.json({ error: `Unknown project: ${slug}` }, { status: 404 });
1779
+ }
1780
+ setActiveProjectSlug(slug);
1781
+ queueMicrotask(() => process.exit(0));
1782
+ return Response.json({ ok: true, slug, willRestart: true });
1783
+ }
1211
1784
  async function handleOpen(req) {
1212
1785
  let body;
1213
1786
  try {
@@ -1247,11 +1820,11 @@ function match(pathname, url) {
1247
1820
  }
1248
1821
  function findDashboardDir() {
1249
1822
  const candidates = [
1250
- join3(import.meta.dir, "dashboard"),
1251
- join3(import.meta.dir, "..", "dashboard")
1823
+ join7(import.meta.dir, "dashboard"),
1824
+ join7(import.meta.dir, "..", "dashboard")
1252
1825
  ];
1253
1826
  for (const dir of candidates) {
1254
- if (existsSync3(join3(dir, "index.html")))
1827
+ if (existsSync6(join7(dir, "index.html")))
1255
1828
  return dir;
1256
1829
  }
1257
1830
  return null;
@@ -1260,13 +1833,13 @@ async function serveDashboard(pathname) {
1260
1833
  if (!DASHBOARD_DIR)
1261
1834
  return null;
1262
1835
  const requested = pathname === "/" ? "/index.html" : pathname;
1263
- const filePath = join3(DASHBOARD_DIR, requested);
1836
+ const filePath = join7(DASHBOARD_DIR, requested);
1264
1837
  if (!filePath.startsWith(DASHBOARD_DIR))
1265
1838
  return null;
1266
1839
  const file = Bun.file(filePath);
1267
1840
  if (await file.exists())
1268
1841
  return new Response(file);
1269
- return new Response(Bun.file(join3(DASHBOARD_DIR, "index.html")));
1842
+ return new Response(Bun.file(join7(DASHBOARD_DIR, "index.html")));
1270
1843
  }
1271
1844
  function startServer(port = PORT) {
1272
1845
  const server = Bun.serve({
@@ -1287,6 +1860,11 @@ function startServer(port = PORT) {
1287
1860
  r.headers.set("Access-Control-Allow-Origin", "*");
1288
1861
  return r;
1289
1862
  }
1863
+ if (req.method === "POST" && url.pathname === "/api/active-project") {
1864
+ const r = await handleSwitchProject(req);
1865
+ r.headers.set("Access-Control-Allow-Origin", "*");
1866
+ return r;
1867
+ }
1290
1868
  if (req.method === "POST") {
1291
1869
  const archiveMatch = /^\/api\/sessions\/([^/]+)\/archive$/.exec(url.pathname);
1292
1870
  if (archiveMatch) {
@@ -1349,7 +1927,18 @@ var PORT, listSessions = (_params, url) => {
1349
1927
  return getSessionStats(sessionId) ?? liveStats(sessionId);
1350
1928
  }, getEngagement = ({
1351
1929
  sessionId
1352
- }) => computeEngagementStats(sessionId), listRecaps = () => getLatestRecaps(), routes, ARCHIVE_ERROR, DASHBOARD_DIR;
1930
+ }) => computeEngagementStats(sessionId), listRecaps = () => getLatestRecaps(), listAllProjects = () => {
1931
+ const active = resolveActiveProject();
1932
+ return listProjects().map((p) => ({
1933
+ slug: p.slug,
1934
+ name: p.name,
1935
+ active: p.slug === active.slug,
1936
+ lastUsedAt: p.lastUsedAt
1937
+ }));
1938
+ }, getActiveProjectMeta = () => {
1939
+ const active = resolveActiveProject();
1940
+ return { slug: active.slug, name: active.name };
1941
+ }, routes, ARCHIVE_ERROR, DASHBOARD_DIR;
1353
1942
  var init_server = __esm(() => {
1354
1943
  init_sessions();
1355
1944
  init_events();
@@ -1357,6 +1946,8 @@ var init_server = __esm(() => {
1357
1946
  init_timing();
1358
1947
  init_engagement_stats();
1359
1948
  init_session_archive();
1949
+ init_registry();
1950
+ init_resolve();
1360
1951
  PORT = Number(process.env.BERTRAND_PORT ?? 5200);
1361
1952
  routes = [
1362
1953
  [/^\/api\/sessions$/, listSessions],
@@ -1365,7 +1956,9 @@ var init_server = __esm(() => {
1365
1956
  [/^\/api\/stats$/, listAllStats],
1366
1957
  [/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
1367
1958
  [/^\/api\/engagement\/(?<sessionId>[^/]+)$/, getEngagement],
1368
- [/^\/api\/recaps$/, listRecaps]
1959
+ [/^\/api\/recaps$/, listRecaps],
1960
+ [/^\/api\/projects$/, listAllProjects],
1961
+ [/^\/api\/active-project$/, getActiveProjectMeta]
1369
1962
  ];
1370
1963
  ARCHIVE_ERROR = {
1371
1964
  "not-found": { status: 404, message: "Session not found" },
@@ -1380,55 +1973,819 @@ var init_server = __esm(() => {
1380
1973
  var exports_serve = {};
1381
1974
  var init_serve = __esm(() => {
1382
1975
  init_router();
1383
- init_server();
1384
- register("serve", async () => {
1385
- startServer();
1386
- await new Promise(() => {});
1976
+ init_server();
1977
+ register("serve", async () => {
1978
+ startServer();
1979
+ await new Promise(() => {});
1980
+ });
1981
+ });
1982
+
1983
+ // src/sync/snapshot.ts
1984
+ import { Database as Database2 } from "bun:sqlite";
1985
+ import { existsSync as existsSync7, unlinkSync } from "fs";
1986
+ function snapshotPathFor(dbPath) {
1987
+ return `${dbPath}.sync-snapshot`;
1988
+ }
1989
+ function takeSnapshot() {
1990
+ cleanupSnapshot();
1991
+ const dbPath = resolveActiveProject().db;
1992
+ const target = snapshotPathFor(dbPath);
1993
+ const src = new Database2(dbPath, { readonly: true });
1994
+ try {
1995
+ src.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
1996
+ } finally {
1997
+ src.close();
1998
+ }
1999
+ return target;
2000
+ }
2001
+ function cleanupSnapshot() {
2002
+ const base = snapshotPathFor(resolveActiveProject().db);
2003
+ for (const suffix of SIDECAR_SUFFIXES) {
2004
+ const p = base + suffix;
2005
+ if (existsSync7(p)) {
2006
+ try {
2007
+ unlinkSync(p);
2008
+ } catch {}
2009
+ }
2010
+ }
2011
+ }
2012
+ var SIDECAR_SUFFIXES;
2013
+ var init_snapshot2 = __esm(() => {
2014
+ init_resolve();
2015
+ SIDECAR_SUFFIXES = ["", "-wal", "-shm"];
2016
+ });
2017
+
2018
+ // src/sync/crypto.ts
2019
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "crypto";
2020
+ function parseKey(base64Key) {
2021
+ const buf = Buffer.from(base64Key, "base64");
2022
+ if (buf.length !== 32) {
2023
+ throw new Error(`BERTRAND_ENCRYPTION_KEY must decode to 32 bytes (got ${buf.length}). ` + `Generate one with: openssl rand -base64 32`);
2024
+ }
2025
+ return buf;
2026
+ }
2027
+ function generateKeyBase64() {
2028
+ return randomBytes2(32).toString("base64");
2029
+ }
2030
+ function encrypt(plaintext, base64Key) {
2031
+ const key = parseKey(base64Key);
2032
+ const iv = randomBytes2(IV_LEN);
2033
+ const cipher = createCipheriv(ALGO, key, iv);
2034
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
2035
+ const tag = cipher.getAuthTag();
2036
+ return Buffer.concat([MAGIC, iv, ciphertext, tag]);
2037
+ }
2038
+ function decrypt(blob, base64Key) {
2039
+ if (blob.length < MAGIC.length + IV_LEN + TAG_LEN) {
2040
+ throw new Error("encrypted blob too small to be valid");
2041
+ }
2042
+ if (!blob.subarray(0, MAGIC.length).equals(MAGIC)) {
2043
+ throw new Error("not a bertrand encrypted blob (magic prefix mismatch). " + "Did you upload a plaintext .db file by mistake?");
2044
+ }
2045
+ const key = parseKey(base64Key);
2046
+ const iv = blob.subarray(MAGIC.length, MAGIC.length + IV_LEN);
2047
+ const tag = blob.subarray(blob.length - TAG_LEN);
2048
+ const ciphertext = blob.subarray(MAGIC.length + IV_LEN, blob.length - TAG_LEN);
2049
+ const decipher = createDecipheriv(ALGO, key, iv);
2050
+ decipher.setAuthTag(tag);
2051
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
2052
+ }
2053
+ var ALGO = "aes-256-gcm", IV_LEN = 12, TAG_LEN = 16, MAGIC;
2054
+ var init_crypto = __esm(() => {
2055
+ MAGIC = Buffer.from("BTRD1", "ascii");
2056
+ });
2057
+
2058
+ // src/sync/engine.ts
2059
+ import { createClient } from "@supabase/supabase-js";
2060
+ import { readFileSync as readFileSync6, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
2061
+ import { dirname as dirname2 } from "path";
2062
+ function client(cfg) {
2063
+ if (!cfg)
2064
+ return null;
2065
+ return createClient(cfg.supabaseUrl, cfg.supabaseServiceKey, {
2066
+ auth: { persistSession: false, autoRefreshToken: false }
2067
+ });
2068
+ }
2069
+ async function push() {
2070
+ if (!hasSyncConfig()) {
2071
+ return { ok: false, operation: "push", error: "no sync config \u2014 run `bertrand sync onboard`" };
2072
+ }
2073
+ const cfg = loadSyncConfig();
2074
+ const supabase = client(cfg);
2075
+ if (!cfg || !supabase) {
2076
+ return { ok: false, operation: "push", error: "sync config incomplete" };
2077
+ }
2078
+ const started = performance.now();
2079
+ try {
2080
+ let snapshotPath;
2081
+ try {
2082
+ snapshotPath = takeSnapshot();
2083
+ } catch (e) {
2084
+ return {
2085
+ ok: false,
2086
+ operation: "push",
2087
+ error: `snapshot failed: ${e instanceof Error ? e.message : String(e)}`
2088
+ };
2089
+ }
2090
+ const plaintext = readFileSync6(snapshotPath);
2091
+ const ciphertext = encrypt(plaintext, cfg.encryptionKey);
2092
+ const { error } = await supabase.storage.from(cfg.bucket).upload(cfg.objectKey, ciphertext, {
2093
+ contentType: "application/octet-stream",
2094
+ upsert: true
2095
+ });
2096
+ if (error) {
2097
+ return { ok: false, operation: "push", error: `upload failed: ${error.message}` };
2098
+ }
2099
+ return {
2100
+ ok: true,
2101
+ operation: "push",
2102
+ bytes: ciphertext.length,
2103
+ durationMs: Math.round(performance.now() - started)
2104
+ };
2105
+ } catch (e) {
2106
+ return { ok: false, operation: "push", error: e instanceof Error ? e.message : String(e) };
2107
+ } finally {
2108
+ cleanupSnapshot();
2109
+ }
2110
+ }
2111
+ async function pull(opts = {}) {
2112
+ if (!hasSyncConfig()) {
2113
+ return { ok: false, operation: "pull", error: "no sync config \u2014 run `bertrand sync onboard`" };
2114
+ }
2115
+ const cfg = loadSyncConfig();
2116
+ const supabase = client(cfg);
2117
+ if (!cfg || !supabase) {
2118
+ return { ok: false, operation: "pull", error: "sync config incomplete" };
2119
+ }
2120
+ const dbPath = resolveActiveProject().db;
2121
+ const holders = findHolders(dbPath);
2122
+ if (holders.length > 0) {
2123
+ const procs = holders.map((h) => `${h.command}(${h.pid})`).join(", ");
2124
+ if (!opts.force) {
2125
+ return {
2126
+ ok: false,
2127
+ operation: "pull",
2128
+ error: `${dbPath} is held by ${procs} \u2014 close active bertrand sessions before pulling, ` + `or pass --force to overwrite anyway (risks corrupting the running session).`
2129
+ };
2130
+ }
2131
+ console.warn(`warning: --force pulling while ${procs} hold ${dbPath}. The running process may crash on next file access.`);
2132
+ }
2133
+ const started = performance.now();
2134
+ try {
2135
+ const { data, error } = await supabase.storage.from(cfg.bucket).download(cfg.objectKey);
2136
+ if (error || !data) {
2137
+ if (error?.message?.toLowerCase().includes("not found")) {
2138
+ return {
2139
+ ok: true,
2140
+ operation: "pull",
2141
+ pulled: false,
2142
+ bytes: 0,
2143
+ durationMs: Math.round(performance.now() - started)
2144
+ };
2145
+ }
2146
+ return { ok: false, operation: "pull", error: `download failed: ${error?.message ?? "no data"}` };
2147
+ }
2148
+ const ciphertext = Buffer.from(await data.arrayBuffer());
2149
+ const plaintext = decrypt(ciphertext, cfg.encryptionKey);
2150
+ const tmp = `${dbPath}.pull-${process.pid}`;
2151
+ const fd = openSync(tmp, "w");
2152
+ try {
2153
+ writeSync(fd, plaintext);
2154
+ fsyncSync(fd);
2155
+ } finally {
2156
+ closeSync(fd);
2157
+ }
2158
+ renameSync3(tmp, dbPath);
2159
+ const dirFd = openSync(dirname2(dbPath), "r");
2160
+ try {
2161
+ fsyncSync(dirFd);
2162
+ } finally {
2163
+ closeSync(dirFd);
2164
+ }
2165
+ return {
2166
+ ok: true,
2167
+ operation: "pull",
2168
+ pulled: true,
2169
+ bytes: plaintext.length,
2170
+ durationMs: Math.round(performance.now() - started)
2171
+ };
2172
+ } catch (e) {
2173
+ return { ok: false, operation: "pull", error: e instanceof Error ? e.message : String(e) };
2174
+ }
2175
+ }
2176
+ async function status() {
2177
+ const cfg = loadSyncConfig();
2178
+ if (!cfg)
2179
+ return { configured: false, local: null, remote: null };
2180
+ const dbPath = resolveActiveProject().db;
2181
+ let local = null;
2182
+ try {
2183
+ const s = statSync3(dbPath);
2184
+ local = { size: s.size, modifiedAt: new Date(s.mtimeMs) };
2185
+ } catch {
2186
+ local = null;
2187
+ }
2188
+ const supabase = client(cfg);
2189
+ let remote = null;
2190
+ if (supabase) {
2191
+ try {
2192
+ const parentPath = cfg.objectKey.includes("/") ? cfg.objectKey.slice(0, cfg.objectKey.lastIndexOf("/")) : "";
2193
+ const fileName = cfg.objectKey.includes("/") ? cfg.objectKey.slice(cfg.objectKey.lastIndexOf("/") + 1) : cfg.objectKey;
2194
+ const { data, error } = await supabase.storage.from(cfg.bucket).list(parentPath, { search: fileName });
2195
+ if (!error && data) {
2196
+ const match2 = data.find((f) => f.name === fileName);
2197
+ if (match2) {
2198
+ remote = {
2199
+ size: match2.metadata?.size ?? 0,
2200
+ modifiedAt: new Date(match2.updated_at ?? match2.created_at ?? Date.now())
2201
+ };
2202
+ }
2203
+ }
2204
+ } catch {}
2205
+ }
2206
+ return { configured: true, local, remote };
2207
+ }
2208
+ var init_engine = __esm(() => {
2209
+ init_resolve();
2210
+ init_lsof();
2211
+ init_config();
2212
+ init_snapshot2();
2213
+ init_crypto();
2214
+ });
2215
+
2216
+ // src/sync/invite.ts
2217
+ function encodeInvite(cfg, project) {
2218
+ const bundle = {
2219
+ v: VERSION,
2220
+ url: cfg.supabaseUrl,
2221
+ key: cfg.supabaseServiceKey,
2222
+ bucket: cfg.bucket,
2223
+ obj: cfg.objectKey,
2224
+ ek: cfg.encryptionKey,
2225
+ psl: project.slug,
2226
+ pn: project.name
2227
+ };
2228
+ return SCHEME + Buffer.from(JSON.stringify(bundle), "utf8").toString("base64url");
2229
+ }
2230
+ function isInvite(value) {
2231
+ return typeof value === "string" && value.startsWith(SCHEME);
2232
+ }
2233
+ function decodeInvite(invite) {
2234
+ if (!invite.startsWith(SCHEME)) {
2235
+ throw new Error(`invite must start with ${SCHEME}`);
2236
+ }
2237
+ const payload = invite.slice(SCHEME.length).trim();
2238
+ let parsed;
2239
+ try {
2240
+ const json = Buffer.from(payload, "base64url").toString("utf8");
2241
+ parsed = JSON.parse(json);
2242
+ } catch {
2243
+ throw new Error("invite is malformed \u2014 could not decode base64/JSON payload");
2244
+ }
2245
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2246
+ throw new Error("invite payload is not a JSON object");
2247
+ }
2248
+ const bundle = parsed;
2249
+ if (bundle.v !== VERSION) {
2250
+ throw new Error(`invite version ${String(bundle.v)} is not supported (expected v${VERSION}). ` + `Both machines must run a per-project-aware bertrand. ` + `Upgrade the source machine and regenerate the invite.`);
2251
+ }
2252
+ for (const field of ["url", "key", "bucket", "obj", "ek", "psl", "pn"]) {
2253
+ if (!bundle[field] || typeof bundle[field] !== "string") {
2254
+ throw new Error(`invite is missing required field: ${field}`);
2255
+ }
2256
+ }
2257
+ return {
2258
+ config: {
2259
+ supabaseUrl: bundle.url,
2260
+ supabaseServiceKey: bundle.key,
2261
+ bucket: bundle.bucket,
2262
+ objectKey: bundle.obj,
2263
+ encryptionKey: bundle.ek
2264
+ },
2265
+ project: {
2266
+ slug: bundle.psl,
2267
+ name: bundle.pn
2268
+ }
2269
+ };
2270
+ }
2271
+ var SCHEME = "bertrand-sync://", VERSION = 2;
2272
+
2273
+ // src/db/migrate.ts
2274
+ import { Database as Database3 } from "bun:sqlite";
2275
+ import { drizzle as drizzle2 } from "drizzle-orm/bun-sqlite";
2276
+ import { migrate as migrate2 } from "drizzle-orm/bun-sqlite/migrator";
2277
+ import { mkdirSync as mkdirSync5 } from "fs";
2278
+ import { dirname as dirname3 } from "path";
2279
+ function runMigrations(dbPath) {
2280
+ const target = dbPath ?? resolveActiveProject().db;
2281
+ mkdirSync5(dirname3(target), { recursive: true });
2282
+ const sqlite = new Database3(target);
2283
+ sqlite.exec("PRAGMA journal_mode = WAL");
2284
+ sqlite.exec("PRAGMA foreign_keys = ON");
2285
+ const db = drizzle2(sqlite);
2286
+ migrate2(db, { migrationsFolder: MIGRATIONS_FOLDER2 });
2287
+ sqlite.close();
2288
+ }
2289
+ var MIGRATIONS_FOLDER2;
2290
+ var init_migrate = __esm(() => {
2291
+ init_resolve();
2292
+ MIGRATIONS_FOLDER2 = import.meta.dir + "/migrations";
2293
+ if (false) {}
2294
+ });
2295
+
2296
+ // src/lib/projects/create.ts
2297
+ import { mkdirSync as mkdirSync6 } from "fs";
2298
+ function createProject(opts) {
2299
+ registerProject({ slug: opts.slug, name: opts.name ?? opts.slug });
2300
+ try {
2301
+ const paths2 = projectPaths(opts.slug);
2302
+ mkdirSync6(paths2.root, { recursive: true });
2303
+ runMigrations(paths2.db);
2304
+ } catch (err) {
2305
+ try {
2306
+ removeProject(opts.slug);
2307
+ } catch {}
2308
+ throw err;
2309
+ }
2310
+ }
2311
+ var init_create = __esm(() => {
2312
+ init_migrate();
2313
+ init_registry();
2314
+ init_paths2();
2315
+ });
2316
+
2317
+ // src/sync/bootstrap.ts
2318
+ import { hostname } from "os";
2319
+ async function bootstrapFromInvite(invite) {
2320
+ let decoded;
2321
+ try {
2322
+ decoded = decodeInvite(invite);
2323
+ } catch (e) {
2324
+ return {
2325
+ ok: false,
2326
+ reason: "decode-failed",
2327
+ error: e instanceof Error ? e.message : String(e)
2328
+ };
2329
+ }
2330
+ const { config: cfg, project } = decoded;
2331
+ if (listProjects().some((p) => p.slug === project.slug)) {
2332
+ return {
2333
+ ok: false,
2334
+ reason: "slug-collision",
2335
+ error: `Project "${project.slug}" already exists on this machine. Remove it first (\`bertrand project remove ${project.slug} --purge\`) or rename it before importing.`
2336
+ };
2337
+ }
2338
+ createProject({ slug: project.slug, name: project.name });
2339
+ setActiveProjectSlug(project.slug);
2340
+ _resetActiveProjectCache();
2341
+ resolveActiveProject();
2342
+ saveSyncConfig({ ...cfg, clientName: `bertrand-${hostname()}` });
2343
+ patchConfig({ sync: { enabled: true } });
2344
+ const result = await pull();
2345
+ if (!result.ok) {
2346
+ return {
2347
+ ok: false,
2348
+ reason: "pull-failed",
2349
+ error: result.error
2350
+ };
2351
+ }
2352
+ return {
2353
+ ok: true,
2354
+ project,
2355
+ pulled: result.pulled ?? false,
2356
+ bytes: result.bytes ?? 0,
2357
+ durationMs: result.durationMs ?? 0
2358
+ };
2359
+ }
2360
+ var init_bootstrap = __esm(() => {
2361
+ init_config();
2362
+ init_engine();
2363
+ init_registry();
2364
+ init_create();
2365
+ init_resolve();
2366
+ init_config2();
2367
+ });
2368
+
2369
+ // src/lib/format.ts
2370
+ function formatDuration(ms) {
2371
+ if (ms < MINUTE)
2372
+ return `${Math.round(ms / SECOND)}s`;
2373
+ const days = Math.floor(ms / DAY);
2374
+ const hours = Math.floor(ms % DAY / HOUR);
2375
+ const minutes = Math.floor(ms % HOUR / MINUTE);
2376
+ if (days > 0)
2377
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
2378
+ if (hours > 0)
2379
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
2380
+ return `${minutes}m`;
2381
+ }
2382
+ function formatAgo(isoOrDate) {
2383
+ const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
2384
+ const ms = Date.now() - date.getTime();
2385
+ if (ms < MINUTE)
2386
+ return "just now";
2387
+ if (ms < HOUR)
2388
+ return `${Math.floor(ms / MINUTE)}m ago`;
2389
+ if (ms < DAY)
2390
+ return `${Math.floor(ms / HOUR)}h ago`;
2391
+ if (ms < 2 * DAY)
2392
+ return "yesterday";
2393
+ if (ms < 7 * DAY)
2394
+ return `${Math.floor(ms / DAY)}d ago`;
2395
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2396
+ }
2397
+ function truncate(text2, maxLen) {
2398
+ if (text2.length <= maxLen)
2399
+ return text2;
2400
+ return text2.slice(0, maxLen - 1) + "\u2026";
2401
+ }
2402
+ function formatTime(iso, includeDate = false) {
2403
+ const date = new Date(iso);
2404
+ const time = date.toLocaleTimeString("en-US", {
2405
+ hour: "numeric",
2406
+ minute: "2-digit"
2407
+ });
2408
+ if (!includeDate)
2409
+ return time;
2410
+ const day = date.toLocaleDateString("en-US", {
2411
+ month: "short",
2412
+ day: "numeric"
2413
+ });
2414
+ return `${day} ${time}`;
2415
+ }
2416
+ var SECOND = 1000, MINUTE, HOUR, DAY;
2417
+ var init_format = __esm(() => {
2418
+ MINUTE = 60 * SECOND;
2419
+ HOUR = 60 * MINUTE;
2420
+ DAY = 24 * HOUR;
2421
+ });
2422
+
2423
+ // src/cli/commands/sync.ts
2424
+ var exports_sync = {};
2425
+ import { hostname as hostname2 } from "os";
2426
+ function printUsage2() {
2427
+ console.log(`
2428
+ bertrand sync \u2014 replicate a project's local DB to Supabase Storage (opt-in)
2429
+
2430
+ Usage:
2431
+ bertrand sync onboard [--project <slug>] Interactive setup for a project
2432
+ bertrand sync invite [--project <slug>] Print a paste-able bundle
2433
+ bertrand sync <bundle> Decode bundle, create project, save config, pull
2434
+ bertrand sync push [--project <slug>] VACUUM \u2192 encrypt \u2192 upload
2435
+ bertrand sync pull [--force] [--project <slug>]
2436
+ Download \u2192 decrypt \u2192 atomic-replace
2437
+ bertrand sync status [--project <slug>] Local + remote object size and timestamps
2438
+ bertrand sync enable [--project <slug>] Turn auto-triggers on
2439
+ bertrand sync disable [--project <slug>] Turn auto-triggers off
2440
+
2441
+ All subcommands default to the active project. Pass --project to operate
2442
+ on a different one without switching active.
2443
+
2444
+ Cross-machine setup:
2445
+ Machine A: bertrand sync invite
2446
+ \u2192 bertrand-sync://eyJ\u2026 (transmit via Signal/iMessage/AirDrop \u2014 sensitive)
2447
+ Machine B: bertrand sync bertrand-sync://eyJ\u2026
2448
+ \u2192 creates the project, onboards, pulls, ready
2449
+ `.trim());
2450
+ }
2451
+ function abort(reason) {
2452
+ console.error(`Aborted: ${reason}`);
2453
+ process.exit(1);
2454
+ }
2455
+ function extractProjectFlag(args) {
2456
+ const rest = [];
2457
+ let slug;
2458
+ for (let i = 0;i < args.length; i++) {
2459
+ const a = args[i];
2460
+ if (a === "--project") {
2461
+ slug = args[i + 1];
2462
+ i++;
2463
+ continue;
2464
+ }
2465
+ if (a.startsWith("--project=")) {
2466
+ slug = a.slice("--project=".length);
2467
+ continue;
2468
+ }
2469
+ rest.push(a);
2470
+ }
2471
+ return { slug, rest };
2472
+ }
2473
+ function applyProjectOverride(slug) {
2474
+ if (!slug)
2475
+ return;
2476
+ const known = listProjects().some((p) => p.slug === slug);
2477
+ if (!known) {
2478
+ abort(`Unknown project: ${slug}. Run \`bertrand project list\` to see registered projects.`);
2479
+ return;
2480
+ }
2481
+ process.env.BERTRAND_PROJECT = slug;
2482
+ _resetActiveProjectCache();
2483
+ }
2484
+ function prompt(label, opts = {}) {
2485
+ const suffix = opts.default ? ` [${opts.default}]` : "";
2486
+ process.stdout.write(`${label}${suffix}: `);
2487
+ return new Promise((resolve) => {
2488
+ let buf = "";
2489
+ const onData = (chunk) => {
2490
+ const s = chunk.toString("utf8");
2491
+ for (const ch of s) {
2492
+ if (ch === `
2493
+ ` || ch === "\r") {
2494
+ process.stdin.off("data", onData);
2495
+ process.stdin.pause();
2496
+ process.stdout.write(`
2497
+ `);
2498
+ return resolve(buf.trim() || opts.default || "");
2499
+ }
2500
+ buf += ch;
2501
+ }
2502
+ };
2503
+ process.stdin.resume();
2504
+ process.stdin.on("data", onData);
2505
+ });
2506
+ }
2507
+ async function runOnboard() {
2508
+ const active = resolveActiveProject();
2509
+ const existing = loadSyncConfig();
2510
+ if (existing) {
2511
+ console.log(`Existing config for project "${active.slug}" at ${active.syncEnv}:`);
2512
+ console.log(` SUPABASE_URL: ${existing.supabaseUrl}`);
2513
+ console.log(` SUPABASE_SERVICE_KEY: ${existing.supabaseServiceKey.slice(0, 12)}\u2026`);
2514
+ console.log(` BERTRAND_SYNC_BUCKET: ${existing.bucket}`);
2515
+ console.log(` BERTRAND_SYNC_OBJECT: ${existing.objectKey}`);
2516
+ console.log(` BERTRAND_ENCRYPTION_KEY: (set, redacted)`);
2517
+ console.log(` BERTRAND_CLIENT_NAME: ${existing.clientName}`);
2518
+ console.log(`
2519
+ Delete ${active.syncEnv} and re-run to start over.`);
2520
+ return;
2521
+ }
2522
+ console.log(`Bertrand sync setup for project "${active.slug}" (${active.name})
2523
+ `);
2524
+ console.log(`In the Supabase dashboard (app.supabase.com):`);
2525
+ console.log(` 1. Create or pick a project`);
2526
+ console.log(` 2. Settings \u2192 General: copy the Project ID (the 20-char lowercase string`);
2527
+ console.log(` shown under "Reference ID" \u2014 looks like "xxxxxxxxxxxxxxxxxxxx")`);
2528
+ console.log(` 3. Settings \u2192 API: copy the service_role key (NOT the anon key \u2014 service_role`);
2529
+ console.log(` is below it and warns "keep secret")`);
2530
+ console.log(` 4. Storage \u2192 New bucket: name it "bertrand", PRIVATE (uncheck "Public bucket")
2531
+ `);
2532
+ const projectId = await prompt("Supabase Project ID");
2533
+ if (!projectId)
2534
+ return abort("no project ID provided");
2535
+ if (!/^[a-z0-9]{15,30}$/i.test(projectId)) {
2536
+ return abort(`"${projectId}" doesn't look like a Supabase project ID. ` + `Expected the 20-ish char string from Settings \u2192 General, not the project name.`);
2537
+ }
2538
+ const supabaseUrl = `https://${projectId}.supabase.co`;
2539
+ console.log(` \u2192 Project URL: ${supabaseUrl}`);
2540
+ const supabaseServiceKey = await prompt("SUPABASE_SERVICE_KEY (eyJ\u2026)");
2541
+ if (!supabaseServiceKey)
2542
+ return abort("no service key provided");
2543
+ if (!supabaseServiceKey.startsWith("eyJ")) {
2544
+ return abort("service key must be a JWT starting with eyJ \u2014 did you paste the URL by mistake?");
2545
+ }
2546
+ const bucket = await prompt("Storage bucket name", { default: "bertrand" });
2547
+ const defaultObject = `projects/${active.slug}/bertrand.db.enc`;
2548
+ const objectKey = await prompt("Object key", { default: defaultObject });
2549
+ const defaultName = `bertrand-${hostname2()}`;
2550
+ const clientName = await prompt("Client name", { default: defaultName });
2551
+ console.log(`
2552
+ Encryption key`);
2553
+ console.log(` First machine? Press ENTER to generate a fresh AES-256-GCM key.`);
2554
+ console.log(` Additional machine? Paste the key from your other machine.
2555
+ `);
2556
+ const pasted = await prompt("BERTRAND_ENCRYPTION_KEY");
2557
+ let encryptionKey;
2558
+ if (pasted) {
2559
+ const decoded = Buffer.from(pasted, "base64");
2560
+ if (decoded.length !== 32) {
2561
+ return abort(`pasted key is ${decoded.length} bytes when decoded; expected 32. ` + `Copy the full key from your other machine without trimming.`);
2562
+ }
2563
+ encryptionKey = pasted;
2564
+ console.log(` \u2713 Using pasted key \u2014 this machine can decrypt blobs from the other one.
2565
+ `);
2566
+ } else {
2567
+ encryptionKey = generateKeyBase64();
2568
+ console.log(`
2569
+ Generated a new key. Save this somewhere safe \u2014 you'll need it on every`);
2570
+ console.log(`other machine that should be able to pull this project:
2571
+ `);
2572
+ console.log(` ${encryptionKey}
2573
+ `);
2574
+ console.log(`Hit ENTER to confirm you've saved it.`);
2575
+ await prompt("");
2576
+ }
2577
+ saveSyncConfig({ supabaseUrl, supabaseServiceKey, bucket, objectKey, encryptionKey, clientName });
2578
+ patchConfig({ sync: { enabled: true } });
2579
+ console.log(`Wrote ${active.syncEnv} (mode 0600). Sync auto-triggers enabled.`);
2580
+ console.log(`
2581
+ Next:`);
2582
+ console.log(` bertrand sync push Send this project's DB to Supabase`);
2583
+ console.log(` bertrand sync invite Generate a bundle to import on another machine`);
2584
+ console.log(` bertrand sync status See sizes and timestamps`);
2585
+ }
2586
+ async function runPush() {
2587
+ if (!hasSyncConfig())
2588
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard`");
2589
+ process.stdout.write("Snapshot \u2192 encrypt \u2192 upload\u2026 ");
2590
+ const result = await push();
2591
+ if (result.ok) {
2592
+ console.log(`done (${formatBytes(result.bytes)} in ${result.durationMs}ms).`);
2593
+ } else {
2594
+ console.log("failed.");
2595
+ console.error(result.error);
2596
+ process.exit(1);
2597
+ }
2598
+ }
2599
+ async function runPull(args) {
2600
+ if (!hasSyncConfig())
2601
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard`");
2602
+ const force = args.includes("--force");
2603
+ process.stdout.write("Download \u2192 decrypt \u2192 replace\u2026 ");
2604
+ const result = await pull({ force });
2605
+ if (result.ok) {
2606
+ if (result.pulled) {
2607
+ console.log(`done (${formatBytes(result.bytes)} in ${result.durationMs}ms).`);
2608
+ } else {
2609
+ console.log("no remote object yet \u2014 nothing to pull.");
2610
+ }
2611
+ } else {
2612
+ console.log("failed.");
2613
+ console.error(result.error);
2614
+ process.exit(1);
2615
+ }
2616
+ }
2617
+ async function runStatus() {
2618
+ const active = resolveActiveProject();
2619
+ if (!hasSyncConfig()) {
2620
+ console.log(`Sync is not configured for project "${active.slug}". Run \`bertrand sync onboard\` to set up.`);
2621
+ return;
2622
+ }
2623
+ console.log(`Sync status for project "${active.slug}" (${active.syncEnv}):`);
2624
+ console.log(` Auto-triggers: ${isSyncEnabled() ? "enabled" : "disabled"}`);
2625
+ const s = await status();
2626
+ if (s.local) {
2627
+ console.log(` Local: ${formatBytes(s.local.size)}, modified ${formatAgo(s.local.modifiedAt)}`);
2628
+ } else {
2629
+ console.log(` Local: (no DB file yet)`);
2630
+ }
2631
+ if (s.remote) {
2632
+ console.log(` Remote: ${formatBytes(s.remote.size)}, modified ${formatAgo(s.remote.modifiedAt)}`);
2633
+ } else {
2634
+ console.log(` Remote: (no object \u2014 push to create)`);
2635
+ }
2636
+ }
2637
+ function runEnable() {
2638
+ if (!hasSyncConfig()) {
2639
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard` first");
2640
+ }
2641
+ patchConfig({ sync: { enabled: true } });
2642
+ console.log("Sync auto-triggers enabled. Pull-on-launch and push-on-session-end will fire.");
2643
+ }
2644
+ function runInvite() {
2645
+ const cfg = loadSyncConfig();
2646
+ if (!cfg) {
2647
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard` first");
2648
+ }
2649
+ const active = resolveActiveProject();
2650
+ const invite = encodeInvite(cfg, { slug: active.slug, name: active.name });
2651
+ console.log("Sensitive: this bundle contains a Supabase service_role token AND the project's encryption key.");
2652
+ console.log(`Transmit only over a secure channel (Signal, iMessage, AirDrop). Treat like an SSH private key.
2653
+ `);
2654
+ console.log(`Project: ${active.slug} (${active.name})
2655
+ `);
2656
+ console.log(invite);
2657
+ console.log(`
2658
+ On the other machine:`);
2659
+ console.log(` bertrand sync ${invite.slice(0, 32)}\u2026`);
2660
+ console.log(` (creates the project locally, saves config, runs first pull)`);
2661
+ }
2662
+ async function runBootstrap(invite) {
2663
+ console.log("Importing project from invite\u2026");
2664
+ const result = await bootstrapFromInvite(invite);
2665
+ if (!result.ok) {
2666
+ if (result.reason === "pull-failed") {
2667
+ console.error(`Pull failed: ${result.error}`);
2668
+ console.error(`(Config is saved, so you can fix and re-run \`bertrand sync pull\`.)`);
2669
+ process.exit(1);
2670
+ }
2671
+ return abort(result.error);
2672
+ }
2673
+ const active = resolveActiveProject();
2674
+ console.log(`Created project "${result.project.slug}" (${result.project.name}) and wrote ${active.syncEnv}.`);
2675
+ if (result.pulled) {
2676
+ console.log(`\u2713 Done (${formatBytes(result.bytes)} in ${result.durationMs}ms). Run \`bertrand\` to start.`);
2677
+ } else {
2678
+ console.log(`\u2713 Config saved, but no remote object yet \u2014 nothing to pull.`);
2679
+ console.log(` Run \`bertrand sync push\` on the source machine first.`);
2680
+ }
2681
+ }
2682
+ function runDisable() {
2683
+ patchConfig({ sync: { enabled: false } });
2684
+ console.log("Sync auto-triggers disabled. Credentials still in place \u2014 re-enable any time with `bertrand sync enable`.");
2685
+ console.log("Manual `bertrand sync push` / `pull` still work while disabled.");
2686
+ }
2687
+ function formatBytes(n) {
2688
+ if (n < 1024)
2689
+ return `${n}B`;
2690
+ if (n < 1024 * 1024)
2691
+ return `${(n / 1024).toFixed(1)}KB`;
2692
+ return `${(n / (1024 * 1024)).toFixed(2)}MB`;
2693
+ }
2694
+ var init_sync = __esm(() => {
2695
+ init_router();
2696
+ init_config();
2697
+ init_engine();
2698
+ init_crypto();
2699
+ init_bootstrap();
2700
+ init_registry();
2701
+ init_resolve();
2702
+ init_format();
2703
+ init_config2();
2704
+ register("sync", async (args) => {
2705
+ const sub = args[0];
2706
+ if (sub && isInvite(sub)) {
2707
+ await runBootstrap(sub);
2708
+ return;
2709
+ }
2710
+ const { slug: projectOverride, rest } = extractProjectFlag(args.slice(1));
2711
+ applyProjectOverride(projectOverride);
2712
+ switch (sub) {
2713
+ case "push":
2714
+ await runPush();
2715
+ return;
2716
+ case "pull":
2717
+ await runPull(rest);
2718
+ return;
2719
+ case "status":
2720
+ await runStatus();
2721
+ return;
2722
+ case "onboard":
2723
+ await runOnboard();
2724
+ return;
2725
+ case "invite":
2726
+ runInvite();
2727
+ return;
2728
+ case "enable":
2729
+ runEnable();
2730
+ return;
2731
+ case "disable":
2732
+ runDisable();
2733
+ return;
2734
+ case undefined:
2735
+ case "--help":
2736
+ case "-h":
2737
+ printUsage2();
2738
+ return;
2739
+ default:
2740
+ console.error(`Unknown subcommand: ${sub}`);
2741
+ printUsage2();
2742
+ process.exit(1);
2743
+ }
1387
2744
  });
1388
2745
  });
1389
2746
 
1390
- // src/db/queries/groups.ts
2747
+ // src/db/queries/categories.ts
1391
2748
  import { eq as eq6, like, or, isNull } from "drizzle-orm";
1392
- function createGroup(opts) {
2749
+ function createCategory(opts) {
1393
2750
  const db = getDb();
1394
2751
  const id = createId();
1395
2752
  let path = opts.slug;
1396
2753
  let depth = 0;
1397
2754
  if (opts.parentId) {
1398
- const parent = db.select().from(groups).where(eq6(groups.id, opts.parentId)).get();
2755
+ const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
1399
2756
  if (!parent)
1400
- throw new Error(`Parent group ${opts.parentId} not found`);
2757
+ throw new Error(`Parent category ${opts.parentId} not found`);
1401
2758
  path = `${parent.path}/${opts.slug}`;
1402
2759
  depth = parent.depth + 1;
1403
2760
  }
1404
- return db.insert(groups).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
2761
+ return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
1405
2762
  }
1406
- function getGroup(id) {
1407
- return getDb().select().from(groups).where(eq6(groups.id, id)).get();
2763
+ function getCategory(id) {
2764
+ return getDb().select().from(categories).where(eq6(categories.id, id)).get();
1408
2765
  }
1409
- function getGroupByPath(path) {
1410
- return getDb().select().from(groups).where(eq6(groups.path, path)).get();
2766
+ function getCategoryByPath(path) {
2767
+ return getDb().select().from(categories).where(eq6(categories.path, path)).get();
1411
2768
  }
1412
- function getOrCreateGroupPath(path) {
1413
- const existing = getGroupByPath(path);
2769
+ function getOrCreateCategoryPath(path) {
2770
+ const existing = getCategoryByPath(path);
1414
2771
  if (existing)
1415
2772
  return existing.id;
1416
2773
  const segments = path.split("/");
1417
2774
  let parentId;
1418
2775
  for (let i = 0;i < segments.length; i++) {
1419
2776
  const partialPath = segments.slice(0, i + 1).join("/");
1420
- const group = getGroupByPath(partialPath);
1421
- if (group) {
1422
- parentId = group.id;
2777
+ const category = getCategoryByPath(partialPath);
2778
+ if (category) {
2779
+ parentId = category.id;
1423
2780
  } else {
1424
2781
  const slug = segments[i];
1425
- const created = createGroup({ slug, name: slug, parentId });
2782
+ const created = createCategory({ slug, name: slug, parentId });
1426
2783
  parentId = created.id;
1427
2784
  }
1428
2785
  }
1429
2786
  return parentId;
1430
2787
  }
1431
- var init_groups = __esm(() => {
2788
+ var init_categories = __esm(() => {
1432
2789
  init_client();
1433
2790
  init_schema();
1434
2791
  init_id();
@@ -1484,74 +2841,20 @@ var init_template2 = __esm(() => {
1484
2841
  init_template();
1485
2842
  });
1486
2843
 
1487
- // src/lib/format.ts
1488
- function formatDuration(ms) {
1489
- if (ms < MINUTE)
1490
- return `${Math.round(ms / SECOND)}s`;
1491
- const days = Math.floor(ms / DAY);
1492
- const hours = Math.floor(ms % DAY / HOUR);
1493
- const minutes = Math.floor(ms % HOUR / MINUTE);
1494
- if (days > 0)
1495
- return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
1496
- if (hours > 0)
1497
- return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
1498
- return `${minutes}m`;
1499
- }
1500
- function formatAgo(isoOrDate) {
1501
- const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
1502
- const ms = Date.now() - date.getTime();
1503
- if (ms < MINUTE)
1504
- return "just now";
1505
- if (ms < HOUR)
1506
- return `${Math.floor(ms / MINUTE)}m ago`;
1507
- if (ms < DAY)
1508
- return `${Math.floor(ms / HOUR)}h ago`;
1509
- if (ms < 2 * DAY)
1510
- return "yesterday";
1511
- if (ms < 7 * DAY)
1512
- return `${Math.floor(ms / DAY)}d ago`;
1513
- return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
1514
- }
1515
- function truncate(text2, maxLen) {
1516
- if (text2.length <= maxLen)
1517
- return text2;
1518
- return text2.slice(0, maxLen - 1) + "\u2026";
1519
- }
1520
- function formatTime(iso, includeDate = false) {
1521
- const date = new Date(iso);
1522
- const time = date.toLocaleTimeString("en-US", {
1523
- hour: "numeric",
1524
- minute: "2-digit"
1525
- });
1526
- if (!includeDate)
1527
- return time;
1528
- const day = date.toLocaleDateString("en-US", {
1529
- month: "short",
1530
- day: "numeric"
1531
- });
1532
- return `${day} ${time}`;
1533
- }
1534
- var SECOND = 1000, MINUTE, HOUR, DAY;
1535
- var init_format = __esm(() => {
1536
- MINUTE = 60 * SECOND;
1537
- HOUR = 60 * MINUTE;
1538
- DAY = 24 * HOUR;
1539
- });
1540
-
1541
2844
  // src/contract/context.ts
1542
- function buildSiblingContext(groupId, groupPath, currentSessionId) {
1543
- const siblings = getSessionsByGroup(groupId).filter((s) => s.id !== currentSessionId);
2845
+ function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
2846
+ const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
1544
2847
  if (siblings.length === 0)
1545
2848
  return "";
1546
2849
  const lines = siblings.map((s) => {
1547
2850
  const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
1548
2851
  const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
1549
- return `- ${groupPath}/${s.slug}: ${s.status}${summary} (${ago})`;
2852
+ return `- ${categoryPath}/${s.slug}: ${s.status}${summary} (${ago})`;
1550
2853
  });
1551
2854
  const guidance = [
1552
2855
  "",
1553
2856
  "To inspect any sibling session's full record, run:",
1554
- " bertrand log <group>/<slug> --json",
2857
+ " bertrand log <category>/<slug> --json",
1555
2858
  "Returns session metadata, stats, conversations, and the full event timeline.",
1556
2859
  "Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
1557
2860
  ].join(`
@@ -1567,7 +2870,7 @@ var init_context = __esm(() => {
1567
2870
  });
1568
2871
 
1569
2872
  // src/engine/process.ts
1570
- import { spawn } from "child_process";
2873
+ import { spawn as spawn2 } from "child_process";
1571
2874
  function launchClaude(opts) {
1572
2875
  const args = [];
1573
2876
  if (opts.resume) {
@@ -1576,16 +2879,19 @@ function launchClaude(opts) {
1576
2879
  args.push("--session-id", opts.claudeId);
1577
2880
  }
1578
2881
  args.push("--append-system-prompt", opts.contract);
2882
+ const active = resolveActiveProject();
1579
2883
  const env = {
1580
2884
  ...process.env,
1581
2885
  BERTRAND_PID: String(process.pid),
1582
2886
  BERTRAND_CLAUDE_ID: opts.claudeId,
1583
2887
  BERTRAND_SESSION: opts.sessionId,
1584
2888
  BERTRAND_SESSION_NAME: opts.sessionName,
1585
- BERTRAND_SESSION_SLUG: opts.sessionSlug
2889
+ BERTRAND_SESSION_SLUG: opts.sessionSlug,
2890
+ BERTRAND_PROJECT: active.slug,
2891
+ BERTRAND_PROJECT_DB: active.db
1586
2892
  };
1587
2893
  return new Promise((resolve, reject) => {
1588
- const child = spawn("claude", args, {
2894
+ const child = spawn2("claude", args, {
1589
2895
  env,
1590
2896
  stdio: "inherit",
1591
2897
  shell: false
@@ -1612,8 +2918,13 @@ function launchClaude(opts) {
1612
2918
  });
1613
2919
  });
1614
2920
  }
2921
+ function isClaudeRunning() {
2922
+ return activeChild !== null;
2923
+ }
1615
2924
  var activeChild = null;
1616
- var init_process = () => {};
2925
+ var init_process = __esm(() => {
2926
+ init_resolve();
2927
+ });
1617
2928
 
1618
2929
  // src/engine/spawn-context.ts
1619
2930
  import { execFile as execFile2 } from "child_process";
@@ -1683,12 +2994,12 @@ var init_spawn_context = __esm(() => {
1683
2994
  });
1684
2995
 
1685
2996
  // src/lib/server-lifecycle.ts
1686
- import { spawn as spawn2 } from "child_process";
1687
- import { readFileSync as readFileSync3, writeFileSync, unlinkSync } from "fs";
1688
- import { join as join4 } from "path";
2997
+ import { spawn as spawn3 } from "child_process";
2998
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2 } from "fs";
2999
+ import { join as join8 } from "path";
1689
3000
  function readPidFile() {
1690
3001
  try {
1691
- const pid = Number(readFileSync3(deps.pidFile, "utf-8").trim());
3002
+ const pid = Number(readFileSync7(deps.pidFile, "utf-8").trim());
1692
3003
  return Number.isFinite(pid) && pid > 0 ? pid : null;
1693
3004
  } catch {
1694
3005
  return null;
@@ -1714,7 +3025,7 @@ async function isPortListening(port) {
1714
3025
  }
1715
3026
  function removePidFile() {
1716
3027
  try {
1717
- unlinkSync(deps.pidFile);
3028
+ unlinkSync2(deps.pidFile);
1718
3029
  } catch {}
1719
3030
  }
1720
3031
  async function ensureServerStarted() {
@@ -1728,14 +3039,14 @@ async function ensureServerStarted() {
1728
3039
  const bin = deps.resolveBin();
1729
3040
  if (!bin)
1730
3041
  return;
1731
- const child = spawn2(bin, ["serve"], {
3042
+ const child = spawn3(bin, ["serve"], {
1732
3043
  detached: true,
1733
3044
  stdio: "ignore",
1734
3045
  env: { ...process.env, BERTRAND_PORT: String(deps.port) }
1735
3046
  });
1736
3047
  child.unref();
1737
3048
  if (child.pid)
1738
- writeFileSync(deps.pidFile, String(child.pid));
3049
+ writeFileSync4(deps.pidFile, String(child.pid));
1739
3050
  }
1740
3051
  function stopServerIfIdle() {
1741
3052
  if (deps.getActiveCount() > 0)
@@ -1753,11 +3064,11 @@ var init_server_lifecycle = __esm(() => {
1753
3064
  init_paths();
1754
3065
  init_sessions();
1755
3066
  defaultDeps = {
1756
- pidFile: join4(paths.root, "server.pid"),
3067
+ pidFile: join8(paths.root, "server.pid"),
1757
3068
  port: Number(process.env.BERTRAND_PORT ?? 5200),
1758
3069
  resolveBin() {
1759
3070
  try {
1760
- const config = JSON.parse(readFileSync3(join4(paths.root, "config.json"), "utf-8"));
3071
+ const config = JSON.parse(readFileSync7(join8(paths.root, "config.json"), "utf-8"));
1761
3072
  return typeof config?.bin === "string" ? config.bin : null;
1762
3073
  } catch {
1763
3074
  return null;
@@ -1770,17 +3081,52 @@ var init_server_lifecycle = __esm(() => {
1770
3081
 
1771
3082
  // src/engine/session.ts
1772
3083
  import { randomUUID } from "crypto";
3084
+ function forceFinalizeLive() {
3085
+ if (!liveSession)
3086
+ return;
3087
+ const session = getSession(liveSession.sessionId);
3088
+ if (!session) {
3089
+ liveSession = null;
3090
+ return;
3091
+ }
3092
+ if (session.status !== "active" && session.status !== "waiting") {
3093
+ liveSession = null;
3094
+ return;
3095
+ }
3096
+ try {
3097
+ updateSession(liveSession.sessionId, {
3098
+ status: "paused",
3099
+ pid: null,
3100
+ endedAt: new Date().toISOString()
3101
+ });
3102
+ } catch {}
3103
+ liveSession = null;
3104
+ }
3105
+ function installExitHandlers() {
3106
+ if (exitHandlersInstalled)
3107
+ return;
3108
+ exitHandlersInstalled = true;
3109
+ process.on("exit", forceFinalizeLive);
3110
+ const onSignal = (signal) => {
3111
+ if (isClaudeRunning())
3112
+ return;
3113
+ process.exit(signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143);
3114
+ };
3115
+ process.on("SIGINT", onSignal);
3116
+ process.on("SIGTERM", onSignal);
3117
+ process.on("SIGHUP", onSignal);
3118
+ }
1773
3119
  async function launch(opts) {
1774
- const existingGroup = getGroupByPath(opts.groupPath);
1775
- if (existingGroup) {
1776
- const existing = getSessionByGroupSlug(existingGroup.id, opts.slug);
3120
+ const existingCategory = getCategoryByPath(opts.categoryPath);
3121
+ if (existingCategory) {
3122
+ const existing = getSessionByCategorySlug(existingCategory.id, opts.slug);
1777
3123
  if (existing) {
1778
- throw new Error(`Session "${opts.slug}" already exists in group "${opts.groupPath}"`);
3124
+ throw new Error(`Session "${opts.slug}" already exists in category "${opts.categoryPath}"`);
1779
3125
  }
1780
3126
  }
1781
- const groupId = getOrCreateGroupPath(opts.groupPath);
3127
+ const categoryId = getOrCreateCategoryPath(opts.categoryPath);
1782
3128
  const session = createSession({
1783
- groupId,
3129
+ categoryId,
1784
3130
  slug: opts.slug,
1785
3131
  name: opts.name ?? opts.slug
1786
3132
  });
@@ -1794,15 +3140,17 @@ async function launch(opts) {
1794
3140
  sessionId: session.id
1795
3141
  });
1796
3142
  updateSession(session.id, { status: "active", pid: process.pid });
3143
+ liveSession = { sessionId: session.id, claudeId };
3144
+ installExitHandlers();
1797
3145
  await ensureServerStarted();
1798
3146
  const spawnContext = await captureSpawnContext();
1799
- const sessionName = `${opts.groupPath}/${opts.slug}`;
3147
+ const sessionName = `${opts.categoryPath}/${opts.slug}`;
1800
3148
  insertEvent({
1801
3149
  sessionId: session.id,
1802
3150
  conversationId: claudeId,
1803
3151
  event: "session.started",
1804
3152
  meta: {
1805
- group_path: opts.groupPath,
3153
+ category_path: opts.categoryPath,
1806
3154
  session_name: opts.name ?? opts.slug,
1807
3155
  session_slug: opts.slug,
1808
3156
  labels: opts.labelNames ?? [],
@@ -1821,7 +3169,7 @@ async function launch(opts) {
1821
3169
  cwd: spawnContext.cwd
1822
3170
  }
1823
3171
  });
1824
- const siblingContext = buildSiblingContext(groupId, opts.groupPath, session.id);
3172
+ const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
1825
3173
  const contract = buildContract(sessionName, siblingContext);
1826
3174
  const exitCode = await launchClaude({
1827
3175
  sessionId: session.id,
@@ -1837,9 +3185,11 @@ async function resume(opts) {
1837
3185
  const session = getSession(opts.sessionId);
1838
3186
  if (!session)
1839
3187
  throw new Error(`Session not found: ${opts.sessionId}`);
1840
- const group = getGroup(session.groupId);
1841
- const sessionName = group ? `${group.path}/${session.slug}` : session.name;
3188
+ const category = getCategory(session.categoryId);
3189
+ const sessionName = category ? `${category.path}/${session.slug}` : session.name;
1842
3190
  updateSession(session.id, { status: "active", pid: process.pid });
3191
+ liveSession = { sessionId: session.id, claudeId: opts.conversationId };
3192
+ installExitHandlers();
1843
3193
  await ensureServerStarted();
1844
3194
  insertEvent({
1845
3195
  sessionId: session.id,
@@ -1847,8 +3197,8 @@ async function resume(opts) {
1847
3197
  event: "session.resumed",
1848
3198
  meta: { claude_id: opts.conversationId }
1849
3199
  });
1850
- const groupPath = group?.path ?? "";
1851
- const siblingContext = buildSiblingContext(session.groupId, groupPath, session.id);
3200
+ const categoryPath = category?.path ?? "";
3201
+ const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
1852
3202
  const contract = buildContract(sessionName, siblingContext);
1853
3203
  const exitCode = await launchClaude({
1854
3204
  sessionId: session.id,
@@ -1880,6 +3230,8 @@ function finalizeSession(sessionId, conversationId, exitCode) {
1880
3230
  pid: null,
1881
3231
  endedAt: new Date().toISOString()
1882
3232
  });
3233
+ if (liveSession?.sessionId === sessionId)
3234
+ liveSession = null;
1883
3235
  insertEvent({
1884
3236
  sessionId,
1885
3237
  event: "session.end"
@@ -1887,11 +3239,12 @@ function finalizeSession(sessionId, conversationId, exitCode) {
1887
3239
  computeAndPersist(sessionId);
1888
3240
  stopServerIfIdle();
1889
3241
  }
3242
+ var liveSession = null, exitHandlersInstalled = false;
1890
3243
  var init_session = __esm(() => {
1891
3244
  init_sessions();
1892
3245
  init_conversations();
1893
3246
  init_events();
1894
- init_groups();
3247
+ init_categories();
1895
3248
  init_labels();
1896
3249
  init_template2();
1897
3250
  init_context();
@@ -1902,13 +3255,13 @@ var init_session = __esm(() => {
1902
3255
  });
1903
3256
 
1904
3257
  // src/tui/app.tsx
1905
- import { spawn as spawn3 } from "child_process";
1906
- import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
1907
- import { join as join5 } from "path";
3258
+ import { spawn as spawn4 } from "child_process";
3259
+ import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
3260
+ import { join as join9 } from "path";
1908
3261
  import { randomUUID as randomUUID2 } from "crypto";
1909
3262
  async function runScreen(screen, ...args) {
1910
3263
  const tmpFile = `/tmp/bertrand-tui-${process.pid}-${Date.now()}.json`;
1911
- const child = spawn3("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
3264
+ const child = spawn4("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
1912
3265
  stdio: "inherit"
1913
3266
  });
1914
3267
  const exitCode = await new Promise((resolve) => {
@@ -1917,13 +3270,16 @@ async function runScreen(screen, ...args) {
1917
3270
  if (exitCode !== 0) {
1918
3271
  throw new Error(`TUI screen "${screen}" exited with code ${exitCode}`);
1919
3272
  }
1920
- const result = JSON.parse(readFileSync4(tmpFile, "utf-8"));
1921
- unlinkSync2(tmpFile);
3273
+ const result = JSON.parse(readFileSync8(tmpFile, "utf-8"));
3274
+ unlinkSync3(tmpFile);
1922
3275
  return result;
1923
3276
  }
1924
3277
  async function startLaunchTui() {
1925
3278
  return runScreen("launch");
1926
3279
  }
3280
+ async function startProjectPickerTui() {
3281
+ return runScreen("project-picker");
3282
+ }
1927
3283
  async function startExitTui(sessionId) {
1928
3284
  return runScreen("exit", sessionId);
1929
3285
  }
@@ -1972,26 +3328,58 @@ async function runSessionLoop(sessionId) {
1972
3328
  }
1973
3329
  }
1974
3330
  }
1975
- async function startTui() {
3331
+ function shouldShowProjectPicker() {
3332
+ if (process.env.BERTRAND_PROJECT)
3333
+ return false;
3334
+ const projects = listProjects();
3335
+ return projects.length !== 1;
3336
+ }
3337
+ function activateProject(slug) {
3338
+ setActiveProjectSlug(slug);
3339
+ _resetActiveProjectCache();
3340
+ }
3341
+ async function runLaunchCycle() {
1976
3342
  const selection = await startLaunchTui();
1977
3343
  switch (selection.type) {
1978
3344
  case "quit":
1979
- break;
3345
+ return;
1980
3346
  case "create": {
1981
3347
  const sessionId = await launch(selection);
1982
3348
  await runSessionLoop(sessionId);
1983
- break;
3349
+ return;
1984
3350
  }
1985
3351
  case "pick": {
1986
3352
  const conversationId = await resolveConversationForResume(selection.sessionId);
1987
3353
  if (!conversationId)
1988
- break;
3354
+ return;
1989
3355
  const sessionId = await resume({
1990
3356
  sessionId: selection.sessionId,
1991
3357
  conversationId
1992
3358
  });
1993
3359
  await runSessionLoop(sessionId);
1994
- break;
3360
+ return;
3361
+ }
3362
+ }
3363
+ }
3364
+ async function startTui() {
3365
+ if (!shouldShowProjectPicker()) {
3366
+ await runLaunchCycle();
3367
+ return;
3368
+ }
3369
+ const projectSelection = await startProjectPickerTui();
3370
+ switch (projectSelection.type) {
3371
+ case "quit":
3372
+ return;
3373
+ case "select": {
3374
+ activateProject(projectSelection.slug);
3375
+ await runLaunchCycle();
3376
+ return;
3377
+ }
3378
+ case "create": {
3379
+ createProject({ slug: projectSelection.slug });
3380
+ activateProject(projectSelection.slug);
3381
+ await runLaunchCycle();
3382
+ return;
1995
3383
  }
1996
3384
  }
1997
3385
  }
@@ -2001,9 +3389,12 @@ var init_app = __esm(() => {
2001
3389
  init_conversations();
2002
3390
  init_session_archive();
2003
3391
  init_session();
3392
+ init_registry();
3393
+ init_create();
3394
+ init_resolve();
2004
3395
  SCREEN_ENTRY = (() => {
2005
- const built = join5(import.meta.dir, "run-screen.js");
2006
- return existsSync4(built) ? built : join5(import.meta.dir, "run-screen.tsx");
3396
+ const built = join9(import.meta.dir, "run-screen.js");
3397
+ return existsSync8(built) ? built : join9(import.meta.dir, "run-screen.tsx");
2007
3398
  })();
2008
3399
  });
2009
3400
 
@@ -2015,7 +3406,7 @@ function parseSessionName(input) {
2015
3406
  }
2016
3407
  const segments = trimmed.split("/").filter(Boolean);
2017
3408
  if (segments.length < 2) {
2018
- throw new Error(`Session name must include at least one group: "group/session" (got "${trimmed}")`);
3409
+ throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
2019
3410
  }
2020
3411
  for (const segment of segments) {
2021
3412
  if (!/^[a-z0-9][a-z0-9._-]*$/i.test(segment)) {
@@ -2023,8 +3414,8 @@ function parseSessionName(input) {
2023
3414
  }
2024
3415
  }
2025
3416
  const slug = segments[segments.length - 1];
2026
- const groupPath = segments.slice(0, -1).join("/");
2027
- return { groupPath, slug };
3417
+ const categoryPath = segments.slice(0, -1).join("/");
3418
+ return { categoryPath, slug };
2028
3419
  }
2029
3420
 
2030
3421
  // src/engine/recovery.ts
@@ -2072,8 +3463,8 @@ var init_launch = __esm(() => {
2072
3463
  recoverStaleSessions();
2073
3464
  const sessionName = args[0];
2074
3465
  if (sessionName) {
2075
- const { groupPath, slug } = parseSessionName(sessionName);
2076
- const sessionId = await launch({ groupPath, slug });
3466
+ const { categoryPath, slug } = parseSessionName(sessionName);
3467
+ const sessionId = await launch({ categoryPath, slug });
2077
3468
  await runSessionLoop(sessionId);
2078
3469
  return;
2079
3470
  }
@@ -2081,28 +3472,6 @@ var init_launch = __esm(() => {
2081
3472
  });
2082
3473
  });
2083
3474
 
2084
- // src/db/migrate.ts
2085
- import { Database as Database2 } from "bun:sqlite";
2086
- import { drizzle as drizzle2 } from "drizzle-orm/bun-sqlite";
2087
- import { migrate } from "drizzle-orm/bun-sqlite/migrator";
2088
- import { mkdirSync as mkdirSync2 } from "fs";
2089
- import { dirname as dirname2 } from "path";
2090
- function runMigrations() {
2091
- mkdirSync2(dirname2(paths.db), { recursive: true });
2092
- const sqlite = new Database2(paths.db);
2093
- sqlite.exec("PRAGMA journal_mode = WAL");
2094
- sqlite.exec("PRAGMA foreign_keys = ON");
2095
- const db = drizzle2(sqlite);
2096
- migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
2097
- sqlite.close();
2098
- }
2099
- var MIGRATIONS_FOLDER;
2100
- var init_migrate = __esm(() => {
2101
- init_paths();
2102
- MIGRATIONS_FOLDER = import.meta.dir + "/migrations";
2103
- if (false) {}
2104
- });
2105
-
2106
3475
  // src/hooks/scripts.ts
2107
3476
  function waitingScript(bin) {
2108
3477
  const BIN = bin;
@@ -2376,14 +3745,14 @@ var init_scripts = __esm(() => {
2376
3745
  });
2377
3746
 
2378
3747
  // src/hooks/install.ts
2379
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2, chmodSync } from "fs";
2380
- import { join as join6 } from "path";
3748
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
3749
+ import { join as join10 } from "path";
2381
3750
  function installHookScripts(bin) {
2382
- mkdirSync3(paths.hooks, { recursive: true });
3751
+ mkdirSync7(paths.hooks, { recursive: true });
2383
3752
  for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
2384
- const filePath = join6(paths.hooks, filename);
2385
- writeFileSync2(filePath, scriptFn(bin));
2386
- chmodSync(filePath, 493);
3753
+ const filePath = join10(paths.hooks, filename);
3754
+ writeFileSync5(filePath, scriptFn(bin));
3755
+ chmodSync2(filePath, 493);
2387
3756
  }
2388
3757
  console.log(`Installed ${Object.keys(HOOK_SCRIPTS).length} hook scripts to ${paths.hooks}`);
2389
3758
  }
@@ -2393,8 +3762,8 @@ var init_install = __esm(() => {
2393
3762
  });
2394
3763
 
2395
3764
  // src/hooks/settings.ts
2396
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
2397
- import { join as join7, dirname as dirname3 } from "path";
3765
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8 } from "fs";
3766
+ import { join as join11, dirname as dirname4 } from "path";
2398
3767
  import { homedir as homedir2 } from "os";
2399
3768
  function isBertrandGroup(group) {
2400
3769
  return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
@@ -2402,7 +3771,7 @@ function isBertrandGroup(group) {
2402
3771
  function installHookSettings() {
2403
3772
  let settings = {};
2404
3773
  try {
2405
- settings = JSON.parse(readFileSync5(SETTINGS_PATH, "utf-8"));
3774
+ settings = JSON.parse(readFileSync9(SETTINGS_PATH, "utf-8"));
2406
3775
  } catch {}
2407
3776
  const existingHooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
2408
3777
  const merged = { ...existingHooks };
@@ -2411,15 +3780,15 @@ function installHookSettings() {
2411
3780
  merged[eventType] = [...existing, ...bertrandGroups];
2412
3781
  }
2413
3782
  settings.hooks = merged;
2414
- mkdirSync4(dirname3(SETTINGS_PATH), { recursive: true });
2415
- writeFileSync3(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
3783
+ mkdirSync8(dirname4(SETTINGS_PATH), { recursive: true });
3784
+ writeFileSync6(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
2416
3785
  `);
2417
3786
  console.log(`Updated ${SETTINGS_PATH} with bertrand hooks`);
2418
3787
  }
2419
3788
  var SETTINGS_PATH, BERTRAND_HOOKS;
2420
3789
  var init_settings = __esm(() => {
2421
3790
  init_paths();
2422
- SETTINGS_PATH = join7(homedir2(), ".claude", "settings.json");
3791
+ SETTINGS_PATH = join11(homedir2(), ".claude", "settings.json");
2423
3792
  BERTRAND_HOOKS = {
2424
3793
  PreToolUse: [
2425
3794
  {
@@ -2463,8 +3832,8 @@ var init_settings = __esm(() => {
2463
3832
  });
2464
3833
 
2465
3834
  // src/lib/completions.ts
2466
- import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync5 } from "fs";
2467
- import { join as join8 } from "path";
3835
+ import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync9 } from "fs";
3836
+ import { join as join12 } from "path";
2468
3837
  function bashCompletion() {
2469
3838
  return `# bertrand bash completion
2470
3839
  _bertrand() {
@@ -2494,11 +3863,11 @@ function fishCompletion() {
2494
3863
  `;
2495
3864
  }
2496
3865
  function generateCompletions() {
2497
- const dir = join8(paths.root, "completions");
2498
- mkdirSync5(dir, { recursive: true });
2499
- writeFileSync4(join8(dir, "bertrand.bash"), bashCompletion());
2500
- writeFileSync4(join8(dir, "_bertrand"), zshCompletion());
2501
- writeFileSync4(join8(dir, "bertrand.fish"), fishCompletion());
3866
+ const dir = join12(paths.root, "completions");
3867
+ mkdirSync9(dir, { recursive: true });
3868
+ writeFileSync7(join12(dir, "bertrand.bash"), bashCompletion());
3869
+ writeFileSync7(join12(dir, "_bertrand"), zshCompletion());
3870
+ writeFileSync7(join12(dir, "bertrand.fish"), fishCompletion());
2502
3871
  console.log(`Shell completions written to ${dir}`);
2503
3872
  console.log(" Add to your shell config:");
2504
3873
  console.log(` bash: source ${dir}/bertrand.bash`);
@@ -2514,8 +3883,10 @@ var init_completions = __esm(() => {
2514
3883
  "log",
2515
3884
  "stats",
2516
3885
  "archive",
3886
+ "project",
2517
3887
  "update",
2518
3888
  "serve",
3889
+ "sync",
2519
3890
  "import",
2520
3891
  "completion",
2521
3892
  "badge",
@@ -2525,9 +3896,9 @@ var init_completions = __esm(() => {
2525
3896
 
2526
3897
  // src/cli/commands/init.ts
2527
3898
  var exports_init = {};
2528
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
3899
+ import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, chmodSync as chmodSync3 } from "fs";
2529
3900
  import { execSync as execSync2 } from "child_process";
2530
- import { join as join9 } from "path";
3901
+ import { join as join13 } from "path";
2531
3902
  function detectTerminal() {
2532
3903
  try {
2533
3904
  execSync2("which wsh", { stdio: "ignore" });
@@ -2536,51 +3907,42 @@ function detectTerminal() {
2536
3907
  return "other";
2537
3908
  }
2538
3909
  }
2539
- function writeConfig(config) {
2540
- writeFileSync5(CONFIG_PATH, JSON.stringify(config, null, 2) + `
2541
- `);
2542
- }
2543
- function readConfig() {
2544
- try {
2545
- return JSON.parse(readFileSync6(CONFIG_PATH, "utf-8"));
2546
- } catch {
2547
- return null;
2548
- }
2549
- }
2550
3910
  function resolveBin() {
2551
3911
  const onPath = Bun.which("bertrand");
2552
3912
  if (onPath)
2553
3913
  return onPath;
2554
3914
  const entry = process.argv[1];
2555
3915
  if (entry && SOURCE_ENTRY.test(entry)) {
2556
- const launcherDir = join9(paths.root, "bin");
2557
- const launcher = join9(launcherDir, "bertrand-dev");
2558
- mkdirSync6(launcherDir, { recursive: true });
2559
- writeFileSync5(launcher, `#!/usr/bin/env bash
3916
+ const launcherDir = join13(paths.root, "bin");
3917
+ const launcher = join13(launcherDir, "bertrand-dev");
3918
+ mkdirSync10(launcherDir, { recursive: true });
3919
+ writeFileSync8(launcher, `#!/usr/bin/env bash
2560
3920
  exec ${process.execPath} ${JSON.stringify(entry)} "$@"
2561
3921
  `);
2562
- chmodSync2(launcher, 493);
3922
+ chmodSync3(launcher, 493);
2563
3923
  return launcher;
2564
3924
  }
2565
3925
  return null;
2566
3926
  }
2567
- var CONFIG_PATH, SOURCE_ENTRY;
3927
+ var SOURCE_ENTRY;
2568
3928
  var init_init = __esm(() => {
2569
3929
  init_router();
2570
3930
  init_migrate();
2571
3931
  init_install();
2572
3932
  init_settings();
2573
3933
  init_paths();
3934
+ init_resolve();
2574
3935
  init_completions();
2575
- CONFIG_PATH = join9(paths.root, "config.json");
3936
+ init_config2();
2576
3937
  SOURCE_ENTRY = /\/src\/index\.tsx?$/;
2577
3938
  register("init", async () => {
2578
3939
  console.log(`bertrand init
2579
3940
  `);
2580
- mkdirSync6(paths.root, { recursive: true });
2581
- mkdirSync6(paths.hooks, { recursive: true });
2582
- runMigrations();
2583
- console.log(` Database: ${paths.db}`);
3941
+ mkdirSync10(paths.root, { recursive: true });
3942
+ mkdirSync10(paths.hooks, { recursive: true });
3943
+ const active = resolveActiveProject();
3944
+ runMigrations(active.db);
3945
+ console.log(` Database: ${active.db}`);
2584
3946
  const bin = resolveBin();
2585
3947
  if (!bin) {
2586
3948
  console.error(`
@@ -2614,7 +3976,7 @@ function buildRows(sessions2) {
2614
3976
  return sessions2.sort((a, b) => new Date(b.session.updatedAt).getTime() - new Date(a.session.updatedAt).getTime()).map((row) => {
2615
3977
  const stats = getSessionStats(row.session.id);
2616
3978
  return {
2617
- name: `${row.groupPath}/${row.session.slug}`,
3979
+ name: `${row.categoryPath}/${row.session.slug}`,
2618
3980
  status: row.session.status,
2619
3981
  updatedAt: row.session.updatedAt,
2620
3982
  conversations: stats?.conversationCount ?? 0,
@@ -2654,7 +4016,7 @@ var STATUS_DOTS;
2654
4016
  var init_list = __esm(() => {
2655
4017
  init_router();
2656
4018
  init_sessions();
2657
- init_groups();
4019
+ init_categories();
2658
4020
  init_stats();
2659
4021
  init_format();
2660
4022
  STATUS_DOTS = {
@@ -2667,17 +4029,17 @@ var init_list = __esm(() => {
2667
4029
  register("list", async (args) => {
2668
4030
  const isJson = args.includes("--json");
2669
4031
  const showAll = args.includes("--all") || args.includes("-a");
2670
- const groupFlag = args.indexOf("--group");
2671
- const groupPath = groupFlag !== -1 ? args[groupFlag + 1] : undefined;
4032
+ const categoryFlag = args.indexOf("--category");
4033
+ const categoryPath = categoryFlag !== -1 ? args[categoryFlag + 1] : undefined;
2672
4034
  let sessionRows;
2673
- if (groupPath) {
2674
- const group = getGroupByPath(groupPath);
2675
- if (!group) {
2676
- console.error(`Group not found: ${groupPath}`);
4035
+ if (categoryPath) {
4036
+ const category = getCategoryByPath(categoryPath);
4037
+ if (!category) {
4038
+ console.error(`Category not found: ${categoryPath}`);
2677
4039
  process.exit(1);
2678
4040
  }
2679
- const groupSessions = getSessionsByGroup(group.id);
2680
- sessionRows = groupSessions.map((s) => ({ session: s, groupPath: group.path }));
4041
+ const categorySessions = getSessionsByCategory(category.id);
4042
+ sessionRows = categorySessions.map((s) => ({ session: s, categoryPath: category.path }));
2681
4043
  if (!showAll) {
2682
4044
  sessionRows = sessionRows.filter((r) => r.session.status !== "archived");
2683
4045
  }
@@ -2924,10 +4286,10 @@ function showAllSessions() {
2924
4286
  console.log("No sessions.");
2925
4287
  return;
2926
4288
  }
2927
- const maxName = Math.max(...rows.map((r) => `${r.groupPath}/${r.session.slug}`.length), 4);
4289
+ const maxName = Math.max(...rows.map((r) => `${r.categoryPath}/${r.session.slug}`.length), 4);
2928
4290
  console.log(`${DIM}${" "} ${"NAME".padEnd(maxName)} ${"STATUS".padEnd(10)} ${"EVENTS".padEnd(6)} LAST ACTIVE${RESET}`);
2929
4291
  for (const row of rows) {
2930
- const name = `${row.groupPath}/${row.session.slug}`;
4292
+ const name = `${row.categoryPath}/${row.session.slug}`;
2931
4293
  const dot = STATUS_DOTS2[row.session.status] ?? "?";
2932
4294
  const stats = getSessionStats(row.session.id);
2933
4295
  const eventCount = String(stats?.eventCount ?? 0).padEnd(6);
@@ -3084,7 +4446,7 @@ var init_log = __esm(() => {
3084
4446
  init_router();
3085
4447
  init_sessions();
3086
4448
  init_events();
3087
- init_groups();
4449
+ init_categories();
3088
4450
  init_stats();
3089
4451
  init_conversations();
3090
4452
  init_catalog();
@@ -3105,27 +4467,27 @@ var init_log = __esm(() => {
3105
4467
  showAllSessions();
3106
4468
  return;
3107
4469
  }
3108
- const { groupPath, slug } = parseSessionName(target);
3109
- const group = getGroupByPath(groupPath);
3110
- if (!group) {
3111
- console.error(`Group not found: ${groupPath}`);
4470
+ const { categoryPath, slug } = parseSessionName(target);
4471
+ const category = getCategoryByPath(categoryPath);
4472
+ if (!category) {
4473
+ console.error(`Category not found: ${categoryPath}`);
3112
4474
  process.exit(1);
3113
4475
  }
3114
- const session = getSessionByGroupSlug(group.id, slug);
4476
+ const session = getSessionByCategorySlug(category.id, slug);
3115
4477
  if (!session) {
3116
4478
  console.error(`Session not found: ${target}`);
3117
4479
  process.exit(1);
3118
4480
  }
3119
- showSessionLog(session, `${groupPath}/${slug}`, isJson);
4481
+ showSessionLog(session, `${categoryPath}/${slug}`, isJson);
3120
4482
  });
3121
4483
  });
3122
4484
 
3123
4485
  // src/cli/commands/stats.ts
3124
4486
  var exports_stats = {};
3125
- function getMetrics(sessionId, name, status) {
4487
+ function getMetrics(sessionId, name, status2) {
3126
4488
  const stats = getSessionStats(sessionId);
3127
4489
  if (stats) {
3128
- return { name, status, ...stats };
4490
+ return { name, status: status2, ...stats };
3129
4491
  }
3130
4492
  const timing = computeTimingsLive(sessionId);
3131
4493
  const allEvents = getEventsBySession(sessionId);
@@ -3142,7 +4504,7 @@ function getMetrics(sessionId, name, status) {
3142
4504
  }
3143
4505
  return {
3144
4506
  name,
3145
- status,
4507
+ status: status2,
3146
4508
  eventCount: allEvents.length,
3147
4509
  conversationCount: conversations2.size,
3148
4510
  interactionCount,
@@ -3189,7 +4551,7 @@ function renderGlobal(metrics, isJson) {
3189
4551
  console.log(` PRs: ${totals.prs}`);
3190
4552
  console.log(` Interactions: ${totals.interactions}`);
3191
4553
  }
3192
- function renderGroup(metrics, groupPath, isJson) {
4554
+ function renderCategory(metrics, categoryPath, isJson) {
3193
4555
  if (isJson) {
3194
4556
  console.log(JSON.stringify(metrics, null, 2));
3195
4557
  return;
@@ -3199,7 +4561,7 @@ function renderGroup(metrics, groupPath, isJson) {
3199
4561
  const reset = "\x1B[0m";
3200
4562
  const sorted = [...metrics].sort((a, b) => b.durationS - a.durationS);
3201
4563
  const maxName = Math.max(...sorted.map((m) => m.name.length), 4);
3202
- console.log(`${bold}${groupPath}${reset}
4564
+ console.log(`${bold}${categoryPath}${reset}
3203
4565
  `);
3204
4566
  console.log(`${dim}${"NAME".padEnd(maxName)} ${"DURATION".padEnd(8)} ${"CLAUDE".padEnd(8)} ${"WAIT".padEnd(8)} ${"ACT%".padEnd(5)} ${"CONVOS".padEnd(6)} PRS${reset}`);
3205
4567
  for (const m of sorted) {
@@ -3240,7 +4602,7 @@ var init_stats2 = __esm(() => {
3240
4602
  init_router();
3241
4603
  init_sessions();
3242
4604
  init_stats();
3243
- init_groups();
4605
+ init_categories();
3244
4606
  init_events();
3245
4607
  init_timing();
3246
4608
  init_format();
@@ -3251,34 +4613,34 @@ var init_stats2 = __esm(() => {
3251
4613
  const target = filteredArgs[0];
3252
4614
  if (!target) {
3253
4615
  const rows = getAllSessions();
3254
- const metrics = rows.map((r) => getMetrics(r.session.id, `${r.groupPath}/${r.session.slug}`, r.session.status));
4616
+ const metrics = rows.map((r) => getMetrics(r.session.id, `${r.categoryPath}/${r.session.slug}`, r.session.status));
3255
4617
  renderGlobal(metrics, isJson);
3256
4618
  return;
3257
4619
  }
3258
4620
  if (target.endsWith("/")) {
3259
- const groupPath2 = target.replace(/\/+$/, "");
3260
- const group2 = getGroupByPath(groupPath2);
3261
- if (!group2) {
3262
- console.error(`Group not found: ${groupPath2}`);
4621
+ const categoryPath2 = target.replace(/\/+$/, "");
4622
+ const category2 = getCategoryByPath(categoryPath2);
4623
+ if (!category2) {
4624
+ console.error(`Category not found: ${categoryPath2}`);
3263
4625
  process.exit(1);
3264
4626
  }
3265
- const groupSessions = getSessionsByGroup(group2.id);
3266
- const metrics = groupSessions.map((s) => getMetrics(s.id, s.slug, s.status));
3267
- renderGroup(metrics, groupPath2, isJson);
4627
+ const categorySessions = getSessionsByCategory(category2.id);
4628
+ const metrics = categorySessions.map((s) => getMetrics(s.id, s.slug, s.status));
4629
+ renderCategory(metrics, categoryPath2, isJson);
3268
4630
  return;
3269
4631
  }
3270
- const { groupPath, slug } = parseSessionName(target);
3271
- const group = getGroupByPath(groupPath);
3272
- if (!group) {
3273
- console.error(`Group not found: ${groupPath}`);
4632
+ const { categoryPath, slug } = parseSessionName(target);
4633
+ const category = getCategoryByPath(categoryPath);
4634
+ if (!category) {
4635
+ console.error(`Category not found: ${categoryPath}`);
3274
4636
  process.exit(1);
3275
4637
  }
3276
- const session = getSessionByGroupSlug(group.id, slug);
4638
+ const session = getSessionByCategorySlug(category.id, slug);
3277
4639
  if (!session) {
3278
4640
  console.error(`Session not found: ${target}`);
3279
4641
  process.exit(1);
3280
4642
  }
3281
- const m = getMetrics(session.id, `${groupPath}/${slug}`, session.status);
4643
+ const m = getMetrics(session.id, `${categoryPath}/${slug}`, session.status);
3282
4644
  renderSession(m, isJson);
3283
4645
  });
3284
4646
  });
@@ -3293,9 +4655,9 @@ var init_backfill_stats = __esm(() => {
3293
4655
  const includeArchived = args.includes("--include-archived");
3294
4656
  const rows = getAllSessions({ excludeArchived: !includeArchived });
3295
4657
  console.log(`Backfilling stats for ${rows.length} session(s)${includeArchived ? "" : " (excluding archived)"}...`);
3296
- for (const { session, groupPath } of rows) {
4658
+ for (const { session, categoryPath } of rows) {
3297
4659
  computeAndPersist(session.id);
3298
- console.log(` \u2713 ${groupPath}/${session.slug}`);
4660
+ console.log(` \u2713 ${categoryPath}/${session.slug}`);
3299
4661
  }
3300
4662
  console.log("Done.");
3301
4663
  });
@@ -3304,23 +4666,23 @@ var init_backfill_stats = __esm(() => {
3304
4666
  // src/cli/commands/archive.ts
3305
4667
  var exports_archive = {};
3306
4668
  function resolveSession(name) {
3307
- const { groupPath, slug } = parseSessionName(name);
3308
- const group = getGroupByPath(groupPath);
3309
- if (!group) {
3310
- console.error(`Group not found: ${groupPath}`);
4669
+ const { categoryPath, slug } = parseSessionName(name);
4670
+ const category = getCategoryByPath(categoryPath);
4671
+ if (!category) {
4672
+ console.error(`Category not found: ${categoryPath}`);
3311
4673
  process.exit(1);
3312
4674
  }
3313
- const session = getSessionByGroupSlug(group.id, slug);
4675
+ const session = getSessionByCategorySlug(category.id, slug);
3314
4676
  if (!session) {
3315
4677
  console.error(`Session not found: ${name}`);
3316
4678
  process.exit(1);
3317
4679
  }
3318
- return { session, groupPath };
4680
+ return { session, categoryPath };
3319
4681
  }
3320
4682
  var init_archive = __esm(() => {
3321
4683
  init_router();
3322
4684
  init_sessions();
3323
- init_groups();
4685
+ init_categories();
3324
4686
  init_session_archive();
3325
4687
  register("archive", async (args) => {
3326
4688
  const isUndo = args.includes("--undo");
@@ -3333,8 +4695,8 @@ var init_archive = __esm(() => {
3333
4695
  console.log("No paused sessions to archive.");
3334
4696
  return;
3335
4697
  }
3336
- for (const { session: session2, groupPath: groupPath2 } of archived) {
3337
- console.log(` archived ${groupPath2}/${session2.slug}`);
4698
+ for (const { session: session2, categoryPath: categoryPath2 } of archived) {
4699
+ console.log(` archived ${categoryPath2}/${session2.slug}`);
3338
4700
  }
3339
4701
  console.log(`
3340
4702
  Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
@@ -3346,8 +4708,8 @@ Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
3346
4708
  console.error(" bertrand archive --all-paused");
3347
4709
  process.exit(1);
3348
4710
  }
3349
- const { session, groupPath } = resolveSession(sessionName);
3350
- const fullName = `${groupPath}/${session.slug}`;
4711
+ const { session, categoryPath } = resolveSession(sessionName);
4712
+ const fullName = `${categoryPath}/${session.slug}`;
3351
4713
  if (isUndo) {
3352
4714
  const result2 = unarchiveSession(session.id);
3353
4715
  if (!result2.ok) {
@@ -3380,6 +4742,302 @@ Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
3380
4742
  });
3381
4743
  });
3382
4744
 
4745
+ // src/cli/commands/project.ts
4746
+ var exports_project = {};
4747
+ __export(exports_project, {
4748
+ switchSubcommand: () => switchSubcommand,
4749
+ renameSubcommand: () => renameSubcommand,
4750
+ removeSubcommand: () => removeSubcommand,
4751
+ listSubcommand: () => listSubcommand,
4752
+ importSubcommand: () => importSubcommand,
4753
+ currentSubcommand: () => currentSubcommand,
4754
+ createSubcommand: () => createSubcommand,
4755
+ _UsageError: () => _UsageError
4756
+ });
4757
+ import { existsSync as existsSync9, rmSync } from "fs";
4758
+ function countSessions(slug) {
4759
+ const dbFile = projectPaths(slug).db;
4760
+ if (!existsSync9(dbFile))
4761
+ return { total: 0, active: 0 };
4762
+ try {
4763
+ const db = getDbForProject(slug);
4764
+ const all = db.select({ status: sessions.status }).from(sessions).all();
4765
+ return {
4766
+ total: all.length,
4767
+ active: all.filter((s) => s.status === "active" || s.status === "waiting").length
4768
+ };
4769
+ } catch {
4770
+ return UNREADABLE_COUNTS;
4771
+ }
4772
+ }
4773
+ function validateSlug(slug) {
4774
+ if (!slug) {
4775
+ throw new UsageError("Slug required.");
4776
+ }
4777
+ if (!SLUG_PATTERN.test(slug)) {
4778
+ throw new UsageError(`Invalid slug "${slug}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes.`);
4779
+ }
4780
+ }
4781
+ function flagKey(token) {
4782
+ if (!token.startsWith("--"))
4783
+ return null;
4784
+ const eq8 = token.indexOf("=");
4785
+ return eq8 === -1 ? token.slice(2) : token.slice(2, eq8);
4786
+ }
4787
+ function flagInlineValue(token) {
4788
+ if (!token.startsWith("--"))
4789
+ return null;
4790
+ const eq8 = token.indexOf("=");
4791
+ return eq8 === -1 ? null : token.slice(eq8 + 1);
4792
+ }
4793
+ function parseFlag(args, name) {
4794
+ for (let i = 0;i < args.length; i++) {
4795
+ const key = flagKey(args[i]);
4796
+ if (key !== name)
4797
+ continue;
4798
+ const inline = flagInlineValue(args[i]);
4799
+ if (inline !== null)
4800
+ return inline;
4801
+ return args[i + 1];
4802
+ }
4803
+ return;
4804
+ }
4805
+ function hasFlag(args, name) {
4806
+ return args.some((a) => flagKey(a) === name);
4807
+ }
4808
+ function positional(args) {
4809
+ const out = [];
4810
+ for (let i = 0;i < args.length; i++) {
4811
+ const a = args[i];
4812
+ if (a.startsWith("--")) {
4813
+ if (a.includes("="))
4814
+ continue;
4815
+ const next = args[i + 1];
4816
+ if (next && !next.startsWith("--"))
4817
+ i++;
4818
+ continue;
4819
+ }
4820
+ out.push(a);
4821
+ }
4822
+ return out;
4823
+ }
4824
+ function listSubcommand(args) {
4825
+ const isJson = hasFlag(args, "json");
4826
+ const projects = listProjects();
4827
+ const activeSlug = getActiveProjectSlug();
4828
+ const rows = projects.map((p) => ({
4829
+ slug: p.slug,
4830
+ name: p.name,
4831
+ active: p.slug === activeSlug,
4832
+ sessions: countSessions(p.slug),
4833
+ lastUsedAt: p.lastUsedAt
4834
+ }));
4835
+ if (isJson) {
4836
+ console.log(JSON.stringify(rows, null, 2));
4837
+ return;
4838
+ }
4839
+ if (rows.length === 0) {
4840
+ console.log("No projects registered yet.");
4841
+ return;
4842
+ }
4843
+ const dim = "\x1B[2m";
4844
+ const reset = "\x1B[0m";
4845
+ const bold = "\x1B[1m";
4846
+ const maxSlug = Math.max(...rows.map((r) => r.slug.length), 4);
4847
+ const maxName = Math.max(...rows.map((r) => r.name.length), 4);
4848
+ console.log(`${dim} ${"SLUG".padEnd(maxSlug)} ${"NAME".padEnd(maxName)} ${"SESSIONS".padEnd(10)} LAST USED${reset}`);
4849
+ for (const r of rows) {
4850
+ const marker = r.active ? `${bold}*${reset}` : " ";
4851
+ const sessionStr = r.sessions.unreadable ? "?".padEnd(10) : `${r.sessions.total} (${r.sessions.active} active)`.padEnd(10);
4852
+ const ago = formatAgo(r.lastUsedAt);
4853
+ console.log(`${marker} ${r.slug.padEnd(maxSlug)} ${r.name.padEnd(maxName)} ${sessionStr} ${ago}`);
4854
+ }
4855
+ }
4856
+ function createSubcommand(args) {
4857
+ const [slug] = positional(args);
4858
+ validateSlug(slug ?? "");
4859
+ const customName = parseFlag(args, "name");
4860
+ const activate = hasFlag(args, "activate");
4861
+ if (listProjects().some((p) => p.slug === slug)) {
4862
+ throw new UsageError(`Project "${slug}" already exists.`);
4863
+ }
4864
+ createProject({ slug, name: customName });
4865
+ if (activate) {
4866
+ setActiveProjectSlug(slug);
4867
+ _resetActiveProjectCache();
4868
+ }
4869
+ console.log(`Created project "${slug}"${activate ? " (now active)" : ""}.`);
4870
+ }
4871
+ function switchSubcommand(args) {
4872
+ const [slug] = positional(args);
4873
+ if (!slug) {
4874
+ throw new UsageError("Usage: bertrand project switch <slug>");
4875
+ }
4876
+ const projects = listProjects();
4877
+ if (!projects.some((p) => p.slug === slug)) {
4878
+ throw new UsageError(`Unknown project slug "${slug}".`);
4879
+ }
4880
+ const currentSlug = getActiveProjectSlug();
4881
+ if (currentSlug !== slug) {
4882
+ const counts = countSessions(currentSlug);
4883
+ if (counts.active > 0) {
4884
+ throw new UsageError(`Cannot switch: project "${currentSlug}" has ${counts.active} active/waiting session(s). Pause them first.`);
4885
+ }
4886
+ }
4887
+ setActiveProjectSlug(slug);
4888
+ _resetActiveProjectCache();
4889
+ console.log(`Switched active project to "${slug}".`);
4890
+ }
4891
+ function currentSubcommand(args) {
4892
+ const isJson = hasFlag(args, "json");
4893
+ const active = resolveActiveProject();
4894
+ if (isJson) {
4895
+ console.log(JSON.stringify(active, null, 2));
4896
+ return;
4897
+ }
4898
+ console.log(`Active project: ${active.slug} (${active.name})`);
4899
+ console.log(` Root: ${active.root}`);
4900
+ console.log(` DB: ${active.db}`);
4901
+ console.log(` SyncEnv: ${active.syncEnv}`);
4902
+ }
4903
+ function renameSubcommand(args) {
4904
+ const [slug, ...rest] = positional(args);
4905
+ const newName = rest.join(" ");
4906
+ if (!slug || !newName) {
4907
+ throw new UsageError("Usage: bertrand project rename <slug> <new-name>");
4908
+ }
4909
+ renameProject(slug, newName);
4910
+ console.log(`Renamed "${slug}" to "${newName}".`);
4911
+ }
4912
+ function removeSubcommand(args) {
4913
+ const [slug] = positional(args);
4914
+ if (!slug) {
4915
+ throw new UsageError("Usage: bertrand project remove <slug> [--force] [--purge]");
4916
+ }
4917
+ validateSlug(slug);
4918
+ const force = hasFlag(args, "force");
4919
+ const purge = hasFlag(args, "purge");
4920
+ const projects = listProjects();
4921
+ const entry = projects.find((p) => p.slug === slug);
4922
+ if (!entry) {
4923
+ throw new UsageError(`Unknown project slug "${slug}".`);
4924
+ }
4925
+ if (slug === getActiveProjectSlug()) {
4926
+ throw new UsageError(`Cannot remove the active project "${slug}". Switch to another project first.`);
4927
+ }
4928
+ if (!force) {
4929
+ const counts = countSessions(slug);
4930
+ if (counts.total > 0) {
4931
+ throw new UsageError(`Project "${slug}" has ${counts.total} session(s). Pass --force to remove anyway.`);
4932
+ }
4933
+ }
4934
+ removeProject(slug);
4935
+ invalidateDbCache(slug);
4936
+ if (purge) {
4937
+ rmSync(projectPaths(slug).root, { recursive: true, force: true });
4938
+ }
4939
+ console.log(`Removed project "${slug}"${purge ? " (directory purged)" : " (directory left on disk; pass --purge to delete)"}.`);
4940
+ }
4941
+ async function importSubcommand(args) {
4942
+ const [bundle] = positional(args);
4943
+ if (!bundle) {
4944
+ throw new UsageError("Usage: bertrand project import <bundle>");
4945
+ }
4946
+ if (!isInvite(bundle)) {
4947
+ throw new UsageError(`Argument doesn't look like an invite bundle (expected to start with bertrand-sync://). ` + `Generate one on the source machine via \`bertrand sync invite\`.`);
4948
+ }
4949
+ console.log("Importing project from invite\u2026");
4950
+ const result = await bootstrapFromInvite(bundle);
4951
+ if (!result.ok) {
4952
+ throw new UsageError(result.error);
4953
+ }
4954
+ console.log(`\u2713 Created project "${result.project.slug}" (${result.project.name}) and activated it.`);
4955
+ if (result.pulled) {
4956
+ console.log(` Pulled ${result.bytes} bytes in ${result.durationMs}ms.`);
4957
+ } else {
4958
+ console.log(` No remote object yet \u2014 run \`bertrand sync push\` on the source machine first.`);
4959
+ }
4960
+ }
4961
+ function printProjectUsage() {
4962
+ console.log(`
4963
+ bertrand project \u2014 manage projects
4964
+
4965
+ Usage:
4966
+ bertrand project list [--json] List all projects
4967
+ bertrand project create <slug> [--name "..."] [--activate]
4968
+ Create a new project
4969
+ bertrand project switch <slug> Set the active project
4970
+ bertrand project current [--json] Show the active project
4971
+ bertrand project rename <slug> <new-name> Rename a project (display name only)
4972
+ bertrand project remove <slug> [--force] [--purge]
4973
+ Remove a project entry
4974
+ bertrand project import <bundle> Import a project from a \`bertrand sync invite\` bundle
4975
+ `.trim());
4976
+ }
4977
+ var SLUG_PATTERN, UsageError, UNREADABLE_COUNTS, KNOWN_SUBS, _UsageError;
4978
+ var init_project = __esm(() => {
4979
+ init_router();
4980
+ init_client();
4981
+ init_schema();
4982
+ init_registry();
4983
+ init_paths2();
4984
+ init_create();
4985
+ init_resolve();
4986
+ init_bootstrap();
4987
+ init_format();
4988
+ SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
4989
+ UsageError = class UsageError extends Error {
4990
+ };
4991
+ UNREADABLE_COUNTS = { total: 0, active: 0, unreadable: true };
4992
+ KNOWN_SUBS = new Set([
4993
+ "list",
4994
+ "create",
4995
+ "switch",
4996
+ "current",
4997
+ "rename",
4998
+ "remove",
4999
+ "import"
5000
+ ]);
5001
+ register("project", async (args) => {
5002
+ const sub = args[0];
5003
+ try {
5004
+ switch (sub) {
5005
+ case "list":
5006
+ return listSubcommand(args.slice(1));
5007
+ case "create":
5008
+ return createSubcommand(args.slice(1));
5009
+ case "switch":
5010
+ return switchSubcommand(args.slice(1));
5011
+ case "current":
5012
+ return currentSubcommand(args.slice(1));
5013
+ case "rename":
5014
+ return renameSubcommand(args.slice(1));
5015
+ case "remove":
5016
+ return removeSubcommand(args.slice(1));
5017
+ case "import":
5018
+ return await importSubcommand(args.slice(1));
5019
+ case undefined:
5020
+ case "--help":
5021
+ case "-h":
5022
+ printProjectUsage();
5023
+ return;
5024
+ default:
5025
+ throw new UsageError(`Unknown subcommand: ${sub}`);
5026
+ }
5027
+ } catch (err) {
5028
+ if (err instanceof UsageError) {
5029
+ console.error(err.message);
5030
+ if (sub && !KNOWN_SUBS.has(sub)) {
5031
+ printProjectUsage();
5032
+ }
5033
+ process.exit(1);
5034
+ }
5035
+ throw err;
5036
+ }
5037
+ });
5038
+ _UsageError = UsageError;
5039
+ });
5040
+
3383
5041
  // src/index.ts
3384
5042
  init_router();
3385
5043
  var command = process.argv[2];
@@ -3390,7 +5048,8 @@ var hotPath = {
3390
5048
  "recap-thinking": () => Promise.resolve().then(() => (init_recap_thinking(), exports_recap_thinking)),
3391
5049
  badge: () => Promise.resolve().then(() => (init_badge(), exports_badge)),
3392
5050
  notify: () => Promise.resolve().then(() => (init_notify(), exports_notify)),
3393
- serve: () => Promise.resolve().then(() => (init_serve(), exports_serve))
5051
+ serve: () => Promise.resolve().then(() => (init_serve(), exports_serve)),
5052
+ sync: () => Promise.resolve().then(() => (init_sync(), exports_sync))
3394
5053
  };
3395
5054
  if (command && command in hotPath) {
3396
5055
  await hotPath[command]();
@@ -3409,7 +5068,9 @@ if (command && command in hotPath) {
3409
5068
  Promise.resolve().then(() => (init_recap_thinking(), exports_recap_thinking)),
3410
5069
  Promise.resolve().then(() => (init_serve(), exports_serve)),
3411
5070
  Promise.resolve().then(() => (init_badge(), exports_badge)),
3412
- Promise.resolve().then(() => (init_notify(), exports_notify))
5071
+ Promise.resolve().then(() => (init_notify(), exports_notify)),
5072
+ Promise.resolve().then(() => (init_sync(), exports_sync)),
5073
+ Promise.resolve().then(() => (init_project(), exports_project))
3413
5074
  ]);
3414
5075
  }
3415
5076
  await route(process.argv);