@narumitw/pi-sync 0.31.0 → 0.35.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,912 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { createSyncBackend, type SyncBackendFactory } from "./backend-factory.js";
5
+ import {
6
+ loadConfig,
7
+ readStateForConfig,
8
+ sessionDirForApply,
9
+ stateDir,
10
+ syncSessionsWarnings,
11
+ writeStateForConfig,
12
+ } from "./config.js";
13
+ import { inspectLock, isLockGuardHeld, isStaleLock, withLock } from "./lock.js";
14
+ import {
15
+ createSnapshot,
16
+ filterSnapshotForConfigPolicy,
17
+ mergeRemotePreservedFiles,
18
+ regenerateSnapshotIdentity,
19
+ scanSnapshot,
20
+ sessionSnapshotPathFromAbsolute,
21
+ snapshotIncludesSessions,
22
+ snapshotWithoutSessions,
23
+ } from "./snapshot.js";
24
+ import { applySnapshot } from "./snapshot-apply.js";
25
+ import { encodeSnapshot } from "./snapshot-codec.js";
26
+ import {
27
+ expectedRemoteHead,
28
+ type PublishSnapshotResult,
29
+ type RemoteHead,
30
+ type SyncBackend,
31
+ } from "./sync-backend.js";
32
+ import {
33
+ countPreservedRemoteFiles,
34
+ errorMessage,
35
+ formatDiff,
36
+ formatPullSummary,
37
+ formatPushSummary,
38
+ formatRollbackSummary,
39
+ formatSnapshotOnlyDiff,
40
+ publicationCapabilityDescription,
41
+ safeTerminalText,
42
+ } from "./sync-format.js";
43
+ import {
44
+ canPullRemoteSessionsOnFirstSync,
45
+ canPullRemoteSettingsOnFirstSync,
46
+ fileHashMap,
47
+ hasLocalChanges,
48
+ hasRemoteChanges,
49
+ remoteChangedSinceState,
50
+ sameHashes,
51
+ shouldRefreshSyncedState,
52
+ snapshotHashesMatchState,
53
+ snapshotsMatch,
54
+ syncPolicyChanged,
55
+ } from "./sync-state.js";
56
+ import type {
57
+ AnySyncConfig,
58
+ CommandOptions,
59
+ Snapshot,
60
+ SnapshotOptions,
61
+ SyncState,
62
+ } from "./types.js";
63
+
64
+ const STATUS_KEY = "sync";
65
+ const VERSION = 1;
66
+ const DEFAULT_PROFILE = "default";
67
+ const POST_LOCAL_COMMIT_TIMEOUT_MS = 30_000;
68
+
69
+ export class PublicationStatePersistenceError extends Error {
70
+ readonly head: RemoteHead;
71
+ readonly backupPath?: string;
72
+
73
+ constructor(head: RemoteHead, cause: unknown, backupPath?: string) {
74
+ super(
75
+ `Remote publication ${head.snapshotId} is active, but local sync state could not be saved${backupPath ? `; local backup: ${backupPath}` : ""}: ${errorMessage(cause)}`,
76
+ { cause },
77
+ );
78
+ this.name = "PublicationStatePersistenceError";
79
+ this.head = head;
80
+ this.backupPath = backupPath;
81
+ }
82
+ }
83
+
84
+ export class RollbackPublicationError extends Error {
85
+ readonly backupPath: string;
86
+
87
+ constructor(backupPath: string, cause: unknown) {
88
+ super(
89
+ `Rollback applied locally with backup ${backupPath}, but remote publication failed: ${errorMessage(cause)}`,
90
+ { cause },
91
+ );
92
+ this.name = "RollbackPublicationError";
93
+ this.backupPath = backupPath;
94
+ }
95
+ }
96
+
97
+ interface PushInput {
98
+ config: AnySyncConfig;
99
+ state: SyncState;
100
+ local: Snapshot;
101
+ backend?: SyncBackend;
102
+ }
103
+
104
+ function backendFor(config: AnySyncConfig, factory: SyncBackendFactory) {
105
+ return factory(config);
106
+ }
107
+
108
+ export async function status(
109
+ ctx: ExtensionCommandContext,
110
+ options: CommandOptions,
111
+ factory: SyncBackendFactory = createSyncBackend,
112
+ ) {
113
+ const config = await loadConfig(options.setup);
114
+ throwIfAborted(options.signal);
115
+ ctx.ui.setStatus(STATUS_KEY, `checking ${config.setupName}`);
116
+ const backend = backendFor(config, factory);
117
+ const local = await createSnapshot(
118
+ config.snapshotIdentity,
119
+ snapshotOptionsForContext(ctx, config),
120
+ );
121
+ throwIfAborted(options.signal);
122
+ const state = await readStateForConfig(config);
123
+ throwIfAborted(options.signal);
124
+ const head = await backend.readHead(options.signal);
125
+ throwIfAborted(options.signal);
126
+ const localChanged = hasLocalChanges(local, state, config);
127
+
128
+ const remoteText = head
129
+ ? `remote: ${head.snapshotId} from ${head.machine} at ${head.createdAt}`
130
+ : "remote: empty";
131
+ const remoteChanged = remoteChangedSinceState(head, state, config, (left, right) =>
132
+ backend.sameRevision(left, right),
133
+ );
134
+ const warnings = syncSessionsWarnings(config);
135
+ ctx.ui.setStatus(STATUS_KEY, undefined);
136
+ ctx.ui.notify(
137
+ [
138
+ `sync setup: ${config.setupName}`,
139
+ `storage connection: ${config.connectionName}`,
140
+ `storage location: ${safeTerminalText(backend.destination)}`,
141
+ `publication safety: ${publicationCapabilityDescription(backend.capability)}`,
142
+ `included content: ${config.include.join(", ") || "none"}`,
143
+ `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
144
+ remoteText,
145
+ `local files: ${local.files.length}`,
146
+ `local changed since last sync: ${localChanged ? "yes" : "no"}`,
147
+ `remote changed since last sync: ${remoteChanged ? "yes" : "no"}`,
148
+ ...warnings,
149
+ ].join("\n"),
150
+ localChanged || remoteChanged || warnings.length > 0 ? "warning" : "info",
151
+ );
152
+ }
153
+
154
+ export async function diff(
155
+ ctx: ExtensionCommandContext,
156
+ options: CommandOptions,
157
+ factory: SyncBackendFactory = createSyncBackend,
158
+ ) {
159
+ const config = await loadConfig(options.setup);
160
+ throwIfAborted(options.signal);
161
+ ctx.ui.setStatus(STATUS_KEY, `checking ${config.setupName}`);
162
+ const backend = backendFor(config, factory);
163
+ const local = await createSnapshot(
164
+ config.snapshotIdentity,
165
+ snapshotOptionsForContext(ctx, config),
166
+ );
167
+ throwIfAborted(options.signal);
168
+ const { snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
169
+ throwIfAborted(options.signal);
170
+ ctx.ui.setStatus(STATUS_KEY, undefined);
171
+
172
+ const warnings = syncSessionsWarnings(config);
173
+ const header = [
174
+ `sync setup: ${config.setupName}`,
175
+ `storage connection: ${config.connectionName}`,
176
+ `storage location: ${safeTerminalText(backend.destination)}`,
177
+ `included content: ${config.include.join(", ") || "none"}`,
178
+ `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
179
+ ...warnings,
180
+ ].join("\n");
181
+ const level = warnings.length > 0 ? "warning" : "info";
182
+ if (!remote) {
183
+ ctx.ui.notify(
184
+ `${header}\n\n${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`,
185
+ level,
186
+ );
187
+ return;
188
+ }
189
+
190
+ ctx.ui.notify(`${header}\n\n${formatDiff(local, remote)}`, level);
191
+ }
192
+
193
+ export async function doctor(
194
+ ctx: ExtensionCommandContext,
195
+ options: CommandOptions,
196
+ factory: SyncBackendFactory = createSyncBackend,
197
+ ) {
198
+ const messages: string[] = [];
199
+ let level: "info" | "warning" = "info";
200
+ let snapshotOptions: SnapshotOptions = {};
201
+ let profile = DEFAULT_PROFILE;
202
+ let backend: SyncBackend | undefined;
203
+ let backendSummary: string[] = [];
204
+
205
+ try {
206
+ const config = await loadConfig(options.setup);
207
+ throwIfAborted(options.signal);
208
+ backend = backendFor(config, factory);
209
+ profile = config.snapshotIdentity;
210
+ snapshotOptions = snapshotOptionsForContext(ctx, config);
211
+ messages.push(
212
+ `config: ok (sync setup ${config.setupName})`,
213
+ `included content: ${config.include.join(", ") || "none"}`,
214
+ `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
215
+ );
216
+ backendSummary = [
217
+ `storage location: ${safeTerminalText(backend.destination)}`,
218
+ `publication safety: ${publicationCapabilityDescription(backend.capability)}`,
219
+ ];
220
+ const warnings = syncSessionsWarnings(config);
221
+ if (warnings.length > 0) {
222
+ level = "warning";
223
+ messages.push(...warnings);
224
+ }
225
+ } catch (error) {
226
+ throwIfAborted(options.signal);
227
+ level = "warning";
228
+ messages.push(`config: ${errorMessage(error)}`);
229
+ }
230
+
231
+ const local = await createSnapshot(profile, snapshotOptions);
232
+ throwIfAborted(options.signal);
233
+ const secrets = scanSnapshot(local);
234
+ if (secrets.length > 0) {
235
+ level = "warning";
236
+ messages.push("secret scan: possible secrets found:");
237
+ messages.push(...secrets.map((secret) => `- ${secret}`));
238
+ } else {
239
+ messages.push(`secret scan: ok (${local.files.length} files checked)`);
240
+ }
241
+
242
+ const lock = await inspectLock();
243
+ throwIfAborted(options.signal);
244
+ if (lock.status === "valid" && isStaleLock(lock.lock)) {
245
+ level = "warning";
246
+ messages.push(
247
+ `lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running`,
248
+ );
249
+ } else if (lock.status === "valid") {
250
+ messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
251
+ } else if (lock.status === "unreadable") {
252
+ level = "warning";
253
+ messages.push(
254
+ "lock: unreadable; use /sync unlock --stale only after verifying no sync is running",
255
+ );
256
+ } else if (await isLockGuardHeld()) {
257
+ throwIfAborted(options.signal);
258
+ level = "warning";
259
+ messages.push("lock: guard active while metadata is missing or still being initialized");
260
+ } else {
261
+ messages.push("lock: free");
262
+ }
263
+
264
+ if (backend) {
265
+ messages.push(...backendSummary);
266
+ const diagnostics = await backend.diagnose(options.signal);
267
+ throwIfAborted(options.signal);
268
+ for (const diagnostic of diagnostics) {
269
+ messages.push(diagnostic.message);
270
+ if (diagnostic.level !== "info") level = "warning";
271
+ }
272
+ }
273
+ ctx.ui.notify(messages.join("\n"), level);
274
+ }
275
+
276
+ export async function push(
277
+ ctx: ExtensionCommandContext | ExtensionContext,
278
+ options: CommandOptions,
279
+ input?: PushInput,
280
+ factory: SyncBackendFactory = createSyncBackend,
281
+ ) {
282
+ const config = input?.config ?? (await loadConfig(options.setup));
283
+ throwIfAborted(options.signal);
284
+ ctx.ui.setStatus(STATUS_KEY, `pushing ${config.setupName}`);
285
+ const backend = input?.backend ?? backendFor(config, factory);
286
+ const state = input?.state ?? (await readStateForConfig(config));
287
+ throwIfAborted(options.signal);
288
+ const local =
289
+ input?.local ??
290
+ (await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config)));
291
+ throwIfAborted(options.signal);
292
+
293
+ let head = await backend.readHead(options.signal);
294
+ let remoteForUpload = await readRemoteSnapshotForUpload(
295
+ backend,
296
+ config,
297
+ head,
298
+ state,
299
+ options.signal,
300
+ );
301
+ if (
302
+ remoteChangedSinceState(head, state, config, (left, right) =>
303
+ backend.sameRevision(left, right),
304
+ ) &&
305
+ !options.force
306
+ ) {
307
+ const remoteForConflict = remoteForUpload
308
+ ? filterSnapshotForConfigPolicy(remoteForUpload, config)
309
+ : undefined;
310
+ if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
311
+ throw new Error(
312
+ "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
313
+ );
314
+ }
315
+ }
316
+
317
+ let upload = await snapshotForUpload(
318
+ backend,
319
+ config,
320
+ local,
321
+ head,
322
+ remoteForUpload,
323
+ options.signal,
324
+ );
325
+ const secrets = scanSnapshot(local);
326
+ if (secrets.length > 0) {
327
+ throw new Error(
328
+ `Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`,
329
+ );
330
+ }
331
+
332
+ if (!(await confirmPush(ctx, options, config, backend, local, upload, head, remoteForUpload))) {
333
+ return;
334
+ }
335
+
336
+ if (options.force) {
337
+ const refreshedHead = await backend.readHead(options.signal);
338
+ if (!sameRemoteHead(backend, head, refreshedHead)) {
339
+ head = refreshedHead;
340
+ remoteForUpload = head
341
+ ? await backend.readSnapshot(head.snapshotRef, options.signal)
342
+ : undefined;
343
+ upload = await snapshotForUpload(
344
+ backend,
345
+ config,
346
+ local,
347
+ head,
348
+ remoteForUpload,
349
+ options.signal,
350
+ );
351
+ if (
352
+ !(await confirmPush(
353
+ ctx,
354
+ options,
355
+ config,
356
+ backend,
357
+ local,
358
+ upload,
359
+ head,
360
+ remoteForUpload,
361
+ "Remote changed during review. Push the refreshed plan?",
362
+ ))
363
+ ) {
364
+ return;
365
+ }
366
+ }
367
+ }
368
+
369
+ const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
370
+ signal: options.signal,
371
+ onCommit: options.onCommit,
372
+ });
373
+ try {
374
+ await writeStateForConfig(config, {
375
+ version: VERSION,
376
+ profile: config.snapshotIdentity,
377
+ lastAppliedSnapshot: result.head.snapshotId,
378
+ lastRemoteRevision: result.head.revision,
379
+ lastFileHashes: fileHashMap(local),
380
+ include: [...config.include],
381
+ });
382
+ } catch (error) {
383
+ throw new PublicationStatePersistenceError(result.head, error);
384
+ }
385
+ if (options.signal?.aborted) return;
386
+ ctx.ui.setStatus(STATUS_KEY, undefined);
387
+ if (!options.silent) {
388
+ ctx.ui.notify(
389
+ [
390
+ `Pushed ${upload.files.length} files from sync setup “${config.setupName}” as ${result.head.snapshotId}.`,
391
+ ...result.warnings,
392
+ ]
393
+ .filter(Boolean)
394
+ .join("\n"),
395
+ result.warnings.length > 0 ? "warning" : "info",
396
+ );
397
+ }
398
+ }
399
+
400
+ export async function pull(
401
+ ctx: ExtensionCommandContext | ExtensionContext,
402
+ options: CommandOptions,
403
+ factory: SyncBackendFactory = createSyncBackend,
404
+ ) {
405
+ const config = await loadConfig(options.setup);
406
+ throwIfAborted(options.signal);
407
+ ctx.ui.setStatus(STATUS_KEY, `pulling ${config.setupName}`);
408
+ const backend = backendFor(config, factory);
409
+ const state = await readStateForConfig(config);
410
+ throwIfAborted(options.signal);
411
+ const local = await createSnapshot(
412
+ config.snapshotIdentity,
413
+ snapshotOptionsForContext(ctx, config),
414
+ );
415
+ throwIfAborted(options.signal);
416
+ const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
417
+ throwIfAborted(options.signal);
418
+ if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
419
+
420
+ const localChanged = hasLocalChanges(local, state, config);
421
+ const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
422
+ if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
423
+ throw new Error(
424
+ "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
425
+ );
426
+ }
427
+
428
+ if (
429
+ !options.yes &&
430
+ !(await ctx.ui.confirm(
431
+ snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
432
+ formatPullSummary(
433
+ config,
434
+ backend.destination,
435
+ local,
436
+ remote,
437
+ protectedSessionPaths(ctx).size,
438
+ ),
439
+ ))
440
+ ) {
441
+ ctx.ui.setStatus(STATUS_KEY, undefined);
442
+ ctx.ui.notify("Pull cancelled.", "info");
443
+ return "cancelled" as const;
444
+ }
445
+
446
+ throwIfAborted(options.signal);
447
+ const backup = await backupLocal(
448
+ config.snapshotIdentity,
449
+ snapshotOptionsForContext(ctx, config),
450
+ options.signal,
451
+ );
452
+ const applySessionDir = await sessionDirForApply(ctx, remote);
453
+ throwIfAborted(options.signal);
454
+ options.onCommit?.();
455
+ const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
456
+ include: config.include,
457
+ sessionDir: applySessionDir,
458
+ });
459
+ await writeStateForConfig(config, {
460
+ version: VERSION,
461
+ profile: config.snapshotIdentity,
462
+ lastAppliedSnapshot: remote.id,
463
+ lastRemoteRevision: head?.revision,
464
+ lastFileHashes,
465
+ include: [...config.include],
466
+ });
467
+ if (options.signal?.aborted) return "applied" as const;
468
+ ctx.ui.setStatus(STATUS_KEY, undefined);
469
+ if (!options.silent) {
470
+ ctx.ui.notify(
471
+ `Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`,
472
+ "info",
473
+ );
474
+ } else if (
475
+ options.auto &&
476
+ config.include.includes("sessions") &&
477
+ snapshotIncludesSessions(remote)
478
+ ) {
479
+ ctx.ui.notify(
480
+ "Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
481
+ "warning",
482
+ );
483
+ }
484
+ if (options.reload) await maybeReload(ctx, options.signal);
485
+ return "applied" as const;
486
+ }
487
+
488
+ export async function syncBoth(
489
+ ctx: ExtensionCommandContext | ExtensionContext,
490
+ options: CommandOptions,
491
+ factory: SyncBackendFactory = createSyncBackend,
492
+ ) {
493
+ const config = await loadConfig(options.setup);
494
+ throwIfAborted(options.signal);
495
+ const backend = backendFor(config, factory);
496
+ const state = await readStateForConfig(config);
497
+ throwIfAborted(options.signal);
498
+ const local = await createSnapshot(
499
+ config.snapshotIdentity,
500
+ snapshotOptionsForContext(ctx, config),
501
+ );
502
+ throwIfAborted(options.signal);
503
+ if (config.include.length === 0) {
504
+ if (!options.silent) {
505
+ ctx.ui.notify(
506
+ `Sync setup “${config.setupName}” includes no files. Choose included content in /sync Settings before syncing.`,
507
+ "warning",
508
+ );
509
+ }
510
+ return;
511
+ }
512
+ const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
513
+ throwIfAborted(options.signal);
514
+ const localChanged = hasLocalChanges(local, state, config);
515
+ const remoteChanged = remote
516
+ ? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx))
517
+ : false;
518
+ const firstSync = !state.lastAppliedSnapshot;
519
+
520
+ if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
521
+ if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
522
+ throw new Error(
523
+ "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
524
+ );
525
+ }
526
+ if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
527
+ if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
528
+ throw new Error(
529
+ "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
530
+ );
531
+ }
532
+ await pull(ctx, options, factory);
533
+ return;
534
+ }
535
+ await writeStateForConfig(config, {
536
+ version: VERSION,
537
+ profile: config.snapshotIdentity,
538
+ lastAppliedSnapshot: remote.id,
539
+ lastRemoteRevision: head?.revision,
540
+ lastFileHashes: fileHashMap(remote),
541
+ include: [...config.include],
542
+ });
543
+ if (!options.silent)
544
+ ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
545
+ return;
546
+ }
547
+ if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
548
+ await writeStateForConfig(config, {
549
+ version: VERSION,
550
+ profile: config.snapshotIdentity,
551
+ lastAppliedSnapshot: remote.id,
552
+ lastRemoteRevision: head?.revision,
553
+ lastFileHashes: fileHashMap(remote),
554
+ include: [...config.include],
555
+ });
556
+ if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
557
+ return;
558
+ }
559
+ if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
560
+ throw new Error(
561
+ "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
562
+ );
563
+ }
564
+ if (remoteChanged) {
565
+ await pull(ctx, options, factory);
566
+ return;
567
+ }
568
+ if (localChanged || !remote) {
569
+ await push(ctx, options, undefined, factory);
570
+ return;
571
+ }
572
+ if (
573
+ shouldRefreshSyncedState(remote, head, state, config, (left, right) =>
574
+ backend.sameRevision(left, right),
575
+ )
576
+ ) {
577
+ await writeStateForConfig(config, {
578
+ version: VERSION,
579
+ profile: config.snapshotIdentity,
580
+ lastAppliedSnapshot: remote.id,
581
+ lastRemoteRevision: head?.revision,
582
+ lastFileHashes: fileHashMap(remote),
583
+ include: [...config.include],
584
+ });
585
+ }
586
+ if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
587
+ }
588
+
589
+ export async function history(
590
+ ctx: ExtensionCommandContext,
591
+ options: CommandOptions,
592
+ factory: SyncBackendFactory = createSyncBackend,
593
+ ) {
594
+ const config = await loadConfig(options.setup);
595
+ throwIfAborted(options.signal);
596
+ const backend = backendFor(config, factory);
597
+ const snapshots = (await backend.listHistory(options.signal)).slice(-20).reverse();
598
+ throwIfAborted(options.signal);
599
+ if (snapshots.length === 0) {
600
+ ctx.ui.notify("No remote pi-sync history found.", "info");
601
+ return;
602
+ }
603
+
604
+ const currentSnapshot = snapshots[0]?.snapshotId;
605
+ if (ctx.mode === "tui") {
606
+ const labels = snapshots.map(
607
+ (item, index) =>
608
+ `${index + 1}. ${item.createdAt} · ${safeTerminalText(item.machine)} · ${item.snapshotId}${item.snapshotId === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " · sessions" : ""}`,
609
+ );
610
+ const selected = await ctx.ui.select(
611
+ `History for sync setup “${safeTerminalText(config.setupName)}”\n\nChoose a snapshot to preview rollback.`,
612
+ [...labels, "Back"],
613
+ );
614
+ if (!selected || selected === "Back") return;
615
+ throwIfAborted(options.signal);
616
+ const index = labels.indexOf(selected);
617
+ const snapshot = snapshots[index];
618
+ if (!snapshot) return;
619
+ await withLock("rollback", () =>
620
+ rollback(ctx, { ...options, args: [snapshot.snapshotRef], yes: false }, factory, {
621
+ backendIdentity: backend.identity,
622
+ setup: config.setupName,
623
+ }),
624
+ );
625
+ return;
626
+ }
627
+ ctx.ui.notify(
628
+ snapshots
629
+ .map((item) => `${item.snapshotRef} ${item.createdAt} ${safeTerminalText(item.machine)}`)
630
+ .join("\n"),
631
+ "info",
632
+ );
633
+ }
634
+
635
+ export async function rollback(
636
+ ctx: ExtensionCommandContext,
637
+ options: CommandOptions,
638
+ factory: SyncBackendFactory = createSyncBackend,
639
+ expectedSelection?: { backendIdentity: string; setup?: string },
640
+ ) {
641
+ const target = options.args[0];
642
+ if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
643
+
644
+ const config = await loadConfig(options.setup);
645
+ throwIfAborted(options.signal);
646
+ const backend = backendFor(config, factory);
647
+ if (
648
+ expectedSelection &&
649
+ (backend.identity !== expectedSelection.backendIdentity ||
650
+ config.setupName !== expectedSelection.setup)
651
+ ) {
652
+ throw new Error(
653
+ "Sync setup or storage location changed while history was open; reopen history and retry.",
654
+ );
655
+ }
656
+ const decoded = await backend.readSnapshot(target, options.signal);
657
+ const selected = filterSnapshotForConfigPolicy(
658
+ config.include.includes("sessions") ? decoded : snapshotWithoutSessions(decoded),
659
+ config,
660
+ );
661
+ const remote = regenerateSnapshotIdentity(selected);
662
+ const local = await createSnapshot(
663
+ config.snapshotIdentity,
664
+ snapshotOptionsForContext(ctx, config),
665
+ );
666
+ const expectedHead = await backend.readHead(options.signal);
667
+ throwIfAborted(options.signal);
668
+
669
+ if (
670
+ !options.yes &&
671
+ !(await ctx.ui.confirm(
672
+ snapshotIncludesSessions(remote)
673
+ ? "Rollback pi settings and sessions?"
674
+ : "Rollback pi settings?",
675
+ formatRollbackSummary(
676
+ config,
677
+ backend.destination,
678
+ local,
679
+ remote,
680
+ target,
681
+ protectedSessionPaths(ctx).size,
682
+ ),
683
+ ))
684
+ ) {
685
+ ctx.ui.notify("Rollback cancelled.", "info");
686
+ return;
687
+ }
688
+
689
+ throwIfAborted(options.signal);
690
+ const backup = await backupLocal(
691
+ config.snapshotIdentity,
692
+ snapshotOptionsForContext(ctx, config),
693
+ options.signal,
694
+ );
695
+ const applySessionDir = await sessionDirForApply(ctx, remote);
696
+ throwIfAborted(options.signal);
697
+ options.onCommit?.();
698
+ const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
699
+ include: config.include,
700
+ sessionDir: applySessionDir,
701
+ });
702
+ let result: PublishSnapshotResult;
703
+ try {
704
+ const completionSignal = AbortSignal.timeout(POST_LOCAL_COMMIT_TIMEOUT_MS);
705
+ const upload = await snapshotForUpload(
706
+ backend,
707
+ config,
708
+ remote,
709
+ expectedHead,
710
+ undefined,
711
+ completionSignal,
712
+ { ignoreUnreadableRemote: true },
713
+ );
714
+ result = await backend.publishSnapshot(upload, expectedRemoteHead(expectedHead), {
715
+ signal: completionSignal,
716
+ });
717
+ } catch (error) {
718
+ throw new RollbackPublicationError(backup, error);
719
+ }
720
+ try {
721
+ await writeStateForConfig(config, {
722
+ version: VERSION,
723
+ profile: config.snapshotIdentity,
724
+ lastAppliedSnapshot: result.head.snapshotId,
725
+ lastRemoteRevision: result.head.revision,
726
+ lastFileHashes,
727
+ include: [...config.include],
728
+ });
729
+ } catch (error) {
730
+ throw new PublicationStatePersistenceError(result.head, error, backup);
731
+ }
732
+ if (options.signal?.aborted) return;
733
+ ctx.ui.notify(
734
+ [
735
+ `Rolled back sync setup “${config.setupName}” to ${target}; latest: ${result.head.snapshotId}. Backup: ${backup}`,
736
+ ...result.warnings,
737
+ ]
738
+ .filter(Boolean)
739
+ .join("\n"),
740
+ result.warnings.length > 0 ? "warning" : "info",
741
+ );
742
+ await maybeReload(ctx, options.signal);
743
+ }
744
+
745
+ function protectedSessionPaths(ctx: ExtensionCommandContext | ExtensionContext) {
746
+ const getSessionFile = ctx.sessionManager.getSessionFile;
747
+ if (typeof getSessionFile !== "function") return new Set<string>();
748
+ const sessionFile = getSessionFile.call(ctx.sessionManager) as string | undefined;
749
+ const snapshotPath = sessionFile
750
+ ? sessionSnapshotPathFromAbsolute(sessionFile, sessionDirFromContext(ctx))
751
+ : undefined;
752
+ return snapshotPath ? new Set([snapshotPath]) : new Set<string>();
753
+ }
754
+
755
+ function snapshotOptionsForContext(
756
+ ctx: ExtensionCommandContext | ExtensionContext,
757
+ config: AnySyncConfig,
758
+ ): SnapshotOptions {
759
+ return {
760
+ include: config.include,
761
+ sessionDir: sessionDirFromContext(ctx),
762
+ };
763
+ }
764
+
765
+ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
766
+ const manager = ctx.sessionManager as typeof ctx.sessionManager & {
767
+ usesDefaultSessionDir?: () => boolean;
768
+ };
769
+ const usesDefaultSessionDir = manager.usesDefaultSessionDir;
770
+ if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
771
+ return undefined;
772
+ }
773
+ const getSessionDir = manager.getSessionDir;
774
+ return typeof getSessionDir === "function"
775
+ ? (getSessionDir.call(manager) as string | undefined)
776
+ : undefined;
777
+ }
778
+
779
+ async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext, signal?: AbortSignal) {
780
+ if (signal?.aborted || !("reload" in ctx)) return;
781
+ if (
782
+ ctx.hasUI &&
783
+ (await ctx.ui.confirm(
784
+ "Reload Pi resources now?",
785
+ "This reloads extensions, skills, prompts, themes, and context files.",
786
+ ))
787
+ ) {
788
+ if (signal?.aborted) return;
789
+ await ctx.reload();
790
+ }
791
+ }
792
+
793
+ async function readRemoteSnapshotForUpload(
794
+ backend: SyncBackend,
795
+ config: AnySyncConfig,
796
+ head: RemoteHead | undefined,
797
+ state: SyncState,
798
+ signal?: AbortSignal,
799
+ ) {
800
+ if (
801
+ !head ||
802
+ (head.snapshotId === state.lastAppliedSnapshot &&
803
+ !syncPolicyChanged(state, config) &&
804
+ (!state.lastRemoteRevision || backend.sameRevision(head.revision, state.lastRemoteRevision)))
805
+ ) {
806
+ return undefined;
807
+ }
808
+ return backend.readSnapshot(head.snapshotRef, signal);
809
+ }
810
+
811
+ async function snapshotForUpload(
812
+ backend: SyncBackend,
813
+ config: AnySyncConfig,
814
+ local: Snapshot,
815
+ head: RemoteHead | undefined,
816
+ remote?: Snapshot,
817
+ signal?: AbortSignal,
818
+ options: { ignoreUnreadableRemote?: boolean } = {},
819
+ ) {
820
+ if (!head) return local;
821
+ let snapshot = remote;
822
+ if (!snapshot) {
823
+ try {
824
+ snapshot = await backend.readSnapshot(head.snapshotRef, signal);
825
+ } catch (error) {
826
+ if (options.ignoreUnreadableRemote) return local;
827
+ throw error;
828
+ }
829
+ }
830
+ return mergeRemotePreservedFiles(local, snapshot, config);
831
+ }
832
+
833
+ async function readRemoteSnapshot(
834
+ backend: SyncBackend,
835
+ config: AnySyncConfig,
836
+ signal?: AbortSignal,
837
+ ) {
838
+ const head = await backend.readHead(signal);
839
+ if (!head) return { head: undefined, snapshot: undefined };
840
+ const snapshot = await backend.readSnapshot(head.snapshotRef, signal);
841
+ if (snapshot.id !== head.snapshotId) {
842
+ throw new Error(
843
+ `Remote head ${head.snapshotId} resolved to unexpected snapshot ${snapshot.id}.`,
844
+ );
845
+ }
846
+ return { head, snapshot: filterSnapshotForConfigPolicy(snapshot, config) };
847
+ }
848
+
849
+ async function confirmPush(
850
+ ctx: ExtensionCommandContext | ExtensionContext,
851
+ options: CommandOptions,
852
+ config: AnySyncConfig,
853
+ backend: SyncBackend,
854
+ local: Snapshot,
855
+ upload: Snapshot,
856
+ head: RemoteHead | undefined,
857
+ remote: Snapshot | undefined,
858
+ title = snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
859
+ ) {
860
+ throwIfAborted(options.signal);
861
+ if (options.yes) return true;
862
+ const confirmed = await ctx.ui.confirm(
863
+ title,
864
+ formatPushSummary(
865
+ config,
866
+ backend.destination,
867
+ upload,
868
+ head,
869
+ countPreservedRemoteFiles(local, upload),
870
+ remote,
871
+ ),
872
+ );
873
+ throwIfAborted(options.signal);
874
+ if (confirmed) return true;
875
+ ctx.ui.setStatus(STATUS_KEY, undefined);
876
+ ctx.ui.notify("Push cancelled.", "info");
877
+ return false;
878
+ }
879
+
880
+ function throwIfAborted(signal?: AbortSignal) {
881
+ if (!signal?.aborted) return;
882
+ throw signal.reason instanceof Error
883
+ ? signal.reason
884
+ : new DOMException("The operation was aborted", "AbortError");
885
+ }
886
+
887
+ function sameRemoteHead(
888
+ backend: SyncBackend,
889
+ left: RemoteHead | undefined,
890
+ right: RemoteHead | undefined,
891
+ ) {
892
+ if (!left || !right) return left === right;
893
+ return backend.sameRevision(left.revision, right.revision);
894
+ }
895
+
896
+ export async function backupLocal(
897
+ profile: string,
898
+ options: SnapshotOptions = {},
899
+ signal?: AbortSignal,
900
+ ) {
901
+ throwIfAborted(signal);
902
+ const snapshot = await createSnapshot(profile, options);
903
+ throwIfAborted(signal);
904
+ const backupDirectory = path.join(stateDir(), "backups");
905
+ await fs.mkdir(backupDirectory, { recursive: true });
906
+ throwIfAborted(signal);
907
+ const backupPath = path.join(backupDirectory, `${snapshot.id}.json.gz`);
908
+ const encoded = await encodeSnapshot(snapshot);
909
+ throwIfAborted(signal);
910
+ await fs.writeFile(backupPath, encoded, { signal });
911
+ return backupPath;
912
+ }