@narumitw/pi-sync 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sync.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import { constants as fsConstants } from "node:fs";
3
2
  import fs from "node:fs/promises";
4
3
  import path from "node:path";
@@ -9,9 +8,17 @@ import type {
9
8
  ExtensionCommandContext,
10
9
  ExtensionContext,
11
10
  } from "@earendil-works/pi-coding-agent";
12
- import { completeSyncArguments, parseOptions, resolveSyncCommand, usage } from "./command.js";
13
11
  import {
14
- agentDir,
12
+ completeSyncArguments,
13
+ parseOptions,
14
+ resolveSyncCommand,
15
+ setSyncTargetCompletions,
16
+ usage,
17
+ validateCommandOptions,
18
+ } from "./command.js";
19
+ import {
20
+ configuredTargetNames,
21
+ deprecatedPiSyncEnvironmentWarnings,
15
22
  ensureStateDir,
16
23
  isEnabled,
17
24
  isExplicitlyEnabled,
@@ -22,16 +29,17 @@ import {
22
29
  localConfigTemplate,
23
30
  normalizeExtraFiles,
24
31
  normalizeSyncFiles,
25
- readState,
32
+ readStateForConfig,
26
33
  sessionDirForApply,
27
34
  sessionTokenWarnings,
28
35
  stateDir,
29
36
  syncSessionsWarnings,
30
37
  writeLocalConfigObject,
31
- writeState,
38
+ writeStateForConfig,
32
39
  } from "./config.js";
33
40
  import { showFileSelection } from "./file-selection.js";
34
41
  import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
42
+ import { showSetupWizard, showSyncManager, useSyncTarget } from "./manager-ui.js";
35
43
  import {
36
44
  historyKey,
37
45
  latestKey,
@@ -50,6 +58,21 @@ import {
50
58
  snapshotWithoutSessions,
51
59
  } from "./snapshot.js";
52
60
  import { applySnapshot } from "./snapshot-apply.js";
61
+ import { recoverPendingSnapshotTransactions } from "./snapshot-transaction.js";
62
+ import {
63
+ countPreservedRemoteFiles,
64
+ errorMessage,
65
+ formatDestination,
66
+ formatDiff,
67
+ formatPullSummary,
68
+ formatPushSummary,
69
+ formatRollbackSummary,
70
+ formatSnapshotOnlyDiff,
71
+ redact,
72
+ remoteIdentity,
73
+ safeTerminalText,
74
+ sha256,
75
+ } from "./sync-format.js";
53
76
  import {
54
77
  canPullRemoteSessionsOnFirstSync,
55
78
  canPullRemoteSettingsOnFirstSync,
@@ -109,6 +132,19 @@ export default function sync(pi: ExtensionAPI) {
109
132
 
110
133
  pi.on("session_start", async (_event, ctx) => {
111
134
  ctx.ui.setStatus(STATUS_KEY, undefined);
135
+ try {
136
+ await recoverPendingSnapshotTransactions();
137
+ } catch (error) {
138
+ ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
139
+ return;
140
+ }
141
+ try {
142
+ setSyncTargetCompletions(await configuredTargetNames());
143
+ } catch {
144
+ setSyncTargetCompletions([]);
145
+ }
146
+ const warnings = deprecatedPiSyncEnvironmentWarnings();
147
+ if (warnings.length > 0) ctx.ui.notify(warnings.join("\n"), "warning");
112
148
  await autoSync(ctx);
113
149
  });
114
150
 
@@ -121,34 +157,59 @@ export default function sync(pi: ExtensionAPI) {
121
157
  }
122
158
 
123
159
  async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
160
+ if (!rawArgs.trim()) {
161
+ try {
162
+ await showSyncManager(ctx, (route, signal, onCommit) =>
163
+ executeCommand(route, ctx, signal, onCommit),
164
+ );
165
+ } catch (error) {
166
+ ctx.ui.setStatus(STATUS_KEY, undefined);
167
+ ctx.ui.notify(errorMessage(error), "error");
168
+ }
169
+ return;
170
+ }
171
+ await executeCommand(rawArgs, ctx);
172
+ }
173
+
174
+ async function executeCommand(
175
+ rawArgs: string,
176
+ ctx: ExtensionCommandContext,
177
+ signal?: AbortSignal,
178
+ onCommit?: () => void,
179
+ ) {
124
180
  try {
125
181
  const command = await resolveSyncCommand(rawArgs, ctx);
126
182
  if (!command) return;
127
183
  const { subcommand, rest } = command;
128
184
  const options = parseOptions(rest);
129
- await ensureStateDir();
185
+ if (signal) options.signal = signal;
186
+ if (onCommit) options.onCommit = onCommit;
187
+ validateCommandOptions(subcommand, options);
130
188
 
131
189
  switch (subcommand) {
132
190
  case "help":
133
191
  ctx.ui.notify(usage(), "info");
134
192
  return;
193
+ case "use":
194
+ await useSyncTarget(ctx, options.args[0] ?? "");
195
+ return;
135
196
  case "init":
136
197
  await initConfig(ctx);
137
198
  return;
138
199
  case "config":
139
- await showConfig(ctx);
200
+ await showConfig(ctx, options);
140
201
  return;
141
202
  case "files":
142
- await showFileSelection(ctx);
203
+ await showFileSelection(ctx, options.target);
143
204
  return;
144
205
  case "status":
145
- await status(ctx);
206
+ await status(ctx, options);
146
207
  return;
147
208
  case "diff":
148
- await diff(ctx);
209
+ await diff(ctx, options);
149
210
  return;
150
211
  case "doctor":
151
- await doctor(ctx);
212
+ await doctor(ctx, options);
152
213
  return;
153
214
  case "push":
154
215
  await withLock("push", () => push(ctx, options));
@@ -160,7 +221,7 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
160
221
  await withLock("sync", () => syncBoth(ctx, options));
161
222
  return;
162
223
  case "history":
163
- await history(ctx);
224
+ await history(ctx, options);
164
225
  return;
165
226
  case "rollback":
166
227
  await withLock("rollback", () => rollback(ctx, options));
@@ -173,6 +234,7 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
173
234
  }
174
235
  } catch (error) {
175
236
  ctx.ui.setStatus(STATUS_KEY, undefined);
237
+ if (signal?.aborted && error instanceof Error && error.name === "AbortError") return;
176
238
  ctx.ui.notify(errorMessage(error), "error");
177
239
  }
178
240
  }
@@ -200,7 +262,7 @@ async function autoPushSessions(ctx: ExtensionContext) {
200
262
  const config = await loadConfig();
201
263
  if (!config.syncSessions) return;
202
264
  await withLock("auto-session-push", async () => {
203
- const state = await readState(config.profile);
265
+ const state = await readStateForConfig(config);
204
266
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
205
267
  if (!hasLocalChanges(local, state, config)) return;
206
268
  await push(ctx, AUTO_SYNC_OPTIONS, { config, state, local });
@@ -219,20 +281,30 @@ async function initConfig(ctx: ExtensionCommandContext) {
219
281
  ctx.ui.notify(`Config already exists: ${configPath}`, "info");
220
282
  return;
221
283
  } catch {
222
- // Create below.
284
+ // Create or guide setup below.
223
285
  }
224
286
 
287
+ if (ctx.mode === "tui") {
288
+ await showSetupWizard(ctx);
289
+ return;
290
+ }
225
291
  await writeLocalConfigObject(localConfigTemplate());
226
292
  ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /sync doctor.`, "info");
227
293
  }
228
294
 
229
- async function showConfig(ctx: ExtensionCommandContext) {
230
- const partial = await loadPartialConfig();
295
+ async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
296
+ const partial = await loadPartialConfig(options.target);
231
297
  const syncSessions = isExplicitlyEnabled(partial.syncSessions);
232
- const warnings = [...sessionTokenWarnings(partial), ...syncSessionsWarnings({ syncSessions })];
298
+ const warnings = [
299
+ ...deprecatedPiSyncEnvironmentWarnings(),
300
+ ...sessionTokenWarnings(partial),
301
+ ...syncSessionsWarnings({ syncSessions }),
302
+ ];
233
303
  ctx.ui.notify(
234
304
  [
235
305
  "pi-sync config:",
306
+ `target: ${partial.target ?? "default"}`,
307
+ `storage profile: ${partial.storageProfile ?? "default"}`,
236
308
  `endpoint: ${partial.endpoint ?? "missing"}`,
237
309
  `bucket: ${partial.bucket ?? "missing"}`,
238
310
  `region: ${partial.region ?? DEFAULT_REGION}`,
@@ -252,12 +324,12 @@ async function showConfig(ctx: ExtensionCommandContext) {
252
324
  );
253
325
  }
254
326
 
255
- async function status(ctx: ExtensionCommandContext) {
256
- ctx.ui.setStatus(STATUS_KEY, "checking");
257
- const config = await loadConfig();
258
- const client = new S3Client(config);
327
+ async function status(ctx: ExtensionCommandContext, options: CommandOptions) {
328
+ const config = await loadConfig(options.target);
329
+ ctx.ui.setStatus(STATUS_KEY, `checking ${config.target ?? "default"}`);
330
+ const client = new S3Client(config, options.signal);
259
331
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
260
- const state = await readState(config.profile);
332
+ const state = await readStateForConfig(config);
261
333
  const latest = await client.getJson<LatestPointer>(latestKey(config));
262
334
  const localChanged = hasLocalChanges(local, state, config);
263
335
 
@@ -267,11 +339,14 @@ async function status(ctx: ExtensionCommandContext) {
267
339
  remoteText = `remote: ${latest.value.snapshot} from ${latest.value.machine} at ${latest.value.createdAt}`;
268
340
  }
269
341
 
270
- const warnings = syncSessionsWarnings(config);
342
+ const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
271
343
  ctx.ui.setStatus(STATUS_KEY, undefined);
272
344
  ctx.ui.notify(
273
345
  [
274
- `profile: ${config.profile}`,
346
+ `target: ${config.target ?? "default"}`,
347
+ `storage profile: ${config.storageProfile ?? "default"}`,
348
+ `destination: ${formatDestination(config)}`,
349
+ `remote namespace: ${config.profile}`,
275
350
  `sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
276
351
  `extra files: ${config.extraFiles.join(", ") || "none"}`,
277
352
  `sessions: ${config.syncSessions ? "included" : "excluded"}`,
@@ -285,16 +360,19 @@ async function status(ctx: ExtensionCommandContext) {
285
360
  );
286
361
  }
287
362
 
288
- async function diff(ctx: ExtensionCommandContext) {
289
- ctx.ui.setStatus(STATUS_KEY, "diff");
290
- const config = await loadConfig();
291
- const client = new S3Client(config);
363
+ async function diff(ctx: ExtensionCommandContext, options: CommandOptions) {
364
+ const config = await loadConfig(options.target);
365
+ ctx.ui.setStatus(STATUS_KEY, `checking ${config.target ?? "default"}`);
366
+ const client = new S3Client(config, options.signal);
292
367
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
293
368
  const remote = await readRemoteSnapshot(client, config);
294
369
  ctx.ui.setStatus(STATUS_KEY, undefined);
295
370
 
296
- const warnings = syncSessionsWarnings(config);
371
+ const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
297
372
  const header = [
373
+ `target: ${config.target ?? "default"}`,
374
+ `storage profile: ${config.storageProfile ?? "default"}`,
375
+ `destination: ${formatDestination(config)}`,
298
376
  `sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
299
377
  `extra files: ${config.extraFiles.join(", ") || "none"}`,
300
378
  `sessions: ${config.syncSessions ? "included" : "excluded"}`,
@@ -312,21 +390,27 @@ async function diff(ctx: ExtensionCommandContext) {
312
390
  ctx.ui.notify(`${header}\n\n${formatDiff(local, remote)}`, level);
313
391
  }
314
392
 
315
- async function doctor(ctx: ExtensionCommandContext) {
393
+ async function doctor(ctx: ExtensionCommandContext, options: CommandOptions) {
316
394
  const messages: string[] = [];
317
395
  let level: "info" | "warning" = "info";
318
396
  let snapshotOptions: SnapshotOptions = {};
319
397
  let profile = DEFAULT_PROFILE;
320
398
 
321
399
  try {
322
- const config = await loadConfig();
400
+ const config = await loadConfig(options.target);
323
401
  profile = config.profile;
324
402
  snapshotOptions = snapshotOptionsForContext(ctx, config);
325
- messages.push(`config: ok (${config.bucket}/${profilePrefix(config)})`);
403
+ messages.push(
404
+ `config: ok (target ${config.target ?? "default"}; ${config.bucket}/${profilePrefix(config)})`,
405
+ );
326
406
  messages.push(`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`);
327
407
  messages.push(`extra files: ${config.extraFiles.join(", ") || "none"}`);
328
408
  messages.push(`sessions: ${config.syncSessions ? "included" : "excluded"}`);
329
- const warnings = [...sessionTokenWarnings(config), ...syncSessionsWarnings(config)];
409
+ const warnings = [
410
+ ...deprecatedPiSyncEnvironmentWarnings(),
411
+ ...sessionTokenWarnings(config),
412
+ ...syncSessionsWarnings(config),
413
+ ];
330
414
  if (warnings.length > 0) {
331
415
  level = "warning";
332
416
  messages.push(...warnings);
@@ -373,10 +457,10 @@ async function push(
373
457
  options: CommandOptions,
374
458
  input?: PushInput,
375
459
  ) {
376
- ctx.ui.setStatus(STATUS_KEY, "pushing");
377
- const config = input?.config ?? (await loadConfig());
378
- const client = input?.client ?? new S3Client(config);
379
- const state = input?.state ?? (await readState(config.profile));
460
+ const config = input?.config ?? (await loadConfig(options.target));
461
+ ctx.ui.setStatus(STATUS_KEY, `pushing ${config.target ?? "default"}`);
462
+ const client = input?.client ?? new S3Client(config, options.signal);
463
+ const state = input?.state ?? (await readStateForConfig(config));
380
464
  const local =
381
465
  input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
382
466
 
@@ -406,7 +490,7 @@ async function push(
406
490
  !options.yes &&
407
491
  !(await ctx.ui.confirm(
408
492
  snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
409
- formatPushSummary(upload, latest, preservedRemoteFileCount),
493
+ formatPushSummary(config, upload, latest, preservedRemoteFileCount, remoteForUpload),
410
494
  ))
411
495
  ) {
412
496
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -414,9 +498,11 @@ async function push(
414
498
  return;
415
499
  }
416
500
 
417
- const pointer = await uploadSnapshot(client, config, upload, latest, options.force);
418
- await updateHistory(client, config, pointer);
419
- await writeState(config.profile, {
501
+ options.onCommit?.();
502
+ const publicationClient = options.onCommit ? new S3Client(config) : client;
503
+ const pointer = await uploadSnapshot(publicationClient, config, upload, latest, options.force);
504
+ const historyWarning = await updateHistorySafely(publicationClient, config, pointer);
505
+ await writeStateForConfig(config, {
420
506
  version: VERSION,
421
507
  profile: config.profile,
422
508
  lastAppliedSnapshot: pointer.snapshot,
@@ -428,15 +514,23 @@ async function push(
428
514
  });
429
515
  ctx.ui.setStatus(STATUS_KEY, undefined);
430
516
  if (!options.silent) {
431
- ctx.ui.notify(`Pushed ${upload.files.length} files as ${pointer.snapshot}.`, "info");
517
+ ctx.ui.notify(
518
+ [
519
+ `Pushed ${upload.files.length} files from target “${config.target ?? "default"}” as ${pointer.snapshot}.`,
520
+ historyWarning,
521
+ ]
522
+ .filter(Boolean)
523
+ .join("\n"),
524
+ historyWarning ? "warning" : "info",
525
+ );
432
526
  }
433
527
  }
434
528
 
435
529
  async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
436
- ctx.ui.setStatus(STATUS_KEY, "pulling");
437
- const config = await loadConfig();
438
- const client = new S3Client(config);
439
- const state = await readState(config.profile);
530
+ const config = await loadConfig(options.target);
531
+ ctx.ui.setStatus(STATUS_KEY, `pulling ${config.target ?? "default"}`);
532
+ const client = new S3Client(config, options.signal);
533
+ const state = await readStateForConfig(config);
440
534
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
441
535
  const remote = await readRemoteSnapshot(client, config);
442
536
  if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
@@ -453,7 +547,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
453
547
  !options.yes &&
454
548
  !(await ctx.ui.confirm(
455
549
  snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
456
- formatDiff(local, remote),
550
+ formatPullSummary(config, local, remote, protectedSessionPaths(ctx).size),
457
551
  ))
458
552
  ) {
459
553
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -461,6 +555,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
461
555
  return;
462
556
  }
463
557
 
558
+ options.onCommit?.();
464
559
  const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
465
560
  const applySessionDir = await sessionDirForApply(ctx, remote);
466
561
  const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
@@ -468,7 +563,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
468
563
  sessionDir: applySessionDir,
469
564
  extraFiles: config.extraFiles,
470
565
  });
471
- await writeState(config.profile, {
566
+ await writeStateForConfig(config, {
472
567
  version: VERSION,
473
568
  profile: config.profile,
474
569
  lastAppliedSnapshot: remote.id,
@@ -494,10 +589,23 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
494
589
  }
495
590
 
496
591
  async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
497
- const config = await loadConfig();
498
- const client = new S3Client(config);
499
- const state = await readState(config.profile);
592
+ const config = await loadConfig(options.target);
593
+ const client = new S3Client(config, options.signal);
594
+ const state = await readStateForConfig(config);
500
595
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
596
+ if (
597
+ normalizeSyncFiles(config.syncFiles).length === 0 &&
598
+ config.extraFiles.length === 0 &&
599
+ !config.syncSessions
600
+ ) {
601
+ if (!options.silent) {
602
+ ctx.ui.notify(
603
+ `Target “${config.target ?? "default"}” manages no files. Choose synced content in /sync Settings before syncing.`,
604
+ "warning",
605
+ );
606
+ }
607
+ return;
608
+ }
501
609
  const remote = await readRemoteSnapshot(client, config);
502
610
  const localChanged = hasLocalChanges(local, state, config);
503
611
  const remoteChanged = remote
@@ -520,7 +628,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
520
628
  await pull(ctx, options);
521
629
  return;
522
630
  }
523
- await writeState(config.profile, {
631
+ await writeStateForConfig(config, {
524
632
  version: VERSION,
525
633
  profile: config.profile,
526
634
  lastAppliedSnapshot: remote.id,
@@ -535,7 +643,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
535
643
  return;
536
644
  }
537
645
  if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
538
- await writeState(config.profile, {
646
+ await writeStateForConfig(config, {
539
647
  version: VERSION,
540
648
  profile: config.profile,
541
649
  lastAppliedSnapshot: remote.id,
@@ -562,7 +670,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
562
670
  return;
563
671
  }
564
672
  if (shouldRefreshSyncedState(remote, state, config)) {
565
- await writeState(config.profile, {
673
+ await writeStateForConfig(config, {
566
674
  version: VERSION,
567
675
  profile: config.profile,
568
676
  lastAppliedSnapshot: remote.id,
@@ -576,20 +684,38 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
576
684
  if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
577
685
  }
578
686
 
579
- async function history(ctx: ExtensionCommandContext) {
580
- const config = await loadConfig();
581
- const client = new S3Client(config);
687
+ async function history(ctx: ExtensionCommandContext, options: CommandOptions) {
688
+ const config = await loadConfig(options.target);
689
+ const client = new S3Client(config, options.signal);
582
690
  const remote = await client.getJson<{ snapshots?: LatestPointer[] }>(historyKey(config));
583
691
  if (remote.missing || !remote.value?.snapshots?.length) {
584
692
  ctx.ui.notify("No remote pi-sync history found.", "info");
585
693
  return;
586
694
  }
587
695
 
696
+ const snapshots = remote.value.snapshots.slice(-20).reverse();
697
+ const currentSnapshot = snapshots[0]?.snapshot;
698
+ if (ctx.mode === "tui") {
699
+ const labels = snapshots.map(
700
+ (item, index) =>
701
+ `${index + 1}. ${item.createdAt} · ${safeTerminalText(item.machine)} · ${item.snapshot}${item.snapshot === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " · sessions" : ""}`,
702
+ );
703
+ const selected = await ctx.ui.select(
704
+ `History for target “${safeTerminalText(config.target ?? "default")}”\n\nChoose a snapshot to preview rollback.`,
705
+ [...labels, "Back"],
706
+ );
707
+ if (!selected || selected === "Back") return;
708
+ const index = labels.indexOf(selected);
709
+ const snapshot = snapshots[index];
710
+ if (!snapshot) return;
711
+ await withLock("rollback", () =>
712
+ rollback(ctx, { ...options, args: [snapshot.snapshot], yes: false }),
713
+ );
714
+ return;
715
+ }
588
716
  ctx.ui.notify(
589
- remote.value.snapshots
590
- .slice(-20)
591
- .reverse()
592
- .map((item) => `${item.snapshot} ${item.createdAt} ${item.machine}`)
717
+ snapshots
718
+ .map((item) => `${item.snapshot} ${item.createdAt} ${safeTerminalText(item.machine)}`)
593
719
  .join("\n"),
594
720
  "info",
595
721
  );
@@ -599,8 +725,8 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
599
725
  const target = options.args[0];
600
726
  if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
601
727
 
602
- const config = await loadConfig();
603
- const client = new S3Client(config);
728
+ const config = await loadConfig(options.target);
729
+ const client = new S3Client(config, options.signal);
604
730
  const snapshot = await client.getBuffer(snapshotKey(config, target));
605
731
  if (!snapshot.value) throw new Error(`Snapshot not found: ${target}`);
606
732
  const decoded = await decodeSnapshot(snapshot.value);
@@ -609,6 +735,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
609
735
  config,
610
736
  { regenerateId: true },
611
737
  );
738
+ const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
612
739
 
613
740
  if (
614
741
  !options.yes &&
@@ -616,13 +743,14 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
616
743
  snapshotIncludesSessions(remote)
617
744
  ? "Rollback pi settings and sessions?"
618
745
  : "Rollback pi settings?",
619
- formatSnapshotOnlyDiff("Rollback would apply", remote),
746
+ formatRollbackSummary(config, local, remote, target, protectedSessionPaths(ctx).size),
620
747
  ))
621
748
  ) {
622
749
  ctx.ui.notify("Rollback cancelled.", "info");
623
750
  return;
624
751
  }
625
752
 
753
+ options.onCommit?.();
626
754
  const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
627
755
  const applySessionDir = await sessionDirForApply(ctx, remote);
628
756
  const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
@@ -630,18 +758,19 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
630
758
  sessionDir: applySessionDir,
631
759
  extraFiles: config.extraFiles,
632
760
  });
633
- const latest = await client.getJson<LatestPointer>(latestKey(config));
634
- const upload = await snapshotForUpload(client, config, remote, latest, undefined, {
761
+ const publicationClient = options.onCommit ? new S3Client(config) : client;
762
+ const latest = await publicationClient.getJson<LatestPointer>(latestKey(config));
763
+ const upload = await snapshotForUpload(publicationClient, config, remote, latest, undefined, {
635
764
  ignoreUnreadableRemote: true,
636
765
  });
637
766
  const encoded = upload.id === decoded.id ? snapshot.value : await encodeSnapshot(upload);
638
767
  if (upload.id !== decoded.id) {
639
- await client.putBuffer(snapshotKey(config, upload.id), encoded, "application/gzip");
768
+ await publicationClient.putBuffer(snapshotKey(config, upload.id), encoded, "application/gzip");
640
769
  }
641
770
  const pointer = pointerFor(config, upload, sha256(encoded));
642
- await client.putJson(latestKey(config), pointer);
643
- await updateHistory(client, config, pointer);
644
- await writeState(config.profile, {
771
+ await publicationClient.putJson(latestKey(config), pointer);
772
+ const historyWarning = await updateHistorySafely(publicationClient, config, pointer);
773
+ await writeStateForConfig(config, {
645
774
  version: VERSION,
646
775
  profile: config.profile,
647
776
  lastAppliedSnapshot: pointer.snapshot,
@@ -651,7 +780,15 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
651
780
  syncSessions: config.syncSessions,
652
781
  extraFiles: config.extraFiles,
653
782
  });
654
- ctx.ui.notify(`Rolled back to ${target}; latest: ${pointer.snapshot}. Backup: ${backup}`, "info");
783
+ ctx.ui.notify(
784
+ [
785
+ `Rolled back target “${config.target ?? "default"}” to ${target}; latest: ${pointer.snapshot}. Backup: ${backup}`,
786
+ historyWarning,
787
+ ]
788
+ .filter(Boolean)
789
+ .join("\n"),
790
+ historyWarning ? "warning" : "info",
791
+ );
655
792
  await maybeReload(ctx);
656
793
  }
657
794
 
@@ -802,6 +939,15 @@ export async function backupLocal(profile: string, options: SnapshotOptions = {}
802
939
  return backupPath;
803
940
  }
804
941
 
942
+ async function updateHistorySafely(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
943
+ try {
944
+ await updateHistory(client, config, pointer);
945
+ return undefined;
946
+ } catch (error) {
947
+ return `Remote snapshot is active, but history could not be updated: ${errorMessage(error)}. Run /sync doctor before relying on history.`;
948
+ }
949
+ }
950
+
805
951
  async function updateHistory(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
806
952
  const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
807
953
  historyKey(config),
@@ -814,71 +960,6 @@ async function updateHistory(client: S3Client, config: SyncConfig, pointer: Late
814
960
  await client.putJson(historyKey(config), { version: VERSION, snapshots: next });
815
961
  }
816
962
 
817
- function formatDiff(local: Snapshot, remote: Snapshot) {
818
- const localMap = fileHashMap(local);
819
- const remoteMap = fileHashMap(remote);
820
- const allPaths = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
821
- const lines = [
822
- `local: ${local.files.length} files`,
823
- `remote: ${remote.id} (${remote.files.length} files)`,
824
- "",
825
- ];
826
- let changed = 0;
827
- for (const filePath of allPaths) {
828
- if (!localMap[filePath]) {
829
- lines.push(`+ ${filePath}`);
830
- changed += 1;
831
- } else if (!remoteMap[filePath]) {
832
- lines.push(`- ${filePath}`);
833
- changed += 1;
834
- } else if (localMap[filePath] !== remoteMap[filePath]) {
835
- lines.push(`~ ${filePath}`);
836
- changed += 1;
837
- }
838
- }
839
- if (changed === 0) lines.push("No file differences.");
840
- return lines.join("\n");
841
- }
842
-
843
- function formatSnapshotOnlyDiff(title: string, snapshot: Snapshot) {
844
- return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `+ ${file.path}`)].join("\n");
845
- }
846
-
847
- function formatPushSummary(
848
- local: Snapshot,
849
- latest: RemoteObject<LatestPointer>,
850
- preservedRemoteFileCount = 0,
851
- ) {
852
- return [
853
- `Upload ${local.files.length} files from ${agentDir()}.`,
854
- latest.value ? `Remote latest: ${latest.value.snapshot}` : "Remote latest: empty",
855
- preservedRemoteFileCount > 0
856
- ? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.`
857
- : "Possible secrets were scanned before this prompt.",
858
- ].join("\n");
859
- }
860
-
861
- function remoteIdentity(remote: RemoteObject<LatestPointer>) {
862
- return remote.missing ? "missing" : (remote.value?.snapshot ?? "unknown");
863
- }
864
-
865
- function countPreservedRemoteFiles(local: Snapshot, upload: Snapshot) {
866
- const localPaths = new Set(local.files.map((file) => file.path));
867
- return upload.files.filter((file) => !localPaths.has(file.path)).length;
868
- }
869
-
870
- function sha256(value: Buffer) {
871
- return createHash("sha256").update(value).digest("hex");
872
- }
873
-
874
- function redact(value: string) {
875
- return value.length <= 8 ? "configured" : `${value.slice(0, 4)}…${value.slice(-4)}`;
876
- }
877
-
878
- function errorMessage(error: unknown) {
879
- return error instanceof Error ? error.message : String(error);
880
- }
881
-
882
963
  export { completeSyncArguments, parseOptions, splitArgs } from "./command.js";
883
964
  export {
884
965
  isCloudflareR2Endpoint,