@narumitw/pi-sync 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +227 -142
- package/package.json +1 -1
- package/src/command.ts +102 -7
- package/src/config.ts +292 -33
- package/src/file-selection.ts +158 -70
- package/src/manager-ui.ts +961 -0
- package/src/s3-client.ts +47 -13
- package/src/settings-management.ts +307 -0
- package/src/snapshot-apply.ts +6 -6
- package/src/snapshot-transaction.ts +201 -0
- package/src/sync-format.ts +168 -0
- package/src/sync.ts +217 -136
- package/src/types.ts +43 -0
package/src/sync.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { constants as fsConstants } from "node:fs";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
3
|
import path from "node:path";
|
|
@@ -9,9 +8,17 @@ import type {
|
|
|
9
8
|
ExtensionCommandContext,
|
|
10
9
|
ExtensionContext,
|
|
11
10
|
} from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { completeSyncArguments, parseOptions, resolveSyncCommand, usage } from "./command.js";
|
|
13
11
|
import {
|
|
14
|
-
|
|
12
|
+
completeSyncArguments,
|
|
13
|
+
parseOptions,
|
|
14
|
+
resolveSyncCommand,
|
|
15
|
+
setSyncTargetCompletions,
|
|
16
|
+
usage,
|
|
17
|
+
validateCommandOptions,
|
|
18
|
+
} from "./command.js";
|
|
19
|
+
import {
|
|
20
|
+
configuredTargetNames,
|
|
21
|
+
deprecatedPiSyncEnvironmentWarnings,
|
|
15
22
|
ensureStateDir,
|
|
16
23
|
isEnabled,
|
|
17
24
|
isExplicitlyEnabled,
|
|
@@ -22,16 +29,17 @@ import {
|
|
|
22
29
|
localConfigTemplate,
|
|
23
30
|
normalizeExtraFiles,
|
|
24
31
|
normalizeSyncFiles,
|
|
25
|
-
|
|
32
|
+
readStateForConfig,
|
|
26
33
|
sessionDirForApply,
|
|
27
34
|
sessionTokenWarnings,
|
|
28
35
|
stateDir,
|
|
29
36
|
syncSessionsWarnings,
|
|
30
37
|
writeLocalConfigObject,
|
|
31
|
-
|
|
38
|
+
writeStateForConfig,
|
|
32
39
|
} from "./config.js";
|
|
33
40
|
import { showFileSelection } from "./file-selection.js";
|
|
34
41
|
import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
|
|
42
|
+
import { showSetupWizard, showSyncManager, useSyncTarget } from "./manager-ui.js";
|
|
35
43
|
import {
|
|
36
44
|
historyKey,
|
|
37
45
|
latestKey,
|
|
@@ -50,6 +58,21 @@ import {
|
|
|
50
58
|
snapshotWithoutSessions,
|
|
51
59
|
} from "./snapshot.js";
|
|
52
60
|
import { applySnapshot } from "./snapshot-apply.js";
|
|
61
|
+
import { recoverPendingSnapshotTransactions } from "./snapshot-transaction.js";
|
|
62
|
+
import {
|
|
63
|
+
countPreservedRemoteFiles,
|
|
64
|
+
errorMessage,
|
|
65
|
+
formatDestination,
|
|
66
|
+
formatDiff,
|
|
67
|
+
formatPullSummary,
|
|
68
|
+
formatPushSummary,
|
|
69
|
+
formatRollbackSummary,
|
|
70
|
+
formatSnapshotOnlyDiff,
|
|
71
|
+
redact,
|
|
72
|
+
remoteIdentity,
|
|
73
|
+
safeTerminalText,
|
|
74
|
+
sha256,
|
|
75
|
+
} from "./sync-format.js";
|
|
53
76
|
import {
|
|
54
77
|
canPullRemoteSessionsOnFirstSync,
|
|
55
78
|
canPullRemoteSettingsOnFirstSync,
|
|
@@ -109,6 +132,19 @@ export default function sync(pi: ExtensionAPI) {
|
|
|
109
132
|
|
|
110
133
|
pi.on("session_start", async (_event, ctx) => {
|
|
111
134
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
135
|
+
try {
|
|
136
|
+
await recoverPendingSnapshotTransactions();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
setSyncTargetCompletions(await configuredTargetNames());
|
|
143
|
+
} catch {
|
|
144
|
+
setSyncTargetCompletions([]);
|
|
145
|
+
}
|
|
146
|
+
const warnings = deprecatedPiSyncEnvironmentWarnings();
|
|
147
|
+
if (warnings.length > 0) ctx.ui.notify(warnings.join("\n"), "warning");
|
|
112
148
|
await autoSync(ctx);
|
|
113
149
|
});
|
|
114
150
|
|
|
@@ -121,34 +157,59 @@ export default function sync(pi: ExtensionAPI) {
|
|
|
121
157
|
}
|
|
122
158
|
|
|
123
159
|
async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
160
|
+
if (!rawArgs.trim()) {
|
|
161
|
+
try {
|
|
162
|
+
await showSyncManager(ctx, (route, signal, onCommit) =>
|
|
163
|
+
executeCommand(route, ctx, signal, onCommit),
|
|
164
|
+
);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
167
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await executeCommand(rawArgs, ctx);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function executeCommand(
|
|
175
|
+
rawArgs: string,
|
|
176
|
+
ctx: ExtensionCommandContext,
|
|
177
|
+
signal?: AbortSignal,
|
|
178
|
+
onCommit?: () => void,
|
|
179
|
+
) {
|
|
124
180
|
try {
|
|
125
181
|
const command = await resolveSyncCommand(rawArgs, ctx);
|
|
126
182
|
if (!command) return;
|
|
127
183
|
const { subcommand, rest } = command;
|
|
128
184
|
const options = parseOptions(rest);
|
|
129
|
-
|
|
185
|
+
if (signal) options.signal = signal;
|
|
186
|
+
if (onCommit) options.onCommit = onCommit;
|
|
187
|
+
validateCommandOptions(subcommand, options);
|
|
130
188
|
|
|
131
189
|
switch (subcommand) {
|
|
132
190
|
case "help":
|
|
133
191
|
ctx.ui.notify(usage(), "info");
|
|
134
192
|
return;
|
|
193
|
+
case "use":
|
|
194
|
+
await useSyncTarget(ctx, options.args[0] ?? "");
|
|
195
|
+
return;
|
|
135
196
|
case "init":
|
|
136
197
|
await initConfig(ctx);
|
|
137
198
|
return;
|
|
138
199
|
case "config":
|
|
139
|
-
await showConfig(ctx);
|
|
200
|
+
await showConfig(ctx, options);
|
|
140
201
|
return;
|
|
141
202
|
case "files":
|
|
142
|
-
await showFileSelection(ctx);
|
|
203
|
+
await showFileSelection(ctx, options.target);
|
|
143
204
|
return;
|
|
144
205
|
case "status":
|
|
145
|
-
await status(ctx);
|
|
206
|
+
await status(ctx, options);
|
|
146
207
|
return;
|
|
147
208
|
case "diff":
|
|
148
|
-
await diff(ctx);
|
|
209
|
+
await diff(ctx, options);
|
|
149
210
|
return;
|
|
150
211
|
case "doctor":
|
|
151
|
-
await doctor(ctx);
|
|
212
|
+
await doctor(ctx, options);
|
|
152
213
|
return;
|
|
153
214
|
case "push":
|
|
154
215
|
await withLock("push", () => push(ctx, options));
|
|
@@ -160,7 +221,7 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
|
160
221
|
await withLock("sync", () => syncBoth(ctx, options));
|
|
161
222
|
return;
|
|
162
223
|
case "history":
|
|
163
|
-
await history(ctx);
|
|
224
|
+
await history(ctx, options);
|
|
164
225
|
return;
|
|
165
226
|
case "rollback":
|
|
166
227
|
await withLock("rollback", () => rollback(ctx, options));
|
|
@@ -173,6 +234,7 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
|
173
234
|
}
|
|
174
235
|
} catch (error) {
|
|
175
236
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
237
|
+
if (signal?.aborted && error instanceof Error && error.name === "AbortError") return;
|
|
176
238
|
ctx.ui.notify(errorMessage(error), "error");
|
|
177
239
|
}
|
|
178
240
|
}
|
|
@@ -200,7 +262,7 @@ async function autoPushSessions(ctx: ExtensionContext) {
|
|
|
200
262
|
const config = await loadConfig();
|
|
201
263
|
if (!config.syncSessions) return;
|
|
202
264
|
await withLock("auto-session-push", async () => {
|
|
203
|
-
const state = await
|
|
265
|
+
const state = await readStateForConfig(config);
|
|
204
266
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
205
267
|
if (!hasLocalChanges(local, state, config)) return;
|
|
206
268
|
await push(ctx, AUTO_SYNC_OPTIONS, { config, state, local });
|
|
@@ -219,20 +281,30 @@ async function initConfig(ctx: ExtensionCommandContext) {
|
|
|
219
281
|
ctx.ui.notify(`Config already exists: ${configPath}`, "info");
|
|
220
282
|
return;
|
|
221
283
|
} catch {
|
|
222
|
-
// Create below.
|
|
284
|
+
// Create or guide setup below.
|
|
223
285
|
}
|
|
224
286
|
|
|
287
|
+
if (ctx.mode === "tui") {
|
|
288
|
+
await showSetupWizard(ctx);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
225
291
|
await writeLocalConfigObject(localConfigTemplate());
|
|
226
292
|
ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /sync doctor.`, "info");
|
|
227
293
|
}
|
|
228
294
|
|
|
229
|
-
async function showConfig(ctx: ExtensionCommandContext) {
|
|
230
|
-
const partial = await loadPartialConfig();
|
|
295
|
+
async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
296
|
+
const partial = await loadPartialConfig(options.target);
|
|
231
297
|
const syncSessions = isExplicitlyEnabled(partial.syncSessions);
|
|
232
|
-
const warnings = [
|
|
298
|
+
const warnings = [
|
|
299
|
+
...deprecatedPiSyncEnvironmentWarnings(),
|
|
300
|
+
...sessionTokenWarnings(partial),
|
|
301
|
+
...syncSessionsWarnings({ syncSessions }),
|
|
302
|
+
];
|
|
233
303
|
ctx.ui.notify(
|
|
234
304
|
[
|
|
235
305
|
"pi-sync config:",
|
|
306
|
+
`target: ${partial.target ?? "default"}`,
|
|
307
|
+
`storage profile: ${partial.storageProfile ?? "default"}`,
|
|
236
308
|
`endpoint: ${partial.endpoint ?? "missing"}`,
|
|
237
309
|
`bucket: ${partial.bucket ?? "missing"}`,
|
|
238
310
|
`region: ${partial.region ?? DEFAULT_REGION}`,
|
|
@@ -252,12 +324,12 @@ async function showConfig(ctx: ExtensionCommandContext) {
|
|
|
252
324
|
);
|
|
253
325
|
}
|
|
254
326
|
|
|
255
|
-
async function status(ctx: ExtensionCommandContext) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const client = new S3Client(config);
|
|
327
|
+
async function status(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
328
|
+
const config = await loadConfig(options.target);
|
|
329
|
+
ctx.ui.setStatus(STATUS_KEY, `checking ${config.target ?? "default"}`);
|
|
330
|
+
const client = new S3Client(config, options.signal);
|
|
259
331
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
260
|
-
const state = await
|
|
332
|
+
const state = await readStateForConfig(config);
|
|
261
333
|
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
262
334
|
const localChanged = hasLocalChanges(local, state, config);
|
|
263
335
|
|
|
@@ -267,11 +339,14 @@ async function status(ctx: ExtensionCommandContext) {
|
|
|
267
339
|
remoteText = `remote: ${latest.value.snapshot} from ${latest.value.machine} at ${latest.value.createdAt}`;
|
|
268
340
|
}
|
|
269
341
|
|
|
270
|
-
const warnings = syncSessionsWarnings(config);
|
|
342
|
+
const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
|
|
271
343
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
272
344
|
ctx.ui.notify(
|
|
273
345
|
[
|
|
274
|
-
`
|
|
346
|
+
`target: ${config.target ?? "default"}`,
|
|
347
|
+
`storage profile: ${config.storageProfile ?? "default"}`,
|
|
348
|
+
`destination: ${formatDestination(config)}`,
|
|
349
|
+
`remote namespace: ${config.profile}`,
|
|
275
350
|
`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
|
|
276
351
|
`extra files: ${config.extraFiles.join(", ") || "none"}`,
|
|
277
352
|
`sessions: ${config.syncSessions ? "included" : "excluded"}`,
|
|
@@ -285,16 +360,19 @@ async function status(ctx: ExtensionCommandContext) {
|
|
|
285
360
|
);
|
|
286
361
|
}
|
|
287
362
|
|
|
288
|
-
async function diff(ctx: ExtensionCommandContext) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const client = new S3Client(config);
|
|
363
|
+
async function diff(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
364
|
+
const config = await loadConfig(options.target);
|
|
365
|
+
ctx.ui.setStatus(STATUS_KEY, `checking ${config.target ?? "default"}`);
|
|
366
|
+
const client = new S3Client(config, options.signal);
|
|
292
367
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
293
368
|
const remote = await readRemoteSnapshot(client, config);
|
|
294
369
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
295
370
|
|
|
296
|
-
const warnings = syncSessionsWarnings(config);
|
|
371
|
+
const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
|
|
297
372
|
const header = [
|
|
373
|
+
`target: ${config.target ?? "default"}`,
|
|
374
|
+
`storage profile: ${config.storageProfile ?? "default"}`,
|
|
375
|
+
`destination: ${formatDestination(config)}`,
|
|
298
376
|
`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
|
|
299
377
|
`extra files: ${config.extraFiles.join(", ") || "none"}`,
|
|
300
378
|
`sessions: ${config.syncSessions ? "included" : "excluded"}`,
|
|
@@ -312,21 +390,27 @@ async function diff(ctx: ExtensionCommandContext) {
|
|
|
312
390
|
ctx.ui.notify(`${header}\n\n${formatDiff(local, remote)}`, level);
|
|
313
391
|
}
|
|
314
392
|
|
|
315
|
-
async function doctor(ctx: ExtensionCommandContext) {
|
|
393
|
+
async function doctor(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
316
394
|
const messages: string[] = [];
|
|
317
395
|
let level: "info" | "warning" = "info";
|
|
318
396
|
let snapshotOptions: SnapshotOptions = {};
|
|
319
397
|
let profile = DEFAULT_PROFILE;
|
|
320
398
|
|
|
321
399
|
try {
|
|
322
|
-
const config = await loadConfig();
|
|
400
|
+
const config = await loadConfig(options.target);
|
|
323
401
|
profile = config.profile;
|
|
324
402
|
snapshotOptions = snapshotOptionsForContext(ctx, config);
|
|
325
|
-
messages.push(
|
|
403
|
+
messages.push(
|
|
404
|
+
`config: ok (target ${config.target ?? "default"}; ${config.bucket}/${profilePrefix(config)})`,
|
|
405
|
+
);
|
|
326
406
|
messages.push(`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`);
|
|
327
407
|
messages.push(`extra files: ${config.extraFiles.join(", ") || "none"}`);
|
|
328
408
|
messages.push(`sessions: ${config.syncSessions ? "included" : "excluded"}`);
|
|
329
|
-
const warnings = [
|
|
409
|
+
const warnings = [
|
|
410
|
+
...deprecatedPiSyncEnvironmentWarnings(),
|
|
411
|
+
...sessionTokenWarnings(config),
|
|
412
|
+
...syncSessionsWarnings(config),
|
|
413
|
+
];
|
|
330
414
|
if (warnings.length > 0) {
|
|
331
415
|
level = "warning";
|
|
332
416
|
messages.push(...warnings);
|
|
@@ -373,10 +457,10 @@ async function push(
|
|
|
373
457
|
options: CommandOptions,
|
|
374
458
|
input?: PushInput,
|
|
375
459
|
) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const client = input?.client ?? new S3Client(config);
|
|
379
|
-
const state = input?.state ?? (await
|
|
460
|
+
const config = input?.config ?? (await loadConfig(options.target));
|
|
461
|
+
ctx.ui.setStatus(STATUS_KEY, `pushing ${config.target ?? "default"}`);
|
|
462
|
+
const client = input?.client ?? new S3Client(config, options.signal);
|
|
463
|
+
const state = input?.state ?? (await readStateForConfig(config));
|
|
380
464
|
const local =
|
|
381
465
|
input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
|
|
382
466
|
|
|
@@ -406,7 +490,7 @@ async function push(
|
|
|
406
490
|
!options.yes &&
|
|
407
491
|
!(await ctx.ui.confirm(
|
|
408
492
|
snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
|
|
409
|
-
formatPushSummary(upload, latest, preservedRemoteFileCount),
|
|
493
|
+
formatPushSummary(config, upload, latest, preservedRemoteFileCount, remoteForUpload),
|
|
410
494
|
))
|
|
411
495
|
) {
|
|
412
496
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -414,9 +498,11 @@ async function push(
|
|
|
414
498
|
return;
|
|
415
499
|
}
|
|
416
500
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
await
|
|
501
|
+
options.onCommit?.();
|
|
502
|
+
const publicationClient = options.onCommit ? new S3Client(config) : client;
|
|
503
|
+
const pointer = await uploadSnapshot(publicationClient, config, upload, latest, options.force);
|
|
504
|
+
const historyWarning = await updateHistorySafely(publicationClient, config, pointer);
|
|
505
|
+
await writeStateForConfig(config, {
|
|
420
506
|
version: VERSION,
|
|
421
507
|
profile: config.profile,
|
|
422
508
|
lastAppliedSnapshot: pointer.snapshot,
|
|
@@ -428,15 +514,23 @@ async function push(
|
|
|
428
514
|
});
|
|
429
515
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
430
516
|
if (!options.silent) {
|
|
431
|
-
ctx.ui.notify(
|
|
517
|
+
ctx.ui.notify(
|
|
518
|
+
[
|
|
519
|
+
`Pushed ${upload.files.length} files from target “${config.target ?? "default"}” as ${pointer.snapshot}.`,
|
|
520
|
+
historyWarning,
|
|
521
|
+
]
|
|
522
|
+
.filter(Boolean)
|
|
523
|
+
.join("\n"),
|
|
524
|
+
historyWarning ? "warning" : "info",
|
|
525
|
+
);
|
|
432
526
|
}
|
|
433
527
|
}
|
|
434
528
|
|
|
435
529
|
async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const client = new S3Client(config);
|
|
439
|
-
const state = await
|
|
530
|
+
const config = await loadConfig(options.target);
|
|
531
|
+
ctx.ui.setStatus(STATUS_KEY, `pulling ${config.target ?? "default"}`);
|
|
532
|
+
const client = new S3Client(config, options.signal);
|
|
533
|
+
const state = await readStateForConfig(config);
|
|
440
534
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
441
535
|
const remote = await readRemoteSnapshot(client, config);
|
|
442
536
|
if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
|
|
@@ -453,7 +547,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
453
547
|
!options.yes &&
|
|
454
548
|
!(await ctx.ui.confirm(
|
|
455
549
|
snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
|
|
456
|
-
|
|
550
|
+
formatPullSummary(config, local, remote, protectedSessionPaths(ctx).size),
|
|
457
551
|
))
|
|
458
552
|
) {
|
|
459
553
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -461,6 +555,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
461
555
|
return;
|
|
462
556
|
}
|
|
463
557
|
|
|
558
|
+
options.onCommit?.();
|
|
464
559
|
const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
|
|
465
560
|
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
466
561
|
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
|
|
@@ -468,7 +563,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
468
563
|
sessionDir: applySessionDir,
|
|
469
564
|
extraFiles: config.extraFiles,
|
|
470
565
|
});
|
|
471
|
-
await
|
|
566
|
+
await writeStateForConfig(config, {
|
|
472
567
|
version: VERSION,
|
|
473
568
|
profile: config.profile,
|
|
474
569
|
lastAppliedSnapshot: remote.id,
|
|
@@ -494,10 +589,23 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
494
589
|
}
|
|
495
590
|
|
|
496
591
|
async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
|
|
497
|
-
const config = await loadConfig();
|
|
498
|
-
const client = new S3Client(config);
|
|
499
|
-
const state = await
|
|
592
|
+
const config = await loadConfig(options.target);
|
|
593
|
+
const client = new S3Client(config, options.signal);
|
|
594
|
+
const state = await readStateForConfig(config);
|
|
500
595
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
596
|
+
if (
|
|
597
|
+
normalizeSyncFiles(config.syncFiles).length === 0 &&
|
|
598
|
+
config.extraFiles.length === 0 &&
|
|
599
|
+
!config.syncSessions
|
|
600
|
+
) {
|
|
601
|
+
if (!options.silent) {
|
|
602
|
+
ctx.ui.notify(
|
|
603
|
+
`Target “${config.target ?? "default"}” manages no files. Choose synced content in /sync Settings before syncing.`,
|
|
604
|
+
"warning",
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
501
609
|
const remote = await readRemoteSnapshot(client, config);
|
|
502
610
|
const localChanged = hasLocalChanges(local, state, config);
|
|
503
611
|
const remoteChanged = remote
|
|
@@ -520,7 +628,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
520
628
|
await pull(ctx, options);
|
|
521
629
|
return;
|
|
522
630
|
}
|
|
523
|
-
await
|
|
631
|
+
await writeStateForConfig(config, {
|
|
524
632
|
version: VERSION,
|
|
525
633
|
profile: config.profile,
|
|
526
634
|
lastAppliedSnapshot: remote.id,
|
|
@@ -535,7 +643,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
535
643
|
return;
|
|
536
644
|
}
|
|
537
645
|
if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
|
|
538
|
-
await
|
|
646
|
+
await writeStateForConfig(config, {
|
|
539
647
|
version: VERSION,
|
|
540
648
|
profile: config.profile,
|
|
541
649
|
lastAppliedSnapshot: remote.id,
|
|
@@ -562,7 +670,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
562
670
|
return;
|
|
563
671
|
}
|
|
564
672
|
if (shouldRefreshSyncedState(remote, state, config)) {
|
|
565
|
-
await
|
|
673
|
+
await writeStateForConfig(config, {
|
|
566
674
|
version: VERSION,
|
|
567
675
|
profile: config.profile,
|
|
568
676
|
lastAppliedSnapshot: remote.id,
|
|
@@ -576,20 +684,38 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
576
684
|
if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
|
|
577
685
|
}
|
|
578
686
|
|
|
579
|
-
async function history(ctx: ExtensionCommandContext) {
|
|
580
|
-
const config = await loadConfig();
|
|
581
|
-
const client = new S3Client(config);
|
|
687
|
+
async function history(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
688
|
+
const config = await loadConfig(options.target);
|
|
689
|
+
const client = new S3Client(config, options.signal);
|
|
582
690
|
const remote = await client.getJson<{ snapshots?: LatestPointer[] }>(historyKey(config));
|
|
583
691
|
if (remote.missing || !remote.value?.snapshots?.length) {
|
|
584
692
|
ctx.ui.notify("No remote pi-sync history found.", "info");
|
|
585
693
|
return;
|
|
586
694
|
}
|
|
587
695
|
|
|
696
|
+
const snapshots = remote.value.snapshots.slice(-20).reverse();
|
|
697
|
+
const currentSnapshot = snapshots[0]?.snapshot;
|
|
698
|
+
if (ctx.mode === "tui") {
|
|
699
|
+
const labels = snapshots.map(
|
|
700
|
+
(item, index) =>
|
|
701
|
+
`${index + 1}. ${item.createdAt} · ${safeTerminalText(item.machine)} · ${item.snapshot}${item.snapshot === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " · sessions" : ""}`,
|
|
702
|
+
);
|
|
703
|
+
const selected = await ctx.ui.select(
|
|
704
|
+
`History for target “${safeTerminalText(config.target ?? "default")}”\n\nChoose a snapshot to preview rollback.`,
|
|
705
|
+
[...labels, "Back"],
|
|
706
|
+
);
|
|
707
|
+
if (!selected || selected === "Back") return;
|
|
708
|
+
const index = labels.indexOf(selected);
|
|
709
|
+
const snapshot = snapshots[index];
|
|
710
|
+
if (!snapshot) return;
|
|
711
|
+
await withLock("rollback", () =>
|
|
712
|
+
rollback(ctx, { ...options, args: [snapshot.snapshot], yes: false }),
|
|
713
|
+
);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
588
716
|
ctx.ui.notify(
|
|
589
|
-
|
|
590
|
-
.
|
|
591
|
-
.reverse()
|
|
592
|
-
.map((item) => `${item.snapshot} ${item.createdAt} ${item.machine}`)
|
|
717
|
+
snapshots
|
|
718
|
+
.map((item) => `${item.snapshot} ${item.createdAt} ${safeTerminalText(item.machine)}`)
|
|
593
719
|
.join("\n"),
|
|
594
720
|
"info",
|
|
595
721
|
);
|
|
@@ -599,8 +725,8 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
599
725
|
const target = options.args[0];
|
|
600
726
|
if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
|
|
601
727
|
|
|
602
|
-
const config = await loadConfig();
|
|
603
|
-
const client = new S3Client(config);
|
|
728
|
+
const config = await loadConfig(options.target);
|
|
729
|
+
const client = new S3Client(config, options.signal);
|
|
604
730
|
const snapshot = await client.getBuffer(snapshotKey(config, target));
|
|
605
731
|
if (!snapshot.value) throw new Error(`Snapshot not found: ${target}`);
|
|
606
732
|
const decoded = await decodeSnapshot(snapshot.value);
|
|
@@ -609,6 +735,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
609
735
|
config,
|
|
610
736
|
{ regenerateId: true },
|
|
611
737
|
);
|
|
738
|
+
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
612
739
|
|
|
613
740
|
if (
|
|
614
741
|
!options.yes &&
|
|
@@ -616,13 +743,14 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
616
743
|
snapshotIncludesSessions(remote)
|
|
617
744
|
? "Rollback pi settings and sessions?"
|
|
618
745
|
: "Rollback pi settings?",
|
|
619
|
-
|
|
746
|
+
formatRollbackSummary(config, local, remote, target, protectedSessionPaths(ctx).size),
|
|
620
747
|
))
|
|
621
748
|
) {
|
|
622
749
|
ctx.ui.notify("Rollback cancelled.", "info");
|
|
623
750
|
return;
|
|
624
751
|
}
|
|
625
752
|
|
|
753
|
+
options.onCommit?.();
|
|
626
754
|
const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
|
|
627
755
|
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
628
756
|
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
|
|
@@ -630,18 +758,19 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
630
758
|
sessionDir: applySessionDir,
|
|
631
759
|
extraFiles: config.extraFiles,
|
|
632
760
|
});
|
|
633
|
-
const
|
|
634
|
-
const
|
|
761
|
+
const publicationClient = options.onCommit ? new S3Client(config) : client;
|
|
762
|
+
const latest = await publicationClient.getJson<LatestPointer>(latestKey(config));
|
|
763
|
+
const upload = await snapshotForUpload(publicationClient, config, remote, latest, undefined, {
|
|
635
764
|
ignoreUnreadableRemote: true,
|
|
636
765
|
});
|
|
637
766
|
const encoded = upload.id === decoded.id ? snapshot.value : await encodeSnapshot(upload);
|
|
638
767
|
if (upload.id !== decoded.id) {
|
|
639
|
-
await
|
|
768
|
+
await publicationClient.putBuffer(snapshotKey(config, upload.id), encoded, "application/gzip");
|
|
640
769
|
}
|
|
641
770
|
const pointer = pointerFor(config, upload, sha256(encoded));
|
|
642
|
-
await
|
|
643
|
-
await
|
|
644
|
-
await
|
|
771
|
+
await publicationClient.putJson(latestKey(config), pointer);
|
|
772
|
+
const historyWarning = await updateHistorySafely(publicationClient, config, pointer);
|
|
773
|
+
await writeStateForConfig(config, {
|
|
645
774
|
version: VERSION,
|
|
646
775
|
profile: config.profile,
|
|
647
776
|
lastAppliedSnapshot: pointer.snapshot,
|
|
@@ -651,7 +780,15 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
651
780
|
syncSessions: config.syncSessions,
|
|
652
781
|
extraFiles: config.extraFiles,
|
|
653
782
|
});
|
|
654
|
-
ctx.ui.notify(
|
|
783
|
+
ctx.ui.notify(
|
|
784
|
+
[
|
|
785
|
+
`Rolled back target “${config.target ?? "default"}” to ${target}; latest: ${pointer.snapshot}. Backup: ${backup}`,
|
|
786
|
+
historyWarning,
|
|
787
|
+
]
|
|
788
|
+
.filter(Boolean)
|
|
789
|
+
.join("\n"),
|
|
790
|
+
historyWarning ? "warning" : "info",
|
|
791
|
+
);
|
|
655
792
|
await maybeReload(ctx);
|
|
656
793
|
}
|
|
657
794
|
|
|
@@ -802,6 +939,15 @@ export async function backupLocal(profile: string, options: SnapshotOptions = {}
|
|
|
802
939
|
return backupPath;
|
|
803
940
|
}
|
|
804
941
|
|
|
942
|
+
async function updateHistorySafely(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
|
|
943
|
+
try {
|
|
944
|
+
await updateHistory(client, config, pointer);
|
|
945
|
+
return undefined;
|
|
946
|
+
} catch (error) {
|
|
947
|
+
return `Remote snapshot is active, but history could not be updated: ${errorMessage(error)}. Run /sync doctor before relying on history.`;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
805
951
|
async function updateHistory(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
|
|
806
952
|
const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
|
|
807
953
|
historyKey(config),
|
|
@@ -814,71 +960,6 @@ async function updateHistory(client: S3Client, config: SyncConfig, pointer: Late
|
|
|
814
960
|
await client.putJson(historyKey(config), { version: VERSION, snapshots: next });
|
|
815
961
|
}
|
|
816
962
|
|
|
817
|
-
function formatDiff(local: Snapshot, remote: Snapshot) {
|
|
818
|
-
const localMap = fileHashMap(local);
|
|
819
|
-
const remoteMap = fileHashMap(remote);
|
|
820
|
-
const allPaths = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
|
|
821
|
-
const lines = [
|
|
822
|
-
`local: ${local.files.length} files`,
|
|
823
|
-
`remote: ${remote.id} (${remote.files.length} files)`,
|
|
824
|
-
"",
|
|
825
|
-
];
|
|
826
|
-
let changed = 0;
|
|
827
|
-
for (const filePath of allPaths) {
|
|
828
|
-
if (!localMap[filePath]) {
|
|
829
|
-
lines.push(`+ ${filePath}`);
|
|
830
|
-
changed += 1;
|
|
831
|
-
} else if (!remoteMap[filePath]) {
|
|
832
|
-
lines.push(`- ${filePath}`);
|
|
833
|
-
changed += 1;
|
|
834
|
-
} else if (localMap[filePath] !== remoteMap[filePath]) {
|
|
835
|
-
lines.push(`~ ${filePath}`);
|
|
836
|
-
changed += 1;
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
if (changed === 0) lines.push("No file differences.");
|
|
840
|
-
return lines.join("\n");
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function formatSnapshotOnlyDiff(title: string, snapshot: Snapshot) {
|
|
844
|
-
return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `+ ${file.path}`)].join("\n");
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function formatPushSummary(
|
|
848
|
-
local: Snapshot,
|
|
849
|
-
latest: RemoteObject<LatestPointer>,
|
|
850
|
-
preservedRemoteFileCount = 0,
|
|
851
|
-
) {
|
|
852
|
-
return [
|
|
853
|
-
`Upload ${local.files.length} files from ${agentDir()}.`,
|
|
854
|
-
latest.value ? `Remote latest: ${latest.value.snapshot}` : "Remote latest: empty",
|
|
855
|
-
preservedRemoteFileCount > 0
|
|
856
|
-
? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.`
|
|
857
|
-
: "Possible secrets were scanned before this prompt.",
|
|
858
|
-
].join("\n");
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
function remoteIdentity(remote: RemoteObject<LatestPointer>) {
|
|
862
|
-
return remote.missing ? "missing" : (remote.value?.snapshot ?? "unknown");
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
function countPreservedRemoteFiles(local: Snapshot, upload: Snapshot) {
|
|
866
|
-
const localPaths = new Set(local.files.map((file) => file.path));
|
|
867
|
-
return upload.files.filter((file) => !localPaths.has(file.path)).length;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
function sha256(value: Buffer) {
|
|
871
|
-
return createHash("sha256").update(value).digest("hex");
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
function redact(value: string) {
|
|
875
|
-
return value.length <= 8 ? "configured" : `${value.slice(0, 4)}…${value.slice(-4)}`;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
function errorMessage(error: unknown) {
|
|
879
|
-
return error instanceof Error ? error.message : String(error);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
963
|
export { completeSyncArguments, parseOptions, splitArgs } from "./command.js";
|
|
883
964
|
export {
|
|
884
965
|
isCloudflareR2Endpoint,
|