@narumitw/pi-sync 0.25.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ import fs from "node:fs/promises";
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
4
+ import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
5
+ import {
6
+ agentDir,
7
+ isExplicitlyEnabled,
8
+ loadPartialConfig,
9
+ localConfigPath,
10
+ updateLocalConfig,
11
+ } from "./config.js";
12
+ import {
13
+ DEFAULT_SYNC_FILES,
14
+ isSafeExtraFileName,
15
+ normalizeExtraFiles,
16
+ normalizeSyncFiles,
17
+ } from "./sync-policy.js";
18
+
19
+ const INCLUDED = "included";
20
+ const EXCLUDED = "excluded";
21
+ const BUILT_IN_PREFIX = "builtin:";
22
+ const EXTRA_PREFIX = "extra:";
23
+ const SESSIONS_ID = "sessions";
24
+
25
+ export async function showFileSelection(ctx: ExtensionCommandContext) {
26
+ const partial = await loadPartialConfig();
27
+ const selectedBuiltIns = new Set(normalizeSyncFiles(partial.syncFiles));
28
+ const selectedExtras = new Set(normalizeExtraFiles(partial.extraFiles));
29
+ const sessionEnvironmentOverride = Object.hasOwn(process.env, "PI_SYNC_SESSIONS");
30
+ const sessionsIncluded = isExplicitlyEnabled(partial.syncSessions);
31
+ const extraCandidates = await listExtraFileCandidates(selectedExtras);
32
+
33
+ if (ctx.mode !== "tui") {
34
+ ctx.ui.notify(
35
+ [
36
+ "pi-sync selected files:",
37
+ `built-ins: ${[...selectedBuiltIns].join(", ") || "none"}`,
38
+ `sessions: ${sessionsIncluded ? "included" : "excluded"}${sessionEnvironmentOverride ? " (PI_SYNC_SESSIONS)" : ""}`,
39
+ `extra files: ${[...selectedExtras].map(safeTerminalText).join(", ") || "none"}`,
40
+ `Edit syncFiles, syncSessions, and extraFiles in ${safeTerminalText(localConfigPath())}.`,
41
+ ].join("\n"),
42
+ "info",
43
+ );
44
+ return;
45
+ }
46
+
47
+ const items: SettingItem[] = [
48
+ ...DEFAULT_SYNC_FILES.map((fileName) => ({
49
+ id: `${BUILT_IN_PREFIX}${fileName}`,
50
+ label: fileName,
51
+ description: fileName.includes(".")
52
+ ? `Sync the top-level ${fileName} file when present.`
53
+ : `Recursively sync every safe file under ${fileName}/.`,
54
+ currentValue: selectedBuiltIns.has(fileName) ? INCLUDED : EXCLUDED,
55
+ values: [INCLUDED, EXCLUDED],
56
+ })),
57
+ {
58
+ id: SESSIONS_ID,
59
+ label: "sessions",
60
+ description: sessionEnvironmentOverride
61
+ ? "Read-only here because PI_SYNC_SESSIONS overrides the local setting. Session JSONL may contain prompts, tool output, paths, images, and secrets."
62
+ : "Session JSONL may contain prompts, tool output, paths, images, and secrets. Sync only to storage you trust.",
63
+ currentValue: sessionEnvironmentOverride
64
+ ? `${sessionsIncluded ? INCLUDED : EXCLUDED} (environment)`
65
+ : sessionsIncluded
66
+ ? INCLUDED
67
+ : EXCLUDED,
68
+ ...(sessionEnvironmentOverride ? {} : { values: [INCLUDED, EXCLUDED] }),
69
+ },
70
+ ...extraCandidates.map((fileName) => ({
71
+ id: `${EXTRA_PREFIX}${fileName}`,
72
+ label: safeTerminalText(fileName),
73
+ description:
74
+ "Additional safe top-level file. It may be absent locally and pulled from another machine.",
75
+ currentValue: selectedExtras.has(fileName) ? INCLUDED : EXCLUDED,
76
+ values: [INCLUDED, EXCLUDED],
77
+ })),
78
+ ];
79
+
80
+ let saveQueue = Promise.resolve();
81
+ let nextOwner = 0;
82
+ const owners = new Map<string, number>();
83
+ let closed = false;
84
+
85
+ await ctx.ui.custom((tui, theme, _keybindings, done) => {
86
+ const container = new Container();
87
+ const title = new Text("", 1, 0);
88
+ const hint = new Text("", 1, 0);
89
+ const updateChrome = () => {
90
+ title.setText(theme.fg("accent", theme.bold("pi-sync Files")));
91
+ hint.setText(
92
+ theme.fg("dim", "Changes save immediately and apply to the next manual or automatic sync."),
93
+ );
94
+ };
95
+ updateChrome();
96
+ container.addChild(title);
97
+
98
+ let settingsList: SettingsList;
99
+ const queueChange = (id: string, newValue: string) => {
100
+ const previousValue = newValue === INCLUDED ? EXCLUDED : INCLUDED;
101
+ const owner = ++nextOwner;
102
+ owners.set(id, owner);
103
+ const save = async () => {
104
+ try {
105
+ await persistSelectionChange(id, newValue === INCLUDED);
106
+ } catch (error) {
107
+ if (owners.get(id) === owner && !closed) {
108
+ settingsList.updateValue(id, previousValue);
109
+ tui.requestRender();
110
+ }
111
+ ctx.ui.notify(`Could not save pi-sync file selection: ${errorMessage(error)}`, "error");
112
+ }
113
+ };
114
+ saveQueue = saveQueue.then(save, save);
115
+ };
116
+
117
+ settingsList = new SettingsList(
118
+ items,
119
+ Math.min(items.length + 2, 15),
120
+ getSettingsListTheme(),
121
+ queueChange,
122
+ () => done(undefined),
123
+ { enableSearch: true },
124
+ );
125
+ container.addChild(settingsList);
126
+ container.addChild(hint);
127
+
128
+ return {
129
+ render(width: number) {
130
+ return container.render(width);
131
+ },
132
+ invalidate() {
133
+ updateChrome();
134
+ container.invalidate();
135
+ },
136
+ handleInput(data: string) {
137
+ settingsList.handleInput(data);
138
+ tui.requestRender();
139
+ },
140
+ };
141
+ });
142
+ closed = true;
143
+ await saveQueue;
144
+ }
145
+
146
+ async function listExtraFileCandidates(configured: Set<string>) {
147
+ const candidates = new Map([...configured].map((fileName) => [fileName.toLowerCase(), fileName]));
148
+ try {
149
+ for (const entry of await fs.readdir(agentDir(), { withFileTypes: true })) {
150
+ if (!entry.isFile() || !isSafeExtraFileName(entry.name)) continue;
151
+ if (!candidates.has(entry.name.toLowerCase())) {
152
+ candidates.set(entry.name.toLowerCase(), entry.name);
153
+ }
154
+ }
155
+ } catch (error) {
156
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
157
+ }
158
+ return [...candidates.values()].sort((left, right) => left.localeCompare(right));
159
+ }
160
+
161
+ async function persistSelectionChange(id: string, included: boolean) {
162
+ await updateLocalConfig((current) => {
163
+ if (id.startsWith(BUILT_IN_PREFIX)) {
164
+ const fileName = id.slice(BUILT_IN_PREFIX.length);
165
+ const selected = new Set(normalizeSyncFiles(current.syncFiles));
166
+ if (included) selected.add(fileName as (typeof DEFAULT_SYNC_FILES)[number]);
167
+ else selected.delete(fileName as (typeof DEFAULT_SYNC_FILES)[number]);
168
+ return {
169
+ ...current,
170
+ syncFiles: DEFAULT_SYNC_FILES.filter((candidate) => selected.has(candidate)),
171
+ };
172
+ }
173
+ if (id.startsWith(EXTRA_PREFIX)) {
174
+ const fileName = id.slice(EXTRA_PREFIX.length);
175
+ const selected = new Set(normalizeExtraFiles(current.extraFiles));
176
+ if (included) selected.add(fileName);
177
+ else selected.delete(fileName);
178
+ return { ...current, extraFiles: [...selected] };
179
+ }
180
+ if (id === SESSIONS_ID) return { ...current, syncSessions: included };
181
+ throw new Error(`Unknown file selection: ${id}`);
182
+ });
183
+ }
184
+
185
+ function safeTerminalText(value: string) {
186
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
187
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
188
+ }
189
+
190
+ function errorMessage(error: unknown) {
191
+ return safeTerminalText(error instanceof Error ? error.message : String(error));
192
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./sync.js";
package/src/lock.ts CHANGED
@@ -82,7 +82,7 @@ export async function withLock<T>(command: string, fn: () => Promise<T>): Promis
82
82
  const inspection = await inspectLock();
83
83
  if (inspection.status === "valid" && isStaleLock(inspection.lock)) {
84
84
  throw new Error(
85
- `pi-sync lock is stale (pid ${inspection.lock.pid}). Run /pisync unlock --stale, then retry.`,
85
+ `pi-sync lock is stale (pid ${inspection.lock.pid}). Run /sync unlock --stale, then retry.`,
86
86
  );
87
87
  }
88
88
  if (inspection.status === "valid") {
@@ -92,7 +92,7 @@ export async function withLock<T>(command: string, fn: () => Promise<T>): Promis
92
92
  }
93
93
  if (inspection.status === "unreadable") {
94
94
  throw new Error(
95
- "pi-sync lock metadata is unreadable. Run /pisync unlock --stale after verifying no sync is running.",
95
+ "pi-sync lock metadata is unreadable. Run /sync unlock --stale after verifying no sync is running.",
96
96
  );
97
97
  }
98
98
 
@@ -208,7 +208,7 @@ async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptio
208
208
  if (inspection.status === "unreadable") {
209
209
  if (!options.stale) {
210
210
  ctx.ui.notify(
211
- "Pi-sync lock metadata is unreadable. Use /pisync unlock --stale only after verifying no sync is running.",
211
+ "Pi-sync lock metadata is unreadable. Use /sync unlock --stale only after verifying no sync is running.",
212
212
  "warning",
213
213
  );
214
214
  return;
@@ -3,7 +3,14 @@ import type { Dirent } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { agentDir } from "./config.js";
6
- import { assertWithinRoot, isDeniedPath, isPathInside, parentPaths, safeJoin, toPosix } from "./paths.js";
6
+ import {
7
+ assertWithinRoot,
8
+ isDeniedPath,
9
+ isPathInside,
10
+ parentPaths,
11
+ safeJoin,
12
+ toPosix,
13
+ } from "./paths.js";
7
14
  import {
8
15
  createSnapshot,
9
16
  isSessionFilePath,
@@ -25,11 +32,12 @@ function fileHashMap(snapshot: Snapshot) {
25
32
  export async function applySnapshot(
26
33
  snapshot: Snapshot,
27
34
  protectedRelativePaths = new Set<string>(),
28
- options: Pick<SnapshotOptions, "sessionDir" | "extraFiles"> = {},
35
+ options: Pick<SnapshotOptions, "syncFiles" | "sessionDir" | "extraFiles"> = {},
29
36
  ) {
30
37
  const root = agentDir();
31
38
  const { sessionDir } = options;
32
39
  const current = await createSnapshot(snapshot.profile, {
40
+ syncFiles: options.syncFiles,
33
41
  syncSessions: snapshotIncludesSessions(snapshot),
34
42
  sessionDir,
35
43
  extraFiles: options.extraFiles,
@@ -79,7 +87,8 @@ export function preflightSnapshotApply(
79
87
 
80
88
  const target = snapshotTarget(root, normalized, options.sessionDir);
81
89
  const content = decodeBase64Strict(file.contentBase64, normalized);
82
- if (sha256(content) !== file.sha256) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
90
+ if (sha256(content) !== file.sha256)
91
+ throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
83
92
  writes.push({ target, content });
84
93
  }
85
94
 
@@ -108,7 +117,9 @@ export function protectSnapshotApplyPlan(
108
117
  ): SnapshotApplyPlan {
109
118
  if (protectedRelativePaths.size === 0) return plan;
110
119
  const protectedTargets = new Set(
111
- [...protectedRelativePaths].map((relativePath) => snapshotTarget(root, relativePath, sessionDir)),
120
+ [...protectedRelativePaths].map((relativePath) =>
121
+ snapshotTarget(root, relativePath, sessionDir),
122
+ ),
112
123
  );
113
124
  return {
114
125
  writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
@@ -141,7 +152,11 @@ export async function addTopLevelCaseVariantDeletes(
141
152
  const deletes = new Set(plan.deletes);
142
153
  for (const entry of entries) {
143
154
  const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
144
- if (canonicalPath && entry.name !== canonicalPath && (entry.isFile() || entry.isSymbolicLink())) {
155
+ if (
156
+ canonicalPath &&
157
+ entry.name !== canonicalPath &&
158
+ (entry.isFile() || entry.isSymbolicLink())
159
+ ) {
145
160
  deletes.add(safeJoin(root, entry.name));
146
161
  }
147
162
  }
@@ -198,7 +213,11 @@ async function preflightSnapshotMutations(
198
213
  await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
199
214
  }
200
215
  for (const item of plan.writes) {
201
- await prepareSnapshotWrite(rootForTarget(root, item.target, sessionDir), item.target, deletePaths);
216
+ await prepareSnapshotWrite(
217
+ rootForTarget(root, item.target, sessionDir),
218
+ item.target,
219
+ deletePaths,
220
+ );
202
221
  }
203
222
  }
204
223
 
@@ -212,7 +231,8 @@ async function prepareSnapshotWrite(root: string, target: string, deletePaths: S
212
231
  await ensureSafeDirectory(root, path.dirname(target));
213
232
  try {
214
233
  const stat = await fs.lstat(target);
215
- if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
234
+ if (stat.isSymbolicLink())
235
+ throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
216
236
  if (stat.isDirectory() && !deletePaths.has(target)) {
217
237
  throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
218
238
  }
@@ -230,8 +250,10 @@ async function ensureSafeDirectory(root: string, directory: string) {
230
250
  current = path.join(current, part);
231
251
  try {
232
252
  const stat = await fs.lstat(current);
233
- if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
234
- if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
253
+ if (stat.isSymbolicLink())
254
+ throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
255
+ if (!stat.isDirectory())
256
+ throw new Error(`Snapshot path parent is not a directory: ${current}`);
235
257
  } catch (error) {
236
258
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
237
259
  await fs.mkdir(current);
@@ -249,8 +271,10 @@ async function assertNoSymlinkParents(root: string, target: string) {
249
271
  current = path.join(current, part);
250
272
  try {
251
273
  const stat = await fs.lstat(current);
252
- if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
253
- if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
274
+ if (stat.isSymbolicLink())
275
+ throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
276
+ if (!stat.isDirectory())
277
+ throw new Error(`Snapshot path parent is not a directory: ${current}`);
254
278
  } catch (error) {
255
279
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
256
280
  throw error;
package/src/snapshot.ts CHANGED
@@ -5,20 +5,25 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { agentDir, configuredSessionDir } from "./config.js";
7
7
  import { isDeniedPath, posixJoin, safeJoin, toPosix } from "./paths.js";
8
+
8
9
  export { isDeniedPath } from "./paths.js";
9
- import type {
10
- Snapshot,
11
- SnapshotFile,
12
- SnapshotOptions,
13
- SyncConfig,
14
- } from "./types.js";
10
+
11
+ import {
12
+ canonicalSnapshotPathForConfig,
13
+ DEFAULT_SYNC_FILES,
14
+ extraFilePathsByLower,
15
+ isConfiguredSnapshotPath,
16
+ isPreservableUnmanagedSnapshotPath,
17
+ normalizeExtraFiles,
18
+ normalizeSyncFiles,
19
+ } from "./sync-policy.js";
20
+ import type { Snapshot, SnapshotFile, SnapshotOptions, SyncConfig } from "./types.js";
21
+
22
+ export { canonicalSnapshotPathForConfig, isConfiguredSnapshotPath } from "./sync-policy.js";
15
23
 
16
24
  const VERSION = 1;
17
- const TOP_LEVEL_FILES = new Set(["settings.json", "keybindings.json", "models.json", "AGENTS.md", "APPEND_SYSTEM.md"]);
18
- const TOP_LEVEL_DIRS = new Set(["skills", "prompts", "themes", "extensions"]);
19
- const TOP_LEVEL_FILE_PATHS = new Map([...TOP_LEVEL_FILES].map((name) => [name.toLowerCase(), name]));
20
- const TOP_LEVEL_FILE_NAMES = new Set(TOP_LEVEL_FILE_PATHS.keys());
21
- const RESERVED_TOP_LEVEL_NAMES = new Set([...TOP_LEVEL_DIRS, "sessions"]);
25
+ const TOP_LEVEL_FILES: readonly string[] = DEFAULT_SYNC_FILES.filter((name) => name.includes("."));
26
+ const TOP_LEVEL_DIRS = new Set<string>(DEFAULT_SYNC_FILES.filter((name) => !name.includes(".")));
22
27
  const SECRET_PATTERNS = [
23
28
  /AWS_SECRET_ACCESS_KEY\s*[=:]\s*['"]?[A-Za-z0-9/+]{35,}/i,
24
29
  /(ANTHROPIC|OPENAI|GEMINI|GOOGLE|FIRECRAWL|GITHUB|CLOUDFLARE|R2|S3)_[A-Z0-9_]*(KEY|TOKEN|SECRET)\s*[=:]\s*['"]?[^\s'"]{12,}/i,
@@ -33,27 +38,6 @@ function expandHome(value: string) {
33
38
  return value;
34
39
  }
35
40
 
36
- export function normalizeExtraFiles(value: unknown) {
37
- if (!Array.isArray(value)) return [];
38
- const seen = new Set<string>();
39
- return value
40
- .filter((item): item is string => typeof item === "string")
41
- .map((item) => item.trim())
42
- .filter((item) => {
43
- const lower = item.toLowerCase();
44
- if (
45
- item === "" || item === "." || item === ".." || item.includes("/") || item.includes("\\") ||
46
- TOP_LEVEL_FILE_NAMES.has(lower) || isDeniedPath(item) || RESERVED_TOP_LEVEL_NAMES.has(lower) || seen.has(lower)
47
- ) return false;
48
- seen.add(lower);
49
- return true;
50
- });
51
- }
52
-
53
- export function extraFilePathsByLower(value: unknown) {
54
- return new Map(normalizeExtraFiles(value).map((fileName) => [fileName.toLowerCase(), fileName]));
55
- }
56
-
57
41
  function selectTopLevelFileEntry(entries: Dirent[], fileName: string) {
58
42
  const exact = entries.find((entry) => entry.isFile() && entry.name === fileName);
59
43
  if (exact) return exact;
@@ -70,15 +54,24 @@ function sha256(value: Buffer) {
70
54
  function isSafeSnapshotPath(relativePath: string) {
71
55
  if (relativePath.includes("\\")) return false;
72
56
  const normalized = toPosix(relativePath);
73
- return Boolean(normalized) && normalized !== "." && normalized !== ".." && !normalized.startsWith("../") &&
74
- !path.posix.isAbsolute(normalized) && path.posix.normalize(normalized) === normalized && !isDeniedPath(normalized);
57
+ return (
58
+ Boolean(normalized) &&
59
+ normalized !== "." &&
60
+ normalized !== ".." &&
61
+ !normalized.startsWith("../") &&
62
+ !path.posix.isAbsolute(normalized) &&
63
+ path.posix.normalize(normalized) === normalized &&
64
+ !isDeniedPath(normalized)
65
+ );
75
66
  }
76
67
 
77
68
  function snapshotsMatch(left: Snapshot, right: Snapshot) {
78
69
  const leftHashes = new Map(left.files.map((file) => [file.path, file.sha256]));
79
70
  const rightHashes = new Map(right.files.map((file) => [file.path, file.sha256]));
80
- return leftHashes.size === rightHashes.size &&
81
- [...leftHashes].every(([filePath, hash]) => rightHashes.get(filePath) === hash);
71
+ return (
72
+ leftHashes.size === rightHashes.size &&
73
+ [...leftHashes].every(([filePath, hash]) => rightHashes.get(filePath) === hash)
74
+ );
82
75
  }
83
76
 
84
77
  function snapshotId() {
@@ -91,6 +84,7 @@ export async function createSnapshot(
91
84
  ): Promise<Snapshot> {
92
85
  const syncSessions = Boolean(options.syncSessions);
93
86
  const files = await collectFiles(agentDir(), {
87
+ syncFiles: options.syncFiles,
94
88
  syncSessions,
95
89
  sessionDir: options.sessionDir ?? (await configuredSessionDir()),
96
90
  extraFiles: options.extraFiles,
@@ -112,12 +106,14 @@ export async function collectFiles(
112
106
  ): Promise<SnapshotFile[]> {
113
107
  const results: SnapshotFile[] = [];
114
108
  const entries = await fs.readdir(root, { withFileTypes: true });
109
+ const selectedFiles = new Set<string>(normalizeSyncFiles(options.syncFiles));
115
110
  for (const entry of entries) {
116
- if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name)) {
111
+ if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name) && selectedFiles.has(entry.name)) {
117
112
  await collectDirectory(results, root, entry.name);
118
113
  }
119
114
  }
120
115
  for (const fileName of TOP_LEVEL_FILES) {
116
+ if (!selectedFiles.has(fileName)) continue;
121
117
  const entry = selectTopLevelFileEntry(entries, fileName);
122
118
  if (entry) await addFile(results, root, entry.name, fileName);
123
119
  }
@@ -168,7 +164,11 @@ async function addFile(
168
164
  if (!isSafeSnapshotPath(snapshotPath)) return;
169
165
  const absolutePath = safeJoin(root, relativePath);
170
166
  const content = await fs.readFile(absolutePath);
171
- results.push({ path: snapshotPath, contentBase64: content.toString("base64"), sha256: sha256(content) });
167
+ results.push({
168
+ path: snapshotPath,
169
+ contentBase64: content.toString("base64"),
170
+ sha256: sha256(content),
171
+ });
172
172
  }
173
173
 
174
174
  export function isSessionPath(relativePath: string) {
@@ -181,11 +181,18 @@ export function isSessionFilePath(relativePath: string) {
181
181
  }
182
182
 
183
183
  export function sessionStorageRoot(root: string, configuredSessionDir?: string) {
184
- return configuredSessionDir ? path.resolve(expandHome(configuredSessionDir)) : path.resolve(root, "sessions");
184
+ return configuredSessionDir
185
+ ? path.resolve(expandHome(configuredSessionDir))
186
+ : path.resolve(root, "sessions");
185
187
  }
186
188
 
187
- export function sessionSnapshotPathFromAbsolute(sessionFile: string, configuredSessionDir?: string) {
188
- const relativePath = toPosix(path.relative(sessionStorageRoot(agentDir(), configuredSessionDir), sessionFile));
189
+ export function sessionSnapshotPathFromAbsolute(
190
+ sessionFile: string,
191
+ configuredSessionDir?: string,
192
+ ) {
193
+ const relativePath = toPosix(
194
+ path.relative(sessionStorageRoot(agentDir(), configuredSessionDir), sessionFile),
195
+ );
189
196
  if (!relativePath || relativePath.startsWith("../") || path.posix.isAbsolute(relativePath)) {
190
197
  return undefined;
191
198
  }
@@ -195,23 +202,21 @@ export function sessionSnapshotPathFromAbsolute(sessionFile: string, configuredS
195
202
 
196
203
  export function snapshotTarget(root: string, relativePath: string, configuredSessionDir?: string) {
197
204
  if (isSessionPath(relativePath)) {
198
- return safeJoin(sessionStorageRoot(root, configuredSessionDir), relativePath.slice("sessions/".length));
205
+ return safeJoin(
206
+ sessionStorageRoot(root, configuredSessionDir),
207
+ relativePath.slice("sessions/".length),
208
+ );
199
209
  }
200
210
  return safeJoin(root, relativePath);
201
211
  }
202
212
 
203
- function isPathInside(parent: string, child: string) {
204
- const relativePath = path.relative(parent, child);
205
- return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
206
- }
207
-
208
213
  export function snapshotIncludesSessions(snapshot: Snapshot) {
209
214
  return snapshot.syncSessions === true || snapshot.files.some((file) => isSessionPath(file.path));
210
215
  }
211
216
 
212
217
  export function filterSnapshotForConfigPolicy(
213
218
  snapshot: Snapshot,
214
- config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
219
+ config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
215
220
  options: { regenerateId?: boolean } = {},
216
221
  ) {
217
222
  const extraFilePaths = extraFilePathsByLower(config.extraFiles);
@@ -230,23 +235,9 @@ export function filterSnapshotForConfigPolicy(
230
235
  };
231
236
  }
232
237
 
233
- export function isConfiguredSnapshotPath(
234
- relativePath: string,
235
- config: Pick<SyncConfig, "syncSessions">,
236
- extraFiles: Set<string>,
237
- ) {
238
- const normalized = toPosix(relativePath);
239
- if (isSessionPath(normalized)) return config.syncSessions;
240
- if (!normalized.includes("/")) {
241
- const lower = normalized.toLowerCase();
242
- return TOP_LEVEL_FILE_NAMES.has(lower) || extraFiles.has(lower);
243
- }
244
- return TOP_LEVEL_DIRS.has(normalized.slice(0, normalized.indexOf("/")));
245
- }
246
-
247
238
  function canonicalizeSnapshotFilesForConfig(
248
239
  files: SnapshotFile[],
249
- config: Pick<SyncConfig, "syncSessions">,
240
+ config: Pick<SyncConfig, "syncFiles" | "syncSessions">,
250
241
  extraFiles: Set<string>,
251
242
  extraFilePaths: Map<string, string>,
252
243
  ) {
@@ -257,14 +248,17 @@ function canonicalizeSnapshotFilesForConfig(
257
248
  >();
258
249
  for (const file of files) {
259
250
  const normalized = toPosix(file.path);
260
- if (!isSafeSnapshotPath(file.path) || !isConfiguredSnapshotPath(normalized, config, extraFiles)) {
251
+ if (
252
+ !isSafeSnapshotPath(file.path) ||
253
+ !isConfiguredSnapshotPath(normalized, config, extraFiles)
254
+ ) {
261
255
  continue;
262
256
  }
263
- const topLevelPath = canonicalTopLevelFilePath(normalized, extraFilePaths);
264
- if (!topLevelPath) {
257
+ if (normalized.includes("/")) {
265
258
  configuredFiles.push(normalized === file.path ? file : { ...file, path: normalized });
266
259
  continue;
267
260
  }
261
+ const topLevelPath = canonicalSnapshotPathForConfig(normalized, extraFilePaths);
268
262
  const candidate = {
269
263
  exact: normalized === topLevelPath,
270
264
  file: { ...file, path: topLevelPath },
@@ -281,17 +275,6 @@ function canonicalizeSnapshotFilesForConfig(
281
275
  ].sort((left, right) => left.path.localeCompare(right.path));
282
276
  }
283
277
 
284
- function canonicalTopLevelFilePath(relativePath: string, extraFilePaths: Map<string, string>) {
285
- const normalized = toPosix(relativePath);
286
- if (normalized.includes("/")) return undefined;
287
- const lower = normalized.toLowerCase();
288
- return TOP_LEVEL_FILE_PATHS.get(lower) ?? extraFilePaths.get(lower);
289
- }
290
-
291
- export function canonicalSnapshotPathForConfig(relativePath: string, extraFilePaths: Map<string, string>) {
292
- return canonicalTopLevelFilePath(relativePath, extraFilePaths) ?? toPosix(relativePath);
293
- }
294
-
295
278
  function isPreferredExtraCandidate(
296
279
  left: { exact: boolean; originalPath: string },
297
280
  right: { exact: boolean; originalPath: string },
@@ -332,38 +315,36 @@ export function scanSnapshot(snapshot: Snapshot) {
332
315
  export function mergeRemotePreservedFiles(
333
316
  local: Snapshot,
334
317
  remote: Snapshot,
335
- config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
318
+ config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
336
319
  ) {
337
- const withSessions = config.syncSessions ? local : mergeRemoteSessionFiles(local, remote);
338
- const paths = new Set(withSessions.files.map((file) => file.path));
339
- const pathNames = new Set(withSessions.files.map((file) => file.path.toLowerCase()));
340
- const extraFileNames = new Set(normalizeExtraFiles(config.extraFiles).map((file) => file.toLowerCase()));
341
- const seenRemoteExtraNames = new Set<string>();
342
- const remoteExtras = remote.files.filter((file) => {
320
+ const localPathNames = new Set(local.files.map((file) => file.path.toLowerCase()));
321
+ const configuredExtraFiles = new Set(
322
+ normalizeExtraFiles(config.extraFiles).map((file) => file.toLowerCase()),
323
+ );
324
+ const preservedPathNames = new Set<string>();
325
+ const preserved = remote.files.filter((file) => {
343
326
  const normalized = toPosix(file.path);
344
327
  const lower = normalized.toLowerCase();
345
328
  if (
346
- paths.has(normalized) ||
347
- pathNames.has(lower) ||
329
+ localPathNames.has(lower) ||
330
+ preservedPathNames.has(lower) ||
348
331
  !isSafeSnapshotPath(file.path) ||
349
- normalized.includes("/") ||
350
- TOP_LEVEL_FILE_NAMES.has(lower) ||
351
- RESERVED_TOP_LEVEL_NAMES.has(lower) ||
352
- extraFileNames.has(lower) ||
353
- seenRemoteExtraNames.has(lower)
332
+ isConfiguredSnapshotPath(normalized, config, configuredExtraFiles) ||
333
+ !isPreservableUnmanagedSnapshotPath(normalized)
354
334
  ) {
355
335
  return false;
356
336
  }
357
- seenRemoteExtraNames.add(lower);
337
+ preservedPathNames.add(lower);
358
338
  return true;
359
339
  });
360
- if (remoteExtras.length === 0) return withSessions;
340
+ if (preserved.length === 0) return local;
361
341
  return {
362
- ...withSessions,
342
+ ...local,
363
343
  id: snapshotId(),
364
344
  createdAt: new Date().toISOString(),
365
345
  machine: os.hostname(),
366
- files: [...withSessions.files, ...remoteExtras].sort((left, right) =>
346
+ syncSessions: snapshotIncludesSessions(local) || snapshotIncludesSessions(remote),
347
+ files: [...local.files, ...preserved].sort((left, right) =>
367
348
  left.path.localeCompare(right.path),
368
349
  ),
369
350
  };