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