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,680 @@
1
+ import {
2
+ PackbatError,
3
+ commandOnPath,
4
+ createArchiveRemote,
5
+ createInitScheduleOptions,
6
+ decryptWithIdentity,
7
+ detectInitStores,
8
+ discoverRclone,
9
+ generateIdentity,
10
+ identityToRecipient,
11
+ installInitSchedule,
12
+ loadConfig,
13
+ previewSchedule,
14
+ resolveHome,
15
+ runDoctor,
16
+ runSync,
17
+ skippedOffboxConfig,
18
+ userHome,
19
+ writeInitConfig
20
+ } from "./chunk-O43RENSG.js";
21
+
22
+ // src/commands/init-wizard.ts
23
+ import { spawn } from "child_process";
24
+ import { existsSync } from "fs";
25
+ import { isAbsolute, join as join3 } from "path";
26
+ import { cancel, confirm, intro, isCancel, log, note, outro, password, select, spinner, text } from "@clack/prompts";
27
+
28
+ // src/core/private-file.ts
29
+ import { randomUUID } from "crypto";
30
+ import { chmod, link, mkdir, rename, rm, writeFile } from "fs/promises";
31
+ import { dirname, join } from "path";
32
+ async function writePrivateFile(path, contents, options = { overwrite: true }) {
33
+ const directory = dirname(path);
34
+ const temporary = join(directory, `.packbat-private.tmp-${process.pid}-${randomUUID()}`);
35
+ await mkdir(directory, { recursive: true });
36
+ try {
37
+ await writeFile(temporary, contents, { mode: 384 });
38
+ await chmod(temporary, 384);
39
+ if (options.overwrite) {
40
+ await rename(temporary, path);
41
+ } else {
42
+ await link(temporary, path);
43
+ await rm(temporary);
44
+ }
45
+ } catch (error) {
46
+ await rm(temporary, { force: true });
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ // src/offbox/rclone-conf.ts
52
+ function renderRemote(lines) {
53
+ return `${lines.join("\n")}
54
+ `;
55
+ }
56
+ function managedRcloneRemoteName(index) {
57
+ if (!Number.isInteger(index) || index < 0) {
58
+ throw new Error("managed remote index must be a non-negative integer");
59
+ }
60
+ return index === 0 ? "packbat" : `packbat-${index + 1}`;
61
+ }
62
+ function renderS3Remote(input, remoteName = managedRcloneRemoteName(0)) {
63
+ return renderRemote([
64
+ `[${remoteName}]`,
65
+ "type = s3",
66
+ "provider = Other",
67
+ `access_key_id = ${input.accessKeyId}`,
68
+ `secret_access_key = ${input.secretAccessKey}`,
69
+ `endpoint = ${input.endpoint}`,
70
+ ...input.region === void 0 ? [] : [`region = ${input.region}`]
71
+ ]);
72
+ }
73
+ function renderSftpRemote(input, remoteName = managedRcloneRemoteName(0)) {
74
+ return renderRemote([
75
+ `[${remoteName}]`,
76
+ "type = sftp",
77
+ `host = ${input.host}`,
78
+ `user = ${input.user}`,
79
+ ...input.port === void 0 ? [] : [`port = ${input.port}`],
80
+ ...input.keyFile === void 0 ? [] : [`key_file = ${input.keyFile}`]
81
+ ]);
82
+ }
83
+ async function writeManagedRcloneConfig(path, contents) {
84
+ await writePrivateFile(path, contents);
85
+ }
86
+
87
+ // src/offbox/rclone-install.ts
88
+ var MANUAL_INSTALL = {
89
+ linux: "sudo apt install rclone",
90
+ other: "https://rclone.org/install/"
91
+ };
92
+ function pickRcloneInstall(platform, hasCommand) {
93
+ if (hasCommand("brew")) {
94
+ return { kind: "brew", command: ["brew", "install", "rclone"] };
95
+ }
96
+ return {
97
+ kind: "manual",
98
+ command: platform === "linux" ? MANUAL_INSTALL.linux : MANUAL_INSTALL.other
99
+ };
100
+ }
101
+
102
+ // package.json
103
+ var package_default = {
104
+ name: "packbat",
105
+ version: "0.1.0",
106
+ description: "Every agent session, kept. Preserves AI-agent sessions as raw, append-only archives in a store you own.",
107
+ license: "MIT",
108
+ author: "Liam Vinberg",
109
+ homepage: "https://packbat.dev",
110
+ repository: {
111
+ type: "git",
112
+ url: "git+https://github.com/liamvinberg/packbat.git"
113
+ },
114
+ bugs: {
115
+ url: "https://github.com/liamvinberg/packbat/issues"
116
+ },
117
+ keywords: [
118
+ "ai-agents",
119
+ "archive",
120
+ "backup",
121
+ "cli"
122
+ ],
123
+ type: "module",
124
+ engines: {
125
+ node: ">=22.16"
126
+ },
127
+ bin: {
128
+ packbat: "bin/packbat.js"
129
+ },
130
+ files: [
131
+ "bin",
132
+ "dist",
133
+ "README.md",
134
+ "LICENSE"
135
+ ],
136
+ publishConfig: {
137
+ access: "public"
138
+ },
139
+ scripts: {
140
+ build: "tsup",
141
+ dev: "tsx src/main.ts",
142
+ typecheck: "tsc",
143
+ test: "vitest run",
144
+ "test:watch": "vitest"
145
+ },
146
+ dependencies: {
147
+ "@clack/prompts": "^1.7.0",
148
+ "age-encryption": "^0.3.0",
149
+ picocolors: "^1.1.1",
150
+ zod: "^4.4.3"
151
+ },
152
+ devDependencies: {
153
+ "@types/node": "^26.1.1",
154
+ tsup: "^8.5.1",
155
+ tsx: "^4.23.0",
156
+ typescript: "^7.0.2",
157
+ vitest: "^4.1.10"
158
+ }
159
+ };
160
+
161
+ // src/offbox/recovery-kit.ts
162
+ var RECOVERY_KIT_FORMAT = 2;
163
+ function renderRemote2(remote) {
164
+ switch (remote.type) {
165
+ case "s3-compatible":
166
+ return [
167
+ "type: s3-compatible",
168
+ `endpoint: ${remote.endpoint}`,
169
+ `bucket: ${remote.bucket}`,
170
+ ...remote.prefix === void 0 ? [] : [`prefix: ${remote.prefix}`],
171
+ `destination: ${remote.destination}`
172
+ ].join("\n");
173
+ case "sftp":
174
+ return [
175
+ "type: sftp",
176
+ `host: ${remote.host}`,
177
+ ...remote.port === void 0 ? [] : [`port: ${remote.port}`],
178
+ `path: ${remote.path}`,
179
+ `destination: ${remote.destination}`
180
+ ].join("\n");
181
+ case "rclone":
182
+ return `type: rclone
183
+ destination: ${remote.destination}`;
184
+ }
185
+ }
186
+ function renderRecoveryKit(input) {
187
+ const remoteSections = input.remotes.map((remote, index) => `${input.remotes.length === 1 ? "Remote" : `Remote ${index + 1}`}
188
+ ${renderRemote2(remote)}`).join("\n\n");
189
+ const firstRemote = input.remotes[0];
190
+ const additionalSetup = input.remotes.length === 1 ? "" : "\nAdd the other remote destinations to config.json.\n";
191
+ const restoreCommands = input.remotes.map(
192
+ (remote, index) => `packbat restore --from-remote --identity <kit-file>${index === 0 ? "" : ` --remote ${remote.destination}`} <unit> --machine <source-machine>`
193
+ ).join("\n");
194
+ return `Packbat recovery kit
195
+ Packbat version: ${package_default.version}
196
+ format: ${RECOVERY_KIT_FORMAT}
197
+ created: ${input.createdAt}
198
+
199
+ Age identity
200
+ ${input.identity}
201
+
202
+ Age recipient
203
+ ${input.recipient}
204
+
205
+ ${remoteSections}
206
+
207
+ Fresh-machine setup
208
+ Configure rclone access to ${input.remotes.map((remote) => remote.destination).join(", ")}, then run:
209
+ packbat init --yes --offbox remote --offbox-remote ${firstRemote.destination} --age-recipient ${input.recipient} --rclone-config default
210
+ ${additionalSetup}
211
+ Fresh-machine restore
212
+ ${restoreCommands}
213
+
214
+ Raw age fallback
215
+ age -d -i <kit-file> -o <archive-file> <archive-file>.age
216
+
217
+ If every copy of this identity is lost, nobody can recover this archive.
218
+ `;
219
+ }
220
+ function recipientChallenge(recipient) {
221
+ return recipient.slice(-8);
222
+ }
223
+ async function writeRecoveryKit(path, contents) {
224
+ await writePrivateFile(path, contents, { overwrite: false });
225
+ }
226
+
227
+ // src/offbox/smoke.ts
228
+ import { mkdtemp, readFile, rm as rm2 } from "fs/promises";
229
+ import { tmpdir } from "os";
230
+ import { join as join2 } from "path";
231
+ async function smokeTestRemoteIndex(config, remoteConfig, identity) {
232
+ const remote = createArchiveRemote(remoteConfig);
233
+ if (!await remote.indexExists(config.machine)) {
234
+ throw new PackbatError("remote index does not exist");
235
+ }
236
+ const stagePath = await mkdtemp(join2(tmpdir(), "packbat-offbox-smoke-"));
237
+ try {
238
+ const encryptedIndexPath = join2(stagePath, "index.jsonl.age");
239
+ await remote.getIndex(config.machine, encryptedIndexPath);
240
+ const [localIndex, ciphertext] = await Promise.all([
241
+ readFile(join2(config.archiveRoot, config.machine, "index.jsonl")),
242
+ readFile(encryptedIndexPath)
243
+ ]);
244
+ const remoteIndexContents = await decryptWithIdentity(identity, ciphertext);
245
+ if (!remoteIndexContents.equals(localIndex)) {
246
+ throw new PackbatError("downloaded remote index does not match the local index");
247
+ }
248
+ } finally {
249
+ await rm2(stagePath, { recursive: true, force: true });
250
+ }
251
+ }
252
+
253
+ // src/commands/init-wizard.ts
254
+ var WIZARD_CANCELLED = /* @__PURE__ */ Symbol("wizard-cancelled");
255
+ var RCLONE_INSTALL_COPY = {
256
+ brewConfirm: "Install rclone with Homebrew? (runs: brew install rclone)",
257
+ manualNoteTitle: "Install rclone",
258
+ manualConfirm: "rclone is installed now",
259
+ skipped: "Off-box is skipped because rclone is not installed."
260
+ };
261
+ function cancelWizard() {
262
+ cancel("Setup cancelled.");
263
+ return WIZARD_CANCELLED;
264
+ }
265
+ function promptResult(value) {
266
+ return isCancel(value) ? cancelWizard() : value;
267
+ }
268
+ function required(value) {
269
+ return value?.trim() ? void 0 : "Enter a value.";
270
+ }
271
+ function singleLine(value) {
272
+ return value?.includes("\n") || value?.includes("\r") ? "Use one line." : void 0;
273
+ }
274
+ async function askRequiredText(message) {
275
+ const answer = promptResult(await text({ message, validate: required }));
276
+ return answer === WIZARD_CANCELLED ? answer : answer.trim();
277
+ }
278
+ async function askOptionalText(message) {
279
+ const answer = promptResult(await text({ message, defaultValue: "", validate: singleLine }));
280
+ return answer === WIZARD_CANCELLED ? answer : answer.trim();
281
+ }
282
+ async function askSecret(message) {
283
+ const answer = promptResult(await password({ message, validate: required }));
284
+ return answer === WIZARD_CANCELLED ? answer : answer.trim();
285
+ }
286
+ function s3Destination(bucket, prefix) {
287
+ const cleanBucket = bucket.replace(/^\/+|\/+$/gu, "");
288
+ const cleanPrefix = prefix.replace(/^\/+|\/+$/gu, "");
289
+ return `packbat:${cleanPrefix === "" ? cleanBucket : `${cleanBucket}/${cleanPrefix}`}`;
290
+ }
291
+ async function runStreaming(command) {
292
+ const executable = command[0];
293
+ if (executable === void 0) return;
294
+ await new Promise((resolve) => {
295
+ const child = spawn(executable, command.slice(1), { env: process.env, stdio: "inherit" });
296
+ child.on("error", () => resolve());
297
+ child.on("close", () => resolve());
298
+ });
299
+ }
300
+ async function ensureRclone() {
301
+ try {
302
+ await discoverRclone();
303
+ return true;
304
+ } catch (error) {
305
+ if (!(error instanceof Error) || !error.message.startsWith("rclone was not found")) {
306
+ throw error;
307
+ }
308
+ }
309
+ const hasBrew = commandOnPath("brew") !== null;
310
+ const install = pickRcloneInstall(process.platform, (command) => command === "brew" && hasBrew);
311
+ if (install.kind === "brew") {
312
+ const accepted = promptResult(await confirm({ message: RCLONE_INSTALL_COPY.brewConfirm, initialValue: true }));
313
+ if (accepted === WIZARD_CANCELLED) return accepted;
314
+ if (!accepted) {
315
+ log.info(RCLONE_INSTALL_COPY.skipped);
316
+ return false;
317
+ }
318
+ await runStreaming(install.command);
319
+ } else {
320
+ note(install.command, RCLONE_INSTALL_COPY.manualNoteTitle);
321
+ const ready = promptResult(await confirm({ message: RCLONE_INSTALL_COPY.manualConfirm, initialValue: true }));
322
+ if (ready === WIZARD_CANCELLED) return ready;
323
+ if (!ready) {
324
+ log.info(RCLONE_INSTALL_COPY.skipped);
325
+ return false;
326
+ }
327
+ }
328
+ try {
329
+ await discoverRclone();
330
+ return true;
331
+ } catch (error) {
332
+ if (!(error instanceof Error) || !error.message.startsWith("rclone was not found")) {
333
+ throw error;
334
+ }
335
+ log.info(RCLONE_INSTALL_COPY.skipped);
336
+ return false;
337
+ }
338
+ }
339
+ async function askS3Remote() {
340
+ const endpoint = await askRequiredText("S3 endpoint");
341
+ if (endpoint === WIZARD_CANCELLED) return endpoint;
342
+ const accessKeyId = await askRequiredText("Access key ID");
343
+ if (accessKeyId === WIZARD_CANCELLED) return accessKeyId;
344
+ const secretAccessKey = await askSecret("Secret access key");
345
+ if (secretAccessKey === WIZARD_CANCELLED) return secretAccessKey;
346
+ const region = await askOptionalText("Region (optional)");
347
+ if (region === WIZARD_CANCELLED) return region;
348
+ const bucket = await askRequiredText("Bucket");
349
+ if (bucket === WIZARD_CANCELLED) return bucket;
350
+ const prefix = await askOptionalText("Prefix (optional)");
351
+ if (prefix === WIZARD_CANCELLED) return prefix;
352
+ const destination = s3Destination(bucket, prefix);
353
+ return {
354
+ remote: { type: "rclone", destination, rcloneConfig: "managed" },
355
+ recovery: {
356
+ type: "s3-compatible",
357
+ destination,
358
+ endpoint,
359
+ bucket,
360
+ ...prefix === "" ? {} : { prefix }
361
+ },
362
+ managedConfig: renderS3Remote({
363
+ endpoint,
364
+ accessKeyId,
365
+ secretAccessKey,
366
+ ...region === "" ? {} : { region }
367
+ })
368
+ };
369
+ }
370
+ async function askSftpRemote() {
371
+ const host = await askRequiredText("SFTP host");
372
+ if (host === WIZARD_CANCELLED) return host;
373
+ const user = await askRequiredText("SFTP user");
374
+ if (user === WIZARD_CANCELLED) return user;
375
+ const portAnswer = promptResult(
376
+ await text({
377
+ message: "Port (optional)",
378
+ defaultValue: "",
379
+ validate(value) {
380
+ const answer = value?.trim() ?? "";
381
+ if (answer === "") return void 0;
382
+ if (!/^\d+$/u.test(answer)) return "Enter a port from 1 to 65535.";
383
+ const port2 = Number.parseInt(answer, 10);
384
+ return port2 >= 1 && port2 <= 65535 ? void 0 : "Enter a port from 1 to 65535.";
385
+ }
386
+ })
387
+ );
388
+ if (portAnswer === WIZARD_CANCELLED) return portAnswer;
389
+ const keyFile = await askOptionalText("SSH key file (optional)");
390
+ if (keyFile === WIZARD_CANCELLED) return keyFile;
391
+ const remotePath = await askRequiredText("Remote path");
392
+ if (remotePath === WIZARD_CANCELLED) return remotePath;
393
+ const port = portAnswer.trim() === "" ? void 0 : Number.parseInt(portAnswer.trim(), 10);
394
+ const destination = `packbat:${remotePath}`;
395
+ return {
396
+ remote: { type: "rclone", destination, rcloneConfig: "managed" },
397
+ recovery: {
398
+ type: "sftp",
399
+ destination,
400
+ host,
401
+ ...port === void 0 ? {} : { port },
402
+ path: remotePath
403
+ },
404
+ managedConfig: renderSftpRemote({
405
+ host,
406
+ user,
407
+ ...port === void 0 ? {} : { port },
408
+ ...keyFile === "" ? {} : { keyFile }
409
+ })
410
+ };
411
+ }
412
+ async function askCustomRemote() {
413
+ const destination = await askRequiredText("Rclone destination");
414
+ if (destination === WIZARD_CANCELLED) return destination;
415
+ const configMode = promptResult(
416
+ await select({
417
+ message: "Rclone config",
418
+ options: [
419
+ { value: "default", label: "Default rclone config" },
420
+ { value: "managed", label: "Managed by Packbat" }
421
+ ],
422
+ initialValue: "default"
423
+ })
424
+ );
425
+ if (configMode === WIZARD_CANCELLED) return configMode;
426
+ return {
427
+ remote: { type: "rclone", destination, rcloneConfig: configMode },
428
+ recovery: { type: "rclone", destination }
429
+ };
430
+ }
431
+ async function askRemote() {
432
+ const kind = promptResult(
433
+ await select({
434
+ message: "Remote kind",
435
+ options: [
436
+ { value: "s3", label: "S3-compatible" },
437
+ { value: "sftp", label: "SFTP" },
438
+ { value: "custom", label: "Any rclone destination" }
439
+ ],
440
+ initialValue: "s3"
441
+ })
442
+ );
443
+ if (kind === WIZARD_CANCELLED) return kind;
444
+ switch (kind) {
445
+ case "s3":
446
+ return await askS3Remote();
447
+ case "sftp":
448
+ return await askSftpRemote();
449
+ case "custom":
450
+ return await askCustomRemote();
451
+ }
452
+ throw new Error("Unknown remote kind");
453
+ }
454
+ async function saveRecoveryKit(kit, homePath) {
455
+ const destination = promptResult(
456
+ await select({
457
+ message: "Recovery kit destination",
458
+ options: [
459
+ { value: "save", label: "Save to a file" },
460
+ { value: "print", label: "Print to terminal" }
461
+ ],
462
+ initialValue: "save"
463
+ })
464
+ );
465
+ if (destination === WIZARD_CANCELLED) return destination;
466
+ if (destination === "print") {
467
+ note(kit, "Recovery kit");
468
+ return true;
469
+ }
470
+ const defaultPath = join3(homePath, "packbat-recovery-kit.txt");
471
+ const path = promptResult(
472
+ await text({
473
+ message: "Recovery kit path",
474
+ initialValue: defaultPath,
475
+ validate(value) {
476
+ const path2 = value?.trim();
477
+ if (!path2) return "Enter a path.";
478
+ return existsSync(path2) ? "Path already exists. Choose another path." : void 0;
479
+ }
480
+ })
481
+ );
482
+ if (path === WIZARD_CANCELLED) return path;
483
+ await writeRecoveryKit(path.trim(), kit);
484
+ log.success(`Saved recovery kit: ${path.trim()}`);
485
+ return true;
486
+ }
487
+ async function verifyCustody(challenge) {
488
+ for (let attempt = 0; attempt < 2; attempt += 1) {
489
+ const answer = promptResult(
490
+ await text({
491
+ message: "Enter the last 8 recipient characters from the recovery kit",
492
+ validate: required
493
+ })
494
+ );
495
+ if (answer === WIZARD_CANCELLED) return answer;
496
+ if (answer.trim() === challenge) {
497
+ return true;
498
+ }
499
+ if (attempt === 0) {
500
+ log.warn("That did not match. One try left.");
501
+ }
502
+ }
503
+ return false;
504
+ }
505
+ async function configureOffbox(config, homePath) {
506
+ const home = resolveHome();
507
+ const choice = promptResult(
508
+ await select({
509
+ message: "Off-box copies",
510
+ options: [
511
+ { value: "skip", label: "Skip for now" },
512
+ { value: "remote", label: "Remote I own" }
513
+ ],
514
+ initialValue: "skip"
515
+ })
516
+ );
517
+ if (choice === WIZARD_CANCELLED) return choice;
518
+ if (choice === "skip") {
519
+ return { kind: "skipped", config: await writeInitConfig(home, config.archiveRoot, skippedOffboxConfig()) };
520
+ }
521
+ const rclone = await ensureRclone();
522
+ if (rclone === WIZARD_CANCELLED) return rclone;
523
+ if (!rclone) {
524
+ return { kind: "skipped", config: await writeInitConfig(home, config.archiveRoot, skippedOffboxConfig()) };
525
+ }
526
+ const remote = await askRemote();
527
+ if (remote === WIZARD_CANCELLED) return remote;
528
+ const identity = await generateIdentity();
529
+ const recipient = await identityToRecipient(identity);
530
+ const kit = renderRecoveryKit({
531
+ identity,
532
+ recipient,
533
+ remotes: [remote.recovery],
534
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
535
+ });
536
+ const saved = await saveRecoveryKit(kit, homePath);
537
+ if (saved === WIZARD_CANCELLED) return saved;
538
+ const custody = await verifyCustody(recipientChallenge(recipient));
539
+ if (custody === WIZARD_CANCELLED) return custody;
540
+ if (!custody) {
541
+ log.warn("Custody was not verified. Off-box is skipped.");
542
+ return { kind: "skipped", config: await writeInitConfig(home, config.archiveRoot, skippedOffboxConfig()) };
543
+ }
544
+ log.success("Recovery kit verified. Future backups need no key prompt.");
545
+ if (remote.managedConfig !== void 0) {
546
+ await writeManagedRcloneConfig(home.rcloneConfPath, remote.managedConfig);
547
+ }
548
+ const offbox = { mode: "configured", recipient, remotes: [remote.remote] };
549
+ return { kind: "configured", config: await writeInitConfig(home, config.archiveRoot, offbox), offbox, identity };
550
+ }
551
+ async function runInitWizard() {
552
+ intro("packbat init");
553
+ const home = resolveHome();
554
+ const homePath = userHome();
555
+ const detection = detectInitStores(homePath);
556
+ if (detection.detected.length === 0) {
557
+ log.info("Detected harnesses: none");
558
+ } else {
559
+ log.info(
560
+ ["Detected harnesses:", ...detection.detected.map((item) => `${item.displayName}: ${item.path}`)].join("\n")
561
+ );
562
+ }
563
+ if (detection.unsupported.length === 0) {
564
+ log.info("Found, not yet supported: none");
565
+ } else {
566
+ log.info(
567
+ ["Found, not yet supported:", ...detection.unsupported.map((item) => `${item.displayName}: ${item.path}`)].join(
568
+ "\n"
569
+ )
570
+ );
571
+ }
572
+ const existing = existsSync(home.configPath) ? loadConfig(home) : void 0;
573
+ const defaultArchiveRoot = existing?.archiveRoot ?? home.defaultArchiveRoot;
574
+ const archiveAnswer = promptResult(
575
+ await text({
576
+ message: "Archive root",
577
+ initialValue: defaultArchiveRoot,
578
+ validate(value) {
579
+ const answer = value?.trim() ?? "";
580
+ if (!isAbsolute(answer)) return "Use an absolute path.";
581
+ if (existing !== void 0 && answer !== existing.archiveRoot) {
582
+ return `Archive root is already ${existing.archiveRoot}. Edit config.json to move it.`;
583
+ }
584
+ return void 0;
585
+ }
586
+ })
587
+ );
588
+ if (archiveAnswer === WIZARD_CANCELLED) return 1;
589
+ let config = await writeInitConfig(home, archiveAnswer.trim());
590
+ const scheduleOptions = await createInitScheduleOptions(home, homePath);
591
+ const schedule = previewSchedule(scheduleOptions);
592
+ log.info(
593
+ [
594
+ "Schedule:",
595
+ ...schedule.artifactPaths.map((path) => `artifact: ${path}`),
596
+ `node: ${scheduleOptions.nodePath}`,
597
+ `entry: ${scheduleOptions.entryPath}`,
598
+ "hourly at :03, plus at login/wake"
599
+ ].join("\n")
600
+ );
601
+ const install = promptResult(await confirm({ message: "Install and activate this schedule?", initialValue: true }));
602
+ if (install === WIZARD_CANCELLED) return 1;
603
+ if (!install) {
604
+ cancel("Setup stopped before schedule install.");
605
+ return 1;
606
+ }
607
+ const installed = await installInitSchedule(scheduleOptions, true);
608
+ for (const message of [...installed.schedule.notes, ...installed.activationNotes]) {
609
+ log.info(message);
610
+ }
611
+ const configured = await configureOffbox(config, homePath);
612
+ if (configured === WIZARD_CANCELLED) return 1;
613
+ config = configured.config;
614
+ let sweepCancelled = false;
615
+ const sweep = spinner({
616
+ onCancel() {
617
+ sweepCancelled = true;
618
+ }
619
+ });
620
+ let summary = "First sweep finished.";
621
+ sweep.start("Running first sweep");
622
+ let syncCode;
623
+ while (true) {
624
+ if (sweepCancelled) return 1;
625
+ let busy = false;
626
+ syncCode = await runSync([], {
627
+ writeSummary: false,
628
+ onSummary(value) {
629
+ summary = value;
630
+ },
631
+ onBusy() {
632
+ busy = true;
633
+ }
634
+ });
635
+ if (!busy) break;
636
+ if (sweepCancelled) return 1;
637
+ sweep.message("Waiting for running sync");
638
+ await new Promise((resolve) => setTimeout(resolve, 250));
639
+ }
640
+ if (syncCode === 1) {
641
+ sweep.error(summary);
642
+ } else {
643
+ sweep.stop(summary);
644
+ }
645
+ if (sweepCancelled) return 1;
646
+ let remoteCode = 0;
647
+ if (configured.kind === "configured") {
648
+ let remoteCancelled = false;
649
+ const remoteCheck = spinner({
650
+ onCancel() {
651
+ remoteCancelled = true;
652
+ }
653
+ });
654
+ remoteCheck.start("Checking remote index");
655
+ const remoteErrors = [];
656
+ for (const remote of configured.offbox.remotes) {
657
+ try {
658
+ await smokeTestRemoteIndex(config, remote, configured.identity);
659
+ } catch (error) {
660
+ remoteErrors.push(`${remote.destination}: ${error instanceof Error ? error.message : String(error)}`);
661
+ }
662
+ }
663
+ if (remoteErrors.length === 0) {
664
+ if (!remoteCancelled) remoteCheck.stop("Remote index checked.");
665
+ } else {
666
+ if (!remoteCancelled) {
667
+ remoteCheck.error(`Remote index check failed: ${remoteErrors.join("; ")}`);
668
+ }
669
+ remoteCode = 1;
670
+ }
671
+ if (remoteCancelled) return 1;
672
+ }
673
+ const doctorCode = await runDoctor([]);
674
+ const operationalFailure = syncCode === 1 || remoteCode === 1 || doctorCode === 1;
675
+ outro(operationalFailure ? "Setup failed. Run `packbat status`." : "Done. Run `packbat status`.");
676
+ return operationalFailure ? 1 : 0;
677
+ }
678
+ export {
679
+ runInitWizard
680
+ };