@narumitw/pi-sync 0.25.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 +238 -121
- package/package.json +7 -2
- package/src/command.ts +162 -24
- package/src/config.ts +395 -89
- package/src/file-selection.ts +280 -0
- package/src/index.ts +1 -0
- package/src/lock.ts +3 -3
- 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 +41 -17
- package/src/snapshot-transaction.ts +201 -0
- package/src/snapshot.ts +76 -95
- package/src/sync-format.ts +168 -0
- package/src/sync-policy.ts +129 -0
- package/src/sync-state.ts +167 -0
- package/src/sync.ts +357 -351
- package/src/types.ts +47 -0
package/src/sync.ts
CHANGED
|
@@ -1,35 +1,45 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { constants as fsConstants } from "node:fs";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
|
-
import os from "node:os";
|
|
5
3
|
import path from "node:path";
|
|
6
|
-
import { gunzip, gzip } from "node:zlib";
|
|
7
4
|
import { promisify } from "node:util";
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
5
|
+
import { gunzip, gzip } from "node:zlib";
|
|
6
|
+
import type {
|
|
7
|
+
ExtensionAPI,
|
|
8
|
+
ExtensionCommandContext,
|
|
9
|
+
ExtensionContext,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import {
|
|
12
|
+
completeSyncArguments,
|
|
13
|
+
parseOptions,
|
|
14
|
+
resolveSyncCommand,
|
|
15
|
+
setSyncTargetCompletions,
|
|
16
|
+
usage,
|
|
17
|
+
validateCommandOptions,
|
|
18
|
+
} from "./command.js";
|
|
10
19
|
import {
|
|
11
|
-
|
|
20
|
+
configuredTargetNames,
|
|
21
|
+
deprecatedPiSyncEnvironmentWarnings,
|
|
12
22
|
ensureStateDir,
|
|
13
|
-
|
|
23
|
+
isEnabled,
|
|
24
|
+
isExplicitlyEnabled,
|
|
14
25
|
isMissingConfigError,
|
|
26
|
+
loadConfig,
|
|
15
27
|
loadPartialConfig,
|
|
16
28
|
localConfigPath,
|
|
29
|
+
localConfigTemplate,
|
|
17
30
|
normalizeExtraFiles,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
configuredSessionDir,
|
|
21
|
-
isCloudflareR2Endpoint,
|
|
22
|
-
isEnabled,
|
|
23
|
-
isExplicitlyEnabled,
|
|
24
|
-
loadConfig,
|
|
25
|
-
readState,
|
|
31
|
+
normalizeSyncFiles,
|
|
32
|
+
readStateForConfig,
|
|
26
33
|
sessionDirForApply,
|
|
27
34
|
sessionTokenWarnings,
|
|
35
|
+
stateDir,
|
|
28
36
|
syncSessionsWarnings,
|
|
29
|
-
|
|
37
|
+
writeLocalConfigObject,
|
|
38
|
+
writeStateForConfig,
|
|
30
39
|
} from "./config.js";
|
|
40
|
+
import { showFileSelection } from "./file-selection.js";
|
|
31
41
|
import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
|
|
32
|
-
import {
|
|
42
|
+
import { showSetupWizard, showSyncManager, useSyncTarget } from "./manager-ui.js";
|
|
33
43
|
import {
|
|
34
44
|
historyKey,
|
|
35
45
|
latestKey,
|
|
@@ -39,27 +49,43 @@ import {
|
|
|
39
49
|
snapshotKey,
|
|
40
50
|
} from "./s3-client.js";
|
|
41
51
|
import {
|
|
42
|
-
addTopLevelCaseVariantDeletes,
|
|
43
|
-
appliedFileHashMap,
|
|
44
|
-
applySnapshot,
|
|
45
|
-
preflightSnapshotApply,
|
|
46
|
-
protectSnapshotApplyPlan,
|
|
47
|
-
} from "./snapshot-apply.js";
|
|
48
|
-
import {
|
|
49
|
-
collectFiles,
|
|
50
52
|
createSnapshot,
|
|
51
|
-
canonicalSnapshotPathForConfig,
|
|
52
53
|
filterSnapshotForConfigPolicy,
|
|
53
|
-
isConfiguredSnapshotPath,
|
|
54
|
-
isDeniedPath,
|
|
55
|
-
isSessionPath,
|
|
56
|
-
sessionSnapshotPathFromAbsolute,
|
|
57
54
|
mergeRemotePreservedFiles,
|
|
58
|
-
mergeRemoteSessionFiles,
|
|
59
55
|
scanSnapshot,
|
|
56
|
+
sessionSnapshotPathFromAbsolute,
|
|
60
57
|
snapshotIncludesSessions,
|
|
61
58
|
snapshotWithoutSessions,
|
|
62
59
|
} from "./snapshot.js";
|
|
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";
|
|
76
|
+
import {
|
|
77
|
+
canPullRemoteSessionsOnFirstSync,
|
|
78
|
+
canPullRemoteSettingsOnFirstSync,
|
|
79
|
+
fileHashMap,
|
|
80
|
+
hasLocalChanges,
|
|
81
|
+
hasRemoteChanges,
|
|
82
|
+
remoteChangedSinceState,
|
|
83
|
+
sameHashes,
|
|
84
|
+
shouldRefreshSyncedState,
|
|
85
|
+
snapshotHashesMatchState,
|
|
86
|
+
snapshotsMatch,
|
|
87
|
+
syncPolicyChanged,
|
|
88
|
+
} from "./sync-state.js";
|
|
63
89
|
import type {
|
|
64
90
|
CommandOptions,
|
|
65
91
|
LatestPointer,
|
|
@@ -73,12 +99,11 @@ import type {
|
|
|
73
99
|
const gzipAsync = promisify(gzip);
|
|
74
100
|
const gunzipAsync = promisify(gunzip);
|
|
75
101
|
|
|
76
|
-
const STATUS_KEY = "
|
|
102
|
+
const STATUS_KEY = "sync";
|
|
77
103
|
const VERSION = 1;
|
|
78
104
|
const DEFAULT_PROFILE = "default";
|
|
79
105
|
const DEFAULT_PREFIX = "pi-sync";
|
|
80
106
|
const DEFAULT_REGION = "auto";
|
|
81
|
-
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
82
107
|
|
|
83
108
|
interface PushInput {
|
|
84
109
|
config: SyncConfig;
|
|
@@ -97,7 +122,7 @@ const AUTO_SYNC_OPTIONS: CommandOptions = {
|
|
|
97
122
|
args: [],
|
|
98
123
|
};
|
|
99
124
|
export default function sync(pi: ExtensionAPI) {
|
|
100
|
-
pi.registerCommand("
|
|
125
|
+
pi.registerCommand("sync", {
|
|
101
126
|
description: "Sync Pi settings through Cloudflare R2 or S3-compatible storage",
|
|
102
127
|
getArgumentCompletions: completeSyncArguments,
|
|
103
128
|
handler: async (args, ctx) => {
|
|
@@ -107,41 +132,84 @@ export default function sync(pi: ExtensionAPI) {
|
|
|
107
132
|
|
|
108
133
|
pi.on("session_start", async (_event, ctx) => {
|
|
109
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");
|
|
110
148
|
await autoSync(ctx);
|
|
111
149
|
});
|
|
112
150
|
|
|
113
151
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
114
|
-
const reason =
|
|
152
|
+
const reason =
|
|
153
|
+
typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
|
|
115
154
|
if (reason !== "reload") await autoPushSessions(ctx);
|
|
116
155
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
117
156
|
});
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
}
|
|
123
173
|
|
|
174
|
+
async function executeCommand(
|
|
175
|
+
rawArgs: string,
|
|
176
|
+
ctx: ExtensionCommandContext,
|
|
177
|
+
signal?: AbortSignal,
|
|
178
|
+
onCommit?: () => void,
|
|
179
|
+
) {
|
|
124
180
|
try {
|
|
125
|
-
await
|
|
181
|
+
const command = await resolveSyncCommand(rawArgs, ctx);
|
|
182
|
+
if (!command) return;
|
|
183
|
+
const { subcommand, rest } = command;
|
|
184
|
+
const options = parseOptions(rest);
|
|
185
|
+
if (signal) options.signal = signal;
|
|
186
|
+
if (onCommit) options.onCommit = onCommit;
|
|
187
|
+
validateCommandOptions(subcommand, options);
|
|
126
188
|
|
|
127
189
|
switch (subcommand) {
|
|
128
190
|
case "help":
|
|
129
191
|
ctx.ui.notify(usage(), "info");
|
|
130
192
|
return;
|
|
193
|
+
case "use":
|
|
194
|
+
await useSyncTarget(ctx, options.args[0] ?? "");
|
|
195
|
+
return;
|
|
131
196
|
case "init":
|
|
132
197
|
await initConfig(ctx);
|
|
133
198
|
return;
|
|
134
199
|
case "config":
|
|
135
|
-
await showConfig(ctx);
|
|
200
|
+
await showConfig(ctx, options);
|
|
201
|
+
return;
|
|
202
|
+
case "files":
|
|
203
|
+
await showFileSelection(ctx, options.target);
|
|
136
204
|
return;
|
|
137
205
|
case "status":
|
|
138
|
-
await status(ctx);
|
|
206
|
+
await status(ctx, options);
|
|
139
207
|
return;
|
|
140
208
|
case "diff":
|
|
141
|
-
await diff(ctx);
|
|
209
|
+
await diff(ctx, options);
|
|
142
210
|
return;
|
|
143
211
|
case "doctor":
|
|
144
|
-
await doctor(ctx);
|
|
212
|
+
await doctor(ctx, options);
|
|
145
213
|
return;
|
|
146
214
|
case "push":
|
|
147
215
|
await withLock("push", () => push(ctx, options));
|
|
@@ -153,7 +221,7 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
|
153
221
|
await withLock("sync", () => syncBoth(ctx, options));
|
|
154
222
|
return;
|
|
155
223
|
case "history":
|
|
156
|
-
await history(ctx);
|
|
224
|
+
await history(ctx, options);
|
|
157
225
|
return;
|
|
158
226
|
case "rollback":
|
|
159
227
|
await withLock("rollback", () => rollback(ctx, options));
|
|
@@ -162,10 +230,11 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
|
162
230
|
await unlock(ctx, options);
|
|
163
231
|
return;
|
|
164
232
|
default:
|
|
165
|
-
ctx.ui.notify(`Unknown /
|
|
233
|
+
ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
|
|
166
234
|
}
|
|
167
235
|
} catch (error) {
|
|
168
236
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
237
|
+
if (signal?.aborted && error instanceof Error && error.name === "AbortError") return;
|
|
169
238
|
ctx.ui.notify(errorMessage(error), "error");
|
|
170
239
|
}
|
|
171
240
|
}
|
|
@@ -193,7 +262,7 @@ async function autoPushSessions(ctx: ExtensionContext) {
|
|
|
193
262
|
const config = await loadConfig();
|
|
194
263
|
if (!config.syncSessions) return;
|
|
195
264
|
await withLock("auto-session-push", async () => {
|
|
196
|
-
const state = await
|
|
265
|
+
const state = await readStateForConfig(config);
|
|
197
266
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
198
267
|
if (!hasLocalChanges(local, state, config)) return;
|
|
199
268
|
await push(ctx, AUTO_SYNC_OPTIONS, { config, state, local });
|
|
@@ -212,32 +281,30 @@ async function initConfig(ctx: ExtensionCommandContext) {
|
|
|
212
281
|
ctx.ui.notify(`Config already exists: ${configPath}`, "info");
|
|
213
282
|
return;
|
|
214
283
|
} catch {
|
|
215
|
-
// Create below.
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
profile: DEFAULT_PROFILE,
|
|
225
|
-
prefix: DEFAULT_PREFIX,
|
|
226
|
-
autoSync: true,
|
|
227
|
-
syncSessions: false,
|
|
228
|
-
extraFiles: [],
|
|
229
|
-
};
|
|
230
|
-
await writeJson(configPath, sample);
|
|
231
|
-
ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /pisync doctor.`, "info");
|
|
284
|
+
// Create or guide setup below.
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (ctx.mode === "tui") {
|
|
288
|
+
await showSetupWizard(ctx);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
await writeLocalConfigObject(localConfigTemplate());
|
|
292
|
+
ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /sync doctor.`, "info");
|
|
232
293
|
}
|
|
233
294
|
|
|
234
|
-
async function showConfig(ctx: ExtensionCommandContext) {
|
|
235
|
-
const partial = await loadPartialConfig();
|
|
295
|
+
async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
296
|
+
const partial = await loadPartialConfig(options.target);
|
|
236
297
|
const syncSessions = isExplicitlyEnabled(partial.syncSessions);
|
|
237
|
-
const warnings = [
|
|
298
|
+
const warnings = [
|
|
299
|
+
...deprecatedPiSyncEnvironmentWarnings(),
|
|
300
|
+
...sessionTokenWarnings(partial),
|
|
301
|
+
...syncSessionsWarnings({ syncSessions }),
|
|
302
|
+
];
|
|
238
303
|
ctx.ui.notify(
|
|
239
304
|
[
|
|
240
305
|
"pi-sync config:",
|
|
306
|
+
`target: ${partial.target ?? "default"}`,
|
|
307
|
+
`storage profile: ${partial.storageProfile ?? "default"}`,
|
|
241
308
|
`endpoint: ${partial.endpoint ?? "missing"}`,
|
|
242
309
|
`bucket: ${partial.bucket ?? "missing"}`,
|
|
243
310
|
`region: ${partial.region ?? DEFAULT_REGION}`,
|
|
@@ -247,6 +314,7 @@ async function showConfig(ctx: ExtensionCommandContext) {
|
|
|
247
314
|
`profile: ${partial.profile ?? DEFAULT_PROFILE}`,
|
|
248
315
|
`prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
|
|
249
316
|
`autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
|
|
317
|
+
`syncFiles: ${normalizeSyncFiles(partial.syncFiles).join(", ") || "none"}`,
|
|
250
318
|
`syncSessions: ${syncSessions ? "enabled" : "disabled"}`,
|
|
251
319
|
`extraFiles: ${normalizeExtraFiles(partial.extraFiles).join(", ") || "none"}`,
|
|
252
320
|
`local config: ${localConfigPath()}`,
|
|
@@ -256,26 +324,31 @@ async function showConfig(ctx: ExtensionCommandContext) {
|
|
|
256
324
|
);
|
|
257
325
|
}
|
|
258
326
|
|
|
259
|
-
async function status(ctx: ExtensionCommandContext) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
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);
|
|
263
331
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
264
|
-
const state = await
|
|
332
|
+
const state = await readStateForConfig(config);
|
|
265
333
|
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
266
334
|
const localChanged = hasLocalChanges(local, state, config);
|
|
267
335
|
|
|
268
336
|
let remoteText = "remote: empty";
|
|
269
|
-
|
|
337
|
+
const remoteChanged = remoteChangedSinceState(latest, state, config);
|
|
270
338
|
if (!latest.missing && latest.value) {
|
|
271
339
|
remoteText = `remote: ${latest.value.snapshot} from ${latest.value.machine} at ${latest.value.createdAt}`;
|
|
272
340
|
}
|
|
273
341
|
|
|
274
|
-
const warnings = syncSessionsWarnings(config);
|
|
342
|
+
const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
|
|
275
343
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
276
344
|
ctx.ui.notify(
|
|
277
345
|
[
|
|
278
|
-
`
|
|
346
|
+
`target: ${config.target ?? "default"}`,
|
|
347
|
+
`storage profile: ${config.storageProfile ?? "default"}`,
|
|
348
|
+
`destination: ${formatDestination(config)}`,
|
|
349
|
+
`remote namespace: ${config.profile}`,
|
|
350
|
+
`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
|
|
351
|
+
`extra files: ${config.extraFiles.join(", ") || "none"}`,
|
|
279
352
|
`sessions: ${config.syncSessions ? "included" : "excluded"}`,
|
|
280
353
|
remoteText,
|
|
281
354
|
`local files: ${local.files.length}`,
|
|
@@ -287,18 +360,24 @@ async function status(ctx: ExtensionCommandContext) {
|
|
|
287
360
|
);
|
|
288
361
|
}
|
|
289
362
|
|
|
290
|
-
async function diff(ctx: ExtensionCommandContext) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
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);
|
|
294
367
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
295
368
|
const remote = await readRemoteSnapshot(client, config);
|
|
296
369
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
297
370
|
|
|
298
|
-
const warnings = syncSessionsWarnings(config);
|
|
299
|
-
const header = [
|
|
300
|
-
"
|
|
301
|
-
|
|
371
|
+
const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
|
|
372
|
+
const header = [
|
|
373
|
+
`target: ${config.target ?? "default"}`,
|
|
374
|
+
`storage profile: ${config.storageProfile ?? "default"}`,
|
|
375
|
+
`destination: ${formatDestination(config)}`,
|
|
376
|
+
`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
|
|
377
|
+
`extra files: ${config.extraFiles.join(", ") || "none"}`,
|
|
378
|
+
`sessions: ${config.syncSessions ? "included" : "excluded"}`,
|
|
379
|
+
...warnings,
|
|
380
|
+
].join("\n");
|
|
302
381
|
const level = warnings.length > 0 ? "warning" : "info";
|
|
303
382
|
if (!remote) {
|
|
304
383
|
ctx.ui.notify(
|
|
@@ -311,19 +390,27 @@ async function diff(ctx: ExtensionCommandContext) {
|
|
|
311
390
|
ctx.ui.notify(`${header}\n\n${formatDiff(local, remote)}`, level);
|
|
312
391
|
}
|
|
313
392
|
|
|
314
|
-
async function doctor(ctx: ExtensionCommandContext) {
|
|
393
|
+
async function doctor(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
315
394
|
const messages: string[] = [];
|
|
316
395
|
let level: "info" | "warning" = "info";
|
|
317
396
|
let snapshotOptions: SnapshotOptions = {};
|
|
318
397
|
let profile = DEFAULT_PROFILE;
|
|
319
398
|
|
|
320
399
|
try {
|
|
321
|
-
const config = await loadConfig();
|
|
400
|
+
const config = await loadConfig(options.target);
|
|
322
401
|
profile = config.profile;
|
|
323
402
|
snapshotOptions = snapshotOptionsForContext(ctx, config);
|
|
324
|
-
messages.push(
|
|
403
|
+
messages.push(
|
|
404
|
+
`config: ok (target ${config.target ?? "default"}; ${config.bucket}/${profilePrefix(config)})`,
|
|
405
|
+
);
|
|
406
|
+
messages.push(`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`);
|
|
407
|
+
messages.push(`extra files: ${config.extraFiles.join(", ") || "none"}`);
|
|
325
408
|
messages.push(`sessions: ${config.syncSessions ? "included" : "excluded"}`);
|
|
326
|
-
const warnings = [
|
|
409
|
+
const warnings = [
|
|
410
|
+
...deprecatedPiSyncEnvironmentWarnings(),
|
|
411
|
+
...sessionTokenWarnings(config),
|
|
412
|
+
...syncSessionsWarnings(config),
|
|
413
|
+
];
|
|
327
414
|
if (warnings.length > 0) {
|
|
328
415
|
level = "warning";
|
|
329
416
|
messages.push(...warnings);
|
|
@@ -347,14 +434,14 @@ async function doctor(ctx: ExtensionCommandContext) {
|
|
|
347
434
|
if (lock.status === "valid" && isStaleLock(lock.lock)) {
|
|
348
435
|
level = "warning";
|
|
349
436
|
messages.push(
|
|
350
|
-
`lock: stale (pid ${lock.lock.pid}); run /
|
|
437
|
+
`lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running`,
|
|
351
438
|
);
|
|
352
439
|
} else if (lock.status === "valid") {
|
|
353
440
|
messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
|
|
354
441
|
} else if (lock.status === "unreadable") {
|
|
355
442
|
level = "warning";
|
|
356
443
|
messages.push(
|
|
357
|
-
"lock: unreadable; use /
|
|
444
|
+
"lock: unreadable; use /sync unlock --stale only after verifying no sync is running",
|
|
358
445
|
);
|
|
359
446
|
} else if (await isLockGuardHeld()) {
|
|
360
447
|
level = "warning";
|
|
@@ -370,33 +457,32 @@ async function push(
|
|
|
370
457
|
options: CommandOptions,
|
|
371
458
|
input?: PushInput,
|
|
372
459
|
) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const client = input?.client ?? new S3Client(config);
|
|
376
|
-
const state = input?.state ?? (await
|
|
377
|
-
const local =
|
|
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));
|
|
464
|
+
const local =
|
|
465
|
+
input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
|
|
378
466
|
|
|
379
467
|
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
380
|
-
const remoteForUpload =
|
|
381
|
-
? undefined
|
|
382
|
-
: await readRemoteSnapshotForUpload(client, config, latest, state);
|
|
468
|
+
const remoteForUpload = await readRemoteSnapshotForUpload(client, config, latest, state);
|
|
383
469
|
if (remoteChangedSinceState(latest, state, config) && !options.force) {
|
|
384
470
|
const remoteForConflict = remoteForUpload
|
|
385
471
|
? filterSnapshotForConfigPolicy(remoteForUpload, config)
|
|
386
472
|
: undefined;
|
|
387
473
|
if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
|
|
388
474
|
throw new Error(
|
|
389
|
-
"Remote or sync policy changed since last sync. Run /
|
|
475
|
+
"Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
|
|
390
476
|
);
|
|
391
477
|
}
|
|
392
478
|
}
|
|
393
479
|
|
|
394
|
-
const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload
|
|
395
|
-
preserveRemote: !options.force,
|
|
396
|
-
});
|
|
480
|
+
const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload);
|
|
397
481
|
const secrets = scanSnapshot(local);
|
|
398
482
|
if (secrets.length > 0) {
|
|
399
|
-
throw new Error(
|
|
483
|
+
throw new Error(
|
|
484
|
+
`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`,
|
|
485
|
+
);
|
|
400
486
|
}
|
|
401
487
|
|
|
402
488
|
const preservedRemoteFileCount = countPreservedRemoteFiles(local, upload);
|
|
@@ -404,7 +490,7 @@ async function push(
|
|
|
404
490
|
!options.yes &&
|
|
405
491
|
!(await ctx.ui.confirm(
|
|
406
492
|
snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
|
|
407
|
-
formatPushSummary(upload, latest, preservedRemoteFileCount),
|
|
493
|
+
formatPushSummary(config, upload, latest, preservedRemoteFileCount, remoteForUpload),
|
|
408
494
|
))
|
|
409
495
|
) {
|
|
410
496
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -412,43 +498,56 @@ async function push(
|
|
|
412
498
|
return;
|
|
413
499
|
}
|
|
414
500
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
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, {
|
|
418
506
|
version: VERSION,
|
|
419
507
|
profile: config.profile,
|
|
420
508
|
lastAppliedSnapshot: pointer.snapshot,
|
|
421
509
|
lastRemoteEtag: undefined,
|
|
422
510
|
lastFileHashes: fileHashMap(local),
|
|
511
|
+
syncFiles: config.syncFiles,
|
|
423
512
|
syncSessions: config.syncSessions,
|
|
424
513
|
extraFiles: config.extraFiles,
|
|
425
514
|
});
|
|
426
515
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
427
516
|
if (!options.silent) {
|
|
428
|
-
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
|
+
);
|
|
429
526
|
}
|
|
430
527
|
}
|
|
431
528
|
|
|
432
529
|
async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const client = new S3Client(config);
|
|
436
|
-
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);
|
|
437
534
|
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
438
535
|
const remote = await readRemoteSnapshot(client, config);
|
|
439
|
-
if (!remote) throw new Error("Remote is empty. Run /
|
|
536
|
+
if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
|
|
440
537
|
|
|
441
538
|
const localChanged = hasLocalChanges(local, state, config);
|
|
442
539
|
const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
|
|
443
540
|
if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
|
|
444
|
-
throw new Error(
|
|
541
|
+
throw new Error(
|
|
542
|
+
"Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
|
|
543
|
+
);
|
|
445
544
|
}
|
|
446
545
|
|
|
447
546
|
if (
|
|
448
547
|
!options.yes &&
|
|
449
548
|
!(await ctx.ui.confirm(
|
|
450
549
|
snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
|
|
451
|
-
|
|
550
|
+
formatPullSummary(config, local, remote, protectedSessionPaths(ctx).size),
|
|
452
551
|
))
|
|
453
552
|
) {
|
|
454
553
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -456,24 +555,30 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
456
555
|
return;
|
|
457
556
|
}
|
|
458
557
|
|
|
558
|
+
options.onCommit?.();
|
|
459
559
|
const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
|
|
460
560
|
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
461
561
|
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
|
|
562
|
+
syncFiles: config.syncFiles,
|
|
462
563
|
sessionDir: applySessionDir,
|
|
463
564
|
extraFiles: config.extraFiles,
|
|
464
565
|
});
|
|
465
|
-
await
|
|
566
|
+
await writeStateForConfig(config, {
|
|
466
567
|
version: VERSION,
|
|
467
568
|
profile: config.profile,
|
|
468
569
|
lastAppliedSnapshot: remote.id,
|
|
469
570
|
lastRemoteEtag: undefined,
|
|
470
571
|
lastFileHashes,
|
|
572
|
+
syncFiles: config.syncFiles,
|
|
471
573
|
syncSessions: config.syncSessions,
|
|
472
574
|
extraFiles: config.extraFiles,
|
|
473
575
|
});
|
|
474
576
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
475
577
|
if (!options.silent) {
|
|
476
|
-
ctx.ui.notify(
|
|
578
|
+
ctx.ui.notify(
|
|
579
|
+
`Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`,
|
|
580
|
+
"info",
|
|
581
|
+
);
|
|
477
582
|
} else if (options.auto && config.syncSessions && snapshotIncludesSessions(remote)) {
|
|
478
583
|
ctx.ui.notify(
|
|
479
584
|
"Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
|
|
@@ -484,10 +589,23 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
484
589
|
}
|
|
485
590
|
|
|
486
591
|
async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
|
|
487
|
-
const config = await loadConfig();
|
|
488
|
-
const client = new S3Client(config);
|
|
489
|
-
const state = await
|
|
592
|
+
const config = await loadConfig(options.target);
|
|
593
|
+
const client = new S3Client(config, options.signal);
|
|
594
|
+
const state = await readStateForConfig(config);
|
|
490
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
|
+
}
|
|
491
609
|
const remote = await readRemoteSnapshot(client, config);
|
|
492
610
|
const localChanged = hasLocalChanges(local, state, config);
|
|
493
611
|
const remoteChanged = remote
|
|
@@ -497,34 +615,41 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
497
615
|
|
|
498
616
|
if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
|
|
499
617
|
if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
|
|
500
|
-
throw new Error(
|
|
618
|
+
throw new Error(
|
|
619
|
+
"Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
|
|
620
|
+
);
|
|
501
621
|
}
|
|
502
622
|
if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
|
|
503
623
|
if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
|
|
504
|
-
throw new Error(
|
|
624
|
+
throw new Error(
|
|
625
|
+
"Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
|
|
626
|
+
);
|
|
505
627
|
}
|
|
506
628
|
await pull(ctx, options);
|
|
507
629
|
return;
|
|
508
630
|
}
|
|
509
|
-
await
|
|
631
|
+
await writeStateForConfig(config, {
|
|
510
632
|
version: VERSION,
|
|
511
633
|
profile: config.profile,
|
|
512
634
|
lastAppliedSnapshot: remote.id,
|
|
513
635
|
lastRemoteEtag: undefined,
|
|
514
636
|
lastFileHashes: fileHashMap(remote),
|
|
637
|
+
syncFiles: config.syncFiles,
|
|
515
638
|
syncSessions: config.syncSessions,
|
|
516
639
|
extraFiles: config.extraFiles,
|
|
517
640
|
});
|
|
518
|
-
if (!options.silent)
|
|
641
|
+
if (!options.silent)
|
|
642
|
+
ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
|
|
519
643
|
return;
|
|
520
644
|
}
|
|
521
645
|
if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
|
|
522
|
-
await
|
|
646
|
+
await writeStateForConfig(config, {
|
|
523
647
|
version: VERSION,
|
|
524
648
|
profile: config.profile,
|
|
525
649
|
lastAppliedSnapshot: remote.id,
|
|
526
650
|
lastRemoteEtag: undefined,
|
|
527
651
|
lastFileHashes: fileHashMap(remote),
|
|
652
|
+
syncFiles: config.syncFiles,
|
|
528
653
|
syncSessions: config.syncSessions,
|
|
529
654
|
extraFiles: config.extraFiles,
|
|
530
655
|
});
|
|
@@ -532,7 +657,9 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
532
657
|
return;
|
|
533
658
|
}
|
|
534
659
|
if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
|
|
535
|
-
throw new Error(
|
|
660
|
+
throw new Error(
|
|
661
|
+
"Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
|
|
662
|
+
);
|
|
536
663
|
}
|
|
537
664
|
if (remoteChanged) {
|
|
538
665
|
await pull(ctx, options);
|
|
@@ -543,12 +670,13 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
543
670
|
return;
|
|
544
671
|
}
|
|
545
672
|
if (shouldRefreshSyncedState(remote, state, config)) {
|
|
546
|
-
await
|
|
673
|
+
await writeStateForConfig(config, {
|
|
547
674
|
version: VERSION,
|
|
548
675
|
profile: config.profile,
|
|
549
676
|
lastAppliedSnapshot: remote.id,
|
|
550
677
|
lastRemoteEtag: undefined,
|
|
551
678
|
lastFileHashes: fileHashMap(remote),
|
|
679
|
+
syncFiles: config.syncFiles,
|
|
552
680
|
syncSessions: config.syncSessions,
|
|
553
681
|
extraFiles: config.extraFiles,
|
|
554
682
|
});
|
|
@@ -556,20 +684,38 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
|
|
|
556
684
|
if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
|
|
557
685
|
}
|
|
558
686
|
|
|
559
|
-
async function history(ctx: ExtensionCommandContext) {
|
|
560
|
-
const config = await loadConfig();
|
|
561
|
-
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);
|
|
562
690
|
const remote = await client.getJson<{ snapshots?: LatestPointer[] }>(historyKey(config));
|
|
563
691
|
if (remote.missing || !remote.value?.snapshots?.length) {
|
|
564
692
|
ctx.ui.notify("No remote pi-sync history found.", "info");
|
|
565
693
|
return;
|
|
566
694
|
}
|
|
567
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
|
+
}
|
|
568
716
|
ctx.ui.notify(
|
|
569
|
-
|
|
570
|
-
.
|
|
571
|
-
.reverse()
|
|
572
|
-
.map((item) => `${item.snapshot} ${item.createdAt} ${item.machine}`)
|
|
717
|
+
snapshots
|
|
718
|
+
.map((item) => `${item.snapshot} ${item.createdAt} ${safeTerminalText(item.machine)}`)
|
|
573
719
|
.join("\n"),
|
|
574
720
|
"info",
|
|
575
721
|
);
|
|
@@ -577,10 +723,10 @@ async function history(ctx: ExtensionCommandContext) {
|
|
|
577
723
|
|
|
578
724
|
async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
579
725
|
const target = options.args[0];
|
|
580
|
-
if (!target) throw new Error("Usage: /
|
|
726
|
+
if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
|
|
581
727
|
|
|
582
|
-
const config = await loadConfig();
|
|
583
|
-
const client = new S3Client(config);
|
|
728
|
+
const config = await loadConfig(options.target);
|
|
729
|
+
const client = new S3Client(config, options.signal);
|
|
584
730
|
const snapshot = await client.getBuffer(snapshotKey(config, target));
|
|
585
731
|
if (!snapshot.value) throw new Error(`Snapshot not found: ${target}`);
|
|
586
732
|
const decoded = await decodeSnapshot(snapshot.value);
|
|
@@ -589,6 +735,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
589
735
|
config,
|
|
590
736
|
{ regenerateId: true },
|
|
591
737
|
);
|
|
738
|
+
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
592
739
|
|
|
593
740
|
if (
|
|
594
741
|
!options.yes &&
|
|
@@ -596,40 +743,52 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
|
596
743
|
snapshotIncludesSessions(remote)
|
|
597
744
|
? "Rollback pi settings and sessions?"
|
|
598
745
|
: "Rollback pi settings?",
|
|
599
|
-
|
|
746
|
+
formatRollbackSummary(config, local, remote, target, protectedSessionPaths(ctx).size),
|
|
600
747
|
))
|
|
601
748
|
) {
|
|
602
749
|
ctx.ui.notify("Rollback cancelled.", "info");
|
|
603
750
|
return;
|
|
604
751
|
}
|
|
605
752
|
|
|
753
|
+
options.onCommit?.();
|
|
606
754
|
const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
|
|
607
755
|
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
608
756
|
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
|
|
757
|
+
syncFiles: config.syncFiles,
|
|
609
758
|
sessionDir: applySessionDir,
|
|
610
759
|
extraFiles: config.extraFiles,
|
|
611
760
|
});
|
|
612
|
-
const
|
|
613
|
-
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, {
|
|
614
764
|
ignoreUnreadableRemote: true,
|
|
615
765
|
});
|
|
616
766
|
const encoded = upload.id === decoded.id ? snapshot.value : await encodeSnapshot(upload);
|
|
617
767
|
if (upload.id !== decoded.id) {
|
|
618
|
-
await
|
|
768
|
+
await publicationClient.putBuffer(snapshotKey(config, upload.id), encoded, "application/gzip");
|
|
619
769
|
}
|
|
620
770
|
const pointer = pointerFor(config, upload, sha256(encoded));
|
|
621
|
-
await
|
|
622
|
-
await
|
|
623
|
-
await
|
|
771
|
+
await publicationClient.putJson(latestKey(config), pointer);
|
|
772
|
+
const historyWarning = await updateHistorySafely(publicationClient, config, pointer);
|
|
773
|
+
await writeStateForConfig(config, {
|
|
624
774
|
version: VERSION,
|
|
625
775
|
profile: config.profile,
|
|
626
776
|
lastAppliedSnapshot: pointer.snapshot,
|
|
627
777
|
lastRemoteEtag: undefined,
|
|
628
778
|
lastFileHashes,
|
|
779
|
+
syncFiles: config.syncFiles,
|
|
629
780
|
syncSessions: config.syncSessions,
|
|
630
781
|
extraFiles: config.extraFiles,
|
|
631
782
|
});
|
|
632
|
-
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
|
+
);
|
|
633
792
|
await maybeReload(ctx);
|
|
634
793
|
}
|
|
635
794
|
|
|
@@ -648,6 +807,7 @@ function snapshotOptionsForContext(
|
|
|
648
807
|
config: SyncConfig,
|
|
649
808
|
): SnapshotOptions {
|
|
650
809
|
return {
|
|
810
|
+
syncFiles: config.syncFiles,
|
|
651
811
|
syncSessions: config.syncSessions,
|
|
652
812
|
sessionDir: sessionDirFromContext(ctx),
|
|
653
813
|
extraFiles: config.extraFiles,
|
|
@@ -670,7 +830,13 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
|
|
|
670
830
|
|
|
671
831
|
async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext) {
|
|
672
832
|
if (!("reload" in ctx)) return;
|
|
673
|
-
if (
|
|
833
|
+
if (
|
|
834
|
+
ctx.hasUI &&
|
|
835
|
+
(await ctx.ui.confirm(
|
|
836
|
+
"Reload Pi resources now?",
|
|
837
|
+
"This reloads extensions, skills, prompts, themes, and context files.",
|
|
838
|
+
))
|
|
839
|
+
) {
|
|
674
840
|
await ctx.reload();
|
|
675
841
|
}
|
|
676
842
|
}
|
|
@@ -697,9 +863,9 @@ async function snapshotForUpload(
|
|
|
697
863
|
local: Snapshot,
|
|
698
864
|
latest: RemoteObject<LatestPointer>,
|
|
699
865
|
remote?: Snapshot,
|
|
700
|
-
options: {
|
|
866
|
+
options: { ignoreUnreadableRemote?: boolean } = {},
|
|
701
867
|
) {
|
|
702
|
-
if (
|
|
868
|
+
if (latest.missing || !latest.value) return local;
|
|
703
869
|
let snapshot = remote;
|
|
704
870
|
if (!snapshot) {
|
|
705
871
|
try {
|
|
@@ -724,12 +890,14 @@ async function uploadSnapshot(
|
|
|
724
890
|
await client.putBuffer(snapshotKey(config, snapshot.id), encoded, "application/gzip");
|
|
725
891
|
const current = await client.getJson<LatestPointer>(latestKey(config));
|
|
726
892
|
if (!force && remoteIdentity(current) !== remoteIdentity(latest)) {
|
|
727
|
-
throw new Error("Remote changed while pushing. Run /
|
|
893
|
+
throw new Error("Remote changed while pushing. Run /sync pull first, then retry.");
|
|
728
894
|
}
|
|
729
895
|
await client.putJson(latestKey(config), pointer);
|
|
730
896
|
const verified = await client.getJson<LatestPointer>(latestKey(config));
|
|
731
897
|
if (verified.value?.snapshot !== pointer.snapshot) {
|
|
732
|
-
throw new Error(
|
|
898
|
+
throw new Error(
|
|
899
|
+
"Remote latest changed immediately after push. Run /sync status before continuing.",
|
|
900
|
+
);
|
|
733
901
|
}
|
|
734
902
|
return pointer;
|
|
735
903
|
}
|
|
@@ -743,8 +911,10 @@ async function readRemoteSnapshotRaw(client: S3Client, config: SyncConfig) {
|
|
|
743
911
|
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
744
912
|
if (latest.missing || !latest.value) return undefined;
|
|
745
913
|
const object = await client.getBuffer(snapshotKey(config, latest.value.snapshot));
|
|
746
|
-
if (!object.value)
|
|
747
|
-
|
|
914
|
+
if (!object.value)
|
|
915
|
+
throw new Error(`Remote latest points to missing snapshot: ${latest.value.snapshot}`);
|
|
916
|
+
if (sha256(object.value) !== latest.value.sha256)
|
|
917
|
+
throw new Error("Remote snapshot checksum mismatch.");
|
|
748
918
|
return decodeSnapshot(object.value);
|
|
749
919
|
}
|
|
750
920
|
|
|
@@ -755,7 +925,8 @@ async function encodeSnapshot(snapshot: Snapshot) {
|
|
|
755
925
|
async function decodeSnapshot(buffer: Buffer): Promise<Snapshot> {
|
|
756
926
|
const decoded = await gunzipAsync(buffer);
|
|
757
927
|
const parsed = JSON.parse(decoded.toString("utf8")) as Snapshot;
|
|
758
|
-
if (parsed.version !== VERSION || !Array.isArray(parsed.files))
|
|
928
|
+
if (parsed.version !== VERSION || !Array.isArray(parsed.files))
|
|
929
|
+
throw new Error("Unsupported snapshot format.");
|
|
759
930
|
return parsed;
|
|
760
931
|
}
|
|
761
932
|
|
|
@@ -768,8 +939,19 @@ export async function backupLocal(profile: string, options: SnapshotOptions = {}
|
|
|
768
939
|
return backupPath;
|
|
769
940
|
}
|
|
770
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
|
+
|
|
771
951
|
async function updateHistory(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
|
|
772
|
-
const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
|
|
952
|
+
const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
|
|
953
|
+
historyKey(config),
|
|
954
|
+
);
|
|
773
955
|
const snapshots = object.value?.snapshots ?? [];
|
|
774
956
|
const next = [
|
|
775
957
|
...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot),
|
|
@@ -778,216 +960,40 @@ async function updateHistory(client: S3Client, config: SyncConfig, pointer: Late
|
|
|
778
960
|
await client.putJson(historyKey(config), { version: VERSION, snapshots: next });
|
|
779
961
|
}
|
|
780
962
|
|
|
781
|
-
function formatDiff(local: Snapshot, remote: Snapshot) {
|
|
782
|
-
const localMap = fileHashMap(local);
|
|
783
|
-
const remoteMap = fileHashMap(remote);
|
|
784
|
-
const allPaths = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
|
|
785
|
-
const lines = [`local: ${local.files.length} files`, `remote: ${remote.id} (${remote.files.length} files)`, ""];
|
|
786
|
-
let changed = 0;
|
|
787
|
-
for (const filePath of allPaths) {
|
|
788
|
-
if (!localMap[filePath]) {
|
|
789
|
-
lines.push(`+ ${filePath}`);
|
|
790
|
-
changed += 1;
|
|
791
|
-
} else if (!remoteMap[filePath]) {
|
|
792
|
-
lines.push(`- ${filePath}`);
|
|
793
|
-
changed += 1;
|
|
794
|
-
} else if (localMap[filePath] !== remoteMap[filePath]) {
|
|
795
|
-
lines.push(`~ ${filePath}`);
|
|
796
|
-
changed += 1;
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
if (changed === 0) lines.push("No file differences.");
|
|
800
|
-
return lines.join("\n");
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
function formatSnapshotOnlyDiff(title: string, snapshot: Snapshot) {
|
|
804
|
-
return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `+ ${file.path}`)].join("\n");
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
function formatPushSummary(
|
|
808
|
-
local: Snapshot,
|
|
809
|
-
latest: RemoteObject<LatestPointer>,
|
|
810
|
-
preservedRemoteFileCount = 0,
|
|
811
|
-
) {
|
|
812
|
-
return [
|
|
813
|
-
`Upload ${local.files.length} files from ${agentDir()}.`,
|
|
814
|
-
latest.value ? `Remote latest: ${latest.value.snapshot}` : "Remote latest: empty",
|
|
815
|
-
preservedRemoteFileCount > 0
|
|
816
|
-
? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.`
|
|
817
|
-
: "Possible secrets were scanned before this prompt.",
|
|
818
|
-
].join("\n");
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
function hasLocalChanges(local: Snapshot, state: SyncState, config: SyncConfig) {
|
|
822
|
-
return !sameHashes(fileHashMap(local), stateHashMapForConfig(state, config));
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
function remoteChangedSinceState(
|
|
826
|
-
latest: RemoteObject<LatestPointer>,
|
|
827
|
-
state: SyncState,
|
|
828
|
-
config: SyncConfig,
|
|
829
|
-
) {
|
|
830
|
-
if (latest.missing) return Boolean(state.lastAppliedSnapshot);
|
|
831
|
-
if (latest.value?.snapshot !== state.lastAppliedSnapshot) return true;
|
|
832
|
-
if (extraFilesChanged(state, config)) return true;
|
|
833
|
-
return config.syncSessions && state.syncSessions !== true && latest.value?.syncSessions === true;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
export function hasRemoteChanges(
|
|
837
|
-
remote: Snapshot,
|
|
838
|
-
state: SyncState,
|
|
839
|
-
config: SyncConfig,
|
|
840
|
-
ignoredPaths = new Set<string>(),
|
|
841
|
-
) {
|
|
842
|
-
if (remote.id === state.lastAppliedSnapshot && !syncPolicyChanged(state, config)) return false;
|
|
843
|
-
return !snapshotHashesMatchState(filterSnapshotForConfigPolicy(remote, config), state, config, ignoredPaths);
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
function remoteIdentity(remote: RemoteObject<LatestPointer>) {
|
|
847
|
-
return remote.missing ? "missing" : (remote.value?.snapshot ?? "unknown");
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
function sameHashes(left: Record<string, string>, right: Record<string, string>) {
|
|
851
|
-
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
852
|
-
for (const key of keys) {
|
|
853
|
-
if (left[key] !== right[key]) return false;
|
|
854
|
-
}
|
|
855
|
-
return true;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
function fileHashMap(snapshot: Snapshot) {
|
|
859
|
-
return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
function stateHashMapForConfig(state: SyncState, config: Pick<SyncConfig, "syncSessions" | "extraFiles">) {
|
|
863
|
-
const extraFilePaths = extraFilePathsByLower(config.extraFiles);
|
|
864
|
-
const extraFiles = new Set(extraFilePaths.keys());
|
|
865
|
-
return Object.fromEntries(
|
|
866
|
-
Object.entries(state.lastFileHashes)
|
|
867
|
-
.filter(([filePath]) => isConfiguredSnapshotPath(filePath, config, extraFiles))
|
|
868
|
-
.map(([filePath, hash]) => [canonicalSnapshotPathForConfig(filePath, extraFilePaths), hash]),
|
|
869
|
-
);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function snapshotHashesMatchState(
|
|
873
|
-
snapshot: Snapshot,
|
|
874
|
-
state: SyncState,
|
|
875
|
-
config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
|
|
876
|
-
ignoredPaths = new Set<string>(),
|
|
877
|
-
) {
|
|
878
|
-
return sameHashes(
|
|
879
|
-
withoutHashPaths(fileHashMap(snapshot), ignoredPaths),
|
|
880
|
-
withoutHashPaths(stateHashMapForConfig(state, config), ignoredPaths),
|
|
881
|
-
);
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
function snapshotsMatch(left: Snapshot, right: Snapshot) {
|
|
885
|
-
return left.syncSessions === right.syncSessions && sameHashes(fileHashMap(left), fileHashMap(right));
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
function withoutHashPaths(hashes: Record<string, string>, ignoredPaths: Set<string>) {
|
|
889
|
-
if (ignoredPaths.size === 0) return hashes;
|
|
890
|
-
return Object.fromEntries(
|
|
891
|
-
Object.entries(hashes).filter(([filePath]) => !ignoredPaths.has(toPosix(filePath))),
|
|
892
|
-
);
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
function syncPolicyChanged(state: SyncState, config: Pick<SyncConfig, "syncSessions" | "extraFiles">) {
|
|
896
|
-
return (state.syncSessions ?? false) !== config.syncSessions || extraFilesChanged(state, config);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
function shouldRefreshSyncedState(
|
|
900
|
-
remote: Snapshot,
|
|
901
|
-
state: SyncState,
|
|
902
|
-
config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
|
|
903
|
-
) {
|
|
904
|
-
return remote.id !== state.lastAppliedSnapshot || syncPolicyChanged(state, config);
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
function extraFilesChanged(state: SyncState, config: Pick<SyncConfig, "extraFiles">) {
|
|
908
|
-
return !sameStringSet(normalizeExtraFiles(state.extraFiles), normalizeExtraFiles(config.extraFiles));
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
function sameStringSet(left: string[], right: string[]) {
|
|
912
|
-
const leftSet = new Set(left);
|
|
913
|
-
const rightSet = new Set(right);
|
|
914
|
-
if (leftSet.size !== rightSet.size) return false;
|
|
915
|
-
return [...leftSet].every((item) => rightSet.has(item));
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
function countPreservedRemoteFiles(local: Snapshot, upload: Snapshot) {
|
|
919
|
-
const localPaths = new Set(local.files.map((file) => file.path));
|
|
920
|
-
return upload.files.filter((file) => !localPaths.has(file.path)).length;
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
export function settingsHashMap(snapshot: Snapshot) {
|
|
924
|
-
return Object.fromEntries(
|
|
925
|
-
snapshot.files
|
|
926
|
-
.filter((file) => !isSessionPath(file.path))
|
|
927
|
-
.map((file) => [file.path, file.sha256]),
|
|
928
|
-
);
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
export function sessionHashMap(snapshot: Snapshot) {
|
|
932
|
-
return Object.fromEntries(
|
|
933
|
-
snapshot.files.filter((file) => isSessionPath(file.path)).map((file) => [file.path, file.sha256]),
|
|
934
|
-
);
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
export function settingsHashMapFromState(state: SyncState) {
|
|
938
|
-
return Object.fromEntries(
|
|
939
|
-
Object.entries(state.lastFileHashes).filter(([filePath]) => !isSessionPath(filePath)),
|
|
940
|
-
);
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
export function settingsHashesMatchState(remote: Snapshot, state: SyncState) {
|
|
944
|
-
return sameHashes(settingsHashMap(remote), settingsHashMapFromState(state));
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
export function canPullRemoteSettingsOnFirstSync(local: Snapshot, remote: Snapshot) {
|
|
948
|
-
const remoteSettings = settingsHashMap(remote);
|
|
949
|
-
return Object.entries(settingsHashMap(local)).every(
|
|
950
|
-
([filePath, hash]) => remoteSettings[filePath] === hash,
|
|
951
|
-
);
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
export function canPullRemoteSessionsOnFirstSync(local: Snapshot, remote: Snapshot) {
|
|
955
|
-
const localSessions = sessionHashMap(local);
|
|
956
|
-
const remoteSessions = sessionHashMap(remote);
|
|
957
|
-
return Object.entries(localSessions).every(([filePath, hash]) => remoteSessions[filePath] === hash);
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
function sha256(value: Buffer) {
|
|
961
|
-
return createHash("sha256").update(value).digest("hex");
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
function redact(value: string) {
|
|
965
|
-
return value.length <= 8 ? "configured" : `${value.slice(0, 4)}…${value.slice(-4)}`;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
function errorMessage(error: unknown) {
|
|
969
|
-
return error instanceof Error ? error.message : String(error);
|
|
970
|
-
}
|
|
971
|
-
|
|
972
963
|
export { completeSyncArguments, parseOptions, splitArgs } from "./command.js";
|
|
973
|
-
export { isCloudflareR2Endpoint, isEnabled, isExplicitlyEnabled, loadConfig, sessionTokenWarnings } from "./config.js";
|
|
974
|
-
export { encodeKey, posixJoin, safeJoin, safeName } from "./paths.js";
|
|
975
964
|
export {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
965
|
+
isCloudflareR2Endpoint,
|
|
966
|
+
isEnabled,
|
|
967
|
+
isExplicitlyEnabled,
|
|
968
|
+
loadConfig,
|
|
969
|
+
sessionTokenWarnings,
|
|
970
|
+
} from "./config.js";
|
|
971
|
+
export { encodeKey, posixJoin, safeJoin, safeName } from "./paths.js";
|
|
981
972
|
export {
|
|
982
|
-
collectFiles,
|
|
983
973
|
canonicalSnapshotPathForConfig,
|
|
974
|
+
collectFiles,
|
|
984
975
|
filterSnapshotForConfigPolicy,
|
|
985
976
|
isConfiguredSnapshotPath,
|
|
986
977
|
isDeniedPath,
|
|
987
978
|
isSessionPath,
|
|
988
|
-
sessionSnapshotPathFromAbsolute,
|
|
989
979
|
mergeRemotePreservedFiles,
|
|
990
980
|
mergeRemoteSessionFiles,
|
|
991
981
|
scanSnapshot,
|
|
982
|
+
sessionSnapshotPathFromAbsolute,
|
|
992
983
|
snapshotWithoutSessions,
|
|
993
984
|
} from "./snapshot.js";
|
|
985
|
+
export {
|
|
986
|
+
addTopLevelCaseVariantDeletes,
|
|
987
|
+
appliedFileHashMap,
|
|
988
|
+
preflightSnapshotApply,
|
|
989
|
+
protectSnapshotApplyPlan,
|
|
990
|
+
} from "./snapshot-apply.js";
|
|
991
|
+
export {
|
|
992
|
+
canPullRemoteSessionsOnFirstSync,
|
|
993
|
+
canPullRemoteSettingsOnFirstSync,
|
|
994
|
+
hasRemoteChanges,
|
|
995
|
+
sessionHashMap,
|
|
996
|
+
settingsHashesMatchState,
|
|
997
|
+
settingsHashMap,
|
|
998
|
+
settingsHashMapFromState,
|
|
999
|
+
} from "./sync-state.js";
|