@narumitw/pi-sync 0.6.1 → 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.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Pi extension that syncs Pi configuration through Cloudflare R2 or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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(["settings.json", "keybindings.json", "models.json", "AGENTS.md"]);
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 = await readRemoteSnapshotForSettingsOnlyUpload(client, config, latest, state);
461
+ const remoteForUpload = options.force
462
+ ? undefined
463
+ : await readRemoteSnapshotForUpload(client, config, latest, state);
406
464
  if (remoteChangedSinceState(latest, state, config) && !options.force) {
407
- if (!remoteForUpload || !settingsHashesMatchState(remoteForUpload, state)) {
408
- throw new Error("Remote changed since last sync. Run /pisync pull first or /pisync push --force.");
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
- const scanTarget = config.syncSessions ? upload : local;
414
- const secrets = scanSnapshot(scanTarget);
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 preservedRemoteSessionCount = Math.max(0, countSessionFiles(upload) - countSessionFiles(local));
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, preservedRemoteSessionCount),
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), applySessionDir);
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 ? hasRemoteChanges(remote, state, config) : false;
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 (!sameHashes(settingsHashMap(local), settingsHashMap(remote))) {
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 = config.syncSessions ? decoded : snapshotWithoutSessions(decoded);
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), applySessionDir);
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 { syncSessions: config.syncSessions, sessionDir: sessionDirFromContext(ctx) };
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
- for (const entry of await fs.readdir(root, { withFileTypes: true })) {
801
- if (entry.isFile() && TOP_LEVEL_FILES.has(entry.name)) {
802
- await addFile(results, root, entry.name);
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 (isDeniedPath(snapshotPath)) return;
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 base = path.posix.basename(normalized).toLowerCase();
975
+ const lower = normalized.toLowerCase();
976
+ const segments = lower.split("/");
977
+ const base = path.posix.basename(lower);
856
978
  return (
857
- normalized.includes("/node_modules/") ||
858
- normalized.includes("/.git/") ||
859
- normalized.includes("/.pisync/") ||
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 filterSnapshotForSessionPolicy(
908
- snapshot: Snapshot | undefined,
909
- syncSessions: boolean,
910
- ): Snapshot | undefined {
911
- if (!snapshot || syncSessions) return snapshot;
912
- return {
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.filter((file) => !isSessionPath(file.path)),
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 readRemoteSnapshotForSettingsOnlyUpload(
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 (config.syncSessions || latest.missing || !latest.value) return undefined;
955
- if (!latest.value.syncSessions && latest.value.snapshot === state.lastAppliedSnapshot) return undefined;
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 (config.syncSessions || latest.missing || !latest.value || latest.value.syncSessions === false) {
967
- return local;
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
- const snapshot = remote ?? (await readRemoteSnapshotRaw(client, config));
970
- return snapshot ? mergeRemoteSessionFiles(local, snapshot) : local;
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
- (file) => isSessionFilePath(file.path) && !isDeniedPath(file.path),
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
- return filterSnapshotForSessionPolicy(await readRemoteSnapshotRaw(client, config), config.syncSessions);
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?: string,
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 = protectSnapshotApplyPlan(
1303
+ const plan = await addTopLevelCaseVariantDeletes(
1047
1304
  root,
1048
- preflightSnapshotApply(root, snapshot, current, { sessionDir }),
1049
- protectedRelativePaths,
1050
- sessionDir,
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
- preservedRemoteSessionCount = 0,
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
- preservedRemoteSessionCount > 0
1210
- ? `Possible secrets in local files were scanned before this prompt; ${preservedRemoteSessionCount} preserved remote session file(s) were not rescanned.`
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.lastFileHashes);
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(remote: Snapshot, state: SyncState, config: SyncConfig) {
1230
- if (remote.id !== state.lastAppliedSnapshot) {
1231
- return config.syncSessions || !settingsHashesMatchState(remote, state);
1232
- }
1233
- return config.syncSessions && state.syncSessions !== true && snapshotIncludesSessions(remote);
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 countSessionFiles(snapshot: Snapshot) {
1253
- return snapshot.files.filter((file) => isSessionPath(file.path)).length;
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
  }