@narumitw/pi-sync 0.31.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sync.ts CHANGED
@@ -1,7 +1,3 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { promisify } from "node:util";
4
- import { gunzip, gzip } from "node:zlib";
5
1
  import type {
6
2
  ExtensionAPI,
7
3
  ExtensionCommandContext,
@@ -11,107 +7,47 @@ import {
11
7
  completeSyncArguments,
12
8
  parseOptions,
13
9
  resolveSyncCommand,
14
- setSyncTargetCompletions,
10
+ setSyncSetupCompletions,
15
11
  usage,
16
12
  validateCommandOptions,
17
13
  } from "./command.js";
18
14
  import {
19
15
  activeLocalConfigPath,
20
- configuredTargetNames,
16
+ configuredSyncSetupNames,
21
17
  consumeLocalConfigMigrationNotice,
22
- deprecatedPiSyncEnvironmentWarnings,
18
+ createLocalConfigDocument,
23
19
  ensureStateDir,
24
- isEnabled,
25
- isExplicitlyEnabled,
26
20
  isMissingConfigError,
27
21
  loadConfig,
28
22
  loadPartialConfig,
29
23
  localConfigPath,
30
24
  localConfigTemplate,
31
- normalizeExtraFiles,
32
- normalizeSyncFiles,
33
25
  readLocalConfigObject,
34
26
  readStateForConfig,
35
- sessionDirForApply,
36
27
  sessionTokenWarnings,
37
- stateDir,
38
28
  syncSessionsWarnings,
39
- writeLocalConfigObject,
40
- writeStateForConfig,
41
29
  } from "./config.js";
42
30
  import { showFileSelection } from "./file-selection.js";
43
- import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
31
+ import { unlock, withLock } from "./lock.js";
44
32
  import { showSetupWizard, showSyncManager } from "./manager-ui.js";
45
- import {
46
- historyKey,
47
- latestKey,
48
- pointerFor,
49
- profilePrefix,
50
- S3Client,
51
- snapshotKey,
52
- } from "./s3-client.js";
53
- import {
54
- createSnapshot,
55
- filterSnapshotForConfigPolicy,
56
- mergeRemotePreservedFiles,
57
- scanSnapshot,
58
- sessionSnapshotPathFromAbsolute,
59
- snapshotIncludesSessions,
60
- snapshotWithoutSessions,
61
- } from "./snapshot.js";
62
- import { applySnapshot } from "./snapshot-apply.js";
33
+ import { SetupPullRequiresUiError, useSyncSetup } from "./setup-switch.js";
34
+ import { createSnapshot } from "./snapshot.js";
63
35
  import { recoverSnapshotTransactionsOnStartup } from "./snapshot-transaction.js";
36
+ import { errorMessage } from "./sync-format.js";
64
37
  import {
65
- countPreservedRemoteFiles,
66
- errorMessage,
67
- formatDestination,
68
- formatDiff,
69
- formatPullSummary,
70
- formatPushSummary,
71
- formatRollbackSummary,
72
- formatSnapshotOnlyDiff,
73
- redact,
74
- remoteIdentity,
75
- safeTerminalText,
76
- sha256,
77
- } from "./sync-format.js";
78
- import {
79
- canPullRemoteSessionsOnFirstSync,
80
- canPullRemoteSettingsOnFirstSync,
81
- fileHashMap,
82
- hasLocalChanges,
83
- hasRemoteChanges,
84
- remoteChangedSinceState,
85
- sameHashes,
86
- shouldRefreshSyncedState,
87
- snapshotHashesMatchState,
88
- snapshotsMatch,
89
- syncPolicyChanged,
90
- } from "./sync-state.js";
91
- import { TargetPullRequiresUiError, useSyncTarget } from "./target-switch.js";
92
- import type {
93
- CommandOptions,
94
- LatestPointer,
95
- RemoteObject,
96
- Snapshot,
97
- SnapshotOptions,
98
- SyncConfig,
99
- SyncState,
100
- } from "./types.js";
38
+ diff,
39
+ doctor,
40
+ history,
41
+ pull,
42
+ push,
43
+ rollback,
44
+ status,
45
+ syncBoth,
46
+ } from "./sync-operations.js";
47
+ import { hasLocalChanges } from "./sync-state.js";
48
+ import type { AnySyncConfig, CommandOptions, SnapshotOptions } from "./types.js";
101
49
 
102
- const [gzipAsync, gunzipAsync] = [promisify(gzip), promisify(gunzip)];
103
50
  const STATUS_KEY = "sync";
104
- const VERSION = 1;
105
- const DEFAULT_PROFILE = "default";
106
- const DEFAULT_PREFIX = "pi-sync";
107
- const DEFAULT_REGION = "auto";
108
-
109
- interface PushInput {
110
- config: SyncConfig;
111
- state: SyncState;
112
- local: Snapshot;
113
- client?: S3Client;
114
- }
115
51
 
