@narumitw/pi-sync 0.1.29
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/LICENSE +21 -0
- package/README.md +191 -0
- package/package.json +41 -0
- package/src/sync.ts +1188 -0
package/src/sync.ts
ADDED
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { gunzip, gzip } from "node:zlib";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import type {
|
|
9
|
+
ExtensionAPI,
|
|
10
|
+
ExtensionCommandContext,
|
|
11
|
+
ExtensionContext,
|
|
12
|
+
} from "@mariozechner/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
const gzipAsync = promisify(gzip);
|
|
15
|
+
const gunzipAsync = promisify(gunzip);
|
|
16
|
+
|
|
17
|
+
const STATUS_KEY = "pisync";
|
|
18
|
+
const VERSION = 1;
|
|
19
|
+
const DEFAULT_PROFILE = "default";
|
|
20
|
+
const DEFAULT_PREFIX = "pi-sync";
|
|
21
|
+
const DEFAULT_REGION = "auto";
|
|
22
|
+
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
const TOP_LEVEL_FILES = new Set(["settings.json", "keybindings.json", "models.json", "AGENTS.md"]);
|
|
25
|
+
const TOP_LEVEL_DIRS = new Set(["skills", "prompts", "themes", "extensions"]);
|
|
26
|
+
const SECRET_PATTERNS = [
|
|
27
|
+
/AWS_SECRET_ACCESS_KEY\s*[=:]\s*['\"]?[A-Za-z0-9/+]{35,}/i,
|
|
28
|
+
/(ANTHROPIC|OPENAI|GEMINI|GOOGLE|FIRECRAWL|GITHUB|CLOUDFLARE|R2|S3)_[A-Z0-9_]*(KEY|TOKEN|SECRET)\s*[=:]\s*['\"]?[^\s'\"]{12,}/i,
|
|
29
|
+
/sk-ant-[A-Za-z0-9_-]{20,}/,
|
|
30
|
+
/sk-[A-Za-z0-9]{20,}/,
|
|
31
|
+
/gh[pousr]_[A-Za-z0-9_]{20,}/,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
interface SyncConfig {
|
|
35
|
+
endpoint: string;
|
|
36
|
+
bucket: string;
|
|
37
|
+
region: string;
|
|
38
|
+
accessKeyId: string;
|
|
39
|
+
secretAccessKey: string;
|
|
40
|
+
sessionToken?: string;
|
|
41
|
+
profile: string;
|
|
42
|
+
prefix: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface PartialConfig {
|
|
46
|
+
endpoint?: string;
|
|
47
|
+
bucket?: string;
|
|
48
|
+
region?: string;
|
|
49
|
+
accessKeyId?: string;
|
|
50
|
+
secretAccessKey?: string;
|
|
51
|
+
sessionToken?: string;
|
|
52
|
+
profile?: string;
|
|
53
|
+
prefix?: string;
|
|
54
|
+
autoSync?: boolean | string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SnapshotFile {
|
|
58
|
+
path: string;
|
|
59
|
+
contentBase64: string;
|
|
60
|
+
sha256: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Snapshot {
|
|
64
|
+
version: number;
|
|
65
|
+
id: string;
|
|
66
|
+
createdAt: string;
|
|
67
|
+
machine: string;
|
|
68
|
+
profile: string;
|
|
69
|
+
files: SnapshotFile[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface LatestPointer {
|
|
73
|
+
version: number;
|
|
74
|
+
profile: string;
|
|
75
|
+
snapshot: string;
|
|
76
|
+
sha256: string;
|
|
77
|
+
createdAt: string;
|
|
78
|
+
machine: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface RemoteObject<T> {
|
|
82
|
+
value?: T;
|
|
83
|
+
etag?: string;
|
|
84
|
+
missing: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface SyncState {
|
|
88
|
+
version: number;
|
|
89
|
+
profile: string;
|
|
90
|
+
lastAppliedSnapshot?: string;
|
|
91
|
+
lastRemoteEtag?: string;
|
|
92
|
+
lastFileHashes: Record<string, string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface LockFile {
|
|
96
|
+
id: string;
|
|
97
|
+
pid: number;
|
|
98
|
+
command: string;
|
|
99
|
+
startedAt: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface CommandOptions {
|
|
103
|
+
yes: boolean;
|
|
104
|
+
force: boolean;
|
|
105
|
+
stale: boolean;
|
|
106
|
+
silent: boolean;
|
|
107
|
+
reload: boolean;
|
|
108
|
+
auto: boolean;
|
|
109
|
+
args: string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const AUTO_SYNC_OPTIONS: CommandOptions = {
|
|
113
|
+
yes: true,
|
|
114
|
+
force: false,
|
|
115
|
+
stale: false,
|
|
116
|
+
silent: true,
|
|
117
|
+
reload: false,
|
|
118
|
+
auto: true,
|
|
119
|
+
args: [],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export default function sync(pi: ExtensionAPI) {
|
|
123
|
+
pi.registerCommand("pisync", {
|
|
124
|
+
description: "Sync Pi settings through Cloudflare R2 or S3-compatible storage",
|
|
125
|
+
handler: async (args, ctx) => {
|
|
126
|
+
await handleCommand(args, ctx);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
131
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
132
|
+
await autoSync(ctx);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
136
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
141
|
+
const [subcommand = "status", ...rest] = splitArgs(rawArgs);
|
|
142
|
+
const options = parseOptions(rest);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await ensureStateDir();
|
|
146
|
+
|
|
147
|
+
switch (subcommand) {
|
|
148
|
+
case "help":
|
|
149
|
+
ctx.ui.notify(usage(), "info");
|
|
150
|
+
return;
|
|
151
|
+
case "init":
|
|
152
|
+
await initConfig(ctx);
|
|
153
|
+
return;
|
|
154
|
+
case "config":
|
|
155
|
+
await showConfig(ctx);
|
|
156
|
+
return;
|
|
157
|
+
case "status":
|
|
158
|
+
await status(ctx);
|
|
159
|
+
return;
|
|
160
|
+
case "diff":
|
|
161
|
+
await diff(ctx);
|
|
162
|
+
return;
|
|
163
|
+
case "doctor":
|
|
164
|
+
await doctor(ctx);
|
|
165
|
+
return;
|
|
166
|
+
case "push":
|
|
167
|
+
await withLock("push", () => push(ctx, options));
|
|
168
|
+
return;
|
|
169
|
+
case "pull":
|
|
170
|
+
await withLock("pull", () => pull(ctx, options));
|
|
171
|
+
return;
|
|
172
|
+
case "sync":
|
|
173
|
+
await withLock("sync", () => syncBoth(ctx, options));
|
|
174
|
+
return;
|
|
175
|
+
case "history":
|
|
176
|
+
await history(ctx);
|
|
177
|
+
return;
|
|
178
|
+
case "rollback":
|
|
179
|
+
await withLock("rollback", () => rollback(ctx, options));
|
|
180
|
+
return;
|
|
181
|
+
case "unlock":
|
|
182
|
+
await unlock(ctx, options);
|
|
183
|
+
return;
|
|
184
|
+
default:
|
|
185
|
+
ctx.ui.notify(`Unknown /pisync command: ${subcommand}\n\n${usage()}`, "warning");
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
189
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function autoSync(ctx: ExtensionContext) {
|
|
194
|
+
try {
|
|
195
|
+
const partial = await loadPartialConfig();
|
|
196
|
+
if (!isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true)) return;
|
|
197
|
+
await ensureStateDir();
|
|
198
|
+
await loadConfig();
|
|
199
|
+
await withLock("auto-sync", () => syncBoth(ctx, AUTO_SYNC_OPTIONS));
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (isMissingConfigError(error)) return;
|
|
202
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
203
|
+
ctx.ui.notify(`pi-sync auto sync skipped: ${errorMessage(error)}`, "warning");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function initConfig(ctx: ExtensionCommandContext) {
|
|
208
|
+
const configPath = localConfigPath();
|
|
209
|
+
try {
|
|
210
|
+
await fs.access(configPath, fsConstants.F_OK);
|
|
211
|
+
ctx.ui.notify(`Config already exists: ${configPath}`, "info");
|
|
212
|
+
return;
|
|
213
|
+
} catch {
|
|
214
|
+
// Create below.
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const sample = {
|
|
218
|
+
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
|
|
219
|
+
bucket: "pi-sync",
|
|
220
|
+
region: DEFAULT_REGION,
|
|
221
|
+
accessKeyId: "<access-key-id>",
|
|
222
|
+
secretAccessKey: "<secret-access-key>",
|
|
223
|
+
profile: DEFAULT_PROFILE,
|
|
224
|
+
prefix: DEFAULT_PREFIX,
|
|
225
|
+
autoSync: true,
|
|
226
|
+
};
|
|
227
|
+
await writeJson(configPath, sample);
|
|
228
|
+
ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /pisync doctor.`, "info");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function showConfig(ctx: ExtensionCommandContext) {
|
|
232
|
+
const partial = await loadPartialConfig();
|
|
233
|
+
ctx.ui.notify(
|
|
234
|
+
[
|
|
235
|
+
"pi-sync config:",
|
|
236
|
+
`endpoint: ${partial.endpoint ?? "missing"}`,
|
|
237
|
+
`bucket: ${partial.bucket ?? "missing"}`,
|
|
238
|
+
`region: ${partial.region ?? DEFAULT_REGION}`,
|
|
239
|
+
`accessKeyId: ${partial.accessKeyId ? redact(partial.accessKeyId) : "missing"}`,
|
|
240
|
+
`secretAccessKey: ${partial.secretAccessKey ? "configured" : "missing"}`,
|
|
241
|
+
`sessionToken: ${partial.sessionToken ? "configured" : "not configured"}`,
|
|
242
|
+
`profile: ${partial.profile ?? DEFAULT_PROFILE}`,
|
|
243
|
+
`prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
|
|
244
|
+
`autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
|
|
245
|
+
`local config: ${localConfigPath()}`,
|
|
246
|
+
].join("\n"),
|
|
247
|
+
"info",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function status(ctx: ExtensionCommandContext) {
|
|
252
|
+
ctx.ui.setStatus(STATUS_KEY, "🔄 checking");
|
|
253
|
+
const config = await loadConfig();
|
|
254
|
+
const client = new S3Client(config);
|
|
255
|
+
const local = await createSnapshot(config.profile);
|
|
256
|
+
const state = await readState(config.profile);
|
|
257
|
+
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
258
|
+
const localChanged = hasLocalChanges(local, state);
|
|
259
|
+
|
|
260
|
+
let remoteText = "remote: empty";
|
|
261
|
+
let remoteChanged = false;
|
|
262
|
+
if (!latest.missing && latest.value) {
|
|
263
|
+
remoteChanged = latest.value.snapshot !== state.lastAppliedSnapshot;
|
|
264
|
+
remoteText = `remote: ${latest.value.snapshot} from ${latest.value.machine} at ${latest.value.createdAt}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
268
|
+
ctx.ui.notify(
|
|
269
|
+
[
|
|
270
|
+
`profile: ${config.profile}`,
|
|
271
|
+
remoteText,
|
|
272
|
+
`local files: ${local.files.length}`,
|
|
273
|
+
`local changed since last sync: ${localChanged ? "yes" : "no"}`,
|
|
274
|
+
`remote changed since last sync: ${remoteChanged ? "yes" : "no"}`,
|
|
275
|
+
].join("\n"),
|
|
276
|
+
localChanged || remoteChanged ? "warning" : "info",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function diff(ctx: ExtensionCommandContext) {
|
|
281
|
+
ctx.ui.setStatus(STATUS_KEY, "🔄 diff");
|
|
282
|
+
const config = await loadConfig();
|
|
283
|
+
const client = new S3Client(config);
|
|
284
|
+
const local = await createSnapshot(config.profile);
|
|
285
|
+
const remote = await readRemoteSnapshot(client, config);
|
|
286
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
287
|
+
|
|
288
|
+
if (!remote) {
|
|
289
|
+
ctx.ui.notify(formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local), "info");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
ctx.ui.notify(formatDiff(local, remote), "info");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function doctor(ctx: ExtensionCommandContext) {
|
|
297
|
+
const messages: string[] = [];
|
|
298
|
+
let level: "info" | "warning" = "info";
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const config = await loadConfig();
|
|
302
|
+
messages.push(`config: ok (${config.bucket}/${profilePrefix(config)})`);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
level = "warning";
|
|
305
|
+
messages.push(`config: ${errorMessage(error)}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const local = await createSnapshot(DEFAULT_PROFILE);
|
|
309
|
+
const secrets = scanSnapshot(local);
|
|
310
|
+
if (secrets.length > 0) {
|
|
311
|
+
level = "warning";
|
|
312
|
+
messages.push("secret scan: possible secrets found:");
|
|
313
|
+
messages.push(...secrets.map((secret) => `- ${secret}`));
|
|
314
|
+
} else {
|
|
315
|
+
messages.push(`secret scan: ok (${local.files.length} files checked)`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const lock = await readLock();
|
|
319
|
+
messages.push(lock ? `lock: held by pid ${lock.pid} since ${lock.startedAt}` : "lock: free");
|
|
320
|
+
ctx.ui.notify(messages.join("\n"), level);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function push(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
|
|
324
|
+
ctx.ui.setStatus(STATUS_KEY, "🔄 pushing");
|
|
325
|
+
const config = await loadConfig();
|
|
326
|
+
const client = new S3Client(config);
|
|
327
|
+
const state = await readState(config.profile);
|
|
328
|
+
const local = await createSnapshot(config.profile);
|
|
329
|
+
const secrets = scanSnapshot(local);
|
|
330
|
+
if (secrets.length > 0) {
|
|
331
|
+
throw new Error(`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
335
|
+
if (remoteChangedSinceState(latest, state) && !options.force) {
|
|
336
|
+
throw new Error("Remote changed since last sync. Run /pisync pull first or /pisync push --force.");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!options.yes && !(await ctx.ui.confirm("Push pi settings?", formatPushSummary(local, latest)))) {
|
|
340
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
341
|
+
ctx.ui.notify("Push cancelled.", "info");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const pointer = await uploadSnapshot(client, config, local, latest, options.force);
|
|
346
|
+
await updateHistory(client, config, pointer);
|
|
347
|
+
await writeState(config.profile, {
|
|
348
|
+
version: VERSION,
|
|
349
|
+
profile: config.profile,
|
|
350
|
+
lastAppliedSnapshot: pointer.snapshot,
|
|
351
|
+
lastRemoteEtag: undefined,
|
|
352
|
+
lastFileHashes: fileHashMap(local),
|
|
353
|
+
});
|
|
354
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
355
|
+
if (!options.silent) {
|
|
356
|
+
ctx.ui.notify(`Pushed ${local.files.length} files as ${pointer.snapshot}.`, "info");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
|
|
361
|
+
ctx.ui.setStatus(STATUS_KEY, "🔄 pulling");
|
|
362
|
+
const config = await loadConfig();
|
|
363
|
+
const client = new S3Client(config);
|
|
364
|
+
const state = await readState(config.profile);
|
|
365
|
+
const local = await createSnapshot(config.profile);
|
|
366
|
+
const remote = await readRemoteSnapshot(client, config);
|
|
367
|
+
if (!remote) throw new Error("Remote is empty. Run /pisync push from a configured machine first.");
|
|
368
|
+
|
|
369
|
+
const localChanged = hasLocalChanges(local, state);
|
|
370
|
+
const remoteChanged = remote.id !== state.lastAppliedSnapshot;
|
|
371
|
+
if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
|
|
372
|
+
throw new Error("Both local and remote changed since last sync. Run /pisync diff, then choose /pisync pull --force or /pisync push --force.");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!options.yes && !(await ctx.ui.confirm("Pull pi settings?", formatDiff(local, remote)))) {
|
|
376
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
377
|
+
ctx.ui.notify("Pull cancelled.", "info");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const backup = await backupLocal(config.profile);
|
|
382
|
+
await applySnapshot(remote);
|
|
383
|
+
await writeState(config.profile, {
|
|
384
|
+
version: VERSION,
|
|
385
|
+
profile: config.profile,
|
|
386
|
+
lastAppliedSnapshot: remote.id,
|
|
387
|
+
lastRemoteEtag: undefined,
|
|
388
|
+
lastFileHashes: fileHashMap(remote),
|
|
389
|
+
});
|
|
390
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
391
|
+
if (!options.silent) {
|
|
392
|
+
ctx.ui.notify(`Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`, "info");
|
|
393
|
+
}
|
|
394
|
+
if (options.reload) await maybeReload(ctx);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
|
|
398
|
+
const config = await loadConfig();
|
|
399
|
+
const client = new S3Client(config);
|
|
400
|
+
const state = await readState(config.profile);
|
|
401
|
+
const local = await createSnapshot(config.profile);
|
|
402
|
+
const remote = await readRemoteSnapshot(client, config);
|
|
403
|
+
const localChanged = hasLocalChanges(local, state);
|
|
404
|
+
const remoteChanged = remote ? remote.id !== state.lastAppliedSnapshot : false;
|
|
405
|
+
const firstSync = !state.lastAppliedSnapshot;
|
|
406
|
+
|
|
407
|
+
if (firstSync && remote && local.files.length > 0) {
|
|
408
|
+
if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
|
|
409
|
+
throw new Error("Remote settings exist and this machine has different local Pi settings. Run /pisync diff, then manually choose /pisync pull or /pisync push.");
|
|
410
|
+
}
|
|
411
|
+
await writeState(config.profile, {
|
|
412
|
+
version: VERSION,
|
|
413
|
+
profile: config.profile,
|
|
414
|
+
lastAppliedSnapshot: remote.id,
|
|
415
|
+
lastRemoteEtag: undefined,
|
|
416
|
+
lastFileHashes: fileHashMap(remote),
|
|
417
|
+
});
|
|
418
|
+
if (!options.silent) ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
|
|
422
|
+
throw new Error("Both local and remote changed. Run /pisync diff and resolve with push --force or pull --force.");
|
|
423
|
+
}
|
|
424
|
+
if (remoteChanged) {
|
|
425
|
+
await pull(ctx, options);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (localChanged || !remote) {
|
|
429
|
+
await push(ctx, options);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function history(ctx: ExtensionCommandContext) {
|
|
436
|
+
const config = await loadConfig();
|
|
437
|
+
const client = new S3Client(config);
|
|
438
|
+
const remote = await client.getJson<{ snapshots?: LatestPointer[] }>(historyKey(config));
|
|
439
|
+
if (remote.missing || !remote.value?.snapshots?.length) {
|
|
440
|
+
ctx.ui.notify("No remote pi-sync history found.", "info");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
ctx.ui.notify(
|
|
445
|
+
remote.value.snapshots
|
|
446
|
+
.slice(-20)
|
|
447
|
+
.reverse()
|
|
448
|
+
.map((item) => `${item.snapshot} ${item.createdAt} ${item.machine}`)
|
|
449
|
+
.join("\n"),
|
|
450
|
+
"info",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
455
|
+
const target = options.args[0];
|
|
456
|
+
if (!target) throw new Error("Usage: /pisync rollback <snapshot-id> [--yes]");
|
|
457
|
+
|
|
458
|
+
const config = await loadConfig();
|
|
459
|
+
const client = new S3Client(config);
|
|
460
|
+
const snapshot = await client.getBuffer(snapshotKey(config, target));
|
|
461
|
+
if (!snapshot.value) throw new Error(`Snapshot not found: ${target}`);
|
|
462
|
+
const remote = await decodeSnapshot(snapshot.value);
|
|
463
|
+
|
|
464
|
+
if (!options.yes && !(await ctx.ui.confirm("Rollback pi settings?", formatSnapshotOnlyDiff("Rollback would apply", remote)))) {
|
|
465
|
+
ctx.ui.notify("Rollback cancelled.", "info");
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const backup = await backupLocal(config.profile);
|
|
470
|
+
await applySnapshot(remote);
|
|
471
|
+
const pointer = pointerFor(config, remote, sha256(snapshot.value));
|
|
472
|
+
await client.putJson(latestKey(config), pointer);
|
|
473
|
+
await updateHistory(client, config, pointer);
|
|
474
|
+
await writeState(config.profile, {
|
|
475
|
+
version: VERSION,
|
|
476
|
+
profile: config.profile,
|
|
477
|
+
lastAppliedSnapshot: remote.id,
|
|
478
|
+
lastRemoteEtag: undefined,
|
|
479
|
+
lastFileHashes: fileHashMap(remote),
|
|
480
|
+
});
|
|
481
|
+
ctx.ui.notify(`Rolled back to ${remote.id}. Backup: ${backup}`, "info");
|
|
482
|
+
await maybeReload(ctx);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
486
|
+
const lock = await readLock();
|
|
487
|
+
if (!lock) {
|
|
488
|
+
ctx.ui.notify("No pi-sync lock is present.", "info");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (!options.stale && !isStaleLock(lock)) {
|
|
492
|
+
ctx.ui.notify("Lock is not stale. Use /pisync unlock --stale only after verifying no sync is running.", "warning");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
await fs.rm(lockPath(), { force: true });
|
|
496
|
+
ctx.ui.notify("Removed stale pi-sync lock.", "info");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext) {
|
|
500
|
+
if (!("reload" in ctx)) return;
|
|
501
|
+
if (ctx.hasUI && (await ctx.ui.confirm("Reload Pi resources now?", "This reloads extensions, skills, prompts, themes, and context files."))) {
|
|
502
|
+
await ctx.reload();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
|
|
507
|
+
await ensureStateDir();
|
|
508
|
+
const lock: LockFile = {
|
|
509
|
+
id: randomUUID(),
|
|
510
|
+
pid: process.pid,
|
|
511
|
+
command,
|
|
512
|
+
startedAt: new Date().toISOString(),
|
|
513
|
+
};
|
|
514
|
+
let handle: fs.FileHandle | undefined;
|
|
515
|
+
try {
|
|
516
|
+
handle = await fs.open(lockPath(), "wx");
|
|
517
|
+
await handle.writeFile(JSON.stringify(lock, null, "\t"));
|
|
518
|
+
await handle.close();
|
|
519
|
+
handle = undefined;
|
|
520
|
+
return await fn();
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
523
|
+
const current = await readLock();
|
|
524
|
+
if (current && isStaleLock(current)) {
|
|
525
|
+
throw new Error(`pi-sync lock is stale (pid ${current.pid}). Run /pisync unlock --stale, then retry.`);
|
|
526
|
+
}
|
|
527
|
+
throw new Error(`pi-sync is already running${current ? ` (${current.command}, pid ${current.pid}, started ${current.startedAt})` : ""}.`);
|
|
528
|
+
}
|
|
529
|
+
throw error;
|
|
530
|
+
} finally {
|
|
531
|
+
await handle?.close();
|
|
532
|
+
const current = await readLock();
|
|
533
|
+
if (current?.id === lock.id) await fs.rm(lockPath(), { force: true });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function loadConfig(): Promise<SyncConfig> {
|
|
538
|
+
const partial = await loadPartialConfig();
|
|
539
|
+
const endpoint = partial.endpoint;
|
|
540
|
+
const bucket = partial.bucket;
|
|
541
|
+
const accessKeyId = partial.accessKeyId;
|
|
542
|
+
const secretAccessKey = partial.secretAccessKey;
|
|
543
|
+
const missing = [
|
|
544
|
+
["endpoint", endpoint],
|
|
545
|
+
["bucket", bucket],
|
|
546
|
+
["accessKeyId", accessKeyId],
|
|
547
|
+
["secretAccessKey", secretAccessKey],
|
|
548
|
+
]
|
|
549
|
+
.filter(([, value]) => !value)
|
|
550
|
+
.map(([name]) => name);
|
|
551
|
+
if (missing.length > 0) {
|
|
552
|
+
throw new Error(`Missing pi-sync config: ${missing.join(", ")}. Run /pisync init or set PI_SYNC_* environment variables.`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
endpoint: endpoint!,
|
|
557
|
+
bucket: bucket!,
|
|
558
|
+
region: partial.region ?? DEFAULT_REGION,
|
|
559
|
+
accessKeyId: accessKeyId!,
|
|
560
|
+
secretAccessKey: secretAccessKey!,
|
|
561
|
+
sessionToken: partial.sessionToken,
|
|
562
|
+
profile: partial.profile ?? DEFAULT_PROFILE,
|
|
563
|
+
prefix: trimSlashes(partial.prefix ?? DEFAULT_PREFIX),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function loadPartialConfig(): Promise<PartialConfig> {
|
|
568
|
+
const fileConfig = await readJsonIfExists<PartialConfig>(localConfigPath());
|
|
569
|
+
return {
|
|
570
|
+
...fileConfig,
|
|
571
|
+
endpoint: process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? fileConfig?.endpoint,
|
|
572
|
+
bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig?.bucket,
|
|
573
|
+
region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig?.region,
|
|
574
|
+
accessKeyId: process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig?.accessKeyId,
|
|
575
|
+
secretAccessKey: process.env.PI_SYNC_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY ?? fileConfig?.secretAccessKey,
|
|
576
|
+
sessionToken: process.env.PI_SYNC_SESSION_TOKEN ?? process.env.AWS_SESSION_TOKEN ?? fileConfig?.sessionToken,
|
|
577
|
+
profile: process.env.PI_SYNC_PROFILE ?? fileConfig?.profile,
|
|
578
|
+
prefix: process.env.PI_SYNC_PREFIX ?? fileConfig?.prefix,
|
|
579
|
+
autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig?.autoSync,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function createSnapshot(profile: string): Promise<Snapshot> {
|
|
584
|
+
const files = await collectFiles(agentDir());
|
|
585
|
+
return {
|
|
586
|
+
version: VERSION,
|
|
587
|
+
id: snapshotId(),
|
|
588
|
+
createdAt: new Date().toISOString(),
|
|
589
|
+
machine: os.hostname(),
|
|
590
|
+
profile,
|
|
591
|
+
files,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function collectFiles(root: string): Promise<SnapshotFile[]> {
|
|
596
|
+
const results: SnapshotFile[] = [];
|
|
597
|
+
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
|
598
|
+
if (entry.isFile() && TOP_LEVEL_FILES.has(entry.name)) {
|
|
599
|
+
await addFile(results, root, entry.name);
|
|
600
|
+
} else if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name)) {
|
|
601
|
+
await collectDirectory(results, root, entry.name);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return results.sort((left, right) => left.path.localeCompare(right.path));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function collectDirectory(results: SnapshotFile[], root: string, relativeDirectory: string) {
|
|
608
|
+
const absoluteDirectory = path.join(root, relativeDirectory);
|
|
609
|
+
for (const entry of await fs.readdir(absoluteDirectory, { withFileTypes: true })) {
|
|
610
|
+
const relativePath = posixJoin(relativeDirectory, entry.name);
|
|
611
|
+
if (isDeniedPath(relativePath)) continue;
|
|
612
|
+
if (entry.isDirectory()) {
|
|
613
|
+
await collectDirectory(results, root, relativePath);
|
|
614
|
+
} else if (entry.isFile()) {
|
|
615
|
+
await addFile(results, root, relativePath);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function addFile(results: SnapshotFile[], root: string, relativePath: string) {
|
|
621
|
+
if (isDeniedPath(relativePath)) return;
|
|
622
|
+
const absolutePath = safeJoin(root, relativePath);
|
|
623
|
+
const content = await fs.readFile(absolutePath);
|
|
624
|
+
results.push({ path: relativePath, contentBase64: content.toString("base64"), sha256: sha256(content) });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function isDeniedPath(relativePath: string) {
|
|
628
|
+
const normalized = toPosix(relativePath);
|
|
629
|
+
const base = path.posix.basename(normalized).toLowerCase();
|
|
630
|
+
return (
|
|
631
|
+
normalized.includes("/node_modules/") ||
|
|
632
|
+
normalized.includes("/.git/") ||
|
|
633
|
+
normalized.includes("/.pisync/") ||
|
|
634
|
+
base === ".env" ||
|
|
635
|
+
base.startsWith(".env.") ||
|
|
636
|
+
base.endsWith(".env") ||
|
|
637
|
+
base.includes("secret") ||
|
|
638
|
+
base.includes("token") ||
|
|
639
|
+
base === "pi-sync.local.json"
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function scanSnapshot(snapshot: Snapshot) {
|
|
644
|
+
const findings: string[] = [];
|
|
645
|
+
for (const file of snapshot.files) {
|
|
646
|
+
const content = Buffer.from(file.contentBase64, "base64");
|
|
647
|
+
if (content.includes(0)) continue;
|
|
648
|
+
const text = content.toString("utf8");
|
|
649
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
650
|
+
if (pattern.test(text)) {
|
|
651
|
+
findings.push(file.path);
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return findings;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function uploadSnapshot(
|
|
660
|
+
client: S3Client,
|
|
661
|
+
config: SyncConfig,
|
|
662
|
+
snapshot: Snapshot,
|
|
663
|
+
latest: RemoteObject<LatestPointer>,
|
|
664
|
+
force: boolean,
|
|
665
|
+
) {
|
|
666
|
+
const encoded = await encodeSnapshot(snapshot);
|
|
667
|
+
const pointer = pointerFor(config, snapshot, sha256(encoded));
|
|
668
|
+
await client.putBuffer(snapshotKey(config, snapshot.id), encoded, "application/gzip");
|
|
669
|
+
const current = await client.getJson<LatestPointer>(latestKey(config));
|
|
670
|
+
if (!force && remoteIdentity(current) !== remoteIdentity(latest)) {
|
|
671
|
+
throw new Error("Remote changed while pushing. Run /pisync pull first, then retry.");
|
|
672
|
+
}
|
|
673
|
+
await client.putJson(latestKey(config), pointer);
|
|
674
|
+
const verified = await client.getJson<LatestPointer>(latestKey(config));
|
|
675
|
+
if (verified.value?.snapshot !== pointer.snapshot) {
|
|
676
|
+
throw new Error("Remote latest changed immediately after push. Run /pisync status before continuing.");
|
|
677
|
+
}
|
|
678
|
+
return pointer;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function readRemoteSnapshot(client: S3Client, config: SyncConfig) {
|
|
682
|
+
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
683
|
+
if (latest.missing || !latest.value) return undefined;
|
|
684
|
+
const object = await client.getBuffer(snapshotKey(config, latest.value.snapshot));
|
|
685
|
+
if (!object.value) throw new Error(`Remote latest points to missing snapshot: ${latest.value.snapshot}`);
|
|
686
|
+
if (sha256(object.value) !== latest.value.sha256) throw new Error("Remote snapshot checksum mismatch.");
|
|
687
|
+
return decodeSnapshot(object.value);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function encodeSnapshot(snapshot: Snapshot) {
|
|
691
|
+
return gzipAsync(Buffer.from(JSON.stringify(snapshot), "utf8"));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function decodeSnapshot(buffer: Buffer): Promise<Snapshot> {
|
|
695
|
+
const decoded = await gunzipAsync(buffer);
|
|
696
|
+
const parsed = JSON.parse(decoded.toString("utf8")) as Snapshot;
|
|
697
|
+
if (parsed.version !== VERSION || !Array.isArray(parsed.files)) throw new Error("Unsupported snapshot format.");
|
|
698
|
+
return parsed;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function applySnapshot(snapshot: Snapshot) {
|
|
702
|
+
const root = agentDir();
|
|
703
|
+
const current = await createSnapshot(snapshot.profile);
|
|
704
|
+
const plan = preflightSnapshotApply(root, snapshot, current);
|
|
705
|
+
await preflightSnapshotMutations(root, plan);
|
|
706
|
+
for (const target of plan.deletes) {
|
|
707
|
+
await fs.rm(target, { force: true, recursive: true });
|
|
708
|
+
}
|
|
709
|
+
for (const item of plan.writes) {
|
|
710
|
+
await fs.writeFile(item.target, item.content);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function preflightSnapshotApply(root: string, snapshot: Snapshot, current: Snapshot) {
|
|
715
|
+
const seenPaths = new Set<string>();
|
|
716
|
+
const remotePaths = new Set<string>();
|
|
717
|
+
const writes: Array<{ target: string; content: Buffer }> = [];
|
|
718
|
+
const deletes: string[] = [];
|
|
719
|
+
|
|
720
|
+
for (const file of snapshot.files) {
|
|
721
|
+
const normalized = toPosix(file.path);
|
|
722
|
+
if (!normalized || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
|
|
723
|
+
throw new Error(`Unsafe path in snapshot: ${file.path}`);
|
|
724
|
+
}
|
|
725
|
+
if (seenPaths.has(normalized)) throw new Error(`Duplicate path in snapshot: ${normalized}`);
|
|
726
|
+
seenPaths.add(normalized);
|
|
727
|
+
remotePaths.add(normalized);
|
|
728
|
+
|
|
729
|
+
const target = safeJoin(root, normalized);
|
|
730
|
+
const content = decodeBase64Strict(file.contentBase64, normalized);
|
|
731
|
+
if (sha256(content) !== file.sha256) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
|
|
732
|
+
writes.push({ target, content });
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const deletePaths = new Set<string>();
|
|
736
|
+
for (const file of current.files) {
|
|
737
|
+
const normalized = toPosix(file.path);
|
|
738
|
+
if (!remotePaths.has(normalized)) deletePaths.add(safeJoin(root, normalized));
|
|
739
|
+
for (const remotePath of remotePaths) {
|
|
740
|
+
if (normalized.startsWith(`${remotePath}/`)) deletePaths.add(safeJoin(root, remotePath));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
deletes.push(...deletePaths);
|
|
744
|
+
|
|
745
|
+
return { writes, deletes };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function decodeBase64Strict(value: string, filePath: string) {
|
|
749
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
|
|
750
|
+
throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
|
|
751
|
+
}
|
|
752
|
+
return Buffer.from(value, "base64");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function backupLocal(profile: string) {
|
|
756
|
+
const snapshot = await createSnapshot(profile);
|
|
757
|
+
const backupDirectory = path.join(stateDir(), "backups");
|
|
758
|
+
await fs.mkdir(backupDirectory, { recursive: true });
|
|
759
|
+
const backupPath = path.join(backupDirectory, `${snapshot.id}.json.gz`);
|
|
760
|
+
await fs.writeFile(backupPath, await encodeSnapshot(snapshot));
|
|
761
|
+
return backupPath;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function updateHistory(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
|
|
765
|
+
const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(historyKey(config));
|
|
766
|
+
const snapshots = object.value?.snapshots ?? [];
|
|
767
|
+
const next = [
|
|
768
|
+
...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot),
|
|
769
|
+
pointer,
|
|
770
|
+
].slice(-100);
|
|
771
|
+
await client.putJson(historyKey(config), { version: VERSION, snapshots: next });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function formatDiff(local: Snapshot, remote: Snapshot) {
|
|
775
|
+
const localMap = fileHashMap(local);
|
|
776
|
+
const remoteMap = fileHashMap(remote);
|
|
777
|
+
const allPaths = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
|
|
778
|
+
const lines = [`local: ${local.files.length} files`, `remote: ${remote.id} (${remote.files.length} files)`, ""];
|
|
779
|
+
let changed = 0;
|
|
780
|
+
for (const filePath of allPaths) {
|
|
781
|
+
if (!localMap[filePath]) {
|
|
782
|
+
lines.push(`+ ${filePath}`);
|
|
783
|
+
changed += 1;
|
|
784
|
+
} else if (!remoteMap[filePath]) {
|
|
785
|
+
lines.push(`- ${filePath}`);
|
|
786
|
+
changed += 1;
|
|
787
|
+
} else if (localMap[filePath] !== remoteMap[filePath]) {
|
|
788
|
+
lines.push(`~ ${filePath}`);
|
|
789
|
+
changed += 1;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (changed === 0) lines.push("No file differences.");
|
|
793
|
+
return lines.join("\n");
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function formatSnapshotOnlyDiff(title: string, snapshot: Snapshot) {
|
|
797
|
+
return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `+ ${file.path}`)].join("\n");
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function formatPushSummary(local: Snapshot, latest: RemoteObject<LatestPointer>) {
|
|
801
|
+
return [
|
|
802
|
+
`Upload ${local.files.length} files from ${agentDir()}.`,
|
|
803
|
+
latest.value ? `Remote latest: ${latest.value.snapshot}` : "Remote latest: empty",
|
|
804
|
+
"Possible secrets were scanned before this prompt.",
|
|
805
|
+
].join("\n");
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function hasLocalChanges(local: Snapshot, state: SyncState) {
|
|
809
|
+
return !sameHashes(fileHashMap(local), state.lastFileHashes);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function remoteChangedSinceState(latest: RemoteObject<LatestPointer>, state: SyncState) {
|
|
813
|
+
if (latest.missing) return Boolean(state.lastAppliedSnapshot);
|
|
814
|
+
return latest.value?.snapshot !== state.lastAppliedSnapshot;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function remoteIdentity(remote: RemoteObject<LatestPointer>) {
|
|
818
|
+
return remote.missing ? "missing" : (remote.value?.snapshot ?? "unknown");
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function sameHashes(left: Record<string, string>, right: Record<string, string>) {
|
|
822
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
823
|
+
for (const key of keys) {
|
|
824
|
+
if (left[key] !== right[key]) return false;
|
|
825
|
+
}
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function fileHashMap(snapshot: Snapshot) {
|
|
830
|
+
return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function readState(profile: string): Promise<SyncState> {
|
|
834
|
+
return (
|
|
835
|
+
(await readJsonIfExists<SyncState>(statePath(profile))) ?? {
|
|
836
|
+
version: VERSION,
|
|
837
|
+
profile,
|
|
838
|
+
lastFileHashes: {},
|
|
839
|
+
}
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function writeState(profile: string, state: SyncState) {
|
|
844
|
+
await writeJson(statePath(profile), state);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
class S3Client {
|
|
848
|
+
private config: SyncConfig;
|
|
849
|
+
private endpoint: URL;
|
|
850
|
+
|
|
851
|
+
constructor(config: SyncConfig) {
|
|
852
|
+
this.config = config;
|
|
853
|
+
this.endpoint = new URL(config.endpoint);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async getJson<T>(key: string): Promise<RemoteObject<T>> {
|
|
857
|
+
const object = await this.request("GET", key);
|
|
858
|
+
if (object.status === 404) return { missing: true };
|
|
859
|
+
if (!object.ok) throw new Error(`S3 GET failed (${object.status}): ${await object.text()}`);
|
|
860
|
+
return { value: (await object.json()) as T, etag: normalizeEtag(object.headers.get("etag")), missing: false };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async getBuffer(key: string): Promise<RemoteObject<Buffer>> {
|
|
864
|
+
const object = await this.request("GET", key);
|
|
865
|
+
if (object.status === 404) return { missing: true };
|
|
866
|
+
if (!object.ok) throw new Error(`S3 GET failed (${object.status}): ${await object.text()}`);
|
|
867
|
+
return { value: Buffer.from(await object.arrayBuffer()), etag: normalizeEtag(object.headers.get("etag")), missing: false };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async putJson(key: string, value: unknown) {
|
|
871
|
+
const body = Buffer.from(JSON.stringify(value, null, "\t"), "utf8");
|
|
872
|
+
await this.putBuffer(key, body, "application/json");
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async putBuffer(key: string, body: Buffer, contentType: string) {
|
|
876
|
+
const headers: Record<string, string> = { "content-type": contentType };
|
|
877
|
+
const response = await this.request("PUT", key, body, headers);
|
|
878
|
+
if (!response.ok) throw new Error(`S3 PUT failed (${response.status}): ${await response.text()}`);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private async request(
|
|
882
|
+
method: "GET" | "PUT",
|
|
883
|
+
key: string,
|
|
884
|
+
body?: Buffer,
|
|
885
|
+
extraHeaders: Record<string, string> = {},
|
|
886
|
+
) {
|
|
887
|
+
const url = new URL(this.endpoint.toString());
|
|
888
|
+
url.pathname = posixJoin(url.pathname, this.config.bucket, encodeKey(key));
|
|
889
|
+
const headers = await signedHeaders({
|
|
890
|
+
method,
|
|
891
|
+
url,
|
|
892
|
+
body,
|
|
893
|
+
extraHeaders,
|
|
894
|
+
accessKeyId: this.config.accessKeyId,
|
|
895
|
+
secretAccessKey: this.config.secretAccessKey,
|
|
896
|
+
sessionToken: this.config.sessionToken,
|
|
897
|
+
region: this.config.region,
|
|
898
|
+
});
|
|
899
|
+
return fetch(url, { method, headers, body: body ? new Uint8Array(body) : undefined });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function signedHeaders(input: {
|
|
904
|
+
method: string;
|
|
905
|
+
url: URL;
|
|
906
|
+
body?: Buffer;
|
|
907
|
+
extraHeaders: Record<string, string>;
|
|
908
|
+
accessKeyId: string;
|
|
909
|
+
secretAccessKey: string;
|
|
910
|
+
sessionToken?: string;
|
|
911
|
+
region: string;
|
|
912
|
+
}) {
|
|
913
|
+
const now = new Date();
|
|
914
|
+
const amzDate = iso8601Basic(now);
|
|
915
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
916
|
+
const payloadHash = sha256(input.body ?? Buffer.alloc(0));
|
|
917
|
+
const headers: Record<string, string> = {
|
|
918
|
+
...lowercaseKeys(input.extraHeaders),
|
|
919
|
+
host: input.url.host,
|
|
920
|
+
"x-amz-content-sha256": payloadHash,
|
|
921
|
+
"x-amz-date": amzDate,
|
|
922
|
+
};
|
|
923
|
+
if (input.sessionToken) headers["x-amz-security-token"] = input.sessionToken;
|
|
924
|
+
const signedHeaderNames = Object.keys(headers).sort();
|
|
925
|
+
const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]?.trim()}\n`).join("");
|
|
926
|
+
const canonicalRequest = [
|
|
927
|
+
input.method,
|
|
928
|
+
input.url.pathname,
|
|
929
|
+
input.url.searchParams.toString(),
|
|
930
|
+
canonicalHeaders,
|
|
931
|
+
signedHeaderNames.join(";"),
|
|
932
|
+
payloadHash,
|
|
933
|
+
].join("\n");
|
|
934
|
+
const scope = `${dateStamp}/${input.region}/s3/aws4_request`;
|
|
935
|
+
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(Buffer.from(canonicalRequest))].join("\n");
|
|
936
|
+
const signingKey = hmac(
|
|
937
|
+
hmac(hmac(hmac(Buffer.from(`AWS4${input.secretAccessKey}`), dateStamp), input.region), "s3"),
|
|
938
|
+
"aws4_request",
|
|
939
|
+
);
|
|
940
|
+
const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
|
|
941
|
+
return {
|
|
942
|
+
...headers,
|
|
943
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaderNames.join(";")}, Signature=${signature}`,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function hmac(key: Buffer, value: string) {
|
|
948
|
+
return createHmac("sha256", key).update(value).digest();
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function sha256(value: Buffer) {
|
|
952
|
+
return createHash("sha256").update(value).digest("hex");
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function latestKey(config: SyncConfig) {
|
|
956
|
+
return posixJoin(profilePrefix(config), "latest.json");
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function historyKey(config: SyncConfig) {
|
|
960
|
+
return posixJoin(profilePrefix(config), "history.json");
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function snapshotKey(config: SyncConfig, id: string) {
|
|
964
|
+
return posixJoin(profilePrefix(config), "snapshots", `${id}.json.gz`);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function profilePrefix(config: SyncConfig) {
|
|
968
|
+
return posixJoin(config.prefix, "profiles", config.profile);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function pointerFor(config: SyncConfig, snapshot: Snapshot, checksum: string): LatestPointer {
|
|
972
|
+
return {
|
|
973
|
+
version: VERSION,
|
|
974
|
+
profile: config.profile,
|
|
975
|
+
snapshot: snapshot.id,
|
|
976
|
+
sha256: checksum,
|
|
977
|
+
createdAt: snapshot.createdAt,
|
|
978
|
+
machine: snapshot.machine,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function agentDir() {
|
|
983
|
+
return path.join(os.homedir(), ".pi", "agent");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function stateDir() {
|
|
987
|
+
return path.join(agentDir(), ".pisync");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function localConfigPath() {
|
|
991
|
+
return path.join(agentDir(), "pi-sync.local.json");
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function statePath(profile: string) {
|
|
995
|
+
return path.join(stateDir(), `${safeName(profile)}.state.json`);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function lockPath() {
|
|
999
|
+
return path.join(stateDir(), "lock");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async function ensureStateDir() {
|
|
1003
|
+
await fs.mkdir(stateDir(), { recursive: true });
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function readLock() {
|
|
1007
|
+
return readJsonIfExists<LockFile>(lockPath());
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function isStaleLock(lock: LockFile) {
|
|
1011
|
+
if (!Number.isInteger(lock.pid) || lock.pid <= 0) return true;
|
|
1012
|
+
try {
|
|
1013
|
+
process.kill(lock.pid, 0);
|
|
1014
|
+
return false;
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") return true;
|
|
1017
|
+
return Date.now() - Date.parse(lock.startedAt) > LOCK_STALE_MS;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
|
|
1022
|
+
try {
|
|
1023
|
+
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function writeJson(filePath: string, value: unknown) {
|
|
1031
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
1032
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, "\t")}\n`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function preflightSnapshotMutations(
|
|
1036
|
+
root: string,
|
|
1037
|
+
plan: { deletes: string[]; writes: Array<{ target: string; content: Buffer }> },
|
|
1038
|
+
) {
|
|
1039
|
+
const deletePaths = new Set(plan.deletes);
|
|
1040
|
+
for (const target of plan.deletes) {
|
|
1041
|
+
await assertNoSymlinkParents(root, target);
|
|
1042
|
+
}
|
|
1043
|
+
for (const item of plan.writes) {
|
|
1044
|
+
await prepareSnapshotWrite(root, item.target, deletePaths);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function prepareSnapshotWrite(root: string, target: string, deletePaths: Set<string>) {
|
|
1049
|
+
await ensureSafeDirectory(root, path.dirname(target));
|
|
1050
|
+
try {
|
|
1051
|
+
const stat = await fs.lstat(target);
|
|
1052
|
+
if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
|
|
1053
|
+
if (stat.isDirectory() && !deletePaths.has(target)) {
|
|
1054
|
+
throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
|
|
1055
|
+
}
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function ensureSafeDirectory(root: string, directory: string) {
|
|
1062
|
+
assertWithinRoot(root, directory);
|
|
1063
|
+
const rootPath = path.resolve(root);
|
|
1064
|
+
const relative = path.relative(rootPath, path.resolve(directory));
|
|
1065
|
+
let current = rootPath;
|
|
1066
|
+
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
1067
|
+
current = path.join(current, part);
|
|
1068
|
+
try {
|
|
1069
|
+
const stat = await fs.lstat(current);
|
|
1070
|
+
if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
1071
|
+
if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
1074
|
+
await fs.mkdir(current);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async function assertNoSymlinkParents(root: string, target: string) {
|
|
1080
|
+
assertWithinRoot(root, target);
|
|
1081
|
+
const rootPath = path.resolve(root);
|
|
1082
|
+
const relative = path.relative(rootPath, path.resolve(target));
|
|
1083
|
+
let current = rootPath;
|
|
1084
|
+
const parts = relative.split(path.sep).filter(Boolean);
|
|
1085
|
+
for (const part of parts.slice(0, -1)) {
|
|
1086
|
+
current = path.join(current, part);
|
|
1087
|
+
try {
|
|
1088
|
+
const stat = await fs.lstat(current);
|
|
1089
|
+
if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
1090
|
+
if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
1093
|
+
throw error;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function safeJoin(root: string, relativePath: string) {
|
|
1099
|
+
const target = path.resolve(root, relativePath);
|
|
1100
|
+
assertWithinRoot(root, target, relativePath);
|
|
1101
|
+
return target;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function assertWithinRoot(root: string, target: string, label = target) {
|
|
1105
|
+
const resolvedRoot = path.resolve(root);
|
|
1106
|
+
const resolvedTarget = path.resolve(target);
|
|
1107
|
+
if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
1108
|
+
throw new Error(`Unsafe path in snapshot: ${label}`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function splitArgs(input: string) {
|
|
1113
|
+
return input.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((arg) => arg.replace(/^['"]|['"]$/g, "")) ?? [];
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function parseOptions(args: string[]): CommandOptions {
|
|
1117
|
+
return {
|
|
1118
|
+
yes: args.includes("--yes") || args.includes("-y"),
|
|
1119
|
+
force: args.includes("--force"),
|
|
1120
|
+
stale: args.includes("--stale"),
|
|
1121
|
+
silent: false,
|
|
1122
|
+
reload: true,
|
|
1123
|
+
auto: false,
|
|
1124
|
+
args: args.filter((arg) => !arg.startsWith("-")),
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function usage() {
|
|
1129
|
+
return [
|
|
1130
|
+
"Usage: /pisync <command>",
|
|
1131
|
+
"Commands: init, config, status, diff, doctor, push, pull, sync, history, rollback <snapshot>, unlock --stale",
|
|
1132
|
+
"Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN/region/profile/prefix, or edit ~/.pi/agent/pi-sync.local.json.",
|
|
1133
|
+
].join("\n");
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function snapshotId() {
|
|
1137
|
+
return `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function iso8601Basic(date: Date) {
|
|
1141
|
+
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function encodeKey(key: string) {
|
|
1145
|
+
return key.split("/").map(encodeURIComponent).join("/");
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function posixJoin(...parts: string[]) {
|
|
1149
|
+
return parts.map((part) => trimSlashes(part)).filter(Boolean).join("/");
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function toPosix(value: string) {
|
|
1153
|
+
return value.split(path.sep).join("/");
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function trimSlashes(value: string) {
|
|
1157
|
+
return value.replace(/^\/+|\/+$/g, "");
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function safeName(value: string) {
|
|
1161
|
+
return value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function lowercaseKeys(value: Record<string, string>) {
|
|
1165
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key.toLowerCase(), item]));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function normalizeEtag(value: string | null) {
|
|
1169
|
+
return value ?? undefined;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function redact(value: string) {
|
|
1173
|
+
return value.length <= 8 ? "configured" : `${value.slice(0, 4)}…${value.slice(-4)}`;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
|
|
1177
|
+
if (value === undefined) return defaultValue;
|
|
1178
|
+
if (typeof value === "boolean") return value;
|
|
1179
|
+
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function isMissingConfigError(error: unknown) {
|
|
1183
|
+
return error instanceof Error && error.message.startsWith("Missing pi-sync config:");
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function errorMessage(error: unknown) {
|
|
1187
|
+
return error instanceof Error ? error.message : String(error);
|
|
1188
|
+
}
|