@narumitw/pi-sync 0.13.0 → 0.14.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/src/sync.ts CHANGED
@@ -1,15 +1,77 @@
1
- import { createHash, createHmac, randomUUID } from "node:crypto";
2
- import { constants as fsConstants, type Dirent } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } 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";
6
6
  import { gunzip, gzip } from "node:zlib";
7
7
  import { promisify } from "node:util";
8
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { completeSyncArguments, parseOptions, splitArgs, usage } from "./command.js";
10
+ import {
11
+ agentDir,
12
+ ensureStateDir,
13
+ extraFilePathsByLower,
14
+ isMissingConfigError,
15
+ isStaleLock,
16
+ loadPartialConfig,
17
+ localConfigPath,
18
+ lockPath,
19
+ normalizeExtraFiles,
20
+ readLock,
21
+ stateDir,
22
+ writeJson,
23
+ configuredSessionDir,
24
+ isCloudflareR2Endpoint,
25
+ isEnabled,
26
+ isExplicitlyEnabled,
27
+ loadConfig,
28
+ readState,
29
+ sessionDirForApply,
30
+ sessionTokenWarnings,
31
+ syncSessionsWarnings,
32
+ withLock,
33
+ writeState,
34
+ } from "./config.js";
35
+ import { encodeKey, posixJoin, safeJoin, safeName, toPosix } from "./paths.js";
36
+ import {
37
+ historyKey,
38
+ latestKey,
39
+ pointerFor,
40
+ profilePrefix,
41
+ S3Client,
42
+ snapshotKey,
43
+ } from "./s3-client.js";
44
+ import {
45
+ addTopLevelCaseVariantDeletes,
46
+ appliedFileHashMap,
47
+ applySnapshot,
48
+ preflightSnapshotApply,
49
+ protectSnapshotApplyPlan,
50
+ } from "./snapshot-apply.js";
51
+ import {
52
+ collectFiles,
53
+ createSnapshot,
54
+ canonicalSnapshotPathForConfig,
55
+ filterSnapshotForConfigPolicy,
56
+ isConfiguredSnapshotPath,
57
+ isDeniedPath,
58
+ isSessionPath,
59
+ sessionSnapshotPathFromAbsolute,
60
+ mergeRemotePreservedFiles,
61
+ mergeRemoteSessionFiles,
62
+ scanSnapshot,
63
+ snapshotIncludesSessions,
64
+ snapshotWithoutSessions,
65
+ } from "./snapshot.js";
8
66
  import type {
9
- ExtensionAPI,
10
- ExtensionCommandContext,
11
- ExtensionContext,
12
- } from "@earendil-works/pi-coding-agent";
67
+ CommandOptions,
68
+ LatestPointer,
69
+ RemoteObject,
70
+ Snapshot,
71
+ SnapshotOptions,
72
+ SyncConfig,
73
+ SyncState,
74
+ } from "./types.js";
13
75
 
14
76
  const gzipAsync = promisify(gzip);
15
77
  const gunzipAsync = promisify(gunzip);
@@ -21,123 +83,6 @@ const DEFAULT_PREFIX = "pi-sync";
21
83
  const DEFAULT_REGION = "auto";
22
84
  const LOCK_STALE_MS = 30 * 60 * 1000;
23
85
 
