packbat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3362 @@
1
+ // src/core/errors.ts
2
+ var PackbatError = class extends Error {
3
+ name = "PackbatError";
4
+ };
5
+ function errorMessage(error) {
6
+ return error instanceof Error ? error.message : String(error);
7
+ }
8
+
9
+ // src/core/config.ts
10
+ import { createHash } from "crypto";
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
12
+ import { dirname, join } from "path";
13
+ import { z } from "zod";
14
+ var CONFIG_VERSION = 2;
15
+ var rcloneRemoteFields = {
16
+ /** Full rclone destination, e.g. "packbat-remote:bucket/prefix" or an absolute local path. */
17
+ destination: z.string().min(1),
18
+ /** "managed" = Packbat-owned rclone.conf; "default" = the user's own rclone config resolution. */
19
+ rcloneConfig: z.enum(["managed", "default"])
20
+ };
21
+ var remoteSchema = z.strictObject({
22
+ type: z.literal("rclone"),
23
+ ...rcloneRemoteFields
24
+ });
25
+ var remotesSchema = z.tuple([remoteSchema], remoteSchema).refine(
26
+ (remotes) => new Set(remotes.map((remote) => remote.destination)).size === remotes.length,
27
+ "remote destinations must be unique"
28
+ // DRAFT copy
29
+ );
30
+ var offboxSchema = z.discriminatedUnion("mode", [
31
+ z.object({
32
+ /** Skipping off-box is a first-class answer, never an error state. */
33
+ mode: z.literal("skipped"),
34
+ skippedAt: z.iso.datetime()
35
+ }),
36
+ z.object({
37
+ mode: z.literal("configured"),
38
+ /** age public recipient. The identity never lives on this machine after onboarding. */
39
+ recipient: z.string().regex(/^age1[0-9a-z]+$/, "must be an age recipient (age1\u2026)"),
40
+ remotes: remotesSchema
41
+ })
42
+ ]);
43
+ var configSchema = z.strictObject({
44
+ version: z.literal(CONFIG_VERSION),
45
+ /**
46
+ * Locked at init (default: lowercase short hostname) so a later hostname
47
+ * change can never silently fork the archive tree. Machine trees are never
48
+ * merged.
49
+ */
50
+ machine: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "must be lowercase and hostname-safe (a-z, 0-9, -)"),
51
+ archiveRoot: z.string().refine((p) => p.startsWith("/"), "must be an absolute path"),
52
+ sweep: z.object({
53
+ intervalMinutes: z.number().int().min(5).max(24 * 60)
54
+ }),
55
+ offbox: offboxSchema
56
+ });
57
+ var legacyConfigSchema = z.strictObject({
58
+ version: z.literal(1),
59
+ machine: configSchema.shape.machine,
60
+ archiveRoot: configSchema.shape.archiveRoot,
61
+ sweep: configSchema.shape.sweep,
62
+ offbox: z.discriminatedUnion("mode", [
63
+ z.object({
64
+ mode: z.literal("skipped"),
65
+ skippedAt: z.iso.datetime()
66
+ }),
67
+ z.object({
68
+ mode: z.literal("configured"),
69
+ recipient: z.string().regex(/^age1[0-9a-z]+$/, "must be an age recipient (age1\u2026)"),
70
+ remote: z.object(rcloneRemoteFields)
71
+ })
72
+ ])
73
+ });
74
+ function remoteStateId(remote) {
75
+ return createHash("sha256").update(`${remote.type}\0${remote.destination}`).digest("hex");
76
+ }
77
+ function remoteStatePath(home, remote) {
78
+ return join(home.statePath, "offbox", remoteStateId(remote));
79
+ }
80
+ function migrateLegacyState(home, remote) {
81
+ const destination = remoteStatePath(home, remote);
82
+ mkdirSync(destination, { recursive: true });
83
+ for (const [legacyName, currentName] of [
84
+ ["offbox-uploaded.jsonl", "uploaded.jsonl"],
85
+ ["offbox-last-success.json", "last-success.json"]
86
+ ]) {
87
+ const legacyPath = join(home.statePath, legacyName);
88
+ if (!existsSync(legacyPath)) {
89
+ continue;
90
+ }
91
+ const currentPath = join(destination, currentName);
92
+ if (existsSync(currentPath)) {
93
+ rmSync(legacyPath);
94
+ } else {
95
+ renameSync(legacyPath, currentPath);
96
+ }
97
+ }
98
+ const legacyOutbox = join(home.statePath, "outbox");
99
+ if (existsSync(legacyOutbox)) {
100
+ const currentOutbox = join(destination, "outbox");
101
+ if (existsSync(currentOutbox)) {
102
+ rmSync(legacyOutbox, { recursive: true });
103
+ } else {
104
+ renameSync(legacyOutbox, currentOutbox);
105
+ }
106
+ }
107
+ }
108
+ function migrateLegacyConfig(home, legacy) {
109
+ const offbox = legacy.offbox.mode === "skipped" ? legacy.offbox : {
110
+ mode: "configured",
111
+ recipient: legacy.offbox.recipient,
112
+ remotes: [{ type: "rclone", ...legacy.offbox.remote }]
113
+ };
114
+ const migrated = { ...legacy, version: CONFIG_VERSION, offbox };
115
+ if (migrated.offbox.mode === "configured") {
116
+ migrateLegacyState(home, migrated.offbox.remotes[0]);
117
+ }
118
+ saveConfig(home, migrated);
119
+ return migrated;
120
+ }
121
+ function loadConfig(home) {
122
+ let raw;
123
+ try {
124
+ raw = readFileSync(home.configPath, "utf8");
125
+ } catch (error) {
126
+ if (error.code === "ENOENT") {
127
+ throw new PackbatError(`no config at ${home.configPath} \u2014 run \`packbat init\` first`);
128
+ }
129
+ throw error;
130
+ }
131
+ let parsed;
132
+ try {
133
+ parsed = JSON.parse(raw);
134
+ } catch (error) {
135
+ throw new PackbatError(`${home.configPath} is not valid JSON: ${error.message}`);
136
+ }
137
+ const result = configSchema.safeParse(parsed);
138
+ if (result.success) {
139
+ return result.data;
140
+ }
141
+ const legacy = legacyConfigSchema.safeParse(parsed);
142
+ if (legacy.success) {
143
+ return migrateLegacyConfig(home, legacy.data);
144
+ }
145
+ throw new PackbatError(`${home.configPath} is invalid:
146
+ ${z.prettifyError(result.error)}`);
147
+ }
148
+ function saveConfig(home, config) {
149
+ mkdirSync(dirname(home.configPath), { recursive: true });
150
+ const tmp = join(dirname(home.configPath), `.config.json.tmp-${process.pid}`);
151
+ writeFileSync(tmp, `${JSON.stringify(config, null, " ")}
152
+ `);
153
+ renameSync(tmp, home.configPath);
154
+ }
155
+
156
+ // src/core/home.ts
157
+ import { homedir } from "os";
158
+ import { join as join2 } from "path";
159
+ function resolveHome(env = process.env) {
160
+ const override = env.PACKBAT_HOME?.trim();
161
+ const root = override ? override : join2(homedir(), ".packbat");
162
+ return {
163
+ root,
164
+ configPath: join2(root, "config.json"),
165
+ statePath: join2(root, "state"),
166
+ logsPath: join2(root, "logs"),
167
+ cachePath: join2(root, "cache"),
168
+ defaultArchiveRoot: join2(root, "archive"),
169
+ rcloneConfPath: join2(root, "rclone.conf")
170
+ };
171
+ }
172
+
173
+ // src/core/exec.ts
174
+ import { accessSync, constants } from "fs";
175
+ import { delimiter, join as join3 } from "path";
176
+ function commandOnPath(command2, env = process.env) {
177
+ for (const directory of (env.PATH ?? "").split(delimiter)) {
178
+ if (directory === "") {
179
+ continue;
180
+ }
181
+ const candidate = join3(directory, command2);
182
+ try {
183
+ accessSync(candidate, constants.X_OK);
184
+ return candidate;
185
+ } catch {
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+
191
+ // src/commands/doctor.ts
192
+ import pc from "picocolors";
193
+
194
+ // src/doctor/facts.ts
195
+ import { execFile } from "child_process";
196
+ import { randomUUID } from "crypto";
197
+ import { constants as constants2 } from "fs";
198
+ import { access, mkdir, readFile as readFile3, rm, statfs, writeFile } from "fs/promises";
199
+ import { homedir as homedir2 } from "os";
200
+ import { join as join12 } from "path";
201
+ import * as zlib from "zlib";
202
+
203
+ // src/adapters/registry.ts
204
+ import { existsSync as existsSync2 } from "fs";
205
+ import { join as join9 } from "path";
206
+
207
+ // src/adapters/claude-code.ts
208
+ import { join as join4 } from "path";
209
+
210
+ // src/core/fs.ts
211
+ import { lstatSync } from "fs";
212
+ import { readdir, stat } from "fs/promises";
213
+ function isEnoent(error) {
214
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
215
+ }
216
+ async function statOrNull(path) {
217
+ try {
218
+ return await stat(path);
219
+ } catch (error) {
220
+ if (isEnoent(error)) {
221
+ return null;
222
+ }
223
+ throw error;
224
+ }
225
+ }
226
+ async function readDirectoryOrEmpty(path) {
227
+ try {
228
+ return await readdir(path, { withFileTypes: true });
229
+ } catch (error) {
230
+ if (isEnoent(error)) {
231
+ return [];
232
+ }
233
+ throw error;
234
+ }
235
+ }
236
+ function pathExists(path) {
237
+ try {
238
+ lstatSync(path);
239
+ return true;
240
+ } catch (error) {
241
+ if (isEnoent(error)) {
242
+ return false;
243
+ }
244
+ throw error;
245
+ }
246
+ }
247
+
248
+ // src/adapters/adapter.ts
249
+ var HARNESS_IDS = ["claude-code", "codex", "pi", "opencode", "gemini"];
250
+ var UUID_SOURCE = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
251
+ function isHarnessId(value) {
252
+ return typeof value === "string" && HARNESS_IDS.some((id) => id === value);
253
+ }
254
+
255
+ // src/adapters/session-files.ts
256
+ import { relative } from "path";
257
+ async function toSessionFile(storeRoot, absPath, role) {
258
+ const stats = await statOrNull(absPath);
259
+ if (stats === null || !stats.isFile()) {
260
+ return null;
261
+ }
262
+ return {
263
+ absPath,
264
+ relPath: relative(storeRoot, absPath),
265
+ role,
266
+ sizeBytes: stats.size,
267
+ mtimeMs: stats.mtimeMs
268
+ };
269
+ }
270
+
271
+ // src/adapters/claude-code.ts
272
+ var TRANSCRIPT_PATTERN = new RegExp(`^(${UUID_SOURCE})\\.jsonl$`, "i");
273
+ var SIDECAR_DIRECTORY_PATTERN = new RegExp(`^(${UUID_SOURCE})$`, "i");
274
+ async function walkSidecars(storeRoot, directory) {
275
+ const files = [];
276
+ const entries = (await readDirectoryOrEmpty(directory)).sort((left, right) => left.name.localeCompare(right.name));
277
+ for (const entry of entries) {
278
+ if (entry.name === ".DS_Store") {
279
+ continue;
280
+ }
281
+ const absPath = join4(directory, entry.name);
282
+ if (entry.isDirectory()) {
283
+ files.push(...await walkSidecars(storeRoot, absPath));
284
+ continue;
285
+ }
286
+ if (!entry.isFile()) {
287
+ continue;
288
+ }
289
+ const file = await toSessionFile(storeRoot, absPath, "sidecar");
290
+ if (file !== null) {
291
+ files.push(file);
292
+ }
293
+ }
294
+ return files;
295
+ }
296
+ async function enumerateProject(storeRoot, projectDirectory) {
297
+ const units = /* @__PURE__ */ new Map();
298
+ const entries = (await readDirectoryOrEmpty(projectDirectory)).sort(
299
+ (left, right) => left.name.localeCompare(right.name)
300
+ );
301
+ for (const entry of entries) {
302
+ if (entry.name === ".DS_Store") {
303
+ continue;
304
+ }
305
+ const absPath = join4(projectDirectory, entry.name);
306
+ if (entry.isFile()) {
307
+ const match2 = TRANSCRIPT_PATTERN.exec(entry.name);
308
+ if (match2?.[1] === void 0) {
309
+ continue;
310
+ }
311
+ const file = await toSessionFile(storeRoot, absPath, "main");
312
+ if (file !== null) {
313
+ units.set(match2[1], { id: match2[1], files: [file, ...units.get(match2[1])?.files ?? []] });
314
+ }
315
+ continue;
316
+ }
317
+ if (!entry.isDirectory()) {
318
+ continue;
319
+ }
320
+ const match = SIDECAR_DIRECTORY_PATTERN.exec(entry.name);
321
+ if (match?.[1] === void 0) {
322
+ continue;
323
+ }
324
+ const sidecars = await walkSidecars(storeRoot, absPath);
325
+ if (sidecars.length > 0) {
326
+ const existing = units.get(match[1]);
327
+ units.set(match[1], { id: match[1], files: [...existing?.files ?? [], ...sidecars] });
328
+ }
329
+ }
330
+ return [...units.values()].sort((left, right) => left.id.localeCompare(right.id));
331
+ }
332
+ var claudeCodeAdapter = {
333
+ id: "claude-code",
334
+ displayName: "Claude Code",
335
+ mutationModel: "append-file",
336
+ retentionRisk: "Claude Code deletes sessions older than cleanupPeriodDays (30 days by default) at startup.",
337
+ storeRoot(env, home) {
338
+ const configRoot = env.CLAUDE_CONFIG_DIR ? env.CLAUDE_CONFIG_DIR : join4(home, ".claude");
339
+ return join4(configRoot, "projects");
340
+ },
341
+ async enumerate(storeRoot) {
342
+ const units = [];
343
+ const projects = (await readDirectoryOrEmpty(storeRoot)).sort((left, right) => left.name.localeCompare(right.name));
344
+ for (const project of projects) {
345
+ if (project.name === ".DS_Store" || !project.isDirectory()) {
346
+ continue;
347
+ }
348
+ units.push(...await enumerateProject(storeRoot, join4(storeRoot, project.name)));
349
+ }
350
+ return units;
351
+ },
352
+ restoreTarget(storeRoot, relPath) {
353
+ return join4(storeRoot, relPath);
354
+ },
355
+ resumeHint(unit) {
356
+ return ["Run from the original project directory:", `claude --resume ${unit.id}`];
357
+ }
358
+ };
359
+
360
+ // src/adapters/codex.ts
361
+ import { join as join5, relative as relative2, sep } from "path";
362
+ var ROLLOUT_PATTERN = new RegExp(`^rollout-.+-(${UUID_SOURCE})\\.jsonl$`, "i");
363
+ var YEAR_PATTERN = /^\d{4}$/;
364
+ var MONTH_OR_DAY_PATTERN = /^\d{2}$/;
365
+ function rolloutId(relPath) {
366
+ const segments = relPath.split(sep);
367
+ const filename = segments.at(-1);
368
+ if (filename === void 0) {
369
+ return null;
370
+ }
371
+ const match = ROLLOUT_PATTERN.exec(filename);
372
+ if (match?.[1] === void 0) {
373
+ return null;
374
+ }
375
+ const isArchived = segments.length === 2 && segments[0] === "archived_sessions";
376
+ const isActive = segments.length === 5 && segments[0] === "sessions" && YEAR_PATTERN.test(segments[1] ?? "") && MONTH_OR_DAY_PATTERN.test(segments[2] ?? "") && MONTH_OR_DAY_PATTERN.test(segments[3] ?? "");
377
+ return isArchived || isActive ? match[1] : null;
378
+ }
379
+ async function walkRollouts(storeRoot, directory) {
380
+ const units = [];
381
+ const entries = (await readDirectoryOrEmpty(directory)).sort((left, right) => left.name.localeCompare(right.name));
382
+ for (const entry of entries) {
383
+ if (entry.name === ".DS_Store") {
384
+ continue;
385
+ }
386
+ const absPath = join5(directory, entry.name);
387
+ if (entry.isDirectory()) {
388
+ units.push(...await walkRollouts(storeRoot, absPath));
389
+ continue;
390
+ }
391
+ if (!entry.isFile()) {
392
+ continue;
393
+ }
394
+ const relPath = relative2(storeRoot, absPath);
395
+ const id = rolloutId(relPath);
396
+ if (id === null) {
397
+ continue;
398
+ }
399
+ const stats = await statOrNull(absPath);
400
+ if (stats === null || !stats.isFile()) {
401
+ continue;
402
+ }
403
+ const file = {
404
+ absPath,
405
+ relPath,
406
+ role: "main",
407
+ sizeBytes: stats.size,
408
+ mtimeMs: stats.mtimeMs
409
+ };
410
+ units.push({ id, files: [file] });
411
+ }
412
+ return units;
413
+ }
414
+ var codexAdapter = {
415
+ id: "codex",
416
+ displayName: "Codex",
417
+ mutationModel: "append-file",
418
+ retentionRisk: null,
419
+ storeRoot(env, home) {
420
+ return env.CODEX_HOME ? env.CODEX_HOME : join5(home, ".codex");
421
+ },
422
+ async enumerate(storeRoot) {
423
+ const units = [
424
+ ...await walkRollouts(storeRoot, join5(storeRoot, "sessions")),
425
+ ...await walkRollouts(storeRoot, join5(storeRoot, "archived_sessions"))
426
+ ];
427
+ return units.sort((left, right) => left.files[0].relPath.localeCompare(right.files[0].relPath));
428
+ },
429
+ restoreTarget(storeRoot, relPath) {
430
+ return join5(storeRoot, relPath);
431
+ },
432
+ resumeHint(unit) {
433
+ const archivedPrefix = `archived_sessions${sep}`;
434
+ if (unit.relPaths.some((relPath) => relPath.startsWith(archivedPrefix))) {
435
+ return [`codex unarchive ${unit.id}`, `codex resume ${unit.id}`];
436
+ }
437
+ return [`codex resume ${unit.id}`];
438
+ }
439
+ };
440
+
441
+ // src/adapters/gemini.ts
442
+ import { open } from "fs/promises";
443
+ import { extname, join as join6 } from "path";
444
+ var MAX_METADATA_BYTES = 64 * 1024;
445
+ var SESSION_PATTERN = /^session-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-([0-9a-f]{8})\.jsonl?$/i;
446
+ var UUID_PATTERN = new RegExp(`^${UUID_SOURCE}$`, "i");
447
+ var LEGACY_ID_PREFIX_PATTERN = new RegExp(`^\\s*\\{\\s*"sessionId"\\s*:\\s*"(${UUID_SOURCE})"`, "i");
448
+ function isRecord(value) {
449
+ return typeof value === "object" && value !== null && !Array.isArray(value);
450
+ }
451
+ async function readMetadataPrefix(path) {
452
+ let handle;
453
+ try {
454
+ handle = await open(path, "r");
455
+ const buffer = Buffer.alloc(MAX_METADATA_BYTES + 1);
456
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
457
+ const newline = buffer.subarray(0, bytesRead).indexOf(10);
458
+ if (extname(path) === ".jsonl") {
459
+ if (newline < 0 && bytesRead > MAX_METADATA_BYTES) {
460
+ return null;
461
+ }
462
+ return { text: buffer.subarray(0, newline < 0 ? bytesRead : newline).toString("utf8"), complete: true };
463
+ }
464
+ return {
465
+ text: buffer.subarray(0, Math.min(bytesRead, MAX_METADATA_BYTES)).toString("utf8"),
466
+ complete: bytesRead <= MAX_METADATA_BYTES
467
+ };
468
+ } catch (error) {
469
+ if (isEnoent(error)) {
470
+ return null;
471
+ }
472
+ throw error;
473
+ } finally {
474
+ await handle?.close();
475
+ }
476
+ }
477
+ function sessionIdFromMetadata(path, metadata) {
478
+ try {
479
+ const parsed = JSON.parse(metadata.text);
480
+ if (!isRecord(parsed) || typeof parsed.sessionId !== "string" || !UUID_PATTERN.test(parsed.sessionId)) {
481
+ return null;
482
+ }
483
+ return parsed.kind === "subagent" ? null : parsed.sessionId;
484
+ } catch {
485
+ if (extname(path) !== ".json" || metadata.complete) {
486
+ return null;
487
+ }
488
+ return LEGACY_ID_PREFIX_PATTERN.exec(metadata.text)?.[1] ?? null;
489
+ }
490
+ }
491
+ async function enumerateProject2(storeRoot, projectDirectory) {
492
+ const marker = await toSessionFile(storeRoot, join6(projectDirectory, ".project_root"), "sidecar");
493
+ const chatsDirectory = join6(projectDirectory, "chats");
494
+ const entries = (await readDirectoryOrEmpty(chatsDirectory)).sort(
495
+ (left, right) => left.name.localeCompare(right.name)
496
+ );
497
+ const units = /* @__PURE__ */ new Map();
498
+ for (const entry of entries) {
499
+ const match = entry.isFile() ? SESSION_PATTERN.exec(entry.name) : null;
500
+ if (match?.[1] === void 0) {
501
+ continue;
502
+ }
503
+ const absPath = join6(chatsDirectory, entry.name);
504
+ const metadata = await readMetadataPrefix(absPath);
505
+ if (metadata === null) {
506
+ continue;
507
+ }
508
+ const id = sessionIdFromMetadata(absPath, metadata);
509
+ if (id === null || id.slice(0, 8).toLowerCase() !== match[1].toLowerCase()) {
510
+ continue;
511
+ }
512
+ const main = await toSessionFile(storeRoot, absPath, "main");
513
+ if (main === null) {
514
+ continue;
515
+ }
516
+ const existing = units.get(id);
517
+ if (existing === void 0) {
518
+ units.set(id, { id, files: marker === null ? [main] : [main, marker] });
519
+ continue;
520
+ }
521
+ const existingIsJsonl = extname(existing.files[0].absPath) === ".jsonl";
522
+ if (existingIsJsonl === (extname(absPath) === ".jsonl")) {
523
+ continue;
524
+ }
525
+ const [jsonlFile, legacyFile] = existingIsJsonl ? [existing.files[0], main] : [main, existing.files[0]];
526
+ const legacySidecar = { ...legacyFile, role: "sidecar" };
527
+ units.set(id, { id, files: marker === null ? [jsonlFile, legacySidecar] : [jsonlFile, legacySidecar, marker] });
528
+ }
529
+ return [...units.values()].sort((left, right) => left.files[0].relPath.localeCompare(right.files[0].relPath));
530
+ }
531
+ var geminiAdapter = {
532
+ id: "gemini",
533
+ displayName: "Gemini CLI",
534
+ mutationModel: "append-file",
535
+ // DRAFT copy
536
+ retentionRisk: "Gemini CLI deletes sessions older than general.sessionRetention.maxAge (30 days by default) at startup, including their associated artifacts.",
537
+ storeRoot(env, home) {
538
+ return join6(env.GEMINI_CLI_HOME || home, ".gemini", "tmp");
539
+ },
540
+ async enumerate(storeRoot) {
541
+ const units = [];
542
+ const projects = (await readDirectoryOrEmpty(storeRoot)).sort((left, right) => left.name.localeCompare(right.name));
543
+ for (const project of projects) {
544
+ if (!project.isDirectory()) {
545
+ continue;
546
+ }
547
+ units.push(...await enumerateProject2(storeRoot, join6(storeRoot, project.name)));
548
+ }
549
+ return units;
550
+ },
551
+ restoreTarget(storeRoot, relPath) {
552
+ return join6(storeRoot, relPath);
553
+ },
554
+ resumeHint(unit) {
555
+ return ["Run from the original project directory:", `gemini --resume ${unit.id}`];
556
+ }
557
+ };
558
+
559
+ // src/adapters/opencode.ts
560
+ import { createHash as createHash2 } from "crypto";
561
+ import { readFile } from "fs/promises";
562
+ import { isAbsolute, join as join7 } from "path";
563
+ var sqliteModule = "node:sqlite";
564
+ var { backup, DatabaseSync } = await import(sqliteModule);
565
+ function assertQuickCheck(database) {
566
+ const results = database.prepare("PRAGMA quick_check").all();
567
+ if (results.length !== 1 || results[0]?.quick_check !== "ok") {
568
+ throw new Error("OpenCode database failed PRAGMA quick_check");
569
+ }
570
+ }
571
+ function sessions(database) {
572
+ return database.prepare("SELECT id, time_created, time_updated FROM session ORDER BY id").all().map((row) => {
573
+ if (typeof row.id !== "string" || typeof row.time_created !== "number" || typeof row.time_updated !== "number") {
574
+ throw new Error("OpenCode session metadata has an unsupported shape");
575
+ }
576
+ return { id: row.id, timeCreated: row.time_created, timeUpdated: row.time_updated };
577
+ });
578
+ }
579
+ function softwareVersion(database) {
580
+ const row = database.prepare("SELECT version FROM session ORDER BY time_updated DESC, id DESC LIMIT 1").get();
581
+ if (row === void 0) {
582
+ return null;
583
+ }
584
+ if (typeof row.version !== "string") {
585
+ throw new Error("OpenCode version metadata has an unsupported shape");
586
+ }
587
+ return row.version;
588
+ }
589
+ async function enumerate(databasePath) {
590
+ const stats = await statOrNull(databasePath);
591
+ if (stats === null || !stats.isFile()) {
592
+ return [];
593
+ }
594
+ return [{ sourcePath: databasePath, sourceMtimeMs: stats.mtimeMs, sourceSize: stats.size }];
595
+ }
596
+ async function snapshot(unit, destination) {
597
+ const source = new DatabaseSync(unit.sourcePath, { readOnly: true });
598
+ try {
599
+ await backup(source, destination);
600
+ } finally {
601
+ source.close();
602
+ }
603
+ const completed = new DatabaseSync(destination, { readOnly: true });
604
+ let capturedSessions;
605
+ let capturedVersion;
606
+ try {
607
+ assertQuickCheck(completed);
608
+ capturedVersion = softwareVersion(completed);
609
+ capturedSessions = sessions(completed);
610
+ } finally {
611
+ completed.close();
612
+ }
613
+ const bytes = await readFile(destination);
614
+ return {
615
+ contentSha256: createHash2("sha256").update(bytes).digest("hex"),
616
+ sizeBytes: bytes.byteLength,
617
+ softwareVersion: capturedVersion,
618
+ sessions: capturedSessions
619
+ };
620
+ }
621
+ async function validateSnapshot(snapshotPath, sessionId) {
622
+ const database = new DatabaseSync(snapshotPath, { readOnly: true });
623
+ try {
624
+ assertQuickCheck(database);
625
+ if (database.prepare("SELECT 1 FROM session WHERE id = ?").get(sessionId) === void 0) {
626
+ throw new Error(`OpenCode snapshot does not contain session ${sessionId}`);
627
+ }
628
+ } finally {
629
+ database.close();
630
+ }
631
+ }
632
+ var openCodeAdapter = {
633
+ id: "opencode",
634
+ // DRAFT copy
635
+ displayName: "OpenCode",
636
+ mutationModel: "db-snapshot",
637
+ // DRAFT copy
638
+ retentionRisk: "OpenCode does not automatically prune session history; explicit deletion removes it from the shared SQLite database.",
639
+ snapshotFilename: "opencode.db.zst",
640
+ storeRoot(env, home) {
641
+ const dataHome = env.XDG_DATA_HOME ? env.XDG_DATA_HOME : join7(home, ".local", "share");
642
+ const dataRoot = join7(dataHome, "opencode");
643
+ if (env.OPENCODE_DB) {
644
+ return isAbsolute(env.OPENCODE_DB) ? env.OPENCODE_DB : join7(dataRoot, env.OPENCODE_DB);
645
+ }
646
+ return join7(dataRoot, "opencode.db");
647
+ },
648
+ enumerate,
649
+ snapshot,
650
+ validateSnapshot,
651
+ resumeHint(unit) {
652
+ return [`opencode -s ${unit.id}`];
653
+ }
654
+ };
655
+
656
+ // src/adapters/pi.ts
657
+ import { join as join8, relative as relative3 } from "path";
658
+ var SANITIZED_ISO_SOURCE = "\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z";
659
+ var SESSION_PATTERN2 = new RegExp(`^${SANITIZED_ISO_SOURCE}_(${UUID_SOURCE})\\.jsonl$`, "i");
660
+ async function enumerateDirectory(storeRoot, directory) {
661
+ const units = [];
662
+ const entries = (await readDirectoryOrEmpty(directory)).sort((left, right) => left.name.localeCompare(right.name));
663
+ for (const entry of entries) {
664
+ if (entry.name === ".DS_Store" || !entry.isFile()) {
665
+ continue;
666
+ }
667
+ const match = SESSION_PATTERN2.exec(entry.name);
668
+ if (match?.[1] === void 0) {
669
+ continue;
670
+ }
671
+ const absPath = join8(directory, entry.name);
672
+ const stats = await statOrNull(absPath);
673
+ if (stats === null || !stats.isFile()) {
674
+ continue;
675
+ }
676
+ const file = {
677
+ absPath,
678
+ relPath: relative3(storeRoot, absPath),
679
+ role: "main",
680
+ sizeBytes: stats.size,
681
+ mtimeMs: stats.mtimeMs
682
+ };
683
+ units.push({ id: match[1], files: [file] });
684
+ }
685
+ return units;
686
+ }
687
+ var piAdapter = {
688
+ id: "pi",
689
+ displayName: "pi",
690
+ mutationModel: "rewrite-file",
691
+ retentionRisk: null,
692
+ storeRoot(env, home) {
693
+ return env.PI_CODING_AGENT_SESSION_DIR ? env.PI_CODING_AGENT_SESSION_DIR : join8(home, ".pi", "agent", "sessions");
694
+ },
695
+ async enumerate(storeRoot) {
696
+ const units = await enumerateDirectory(storeRoot, storeRoot);
697
+ const projects = (await readDirectoryOrEmpty(storeRoot)).sort((left, right) => left.name.localeCompare(right.name));
698
+ for (const project of projects) {
699
+ if (project.name === ".DS_Store" || !project.isDirectory()) {
700
+ continue;
701
+ }
702
+ units.push(...await enumerateDirectory(storeRoot, join8(storeRoot, project.name)));
703
+ }
704
+ return units.sort((left, right) => left.files[0].relPath.localeCompare(right.files[0].relPath));
705
+ },
706
+ restoreTarget(storeRoot, relPath) {
707
+ return join8(storeRoot, relPath);
708
+ },
709
+ resumeHint(unit) {
710
+ return [`pi --session ${unit.id}`];
711
+ }
712
+ };
713
+
714
+ // src/adapters/registry.ts
715
+ var adapters = [
716
+ claudeCodeAdapter,
717
+ codexAdapter,
718
+ piAdapter,
719
+ openCodeAdapter,
720
+ geminiAdapter
721
+ ];
722
+ function existingPath(path) {
723
+ return existsSync2(path) ? path : null;
724
+ }
725
+ var unsupportedStores = [
726
+ {
727
+ id: "cursor",
728
+ displayName: "Cursor CLI",
729
+ mutationModel: "undisclosed",
730
+ detect(_env, home) {
731
+ return existingPath(join9(home, ".cursor"));
732
+ }
733
+ }
734
+ ];
735
+ function getAdapter(id) {
736
+ return adapters.find((adapter) => adapter.id === id);
737
+ }
738
+
739
+ // src/core/index.ts
740
+ import { createHash as createHash3 } from "crypto";
741
+ import { appendFile, readFile as readFile2 } from "fs/promises";
742
+ import { basename, join as join10 } from "path";
743
+ function isSnapshotSession(value) {
744
+ if (typeof value !== "object" || value === null) {
745
+ return false;
746
+ }
747
+ const session = value;
748
+ return typeof session.id === "string" && typeof session.timeCreated === "number" && typeof session.timeUpdated === "number";
749
+ }
750
+ function isDatabaseSnapshotManifest(value) {
751
+ if (typeof value !== "object" || value === null) {
752
+ return false;
753
+ }
754
+ const manifest = value;
755
+ return manifest.v === 1 && manifest.kind === "db-snapshot" && isHarnessId(manifest.harness) && typeof manifest.sourcePath === "string" && (manifest.harnessVersion === null || typeof manifest.harnessVersion === "string") && typeof manifest.snapshotAt === "string" && typeof manifest.contentSha256 === "string" && typeof manifest.sizeBytes === "number" && Array.isArray(manifest.sessions) && manifest.sessions.every(isSnapshotSession) && typeof manifest.payload === "string" && manifest.payload !== "" && basename(manifest.payload) === manifest.payload && !Number.isNaN(Date.parse(manifest.snapshotAt));
756
+ }
757
+ function isArchiveIndexRecord(value) {
758
+ if (typeof value !== "object" || value === null) {
759
+ return false;
760
+ }
761
+ const record = value;
762
+ const common = record.v === 1 && typeof record.path === "string" && isHarnessId(record.harness) && typeof record.machine === "string" && typeof record.unit === "string" && typeof record.source === "string" && typeof record.sourceMtimeMs === "number" && typeof record.sourceSize === "number" && typeof record.storedSize === "number" && typeof record.sha256 === "string" && typeof record.archivedAt === "string";
763
+ if (!common) {
764
+ return false;
765
+ }
766
+ if (record.role === "main" || record.role === "sidecar") {
767
+ return true;
768
+ }
769
+ return record.role === "database" && typeof record.contentSha256 === "string" && typeof record.snapshotAt === "string" && (record.harnessVersion === null || typeof record.harnessVersion === "string") && Array.isArray(record.sessions) && record.sessions.every(isSnapshotSession);
770
+ }
771
+ function isDatabaseSnapshotIndexRecord(record) {
772
+ return record.role === "database";
773
+ }
774
+ function parseIndex(contents) {
775
+ const records = /* @__PURE__ */ new Map();
776
+ let corruptLines = 0;
777
+ for (const line of contents.split("\n")) {
778
+ if (line.trim() === "") {
779
+ continue;
780
+ }
781
+ try {
782
+ const record = JSON.parse(line);
783
+ if (!isArchiveIndexRecord(record)) {
784
+ corruptLines += 1;
785
+ continue;
786
+ }
787
+ records.set(record.path, record);
788
+ } catch {
789
+ corruptLines += 1;
790
+ }
791
+ }
792
+ return { records, corruptLines };
793
+ }
794
+ async function readIndex(path) {
795
+ try {
796
+ return parseIndex(await readFile2(path, "utf8"));
797
+ } catch (error) {
798
+ if (isEnoent(error)) {
799
+ return { records: /* @__PURE__ */ new Map(), corruptLines: 0 };
800
+ }
801
+ throw error;
802
+ }
803
+ }
804
+ async function readSnapshotIndexRecords(machineRoot, machine) {
805
+ const records = [];
806
+ for (const harness of HARNESS_IDS) {
807
+ const snapshotRoot = join10(machineRoot, harness, "snapshots");
808
+ const directories = (await readDirectoryOrEmpty(snapshotRoot)).filter((entry) => entry.isDirectory());
809
+ for (const directory of directories) {
810
+ let value;
811
+ try {
812
+ value = JSON.parse(await readFile2(join10(snapshotRoot, directory.name, "manifest.json"), "utf8"));
813
+ } catch (error) {
814
+ if (isEnoent(error) || error instanceof SyntaxError) {
815
+ continue;
816
+ }
817
+ throw error;
818
+ }
819
+ if (!isDatabaseSnapshotManifest(value) || value.harness !== harness) {
820
+ continue;
821
+ }
822
+ const payloadPath = join10(snapshotRoot, directory.name, value.payload);
823
+ const payloadStat = await statOrNull(payloadPath);
824
+ if (payloadStat === null || !payloadStat.isFile()) {
825
+ continue;
826
+ }
827
+ const storedBytes = await readFile2(payloadPath);
828
+ records.push({
829
+ v: 1,
830
+ path: join10(harness, "snapshots", directory.name, value.payload),
831
+ harness,
832
+ machine,
833
+ unit: value.contentSha256,
834
+ role: "database",
835
+ source: value.sourcePath,
836
+ sourceMtimeMs: Date.parse(value.snapshotAt),
837
+ sourceSize: value.sizeBytes,
838
+ storedSize: storedBytes.byteLength,
839
+ sha256: createHash3("sha256").update(storedBytes).digest("hex"),
840
+ archivedAt: value.snapshotAt,
841
+ contentSha256: value.contentSha256,
842
+ snapshotAt: value.snapshotAt,
843
+ harnessVersion: value.harnessVersion,
844
+ sessions: value.sessions
845
+ });
846
+ }
847
+ }
848
+ return records;
849
+ }
850
+ async function readDerivedIndex(machineRoot, machine) {
851
+ const index = await readIndex(join10(machineRoot, "index.jsonl"));
852
+ for (const record of await readSnapshotIndexRecords(machineRoot, machine)) {
853
+ index.records.set(record.path, record);
854
+ }
855
+ return index;
856
+ }
857
+ async function appendIndex(path, record) {
858
+ await appendFile(path, `${JSON.stringify(record)}
859
+ `);
860
+ }
861
+
862
+ // src/schedule/cron.ts
863
+ var CRON_MARKER = "# packbat-sync";
864
+ function shellQuote(value) {
865
+ return `'${value.replaceAll("'", `'"'"'`).replaceAll("%", "\\%")}'`;
866
+ }
867
+ function isPackbatEntry(line) {
868
+ return line.trimEnd().endsWith(CRON_MARKER);
869
+ }
870
+ function generateCronEntry(options) {
871
+ const environment = [...options.environment].map(([key, value]) => `${key}=${shellQuote(value)} `).join("");
872
+ return `3 * * * * ${environment}${shellQuote(options.nodePath)} ${shellQuote(options.entryPath)} ${shellQuote("sync")} ${CRON_MARKER}`;
873
+ }
874
+ function stripCronEntry(contents) {
875
+ const hadTrailingNewline = contents.endsWith("\n");
876
+ const lines = contents.split("\n");
877
+ if (hadTrailingNewline) {
878
+ lines.pop();
879
+ }
880
+ const foreignLines = lines.filter((line) => !isPackbatEntry(line));
881
+ if (foreignLines.length === 0) {
882
+ return "";
883
+ }
884
+ return `${foreignLines.join("\n")}${hadTrailingNewline ? "\n" : ""}`;
885
+ }
886
+ function mergeCronTab(contents, entry) {
887
+ const foreign = stripCronEntry(contents);
888
+ const separator = foreign === "" || foreign.endsWith("\n") ? "" : "\n";
889
+ return `${foreign}${separator}${entry}
890
+ `;
891
+ }
892
+
893
+ // src/schedule/launchd.ts
894
+ import { join as join11 } from "path";
895
+ var LAUNCHD_LABEL = "dev.packbat.sync";
896
+ function escapeXmlText(value) {
897
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
898
+ }
899
+ function generateLaunchdPlist(options) {
900
+ const environment = options.environment.size === 0 ? "" : ` <key>EnvironmentVariables</key>
901
+ <dict>
902
+ ${[...options.environment].map(([key, value]) => ` <key>${escapeXmlText(key)}</key>
903
+ <string>${escapeXmlText(value)}</string>
904
+ `).join("")} </dict>
905
+ `;
906
+ const logPath = escapeXmlText(join11(options.logsPath, "launchd.log"));
907
+ return `<?xml version="1.0" encoding="UTF-8"?>
908
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
909
+ <plist version="1.0">
910
+ <dict>
911
+ <key>Label</key>
912
+ <string>${LAUNCHD_LABEL}</string>
913
+ <key>ProgramArguments</key>
914
+ <array>
915
+ <string>${escapeXmlText(options.nodePath)}</string>
916
+ <string>${escapeXmlText(options.entryPath)}</string>
917
+ <string>sync</string>
918
+ </array>
919
+ ${environment} <key>StartCalendarInterval</key>
920
+ <dict>
921
+ <key>Minute</key>
922
+ <integer>3</integer>
923
+ </dict>
924
+ <key>RunAtLoad</key>
925
+ <true/>
926
+ <key>ProcessType</key>
927
+ <string>Background</string>
928
+ <key>StandardOutPath</key>
929
+ <string>${logPath}</string>
930
+ <key>StandardErrorPath</key>
931
+ <string>${logPath}</string>
932
+ </dict>
933
+ </plist>
934
+ `;
935
+ }
936
+
937
+ // src/schedule/systemd.ts
938
+ var SYSTEMD_SERVICE = "packbat-sync.service";
939
+ var SYSTEMD_TIMER = "packbat-sync.timer";
940
+ function quoteSystemdValue(value) {
941
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%").replaceAll("\n", "\\n")}"`;
942
+ }
943
+ function generateSystemdService(options) {
944
+ const environment = [...options.environment].map(([key, value]) => `Environment=${quoteSystemdValue(`${key}=${value}`)}
945
+ `).join("");
946
+ return `[Unit]
947
+ Description=Archive AI agent sessions with Packbat
948
+
949
+ [Service]
950
+ Type=oneshot
951
+ ExecStart=${quoteSystemdValue(options.nodePath)} ${quoteSystemdValue(options.entryPath)} ${quoteSystemdValue("sync")}
952
+ ${environment}`;
953
+ }
954
+ function generateSystemdTimer() {
955
+ return `[Unit]
956
+ Description=Run Packbat sync hourly
957
+
958
+ [Timer]
959
+ OnCalendar=*-*-* *:03:00
960
+ Persistent=true
961
+ Unit=${SYSTEMD_SERVICE}
962
+
963
+ [Install]
964
+ WantedBy=timers.target
965
+ `;
966
+ }
967
+
968
+ // src/doctor/facts.ts
969
+ var MINIMUM_FREE_BYTES = 500 * 1024 * 1024;
970
+ function fact(id, status, detail, data) {
971
+ return { id, title: id, status, detail, ...data === void 0 ? {} : { data } };
972
+ }
973
+ function createDoctorContext(config, home, env = process.env, now = /* @__PURE__ */ new Date()) {
974
+ const configuredHome = env.HOME?.trim();
975
+ return { config, home, userHome: configuredHome ? configuredHome : homedir2(), env, now };
976
+ }
977
+ async function command(commandPath, args, env) {
978
+ return await new Promise((resolve) => {
979
+ execFile(commandPath, args, { encoding: "utf8", env }, (error, stdout, stderr) => {
980
+ resolve({ ok: error === null, stdout, stderr });
981
+ });
982
+ });
983
+ }
984
+ function decodeXml(value) {
985
+ return value.replaceAll("&quot;", '"').replaceAll("&gt;", ">").replaceAll("&lt;", "<").replaceAll("&amp;", "&");
986
+ }
987
+ function launchdArguments(contents) {
988
+ const section = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(contents)?.[1];
989
+ if (section === void 0) {
990
+ return null;
991
+ }
992
+ return [...section.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((match) => decodeXml(match[1] ?? ""));
993
+ }
994
+ function launchdEnvironment(contents) {
995
+ const marker = "<key>EnvironmentVariables</key>";
996
+ if (!contents.includes(marker)) {
997
+ return /* @__PURE__ */ new Map();
998
+ }
999
+ const section = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(contents)?.[1];
1000
+ if (section === void 0) {
1001
+ return null;
1002
+ }
1003
+ const pairPattern = /<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g;
1004
+ if (section.replace(pairPattern, "").trim() !== "") {
1005
+ return null;
1006
+ }
1007
+ return new Map(
1008
+ [...section.matchAll(pairPattern)].map((match) => [decodeXml(match[1] ?? ""), decodeXml(match[2] ?? "")])
1009
+ );
1010
+ }
1011
+ function decodeSystemdValue(value) {
1012
+ return value.replaceAll("%%", "%").replaceAll("\\n", "\n").replaceAll('\\"', '"').replaceAll("\\\\", "\\");
1013
+ }
1014
+ function systemdArguments(contents) {
1015
+ const line = /^ExecStart=(.+)$/m.exec(contents)?.[1];
1016
+ if (line === void 0) {
1017
+ return null;
1018
+ }
1019
+ const values = [...line.matchAll(/"((?:\\.|[^"])*)"/g)].map((match) => decodeSystemdValue(match[1] ?? ""));
1020
+ return values.length === 3 ? values : null;
1021
+ }
1022
+ function systemdEnvironment(contents) {
1023
+ const environment = /* @__PURE__ */ new Map();
1024
+ for (const line of contents.split("\n")) {
1025
+ if (!line.startsWith("Environment=")) {
1026
+ continue;
1027
+ }
1028
+ const encoded = /^Environment="((?:\\.|[^"])*)"$/.exec(line)?.[1];
1029
+ if (encoded === void 0) {
1030
+ return null;
1031
+ }
1032
+ const assignment = decodeSystemdValue(encoded);
1033
+ const separator = assignment.indexOf("=");
1034
+ if (separator <= 0) {
1035
+ return null;
1036
+ }
1037
+ environment.set(assignment.slice(0, separator), assignment.slice(separator + 1));
1038
+ }
1039
+ return environment;
1040
+ }
1041
+ function shellQuotedToken(input, start) {
1042
+ if (input[start] !== "'") {
1043
+ return null;
1044
+ }
1045
+ let value = "";
1046
+ let index = start + 1;
1047
+ while (index < input.length) {
1048
+ if (input.startsWith(`'"'"'`, index)) {
1049
+ value += "'";
1050
+ index += 5;
1051
+ continue;
1052
+ }
1053
+ const character = input[index];
1054
+ if (character === "'") {
1055
+ return { value: value.replaceAll("\\%", "%"), next: index + 1 };
1056
+ }
1057
+ value += character;
1058
+ index += 1;
1059
+ }
1060
+ return null;
1061
+ }
1062
+ function cronArguments(contents) {
1063
+ const line = contents.trimEnd();
1064
+ const prefix = "3 * * * * ";
1065
+ if (!line.startsWith(prefix) || !line.endsWith(` ${CRON_MARKER}`)) {
1066
+ return null;
1067
+ }
1068
+ let index = prefix.length;
1069
+ const environment = /* @__PURE__ */ new Map();
1070
+ while (true) {
1071
+ const assignment = /^([A-Z][A-Z0-9_]*)=/.exec(line.slice(index));
1072
+ const key = assignment?.[1];
1073
+ if (assignment === null || key === void 0) {
1074
+ break;
1075
+ }
1076
+ index += assignment[0].length;
1077
+ const value = shellQuotedToken(line, index);
1078
+ if (value === null || line[value.next] !== " ") {
1079
+ return null;
1080
+ }
1081
+ environment.set(key, value.value);
1082
+ index = value.next + 1;
1083
+ }
1084
+ const node = shellQuotedToken(line, index);
1085
+ if (node === null || line[node.next] !== " ") {
1086
+ return null;
1087
+ }
1088
+ const entry = shellQuotedToken(line, node.next + 1);
1089
+ if (entry === null || line[entry.next] !== " ") {
1090
+ return null;
1091
+ }
1092
+ const sync = shellQuotedToken(line, entry.next + 1);
1093
+ if (sync === null || sync.value !== "sync" || line.slice(sync.next) !== ` ${CRON_MARKER}`) {
1094
+ return null;
1095
+ }
1096
+ return { nodePath: node.value, entryPath: entry.value, environment };
1097
+ }
1098
+ async function executablePathsExist(nodePath, entryPath) {
1099
+ const missing = [];
1100
+ for (const path of [nodePath, entryPath]) {
1101
+ try {
1102
+ await access(path, constants2.F_OK);
1103
+ } catch {
1104
+ missing.push(path);
1105
+ }
1106
+ }
1107
+ return missing;
1108
+ }
1109
+ function installedData(schedule) {
1110
+ return {
1111
+ kind: schedule.kind,
1112
+ artifactPaths: schedule.artifactPaths,
1113
+ nodePath: schedule.nodePath,
1114
+ entryPath: schedule.entryPath
1115
+ };
1116
+ }
1117
+ async function checkLaunchdInstalled(context) {
1118
+ const artifactPath = join12(context.userHome, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
1119
+ let contents;
1120
+ try {
1121
+ contents = await readFile3(artifactPath, "utf8");
1122
+ } catch (error) {
1123
+ if (isEnoent(error)) {
1124
+ return { fact: fact("installed", "problem", `missing ${artifactPath}`), schedule: null };
1125
+ }
1126
+ return { fact: fact("installed", "problem", `cannot read ${artifactPath}`), schedule: null };
1127
+ }
1128
+ const args = launchdArguments(contents);
1129
+ const environment = launchdEnvironment(contents);
1130
+ if (args === null || environment === null || args.length !== 3 || args[2] !== "sync") {
1131
+ return { fact: fact("installed", "problem", "launchd artifact does not match Packbat's schedule"), schedule: null };
1132
+ }
1133
+ const nodePath = args[0] ?? "";
1134
+ const entryPath = args[1] ?? "";
1135
+ const expected = generateLaunchdPlist({
1136
+ nodePath,
1137
+ entryPath,
1138
+ logsPath: context.home.logsPath,
1139
+ environment
1140
+ });
1141
+ const schedule = { kind: "launchd", artifactPaths: [artifactPath], nodePath, entryPath };
1142
+ if (contents !== expected) {
1143
+ return {
1144
+ fact: fact("installed", "problem", "launchd artifact does not match Packbat's schedule", installedData(schedule)),
1145
+ schedule
1146
+ };
1147
+ }
1148
+ const missingPaths = await executablePathsExist(nodePath, entryPath);
1149
+ if (missingPaths.length > 0) {
1150
+ return {
1151
+ fact: fact("installed", "problem", `scheduled path missing: ${missingPaths.join(", ")}`, installedData(schedule)),
1152
+ schedule
1153
+ };
1154
+ }
1155
+ return { fact: fact("installed", "ok", "launchd schedule matches", installedData(schedule)), schedule };
1156
+ }
1157
+ function systemdArtifactPaths(userHome3) {
1158
+ const root = join12(userHome3, ".config", "systemd", "user");
1159
+ return { service: join12(root, SYSTEMD_SERVICE), timer: join12(root, SYSTEMD_TIMER) };
1160
+ }
1161
+ async function checkSystemdInstalled(context) {
1162
+ const paths = systemdArtifactPaths(context.userHome);
1163
+ let service;
1164
+ let timer;
1165
+ try {
1166
+ [service, timer] = await Promise.all([readFile3(paths.service, "utf8"), readFile3(paths.timer, "utf8")]);
1167
+ } catch {
1168
+ return {
1169
+ fact: fact("installed", "problem", `missing or unreadable ${SYSTEMD_SERVICE} / ${SYSTEMD_TIMER}`),
1170
+ schedule: null
1171
+ };
1172
+ }
1173
+ const args = systemdArguments(service);
1174
+ const environment = systemdEnvironment(service);
1175
+ if (args === null || environment === null || args[2] !== "sync") {
1176
+ return { fact: fact("installed", "problem", "systemd artifacts do not match Packbat's schedule"), schedule: null };
1177
+ }
1178
+ const nodePath = args[0] ?? "";
1179
+ const entryPath = args[1] ?? "";
1180
+ const schedule = {
1181
+ kind: "systemd",
1182
+ artifactPaths: [paths.service, paths.timer],
1183
+ nodePath,
1184
+ entryPath
1185
+ };
1186
+ const expectedService = generateSystemdService({
1187
+ nodePath,
1188
+ entryPath,
1189
+ environment
1190
+ });
1191
+ if (service !== expectedService || timer !== generateSystemdTimer()) {
1192
+ return {
1193
+ fact: fact("installed", "problem", "systemd artifacts do not match Packbat's schedule", installedData(schedule)),
1194
+ schedule
1195
+ };
1196
+ }
1197
+ const missingPaths = await executablePathsExist(nodePath, entryPath);
1198
+ if (missingPaths.length > 0) {
1199
+ return {
1200
+ fact: fact("installed", "problem", `scheduled path missing: ${missingPaths.join(", ")}`, installedData(schedule)),
1201
+ schedule
1202
+ };
1203
+ }
1204
+ return { fact: fact("installed", "ok", "systemd schedule matches", installedData(schedule)), schedule };
1205
+ }
1206
+ async function checkCronInstalled(context) {
1207
+ const artifactPath = join12(context.home.statePath, "schedule.cron");
1208
+ let contents;
1209
+ try {
1210
+ contents = await readFile3(artifactPath, "utf8");
1211
+ } catch {
1212
+ return { fact: fact("installed", "problem", `missing or unreadable ${artifactPath}`), schedule: null };
1213
+ }
1214
+ const args = cronArguments(contents);
1215
+ if (args === null) {
1216
+ return { fact: fact("installed", "problem", "cron artifact does not match Packbat's schedule"), schedule: null };
1217
+ }
1218
+ const schedule = {
1219
+ kind: "cron",
1220
+ artifactPaths: [artifactPath],
1221
+ nodePath: args.nodePath,
1222
+ entryPath: args.entryPath
1223
+ };
1224
+ const expected = `${generateCronEntry({
1225
+ nodePath: args.nodePath,
1226
+ entryPath: args.entryPath,
1227
+ environment: args.environment
1228
+ })}
1229
+ `;
1230
+ if (contents !== expected) {
1231
+ return {
1232
+ fact: fact("installed", "problem", "cron artifact does not match Packbat's schedule", installedData(schedule)),
1233
+ schedule
1234
+ };
1235
+ }
1236
+ const missingPaths = await executablePathsExist(args.nodePath, args.entryPath);
1237
+ if (missingPaths.length > 0) {
1238
+ return {
1239
+ fact: fact("installed", "problem", `scheduled path missing: ${missingPaths.join(", ")}`, installedData(schedule)),
1240
+ schedule
1241
+ };
1242
+ }
1243
+ return { fact: fact("installed", "ok", "cron schedule matches", installedData(schedule)), schedule };
1244
+ }
1245
+ async function checkInstalled(context) {
1246
+ if (process.platform === "darwin") {
1247
+ return await checkLaunchdInstalled(context);
1248
+ }
1249
+ if (process.platform === "linux") {
1250
+ const paths = systemdArtifactPaths(context.userHome);
1251
+ const hasSystemdArtifact = pathExists(paths.service) || pathExists(paths.timer);
1252
+ const hasCronArtifact = pathExists(join12(context.home.statePath, "schedule.cron"));
1253
+ if (hasSystemdArtifact && !hasCronArtifact) {
1254
+ return await checkSystemdInstalled(context);
1255
+ }
1256
+ if (hasCronArtifact && !hasSystemdArtifact) {
1257
+ return await checkCronInstalled(context);
1258
+ }
1259
+ return commandOnPath("systemctl", context.env) !== null ? await checkSystemdInstalled(context) : await checkCronInstalled(context);
1260
+ }
1261
+ return {
1262
+ fact: fact("installed", "problem", `scheduling is not supported on ${process.platform}`),
1263
+ schedule: null
1264
+ };
1265
+ }
1266
+ function parseLaunchdExitStatus(output) {
1267
+ const match = /last exit (?:code|status)\s*=\s*(-?\d+)/i.exec(output);
1268
+ if (match?.[1] === void 0) {
1269
+ return null;
1270
+ }
1271
+ const value = Number.parseInt(match[1], 10);
1272
+ return Number.isNaN(value) ? null : value;
1273
+ }
1274
+ function parseLaunchdArtifactPath(output) {
1275
+ return /^\s*path\s*=\s*(.+?)\s*$/im.exec(output)?.[1] ?? null;
1276
+ }
1277
+ async function checkLaunchdLive(context, schedule) {
1278
+ if (process.getuid === void 0) {
1279
+ return fact("live", "problem", "launchd user domain is unavailable");
1280
+ }
1281
+ const target = `gui/${process.getuid()}/${LAUNCHD_LABEL}`;
1282
+ const result = await command("launchctl", ["print", target], context.env);
1283
+ if (!result.ok) {
1284
+ return fact("live", "problem", `launchd job is not loaded (${target})`);
1285
+ }
1286
+ const expectedPath = schedule.artifactPaths[0] ?? "";
1287
+ const loadedPath = parseLaunchdArtifactPath(result.stdout);
1288
+ if (loadedPath === null) {
1289
+ return fact("live", "problem", `launchd job artifact path is unavailable; expected ${expectedPath}`, {
1290
+ expectedPath
1291
+ });
1292
+ }
1293
+ if (loadedPath !== expectedPath) {
1294
+ return fact("live", "problem", `launchd job is loaded from ${loadedPath}; expected ${expectedPath}`, {
1295
+ loadedPath,
1296
+ expectedPath
1297
+ });
1298
+ }
1299
+ const exitStatus = parseLaunchdExitStatus(result.stdout);
1300
+ if (exitStatus === null) {
1301
+ return fact("live", "info", "loaded, state unverified");
1302
+ }
1303
+ return exitStatus === 0 ? fact("live", "ok", "loaded, last exit 0", { exitStatus }) : fact("live", "problem", `loaded, last exit ${exitStatus}`, { exitStatus });
1304
+ }
1305
+ function parseProperties(contents) {
1306
+ const properties = {};
1307
+ for (const line of contents.split("\n")) {
1308
+ const separator = line.indexOf("=");
1309
+ if (separator > 0) {
1310
+ properties[line.slice(0, separator)] = line.slice(separator + 1);
1311
+ }
1312
+ }
1313
+ return properties;
1314
+ }
1315
+ async function checkSystemdLive(context) {
1316
+ const result = await command(
1317
+ "systemctl",
1318
+ ["--user", "show", SYSTEMD_TIMER, "--property=LoadState,UnitFileState,ActiveState"],
1319
+ context.env
1320
+ );
1321
+ if (!result.ok) {
1322
+ return fact("live", "problem", `${SYSTEMD_TIMER} could not be inspected`);
1323
+ }
1324
+ const properties = parseProperties(result.stdout);
1325
+ const expected = { LoadState: "loaded", UnitFileState: "enabled", ActiveState: "active" };
1326
+ const problems = Object.entries(expected).filter(([key, value]) => properties[key] !== value).map(([key, value]) => `${key}=${properties[key] ?? "missing"} (want ${value})`);
1327
+ return problems.length === 0 ? fact("live", "ok", "systemd timer is loaded, enabled, active", properties) : fact("live", "problem", problems.join(", "), properties);
1328
+ }
1329
+ async function checkCronLive(context) {
1330
+ const result = await command("crontab", ["-l"], context.env);
1331
+ if (!result.ok) {
1332
+ return fact("live", "problem", "Packbat marker is absent from crontab");
1333
+ }
1334
+ const present = result.stdout.split("\n").some((line) => line.trimEnd().endsWith(CRON_MARKER));
1335
+ return present ? fact("live", "info", "cron marker is present; cron cannot prove execution") : fact("live", "problem", "Packbat marker is absent from crontab");
1336
+ }
1337
+ async function checkLive(context, installed, options = { probe: true }) {
1338
+ if (!options.probe) {
1339
+ return fact("live", "info", "live state not checked; run `packbat doctor`");
1340
+ }
1341
+ if (installed.schedule === null) {
1342
+ return fact("live", "problem", "schedule state cannot be checked until it is installed");
1343
+ }
1344
+ switch (installed.schedule.kind) {
1345
+ case "launchd":
1346
+ return await checkLaunchdLive(context, installed.schedule);
1347
+ case "systemd":
1348
+ return await checkSystemdLive(context);
1349
+ case "cron":
1350
+ return await checkCronLive(context);
1351
+ }
1352
+ }
1353
+ function isRunStamp(value) {
1354
+ if (typeof value !== "object" || value === null) {
1355
+ return false;
1356
+ }
1357
+ const record = value;
1358
+ return typeof record.startedAt === "string" && typeof record.finishedAt === "string" && typeof record.ok === "boolean" && typeof record.archived === "number" && typeof record.unchanged === "number" && typeof record.failed === "number" && (record.repaired === void 0 || typeof record.repaired === "number");
1359
+ }
1360
+ async function readStamp(path) {
1361
+ let raw;
1362
+ try {
1363
+ raw = await readFile3(path, "utf8");
1364
+ } catch (error) {
1365
+ if (isEnoent(error)) {
1366
+ return { kind: "missing" };
1367
+ }
1368
+ return { kind: "invalid", detail: "unreadable" };
1369
+ }
1370
+ try {
1371
+ const value = JSON.parse(raw);
1372
+ if (!isRunStamp(value)) {
1373
+ return { kind: "invalid", detail: "invalid shape" };
1374
+ }
1375
+ const finishedAtMs = Date.parse(value.finishedAt);
1376
+ return Number.isNaN(finishedAtMs) ? { kind: "invalid", detail: "invalid finishedAt" } : { kind: "value", value, finishedAtMs };
1377
+ } catch {
1378
+ return { kind: "invalid", detail: "invalid JSON" };
1379
+ }
1380
+ }
1381
+ function windowMs(context) {
1382
+ return context.config.sweep.intervalMinutes * 2 * 60 * 1e3;
1383
+ }
1384
+ function ageMs(timestampMs, now) {
1385
+ return Math.max(0, now.getTime() - timestampMs);
1386
+ }
1387
+ function formatAge(milliseconds) {
1388
+ const seconds = Math.floor(milliseconds / 1e3);
1389
+ if (seconds < 60) {
1390
+ return `${seconds}s ago`;
1391
+ }
1392
+ const minutes = Math.floor(seconds / 60);
1393
+ if (minutes < 60) {
1394
+ return `${minutes}m ago`;
1395
+ }
1396
+ const hours = Math.floor(minutes / 60);
1397
+ if (hours < 48) {
1398
+ return `${hours}h ago`;
1399
+ }
1400
+ return `${Math.floor(hours / 24)}d ago`;
1401
+ }
1402
+ async function checkFresh(context) {
1403
+ const [lastRun, lastSuccess] = await Promise.all([
1404
+ readStamp(join12(context.home.statePath, "last-run.json")),
1405
+ readStamp(join12(context.home.statePath, "last-success.json"))
1406
+ ]);
1407
+ if (lastSuccess.kind === "missing") {
1408
+ return { fact: fact("fresh", "problem", "never succeeded"), lastRun, lastSuccess };
1409
+ }
1410
+ if (lastSuccess.kind === "invalid") {
1411
+ return {
1412
+ fact: fact("fresh", "problem", `last-success is ${lastSuccess.detail}`),
1413
+ lastRun,
1414
+ lastSuccess
1415
+ };
1416
+ }
1417
+ const successAgeMs = ageMs(lastSuccess.finishedAtMs, context.now);
1418
+ const fresh = successAgeMs < windowMs(context);
1419
+ if (fresh) {
1420
+ return {
1421
+ fact: fact("fresh", "ok", `last success ${formatAge(successAgeMs)}`, { lastSuccess: lastSuccess.value }),
1422
+ lastRun,
1423
+ lastSuccess
1424
+ };
1425
+ }
1426
+ let detail = `last success ${formatAge(successAgeMs)}`;
1427
+ if (lastRun.kind === "value" && !lastRun.value.ok && ageMs(lastRun.finishedAtMs, context.now) < windowMs(context)) {
1428
+ detail += `; latest run failed ${formatAge(ageMs(lastRun.finishedAtMs, context.now))}`;
1429
+ }
1430
+ return {
1431
+ fact: fact("fresh", "problem", detail, { lastSuccess: lastSuccess.value }),
1432
+ lastRun,
1433
+ lastSuccess
1434
+ };
1435
+ }
1436
+ function retentionFact() {
1437
+ const risks = adapters.flatMap(
1438
+ (adapter) => adapter.retentionRisk === null ? [] : [
1439
+ {
1440
+ harness: adapter.id,
1441
+ risk: adapter.retentionRisk
1442
+ }
1443
+ ]
1444
+ );
1445
+ return fact(
1446
+ "retention",
1447
+ "info",
1448
+ // DRAFT copy
1449
+ risks.map(({ harness, risk }) => `${harness}: ${risk.replace(/\.$/, "")}; the hourly sweep stays ahead of it`).join("\n"),
1450
+ { risks }
1451
+ );
1452
+ }
1453
+ async function unsupportedStoreFacts(context) {
1454
+ const facts = [];
1455
+ for (const store of unsupportedStores) {
1456
+ const path = store.detect(context.env, context.userHome);
1457
+ if (path !== null) {
1458
+ facts.push(fact(`unsupported-${store.id}`, "info", `found ${store.id} at ${path} \u2014 not yet supported`, { path }));
1459
+ }
1460
+ }
1461
+ return facts;
1462
+ }
1463
+ async function storeReadabilityFact(context) {
1464
+ const unreadable = [];
1465
+ const present = [];
1466
+ for (const adapter of adapters) {
1467
+ const path = adapter.storeRoot(context.env, context.userHome);
1468
+ try {
1469
+ await access(path, constants2.F_OK);
1470
+ present.push(path);
1471
+ await access(path, constants2.R_OK);
1472
+ } catch (error) {
1473
+ if (!isEnoent(error)) {
1474
+ unreadable.push(path);
1475
+ }
1476
+ }
1477
+ }
1478
+ return unreadable.length === 0 ? fact(
1479
+ "stores-readable",
1480
+ "ok",
1481
+ `${present.length} existing source store${present.length === 1 ? " is" : "s are"} readable`,
1482
+ {
1483
+ present
1484
+ }
1485
+ ) : fact("stores-readable", "problem", `unreadable: ${unreadable.join(", ")}`, { present, unreadable });
1486
+ }
1487
+ async function archiveWritableFact(context) {
1488
+ const probePath = join12(context.config.archiveRoot, `.packbat-doctor-${process.pid}-${randomUUID()}`);
1489
+ try {
1490
+ await mkdir(context.config.archiveRoot, { recursive: true });
1491
+ await writeFile(probePath, "packbat doctor\n");
1492
+ await rm(probePath);
1493
+ return fact("archive-writable", "ok", `writable: ${context.config.archiveRoot}`);
1494
+ } catch (error) {
1495
+ await rm(probePath, { force: true }).catch(() => void 0);
1496
+ return fact(
1497
+ "archive-writable",
1498
+ "problem",
1499
+ `cannot write ${context.config.archiveRoot}: ${error instanceof Error ? error.message : String(error)}`
1500
+ );
1501
+ }
1502
+ }
1503
+ async function diskHeadroomFact(context) {
1504
+ try {
1505
+ const stats = await statfs(context.config.archiveRoot);
1506
+ const freeBytes = stats.bavail * stats.bsize;
1507
+ const freeMiB = Math.floor(freeBytes / 1024 / 1024);
1508
+ return freeBytes < MINIMUM_FREE_BYTES ? fact("disk-headroom", "problem", `${freeMiB} MiB free; need at least 500 MiB`, { freeBytes }) : fact("disk-headroom", "ok", `${freeMiB} MiB free`, { freeBytes });
1509
+ } catch (error) {
1510
+ return fact(
1511
+ "disk-headroom",
1512
+ "problem",
1513
+ `cannot inspect ${context.config.archiveRoot}: ${error instanceof Error ? error.message : String(error)}`
1514
+ );
1515
+ }
1516
+ }
1517
+ function compressionFact() {
1518
+ try {
1519
+ if (typeof zlib.zstdCompressSync !== "function" || typeof zlib.zstdDecompressSync !== "function") {
1520
+ throw new Error("zstd is unavailable");
1521
+ }
1522
+ const source = Buffer.from("packbat compression smoke\n", "utf8");
1523
+ const restored = zlib.zstdDecompressSync(zlib.zstdCompressSync(source));
1524
+ if (!restored.equals(source)) {
1525
+ throw new Error("round-trip mismatch");
1526
+ }
1527
+ return fact("compression", "ok", "zstd round-trip passed");
1528
+ } catch (error) {
1529
+ return fact(
1530
+ "compression",
1531
+ "problem",
1532
+ `zstd round-trip failed: ${error instanceof Error ? error.message : String(error)}`
1533
+ );
1534
+ }
1535
+ }
1536
+ async function checkRemoteOffbox(context, remote) {
1537
+ const prefix = `${remote.destination} \xB7 `;
1538
+ const path = join12(remoteStatePath(context.home, remote), "last-success.json");
1539
+ let value;
1540
+ try {
1541
+ value = JSON.parse(await readFile3(path, "utf8"));
1542
+ } catch (error) {
1543
+ if (isEnoent(error)) {
1544
+ return fact("offbox", "problem", `${prefix}off-box has never succeeded`);
1545
+ }
1546
+ return fact("offbox", "problem", `${prefix}last-success is unreadable or invalid`);
1547
+ }
1548
+ const rawFinishedAt = typeof value === "object" && value !== null ? value.finishedAt : void 0;
1549
+ const finishedAt = typeof rawFinishedAt === "string" ? rawFinishedAt : null;
1550
+ const finishedAtMs = finishedAt === null ? Number.NaN : Date.parse(finishedAt);
1551
+ if (Number.isNaN(finishedAtMs)) {
1552
+ return fact("offbox", "problem", `${prefix}last-success has an invalid finishedAt`);
1553
+ }
1554
+ const elapsed = ageMs(finishedAtMs, context.now);
1555
+ return elapsed < windowMs(context) ? fact("offbox", "ok", `${prefix}last off-box success ${formatAge(elapsed)}`, {
1556
+ finishedAt,
1557
+ destination: remote.destination
1558
+ }) : fact("offbox", "problem", `${prefix}last off-box success ${formatAge(elapsed)}`, {
1559
+ finishedAt,
1560
+ destination: remote.destination
1561
+ });
1562
+ }
1563
+ async function checkOffbox(context) {
1564
+ if (context.config.offbox.mode === "skipped") {
1565
+ return [fact("offbox", "info", `off-box skipped on ${context.config.offbox.skippedAt.slice(0, 10)}`)];
1566
+ }
1567
+ return await Promise.all(
1568
+ context.config.offbox.remotes.map(async (remote) => await checkRemoteOffbox(context, remote))
1569
+ );
1570
+ }
1571
+ async function collectEnvironmentFacts(context) {
1572
+ const unsupported = await unsupportedStoreFacts(context);
1573
+ const readable = await storeReadabilityFact(context);
1574
+ const writable = await archiveWritableFact(context);
1575
+ const disk = await diskHeadroomFact(context);
1576
+ const compression = compressionFact();
1577
+ const offbox = await checkOffbox(context);
1578
+ return [...unsupported, readable, writable, disk, compression, ...offbox];
1579
+ }
1580
+ async function readHarnessTallies(context) {
1581
+ const machineRoot = join12(context.config.archiveRoot, context.config.machine);
1582
+ const index = await readDerivedIndex(machineRoot, context.config.machine);
1583
+ return adapters.map((adapter) => {
1584
+ const records = [...index.records.values()].filter((record) => record.harness === adapter.id);
1585
+ return {
1586
+ harness: adapter.id,
1587
+ units: new Set(records.map((record) => record.unit)).size,
1588
+ files: records.length,
1589
+ storedBytes: records.reduce((sum, record) => sum + record.storedSize, 0)
1590
+ };
1591
+ });
1592
+ }
1593
+ function remedyForFact(item) {
1594
+ if (item.id === "installed") {
1595
+ return "re-run `packbat init`";
1596
+ }
1597
+ if (item.id === "live") {
1598
+ return "re-run `packbat init` and inspect the scheduler";
1599
+ }
1600
+ if (item.id === "fresh") {
1601
+ return "run `packbat sync` and inspect the log; Claude Code's 30-day cleanup keeps running while sweeps fail";
1602
+ }
1603
+ if (item.id === "reconciled") {
1604
+ return "run `packbat sync`";
1605
+ }
1606
+ if (item.id === "stores-readable") {
1607
+ return "fix read access to the listed store roots";
1608
+ }
1609
+ if (item.id === "archive-writable") {
1610
+ return "fix write access to the archive root";
1611
+ }
1612
+ if (item.id === "disk-headroom") {
1613
+ return "free at least 500 MiB at the archive root";
1614
+ }
1615
+ if (item.id === "compression") {
1616
+ return "use Node 22.15 or newer";
1617
+ }
1618
+ if (item.id === "offbox") {
1619
+ return "run the off-box sync and inspect its log";
1620
+ }
1621
+ return "inspect this check and retry";
1622
+ }
1623
+
1624
+ // src/doctor/reconcile.ts
1625
+ import { mkdtemp, rm as rm4, stat as stat4 } from "fs/promises";
1626
+ import { tmpdir } from "os";
1627
+ import { join as join16, relative as relative4, sep as sep2 } from "path";
1628
+
1629
+ // src/core/archive.ts
1630
+ import { createHash as createHash5, randomUUID as randomUUID4 } from "crypto";
1631
+ import { mkdir as mkdir3, readFile as readFile5, rm as rm3, stat as stat3, utimes as utimes2 } from "fs/promises";
1632
+ import { homedir as homedir3 } from "os";
1633
+ import { dirname as dirname4, join as join15 } from "path";
1634
+
1635
+ // src/core/compress.ts
1636
+ import { createHash as createHash4, randomUUID as randomUUID2 } from "crypto";
1637
+ import { readFile as readFile4, rename, rm as rm2, stat as stat2, utimes, writeFile as writeFile2 } from "fs/promises";
1638
+ import { basename as basename2, dirname as dirname2, join as join13 } from "path";
1639
+ import * as zlib2 from "zlib";
1640
+ function assertZstdSupport() {
1641
+ if (typeof zlib2.zstdCompressSync !== "function" || typeof zlib2.zstdDecompressSync !== "function") {
1642
+ throw new PackbatError("zstd compression requires Node >= 22.16");
1643
+ }
1644
+ }
1645
+ async function compressFile(source, destination) {
1646
+ assertZstdSupport();
1647
+ const sourceStat = await stat2(source);
1648
+ const sourceBytes = await readFile4(source);
1649
+ const storedBytes = zlib2.zstdCompressSync(sourceBytes);
1650
+ const temporary = join13(dirname2(destination), `.${basename2(destination)}.tmp-${process.pid}-${randomUUID2()}`);
1651
+ try {
1652
+ await writeFile2(temporary, storedBytes);
1653
+ await rename(temporary, destination);
1654
+ } catch (error) {
1655
+ await rm2(temporary, { force: true });
1656
+ throw error;
1657
+ }
1658
+ await utimes(destination, sourceStat.atimeMs / 1e3, sourceStat.mtimeMs / 1e3);
1659
+ return {
1660
+ sourceMtimeMs: sourceStat.mtimeMs,
1661
+ sourceSize: sourceStat.size,
1662
+ storedSize: storedBytes.byteLength,
1663
+ sha256: createHash4("sha256").update(storedBytes).digest("hex")
1664
+ };
1665
+ }
1666
+ function decompressBytes(bytes) {
1667
+ assertZstdSupport();
1668
+ return zlib2.zstdDecompressSync(bytes);
1669
+ }
1670
+
1671
+ // src/core/stamps.ts
1672
+ import { randomUUID as randomUUID3 } from "crypto";
1673
+ import { mkdir as mkdir2, rename as rename2, writeFile as writeFile3 } from "fs/promises";
1674
+ import { dirname as dirname3, join as join14 } from "path";
1675
+ async function writeAtomicJson(path, value) {
1676
+ await mkdir2(dirname3(path), { recursive: true });
1677
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID3()}`;
1678
+ await writeFile3(temporary, `${JSON.stringify(value, null, " ")}
1679
+ `);
1680
+ await rename2(temporary, path);
1681
+ }
1682
+ async function writeRunStamps(statePath, stamp) {
1683
+ await mkdir2(statePath, { recursive: true });
1684
+ await writeAtomicJson(join14(statePath, "last-run.json"), stamp);
1685
+ if (stamp.ok) {
1686
+ await writeAtomicJson(join14(statePath, "last-success.json"), stamp);
1687
+ }
1688
+ }
1689
+
1690
+ // src/core/archive.ts
1691
+ function shouldArchive(decision) {
1692
+ if (decision.stored === null) {
1693
+ return true;
1694
+ }
1695
+ if (decision.sourceMtimeMs > decision.stored.mtimeMs) {
1696
+ return true;
1697
+ }
1698
+ return decision.sourceMtimeMs === decision.stored.mtimeMs && decision.indexSourceSize !== void 0 && decision.sourceSize !== decision.indexSourceSize;
1699
+ }
1700
+ async function storedFile(path) {
1701
+ try {
1702
+ const storedStat = await stat3(path);
1703
+ return { mtimeMs: storedStat.mtimeMs };
1704
+ } catch (error) {
1705
+ if (isEnoent(error)) {
1706
+ return null;
1707
+ }
1708
+ throw error;
1709
+ }
1710
+ }
1711
+ function emptyCounts() {
1712
+ return { archived: 0, unchanged: 0, failed: 0 };
1713
+ }
1714
+ function emptyPerHarness() {
1715
+ return Object.fromEntries(HARNESS_IDS.map((harness) => [harness, emptyCounts()]));
1716
+ }
1717
+ function increment(result, harness, field) {
1718
+ result[field] += 1;
1719
+ result.perHarness[harness][field] += 1;
1720
+ }
1721
+ function userHome(env) {
1722
+ const configured = env.HOME?.trim();
1723
+ return configured ? configured : homedir3();
1724
+ }
1725
+ function utcBasic(isoTimestamp) {
1726
+ return isoTimestamp.replaceAll("-", "").replaceAll(":", "");
1727
+ }
1728
+ async function newestSnapshot(machinePath, adapter) {
1729
+ const snapshotRoot = join15(machinePath, adapter.id, "snapshots");
1730
+ const entries = (await readDirectoryOrEmpty(snapshotRoot)).filter((entry) => entry.isDirectory()).sort((left, right) => right.name.localeCompare(left.name));
1731
+ for (const entry of entries) {
1732
+ const directory = join15(snapshotRoot, entry.name);
1733
+ let value;
1734
+ try {
1735
+ value = JSON.parse(await readFile5(join15(directory, "manifest.json"), "utf8"));
1736
+ } catch (error) {
1737
+ if (isEnoent(error) || error instanceof SyntaxError) {
1738
+ continue;
1739
+ }
1740
+ throw error;
1741
+ }
1742
+ if (!isDatabaseSnapshotManifest(value) || value.harness !== adapter.id || value.payload !== adapter.snapshotFilename) {
1743
+ continue;
1744
+ }
1745
+ const payloadPath = join15(directory, value.payload);
1746
+ const payloadStat = await statOrNull(payloadPath);
1747
+ if (payloadStat === null || !payloadStat.isFile()) {
1748
+ continue;
1749
+ }
1750
+ try {
1751
+ const databaseBytes = decompressBytes(await readFile5(payloadPath));
1752
+ if (databaseBytes.byteLength !== value.sizeBytes || createHash5("sha256").update(databaseBytes).digest("hex") !== value.contentSha256) {
1753
+ return null;
1754
+ }
1755
+ } catch {
1756
+ return null;
1757
+ }
1758
+ return {
1759
+ manifest: value,
1760
+ payloadPath,
1761
+ relativePayloadPath: join15(adapter.id, "snapshots", entry.name, value.payload)
1762
+ };
1763
+ }
1764
+ return null;
1765
+ }
1766
+ async function snapshotIndexRecord(machine, snapshot2) {
1767
+ const storedBytes = await readFile5(snapshot2.payloadPath);
1768
+ return {
1769
+ v: 1,
1770
+ path: snapshot2.relativePayloadPath,
1771
+ harness: snapshot2.manifest.harness,
1772
+ machine,
1773
+ unit: snapshot2.manifest.contentSha256,
1774
+ role: "database",
1775
+ source: snapshot2.manifest.sourcePath,
1776
+ sourceMtimeMs: Date.parse(snapshot2.manifest.snapshotAt),
1777
+ sourceSize: snapshot2.manifest.sizeBytes,
1778
+ storedSize: storedBytes.byteLength,
1779
+ sha256: createHash5("sha256").update(storedBytes).digest("hex"),
1780
+ archivedAt: snapshot2.manifest.snapshotAt,
1781
+ contentSha256: snapshot2.manifest.contentSha256,
1782
+ snapshotAt: snapshot2.manifest.snapshotAt,
1783
+ harnessVersion: snapshot2.manifest.harnessVersion,
1784
+ sessions: snapshot2.manifest.sessions
1785
+ };
1786
+ }
1787
+ async function archiveDatabaseSnapshot(config, machinePath, indexPath, index, adapter, unit, result) {
1788
+ const stagingRoot = join15(machinePath, adapter.id, ".staging");
1789
+ const stagedDatabase = join15(stagingRoot, `snapshot-${process.pid}-${randomUUID4()}.db`);
1790
+ await mkdir3(stagingRoot, { recursive: true });
1791
+ try {
1792
+ const capture = await adapter.snapshot(unit, stagedDatabase);
1793
+ const newest = await newestSnapshot(machinePath, adapter);
1794
+ if (newest?.manifest.contentSha256 === capture.contentSha256) {
1795
+ if (!index.records.has(newest.relativePayloadPath)) {
1796
+ const repaired = await snapshotIndexRecord(config.machine, newest);
1797
+ await appendIndex(indexPath, repaired);
1798
+ index.records.set(repaired.path, repaired);
1799
+ result.repaired += 1;
1800
+ }
1801
+ increment(result, adapter.id, "unchanged");
1802
+ return;
1803
+ }
1804
+ const snapshotAt = (/* @__PURE__ */ new Date()).toISOString();
1805
+ const snapshotName = `${utcBasic(snapshotAt)}-${capture.contentSha256}`;
1806
+ const snapshotRoot = join15(machinePath, adapter.id, "snapshots");
1807
+ const snapshotDirectory = join15(snapshotRoot, snapshotName);
1808
+ await mkdir3(snapshotRoot, { recursive: true });
1809
+ await mkdir3(snapshotDirectory);
1810
+ const snapshotTime = Date.parse(snapshotAt) / 1e3;
1811
+ await utimes2(stagedDatabase, snapshotTime, snapshotTime);
1812
+ const payloadPath = join15(snapshotDirectory, adapter.snapshotFilename);
1813
+ const compressed = await compressFile(stagedDatabase, payloadPath);
1814
+ const manifest = {
1815
+ v: 1,
1816
+ kind: "db-snapshot",
1817
+ harness: adapter.id,
1818
+ sourcePath: unit.sourcePath,
1819
+ harnessVersion: capture.softwareVersion,
1820
+ snapshotAt,
1821
+ contentSha256: capture.contentSha256,
1822
+ sizeBytes: capture.sizeBytes,
1823
+ sessions: capture.sessions,
1824
+ payload: adapter.snapshotFilename
1825
+ };
1826
+ await writeAtomicJson(join15(snapshotDirectory, "manifest.json"), manifest);
1827
+ const relativePayloadPath = join15(adapter.id, "snapshots", snapshotName, adapter.snapshotFilename);
1828
+ const record = {
1829
+ v: 1,
1830
+ path: relativePayloadPath,
1831
+ harness: adapter.id,
1832
+ machine: config.machine,
1833
+ unit: capture.contentSha256,
1834
+ role: "database",
1835
+ source: unit.sourcePath,
1836
+ sourceMtimeMs: compressed.sourceMtimeMs,
1837
+ sourceSize: capture.sizeBytes,
1838
+ storedSize: compressed.storedSize,
1839
+ sha256: compressed.sha256,
1840
+ archivedAt: snapshotAt,
1841
+ contentSha256: capture.contentSha256,
1842
+ snapshotAt,
1843
+ harnessVersion: capture.softwareVersion,
1844
+ sessions: capture.sessions
1845
+ };
1846
+ await appendIndex(indexPath, record);
1847
+ index.records.set(record.path, record);
1848
+ increment(result, adapter.id, "archived");
1849
+ } catch (error) {
1850
+ increment(result, adapter.id, "failed");
1851
+ result.errors.push(`${adapter.id}: ${unit.sourcePath}: ${errorMessage(error)}`);
1852
+ } finally {
1853
+ await Promise.all([
1854
+ rm3(stagedDatabase, { force: true }),
1855
+ rm3(`${stagedDatabase}-wal`, { force: true }),
1856
+ rm3(`${stagedDatabase}-shm`, { force: true })
1857
+ ]);
1858
+ }
1859
+ }
1860
+ async function archiveSessions(config, machinePath, indexPath, index, adapter, units, result) {
1861
+ for (const unit of units) {
1862
+ for (const file of unit.files) {
1863
+ const relativePath = join15(adapter.id, `${file.relPath}.zst`);
1864
+ const destination = join15(machinePath, relativePath);
1865
+ try {
1866
+ const currentIndexRecord = index.records.get(relativePath);
1867
+ const stored = await storedFile(destination);
1868
+ if (!shouldArchive({
1869
+ sourceMtimeMs: file.mtimeMs,
1870
+ sourceSize: file.sizeBytes,
1871
+ stored,
1872
+ indexSourceSize: currentIndexRecord?.sourceSize
1873
+ })) {
1874
+ if (stored !== null && currentIndexRecord?.sourceMtimeMs !== stored.mtimeMs) {
1875
+ const storedBytes = await readFile5(destination);
1876
+ const record2 = {
1877
+ v: 1,
1878
+ path: relativePath,
1879
+ harness: adapter.id,
1880
+ machine: config.machine,
1881
+ unit: unit.id,
1882
+ role: file.role,
1883
+ source: file.absPath,
1884
+ sourceMtimeMs: stored.mtimeMs,
1885
+ sourceSize: file.sizeBytes,
1886
+ storedSize: storedBytes.byteLength,
1887
+ sha256: createHash5("sha256").update(storedBytes).digest("hex"),
1888
+ archivedAt: (/* @__PURE__ */ new Date()).toISOString()
1889
+ };
1890
+ await appendIndex(indexPath, record2);
1891
+ index.records.set(relativePath, record2);
1892
+ result.repaired += 1;
1893
+ }
1894
+ increment(result, adapter.id, "unchanged");
1895
+ continue;
1896
+ }
1897
+ await mkdir3(dirname4(destination), { recursive: true });
1898
+ const compressed = await compressFile(file.absPath, destination);
1899
+ const record = {
1900
+ v: 1,
1901
+ path: relativePath,
1902
+ harness: adapter.id,
1903
+ machine: config.machine,
1904
+ unit: unit.id,
1905
+ role: file.role,
1906
+ source: file.absPath,
1907
+ sourceMtimeMs: compressed.sourceMtimeMs,
1908
+ sourceSize: compressed.sourceSize,
1909
+ storedSize: compressed.storedSize,
1910
+ sha256: compressed.sha256,
1911
+ archivedAt: (/* @__PURE__ */ new Date()).toISOString()
1912
+ };
1913
+ await appendIndex(indexPath, record);
1914
+ index.records.set(relativePath, record);
1915
+ increment(result, adapter.id, "archived");
1916
+ } catch (error) {
1917
+ increment(result, adapter.id, "failed");
1918
+ result.errors.push(`${adapter.id}: ${file.absPath}: ${errorMessage(error)}`);
1919
+ }
1920
+ }
1921
+ }
1922
+ }
1923
+ async function sweep(config, env) {
1924
+ const result = {
1925
+ ...emptyCounts(),
1926
+ repaired: 0,
1927
+ perHarness: emptyPerHarness(),
1928
+ errors: []
1929
+ };
1930
+ const machinePath = join15(config.archiveRoot, config.machine);
1931
+ const indexPath = join15(machinePath, "index.jsonl");
1932
+ const index = await readIndex(indexPath);
1933
+ for (const adapter of adapters) {
1934
+ const storeRoot = adapter.storeRoot(env, userHome(env));
1935
+ if (adapter.mutationModel === "db-snapshot") {
1936
+ let units2;
1937
+ try {
1938
+ units2 = await adapter.enumerate(storeRoot);
1939
+ } catch (error) {
1940
+ increment(result, adapter.id, "failed");
1941
+ result.errors.push(`${adapter.id}: could not enumerate store: ${errorMessage(error)}`);
1942
+ continue;
1943
+ }
1944
+ for (const unit of units2) {
1945
+ await archiveDatabaseSnapshot(config, machinePath, indexPath, index, adapter, unit, result);
1946
+ }
1947
+ continue;
1948
+ }
1949
+ let units;
1950
+ try {
1951
+ units = await adapter.enumerate(storeRoot);
1952
+ } catch (error) {
1953
+ increment(result, adapter.id, "failed");
1954
+ result.errors.push(`${adapter.id}: could not enumerate store: ${errorMessage(error)}`);
1955
+ continue;
1956
+ }
1957
+ await archiveSessions(config, machinePath, indexPath, index, adapter, units, result);
1958
+ }
1959
+ return result;
1960
+ }
1961
+
1962
+ // src/doctor/reconcile.ts
1963
+ function emptyHarness() {
1964
+ return { sources: 0, archived: 0, missing: 0, stale: 0, pending: 0, orphaned: 0 };
1965
+ }
1966
+ function harnessRecord() {
1967
+ return Object.fromEntries(HARNESS_IDS.map((harness) => [harness, emptyHarness()]));
1968
+ }
1969
+ async function walkFiles(root) {
1970
+ const entries = await readDirectoryOrEmpty(root);
1971
+ const files = [];
1972
+ for (const entry of entries) {
1973
+ const path = join16(root, entry.name);
1974
+ if (entry.isDirectory()) {
1975
+ files.push(...await walkFiles(path));
1976
+ } else {
1977
+ files.push(path);
1978
+ }
1979
+ }
1980
+ return files;
1981
+ }
1982
+ async function storedMtime(path) {
1983
+ return (await statOrNull(path))?.mtimeMs ?? null;
1984
+ }
1985
+ async function sourceExists(path) {
1986
+ return await statOrNull(path) !== null;
1987
+ }
1988
+ function archivedSourcePath(archiveRelativePath, storeRoots) {
1989
+ const parts = archiveRelativePath.split(sep2);
1990
+ const harness = parts.shift();
1991
+ if (!isHarnessId(harness)) {
1992
+ return null;
1993
+ }
1994
+ const last = parts.at(-1);
1995
+ if (last === void 0 || !last.endsWith(".zst")) {
1996
+ return null;
1997
+ }
1998
+ parts[parts.length - 1] = last.slice(0, -4);
1999
+ const root = storeRoots.get(harness);
2000
+ return root === void 0 ? null : { harness, sourcePath: join16(root, ...parts) };
2001
+ }
2002
+ function anomalyDetail(harnesses) {
2003
+ const details = [];
2004
+ for (const adapter of adapters) {
2005
+ const data = harnesses[adapter.id];
2006
+ const items = [];
2007
+ for (const key of ["missing", "stale", "pending", "orphaned"]) {
2008
+ if (data[key] > 0) {
2009
+ items.push(`${data[key]} ${key}`);
2010
+ }
2011
+ }
2012
+ if (items.length > 0) {
2013
+ details.push(`${adapter.id}: ${items.join(", ")}`);
2014
+ }
2015
+ }
2016
+ return details;
2017
+ }
2018
+ async function checkReconciled(context) {
2019
+ const machinePath = join16(context.config.archiveRoot, context.config.machine);
2020
+ const harnesses = harnessRecord();
2021
+ const storeRoots = /* @__PURE__ */ new Map();
2022
+ const sources = [];
2023
+ const enumerationErrors = [];
2024
+ const reconciliationErrors = [];
2025
+ for (const adapter of adapters) {
2026
+ const storeRoot = adapter.storeRoot(context.env, context.userHome);
2027
+ storeRoots.set(adapter.id, storeRoot);
2028
+ try {
2029
+ if (adapter.mutationModel === "db-snapshot") {
2030
+ for (const unit of await adapter.enumerate(storeRoot)) {
2031
+ const temporaryRoot = await mkdtemp(join16(tmpdir(), "packbat-doctor-snapshot-"));
2032
+ try {
2033
+ const capture = await adapter.snapshot(unit, join16(temporaryRoot, "snapshot.db"));
2034
+ sources.push({
2035
+ kind: "db-snapshot",
2036
+ harness: adapter.id,
2037
+ unit,
2038
+ contentSha256: capture.contentSha256,
2039
+ observedMtimeMs: Math.max(unit.sourceMtimeMs, ...capture.sessions.map((session) => session.timeUpdated))
2040
+ });
2041
+ harnesses[adapter.id].sources += 1;
2042
+ } finally {
2043
+ await rm4(temporaryRoot, { recursive: true, force: true });
2044
+ }
2045
+ }
2046
+ continue;
2047
+ }
2048
+ for (const unit of await adapter.enumerate(storeRoot)) {
2049
+ for (const file of unit.files) {
2050
+ const relativeArchivePath = join16(adapter.id, `${file.relPath}.zst`);
2051
+ sources.push({
2052
+ kind: "session",
2053
+ harness: adapter.id,
2054
+ file,
2055
+ archivePath: join16(machinePath, relativeArchivePath),
2056
+ relativeArchivePath
2057
+ });
2058
+ harnesses[adapter.id].sources += 1;
2059
+ }
2060
+ }
2061
+ } catch (error) {
2062
+ enumerationErrors.push(`${adapter.id}: ${error instanceof Error ? error.message : String(error)}`);
2063
+ }
2064
+ }
2065
+ let treeFiles;
2066
+ let index;
2067
+ try {
2068
+ [treeFiles, index] = await Promise.all([
2069
+ walkFiles(machinePath),
2070
+ readDerivedIndex(machinePath, context.config.machine)
2071
+ ]);
2072
+ } catch (error) {
2073
+ return {
2074
+ id: "reconciled",
2075
+ title: "reconciled",
2076
+ status: "problem",
2077
+ detail: `archive tree cannot be read: ${error instanceof Error ? error.message : String(error)}`,
2078
+ data: { harnesses, enumerationErrors }
2079
+ };
2080
+ }
2081
+ const archiveFiles = treeFiles.filter((path) => {
2082
+ const archivePath = relative4(machinePath, path);
2083
+ return archivePath !== "index.jsonl" && !archivePath.endsWith(`${sep2}manifest.json`) && !archivePath.split(sep2).includes(".staging");
2084
+ });
2085
+ const treeRelative = archiveFiles.map((path) => relative4(machinePath, path));
2086
+ const treeSet = new Set(treeRelative);
2087
+ for (const archiveRelativePath of treeRelative) {
2088
+ const harness = archiveRelativePath.split(sep2, 1)[0];
2089
+ if (isHarnessId(harness)) {
2090
+ harnesses[harness].archived += 1;
2091
+ }
2092
+ }
2093
+ for (const source of sources) {
2094
+ if (source.kind === "db-snapshot") {
2095
+ const newest = [...index.records.values()].filter(
2096
+ (record) => record.harness === source.harness && isDatabaseSnapshotIndexRecord(record)
2097
+ ).sort(
2098
+ (left, right) => right.sourceMtimeMs - left.sourceMtimeMs || right.archivedAt.localeCompare(left.archivedAt)
2099
+ )[0];
2100
+ if (newest?.contentSha256 === source.contentSha256) {
2101
+ continue;
2102
+ }
2103
+ const pending = ageMs(source.observedMtimeMs, context.now) < windowMs(context);
2104
+ harnesses[source.harness][newest === void 0 ? pending ? "pending" : "missing" : pending ? "pending" : "stale"] += 1;
2105
+ continue;
2106
+ }
2107
+ let mtimeMs;
2108
+ try {
2109
+ mtimeMs = await storedMtime(source.archivePath);
2110
+ } catch (error) {
2111
+ reconciliationErrors.push(
2112
+ `${source.harness}: cannot inspect ${source.archivePath}: ${error instanceof Error ? error.message : String(error)}`
2113
+ );
2114
+ continue;
2115
+ }
2116
+ if (mtimeMs === null) {
2117
+ if (ageMs(source.file.mtimeMs, context.now) < windowMs(context)) {
2118
+ harnesses[source.harness].pending += 1;
2119
+ } else {
2120
+ harnesses[source.harness].missing += 1;
2121
+ }
2122
+ continue;
2123
+ }
2124
+ const stale = shouldArchive({
2125
+ sourceMtimeMs: source.file.mtimeMs,
2126
+ sourceSize: source.file.sizeBytes,
2127
+ stored: { mtimeMs },
2128
+ indexSourceSize: index.records.get(source.relativeArchivePath)?.sourceSize
2129
+ });
2130
+ if (!stale) {
2131
+ continue;
2132
+ }
2133
+ if (ageMs(source.file.mtimeMs, context.now) < windowMs(context)) {
2134
+ harnesses[source.harness].pending += 1;
2135
+ } else {
2136
+ harnesses[source.harness].stale += 1;
2137
+ }
2138
+ }
2139
+ for (const archiveRelativePath of treeRelative) {
2140
+ const indexed = index.records.get(archiveRelativePath);
2141
+ const derived = indexed !== void 0 && isDatabaseSnapshotIndexRecord(indexed) ? { harness: indexed.harness, sourcePath: indexed.source } : archivedSourcePath(archiveRelativePath, storeRoots);
2142
+ if (derived !== null) {
2143
+ try {
2144
+ if (!await sourceExists(derived.sourcePath)) {
2145
+ harnesses[derived.harness].orphaned += 1;
2146
+ }
2147
+ } catch (error) {
2148
+ reconciliationErrors.push(
2149
+ `${derived.harness}: cannot inspect ${derived.sourcePath}: ${error instanceof Error ? error.message : String(error)}`
2150
+ );
2151
+ }
2152
+ }
2153
+ }
2154
+ let metadataMismatch = 0;
2155
+ for (const archiveRelativePath of treeRelative) {
2156
+ const record = index.records.get(archiveRelativePath);
2157
+ if (record === void 0) {
2158
+ continue;
2159
+ }
2160
+ try {
2161
+ const archiveStat = await stat4(join16(machinePath, archiveRelativePath));
2162
+ if (archiveStat.size !== record.storedSize || archiveStat.mtimeMs !== record.sourceMtimeMs) {
2163
+ metadataMismatch += 1;
2164
+ }
2165
+ } catch (error) {
2166
+ reconciliationErrors.push(
2167
+ `index: cannot inspect ${archiveRelativePath}: ${error instanceof Error ? error.message : String(error)}`
2168
+ );
2169
+ }
2170
+ }
2171
+ const indexDrift = {
2172
+ unindexed: treeRelative.filter((path) => !index.records.has(path)).length,
2173
+ missingFromTree: [...index.records.keys()].filter((path) => !treeSet.has(path)).length,
2174
+ metadataMismatch,
2175
+ corruptLines: index.corruptLines
2176
+ };
2177
+ const totals = Object.values(harnesses).reduce(
2178
+ (result, value) => ({
2179
+ sources: result.sources + value.sources,
2180
+ archived: result.archived + value.archived,
2181
+ missing: result.missing + value.missing,
2182
+ stale: result.stale + value.stale,
2183
+ pending: result.pending + value.pending,
2184
+ orphaned: result.orphaned + value.orphaned
2185
+ }),
2186
+ emptyHarness()
2187
+ );
2188
+ const driftCount = indexDrift.unindexed + indexDrift.missingFromTree + indexDrift.metadataMismatch + indexDrift.corruptLines;
2189
+ const hasProblem = totals.missing > 0 || totals.stale > 0 || indexDrift.missingFromTree > 0 || enumerationErrors.length > 0 || reconciliationErrors.length > 0;
2190
+ const hasInfo = totals.pending > 0 || totals.orphaned > 0 || driftCount > 0;
2191
+ const details = anomalyDetail(harnesses);
2192
+ if (enumerationErrors.length > 0) {
2193
+ details.push(`enumeration failed: ${enumerationErrors.join("; ")}`);
2194
+ }
2195
+ if (reconciliationErrors.length > 0) {
2196
+ details.push(`inspection failed: ${reconciliationErrors.join("; ")}`);
2197
+ }
2198
+ if (driftCount > 0) {
2199
+ details.push(`index drift: ${driftCount}`);
2200
+ }
2201
+ if (indexDrift.missingFromTree > 0) {
2202
+ details.push("archived payloads recorded in the index are missing from the tree");
2203
+ }
2204
+ const detail = details.length === 0 ? `nothing missed; ${totals.sources} source file${totals.sources === 1 ? "" : "s"} current` : `${hasProblem ? "coverage gaps" : "nothing missed"}; ${details.join("; ")}`;
2205
+ return {
2206
+ id: "reconciled",
2207
+ title: "reconciled",
2208
+ status: hasProblem ? "problem" : hasInfo ? "info" : "ok",
2209
+ detail,
2210
+ data: {
2211
+ windowMinutes: context.config.sweep.intervalMinutes * 2,
2212
+ totals,
2213
+ harnesses,
2214
+ indexDrift,
2215
+ enumerationErrors,
2216
+ reconciliationErrors
2217
+ }
2218
+ };
2219
+ }
2220
+
2221
+ // src/commands/doctor.ts
2222
+ var USAGE = "Usage: packbat doctor [--json]\n";
2223
+ function parseOptions(argv) {
2224
+ if (argv.length === 0) {
2225
+ return { json: false };
2226
+ }
2227
+ if (argv.length === 1 && argv[0] === "--json") {
2228
+ return { json: true };
2229
+ }
2230
+ process.stderr.write(`packbat doctor: only --json is accepted
2231
+
2232
+ ${USAGE}`);
2233
+ return null;
2234
+ }
2235
+ function symbol(item) {
2236
+ switch (item.status) {
2237
+ case "ok":
2238
+ return pc.green("\u2713");
2239
+ case "problem":
2240
+ return pc.red("\u2717");
2241
+ case "info":
2242
+ return pc.dim("\xB7");
2243
+ }
2244
+ }
2245
+ function printHuman(facts) {
2246
+ for (const item of facts) {
2247
+ process.stdout.write(`${symbol(item)} ${item.title}: ${item.detail}
2248
+ `);
2249
+ }
2250
+ const problems = facts.filter((item) => item.status === "problem");
2251
+ if (problems.length > 0) {
2252
+ process.stdout.write("\nproblems:\n");
2253
+ for (const item of problems) {
2254
+ process.stdout.write(` ${item.title}: ${remedyForFact(item)}
2255
+ `);
2256
+ }
2257
+ }
2258
+ }
2259
+ async function runDoctor(argv) {
2260
+ const options = parseOptions(argv);
2261
+ if (options === null) {
2262
+ return 1;
2263
+ }
2264
+ const home = resolveHome();
2265
+ const config = loadConfig(home);
2266
+ const context = createDoctorContext(config, home);
2267
+ const installed = await checkInstalled(context);
2268
+ const [live, fresh, reconciled] = await Promise.all([
2269
+ checkLive(context, installed),
2270
+ checkFresh(context),
2271
+ checkReconciled(context)
2272
+ ]);
2273
+ const environment = await collectEnvironmentFacts(context);
2274
+ const facts = [installed.fact, live, fresh.fact, reconciled, retentionFact(), ...environment];
2275
+ const ok = !facts.some((item) => item.status === "problem");
2276
+ if (options.json) {
2277
+ process.stdout.write(`${JSON.stringify({ v: 1, ok, machine: config.machine, facts })}
2278
+ `);
2279
+ } else {
2280
+ printHuman(facts);
2281
+ }
2282
+ return ok ? 0 : 2;
2283
+ }
2284
+
2285
+ // src/schedule/scheduler.ts
2286
+ import { execFile as execFile2, spawn, spawnSync } from "child_process";
2287
+ import { accessSync as accessSync2, constants as constants3 } from "fs";
2288
+ import { mkdir as mkdir4, readFile as readFile6, rm as rm5, writeFile as writeFile4 } from "fs/promises";
2289
+ import { isAbsolute as isAbsolute2, join as join17 } from "path";
2290
+ import { promisify } from "util";
2291
+ var execFileAsync = promisify(execFile2);
2292
+ function systemctlForRemoval(env) {
2293
+ const fromPath = commandOnPath("systemctl", env);
2294
+ if (fromPath !== null) {
2295
+ return fromPath;
2296
+ }
2297
+ for (const candidate of ["/usr/bin/systemctl", "/bin/systemctl"]) {
2298
+ try {
2299
+ accessSync2(candidate, constants3.X_OK);
2300
+ return candidate;
2301
+ } catch {
2302
+ }
2303
+ }
2304
+ return null;
2305
+ }
2306
+ function userId() {
2307
+ if (process.getuid === void 0) {
2308
+ throw new PackbatError("scheduler requires a POSIX user id");
2309
+ }
2310
+ return process.getuid();
2311
+ }
2312
+ function assertAbsoluteCommand(options) {
2313
+ if (!isAbsolute2(options.nodePath) || !isAbsolute2(options.entryPath)) {
2314
+ throw new PackbatError("scheduler command paths must be absolute");
2315
+ }
2316
+ }
2317
+ async function runSchedulerCommand(command2, args) {
2318
+ try {
2319
+ const result = await execFileAsync(command2, args, { encoding: "utf8" });
2320
+ return result.stdout;
2321
+ } catch (error) {
2322
+ const message = error instanceof Error ? error.message : String(error);
2323
+ throw new PackbatError(`scheduler command failed: ${command2} ${args.join(" ")}: ${message}`);
2324
+ }
2325
+ }
2326
+ async function runIgnoringSchedulerFailure(command2, args) {
2327
+ try {
2328
+ await execFileAsync(command2, args);
2329
+ } catch {
2330
+ }
2331
+ }
2332
+ async function readCrontab(crontab) {
2333
+ try {
2334
+ const result = await execFileAsync(crontab, ["-l"], { encoding: "utf8" });
2335
+ return result.stdout;
2336
+ } catch (error) {
2337
+ if (error.code === 1) {
2338
+ return "";
2339
+ }
2340
+ const message = error instanceof Error ? error.message : String(error);
2341
+ throw new PackbatError(`scheduler command failed: ${crontab} -l: ${message}`);
2342
+ }
2343
+ }
2344
+ async function writeCrontab(crontab, contents) {
2345
+ await new Promise((resolve, reject) => {
2346
+ const child = spawn(crontab, ["-"], { stdio: ["pipe", "ignore", "pipe"] });
2347
+ let stderr = "";
2348
+ child.stderr.on("data", (chunk) => {
2349
+ stderr += chunk.toString("utf8");
2350
+ });
2351
+ child.on("error", reject);
2352
+ child.on("close", (code) => {
2353
+ if (code === 0) {
2354
+ resolve();
2355
+ return;
2356
+ }
2357
+ reject(new PackbatError(`scheduler command failed: ${crontab} -: ${stderr.trim() || `exit ${code}`}`));
2358
+ });
2359
+ child.stdin.end(contents);
2360
+ });
2361
+ }
2362
+ function launchdPath(userHome3) {
2363
+ return join17(userHome3, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
2364
+ }
2365
+ function systemdPaths(userHome3) {
2366
+ const directory = join17(userHome3, ".config", "systemd", "user");
2367
+ return { service: join17(directory, SYSTEMD_SERVICE), timer: join17(directory, SYSTEMD_TIMER) };
2368
+ }
2369
+ function systemdEnablementPath(userHome3) {
2370
+ return join17(userHome3, ".config", "systemd", "user", "timers.target.wants", SYSTEMD_TIMER);
2371
+ }
2372
+ function cronArtifactPath(statePath) {
2373
+ return join17(statePath, "schedule.cron");
2374
+ }
2375
+ function activationMarkerPath(statePath) {
2376
+ return join17(statePath, "schedule-activated");
2377
+ }
2378
+ async function installLaunchd(options) {
2379
+ const path = launchdPath(options.userHome);
2380
+ await mkdir4(join17(options.userHome, "Library", "LaunchAgents"), { recursive: true });
2381
+ await writeFile4(path, generateLaunchdPlist(options));
2382
+ return { artifactPaths: [path], notes: [] };
2383
+ }
2384
+ async function installSystemd(options) {
2385
+ const paths = systemdPaths(options.userHome);
2386
+ await mkdir4(join17(options.userHome, ".config", "systemd", "user"), { recursive: true });
2387
+ await Promise.all([
2388
+ writeFile4(paths.service, generateSystemdService(options)),
2389
+ writeFile4(paths.timer, generateSystemdTimer())
2390
+ ]);
2391
+ return { artifactPaths: [paths.service, paths.timer], notes: [] };
2392
+ }
2393
+ async function installCron(options) {
2394
+ const path = cronArtifactPath(options.statePath);
2395
+ await mkdir4(options.statePath, { recursive: true });
2396
+ await writeFile4(path, `${generateCronEntry(options)}
2397
+ `);
2398
+ return { artifactPaths: [path], notes: [] };
2399
+ }
2400
+ function scheduleKind(env) {
2401
+ if (process.platform === "darwin") {
2402
+ return "launchd";
2403
+ }
2404
+ if (process.platform === "linux") {
2405
+ const systemctl = commandOnPath("systemctl", env);
2406
+ if (systemctl === null) {
2407
+ return "cron";
2408
+ }
2409
+ return spawnSync(systemctl, ["--user", "show-environment"], { env, stdio: "ignore" }).status === 0 ? "systemd" : "cron";
2410
+ }
2411
+ throw new PackbatError(`scheduling is not supported on ${process.platform}`);
2412
+ }
2413
+ function previewSchedule(options) {
2414
+ assertAbsoluteCommand(options);
2415
+ switch (scheduleKind(options.env)) {
2416
+ case "launchd":
2417
+ return { artifactPaths: [launchdPath(options.userHome)], notes: [] };
2418
+ case "systemd": {
2419
+ const paths = systemdPaths(options.userHome);
2420
+ return { artifactPaths: [paths.service, paths.timer], notes: [] };
2421
+ }
2422
+ case "cron":
2423
+ return { artifactPaths: [cronArtifactPath(options.statePath)], notes: [] };
2424
+ }
2425
+ }
2426
+ async function installSchedule(options) {
2427
+ assertAbsoluteCommand(options);
2428
+ switch (scheduleKind(options.env)) {
2429
+ case "launchd":
2430
+ return await installLaunchd(options);
2431
+ case "systemd":
2432
+ return await installSystemd(options);
2433
+ case "cron":
2434
+ return await installCron(options);
2435
+ }
2436
+ }
2437
+ async function stripInstalledCron(statePath, env) {
2438
+ const crontab = commandOnPath("crontab", env);
2439
+ if (crontab !== null) {
2440
+ const current = await readCrontab(crontab);
2441
+ const stripped = stripCronEntry(current);
2442
+ if (stripped !== current) {
2443
+ await writeCrontab(crontab, stripped);
2444
+ }
2445
+ }
2446
+ await rm5(cronArtifactPath(statePath), { force: true });
2447
+ }
2448
+ async function deactivateLaunchd(options) {
2449
+ if (!pathExists(launchdPath(options.userHome)) && !pathExists(activationMarkerPath(options.statePath))) {
2450
+ return;
2451
+ }
2452
+ await runIgnoringSchedulerFailure("launchctl", ["bootout", `gui/${userId()}/${LAUNCHD_LABEL}`]);
2453
+ }
2454
+ async function deactivateLinux(options) {
2455
+ const paths = systemdPaths(options.userHome);
2456
+ const enablementPath = systemdEnablementPath(options.userHome);
2457
+ const wasActivated = pathExists(activationMarkerPath(options.statePath));
2458
+ const hasSystemdArtifacts = [paths.service, paths.timer, enablementPath].some(pathExists);
2459
+ if (hasSystemdArtifacts && (wasActivated || pathExists(enablementPath))) {
2460
+ const systemctl = systemctlForRemoval(options.env);
2461
+ if (systemctl === null) {
2462
+ if (pathExists(enablementPath)) {
2463
+ throw new PackbatError("cannot deactivate the enabled systemd timer because systemctl is unavailable");
2464
+ }
2465
+ } else if (pathExists(enablementPath)) {
2466
+ await runSchedulerCommand(systemctl, ["--user", "disable", "--now", SYSTEMD_TIMER]);
2467
+ } else {
2468
+ await runIgnoringSchedulerFailure(systemctl, ["--user", "disable", "--now", SYSTEMD_TIMER]);
2469
+ }
2470
+ }
2471
+ if (wasActivated && pathExists(cronArtifactPath(options.statePath))) {
2472
+ const crontab = commandOnPath("crontab", options.env);
2473
+ if (crontab === null) {
2474
+ throw new PackbatError("cannot deactivate the installed cron schedule because crontab is unavailable");
2475
+ }
2476
+ const current = await readCrontab(crontab);
2477
+ const stripped = stripCronEntry(current);
2478
+ if (stripped !== current) {
2479
+ await writeCrontab(crontab, stripped);
2480
+ }
2481
+ }
2482
+ }
2483
+ async function deactivateSchedule(options) {
2484
+ if (process.platform === "darwin") {
2485
+ await deactivateLaunchd(options);
2486
+ await rm5(activationMarkerPath(options.statePath), { force: true });
2487
+ return;
2488
+ }
2489
+ if (process.platform === "linux") {
2490
+ await deactivateLinux(options);
2491
+ await rm5(activationMarkerPath(options.statePath), { force: true });
2492
+ return;
2493
+ }
2494
+ throw new PackbatError(`scheduling is not supported on ${process.platform}`);
2495
+ }
2496
+ async function activateLaunchd(options) {
2497
+ const path = launchdPath(options.userHome);
2498
+ const domain = `gui/${userId()}`;
2499
+ await runIgnoringSchedulerFailure("launchctl", ["bootout", domain, path]);
2500
+ await runSchedulerCommand("launchctl", ["bootstrap", domain, path]);
2501
+ return [];
2502
+ }
2503
+ async function activateSystemd(options, systemctl) {
2504
+ await runSchedulerCommand(systemctl, ["--user", "daemon-reload"]);
2505
+ await runSchedulerCommand(systemctl, ["--user", "enable", "--now", SYSTEMD_TIMER]);
2506
+ await stripInstalledCron(options.statePath, options.env);
2507
+ const notes = [];
2508
+ const loginctl = commandOnPath("loginctl", options.env);
2509
+ if (loginctl !== null) {
2510
+ try {
2511
+ const linger = await runSchedulerCommand(loginctl, ["show-user", String(userId()), "-p", "Linger"]);
2512
+ if (!/^Linger=yes$/m.test(linger)) {
2513
+ notes.push("note: systemd linger is off; scheduled sync resumes at the next login");
2514
+ }
2515
+ } catch {
2516
+ }
2517
+ }
2518
+ return notes;
2519
+ }
2520
+ async function activateCron(options) {
2521
+ const crontab = commandOnPath("crontab", options.env);
2522
+ if (crontab === null) {
2523
+ throw new PackbatError("no supported scheduler found (systemctl and crontab are unavailable)");
2524
+ }
2525
+ const entry = (await readFile6(cronArtifactPath(options.statePath), "utf8")).trimEnd();
2526
+ const current = await readCrontab(crontab);
2527
+ await writeCrontab(crontab, mergeCronTab(current, entry));
2528
+ const paths = systemdPaths(options.userHome);
2529
+ await Promise.all(
2530
+ [paths.service, paths.timer, systemdEnablementPath(options.userHome)].map(
2531
+ async (path) => await rm5(path, { force: true })
2532
+ )
2533
+ );
2534
+ return [];
2535
+ }
2536
+ async function activateSchedule(options) {
2537
+ let notes;
2538
+ switch (scheduleKind(options.env)) {
2539
+ case "launchd":
2540
+ notes = await activateLaunchd(options);
2541
+ break;
2542
+ case "systemd": {
2543
+ const systemctl = commandOnPath("systemctl", options.env);
2544
+ if (systemctl === null) {
2545
+ throw new PackbatError("systemd user manager became unavailable during schedule activation");
2546
+ }
2547
+ notes = await activateSystemd(options, systemctl);
2548
+ break;
2549
+ }
2550
+ case "cron":
2551
+ notes = await activateCron(options);
2552
+ break;
2553
+ }
2554
+ await mkdir4(options.statePath, { recursive: true });
2555
+ await writeFile4(activationMarkerPath(options.statePath), "active\n");
2556
+ return notes;
2557
+ }
2558
+ function scheduleWasActivated(statePath) {
2559
+ return pathExists(activationMarkerPath(statePath));
2560
+ }
2561
+ async function uninstallLaunchd(options) {
2562
+ const path = launchdPath(options.userHome);
2563
+ const cronPath = cronArtifactPath(options.statePath);
2564
+ const markerPath = activationMarkerPath(options.statePath);
2565
+ const removedPaths = [path, cronPath, markerPath].filter(pathExists);
2566
+ if (pathExists(markerPath)) {
2567
+ await runIgnoringSchedulerFailure("launchctl", ["bootout", `gui/${userId()}/${LAUNCHD_LABEL}`]);
2568
+ }
2569
+ await Promise.all(removedPaths.map(async (artifactPath) => await rm5(artifactPath, { force: true })));
2570
+ return { removedPaths };
2571
+ }
2572
+ async function uninstallLinux(options) {
2573
+ const paths = systemdPaths(options.userHome);
2574
+ const cronPath = cronArtifactPath(options.statePath);
2575
+ const present = [
2576
+ paths.service,
2577
+ paths.timer,
2578
+ systemdEnablementPath(options.userHome),
2579
+ cronPath,
2580
+ activationMarkerPath(options.statePath)
2581
+ ].filter(pathExists);
2582
+ const crontab = commandOnPath("crontab", options.env);
2583
+ let hadCronEntry = false;
2584
+ if (crontab !== null) {
2585
+ const current = await readCrontab(crontab);
2586
+ hadCronEntry = stripCronEntry(current) !== current;
2587
+ }
2588
+ await deactivateLinux(options);
2589
+ const systemctl = systemctlForRemoval(options.env);
2590
+ await Promise.all(present.map(async (path) => await rm5(path, { force: true })));
2591
+ const removedPaths = [...present];
2592
+ if (crontab !== null) {
2593
+ const current = await readCrontab(crontab);
2594
+ const stripped = stripCronEntry(current);
2595
+ if (stripped !== current) {
2596
+ await writeCrontab(crontab, stripped);
2597
+ }
2598
+ if (hadCronEntry) {
2599
+ removedPaths.push("crontab (# packbat-sync)");
2600
+ }
2601
+ }
2602
+ if (systemctl !== null) {
2603
+ await runIgnoringSchedulerFailure(systemctl, ["--user", "daemon-reload"]);
2604
+ }
2605
+ return { removedPaths };
2606
+ }
2607
+ async function uninstallSchedule(options) {
2608
+ if (process.platform === "darwin") {
2609
+ return await uninstallLaunchd(options);
2610
+ }
2611
+ if (process.platform === "linux") {
2612
+ return await uninstallLinux(options);
2613
+ }
2614
+ throw new PackbatError(`scheduling is not supported on ${process.platform}`);
2615
+ }
2616
+
2617
+ // src/core/setup.ts
2618
+ import { existsSync as existsSync3 } from "fs";
2619
+ import { mkdir as mkdir5, realpath } from "fs/promises";
2620
+ import { homedir as homedir4 } from "os";
2621
+ import { isAbsolute as isAbsolute3 } from "path";
2622
+
2623
+ // src/schedule/environment.ts
2624
+ var SCHEDULE_ENVIRONMENT_KEYS = [
2625
+ "PACKBAT_HOME",
2626
+ "CLAUDE_CONFIG_DIR",
2627
+ "CODEX_HOME",
2628
+ "GEMINI_CLI_HOME",
2629
+ "OPENCODE_DB",
2630
+ "PI_CODING_AGENT_SESSION_DIR",
2631
+ "XDG_DATA_HOME"
2632
+ ];
2633
+ function scheduleEnvironment(env) {
2634
+ const result = /* @__PURE__ */ new Map();
2635
+ for (const key of SCHEDULE_ENVIRONMENT_KEYS) {
2636
+ const value = env[key];
2637
+ if (value !== void 0 && value.trim() !== "") {
2638
+ result.set(key, value);
2639
+ }
2640
+ }
2641
+ return result;
2642
+ }
2643
+
2644
+ // src/core/machine.ts
2645
+ import { hostname } from "os";
2646
+ function sanitizeMachineName(value) {
2647
+ const sanitized = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2648
+ return sanitized === "" ? "machine" : sanitized;
2649
+ }
2650
+ function defaultMachineName() {
2651
+ return sanitizeMachineName(hostname().split(".", 1)[0] ?? "");
2652
+ }
2653
+
2654
+ // src/core/setup.ts
2655
+ function skippedOffboxConfig() {
2656
+ return { mode: "skipped", skippedAt: (/* @__PURE__ */ new Date()).toISOString() };
2657
+ }
2658
+ function userHome2() {
2659
+ const configured = process.env.HOME?.trim();
2660
+ return configured ? configured : homedir4();
2661
+ }
2662
+ function detectInitStores(homePath) {
2663
+ return {
2664
+ detected: adapters.map((adapter) => ({ displayName: adapter.displayName, path: adapter.storeRoot(process.env, homePath) })).filter(({ path }) => existsSync3(path)),
2665
+ unsupported: unsupportedStores.map((store) => ({ displayName: store.displayName, path: store.detect(process.env, homePath) })).filter((entry) => entry.path !== null)
2666
+ };
2667
+ }
2668
+ async function writeInitConfig(home, archiveRoot, offbox) {
2669
+ if (!isAbsolute3(archiveRoot)) {
2670
+ throw new PackbatError("archive root must be absolute");
2671
+ }
2672
+ let config;
2673
+ if (existsSync3(home.configPath)) {
2674
+ config = loadConfig(home);
2675
+ if (archiveRoot !== config.archiveRoot) {
2676
+ throw new PackbatError(`archive root is already ${config.archiveRoot}; edit config.json to move the archive`);
2677
+ }
2678
+ if (offbox !== void 0) {
2679
+ config = { ...config, offbox };
2680
+ }
2681
+ } else {
2682
+ config = {
2683
+ version: CONFIG_VERSION,
2684
+ machine: defaultMachineName(),
2685
+ archiveRoot,
2686
+ sweep: { intervalMinutes: 60 },
2687
+ offbox: offbox ?? skippedOffboxConfig()
2688
+ };
2689
+ }
2690
+ saveConfig(home, config);
2691
+ await Promise.all([
2692
+ mkdir5(home.statePath, { recursive: true }),
2693
+ mkdir5(home.logsPath, { recursive: true }),
2694
+ mkdir5(config.archiveRoot, { recursive: true })
2695
+ ]);
2696
+ return config;
2697
+ }
2698
+ async function createInitScheduleOptions(home, homePath) {
2699
+ const entryArgument = process.argv[1];
2700
+ if (entryArgument === void 0) {
2701
+ throw new Error("CLI entry path is unavailable");
2702
+ }
2703
+ return {
2704
+ userHome: homePath,
2705
+ statePath: home.statePath,
2706
+ logsPath: home.logsPath,
2707
+ nodePath: process.execPath,
2708
+ entryPath: await realpath(entryArgument),
2709
+ environment: scheduleEnvironment(process.env),
2710
+ env: process.env
2711
+ };
2712
+ }
2713
+ async function installInitSchedule(options, activate) {
2714
+ if (activate || scheduleWasActivated(options.statePath)) {
2715
+ await deactivateSchedule(options);
2716
+ }
2717
+ const schedule = await installSchedule(options);
2718
+ const activationNotes = activate ? await activateSchedule(options) : [];
2719
+ return { schedule, activationNotes };
2720
+ }
2721
+
2722
+ // src/offbox/age.ts
2723
+ import {
2724
+ identityToRecipient as ageIdentityToRecipient,
2725
+ Decrypter,
2726
+ Encrypter,
2727
+ generateIdentity as generateAgeIdentity
2728
+ } from "age-encryption";
2729
+ async function generateIdentity() {
2730
+ return await generateAgeIdentity();
2731
+ }
2732
+ async function identityToRecipient(identity) {
2733
+ return await ageIdentityToRecipient(identity);
2734
+ }
2735
+ async function encryptToRecipient(recipient, plaintext) {
2736
+ const encrypter = new Encrypter();
2737
+ encrypter.addRecipient(recipient);
2738
+ return Buffer.from(await encrypter.encrypt(plaintext));
2739
+ }
2740
+ async function decryptWithIdentity(identity, ciphertext) {
2741
+ const decrypter = new Decrypter();
2742
+ decrypter.addIdentity(identity);
2743
+ return Buffer.from(await decrypter.decrypt(ciphertext));
2744
+ }
2745
+ function parseIdentityFile(contents) {
2746
+ const identity = contents.split(/\r?\n/u).map((line) => line.trim()).find((line) => /^AGE-SECRET-KEY-1[0-9A-Z]+$/u.test(line));
2747
+ if (identity === void 0) {
2748
+ throw new PackbatError("identity file does not contain an AGE-SECRET-KEY-1\u2026 identity");
2749
+ }
2750
+ return identity;
2751
+ }
2752
+
2753
+ // src/offbox/rclone.ts
2754
+ import { spawn as spawn2 } from "child_process";
2755
+ import { constants as constants4 } from "fs";
2756
+ import { access as access2, chmod, mkdir as mkdir6, open as open2 } from "fs/promises";
2757
+ import { dirname as dirname5 } from "path";
2758
+ var RCLONE_MISSING = "rclone was not found on PATH; install it with `brew install rclone` (macOS) or `apt install rclone` (Debian/Ubuntu)";
2759
+ async function discoverRclone(env = process.env) {
2760
+ const fromPath = commandOnPath("rclone", env);
2761
+ if (fromPath !== null) {
2762
+ return fromPath;
2763
+ }
2764
+ for (const candidate of ["/opt/homebrew/bin/rclone", "/usr/local/bin/rclone", "/usr/bin/rclone"]) {
2765
+ try {
2766
+ await access2(candidate, constants4.X_OK);
2767
+ return candidate;
2768
+ } catch (error) {
2769
+ if (error.code !== "ENOENT" && error.code !== "EACCES") {
2770
+ throw error;
2771
+ }
2772
+ }
2773
+ }
2774
+ throw new PackbatError(RCLONE_MISSING);
2775
+ }
2776
+ async function managedConfigArguments(mode) {
2777
+ if (mode === "default") {
2778
+ return [];
2779
+ }
2780
+ const configPath = resolveHome().rcloneConfPath;
2781
+ await mkdir6(dirname5(configPath), { recursive: true });
2782
+ const handle = await open2(configPath, "a", 384);
2783
+ await handle.close();
2784
+ await chmod(configPath, 384);
2785
+ return ["--config", configPath];
2786
+ }
2787
+ async function runRclone(command2, args, mode) {
2788
+ const executable = await discoverRclone();
2789
+ const configArguments = await managedConfigArguments(mode);
2790
+ return await new Promise((resolve, reject) => {
2791
+ const child = spawn2(executable, [command2, ...configArguments, ...args], {
2792
+ env: process.env,
2793
+ stdio: ["ignore", "pipe", "pipe"]
2794
+ });
2795
+ let stdout = "";
2796
+ let stderr = "";
2797
+ child.stdout.on("data", (chunk) => {
2798
+ stdout += chunk.toString("utf8");
2799
+ });
2800
+ child.stderr.on("data", (chunk) => {
2801
+ stderr += chunk.toString("utf8");
2802
+ });
2803
+ child.on("error", (error) => {
2804
+ reject(new PackbatError(`could not start rclone: ${error.message}`));
2805
+ });
2806
+ child.on("close", (code) => {
2807
+ if (code === 0) {
2808
+ resolve(stdout);
2809
+ return;
2810
+ }
2811
+ const output = `${stdout}${stderr}`;
2812
+ reject(new PackbatError(`rclone ${command2} failed${output.trim() === "" ? "" : `: ${output.trim()}`}`));
2813
+ });
2814
+ });
2815
+ }
2816
+ async function remoteFileExists(destinationFile, mode) {
2817
+ let output;
2818
+ try {
2819
+ output = await runRclone("lsjson", [destinationFile], mode);
2820
+ } catch {
2821
+ return false;
2822
+ }
2823
+ let items;
2824
+ try {
2825
+ items = JSON.parse(output);
2826
+ } catch {
2827
+ throw new PackbatError("rclone lsjson returned invalid JSON");
2828
+ }
2829
+ return Array.isArray(items) && items.length > 0;
2830
+ }
2831
+ async function copyTree(source, destination, mode) {
2832
+ await runRclone("copy", [source, destination], mode);
2833
+ }
2834
+ async function copyFile(source, destinationFile, mode) {
2835
+ await runRclone("copyto", [source, destinationFile], mode);
2836
+ }
2837
+ function joinRcloneDestination(destination, relativePath) {
2838
+ const child = relativePath.replace(/^\/+/, "");
2839
+ if (destination.endsWith(":")) {
2840
+ return `${destination}${child}`;
2841
+ }
2842
+ const base = destination.replace(/\/+$/, "");
2843
+ return `${base === "" ? "/" : `${base}/`}${child}`;
2844
+ }
2845
+
2846
+ // src/core/lock.ts
2847
+ import { randomUUID as randomUUID5 } from "crypto";
2848
+ import { link, mkdir as mkdir7, readFile as readFile7, rm as rm6, writeFile as writeFile5 } from "fs/promises";
2849
+ import { join as join18 } from "path";
2850
+ function isProcessAlive(pid) {
2851
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
2852
+ return false;
2853
+ }
2854
+ try {
2855
+ process.kill(pid, 0);
2856
+ return true;
2857
+ } catch (error) {
2858
+ const code = error.code;
2859
+ if (code === "EPERM") {
2860
+ return true;
2861
+ }
2862
+ if (code === "ESRCH") {
2863
+ return false;
2864
+ }
2865
+ throw error;
2866
+ }
2867
+ }
2868
+ async function lockOwnerIsAlive(path) {
2869
+ try {
2870
+ const parsed = JSON.parse(await readFile7(path, "utf8"));
2871
+ if (typeof parsed !== "object" || parsed === null || !("pid" in parsed)) {
2872
+ return false;
2873
+ }
2874
+ return isProcessAlive(parsed.pid);
2875
+ } catch (error) {
2876
+ if (error.code === "ENOENT" || error instanceof SyntaxError) {
2877
+ return false;
2878
+ }
2879
+ throw error;
2880
+ }
2881
+ }
2882
+ async function tryAcquire(path) {
2883
+ const contents = { pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
2884
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID5()}`;
2885
+ try {
2886
+ await writeFile5(temporary, `${JSON.stringify(contents)}
2887
+ `, { flag: "wx" });
2888
+ try {
2889
+ await link(temporary, path);
2890
+ } catch (error) {
2891
+ if (error.code === "EEXIST") {
2892
+ return false;
2893
+ }
2894
+ throw error;
2895
+ }
2896
+ } finally {
2897
+ await rm6(temporary, { force: true });
2898
+ }
2899
+ return true;
2900
+ }
2901
+ async function withLock(statePath, name, fn) {
2902
+ await mkdir7(statePath, { recursive: true });
2903
+ const path = join18(statePath, `${name}.lock`);
2904
+ let acquired = await tryAcquire(path);
2905
+ if (!acquired) {
2906
+ if (await lockOwnerIsAlive(path)) {
2907
+ return { acquired: false };
2908
+ }
2909
+ await rm6(path, { force: true });
2910
+ acquired = await tryAcquire(path);
2911
+ if (!acquired) {
2912
+ return { acquired: false };
2913
+ }
2914
+ }
2915
+ try {
2916
+ return { acquired: true, value: await fn() };
2917
+ } finally {
2918
+ await rm6(path, { force: true });
2919
+ }
2920
+ }
2921
+ async function withSyncLock(statePath, fn) {
2922
+ return await withLock(statePath, "sync", fn);
2923
+ }
2924
+ async function withRetrievalLock(statePath, fn) {
2925
+ return await withLock(statePath, "retrieval", fn);
2926
+ }
2927
+
2928
+ // src/core/log.ts
2929
+ import { appendFile as appendFile2, mkdir as mkdir8, rename as rename3, rm as rm7, stat as stat5 } from "fs/promises";
2930
+ import { join as join19 } from "path";
2931
+ var MAX_LOG_BYTES = 1024 * 1024;
2932
+ var LOG_NAME = "packbat.log";
2933
+ async function renameIfPresent(source, destination) {
2934
+ try {
2935
+ await rename3(source, destination);
2936
+ } catch (error) {
2937
+ if (error.code !== "ENOENT") {
2938
+ throw error;
2939
+ }
2940
+ }
2941
+ }
2942
+ async function rotate(path) {
2943
+ await rm7(`${path}.3`, { force: true });
2944
+ await renameIfPresent(`${path}.2`, `${path}.3`);
2945
+ await renameIfPresent(`${path}.1`, `${path}.2`);
2946
+ await renameIfPresent(path, `${path}.1`);
2947
+ }
2948
+ async function appendLog(logsPath, line, at = /* @__PURE__ */ new Date()) {
2949
+ await mkdir8(logsPath, { recursive: true });
2950
+ const path = join19(logsPath, LOG_NAME);
2951
+ const entry = `[${at.toISOString()}] ${line}
2952
+ `;
2953
+ let existingSize = 0;
2954
+ try {
2955
+ existingSize = (await stat5(path)).size;
2956
+ } catch (error) {
2957
+ if (error.code !== "ENOENT") {
2958
+ throw error;
2959
+ }
2960
+ }
2961
+ if (existingSize + Buffer.byteLength(entry) > MAX_LOG_BYTES) {
2962
+ await rotate(path);
2963
+ }
2964
+ await appendFile2(path, entry);
2965
+ }
2966
+
2967
+ // src/offbox/outbox.ts
2968
+ import { createHash as createHash6, randomUUID as randomUUID6 } from "crypto";
2969
+ import { appendFile as appendFile3, mkdir as mkdir9, readdir as readdir2, readFile as readFile8, rename as rename4, rm as rm8, stat as stat6, writeFile as writeFile6 } from "fs/promises";
2970
+ import { basename as basename3, dirname as dirname6, join as join20, relative as relative5 } from "path";
2971
+
2972
+ // src/offbox/remote.ts
2973
+ var RcloneArchiveRemote = class {
2974
+ constructor(config) {
2975
+ this.config = config;
2976
+ this.destination = config.destination;
2977
+ }
2978
+ config;
2979
+ destination;
2980
+ async indexExists(machine) {
2981
+ return await remoteFileExists(
2982
+ joinRcloneDestination(this.config.destination, `${machine}/index.jsonl.age`),
2983
+ this.config.rcloneConfig
2984
+ );
2985
+ }
2986
+ async putArchiveObjects(sourceRoot) {
2987
+ await copyTree(sourceRoot, this.config.destination, this.config.rcloneConfig);
2988
+ }
2989
+ async putIndex(machine, sourcePath) {
2990
+ await copyFile(
2991
+ sourcePath,
2992
+ joinRcloneDestination(this.config.destination, `${machine}/index.jsonl.age`),
2993
+ this.config.rcloneConfig
2994
+ );
2995
+ }
2996
+ async getIndex(machine, destinationPath) {
2997
+ await copyFile(
2998
+ joinRcloneDestination(this.config.destination, `${machine}/index.jsonl.age`),
2999
+ destinationPath,
3000
+ this.config.rcloneConfig
3001
+ );
3002
+ }
3003
+ async getArchiveObject(machine, archivePath, destinationPath) {
3004
+ await copyFile(
3005
+ joinRcloneDestination(this.config.destination, `${machine}/${archivePath}.age`),
3006
+ destinationPath,
3007
+ this.config.rcloneConfig
3008
+ );
3009
+ }
3010
+ };
3011
+ function createArchiveRemote(config) {
3012
+ switch (config.type) {
3013
+ case "rclone":
3014
+ return new RcloneArchiveRemote(config);
3015
+ }
3016
+ }
3017
+
3018
+ // src/offbox/outbox.ts
3019
+ var WEEK_MS = 7 * 24 * 60 * 60 * 1e3;
3020
+ var REMINDER = "If this laptop dies, sessions not copied off-box die with it.";
3021
+ function isUploadedRecord(value) {
3022
+ if (typeof value !== "object" || value === null) {
3023
+ return false;
3024
+ }
3025
+ const record = value;
3026
+ return record.v === 1 && typeof record.path === "string" && typeof record.mtimeMs === "number" && typeof record.uploadedAt === "string" && typeof record.recipient === "string" && typeof record.destination === "string" && (record.rcloneConfig === "managed" || record.rcloneConfig === "default");
3027
+ }
3028
+ function parseUploadedRecords(contents) {
3029
+ const records = /* @__PURE__ */ new Map();
3030
+ for (const line of contents.split("\n")) {
3031
+ if (line.trim() === "") {
3032
+ continue;
3033
+ }
3034
+ try {
3035
+ const value = JSON.parse(line);
3036
+ if (isUploadedRecord(value)) {
3037
+ records.set(value.path, value);
3038
+ }
3039
+ } catch {
3040
+ }
3041
+ }
3042
+ return records;
3043
+ }
3044
+ async function readUploadedRecords(path) {
3045
+ try {
3046
+ return parseUploadedRecords(await readFile8(path, "utf8"));
3047
+ } catch (error) {
3048
+ if (isEnoent(error)) {
3049
+ return /* @__PURE__ */ new Map();
3050
+ }
3051
+ throw error;
3052
+ }
3053
+ }
3054
+ async function readIndexState(path) {
3055
+ try {
3056
+ const value = JSON.parse(await readFile8(path, "utf8"));
3057
+ if (typeof value === "object" && value !== null && value.v === 1 && typeof value.hash === "string" && typeof value.recipient === "string") {
3058
+ return value;
3059
+ }
3060
+ return null;
3061
+ } catch (error) {
3062
+ if (isEnoent(error) || error instanceof SyntaxError) {
3063
+ return null;
3064
+ }
3065
+ throw error;
3066
+ }
3067
+ }
3068
+ async function pathExists2(path) {
3069
+ try {
3070
+ await stat6(path);
3071
+ return true;
3072
+ } catch (error) {
3073
+ if (isEnoent(error)) {
3074
+ return false;
3075
+ }
3076
+ throw error;
3077
+ }
3078
+ }
3079
+ async function walkArchiveFiles(machinePath, machine) {
3080
+ const files = [];
3081
+ async function walk(path) {
3082
+ for (const entry of await readdir2(path, { withFileTypes: true })) {
3083
+ const child = join20(path, entry.name);
3084
+ if (entry.isDirectory()) {
3085
+ await walk(child);
3086
+ } else if (entry.isFile()) {
3087
+ const pathFromMachine = relative5(machinePath, child);
3088
+ if (pathFromMachine !== "index.jsonl") {
3089
+ files.push({
3090
+ path: join20(machine, pathFromMachine),
3091
+ absolutePath: child,
3092
+ mtimeMs: (await stat6(child)).mtimeMs
3093
+ });
3094
+ }
3095
+ }
3096
+ }
3097
+ }
3098
+ await walk(machinePath);
3099
+ return files.sort((left, right) => left.path.localeCompare(right.path));
3100
+ }
3101
+ async function encryptFile(source, destination, recipient) {
3102
+ await mkdir9(dirname6(destination), { recursive: true });
3103
+ const temporary = join20(dirname6(destination), `.${basename3(destination)}.tmp-${process.pid}-${randomUUID6()}`);
3104
+ try {
3105
+ const ciphertext = await encryptToRecipient(recipient, await readFile8(source));
3106
+ await writeFile6(temporary, ciphertext);
3107
+ await rename4(temporary, destination);
3108
+ return ciphertext.byteLength;
3109
+ } catch (error) {
3110
+ await rm8(temporary, { force: true });
3111
+ throw error;
3112
+ }
3113
+ }
3114
+ async function publishRemote(home, config, offbox, remoteConfig) {
3115
+ const machinePath = join20(config.archiveRoot, config.machine);
3116
+ const indexPath = join20(machinePath, "index.jsonl");
3117
+ await mkdir9(machinePath, { recursive: true });
3118
+ await writeFile6(indexPath, "", { flag: "a" });
3119
+ const statePath = remoteStatePath(home, remoteConfig);
3120
+ const uploadedPath = join20(statePath, "uploaded.jsonl");
3121
+ const successPath = join20(statePath, "last-success.json");
3122
+ const indexStatePath = join20(statePath, "index.json");
3123
+ const uploaded = await readUploadedRecords(uploadedPath);
3124
+ const previousIndex = await readIndexState(indexStatePath);
3125
+ const remote = createArchiveRemote(remoteConfig);
3126
+ const hasPublished = uploaded.size > 0 || previousIndex !== null || await pathExists2(successPath);
3127
+ if (!hasPublished && await remote.indexExists(config.machine)) {
3128
+ throw new PackbatError(
3129
+ `an archive for machine \`${config.machine}\` already exists at the remote; restore it first (\`packbat restore --from-remote --identity <kit-file>\`) or change \`machine\` in config.json.`
3130
+ );
3131
+ }
3132
+ const archiveFiles = await walkArchiveFiles(machinePath, config.machine);
3133
+ const changed = archiveFiles.filter((file) => {
3134
+ const previous = uploaded.get(file.path);
3135
+ return previous === void 0 || previous.recipient !== offbox.recipient || previous.destination !== remoteConfig.destination || previous.rcloneConfig !== remoteConfig.rcloneConfig || file.mtimeMs > previous.mtimeMs;
3136
+ });
3137
+ const outboxPath = join20(statePath, "outbox");
3138
+ await rm8(outboxPath, { recursive: true, force: true });
3139
+ let bytes = 0;
3140
+ for (const file of changed) {
3141
+ bytes += await encryptFile(file.absolutePath, join20(outboxPath, `${file.path}.age`), offbox.recipient);
3142
+ }
3143
+ if (changed.length > 0) {
3144
+ await remote.putArchiveObjects(outboxPath);
3145
+ }
3146
+ const indexContents = await readFile8(indexPath);
3147
+ const indexHash = createHash6("sha256").update(indexContents).digest("hex");
3148
+ const indexChanged = previousIndex?.hash !== indexHash || previousIndex.recipient !== offbox.recipient;
3149
+ const encryptedIndexPath = join20(outboxPath, config.machine, "index.jsonl.age");
3150
+ if (indexChanged) {
3151
+ await encryptFile(indexPath, encryptedIndexPath, offbox.recipient);
3152
+ await remote.putIndex(config.machine, encryptedIndexPath);
3153
+ }
3154
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
3155
+ if (changed.length > 0) {
3156
+ await mkdir9(statePath, { recursive: true });
3157
+ await appendFile3(
3158
+ uploadedPath,
3159
+ `${changed.map(
3160
+ (file) => JSON.stringify({
3161
+ v: 1,
3162
+ path: file.path,
3163
+ mtimeMs: file.mtimeMs,
3164
+ uploadedAt: finishedAt,
3165
+ recipient: offbox.recipient,
3166
+ destination: remoteConfig.destination,
3167
+ rcloneConfig: remoteConfig.rcloneConfig
3168
+ })
3169
+ ).join("\n")}
3170
+ `
3171
+ );
3172
+ }
3173
+ if (indexChanged) {
3174
+ await writeAtomicJson(indexStatePath, { v: 1, hash: indexHash, recipient: offbox.recipient });
3175
+ }
3176
+ await rm8(outboxPath, { recursive: true, force: true });
3177
+ const result = {
3178
+ destination: remote.destination,
3179
+ finishedAt,
3180
+ uploaded: changed.length,
3181
+ bytes,
3182
+ indexUploaded: indexChanged
3183
+ };
3184
+ await writeAtomicJson(successPath, result);
3185
+ return { ok: true, ...result };
3186
+ }
3187
+ async function publishOffbox(home, config, offbox) {
3188
+ const outcomes = [];
3189
+ for (const remote of offbox.remotes) {
3190
+ try {
3191
+ outcomes.push(await publishRemote(home, config, offbox, remote));
3192
+ } catch (error) {
3193
+ outcomes.push({
3194
+ destination: remote.destination,
3195
+ ok: false,
3196
+ error: error instanceof Error ? error.message : String(error)
3197
+ });
3198
+ }
3199
+ }
3200
+ return outcomes;
3201
+ }
3202
+ async function remindOffboxSkipped(home, now = /* @__PURE__ */ new Date()) {
3203
+ const stampPath = join20(home.statePath, "offbox-reminder.json");
3204
+ let remindedAtMs = Number.NaN;
3205
+ try {
3206
+ const value = JSON.parse(await readFile8(stampPath, "utf8"));
3207
+ const remindedAt = typeof value === "object" && value !== null ? value.remindedAt : void 0;
3208
+ if (typeof remindedAt === "string") {
3209
+ remindedAtMs = Date.parse(remindedAt);
3210
+ }
3211
+ } catch (error) {
3212
+ if (!isEnoent(error) && !(error instanceof SyntaxError)) {
3213
+ throw error;
3214
+ }
3215
+ }
3216
+ if (!Number.isNaN(remindedAtMs) && now.getTime() - remindedAtMs < WEEK_MS) {
3217
+ return;
3218
+ }
3219
+ await appendLog(home.logsPath, REMINDER, now);
3220
+ await writeAtomicJson(stampPath, { remindedAt: now.toISOString() });
3221
+ }
3222
+
3223
+ // src/commands/sync.ts
3224
+ var USAGE2 = "Usage: packbat sync\n";
3225
+ function reportSummary(summary, options) {
3226
+ options.onSummary?.(summary);
3227
+ if (options.writeSummary !== false) {
3228
+ process.stdout.write(`${summary}
3229
+ `);
3230
+ }
3231
+ }
3232
+ function offboxSummary(outcomes) {
3233
+ return `off-box ${outcomes.filter((outcome) => outcome.ok).length}/${outcomes.length}`;
3234
+ }
3235
+ async function runSync(argv, output = {}) {
3236
+ if (argv.length > 0) {
3237
+ process.stderr.write(USAGE2);
3238
+ return 1;
3239
+ }
3240
+ const home = resolveHome();
3241
+ const config = loadConfig(home);
3242
+ assertZstdSupport();
3243
+ const locked = await withSyncLock(home.statePath, async () => {
3244
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3245
+ let archived = 0;
3246
+ let unchanged = 0;
3247
+ let failed = 0;
3248
+ let repaired = 0;
3249
+ let errors = [];
3250
+ let offboxOutcomes = [];
3251
+ let offboxError;
3252
+ try {
3253
+ const result = await sweep(config, process.env);
3254
+ archived = result.archived;
3255
+ unchanged = result.unchanged;
3256
+ failed = result.failed;
3257
+ repaired = result.repaired;
3258
+ errors = result.errors;
3259
+ } catch (error) {
3260
+ failed = 1;
3261
+ errors = [`sweep: ${error instanceof Error ? error.message : String(error)}`];
3262
+ }
3263
+ const ok = failed === 0;
3264
+ if (ok) {
3265
+ try {
3266
+ if (config.offbox.mode === "configured") {
3267
+ offboxOutcomes = await publishOffbox(home, config, config.offbox);
3268
+ } else {
3269
+ await remindOffboxSkipped(home);
3270
+ }
3271
+ } catch (error) {
3272
+ offboxError = error instanceof Error ? error.message : String(error);
3273
+ }
3274
+ }
3275
+ const offboxFailures = offboxOutcomes.filter(
3276
+ (outcome) => !outcome.ok && outcome.error !== void 0
3277
+ );
3278
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
3279
+ const summary = `archived ${archived}, unchanged ${unchanged}, failed ${failed}${repaired > 0 ? `, repaired ${repaired}` : ""}${offboxOutcomes.length > 0 ? `, ${offboxSummary(offboxOutcomes)}` : ""}`;
3280
+ await writeRunStamps(home.statePath, {
3281
+ startedAt,
3282
+ finishedAt,
3283
+ ok,
3284
+ archived,
3285
+ unchanged,
3286
+ failed,
3287
+ repaired,
3288
+ ...offboxFailures.length > 0 ? { offbox: offboxFailures.map((outcome) => `${outcome.destination}: ${outcome.error}`).join("; ") } : offboxError === void 0 ? {} : { offbox: offboxError }
3289
+ });
3290
+ await appendLog(home.logsPath, summary, new Date(finishedAt));
3291
+ if (offboxError !== void 0) {
3292
+ await appendLog(home.logsPath, `off-box failed: ${offboxError}`, new Date(finishedAt));
3293
+ }
3294
+ for (const outcome of offboxFailures) {
3295
+ await appendLog(home.logsPath, `off-box failed (${outcome.destination}): ${outcome.error}`, new Date(finishedAt));
3296
+ }
3297
+ for (const error of errors) {
3298
+ process.stderr.write(`packbat sync: ${error}
3299
+ `);
3300
+ }
3301
+ if (offboxError !== void 0) {
3302
+ process.stderr.write(`packbat sync: off-box: ${offboxError}
3303
+ `);
3304
+ }
3305
+ for (const outcome of offboxFailures) {
3306
+ process.stderr.write(`packbat sync: off-box ${outcome.destination}: ${outcome.error}
3307
+ `);
3308
+ }
3309
+ reportSummary(summary, output);
3310
+ return ok && offboxFailures.length === 0 && offboxError === void 0 ? 0 : 1;
3311
+ });
3312
+ if (!locked.acquired) {
3313
+ output.onBusy?.();
3314
+ reportSummary("sync already running", output);
3315
+ return 0;
3316
+ }
3317
+ return locked.value;
3318
+ }
3319
+
3320
+ export {
3321
+ PackbatError,
3322
+ errorMessage,
3323
+ loadConfig,
3324
+ resolveHome,
3325
+ isEnoent,
3326
+ readDirectoryOrEmpty,
3327
+ HARNESS_IDS,
3328
+ UUID_SOURCE,
3329
+ isHarnessId,
3330
+ adapters,
3331
+ getAdapter,
3332
+ commandOnPath,
3333
+ isDatabaseSnapshotIndexRecord,
3334
+ readIndex,
3335
+ readDerivedIndex,
3336
+ createDoctorContext,
3337
+ checkInstalled,
3338
+ checkLive,
3339
+ ageMs,
3340
+ formatAge,
3341
+ checkFresh,
3342
+ checkOffbox,
3343
+ readHarnessTallies,
3344
+ decompressBytes,
3345
+ runDoctor,
3346
+ previewSchedule,
3347
+ uninstallSchedule,
3348
+ skippedOffboxConfig,
3349
+ userHome2 as userHome,
3350
+ detectInitStores,
3351
+ writeInitConfig,
3352
+ createInitScheduleOptions,
3353
+ installInitSchedule,
3354
+ withRetrievalLock,
3355
+ generateIdentity,
3356
+ identityToRecipient,
3357
+ decryptWithIdentity,
3358
+ parseIdentityFile,
3359
+ discoverRclone,
3360
+ createArchiveRemote,
3361
+ runSync
3362
+ };