bertrand 0.18.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 +1253 -343
- 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 +384 -48
- package/package.json +1 -1
package/dist/bertrand.js
CHANGED
|
@@ -23,20 +23,325 @@ 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
27
|
sessions: join(homedir(), BERTRAND_DIR, "sessions"),
|
|
28
|
+
db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
|
|
29
29
|
syncEnv: join(homedir(), BERTRAND_DIR, "sync.env")
|
|
30
30
|
};
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
// src/
|
|
33
|
+
// src/lib/projects/registry.ts
|
|
34
34
|
import {
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
35
37
|
readFileSync,
|
|
38
|
+
readdirSync,
|
|
39
|
+
renameSync,
|
|
36
40
|
statSync,
|
|
37
|
-
writeFileSync
|
|
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")
|
|
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,
|
|
38
343
|
chmodSync,
|
|
39
|
-
existsSync
|
|
344
|
+
existsSync as existsSync3
|
|
40
345
|
} from "fs";
|
|
41
346
|
function parseEnv(contents) {
|
|
42
347
|
const out = {};
|
|
@@ -58,22 +363,26 @@ function parseEnv(contents) {
|
|
|
58
363
|
}
|
|
59
364
|
return out;
|
|
60
365
|
}
|
|
61
|
-
function
|
|
62
|
-
return
|
|
366
|
+
function defaultPath() {
|
|
367
|
+
return resolveActiveProject().syncEnv;
|
|
63
368
|
}
|
|
64
|
-
function
|
|
65
|
-
|
|
369
|
+
function hasSyncConfig(syncEnvPath) {
|
|
370
|
+
return existsSync3(syncEnvPath ?? defaultPath());
|
|
371
|
+
}
|
|
372
|
+
function loadSyncConfig(syncEnvPath) {
|
|
373
|
+
const path = syncEnvPath ?? defaultPath();
|
|
374
|
+
if (!existsSync3(path))
|
|
66
375
|
return null;
|
|
67
|
-
const mode =
|
|
376
|
+
const mode = statSync2(path).mode & 511;
|
|
68
377
|
if (mode & 63) {
|
|
69
|
-
console.warn(`warning: ${
|
|
378
|
+
console.warn(`warning: ${path} was mode 0${mode.toString(8)} (should be 0600). Tightening to 0600.`);
|
|
70
379
|
try {
|
|
71
|
-
chmodSync(
|
|
380
|
+
chmodSync(path, 384);
|
|
72
381
|
} catch (e) {
|
|
73
382
|
console.warn(`failed to chmod 0600: ${e instanceof Error ? e.message : String(e)}. Fix manually.`);
|
|
74
383
|
}
|
|
75
384
|
}
|
|
76
|
-
const env = parseEnv(
|
|
385
|
+
const env = parseEnv(readFileSync2(path, "utf8"));
|
|
77
386
|
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_KEY || !env.BERTRAND_SYNC_BUCKET || !env.BERTRAND_ENCRYPTION_KEY) {
|
|
78
387
|
return null;
|
|
79
388
|
}
|
|
@@ -86,7 +395,8 @@ function loadSyncConfig() {
|
|
|
86
395
|
clientName: env.BERTRAND_CLIENT_NAME || `bertrand-${process.platform}-${process.pid}`
|
|
87
396
|
};
|
|
88
397
|
}
|
|
89
|
-
function saveSyncConfig(cfg) {
|
|
398
|
+
function saveSyncConfig(cfg, syncEnvPath) {
|
|
399
|
+
const path = syncEnvPath ?? defaultPath();
|
|
90
400
|
const lines = [
|
|
91
401
|
"# bertrand sync configuration",
|
|
92
402
|
"# Created by `bertrand sync onboard`. chmod 600.",
|
|
@@ -105,13 +415,13 @@ function saveSyncConfig(cfg) {
|
|
|
105
415
|
`BERTRAND_CLIENT_NAME=${cfg.clientName}`,
|
|
106
416
|
""
|
|
107
417
|
];
|
|
108
|
-
|
|
418
|
+
writeFileSync2(path, lines.join(`
|
|
109
419
|
`), { mode: 384 });
|
|
110
|
-
chmodSync(
|
|
420
|
+
chmodSync(path, 384);
|
|
111
421
|
}
|
|
112
422
|
var KEYS;
|
|
113
423
|
var init_config = __esm(() => {
|
|
114
|
-
|
|
424
|
+
init_resolve();
|
|
115
425
|
KEYS = [
|
|
116
426
|
"SUPABASE_URL",
|
|
117
427
|
"SUPABASE_SERVICE_KEY",
|
|
@@ -123,17 +433,21 @@ var init_config = __esm(() => {
|
|
|
123
433
|
});
|
|
124
434
|
|
|
125
435
|
// src/lib/config.ts
|
|
126
|
-
import { readFileSync as
|
|
127
|
-
import { join as
|
|
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
|
+
}
|
|
128
441
|
function readConfig() {
|
|
129
442
|
try {
|
|
130
|
-
return JSON.parse(
|
|
443
|
+
return JSON.parse(readFileSync3(configPath(), "utf-8"));
|
|
131
444
|
} catch {
|
|
132
445
|
return null;
|
|
133
446
|
}
|
|
134
447
|
}
|
|
135
448
|
function writeConfig(config) {
|
|
136
|
-
|
|
449
|
+
mkdirSync3(_getRegistryDir(), { recursive: true });
|
|
450
|
+
writeFileSync3(configPath(), JSON.stringify(config, null, 2) + `
|
|
137
451
|
`);
|
|
138
452
|
}
|
|
139
453
|
function patchConfig(patch) {
|
|
@@ -160,10 +474,8 @@ function isPlainObject(v) {
|
|
|
160
474
|
function isSyncEnabled() {
|
|
161
475
|
return readConfig()?.sync?.enabled === true;
|
|
162
476
|
}
|
|
163
|
-
var CONFIG_PATH;
|
|
164
477
|
var init_config2 = __esm(() => {
|
|
165
|
-
|
|
166
|
-
CONFIG_PATH = join2(paths.root, "config.json");
|
|
478
|
+
init_registry();
|
|
167
479
|
});
|
|
168
480
|
|
|
169
481
|
// src/sync/trigger.ts
|
|
@@ -191,15 +503,29 @@ var init_trigger = __esm(() => {
|
|
|
191
503
|
});
|
|
192
504
|
|
|
193
505
|
// src/cli/router.ts
|
|
194
|
-
import { existsSync as
|
|
506
|
+
import { existsSync as existsSync4 } from "fs";
|
|
195
507
|
function register(name, handler) {
|
|
196
508
|
commands.set(name, handler);
|
|
197
509
|
}
|
|
198
510
|
function alias(from, to) {
|
|
199
511
|
aliases.set(from, to);
|
|
200
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
|
+
}
|
|
201
527
|
async function autoInitIfFirstRun() {
|
|
202
|
-
if (
|
|
528
|
+
if (existsSync4(resolveActiveProject().db))
|
|
203
529
|
return;
|
|
204
530
|
const init = commands.get("init");
|
|
205
531
|
if (!init)
|
|
@@ -216,6 +542,9 @@ async function autoInitIfFirstRun() {
|
|
|
216
542
|
async function route(argv) {
|
|
217
543
|
const args = argv.slice(2);
|
|
218
544
|
const command = args[0];
|
|
545
|
+
if (!command || !HOOK_COMMANDS.has(command)) {
|
|
546
|
+
migrateOrAbort();
|
|
547
|
+
}
|
|
219
548
|
if (!command) {
|
|
220
549
|
await autoInitIfFirstRun();
|
|
221
550
|
triggerBackgroundPull();
|
|
@@ -252,17 +581,28 @@ Usage:
|
|
|
252
581
|
bertrand log <session> View session log
|
|
253
582
|
bertrand stats <session> Session statistics
|
|
254
583
|
bertrand archive <name> Archive/unarchive a session
|
|
584
|
+
bertrand project <op> list|create|switch|current|rename|remove (see: bertrand project --help)
|
|
255
585
|
bertrand update Hook-facing state writer (internal)
|
|
256
586
|
bertrand serve Start dashboard HTTP server
|
|
257
587
|
bertrand sync <op> push|pull|status|onboard (see: bertrand sync --help)
|
|
258
588
|
`.trim());
|
|
259
589
|
}
|
|
260
|
-
var commands, aliases;
|
|
590
|
+
var commands, aliases, HOOK_COMMANDS;
|
|
261
591
|
var init_router = __esm(() => {
|
|
262
|
-
|
|
592
|
+
init_resolve();
|
|
593
|
+
init_migrate_layout();
|
|
594
|
+
init_registry();
|
|
263
595
|
init_trigger();
|
|
264
596
|
commands = new Map;
|
|
265
597
|
aliases = new Map;
|
|
598
|
+
HOOK_COMMANDS = new Set([
|
|
599
|
+
"update",
|
|
600
|
+
"snapshot",
|
|
601
|
+
"recap-thinking",
|
|
602
|
+
"assistant-message",
|
|
603
|
+
"notify",
|
|
604
|
+
"badge"
|
|
605
|
+
]);
|
|
266
606
|
});
|
|
267
607
|
|
|
268
608
|
// src/db/schema.ts
|
|
@@ -273,17 +613,17 @@ __export(exports_schema, {
|
|
|
273
613
|
sessionStats: () => sessionStats,
|
|
274
614
|
sessionLabels: () => sessionLabels,
|
|
275
615
|
labels: () => labels,
|
|
276
|
-
groups: () => groups,
|
|
277
616
|
events: () => events,
|
|
278
|
-
conversations: () => conversations
|
|
617
|
+
conversations: () => conversations,
|
|
618
|
+
categories: () => categories
|
|
279
619
|
});
|
|
280
620
|
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
281
621
|
import { sql } from "drizzle-orm";
|
|
282
|
-
var
|
|
622
|
+
var categories, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
|
|
283
623
|
var init_schema = __esm(() => {
|
|
284
|
-
|
|
624
|
+
categories = sqliteTable("categories", {
|
|
285
625
|
id: text("id").primaryKey(),
|
|
286
|
-
parentId: text("parent_id").references(() =>
|
|
626
|
+
parentId: text("parent_id").references(() => categories.id, {
|
|
287
627
|
onDelete: "cascade"
|
|
288
628
|
}),
|
|
289
629
|
slug: text("slug").notNull(),
|
|
@@ -293,8 +633,8 @@ var init_schema = __esm(() => {
|
|
|
293
633
|
color: text("color"),
|
|
294
634
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`)
|
|
295
635
|
}, (t) => [
|
|
296
|
-
uniqueIndex("
|
|
297
|
-
index("
|
|
636
|
+
uniqueIndex("categories_parent_slug").on(t.parentId, t.slug),
|
|
637
|
+
index("categories_path").on(t.path)
|
|
298
638
|
]);
|
|
299
639
|
labels = sqliteTable("labels", {
|
|
300
640
|
id: text("id").primaryKey(),
|
|
@@ -304,7 +644,7 @@ var init_schema = __esm(() => {
|
|
|
304
644
|
});
|
|
305
645
|
sessions = sqliteTable("sessions", {
|
|
306
646
|
id: text("id").primaryKey(),
|
|
307
|
-
|
|
647
|
+
categoryId: text("category_id").notNull().references(() => categories.id, { onDelete: "cascade" }),
|
|
308
648
|
slug: text("slug").notNull(),
|
|
309
649
|
name: text("name").notNull(),
|
|
310
650
|
status: text("status", {
|
|
@@ -317,7 +657,7 @@ var init_schema = __esm(() => {
|
|
|
317
657
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
|
318
658
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
|
|
319
659
|
}, (t) => [
|
|
320
|
-
uniqueIndex("
|
|
660
|
+
uniqueIndex("sessions_category_slug").on(t.categoryId, t.slug),
|
|
321
661
|
index("sessions_status").on(t.status),
|
|
322
662
|
index("sessions_started").on(t.startedAt)
|
|
323
663
|
]);
|
|
@@ -383,25 +723,61 @@ var init_schema = __esm(() => {
|
|
|
383
723
|
// src/db/client.ts
|
|
384
724
|
import { Database } from "bun:sqlite";
|
|
385
725
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
386
|
-
import {
|
|
726
|
+
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
|
727
|
+
import { mkdirSync as mkdirSync4 } from "fs";
|
|
387
728
|
import { dirname } from "path";
|
|
388
729
|
function getDb() {
|
|
389
|
-
if (
|
|
390
|
-
return
|
|
391
|
-
|
|
392
|
-
|
|
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);
|
|
393
755
|
sqlite.exec("PRAGMA journal_mode = WAL");
|
|
394
756
|
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
395
757
|
sqlite.exec("PRAGMA synchronous = NORMAL");
|
|
396
758
|
sqlite.exec("PRAGMA cache_size = -8000");
|
|
397
759
|
sqlite.exec("PRAGMA temp_store = MEMORY");
|
|
398
|
-
|
|
399
|
-
|
|
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;
|
|
400
772
|
}
|
|
401
|
-
var
|
|
773
|
+
var MIGRATIONS_FOLDER, _cache, _migrated, _testDb = null;
|
|
402
774
|
var init_client = __esm(() => {
|
|
403
|
-
|
|
775
|
+
init_resolve();
|
|
776
|
+
init_paths2();
|
|
404
777
|
init_schema();
|
|
778
|
+
MIGRATIONS_FOLDER = import.meta.dir + "/migrations";
|
|
779
|
+
_cache = new Map;
|
|
780
|
+
_migrated = new Set;
|
|
405
781
|
});
|
|
406
782
|
|
|
407
783
|
// src/lib/id.ts
|
|
@@ -421,18 +797,18 @@ function createSession(opts) {
|
|
|
421
797
|
function getSession(id) {
|
|
422
798
|
return getDb().select().from(sessions).where(eq(sessions.id, id)).get();
|
|
423
799
|
}
|
|
424
|
-
function
|
|
425
|
-
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();
|
|
426
802
|
}
|
|
427
|
-
function
|
|
428
|
-
return getDb().select().from(sessions).where(eq(sessions.
|
|
803
|
+
function getSessionsByCategory(categoryId) {
|
|
804
|
+
return getDb().select().from(sessions).where(eq(sessions.categoryId, categoryId)).all();
|
|
429
805
|
}
|
|
430
806
|
function getActiveSessions() {
|
|
431
|
-
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();
|
|
432
808
|
}
|
|
433
809
|
function getAllSessions(opts) {
|
|
434
810
|
const db = getDb();
|
|
435
|
-
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));
|
|
436
812
|
if (opts?.excludeArchived) {
|
|
437
813
|
return query.where(inArray(sessions.status, [
|
|
438
814
|
"active",
|
|
@@ -569,6 +945,16 @@ var init_conversations = __esm(() => {
|
|
|
569
945
|
|
|
570
946
|
// src/cli/commands/update.ts
|
|
571
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
|
+
}
|
|
572
958
|
var EVENT_STATUS_MAP;
|
|
573
959
|
var init_update = __esm(() => {
|
|
574
960
|
init_router();
|
|
@@ -619,6 +1005,7 @@ var init_update = __esm(() => {
|
|
|
619
1005
|
if (newStatus && newStatus === session.status) {
|
|
620
1006
|
return;
|
|
621
1007
|
}
|
|
1008
|
+
const ignoreStatusFlip = shouldIgnoreStatusFlip(newStatus, session.pid);
|
|
622
1009
|
let meta;
|
|
623
1010
|
if (metaJson) {
|
|
624
1011
|
try {
|
|
@@ -639,7 +1026,7 @@ var init_update = __esm(() => {
|
|
|
639
1026
|
summary: summaryArg || meta?.question || joinedAnswers || undefined,
|
|
640
1027
|
meta
|
|
641
1028
|
});
|
|
642
|
-
if (newStatus) {
|
|
1029
|
+
if (newStatus && !ignoreStatusFlip) {
|
|
643
1030
|
updateSessionStatus(sessionId, newStatus);
|
|
644
1031
|
}
|
|
645
1032
|
if (event === "session.waiting" && conversationId && meta?.question) {
|
|
@@ -652,7 +1039,7 @@ var init_update = __esm(() => {
|
|
|
652
1039
|
});
|
|
653
1040
|
|
|
654
1041
|
// src/lib/transcript.ts
|
|
655
|
-
import { existsSync as
|
|
1042
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
656
1043
|
function getContextWindowSize(model) {
|
|
657
1044
|
for (const [prefix, size] of Object.entries(CONTEXT_WINDOW_SIZES)) {
|
|
658
1045
|
if (model.startsWith(prefix))
|
|
@@ -661,9 +1048,9 @@ function getContextWindowSize(model) {
|
|
|
661
1048
|
return 200000;
|
|
662
1049
|
}
|
|
663
1050
|
function getLatestAssistantTurn(filePath) {
|
|
664
|
-
if (!
|
|
1051
|
+
if (!existsSync5(filePath))
|
|
665
1052
|
return null;
|
|
666
|
-
const text2 =
|
|
1053
|
+
const text2 = readFileSync4(filePath, "utf-8");
|
|
667
1054
|
const lines = text2.split(`
|
|
668
1055
|
`);
|
|
669
1056
|
const assistantEntries = [];
|
|
@@ -721,9 +1108,9 @@ function getLatestAssistantTurn(filePath) {
|
|
|
721
1108
|
};
|
|
722
1109
|
}
|
|
723
1110
|
function getContextSnapshot(filePath) {
|
|
724
|
-
if (!
|
|
1111
|
+
if (!existsSync5(filePath))
|
|
725
1112
|
return null;
|
|
726
|
-
const text2 =
|
|
1113
|
+
const text2 = readFileSync4(filePath, "utf-8");
|
|
727
1114
|
const lines = text2.split(`
|
|
728
1115
|
`);
|
|
729
1116
|
for (let i = lines.length - 1;i >= 0; i--) {
|
|
@@ -990,8 +1377,8 @@ class NoopAdapter {
|
|
|
990
1377
|
}
|
|
991
1378
|
|
|
992
1379
|
// src/terminal/index.ts
|
|
993
|
-
import { readFileSync as
|
|
994
|
-
import { join as
|
|
1380
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
1381
|
+
import { join as join6 } from "path";
|
|
995
1382
|
function getTerminalAdapter() {
|
|
996
1383
|
if (cachedAdapter)
|
|
997
1384
|
return cachedAdapter;
|
|
@@ -1010,7 +1397,7 @@ function getTerminalAdapter() {
|
|
|
1010
1397
|
}
|
|
1011
1398
|
function readConfigTerminal() {
|
|
1012
1399
|
try {
|
|
1013
|
-
const config = JSON.parse(
|
|
1400
|
+
const config = JSON.parse(readFileSync5(join6(paths.root, "config.json"), "utf-8"));
|
|
1014
1401
|
return config.terminal ?? null;
|
|
1015
1402
|
} catch {
|
|
1016
1403
|
return null;
|
|
@@ -1349,7 +1736,7 @@ function archiveAllPaused() {
|
|
|
1349
1736
|
const archived = [];
|
|
1350
1737
|
for (const row of paused) {
|
|
1351
1738
|
const updated = updateSessionStatus(row.session.id, "archived");
|
|
1352
|
-
archived.push({ session: updated,
|
|
1739
|
+
archived.push({ session: updated, categoryPath: row.categoryPath });
|
|
1353
1740
|
}
|
|
1354
1741
|
return { archived };
|
|
1355
1742
|
}
|
|
@@ -1361,8 +1748,8 @@ var init_session_archive = __esm(() => {
|
|
|
1361
1748
|
|
|
1362
1749
|
// src/server/index.ts
|
|
1363
1750
|
import { execFile } from "child_process";
|
|
1364
|
-
import { existsSync as
|
|
1365
|
-
import { join as
|
|
1751
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1752
|
+
import { join as join7 } from "path";
|
|
1366
1753
|
function liveStats(sessionId) {
|
|
1367
1754
|
return {
|
|
1368
1755
|
sessionId,
|
|
@@ -1376,6 +1763,24 @@ function archiveResponse(result) {
|
|
|
1376
1763
|
const meta = ARCHIVE_ERROR[result.reason] ?? { status: 400, message: "Operation failed" };
|
|
1377
1764
|
return Response.json({ error: meta.message, reason: result.reason }, { status: meta.status });
|
|
1378
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
|
+
}
|
|
1379
1784
|
async function handleOpen(req) {
|
|
1380
1785
|
let body;
|
|
1381
1786
|
try {
|
|
@@ -1415,11 +1820,11 @@ function match(pathname, url) {
|
|
|
1415
1820
|
}
|
|
1416
1821
|
function findDashboardDir() {
|
|
1417
1822
|
const candidates = [
|
|
1418
|
-
|
|
1419
|
-
|
|
1823
|
+
join7(import.meta.dir, "dashboard"),
|
|
1824
|
+
join7(import.meta.dir, "..", "dashboard")
|
|
1420
1825
|
];
|
|
1421
1826
|
for (const dir of candidates) {
|
|
1422
|
-
if (
|
|
1827
|
+
if (existsSync6(join7(dir, "index.html")))
|
|
1423
1828
|
return dir;
|
|
1424
1829
|
}
|
|
1425
1830
|
return null;
|
|
@@ -1428,13 +1833,13 @@ async function serveDashboard(pathname) {
|
|
|
1428
1833
|
if (!DASHBOARD_DIR)
|
|
1429
1834
|
return null;
|
|
1430
1835
|
const requested = pathname === "/" ? "/index.html" : pathname;
|
|
1431
|
-
const filePath =
|
|
1836
|
+
const filePath = join7(DASHBOARD_DIR, requested);
|
|
1432
1837
|
if (!filePath.startsWith(DASHBOARD_DIR))
|
|
1433
1838
|
return null;
|
|
1434
1839
|
const file = Bun.file(filePath);
|
|
1435
1840
|
if (await file.exists())
|
|
1436
1841
|
return new Response(file);
|
|
1437
|
-
return new Response(Bun.file(
|
|
1842
|
+
return new Response(Bun.file(join7(DASHBOARD_DIR, "index.html")));
|
|
1438
1843
|
}
|
|
1439
1844
|
function startServer(port = PORT) {
|
|
1440
1845
|
const server = Bun.serve({
|
|
@@ -1455,6 +1860,11 @@ function startServer(port = PORT) {
|
|
|
1455
1860
|
r.headers.set("Access-Control-Allow-Origin", "*");
|
|
1456
1861
|
return r;
|
|
1457
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
|
+
}
|
|
1458
1868
|
if (req.method === "POST") {
|
|
1459
1869
|
const archiveMatch = /^\/api\/sessions\/([^/]+)\/archive$/.exec(url.pathname);
|
|
1460
1870
|
if (archiveMatch) {
|
|
@@ -1517,7 +1927,18 @@ var PORT, listSessions = (_params, url) => {
|
|
|
1517
1927
|
return getSessionStats(sessionId) ?? liveStats(sessionId);
|
|
1518
1928
|
}, getEngagement = ({
|
|
1519
1929
|
sessionId
|
|
1520
|
-
}) => 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;
|
|
1521
1942
|
var init_server = __esm(() => {
|
|
1522
1943
|
init_sessions();
|
|
1523
1944
|
init_events();
|
|
@@ -1525,6 +1946,8 @@ var init_server = __esm(() => {
|
|
|
1525
1946
|
init_timing();
|
|
1526
1947
|
init_engagement_stats();
|
|
1527
1948
|
init_session_archive();
|
|
1949
|
+
init_registry();
|
|
1950
|
+
init_resolve();
|
|
1528
1951
|
PORT = Number(process.env.BERTRAND_PORT ?? 5200);
|
|
1529
1952
|
routes = [
|
|
1530
1953
|
[/^\/api\/sessions$/, listSessions],
|
|
@@ -1533,7 +1956,9 @@ var init_server = __esm(() => {
|
|
|
1533
1956
|
[/^\/api\/stats$/, listAllStats],
|
|
1534
1957
|
[/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
|
|
1535
1958
|
[/^\/api\/engagement\/(?<sessionId>[^/]+)$/, getEngagement],
|
|
1536
|
-
[/^\/api\/recaps$/, listRecaps]
|
|
1959
|
+
[/^\/api\/recaps$/, listRecaps],
|
|
1960
|
+
[/^\/api\/projects$/, listAllProjects],
|
|
1961
|
+
[/^\/api\/active-project$/, getActiveProjectMeta]
|
|
1537
1962
|
];
|
|
1538
1963
|
ARCHIVE_ERROR = {
|
|
1539
1964
|
"not-found": { status: 404, message: "Session not found" },
|
|
@@ -1557,36 +1982,41 @@ var init_serve = __esm(() => {
|
|
|
1557
1982
|
|
|
1558
1983
|
// src/sync/snapshot.ts
|
|
1559
1984
|
import { Database as Database2 } from "bun:sqlite";
|
|
1560
|
-
import { existsSync as
|
|
1985
|
+
import { existsSync as existsSync7, unlinkSync } from "fs";
|
|
1986
|
+
function snapshotPathFor(dbPath) {
|
|
1987
|
+
return `${dbPath}.sync-snapshot`;
|
|
1988
|
+
}
|
|
1561
1989
|
function takeSnapshot() {
|
|
1562
1990
|
cleanupSnapshot();
|
|
1563
|
-
const
|
|
1991
|
+
const dbPath = resolveActiveProject().db;
|
|
1992
|
+
const target = snapshotPathFor(dbPath);
|
|
1993
|
+
const src = new Database2(dbPath, { readonly: true });
|
|
1564
1994
|
try {
|
|
1565
|
-
src.exec(`VACUUM INTO '${
|
|
1995
|
+
src.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
|
|
1566
1996
|
} finally {
|
|
1567
1997
|
src.close();
|
|
1568
1998
|
}
|
|
1569
|
-
return
|
|
1999
|
+
return target;
|
|
1570
2000
|
}
|
|
1571
2001
|
function cleanupSnapshot() {
|
|
2002
|
+
const base = snapshotPathFor(resolveActiveProject().db);
|
|
1572
2003
|
for (const suffix of SIDECAR_SUFFIXES) {
|
|
1573
|
-
const p =
|
|
1574
|
-
if (
|
|
2004
|
+
const p = base + suffix;
|
|
2005
|
+
if (existsSync7(p)) {
|
|
1575
2006
|
try {
|
|
1576
2007
|
unlinkSync(p);
|
|
1577
2008
|
} catch {}
|
|
1578
2009
|
}
|
|
1579
2010
|
}
|
|
1580
2011
|
}
|
|
1581
|
-
var SIDECAR_SUFFIXES
|
|
2012
|
+
var SIDECAR_SUFFIXES;
|
|
1582
2013
|
var init_snapshot2 = __esm(() => {
|
|
1583
|
-
|
|
2014
|
+
init_resolve();
|
|
1584
2015
|
SIDECAR_SUFFIXES = ["", "-wal", "-shm"];
|
|
1585
|
-
SNAPSHOT_PATH = `${paths.db}.sync-snapshot`;
|
|
1586
2016
|
});
|
|
1587
2017
|
|
|
1588
2018
|
// src/sync/crypto.ts
|
|
1589
|
-
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
|
2019
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "crypto";
|
|
1590
2020
|
function parseKey(base64Key) {
|
|
1591
2021
|
const buf = Buffer.from(base64Key, "base64");
|
|
1592
2022
|
if (buf.length !== 32) {
|
|
@@ -1595,11 +2025,11 @@ function parseKey(base64Key) {
|
|
|
1595
2025
|
return buf;
|
|
1596
2026
|
}
|
|
1597
2027
|
function generateKeyBase64() {
|
|
1598
|
-
return
|
|
2028
|
+
return randomBytes2(32).toString("base64");
|
|
1599
2029
|
}
|
|
1600
2030
|
function encrypt(plaintext, base64Key) {
|
|
1601
2031
|
const key = parseKey(base64Key);
|
|
1602
|
-
const iv =
|
|
2032
|
+
const iv = randomBytes2(IV_LEN);
|
|
1603
2033
|
const cipher = createCipheriv(ALGO, key, iv);
|
|
1604
2034
|
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
1605
2035
|
const tag = cipher.getAuthTag();
|
|
@@ -1627,9 +2057,8 @@ var init_crypto = __esm(() => {
|
|
|
1627
2057
|
|
|
1628
2058
|
// src/sync/engine.ts
|
|
1629
2059
|
import { createClient } from "@supabase/supabase-js";
|
|
1630
|
-
import { readFileSync as
|
|
2060
|
+
import { readFileSync as readFileSync6, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
|
|
1631
2061
|
import { dirname as dirname2 } from "path";
|
|
1632
|
-
import { execFileSync } from "child_process";
|
|
1633
2062
|
function client(cfg) {
|
|
1634
2063
|
if (!cfg)
|
|
1635
2064
|
return null;
|
|
@@ -1658,7 +2087,7 @@ async function push() {
|
|
|
1658
2087
|
error: `snapshot failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1659
2088
|
};
|
|
1660
2089
|
}
|
|
1661
|
-
const plaintext =
|
|
2090
|
+
const plaintext = readFileSync6(snapshotPath);
|
|
1662
2091
|
const ciphertext = encrypt(plaintext, cfg.encryptionKey);
|
|
1663
2092
|
const { error } = await supabase.storage.from(cfg.bucket).upload(cfg.objectKey, ciphertext, {
|
|
1664
2093
|
contentType: "application/octet-stream",
|
|
@@ -1688,17 +2117,18 @@ async function pull(opts = {}) {
|
|
|
1688
2117
|
if (!cfg || !supabase) {
|
|
1689
2118
|
return { ok: false, operation: "pull", error: "sync config incomplete" };
|
|
1690
2119
|
}
|
|
1691
|
-
const
|
|
2120
|
+
const dbPath = resolveActiveProject().db;
|
|
2121
|
+
const holders = findHolders(dbPath);
|
|
1692
2122
|
if (holders.length > 0) {
|
|
1693
2123
|
const procs = holders.map((h) => `${h.command}(${h.pid})`).join(", ");
|
|
1694
2124
|
if (!opts.force) {
|
|
1695
2125
|
return {
|
|
1696
2126
|
ok: false,
|
|
1697
2127
|
operation: "pull",
|
|
1698
|
-
error: `${
|
|
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).`
|
|
1699
2129
|
};
|
|
1700
2130
|
}
|
|
1701
|
-
console.warn(`warning: --force pulling while ${procs} hold ${
|
|
2131
|
+
console.warn(`warning: --force pulling while ${procs} hold ${dbPath}. The running process may crash on next file access.`);
|
|
1702
2132
|
}
|
|
1703
2133
|
const started = performance.now();
|
|
1704
2134
|
try {
|
|
@@ -1717,7 +2147,7 @@ async function pull(opts = {}) {
|
|
|
1717
2147
|
}
|
|
1718
2148
|
const ciphertext = Buffer.from(await data.arrayBuffer());
|
|
1719
2149
|
const plaintext = decrypt(ciphertext, cfg.encryptionKey);
|
|
1720
|
-
const tmp = `${
|
|
2150
|
+
const tmp = `${dbPath}.pull-${process.pid}`;
|
|
1721
2151
|
const fd = openSync(tmp, "w");
|
|
1722
2152
|
try {
|
|
1723
2153
|
writeSync(fd, plaintext);
|
|
@@ -1725,8 +2155,8 @@ async function pull(opts = {}) {
|
|
|
1725
2155
|
} finally {
|
|
1726
2156
|
closeSync(fd);
|
|
1727
2157
|
}
|
|
1728
|
-
|
|
1729
|
-
const dirFd = openSync(dirname2(
|
|
2158
|
+
renameSync3(tmp, dbPath);
|
|
2159
|
+
const dirFd = openSync(dirname2(dbPath), "r");
|
|
1730
2160
|
try {
|
|
1731
2161
|
fsyncSync(dirFd);
|
|
1732
2162
|
} finally {
|
|
@@ -1747,9 +2177,10 @@ async function status() {
|
|
|
1747
2177
|
const cfg = loadSyncConfig();
|
|
1748
2178
|
if (!cfg)
|
|
1749
2179
|
return { configured: false, local: null, remote: null };
|
|
2180
|
+
const dbPath = resolveActiveProject().db;
|
|
1750
2181
|
let local = null;
|
|
1751
2182
|
try {
|
|
1752
|
-
const s =
|
|
2183
|
+
const s = statSync3(dbPath);
|
|
1753
2184
|
local = { size: s.size, modifiedAt: new Date(s.mtimeMs) };
|
|
1754
2185
|
} catch {
|
|
1755
2186
|
local = null;
|
|
@@ -1774,48 +2205,25 @@ async function status() {
|
|
|
1774
2205
|
}
|
|
1775
2206
|
return { configured: true, local, remote };
|
|
1776
2207
|
}
|
|
1777
|
-
function findHolders(path) {
|
|
1778
|
-
try {
|
|
1779
|
-
const out = execFileSync("lsof", ["-F", "pcn", path], {
|
|
1780
|
-
encoding: "utf8",
|
|
1781
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
1782
|
-
});
|
|
1783
|
-
const holders = [];
|
|
1784
|
-
let pid = 0;
|
|
1785
|
-
let command = "";
|
|
1786
|
-
for (const line of out.split(`
|
|
1787
|
-
`)) {
|
|
1788
|
-
if (line.startsWith("p"))
|
|
1789
|
-
pid = Number(line.slice(1));
|
|
1790
|
-
else if (line.startsWith("c"))
|
|
1791
|
-
command = line.slice(1);
|
|
1792
|
-
else if (line.startsWith("n")) {
|
|
1793
|
-
if (pid && pid !== process.pid && command) {
|
|
1794
|
-
holders.push({ pid, command });
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
}
|
|
1798
|
-
return holders;
|
|
1799
|
-
} catch {
|
|
1800
|
-
return [];
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
2208
|
var init_engine = __esm(() => {
|
|
1804
|
-
|
|
2209
|
+
init_resolve();
|
|
2210
|
+
init_lsof();
|
|
1805
2211
|
init_config();
|
|
1806
2212
|
init_snapshot2();
|
|
1807
2213
|
init_crypto();
|
|
1808
2214
|
});
|
|
1809
2215
|
|
|
1810
2216
|
// src/sync/invite.ts
|
|
1811
|
-
function encodeInvite(cfg) {
|
|
2217
|
+
function encodeInvite(cfg, project) {
|
|
1812
2218
|
const bundle = {
|
|
1813
2219
|
v: VERSION,
|
|
1814
2220
|
url: cfg.supabaseUrl,
|
|
1815
2221
|
key: cfg.supabaseServiceKey,
|
|
1816
2222
|
bucket: cfg.bucket,
|
|
1817
2223
|
obj: cfg.objectKey,
|
|
1818
|
-
ek: cfg.encryptionKey
|
|
2224
|
+
ek: cfg.encryptionKey,
|
|
2225
|
+
psl: project.slug,
|
|
2226
|
+
pn: project.name
|
|
1819
2227
|
};
|
|
1820
2228
|
return SCHEME + Buffer.from(JSON.stringify(bundle), "utf8").toString("base64url");
|
|
1821
2229
|
}
|
|
@@ -1839,22 +2247,124 @@ function decodeInvite(invite) {
|
|
|
1839
2247
|
}
|
|
1840
2248
|
const bundle = parsed;
|
|
1841
2249
|
if (bundle.v !== VERSION) {
|
|
1842
|
-
throw new Error(`invite version ${String(bundle.v)} is not supported
|
|
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.`);
|
|
1843
2251
|
}
|
|
1844
|
-
for (const field of ["url", "key", "bucket", "obj", "ek"]) {
|
|
2252
|
+
for (const field of ["url", "key", "bucket", "obj", "ek", "psl", "pn"]) {
|
|
1845
2253
|
if (!bundle[field] || typeof bundle[field] !== "string") {
|
|
1846
2254
|
throw new Error(`invite is missing required field: ${field}`);
|
|
1847
2255
|
}
|
|
1848
2256
|
}
|
|
1849
2257
|
return {
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
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
|
|
1855
2358
|
};
|
|
1856
2359
|
}
|
|
1857
|
-
var
|
|
2360
|
+
var init_bootstrap = __esm(() => {
|
|
2361
|
+
init_config();
|
|
2362
|
+
init_engine();
|
|
2363
|
+
init_registry();
|
|
2364
|
+
init_create();
|
|
2365
|
+
init_resolve();
|
|
2366
|
+
init_config2();
|
|
2367
|
+
});
|
|
1858
2368
|
|
|
1859
2369
|
// src/lib/format.ts
|
|
1860
2370
|
function formatDuration(ms) {
|
|
@@ -1912,32 +2422,65 @@ var init_format = __esm(() => {
|
|
|
1912
2422
|
|
|
1913
2423
|
// src/cli/commands/sync.ts
|
|
1914
2424
|
var exports_sync = {};
|
|
1915
|
-
import { hostname } from "os";
|
|
2425
|
+
import { hostname as hostname2 } from "os";
|
|
1916
2426
|
function printUsage2() {
|
|
1917
2427
|
console.log(`
|
|
1918
|
-
bertrand sync \u2014 replicate
|
|
2428
|
+
bertrand sync \u2014 replicate a project's local DB to Supabase Storage (opt-in)
|
|
1919
2429
|
|
|
1920
2430
|
Usage:
|
|
1921
|
-
bertrand sync onboard
|
|
1922
|
-
bertrand sync invite
|
|
1923
|
-
bertrand sync <bundle>
|
|
1924
|
-
bertrand sync push
|
|
1925
|
-
bertrand sync pull [--force]
|
|
1926
|
-
|
|
1927
|
-
bertrand sync
|
|
1928
|
-
bertrand sync
|
|
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.
|
|
1929
2443
|
|
|
1930
2444
|
Cross-machine setup:
|
|
1931
2445
|
Machine A: bertrand sync invite
|
|
1932
2446
|
\u2192 bertrand-sync://eyJ\u2026 (transmit via Signal/iMessage/AirDrop \u2014 sensitive)
|
|
1933
2447
|
Machine B: bertrand sync bertrand-sync://eyJ\u2026
|
|
1934
|
-
\u2192 onboards, pulls, ready
|
|
2448
|
+
\u2192 creates the project, onboards, pulls, ready
|
|
1935
2449
|
`.trim());
|
|
1936
2450
|
}
|
|
1937
2451
|
function abort(reason) {
|
|
1938
2452
|
console.error(`Aborted: ${reason}`);
|
|
1939
2453
|
process.exit(1);
|
|
1940
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
|
+
}
|
|
1941
2484
|
function prompt(label, opts = {}) {
|
|
1942
2485
|
const suffix = opts.default ? ` [${opts.default}]` : "";
|
|
1943
2486
|
process.stdout.write(`${label}${suffix}: `);
|
|
@@ -1962,9 +2505,10 @@ function prompt(label, opts = {}) {
|
|
|
1962
2505
|
});
|
|
1963
2506
|
}
|
|
1964
2507
|
async function runOnboard() {
|
|
2508
|
+
const active = resolveActiveProject();
|
|
1965
2509
|
const existing = loadSyncConfig();
|
|
1966
2510
|
if (existing) {
|
|
1967
|
-
console.log(`Existing config at ${
|
|
2511
|
+
console.log(`Existing config for project "${active.slug}" at ${active.syncEnv}:`);
|
|
1968
2512
|
console.log(` SUPABASE_URL: ${existing.supabaseUrl}`);
|
|
1969
2513
|
console.log(` SUPABASE_SERVICE_KEY: ${existing.supabaseServiceKey.slice(0, 12)}\u2026`);
|
|
1970
2514
|
console.log(` BERTRAND_SYNC_BUCKET: ${existing.bucket}`);
|
|
@@ -1972,10 +2516,10 @@ async function runOnboard() {
|
|
|
1972
2516
|
console.log(` BERTRAND_ENCRYPTION_KEY: (set, redacted)`);
|
|
1973
2517
|
console.log(` BERTRAND_CLIENT_NAME: ${existing.clientName}`);
|
|
1974
2518
|
console.log(`
|
|
1975
|
-
Delete ${
|
|
2519
|
+
Delete ${active.syncEnv} and re-run to start over.`);
|
|
1976
2520
|
return;
|
|
1977
2521
|
}
|
|
1978
|
-
console.log(`Bertrand sync setup
|
|
2522
|
+
console.log(`Bertrand sync setup for project "${active.slug}" (${active.name})
|
|
1979
2523
|
`);
|
|
1980
2524
|
console.log(`In the Supabase dashboard (app.supabase.com):`);
|
|
1981
2525
|
console.log(` 1. Create or pick a project`);
|
|
@@ -1985,7 +2529,7 @@ Delete ${paths.syncEnv} and re-run to start over.`);
|
|
|
1985
2529
|
console.log(` is below it and warns "keep secret")`);
|
|
1986
2530
|
console.log(` 4. Storage \u2192 New bucket: name it "bertrand", PRIVATE (uncheck "Public bucket")
|
|
1987
2531
|
`);
|
|
1988
|
-
const projectId = await prompt("Project ID");
|
|
2532
|
+
const projectId = await prompt("Supabase Project ID");
|
|
1989
2533
|
if (!projectId)
|
|
1990
2534
|
return abort("no project ID provided");
|
|
1991
2535
|
if (!/^[a-z0-9]{15,30}$/i.test(projectId)) {
|
|
@@ -2000,8 +2544,9 @@ Delete ${paths.syncEnv} and re-run to start over.`);
|
|
|
2000
2544
|
return abort("service key must be a JWT starting with eyJ \u2014 did you paste the URL by mistake?");
|
|
2001
2545
|
}
|
|
2002
2546
|
const bucket = await prompt("Storage bucket name", { default: "bertrand" });
|
|
2003
|
-
const
|
|
2004
|
-
const
|
|
2547
|
+
const defaultObject = `projects/${active.slug}/bertrand.db.enc`;
|
|
2548
|
+
const objectKey = await prompt("Object key", { default: defaultObject });
|
|
2549
|
+
const defaultName = `bertrand-${hostname2()}`;
|
|
2005
2550
|
const clientName = await prompt("Client name", { default: defaultName });
|
|
2006
2551
|
console.log(`
|
|
2007
2552
|
Encryption key`);
|
|
@@ -2022,7 +2567,7 @@ Encryption key`);
|
|
|
2022
2567
|
encryptionKey = generateKeyBase64();
|
|
2023
2568
|
console.log(`
|
|
2024
2569
|
Generated a new key. Save this somewhere safe \u2014 you'll need it on every`);
|
|
2025
|
-
console.log(`other machine that should be able to pull this
|
|
2570
|
+
console.log(`other machine that should be able to pull this project:
|
|
2026
2571
|
`);
|
|
2027
2572
|
console.log(` ${encryptionKey}
|
|
2028
2573
|
`);
|
|
@@ -2031,18 +2576,16 @@ Generated a new key. Save this somewhere safe \u2014 you'll need it on every`);
|
|
|
2031
2576
|
}
|
|
2032
2577
|
saveSyncConfig({ supabaseUrl, supabaseServiceKey, bucket, objectKey, encryptionKey, clientName });
|
|
2033
2578
|
patchConfig({ sync: { enabled: true } });
|
|
2034
|
-
console.log(`Wrote ${
|
|
2579
|
+
console.log(`Wrote ${active.syncEnv} (mode 0600). Sync auto-triggers enabled.`);
|
|
2035
2580
|
console.log(`
|
|
2036
2581
|
Next:`);
|
|
2037
|
-
console.log(` bertrand sync push Send
|
|
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`);
|
|
2038
2584
|
console.log(` bertrand sync status See sizes and timestamps`);
|
|
2039
|
-
console.log(`
|
|
2040
|
-
On another machine: paste the same SUPABASE_URL, a fresh SERVICE_KEY (you can reuse),`);
|
|
2041
|
-
console.log(`the same bucket and object key, and the SAME encryption key. Then \`bertrand sync pull\`.`);
|
|
2042
2585
|
}
|
|
2043
2586
|
async function runPush() {
|
|
2044
2587
|
if (!hasSyncConfig())
|
|
2045
|
-
return abort("no sync config \u2014 run `bertrand sync onboard`");
|
|
2588
|
+
return abort("no sync config for the active project \u2014 run `bertrand sync onboard`");
|
|
2046
2589
|
process.stdout.write("Snapshot \u2192 encrypt \u2192 upload\u2026 ");
|
|
2047
2590
|
const result = await push();
|
|
2048
2591
|
if (result.ok) {
|
|
@@ -2055,7 +2598,7 @@ async function runPush() {
|
|
|
2055
2598
|
}
|
|
2056
2599
|
async function runPull(args) {
|
|
2057
2600
|
if (!hasSyncConfig())
|
|
2058
|
-
return abort("no sync config \u2014 run `bertrand sync onboard`");
|
|
2601
|
+
return abort("no sync config for the active project \u2014 run `bertrand sync onboard`");
|
|
2059
2602
|
const force = args.includes("--force");
|
|
2060
2603
|
process.stdout.write("Download \u2192 decrypt \u2192 replace\u2026 ");
|
|
2061
2604
|
const result = await pull({ force });
|
|
@@ -2072,11 +2615,12 @@ async function runPull(args) {
|
|
|
2072
2615
|
}
|
|
2073
2616
|
}
|
|
2074
2617
|
async function runStatus() {
|
|
2618
|
+
const active = resolveActiveProject();
|
|
2075
2619
|
if (!hasSyncConfig()) {
|
|
2076
|
-
console.log(
|
|
2620
|
+
console.log(`Sync is not configured for project "${active.slug}". Run \`bertrand sync onboard\` to set up.`);
|
|
2077
2621
|
return;
|
|
2078
2622
|
}
|
|
2079
|
-
console.log(`Sync status (${
|
|
2623
|
+
console.log(`Sync status for project "${active.slug}" (${active.syncEnv}):`);
|
|
2080
2624
|
console.log(` Auto-triggers: ${isSyncEnabled() ? "enabled" : "disabled"}`);
|
|
2081
2625
|
const s = await status();
|
|
2082
2626
|
if (s.local) {
|
|
@@ -2092,7 +2636,7 @@ async function runStatus() {
|
|
|
2092
2636
|
}
|
|
2093
2637
|
function runEnable() {
|
|
2094
2638
|
if (!hasSyncConfig()) {
|
|
2095
|
-
return abort("no sync config \u2014 run `bertrand sync onboard` first");
|
|
2639
|
+
return abort("no sync config for the active project \u2014 run `bertrand sync onboard` first");
|
|
2096
2640
|
}
|
|
2097
2641
|
patchConfig({ sync: { enabled: true } });
|
|
2098
2642
|
console.log("Sync auto-triggers enabled. Pull-on-launch and push-on-session-end will fire.");
|
|
@@ -2100,44 +2644,39 @@ function runEnable() {
|
|
|
2100
2644
|
function runInvite() {
|
|
2101
2645
|
const cfg = loadSyncConfig();
|
|
2102
2646
|
if (!cfg) {
|
|
2103
|
-
return abort("no sync config \u2014 run `bertrand sync onboard` first");
|
|
2647
|
+
return abort("no sync config for the active project \u2014 run `bertrand sync onboard` first");
|
|
2104
2648
|
}
|
|
2105
|
-
const
|
|
2106
|
-
|
|
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.");
|
|
2107
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})
|
|
2108
2655
|
`);
|
|
2109
2656
|
console.log(invite);
|
|
2110
2657
|
console.log(`
|
|
2111
2658
|
On the other machine:`);
|
|
2112
2659
|
console.log(` bertrand sync ${invite.slice(0, 32)}\u2026`);
|
|
2660
|
+
console.log(` (creates the project locally, saves config, runs first pull)`);
|
|
2113
2661
|
}
|
|
2114
2662
|
async function runBootstrap(invite) {
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
return abort(e instanceof Error ? e.message : String(e));
|
|
2123
|
-
}
|
|
2124
|
-
const clientName = `bertrand-${hostname()}`;
|
|
2125
|
-
saveSyncConfig({ ...decoded, clientName });
|
|
2126
|
-
patchConfig({ sync: { enabled: true } });
|
|
2127
|
-
console.log(`Wrote ${paths.syncEnv} (mode 0600). Auto-triggers enabled.`);
|
|
2128
|
-
console.log(`Pulling\u2026`);
|
|
2129
|
-
const result = await pull();
|
|
2130
|
-
if (result.ok) {
|
|
2131
|
-
if (result.pulled) {
|
|
2132
|
-
console.log(`\u2713 Done (${formatBytes(result.bytes)} in ${result.durationMs}ms). Run \`bertrand\` to start.`);
|
|
2133
|
-
} else {
|
|
2134
|
-
console.log(`\u2713 Config saved, but no remote object yet \u2014 nothing to pull.`);
|
|
2135
|
-
console.log(` Run \`bertrand sync push\` on the source machine first.`);
|
|
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);
|
|
2136
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.`);
|
|
2137
2677
|
} else {
|
|
2138
|
-
console.
|
|
2139
|
-
console.
|
|
2140
|
-
process.exit(1);
|
|
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.`);
|
|
2141
2680
|
}
|
|
2142
2681
|
}
|
|
2143
2682
|
function runDisable() {
|
|
@@ -2157,7 +2696,9 @@ var init_sync = __esm(() => {
|
|
|
2157
2696
|
init_config();
|
|
2158
2697
|
init_engine();
|
|
2159
2698
|
init_crypto();
|
|
2160
|
-
|
|
2699
|
+
init_bootstrap();
|
|
2700
|
+
init_registry();
|
|
2701
|
+
init_resolve();
|
|
2161
2702
|
init_format();
|
|
2162
2703
|
init_config2();
|
|
2163
2704
|
register("sync", async (args) => {
|
|
@@ -2166,12 +2707,14 @@ var init_sync = __esm(() => {
|
|
|
2166
2707
|
await runBootstrap(sub);
|
|
2167
2708
|
return;
|
|
2168
2709
|
}
|
|
2710
|
+
const { slug: projectOverride, rest } = extractProjectFlag(args.slice(1));
|
|
2711
|
+
applyProjectOverride(projectOverride);
|
|
2169
2712
|
switch (sub) {
|
|
2170
2713
|
case "push":
|
|
2171
2714
|
await runPush();
|
|
2172
2715
|
return;
|
|
2173
2716
|
case "pull":
|
|
2174
|
-
await runPull(
|
|
2717
|
+
await runPull(rest);
|
|
2175
2718
|
return;
|
|
2176
2719
|
case "status":
|
|
2177
2720
|
await runStatus();
|
|
@@ -2201,48 +2744,48 @@ var init_sync = __esm(() => {
|
|
|
2201
2744
|
});
|
|
2202
2745
|
});
|
|
2203
2746
|
|
|
2204
|
-
// src/db/queries/
|
|
2747
|
+
// src/db/queries/categories.ts
|
|
2205
2748
|
import { eq as eq6, like, or, isNull } from "drizzle-orm";
|
|
2206
|
-
function
|
|
2749
|
+
function createCategory(opts) {
|
|
2207
2750
|
const db = getDb();
|
|
2208
2751
|
const id = createId();
|
|
2209
2752
|
let path = opts.slug;
|
|
2210
2753
|
let depth = 0;
|
|
2211
2754
|
if (opts.parentId) {
|
|
2212
|
-
const parent = db.select().from(
|
|
2755
|
+
const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
|
|
2213
2756
|
if (!parent)
|
|
2214
|
-
throw new Error(`Parent
|
|
2757
|
+
throw new Error(`Parent category ${opts.parentId} not found`);
|
|
2215
2758
|
path = `${parent.path}/${opts.slug}`;
|
|
2216
2759
|
depth = parent.depth + 1;
|
|
2217
2760
|
}
|
|
2218
|
-
return db.insert(
|
|
2761
|
+
return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
|
|
2219
2762
|
}
|
|
2220
|
-
function
|
|
2221
|
-
return getDb().select().from(
|
|
2763
|
+
function getCategory(id) {
|
|
2764
|
+
return getDb().select().from(categories).where(eq6(categories.id, id)).get();
|
|
2222
2765
|
}
|
|
2223
|
-
function
|
|
2224
|
-
return getDb().select().from(
|
|
2766
|
+
function getCategoryByPath(path) {
|
|
2767
|
+
return getDb().select().from(categories).where(eq6(categories.path, path)).get();
|
|
2225
2768
|
}
|
|
2226
|
-
function
|
|
2227
|
-
const existing =
|
|
2769
|
+
function getOrCreateCategoryPath(path) {
|
|
2770
|
+
const existing = getCategoryByPath(path);
|
|
2228
2771
|
if (existing)
|
|
2229
2772
|
return existing.id;
|
|
2230
2773
|
const segments = path.split("/");
|
|
2231
2774
|
let parentId;
|
|
2232
2775
|
for (let i = 0;i < segments.length; i++) {
|
|
2233
2776
|
const partialPath = segments.slice(0, i + 1).join("/");
|
|
2234
|
-
const
|
|
2235
|
-
if (
|
|
2236
|
-
parentId =
|
|
2777
|
+
const category = getCategoryByPath(partialPath);
|
|
2778
|
+
if (category) {
|
|
2779
|
+
parentId = category.id;
|
|
2237
2780
|
} else {
|
|
2238
2781
|
const slug = segments[i];
|
|
2239
|
-
const created =
|
|
2782
|
+
const created = createCategory({ slug, name: slug, parentId });
|
|
2240
2783
|
parentId = created.id;
|
|
2241
2784
|
}
|
|
2242
2785
|
}
|
|
2243
2786
|
return parentId;
|
|
2244
2787
|
}
|
|
2245
|
-
var
|
|
2788
|
+
var init_categories = __esm(() => {
|
|
2246
2789
|
init_client();
|
|
2247
2790
|
init_schema();
|
|
2248
2791
|
init_id();
|
|
@@ -2299,19 +2842,19 @@ var init_template2 = __esm(() => {
|
|
|
2299
2842
|
});
|
|
2300
2843
|
|
|
2301
2844
|
// src/contract/context.ts
|
|
2302
|
-
function buildSiblingContext(
|
|
2303
|
-
const siblings =
|
|
2845
|
+
function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
|
|
2846
|
+
const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
|
|
2304
2847
|
if (siblings.length === 0)
|
|
2305
2848
|
return "";
|
|
2306
2849
|
const lines = siblings.map((s) => {
|
|
2307
2850
|
const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
|
|
2308
2851
|
const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
|
|
2309
|
-
return `- ${
|
|
2852
|
+
return `- ${categoryPath}/${s.slug}: ${s.status}${summary} (${ago})`;
|
|
2310
2853
|
});
|
|
2311
2854
|
const guidance = [
|
|
2312
2855
|
"",
|
|
2313
2856
|
"To inspect any sibling session's full record, run:",
|
|
2314
|
-
" bertrand log <
|
|
2857
|
+
" bertrand log <category>/<slug> --json",
|
|
2315
2858
|
"Returns session metadata, stats, conversations, and the full event timeline.",
|
|
2316
2859
|
"Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
|
|
2317
2860
|
].join(`
|
|
@@ -2336,13 +2879,16 @@ function launchClaude(opts) {
|
|
|
2336
2879
|
args.push("--session-id", opts.claudeId);
|
|
2337
2880
|
}
|
|
2338
2881
|
args.push("--append-system-prompt", opts.contract);
|
|
2882
|
+
const active = resolveActiveProject();
|
|
2339
2883
|
const env = {
|
|
2340
2884
|
...process.env,
|
|
2341
2885
|
BERTRAND_PID: String(process.pid),
|
|
2342
2886
|
BERTRAND_CLAUDE_ID: opts.claudeId,
|
|
2343
2887
|
BERTRAND_SESSION: opts.sessionId,
|
|
2344
2888
|
BERTRAND_SESSION_NAME: opts.sessionName,
|
|
2345
|
-
BERTRAND_SESSION_SLUG: opts.sessionSlug
|
|
2889
|
+
BERTRAND_SESSION_SLUG: opts.sessionSlug,
|
|
2890
|
+
BERTRAND_PROJECT: active.slug,
|
|
2891
|
+
BERTRAND_PROJECT_DB: active.db
|
|
2346
2892
|
};
|
|
2347
2893
|
return new Promise((resolve, reject) => {
|
|
2348
2894
|
const child = spawn2("claude", args, {
|
|
@@ -2372,8 +2918,13 @@ function launchClaude(opts) {
|
|
|
2372
2918
|
});
|
|
2373
2919
|
});
|
|
2374
2920
|
}
|
|
2921
|
+
function isClaudeRunning() {
|
|
2922
|
+
return activeChild !== null;
|
|
2923
|
+
}
|
|
2375
2924
|
var activeChild = null;
|
|
2376
|
-
var init_process = () => {
|
|
2925
|
+
var init_process = __esm(() => {
|
|
2926
|
+
init_resolve();
|
|
2927
|
+
});
|
|
2377
2928
|
|
|
2378
2929
|
// src/engine/spawn-context.ts
|
|
2379
2930
|
import { execFile as execFile2 } from "child_process";
|
|
@@ -2444,11 +2995,11 @@ var init_spawn_context = __esm(() => {
|
|
|
2444
2995
|
|
|
2445
2996
|
// src/lib/server-lifecycle.ts
|
|
2446
2997
|
import { spawn as spawn3 } from "child_process";
|
|
2447
|
-
import { readFileSync as
|
|
2448
|
-
import { join as
|
|
2998
|
+
import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2 } from "fs";
|
|
2999
|
+
import { join as join8 } from "path";
|
|
2449
3000
|
function readPidFile() {
|
|
2450
3001
|
try {
|
|
2451
|
-
const pid = Number(
|
|
3002
|
+
const pid = Number(readFileSync7(deps.pidFile, "utf-8").trim());
|
|
2452
3003
|
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
2453
3004
|
} catch {
|
|
2454
3005
|
return null;
|
|
@@ -2495,7 +3046,7 @@ async function ensureServerStarted() {
|
|
|
2495
3046
|
});
|
|
2496
3047
|
child.unref();
|
|
2497
3048
|
if (child.pid)
|
|
2498
|
-
|
|
3049
|
+
writeFileSync4(deps.pidFile, String(child.pid));
|
|
2499
3050
|
}
|
|
2500
3051
|
function stopServerIfIdle() {
|
|
2501
3052
|
if (deps.getActiveCount() > 0)
|
|
@@ -2513,11 +3064,11 @@ var init_server_lifecycle = __esm(() => {
|
|
|
2513
3064
|
init_paths();
|
|
2514
3065
|
init_sessions();
|
|
2515
3066
|
defaultDeps = {
|
|
2516
|
-
pidFile:
|
|
3067
|
+
pidFile: join8(paths.root, "server.pid"),
|
|
2517
3068
|
port: Number(process.env.BERTRAND_PORT ?? 5200),
|
|
2518
3069
|
resolveBin() {
|
|
2519
3070
|
try {
|
|
2520
|
-
const config = JSON.parse(
|
|
3071
|
+
const config = JSON.parse(readFileSync7(join8(paths.root, "config.json"), "utf-8"));
|
|
2521
3072
|
return typeof config?.bin === "string" ? config.bin : null;
|
|
2522
3073
|
} catch {
|
|
2523
3074
|
return null;
|
|
@@ -2530,17 +3081,52 @@ var init_server_lifecycle = __esm(() => {
|
|
|
2530
3081
|
|
|
2531
3082
|
// src/engine/session.ts
|
|
2532
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
|
+
}
|
|
2533
3119
|
async function launch(opts) {
|
|
2534
|
-
const
|
|
2535
|
-
if (
|
|
2536
|
-
const existing =
|
|
3120
|
+
const existingCategory = getCategoryByPath(opts.categoryPath);
|
|
3121
|
+
if (existingCategory) {
|
|
3122
|
+
const existing = getSessionByCategorySlug(existingCategory.id, opts.slug);
|
|
2537
3123
|
if (existing) {
|
|
2538
|
-
throw new Error(`Session "${opts.slug}" already exists in
|
|
3124
|
+
throw new Error(`Session "${opts.slug}" already exists in category "${opts.categoryPath}"`);
|
|
2539
3125
|
}
|
|
2540
3126
|
}
|
|
2541
|
-
const
|
|
3127
|
+
const categoryId = getOrCreateCategoryPath(opts.categoryPath);
|
|
2542
3128
|
const session = createSession({
|
|
2543
|
-
|
|
3129
|
+
categoryId,
|
|
2544
3130
|
slug: opts.slug,
|
|
2545
3131
|
name: opts.name ?? opts.slug
|
|
2546
3132
|
});
|
|
@@ -2554,15 +3140,17 @@ async function launch(opts) {
|
|
|
2554
3140
|
sessionId: session.id
|
|
2555
3141
|
});
|
|
2556
3142
|
updateSession(session.id, { status: "active", pid: process.pid });
|
|
3143
|
+
liveSession = { sessionId: session.id, claudeId };
|
|
3144
|
+
installExitHandlers();
|
|
2557
3145
|
await ensureServerStarted();
|
|
2558
3146
|
const spawnContext = await captureSpawnContext();
|
|
2559
|
-
const sessionName = `${opts.
|
|
3147
|
+
const sessionName = `${opts.categoryPath}/${opts.slug}`;
|
|
2560
3148
|
insertEvent({
|
|
2561
3149
|
sessionId: session.id,
|
|
2562
3150
|
conversationId: claudeId,
|
|
2563
3151
|
event: "session.started",
|
|
2564
3152
|
meta: {
|
|
2565
|
-
|
|
3153
|
+
category_path: opts.categoryPath,
|
|
2566
3154
|
session_name: opts.name ?? opts.slug,
|
|
2567
3155
|
session_slug: opts.slug,
|
|
2568
3156
|
labels: opts.labelNames ?? [],
|
|
@@ -2581,7 +3169,7 @@ async function launch(opts) {
|
|
|
2581
3169
|
cwd: spawnContext.cwd
|
|
2582
3170
|
}
|
|
2583
3171
|
});
|
|
2584
|
-
const siblingContext = buildSiblingContext(
|
|
3172
|
+
const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
|
|
2585
3173
|
const contract = buildContract(sessionName, siblingContext);
|
|
2586
3174
|
const exitCode = await launchClaude({
|
|
2587
3175
|
sessionId: session.id,
|
|
@@ -2597,9 +3185,11 @@ async function resume(opts) {
|
|
|
2597
3185
|
const session = getSession(opts.sessionId);
|
|
2598
3186
|
if (!session)
|
|
2599
3187
|
throw new Error(`Session not found: ${opts.sessionId}`);
|
|
2600
|
-
const
|
|
2601
|
-
const sessionName =
|
|
3188
|
+
const category = getCategory(session.categoryId);
|
|
3189
|
+
const sessionName = category ? `${category.path}/${session.slug}` : session.name;
|
|
2602
3190
|
updateSession(session.id, { status: "active", pid: process.pid });
|
|
3191
|
+
liveSession = { sessionId: session.id, claudeId: opts.conversationId };
|
|
3192
|
+
installExitHandlers();
|
|
2603
3193
|
await ensureServerStarted();
|
|
2604
3194
|
insertEvent({
|
|
2605
3195
|
sessionId: session.id,
|
|
@@ -2607,8 +3197,8 @@ async function resume(opts) {
|
|
|
2607
3197
|
event: "session.resumed",
|
|
2608
3198
|
meta: { claude_id: opts.conversationId }
|
|
2609
3199
|
});
|
|
2610
|
-
const
|
|
2611
|
-
const siblingContext = buildSiblingContext(session.
|
|
3200
|
+
const categoryPath = category?.path ?? "";
|
|
3201
|
+
const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
|
|
2612
3202
|
const contract = buildContract(sessionName, siblingContext);
|
|
2613
3203
|
const exitCode = await launchClaude({
|
|
2614
3204
|
sessionId: session.id,
|
|
@@ -2640,6 +3230,8 @@ function finalizeSession(sessionId, conversationId, exitCode) {
|
|
|
2640
3230
|
pid: null,
|
|
2641
3231
|
endedAt: new Date().toISOString()
|
|
2642
3232
|
});
|
|
3233
|
+
if (liveSession?.sessionId === sessionId)
|
|
3234
|
+
liveSession = null;
|
|
2643
3235
|
insertEvent({
|
|
2644
3236
|
sessionId,
|
|
2645
3237
|
event: "session.end"
|
|
@@ -2647,11 +3239,12 @@ function finalizeSession(sessionId, conversationId, exitCode) {
|
|
|
2647
3239
|
computeAndPersist(sessionId);
|
|
2648
3240
|
stopServerIfIdle();
|
|
2649
3241
|
}
|
|
3242
|
+
var liveSession = null, exitHandlersInstalled = false;
|
|
2650
3243
|
var init_session = __esm(() => {
|
|
2651
3244
|
init_sessions();
|
|
2652
3245
|
init_conversations();
|
|
2653
3246
|
init_events();
|
|
2654
|
-
|
|
3247
|
+
init_categories();
|
|
2655
3248
|
init_labels();
|
|
2656
3249
|
init_template2();
|
|
2657
3250
|
init_context();
|
|
@@ -2663,8 +3256,8 @@ var init_session = __esm(() => {
|
|
|
2663
3256
|
|
|
2664
3257
|
// src/tui/app.tsx
|
|
2665
3258
|
import { spawn as spawn4 } from "child_process";
|
|
2666
|
-
import { existsSync as
|
|
2667
|
-
import { join as
|
|
3259
|
+
import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
|
|
3260
|
+
import { join as join9 } from "path";
|
|
2668
3261
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
2669
3262
|
async function runScreen(screen, ...args) {
|
|
2670
3263
|
const tmpFile = `/tmp/bertrand-tui-${process.pid}-${Date.now()}.json`;
|
|
@@ -2677,13 +3270,16 @@ async function runScreen(screen, ...args) {
|
|
|
2677
3270
|
if (exitCode !== 0) {
|
|
2678
3271
|
throw new Error(`TUI screen "${screen}" exited with code ${exitCode}`);
|
|
2679
3272
|
}
|
|
2680
|
-
const result = JSON.parse(
|
|
3273
|
+
const result = JSON.parse(readFileSync8(tmpFile, "utf-8"));
|
|
2681
3274
|
unlinkSync3(tmpFile);
|
|
2682
3275
|
return result;
|
|
2683
3276
|
}
|
|
2684
3277
|
async function startLaunchTui() {
|
|
2685
3278
|
return runScreen("launch");
|
|
2686
3279
|
}
|
|
3280
|
+
async function startProjectPickerTui() {
|
|
3281
|
+
return runScreen("project-picker");
|
|
3282
|
+
}
|
|
2687
3283
|
async function startExitTui(sessionId) {
|
|
2688
3284
|
return runScreen("exit", sessionId);
|
|
2689
3285
|
}
|
|
@@ -2732,26 +3328,58 @@ async function runSessionLoop(sessionId) {
|
|
|
2732
3328
|
}
|
|
2733
3329
|
}
|
|
2734
3330
|
}
|
|
2735
|
-
|
|
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() {
|
|
2736
3342
|
const selection = await startLaunchTui();
|
|
2737
3343
|
switch (selection.type) {
|
|
2738
3344
|
case "quit":
|
|
2739
|
-
|
|
3345
|
+
return;
|
|
2740
3346
|
case "create": {
|
|
2741
3347
|
const sessionId = await launch(selection);
|
|
2742
3348
|
await runSessionLoop(sessionId);
|
|
2743
|
-
|
|
3349
|
+
return;
|
|
2744
3350
|
}
|
|
2745
3351
|
case "pick": {
|
|
2746
3352
|
const conversationId = await resolveConversationForResume(selection.sessionId);
|
|
2747
3353
|
if (!conversationId)
|
|
2748
|
-
|
|
3354
|
+
return;
|
|
2749
3355
|
const sessionId = await resume({
|
|
2750
3356
|
sessionId: selection.sessionId,
|
|
2751
3357
|
conversationId
|
|
2752
3358
|
});
|
|
2753
3359
|
await runSessionLoop(sessionId);
|
|
2754
|
-
|
|
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;
|
|
2755
3383
|
}
|
|
2756
3384
|
}
|
|
2757
3385
|
}
|
|
@@ -2761,9 +3389,12 @@ var init_app = __esm(() => {
|
|
|
2761
3389
|
init_conversations();
|
|
2762
3390
|
init_session_archive();
|
|
2763
3391
|
init_session();
|
|
3392
|
+
init_registry();
|
|
3393
|
+
init_create();
|
|
3394
|
+
init_resolve();
|
|
2764
3395
|
SCREEN_ENTRY = (() => {
|
|
2765
|
-
const built =
|
|
2766
|
-
return
|
|
3396
|
+
const built = join9(import.meta.dir, "run-screen.js");
|
|
3397
|
+
return existsSync8(built) ? built : join9(import.meta.dir, "run-screen.tsx");
|
|
2767
3398
|
})();
|
|
2768
3399
|
});
|
|
2769
3400
|
|
|
@@ -2775,7 +3406,7 @@ function parseSessionName(input) {
|
|
|
2775
3406
|
}
|
|
2776
3407
|
const segments = trimmed.split("/").filter(Boolean);
|
|
2777
3408
|
if (segments.length < 2) {
|
|
2778
|
-
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}")`);
|
|
2779
3410
|
}
|
|
2780
3411
|
for (const segment of segments) {
|
|
2781
3412
|
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(segment)) {
|
|
@@ -2783,8 +3414,8 @@ function parseSessionName(input) {
|
|
|
2783
3414
|
}
|
|
2784
3415
|
}
|
|
2785
3416
|
const slug = segments[segments.length - 1];
|
|
2786
|
-
const
|
|
2787
|
-
return {
|
|
3417
|
+
const categoryPath = segments.slice(0, -1).join("/");
|
|
3418
|
+
return { categoryPath, slug };
|
|
2788
3419
|
}
|
|
2789
3420
|
|
|
2790
3421
|
// src/engine/recovery.ts
|
|
@@ -2832,8 +3463,8 @@ var init_launch = __esm(() => {
|
|
|
2832
3463
|
recoverStaleSessions();
|
|
2833
3464
|
const sessionName = args[0];
|
|
2834
3465
|
if (sessionName) {
|
|
2835
|
-
const {
|
|
2836
|
-
const sessionId = await launch({
|
|
3466
|
+
const { categoryPath, slug } = parseSessionName(sessionName);
|
|
3467
|
+
const sessionId = await launch({ categoryPath, slug });
|
|
2837
3468
|
await runSessionLoop(sessionId);
|
|
2838
3469
|
return;
|
|
2839
3470
|
}
|
|
@@ -2841,28 +3472,6 @@ var init_launch = __esm(() => {
|
|
|
2841
3472
|
});
|
|
2842
3473
|
});
|
|
2843
3474
|
|
|
2844
|
-
// src/db/migrate.ts
|
|
2845
|
-
import { Database as Database3 } from "bun:sqlite";
|
|
2846
|
-
import { drizzle as drizzle2 } from "drizzle-orm/bun-sqlite";
|
|
2847
|
-
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
|
2848
|
-
import { mkdirSync as mkdirSync2 } from "fs";
|
|
2849
|
-
import { dirname as dirname3 } from "path";
|
|
2850
|
-
function runMigrations() {
|
|
2851
|
-
mkdirSync2(dirname3(paths.db), { recursive: true });
|
|
2852
|
-
const sqlite = new Database3(paths.db);
|
|
2853
|
-
sqlite.exec("PRAGMA journal_mode = WAL");
|
|
2854
|
-
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
2855
|
-
const db = drizzle2(sqlite);
|
|
2856
|
-
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
2857
|
-
sqlite.close();
|
|
2858
|
-
}
|
|
2859
|
-
var MIGRATIONS_FOLDER;
|
|
2860
|
-
var init_migrate = __esm(() => {
|
|
2861
|
-
init_paths();
|
|
2862
|
-
MIGRATIONS_FOLDER = import.meta.dir + "/migrations";
|
|
2863
|
-
if (false) {}
|
|
2864
|
-
});
|
|
2865
|
-
|
|
2866
3475
|
// src/hooks/scripts.ts
|
|
2867
3476
|
function waitingScript(bin) {
|
|
2868
3477
|
const BIN = bin;
|
|
@@ -3136,13 +3745,13 @@ var init_scripts = __esm(() => {
|
|
|
3136
3745
|
});
|
|
3137
3746
|
|
|
3138
3747
|
// src/hooks/install.ts
|
|
3139
|
-
import { mkdirSync as
|
|
3140
|
-
import { join as
|
|
3748
|
+
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
|
|
3749
|
+
import { join as join10 } from "path";
|
|
3141
3750
|
function installHookScripts(bin) {
|
|
3142
|
-
|
|
3751
|
+
mkdirSync7(paths.hooks, { recursive: true });
|
|
3143
3752
|
for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
|
|
3144
|
-
const filePath =
|
|
3145
|
-
|
|
3753
|
+
const filePath = join10(paths.hooks, filename);
|
|
3754
|
+
writeFileSync5(filePath, scriptFn(bin));
|
|
3146
3755
|
chmodSync2(filePath, 493);
|
|
3147
3756
|
}
|
|
3148
3757
|
console.log(`Installed ${Object.keys(HOOK_SCRIPTS).length} hook scripts to ${paths.hooks}`);
|
|
@@ -3153,8 +3762,8 @@ var init_install = __esm(() => {
|
|
|
3153
3762
|
});
|
|
3154
3763
|
|
|
3155
3764
|
// src/hooks/settings.ts
|
|
3156
|
-
import { readFileSync as
|
|
3157
|
-
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";
|
|
3158
3767
|
import { homedir as homedir2 } from "os";
|
|
3159
3768
|
function isBertrandGroup(group) {
|
|
3160
3769
|
return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
|
|
@@ -3162,7 +3771,7 @@ function isBertrandGroup(group) {
|
|
|
3162
3771
|
function installHookSettings() {
|
|
3163
3772
|
let settings = {};
|
|
3164
3773
|
try {
|
|
3165
|
-
settings = JSON.parse(
|
|
3774
|
+
settings = JSON.parse(readFileSync9(SETTINGS_PATH, "utf-8"));
|
|
3166
3775
|
} catch {}
|
|
3167
3776
|
const existingHooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
|
|
3168
3777
|
const merged = { ...existingHooks };
|
|
@@ -3171,15 +3780,15 @@ function installHookSettings() {
|
|
|
3171
3780
|
merged[eventType] = [...existing, ...bertrandGroups];
|
|
3172
3781
|
}
|
|
3173
3782
|
settings.hooks = merged;
|
|
3174
|
-
|
|
3175
|
-
|
|
3783
|
+
mkdirSync8(dirname4(SETTINGS_PATH), { recursive: true });
|
|
3784
|
+
writeFileSync6(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
|
|
3176
3785
|
`);
|
|
3177
3786
|
console.log(`Updated ${SETTINGS_PATH} with bertrand hooks`);
|
|
3178
3787
|
}
|
|
3179
3788
|
var SETTINGS_PATH, BERTRAND_HOOKS;
|
|
3180
3789
|
var init_settings = __esm(() => {
|
|
3181
3790
|
init_paths();
|
|
3182
|
-
SETTINGS_PATH =
|
|
3791
|
+
SETTINGS_PATH = join11(homedir2(), ".claude", "settings.json");
|
|
3183
3792
|
BERTRAND_HOOKS = {
|
|
3184
3793
|
PreToolUse: [
|
|
3185
3794
|
{
|
|
@@ -3223,8 +3832,8 @@ var init_settings = __esm(() => {
|
|
|
3223
3832
|
});
|
|
3224
3833
|
|
|
3225
3834
|
// src/lib/completions.ts
|
|
3226
|
-
import { writeFileSync as
|
|
3227
|
-
import { join as
|
|
3835
|
+
import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync9 } from "fs";
|
|
3836
|
+
import { join as join12 } from "path";
|
|
3228
3837
|
function bashCompletion() {
|
|
3229
3838
|
return `# bertrand bash completion
|
|
3230
3839
|
_bertrand() {
|
|
@@ -3254,11 +3863,11 @@ function fishCompletion() {
|
|
|
3254
3863
|
`;
|
|
3255
3864
|
}
|
|
3256
3865
|
function generateCompletions() {
|
|
3257
|
-
const dir =
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
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());
|
|
3262
3871
|
console.log(`Shell completions written to ${dir}`);
|
|
3263
3872
|
console.log(" Add to your shell config:");
|
|
3264
3873
|
console.log(` bash: source ${dir}/bertrand.bash`);
|
|
@@ -3274,8 +3883,10 @@ var init_completions = __esm(() => {
|
|
|
3274
3883
|
"log",
|
|
3275
3884
|
"stats",
|
|
3276
3885
|
"archive",
|
|
3886
|
+
"project",
|
|
3277
3887
|
"update",
|
|
3278
3888
|
"serve",
|
|
3889
|
+
"sync",
|
|
3279
3890
|
"import",
|
|
3280
3891
|
"completion",
|
|
3281
3892
|
"badge",
|
|
@@ -3285,9 +3896,9 @@ var init_completions = __esm(() => {
|
|
|
3285
3896
|
|
|
3286
3897
|
// src/cli/commands/init.ts
|
|
3287
3898
|
var exports_init = {};
|
|
3288
|
-
import { mkdirSync as
|
|
3899
|
+
import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, chmodSync as chmodSync3 } from "fs";
|
|
3289
3900
|
import { execSync as execSync2 } from "child_process";
|
|
3290
|
-
import { join as
|
|
3901
|
+
import { join as join13 } from "path";
|
|
3291
3902
|
function detectTerminal() {
|
|
3292
3903
|
try {
|
|
3293
3904
|
execSync2("which wsh", { stdio: "ignore" });
|
|
@@ -3302,10 +3913,10 @@ function resolveBin() {
|
|
|
3302
3913
|
return onPath;
|
|
3303
3914
|
const entry = process.argv[1];
|
|
3304
3915
|
if (entry && SOURCE_ENTRY.test(entry)) {
|
|
3305
|
-
const launcherDir =
|
|
3306
|
-
const launcher =
|
|
3307
|
-
|
|
3308
|
-
|
|
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
|
|
3309
3920
|
exec ${process.execPath} ${JSON.stringify(entry)} "$@"
|
|
3310
3921
|
`);
|
|
3311
3922
|
chmodSync3(launcher, 493);
|
|
@@ -3320,16 +3931,18 @@ var init_init = __esm(() => {
|
|
|
3320
3931
|
init_install();
|
|
3321
3932
|
init_settings();
|
|
3322
3933
|
init_paths();
|
|
3934
|
+
init_resolve();
|
|
3323
3935
|
init_completions();
|
|
3324
3936
|
init_config2();
|
|
3325
3937
|
SOURCE_ENTRY = /\/src\/index\.tsx?$/;
|
|
3326
3938
|
register("init", async () => {
|
|
3327
3939
|
console.log(`bertrand init
|
|
3328
3940
|
`);
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
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}`);
|
|
3333
3946
|
const bin = resolveBin();
|
|
3334
3947
|
if (!bin) {
|
|
3335
3948
|
console.error(`
|
|
@@ -3363,7 +3976,7 @@ function buildRows(sessions2) {
|
|
|
3363
3976
|
return sessions2.sort((a, b) => new Date(b.session.updatedAt).getTime() - new Date(a.session.updatedAt).getTime()).map((row) => {
|
|
3364
3977
|
const stats = getSessionStats(row.session.id);
|
|
3365
3978
|
return {
|
|
3366
|
-
name: `${row.
|
|
3979
|
+
name: `${row.categoryPath}/${row.session.slug}`,
|
|
3367
3980
|
status: row.session.status,
|
|
3368
3981
|
updatedAt: row.session.updatedAt,
|
|
3369
3982
|
conversations: stats?.conversationCount ?? 0,
|
|
@@ -3403,7 +4016,7 @@ var STATUS_DOTS;
|
|
|
3403
4016
|
var init_list = __esm(() => {
|
|
3404
4017
|
init_router();
|
|
3405
4018
|
init_sessions();
|
|
3406
|
-
|
|
4019
|
+
init_categories();
|
|
3407
4020
|
init_stats();
|
|
3408
4021
|
init_format();
|
|
3409
4022
|
STATUS_DOTS = {
|
|
@@ -3416,17 +4029,17 @@ var init_list = __esm(() => {
|
|
|
3416
4029
|
register("list", async (args) => {
|
|
3417
4030
|
const isJson = args.includes("--json");
|
|
3418
4031
|
const showAll = args.includes("--all") || args.includes("-a");
|
|
3419
|
-
const
|
|
3420
|
-
const
|
|
4032
|
+
const categoryFlag = args.indexOf("--category");
|
|
4033
|
+
const categoryPath = categoryFlag !== -1 ? args[categoryFlag + 1] : undefined;
|
|
3421
4034
|
let sessionRows;
|
|
3422
|
-
if (
|
|
3423
|
-
const
|
|
3424
|
-
if (!
|
|
3425
|
-
console.error(`
|
|
4035
|
+
if (categoryPath) {
|
|
4036
|
+
const category = getCategoryByPath(categoryPath);
|
|
4037
|
+
if (!category) {
|
|
4038
|
+
console.error(`Category not found: ${categoryPath}`);
|
|
3426
4039
|
process.exit(1);
|
|
3427
4040
|
}
|
|
3428
|
-
const
|
|
3429
|
-
sessionRows =
|
|
4041
|
+
const categorySessions = getSessionsByCategory(category.id);
|
|
4042
|
+
sessionRows = categorySessions.map((s) => ({ session: s, categoryPath: category.path }));
|
|
3430
4043
|
if (!showAll) {
|
|
3431
4044
|
sessionRows = sessionRows.filter((r) => r.session.status !== "archived");
|
|
3432
4045
|
}
|
|
@@ -3673,10 +4286,10 @@ function showAllSessions() {
|
|
|
3673
4286
|
console.log("No sessions.");
|
|
3674
4287
|
return;
|
|
3675
4288
|
}
|
|
3676
|
-
const maxName = Math.max(...rows.map((r) => `${r.
|
|
4289
|
+
const maxName = Math.max(...rows.map((r) => `${r.categoryPath}/${r.session.slug}`.length), 4);
|
|
3677
4290
|
console.log(`${DIM}${" "} ${"NAME".padEnd(maxName)} ${"STATUS".padEnd(10)} ${"EVENTS".padEnd(6)} LAST ACTIVE${RESET}`);
|
|
3678
4291
|
for (const row of rows) {
|
|
3679
|
-
const name = `${row.
|
|
4292
|
+
const name = `${row.categoryPath}/${row.session.slug}`;
|
|
3680
4293
|
const dot = STATUS_DOTS2[row.session.status] ?? "?";
|
|
3681
4294
|
const stats = getSessionStats(row.session.id);
|
|
3682
4295
|
const eventCount = String(stats?.eventCount ?? 0).padEnd(6);
|
|
@@ -3833,7 +4446,7 @@ var init_log = __esm(() => {
|
|
|
3833
4446
|
init_router();
|
|
3834
4447
|
init_sessions();
|
|
3835
4448
|
init_events();
|
|
3836
|
-
|
|
4449
|
+
init_categories();
|
|
3837
4450
|
init_stats();
|
|
3838
4451
|
init_conversations();
|
|
3839
4452
|
init_catalog();
|
|
@@ -3854,18 +4467,18 @@ var init_log = __esm(() => {
|
|
|
3854
4467
|
showAllSessions();
|
|
3855
4468
|
return;
|
|
3856
4469
|
}
|
|
3857
|
-
const {
|
|
3858
|
-
const
|
|
3859
|
-
if (!
|
|
3860
|
-
console.error(`
|
|
4470
|
+
const { categoryPath, slug } = parseSessionName(target);
|
|
4471
|
+
const category = getCategoryByPath(categoryPath);
|
|
4472
|
+
if (!category) {
|
|
4473
|
+
console.error(`Category not found: ${categoryPath}`);
|
|
3861
4474
|
process.exit(1);
|
|
3862
4475
|
}
|
|
3863
|
-
const session =
|
|
4476
|
+
const session = getSessionByCategorySlug(category.id, slug);
|
|
3864
4477
|
if (!session) {
|
|
3865
4478
|
console.error(`Session not found: ${target}`);
|
|
3866
4479
|
process.exit(1);
|
|
3867
4480
|
}
|
|
3868
|
-
showSessionLog(session, `${
|
|
4481
|
+
showSessionLog(session, `${categoryPath}/${slug}`, isJson);
|
|
3869
4482
|
});
|
|
3870
4483
|
});
|
|
3871
4484
|
|
|
@@ -3938,7 +4551,7 @@ function renderGlobal(metrics, isJson) {
|
|
|
3938
4551
|
console.log(` PRs: ${totals.prs}`);
|
|
3939
4552
|
console.log(` Interactions: ${totals.interactions}`);
|
|
3940
4553
|
}
|
|
3941
|
-
function
|
|
4554
|
+
function renderCategory(metrics, categoryPath, isJson) {
|
|
3942
4555
|
if (isJson) {
|
|
3943
4556
|
console.log(JSON.stringify(metrics, null, 2));
|
|
3944
4557
|
return;
|
|
@@ -3948,7 +4561,7 @@ function renderGroup(metrics, groupPath, isJson) {
|
|
|
3948
4561
|
const reset = "\x1B[0m";
|
|
3949
4562
|
const sorted = [...metrics].sort((a, b) => b.durationS - a.durationS);
|
|
3950
4563
|
const maxName = Math.max(...sorted.map((m) => m.name.length), 4);
|
|
3951
|
-
console.log(`${bold}${
|
|
4564
|
+
console.log(`${bold}${categoryPath}${reset}
|
|
3952
4565
|
`);
|
|
3953
4566
|
console.log(`${dim}${"NAME".padEnd(maxName)} ${"DURATION".padEnd(8)} ${"CLAUDE".padEnd(8)} ${"WAIT".padEnd(8)} ${"ACT%".padEnd(5)} ${"CONVOS".padEnd(6)} PRS${reset}`);
|
|
3954
4567
|
for (const m of sorted) {
|
|
@@ -3989,7 +4602,7 @@ var init_stats2 = __esm(() => {
|
|
|
3989
4602
|
init_router();
|
|
3990
4603
|
init_sessions();
|
|
3991
4604
|
init_stats();
|
|
3992
|
-
|
|
4605
|
+
init_categories();
|
|
3993
4606
|
init_events();
|
|
3994
4607
|
init_timing();
|
|
3995
4608
|
init_format();
|
|
@@ -4000,34 +4613,34 @@ var init_stats2 = __esm(() => {
|
|
|
4000
4613
|
const target = filteredArgs[0];
|
|
4001
4614
|
if (!target) {
|
|
4002
4615
|
const rows = getAllSessions();
|
|
4003
|
-
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));
|
|
4004
4617
|
renderGlobal(metrics, isJson);
|
|
4005
4618
|
return;
|
|
4006
4619
|
}
|
|
4007
4620
|
if (target.endsWith("/")) {
|
|
4008
|
-
const
|
|
4009
|
-
const
|
|
4010
|
-
if (!
|
|
4011
|
-
console.error(`
|
|
4621
|
+
const categoryPath2 = target.replace(/\/+$/, "");
|
|
4622
|
+
const category2 = getCategoryByPath(categoryPath2);
|
|
4623
|
+
if (!category2) {
|
|
4624
|
+
console.error(`Category not found: ${categoryPath2}`);
|
|
4012
4625
|
process.exit(1);
|
|
4013
4626
|
}
|
|
4014
|
-
const
|
|
4015
|
-
const metrics =
|
|
4016
|
-
|
|
4627
|
+
const categorySessions = getSessionsByCategory(category2.id);
|
|
4628
|
+
const metrics = categorySessions.map((s) => getMetrics(s.id, s.slug, s.status));
|
|
4629
|
+
renderCategory(metrics, categoryPath2, isJson);
|
|
4017
4630
|
return;
|
|
4018
4631
|
}
|
|
4019
|
-
const {
|
|
4020
|
-
const
|
|
4021
|
-
if (!
|
|
4022
|
-
console.error(`
|
|
4632
|
+
const { categoryPath, slug } = parseSessionName(target);
|
|
4633
|
+
const category = getCategoryByPath(categoryPath);
|
|
4634
|
+
if (!category) {
|
|
4635
|
+
console.error(`Category not found: ${categoryPath}`);
|
|
4023
4636
|
process.exit(1);
|
|
4024
4637
|
}
|
|
4025
|
-
const session =
|
|
4638
|
+
const session = getSessionByCategorySlug(category.id, slug);
|
|
4026
4639
|
if (!session) {
|
|
4027
4640
|
console.error(`Session not found: ${target}`);
|
|
4028
4641
|
process.exit(1);
|
|
4029
4642
|
}
|
|
4030
|
-
const m = getMetrics(session.id, `${
|
|
4643
|
+
const m = getMetrics(session.id, `${categoryPath}/${slug}`, session.status);
|
|
4031
4644
|
renderSession(m, isJson);
|
|
4032
4645
|
});
|
|
4033
4646
|
});
|
|
@@ -4042,9 +4655,9 @@ var init_backfill_stats = __esm(() => {
|
|
|
4042
4655
|
const includeArchived = args.includes("--include-archived");
|
|
4043
4656
|
const rows = getAllSessions({ excludeArchived: !includeArchived });
|
|
4044
4657
|
console.log(`Backfilling stats for ${rows.length} session(s)${includeArchived ? "" : " (excluding archived)"}...`);
|
|
4045
|
-
for (const { session,
|
|
4658
|
+
for (const { session, categoryPath } of rows) {
|
|
4046
4659
|
computeAndPersist(session.id);
|
|
4047
|
-
console.log(` \u2713 ${
|
|
4660
|
+
console.log(` \u2713 ${categoryPath}/${session.slug}`);
|
|
4048
4661
|
}
|
|
4049
4662
|
console.log("Done.");
|
|
4050
4663
|
});
|
|
@@ -4053,23 +4666,23 @@ var init_backfill_stats = __esm(() => {
|
|
|
4053
4666
|
// src/cli/commands/archive.ts
|
|
4054
4667
|
var exports_archive = {};
|
|
4055
4668
|
function resolveSession(name) {
|
|
4056
|
-
const {
|
|
4057
|
-
const
|
|
4058
|
-
if (!
|
|
4059
|
-
console.error(`
|
|
4669
|
+
const { categoryPath, slug } = parseSessionName(name);
|
|
4670
|
+
const category = getCategoryByPath(categoryPath);
|
|
4671
|
+
if (!category) {
|
|
4672
|
+
console.error(`Category not found: ${categoryPath}`);
|
|
4060
4673
|
process.exit(1);
|
|
4061
4674
|
}
|
|
4062
|
-
const session =
|
|
4675
|
+
const session = getSessionByCategorySlug(category.id, slug);
|
|
4063
4676
|
if (!session) {
|
|
4064
4677
|
console.error(`Session not found: ${name}`);
|
|
4065
4678
|
process.exit(1);
|
|
4066
4679
|
}
|
|
4067
|
-
return { session,
|
|
4680
|
+
return { session, categoryPath };
|
|
4068
4681
|
}
|
|
4069
4682
|
var init_archive = __esm(() => {
|
|
4070
4683
|
init_router();
|
|
4071
4684
|
init_sessions();
|
|
4072
|
-
|
|
4685
|
+
init_categories();
|
|
4073
4686
|
init_session_archive();
|
|
4074
4687
|
register("archive", async (args) => {
|
|
4075
4688
|
const isUndo = args.includes("--undo");
|
|
@@ -4082,8 +4695,8 @@ var init_archive = __esm(() => {
|
|
|
4082
4695
|
console.log("No paused sessions to archive.");
|
|
4083
4696
|
return;
|
|
4084
4697
|
}
|
|
4085
|
-
for (const { session: session2,
|
|
4086
|
-
console.log(` archived ${
|
|
4698
|
+
for (const { session: session2, categoryPath: categoryPath2 } of archived) {
|
|
4699
|
+
console.log(` archived ${categoryPath2}/${session2.slug}`);
|
|
4087
4700
|
}
|
|
4088
4701
|
console.log(`
|
|
4089
4702
|
Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
|
|
@@ -4095,8 +4708,8 @@ Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
|
|
|
4095
4708
|
console.error(" bertrand archive --all-paused");
|
|
4096
4709
|
process.exit(1);
|
|
4097
4710
|
}
|
|
4098
|
-
const { session,
|
|
4099
|
-
const fullName = `${
|
|
4711
|
+
const { session, categoryPath } = resolveSession(sessionName);
|
|
4712
|
+
const fullName = `${categoryPath}/${session.slug}`;
|
|
4100
4713
|
if (isUndo) {
|
|
4101
4714
|
const result2 = unarchiveSession(session.id);
|
|
4102
4715
|
if (!result2.ok) {
|
|
@@ -4129,6 +4742,302 @@ Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
|
|
|
4129
4742
|
});
|
|
4130
4743
|
});
|
|
4131
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
|
+
|
|
4132
5041
|
// src/index.ts
|
|
4133
5042
|
init_router();
|
|
4134
5043
|
var command = process.argv[2];
|
|
@@ -4160,7 +5069,8 @@ if (command && command in hotPath) {
|
|
|
4160
5069
|
Promise.resolve().then(() => (init_serve(), exports_serve)),
|
|
4161
5070
|
Promise.resolve().then(() => (init_badge(), exports_badge)),
|
|
4162
5071
|
Promise.resolve().then(() => (init_notify(), exports_notify)),
|
|
4163
|
-
Promise.resolve().then(() => (init_sync(), exports_sync))
|
|
5072
|
+
Promise.resolve().then(() => (init_sync(), exports_sync)),
|
|
5073
|
+
Promise.resolve().then(() => (init_project(), exports_project))
|
|
4164
5074
|
]);
|
|
4165
5075
|
}
|
|
4166
5076
|
await route(process.argv);
|