116
52
  const AUTO_SYNC_OPTIONS: CommandOptions = {
117
53
  yes: true,
@@ -123,55 +59,89 @@ const AUTO_SYNC_OPTIONS: CommandOptions = {
123
59
  args: [],
124
60
  };
125
61
  export default function sync(pi: ExtensionAPI) {
62
+ let sessionAbort = new AbortController();
63
+ let shutdownAbort: AbortController | undefined;
64
+
126
65
  pi.registerCommand("sync", {
127
- description: "Sync Pi settings through Cloudflare R2 or S3-compatible storage",
66
+ description: "Sync Pi settings through Git, WebDAV, R2, or S3-compatible storage",
128
67
  getArgumentCompletions: completeSyncArguments,
129
68
  handler: async (args, ctx) => {
130
- await handleCommand(args, ctx);
69
+ if (!ctx.hasUI) {
70
+ throw new Error(
71
+ "/sync requires TUI or RPC mode so results and safety prompts are observable.",
72
+ );
73
+ }
74
+ await handleCommand(args, ctx, sessionAbort.signal);
131
75
  },
132
76
  });
133
77
 
134
78
  pi.on("session_start", async (_event, ctx) => {
79
+ shutdownAbort?.abort(new DOMException("Session replaced", "AbortError"));
80
+ shutdownAbort = undefined;
81
+ sessionAbort.abort(new DOMException("Session replaced", "AbortError"));
82
+ sessionAbort = new AbortController();
83
+ const signal = sessionAbort.signal;
135
84
  ctx.ui.setStatus(STATUS_KEY, undefined);
136
85
  try {
137
86
  await recoverSnapshotTransactionsOnStartup();
87
+ if (signal.aborted) return;
138
88
  } catch (error) {
89
+ if (signal.aborted) return;
139
90
  ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
140
91
  return;
141
92
  }
142
93
  try {
143
- setSyncTargetCompletions(await configuredTargetNames());
94
+ setSyncSetupCompletions(await configuredSyncSetupNames());
95
+ if (signal.aborted) return;
144
96
  } catch {
145
- setSyncTargetCompletions([]);
97
+ if (signal.aborted) return;
98
+ setSyncSetupCompletions([]);
146
99
  }
147
100
  const migrationNotice = consumeLocalConfigMigrationNotice();
148
101
  if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
149
- const warnings = deprecatedPiSyncEnvironmentWarnings();
150
- if (warnings.length > 0) ctx.ui.notify(warnings.join("\n"), "warning");
151
- await autoSync(ctx);
102
+ if (signal.aborted) return;
103
+ await autoSync(ctx, signal);
152
104
  });
153
105
 
154
106
  pi.on("session_shutdown", async (event, ctx) => {
107
+ sessionAbort.abort(new DOMException("Session shut down", "AbortError"));
108
+ shutdownAbort?.abort(new DOMException("Session shut down again", "AbortError"));
109
+ const controller = new AbortController();
110
+ shutdownAbort = controller;
111
+ const signal = combineSignals(controller.signal, AbortSignal.timeout(30_000));
155
112
  const reason =
156
113
  typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
157
- if (reason !== "reload") await autoPushSessions(ctx);
114
+ try {
115
+ if (reason !== "reload") await autoPushSessions(ctx, signal);
116
+ } finally {
117
+ if (shutdownAbort === controller) shutdownAbort = undefined;
118
+ }
119
+ if (signal.aborted) return;
158
120
  ctx.ui.setStatus(STATUS_KEY, undefined);
159
121
  });
160
122
  }
161
123
 
162
- async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
124
+ async function handleCommand(
125
+ rawArgs: string,
126
+ ctx: ExtensionCommandContext,
127
+ sessionSignal: AbortSignal,
128
+ ) {
163
129
  if (!rawArgs.trim()) {
164
130
  try {
165
- await showSyncManager(ctx, (route, signal, onCommit, target) =>
166
- executeCommand(route, ctx, signal, onCommit, target),
131
+ await showSyncManager(
132
+ ctx,
133
+ (route, signal, onCommit, target) =>
134
+ executeCommand(route, ctx, combineSignals(sessionSignal, signal), onCommit, target),
135
+ sessionSignal,
167
136
  );
168
137
  } catch (error) {
138
+ if (sessionSignal.aborted) return;
169
139
  ctx.ui.setStatus(STATUS_KEY, undefined);
170
140
  ctx.ui.notify(errorMessage(error), "error");
171
141
  }
172
142
  return;
173
143
  }
174
- await executeCommand(rawArgs, ctx);
144
+ await executeCommand(rawArgs, ctx, sessionSignal);
175
145
  }
176
146
 
177
147
  async function executeCommand(
@@ -179,14 +149,14 @@ async function executeCommand(
179
149
  ctx: ExtensionCommandContext,
180
150
  signal?: AbortSignal,
181
151
  onCommit?: () => void,
182
- target?: string,
152
+ setup?: string,
183
153
  ) {
184
154
  try {
185
155
  const command = await resolveSyncCommand(rawArgs, ctx);
186
- if (!command) return;
156
+ if (signal?.aborted || !command) return;
187
157
  const { subcommand, rest } = command;
188
158
  const options = parseOptions(rest);
189
- if (target !== undefined) options.target = target;
159
+ if (setup !== undefined) options.setup = setup;
190
160
  if (signal) options.signal = signal;
191
161
  if (onCommit) options.onCommit = onCommit;
192
162
  validateCommandOptions(subcommand, options);
@@ -196,18 +166,23 @@ async function executeCommand(
196
166
  ctx.ui.notify(usage(), "info");
197
167
  return;
198
168
  case "use":
199
- await useSyncTarget(ctx, options.args[0] ?? "", (selectedTarget) =>
200
- withLock("pull", () => pull(ctx, { ...options, target: selectedTarget })),
169
+ await useSyncSetup(
170
+ ctx,
171
+ options.args[0] ?? "",
172
+ (selectedSetup) =>
173
+ withLock("pull", () => pull(ctx, { ...options, setup: selectedSetup })),
174
+ undefined,
175
+ options.signal,
201
176
  );
202
177
  return;
203
178
  case "init":
204
- await initConfig(ctx);
179
+ await initConfig(ctx, signal);
205
180
  return;
206
181
  case "config":
207
182
  await showConfig(ctx, options);
208
183
  return;
209
184
  case "files":
210
- await showFileSelection(ctx, options.target);
185
+ await showFileSelection(ctx, options.setup, options.signal);
211
186
  return;
212
187
  case "status":
213
188
  await status(ctx, options);
@@ -239,49 +214,64 @@ async function executeCommand(
239
214
  ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
240
215
  }
241
216
  } catch (error) {
217
+ if (signal?.aborted) return;
242
218
  ctx.ui.setStatus(STATUS_KEY, undefined);
243
- if (signal?.aborted && error instanceof Error && error.name === "AbortError") return;
244
- if (error instanceof TargetPullRequiresUiError) throw error;
219
+ if (error instanceof SetupPullRequiresUiError) throw error;
245
220
  ctx.ui.notify(errorMessage(error), "error");
246
221
  }
247
222
  }
248
223
 
249
- async function autoSync(ctx: ExtensionContext) {
224
+ async function autoSync(ctx: ExtensionContext, signal: AbortSignal) {
250
225
  try {
251
226
  const partial = await loadPartialConfig();
252
- if (!isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true)) return;
227
+ throwIfAborted(signal);
228
+ if (!partial.automatic) return;
253
229
  await ensureStateDir();
230
+ throwIfAborted(signal);
254
231
  await loadConfig();
255
- await withLock("auto-sync", () => syncBoth(ctx, AUTO_SYNC_OPTIONS));
232
+ throwIfAborted(signal);
233
+ await withLock("auto-sync", () => {
234
+ throwIfAborted(signal);
235
+ return syncBoth(ctx, { ...AUTO_SYNC_OPTIONS, signal });
236
+ });
256
237
  } catch (error) {
257
- if (isMissingConfigError(error)) return;
238
+ if (signal.aborted || isMissingConfigError(error)) return;
258
239
  ctx.ui.setStatus(STATUS_KEY, undefined);
259
240
  ctx.ui.notify(`pi-sync auto sync skipped: ${errorMessage(error)}`, "warning");
260
241
  }
261
242
  }
262
243
 
263
- async function autoPushSessions(ctx: ExtensionContext) {
244
+ async function autoPushSessions(ctx: ExtensionContext, signal: AbortSignal) {
264
245
  try {
265
246
  const partial = await loadPartialConfig();
266
- if (!isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true)) return;
267
- if (!isExplicitlyEnabled(partial.syncSessions)) return;
247
+ throwIfAborted(signal);
248
+ if (!partial.automatic) return;
249
+ if (!partial.include.includes("sessions")) return;
268
250
  await ensureStateDir();
251
+ throwIfAborted(signal);
269
252
  const config = await loadConfig();
270
- if (!config.syncSessions) return;
253
+ throwIfAborted(signal);
254
+ if (!config.include.includes("sessions")) return;
271
255
  await withLock("auto-session-push", async () => {
256
+ throwIfAborted(signal);
272
257
  const state = await readStateForConfig(config);
273
- const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
258
+ throwIfAborted(signal);
259
+ const local = await createSnapshot(
260
+ config.snapshotIdentity,
261
+ snapshotOptionsForContext(ctx, config),
262
+ );
263
+ throwIfAborted(signal);
274
264
  if (!hasLocalChanges(local, state, config)) return;
275
- await push(ctx, AUTO_SYNC_OPTIONS, { config, state, local });
265
+ await push(ctx, { ...AUTO_SYNC_OPTIONS, signal }, { config, state, local });
276
266
  });
277
267
  } catch (error) {
278
- if (isMissingConfigError(error)) return;
268
+ if (signal.aborted || isMissingConfigError(error)) return;
279
269
  ctx.ui.setStatus(STATUS_KEY, undefined);
280
270
  ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
281
271
  }
282
272
  }
283
273
 
284
- async function initConfig(ctx: ExtensionCommandContext) {
274
+ async function initConfig(ctx: ExtensionCommandContext, signal?: AbortSignal) {
285
275
  const configPath = localConfigPath();
286
276
  if (await readLocalConfigObject()) {
287
277
  ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
@@ -289,533 +279,115 @@ async function initConfig(ctx: ExtensionCommandContext) {
289
279
  }
290
280
 
291
281
  if (ctx.mode === "tui") {
292
- await showSetupWizard(ctx);
282
+ await showSetupWizard(ctx, signal);
293
283
  return;
294
284
  }
295
- await writeLocalConfigObject(localConfigTemplate());
296
- ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /sync doctor.`, "info");
285
+ await createLocalConfigDocument(localConfigTemplate());
286
+ ctx.ui.notify(
287
+ `Created ${configPath}. Add a storage connection and sync setup before syncing.`,
288
+ "info",
289
+ );
297
290
  }
298
291
 
299
292
  async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
300
- const partial = await loadPartialConfig(options.target);
301
- const syncSessions = isExplicitlyEnabled(partial.syncSessions);
293
+ const config = await loadConfig(options.setup);
302
294
  const warnings = [
303
- ...deprecatedPiSyncEnvironmentWarnings(),
304
- ...sessionTokenWarnings(partial),
305
- ...syncSessionsWarnings({ syncSessions }),
295
+ ...(config.backend.type === "s3" ? sessionTokenWarnings(config.backend.profile) : []),
296
+ ...syncSessionsWarnings(config),
306
297
  ];
298
+ const storageLines = configStorageLines(config);
307
299
  ctx.ui.notify(
308
300
  [
309
301
  "pi-sync config:",
310
- `target: ${partial.target ?? "default"}`,
311
- `storage profile: ${partial.storageProfile ?? "default"}`,
312
- `endpoint: ${partial.endpoint ?? "missing"}`,
313
- `bucket: ${partial.bucket ?? "missing"}`,
314
- `region: ${partial.region ?? DEFAULT_REGION}`,
315
- `accessKeyId: ${partial.accessKeyId ? redact(partial.accessKeyId) : "missing"}`,
316
- `secretAccessKey: ${partial.secretAccessKey ? "configured" : "missing"}`,
317
- `sessionToken: ${partial.sessionToken ? "configured" : "not configured"}`,
318
- `profile: ${partial.profile ?? DEFAULT_PROFILE}`,
319
- `prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
320
- `autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
321
- `syncFiles: ${normalizeSyncFiles(partial.syncFiles).join(", ") || "none"}`,
322
- `syncSessions: ${syncSessions ? "enabled" : "disabled"}`,
323
- `extraFiles: ${normalizeExtraFiles(partial.extraFiles).join(", ") || "none"}`,
324
- `local config: ${localConfigPath()}`,
302
+ `sync setup: ${config.setupName}`,
303
+ `storage connection: ${config.connectionName}`,
304
+ ...storageLines,
305
+ `storage path: ${config.storagePath}`,
306
+ `automatic sync: ${config.automatic ? "enabled" : "disabled"}`,
307
+ `included content: ${config.include.join(", ") || "none"}`,
308
+ `sessions: ${config.include.includes("sessions") ? "included" : "not included"}`,
309
+ `settings file: ${localConfigPath()}`,
325
310
  ...warnings,
326
311
  ].join("\n"),
327
312
  warnings.length > 0 ? "warning" : "info",
328
313
  );
329
314
  }
330
315
 
331
- async function status(ctx: ExtensionCommandContext, options: CommandOptions) {
332
- const config = await loadConfig(options.target);
333
- ctx.ui.setStatus(STATUS_KEY, `checking ${config.target ?? "default"}`);
334
- const client = new S3Client(config, options.signal);
335
- const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
336
- const state = await readStateForConfig(config);
337
- const latest = await client.getJson<LatestPointer>(latestKey(config));
338
- const localChanged = hasLocalChanges(local, state, config);
339
-
340
- let remoteText = "remote: empty";
341
- const remoteChanged = remoteChangedSinceState(latest, state, config);
342
- if (!latest.missing && latest.value) {
343
- remoteText = `remote: ${latest.value.snapshot} from ${latest.value.machine} at ${latest.value.createdAt}`;
316
+ function configStorageLines(config: AnySyncConfig) {
317
+ switch (config.backend.type) {
318
+ case "git":
319
+ return [
320
+ "kind: git",
321
+ `remote: ${displayGitRemote(config.backend.profile.remote)}`,
322
+ "authentication: existing Git/SSH credentials (not stored)",
323
+ `branch: ${config.backend.destination.branch}`,
324
+ ];
325
+ case "webdav":
326
+ return [
327
+ "kind: webdav",
328
+ `url: ${displayWebDavUrl(config.backend.profile.url, config.backend.profile.username)}`,
329
+ "username: configured (value hidden)",
330
+ "password: configured",
331
+ ];
332
+ case "s3":
333
+ return [
334
+ "kind: s3",
335
+ `endpoint: ${config.backend.profile.endpoint}`,
336
+ `bucket: ${config.backend.destination.bucket}`,
337
+ `region: ${config.backend.profile.region}`,
338
+ "access key id: configured",
339
+ "secret access key: configured",
340
+ `session token: ${config.backend.profile.sessionToken ? "configured" : "not configured"}`,
341
+ ];
344
342
  }
345
-
346
- const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
347
- ctx.ui.setStatus(STATUS_KEY, undefined);
348
- ctx.ui.notify(
349
- [
350
- `target: ${config.target ?? "default"}`,
351
- `storage profile: ${config.storageProfile ?? "default"}`,
352
- `destination: ${formatDestination(config)}`,
353
- `remote namespace: ${config.profile}`,
354
- `sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
355
- `extra files: ${config.extraFiles.join(", ") || "none"}`,
356
- `sessions: ${config.syncSessions ? "included" : "excluded"}`,
357
- remoteText,
358
- `local files: ${local.files.length}`,
359
- `local changed since last sync: ${localChanged ? "yes" : "no"}`,
360
- `remote changed since last sync: ${remoteChanged ? "yes" : "no"}`,
361
- ...warnings,
362
- ].join("\n"),
363
- localChanged || remoteChanged || warnings.length > 0 ? "warning" : "info",
364
- );
365
343
  }
366
344
 
367
- async function diff(ctx: ExtensionCommandContext, options: CommandOptions) {
368
- const config = await loadConfig(options.target);
369
- ctx.ui.setStatus(STATUS_KEY, `checking ${config.target ?? "default"}`);
370
- const client = new S3Client(config, options.signal);
371
- const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
372
- const remote = await readRemoteSnapshot(client, config);
373
- ctx.ui.setStatus(STATUS_KEY, undefined);
374
-
375
- const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
376
- const header = [
377
- `target: ${config.target ?? "default"}`,
378
- `storage profile: ${config.storageProfile ?? "default"}`,
379
- `destination: ${formatDestination(config)}`,
380
- `sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
381
- `extra files: ${config.extraFiles.join(", ") || "none"}`,
382
- `sessions: ${config.syncSessions ? "included" : "excluded"}`,
383
- ...warnings,
384
- ].join("\n");
385
- const level = warnings.length > 0 ? "warning" : "info";
386
- if (!remote) {
387
- ctx.ui.notify(
388
- `${header}\n\n${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`,
389
- level,
390
- );
391
- return;
392
- }
393
-
394
- ctx.ui.notify(`${header}\n\n${formatDiff(local, remote)}`, level);
395
- }
396
-
397
- async function doctor(ctx: ExtensionCommandContext, options: CommandOptions) {
398
- const messages: string[] = [];
399
- let level: "info" | "warning" = "info";
400
- let snapshotOptions: SnapshotOptions = {};
401
- let profile = DEFAULT_PROFILE;
402
-
345
+ function displayGitRemote(value: string | undefined) {
346
+ if (!value) return "missing";
403
347
  try {
404
- const config = await loadConfig(options.target);
405
- profile = config.profile;
406
- snapshotOptions = snapshotOptionsForContext(ctx, config);
407
- messages.push(
408
- `config: ok (target ${config.target ?? "default"}; ${config.bucket}/${profilePrefix(config)})`,
409
- );
410
- messages.push(`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`);
411
- messages.push(`extra files: ${config.extraFiles.join(", ") || "none"}`);
412
- messages.push(`sessions: ${config.syncSessions ? "included" : "excluded"}`);
413
- const warnings = [
414
- ...deprecatedPiSyncEnvironmentWarnings(),
415
- ...sessionTokenWarnings(config),
416
- ...syncSessionsWarnings(config),
417
- ];
418
- if (warnings.length > 0) {
419
- level = "warning";
420
- messages.push(...warnings);
421
- }
422
- } catch (error) {
423
- level = "warning";
424
- messages.push(`config: ${errorMessage(error)}`);
425
- }
426
-
427
- const local = await createSnapshot(profile, snapshotOptions);
428
- const secrets = scanSnapshot(local);
429
- if (secrets.length > 0) {
430
- level = "warning";
431
- messages.push("secret scan: possible secrets found:");
432
- messages.push(...secrets.map((secret) => `- ${secret}`));
433
- } else {
434
- messages.push(`secret scan: ok (${local.files.length} files checked)`);
435
- }
436
-
437
- const lock = await inspectLock();
438
- if (lock.status === "valid" && isStaleLock(lock.lock)) {
439
- level = "warning";
440
- messages.push(
441
- `lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running`,
442
- );
443
- } else if (lock.status === "valid") {
444
- messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
445
- } else if (lock.status === "unreadable") {
446
- level = "warning";
447
- messages.push(
448
- "lock: unreadable; use /sync unlock --stale only after verifying no sync is running",
449
- );
450
- } else if (await isLockGuardHeld()) {
451
- level = "warning";
452
- messages.push("lock: guard active while metadata is missing or still being initialized");
453
- } else {
454
- messages.push("lock: free");
455
- }
456
- ctx.ui.notify(messages.join("\n"), level);
457
- }
458
-
459
- async function push(
460
- ctx: ExtensionCommandContext | ExtensionContext,
461
- options: CommandOptions,
462
- input?: PushInput,
463
- ) {
464
- const config = input?.config ?? (await loadConfig(options.target));
465
- ctx.ui.setStatus(STATUS_KEY, `pushing ${config.target ?? "default"}`);
466
- const client = input?.client ?? new S3Client(config, options.signal);
467
- const state = input?.state ?? (await readStateForConfig(config));
468
- const local =
469
- input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
470
-
471
- const latest = await client.getJson<LatestPointer>(latestKey(config));
472
- const remoteForUpload = await readRemoteSnapshotForUpload(client, config, latest, state);
473
- if (remoteChangedSinceState(latest, state, config) && !options.force) {
474
- const remoteForConflict = remoteForUpload
475
- ? filterSnapshotForConfigPolicy(remoteForUpload, config)
476
- : undefined;
477
- if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
478
- throw new Error(
479
- "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
480
- );
481
- }
482
- }
483
-
484
- const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload);
485
- const secrets = scanSnapshot(local);
486
- if (secrets.length > 0) {
487
- throw new Error(
488
- `Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`,
489
- );
490
- }
491
-
492
- const preservedRemoteFileCount = countPreservedRemoteFiles(local, upload);
493
- if (
494
- !options.yes &&
495
- !(await ctx.ui.confirm(
496
- snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
497
- formatPushSummary(config, upload, latest, preservedRemoteFileCount, remoteForUpload),
498
- ))
499
- ) {
500
- ctx.ui.setStatus(STATUS_KEY, undefined);
501
- ctx.ui.notify("Push cancelled.", "info");
502
- return;
503
- }
504
-
505
- options.onCommit?.();
506
- const publicationClient = options.onCommit ? new S3Client(config) : client;
507
- const pointer = await uploadSnapshot(publicationClient, config, upload, latest, options.force);
508
- const historyWarning = await updateHistorySafely(publicationClient, config, pointer);
509
- await writeStateForConfig(config, {
510
- version: VERSION,
511
- profile: config.profile,
512
- lastAppliedSnapshot: pointer.snapshot,
513
- lastRemoteEtag: undefined,
514
- lastFileHashes: fileHashMap(local),
515
- syncFiles: config.syncFiles,
516
- syncSessions: config.syncSessions,
517
- extraFiles: config.extraFiles,
518
- });
519
- ctx.ui.setStatus(STATUS_KEY, undefined);
520
- if (!options.silent) {
521
- ctx.ui.notify(
522
- [
523
- `Pushed ${upload.files.length} files from target “${config.target ?? "default"}” as ${pointer.snapshot}.`,
524
- historyWarning,
525
- ]
526
- .filter(Boolean)
527
- .join("\n"),
528
- historyWarning ? "warning" : "info",
529
- );
348
+ const url = new URL(value);
349
+ url.username = "";
350
+ url.password = "";
351
+ url.search = "";
352
+ url.hash = "";
353
+ return url.toString();
354
+ } catch {
355
+ return value.replace(/^(?:[^@\s]+@)?(?<host>[^:]+):.+$/u, "$<host>:…");
530
356
  }
531
357
  }
532
358
 
533
- async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
534
- const config = await loadConfig(options.target);
535
- ctx.ui.setStatus(STATUS_KEY, `pulling ${config.target ?? "default"}`);
536
- const client = new S3Client(config, options.signal);
537
- const state = await readStateForConfig(config);
538
- const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
539
- const remote = await readRemoteSnapshot(client, config);
540
- if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
541
-
542
- const localChanged = hasLocalChanges(local, state, config);
543
- const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
544
- if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
545
- throw new Error(
546
- "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
547
- );
548
- }
549
-
550
- if (
551
- !options.yes &&
552
- !(await ctx.ui.confirm(
553
- snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
554
- formatPullSummary(config, local, remote, protectedSessionPaths(ctx).size),
555
- ))
556
- ) {
557
- ctx.ui.setStatus(STATUS_KEY, undefined);
558
- ctx.ui.notify("Pull cancelled.", "info");
559
- return "cancelled" as const;
560
- }
561
-
562
- options.onCommit?.();
563
- const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
564
- const applySessionDir = await sessionDirForApply(ctx, remote);
565
- const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
566
- syncFiles: config.syncFiles,
567
- sessionDir: applySessionDir,
568
- extraFiles: config.extraFiles,
569
- });
570
- await writeStateForConfig(config, {
571
- version: VERSION,
572
- profile: config.profile,
573
- lastAppliedSnapshot: remote.id,
574
- lastRemoteEtag: undefined,
575
- lastFileHashes,
576
- syncFiles: config.syncFiles,
577
- syncSessions: config.syncSessions,
578
- extraFiles: config.extraFiles,
579
- });
580
- ctx.ui.setStatus(STATUS_KEY, undefined);
581
- if (!options.silent) {
582
- ctx.ui.notify(
583
- `Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`,
584
- "info",
585
- );
586
- } else if (options.auto && config.syncSessions && snapshotIncludesSessions(remote)) {
587
- ctx.ui.notify(
588
- "Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
589
- "warning",
590
- );
591
- }
592
- if (options.reload) await maybeReload(ctx);
593
- return "applied" as const;
594
- }
595
-
596
- async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
597
- const config = await loadConfig(options.target);
598
- const client = new S3Client(config, options.signal);
599
- const state = await readStateForConfig(config);
600
- const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
601
- if (
602
- normalizeSyncFiles(config.syncFiles).length === 0 &&
603
- config.extraFiles.length === 0 &&
604
- !config.syncSessions
605
- ) {
606
- if (!options.silent) {
607
- ctx.ui.notify(
608
- `Target “${config.target ?? "default"}” manages no files. Choose synced content in /sync Settings before syncing.`,
609
- "warning",
610
- );
611
- }
612
- return;
613
- }
614
- const remote = await readRemoteSnapshot(client, config);
615
- const localChanged = hasLocalChanges(local, state, config);
616
- const remoteChanged = remote
617
- ? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx))
618
- : false;
619
- const firstSync = !state.lastAppliedSnapshot;
620
-
621
- if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
622
- if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
623
- throw new Error(
624
- "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
625
- );
626
- }
627
- if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
628
- if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
629
- throw new Error(
630
- "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
631
- );
632
- }
633
- await pull(ctx, options);
634
- return;
635
- }
636
- await writeStateForConfig(config, {
637
- version: VERSION,
638
- profile: config.profile,
639
- lastAppliedSnapshot: remote.id,
640
- lastRemoteEtag: undefined,
641
- lastFileHashes: fileHashMap(remote),
642
- syncFiles: config.syncFiles,
643
- syncSessions: config.syncSessions,
644
- extraFiles: config.extraFiles,
645
- });
646
- if (!options.silent)
647
- ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
648
- return;
649
- }
650
- if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
651
- await writeStateForConfig(config, {
652
- version: VERSION,
653
- profile: config.profile,
654
- lastAppliedSnapshot: remote.id,
655
- lastRemoteEtag: undefined,
656
- lastFileHashes: fileHashMap(remote),
657
- syncFiles: config.syncFiles,
658
- syncSessions: config.syncSessions,
659
- extraFiles: config.extraFiles,
660
- });
661
- if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
662
- return;
663
- }
664
- if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
665
- throw new Error(
666
- "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
667
- );
668
- }
669
- if (remoteChanged) {
670
- await pull(ctx, options);
671
- return;
672
- }
673
- if (localChanged || !remote) {
674
- await push(ctx, options);
675
- return;
676
- }
677
- if (shouldRefreshSyncedState(remote, state, config)) {
678
- await writeStateForConfig(config, {
679
- version: VERSION,
680
- profile: config.profile,
681
- lastAppliedSnapshot: remote.id,
682
- lastRemoteEtag: undefined,
683
- lastFileHashes: fileHashMap(remote),
684
- syncFiles: config.syncFiles,
685
- syncSessions: config.syncSessions,
686
- extraFiles: config.extraFiles,
687
- });
688
- }
689
- if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
690
- }
691
-
692
- async function history(ctx: ExtensionCommandContext, options: CommandOptions) {
693
- const config = await loadConfig(options.target);
694
- const client = new S3Client(config, options.signal);
695
- const remote = await client.getJson<{ snapshots?: LatestPointer[] }>(historyKey(config));
696
- if (remote.missing || !remote.value?.snapshots?.length) {
697
- ctx.ui.notify("No remote pi-sync history found.", "info");
698
- return;
699
- }
700
-
701
- const snapshots = remote.value.snapshots.slice(-20).reverse();
702
- const currentSnapshot = snapshots[0]?.snapshot;
703
- if (ctx.mode === "tui") {
704
- const labels = snapshots.map(
705
- (item, index) =>
706
- `${index + 1}. ${item.createdAt} · ${safeTerminalText(item.machine)} · ${item.snapshot}${item.snapshot === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " · sessions" : ""}`,
707
- );
708
- const selected = await ctx.ui.select(
709
- `History for target “${safeTerminalText(config.target ?? "default")}”\n\nChoose a snapshot to preview rollback.`,
710
- [...labels, "Back"],
711
- );
712
- if (!selected || selected === "Back") return;
713
- const index = labels.indexOf(selected);
714
- const snapshot = snapshots[index];
715
- if (!snapshot) return;
716
- await withLock("rollback", () =>
717
- rollback(ctx, { ...options, args: [snapshot.snapshot], yes: false }),
718
- );
719
- return;
359
+ function displayWebDavUrl(value: string | undefined, username: string | undefined) {
360
+ if (!value) return "missing";
361
+ try {
362
+ const url = new URL(value);
363
+ url.username = "";
364
+ url.password = "";
365
+ url.search = "";
366
+ url.hash = "";
367
+ return username ? `${url.origin}/…` : `${url.origin}${url.pathname}`;
368
+ } catch {
369
+ return "invalid (value hidden)";
720
370
  }
721
- ctx.ui.notify(
722
- snapshots
723
- .map((item) => `${item.snapshot} ${item.createdAt} ${safeTerminalText(item.machine)}`)
724
- .join("\n"),
725
- "info",
726
- );
727
371
  }
728
372
 
729
- async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
730
- const target = options.args[0];
731
- if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
732
-
733
- const config = await loadConfig(options.target);
734
- const client = new S3Client(config, options.signal);
735
- const snapshot = await client.getBuffer(snapshotKey(config, target));
736
- if (!snapshot.value) throw new Error(`Snapshot not found: ${target}`);
737
- const decoded = await decodeSnapshot(snapshot.value);
738
- const remote = filterSnapshotForConfigPolicy(
739
- config.syncSessions ? decoded : snapshotWithoutSessions(decoded),
740
- config,
741
- { regenerateId: true },
742
- );
743
- const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
744
-
745
- if (
746
- !options.yes &&
747
- !(await ctx.ui.confirm(
748
- snapshotIncludesSessions(remote)
749
- ? "Rollback pi settings and sessions?"
750
- : "Rollback pi settings?",
751
- formatRollbackSummary(config, local, remote, target, protectedSessionPaths(ctx).size),
752
- ))
753
- ) {
754
- ctx.ui.notify("Rollback cancelled.", "info");
755
- return;
756
- }
757
-
758
- options.onCommit?.();
759
- const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
760
- const applySessionDir = await sessionDirForApply(ctx, remote);
761
- const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
762
- syncFiles: config.syncFiles,
763
- sessionDir: applySessionDir,
764
- extraFiles: config.extraFiles,
765
- });
766
- const publicationClient = options.onCommit ? new S3Client(config) : client;
767
- const latest = await publicationClient.getJson<LatestPointer>(latestKey(config));
768
- const upload = await snapshotForUpload(publicationClient, config, remote, latest, undefined, {
769
- ignoreUnreadableRemote: true,
770
- });
771
- const encoded = upload.id === decoded.id ? snapshot.value : await encodeSnapshot(upload);
772
- if (upload.id !== decoded.id) {
773
- await publicationClient.putBuffer(snapshotKey(config, upload.id), encoded, "application/gzip");
774
- }
775
- const pointer = pointerFor(config, upload, sha256(encoded));
776
- await publicationClient.putJson(latestKey(config), pointer);
777
- const historyWarning = await updateHistorySafely(publicationClient, config, pointer);
778
- await writeStateForConfig(config, {
779
- version: VERSION,
780
- profile: config.profile,
781
- lastAppliedSnapshot: pointer.snapshot,
782
- lastRemoteEtag: undefined,
783
- lastFileHashes,
784
- syncFiles: config.syncFiles,
785
- syncSessions: config.syncSessions,
786
- extraFiles: config.extraFiles,
787
- });
788
- ctx.ui.notify(
789
- [
790
- `Rolled back target “${config.target ?? "default"}” to ${target}; latest: ${pointer.snapshot}. Backup: ${backup}`,
791
- historyWarning,
792
- ]
793
- .filter(Boolean)
794
- .join("\n"),
795
- historyWarning ? "warning" : "info",
796
- );
797
- await maybeReload(ctx);
373
+ function throwIfAborted(signal: AbortSignal) {
374
+ if (!signal.aborted) return;
375
+ throw signal.reason instanceof Error
376
+ ? signal.reason
377
+ : new DOMException("The operation was aborted", "AbortError");
798
378
  }
799
379
 
800
- function protectedSessionPaths(ctx: ExtensionCommandContext | ExtensionContext) {
801
- const getSessionFile = ctx.sessionManager.getSessionFile;
802
- if (typeof getSessionFile !== "function") return new Set<string>();
803
- const sessionFile = getSessionFile.call(ctx.sessionManager) as string | undefined;
804
- const snapshotPath = sessionFile
805
- ? sessionSnapshotPathFromAbsolute(sessionFile, sessionDirFromContext(ctx))
806
- : undefined;
807
- return snapshotPath ? new Set([snapshotPath]) : new Set<string>();
380
+ function combineSignals(primary: AbortSignal, secondary?: AbortSignal) {
381
+ return secondary ? AbortSignal.any([primary, secondary]) : primary;
808
382
  }
809
383
 
810
384
  function snapshotOptionsForContext(
811
385
  ctx: ExtensionCommandContext | ExtensionContext,
812
- config: SyncConfig,
386
+ config: AnySyncConfig,
813
387
  ): SnapshotOptions {
814
388
  return {
815
- syncFiles: config.syncFiles,
816
- syncSessions: config.syncSessions,
389
+ include: config.include,
817
390
  sessionDir: sessionDirFromContext(ctx),
818
- extraFiles: config.extraFiles,
819
391
  };
820
392
  }
821
393
 
@@ -833,138 +405,6 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
833
405
  : undefined;
834
406
  }
835
407
 
836
- async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext) {
837
- if (!("reload" in ctx)) return;
838
- if (
839
- ctx.hasUI &&
840
- (await ctx.ui.confirm(
841
- "Reload Pi resources now?",
842
- "This reloads extensions, skills, prompts, themes, and context files.",
843
- ))
844
- ) {
845
- await ctx.reload();
846
- }
847
- }
848
-
849
- async function readRemoteSnapshotForUpload(
850
- client: S3Client,
851
- config: SyncConfig,
852
- latest: RemoteObject<LatestPointer>,
853
- state: SyncState,
854
- ) {
855
- if (
856
- latest.missing ||
857
- !latest.value ||
858
- (latest.value.snapshot === state.lastAppliedSnapshot && !syncPolicyChanged(state, config))
859
- ) {
860
- return undefined;
861
- }
862
- return readRemoteSnapshotRaw(client, config);
863
- }
864
-
865
- async function snapshotForUpload(
866
- client: S3Client,
867
- config: SyncConfig,
868
- local: Snapshot,
869
- latest: RemoteObject<LatestPointer>,
870
- remote?: Snapshot,
871
- options: { ignoreUnreadableRemote?: boolean } = {},
872
- ) {
873
- if (latest.missing || !latest.value) return local;
874
- let snapshot = remote;
875
- if (!snapshot) {
876
- try {
877
- snapshot = await readRemoteSnapshotRaw(client, config);
878
- } catch (error) {
879
- if (options.ignoreUnreadableRemote) return local;
880
- throw error;
881
- }
882
- }
883
- return snapshot ? mergeRemotePreservedFiles(local, snapshot, config) : local;
884
- }
885
-
886
- async function uploadSnapshot(
887
- client: S3Client,
888
- config: SyncConfig,
889
- snapshot: Snapshot,
890
- latest: RemoteObject<LatestPointer>,
891
- force: boolean,
892
- ) {
893
- const encoded = await encodeSnapshot(snapshot);
894
- const pointer = pointerFor(config, snapshot, sha256(encoded));
895
- await client.putBuffer(snapshotKey(config, snapshot.id), encoded, "application/gzip");
896
- const current = await client.getJson<LatestPointer>(latestKey(config));
897
- if (!force && remoteIdentity(current) !== remoteIdentity(latest)) {
898
- throw new Error("Remote changed while pushing. Run /sync pull first, then retry.");
899
- }
900
- await client.putJson(latestKey(config), pointer);
901
- const verified = await client.getJson<LatestPointer>(latestKey(config));
902
- if (verified.value?.snapshot !== pointer.snapshot) {
903
- throw new Error(
904
- "Remote latest changed immediately after push. Run /sync status before continuing.",
905
- );
906
- }
907
- return pointer;
908
- }
909
-
910
- async function readRemoteSnapshot(client: S3Client, config: SyncConfig) {
911
- const snapshot = await readRemoteSnapshotRaw(client, config);
912
- return snapshot ? filterSnapshotForConfigPolicy(snapshot, config) : undefined;
913
- }
914
-
915
- async function readRemoteSnapshotRaw(client: S3Client, config: SyncConfig) {
916
- const latest = await client.getJson<LatestPointer>(latestKey(config));
917
- if (latest.missing || !latest.value) return undefined;
918
- const object = await client.getBuffer(snapshotKey(config, latest.value.snapshot));
919
- if (!object.value)
920
- throw new Error(`Remote latest points to missing snapshot: ${latest.value.snapshot}`);
921
- if (sha256(object.value) !== latest.value.sha256)
922
- throw new Error("Remote snapshot checksum mismatch.");
923
- return decodeSnapshot(object.value);
924
- }
925
-
926
- async function encodeSnapshot(snapshot: Snapshot) {
927
- return gzipAsync(Buffer.from(JSON.stringify(snapshot), "utf8"));
928
- }
929
-
930
- async function decodeSnapshot(buffer: Buffer): Promise<Snapshot> {
931
- const decoded = await gunzipAsync(buffer);
932
- const parsed = JSON.parse(decoded.toString("utf8")) as Snapshot;
933
- if (parsed.version !== VERSION || !Array.isArray(parsed.files))
934
- throw new Error("Unsupported snapshot format.");
935
- return parsed;
936
- }
937
-
938
- export async function backupLocal(profile: string, options: SnapshotOptions = {}) {
939
- const snapshot = await createSnapshot(profile, options);
940
- const backupDirectory = path.join(stateDir(), "backups");
941
- await fs.mkdir(backupDirectory, { recursive: true });
942
- const backupPath = path.join(backupDirectory, `${snapshot.id}.json.gz`);
943
- await fs.writeFile(backupPath, await encodeSnapshot(snapshot));
944
- return backupPath;
945
- }
946
-
947
- async function updateHistorySafely(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
948
- try {
949
- await updateHistory(client, config, pointer);
950
- return undefined;
951
- } catch (error) {
952
- return `Remote snapshot is active, but history could not be updated: ${errorMessage(error)}. Run /sync doctor before relying on history.`;
953
- }
954
- }
955
-
956
- async function updateHistory(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
957
- const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
958
- historyKey(config),
959
- );
960
- const snapshots = object.value?.snapshots ?? [];
961
- const next = [
962
- ...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot),
963
- pointer,
964
- ].slice(-100);
965
- await client.putJson(historyKey(config), { version: VERSION, snapshots: next });
966
- }
967
-
968
408
  export { completeSyncArguments, parseOptions, splitArgs } from "./command.js";
969
409
  export {
970
410
  isCloudflareR2Endpoint,
@@ -993,6 +433,7 @@ export {
993
433
  preflightSnapshotApply,
994
434
  protectSnapshotApplyPlan,
995
435
  } from "./snapshot-apply.js";
436
+ export { backupLocal } from "./sync-operations.js";
996
437
  export {
997
438
  canPullRemoteSessionsOnFirstSync,
998
439
  canPullRemoteSettingsOnFirstSync,