@narumitw/pi-sync 0.13.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/command.ts +97 -0
- package/src/config.ts +338 -0
- package/src/paths.ts +68 -0
- package/src/s3-client.ts +194 -0
- package/src/snapshot-apply.ts +259 -0
- package/src/snapshot.ts +388 -0
- package/src/sync.ts +91 -1282
- package/src/types.ts +102 -0
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { Dirent } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { agentDir, configuredSessionDir } from "./config.js";
|
|
7
|
+
import { isDeniedPath, posixJoin, safeJoin, toPosix } from "./paths.js";
|
|
8
|
+
export { isDeniedPath } from "./paths.js";
|
|
9
|
+
import type {
|
|
10
|
+
Snapshot,
|
|
11
|
+
SnapshotFile,
|
|
12
|
+
SnapshotOptions,
|
|
13
|
+
SyncConfig,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
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"]);
|
|
22
|
+
const SECRET_PATTERNS = [
|
|
23
|
+
/AWS_SECRET_ACCESS_KEY\s*[=:]\s*['"]?[A-Za-z0-9/+]{35,}/i,
|
|
24
|
+
/(ANTHROPIC|OPENAI|GEMINI|GOOGLE|FIRECRAWL|GITHUB|CLOUDFLARE|R2|S3)_[A-Z0-9_]*(KEY|TOKEN|SECRET)\s*[=:]\s*['"]?[^\s'"]{12,}/i,
|
|
25
|
+
/sk-ant-[A-Za-z0-9_-]{20,}/,
|
|
26
|
+
/sk-[A-Za-z0-9]{20,}/,
|
|
27
|
+
/gh[pousr]_[A-Za-z0-9_]{20,}/,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function expandHome(value: string) {
|
|
31
|
+
if (value === "~") return process.env.HOME ?? value;
|
|
32
|
+
if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
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
|
+
function selectTopLevelFileEntry(entries: Dirent[], fileName: string) {
|
|
58
|
+
const exact = entries.find((entry) => entry.isFile() && entry.name === fileName);
|
|
59
|
+
if (exact) return exact;
|
|
60
|
+
const lower = fileName.toLowerCase();
|
|
61
|
+
return entries
|
|
62
|
+
.filter((entry) => entry.isFile() && entry.name.toLowerCase() === lower)
|
|
63
|
+
.sort((left, right) => left.name.localeCompare(right.name))[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sha256(value: Buffer) {
|
|
67
|
+
return createHash("sha256").update(value).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isSafeSnapshotPath(relativePath: string) {
|
|
71
|
+
if (relativePath.includes("\\")) return false;
|
|
72
|
+
const normalized = toPosix(relativePath);
|
|
73
|
+
return Boolean(normalized) && normalized !== "." && normalized !== ".." && !normalized.startsWith("../") &&
|
|
74
|
+
!path.posix.isAbsolute(normalized) && path.posix.normalize(normalized) === normalized && !isDeniedPath(normalized);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function snapshotsMatch(left: Snapshot, right: Snapshot) {
|
|
78
|
+
const leftHashes = new Map(left.files.map((file) => [file.path, file.sha256]));
|
|
79
|
+
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);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function snapshotId() {
|
|
85
|
+
return `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function createSnapshot(
|
|
89
|
+
profile: string,
|
|
90
|
+
options: SnapshotOptions = {},
|
|
91
|
+
): Promise<Snapshot> {
|
|
92
|
+
const syncSessions = Boolean(options.syncSessions);
|
|
93
|
+
const files = await collectFiles(agentDir(), {
|
|
94
|
+
syncSessions,
|
|
95
|
+
sessionDir: options.sessionDir ?? (await configuredSessionDir()),
|
|
96
|
+
extraFiles: options.extraFiles,
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
version: VERSION,
|
|
100
|
+
id: snapshotId(),
|
|
101
|
+
createdAt: new Date().toISOString(),
|
|
102
|
+
machine: os.hostname(),
|
|
103
|
+
profile,
|
|
104
|
+
syncSessions,
|
|
105
|
+
files,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function collectFiles(
|
|
110
|
+
root: string,
|
|
111
|
+
options: SnapshotOptions = {},
|
|
112
|
+
): Promise<SnapshotFile[]> {
|
|
113
|
+
const results: SnapshotFile[] = [];
|
|
114
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name)) {
|
|
117
|
+
await collectDirectory(results, root, entry.name);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const fileName of TOP_LEVEL_FILES) {
|
|
121
|
+
const entry = selectTopLevelFileEntry(entries, fileName);
|
|
122
|
+
if (entry) await addFile(results, root, entry.name, fileName);
|
|
123
|
+
}
|
|
124
|
+
for (const extraFileName of normalizeExtraFiles(options.extraFiles)) {
|
|
125
|
+
const entry = selectTopLevelFileEntry(entries, extraFileName);
|
|
126
|
+
if (entry) await addFile(results, root, entry.name, extraFileName);
|
|
127
|
+
}
|
|
128
|
+
if (options.syncSessions) {
|
|
129
|
+
try {
|
|
130
|
+
await collectDirectory(results, sessionStorageRoot(root, options.sessionDir), "", {
|
|
131
|
+
sessionsOnly: true,
|
|
132
|
+
virtualPrefix: "sessions",
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return results.sort((left, right) => left.path.localeCompare(right.path));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function collectDirectory(
|
|
142
|
+
results: SnapshotFile[],
|
|
143
|
+
root: string,
|
|
144
|
+
relativeDirectory: string,
|
|
145
|
+
options: { sessionsOnly?: boolean; virtualPrefix?: string } = {},
|
|
146
|
+
) {
|
|
147
|
+
const absoluteDirectory = path.join(root, relativeDirectory);
|
|
148
|
+
for (const entry of await fs.readdir(absoluteDirectory, { withFileTypes: true })) {
|
|
149
|
+
const relativePath = relativeDirectory ? posixJoin(relativeDirectory, entry.name) : entry.name;
|
|
150
|
+
const snapshotPath = options.virtualPrefix
|
|
151
|
+
? posixJoin(options.virtualPrefix, relativePath)
|
|
152
|
+
: relativePath;
|
|
153
|
+
if (isDeniedPath(snapshotPath)) continue;
|
|
154
|
+
if (entry.isDirectory()) {
|
|
155
|
+
await collectDirectory(results, root, relativePath, options);
|
|
156
|
+
} else if (entry.isFile() && (!options.sessionsOnly || isSessionFilePath(snapshotPath))) {
|
|
157
|
+
await addFile(results, root, relativePath, snapshotPath);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function addFile(
|
|
163
|
+
results: SnapshotFile[],
|
|
164
|
+
root: string,
|
|
165
|
+
relativePath: string,
|
|
166
|
+
snapshotPath = relativePath,
|
|
167
|
+
) {
|
|
168
|
+
if (!isSafeSnapshotPath(snapshotPath)) return;
|
|
169
|
+
const absolutePath = safeJoin(root, relativePath);
|
|
170
|
+
const content = await fs.readFile(absolutePath);
|
|
171
|
+
results.push({ path: snapshotPath, contentBase64: content.toString("base64"), sha256: sha256(content) });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isSessionPath(relativePath: string) {
|
|
175
|
+
return toPosix(relativePath).startsWith("sessions/");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function isSessionFilePath(relativePath: string) {
|
|
179
|
+
const normalized = toPosix(relativePath);
|
|
180
|
+
return isSessionPath(normalized) && normalized.endsWith(".jsonl");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function sessionStorageRoot(root: string, configuredSessionDir?: string) {
|
|
184
|
+
return configuredSessionDir ? path.resolve(expandHome(configuredSessionDir)) : path.resolve(root, "sessions");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function sessionSnapshotPathFromAbsolute(sessionFile: string, configuredSessionDir?: string) {
|
|
188
|
+
const relativePath = toPosix(path.relative(sessionStorageRoot(agentDir(), configuredSessionDir), sessionFile));
|
|
189
|
+
if (!relativePath || relativePath.startsWith("../") || path.posix.isAbsolute(relativePath)) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const snapshotPath = posixJoin("sessions", relativePath);
|
|
193
|
+
return isSessionFilePath(snapshotPath) ? snapshotPath : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function snapshotTarget(root: string, relativePath: string, configuredSessionDir?: string) {
|
|
197
|
+
if (isSessionPath(relativePath)) {
|
|
198
|
+
return safeJoin(sessionStorageRoot(root, configuredSessionDir), relativePath.slice("sessions/".length));
|
|
199
|
+
}
|
|
200
|
+
return safeJoin(root, relativePath);
|
|
201
|
+
}
|
|
202
|
+
|
|
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
|
+
export function snapshotIncludesSessions(snapshot: Snapshot) {
|
|
209
|
+
return snapshot.syncSessions === true || snapshot.files.some((file) => isSessionPath(file.path));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function filterSnapshotForConfigPolicy(
|
|
213
|
+
snapshot: Snapshot,
|
|
214
|
+
config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
|
|
215
|
+
options: { regenerateId?: boolean } = {},
|
|
216
|
+
) {
|
|
217
|
+
const extraFilePaths = extraFilePathsByLower(config.extraFiles);
|
|
218
|
+
const extraFiles = new Set(extraFilePaths.keys());
|
|
219
|
+
const filtered = {
|
|
220
|
+
...snapshot,
|
|
221
|
+
syncSessions: config.syncSessions ? snapshot.syncSessions : false,
|
|
222
|
+
files: canonicalizeSnapshotFilesForConfig(snapshot.files, config, extraFiles, extraFilePaths),
|
|
223
|
+
};
|
|
224
|
+
if (!options.regenerateId || snapshotsMatch(snapshot, filtered)) return filtered;
|
|
225
|
+
return {
|
|
226
|
+
...filtered,
|
|
227
|
+
id: snapshotId(),
|
|
228
|
+
createdAt: new Date().toISOString(),
|
|
229
|
+
machine: os.hostname(),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
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
|
+
function canonicalizeSnapshotFilesForConfig(
|
|
248
|
+
files: SnapshotFile[],
|
|
249
|
+
config: Pick<SyncConfig, "syncSessions">,
|
|
250
|
+
extraFiles: Set<string>,
|
|
251
|
+
extraFilePaths: Map<string, string>,
|
|
252
|
+
) {
|
|
253
|
+
const configuredFiles: SnapshotFile[] = [];
|
|
254
|
+
const extraCandidates = new Map<
|
|
255
|
+
string,
|
|
256
|
+
{ exact: boolean; file: SnapshotFile; originalPath: string }
|
|
257
|
+
>();
|
|
258
|
+
for (const file of files) {
|
|
259
|
+
const normalized = toPosix(file.path);
|
|
260
|
+
if (!isSafeSnapshotPath(file.path) || !isConfiguredSnapshotPath(normalized, config, extraFiles)) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const topLevelPath = canonicalTopLevelFilePath(normalized, extraFilePaths);
|
|
264
|
+
if (!topLevelPath) {
|
|
265
|
+
configuredFiles.push(normalized === file.path ? file : { ...file, path: normalized });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const candidate = {
|
|
269
|
+
exact: normalized === topLevelPath,
|
|
270
|
+
file: { ...file, path: topLevelPath },
|
|
271
|
+
originalPath: normalized,
|
|
272
|
+
};
|
|
273
|
+
const current = extraCandidates.get(topLevelPath.toLowerCase());
|
|
274
|
+
if (!current || isPreferredExtraCandidate(candidate, current)) {
|
|
275
|
+
extraCandidates.set(topLevelPath.toLowerCase(), candidate);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return [
|
|
279
|
+
...configuredFiles,
|
|
280
|
+
...[...extraCandidates.values()].map((candidate) => candidate.file),
|
|
281
|
+
].sort((left, right) => left.path.localeCompare(right.path));
|
|
282
|
+
}
|
|
283
|
+
|
|
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
|
+
function isPreferredExtraCandidate(
|
|
296
|
+
left: { exact: boolean; originalPath: string },
|
|
297
|
+
right: { exact: boolean; originalPath: string },
|
|
298
|
+
) {
|
|
299
|
+
if (left.exact !== right.exact) return left.exact;
|
|
300
|
+
return left.originalPath.localeCompare(right.originalPath) < 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function snapshotWithoutSessions(snapshot: Snapshot) {
|
|
304
|
+
const files = snapshot.files.filter((file) => !isSessionPath(file.path));
|
|
305
|
+
if (files.length === snapshot.files.length && snapshot.syncSessions !== true) return snapshot;
|
|
306
|
+
return {
|
|
307
|
+
...snapshot,
|
|
308
|
+
id: snapshotId(),
|
|
309
|
+
createdAt: new Date().toISOString(),
|
|
310
|
+
machine: os.hostname(),
|
|
311
|
+
syncSessions: false,
|
|
312
|
+
files,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function scanSnapshot(snapshot: Snapshot) {
|
|
317
|
+
const findings: string[] = [];
|
|
318
|
+
for (const file of snapshot.files) {
|
|
319
|
+
const content = Buffer.from(file.contentBase64, "base64");
|
|
320
|
+
if (content.includes(0)) continue;
|
|
321
|
+
const text = content.toString("utf8");
|
|
322
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
323
|
+
if (pattern.test(text)) {
|
|
324
|
+
findings.push(file.path);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return findings;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function mergeRemotePreservedFiles(
|
|
333
|
+
local: Snapshot,
|
|
334
|
+
remote: Snapshot,
|
|
335
|
+
config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
|
|
336
|
+
) {
|
|
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) => {
|
|
343
|
+
const normalized = toPosix(file.path);
|
|
344
|
+
const lower = normalized.toLowerCase();
|
|
345
|
+
if (
|
|
346
|
+
paths.has(normalized) ||
|
|
347
|
+
pathNames.has(lower) ||
|
|
348
|
+
!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)
|
|
354
|
+
) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
seenRemoteExtraNames.add(lower);
|
|
358
|
+
return true;
|
|
359
|
+
});
|
|
360
|
+
if (remoteExtras.length === 0) return withSessions;
|
|
361
|
+
return {
|
|
362
|
+
...withSessions,
|
|
363
|
+
id: snapshotId(),
|
|
364
|
+
createdAt: new Date().toISOString(),
|
|
365
|
+
machine: os.hostname(),
|
|
366
|
+
files: [...withSessions.files, ...remoteExtras].sort((left, right) =>
|
|
367
|
+
left.path.localeCompare(right.path),
|
|
368
|
+
),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function mergeRemoteSessionFiles(local: Snapshot, remote: Snapshot) {
|
|
373
|
+
const remoteSessions = remote.files.filter((file) => {
|
|
374
|
+
const normalized = toPosix(file.path);
|
|
375
|
+
return isSessionFilePath(normalized) && isSafeSnapshotPath(file.path);
|
|
376
|
+
});
|
|
377
|
+
if (remoteSessions.length === 0 && !snapshotIncludesSessions(remote)) return local;
|
|
378
|
+
return {
|
|
379
|
+
...local,
|
|
380
|
+
id: snapshotId(),
|
|
381
|
+
createdAt: new Date().toISOString(),
|
|
382
|
+
machine: os.hostname(),
|
|
383
|
+
syncSessions: true,
|
|
384
|
+
files: [...local.files.filter((file) => !isSessionPath(file.path)), ...remoteSessions].sort(
|
|
385
|
+
(left, right) => left.path.localeCompare(right.path),
|
|
386
|
+
),
|
|
387
|
+
};
|
|
388
|
+
}
|