24
- const TOP_LEVEL_FILES = new Set([
25
- "settings.json",
26
- "keybindings.json",
27
- "models.json",
28
- "AGENTS.md",
29
- "APPEND_SYSTEM.md",
30
- ]);
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"]);
35
- const SECRET_PATTERNS = [
36
- /AWS_SECRET_ACCESS_KEY\s*[=:]\s*['\"]?[A-Za-z0-9/+]{35,}/i,
37
- /(ANTHROPIC|OPENAI|GEMINI|GOOGLE|FIRECRAWL|GITHUB|CLOUDFLARE|R2|S3)_[A-Z0-9_]*(KEY|TOKEN|SECRET)\s*[=:]\s*['\"]?[^\s'\"]{12,}/i,
38
- /sk-ant-[A-Za-z0-9_-]{20,}/,
39
- /sk-[A-Za-z0-9]{20,}/,
40
- /gh[pousr]_[A-Za-z0-9_]{20,}/,
41
- ];
42
-
43
- interface SyncConfig {
44
- endpoint: string;
45
- bucket: string;
46
- region: string;
47
- accessKeyId: string;
48
- secretAccessKey: string;
49
- sessionToken?: string;
50
- profile: string;
51
- prefix: string;
52
- syncSessions: boolean;
53
- extraFiles: string[];
54
- }
55
-
56
- interface PartialConfig {
57
- endpoint?: string;
58
- bucket?: string;
59
- region?: string;
60
- accessKeyId?: string;
61
- secretAccessKey?: string;
62
- sessionToken?: string;
63
- profile?: string;
64
- prefix?: string;
65
- autoSync?: boolean | string;
66
- syncSessions?: boolean | string;
67
- extraFiles?: unknown;
68
- }
69
-
70
- interface SnapshotFile {
71
- path: string;
72
- contentBase64: string;
73
- sha256: string;
74
- }
75
-
76
- interface Snapshot {
77
- version: number;
78
- id: string;
79
- createdAt: string;
80
- machine: string;
81
- profile: string;
82
- syncSessions?: boolean;
83
- files: SnapshotFile[];
84
- }
85
-
86
- interface LatestPointer {
87
- version: number;
88
- profile: string;
89
- snapshot: string;
90
- sha256: string;
91
- createdAt: string;
92
- machine: string;
93
- syncSessions?: boolean;
94
- }
95
-
96
- interface RemoteObject<T> {
97
- value?: T;
98
- etag?: string;
99
- missing: boolean;
100
- }
101
-
102
- interface SyncState {
103
- version: number;
104
- profile: string;
105
- lastAppliedSnapshot?: string;
106
- lastRemoteEtag?: string;
107
- lastFileHashes: Record<string, string>;
108
- syncSessions?: boolean;
109
- extraFiles?: string[];
110
- }
111
-
112
- interface LockFile {
113
- id: string;
114
- pid: number;
115
- command: string;
116
- startedAt: string;
117
- }
118
-
119
- interface CommandOptions {
120
- yes: boolean;
121
- force: boolean;
122
- stale: boolean;
123
- silent: boolean;
124
- reload: boolean;
125
- auto: boolean;
126
- args: string[];
127
- }
128
-
129
- interface CommandArgumentCompletion {
130
- value: string;
131
- label: string;
132
- description?: string;
133
- }
134
-
135
- interface SnapshotOptions {
136
- syncSessions?: boolean;
137
- sessionDir?: string;
138
- extraFiles?: string[];
139
- }
140
-
141
86
  interface PushInput {
142
87
  config: SyncConfig;
143
88
  state: SyncState;
@@ -145,11 +90,6 @@ interface PushInput {
145
90
  client?: S3Client;
146
91
  }
147
92
 
148
- interface SnapshotApplyPlan {
149
- writes: Array<{ target: string; content: Buffer }>;
150
- deletes: string[];
151
- }
152
-
153
93
  const AUTO_SYNC_OPTIONS: CommandOptions = {
154
94
  yes: true,
155
95
  force: false,
@@ -159,41 +99,6 @@ const AUTO_SYNC_OPTIONS: CommandOptions = {
159
99
  auto: true,
160
100
  args: [],
161
101
  };
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
- };
196
-
197
102
  export default function sync(pi: ExtensionAPI) {
198
103
  pi.registerCommand("pisync", {
199
104
  description: "Sync Pi settings through Cloudflare R2 or S3-compatible storage",
@@ -770,382 +675,6 @@ async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext) {
770
675
  }
771
676
  }
772
677
 
773
- async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
774
- await ensureStateDir();
775
- const lock: LockFile = {
776
- id: randomUUID(),
777
- pid: process.pid,
778
- command,
779
- startedAt: new Date().toISOString(),
780
- };
781
- let handle: fs.FileHandle | undefined;
782
- try {
783
- handle = await fs.open(lockPath(), "wx");
784
- await handle.writeFile(JSON.stringify(lock, null, "\t"));
785
- await handle.close();
786
- handle = undefined;
787
- return await fn();
788
- } catch (error) {
789
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
790
- const current = await readLock();
791
- if (current && isStaleLock(current)) {
792
- throw new Error(`pi-sync lock is stale (pid ${current.pid}). Run /pisync unlock --stale, then retry.`);
793
- }
794
- throw new Error(`pi-sync is already running${current ? ` (${current.command}, pid ${current.pid}, started ${current.startedAt})` : ""}.`);
795
- }
796
- throw error;
797
- } finally {
798
- await handle?.close();
799
- const current = await readLock();
800
- if (current?.id === lock.id) await fs.rm(lockPath(), { force: true });
801
- }
802
- }
803
-
804
- async function loadConfigInternal(): Promise<SyncConfig> {
805
- const partial = await loadPartialConfig();
806
- const endpoint = partial.endpoint;
807
- const bucket = partial.bucket;
808
- const accessKeyId = partial.accessKeyId;
809
- const secretAccessKey = partial.secretAccessKey;
810
- const missing = [
811
- ["endpoint", endpoint],
812
- ["bucket", bucket],
813
- ["accessKeyId", accessKeyId],
814
- ["secretAccessKey", secretAccessKey],
815
- ]
816
- .filter(([, value]) => !value)
817
- .map(([name]) => name);
818
- if (missing.length > 0) {
819
- throw new Error(`Missing pi-sync config: ${missing.join(", ")}. Run /pisync init or set PI_SYNC_* environment variables.`);
820
- }
821
-
822
- return {
823
- endpoint: endpoint!,
824
- bucket: bucket!,
825
- region: partial.region ?? DEFAULT_REGION,
826
- accessKeyId: accessKeyId!,
827
- secretAccessKey: secretAccessKey!,
828
- sessionToken: partial.sessionToken,
829
- profile: partial.profile ?? DEFAULT_PROFILE,
830
- prefix: trimSlashes(partial.prefix ?? DEFAULT_PREFIX),
831
- syncSessions: isExplicitlyEnabled(partial.syncSessions),
832
- extraFiles: normalizeExtraFiles(partial.extraFiles),
833
- };
834
- }
835
-
836
- export async function loadConfig(): Promise<SyncConfig> {
837
- return loadConfigInternal();
838
- }
839
-
840
- async function loadPartialConfig(): Promise<PartialConfig> {
841
- const fileConfig = (await readJsonIfExists<PartialConfig>(localConfigPath())) ?? {};
842
- return {
843
- ...fileConfig,
844
- endpoint: process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? fileConfig.endpoint,
845
- bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig.bucket,
846
- region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig.region,
847
- accessKeyId: process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig.accessKeyId,
848
- secretAccessKey: process.env.PI_SYNC_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY ?? fileConfig.secretAccessKey,
849
- sessionToken: selectSessionToken(fileConfig.sessionToken),
850
- profile: process.env.PI_SYNC_PROFILE ?? fileConfig.profile,
851
- prefix: process.env.PI_SYNC_PREFIX ?? fileConfig.prefix,
852
- autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig.autoSync,
853
- syncSessions: process.env.PI_SYNC_SESSIONS ?? fileConfig.syncSessions,
854
- extraFiles: fileConfig.extraFiles,
855
- };
856
- }
857
-
858
- async function configuredSessionDir() {
859
- const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
860
- if (envSessionDir) return expandHome(envSessionDir);
861
- const settings = await readJsonIfExists<{ sessionDir?: string }>(path.join(agentDir(), "settings.json"));
862
- return settings?.sessionDir ? expandHome(settings.sessionDir) : undefined;
863
- }
864
-
865
- async function sessionDirForApply(ctx: ExtensionCommandContext | ExtensionContext, snapshot: Snapshot) {
866
- const contextSessionDir = sessionDirFromContext(ctx);
867
- const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
868
- if (envSessionDir) return contextSessionDir ?? expandHome(envSessionDir);
869
-
870
- const localSessionDir = await configuredSessionDir();
871
- if (contextSessionDir && path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")) {
872
- return contextSessionDir;
873
- }
874
- return sessionDirFromSnapshot(snapshot) ?? contextSessionDir;
875
- }
876
-
877
- function sessionDirFromSnapshot(snapshot: Snapshot) {
878
- const settingsFile = snapshot.files.find((file) => file.path === "settings.json");
879
- if (!settingsFile) return undefined;
880
- try {
881
- const settings = JSON.parse(
882
- decodeBase64Strict(settingsFile.contentBase64, settingsFile.path).toString("utf8"),
883
- ) as { sessionDir?: string };
884
- return settings.sessionDir ? expandHome(settings.sessionDir) : undefined;
885
- } catch {
886
- return undefined;
887
- }
888
- }
889
-
890
- async function createSnapshot(profile: string, options: SnapshotOptions = {}): Promise<Snapshot> {
891
- const syncSessions = Boolean(options.syncSessions);
892
- const files = await collectFiles(agentDir(), {
893
- syncSessions,
894
- sessionDir: options.sessionDir ?? (await configuredSessionDir()),
895
- extraFiles: options.extraFiles,
896
- });
897
- return {
898
- version: VERSION,
899
- id: snapshotId(),
900
- createdAt: new Date().toISOString(),
901
- machine: os.hostname(),
902
- profile,
903
- syncSessions,
904
- files,
905
- };
906
- }
907
-
908
- export async function collectFiles(
909
- root: string,
910
- options: SnapshotOptions = {},
911
- ): Promise<SnapshotFile[]> {
912
- const results: SnapshotFile[] = [];
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)) {
916
- await collectDirectory(results, root, entry.name);
917
- }
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
- }
927
- if (options.syncSessions) {
928
- try {
929
- await collectDirectory(results, sessionStorageRoot(root, options.sessionDir), "", {
930
- sessionsOnly: true,
931
- virtualPrefix: "sessions",
932
- });
933
- } catch (error) {
934
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
935
- }
936
- }
937
- return results.sort((left, right) => left.path.localeCompare(right.path));
938
- }
939
-
940
- async function collectDirectory(
941
- results: SnapshotFile[],
942
- root: string,
943
- relativeDirectory: string,
944
- options: { sessionsOnly?: boolean; virtualPrefix?: string } = {},
945
- ) {
946
- const absoluteDirectory = path.join(root, relativeDirectory);
947
- for (const entry of await fs.readdir(absoluteDirectory, { withFileTypes: true })) {
948
- const relativePath = relativeDirectory ? posixJoin(relativeDirectory, entry.name) : entry.name;
949
- const snapshotPath = options.virtualPrefix
950
- ? posixJoin(options.virtualPrefix, relativePath)
951
- : relativePath;
952
- if (isDeniedPath(snapshotPath)) continue;
953
- if (entry.isDirectory()) {
954
- await collectDirectory(results, root, relativePath, options);
955
- } else if (entry.isFile() && (!options.sessionsOnly || isSessionFilePath(snapshotPath))) {
956
- await addFile(results, root, relativePath, snapshotPath);
957
- }
958
- }
959
- }
960
-
961
- async function addFile(
962
- results: SnapshotFile[],
963
- root: string,
964
- relativePath: string,
965
- snapshotPath = relativePath,
966
- ) {
967
- if (!isSafeSnapshotPath(snapshotPath)) return;
968
- const absolutePath = safeJoin(root, relativePath);
969
- const content = await fs.readFile(absolutePath);
970
- results.push({ path: snapshotPath, contentBase64: content.toString("base64"), sha256: sha256(content) });
971
- }
972
-
973
- export function isDeniedPath(relativePath: string) {
974
- const normalized = toPosix(relativePath);
975
- const lower = normalized.toLowerCase();
976
- const segments = lower.split("/");
977
- const base = path.posix.basename(lower);
978
- return (
979
- segments.includes("node_modules") ||
980
- segments.includes(".git") ||
981
- segments.includes(".pisync") ||
982
- base === ".env" ||
983
- base.startsWith(".env.") ||
984
- base.endsWith(".env") ||
985
- base.includes("secret") ||
986
- base.includes("token") ||
987
- base === "pi-sync.local.json"
988
- );
989
- }
990
-
991
- function isSessionPath(relativePath: string) {
992
- return toPosix(relativePath).startsWith("sessions/");
993
- }
994
-
995
- function isSessionFilePath(relativePath: string) {
996
- const normalized = toPosix(relativePath);
997
- return isSessionPath(normalized) && normalized.endsWith(".jsonl");
998
- }
999
-
1000
- function sessionStorageRoot(root: string, configuredSessionDir?: string) {
1001
- return configuredSessionDir ? path.resolve(expandHome(configuredSessionDir)) : path.resolve(root, "sessions");
1002
- }
1003
-
1004
- function sessionSnapshotPathFromAbsolute(sessionFile: string, configuredSessionDir?: string) {
1005
- const relativePath = toPosix(path.relative(sessionStorageRoot(agentDir(), configuredSessionDir), sessionFile));
1006
- if (!relativePath || relativePath.startsWith("../") || path.posix.isAbsolute(relativePath)) {
1007
- return undefined;
1008
- }
1009
- const snapshotPath = posixJoin("sessions", relativePath);
1010
- return isSessionFilePath(snapshotPath) ? snapshotPath : undefined;
1011
- }
1012
-
1013
- function snapshotTarget(root: string, relativePath: string, configuredSessionDir?: string) {
1014
- if (isSessionPath(relativePath)) {
1015
- return safeJoin(sessionStorageRoot(root, configuredSessionDir), relativePath.slice("sessions/".length));
1016
- }
1017
- return safeJoin(root, relativePath);
1018
- }
1019
-
1020
- function isPathInside(parent: string, child: string) {
1021
- const relativePath = path.relative(parent, child);
1022
- return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
1023
- }
1024
-
1025
- function snapshotIncludesSessions(snapshot: Snapshot) {
1026
- return snapshot.syncSessions === true || snapshot.files.some((file) => isSessionPath(file.path));
1027
- }
1028
-
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 = {
1037
- ...snapshot,
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(),
1047
- };
1048
- }
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
-
1120
- export function snapshotWithoutSessions(snapshot: Snapshot) {
1121
- const files = snapshot.files.filter((file) => !isSessionPath(file.path));
1122
- if (files.length === snapshot.files.length && snapshot.syncSessions !== true) return snapshot;
1123
- return {
1124
- ...snapshot,
1125
- id: snapshotId(),
1126
- createdAt: new Date().toISOString(),
1127
- machine: os.hostname(),
1128
- syncSessions: false,
1129
- files,
1130
- };
1131
- }
1132
-
1133
- export function scanSnapshot(snapshot: Snapshot) {
1134
- const findings: string[] = [];
1135
- for (const file of snapshot.files) {
1136
- const content = Buffer.from(file.contentBase64, "base64");
1137
- if (content.includes(0)) continue;
1138
- const text = content.toString("utf8");
1139
- for (const pattern of SECRET_PATTERNS) {
1140
- if (pattern.test(text)) {
1141
- findings.push(file.path);
1142
- break;
1143
- }
1144
- }
1145
- }
1146
- return findings;
1147
- }
1148
-
1149
678
  async function readRemoteSnapshotForUpload(
1150
679
  client: S3Client,
1151
680
  config: SyncConfig,
@@ -1183,64 +712,6 @@ async function snapshotForUpload(
1183
712
  return snapshot ? mergeRemotePreservedFiles(local, snapshot, config) : local;
1184
713
  }
1185
714
 
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
- };
1224
- }
1225
-
1226
- export function mergeRemoteSessionFiles(local: Snapshot, remote: Snapshot) {
1227
- const remoteSessions = remote.files.filter((file) => {
1228
- const normalized = toPosix(file.path);
1229
- return isSessionFilePath(normalized) && isSafeSnapshotPath(file.path);
1230
- });
1231
- if (remoteSessions.length === 0 && !snapshotIncludesSessions(remote)) return local;
1232
- return {
1233
- ...local,
1234
- id: snapshotId(),
1235
- createdAt: new Date().toISOString(),
1236
- machine: os.hostname(),
1237
- syncSessions: true,
1238
- files: [...local.files.filter((file) => !isSessionPath(file.path)), ...remoteSessions].sort(
1239
- (left, right) => left.path.localeCompare(right.path),
1240
- ),
1241
- };
1242
- }
1243
-
1244
715
  async function uploadSnapshot(
1245
716
  client: S3Client,
1246
717
  config: SyncConfig,
@@ -1288,172 +759,6 @@ async function decodeSnapshot(buffer: Buffer): Promise<Snapshot> {
1288
759
  return parsed;
1289
760
  }
1290
761
 
1291
- async function applySnapshot(
1292
- snapshot: Snapshot,
1293
- protectedRelativePaths = new Set<string>(),
1294
- options: Pick<SnapshotOptions, "sessionDir" | "extraFiles"> = {},
1295
- ) {
1296
- const root = agentDir();
1297
- const { sessionDir } = options;
1298
- const current = await createSnapshot(snapshot.profile, {
1299
- syncSessions: snapshotIncludesSessions(snapshot),
1300
- sessionDir,
1301
- extraFiles: options.extraFiles,
1302
- });
1303
- const plan = await addTopLevelCaseVariantDeletes(
1304
- root,
1305
- protectSnapshotApplyPlan(
1306
- root,
1307
- preflightSnapshotApply(root, snapshot, current, { sessionDir }),
1308
- protectedRelativePaths,
1309
- sessionDir,
1310
- ),
1311
- snapshot,
1312
- );
1313
- await preflightSnapshotMutations(root, plan, sessionDir);
1314
- for (const target of plan.deletes) {
1315
- await fs.rm(target, { force: true, recursive: true });
1316
- }
1317
- for (const item of plan.writes) {
1318
- await fs.writeFile(item.target, item.content);
1319
- }
1320
- return appliedFileHashMap(snapshot, current, protectedRelativePaths);
1321
- }
1322
-
1323
- export function preflightSnapshotApply(
1324
- root: string,
1325
- snapshot: Snapshot,
1326
- current: Snapshot,
1327
- options: { sessionDir?: string } = {},
1328
- ): SnapshotApplyPlan {
1329
- const seenPaths = new Set<string>();
1330
- const remotePaths = new Set<string>();
1331
- const writes: Array<{ target: string; content: Buffer }> = [];
1332
- const deletes: string[] = [];
1333
-
1334
- for (const file of snapshot.files) {
1335
- const normalized = toPosix(file.path);
1336
- if (!isSafeSnapshotPath(file.path)) {
1337
- throw new Error(`Unsafe path in snapshot: ${file.path}`);
1338
- }
1339
- if (isSessionPath(normalized) && !isSessionFilePath(normalized)) {
1340
- throw new Error(`Unsafe session path in snapshot: ${file.path}`);
1341
- }
1342
- if (seenPaths.has(normalized)) throw new Error(`Duplicate path in snapshot: ${normalized}`);
1343
- seenPaths.add(normalized);
1344
- remotePaths.add(normalized);
1345
-
1346
- const target = snapshotTarget(root, normalized, options.sessionDir);
1347
- const content = decodeBase64Strict(file.contentBase64, normalized);
1348
- if (sha256(content) !== file.sha256) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
1349
- writes.push({ target, content });
1350
- }
1351
-
1352
- const deletePaths = new Set<string>();
1353
- for (const file of current.files) {
1354
- const normalized = toPosix(file.path);
1355
- if (!remotePaths.has(normalized)) {
1356
- deletePaths.add(snapshotTarget(root, normalized, options.sessionDir));
1357
- }
1358
- for (const remotePath of parentPaths(normalized)) {
1359
- if (remotePaths.has(remotePath)) {
1360
- deletePaths.add(snapshotTarget(root, remotePath, options.sessionDir));
1361
- }
1362
- }
1363
- }
1364
- deletes.push(...deletePaths);
1365
-
1366
- return { writes, deletes };
1367
- }
1368
-
1369
- export function protectSnapshotApplyPlan(
1370
- root: string,
1371
- plan: SnapshotApplyPlan,
1372
- protectedRelativePaths: Set<string>,
1373
- sessionDir?: string,
1374
- ): SnapshotApplyPlan {
1375
- if (protectedRelativePaths.size === 0) return plan;
1376
- const protectedTargets = new Set(
1377
- [...protectedRelativePaths].map((relativePath) => snapshotTarget(root, relativePath, sessionDir)),
1378
- );
1379
- return {
1380
- writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
1381
- deletes: plan.deletes.filter((target) => !protectedTargets.has(target)),
1382
- };
1383
- }
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
-
1417
- export function appliedFileHashMap(
1418
- snapshot: Snapshot,
1419
- current: Snapshot,
1420
- protectedRelativePaths: Set<string>,
1421
- ) {
1422
- const hashes = fileHashMap(snapshot);
1423
- if (protectedRelativePaths.size === 0) return hashes;
1424
- const currentHashes = fileHashMap(current);
1425
- for (const relativePath of protectedRelativePaths) {
1426
- const normalized = toPosix(relativePath);
1427
- if (currentHashes[normalized]) {
1428
- hashes[normalized] = currentHashes[normalized];
1429
- } else {
1430
- delete hashes[normalized];
1431
- }
1432
- }
1433
- return hashes;
1434
- }
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
-
1450
- function decodeBase64Strict(value: string, filePath: string) {
1451
- if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
1452
- throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
1453
- }
1454
- return Buffer.from(value, "base64");
1455
- }
1456
-
1457
762
  export async function backupLocal(profile: string, options: SnapshotOptions = {}) {
1458
763
  const snapshot = await createSnapshot(profile, options);
1459
764
  const backupDirectory = path.join(stateDir(), "backups");
@@ -1652,533 +957,37 @@ export function canPullRemoteSessionsOnFirstSync(local: Snapshot, remote: Snapsh
1652
957
  return Object.entries(localSessions).every(([filePath, hash]) => remoteSessions[filePath] === hash);
1653
958
  }
1654
959
 
1655
- async function readState(profile: string): Promise<SyncState> {
1656
- return (
1657
- (await readJsonIfExists<SyncState>(statePath(profile))) ?? {
1658
- version: VERSION,
1659
- profile,
1660
- lastFileHashes: {},
1661
- }
1662
- );
1663
- }
1664
-
1665
- async function writeState(profile: string, state: SyncState) {
1666
- await writeJson(statePath(profile), state);
1667
- }
1668
-
1669
- class S3Client {
1670
- private config: SyncConfig;
1671
- private endpoint: URL;
1672
- private omitSessionTokenAfterRejection = false;
1673
-
1674
- constructor(config: SyncConfig) {
1675
- this.config = config;
1676
- this.endpoint = new URL(config.endpoint);
1677
- }
1678
-
1679
- async getJson<T>(key: string): Promise<RemoteObject<T>> {
1680
- const object = await this.request("GET", key);
1681
- if (object.status === 404) return { missing: true };
1682
- if (!object.ok) throw new Error(`S3 GET failed (${object.status}): ${await object.text()}`);
1683
- return { value: (await object.json()) as T, etag: normalizeEtag(object.headers.get("etag")), missing: false };
1684
- }
1685
-
1686
- async getBuffer(key: string): Promise<RemoteObject<Buffer>> {
1687
- const object = await this.request("GET", key);
1688
- if (object.status === 404) return { missing: true };
1689
- if (!object.ok) throw new Error(`S3 GET failed (${object.status}): ${await object.text()}`);
1690
- return { value: Buffer.from(await object.arrayBuffer()), etag: normalizeEtag(object.headers.get("etag")), missing: false };
1691
- }
1692
-
1693
- async putJson(key: string, value: unknown) {
1694
- const body = Buffer.from(JSON.stringify(value, null, "\t"), "utf8");
1695
- await this.putBuffer(key, body, "application/json");
1696
- }
1697
-
1698
- async putBuffer(key: string, body: Buffer, contentType: string) {
1699
- const headers: Record<string, string> = { "content-type": contentType };
1700
- const response = await this.request("PUT", key, body, headers);
1701
- if (!response.ok) throw new Error(`S3 PUT failed (${response.status}): ${await response.text()}`);
1702
- }
1703
-
1704
- private async request(
1705
- method: "GET" | "PUT",
1706
- key: string,
1707
- body?: Buffer,
1708
- extraHeaders: Record<string, string> = {},
1709
- ) {
1710
- const url = new URL(this.endpoint.toString());
1711
- url.pathname = posixJoin(url.pathname, this.config.bucket, encodeKey(key));
1712
- const send = async (sessionToken: string | undefined) => {
1713
- const headers = await signedHeaders({
1714
- method,
1715
- url,
1716
- body,
1717
- extraHeaders,
1718
- accessKeyId: this.config.accessKeyId,
1719
- secretAccessKey: this.config.secretAccessKey,
1720
- sessionToken,
1721
- region: this.config.region,
1722
- });
1723
- return fetch(url, { method, headers, body: body ? new Uint8Array(body) : undefined });
1724
- };
1725
- const sessionToken = this.omitSessionTokenAfterRejection ? undefined : this.config.sessionToken;
1726
- const response = await send(sessionToken);
1727
- if (!(await this.shouldRetryWithoutSessionToken(response, sessionToken))) return response;
1728
-
1729
- const retry = await send(undefined);
1730
- if (retry.ok || retry.status === 404) this.omitSessionTokenAfterRejection = true;
1731
- return retry;
1732
- }
1733
-
1734
- private async shouldRetryWithoutSessionToken(response: Response, sessionToken: string | undefined) {
1735
- if (
1736
- !sessionToken ||
1737
- !isCloudflareR2Endpoint(this.config.endpoint) ||
1738
- response.ok ||
1739
- response.status !== 400
1740
- ) {
1741
- return false;
1742
- }
1743
- return isSecurityTokenInvalidArgument(await response.clone().text());
1744
- }
1745
- }
1746
-
1747
- async function signedHeaders(input: {
1748
- method: string;
1749
- url: URL;
1750
- body?: Buffer;
1751
- extraHeaders: Record<string, string>;
1752
- accessKeyId: string;
1753
- secretAccessKey: string;
1754
- sessionToken?: string;
1755
- region: string;
1756
- }) {
1757
- const now = new Date();
1758
- const amzDate = iso8601Basic(now);
1759
- const dateStamp = amzDate.slice(0, 8);
1760
- const payloadHash = sha256(input.body ?? Buffer.alloc(0));
1761
- const headers: Record<string, string> = {
1762
- ...lowercaseKeys(input.extraHeaders),
1763
- host: input.url.host,
1764
- "x-amz-content-sha256": payloadHash,
1765
- "x-amz-date": amzDate,
1766
- };
1767
- if (input.sessionToken) headers["x-amz-security-token"] = input.sessionToken;
1768
- const signedHeaderNames = Object.keys(headers).sort();
1769
- const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]?.trim()}\n`).join("");
1770
- const canonicalRequest = [
1771
- input.method,
1772
- input.url.pathname,
1773
- input.url.searchParams.toString(),
1774
- canonicalHeaders,
1775
- signedHeaderNames.join(";"),
1776
- payloadHash,
1777
- ].join("\n");
1778
- const scope = `${dateStamp}/${input.region}/s3/aws4_request`;
1779
- const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(Buffer.from(canonicalRequest))].join("\n");
1780
- const signingKey = hmac(
1781
- hmac(hmac(hmac(Buffer.from(`AWS4${input.secretAccessKey}`), dateStamp), input.region), "s3"),
1782
- "aws4_request",
1783
- );
1784
- const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
1785
- return {
1786
- ...headers,
1787
- authorization: `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaderNames.join(";")}, Signature=${signature}`,
1788
- };
1789
- }
1790
-
1791
- function hmac(key: Buffer, value: string) {
1792
- return createHmac("sha256", key).update(value).digest();
1793
- }
1794
-
1795
960
  function sha256(value: Buffer) {
1796
961
  return createHash("sha256").update(value).digest("hex");
1797
962
  }
1798
963
 
1799
- function latestKey(config: SyncConfig) {
1800
- return posixJoin(profilePrefix(config), "latest.json");
1801
- }
1802
-
1803
- function historyKey(config: SyncConfig) {
1804
- return posixJoin(profilePrefix(config), "history.json");
1805
- }
1806
-
1807
- function snapshotKey(config: SyncConfig, id: string) {
1808
- return posixJoin(profilePrefix(config), "snapshots", `${id}.json.gz`);
1809
- }
1810
-
1811
- function profilePrefix(config: SyncConfig) {
1812
- return posixJoin(config.prefix, "profiles", config.profile);
1813
- }
1814
-
1815
- function pointerFor(config: SyncConfig, snapshot: Snapshot, checksum: string): LatestPointer {
1816
- return {
1817
- version: VERSION,
1818
- profile: config.profile,
1819
- snapshot: snapshot.id,
1820
- sha256: checksum,
1821
- createdAt: snapshot.createdAt,
1822
- machine: snapshot.machine,
1823
- syncSessions: snapshotIncludesSessions(snapshot),
1824
- };
1825
- }
1826
-
1827
- function agentDir() {
1828
- const configured = normalizeOptionalString(process.env.PI_CODING_AGENT_DIR);
1829
- return configured ? expandHome(configured) : path.join(os.homedir(), ".pi", "agent");
1830
- }
1831
-
1832
- function expandHome(value: string) {
1833
- return value === "~" || value.startsWith("~/")
1834
- ? path.join(os.homedir(), value.slice(2))
1835
- : value;
1836
- }
1837
-
1838
- function stateDir() {
1839
- return path.join(agentDir(), ".pisync");
1840
- }
1841
-
1842
- function localConfigPath() {
1843
- return path.join(agentDir(), "pi-sync.local.json");
1844
- }
1845
-
1846
- function statePath(profile: string) {
1847
- return path.join(stateDir(), `${safeName(profile)}.state.json`);
1848
- }
1849
-
1850
- function lockPath() {
1851
- return path.join(stateDir(), "lock");
1852
- }
1853
-
1854
- async function ensureStateDir() {
1855
- await fs.mkdir(stateDir(), { recursive: true });
1856
- }
1857
-
1858
- async function readLock() {
1859
- return readJsonIfExists<LockFile>(lockPath());
1860
- }
1861
-
1862
- function isStaleLock(lock: LockFile) {
1863
- if (!Number.isInteger(lock.pid) || lock.pid <= 0) return true;
1864
- try {
1865
- process.kill(lock.pid, 0);
1866
- return false;
1867
- } catch (error) {
1868
- if ((error as NodeJS.ErrnoException).code === "ESRCH") return true;
1869
- return Date.now() - Date.parse(lock.startedAt) > LOCK_STALE_MS;
1870
- }
1871
- }
1872
-
1873
- async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
1874
- try {
1875
- return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
1876
- } catch (error) {
1877
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
1878
- throw error;
1879
- }
1880
- }
1881
-
1882
- async function writeJson(filePath: string, value: unknown) {
1883
- await fs.mkdir(path.dirname(filePath), { recursive: true });
1884
- await fs.writeFile(filePath, `${JSON.stringify(value, null, "\t")}\n`);
1885
- }
1886
-
1887
- async function preflightSnapshotMutations(
1888
- root: string,
1889
- plan: { deletes: string[]; writes: Array<{ target: string; content: Buffer }> },
1890
- sessionDir?: string,
1891
- ) {
1892
- const deletePaths = new Set(plan.deletes);
1893
- for (const target of plan.deletes) {
1894
- await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
1895
- }
1896
- for (const item of plan.writes) {
1897
- await prepareSnapshotWrite(rootForTarget(root, item.target, sessionDir), item.target, deletePaths);
1898
- }
1899
- }
1900
-
1901
- function rootForTarget(root: string, target: string, sessionDir?: string) {
1902
- const sessionRoot = sessionDir ? sessionStorageRoot(root, sessionDir) : undefined;
1903
- if (sessionRoot && isPathInside(sessionRoot, target)) return sessionRoot;
1904
- return root;
1905
- }
1906
-
1907
- async function prepareSnapshotWrite(root: string, target: string, deletePaths: Set<string>) {
1908
- await ensureSafeDirectory(root, path.dirname(target));
1909
- try {
1910
- const stat = await fs.lstat(target);
1911
- if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
1912
- if (stat.isDirectory() && !deletePaths.has(target)) {
1913
- throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
1914
- }
1915
- } catch (error) {
1916
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1917
- }
1918
- }
1919
-
1920
- async function ensureSafeDirectory(root: string, directory: string) {
1921
- assertWithinRoot(root, directory);
1922
- const rootPath = path.resolve(root);
1923
- const relative = path.relative(rootPath, path.resolve(directory));
1924
- let current = rootPath;
1925
- for (const part of relative.split(path.sep).filter(Boolean)) {
1926
- current = path.join(current, part);
1927
- try {
1928
- const stat = await fs.lstat(current);
1929
- if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
1930
- if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
1931
- } catch (error) {
1932
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1933
- await fs.mkdir(current);
1934
- }
1935
- }
1936
- }
1937
-
1938
- async function assertNoSymlinkParents(root: string, target: string) {
1939
- assertWithinRoot(root, target);
1940
- const rootPath = path.resolve(root);
1941
- const relative = path.relative(rootPath, path.resolve(target));
1942
- let current = rootPath;
1943
- const parts = relative.split(path.sep).filter(Boolean);
1944
- for (const part of parts.slice(0, -1)) {
1945
- current = path.join(current, part);
1946
- try {
1947
- const stat = await fs.lstat(current);
1948
- if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
1949
- if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
1950
- } catch (error) {
1951
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1952
- throw error;
1953
- }
1954
- }
1955
- }
1956
-
1957
- export function safeJoin(root: string, relativePath: string) {
1958
- const target = path.resolve(root, relativePath);
1959
- assertWithinRoot(root, target, relativePath);
1960
- return target;
1961
- }
1962
-
1963
- function assertWithinRoot(root: string, target: string, label = target) {
1964
- const resolvedRoot = path.resolve(root);
1965
- const resolvedTarget = path.resolve(target);
1966
- if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) {
1967
- throw new Error(`Unsafe path in snapshot: ${label}`);
1968
- }
1969
- }
1970
-
1971
- export function splitArgs(input: string) {
1972
- return input.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((arg) => arg.replace(/^['"]|['"]$/g, "")) ?? [];
1973
- }
1974
-
1975
- export function parseOptions(args: string[]): CommandOptions {
1976
- return {
1977
- yes: args.includes("--yes") || args.includes("-y"),
1978
- force: args.includes("--force"),
1979
- stale: args.includes("--stale"),
1980
- silent: false,
1981
- reload: true,
1982
- auto: false,
1983
- args: args.filter((arg) => !arg.startsWith("-")),
1984
- };
1985
- }
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
-
2024
- function usage() {
2025
- return [
2026
- "Usage: /pisync <command>",
2027
- "Commands: init, config, status, diff, doctor, push, pull, sync, history, rollback <snapshot>, unlock --stale",
2028
- "Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN, PI_SYNC_SESSIONS/syncSessions, region/profile/prefix, or edit ~/.pi/agent/pi-sync.local.json (or $PI_CODING_AGENT_DIR/pi-sync.local.json when PI_CODING_AGENT_DIR is set).",
2029
- ].join("\n");
2030
- }
2031
-
2032
- function snapshotId() {
2033
- return `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
2034
- }
2035
-
2036
- function iso8601Basic(date: Date) {
2037
- return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
2038
- }
2039
-
2040
- export function encodeKey(key: string) {
2041
- return key.split("/").map(encodeURIComponent).join("/");
2042
- }
2043
-
2044
- export function posixJoin(...parts: string[]) {
2045
- return parts.map((part) => trimSlashes(part)).filter(Boolean).join("/");
2046
- }
2047
-
2048
- function parentPaths(relativePath: string) {
2049
- const results: string[] = [];
2050
- let index = relativePath.lastIndexOf("/");
2051
- while (index > 0) {
2052
- results.push(relativePath.slice(0, index));
2053
- index = relativePath.lastIndexOf("/", index - 1);
2054
- }
2055
- return results;
2056
- }
2057
-
2058
- function toPosix(value: string) {
2059
- return value.split(path.sep).join("/");
2060
- }
2061
-
2062
- function trimSlashes(value: string) {
2063
- return value.replace(/^\/+|\/+$/g, "");
2064
- }
2065
-
2066
- export function safeName(value: string) {
2067
- return value.replace(/[^A-Za-z0-9._-]/g, "_");
2068
- }
2069
-
2070
- function lowercaseKeys(value: Record<string, string>) {
2071
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key.toLowerCase(), item]));
2072
- }
2073
-
2074
- function normalizeEtag(value: string | null) {
2075
- return value ?? undefined;
2076
- }
2077
-
2078
964
  function redact(value: string) {
2079
965
  return value.length <= 8 ? "configured" : `${value.slice(0, 4)}…${value.slice(-4)}`;
2080
966
  }
