@narumitw/pi-sync 0.6.2 → 0.7.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 +5 -1
- package/package.json +1 -1
- package/src/sync.ts +520 -75
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ It syncs automatically by default when Pi starts, then uses immutable snapshot b
|
|
|
14
14
|
- `keybindings.json`
|
|
15
15
|
- `models.json`
|
|
16
16
|
- `AGENTS.md`
|
|
17
|
+
- `APPEND_SYSTEM.md`
|
|
17
18
|
- `skills/`, `prompts/`, `themes/`, and `extensions/`
|
|
18
19
|
- optionally denylist-filtered `sessions/**/*.jsonl` when `syncSessions` is enabled
|
|
19
20
|
- Stores each remote version as an immutable gzip-compressed JSON snapshot bundle.
|
|
@@ -70,7 +71,8 @@ Example:
|
|
|
70
71
|
"profile": "default",
|
|
71
72
|
"prefix": "pi-sync",
|
|
72
73
|
"autoSync": true,
|
|
73
|
-
"syncSessions": false
|
|
74
|
+
"syncSessions": false,
|
|
75
|
+
"extraFiles": []
|
|
74
76
|
}
|
|
75
77
|
```
|
|
76
78
|
|
|
@@ -89,6 +91,8 @@ export PI_SYNC_AUTO_SYNC="true"
|
|
|
89
91
|
export PI_SYNC_SESSIONS="false" # opt in with true to sync Pi conversation JSONL files
|
|
90
92
|
```
|
|
91
93
|
|
|
94
|
+
Set `extraFiles` to additional top-level file names to include beyond the default allowlist. Machines without a matching `extraFiles` entry preserve those remote files but do not apply or delete them locally.
|
|
95
|
+
|
|
92
96
|
`PI_SYNC_ACCESS_KEY_ID`, `PI_SYNC_SECRET_ACCESS_KEY`, and `PI_SYNC_SESSION_TOKEN` are local-only credentials. Do not put them in files that pi-sync syncs. `PI_SYNC_SESSION_TOKEN` is optional and only needed for temporary credentials such as AWS STS, AWS SSO, assumed roles, or S3-compatible providers that issue short-lived credentials.
|
|
93
97
|
|
|
94
98
|
Cloudflare R2 static access keys do not use a session token and usually reject requests signed with `X-Amz-Security-Token`. R2 temporary credentials that require a token are still supported. For R2 endpoints (`*.r2.cloudflarestorage.com`), pi-sync first sends the configured session token; if R2 rejects it with `InvalidArgument: X-Amz-Security-Token`, pi-sync retries that request once without the token and omits the token for the rest of the same command after a successful retry.
|
package/package.json
CHANGED
package/src/sync.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
2
|
-
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { constants as fsConstants, type Dirent } from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
@@ -21,8 +21,17 @@ const DEFAULT_PREFIX = "pi-sync";
|
|
|
21
21
|
const DEFAULT_REGION = "auto";
|
|
22
22
|
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
23
23
|
|
|
24
|
-
const TOP_LEVEL_FILES = new Set([
|
|
24
|
+
const TOP_LEVEL_FILES = new Set([
|
|
25
|
+
"settings.json",
|
|
26
|
+
"keybindings.json",
|
|
27
|
+
"models.json",
|
|
28
|
+
"AGENTS.md",
|
|
29
|
+
"APPEND_SYSTEM.md",
|
|
30
|
+
]);
|
|
25
31
|
const TOP_LEVEL_DIRS = new Set(["skills", "prompts", "themes", "extensions"]);
|
|
32
|
+
const TOP_LEVEL_FILE_PATHS = new Map([...TOP_LEVEL_FILES].map((name) => [name.toLowerCase(), name]));
|
|
33
|
+
const TOP_LEVEL_FILE_NAMES = new Set(TOP_LEVEL_FILE_PATHS.keys());
|
|
34
|
+
const RESERVED_TOP_LEVEL_NAMES = new Set([...TOP_LEVEL_DIRS, "sessions"]);
|
|
26
35
|
const SECRET_PATTERNS = [
|
|
27
36
|
/AWS_SECRET_ACCESS_KEY\s*[=:]\s*['\"]?[A-Za-z0-9/+]{35,}/i,
|
|
28
37
|
/(ANTHROPIC|OPENAI|GEMINI|GOOGLE|FIRECRAWL|GITHUB|CLOUDFLARE|R2|S3)_[A-Z0-9_]*(KEY|TOKEN|SECRET)\s*[=:]\s*['\"]?[^\s'\"]{12,}/i,
|
|
@@ -41,6 +50,7 @@ interface SyncConfig {
|
|
|
41
50
|
profile: string;
|
|
42
51
|
prefix: string;
|
|
43
52
|
syncSessions: boolean;
|
|
53
|
+
extraFiles: string[];
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
interface PartialConfig {
|
|
@@ -54,6 +64,7 @@ interface PartialConfig {
|
|
|
54
64
|
prefix?: string;
|
|
55
65
|
autoSync?: boolean | string;
|
|
56
66
|
syncSessions?: boolean | string;
|
|
67
|
+
extraFiles?: unknown;
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
interface SnapshotFile {
|
|
@@ -95,6 +106,7 @@ interface SyncState {
|
|
|
95
106
|
lastRemoteEtag?: string;
|
|
96
107
|
lastFileHashes: Record<string, string>;
|
|
97
108
|
syncSessions?: boolean;
|
|
109
|
+
extraFiles?: string[];
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
interface LockFile {
|
|
@@ -114,9 +126,16 @@ interface CommandOptions {
|
|
|
114
126
|
args: string[];
|
|
115
127
|
}
|
|
116
128
|
|
|
129
|
+
interface CommandArgumentCompletion {
|
|
130
|
+
value: string;
|
|
131
|
+
label: string;
|
|
132
|
+
description?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
interface SnapshotOptions {
|
|
118
136
|
syncSessions?: boolean;
|
|
119
137
|
sessionDir?: string;
|
|
138
|
+
extraFiles?: string[];
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
interface PushInput {
|
|
@@ -140,10 +159,45 @@ const AUTO_SYNC_OPTIONS: CommandOptions = {
|
|
|
140
159
|
auto: true,
|
|
141
160
|
args: [],
|
|
142
161
|
};
|
|
162
|
+
const YES_FLAG_COMPLETIONS: readonly CommandArgumentCompletion[] = [
|
|
163
|
+
{ value: "--yes", label: "--yes", description: "Skip confirmation prompts" },
|
|
164
|
+
{ value: "-y", label: "-y", description: "Skip confirmation prompts" },
|
|
165
|
+
];
|
|
166
|
+
const SYNC_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
|
|
167
|
+
{ value: "help", label: "help", description: "Show command usage" },
|
|
168
|
+
{ value: "init", label: "init", description: "Create local config template" },
|
|
169
|
+
{ value: "config", label: "config", description: "Show resolved configuration" },
|
|
170
|
+
{ value: "status", label: "status", description: "Show sync status" },
|
|
171
|
+
{ value: "diff", label: "diff", description: "Show local/remote diff" },
|
|
172
|
+
{ value: "doctor", label: "doctor", description: "Check config, secrets, and lock state" },
|
|
173
|
+
{ value: "push", label: "push", description: "Upload local settings" },
|
|
174
|
+
{ value: "pull", label: "pull", description: "Apply remote settings" },
|
|
175
|
+
{ value: "sync", label: "sync", description: "Push or pull as needed" },
|
|
176
|
+
{ value: "history", label: "history", description: "Show recent remote snapshots" },
|
|
177
|
+
{ value: "rollback", label: "rollback", description: "Apply a previous snapshot" },
|
|
178
|
+
{ value: "unlock", label: "unlock", description: "Remove a stale local lock" },
|
|
179
|
+
];
|
|
180
|
+
const SYNC_FLAG_COMPLETIONS: Record<string, readonly CommandArgumentCompletion[]> = {
|
|
181
|
+
push: [
|
|
182
|
+
...YES_FLAG_COMPLETIONS,
|
|
183
|
+
{ value: "--force", label: "--force", description: "Overwrite visible remote changes" },
|
|
184
|
+
],
|
|
185
|
+
pull: [
|
|
186
|
+
...YES_FLAG_COMPLETIONS,
|
|
187
|
+
{ value: "--force", label: "--force", description: "Overwrite local changes" },
|
|
188
|
+
],
|
|
189
|
+
sync: [
|
|
190
|
+
...YES_FLAG_COMPLETIONS,
|
|
191
|
+
{ value: "--force", label: "--force", description: "Resolve conflicts by forcing action" },
|
|
192
|
+
],
|
|
193
|
+
rollback: YES_FLAG_COMPLETIONS,
|
|
194
|
+
unlock: [{ value: "--stale", label: "--stale", description: "Remove only a stale lock" }],
|
|
195
|
+
};
|
|
143
196
|
|
|
144
197
|
export default function sync(pi: ExtensionAPI) {
|
|
145
198
|
pi.registerCommand("pisync", {
|
|
146
199
|
description: "Sync Pi settings through Cloudflare R2 or S3-compatible storage",
|
|
200
|
+
getArgumentCompletions: completeSyncArguments,
|
|
147
201
|
handler: async (args, ctx) => {
|
|
148
202
|
await handleCommand(args, ctx);
|
|
149
203
|
},
|
|
@@ -239,7 +293,7 @@ async function autoPushSessions(ctx: ExtensionContext) {
|
|
|
239
293
|
await withLock("auto-session-push", async () => {
|
|
240
294
|
const state = await readState(config.profile);
|
|
241
295
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
242
|
-
if (!hasLocalChanges(local, state)) return;
|
|
296
|
+
if (!hasLocalChanges(local, state, config)) return;
|
|
243
297
|
await push(ctx, AUTO_SYNC_OPTIONS, { config, state, local });
|
|
244
298
|
});
|
|
245
299
|
} catch (error) {
|
|
@@ -269,6 +323,7 @@ async function initConfig(ctx: ExtensionCommandContext) {
|
|
|
269
323
|
prefix: DEFAULT_PREFIX,
|
|
270
324
|
autoSync: true,
|
|
271
325
|
syncSessions: false,
|
|
326
|
+
extraFiles: [],
|
|
272
327
|
};
|
|
273
328
|
await writeJson(configPath, sample);
|
|
274
329
|
ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /pisync doctor.`, "info");
|
|
@@ -291,6 +346,7 @@ async function showConfig(ctx: ExtensionCommandContext) {
|
|
|
291
346
|
`prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
|
|
292
347
|
`autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
|
|
293
348
|
`syncSessions: ${syncSessions ? "enabled" : "disabled"}`,
|
|
349
|
+
`extraFiles: ${normalizeExtraFiles(partial.extraFiles).join(", ") || "none"}`,
|
|
294
350
|
`local config: ${localConfigPath()}`,
|
|
295
351
|
...warnings,
|
|
296
352
|
].join("\n"),
|
|
@@ -305,7 +361,7 @@ async function status(ctx: ExtensionCommandContext) {
|
|
|
305
361
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
306
362
|
const state = await readState(config.profile);
|
|
307
363
|
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
308
|
-
const localChanged = hasLocalChanges(local, state);
|
|
364
|
+
const localChanged = hasLocalChanges(local, state, config);
|
|
309
365
|
|
|
310
366
|
let remoteText = "remote: empty";
|
|
311
367
|
let remoteChanged = remoteChangedSinceState(latest, state, config);
|
|
@@ -402,26 +458,34 @@ async function push(
|
|
|
402
458
|
const local = input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
|
|
403
459
|
|
|
404
460
|
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
405
|
-
const remoteForUpload =
|
|
461
|
+
const remoteForUpload = options.force
|
|
462
|
+
? undefined
|
|
463
|
+
: await readRemoteSnapshotForUpload(client, config, latest, state);
|
|
406
464
|
if (remoteChangedSinceState(latest, state, config) && !options.force) {
|
|
407
|
-
|
|
408
|
-
|
|
465
|
+
const remoteForConflict = remoteForUpload
|
|
466
|
+
? filterSnapshotForConfigPolicy(remoteForUpload, config)
|
|
467
|
+
: undefined;
|
|
468
|
+
if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
"Remote or sync policy changed since last sync. Run /pisync pull first or /pisync push --force.",
|
|
471
|
+
);
|
|
409
472
|
}
|
|
410
473
|
}
|
|
411
474
|
|
|
412
|
-
const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload
|
|
413
|
-
|
|
414
|
-
|
|
475
|
+
const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload, {
|
|
476
|
+
preserveRemote: !options.force,
|
|
477
|
+
});
|
|
478
|
+
const secrets = scanSnapshot(local);
|
|
415
479
|
if (secrets.length > 0) {
|
|
416
480
|
throw new Error(`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`);
|
|
417
481
|
}
|
|
418
482
|
|
|
419
|
-
const
|
|
483
|
+
const preservedRemoteFileCount = countPreservedRemoteFiles(local, upload);
|
|
420
484
|
if (
|
|
421
485
|
!options.yes &&
|
|
422
486
|
!(await ctx.ui.confirm(
|
|
423
487
|
snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
|
|
424
|
-
formatPushSummary(upload, latest,
|
|
488
|
+
formatPushSummary(upload, latest, preservedRemoteFileCount),
|
|
425
489
|
))
|
|
426
490
|
) {
|
|
427
491
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -438,6 +502,7 @@ async function push(
|
|
|
438
502
|
lastRemoteEtag: undefined,
|
|
439
503
|
lastFileHashes: fileHashMap(local),
|
|
440
504
|
syncSessions: config.syncSessions,
|
|
505
|
+
extraFiles: config.extraFiles,
|
|
441
506
|
});
|
|
442
507
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
443
508
|
if (!options.silent) {
|
|
@@ -454,8 +519,8 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
454
519
|
const remote = await readRemoteSnapshot(client, config);
|
|
455
520
|
if (!remote) throw new Error("Remote is empty. Run /pisync push from a configured machine first.");
|
|
456
521
|
|
|
457
|
-
const localChanged = hasLocalChanges(local, state);
|
|
458
|
-
const remoteChanged = hasRemoteChanges(remote, state, config);
|
|
522
|
+
const localChanged = hasLocalChanges(local, state, config);
|
|
523
|
+
const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
|
|
459
524
|
if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
|
|
460
525
|
throw new Error("Both local and remote changed since last sync. Run /pisync diff, then choose /pisync pull --force or /pisync push --force.");
|
|
461
526
|
}
|
|
@@ -474,7 +539,10 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
474
539
|
|
|
475
540
|
const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
|
|
476
541
|
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
477
|
-
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx),
|
|
542
|
+
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
|
|
543
|
+
sessionDir: applySessionDir,
|
|
544
|
+
extraFiles: config.extraFiles,
|
|
545
|
+
});
|
|
478
546
|
await writeState(config.profile, {
|
|
479
547
|
version: VERSION,
|
|
480
548
|
profile: config.profile,
|
|
@@ -482,6 +550,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
482
550
|
lastRemoteEtag: undefined,
|
|
483
551
|
lastFileHashes,
|
|
484
552
|
syncSessions: config.syncSessions,
|
|
553
|
+
extraFiles: config.extraFiles,
|
|
485
554
|
});
|
|
486
555
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
487
556
|
if (!options.silent) {
|
|
@@ -501,12 +570,14 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
501
570
|
const state = await readState(config.profile);
|
|
502
571
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
503
572
|
const remote = await readRemoteSnapshot(client, config);
|
|
504
|
-
const localChanged = hasLocalChanges(local, state);
|
|
505
|
-
const remoteChanged = remote
|
|
573
|
+
const localChanged = hasLocalChanges(local, state, config);
|
|
574
|
+
const remoteChanged = remote
|
|
575
|
+
? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx))
|
|
576
|
+
: false;
|
|
506
577
|
const firstSync = !state.lastAppliedSnapshot;
|
|
507
578
|
|
|
508
|
-
if (firstSync && remote && local.files.length > 0) {
|
|
509
|
-
if (!
|
|
579
|
+
if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
|
|
580
|
+
if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
|
|
510
581
|
throw new Error("Remote settings exist and this machine has different local Pi settings. Run /pisync diff, then manually choose /pisync pull or /pisync push.");
|
|
511
582
|
}
|
|
512
583
|
if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
|
|
@@ -523,10 +594,24 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
523
594
|
lastRemoteEtag: undefined,
|
|
524
595
|
lastFileHashes: fileHashMap(remote),
|
|
525
596
|
syncSessions: config.syncSessions,
|
|
597
|
+
extraFiles: config.extraFiles,
|
|
526
598
|
});
|
|
527
599
|
if (!options.silent) ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
|
|
528
600
|
return;
|
|
529
601
|
}
|
|
602
|
+
if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
|
|
603
|
+
await writeState(config.profile, {
|
|
604
|
+
version: VERSION,
|
|
605
|
+
profile: config.profile,
|
|
606
|
+
lastAppliedSnapshot: remote.id,
|
|
607
|
+
lastRemoteEtag: undefined,
|
|
608
|
+
lastFileHashes: fileHashMap(remote),
|
|
609
|
+
syncSessions: config.syncSessions,
|
|
610
|
+
extraFiles: config.extraFiles,
|
|
611
|
+
});
|
|
612
|
+
if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
530
615
|
if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
|
|
531
616
|
throw new Error("Both local and remote changed. Run /pisync diff and resolve with push --force or pull --force.");
|
|
532
617
|
}
|
|
@@ -538,6 +623,17 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
538
623
|
await push(ctx, options);
|
|
539
624
|
return;
|
|
540
625
|
}
|
|
626
|
+
if (shouldRefreshSyncedState(remote, state, config)) {
|
|
627
|
+
await writeState(config.profile, {
|
|
628
|
+
version: VERSION,
|
|
629
|
+
profile: config.profile,
|
|
630
|
+
lastAppliedSnapshot: remote.id,
|
|
631
|
+
lastRemoteEtag: undefined,
|
|
632
|
+
lastFileHashes: fileHashMap(remote),
|
|
633
|
+
syncSessions: config.syncSessions,
|
|
634
|
+
extraFiles: config.extraFiles,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
541
637
|
if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
|
|
542
638
|
}
|
|
543
639
|
|
|
@@ -569,7 +665,11 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
569
665
|
const snapshot = await client.getBuffer(snapshotKey(config, target));
|
|
570
666
|
if (!snapshot.value) throw new Error(`Snapshot not found: ${target}`);
|
|
571
667
|
const decoded = await decodeSnapshot(snapshot.value);
|
|
572
|
-
const remote =
|
|
668
|
+
const remote = filterSnapshotForConfigPolicy(
|
|
669
|
+
config.syncSessions ? decoded : snapshotWithoutSessions(decoded),
|
|
670
|
+
config,
|
|
671
|
+
{ regenerateId: true },
|
|
672
|
+
);
|
|
573
673
|
|
|
574
674
|
if (
|
|
575
675
|
!options.yes &&
|
|
@@ -586,9 +686,14 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
586
686
|
|
|
587
687
|
const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
|
|
588
688
|
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
589
|
-
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx),
|
|
689
|
+
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
|
|
690
|
+
sessionDir: applySessionDir,
|
|
691
|
+
extraFiles: config.extraFiles,
|
|
692
|
+
});
|
|
590
693
|
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
591
|
-
const upload = await snapshotForUpload(client, config, remote, latest
|
|
694
|
+
const upload = await snapshotForUpload(client, config, remote, latest, undefined, {
|
|
695
|
+
ignoreUnreadableRemote: true,
|
|
696
|
+
});
|
|
592
697
|
const encoded = upload.id === decoded.id ? snapshot.value : await encodeSnapshot(upload);
|
|
593
698
|
if (upload.id !== decoded.id) {
|
|
594
699
|
await client.putBuffer(snapshotKey(config, upload.id), encoded, "application/gzip");
|
|
@@ -603,6 +708,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
603
708
|
lastRemoteEtag: undefined,
|
|
604
709
|
lastFileHashes,
|
|
605
710
|
syncSessions: config.syncSessions,
|
|
711
|
+
extraFiles: config.extraFiles,
|
|
606
712
|
});
|
|
607
713
|
ctx.ui.notify(`Rolled back to ${target}; latest: ${pointer.snapshot}. Backup: ${backup}`, "info");
|
|
608
714
|
await maybeReload(ctx);
|
|
@@ -636,7 +742,11 @@ function snapshotOptionsForContext(
|
|
|
636
742
|
ctx: ExtensionCommandContext | ExtensionContext,
|
|
637
743
|
config: SyncConfig,
|
|
638
744
|
): SnapshotOptions {
|
|
639
|
-
return {
|
|
745
|
+
return {
|
|
746
|
+
syncSessions: config.syncSessions,
|
|
747
|
+
sessionDir: sessionDirFromContext(ctx),
|
|
748
|
+
extraFiles: config.extraFiles,
|
|
749
|
+
};
|
|
640
750
|
}
|
|
641
751
|
|
|
642
752
|
function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
|
|
@@ -719,6 +829,7 @@ async function loadConfigInternal(): Promise<SyncConfig> {
|
|
|
719
829
|
profile: partial.profile ?? DEFAULT_PROFILE,
|
|
720
830
|
prefix: trimSlashes(partial.prefix ?? DEFAULT_PREFIX),
|
|
721
831
|
syncSessions: isExplicitlyEnabled(partial.syncSessions),
|
|
832
|
+
extraFiles: normalizeExtraFiles(partial.extraFiles),
|
|
722
833
|
};
|
|
723
834
|
}
|
|
724
835
|
|
|
@@ -740,6 +851,7 @@ async function loadPartialConfig(): Promise<PartialConfig> {
|
|
|
740
851
|
prefix: process.env.PI_SYNC_PREFIX ?? fileConfig.prefix,
|
|
741
852
|
autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig.autoSync,
|
|
742
853
|
syncSessions: process.env.PI_SYNC_SESSIONS ?? fileConfig.syncSessions,
|
|
854
|
+
extraFiles: fileConfig.extraFiles,
|
|
743
855
|
};
|
|
744
856
|
}
|
|
745
857
|
|
|
@@ -780,6 +892,7 @@ async function createSnapshot(profile: string, options: SnapshotOptions = {}): P
|
|
|
780
892
|
const files = await collectFiles(agentDir(), {
|
|
781
893
|
syncSessions,
|
|
782
894
|
sessionDir: options.sessionDir ?? (await configuredSessionDir()),
|
|
895
|
+
extraFiles: options.extraFiles,
|
|
783
896
|
});
|
|
784
897
|
return {
|
|
785
898
|
version: VERSION,
|
|
@@ -797,13 +910,20 @@ export async function collectFiles(
|
|
|
797
910
|
options: SnapshotOptions = {},
|
|
798
911
|
): Promise<SnapshotFile[]> {
|
|
799
912
|
const results: SnapshotFile[] = [];
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
} else if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name)) {
|
|
913
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
914
|
+
for (const entry of entries) {
|
|
915
|
+
if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name)) {
|
|
804
916
|
await collectDirectory(results, root, entry.name);
|
|
805
917
|
}
|
|
806
918
|
}
|
|
919
|
+
for (const fileName of TOP_LEVEL_FILES) {
|
|
920
|
+
const entry = selectTopLevelFileEntry(entries, fileName);
|
|
921
|
+
if (entry) await addFile(results, root, entry.name, fileName);
|
|
922
|
+
}
|
|
923
|
+
for (const extraFileName of normalizeExtraFiles(options.extraFiles)) {
|
|
924
|
+
const entry = selectTopLevelFileEntry(entries, extraFileName);
|
|
925
|
+
if (entry) await addFile(results, root, entry.name, extraFileName);
|
|
926
|
+
}
|
|
807
927
|
if (options.syncSessions) {
|
|
808
928
|
try {
|
|
809
929
|
await collectDirectory(results, sessionStorageRoot(root, options.sessionDir), "", {
|
|
@@ -844,7 +964,7 @@ async function addFile(
|
|
|
844
964
|
relativePath: string,
|
|
845
965
|
snapshotPath = relativePath,
|
|
846
966
|
) {
|
|
847
|
-
if (
|
|
967
|
+
if (!isSafeSnapshotPath(snapshotPath)) return;
|
|
848
968
|
const absolutePath = safeJoin(root, relativePath);
|
|
849
969
|
const content = await fs.readFile(absolutePath);
|
|
850
970
|
results.push({ path: snapshotPath, contentBase64: content.toString("base64"), sha256: sha256(content) });
|
|
@@ -852,11 +972,13 @@ async function addFile(
|
|
|
852
972
|
|
|
853
973
|
export function isDeniedPath(relativePath: string) {
|
|
854
974
|
const normalized = toPosix(relativePath);
|
|
855
|
-
const
|
|
975
|
+
const lower = normalized.toLowerCase();
|
|
976
|
+
const segments = lower.split("/");
|
|
977
|
+
const base = path.posix.basename(lower);
|
|
856
978
|
return (
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
979
|
+
segments.includes("node_modules") ||
|
|
980
|
+
segments.includes(".git") ||
|
|
981
|
+
segments.includes(".pisync") ||
|
|
860
982
|
base === ".env" ||
|
|
861
983
|
base.startsWith(".env.") ||
|
|
862
984
|
base.endsWith(".env") ||
|
|
@@ -904,18 +1026,97 @@ function snapshotIncludesSessions(snapshot: Snapshot) {
|
|
|
904
1026
|
return snapshot.syncSessions === true || snapshot.files.some((file) => isSessionPath(file.path));
|
|
905
1027
|
}
|
|
906
1028
|
|
|
907
|
-
function
|
|
908
|
-
snapshot: Snapshot
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1029
|
+
export function filterSnapshotForConfigPolicy(
|
|
1030
|
+
snapshot: Snapshot,
|
|
1031
|
+
config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
|
|
1032
|
+
options: { regenerateId?: boolean } = {},
|
|
1033
|
+
) {
|
|
1034
|
+
const extraFilePaths = extraFilePathsByLower(config.extraFiles);
|
|
1035
|
+
const extraFiles = new Set(extraFilePaths.keys());
|
|
1036
|
+
const filtered = {
|
|
913
1037
|
...snapshot,
|
|
914
|
-
syncSessions: false,
|
|
915
|
-
files: snapshot.files
|
|
1038
|
+
syncSessions: config.syncSessions ? snapshot.syncSessions : false,
|
|
1039
|
+
files: canonicalizeSnapshotFilesForConfig(snapshot.files, config, extraFiles, extraFilePaths),
|
|
1040
|
+
};
|
|
1041
|
+
if (!options.regenerateId || snapshotsMatch(snapshot, filtered)) return filtered;
|
|
1042
|
+
return {
|
|
1043
|
+
...filtered,
|
|
1044
|
+
id: snapshotId(),
|
|
1045
|
+
createdAt: new Date().toISOString(),
|
|
1046
|
+
machine: os.hostname(),
|
|
916
1047
|
};
|
|
917
1048
|
}
|
|
918
1049
|
|
|
1050
|
+
function isConfiguredSnapshotPath(
|
|
1051
|
+
relativePath: string,
|
|
1052
|
+
config: Pick<SyncConfig, "syncSessions">,
|
|
1053
|
+
extraFiles: Set<string>,
|
|
1054
|
+
) {
|
|
1055
|
+
const normalized = toPosix(relativePath);
|
|
1056
|
+
if (isSessionPath(normalized)) return config.syncSessions;
|
|
1057
|
+
if (!normalized.includes("/")) {
|
|
1058
|
+
const lower = normalized.toLowerCase();
|
|
1059
|
+
return TOP_LEVEL_FILE_NAMES.has(lower) || extraFiles.has(lower);
|
|
1060
|
+
}
|
|
1061
|
+
return TOP_LEVEL_DIRS.has(normalized.slice(0, normalized.indexOf("/")));
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function canonicalizeSnapshotFilesForConfig(
|
|
1065
|
+
files: SnapshotFile[],
|
|
1066
|
+
config: Pick<SyncConfig, "syncSessions">,
|
|
1067
|
+
extraFiles: Set<string>,
|
|
1068
|
+
extraFilePaths: Map<string, string>,
|
|
1069
|
+
) {
|
|
1070
|
+
const configuredFiles: SnapshotFile[] = [];
|
|
1071
|
+
const extraCandidates = new Map<
|
|
1072
|
+
string,
|
|
1073
|
+
{ exact: boolean; file: SnapshotFile; originalPath: string }
|
|
1074
|
+
>();
|
|
1075
|
+
for (const file of files) {
|
|
1076
|
+
const normalized = toPosix(file.path);
|
|
1077
|
+
if (!isSafeSnapshotPath(file.path) || !isConfiguredSnapshotPath(normalized, config, extraFiles)) {
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
const topLevelPath = canonicalTopLevelFilePath(normalized, extraFilePaths);
|
|
1081
|
+
if (!topLevelPath) {
|
|
1082
|
+
configuredFiles.push(normalized === file.path ? file : { ...file, path: normalized });
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
const candidate = {
|
|
1086
|
+
exact: normalized === topLevelPath,
|
|
1087
|
+
file: { ...file, path: topLevelPath },
|
|
1088
|
+
originalPath: normalized,
|
|
1089
|
+
};
|
|
1090
|
+
const current = extraCandidates.get(topLevelPath.toLowerCase());
|
|
1091
|
+
if (!current || isPreferredExtraCandidate(candidate, current)) {
|
|
1092
|
+
extraCandidates.set(topLevelPath.toLowerCase(), candidate);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return [
|
|
1096
|
+
...configuredFiles,
|
|
1097
|
+
...[...extraCandidates.values()].map((candidate) => candidate.file),
|
|
1098
|
+
].sort((left, right) => left.path.localeCompare(right.path));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function canonicalTopLevelFilePath(relativePath: string, extraFilePaths: Map<string, string>) {
|
|
1102
|
+
const normalized = toPosix(relativePath);
|
|
1103
|
+
if (normalized.includes("/")) return undefined;
|
|
1104
|
+
const lower = normalized.toLowerCase();
|
|
1105
|
+
return TOP_LEVEL_FILE_PATHS.get(lower) ?? extraFilePaths.get(lower);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function canonicalSnapshotPathForConfig(relativePath: string, extraFilePaths: Map<string, string>) {
|
|
1109
|
+
return canonicalTopLevelFilePath(relativePath, extraFilePaths) ?? toPosix(relativePath);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function isPreferredExtraCandidate(
|
|
1113
|
+
left: { exact: boolean; originalPath: string },
|
|
1114
|
+
right: { exact: boolean; originalPath: string },
|
|
1115
|
+
) {
|
|
1116
|
+
if (left.exact !== right.exact) return left.exact;
|
|
1117
|
+
return left.originalPath.localeCompare(right.originalPath) < 0;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
919
1120
|
export function snapshotWithoutSessions(snapshot: Snapshot) {
|
|
920
1121
|
const files = snapshot.files.filter((file) => !isSessionPath(file.path));
|
|
921
1122
|
if (files.length === snapshot.files.length && snapshot.syncSessions !== true) return snapshot;
|
|
@@ -945,14 +1146,19 @@ export function scanSnapshot(snapshot: Snapshot) {
|
|
|
945
1146
|
return findings;
|
|
946
1147
|
}
|
|
947
1148
|
|
|
948
|
-
async function
|
|
1149
|
+
async function readRemoteSnapshotForUpload(
|
|
949
1150
|
client: S3Client,
|
|
950
1151
|
config: SyncConfig,
|
|
951
1152
|
latest: RemoteObject<LatestPointer>,
|
|
952
1153
|
state: SyncState,
|
|
953
1154
|
) {
|
|
954
|
-
if (
|
|
955
|
-
|
|
1155
|
+
if (
|
|
1156
|
+
latest.missing ||
|
|
1157
|
+
!latest.value ||
|
|
1158
|
+
(latest.value.snapshot === state.lastAppliedSnapshot && !syncPolicyChanged(state, config))
|
|
1159
|
+
) {
|
|
1160
|
+
return undefined;
|
|
1161
|
+
}
|
|
956
1162
|
return readRemoteSnapshotRaw(client, config);
|
|
957
1163
|
}
|
|
958
1164
|
|
|
@@ -962,18 +1168,66 @@ async function snapshotForUpload(
|
|
|
962
1168
|
local: Snapshot,
|
|
963
1169
|
latest: RemoteObject<LatestPointer>,
|
|
964
1170
|
remote?: Snapshot,
|
|
1171
|
+
options: { preserveRemote?: boolean; ignoreUnreadableRemote?: boolean } = {},
|
|
965
1172
|
) {
|
|
966
|
-
if (
|
|
967
|
-
|
|
1173
|
+
if (options.preserveRemote === false || latest.missing || !latest.value) return local;
|
|
1174
|
+
let snapshot = remote;
|
|
1175
|
+
if (!snapshot) {
|
|
1176
|
+
try {
|
|
1177
|
+
snapshot = await readRemoteSnapshotRaw(client, config);
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
if (options.ignoreUnreadableRemote) return local;
|
|
1180
|
+
throw error;
|
|
1181
|
+
}
|
|
968
1182
|
}
|
|
969
|
-
|
|
970
|
-
|
|
1183
|
+
return snapshot ? mergeRemotePreservedFiles(local, snapshot, config) : local;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
export function mergeRemotePreservedFiles(
|
|
1187
|
+
local: Snapshot,
|
|
1188
|
+
remote: Snapshot,
|
|
1189
|
+
config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
|
|
1190
|
+
) {
|
|
1191
|
+
const withSessions = config.syncSessions ? local : mergeRemoteSessionFiles(local, remote);
|
|
1192
|
+
const paths = new Set(withSessions.files.map((file) => file.path));
|
|
1193
|
+
const pathNames = new Set(withSessions.files.map((file) => file.path.toLowerCase()));
|
|
1194
|
+
const extraFileNames = new Set(normalizeExtraFiles(config.extraFiles).map((file) => file.toLowerCase()));
|
|
1195
|
+
const seenRemoteExtraNames = new Set<string>();
|
|
1196
|
+
const remoteExtras = remote.files.filter((file) => {
|
|
1197
|
+
const normalized = toPosix(file.path);
|
|
1198
|
+
const lower = normalized.toLowerCase();
|
|
1199
|
+
if (
|
|
1200
|
+
paths.has(normalized) ||
|
|
1201
|
+
pathNames.has(lower) ||
|
|
1202
|
+
!isSafeSnapshotPath(file.path) ||
|
|
1203
|
+
normalized.includes("/") ||
|
|
1204
|
+
TOP_LEVEL_FILE_NAMES.has(lower) ||
|
|
1205
|
+
RESERVED_TOP_LEVEL_NAMES.has(lower) ||
|
|
1206
|
+
extraFileNames.has(lower) ||
|
|
1207
|
+
seenRemoteExtraNames.has(lower)
|
|
1208
|
+
) {
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
seenRemoteExtraNames.add(lower);
|
|
1212
|
+
return true;
|
|
1213
|
+
});
|
|
1214
|
+
if (remoteExtras.length === 0) return withSessions;
|
|
1215
|
+
return {
|
|
1216
|
+
...withSessions,
|
|
1217
|
+
id: snapshotId(),
|
|
1218
|
+
createdAt: new Date().toISOString(),
|
|
1219
|
+
machine: os.hostname(),
|
|
1220
|
+
files: [...withSessions.files, ...remoteExtras].sort((left, right) =>
|
|
1221
|
+
left.path.localeCompare(right.path),
|
|
1222
|
+
),
|
|
1223
|
+
};
|
|
971
1224
|
}
|
|
972
1225
|
|
|
973
1226
|
export function mergeRemoteSessionFiles(local: Snapshot, remote: Snapshot) {
|
|
974
|
-
const remoteSessions = remote.files.filter(
|
|
975
|
-
|
|
976
|
-
|
|
1227
|
+
const remoteSessions = remote.files.filter((file) => {
|
|
1228
|
+
const normalized = toPosix(file.path);
|
|
1229
|
+
return isSessionFilePath(normalized) && isSafeSnapshotPath(file.path);
|
|
1230
|
+
});
|
|
977
1231
|
if (remoteSessions.length === 0 && !snapshotIncludesSessions(remote)) return local;
|
|
978
1232
|
return {
|
|
979
1233
|
...local,
|
|
@@ -1010,7 +1264,8 @@ async function uploadSnapshot(
|
|
|
1010
1264
|
}
|
|
1011
1265
|
|
|
1012
1266
|
async function readRemoteSnapshot(client: S3Client, config: SyncConfig) {
|
|
1013
|
-
|
|
1267
|
+
const snapshot = await readRemoteSnapshotRaw(client, config);
|
|
1268
|
+
return snapshot ? filterSnapshotForConfigPolicy(snapshot, config) : undefined;
|
|
1014
1269
|
}
|
|
1015
1270
|
|
|
1016
1271
|
async function readRemoteSnapshotRaw(client: S3Client, config: SyncConfig) {
|
|
@@ -1036,18 +1291,24 @@ async function decodeSnapshot(buffer: Buffer): Promise<Snapshot> {
|
|
|
1036
1291
|
async function applySnapshot(
|
|
1037
1292
|
snapshot: Snapshot,
|
|
1038
1293
|
protectedRelativePaths = new Set<string>(),
|
|
1039
|
-
sessionDir
|
|
1294
|
+
options: Pick<SnapshotOptions, "sessionDir" | "extraFiles"> = {},
|
|
1040
1295
|
) {
|
|
1041
1296
|
const root = agentDir();
|
|
1297
|
+
const { sessionDir } = options;
|
|
1042
1298
|
const current = await createSnapshot(snapshot.profile, {
|
|
1043
1299
|
syncSessions: snapshotIncludesSessions(snapshot),
|
|
1044
1300
|
sessionDir,
|
|
1301
|
+
extraFiles: options.extraFiles,
|
|
1045
1302
|
});
|
|
1046
|
-
const plan =
|
|
1303
|
+
const plan = await addTopLevelCaseVariantDeletes(
|
|
1047
1304
|
root,
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1305
|
+
protectSnapshotApplyPlan(
|
|
1306
|
+
root,
|
|
1307
|
+
preflightSnapshotApply(root, snapshot, current, { sessionDir }),
|
|
1308
|
+
protectedRelativePaths,
|
|
1309
|
+
sessionDir,
|
|
1310
|
+
),
|
|
1311
|
+
snapshot,
|
|
1051
1312
|
);
|
|
1052
1313
|
await preflightSnapshotMutations(root, plan, sessionDir);
|
|
1053
1314
|
for (const target of plan.deletes) {
|
|
@@ -1072,13 +1333,7 @@ export function preflightSnapshotApply(
|
|
|
1072
1333
|
|
|
1073
1334
|
for (const file of snapshot.files) {
|
|
1074
1335
|
const normalized = toPosix(file.path);
|
|
1075
|
-
if (
|
|
1076
|
-
!normalized ||
|
|
1077
|
-
normalized.startsWith("../") ||
|
|
1078
|
-
path.posix.isAbsolute(normalized) ||
|
|
1079
|
-
path.posix.normalize(normalized) !== normalized ||
|
|
1080
|
-
isDeniedPath(normalized)
|
|
1081
|
-
) {
|
|
1336
|
+
if (!isSafeSnapshotPath(file.path)) {
|
|
1082
1337
|
throw new Error(`Unsafe path in snapshot: ${file.path}`);
|
|
1083
1338
|
}
|
|
1084
1339
|
if (isSessionPath(normalized) && !isSessionFilePath(normalized)) {
|
|
@@ -1127,6 +1382,38 @@ export function protectSnapshotApplyPlan(
|
|
|
1127
1382
|
};
|
|
1128
1383
|
}
|
|
1129
1384
|
|
|
1385
|
+
export async function addTopLevelCaseVariantDeletes(
|
|
1386
|
+
root: string,
|
|
1387
|
+
plan: SnapshotApplyPlan,
|
|
1388
|
+
snapshot: Pick<Snapshot, "files">,
|
|
1389
|
+
): Promise<SnapshotApplyPlan> {
|
|
1390
|
+
const topLevelPaths = new Map<string, string>();
|
|
1391
|
+
for (const file of snapshot.files) {
|
|
1392
|
+
const normalized = toPosix(file.path);
|
|
1393
|
+
if (!normalized.includes("/") && isSafeSnapshotPath(file.path)) {
|
|
1394
|
+
topLevelPaths.set(normalized.toLowerCase(), normalized);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
if (topLevelPaths.size === 0) return plan;
|
|
1398
|
+
|
|
1399
|
+
let entries: Dirent[];
|
|
1400
|
+
try {
|
|
1401
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return plan;
|
|
1404
|
+
throw error;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const deletes = new Set(plan.deletes);
|
|
1408
|
+
for (const entry of entries) {
|
|
1409
|
+
const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
|
|
1410
|
+
if (canonicalPath && entry.name !== canonicalPath && (entry.isFile() || entry.isSymbolicLink())) {
|
|
1411
|
+
deletes.add(safeJoin(root, entry.name));
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
return { ...plan, deletes: [...deletes] };
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1130
1417
|
export function appliedFileHashMap(
|
|
1131
1418
|
snapshot: Snapshot,
|
|
1132
1419
|
current: Snapshot,
|
|
@@ -1146,6 +1433,20 @@ export function appliedFileHashMap(
|
|
|
1146
1433
|
return hashes;
|
|
1147
1434
|
}
|
|
1148
1435
|
|
|
1436
|
+
function isSafeSnapshotPath(relativePath: string) {
|
|
1437
|
+
if (relativePath.includes("\\")) return false;
|
|
1438
|
+
const normalized = toPosix(relativePath);
|
|
1439
|
+
return (
|
|
1440
|
+
Boolean(normalized) &&
|
|
1441
|
+
normalized !== "." &&
|
|
1442
|
+
normalized !== ".." &&
|
|
1443
|
+
!normalized.startsWith("../") &&
|
|
1444
|
+
!path.posix.isAbsolute(normalized) &&
|
|
1445
|
+
path.posix.normalize(normalized) === normalized &&
|
|
1446
|
+
!isDeniedPath(normalized)
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1149
1450
|
function decodeBase64Strict(value: string, filePath: string) {
|
|
1150
1451
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
|
|
1151
1452
|
throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
|
|
@@ -1201,19 +1502,19 @@ function formatSnapshotOnlyDiff(title: string, snapshot: Snapshot) {
|
|
|
1201
1502
|
function formatPushSummary(
|
|
1202
1503
|
local: Snapshot,
|
|
1203
1504
|
latest: RemoteObject<LatestPointer>,
|
|
1204
|
-
|
|
1505
|
+
preservedRemoteFileCount = 0,
|
|
1205
1506
|
) {
|
|
1206
1507
|
return [
|
|
1207
1508
|
`Upload ${local.files.length} files from ${agentDir()}.`,
|
|
1208
1509
|
latest.value ? `Remote latest: ${latest.value.snapshot}` : "Remote latest: empty",
|
|
1209
|
-
|
|
1210
|
-
? `Possible secrets in
|
|
1510
|
+
preservedRemoteFileCount > 0
|
|
1511
|
+
? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.`
|
|
1211
1512
|
: "Possible secrets were scanned before this prompt.",
|
|
1212
1513
|
].join("\n");
|
|
1213
1514
|
}
|
|
1214
1515
|
|
|
1215
|
-
function hasLocalChanges(local: Snapshot, state: SyncState) {
|
|
1216
|
-
return !sameHashes(fileHashMap(local), state
|
|
1516
|
+
function hasLocalChanges(local: Snapshot, state: SyncState, config: SyncConfig) {
|
|
1517
|
+
return !sameHashes(fileHashMap(local), stateHashMapForConfig(state, config));
|
|
1217
1518
|
}
|
|
1218
1519
|
|
|
1219
1520
|
function remoteChangedSinceState(
|
|
@@ -1223,14 +1524,18 @@ function remoteChangedSinceState(
|
|
|
1223
1524
|
) {
|
|
1224
1525
|
if (latest.missing) return Boolean(state.lastAppliedSnapshot);
|
|
1225
1526
|
if (latest.value?.snapshot !== state.lastAppliedSnapshot) return true;
|
|
1527
|
+
if (extraFilesChanged(state, config)) return true;
|
|
1226
1528
|
return config.syncSessions && state.syncSessions !== true && latest.value?.syncSessions === true;
|
|
1227
1529
|
}
|
|
1228
1530
|
|
|
1229
|
-
export function hasRemoteChanges(
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1531
|
+
export function hasRemoteChanges(
|
|
1532
|
+
remote: Snapshot,
|
|
1533
|
+
state: SyncState,
|
|
1534
|
+
config: SyncConfig,
|
|
1535
|
+
ignoredPaths = new Set<string>(),
|
|
1536
|
+
) {
|
|
1537
|
+
if (remote.id === state.lastAppliedSnapshot && !syncPolicyChanged(state, config)) return false;
|
|
1538
|
+
return !snapshotHashesMatchState(filterSnapshotForConfigPolicy(remote, config), state, config, ignoredPaths);
|
|
1234
1539
|
}
|
|
1235
1540
|
|
|
1236
1541
|
function remoteIdentity(remote: RemoteObject<LatestPointer>) {
|
|
@@ -1249,8 +1554,65 @@ function fileHashMap(snapshot: Snapshot) {
|
|
|
1249
1554
|
return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
|
|
1250
1555
|
}
|
|
1251
1556
|
|
|
1252
|
-
function
|
|
1253
|
-
|
|
1557
|
+
function stateHashMapForConfig(state: SyncState, config: Pick<SyncConfig, "syncSessions" | "extraFiles">) {
|
|
1558
|
+
const extraFilePaths = extraFilePathsByLower(config.extraFiles);
|
|
1559
|
+
const extraFiles = new Set(extraFilePaths.keys());
|
|
1560
|
+
return Object.fromEntries(
|
|
1561
|
+
Object.entries(state.lastFileHashes)
|
|
1562
|
+
.filter(([filePath]) => isConfiguredSnapshotPath(filePath, config, extraFiles))
|
|
1563
|
+
.map(([filePath, hash]) => [canonicalSnapshotPathForConfig(filePath, extraFilePaths), hash]),
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function snapshotHashesMatchState(
|
|
1568
|
+
snapshot: Snapshot,
|
|
1569
|
+
state: SyncState,
|
|
1570
|
+
config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
|
|
1571
|
+
ignoredPaths = new Set<string>(),
|
|
1572
|
+
) {
|
|
1573
|
+
return sameHashes(
|
|
1574
|
+
withoutHashPaths(fileHashMap(snapshot), ignoredPaths),
|
|
1575
|
+
withoutHashPaths(stateHashMapForConfig(state, config), ignoredPaths),
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function snapshotsMatch(left: Snapshot, right: Snapshot) {
|
|
1580
|
+
return left.syncSessions === right.syncSessions && sameHashes(fileHashMap(left), fileHashMap(right));
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
function withoutHashPaths(hashes: Record<string, string>, ignoredPaths: Set<string>) {
|
|
1584
|
+
if (ignoredPaths.size === 0) return hashes;
|
|
1585
|
+
return Object.fromEntries(
|
|
1586
|
+
Object.entries(hashes).filter(([filePath]) => !ignoredPaths.has(toPosix(filePath))),
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function syncPolicyChanged(state: SyncState, config: Pick<SyncConfig, "syncSessions" | "extraFiles">) {
|
|
1591
|
+
return (state.syncSessions ?? false) !== config.syncSessions || extraFilesChanged(state, config);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function shouldRefreshSyncedState(
|
|
1595
|
+
remote: Snapshot,
|
|
1596
|
+
state: SyncState,
|
|
1597
|
+
config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
|
|
1598
|
+
) {
|
|
1599
|
+
return remote.id !== state.lastAppliedSnapshot || syncPolicyChanged(state, config);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function extraFilesChanged(state: SyncState, config: Pick<SyncConfig, "extraFiles">) {
|
|
1603
|
+
return !sameStringSet(normalizeExtraFiles(state.extraFiles), normalizeExtraFiles(config.extraFiles));
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function sameStringSet(left: string[], right: string[]) {
|
|
1607
|
+
const leftSet = new Set(left);
|
|
1608
|
+
const rightSet = new Set(right);
|
|
1609
|
+
if (leftSet.size !== rightSet.size) return false;
|
|
1610
|
+
return [...leftSet].every((item) => rightSet.has(item));
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function countPreservedRemoteFiles(local: Snapshot, upload: Snapshot) {
|
|
1614
|
+
const localPaths = new Set(local.files.map((file) => file.path));
|
|
1615
|
+
return upload.files.filter((file) => !localPaths.has(file.path)).length;
|
|
1254
1616
|
}
|
|
1255
1617
|
|
|
1256
1618
|
export function settingsHashMap(snapshot: Snapshot) {
|
|
@@ -1277,6 +1639,13 @@ export function settingsHashesMatchState(remote: Snapshot, state: SyncState) {
|
|
|
1277
1639
|
return sameHashes(settingsHashMap(remote), settingsHashMapFromState(state));
|
|
1278
1640
|
}
|
|
1279
1641
|
|
|
1642
|
+
export function canPullRemoteSettingsOnFirstSync(local: Snapshot, remote: Snapshot) {
|
|
1643
|
+
const remoteSettings = settingsHashMap(remote);
|
|
1644
|
+
return Object.entries(settingsHashMap(local)).every(
|
|
1645
|
+
([filePath, hash]) => remoteSettings[filePath] === hash,
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1280
1649
|
export function canPullRemoteSessionsOnFirstSync(local: Snapshot, remote: Snapshot) {
|
|
1281
1650
|
const localSessions = sessionHashMap(local);
|
|
1282
1651
|
const remoteSessions = sessionHashMap(remote);
|
|
@@ -1615,6 +1984,43 @@ export function parseOptions(args: string[]): CommandOptions {
|
|
|
1615
1984
|
};
|
|
1616
1985
|
}
|
|
1617
1986
|
|
|
1987
|
+
export function completeSyncArguments(argumentPrefix: string): CommandArgumentCompletion[] | null {
|
|
1988
|
+
const prefix = argumentPrefix.trimStart();
|
|
1989
|
+
if (prefix === "") return [...SYNC_COMMAND_COMPLETIONS];
|
|
1990
|
+
|
|
1991
|
+
const trailingSpace = /\s$/.test(prefix);
|
|
1992
|
+
const tokens = splitArgs(prefix);
|
|
1993
|
+
if (tokens.length === 0) return [...SYNC_COMMAND_COMPLETIONS];
|
|
1994
|
+
|
|
1995
|
+
const [command] = tokens;
|
|
1996
|
+
if (tokens.length === 1 && !trailingSpace) {
|
|
1997
|
+
const matches = SYNC_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(command));
|
|
1998
|
+
return matches.length > 0 ? [...matches] : null;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const flagCompletions = SYNC_FLAG_COMPLETIONS[command];
|
|
2002
|
+
if (!flagCompletions) return null;
|
|
2003
|
+
|
|
2004
|
+
const args = tokens.slice(1);
|
|
2005
|
+
const completedArgs = trailingSpace ? args : args.slice(0, -1);
|
|
2006
|
+
const completedValues = completedArgs.filter((arg) => !arg.startsWith("-"));
|
|
2007
|
+
if (command === "rollback" ? completedValues.length > 1 : completedValues.length > 0) {
|
|
2008
|
+
return null;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
const current = trailingSpace ? "" : (args.at(-1) ?? "");
|
|
2012
|
+
if (current && !current.startsWith("-")) return null;
|
|
2013
|
+
|
|
2014
|
+
const currentRaw = trailingSpace ? "" : (prefix.match(/\S+$/)?.[0] ?? "");
|
|
2015
|
+
const completionPrefix = trailingSpace
|
|
2016
|
+
? prefix
|
|
2017
|
+
: prefix.slice(0, prefix.length - currentRaw.length);
|
|
2018
|
+
const matches = flagCompletions.filter((item) => item.value.startsWith(current));
|
|
2019
|
+
return matches.length > 0
|
|
2020
|
+
? matches.map((item) => ({ ...item, value: `${completionPrefix}${item.value}` }))
|
|
2021
|
+
: null;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1618
2024
|
function usage() {
|
|
1619
2025
|
return [
|
|
1620
2026
|
"Usage: /pisync <command>",
|
|
@@ -1715,6 +2121,45 @@ function normalizeOptionalString(value: string | undefined) {
|
|
|
1715
2121
|
return normalized ? normalized : undefined;
|
|
1716
2122
|
}
|
|
1717
2123
|
|
|
2124
|
+
function normalizeExtraFiles(value: unknown) {
|
|
2125
|
+
if (!Array.isArray(value)) return [];
|
|
2126
|
+
const seen = new Set<string>();
|
|
2127
|
+
return value
|
|
2128
|
+
.filter((item): item is string => typeof item === "string")
|
|
2129
|
+
.map((item) => item.trim())
|
|
2130
|
+
.filter((item) => {
|
|
2131
|
+
const lower = item.toLowerCase();
|
|
2132
|
+
if (
|
|
2133
|
+
item === "" ||
|
|
2134
|
+
item === "." ||
|
|
2135
|
+
item === ".." ||
|
|
2136
|
+
item.includes("/") ||
|
|
2137
|
+
item.includes("\\") ||
|
|
2138
|
+
TOP_LEVEL_FILE_NAMES.has(lower) ||
|
|
2139
|
+
isDeniedPath(item) ||
|
|
2140
|
+
RESERVED_TOP_LEVEL_NAMES.has(lower) ||
|
|
2141
|
+
seen.has(lower)
|
|
2142
|
+
) {
|
|
2143
|
+
return false;
|
|
2144
|
+
}
|
|
2145
|
+
seen.add(lower);
|
|
2146
|
+
return true;
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function extraFilePathsByLower(value: unknown) {
|
|
2151
|
+
return new Map(normalizeExtraFiles(value).map((fileName) => [fileName.toLowerCase(), fileName]));
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function selectTopLevelFileEntry(entries: Dirent[], fileName: string) {
|
|
2155
|
+
const exact = entries.find((entry) => entry.isFile() && entry.name === fileName);
|
|
2156
|
+
if (exact) return exact;
|
|
2157
|
+
const lower = fileName.toLowerCase();
|
|
2158
|
+
return entries
|
|
2159
|
+
.filter((entry) => entry.isFile() && entry.name.toLowerCase() === lower)
|
|
2160
|
+
.sort((left, right) => left.name.localeCompare(right.name))[0];
|
|
2161
|
+
}
|
|
2162
|
+
|
|
1718
2163
|
function hasEnv(name: string) {
|
|
1719
2164
|
return Object.prototype.hasOwnProperty.call(process.env, name);
|
|
1720
2165
|
}
|