@narumitw/pi-sync 0.25.0 → 0.28.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,35 +1,37 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
- import os from "node:os";
5
4
  import path from "node:path";
6
- import { gunzip, gzip } from "node:zlib";
7
5
  import { promisify } from "node:util";
8
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import { completeSyncArguments, parseOptions, splitArgs, usage } from "./command.js";
6
+ import { gunzip, gzip } from "node:zlib";
7
+ import type {
8
+ ExtensionAPI,
9
+ ExtensionCommandContext,
10
+ ExtensionContext,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { completeSyncArguments, parseOptions, resolveSyncCommand, usage } from "./command.js";
10
13
  import {
11
14
  agentDir,
12
15
  ensureStateDir,
13
- extraFilePathsByLower,
16
+ isEnabled,
17
+ isExplicitlyEnabled,
14
18
  isMissingConfigError,
19
+ loadConfig,
15
20
  loadPartialConfig,
16
21
  localConfigPath,
22
+ localConfigTemplate,
17
23
  normalizeExtraFiles,
18
- stateDir,
19
- writeJson,
20
- configuredSessionDir,
21
- isCloudflareR2Endpoint,
22
- isEnabled,
23
- isExplicitlyEnabled,
24
- loadConfig,
24
+ normalizeSyncFiles,
25
25
  readState,
26
26
  sessionDirForApply,
27
27
  sessionTokenWarnings,
28
+ stateDir,
28
29
  syncSessionsWarnings,
30
+ writeLocalConfigObject,
29
31
  writeState,
30
32
  } from "./config.js";
33
+ import { showFileSelection } from "./file-selection.js";
31
34
  import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
32
- import { encodeKey, posixJoin, safeJoin, safeName, toPosix } from "./paths.js";
33
35
  import {
34
36
  historyKey,
35
37
  latestKey,
@@ -39,27 +41,28 @@ import {
39
41
  snapshotKey,
40
42
  } from "./s3-client.js";
41
43
  import {
42
- addTopLevelCaseVariantDeletes,
43
- appliedFileHashMap,
44
- applySnapshot,
45
- preflightSnapshotApply,
46
- protectSnapshotApplyPlan,
47
- } from "./snapshot-apply.js";
48
- import {
49
- collectFiles,
50
44
  createSnapshot,
51
- canonicalSnapshotPathForConfig,
52
45
  filterSnapshotForConfigPolicy,
53
- isConfiguredSnapshotPath,
54
- isDeniedPath,
55
- isSessionPath,
56
- sessionSnapshotPathFromAbsolute,
57
46
  mergeRemotePreservedFiles,
58
- mergeRemoteSessionFiles,
59
47
  scanSnapshot,
48
+ sessionSnapshotPathFromAbsolute,
60
49
  snapshotIncludesSessions,
61
50
  snapshotWithoutSessions,
62
51
  } from "./snapshot.js";
52
+ import { applySnapshot } from "./snapshot-apply.js";
53
+ import {
54
+ canPullRemoteSessionsOnFirstSync,
55
+ canPullRemoteSettingsOnFirstSync,
56
+ fileHashMap,
57
+ hasLocalChanges,
58
+ hasRemoteChanges,
59
+ remoteChangedSinceState,
60
+ sameHashes,
61
+ shouldRefreshSyncedState,
62
+ snapshotHashesMatchState,
63
+ snapshotsMatch,
64
+ syncPolicyChanged,
65
+ } from "./sync-state.js";
63
66
  import type {
64
67
  CommandOptions,
65
68
  LatestPointer,
@@ -73,12 +76,11 @@ import type {
73
76
  const gzipAsync = promisify(gzip);
74
77
  const gunzipAsync = promisify(gunzip);
75
78
 
76
- const STATUS_KEY = "pisync";
79
+ const STATUS_KEY = "sync";
77
80
  const VERSION = 1;
78
81
  const DEFAULT_PROFILE = "default";
79
82
  const DEFAULT_PREFIX = "pi-sync";
80
83
  const DEFAULT_REGION = "auto";
81
- const LOCK_STALE_MS = 30 * 60 * 1000;
82
84
 
83
85
  interface PushInput {
84
86
  config: SyncConfig;
@@ -97,7 +99,7 @@ const AUTO_SYNC_OPTIONS: CommandOptions = {
97
99
  args: [],
98
100
  };
99
101
  export default function sync(pi: ExtensionAPI) {
100
- pi.registerCommand("pisync", {
102
+ pi.registerCommand("sync", {
101
103
  description: "Sync Pi settings through Cloudflare R2 or S3-compatible storage",
102
104
  getArgumentCompletions: completeSyncArguments,
103
105
  handler: async (args, ctx) => {
@@ -111,17 +113,19 @@ export default function sync(pi: ExtensionAPI) {
111
113
  });
112
114
 
113
115
  pi.on("session_shutdown", async (event, ctx) => {
114
- const reason = typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
116
+ const reason =
117
+ typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
115
118
  if (reason !== "reload") await autoPushSessions(ctx);
116
119
  ctx.ui.setStatus(STATUS_KEY, undefined);
117
120
  });
118
121
  }
119
122
 
120
123
  async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
121
- const [subcommand = "status", ...rest] = splitArgs(rawArgs);
122
- const options = parseOptions(rest);
123
-
124
124
  try {
125
+ const command = await resolveSyncCommand(rawArgs, ctx);
126
+ if (!command) return;
127
+ const { subcommand, rest } = command;
128
+ const options = parseOptions(rest);
125
129
  await ensureStateDir();
126
130
 
127
131
  switch (subcommand) {
@@ -134,6 +138,9 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
134
138
  case "config":
135
139
  await showConfig(ctx);
136
140
  return;
141
+ case "files":
142
+ await showFileSelection(ctx);
143
+ return;
137
144
  case "status":
138
145
  await status(ctx);
139
146
  return;
@@ -162,7 +169,7 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
162
169
  await unlock(ctx, options);
163
170
  return;
164
171
  default:
165
- ctx.ui.notify(`Unknown /pisync command: ${subcommand}\n\n${usage()}`, "warning");
172
+ ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
166
173
  }
167
174
  } catch (error) {
168
175
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -215,20 +222,8 @@ async function initConfig(ctx: ExtensionCommandContext) {
215
222
  // Create below.
216
223
  }
217
224
 
218
- const sample = {
219
- endpoint: "https://<account-id>.r2.cloudflarestorage.com",
220
- bucket: "pi-sync",
221
- region: DEFAULT_REGION,
222
- accessKeyId: "<access-key-id>",
223
- secretAccessKey: "<secret-access-key>",
224
- profile: DEFAULT_PROFILE,
225
- prefix: DEFAULT_PREFIX,
226
- autoSync: true,
227
- syncSessions: false,
228
- extraFiles: [],
229
- };
230
- await writeJson(configPath, sample);
231
- ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /pisync doctor.`, "info");
225
+ await writeLocalConfigObject(localConfigTemplate());
226
+ ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /sync doctor.`, "info");
232
227
  }
233
228
 
234
229
  async function showConfig(ctx: ExtensionCommandContext) {
@@ -247,6 +242,7 @@ async function showConfig(ctx: ExtensionCommandContext) {
247
242
  `profile: ${partial.profile ?? DEFAULT_PROFILE}`,
248
243
  `prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
249
244
  `autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
245
+ `syncFiles: ${normalizeSyncFiles(partial.syncFiles).join(", ") || "none"}`,
250
246
  `syncSessions: ${syncSessions ? "enabled" : "disabled"}`,
251
247
  `extraFiles: ${normalizeExtraFiles(partial.extraFiles).join(", ") || "none"}`,
252
248
  `local config: ${localConfigPath()}`,
@@ -266,7 +262,7 @@ async function status(ctx: ExtensionCommandContext) {
266
262
  const localChanged = hasLocalChanges(local, state, config);
267
263
 
268
264
  let remoteText = "remote: empty";
269
- let remoteChanged = remoteChangedSinceState(latest, state, config);
265
+ const remoteChanged = remoteChangedSinceState(latest, state, config);
270
266
  if (!latest.missing && latest.value) {
271
267
  remoteText = `remote: ${latest.value.snapshot} from ${latest.value.machine} at ${latest.value.createdAt}`;
272
268
  }
@@ -276,6 +272,8 @@ async function status(ctx: ExtensionCommandContext) {
276
272
  ctx.ui.notify(
277
273
  [
278
274
  `profile: ${config.profile}`,
275
+ `sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
276
+ `extra files: ${config.extraFiles.join(", ") || "none"}`,
279
277
  `sessions: ${config.syncSessions ? "included" : "excluded"}`,
280
278
  remoteText,
281
279
  `local files: ${local.files.length}`,
@@ -296,9 +294,12 @@ async function diff(ctx: ExtensionCommandContext) {
296
294
  ctx.ui.setStatus(STATUS_KEY, undefined);
297
295
 
298
296
  const warnings = syncSessionsWarnings(config);
299
- const header = [`sessions: ${config.syncSessions ? "included" : "excluded"}`, ...warnings].join(
300
- "\n",
301
- );
297
+ const header = [
298
+ `sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
299
+ `extra files: ${config.extraFiles.join(", ") || "none"}`,
300
+ `sessions: ${config.syncSessions ? "included" : "excluded"}`,
301
+ ...warnings,
302
+ ].join("\n");
302
303
  const level = warnings.length > 0 ? "warning" : "info";
303
304
  if (!remote) {
304
305
  ctx.ui.notify(
@@ -322,6 +323,8 @@ async function doctor(ctx: ExtensionCommandContext) {
322
323
  profile = config.profile;
323
324
  snapshotOptions = snapshotOptionsForContext(ctx, config);
324
325
  messages.push(`config: ok (${config.bucket}/${profilePrefix(config)})`);
326
+ messages.push(`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`);
327
+ messages.push(`extra files: ${config.extraFiles.join(", ") || "none"}`);
325
328
  messages.push(`sessions: ${config.syncSessions ? "included" : "excluded"}`);
326
329
  const warnings = [...sessionTokenWarnings(config), ...syncSessionsWarnings(config)];
327
330
  if (warnings.length > 0) {
@@ -347,14 +350,14 @@ async function doctor(ctx: ExtensionCommandContext) {
347
350
  if (lock.status === "valid" && isStaleLock(lock.lock)) {
348
351
  level = "warning";
349
352
  messages.push(
350
- `lock: stale (pid ${lock.lock.pid}); run /pisync unlock after verifying no sync is running`,
353
+ `lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running`,
351
354
  );
352
355
  } else if (lock.status === "valid") {
353
356
  messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
354
357
  } else if (lock.status === "unreadable") {
355
358
  level = "warning";
356
359
  messages.push(
357
- "lock: unreadable; use /pisync unlock --stale only after verifying no sync is running",
360
+ "lock: unreadable; use /sync unlock --stale only after verifying no sync is running",
358
361
  );
359
362
  } else if (await isLockGuardHeld()) {
360
363
  level = "warning";
@@ -374,29 +377,28 @@ async function push(
374
377
  const config = input?.config ?? (await loadConfig());
375
378
  const client = input?.client ?? new S3Client(config);
376
379
  const state = input?.state ?? (await readState(config.profile));
377
- const local = input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
380
+ const local =
381
+ input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
378
382
 
379
383
  const latest = await client.getJson<LatestPointer>(latestKey(config));
380
- const remoteForUpload = options.force
381
- ? undefined
382
- : await readRemoteSnapshotForUpload(client, config, latest, state);
384
+ const remoteForUpload = await readRemoteSnapshotForUpload(client, config, latest, state);
383
385
  if (remoteChangedSinceState(latest, state, config) && !options.force) {
384
386
  const remoteForConflict = remoteForUpload
385
387
  ? filterSnapshotForConfigPolicy(remoteForUpload, config)
386
388
  : undefined;
387
389
  if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
388
390
  throw new Error(
389
- "Remote or sync policy changed since last sync. Run /pisync pull first or /pisync push --force.",
391
+ "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
390
392
  );
391
393
  }
392
394
  }
393
395
 
394
- const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload, {
395
- preserveRemote: !options.force,
396
- });
396
+ const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload);
397
397
  const secrets = scanSnapshot(local);
398
398
  if (secrets.length > 0) {
399
- throw new Error(`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`);
399
+ throw new Error(
400
+ `Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`,
401
+ );
400
402
  }
401
403
 
402
404
  const preservedRemoteFileCount = countPreservedRemoteFiles(local, upload);
@@ -420,6 +422,7 @@ async function push(
420
422
  lastAppliedSnapshot: pointer.snapshot,
421
423
  lastRemoteEtag: undefined,
422
424
  lastFileHashes: fileHashMap(local),
425
+ syncFiles: config.syncFiles,
423
426
  syncSessions: config.syncSessions,
424
427
  extraFiles: config.extraFiles,
425
428
  });
@@ -436,12 +439,14 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
436
439
  const state = await readState(config.profile);
437
440
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
438
441
  const remote = await readRemoteSnapshot(client, config);
439
- if (!remote) throw new Error("Remote is empty. Run /pisync push from a configured machine first.");
442
+ if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
440
443
 
441
444
  const localChanged = hasLocalChanges(local, state, config);
442
445
  const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
443
446
  if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
444
- throw new Error("Both local and remote changed since last sync. Run /pisync diff, then choose /pisync pull --force or /pisync push --force.");
447
+ throw new Error(
448
+ "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
449
+ );
445
450
  }
446
451
 
447
452
  if (
@@ -459,6 +464,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
459
464
  const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
460
465
  const applySessionDir = await sessionDirForApply(ctx, remote);
461
466
  const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
467
+ syncFiles: config.syncFiles,
462
468
  sessionDir: applySessionDir,
463
469
  extraFiles: config.extraFiles,
464
470
  });
@@ -468,12 +474,16 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
468
474
  lastAppliedSnapshot: remote.id,
469
475
  lastRemoteEtag: undefined,
470
476
  lastFileHashes,
477
+ syncFiles: config.syncFiles,
471
478
  syncSessions: config.syncSessions,
472
479
  extraFiles: config.extraFiles,
473
480
  });
474
481
  ctx.ui.setStatus(STATUS_KEY, undefined);
475
482
  if (!options.silent) {
476
- ctx.ui.notify(`Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`, "info");
483
+ ctx.ui.notify(
484
+ `Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`,
485
+ "info",
486
+ );
477
487
  } else if (options.auto && config.syncSessions && snapshotIncludesSessions(remote)) {
478
488
  ctx.ui.notify(
479
489
  "Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
@@ -497,11 +507,15 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
497
507
 
498
508
  if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
499
509
  if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
500
- throw new Error("Remote settings exist and this machine has different local Pi settings. Run /pisync diff, then manually choose /pisync pull or /pisync push.");
510
+ throw new Error(
511
+ "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
512
+ );
501
513
  }
502
514
  if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
503
515
  if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
504
- throw new Error("Remote settings match, but local and remote Pi sessions differ. Run /pisync diff, then manually choose /pisync pull or /pisync push.");
516
+ throw new Error(
517
+ "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
518
+ );
505
519
  }
506
520
  await pull(ctx, options);
507
521
  return;
@@ -512,10 +526,12 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
512
526
  lastAppliedSnapshot: remote.id,
513
527
  lastRemoteEtag: undefined,
514
528
  lastFileHashes: fileHashMap(remote),
529
+ syncFiles: config.syncFiles,
515
530
  syncSessions: config.syncSessions,
516
531
  extraFiles: config.extraFiles,
517
532
  });
518
- if (!options.silent) ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
533
+ if (!options.silent)
534
+ ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
519
535
  return;
520
536
  }
521
537
  if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
@@ -525,6 +541,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
525
541
  lastAppliedSnapshot: remote.id,
526
542
  lastRemoteEtag: undefined,
527
543
  lastFileHashes: fileHashMap(remote),
544
+ syncFiles: config.syncFiles,
528
545
  syncSessions: config.syncSessions,
529
546
  extraFiles: config.extraFiles,
530
547
  });
@@ -532,7 +549,9 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
532
549
  return;
533
550
  }
534
551
  if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
535
- throw new Error("Both local and remote changed. Run /pisync diff and resolve with push --force or pull --force.");
552
+ throw new Error(
553
+ "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
554
+ );
536
555
  }
537
556
  if (remoteChanged) {
538
557
  await pull(ctx, options);
@@ -549,6 +568,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
549
568
  lastAppliedSnapshot: remote.id,
550
569
  lastRemoteEtag: undefined,
551
570
  lastFileHashes: fileHashMap(remote),
571
+ syncFiles: config.syncFiles,
552
572
  syncSessions: config.syncSessions,
553
573
  extraFiles: config.extraFiles,
554
574
  });
@@ -577,7 +597,7 @@ async function history(ctx: ExtensionCommandContext) {
577
597
 
578
598
  async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
579
599
  const target = options.args[0];
580
- if (!target) throw new Error("Usage: /pisync rollback <snapshot-id> [--yes]");
600
+ if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
581
601
 
582
602
  const config = await loadConfig();
583
603
  const client = new S3Client(config);
@@ -606,6 +626,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
606
626
  const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
607
627
  const applySessionDir = await sessionDirForApply(ctx, remote);
608
628
  const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
629
+ syncFiles: config.syncFiles,
609
630
  sessionDir: applySessionDir,
610
631
  extraFiles: config.extraFiles,
611
632
  });
@@ -626,6 +647,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
626
647
  lastAppliedSnapshot: pointer.snapshot,
627
648
  lastRemoteEtag: undefined,
628
649
  lastFileHashes,
650
+ syncFiles: config.syncFiles,
629
651
  syncSessions: config.syncSessions,
630
652
  extraFiles: config.extraFiles,
631
653
  });
@@ -648,6 +670,7 @@ function snapshotOptionsForContext(
648
670
  config: SyncConfig,
649
671
  ): SnapshotOptions {
650
672
  return {
673
+ syncFiles: config.syncFiles,
651
674
  syncSessions: config.syncSessions,
652
675
  sessionDir: sessionDirFromContext(ctx),
653
676
  extraFiles: config.extraFiles,
@@ -670,7 +693,13 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
670
693
 
671
694
  async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext) {
672
695
  if (!("reload" in ctx)) return;
673
- if (ctx.hasUI && (await ctx.ui.confirm("Reload Pi resources now?", "This reloads extensions, skills, prompts, themes, and context files."))) {
696
+ if (
697
+ ctx.hasUI &&
698
+ (await ctx.ui.confirm(
699
+ "Reload Pi resources now?",
700
+ "This reloads extensions, skills, prompts, themes, and context files.",
701
+ ))
702
+ ) {
674
703
  await ctx.reload();
675
704
  }
676
705
  }
@@ -697,9 +726,9 @@ async function snapshotForUpload(
697
726
  local: Snapshot,
698
727
  latest: RemoteObject<LatestPointer>,
699
728
  remote?: Snapshot,
700
- options: { preserveRemote?: boolean; ignoreUnreadableRemote?: boolean } = {},
729
+ options: { ignoreUnreadableRemote?: boolean } = {},
701
730
  ) {
702
- if (options.preserveRemote === false || latest.missing || !latest.value) return local;
731
+ if (latest.missing || !latest.value) return local;
703
732
  let snapshot = remote;
704
733
  if (!snapshot) {
705
734
  try {
@@ -724,12 +753,14 @@ async function uploadSnapshot(
724
753
  await client.putBuffer(snapshotKey(config, snapshot.id), encoded, "application/gzip");
725
754
  const current = await client.getJson<LatestPointer>(latestKey(config));
726
755
  if (!force && remoteIdentity(current) !== remoteIdentity(latest)) {
727
- throw new Error("Remote changed while pushing. Run /pisync pull first, then retry.");
756
+ throw new Error("Remote changed while pushing. Run /sync pull first, then retry.");
728
757
  }
729
758
  await client.putJson(latestKey(config), pointer);
730
759
  const verified = await client.getJson<LatestPointer>(latestKey(config));
731
760
  if (verified.value?.snapshot !== pointer.snapshot) {
732
- throw new Error("Remote latest changed immediately after push. Run /pisync status before continuing.");
761
+ throw new Error(
762
+ "Remote latest changed immediately after push. Run /sync status before continuing.",
763
+ );
733
764
  }
734
765
  return pointer;
735
766
  }
@@ -743,8 +774,10 @@ async function readRemoteSnapshotRaw(client: S3Client, config: SyncConfig) {
743
774
  const latest = await client.getJson<LatestPointer>(latestKey(config));
744
775
  if (latest.missing || !latest.value) return undefined;
745
776
  const object = await client.getBuffer(snapshotKey(config, latest.value.snapshot));
746
- if (!object.value) throw new Error(`Remote latest points to missing snapshot: ${latest.value.snapshot}`);
747
- if (sha256(object.value) !== latest.value.sha256) throw new Error("Remote snapshot checksum mismatch.");
777
+ if (!object.value)
778
+ throw new Error(`Remote latest points to missing snapshot: ${latest.value.snapshot}`);
779
+ if (sha256(object.value) !== latest.value.sha256)
780
+ throw new Error("Remote snapshot checksum mismatch.");
748
781
  return decodeSnapshot(object.value);
749
782
  }
750
783
 
@@ -755,7 +788,8 @@ async function encodeSnapshot(snapshot: Snapshot) {
755
788
  async function decodeSnapshot(buffer: Buffer): Promise<Snapshot> {
756
789
  const decoded = await gunzipAsync(buffer);
757
790
  const parsed = JSON.parse(decoded.toString("utf8")) as Snapshot;
758
- if (parsed.version !== VERSION || !Array.isArray(parsed.files)) throw new Error("Unsupported snapshot format.");
791
+ if (parsed.version !== VERSION || !Array.isArray(parsed.files))
792
+ throw new Error("Unsupported snapshot format.");
759
793
  return parsed;
760
794
  }
761
795
 
@@ -769,7 +803,9 @@ export async function backupLocal(profile: string, options: SnapshotOptions = {}
769
803
  }
770
804
 
771
805
  async function updateHistory(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
772
- const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(historyKey(config));
806
+ const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
807
+ historyKey(config),
808
+ );
773
809
  const snapshots = object.value?.snapshots ?? [];
774
810
  const next = [
775
811
  ...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot),
@@ -782,7 +818,11 @@ function formatDiff(local: Snapshot, remote: Snapshot) {
782
818
  const localMap = fileHashMap(local);
783
819
  const remoteMap = fileHashMap(remote);
784
820
  const allPaths = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
785
- const lines = [`local: ${local.files.length} files`, `remote: ${remote.id} (${remote.files.length} files)`, ""];
821
+ const lines = [
822
+ `local: ${local.files.length} files`,
823
+ `remote: ${remote.id} (${remote.files.length} files)`,
824
+ "",
825
+ ];
786
826
  let changed = 0;
787
827
  for (const filePath of allPaths) {
788
828
  if (!localMap[filePath]) {
@@ -818,145 +858,15 @@ function formatPushSummary(
818
858
  ].join("\n");
819
859
  }
820
860
 
821
- function hasLocalChanges(local: Snapshot, state: SyncState, config: SyncConfig) {
822
- return !sameHashes(fileHashMap(local), stateHashMapForConfig(state, config));
823
- }
824
-
825
- function remoteChangedSinceState(
826
- latest: RemoteObject<LatestPointer>,
827
- state: SyncState,
828
- config: SyncConfig,
829
- ) {
830
- if (latest.missing) return Boolean(state.lastAppliedSnapshot);
831
- if (latest.value?.snapshot !== state.lastAppliedSnapshot) return true;
832
- if (extraFilesChanged(state, config)) return true;
833
- return config.syncSessions && state.syncSessions !== true && latest.value?.syncSessions === true;
834
- }
835
-
836
- export function hasRemoteChanges(
837
- remote: Snapshot,
838
- state: SyncState,
839
- config: SyncConfig,
840
- ignoredPaths = new Set<string>(),
841
- ) {
842
- if (remote.id === state.lastAppliedSnapshot && !syncPolicyChanged(state, config)) return false;
843
- return !snapshotHashesMatchState(filterSnapshotForConfigPolicy(remote, config), state, config, ignoredPaths);
844
- }
845
-
846
861
  function remoteIdentity(remote: RemoteObject<LatestPointer>) {
847
862
  return remote.missing ? "missing" : (remote.value?.snapshot ?? "unknown");
848
863
  }
849
864
 
850
- function sameHashes(left: Record<string, string>, right: Record<string, string>) {
851
- const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
852
- for (const key of keys) {
853
- if (left[key] !== right[key]) return false;
854
- }
855
- return true;
856
- }
857
-
858
- function fileHashMap(snapshot: Snapshot) {
859
- return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
860
- }
861
-
862
- function stateHashMapForConfig(state: SyncState, config: Pick<SyncConfig, "syncSessions" | "extraFiles">) {
863
- const extraFilePaths = extraFilePathsByLower(config.extraFiles);
864
- const extraFiles = new Set(extraFilePaths.keys());
865
- return Object.fromEntries(
866
- Object.entries(state.lastFileHashes)
867
- .filter(([filePath]) => isConfiguredSnapshotPath(filePath, config, extraFiles))
868
- .map(([filePath, hash]) => [canonicalSnapshotPathForConfig(filePath, extraFilePaths), hash]),
869
- );
870
- }
871
-
872
- function snapshotHashesMatchState(
873
- snapshot: Snapshot,
874
- state: SyncState,
875
- config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
876
- ignoredPaths = new Set<string>(),
877
- ) {
878
- return sameHashes(
879
- withoutHashPaths(fileHashMap(snapshot), ignoredPaths),
880
- withoutHashPaths(stateHashMapForConfig(state, config), ignoredPaths),
881
- );
882
- }
883
-
884
- function snapshotsMatch(left: Snapshot, right: Snapshot) {
885
- return left.syncSessions === right.syncSessions && sameHashes(fileHashMap(left), fileHashMap(right));
886
- }
887
-
888
- function withoutHashPaths(hashes: Record<string, string>, ignoredPaths: Set<string>) {
889
- if (ignoredPaths.size === 0) return hashes;
890
- return Object.fromEntries(
891
- Object.entries(hashes).filter(([filePath]) => !ignoredPaths.has(toPosix(filePath))),
892
- );
893
- }
894
-
895
- function syncPolicyChanged(state: SyncState, config: Pick<SyncConfig, "syncSessions" | "extraFiles">) {
896
- return (state.syncSessions ?? false) !== config.syncSessions || extraFilesChanged(state, config);
897
- }
898
-
899
- function shouldRefreshSyncedState(
900
- remote: Snapshot,
901
- state: SyncState,
902
- config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
903
- ) {
904
- return remote.id !== state.lastAppliedSnapshot || syncPolicyChanged(state, config);
905
- }
906
-
907
- function extraFilesChanged(state: SyncState, config: Pick<SyncConfig, "extraFiles">) {
908
- return !sameStringSet(normalizeExtraFiles(state.extraFiles), normalizeExtraFiles(config.extraFiles));
909
- }
910
-
911
- function sameStringSet(left: string[], right: string[]) {
912
- const leftSet = new Set(left);
913
- const rightSet = new Set(right);
914
- if (leftSet.size !== rightSet.size) return false;
915
- return [...leftSet].every((item) => rightSet.has(item));
916
- }
917
-
918
865
  function countPreservedRemoteFiles(local: Snapshot, upload: Snapshot) {
919
866
  const localPaths = new Set(local.files.map((file) => file.path));
920
867
  return upload.files.filter((file) => !localPaths.has(file.path)).length;
921
868
  }
922
869
 
923
- export function settingsHashMap(snapshot: Snapshot) {
924
- return Object.fromEntries(
925
- snapshot.files
926
- .filter((file) => !isSessionPath(file.path))
927
- .map((file) => [file.path, file.sha256]),
928
- );
929
- }
930
-
931
- export function sessionHashMap(snapshot: Snapshot) {
932
- return Object.fromEntries(
933
- snapshot.files.filter((file) => isSessionPath(file.path)).map((file) => [file.path, file.sha256]),
934
- );
935
- }
936
-
937
- export function settingsHashMapFromState(state: SyncState) {
938
- return Object.fromEntries(
939
- Object.entries(state.lastFileHashes).filter(([filePath]) => !isSessionPath(filePath)),
940
- );
941
- }
942
-
943
- export function settingsHashesMatchState(remote: Snapshot, state: SyncState) {
944
- return sameHashes(settingsHashMap(remote), settingsHashMapFromState(state));
945
- }
946
-
947
- export function canPullRemoteSettingsOnFirstSync(local: Snapshot, remote: Snapshot) {
948
- const remoteSettings = settingsHashMap(remote);
949
- return Object.entries(settingsHashMap(local)).every(
950
- ([filePath, hash]) => remoteSettings[filePath] === hash,
951
- );
952
- }
953
-
954
- export function canPullRemoteSessionsOnFirstSync(local: Snapshot, remote: Snapshot) {
955
- const localSessions = sessionHashMap(local);
956
- const remoteSessions = sessionHashMap(remote);
957
- return Object.entries(localSessions).every(([filePath, hash]) => remoteSessions[filePath] === hash);
958
- }
959
-
960
870
  function sha256(value: Buffer) {
961
871
  return createHash("sha256").update(value).digest("hex");
962
872
  }
@@ -970,24 +880,39 @@ function errorMessage(error: unknown) {
970
880
  }
971
881
 
972
882
  export { completeSyncArguments, parseOptions, splitArgs } from "./command.js";
973
- export { isCloudflareR2Endpoint, isEnabled, isExplicitlyEnabled, loadConfig, sessionTokenWarnings } from "./config.js";
974
- export { encodeKey, posixJoin, safeJoin, safeName } from "./paths.js";
975
883
  export {
976
- addTopLevelCaseVariantDeletes,
977
- appliedFileHashMap,
978
- preflightSnapshotApply,
979
- protectSnapshotApplyPlan,
980
- } from "./snapshot-apply.js";
884
+ isCloudflareR2Endpoint,
885
+ isEnabled,
886
+ isExplicitlyEnabled,
887
+ loadConfig,
888
+ sessionTokenWarnings,
889
+ } from "./config.js";
890
+ export { encodeKey, posixJoin, safeJoin, safeName } from "./paths.js";
981
891
  export {
982
- collectFiles,
983
892
  canonicalSnapshotPathForConfig,
893
+ collectFiles,
984
894
  filterSnapshotForConfigPolicy,
985
895
  isConfiguredSnapshotPath,
986
896
  isDeniedPath,
987
897
  isSessionPath,
988
- sessionSnapshotPathFromAbsolute,
989
898
  mergeRemotePreservedFiles,
990
899
  mergeRemoteSessionFiles,
991
900
  scanSnapshot,
901
+ sessionSnapshotPathFromAbsolute,
992
902
  snapshotWithoutSessions,
993
903
  } from "./snapshot.js";
904
+ export {
905
+ addTopLevelCaseVariantDeletes,
906
+ appliedFileHashMap,
907
+ preflightSnapshotApply,
908
+ protectSnapshotApplyPlan,
909
+ } from "./snapshot-apply.js";
910
+ export {
911
+ canPullRemoteSessionsOnFirstSync,
912
+ canPullRemoteSettingsOnFirstSync,
913
+ hasRemoteChanges,
914
+ sessionHashMap,
915
+ settingsHashesMatchState,
916
+ settingsHashMap,
917
+ settingsHashMapFromState,
918
+ } from "./sync-state.js";