@narumitw/pi-sync 0.24.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 +11 -2
- package/src/command.ts +61 -18
- package/src/config.ts +121 -122
- package/src/file-selection.ts +192 -0
- package/src/index.ts +1 -0
- package/src/lock.ts +301 -0
- 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 +164 -239
- package/src/types.ts +4 -0
package/src/sync.ts
CHANGED
|
@@ -1,38 +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,
|
|
15
|
-
|
|
19
|
+
loadConfig,
|
|
16
20
|
loadPartialConfig,
|
|
17
21
|
localConfigPath,
|
|
18
|
-
|
|
22
|
+
localConfigTemplate,
|
|
19
23
|
normalizeExtraFiles,
|
|
20
|
-
|
|
21
|
-
stateDir,
|
|
22
|
-
writeJson,
|
|
23
|
-
configuredSessionDir,
|
|
24
|
-
isCloudflareR2Endpoint,
|
|
25
|
-
isEnabled,
|
|
26
|
-
isExplicitlyEnabled,
|
|
27
|
-
loadConfig,
|
|
24
|
+
normalizeSyncFiles,
|
|
28
25
|
readState,
|
|
29
26
|
sessionDirForApply,
|
|
30
27
|
sessionTokenWarnings,
|
|
28
|
+
stateDir,
|
|
31
29
|
syncSessionsWarnings,
|
|
32
|
-
|
|
30
|
+
writeLocalConfigObject,
|
|
33
31
|
writeState,
|
|
34
32
|
} from "./config.js";
|
|
35
|
-
import {
|
|
33
|
+
import { showFileSelection } from "./file-selection.js";
|
|
34
|
+
import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
|
|
36
35
|
import {
|
|
37
36
|
historyKey,
|
|
38
37
|
latestKey,
|
|
@@ -42,27 +41,28 @@ import {
|
|
|
42
41
|
snapshotKey,
|
|
43
42
|
} from "./s3-client.js";
|
|
44
43
|
import {
|
|
45
|
-
addTopLevelCaseVariantDeletes,
|
|
46
|
-
appliedFileHashMap,
|
|
47
|
-
applySnapshot,
|
|
48
|
-
preflightSnapshotApply,
|
|
49
|
-
protectSnapshotApplyPlan,
|
|
50
|
-
} from "./snapshot-apply.js";
|
|
51
|
-
import {
|
|
52
|
-
collectFiles,
|
|
53
44
|
createSnapshot,
|
|
54
|
-
canonicalSnapshotPathForConfig,
|
|
55
45
|
filterSnapshotForConfigPolicy,
|
|
56
|
-
isConfiguredSnapshotPath,
|
|
57
|
-
isDeniedPath,
|
|
58
|
-
isSessionPath,
|
|
59
|
-
sessionSnapshotPathFromAbsolute,
|
|
60
46
|
mergeRemotePreservedFiles,
|
|
61
|
-
mergeRemoteSessionFiles,
|
|
62
47
|
scanSnapshot,
|
|
48
|
+
sessionSnapshotPathFromAbsolute,
|
|
63
49
|
snapshotIncludesSessions,
|
|
64
50
|
snapshotWithoutSessions,
|
|
65
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";
|
|
66
66
|
import type {
|
|
67
67
|
CommandOptions,
|
|
68
68
|
LatestPointer,
|
|
@@ -76,12 +76,11 @@ import type {
|
|
|
76
76
|
const gzipAsync = promisify(gzip);
|
|
77
77
|
const gunzipAsync = promisify(gunzip);
|
|
78
78
|
|
|
79
|
-
const STATUS_KEY = "
|
|
79
|
+
const STATUS_KEY = "sync";
|
|
80
80
|
const VERSION = 1;
|
|
81
81
|
const DEFAULT_PROFILE = "default";
|
|
82
82
|
const DEFAULT_PREFIX = "pi-sync";
|
|
83
83
|
const DEFAULT_REGION = "auto";
|
|
84
|
-
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
85
84
|
|
|
86
85
|
interface PushInput {
|
|
87
86
|
config: SyncConfig;
|
|
@@ -100,7 +99,7 @@ const AUTO_SYNC_OPTIONS: CommandOptions = {
|
|
|
100
99
|
args: [],
|
|
101
100
|
};
|
|
102
101
|
export default function sync(pi: ExtensionAPI) {
|
|
103
|
-
pi.registerCommand("
|
|
102
|
+
pi.registerCommand("sync", {
|
|
104
103
|
description: "Sync Pi settings through Cloudflare R2 or S3-compatible storage",
|
|
105
104
|
getArgumentCompletions: completeSyncArguments,
|
|
106
105
|
handler: async (args, ctx) => {
|
|
@@ -114,17 +113,19 @@ export default function sync(pi: ExtensionAPI) {
|
|
|
114
113
|
});
|
|
115
114
|
|
|
116
115
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
117
|
-
const reason =
|
|
116
|
+
const reason =
|
|
117
|
+
typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
|
|
118
118
|
if (reason !== "reload") await autoPushSessions(ctx);
|
|
119
119
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
120
120
|
});
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
124
|
-
const [subcommand = "status", ...rest] = splitArgs(rawArgs);
|
|
125
|
-
const options = parseOptions(rest);
|
|
126
|
-
|
|
127
124
|
try {
|
|
125
|
+
const command = await resolveSyncCommand(rawArgs, ctx);
|
|
126
|
+
if (!command) return;
|
|
127
|
+
const { subcommand, rest } = command;
|
|
128
|
+
const options = parseOptions(rest);
|
|
128
129
|
await ensureStateDir();
|
|
129
130
|
|
|
130
131
|
switch (subcommand) {
|
|
@@ -137,6 +138,9 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
|
137
138
|
case "config":
|
|
138
139
|
await showConfig(ctx);
|
|
139
140
|
return;
|
|
141
|
+
case "files":
|
|
142
|
+
await showFileSelection(ctx);
|
|
143
|
+
return;
|
|
140
144
|
case "status":
|
|
141
145
|
await status(ctx);
|
|
142
146
|
return;
|
|
@@ -165,7 +169,7 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
|
165
169
|
await unlock(ctx, options);
|
|
166
170
|
return;
|
|
167
171
|
default:
|
|
168
|
-
ctx.ui.notify(`Unknown /
|
|
172
|
+
ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
|
|
169
173
|
}
|
|
170
174
|
} catch (error) {
|
|
171
175
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -218,20 +222,8 @@ async function initConfig(ctx: ExtensionCommandContext) {
|
|
|
218
222
|
// Create below.
|
|
219
223
|
}
|
|
220
224
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
bucket: "pi-sync",
|
|
224
|
-
region: DEFAULT_REGION,
|
|
225
|
-
accessKeyId: "<access-key-id>",
|
|
226
|
-
secretAccessKey: "<secret-access-key>",
|
|
227
|
-
profile: DEFAULT_PROFILE,
|
|
228
|
-
prefix: DEFAULT_PREFIX,
|
|
229
|
-
autoSync: true,
|
|
230
|
-
syncSessions: false,
|
|
231
|
-
extraFiles: [],
|
|
232
|
-
};
|
|
233
|
-
await writeJson(configPath, sample);
|
|
234
|
-
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");
|
|
235
227
|
}
|
|
236
228
|
|
|
237
229
|
async function showConfig(ctx: ExtensionCommandContext) {
|
|
@@ -250,6 +242,7 @@ async function showConfig(ctx: ExtensionCommandContext) {
|
|
|
250
242
|
`profile: ${partial.profile ?? DEFAULT_PROFILE}`,
|
|
251
243
|
`prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
|
|
252
244
|
`autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
|
|
245
|
+
`syncFiles: ${normalizeSyncFiles(partial.syncFiles).join(", ") || "none"}`,
|
|
253
246
|
`syncSessions: ${syncSessions ? "enabled" : "disabled"}`,
|
|
254
247
|
`extraFiles: ${normalizeExtraFiles(partial.extraFiles).join(", ") || "none"}`,
|
|
255
248
|
`local config: ${localConfigPath()}`,
|
|
@@ -269,7 +262,7 @@ async function status(ctx: ExtensionCommandContext) {
|
|
|
269
262
|
const localChanged = hasLocalChanges(local, state, config);
|
|
270
263
|
|
|
271
264
|
let remoteText = "remote: empty";
|
|
272
|
-
|
|
265
|
+
const remoteChanged = remoteChangedSinceState(latest, state, config);
|
|
273
266
|
if (!latest.missing && latest.value) {
|
|
274
267
|
remoteText = `remote: ${latest.value.snapshot} from ${latest.value.machine} at ${latest.value.createdAt}`;
|
|
275
268
|
}
|
|
@@ -279,6 +272,8 @@ async function status(ctx: ExtensionCommandContext) {
|
|
|
279
272
|
ctx.ui.notify(
|
|
280
273
|
[
|
|
281
274
|
`profile: ${config.profile}`,
|
|
275
|
+
`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
|
|
276
|
+
`extra files: ${config.extraFiles.join(", ") || "none"}`,
|
|
282
277
|
`sessions: ${config.syncSessions ? "included" : "excluded"}`,
|
|
283
278
|
remoteText,
|
|
284
279
|
`local files: ${local.files.length}`,
|
|
@@ -299,9 +294,12 @@ async function diff(ctx: ExtensionCommandContext) {
|
|
|
299
294
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
300
295
|
|
|
301
296
|
const warnings = syncSessionsWarnings(config);
|
|
302
|
-
const header = [
|
|
303
|
-
"
|
|
304
|
-
|
|
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");
|
|
305
303
|
const level = warnings.length > 0 ? "warning" : "info";
|
|
306
304
|
if (!remote) {
|
|
307
305
|
ctx.ui.notify(
|
|
@@ -325,6 +323,8 @@ async function doctor(ctx: ExtensionCommandContext) {
|
|
|
325
323
|
profile = config.profile;
|
|
326
324
|
snapshotOptions = snapshotOptionsForContext(ctx, config);
|
|
327
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"}`);
|
|
328
328
|
messages.push(`sessions: ${config.syncSessions ? "included" : "excluded"}`);
|
|
329
329
|
const warnings = [...sessionTokenWarnings(config), ...syncSessionsWarnings(config)];
|
|
330
330
|
if (warnings.length > 0) {
|
|
@@ -346,8 +346,25 @@ async function doctor(ctx: ExtensionCommandContext) {
|
|
|
346
346
|
messages.push(`secret scan: ok (${local.files.length} files checked)`);
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
-
const lock = await
|
|
350
|
-
|
|
349
|
+
const lock = await inspectLock();
|
|
350
|
+
if (lock.status === "valid" && isStaleLock(lock.lock)) {
|
|
351
|
+
level = "warning";
|
|
352
|
+
messages.push(
|
|
353
|
+
`lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running`,
|
|
354
|
+
);
|
|
355
|
+
} else if (lock.status === "valid") {
|
|
356
|
+
messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
|
|
357
|
+
} else if (lock.status === "unreadable") {
|
|
358
|
+
level = "warning";
|
|
359
|
+
messages.push(
|
|
360
|
+
"lock: unreadable; use /sync unlock --stale only after verifying no sync is running",
|
|
361
|
+
);
|
|
362
|
+
} else if (await isLockGuardHeld()) {
|
|
363
|
+
level = "warning";
|
|
364
|
+
messages.push("lock: guard active while metadata is missing or still being initialized");
|
|
365
|
+
} else {
|
|
366
|
+
messages.push("lock: free");
|
|
367
|
+
}
|
|
351
368
|
ctx.ui.notify(messages.join("\n"), level);
|
|
352
369
|
}
|
|
353
370
|
|
|
@@ -360,29 +377,28 @@ async function push(
|
|
|
360
377
|
const config = input?.config ?? (await loadConfig());
|
|
361
378
|
const client = input?.client ?? new S3Client(config);
|
|
362
379
|
const state = input?.state ?? (await readState(config.profile));
|
|
363
|
-
const local =
|
|
380
|
+
const local =
|
|
381
|
+
input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
|
|
364
382
|
|
|
365
383
|
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
366
|
-
const remoteForUpload =
|
|
367
|
-
? undefined
|
|
368
|
-
: await readRemoteSnapshotForUpload(client, config, latest, state);
|
|
384
|
+
const remoteForUpload = await readRemoteSnapshotForUpload(client, config, latest, state);
|
|
369
385
|
if (remoteChangedSinceState(latest, state, config) && !options.force) {
|
|
370
386
|
const remoteForConflict = remoteForUpload
|
|
371
387
|
? filterSnapshotForConfigPolicy(remoteForUpload, config)
|
|
372
388
|
: undefined;
|
|
373
389
|
if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
|
|
374
390
|
throw new Error(
|
|
375
|
-
"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.",
|
|
376
392
|
);
|
|
377
393
|
}
|
|
378
394
|
}
|
|
379
395
|
|
|
380
|
-
const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload
|
|
381
|
-
preserveRemote: !options.force,
|
|
382
|
-
});
|
|
396
|
+
const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload);
|
|
383
397
|
const secrets = scanSnapshot(local);
|
|
384
398
|
if (secrets.length > 0) {
|
|
385
|
-
throw new Error(
|
|
399
|
+
throw new Error(
|
|
400
|
+
`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`,
|
|
401
|
+
);
|
|
386
402
|
}
|
|
387
403
|
|
|
388
404
|
const preservedRemoteFileCount = countPreservedRemoteFiles(local, upload);
|
|
@@ -406,6 +422,7 @@ async function push(
|
|
|
406
422
|
lastAppliedSnapshot: pointer.snapshot,
|
|
407
423
|
lastRemoteEtag: undefined,
|
|
408
424
|
lastFileHashes: fileHashMap(local),
|
|
425
|
+
syncFiles: config.syncFiles,
|
|
409
426
|
syncSessions: config.syncSessions,
|
|
410
427
|
extraFiles: config.extraFiles,
|
|
411
428
|
});
|
|
@@ -422,12 +439,14 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
422
439
|
const state = await readState(config.profile);
|
|
423
440
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
424
441
|
const remote = await readRemoteSnapshot(client, config);
|
|
425
|
-
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.");
|
|
426
443
|
|
|
427
444
|
const localChanged = hasLocalChanges(local, state, config);
|
|
428
445
|
const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
|
|
429
446
|
if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
|
|
430
|
-
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
|
+
);
|
|
431
450
|
}
|
|
432
451
|
|
|
433
452
|
if (
|
|
@@ -445,6 +464,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
445
464
|
const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
|
|
446
465
|
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
447
466
|
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
|
|
467
|
+
syncFiles: config.syncFiles,
|
|
448
468
|
sessionDir: applySessionDir,
|
|
449
469
|
extraFiles: config.extraFiles,
|
|
450
470
|
});
|
|
@@ -454,12 +474,16 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
454
474
|
lastAppliedSnapshot: remote.id,
|
|
455
475
|
lastRemoteEtag: undefined,
|
|
456
476
|
lastFileHashes,
|
|
477
|
+
syncFiles: config.syncFiles,
|
|
457
478
|
syncSessions: config.syncSessions,
|
|
458
479
|
extraFiles: config.extraFiles,
|
|
459
480
|
});
|
|
460
481
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
461
482
|
if (!options.silent) {
|
|
462
|
-
ctx.ui.notify(
|
|
483
|
+
ctx.ui.notify(
|
|
484
|
+
`Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`,
|
|
485
|
+
"info",
|
|
486
|
+
);
|
|
463
487
|
} else if (options.auto && config.syncSessions && snapshotIncludesSessions(remote)) {
|
|
464
488
|
ctx.ui.notify(
|
|
465
489
|
"Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
|
|
@@ -483,11 +507,15 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
483
507
|
|
|
484
508
|
if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
|
|
485
509
|
if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
|
|
486
|
-
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
|
+
);
|
|
487
513
|
}
|
|
488
514
|
if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
|
|
489
515
|
if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
|
|
490
|
-
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
|
+
);
|
|
491
519
|
}
|
|
492
520
|
await pull(ctx, options);
|
|
493
521
|
return;
|
|
@@ -498,10 +526,12 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
498
526
|
lastAppliedSnapshot: remote.id,
|
|
499
527
|
lastRemoteEtag: undefined,
|
|
500
528
|
lastFileHashes: fileHashMap(remote),
|
|
529
|
+
syncFiles: config.syncFiles,
|
|
501
530
|
syncSessions: config.syncSessions,
|
|
502
531
|
extraFiles: config.extraFiles,
|
|
503
532
|
});
|
|
504
|
-
if (!options.silent)
|
|
533
|
+
if (!options.silent)
|
|
534
|
+
ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
|
|
505
535
|
return;
|
|
506
536
|
}
|
|
507
537
|
if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
|
|
@@ -511,6 +541,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
511
541
|
lastAppliedSnapshot: remote.id,
|
|
512
542
|
lastRemoteEtag: undefined,
|
|
513
543
|
lastFileHashes: fileHashMap(remote),
|
|
544
|
+
syncFiles: config.syncFiles,
|
|
514
545
|
syncSessions: config.syncSessions,
|
|
515
546
|
extraFiles: config.extraFiles,
|
|
516
547
|
});
|
|
@@ -518,7 +549,9 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
518
549
|
return;
|
|
519
550
|
}
|
|
520
551
|
if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
|
|
521
|
-
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
|
+
);
|
|
522
555
|
}
|
|
523
556
|
if (remoteChanged) {
|
|
524
557
|
await pull(ctx, options);
|
|
@@ -535,6 +568,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
535
568
|
lastAppliedSnapshot: remote.id,
|
|
536
569
|
lastRemoteEtag: undefined,
|
|
537
570
|
lastFileHashes: fileHashMap(remote),
|
|
571
|
+
syncFiles: config.syncFiles,
|
|
538
572
|
syncSessions: config.syncSessions,
|
|
539
573
|
extraFiles: config.extraFiles,
|
|
540
574
|
});
|
|
@@ -563,7 +597,7 @@ async function history(ctx: ExtensionCommandContext) {
|
|
|
563
597
|
|
|
564
598
|
async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
565
599
|
const target = options.args[0];
|
|
566
|
-
if (!target) throw new Error("Usage: /
|
|
600
|
+
if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
|
|
567
601
|
|
|
568
602
|
const config = await loadConfig();
|
|
569
603
|
const client = new S3Client(config);
|
|
@@ -592,6 +626,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
592
626
|
const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
|
|
593
627
|
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
594
628
|
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
|
|
629
|
+
syncFiles: config.syncFiles,
|
|
595
630
|
sessionDir: applySessionDir,
|
|
596
631
|
extraFiles: config.extraFiles,
|
|
597
632
|
});
|
|
@@ -612,6 +647,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
612
647
|
lastAppliedSnapshot: pointer.snapshot,
|
|
613
648
|
lastRemoteEtag: undefined,
|
|
614
649
|
lastFileHashes,
|
|
650
|
+
syncFiles: config.syncFiles,
|
|
615
651
|
syncSessions: config.syncSessions,
|
|
616
652
|
extraFiles: config.extraFiles,
|
|
617
653
|
});
|
|
@@ -619,20 +655,6 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
619
655
|
await maybeReload(ctx);
|
|
620
656
|
}
|
|
621
657
|
|
|
622
|
-
async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
623
|
-
const lock = await readLock();
|
|
624
|
-
if (!lock) {
|
|
625
|
-
ctx.ui.notify("No pi-sync lock is present.", "info");
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
if (!options.stale && !isStaleLock(lock)) {
|
|
629
|
-
ctx.ui.notify("Lock is not stale. Use /pisync unlock --stale only after verifying no sync is running.", "warning");
|
|
630
|
-
return;
|
|
631
|
-
}
|
|
632
|
-
await fs.rm(lockPath(), { force: true });
|
|
633
|
-
ctx.ui.notify("Removed stale pi-sync lock.", "info");
|
|
634
|
-
}
|
|
635
|
-
|
|
636
658
|
function protectedSessionPaths(ctx: ExtensionCommandContext | ExtensionContext) {
|
|
637
659
|
const getSessionFile = ctx.sessionManager.getSessionFile;
|
|
638
660
|
if (typeof getSessionFile !== "function") return new Set<string>();
|
|
@@ -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";
|