@narumitw/pi-sync 0.49.10 → 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.
- package/README.md +10 -1
- package/dist/chunks/chunk-3GRWMWUZ.ts +213 -0
- package/dist/chunks/chunk-3GRWMWUZ.ts.map +7 -0
- package/dist/chunks/chunk-3NDD5QPM.ts +203 -0
- package/dist/chunks/chunk-3NDD5QPM.ts.map +7 -0
- package/dist/chunks/chunk-4F3W4CSC.ts +195 -0
- package/dist/chunks/chunk-4F3W4CSC.ts.map +7 -0
- package/dist/chunks/chunk-4U6AFLJB.ts +221 -0
- package/dist/chunks/chunk-4U6AFLJB.ts.map +7 -0
- package/dist/chunks/chunk-5MQ76BK5.ts +34 -0
- package/dist/chunks/chunk-5MQ76BK5.ts.map +7 -0
- package/dist/chunks/chunk-APZVBSM6.ts +1238 -0
- package/dist/chunks/chunk-APZVBSM6.ts.map +7 -0
- package/dist/chunks/chunk-BHJUISB4.ts +134 -0
- package/dist/chunks/chunk-BHJUISB4.ts.map +7 -0
- package/dist/chunks/chunk-D3S54OXI.ts +653 -0
- package/dist/chunks/chunk-D3S54OXI.ts.map +7 -0
- package/dist/chunks/chunk-EWX3TPLQ.ts +356 -0
- package/dist/chunks/chunk-EWX3TPLQ.ts.map +7 -0
- package/dist/chunks/chunk-F5QL2GPY.ts +341 -0
- package/dist/chunks/chunk-F5QL2GPY.ts.map +7 -0
- package/dist/chunks/chunk-FR6CVXAL.ts +81 -0
- package/dist/chunks/chunk-FR6CVXAL.ts.map +7 -0
- package/dist/chunks/chunk-HGV24P4T.ts +53 -0
- package/dist/chunks/chunk-HGV24P4T.ts.map +7 -0
- package/dist/chunks/chunk-JNOOSPEW.ts +82 -0
- package/dist/chunks/chunk-JNOOSPEW.ts.map +7 -0
- package/dist/chunks/chunk-LJDSHNS3.ts +259 -0
- package/dist/chunks/chunk-LJDSHNS3.ts.map +7 -0
- package/dist/chunks/chunk-RGJ6SOYR.ts +128 -0
- package/dist/chunks/chunk-RGJ6SOYR.ts.map +7 -0
- package/dist/chunks/chunk-W64NEHXT.ts +167 -0
- package/dist/chunks/chunk-W64NEHXT.ts.map +7 -0
- package/dist/chunks/chunk-YQ6UW7IF.ts +394 -0
- package/dist/chunks/chunk-YQ6UW7IF.ts.map +7 -0
- package/dist/chunks/file-selection-4UABOP44.ts +290 -0
- package/dist/chunks/file-selection-4UABOP44.ts.map +7 -0
- package/dist/chunks/git-backend-E3UOUUOM.ts +1104 -0
- package/dist/chunks/git-backend-E3UOUUOM.ts.map +7 -0
- package/dist/chunks/manager-result-dispatcher-YKBLZ7KL.ts +18 -0
- package/dist/chunks/manager-result-dispatcher-YKBLZ7KL.ts.map +7 -0
- package/dist/chunks/manager-ui-7E25VWUP.ts +2754 -0
- package/dist/chunks/manager-ui-7E25VWUP.ts.map +7 -0
- package/dist/chunks/remote-selection-ui-J6OW5DKZ.ts +17 -0
- package/dist/chunks/remote-selection-ui-J6OW5DKZ.ts.map +7 -0
- package/dist/chunks/s3-backend-3QZGO2EF.ts +680 -0
- package/dist/chunks/s3-backend-3QZGO2EF.ts.map +7 -0
- package/dist/chunks/setup-switch-X3W3FALZ.ts +27 -0
- package/dist/chunks/setup-switch-X3W3FALZ.ts.map +7 -0
- package/dist/chunks/snapshot-WBSV4MQG.ts +45 -0
- package/dist/chunks/snapshot-WBSV4MQG.ts.map +7 -0
- package/dist/chunks/sync-operations-MPAFVVHA.ts +1112 -0
- package/dist/chunks/sync-operations-MPAFVVHA.ts.map +7 -0
- package/dist/chunks/sync-state-36H7ULG2.ts +41 -0
- package/dist/chunks/sync-state-36H7ULG2.ts.map +7 -0
- package/dist/chunks/webdav-backend-ZD5RHRIU.ts +869 -0
- package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +7 -0
- package/dist/index.ts +668 -0
- package/dist/index.ts.map +7 -0
- package/package.json +10 -6
|
@@ -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
|