@narumitw/pi-sync 0.49.9 → 0.49.11

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.
Files changed (61) hide show
  1. package/README.md +10 -1
  2. package/dist/chunks/chunk-3GRWMWUZ.ts +213 -0
  3. package/dist/chunks/chunk-3GRWMWUZ.ts.map +7 -0
  4. package/dist/chunks/chunk-3NDD5QPM.ts +203 -0
  5. package/dist/chunks/chunk-3NDD5QPM.ts.map +7 -0
  6. package/dist/chunks/chunk-4F3W4CSC.ts +195 -0
  7. package/dist/chunks/chunk-4F3W4CSC.ts.map +7 -0
  8. package/dist/chunks/chunk-4U6AFLJB.ts +221 -0
  9. package/dist/chunks/chunk-4U6AFLJB.ts.map +7 -0
  10. package/dist/chunks/chunk-5MQ76BK5.ts +34 -0
  11. package/dist/chunks/chunk-5MQ76BK5.ts.map +7 -0
  12. package/dist/chunks/chunk-APZVBSM6.ts +1238 -0
  13. package/dist/chunks/chunk-APZVBSM6.ts.map +7 -0
  14. package/dist/chunks/chunk-BHJUISB4.ts +134 -0
  15. package/dist/chunks/chunk-BHJUISB4.ts.map +7 -0
  16. package/dist/chunks/chunk-D3S54OXI.ts +653 -0
  17. package/dist/chunks/chunk-D3S54OXI.ts.map +7 -0
  18. package/dist/chunks/chunk-EWX3TPLQ.ts +356 -0
  19. package/dist/chunks/chunk-EWX3TPLQ.ts.map +7 -0
  20. package/dist/chunks/chunk-F5QL2GPY.ts +341 -0
  21. package/dist/chunks/chunk-F5QL2GPY.ts.map +7 -0
  22. package/dist/chunks/chunk-FR6CVXAL.ts +81 -0
  23. package/dist/chunks/chunk-FR6CVXAL.ts.map +7 -0
  24. package/dist/chunks/chunk-HGV24P4T.ts +53 -0
  25. package/dist/chunks/chunk-HGV24P4T.ts.map +7 -0
  26. package/dist/chunks/chunk-JNOOSPEW.ts +82 -0
  27. package/dist/chunks/chunk-JNOOSPEW.ts.map +7 -0
  28. package/dist/chunks/chunk-LJDSHNS3.ts +259 -0
  29. package/dist/chunks/chunk-LJDSHNS3.ts.map +7 -0
  30. package/dist/chunks/chunk-RGJ6SOYR.ts +128 -0
  31. package/dist/chunks/chunk-RGJ6SOYR.ts.map +7 -0
  32. package/dist/chunks/chunk-W64NEHXT.ts +167 -0
  33. package/dist/chunks/chunk-W64NEHXT.ts.map +7 -0
  34. package/dist/chunks/chunk-YQ6UW7IF.ts +394 -0
  35. package/dist/chunks/chunk-YQ6UW7IF.ts.map +7 -0
  36. package/dist/chunks/file-selection-4UABOP44.ts +290 -0
  37. package/dist/chunks/file-selection-4UABOP44.ts.map +7 -0
  38. package/dist/chunks/git-backend-E3UOUUOM.ts +1104 -0
  39. package/dist/chunks/git-backend-E3UOUUOM.ts.map +7 -0
  40. package/dist/chunks/manager-result-dispatcher-YKBLZ7KL.ts +18 -0
  41. package/dist/chunks/manager-result-dispatcher-YKBLZ7KL.ts.map +7 -0
  42. package/dist/chunks/manager-ui-7E25VWUP.ts +2754 -0
  43. package/dist/chunks/manager-ui-7E25VWUP.ts.map +7 -0
  44. package/dist/chunks/remote-selection-ui-J6OW5DKZ.ts +17 -0
  45. package/dist/chunks/remote-selection-ui-J6OW5DKZ.ts.map +7 -0
  46. package/dist/chunks/s3-backend-3QZGO2EF.ts +680 -0
  47. package/dist/chunks/s3-backend-3QZGO2EF.ts.map +7 -0
  48. package/dist/chunks/setup-switch-X3W3FALZ.ts +27 -0
  49. package/dist/chunks/setup-switch-X3W3FALZ.ts.map +7 -0
  50. package/dist/chunks/snapshot-WBSV4MQG.ts +45 -0
  51. package/dist/chunks/snapshot-WBSV4MQG.ts.map +7 -0
  52. package/dist/chunks/sync-operations-MPAFVVHA.ts +1112 -0
  53. package/dist/chunks/sync-operations-MPAFVVHA.ts.map +7 -0
  54. package/dist/chunks/sync-state-36H7ULG2.ts +41 -0
  55. package/dist/chunks/sync-state-36H7ULG2.ts.map +7 -0
  56. package/dist/chunks/webdav-backend-ZD5RHRIU.ts +869 -0
  57. package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +7 -0
  58. package/dist/index.ts +668 -0
  59. package/dist/index.ts.map +7 -0
  60. package/package.json +10 -6
  61. package/src/state-directory.ts +55 -1
