@narumitw/pi-sync 0.25.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,35 +1,45 @@
1
- import { createHash } from "node:crypto";
2
1
  import { constants as fsConstants } from "node:fs";
3
2
  import fs from "node:fs/promises";
4
- import os from "node:os";
5
3
  import path from "node:path";
6
- import { gunzip, gzip } from "node:zlib";
7
4
  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";
5
+ import { gunzip, gzip } from "node:zlib";
6
+ import type {
7
+ ExtensionAPI,
8
+ ExtensionCommandContext,
9
+ ExtensionContext,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ completeSyncArguments,
13
+ parseOptions,
14
+ resolveSyncCommand,
15
+ setSyncTargetCompletions,
16
+ usage,
17
+ validateCommandOptions,
18
+ } from "./command.js";
10
19
  import {
11
- agentDir,
20
+ configuredTargetNames,
21
+ deprecatedPiSyncEnvironmentWarnings,
12
22
  ensureStateDir,
13
- extraFilePathsByLower,
23
+ isEnabled,
24
+ isExplicitlyEnabled,
14
25
  isMissingConfigError,
26
+ loadConfig,
15
27
  loadPartialConfig,
16
28
  localConfigPath,
29
+ localConfigTemplate,
17
30
  normalizeExtraFiles,
18
- stateDir,
19
- writeJson,
20
- configuredSessionDir,
21
- isCloudflareR2Endpoint,
22
- isEnabled,
23
- isExplicitlyEnabled,
24
- loadConfig,
25
- readState,
31
+ normalizeSyncFiles,
32
+ readStateForConfig,
26
33
  sessionDirForApply,
27
34
  sessionTokenWarnings,
35
+ stateDir,
28
36
  syncSessionsWarnings,
29
- writeState,
37
+ writeLocalConfigObject,
38
+ writeStateForConfig,
30
39
  } from "./config.js";
40
+ import { showFileSelection } from "./file-selection.js";
31
41
  import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