2081
967
 
2082
- function selectSessionToken(fileSessionToken: string | undefined) {
2083
- if (hasEnv("PI_SYNC_SESSION_TOKEN")) return normalizeOptionalString(process.env.PI_SYNC_SESSION_TOKEN);
2084
- return normalizeOptionalString(process.env.AWS_SESSION_TOKEN) ?? normalizeOptionalString(fileSessionToken);
2085
- }
2086
-
2087
- export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?: string }) {
2088
- if (!isCloudflareR2Endpoint(config.endpoint) || !config.sessionToken) return [];
2089
- return [
2090
- "session token: configured for Cloudflare R2; if R2 rejects X-Amz-Security-Token, pi-sync retries once without it. R2 static access keys usually do not need a session token.",
2091
- ];
2092
- }
2093
-
2094
- function syncSessionsWarnings(config: { syncSessions?: boolean }) {
2095
- if (!config.syncSessions) return [];
2096
- return [
2097
- "sessions: enabled; Pi session JSONL can contain prompts, tool output, file paths, images, and secrets. Sync sessions only to storage you trust.",
2098
- ];
2099
- }
2100
-
2101
- function isSecurityTokenInvalidArgument(text: string) {
2102
- return (
2103
- text.includes("<Code>InvalidArgument</Code>") &&
2104
- text.includes("<Message>X-Amz-Security-Token</Message>")
2105
- );
2106
- }
2107
-
2108
- export function isCloudflareR2Endpoint(endpoint: string | undefined) {
2109
- const value = endpoint?.trim();
2110
- if (!value) return false;
2111
- try {
2112
- const hostname = new URL(value).hostname.toLowerCase();
2113
- return hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
2114
- } catch {
2115
- return false;
2116
- }
2117
- }
2118
-
2119
- function normalizeOptionalString(value: string | undefined) {
2120
- const normalized = value?.trim();
2121
- return normalized ? normalized : undefined;
2122
- }
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
-
2163
- function hasEnv(name: string) {
2164
- return Object.prototype.hasOwnProperty.call(process.env, name);
2165
- }
2166
-
2167
- export function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
2168
- if (value === undefined) return defaultValue;
2169
- if (typeof value === "boolean") return value;
2170
- return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
2171
- }
2172
-
2173
- export function isExplicitlyEnabled(value: boolean | string | undefined) {
2174
- if (typeof value === "boolean") return value;
2175
- return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
2176
- }
2177
-
2178
- function isMissingConfigError(error: unknown) {
2179
- return error instanceof Error && error.message.startsWith("Missing pi-sync config:");
2180
- }
2181
-
2182
968
  function errorMessage(error: unknown) {
2183
969
  return error instanceof Error ? error.message : String(error);
2184
970
  }
971
+
972
+ export { completeSyncArguments, parseOptions, splitArgs } from "./command.js";
973
+ export { isCloudflareR2Endpoint, isEnabled, isExplicitlyEnabled, loadConfig, sessionTokenWarnings } from "./config.js";
974
+ export { encodeKey, posixJoin, safeJoin, safeName } from "./paths.js";
975
+ export {
976
+ addTopLevelCaseVariantDeletes,
977
+ appliedFileHashMap,
978
+ preflightSnapshotApply,
979
+ protectSnapshotApplyPlan,
980
+ } from "./snapshot-apply.js";
981
+ export {
982
+ collectFiles,
983
+ canonicalSnapshotPathForConfig,
984
+ filterSnapshotForConfigPolicy,
985
+ isConfiguredSnapshotPath,
986
+ isDeniedPath,
987
+ isSessionPath,
988
+ sessionSnapshotPathFromAbsolute,
989
+ mergeRemotePreservedFiles,
990
+ mergeRemoteSessionFiles,
991
+ scanSnapshot,
992
+ snapshotWithoutSessions,
993
+ } from "./snapshot.js";