@@ -0,0 +1,1112 @@
1
+ // @generated by scripts/build-runtime.mjs; do not edit.
2
+ // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader.
3
+ import {
4
+ createSyncBackend,
5
+ formatRemoteSelectionStatus,
6
+ readSnapshotForHead,
7
+ requireCompatibleRemoteSelection
8
+ } from "./chunk-FR6CVXAL.js";
9
+ import {
10
+ applySnapshotTransaction,
11
+ recoverPendingSnapshotTransactions
12
+ } from "./chunk-3NDD5QPM.js";
13
+ import {
14
+ inspectLock,
15
+ isLockGuardHeld,
16
+ isStaleLock,
17
+ withLock
18
+ } from "./chunk-LJDSHNS3.js";
19
+ import {
20
+ SyncDecisionRequiredError,
21
+ countPreservedRemoteFiles,
22
+ errorMessage,
23
+ formatDiff,
24
+ formatPullSummary,
25
+ formatPushSummary,
26
+ formatRollbackSummary,
27
+ formatSnapshotOnlyDiff,
28
+ publicationCapabilityDescription,
29
+ safeTerminalText
30
+ } from "./chunk-W64NEHXT.js";
31
+ import {
32
+ canPullRemoteSessionsOnFirstSync,
33
+ canPullRemoteSettingsOnFirstSync,
34
+ fileHashMap,
35
+ hasLocalChanges,
36
+ hasRemoteChanges,
37
+ remoteChangedSinceState,
38
+ sameHashes,
39
+ shouldRefreshSyncedState,
40
+ snapshotHashesMatchState,
41
+ snapshotsMatch,
42
+ syncPolicyChanged
43
+ } from "./chunk-RGJ6SOYR.js";
44
+ import {
45
+ createSnapshot,
46
+ filterSnapshotForConfigPolicy,
47
+ isSessionFilePath,
48
+ isSessionPath,
49
+ mergeRemotePreservedFiles,
50
+ regenerateSnapshotIdentity,
51
+ scanSnapshot,
52
+ sessionSnapshotPathFromAbsolute,
53
+ sessionStorageRoot,
54
+ snapshotIncludesSessions,
55
+ snapshotTarget,
56
+ snapshotWithoutSessions
57
+ } from "./chunk-EWX3TPLQ.js";
58
+ import {
59
+ agentDir,
60
+ loadConfig,
61
+ readStateForConfig,
62
+ sessionDirForApply,
63
+ syncConfigReviewFingerprint,
64
+ syncConfigReviewIdentity,
65
+ syncSessionsWarnings,
66
+ writeStateForConfig
67
+ } from "./chunk-APZVBSM6.js";
68
+ import {
69
+ encodeSnapshot
70
+ } from "./chunk-HGV24P4T.js";
71
+ import {
72
+ stateDir
73
+ } from "./chunk-F5QL2GPY.js";
74
+ import {
75
+ expectedRemoteHead
76
+ } from "./chunk-5MQ76BK5.js";
77
+ import {
78
+ assertWithinRoot,
79
+ includeFromSelectionConfig,
80
+ inspectRemoteSelection,
81
+ isDeniedPath,
82
+ isPathInside,
83
+ parentPaths,
84
+ remoteSelectionMismatch,
85
+ safeJoin,
86
+ toPosix
87
+ } from "./chunk-YQ6UW7IF.js";
88
+
89
+ // src/sync-operations.ts
90
+ import fs2 from "node:fs/promises";
91
+ import path2 from "node:path";
92
+
93
+ // src/snapshot-apply.ts
94
+ import { createHash } from "node:crypto";
95
+ import fs from "node:fs/promises";
96
+ import path from "node:path";
97
+ function sha256(value) {
98
+ return createHash("sha256").update(value).digest("hex");
99
+ }
100
+ function fileHashMap2(snapshot) {
101
+ return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
102
+ }
103
+ async function applySnapshot(snapshot, protectedRelativePaths = /* @__PURE__ */ new Set(), options = {}) {
104
+ const root = agentDir();
105
+ const { sessionDir } = options;
106
+ await recoverPendingSnapshotTransactions();
107
+ const current = await createSnapshot(snapshot.profile, {
108
+ ...options,
109
+ ...options.include === void 0 ? { syncSessions: snapshotIncludesSessions(snapshot) } : {},
110
+ sessionDir
111
+ });
112
+ const plan = await addTopLevelCaseVariantDeletes(
113
+ root,
114
+ protectSnapshotApplyPlan(
115
+ root,
116
+ preflightSnapshotApply(root, snapshot, current, { sessionDir }),
117
+ protectedRelativePaths,
118
+ sessionDir
119
+ ),
120
+ snapshot
121
+ );
122
+ await preflightSnapshotMutations(root, plan, sessionDir);
123
+ await applySnapshotTransaction(plan, { sessionDir });
124
+ return appliedFileHashMap(snapshot, current, protectedRelativePaths);
125
+ }
126
+ function preflightSnapshotApply(root, snapshot, current, options = {}) {
127
+ const seenPaths = /* @__PURE__ */ new Set();
128
+ const remotePaths = /* @__PURE__ */ new Set();
129
+ const writes = [];
130
+ const deletes = [];
131
+ for (const file of snapshot.files) {
132
+ const normalized = toPosix(file.path);
133
+ if (!isSafeSnapshotPath(file.path)) {
134
+ throw new Error(`Unsafe path in snapshot: ${file.path}`);
135
+ }
136
+ if (isSessionPath(normalized) && !isSessionFilePath(normalized)) {
137
+ throw new Error(`Unsafe session path in snapshot: ${file.path}`);
138
+ }
139
+ if (seenPaths.has(normalized)) throw new Error(`Duplicate path in snapshot: ${normalized}`);
140
+ seenPaths.add(normalized);
141
+ remotePaths.add(normalized);
142
+ const target = snapshotTarget(root, normalized, options.sessionDir);
143
+ const content = decodeBase64Strict(file.contentBase64, normalized);
144
+ if (sha256(content) !== file.sha256)
145
+ throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
146
+ writes.push({ target, content });
147
+ }
148
+ const deletePaths = /* @__PURE__ */ new Set();
149
+ for (const file of current.files) {
150
+ const normalized = toPosix(file.path);
151
+ if (!remotePaths.has(normalized)) {
152
+ deletePaths.add(snapshotTarget(root, normalized, options.sessionDir));
153
+ }
154
+ for (const remotePath of parentPaths(normalized)) {
155
+ if (remotePaths.has(remotePath)) {
156
+ deletePaths.add(snapshotTarget(root, remotePath, options.sessionDir));
157
+ }
158
+ }
159
+ }
160
+ deletes.push(...deletePaths);
161
+ return { writes, deletes };
162
+ }
163
+ function protectSnapshotApplyPlan(root, plan, protectedRelativePaths, sessionDir) {
164
+ if (protectedRelativePaths.size === 0) return plan;
165
+ const protectedTargets = new Set(
166
+ [...protectedRelativePaths].map(
167
+ (relativePath) => snapshotTarget(root, relativePath, sessionDir)
168
+ )
169
+ );
170
+ return {
171
+ writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
172
+ deletes: plan.deletes.filter((target) => !protectedTargets.has(target))
173
+ };
174
+ }
175
+ async function addTopLevelCaseVariantDeletes(root, plan, snapshot) {
176
+ const topLevelPaths = /* @__PURE__ */ new Map();
177
+ for (const file of snapshot.files) {
178
+ const normalized = toPosix(file.path);
179
+ if (!normalized.includes("/") && isSafeSnapshotPath(file.path)) {
180
+ topLevelPaths.set(normalized.toLowerCase(), normalized);
181
+ }
182
+ }
183
+ if (topLevelPaths.size === 0) return plan;
184
+ let entries;
185
+ try {
186
+ entries = await fs.readdir(root, { withFileTypes: true });
187
+ } catch (error) {
188
+ if (error.code === "ENOENT") return plan;
189
+ throw error;
190
+ }
191
+ const deletes = new Set(plan.deletes);
192
+ for (const entry of entries) {
193
+ const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
194
+ if (canonicalPath && entry.name !== canonicalPath && (entry.isFile() || entry.isSymbolicLink())) {
195
+ deletes.add(safeJoin(root, entry.name));
196
+ }
197
+ }
198
+ return { ...plan, deletes: [...deletes] };
199
+ }
200
+ function appliedFileHashMap(snapshot, current, protectedRelativePaths) {
201
+ const hashes = fileHashMap2(snapshot);
202
+ if (protectedRelativePaths.size === 0) return hashes;
203
+ const currentHashes = fileHashMap2(current);
204
+ for (const relativePath of protectedRelativePaths) {
205
+ const normalized = toPosix(relativePath);
206
+ if (currentHashes[normalized]) {
207
+ hashes[normalized] = currentHashes[normalized];
208
+ } else {
209
+ delete hashes[normalized];
210
+ }
211
+ }
212
+ return hashes;
213
+ }
214
+ function isSafeSnapshotPath(relativePath) {
215
+ if (relativePath.includes("\\")) return false;
216
+ const normalized = toPosix(relativePath);
217
+ return Boolean(normalized) && normalized !== "." && normalized !== ".." && !normalized.startsWith("../") && !path.posix.isAbsolute(normalized) && path.posix.normalize(normalized) === normalized && !isDeniedPath(normalized);
218
+ }
219
+ function decodeBase64Strict(value, filePath) {
220
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
221
+ throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
222
+ }
223
+ return Buffer.from(value, "base64");
224
+ }
225
+ async function preflightSnapshotMutations(root, plan, sessionDir) {
226
+ const deletePaths = new Set(plan.deletes);
227
+ for (const target of plan.deletes) {
228
+ await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
229
+ }
230
+ for (const item of plan.writes) {
231
+ await prepareSnapshotWrite(
232
+ rootForTarget(root, item.target, sessionDir),
233
+ item.target,
234
+ deletePaths
235
+ );
236
+ }
237
+ }
238
+ function rootForTarget(root, target, sessionDir) {
239
+ const sessionRoot = sessionDir ? sessionStorageRoot(root, sessionDir) : void 0;
240
+ if (sessionRoot && isPathInside(sessionRoot, target)) return sessionRoot;
241
+ return root;
242
+ }
243
+ async function prepareSnapshotWrite(root, target, deletePaths) {
244
+ const parentWillBeReplaced = await ensureSafeDirectory(root, path.dirname(target), deletePaths);
245
+ if (parentWillBeReplaced) return;
246
+ try {
247
+ const stat = await fs.lstat(target);
248
+ if (stat.isSymbolicLink())
249
+ throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
250
+ if (stat.isDirectory() && !deletePaths.has(target)) {
251
+ throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
252
+ }
253
+ } catch (error) {
254
+ if (error.code !== "ENOENT") throw error;
255
+ }
256
+ }
257
+ async function ensureSafeDirectory(root, directory, deletePaths) {
258
+ assertWithinRoot(root, directory);
259
+ const rootPath = path.resolve(root);
260
+ const relative = path.relative(rootPath, path.resolve(directory));
261
+ let current = rootPath;
262
+ for (const part of relative.split(path.sep).filter(Boolean)) {
263
+ current = path.join(current, part);
264
+ try {
265
+ const stat = await fs.lstat(current);
266
+ if (stat.isSymbolicLink())
267
+ throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
268
+ if (!stat.isDirectory()) {
269
+ if (deletePaths.has(current)) return true;
270
+ throw new Error(`Snapshot path parent is not a directory: ${current}`);
271
+ }
272
+ } catch (error) {
273
+ if (error.code !== "ENOENT") throw error;
274
+ await fs.mkdir(current);
275
+ }
276
+ }
277
+ return false;
278
+ }
279
+ async function assertNoSymlinkParents(root, target) {
280
+ assertWithinRoot(root, target);
281
+ const rootPath = path.resolve(root);
282
+ const relative = path.relative(rootPath, path.resolve(target));
283
+ let current = rootPath;
284
+ const parts = relative.split(path.sep).filter(Boolean);
285
+ for (const part of parts.slice(0, -1)) {
286
+ current = path.join(current, part);
287
+ try {
288
+ const stat = await fs.lstat(current);
289
+ if (stat.isSymbolicLink())
290
+ throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
291
+ if (!stat.isDirectory())
292
+ throw new Error(`Snapshot path parent is not a directory: ${current}`);
293
+ } catch (error) {
294
+ if (error.code === "ENOENT") return;
295
+ throw error;
296
+ }
297
+ }
298
+ }
299
+
300
+ // src/sync-decision.ts
301
+ function createSyncDecision(options) {
302
+ const { config, state, local, remote, kind } = options;
303
+ const policyChanged = Boolean(state.lastAppliedSnapshot) && syncPolicyChanged(state, config);
304
+ const previousInclude = includeFromSelectionConfig(state);
305
+ const currentInclude = [...config.include];
306
+ const causes = {
307
+ localChanged: options.localChanged,
308
+ remoteChanged: options.remoteChanged,
309
+ policyChanged
310
+ };
311
+ const causeLines = kind === "first-sync-settings-diverged" ? ["This machine and the remote have different Pi settings on first sync."] : kind === "first-sync-sessions-diverged" ? ["Pi settings match, but local and remote sessions differ on first sync."] : kind === "remote-empty" ? ["The remote storage location is empty."] : [
312
+ ...causes.localChanged ? ["Local content changed since the last sync."] : [],
313
+ ...causes.remoteChanged ? ["Remote content changed since the last sync."] : [],
314
+ ...causes.policyChanged ? ["Included content changed since the last sync."] : []
315
+ ];
316
+ const comparison = remote ? formatDiff(local, remote) : formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local);
317
+ const review = [
318
+ `Sync setup: ${safeTerminalText(config.setupName)}`,
319
+ "",
320
+ "Why a decision is required:",
321
+ ...causeLines,
322
+ ...policyChanged ? [
323
+ "",
324
+ `Previously included: ${safeList(previousInclude)}`,
325
+ `Currently included: ${safeList(currentInclude)}`
326
+ ] : [],
327
+ "",
328
+ "Observed differences:",
329
+ ...comparison.split("\n").map(safeTerminalText)
330
+ ].join("\n");
331
+ return new SyncDecisionRequiredError({
332
+ kind,
333
+ setupName: config.setupName,
334
+ configIdentity: syncConfigReviewIdentity(config),
335
+ causes,
336
+ ...policyChanged ? { previousInclude: [...previousInclude] } : {},
337
+ currentInclude,
338
+ review,
339
+ directions: kind === "remote-empty" ? ["push"] : ["push", "pull"],
340
+ directMessage: options.directMessage
341
+ });
342
+ }
343
+ function safeList(values) {
344
+ return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
345
+ }
346
+
347
+ // src/sync-operations.ts
348
+ var STATUS_KEY = "sync";
349
+ var VERSION = 1;
350
+ var DEFAULT_PROFILE = "default";
351
+ var POST_LOCAL_COMMIT_TIMEOUT_MS = 3e4;
352
+ var PublicationStatePersistenceError = class extends Error {
353
+ head;
354
+ backupPath;
355
+ constructor(head, cause, backupPath) {
356
+ super(
357
+ `Remote publication ${head.snapshotId} is active, but local sync state could not be saved${backupPath ? `; local backup: ${backupPath}` : ""}: ${errorMessage(cause)}`,
358
+ { cause }
359
+ );
360
+ this.name = "PublicationStatePersistenceError";
361
+ this.head = head;
362
+ this.backupPath = backupPath;
363
+ }
364
+ };
365
+ var RollbackPublicationError = class extends Error {
366
+ backupPath;
367
+ constructor(backupPath, cause) {
368
+ super(
369
+ `Rollback applied locally with backup ${backupPath}, but remote publication failed: ${errorMessage(cause)}`,
370
+ { cause }
371
+ );
372
+ this.name = "RollbackPublicationError";
373
+ this.backupPath = backupPath;
374
+ }
375
+ };
376
+ function backendFor(config, factory) {
377
+ return factory(config);
378
+ }
379
+ async function status(ctx, options, factory = createSyncBackend) {
380
+ const config = await loadConfig(options.setup);
381
+ throwIfAborted(options.signal);
382
+ ctx.ui.setStatus(STATUS_KEY, `checking ${config.setupName}`);
383
+ const backend = await backendFor(config, factory);
384
+ const local = await createSnapshot(
385
+ config.snapshotIdentity,
386
+ snapshotOptionsForContext(ctx, config)
387
+ );
388
+ throwIfAborted(options.signal);
389
+ const state = await readStateForConfig(config);
390
+ throwIfAborted(options.signal);
391
+ const head = await backend.readHead(options.signal);
392
+ throwIfAborted(options.signal);
393
+ const selectionState = head ? inspectRemoteSelection(config.include, { selection: head.selection, files: [] }) : void 0;
394
+ const localChanged = hasLocalChanges(local, state, config);
395
+ const remoteText = head ? `remote: ${head.snapshotId} from ${head.machine} at ${head.createdAt}` : "remote: empty";
396
+ const remoteChanged = remoteChangedSinceState(
397
+ head,
398
+ state,
399
+ config,
400
+ (left, right) => backend.sameRevision(left, right)
401
+ );
402
+ const warnings = syncSessionsWarnings(config);
403
+ ctx.ui.setStatus(STATUS_KEY, void 0);
404
+ ctx.ui.notify(
405
+ [
406
+ `sync setup: ${config.setupName}`,
407
+ `storage connection: ${config.connectionName}`,
408
+ `storage location: ${safeTerminalText(backend.destination)}`,
409
+ `publication safety: ${publicationCapabilityDescription(backend.capability)}`,
410
+ `included content: ${config.include.join(", ") || "none"}`,
411
+ `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
412
+ remoteText,
413
+ formatRemoteSelectionStatus(selectionState),
414
+ `local files: ${local.files.length}`,
415
+ `local changed since last sync: ${localChanged ? "yes" : "no"}`,
416
+ `remote changed since last sync: ${remoteChanged ? "yes" : "no"}`,
417
+ ...warnings
418
+ ].join("\n"),
419
+ localChanged || remoteChanged || selectionState?.kind === "different" || warnings.length > 0 ? "warning" : "info"
420
+ );
421
+ }
422
+ async function diff(ctx, options, factory = createSyncBackend) {
423
+ const config = await loadConfig(options.setup);
424
+ throwIfAborted(options.signal);
425
+ ctx.ui.setStatus(STATUS_KEY, `checking ${config.setupName}`);
426
+ const backend = await backendFor(config, factory);
427
+ const local = await createSnapshot(
428
+ config.snapshotIdentity,
429
+ snapshotOptionsForContext(ctx, config)
430
+ );
431
+ throwIfAborted(options.signal);
432
+ const { snapshot: remote, selectionState } = await readRemoteSnapshot(
433
+ backend,
434
+ config,
435
+ options.signal,
436
+ { allowSelectionDifference: true }
437
+ );
438
+ throwIfAborted(options.signal);
439
+ ctx.ui.setStatus(STATUS_KEY, void 0);
440
+ const warnings = syncSessionsWarnings(config);
441
+ const header = [
442
+ `sync setup: ${config.setupName}`,
443
+ `storage connection: ${config.connectionName}`,
444
+ `storage location: ${safeTerminalText(backend.destination)}`,
445
+ `included content: ${config.include.join(", ") || "none"}`,
446
+ `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
447
+ formatRemoteSelectionStatus(selectionState),
448
+ ...warnings
449
+ ].join("\n");
450
+ const level = warnings.length > 0 || selectionState?.kind === "different" ? "warning" : "info";
451
+ if (!remote) {
452
+ ctx.ui.notify(
453
+ `${header}
454
+
455
+ ${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`,
456
+ level
457
+ );
458
+ return;
459
+ }
460
+ ctx.ui.notify(`${header}
461
+
462
+ ${formatDiff(local, remote)}`, level);
463
+ }
464
+ async function doctor(ctx, options, factory = createSyncBackend) {
465
+ const messages = [];
466
+ let level = "info";
467
+ let snapshotOptions = {};
468
+ let profile = DEFAULT_PROFILE;
469
+ let backend;
470
+ let backendSummary = [];
471
+ try {
472
+ const config = await loadConfig(options.setup);
473
+ throwIfAborted(options.signal);
474
+ backend = await backendFor(config, factory);
475
+ profile = config.snapshotIdentity;
476
+ snapshotOptions = snapshotOptionsForContext(ctx, config);
477
+ messages.push(
478
+ `config: ok (sync setup ${config.setupName})`,
479
+ `included content: ${config.include.join(", ") || "none"}`,
480
+ `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`
481
+ );
482
+ backendSummary = [
483
+ `storage location: ${safeTerminalText(backend.destination)}`,
484
+ `publication safety: ${publicationCapabilityDescription(backend.capability)}`
485
+ ];
486
+ const warnings = syncSessionsWarnings(config);
487
+ if (warnings.length > 0) {
488
+ level = "warning";
489
+ messages.push(...warnings);
490
+ }
491
+ } catch (error) {
492
+ throwIfAborted(options.signal);
493
+ level = "warning";
494
+ messages.push(`config: ${errorMessage(error)}`);
495
+ }
496
+ const local = await createSnapshot(profile, snapshotOptions);
497
+ throwIfAborted(options.signal);
498
+ const secrets = scanSnapshot(local);
499
+ if (secrets.length > 0) {
500
+ level = "warning";
501
+ messages.push("secret scan: possible secrets found:");
502
+ messages.push(...secrets.map((secret) => `- ${secret}`));
503
+ } else {
504
+ messages.push(`secret scan: ok (${local.files.length} files checked)`);
505
+ }
506
+ const lock = await inspectLock();
507
+ throwIfAborted(options.signal);
508
+ if (lock.status === "valid" && isStaleLock(lock.lock)) {
509
+ level = "warning";
510
+ messages.push(
511
+ `lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running`
512
+ );
513
+ } else if (lock.status === "valid") {
514
+ messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
515
+ } else if (lock.status === "unreadable") {
516
+ level = "warning";
517
+ messages.push(
518
+ "lock: unreadable; use /sync unlock --stale only after verifying no sync is running"
519
+ );
520
+ } else if (await isLockGuardHeld()) {
521
+ throwIfAborted(options.signal);
522
+ level = "warning";
523
+ messages.push("lock: guard active while metadata is missing or still being initialized");
524
+ } else {
525
+ messages.push("lock: free");
526
+ }
527
+ if (backend) {
528
+ messages.push(...backendSummary);
529
+ const diagnostics = await backend.diagnose(options.signal);
530
+ throwIfAborted(options.signal);
531
+ for (const diagnostic of diagnostics) {
532
+ messages.push(diagnostic.message);
533
+ if (diagnostic.level !== "info") level = "warning";
534
+ }
535
+ }
536
+ ctx.ui.notify(messages.join("\n"), level);
537
+ }
538
+ async function push(ctx, options, input, factory = createSyncBackend) {
539
+ const config = input?.config ?? await loadConfig(options.setup);
540
+ throwIfAborted(options.signal);
541
+ ctx.ui.setStatus(STATUS_KEY, `pushing ${config.setupName}`);
542
+ const backend = input?.backend ?? await backendFor(config, factory);
543
+ const state = input?.state ?? await readStateForConfig(config);
544
+ throwIfAborted(options.signal);
545
+ const local = input?.local ?? await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
546
+ throwIfAborted(options.signal);
547
+ let head = await backend.readHead(options.signal);
548
+ let remoteForUpload = await readRemoteSnapshotForUpload(
549
+ backend,
550
+ config,
551
+ head,
552
+ state,
553
+ options.signal
554
+ );
555
+ if (!options.force && !remoteForUpload && head?.selection && inspectRemoteSelection(config.include, { selection: head.selection, files: [] }).kind === "different") {
556
+ remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
557
+ }
558
+ if (remoteForUpload && !options.force) {
559
+ requireCompatibleRemoteSelection(config, remoteForUpload);
560
+ }
561
+ if (remoteChangedSinceState(
562
+ head,
563
+ state,
564
+ config,
565
+ (left, right) => backend.sameRevision(left, right)
566
+ ) && !options.force) {
567
+ const remoteForConflict = remoteForUpload ? filterSnapshotForConfigPolicy(remoteForUpload, config) : void 0;
568
+ if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
569
+ throw createSyncDecision({
570
+ kind: head ? "remote-or-policy-changed" : "remote-empty",
571
+ config,
572
+ state,
573
+ local,
574
+ remote: remoteForConflict,
575
+ localChanged: hasLocalChanges(local, state, config),
576
+ remoteChanged: true,
577
+ directMessage: "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force."
578
+ });
579
+ }
580
+ }
581
+ let upload = await snapshotForUpload(
582
+ backend,
583
+ config,
584
+ local,
585
+ head,
586
+ remoteForUpload,
587
+ options.signal
588
+ );
589
+ const secrets = scanSnapshot(local);
590
+ if (secrets.length > 0) {
591
+ throw new Error(
592
+ `Refusing to push possible secrets:
593
+ ${secrets.map((s) => `- ${s}`).join("\n")}`
594
+ );
595
+ }
596
+ if (!await confirmPush(ctx, options, config, backend, local, upload, head, remoteForUpload)) {
597
+ return "cancelled";
598
+ }
599
+ if (options.force) {
600
+ const refreshedHead = await backend.readHead(options.signal);
601
+ if (!sameRemoteHead(backend, head, refreshedHead)) {
602
+ head = refreshedHead;
603
+ remoteForUpload = head ? await backend.readSnapshot(head.snapshotRef, options.signal) : void 0;
604
+ upload = await snapshotForUpload(
605
+ backend,
606
+ config,
607
+ local,
608
+ head,
609
+ remoteForUpload,
610
+ options.signal
611
+ );
612
+ if (!await confirmPush(
613
+ ctx,
614
+ options,
615
+ config,
616
+ backend,
617
+ local,
618
+ upload,
619
+ head,
620
+ remoteForUpload,
621
+ "Remote changed during review. Push the refreshed plan?"
622
+ )) {
623
+ return "cancelled";
624
+ }
625
+ }
626
+ }
627
+ const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
628
+ signal: options.signal,
629
+ onCommit: options.onCommit
630
+ });
631
+ try {
632
+ await writeStateForConfig(config, {
633
+ version: VERSION,
634
+ profile: config.snapshotIdentity,
635
+ lastAppliedSnapshot: result.head.snapshotId,
636
+ lastRemoteRevision: result.head.revision,
637
+ lastFileHashes: fileHashMap(local),
638
+ include: [...config.include]
639
+ });
640
+ } catch (error) {
641
+ throw new PublicationStatePersistenceError(result.head, error);
642
+ }
643
+ if (options.signal?.aborted) return;
644
+ ctx.ui.setStatus(STATUS_KEY, void 0);
645
+ if (!options.silent) {
646
+ ctx.ui.notify(
647
+ [
648
+ `Pushed ${upload.files.length} files from sync setup \u201C${config.setupName}\u201D as ${result.head.snapshotId}.`,
649
+ ...result.warnings
650
+ ].filter(Boolean).join("\n"),
651
+ result.warnings.length > 0 ? "warning" : "info"
652
+ );
653
+ }
654
+ return "applied";
655
+ }
656
+ async function pull(ctx, options, factory = createSyncBackend) {
657
+ const config = await loadConfig(options.setup);
658
+ throwIfAborted(options.signal);
659
+ ctx.ui.setStatus(STATUS_KEY, `pulling ${config.setupName}`);
660
+ const backend = await backendFor(config, factory);
661
+ const state = await readStateForConfig(config);
662
+ throwIfAborted(options.signal);
663
+ const local = await createSnapshot(
664
+ config.snapshotIdentity,
665
+ snapshotOptionsForContext(ctx, config)
666
+ );
667
+ throwIfAborted(options.signal);
668
+ const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
669
+ throwIfAborted(options.signal);
670
+ const localChanged = hasLocalChanges(local, state, config);
671
+ if (!remote) {
672
+ throw createSyncDecision({
673
+ kind: "remote-empty",
674
+ config,
675
+ state,
676
+ local,
677
+ localChanged,
678
+ remoteChanged: false,
679
+ directMessage: "Remote is empty. Run /sync push from a configured machine first."
680
+ });
681
+ }
682
+ const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
683
+ if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
684
+ throw createSyncDecision({
685
+ kind: "both-changed",
686
+ config,
687
+ state,
688
+ local,
689
+ remote,
690
+ localChanged,
691
+ remoteChanged,
692
+ directMessage: "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force."
693
+ });
694
+ }
695
+ if (!options.yes && !await ctx.ui.confirm(
696
+ snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
697
+ formatPullSummary(
698
+ config,
699
+ backend.destination,
700
+ local,
701
+ remote,
702
+ protectedSessionPaths(ctx).size
703
+ )
704
+ )) {
705
+ ctx.ui.setStatus(STATUS_KEY, void 0);
706
+ ctx.ui.notify("Pull cancelled.", "info");
707
+ return "cancelled";
708
+ }
709
+ throwIfAborted(options.signal);
710
+ const backup = await backupLocal(
711
+ config.snapshotIdentity,
712
+ snapshotOptionsForContext(ctx, config),
713
+ options.signal
714
+ );
715
+ const applySessionDir = await sessionDirForApply(ctx, remote);
716
+ throwIfAborted(options.signal);
717
+ options.onCommit?.();
718
+ const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
719
+ include: config.include,
720
+ sessionDir: applySessionDir
721
+ });
722
+ await writeStateForConfig(config, {
723
+ version: VERSION,
724
+ profile: config.snapshotIdentity,
725
+ lastAppliedSnapshot: remote.id,
726
+ lastRemoteRevision: head?.revision,
727
+ lastFileHashes,
728
+ include: [...config.include]
729
+ });
730
+ if (options.signal?.aborted) return "applied";
731
+ ctx.ui.setStatus(STATUS_KEY, void 0);
732
+ if (!options.silent) {
733
+ ctx.ui.notify(
734
+ `Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`,
735
+ "info"
736
+ );
737
+ } else if (options.auto && config.include.includes("sessions") && snapshotIncludesSessions(remote)) {
738
+ ctx.ui.notify(
739
+ "Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
740
+ "warning"
741
+ );
742
+ }
743
+ if (options.reload) await maybeReload(ctx, options.signal);
744
+ return "applied";
745
+ }
746
+ async function syncBoth(ctx, options, factory = createSyncBackend) {
747
+ const config = await loadConfig(options.setup);
748
+ throwIfAborted(options.signal);
749
+ const backend = await backendFor(config, factory);
750
+ const state = await readStateForConfig(config);
751
+ throwIfAborted(options.signal);
752
+ const local = await createSnapshot(
753
+ config.snapshotIdentity,
754
+ snapshotOptionsForContext(ctx, config)
755
+ );
756
+ throwIfAborted(options.signal);
757
+ if (config.include.length === 0) {
758
+ if (!options.silent) {
759
+ ctx.ui.notify(
760
+ `Sync setup \u201C${config.setupName}\u201D includes no files. Choose included content in /sync Settings before syncing.`,
761
+ "warning"
762
+ );
763
+ }
764
+ return;
765
+ }
766
+ const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
767
+ throwIfAborted(options.signal);
768
+ const localChanged = hasLocalChanges(local, state, config);
769
+ const remoteChanged = remote ? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx)) : false;
770
+ const firstSync = !state.lastAppliedSnapshot;
771
+ if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
772
+ if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
773
+ throw createSyncDecision({
774
+ kind: "first-sync-settings-diverged",
775
+ config,
776
+ state,
777
+ local,
778
+ remote,
779
+ localChanged: true,
780
+ remoteChanged: true,
781
+ directMessage: "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push."
782
+ });
783
+ }
784
+ if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
785
+ if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
786
+ throw createSyncDecision({
787
+ kind: "first-sync-sessions-diverged",
788
+ config,
789
+ state,
790
+ local,
791
+ remote,
792
+ localChanged: true,
793
+ remoteChanged: true,
794
+ directMessage: "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push."
795
+ });
796
+ }
797
+ await pull(ctx, options, factory);
798
+ return;
799
+ }
800
+ await writeStateForConfig(config, {
801
+ version: VERSION,
802
+ profile: config.snapshotIdentity,
803
+ lastAppliedSnapshot: remote.id,
804
+ lastRemoteRevision: head?.revision,
805
+ lastFileHashes: fileHashMap(remote),
806
+ include: [...config.include]
807
+ });
808
+ if (!options.silent)
809
+ ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
810
+ return;
811
+ }
812
+ if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
813
+ await writeStateForConfig(config, {
814
+ version: VERSION,
815
+ profile: config.snapshotIdentity,
816
+ lastAppliedSnapshot: remote.id,
817
+ lastRemoteRevision: head?.revision,
818
+ lastFileHashes: fileHashMap(remote),
819
+ include: [...config.include]
820
+ });
821
+ if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
822
+ return;
823
+ }
824
+ if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
825
+ throw createSyncDecision({
826
+ kind: "both-changed",
827
+ config,
828
+ state,
829
+ local,
830
+ remote,
831
+ localChanged,
832
+ remoteChanged,
833
+ directMessage: "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force."
834
+ });
835
+ }
836
+ if (remoteChanged) {
837
+ await pull(ctx, options, factory);
838
+ return;
839
+ }
840
+ if (localChanged || !remote) {
841
+ await push(ctx, options, void 0, factory);
842
+ return;
843
+ }
844
+ if (shouldRefreshSyncedState(
845
+ remote,
846
+ head,
847
+ state,
848
+ config,
849
+ (left, right) => backend.sameRevision(left, right)
850
+ )) {
851
+ await writeStateForConfig(config, {
852
+ version: VERSION,
853
+ profile: config.snapshotIdentity,
854
+ lastAppliedSnapshot: remote.id,
855
+ lastRemoteRevision: head?.revision,
856
+ lastFileHashes: fileHashMap(remote),
857
+ include: [...config.include]
858
+ });
859
+ }
860
+ if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
861
+ }
862
+ async function history(ctx, options, factory = createSyncBackend) {
863
+ const config = await loadConfig(options.setup);
864
+ throwIfAborted(options.signal);
865
+ const backend = await backendFor(config, factory);
866
+ const snapshots = (await backend.listHistory(options.signal)).slice(-20).reverse();
867
+ throwIfAborted(options.signal);
868
+ if (snapshots.length === 0) {
869
+ ctx.ui.notify("No remote pi-sync history found.", "info");
870
+ return;
871
+ }
872
+ const currentSnapshot = snapshots[0]?.snapshotId;
873
+ if (ctx.mode === "tui") {
874
+ const labels = snapshots.map(
875
+ (item, index2) => `${index2 + 1}. ${item.createdAt} \xB7 ${safeTerminalText(item.machine)} \xB7 ${item.snapshotId}${item.snapshotId === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " \xB7 sessions" : ""}`
876
+ );
877
+ const selected = await ctx.ui.select(
878
+ `History for sync setup \u201C${safeTerminalText(config.setupName)}\u201D
879
+
880
+ Choose a snapshot to preview rollback.`,
881
+ [...labels, "Back"]
882
+ );
883
+ if (!selected || selected === "Back") return;
884
+ throwIfAborted(options.signal);
885
+ const index = labels.indexOf(selected);
886
+ const snapshot = snapshots[index];
887
+ if (!snapshot) return;
888
+ await withLock(
889
+ "rollback",
890
+ () => rollback(ctx, { ...options, args: [snapshot.snapshotRef], yes: false }, factory, {
891
+ backendIdentity: backend.identity,
892
+ setup: config.setupName
893
+ })
894
+ );
895
+ return;
896
+ }
897
+ ctx.ui.notify(
898
+ snapshots.map((item) => `${item.snapshotRef} ${item.createdAt} ${safeTerminalText(item.machine)}`).join("\n"),
899
+ "info"
900
+ );
901
+ }
902
+ async function rollback(ctx, options, factory = createSyncBackend, expectedSelection) {
903
+ const target = options.args[0];
904
+ if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
905
+ const config = await loadConfig(options.setup);
906
+ throwIfAborted(options.signal);
907
+ const backend = await backendFor(config, factory);
908
+ if (expectedSelection && (backend.identity !== expectedSelection.backendIdentity || config.setupName !== expectedSelection.setup)) {
909
+ throw new Error(
910
+ "Sync setup or storage location changed while history was open; reopen history and retry."
911
+ );
912
+ }
913
+ const decoded = await backend.readSnapshot(target, options.signal);
914
+ const selected = filterSnapshotForConfigPolicy(
915
+ config.include.includes("sessions") ? decoded : snapshotWithoutSessions(decoded),
916
+ config
917
+ );
918
+ const remote = regenerateSnapshotIdentity(selected);
919
+ const local = await createSnapshot(
920
+ config.snapshotIdentity,
921
+ snapshotOptionsForContext(ctx, config)
922
+ );
923
+ const expectedHead = await backend.readHead(options.signal);
924
+ throwIfAborted(options.signal);
925
+ if (!options.yes && !await ctx.ui.confirm(
926
+ snapshotIncludesSessions(remote) ? "Rollback pi settings and sessions?" : "Rollback pi settings?",
927
+ formatRollbackSummary(
928
+ config,
929
+ backend.destination,
930
+ local,
931
+ remote,
932
+ target,
933
+ protectedSessionPaths(ctx).size
934
+ )
935
+ )) {
936
+ ctx.ui.notify("Rollback cancelled.", "info");
937
+ return;
938
+ }
939
+ throwIfAborted(options.signal);
940
+ const backup = await backupLocal(
941
+ config.snapshotIdentity,
942
+ snapshotOptionsForContext(ctx, config),
943
+ options.signal
944
+ );
945
+ const applySessionDir = await sessionDirForApply(ctx, remote);
946
+ throwIfAborted(options.signal);
947
+ options.onCommit?.();
948
+ const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
949
+ include: config.include,
950
+ sessionDir: applySessionDir
951
+ });
952
+ let result;
953
+ try {
954
+ const completionSignal = AbortSignal.timeout(POST_LOCAL_COMMIT_TIMEOUT_MS);
955
+ const upload = await snapshotForUpload(
956
+ backend,
957
+ config,
958
+ remote,
959
+ expectedHead,
960
+ void 0,
961
+ completionSignal,
962
+ { ignoreUnreadableRemote: true }
963
+ );
964
+ result = await backend.publishSnapshot(upload, expectedRemoteHead(expectedHead), {
965
+ signal: completionSignal
966
+ });
967
+ } catch (error) {
968
+ throw new RollbackPublicationError(backup, error);
969
+ }
970
+ try {
971
+ await writeStateForConfig(config, {
972
+ version: VERSION,
973
+ profile: config.snapshotIdentity,
974
+ lastAppliedSnapshot: result.head.snapshotId,
975
+ lastRemoteRevision: result.head.revision,
976
+ lastFileHashes,
977
+ include: [...config.include]
978
+ });
979
+ } catch (error) {
980
+ throw new PublicationStatePersistenceError(result.head, error, backup);
981
+ }
982
+ if (options.signal?.aborted) return;
983
+ ctx.ui.notify(
984
+ [
985
+ `Rolled back sync setup \u201C${config.setupName}\u201D to ${target}; latest: ${result.head.snapshotId}. Backup: ${backup}`,
986
+ ...result.warnings
987
+ ].filter(Boolean).join("\n"),
988
+ result.warnings.length > 0 ? "warning" : "info"
989
+ );
990
+ await maybeReload(ctx, options.signal);
991
+ }
992
+ function protectedSessionPaths(ctx) {
993
+ const getSessionFile = ctx.sessionManager.getSessionFile;
994
+ if (typeof getSessionFile !== "function") return /* @__PURE__ */ new Set();
995
+ const sessionFile = getSessionFile.call(ctx.sessionManager);
996
+ const snapshotPath = sessionFile ? sessionSnapshotPathFromAbsolute(sessionFile, sessionDirFromContext(ctx)) : void 0;
997
+ return snapshotPath ? /* @__PURE__ */ new Set([snapshotPath]) : /* @__PURE__ */ new Set();
998
+ }
999
+ function snapshotOptionsForContext(ctx, config) {
1000
+ return {
1001
+ include: config.include,
1002
+ sessionDir: sessionDirFromContext(ctx)
1003
+ };
1004
+ }
1005
+ function sessionDirFromContext(ctx) {
1006
+ const manager = ctx.sessionManager;
1007
+ const usesDefaultSessionDir = manager.usesDefaultSessionDir;
1008
+ if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
1009
+ return void 0;
1010
+ }
1011
+ const getSessionDir = manager.getSessionDir;
1012
+ return typeof getSessionDir === "function" ? getSessionDir.call(manager) : void 0;
1013
+ }
1014
+ async function maybeReload(ctx, signal) {
1015
+ if (signal?.aborted || !("reload" in ctx)) return;
1016
+ if (ctx.hasUI && await ctx.ui.confirm(
1017
+ "Reload Pi resources now?",
1018
+ "This reloads extensions, skills, prompts, themes, and context files."
1019
+ )) {
1020
+ if (signal?.aborted) return;
1021
+ await ctx.reload();
1022
+ }
1023
+ }
1024
+ async function readRemoteSnapshotForUpload(backend, config, head, state, signal) {
1025
+ if (!head || head.snapshotId === state.lastAppliedSnapshot && !syncPolicyChanged(state, config) && (!state.lastRemoteRevision || backend.sameRevision(head.revision, state.lastRemoteRevision))) {
1026
+ return void 0;
1027
+ }
1028
+ return backend.readSnapshot(head.snapshotRef, signal);
1029
+ }
1030
+ async function snapshotForUpload(backend, config, local, head, remote, signal, options = {}) {
1031
+ if (!head) return local;
1032
+ let snapshot = remote;
1033
+ if (!snapshot) {
1034
+ try {
1035
+ snapshot = await backend.readSnapshot(head.snapshotRef, signal);
1036
+ } catch (error) {
1037
+ if (options.ignoreUnreadableRemote) return local;
1038
+ throw error;
1039
+ }
1040
+ }
1041
+ return mergeRemotePreservedFiles(local, snapshot, config);
1042
+ }
1043
+ async function readRemoteSnapshot(backend, config, signal, options = {}) {
1044
+ const head = await backend.readHead(signal);
1045
+ if (!head) return { head: void 0, snapshot: void 0, selectionState: void 0 };
1046
+ const snapshot = await readSnapshotForHead(backend, head, signal);
1047
+ const selectionState = inspectRemoteSelection(config.include, snapshot);
1048
+ if (!options.allowSelectionDifference && selectionState.kind === "different") {
1049
+ const configIdentity = syncConfigReviewFingerprint(config);
1050
+ throw remoteSelectionMismatch(config, selectionState.include, configIdentity);
1051
+ }
1052
+ return {
1053
+ head,
1054
+ snapshot: filterSnapshotForConfigPolicy(snapshot, config),
1055
+ selectionState
1056
+ };
1057
+ }
1058
+ async function confirmPush(ctx, options, config, backend, local, upload, head, remote, title = snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?") {
1059
+ throwIfAborted(options.signal);
1060
+ if (options.yes) return true;
1061
+ const confirmed = await ctx.ui.confirm(
1062
+ title,
1063
+ formatPushSummary(
1064
+ config,
1065
+ backend.destination,
1066
+ upload,
1067
+ head,
1068
+ countPreservedRemoteFiles(local, upload),
1069
+ remote
1070
+ )
1071
+ );
1072
+ throwIfAborted(options.signal);
1073
+ if (confirmed) return true;
1074
+ ctx.ui.setStatus(STATUS_KEY, void 0);
1075
+ ctx.ui.notify("Push cancelled.", "info");
1076
+ return false;
1077
+ }
1078
+ function throwIfAborted(signal) {
1079
+ if (!signal?.aborted) return;
1080
+ throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
1081
+ }
1082
+ function sameRemoteHead(backend, left, right) {
1083
+ if (!left || !right) return left === right;
1084
+ return backend.sameRevision(left.revision, right.revision);
1085
+ }
1086
+ async function backupLocal(profile, options = {}, signal) {
1087
+ throwIfAborted(signal);
1088
+ const snapshot = await createSnapshot(profile, options);
1089
+ throwIfAborted(signal);
1090
+ const backupDirectory = path2.join(stateDir(), "backups");
1091
+ await fs2.mkdir(backupDirectory, { recursive: true });
1092
+ throwIfAborted(signal);
1093
+ const backupPath = path2.join(backupDirectory, `${snapshot.id}.json.gz`);
1094
+ const encoded = await encodeSnapshot(snapshot);
1095
+ throwIfAborted(signal);
1096
+ await fs2.writeFile(backupPath, encoded, { signal });
1097
+ return backupPath;
1098
+ }
1099
+ export {
1100
+ PublicationStatePersistenceError,
1101
+ RollbackPublicationError,
1102
+ backupLocal,
1103
+ diff,
1104
+ doctor,
1105
+ history,
1106
+ pull,
1107
+ push,
1108
+ rollback,
1109
+ status,
1110
+ syncBoth
1111
+ };
1112
+ //# sourceMappingURL=sync-operations-MPAFVVHA.ts.map