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 +1952 -291
- package/dist/dashboard/assets/{index-e53khqNa.js → index-ofrZyO9k.js} +102 -102
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/sw.js +1 -1
- package/dist/migrations/0003_categories_rename.sql +8 -0
- package/dist/migrations/meta/0003_snapshot.json +777 -0
- package/dist/migrations/meta/_journal.json +7 -0
- package/dist/run-screen.js +386 -49
- package/package.json +2 -1
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 (
|
|
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
|
-
|
|
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
|
|
622
|
+
var categories, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
|
|
119
623
|
var init_schema = __esm(() => {
|
|
120
|
-
|
|
624
|
+
categories = sqliteTable("categories", {
|
|
121
625
|
id: text("id").primaryKey(),
|
|
122
|
-
parentId: text("parent_id").references(() =>
|
|
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("
|
|
133
|
-
index("
|
|
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
|
-
|
|
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("
|
|
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 {
|
|
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 (
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
773
|
+
var MIGRATIONS_FOLDER, _cache, _migrated, _testDb = null;
|
|
238
774
|
var init_client = __esm(() => {
|
|
239
|
-
|
|
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
|
|
261
|
-
return getDb().select().from(sessions).where(and(eq(sessions.
|
|
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
|
|
264
|
-
return getDb().select().from(sessions).where(eq(sessions.
|
|
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,
|
|
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,
|
|
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
|
|
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 (!
|
|
1051
|
+
if (!existsSync5(filePath))
|
|
497
1052
|
return null;
|
|
498
|
-
const text2 =
|
|
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 (!
|
|
1111
|
+
if (!existsSync5(filePath))
|
|
557
1112
|
return null;
|
|
558
|
-
const text2 =
|
|
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
|
|
826
|
-
import { join as
|
|
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(
|
|
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,
|
|
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
|
|
1197
|
-
import { join as
|
|
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
|
-
|
|
1251
|
-
|
|
1823
|
+
join7(import.meta.dir, "dashboard"),
|
|
1824
|
+
join7(import.meta.dir, "..", "dashboard")
|
|
1252
1825
|
];
|
|
1253
1826
|
for (const dir of candidates) {
|
|
1254
|
-
if (
|
|
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 =
|
|
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(
|
|
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(),
|
|
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/
|
|
2747
|
+
// src/db/queries/categories.ts
|
|
1391
2748
|
import { eq as eq6, like, or, isNull } from "drizzle-orm";
|
|
1392
|
-
function
|
|
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(
|
|
2755
|
+
const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
|
|
1399
2756
|
if (!parent)
|
|
1400
|
-
throw new Error(`Parent
|
|
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(
|
|
2761
|
+
return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
|
|
1405
2762
|
}
|
|
1406
|
-
function
|
|
1407
|
-
return getDb().select().from(
|
|
2763
|
+
function getCategory(id) {
|
|
2764
|
+
return getDb().select().from(categories).where(eq6(categories.id, id)).get();
|
|
1408
2765
|
}
|
|
1409
|
-
function
|
|
1410
|
-
return getDb().select().from(
|
|
2766
|
+
function getCategoryByPath(path) {
|
|
2767
|
+
return getDb().select().from(categories).where(eq6(categories.path, path)).get();
|
|
1411
2768
|
}
|
|
1412
|
-
function
|
|
1413
|
-
const existing =
|
|
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
|
|
1421
|
-
if (
|
|
1422
|
-
parentId =
|
|
2777
|
+
const category = getCategoryByPath(partialPath);
|
|
2778
|
+
if (category) {
|
|
2779
|
+
parentId = category.id;
|
|
1423
2780
|
} else {
|
|
1424
2781
|
const slug = segments[i];
|
|
1425
|
-
const created =
|
|
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
|
|
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(
|
|
1543
|
-
const siblings =
|
|
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 `- ${
|
|
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 <
|
|
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 =
|
|
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
|
|
1687
|
-
import { readFileSync as
|
|
1688
|
-
import { join as
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
|
1775
|
-
if (
|
|
1776
|
-
const existing =
|
|
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
|
|
3124
|
+
throw new Error(`Session "${opts.slug}" already exists in category "${opts.categoryPath}"`);
|
|
1779
3125
|
}
|
|
1780
3126
|
}
|
|
1781
|
-
const
|
|
3127
|
+
const categoryId = getOrCreateCategoryPath(opts.categoryPath);
|
|
1782
3128
|
const session = createSession({
|
|
1783
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
|
1841
|
-
const sessionName =
|
|
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
|
|
1851
|
-
const siblingContext = buildSiblingContext(session.
|
|
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
|
-
|
|
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
|
|
1906
|
-
import { existsSync as
|
|
1907
|
-
import { join as
|
|
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 =
|
|
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(
|
|
1921
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3345
|
+
return;
|
|
1980
3346
|
case "create": {
|
|
1981
3347
|
const sessionId = await launch(selection);
|
|
1982
3348
|
await runSessionLoop(sessionId);
|
|
1983
|
-
|
|
3349
|
+
return;
|
|
1984
3350
|
}
|
|
1985
3351
|
case "pick": {
|
|
1986
3352
|
const conversationId = await resolveConversationForResume(selection.sessionId);
|
|
1987
3353
|
if (!conversationId)
|
|
1988
|
-
|
|
3354
|
+
return;
|
|
1989
3355
|
const sessionId = await resume({
|
|
1990
3356
|
sessionId: selection.sessionId,
|
|
1991
3357
|
conversationId
|
|
1992
3358
|
});
|
|
1993
3359
|
await runSessionLoop(sessionId);
|
|
1994
|
-
|
|
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 =
|
|
2006
|
-
return
|
|
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
|
|
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
|
|
2027
|
-
return {
|
|
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 {
|
|
2076
|
-
const sessionId = await launch({
|
|
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
|
|
2380
|
-
import { join as
|
|
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
|
-
|
|
3751
|
+
mkdirSync7(paths.hooks, { recursive: true });
|
|
2383
3752
|
for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
|
|
2384
|
-
const filePath =
|
|
2385
|
-
|
|
2386
|
-
|
|
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
|
|
2397
|
-
import { join as
|
|
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(
|
|
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
|
-
|
|
2415
|
-
|
|
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 =
|
|
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
|
|
2467
|
-
import { join as
|
|
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 =
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
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
|
|
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
|
|
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 =
|
|
2557
|
-
const launcher =
|
|
2558
|
-
|
|
2559
|
-
|
|
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
|
-
|
|
3922
|
+
chmodSync3(launcher, 493);
|
|
2563
3923
|
return launcher;
|
|
2564
3924
|
}
|
|
2565
3925
|
return null;
|
|
2566
3926
|
}
|
|
2567
|
-
var
|
|
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
|
-
|
|
3936
|
+
init_config2();
|
|
2576
3937
|
SOURCE_ENTRY = /\/src\/index\.tsx?$/;
|
|
2577
3938
|
register("init", async () => {
|
|
2578
3939
|
console.log(`bertrand init
|
|
2579
3940
|
`);
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
2671
|
-
const
|
|
4032
|
+
const categoryFlag = args.indexOf("--category");
|
|
4033
|
+
const categoryPath = categoryFlag !== -1 ? args[categoryFlag + 1] : undefined;
|
|
2672
4034
|
let sessionRows;
|
|
2673
|
-
if (
|
|
2674
|
-
const
|
|
2675
|
-
if (!
|
|
2676
|
-
console.error(`
|
|
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
|
|
2680
|
-
sessionRows =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
3109
|
-
const
|
|
3110
|
-
if (!
|
|
3111
|
-
console.error(`
|
|
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 =
|
|
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, `${
|
|
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,
|
|
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
|
|
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}${
|
|
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
|
-
|
|
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.
|
|
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
|
|
3260
|
-
const
|
|
3261
|
-
if (!
|
|
3262
|
-
console.error(`
|
|
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
|
|
3266
|
-
const metrics =
|
|
3267
|
-
|
|
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 {
|
|
3271
|
-
const
|
|
3272
|
-
if (!
|
|
3273
|
-
console.error(`
|
|
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 =
|
|
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, `${
|
|
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,
|
|
4658
|
+
for (const { session, categoryPath } of rows) {
|
|
3297
4659
|
computeAndPersist(session.id);
|
|
3298
|
-
console.log(` \u2713 ${
|
|
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 {
|
|
3308
|
-
const
|
|
3309
|
-
if (!
|
|
3310
|
-
console.error(`
|
|
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 =
|
|
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,
|
|
4680
|
+
return { session, categoryPath };
|
|
3319
4681
|
}
|
|
3320
4682
|
var init_archive = __esm(() => {
|
|
3321
4683
|
init_router();
|
|
3322
4684
|
init_sessions();
|
|
3323
|
-
|
|
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,
|
|
3337
|
-
console.log(` archived ${
|
|
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,
|
|
3350
|
-
const fullName = `${
|
|
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);
|