32
- import { encodeKey, posixJoin, safeJoin, safeName, toPosix } from "./paths.js";
42
+ import { showSetupWizard, showSyncManager, useSyncTarget } from "./manager-ui.js";
33
43
  import {
34
44
  historyKey,
35
45
  latestKey,
@@ -39,27 +49,43 @@ import {
39
49
  snapshotKey,
40
50
  } from "./s3-client.js";
41
51
  import {
42
- addTopLevelCaseVariantDeletes,
43
- appliedFileHashMap,
44
- applySnapshot,
45
- preflightSnapshotApply,
46
- protectSnapshotApplyPlan,
47
- } from "./snapshot-apply.js";
48
- import {
49
- collectFiles,
50
52
  createSnapshot,
51
- canonicalSnapshotPathForConfig,
52
53
  filterSnapshotForConfigPolicy,
53
- isConfiguredSnapshotPath,
54
- isDeniedPath,
55
- isSessionPath,
56
- sessionSnapshotPathFromAbsolute,
57
54
  mergeRemotePreservedFiles,
58
- mergeRemoteSessionFiles,
59
55
  scanSnapshot,
56
+ sessionSnapshotPathFromAbsolute,
60
57
  snapshotIncludesSessions,
61
58
  snapshotWithoutSessions,
62
59
  } from "./snapshot.js";
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";
76
+ import {
77
+ canPullRemoteSessionsOnFirstSync,
78
+ canPullRemoteSettingsOnFirstSync,
79
+ fileHashMap,
80
+ hasLocalChanges,
81
+ hasRemoteChanges,
82
+ remoteChangedSinceState,
83
+ sameHashes,
84
+ shouldRefreshSyncedState,
85
+ snapshotHashesMatchState,
86
+ snapshotsMatch,
87
+ syncPolicyChanged,
88
+ } from "./sync-state.js";
63
89
  import type {
64
90
  CommandOptions,
65
91
  LatestPointer,
@@ -73,12 +99,11 @@ import type {
73
99
  const gzipAsync = promisify(gzip);
74
100
  const gunzipAsync = promisify(gunzip);
75
101
 
76
- const STATUS_KEY = "pisync";
102
+ const STATUS_KEY = "sync";
77
103
  const VERSION = 1;
78
104
  const DEFAULT_PROFILE = "default";
79
105
  const DEFAULT_PREFIX = "pi-sync";
80
106
  const DEFAULT_REGION = "auto";
81
- const LOCK_STALE_MS = 30 * 60 * 1000;
82
107
 
83
108
  interface PushInput {
84
109
  config: SyncConfig;
@@ -97,7 +122,7 @@ const AUTO_SYNC_OPTIONS: CommandOptions = {
97
122
  args: [],
98
123
  };
99
124
  export default function sync(pi: ExtensionAPI) {
100
- pi.registerCommand("pisync", {
125
+ pi.registerCommand("sync", {
101
126
  description: "Sync Pi settings through Cloudflare R2 or S3-compatible storage",
102
127
  getArgumentCompletions: completeSyncArguments,
103
128
  handler: async (args, ctx) => {
@@ -107,41 +132,84 @@ export default function sync(pi: ExtensionAPI) {
107
132
 
108
133
  pi.on("session_start", async (_event, ctx) => {
109
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");
110
148
  await autoSync(ctx);
111
149
  });
112
150
 
113
151
  pi.on("session_shutdown", async (event, ctx) => {
114
- const reason = typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
152
+ const reason =
153
+ typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
115
154
  if (reason !== "reload") await autoPushSessions(ctx);
116
155
  ctx.ui.setStatus(STATUS_KEY, undefined);
117
156
  });
118
157
  }
119
158
 
120
159
  async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
121
- const [subcommand = "status", ...rest] = splitArgs(rawArgs);
122
- const options = parseOptions(rest);
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
+ }
123
173
 
174
+ async function executeCommand(
175
+ rawArgs: string,
176
+ ctx: ExtensionCommandContext,
177
+ signal?: AbortSignal,
178
+ onCommit?: () => void,
179
+ ) {
124
180
  try {
125
- await ensureStateDir();
181
+ const command = await resolveSyncCommand(rawArgs, ctx);
182
+ if (!command) return;
183
+ const { subcommand, rest } = command;
184
+ const options = parseOptions(rest);
185
+ if (signal) options.signal = signal;
186
+ if (onCommit) options.onCommit = onCommit;
187
+ validateCommandOptions(subcommand, options);
126
188
 
127
189
  switch (subcommand) {
128
190
  case "help":
129
191
  ctx.ui.notify(usage(), "info");
130
192
  return;
193
+ case "use":
194
+ await useSyncTarget(ctx, options.args[0] ?? "");
195
+ return;
131
196
  case "init":
132
197
  await initConfig(ctx);
133
198
  return;
134
199
  case "config":
135
- await showConfig(ctx);
200
+ await showConfig(ctx, options);
201
+ return;
202
+ case "files":
203
+ await showFileSelection(ctx, options.target);
136
204
  return;
137
205
  case "status":
138
- await status(ctx);
206
+ await status(ctx, options);
139
207
  return;
140
208
  case "diff":
141
- await diff(ctx);
209
+ await diff(ctx, options);
142
210
  return;
143
211
  case "doctor":
144
- await doctor(ctx);
212
+ await doctor(ctx, options);
145
213
  return;
146
214
  case "push":
147
215
  await withLock("push", () => push(ctx, options));
@@ -153,7 +221,7 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
153
221
  await withLock("sync", () => syncBoth(ctx, options));
154
222
  return;
155
223
  case "history":
156
- await history(ctx);
224
+ await history(ctx, options);
157
225
  return;
158
226
  case "rollback":
159
227
  await withLock("rollback", () => rollback(ctx, options));
@@ -162,10 +230,11 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
162
230
  await unlock(ctx, options);
163
231
  return;
164
232
  default:
165
- ctx.ui.notify(`Unknown /pisync command: ${subcommand}\n\n${usage()}`, "warning");
233
+ ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
166
234
  }
167
235
  } catch (error) {
168
236
  ctx.ui.setStatus(STATUS_KEY, undefined);
237
+ if (signal?.aborted && error instanceof Error && error.name === "AbortError") return;
169
238
  ctx.ui.notify(errorMessage(error), "error");
170
239
  }
171
240
  }
@@ -193,7 +262,7 @@ async function autoPushSessions(ctx: ExtensionContext) {
193
262
  const config = await loadConfig();
194
263
  if (!config.syncSessions) return;
195
264
  await withLock("auto-session-push", async () => {
196
- const state = await readState(config.profile);
265
+ const state = await readStateForConfig(config);
197
266
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
198
267
  if (!hasLocalChanges(local, state, config)) return;
199
268
  await push(ctx, AUTO_SYNC_OPTIONS, { config, state, local });
@@ -212,32 +281,30 @@ async function initConfig(ctx: ExtensionCommandContext) {
212
281
  ctx.ui.notify(`Config already exists: ${configPath}`, "info");
213
282
  return;
214
283
  } catch {
215
- // Create below.
216
- }
217
-
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");
284
+ // Create or guide setup below.
285
+ }
286
+
287
+ if (ctx.mode === "tui") {
288
+ await showSetupWizard(ctx);
289
+ return;
290
+ }
291
+ await writeLocalConfigObject(localConfigTemplate());
292
+ ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /sync doctor.`, "info");
232
293
  }
233
294
 
234
- async function showConfig(ctx: ExtensionCommandContext) {
235
- const partial = await loadPartialConfig();
295
+ async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
296
+ const partial = await loadPartialConfig(options.target);
236
297
  const syncSessions = isExplicitlyEnabled(partial.syncSessions);
237
- const warnings = [...sessionTokenWarnings(partial), ...syncSessionsWarnings({ syncSessions })];
298
+ const warnings = [
299
+ ...deprecatedPiSyncEnvironmentWarnings(),
300
+ ...sessionTokenWarnings(partial),
301
+ ...syncSessionsWarnings({ syncSessions }),
302
+ ];
238
303
  ctx.ui.notify(
239
304
  [
240
305
  "pi-sync config:",
306
+ `target: ${partial.target ?? "default"}`,
307
+ `storage profile: ${partial.storageProfile ?? "default"}`,
241
308
  `endpoint: ${partial.endpoint ?? "missing"}`,
242
309
  `bucket: ${partial.bucket ?? "missing"}`,
243
310
  `region: ${partial.region ?? DEFAULT_REGION}`,
@@ -247,6 +314,7 @@ async function showConfig(ctx: ExtensionCommandContext) {
247
314
  `profile: ${partial.profile ?? DEFAULT_PROFILE}`,
248
315
  `prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
249
316
  `autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
317
+ `syncFiles: ${normalizeSyncFiles(partial.syncFiles).join(", ") || "none"}`,
250
318
  `syncSessions: ${syncSessions ? "enabled" : "disabled"}`,
251
319
  `extraFiles: ${normalizeExtraFiles(partial.extraFiles).join(", ") || "none"}`,
252
320
  `local config: ${localConfigPath()}`,
@@ -256,26 +324,31 @@ async function showConfig(ctx: ExtensionCommandContext) {
256
324
  );
257
325
  }
258
326
 
259
- async function status(ctx: ExtensionCommandContext) {
260
- ctx.ui.setStatus(STATUS_KEY, "checking");
261
- const config = await loadConfig();
262
- 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);
263
331
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
264
- const state = await readState(config.profile);
332
+ const state = await readStateForConfig(config);
265
333
  const latest = await client.getJson<LatestPointer>(latestKey(config));
266
334
  const localChanged = hasLocalChanges(local, state, config);
267
335
 
268
336
  let remoteText = "remote: empty";
269
- let remoteChanged = remoteChangedSinceState(latest, state, config);
337
+ const remoteChanged = remoteChangedSinceState(latest, state, config);
270
338
  if (!latest.missing && latest.value) {
271
339
  remoteText = `remote: ${latest.value.snapshot} from ${latest.value.machine} at ${latest.value.createdAt}`;
272
340
  }
273
341
 
274
- const warnings = syncSessionsWarnings(config);
342
+ const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
275
343
  ctx.ui.setStatus(STATUS_KEY, undefined);
276
344
  ctx.ui.notify(
277
345
  [
278
- `profile: ${config.profile}`,
346
+ `target: ${config.target ?? "default"}`,
347
+ `storage profile: ${config.storageProfile ?? "default"}`,
348
+ `destination: ${formatDestination(config)}`,
349
+ `remote namespace: ${config.profile}`,
350
+ `sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
351
+ `extra files: ${config.extraFiles.join(", ") || "none"}`,
279
352
  `sessions: ${config.syncSessions ? "included" : "excluded"}`,
280
353
  remoteText,
281
354
  `local files: ${local.files.length}`,
@@ -287,18 +360,24 @@ async function status(ctx: ExtensionCommandContext) {
287
360
  );
288
361
  }
289
362
 
290
- async function diff(ctx: ExtensionCommandContext) {
291
- ctx.ui.setStatus(STATUS_KEY, "diff");
292
- const config = await loadConfig();
293
- 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);
294
367
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
295
368
  const remote = await readRemoteSnapshot(client, config);
296
369
  ctx.ui.setStatus(STATUS_KEY, undefined);
297
370
 
298
- const warnings = syncSessionsWarnings(config);
299
- const header = [`sessions: ${config.syncSessions ? "included" : "excluded"}`, ...warnings].join(
300
- "\n",
301
- );
371
+ const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
372
+ const header = [
373
+ `target: ${config.target ?? "default"}`,
374
+ `storage profile: ${config.storageProfile ?? "default"}`,
375
+ `destination: ${formatDestination(config)}`,
376
+ `sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
377
+ `extra files: ${config.extraFiles.join(", ") || "none"}`,
378
+ `sessions: ${config.syncSessions ? "included" : "excluded"}`,
379
+ ...warnings,
380
+ ].join("\n");
302
381
  const level = warnings.length > 0 ? "warning" : "info";
303
382
  if (!remote) {
304
383
  ctx.ui.notify(
@@ -311,19 +390,27 @@ async function diff(ctx: ExtensionCommandContext) {
311
390
  ctx.ui.notify(`${header}\n\n${formatDiff(local, remote)}`, level);
312
391
  }
313
392
 
314
- async function doctor(ctx: ExtensionCommandContext) {
393
+ async function doctor(ctx: ExtensionCommandContext, options: CommandOptions) {
315
394
  const messages: string[] = [];
316
395
  let level: "info" | "warning" = "info";
317
396
  let snapshotOptions: SnapshotOptions = {};
318
397
  let profile = DEFAULT_PROFILE;
319
398
 
320
399
  try {
321
- const config = await loadConfig();
400
+ const config = await loadConfig(options.target);
322
401
  profile = config.profile;
323
402
  snapshotOptions = snapshotOptionsForContext(ctx, config);
324
- messages.push(`config: ok (${config.bucket}/${profilePrefix(config)})`);
403
+ messages.push(
404
+ `config: ok (target ${config.target ?? "default"}; ${config.bucket}/${profilePrefix(config)})`,
405
+ );
406
+ messages.push(`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`);
407
+ messages.push(`extra files: ${config.extraFiles.join(", ") || "none"}`);
325
408
  messages.push(`sessions: ${config.syncSessions ? "included" : "excluded"}`);
326
- const warnings = [...sessionTokenWarnings(config), ...syncSessionsWarnings(config)];
409
+ const warnings = [
410
+ ...deprecatedPiSyncEnvironmentWarnings(),
411
+ ...sessionTokenWarnings(config),
412
+ ...syncSessionsWarnings(config),
413
+ ];
327
414
  if (warnings.length > 0) {
328
415
  level = "warning";
329
416
  messages.push(...warnings);
@@ -347,14 +434,14 @@ async function doctor(ctx: ExtensionCommandContext) {
347
434
  if (lock.status === "valid" && isStaleLock(lock.lock)) {
348
435
  level = "warning";
349
436
  messages.push(
350
- `lock: stale (pid ${lock.lock.pid}); run /pisync unlock after verifying no sync is running`,
437
+ `lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running`,
351
438
  );
352
439
  } else if (lock.status === "valid") {
353
440
  messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
354
441
  } else if (lock.status === "unreadable") {
355
442
  level = "warning";
356
443
  messages.push(
357
- "lock: unreadable; use /pisync unlock --stale only after verifying no sync is running",
444
+ "lock: unreadable; use /sync unlock --stale only after verifying no sync is running",
358
445
  );
359
446
  } else if (await isLockGuardHeld()) {
360
447
  level = "warning";
@@ -370,33 +457,32 @@ async function push(
370
457
  options: CommandOptions,
371
458
  input?: PushInput,
372
459
  ) {
373
- ctx.ui.setStatus(STATUS_KEY, "pushing");
374
- const config = input?.config ?? (await loadConfig());
375
- const client = input?.client ?? new S3Client(config);
376
- const state = input?.state ?? (await readState(config.profile));
377
- const local = input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
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));
464
+ const local =
465
+ input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
378
466
 
379
467
  const latest = await client.getJson<LatestPointer>(latestKey(config));
380
- const remoteForUpload = options.force
381
- ? undefined
382
- : await readRemoteSnapshotForUpload(client, config, latest, state);
468
+ const remoteForUpload = await readRemoteSnapshotForUpload(client, config, latest, state);
383
469
  if (remoteChangedSinceState(latest, state, config) && !options.force) {
384
470
  const remoteForConflict = remoteForUpload
385
471
  ? filterSnapshotForConfigPolicy(remoteForUpload, config)
386
472
  : undefined;
387
473
  if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
388
474
  throw new Error(
389
- "Remote or sync policy changed since last sync. Run /pisync pull first or /pisync push --force.",
475
+ "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
390
476
  );
391
477
  }
392
478
  }
393
479
 
394
- const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload, {
395
- preserveRemote: !options.force,
396
- });
480
+ const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload);
397
481
  const secrets = scanSnapshot(local);
398
482
  if (secrets.length > 0) {
399
- throw new Error(`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`);
483
+ throw new Error(
484
+ `Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`,
485
+ );
400
486
  }
401
487
 
402
488
  const preservedRemoteFileCount = countPreservedRemoteFiles(local, upload);
@@ -404,7 +490,7 @@ async function push(
404
490
  !options.yes &&
405
491
  !(await ctx.ui.confirm(
406
492
  snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
407
- formatPushSummary(upload, latest, preservedRemoteFileCount),
493
+ formatPushSummary(config, upload, latest, preservedRemoteFileCount, remoteForUpload),
408
494
  ))
409
495
  ) {
410
496
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -412,43 +498,56 @@ async function push(
412
498
  return;
413
499
  }
414
500
 
415
- const pointer = await uploadSnapshot(client, config, upload, latest, options.force);
416
- await updateHistory(client, config, pointer);
417
- 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, {
418
506
  version: VERSION,
419
507
  profile: config.profile,
420
508
  lastAppliedSnapshot: pointer.snapshot,
421
509
  lastRemoteEtag: undefined,
422
510
  lastFileHashes: fileHashMap(local),
511
+ syncFiles: config.syncFiles,
423
512
  syncSessions: config.syncSessions,
424
513
  extraFiles: config.extraFiles,
425
514
  });
426
515
  ctx.ui.setStatus(STATUS_KEY, undefined);
427
516
  if (!options.silent) {
428
- 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
+ );
429
526
  }
430
527
  }
431
528
 
432
529
  async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
433
- ctx.ui.setStatus(STATUS_KEY, "pulling");
434
- const config = await loadConfig();
435
- const client = new S3Client(config);
436
- 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);
437
534
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
438
535
  const remote = await readRemoteSnapshot(client, config);
439
- if (!remote) throw new Error("Remote is empty. Run /pisync push from a configured machine first.");
536
+ if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
440
537
 
441
538
  const localChanged = hasLocalChanges(local, state, config);
442
539
  const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
443
540
  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.");
541
+ throw new Error(
542
+ "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
543
+ );
445
544
  }
446
545
 
447
546
  if (
448
547
  !options.yes &&
449
548
  !(await ctx.ui.confirm(
450
549
  snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
451
- formatDiff(local, remote),
550
+ formatPullSummary(config, local, remote, protectedSessionPaths(ctx).size),
452
551
  ))
453
552
  ) {
454
553
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -456,24 +555,30 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
456
555
  return;
457
556
  }
458
557
 
558
+ options.onCommit?.();
459
559
  const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
460
560
  const applySessionDir = await sessionDirForApply(ctx, remote);
461
561
  const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
562
+ syncFiles: config.syncFiles,
462
563
  sessionDir: applySessionDir,
463
564
  extraFiles: config.extraFiles,
464
565
  });
465
- await writeState(config.profile, {
566
+ await writeStateForConfig(config, {
466
567
  version: VERSION,
467
568
  profile: config.profile,
468
569
  lastAppliedSnapshot: remote.id,
469
570
  lastRemoteEtag: undefined,
470
571
  lastFileHashes,
572
+ syncFiles: config.syncFiles,
471
573
  syncSessions: config.syncSessions,
472
574
  extraFiles: config.extraFiles,
473
575
  });
474
576
  ctx.ui.setStatus(STATUS_KEY, undefined);
475
577
  if (!options.silent) {
476
- ctx.ui.notify(`Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`, "info");
578
+ ctx.ui.notify(
579
+ `Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`,
580
+ "info",
581
+ );
477
582
  } else if (options.auto && config.syncSessions && snapshotIncludesSessions(remote)) {
478
583
  ctx.ui.notify(
479
584
  "Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
@@ -484,10 +589,23 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
484
589
  }
485
590
 
486
591
  async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
487
- const config = await loadConfig();
488
- const client = new S3Client(config);
489
- 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);
490
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
+ }
491
609
  const remote = await readRemoteSnapshot(client, config);
492
610
  const localChanged = hasLocalChanges(local, state, config);
493
611
  const remoteChanged = remote
@@ -497,34 +615,41 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
497
615
 
498
616
  if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
499
617
  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.");
618
+ throw new Error(
619
+ "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
620
+ );
501
621
  }
502
622
  if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
503
623
  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.");
624
+ throw new Error(
625
+ "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
626
+ );
505
627
  }
506
628
  await pull(ctx, options);
507
629
  return;
508
630
  }
509
- await writeState(config.profile, {
631
+ await writeStateForConfig(config, {
510
632
  version: VERSION,
511
633
  profile: config.profile,
512
634
  lastAppliedSnapshot: remote.id,
513
635
  lastRemoteEtag: undefined,
514
636
  lastFileHashes: fileHashMap(remote),
637
+ syncFiles: config.syncFiles,
515
638
  syncSessions: config.syncSessions,
516
639
  extraFiles: config.extraFiles,
517
640
  });
518
- if (!options.silent) ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
641
+ if (!options.silent)
642
+ ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
519
643
  return;
520
644
  }
521
645
  if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
522
- await writeState(config.profile, {
646
+ await writeStateForConfig(config, {
523
647
  version: VERSION,
524
648
  profile: config.profile,
525
649
  lastAppliedSnapshot: remote.id,
526
650
  lastRemoteEtag: undefined,
527
651
  lastFileHashes: fileHashMap(remote),
652
+ syncFiles: config.syncFiles,
528
653
  syncSessions: config.syncSessions,
529
654
  extraFiles: config.extraFiles,
530
655
  });
@@ -532,7 +657,9 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
532
657
  return;
533
658
  }
534
659
  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.");
660
+ throw new Error(
661
+ "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
662
+ );
536
663
  }
537
664
  if (remoteChanged) {
538
665
  await pull(ctx, options);
@@ -543,12 +670,13 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
543
670
  return;
544
671
  }
545
672
  if (shouldRefreshSyncedState(remote, state, config)) {
546
- await writeState(config.profile, {
673
+ await writeStateForConfig(config, {
547
674
  version: VERSION,
548
675
  profile: config.profile,
549
676
  lastAppliedSnapshot: remote.id,
550
677
  lastRemoteEtag: undefined,
551
678
  lastFileHashes: fileHashMap(remote),
679
+ syncFiles: config.syncFiles,
552
680
  syncSessions: config.syncSessions,
553
681
  extraFiles: config.extraFiles,
554
682
  });
@@ -556,20 +684,38 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
556
684
  if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
557
685
  }
558
686
 
559
- async function history(ctx: ExtensionCommandContext) {
560
- const config = await loadConfig();
561
- 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);
562
690
  const remote = await client.getJson<{ snapshots?: LatestPointer[] }>(historyKey(config));
563
691
  if (remote.missing || !remote.value?.snapshots?.length) {
564
692
  ctx.ui.notify("No remote pi-sync history found.", "info");
565
693
  return;
566
694
  }
567
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
+ }
568
716
  ctx.ui.notify(
569
- remote.value.snapshots
570
- .slice(-20)
571
- .reverse()
572
- .map((item) => `${item.snapshot} ${item.createdAt} ${item.machine}`)
717
+ snapshots
718
+ .map((item) => `${item.snapshot} ${item.createdAt} ${safeTerminalText(item.machine)}`)
573
719
  .join("\n"),
574
720
  "info",
575
721
  );
@@ -577,10 +723,10 @@ async function history(ctx: ExtensionCommandContext) {
577
723
 
578
724
  async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
579
725
  const target = options.args[0];
580
- if (!target) throw new Error("Usage: /pisync rollback <snapshot-id> [--yes]");
726
+ if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
581
727
 
582
- const config = await loadConfig();
583
- const client = new S3Client(config);
728
+ const config = await loadConfig(options.target);
729
+ const client = new S3Client(config, options.signal);
584
730
  const snapshot = await client.getBuffer(snapshotKey(config, target));
585
731
  if (!snapshot.value) throw new Error(`Snapshot not found: ${target}`);
586
732
  const decoded = await decodeSnapshot(snapshot.value);
@@ -589,6 +735,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
589
735
  config,
590
736
  { regenerateId: true },
591
737
  );
738
+ const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
592
739
 
593
740
  if (
594
741
  !options.yes &&
@@ -596,40 +743,52 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
596
743
  snapshotIncludesSessions(remote)
597
744
  ? "Rollback pi settings and sessions?"
598
745
  : "Rollback pi settings?",
599
- formatSnapshotOnlyDiff("Rollback would apply", remote),
746
+ formatRollbackSummary(config, local, remote, target, protectedSessionPaths(ctx).size),
600
747
  ))
601
748
  ) {
602
749
  ctx.ui.notify("Rollback cancelled.", "info");
603
750
  return;
604
751
  }
605
752
 
753
+ options.onCommit?.();
606
754
  const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
607
755
  const applySessionDir = await sessionDirForApply(ctx, remote);
608
756
  const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
757
+ syncFiles: config.syncFiles,
609
758
  sessionDir: applySessionDir,
610
759
  extraFiles: config.extraFiles,
611
760
  });
612
- const latest = await client.getJson<LatestPointer>(latestKey(config));
613
- 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, {
614
764
  ignoreUnreadableRemote: true,
615
765
  });
616
766
  const encoded = upload.id === decoded.id ? snapshot.value : await encodeSnapshot(upload);
617
767
  if (upload.id !== decoded.id) {
618
- await client.putBuffer(snapshotKey(config, upload.id), encoded, "application/gzip");
768
+ await publicationClient.putBuffer(snapshotKey(config, upload.id), encoded, "application/gzip");
619
769
  }
620
770
  const pointer = pointerFor(config, upload, sha256(encoded));
621
- await client.putJson(latestKey(config), pointer);
622
- await updateHistory(client, config, pointer);
623
- await writeState(config.profile, {
771
+ await publicationClient.putJson(latestKey(config), pointer);
772
+ const historyWarning = await updateHistorySafely(publicationClient, config, pointer);
773
+ await writeStateForConfig(config, {
624
774
  version: VERSION,
625
775
  profile: config.profile,
626
776
  lastAppliedSnapshot: pointer.snapshot,
627
777
  lastRemoteEtag: undefined,
628
778
  lastFileHashes,
779
+ syncFiles: config.syncFiles,
629
780
  syncSessions: config.syncSessions,
630
781
  extraFiles: config.extraFiles,
631
782
  });
632
- 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
+ );
633
792
  await maybeReload(ctx);
634
793
  }
635
794
 
@@ -648,6 +807,7 @@ function snapshotOptionsForContext(
648
807
  config: SyncConfig,
649
808
  ): SnapshotOptions {
650
809
  return {
810
+ syncFiles: config.syncFiles,
651
811
  syncSessions: config.syncSessions,
652
812
  sessionDir: sessionDirFromContext(ctx),
653
813
  extraFiles: config.extraFiles,
@@ -670,7 +830,13 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
670
830
 
671
831
  async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext) {
672
832
  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."))) {
833
+ if (
834
+ ctx.hasUI &&
835
+ (await ctx.ui.confirm(
836
+ "Reload Pi resources now?",
837
+ "This reloads extensions, skills, prompts, themes, and context files.",
838
+ ))
839
+ ) {
674
840
  await ctx.reload();
675
841
  }
676
842
  }
@@ -697,9 +863,9 @@ async function snapshotForUpload(
697
863
  local: Snapshot,
698
864
  latest: RemoteObject<LatestPointer>,
699
865
  remote?: Snapshot,
700
- options: { preserveRemote?: boolean; ignoreUnreadableRemote?: boolean } = {},
866
+ options: { ignoreUnreadableRemote?: boolean } = {},
701
867
  ) {
702
- if (options.preserveRemote === false || latest.missing || !latest.value) return local;
868
+ if (latest.missing || !latest.value) return local;
703
869
  let snapshot = remote;
704
870
  if (!snapshot) {
705
871
  try {
@@ -724,12 +890,14 @@ async function uploadSnapshot(
724
890
  await client.putBuffer(snapshotKey(config, snapshot.id), encoded, "application/gzip");
725
891
  const current = await client.getJson<LatestPointer>(latestKey(config));
726
892
  if (!force && remoteIdentity(current) !== remoteIdentity(latest)) {
727
- throw new Error("Remote changed while pushing. Run /pisync pull first, then retry.");
893
+ throw new Error("Remote changed while pushing. Run /sync pull first, then retry.");
728
894
  }
729
895
  await client.putJson(latestKey(config), pointer);
730
896
  const verified = await client.getJson<LatestPointer>(latestKey(config));
731
897
  if (verified.value?.snapshot !== pointer.snapshot) {
732
- throw new Error("Remote latest changed immediately after push. Run /pisync status before continuing.");
898
+ throw new Error(
899
+ "Remote latest changed immediately after push. Run /sync status before continuing.",
900
+ );
733
901
  }
734
902
  return pointer;
735
903
  }
@@ -743,8 +911,10 @@ async function readRemoteSnapshotRaw(client: S3Client, config: SyncConfig) {
743
911
  const latest = await client.getJson<LatestPointer>(latestKey(config));
744
912
  if (latest.missing || !latest.value) return undefined;
745
913
  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.");
914
+ if (!object.value)
915
+ throw new Error(`Remote latest points to missing snapshot: ${latest.value.snapshot}`);
916
+ if (sha256(object.value) !== latest.value.sha256)
917
+ throw new Error("Remote snapshot checksum mismatch.");
748
918
  return decodeSnapshot(object.value);
749
919
  }
750
920
 
@@ -755,7 +925,8 @@ async function encodeSnapshot(snapshot: Snapshot) {
755
925
  async function decodeSnapshot(buffer: Buffer): Promise<Snapshot> {
756
926
  const decoded = await gunzipAsync(buffer);
757
927
  const parsed = JSON.parse(decoded.toString("utf8")) as Snapshot;
758
- if (parsed.version !== VERSION || !Array.isArray(parsed.files)) throw new Error("Unsupported snapshot format.");
928
+ if (parsed.version !== VERSION || !Array.isArray(parsed.files))
929
+ throw new Error("Unsupported snapshot format.");
759
930
  return parsed;
760
931
  }
761
932
 
@@ -768,8 +939,19 @@ export async function backupLocal(profile: string, options: SnapshotOptions = {}
768
939
  return backupPath;
769
940
  }
770
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
+
771
951
  async function updateHistory(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
772
- const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(historyKey(config));
952
+ const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
953
+ historyKey(config),
954
+ );
773
955
  const snapshots = object.value?.snapshots ?? [];
774
956
  const next = [
775
957
  ...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot),
@@ -778,216 +960,40 @@ async function updateHistory(client: S3Client, config: SyncConfig, pointer: Late
778
960
  await client.putJson(historyKey(config), { version: VERSION, snapshots: next });
779
961
  }
780
962
 
781
- function formatDiff(local: Snapshot, remote: Snapshot) {
782
- const localMap = fileHashMap(local);
783
- const remoteMap = fileHashMap(remote);
784
- 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)`, ""];
786
- let changed = 0;
787
- for (const filePath of allPaths) {
788
- if (!localMap[filePath]) {
789
- lines.push(`+ ${filePath}`);
790
- changed += 1;
791
- } else if (!remoteMap[filePath]) {
792
- lines.push(`- ${filePath}`);
793
- changed += 1;
794
- } else if (localMap[filePath] !== remoteMap[filePath]) {
795
- lines.push(`~ ${filePath}`);
796
- changed += 1;
797
- }
798
- }
799
- if (changed === 0) lines.push("No file differences.");
800
- return lines.join("\n");
801
- }
802
-
803
- function formatSnapshotOnlyDiff(title: string, snapshot: Snapshot) {
804
- return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `+ ${file.path}`)].join("\n");
805
- }
806
-
807
- function formatPushSummary(
808
- local: Snapshot,
809
- latest: RemoteObject<LatestPointer>,
810
- preservedRemoteFileCount = 0,
811
- ) {
812
- return [
813
- `Upload ${local.files.length} files from ${agentDir()}.`,
814
- latest.value ? `Remote latest: ${latest.value.snapshot}` : "Remote latest: empty",
815
- preservedRemoteFileCount > 0
816
- ? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.`
817
- : "Possible secrets were scanned before this prompt.",
818
- ].join("\n");
819
- }
820
-
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
- function remoteIdentity(remote: RemoteObject<LatestPointer>) {
847
- return remote.missing ? "missing" : (remote.value?.snapshot ?? "unknown");
848
- }
849
-
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
- function countPreservedRemoteFiles(local: Snapshot, upload: Snapshot) {
919
- const localPaths = new Set(local.files.map((file) => file.path));
920
- return upload.files.filter((file) => !localPaths.has(file.path)).length;
921
- }
922
-
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
- function sha256(value: Buffer) {
961
- return createHash("sha256").update(value).digest("hex");
962
- }
963
-
964
- function redact(value: string) {
965
- return value.length <= 8 ? "configured" : `${value.slice(0, 4)}…${value.slice(-4)}`;
966
- }
967
-
968
- function errorMessage(error: unknown) {
969
- return error instanceof Error ? error.message : String(error);
970
- }
971
-
972
963
  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
964
  export {
976
- addTopLevelCaseVariantDeletes,
977
- appliedFileHashMap,
978
- preflightSnapshotApply,
979
- protectSnapshotApplyPlan,
980
- } from "./snapshot-apply.js";
965
+ isCloudflareR2Endpoint,
966
+ isEnabled,
967
+ isExplicitlyEnabled,
968
+ loadConfig,
969
+ sessionTokenWarnings,
970
+ } from "./config.js";
971
+ export { encodeKey, posixJoin, safeJoin, safeName } from "./paths.js";
981
972
  export {
982
- collectFiles,
983
973
  canonicalSnapshotPathForConfig,
974
+ collectFiles,
984
975
  filterSnapshotForConfigPolicy,
985
976
  isConfiguredSnapshotPath,
986
977
  isDeniedPath,
987
978
  isSessionPath,
988
- sessionSnapshotPathFromAbsolute,
989
979
  mergeRemotePreservedFiles,
990
980
  mergeRemoteSessionFiles,
991
981
  scanSnapshot,
982
+ sessionSnapshotPathFromAbsolute,
992
983
  snapshotWithoutSessions,
993
984
  } from "./snapshot.js";
985
+ export {
986
+ addTopLevelCaseVariantDeletes,
987
+ appliedFileHashMap,
988
+ preflightSnapshotApply,
989
+ protectSnapshotApplyPlan,
990
+ } from "./snapshot-apply.js";
991
+ export {
992
+ canPullRemoteSessionsOnFirstSync,
993
+ canPullRemoteSettingsOnFirstSync,
994
+ hasRemoteChanges,
995
+ sessionHashMap,
996
+ settingsHashesMatchState,
997
+ settingsHashMap,
998
+ settingsHashMapFromState,
999
+ } from "./sync-state.js";