@narumitw/pi-sync 0.30.0 → 0.34.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 +159 -236
- package/package.json +14 -6
- package/src/backend-factory.ts +20 -0
- package/src/command.ts +39 -39
- package/src/config-errors.ts +7 -0
- package/src/config-file.ts +64 -8
- package/src/config.ts +605 -476
- package/src/file-selection.ts +121 -140
- package/src/git-backend.ts +832 -0
- package/src/git-config.ts +136 -0
- package/src/git-runner.ts +276 -0
- package/src/git-storage.ts +288 -0
- package/src/git-ui.ts +307 -0
- package/src/manager-helpers.ts +64 -0
- package/src/manager-ui.ts +537 -542
- package/src/paths.ts +4 -1
- package/src/s3-backend.ts +498 -0
- package/src/s3-client.ts +147 -64
- package/src/s3-credentials-ui.ts +92 -0
- package/src/secret-input.ts +199 -0
- package/src/settings-management.ts +191 -277
- package/src/settings-ui.ts +94 -115
- package/src/setup-switch.ts +150 -0
- package/src/snapshot-apply.ts +13 -7
- package/src/snapshot-codec.ts +48 -0
- package/src/snapshot-transaction.ts +3 -1
- package/src/snapshot.ts +63 -32
- package/src/storage-connections-ui.ts +308 -0
- package/src/sync-backend.ts +97 -0
- package/src/sync-format.ts +31 -33
- package/src/sync-operations.ts +912 -0
- package/src/sync-policy.ts +137 -61
- package/src/sync-setups-ui.ts +177 -0
- package/src/sync-state.ts +48 -48
- package/src/sync.ts +180 -739
- package/src/types.ts +177 -53
- package/src/webdav-backend.ts +678 -0
- package/src/webdav-client.ts +390 -0
- package/src/webdav-config.ts +92 -0
- package/src/webdav-ui.ts +421 -0
- package/src/target-switch.ts +0 -119
package/src/sync.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { promisify } from "node:util";
|
|
4
|
-
import { gunzip, gzip } from "node:zlib";
|
|
5
1
|
import type {
|
|
6
2
|
ExtensionAPI,
|
|
7
3
|
ExtensionCommandContext,
|
|
@@ -11,107 +7,47 @@ import {
|
|
|
11
7
|
completeSyncArguments,
|
|
12
8
|
parseOptions,
|
|
13
9
|
resolveSyncCommand,
|
|
14
|
-
|
|
10
|
+
setSyncSetupCompletions,
|
|
15
11
|
usage,
|
|
16
12
|
validateCommandOptions,
|
|
17
13
|
} from "./command.js";
|
|
18
14
|
import {
|
|
19
15
|
activeLocalConfigPath,
|
|
20
|
-
|
|
16
|
+
configuredSyncSetupNames,
|
|
21
17
|
consumeLocalConfigMigrationNotice,
|
|
22
|
-
|
|
18
|
+
createLocalConfigDocument,
|
|
23
19
|
ensureStateDir,
|
|
24
|
-
isEnabled,
|
|
25
|
-
isExplicitlyEnabled,
|
|
26
20
|
isMissingConfigError,
|
|
27
21
|
loadConfig,
|
|
28
22
|
loadPartialConfig,
|
|
29
23
|
localConfigPath,
|
|
30
24
|
localConfigTemplate,
|
|
31
|
-
normalizeExtraFiles,
|
|
32
|
-
normalizeSyncFiles,
|
|
33
25
|
readLocalConfigObject,
|
|
34
26
|
readStateForConfig,
|
|
35
|
-
sessionDirForApply,
|
|
36
27
|
sessionTokenWarnings,
|
|
37
|
-
stateDir,
|
|
38
28
|
syncSessionsWarnings,
|
|
39
|
-
writeLocalConfigObject,
|
|
40
|
-
writeStateForConfig,
|
|
41
29
|
} from "./config.js";
|
|
42
30
|
import { showFileSelection } from "./file-selection.js";
|
|
43
|
-
import {
|
|
31
|
+
import { unlock, withLock } from "./lock.js";
|
|
44
32
|
import { showSetupWizard, showSyncManager } from "./manager-ui.js";
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
latestKey,
|
|
48
|
-
pointerFor,
|
|
49
|
-
profilePrefix,
|
|
50
|
-
S3Client,
|
|
51
|
-
snapshotKey,
|
|
52
|
-
} from "./s3-client.js";
|
|
53
|
-
import {
|
|
54
|
-
createSnapshot,
|
|
55
|
-
filterSnapshotForConfigPolicy,
|
|
56
|
-
mergeRemotePreservedFiles,
|
|
57
|
-
scanSnapshot,
|
|
58
|
-
sessionSnapshotPathFromAbsolute,
|
|
59
|
-
snapshotIncludesSessions,
|
|
60
|
-
snapshotWithoutSessions,
|
|
61
|
-
} from "./snapshot.js";
|
|
62
|
-
import { applySnapshot } from "./snapshot-apply.js";
|
|
33
|
+
import { SetupPullRequiresUiError, useSyncSetup } from "./setup-switch.js";
|
|
34
|
+
import { createSnapshot } from "./snapshot.js";
|
|
63
35
|
import { recoverSnapshotTransactionsOnStartup } from "./snapshot-transaction.js";
|
|
36
|
+
import { errorMessage } from "./sync-format.js";
|
|
64
37
|
import {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
sha256,
|
|
77
|
-
} from "./sync-format.js";
|
|
78
|
-
import {
|
|
79
|
-
canPullRemoteSessionsOnFirstSync,
|
|
80
|
-
canPullRemoteSettingsOnFirstSync,
|
|
81
|
-
fileHashMap,
|
|
82
|
-
hasLocalChanges,
|
|
83
|
-
hasRemoteChanges,
|
|
84
|
-
remoteChangedSinceState,
|
|
85
|
-
sameHashes,
|
|
86
|
-
shouldRefreshSyncedState,
|
|
87
|
-
snapshotHashesMatchState,
|
|
88
|
-
snapshotsMatch,
|
|
89
|
-
syncPolicyChanged,
|
|
90
|
-
} from "./sync-state.js";
|
|
91
|
-
import { TargetPullRequiresUiError, useSyncTarget } from "./target-switch.js";
|
|
92
|
-
import type {
|
|
93
|
-
CommandOptions,
|
|
94
|
-
LatestPointer,
|
|
95
|
-
RemoteObject,
|
|
96
|
-
Snapshot,
|
|
97
|
-
SnapshotOptions,
|
|
98
|
-
SyncConfig,
|
|
99
|
-
SyncState,
|
|
100
|
-
} from "./types.js";
|
|
38
|
+
diff,
|
|
39
|
+
doctor,
|
|
40
|
+
history,
|
|
41
|
+
pull,
|
|
42
|
+
push,
|
|
43
|
+
rollback,
|
|
44
|
+
status,
|
|
45
|
+
syncBoth,
|
|
46
|
+
} from "./sync-operations.js";
|
|
47
|
+
import { hasLocalChanges } from "./sync-state.js";
|
|
48
|
+
import type { AnySyncConfig, CommandOptions, SnapshotOptions } from "./types.js";
|
|
101
49
|
|
|
102
|
-
const [gzipAsync, gunzipAsync] = [promisify(gzip), promisify(gunzip)];
|
|
103
50
|
const STATUS_KEY = "sync";
|
|
104
|
-
const VERSION = 1;
|
|
105
|
-
const DEFAULT_PROFILE = "default";
|
|
106
|
-
const DEFAULT_PREFIX = "pi-sync";
|
|
107
|
-
const DEFAULT_REGION = "auto";
|
|
108
|
-
|
|
109
|
-
interface PushInput {
|
|
110
|
-
config: SyncConfig;
|
|
111
|
-
state: SyncState;
|
|
112
|
-
local: Snapshot;
|
|
113
|
-
client?: S3Client;
|
|
114
|
-
}
|
|
115
51
|
|
|
116
52
|
const AUTO_SYNC_OPTIONS: CommandOptions = {
|
|
117
53
|
yes: true,
|
|
@@ -123,55 +59,89 @@ const AUTO_SYNC_OPTIONS: CommandOptions = {
|
|
|
123
59
|
args: [],
|
|
124
60
|
};
|
|
125
61
|
export default function sync(pi: ExtensionAPI) {
|
|
62
|
+
let sessionAbort = new AbortController();
|
|
63
|
+
let shutdownAbort: AbortController | undefined;
|
|
64
|
+
|
|
126
65
|
pi.registerCommand("sync", {
|
|
127
|
-
description: "Sync Pi settings through
|
|
66
|
+
description: "Sync Pi settings through Git, WebDAV, R2, or S3-compatible storage",
|
|
128
67
|
getArgumentCompletions: completeSyncArguments,
|
|
129
68
|
handler: async (args, ctx) => {
|
|
130
|
-
|
|
69
|
+
if (!ctx.hasUI) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"/sync requires TUI or RPC mode so results and safety prompts are observable.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
await handleCommand(args, ctx, sessionAbort.signal);
|
|
131
75
|
},
|
|
132
76
|
});
|
|
133
77
|
|
|
134
78
|
pi.on("session_start", async (_event, ctx) => {
|
|
79
|
+
shutdownAbort?.abort(new DOMException("Session replaced", "AbortError"));
|
|
80
|
+
shutdownAbort = undefined;
|
|
81
|
+
sessionAbort.abort(new DOMException("Session replaced", "AbortError"));
|
|
82
|
+
sessionAbort = new AbortController();
|
|
83
|
+
const signal = sessionAbort.signal;
|
|
135
84
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
136
85
|
try {
|
|
137
86
|
await recoverSnapshotTransactionsOnStartup();
|
|
87
|
+
if (signal.aborted) return;
|
|
138
88
|
} catch (error) {
|
|
89
|
+
if (signal.aborted) return;
|
|
139
90
|
ctx.ui.notify(`pi-sync recovery required: ${errorMessage(error)}`, "error");
|
|
140
91
|
return;
|
|
141
92
|
}
|
|
142
93
|
try {
|
|
143
|
-
|
|
94
|
+
setSyncSetupCompletions(await configuredSyncSetupNames());
|
|
95
|
+
if (signal.aborted) return;
|
|
144
96
|
} catch {
|
|
145
|
-
|
|
97
|
+
if (signal.aborted) return;
|
|
98
|
+
setSyncSetupCompletions([]);
|
|
146
99
|
}
|
|
147
100
|
const migrationNotice = consumeLocalConfigMigrationNotice();
|
|
148
101
|
if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
await autoSync(ctx);
|
|
102
|
+
if (signal.aborted) return;
|
|
103
|
+
await autoSync(ctx, signal);
|
|
152
104
|
});
|
|
153
105
|
|
|
154
106
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
107
|
+
sessionAbort.abort(new DOMException("Session shut down", "AbortError"));
|
|
108
|
+
shutdownAbort?.abort(new DOMException("Session shut down again", "AbortError"));
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
shutdownAbort = controller;
|
|
111
|
+
const signal = combineSignals(controller.signal, AbortSignal.timeout(30_000));
|
|
155
112
|
const reason =
|
|
156
113
|
typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
|
|
157
|
-
|
|
114
|
+
try {
|
|
115
|
+
if (reason !== "reload") await autoPushSessions(ctx, signal);
|
|
116
|
+
} finally {
|
|
117
|
+
if (shutdownAbort === controller) shutdownAbort = undefined;
|
|
118
|
+
}
|
|
119
|
+
if (signal.aborted) return;
|
|
158
120
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
159
121
|
});
|
|
160
122
|
}
|
|
161
123
|
|
|
162
|
-
async function handleCommand(
|
|
124
|
+
async function handleCommand(
|
|
125
|
+
rawArgs: string,
|
|
126
|
+
ctx: ExtensionCommandContext,
|
|
127
|
+
sessionSignal: AbortSignal,
|
|
128
|
+
) {
|
|
163
129
|
if (!rawArgs.trim()) {
|
|
164
130
|
try {
|
|
165
|
-
await showSyncManager(
|
|
166
|
-
|
|
131
|
+
await showSyncManager(
|
|
132
|
+
ctx,
|
|
133
|
+
(route, signal, onCommit, target) =>
|
|
134
|
+
executeCommand(route, ctx, combineSignals(sessionSignal, signal), onCommit, target),
|
|
135
|
+
sessionSignal,
|
|
167
136
|
);
|
|
168
137
|
} catch (error) {
|
|
138
|
+
if (sessionSignal.aborted) return;
|
|
169
139
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
170
140
|
ctx.ui.notify(errorMessage(error), "error");
|
|
171
141
|
}
|
|
172
142
|
return;
|
|
173
143
|
}
|
|
174
|
-
await executeCommand(rawArgs, ctx);
|
|
144
|
+
await executeCommand(rawArgs, ctx, sessionSignal);
|
|
175
145
|
}
|
|
176
146
|
|
|
177
147
|
async function executeCommand(
|
|
@@ -179,14 +149,14 @@ async function executeCommand(
|
|
|
179
149
|
ctx: ExtensionCommandContext,
|
|
180
150
|
signal?: AbortSignal,
|
|
181
151
|
onCommit?: () => void,
|
|
182
|
-
|
|
152
|
+
setup?: string,
|
|
183
153
|
) {
|
|
184
154
|
try {
|
|
185
155
|
const command = await resolveSyncCommand(rawArgs, ctx);
|
|
186
|
-
if (!command) return;
|
|
156
|
+
if (signal?.aborted || !command) return;
|
|
187
157
|
const { subcommand, rest } = command;
|
|
188
158
|
const options = parseOptions(rest);
|
|
189
|
-
if (
|
|
159
|
+
if (setup !== undefined) options.setup = setup;
|
|
190
160
|
if (signal) options.signal = signal;
|
|
191
161
|
if (onCommit) options.onCommit = onCommit;
|
|
192
162
|
validateCommandOptions(subcommand, options);
|
|
@@ -196,18 +166,23 @@ async function executeCommand(
|
|
|
196
166
|
ctx.ui.notify(usage(), "info");
|
|
197
167
|
return;
|
|
198
168
|
case "use":
|
|
199
|
-
await
|
|
200
|
-
|
|
169
|
+
await useSyncSetup(
|
|
170
|
+
ctx,
|
|
171
|
+
options.args[0] ?? "",
|
|
172
|
+
(selectedSetup) =>
|
|
173
|
+
withLock("pull", () => pull(ctx, { ...options, setup: selectedSetup })),
|
|
174
|
+
undefined,
|
|
175
|
+
options.signal,
|
|
201
176
|
);
|
|
202
177
|
return;
|
|
203
178
|
case "init":
|
|
204
|
-
await initConfig(ctx);
|
|
179
|
+
await initConfig(ctx, signal);
|
|
205
180
|
return;
|
|
206
181
|
case "config":
|
|
207
182
|
await showConfig(ctx, options);
|
|
208
183
|
return;
|
|
209
184
|
case "files":
|
|
210
|
-
await showFileSelection(ctx, options.
|
|
185
|
+
await showFileSelection(ctx, options.setup, options.signal);
|
|
211
186
|
return;
|
|
212
187
|
case "status":
|
|
213
188
|
await status(ctx, options);
|
|
@@ -239,49 +214,64 @@ async function executeCommand(
|
|
|
239
214
|
ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
|
|
240
215
|
}
|
|
241
216
|
} catch (error) {
|
|
217
|
+
if (signal?.aborted) return;
|
|
242
218
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
243
|
-
if (
|
|
244
|
-
if (error instanceof TargetPullRequiresUiError) throw error;
|
|
219
|
+
if (error instanceof SetupPullRequiresUiError) throw error;
|
|
245
220
|
ctx.ui.notify(errorMessage(error), "error");
|
|
246
221
|
}
|
|
247
222
|
}
|
|
248
223
|
|
|
249
|
-
async function autoSync(ctx: ExtensionContext) {
|
|
224
|
+
async function autoSync(ctx: ExtensionContext, signal: AbortSignal) {
|
|
250
225
|
try {
|
|
251
226
|
const partial = await loadPartialConfig();
|
|
252
|
-
|
|
227
|
+
throwIfAborted(signal);
|
|
228
|
+
if (!partial.automatic) return;
|
|
253
229
|
await ensureStateDir();
|
|
230
|
+
throwIfAborted(signal);
|
|
254
231
|
await loadConfig();
|
|
255
|
-
|
|
232
|
+
throwIfAborted(signal);
|
|
233
|
+
await withLock("auto-sync", () => {
|
|
234
|
+
throwIfAborted(signal);
|
|
235
|
+
return syncBoth(ctx, { ...AUTO_SYNC_OPTIONS, signal });
|
|
236
|
+
});
|
|
256
237
|
} catch (error) {
|
|
257
|
-
if (isMissingConfigError(error)) return;
|
|
238
|
+
if (signal.aborted || isMissingConfigError(error)) return;
|
|
258
239
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
259
240
|
ctx.ui.notify(`pi-sync auto sync skipped: ${errorMessage(error)}`, "warning");
|
|
260
241
|
}
|
|
261
242
|
}
|
|
262
243
|
|
|
263
|
-
async function autoPushSessions(ctx: ExtensionContext) {
|
|
244
|
+
async function autoPushSessions(ctx: ExtensionContext, signal: AbortSignal) {
|
|
264
245
|
try {
|
|
265
246
|
const partial = await loadPartialConfig();
|
|
266
|
-
|
|
267
|
-
if (!
|
|
247
|
+
throwIfAborted(signal);
|
|
248
|
+
if (!partial.automatic) return;
|
|
249
|
+
if (!partial.include.includes("sessions")) return;
|
|
268
250
|
await ensureStateDir();
|
|
251
|
+
throwIfAborted(signal);
|
|
269
252
|
const config = await loadConfig();
|
|
270
|
-
|
|
253
|
+
throwIfAborted(signal);
|
|
254
|
+
if (!config.include.includes("sessions")) return;
|
|
271
255
|
await withLock("auto-session-push", async () => {
|
|
256
|
+
throwIfAborted(signal);
|
|
272
257
|
const state = await readStateForConfig(config);
|
|
273
|
-
|
|
258
|
+
throwIfAborted(signal);
|
|
259
|
+
const local = await createSnapshot(
|
|
260
|
+
config.snapshotIdentity,
|
|
261
|
+
snapshotOptionsForContext(ctx, config),
|
|
262
|
+
);
|
|
263
|
+
throwIfAborted(signal);
|
|
274
264
|
if (!hasLocalChanges(local, state, config)) return;
|
|
275
|
-
await push(ctx, AUTO_SYNC_OPTIONS, { config, state, local });
|
|
265
|
+
await push(ctx, { ...AUTO_SYNC_OPTIONS, signal }, { config, state, local });
|
|
276
266
|
});
|
|
277
267
|
} catch (error) {
|
|
278
|
-
if (isMissingConfigError(error)) return;
|
|
268
|
+
if (signal.aborted || isMissingConfigError(error)) return;
|
|
279
269
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
280
270
|
ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
|
|
281
271
|
}
|
|
282
272
|
}
|
|
283
273
|
|
|
284
|
-
async function initConfig(ctx: ExtensionCommandContext) {
|
|
274
|
+
async function initConfig(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
285
275
|
const configPath = localConfigPath();
|
|
286
276
|
if (await readLocalConfigObject()) {
|
|
287
277
|
ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
|
|
@@ -289,533 +279,115 @@ async function initConfig(ctx: ExtensionCommandContext) {
|
|
|
289
279
|
}
|
|
290
280
|
|
|
291
281
|
if (ctx.mode === "tui") {
|
|
292
|
-
await showSetupWizard(ctx);
|
|
282
|
+
await showSetupWizard(ctx, signal);
|
|
293
283
|
return;
|
|
294
284
|
}
|
|
295
|
-
await
|
|
296
|
-
ctx.ui.notify(
|
|
285
|
+
await createLocalConfigDocument(localConfigTemplate());
|
|
286
|
+
ctx.ui.notify(
|
|
287
|
+
`Created ${configPath}. Add a storage connection and sync setup before syncing.`,
|
|
288
|
+
"info",
|
|
289
|
+
);
|
|
297
290
|
}
|
|
298
291
|
|
|
299
292
|
async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
300
|
-
const
|
|
301
|
-
const syncSessions = isExplicitlyEnabled(partial.syncSessions);
|
|
293
|
+
const config = await loadConfig(options.setup);
|
|
302
294
|
const warnings = [
|
|
303
|
-
...
|
|
304
|
-
...
|
|
305
|
-
...syncSessionsWarnings({ syncSessions }),
|
|
295
|
+
...(config.backend.type === "s3" ? sessionTokenWarnings(config.backend.profile) : []),
|
|
296
|
+
...syncSessionsWarnings(config),
|
|
306
297
|
];
|
|
298
|
+
const storageLines = configStorageLines(config);
|
|
307
299
|
ctx.ui.notify(
|
|
308
300
|
[
|
|
309
301
|
"pi-sync config:",
|
|
310
|
-
`
|
|
311
|
-
`storage
|
|
312
|
-
|
|
313
|
-
`
|
|
314
|
-
`
|
|
315
|
-
`
|
|
316
|
-
`
|
|
317
|
-
`
|
|
318
|
-
`profile: ${partial.profile ?? DEFAULT_PROFILE}`,
|
|
319
|
-
`prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
|
|
320
|
-
`autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
|
|
321
|
-
`syncFiles: ${normalizeSyncFiles(partial.syncFiles).join(", ") || "none"}`,
|
|
322
|
-
`syncSessions: ${syncSessions ? "enabled" : "disabled"}`,
|
|
323
|
-
`extraFiles: ${normalizeExtraFiles(partial.extraFiles).join(", ") || "none"}`,
|
|
324
|
-
`local config: ${localConfigPath()}`,
|
|
302
|
+
`sync setup: ${config.setupName}`,
|
|
303
|
+
`storage connection: ${config.connectionName}`,
|
|
304
|
+
...storageLines,
|
|
305
|
+
`storage path: ${config.storagePath}`,
|
|
306
|
+
`automatic sync: ${config.automatic ? "enabled" : "disabled"}`,
|
|
307
|
+
`included content: ${config.include.join(", ") || "none"}`,
|
|
308
|
+
`sessions: ${config.include.includes("sessions") ? "included" : "not included"}`,
|
|
309
|
+
`settings file: ${localConfigPath()}`,
|
|
325
310
|
...warnings,
|
|
326
311
|
].join("\n"),
|
|
327
312
|
warnings.length > 0 ? "warning" : "info",
|
|
328
313
|
);
|
|
329
314
|
}
|
|
330
315
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
316
|
+
function configStorageLines(config: AnySyncConfig) {
|
|
317
|
+
switch (config.backend.type) {
|
|
318
|
+
case "git":
|
|
319
|
+
return [
|
|
320
|
+
"kind: git",
|
|
321
|
+
`remote: ${displayGitRemote(config.backend.profile.remote)}`,
|
|
322
|
+
"authentication: existing Git/SSH credentials (not stored)",
|
|
323
|
+
`branch: ${config.backend.destination.branch}`,
|
|
324
|
+
];
|
|
325
|
+
case "webdav":
|
|
326
|
+
return [
|
|
327
|
+
"kind: webdav",
|
|
328
|
+
`url: ${displayWebDavUrl(config.backend.profile.url, config.backend.profile.username)}`,
|
|
329
|
+
"username: configured (value hidden)",
|
|
330
|
+
"password: configured",
|
|
331
|
+
];
|
|
332
|
+
case "s3":
|
|
333
|
+
return [
|
|
334
|
+
"kind: s3",
|
|
335
|
+
`endpoint: ${config.backend.profile.endpoint}`,
|
|
336
|
+
`bucket: ${config.backend.destination.bucket}`,
|
|
337
|
+
`region: ${config.backend.profile.region}`,
|
|
338
|
+
"access key id: configured",
|
|
339
|
+
"secret access key: configured",
|
|
340
|
+
`session token: ${config.backend.profile.sessionToken ? "configured" : "not configured"}`,
|
|
341
|
+
];
|
|
344
342
|
}
|
|
345
|
-
|
|
346
|
-
const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
|
|
347
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
348
|
-
ctx.ui.notify(
|
|
349
|
-
[
|
|
350
|
-
`target: ${config.target ?? "default"}`,
|
|
351
|
-
`storage profile: ${config.storageProfile ?? "default"}`,
|
|
352
|
-
`destination: ${formatDestination(config)}`,
|
|
353
|
-
`remote namespace: ${config.profile}`,
|
|
354
|
-
`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
|
|
355
|
-
`extra files: ${config.extraFiles.join(", ") || "none"}`,
|
|
356
|
-
`sessions: ${config.syncSessions ? "included" : "excluded"}`,
|
|
357
|
-
remoteText,
|
|
358
|
-
`local files: ${local.files.length}`,
|
|
359
|
-
`local changed since last sync: ${localChanged ? "yes" : "no"}`,
|
|
360
|
-
`remote changed since last sync: ${remoteChanged ? "yes" : "no"}`,
|
|
361
|
-
...warnings,
|
|
362
|
-
].join("\n"),
|
|
363
|
-
localChanged || remoteChanged || warnings.length > 0 ? "warning" : "info",
|
|
364
|
-
);
|
|
365
343
|
}
|
|
366
344
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
ctx.ui.setStatus(STATUS_KEY, `checking ${config.target ?? "default"}`);
|
|
370
|
-
const client = new S3Client(config, options.signal);
|
|
371
|
-
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
372
|
-
const remote = await readRemoteSnapshot(client, config);
|
|
373
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
374
|
-
|
|
375
|
-
const warnings = [...deprecatedPiSyncEnvironmentWarnings(), ...syncSessionsWarnings(config)];
|
|
376
|
-
const header = [
|
|
377
|
-
`target: ${config.target ?? "default"}`,
|
|
378
|
-
`storage profile: ${config.storageProfile ?? "default"}`,
|
|
379
|
-
`destination: ${formatDestination(config)}`,
|
|
380
|
-
`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
|
|
381
|
-
`extra files: ${config.extraFiles.join(", ") || "none"}`,
|
|
382
|
-
`sessions: ${config.syncSessions ? "included" : "excluded"}`,
|
|
383
|
-
...warnings,
|
|
384
|
-
].join("\n");
|
|
385
|
-
const level = warnings.length > 0 ? "warning" : "info";
|
|
386
|
-
if (!remote) {
|
|
387
|
-
ctx.ui.notify(
|
|
388
|
-
`${header}\n\n${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`,
|
|
389
|
-
level,
|
|
390
|
-
);
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
ctx.ui.notify(`${header}\n\n${formatDiff(local, remote)}`, level);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
async function doctor(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
398
|
-
const messages: string[] = [];
|
|
399
|
-
let level: "info" | "warning" = "info";
|
|
400
|
-
let snapshotOptions: SnapshotOptions = {};
|
|
401
|
-
let profile = DEFAULT_PROFILE;
|
|
402
|
-
|
|
345
|
+
function displayGitRemote(value: string | undefined) {
|
|
346
|
+
if (!value) return "missing";
|
|
403
347
|
try {
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
messages.push(`sessions: ${config.syncSessions ? "included" : "excluded"}`);
|
|
413
|
-
const warnings = [
|
|
414
|
-
...deprecatedPiSyncEnvironmentWarnings(),
|
|
415
|
-
...sessionTokenWarnings(config),
|
|
416
|
-
...syncSessionsWarnings(config),
|
|
417
|
-
];
|
|
418
|
-
if (warnings.length > 0) {
|
|
419
|
-
level = "warning";
|
|
420
|
-
messages.push(...warnings);
|
|
421
|
-
}
|
|
422
|
-
} catch (error) {
|
|
423
|
-
level = "warning";
|
|
424
|
-
messages.push(`config: ${errorMessage(error)}`);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const local = await createSnapshot(profile, snapshotOptions);
|
|
428
|
-
const secrets = scanSnapshot(local);
|
|
429
|
-
if (secrets.length > 0) {
|
|
430
|
-
level = "warning";
|
|
431
|
-
messages.push("secret scan: possible secrets found:");
|
|
432
|
-
messages.push(...secrets.map((secret) => `- ${secret}`));
|
|
433
|
-
} else {
|
|
434
|
-
messages.push(`secret scan: ok (${local.files.length} files checked)`);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const lock = await inspectLock();
|
|
438
|
-
if (lock.status === "valid" && isStaleLock(lock.lock)) {
|
|
439
|
-
level = "warning";
|
|
440
|
-
messages.push(
|
|
441
|
-
`lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running`,
|
|
442
|
-
);
|
|
443
|
-
} else if (lock.status === "valid") {
|
|
444
|
-
messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
|
|
445
|
-
} else if (lock.status === "unreadable") {
|
|
446
|
-
level = "warning";
|
|
447
|
-
messages.push(
|
|
448
|
-
"lock: unreadable; use /sync unlock --stale only after verifying no sync is running",
|
|
449
|
-
);
|
|
450
|
-
} else if (await isLockGuardHeld()) {
|
|
451
|
-
level = "warning";
|
|
452
|
-
messages.push("lock: guard active while metadata is missing or still being initialized");
|
|
453
|
-
} else {
|
|
454
|
-
messages.push("lock: free");
|
|
455
|
-
}
|
|
456
|
-
ctx.ui.notify(messages.join("\n"), level);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async function push(
|
|
460
|
-
ctx: ExtensionCommandContext | ExtensionContext,
|
|
461
|
-
options: CommandOptions,
|
|
462
|
-
input?: PushInput,
|
|
463
|
-
) {
|
|
464
|
-
const config = input?.config ?? (await loadConfig(options.target));
|
|
465
|
-
ctx.ui.setStatus(STATUS_KEY, `pushing ${config.target ?? "default"}`);
|
|
466
|
-
const client = input?.client ?? new S3Client(config, options.signal);
|
|
467
|
-
const state = input?.state ?? (await readStateForConfig(config));
|
|
468
|
-
const local =
|
|
469
|
-
input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
|
|
470
|
-
|
|
471
|
-
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
472
|
-
const remoteForUpload = await readRemoteSnapshotForUpload(client, config, latest, state);
|
|
473
|
-
if (remoteChangedSinceState(latest, state, config) && !options.force) {
|
|
474
|
-
const remoteForConflict = remoteForUpload
|
|
475
|
-
? filterSnapshotForConfigPolicy(remoteForUpload, config)
|
|
476
|
-
: undefined;
|
|
477
|
-
if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
|
|
478
|
-
throw new Error(
|
|
479
|
-
"Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
|
|
480
|
-
);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload);
|
|
485
|
-
const secrets = scanSnapshot(local);
|
|
486
|
-
if (secrets.length > 0) {
|
|
487
|
-
throw new Error(
|
|
488
|
-
`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`,
|
|
489
|
-
);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
const preservedRemoteFileCount = countPreservedRemoteFiles(local, upload);
|
|
493
|
-
if (
|
|
494
|
-
!options.yes &&
|
|
495
|
-
!(await ctx.ui.confirm(
|
|
496
|
-
snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
|
|
497
|
-
formatPushSummary(config, upload, latest, preservedRemoteFileCount, remoteForUpload),
|
|
498
|
-
))
|
|
499
|
-
) {
|
|
500
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
501
|
-
ctx.ui.notify("Push cancelled.", "info");
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
options.onCommit?.();
|
|
506
|
-
const publicationClient = options.onCommit ? new S3Client(config) : client;
|
|
507
|
-
const pointer = await uploadSnapshot(publicationClient, config, upload, latest, options.force);
|
|
508
|
-
const historyWarning = await updateHistorySafely(publicationClient, config, pointer);
|
|
509
|
-
await writeStateForConfig(config, {
|
|
510
|
-
version: VERSION,
|
|
511
|
-
profile: config.profile,
|
|
512
|
-
lastAppliedSnapshot: pointer.snapshot,
|
|
513
|
-
lastRemoteEtag: undefined,
|
|
514
|
-
lastFileHashes: fileHashMap(local),
|
|
515
|
-
syncFiles: config.syncFiles,
|
|
516
|
-
syncSessions: config.syncSessions,
|
|
517
|
-
extraFiles: config.extraFiles,
|
|
518
|
-
});
|
|
519
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
520
|
-
if (!options.silent) {
|
|
521
|
-
ctx.ui.notify(
|
|
522
|
-
[
|
|
523
|
-
`Pushed ${upload.files.length} files from target “${config.target ?? "default"}” as ${pointer.snapshot}.`,
|
|
524
|
-
historyWarning,
|
|
525
|
-
]
|
|
526
|
-
.filter(Boolean)
|
|
527
|
-
.join("\n"),
|
|
528
|
-
historyWarning ? "warning" : "info",
|
|
529
|
-
);
|
|
348
|
+
const url = new URL(value);
|
|
349
|
+
url.username = "";
|
|
350
|
+
url.password = "";
|
|
351
|
+
url.search = "";
|
|
352
|
+
url.hash = "";
|
|
353
|
+
return url.toString();
|
|
354
|
+
} catch {
|
|
355
|
+
return value.replace(/^(?:[^@\s]+@)?(?<host>[^:]+):.+$/u, "$<host>:…");
|
|
530
356
|
}
|
|
531
357
|
}
|
|
532
358
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
|
|
545
|
-
throw new Error(
|
|
546
|
-
"Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
|
|
547
|
-
);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
if (
|
|
551
|
-
!options.yes &&
|
|
552
|
-
!(await ctx.ui.confirm(
|
|
553
|
-
snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
|
|
554
|
-
formatPullSummary(config, local, remote, protectedSessionPaths(ctx).size),
|
|
555
|
-
))
|
|
556
|
-
) {
|
|
557
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
558
|
-
ctx.ui.notify("Pull cancelled.", "info");
|
|
559
|
-
return "cancelled" as const;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
options.onCommit?.();
|
|
563
|
-
const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
|
|
564
|
-
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
565
|
-
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
|
|
566
|
-
syncFiles: config.syncFiles,
|
|
567
|
-
sessionDir: applySessionDir,
|
|
568
|
-
extraFiles: config.extraFiles,
|
|
569
|
-
});
|
|
570
|
-
await writeStateForConfig(config, {
|
|
571
|
-
version: VERSION,
|
|
572
|
-
profile: config.profile,
|
|
573
|
-
lastAppliedSnapshot: remote.id,
|
|
574
|
-
lastRemoteEtag: undefined,
|
|
575
|
-
lastFileHashes,
|
|
576
|
-
syncFiles: config.syncFiles,
|
|
577
|
-
syncSessions: config.syncSessions,
|
|
578
|
-
extraFiles: config.extraFiles,
|
|
579
|
-
});
|
|
580
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
581
|
-
if (!options.silent) {
|
|
582
|
-
ctx.ui.notify(
|
|
583
|
-
`Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`,
|
|
584
|
-
"info",
|
|
585
|
-
);
|
|
586
|
-
} else if (options.auto && config.syncSessions && snapshotIncludesSessions(remote)) {
|
|
587
|
-
ctx.ui.notify(
|
|
588
|
-
"Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
|
|
589
|
-
"warning",
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
if (options.reload) await maybeReload(ctx);
|
|
593
|
-
return "applied" as const;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
|
|
597
|
-
const config = await loadConfig(options.target);
|
|
598
|
-
const client = new S3Client(config, options.signal);
|
|
599
|
-
const state = await readStateForConfig(config);
|
|
600
|
-
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
601
|
-
if (
|
|
602
|
-
normalizeSyncFiles(config.syncFiles).length === 0 &&
|
|
603
|
-
config.extraFiles.length === 0 &&
|
|
604
|
-
!config.syncSessions
|
|
605
|
-
) {
|
|
606
|
-
if (!options.silent) {
|
|
607
|
-
ctx.ui.notify(
|
|
608
|
-
`Target “${config.target ?? "default"}” manages no files. Choose synced content in /sync Settings before syncing.`,
|
|
609
|
-
"warning",
|
|
610
|
-
);
|
|
611
|
-
}
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
const remote = await readRemoteSnapshot(client, config);
|
|
615
|
-
const localChanged = hasLocalChanges(local, state, config);
|
|
616
|
-
const remoteChanged = remote
|
|
617
|
-
? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx))
|
|
618
|
-
: false;
|
|
619
|
-
const firstSync = !state.lastAppliedSnapshot;
|
|
620
|
-
|
|
621
|
-
if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
|
|
622
|
-
if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
|
|
623
|
-
throw new Error(
|
|
624
|
-
"Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
|
|
625
|
-
);
|
|
626
|
-
}
|
|
627
|
-
if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
|
|
628
|
-
if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
|
|
629
|
-
throw new Error(
|
|
630
|
-
"Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
|
|
631
|
-
);
|
|
632
|
-
}
|
|
633
|
-
await pull(ctx, options);
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
await writeStateForConfig(config, {
|
|
637
|
-
version: VERSION,
|
|
638
|
-
profile: config.profile,
|
|
639
|
-
lastAppliedSnapshot: remote.id,
|
|
640
|
-
lastRemoteEtag: undefined,
|
|
641
|
-
lastFileHashes: fileHashMap(remote),
|
|
642
|
-
syncFiles: config.syncFiles,
|
|
643
|
-
syncSessions: config.syncSessions,
|
|
644
|
-
extraFiles: config.extraFiles,
|
|
645
|
-
});
|
|
646
|
-
if (!options.silent)
|
|
647
|
-
ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
|
|
651
|
-
await writeStateForConfig(config, {
|
|
652
|
-
version: VERSION,
|
|
653
|
-
profile: config.profile,
|
|
654
|
-
lastAppliedSnapshot: remote.id,
|
|
655
|
-
lastRemoteEtag: undefined,
|
|
656
|
-
lastFileHashes: fileHashMap(remote),
|
|
657
|
-
syncFiles: config.syncFiles,
|
|
658
|
-
syncSessions: config.syncSessions,
|
|
659
|
-
extraFiles: config.extraFiles,
|
|
660
|
-
});
|
|
661
|
-
if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
|
|
665
|
-
throw new Error(
|
|
666
|
-
"Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
|
|
667
|
-
);
|
|
668
|
-
}
|
|
669
|
-
if (remoteChanged) {
|
|
670
|
-
await pull(ctx, options);
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
if (localChanged || !remote) {
|
|
674
|
-
await push(ctx, options);
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
if (shouldRefreshSyncedState(remote, state, config)) {
|
|
678
|
-
await writeStateForConfig(config, {
|
|
679
|
-
version: VERSION,
|
|
680
|
-
profile: config.profile,
|
|
681
|
-
lastAppliedSnapshot: remote.id,
|
|
682
|
-
lastRemoteEtag: undefined,
|
|
683
|
-
lastFileHashes: fileHashMap(remote),
|
|
684
|
-
syncFiles: config.syncFiles,
|
|
685
|
-
syncSessions: config.syncSessions,
|
|
686
|
-
extraFiles: config.extraFiles,
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
async function history(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
693
|
-
const config = await loadConfig(options.target);
|
|
694
|
-
const client = new S3Client(config, options.signal);
|
|
695
|
-
const remote = await client.getJson<{ snapshots?: LatestPointer[] }>(historyKey(config));
|
|
696
|
-
if (remote.missing || !remote.value?.snapshots?.length) {
|
|
697
|
-
ctx.ui.notify("No remote pi-sync history found.", "info");
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
const snapshots = remote.value.snapshots.slice(-20).reverse();
|
|
702
|
-
const currentSnapshot = snapshots[0]?.snapshot;
|
|
703
|
-
if (ctx.mode === "tui") {
|
|
704
|
-
const labels = snapshots.map(
|
|
705
|
-
(item, index) =>
|
|
706
|
-
`${index + 1}. ${item.createdAt} · ${safeTerminalText(item.machine)} · ${item.snapshot}${item.snapshot === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " · sessions" : ""}`,
|
|
707
|
-
);
|
|
708
|
-
const selected = await ctx.ui.select(
|
|
709
|
-
`History for target “${safeTerminalText(config.target ?? "default")}”\n\nChoose a snapshot to preview rollback.`,
|
|
710
|
-
[...labels, "Back"],
|
|
711
|
-
);
|
|
712
|
-
if (!selected || selected === "Back") return;
|
|
713
|
-
const index = labels.indexOf(selected);
|
|
714
|
-
const snapshot = snapshots[index];
|
|
715
|
-
if (!snapshot) return;
|
|
716
|
-
await withLock("rollback", () =>
|
|
717
|
-
rollback(ctx, { ...options, args: [snapshot.snapshot], yes: false }),
|
|
718
|
-
);
|
|
719
|
-
return;
|
|
359
|
+
function displayWebDavUrl(value: string | undefined, username: string | undefined) {
|
|
360
|
+
if (!value) return "missing";
|
|
361
|
+
try {
|
|
362
|
+
const url = new URL(value);
|
|
363
|
+
url.username = "";
|
|
364
|
+
url.password = "";
|
|
365
|
+
url.search = "";
|
|
366
|
+
url.hash = "";
|
|
367
|
+
return username ? `${url.origin}/…` : `${url.origin}${url.pathname}`;
|
|
368
|
+
} catch {
|
|
369
|
+
return "invalid (value hidden)";
|
|
720
370
|
}
|
|
721
|
-
ctx.ui.notify(
|
|
722
|
-
snapshots
|
|
723
|
-
.map((item) => `${item.snapshot} ${item.createdAt} ${safeTerminalText(item.machine)}`)
|
|
724
|
-
.join("\n"),
|
|
725
|
-
"info",
|
|
726
|
-
);
|
|
727
371
|
}
|
|
728
372
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
const client = new S3Client(config, options.signal);
|
|
735
|
-
const snapshot = await client.getBuffer(snapshotKey(config, target));
|
|
736
|
-
if (!snapshot.value) throw new Error(`Snapshot not found: ${target}`);
|
|
737
|
-
const decoded = await decodeSnapshot(snapshot.value);
|
|
738
|
-
const remote = filterSnapshotForConfigPolicy(
|
|
739
|
-
config.syncSessions ? decoded : snapshotWithoutSessions(decoded),
|
|
740
|
-
config,
|
|
741
|
-
{ regenerateId: true },
|
|
742
|
-
);
|
|
743
|
-
const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
|
|
744
|
-
|
|
745
|
-
if (
|
|
746
|
-
!options.yes &&
|
|
747
|
-
!(await ctx.ui.confirm(
|
|
748
|
-
snapshotIncludesSessions(remote)
|
|
749
|
-
? "Rollback pi settings and sessions?"
|
|
750
|
-
: "Rollback pi settings?",
|
|
751
|
-
formatRollbackSummary(config, local, remote, target, protectedSessionPaths(ctx).size),
|
|
752
|
-
))
|
|
753
|
-
) {
|
|
754
|
-
ctx.ui.notify("Rollback cancelled.", "info");
|
|
755
|
-
return;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
options.onCommit?.();
|
|
759
|
-
const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
|
|
760
|
-
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
761
|
-
const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
|
|
762
|
-
syncFiles: config.syncFiles,
|
|
763
|
-
sessionDir: applySessionDir,
|
|
764
|
-
extraFiles: config.extraFiles,
|
|
765
|
-
});
|
|
766
|
-
const publicationClient = options.onCommit ? new S3Client(config) : client;
|
|
767
|
-
const latest = await publicationClient.getJson<LatestPointer>(latestKey(config));
|
|
768
|
-
const upload = await snapshotForUpload(publicationClient, config, remote, latest, undefined, {
|
|
769
|
-
ignoreUnreadableRemote: true,
|
|
770
|
-
});
|
|
771
|
-
const encoded = upload.id === decoded.id ? snapshot.value : await encodeSnapshot(upload);
|
|
772
|
-
if (upload.id !== decoded.id) {
|
|
773
|
-
await publicationClient.putBuffer(snapshotKey(config, upload.id), encoded, "application/gzip");
|
|
774
|
-
}
|
|
775
|
-
const pointer = pointerFor(config, upload, sha256(encoded));
|
|
776
|
-
await publicationClient.putJson(latestKey(config), pointer);
|
|
777
|
-
const historyWarning = await updateHistorySafely(publicationClient, config, pointer);
|
|
778
|
-
await writeStateForConfig(config, {
|
|
779
|
-
version: VERSION,
|
|
780
|
-
profile: config.profile,
|
|
781
|
-
lastAppliedSnapshot: pointer.snapshot,
|
|
782
|
-
lastRemoteEtag: undefined,
|
|
783
|
-
lastFileHashes,
|
|
784
|
-
syncFiles: config.syncFiles,
|
|
785
|
-
syncSessions: config.syncSessions,
|
|
786
|
-
extraFiles: config.extraFiles,
|
|
787
|
-
});
|
|
788
|
-
ctx.ui.notify(
|
|
789
|
-
[
|
|
790
|
-
`Rolled back target “${config.target ?? "default"}” to ${target}; latest: ${pointer.snapshot}. Backup: ${backup}`,
|
|
791
|
-
historyWarning,
|
|
792
|
-
]
|
|
793
|
-
.filter(Boolean)
|
|
794
|
-
.join("\n"),
|
|
795
|
-
historyWarning ? "warning" : "info",
|
|
796
|
-
);
|
|
797
|
-
await maybeReload(ctx);
|
|
373
|
+
function throwIfAborted(signal: AbortSignal) {
|
|
374
|
+
if (!signal.aborted) return;
|
|
375
|
+
throw signal.reason instanceof Error
|
|
376
|
+
? signal.reason
|
|
377
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
798
378
|
}
|
|
799
379
|
|
|
800
|
-
function
|
|
801
|
-
|
|
802
|
-
if (typeof getSessionFile !== "function") return new Set<string>();
|
|
803
|
-
const sessionFile = getSessionFile.call(ctx.sessionManager) as string | undefined;
|
|
804
|
-
const snapshotPath = sessionFile
|
|
805
|
-
? sessionSnapshotPathFromAbsolute(sessionFile, sessionDirFromContext(ctx))
|
|
806
|
-
: undefined;
|
|
807
|
-
return snapshotPath ? new Set([snapshotPath]) : new Set<string>();
|
|
380
|
+
function combineSignals(primary: AbortSignal, secondary?: AbortSignal) {
|
|
381
|
+
return secondary ? AbortSignal.any([primary, secondary]) : primary;
|
|
808
382
|
}
|
|
809
383
|
|
|
810
384
|
function snapshotOptionsForContext(
|
|
811
385
|
ctx: ExtensionCommandContext | ExtensionContext,
|
|
812
|
-
config:
|
|
386
|
+
config: AnySyncConfig,
|
|
813
387
|
): SnapshotOptions {
|
|
814
388
|
return {
|
|
815
|
-
|
|
816
|
-
syncSessions: config.syncSessions,
|
|
389
|
+
include: config.include,
|
|
817
390
|
sessionDir: sessionDirFromContext(ctx),
|
|
818
|
-
extraFiles: config.extraFiles,
|
|
819
391
|
};
|
|
820
392
|
}
|
|
821
393
|
|
|
@@ -833,138 +405,6 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
|
|
|
833
405
|
: undefined;
|
|
834
406
|
}
|
|
835
407
|
|
|
836
|
-
async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext) {
|
|
837
|
-
if (!("reload" in ctx)) return;
|
|
838
|
-
if (
|
|
839
|
-
ctx.hasUI &&
|
|
840
|
-
(await ctx.ui.confirm(
|
|
841
|
-
"Reload Pi resources now?",
|
|
842
|
-
"This reloads extensions, skills, prompts, themes, and context files.",
|
|
843
|
-
))
|
|
844
|
-
) {
|
|
845
|
-
await ctx.reload();
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
async function readRemoteSnapshotForUpload(
|
|
850
|
-
client: S3Client,
|
|
851
|
-
config: SyncConfig,
|
|
852
|
-
latest: RemoteObject<LatestPointer>,
|
|
853
|
-
state: SyncState,
|
|
854
|
-
) {
|
|
855
|
-
if (
|
|
856
|
-
latest.missing ||
|
|
857
|
-
!latest.value ||
|
|
858
|
-
(latest.value.snapshot === state.lastAppliedSnapshot && !syncPolicyChanged(state, config))
|
|
859
|
-
) {
|
|
860
|
-
return undefined;
|
|
861
|
-
}
|
|
862
|
-
return readRemoteSnapshotRaw(client, config);
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
async function snapshotForUpload(
|
|
866
|
-
client: S3Client,
|
|
867
|
-
config: SyncConfig,
|
|
868
|
-
local: Snapshot,
|
|
869
|
-
latest: RemoteObject<LatestPointer>,
|
|
870
|
-
remote?: Snapshot,
|
|
871
|
-
options: { ignoreUnreadableRemote?: boolean } = {},
|
|
872
|
-
) {
|
|
873
|
-
if (latest.missing || !latest.value) return local;
|
|
874
|
-
let snapshot = remote;
|
|
875
|
-
if (!snapshot) {
|
|
876
|
-
try {
|
|
877
|
-
snapshot = await readRemoteSnapshotRaw(client, config);
|
|
878
|
-
} catch (error) {
|
|
879
|
-
if (options.ignoreUnreadableRemote) return local;
|
|
880
|
-
throw error;
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
return snapshot ? mergeRemotePreservedFiles(local, snapshot, config) : local;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
async function uploadSnapshot(
|
|
887
|
-
client: S3Client,
|
|
888
|
-
config: SyncConfig,
|
|
889
|
-
snapshot: Snapshot,
|
|
890
|
-
latest: RemoteObject<LatestPointer>,
|
|
891
|
-
force: boolean,
|
|
892
|
-
) {
|
|
893
|
-
const encoded = await encodeSnapshot(snapshot);
|
|
894
|
-
const pointer = pointerFor(config, snapshot, sha256(encoded));
|
|
895
|
-
await client.putBuffer(snapshotKey(config, snapshot.id), encoded, "application/gzip");
|
|
896
|
-
const current = await client.getJson<LatestPointer>(latestKey(config));
|
|
897
|
-
if (!force && remoteIdentity(current) !== remoteIdentity(latest)) {
|
|
898
|
-
throw new Error("Remote changed while pushing. Run /sync pull first, then retry.");
|
|
899
|
-
}
|
|
900
|
-
await client.putJson(latestKey(config), pointer);
|
|
901
|
-
const verified = await client.getJson<LatestPointer>(latestKey(config));
|
|
902
|
-
if (verified.value?.snapshot !== pointer.snapshot) {
|
|
903
|
-
throw new Error(
|
|
904
|
-
"Remote latest changed immediately after push. Run /sync status before continuing.",
|
|
905
|
-
);
|
|
906
|
-
}
|
|
907
|
-
return pointer;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
async function readRemoteSnapshot(client: S3Client, config: SyncConfig) {
|
|
911
|
-
const snapshot = await readRemoteSnapshotRaw(client, config);
|
|
912
|
-
return snapshot ? filterSnapshotForConfigPolicy(snapshot, config) : undefined;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
async function readRemoteSnapshotRaw(client: S3Client, config: SyncConfig) {
|
|
916
|
-
const latest = await client.getJson<LatestPointer>(latestKey(config));
|
|
917
|
-
if (latest.missing || !latest.value) return undefined;
|
|
918
|
-
const object = await client.getBuffer(snapshotKey(config, latest.value.snapshot));
|
|
919
|
-
if (!object.value)
|
|
920
|
-
throw new Error(`Remote latest points to missing snapshot: ${latest.value.snapshot}`);
|
|
921
|
-
if (sha256(object.value) !== latest.value.sha256)
|
|
922
|
-
throw new Error("Remote snapshot checksum mismatch.");
|
|
923
|
-
return decodeSnapshot(object.value);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
async function encodeSnapshot(snapshot: Snapshot) {
|
|
927
|
-
return gzipAsync(Buffer.from(JSON.stringify(snapshot), "utf8"));
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
async function decodeSnapshot(buffer: Buffer): Promise<Snapshot> {
|
|
931
|
-
const decoded = await gunzipAsync(buffer);
|
|
932
|
-
const parsed = JSON.parse(decoded.toString("utf8")) as Snapshot;
|
|
933
|
-
if (parsed.version !== VERSION || !Array.isArray(parsed.files))
|
|
934
|
-
throw new Error("Unsupported snapshot format.");
|
|
935
|
-
return parsed;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
export async function backupLocal(profile: string, options: SnapshotOptions = {}) {
|
|
939
|
-
const snapshot = await createSnapshot(profile, options);
|
|
940
|
-
const backupDirectory = path.join(stateDir(), "backups");
|
|
941
|
-
await fs.mkdir(backupDirectory, { recursive: true });
|
|
942
|
-
const backupPath = path.join(backupDirectory, `${snapshot.id}.json.gz`);
|
|
943
|
-
await fs.writeFile(backupPath, await encodeSnapshot(snapshot));
|
|
944
|
-
return backupPath;
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
async function updateHistorySafely(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
|
|
948
|
-
try {
|
|
949
|
-
await updateHistory(client, config, pointer);
|
|
950
|
-
return undefined;
|
|
951
|
-
} catch (error) {
|
|
952
|
-
return `Remote snapshot is active, but history could not be updated: ${errorMessage(error)}. Run /sync doctor before relying on history.`;
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
async function updateHistory(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
|
|
957
|
-
const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
|
|
958
|
-
historyKey(config),
|
|
959
|
-
);
|
|
960
|
-
const snapshots = object.value?.snapshots ?? [];
|
|
961
|
-
const next = [
|
|
962
|
-
...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot),
|
|
963
|
-
pointer,
|
|
964
|
-
].slice(-100);
|
|
965
|
-
await client.putJson(historyKey(config), { version: VERSION, snapshots: next });
|
|
966
|
-
}
|
|
967
|
-
|
|
968
408
|
export { completeSyncArguments, parseOptions, splitArgs } from "./command.js";
|
|
969
409
|
export {
|
|
970
410
|
isCloudflareR2Endpoint,
|
|
@@ -993,6 +433,7 @@ export {
|
|
|
993
433
|
preflightSnapshotApply,
|
|
994
434
|
protectSnapshotApplyPlan,
|
|
995
435
|
} from "./snapshot-apply.js";
|
|
436
|
+
export { backupLocal } from "./sync-operations.js";
|
|
996
437
|
export {
|
|
997
438
|
canPullRemoteSessionsOnFirstSync,
|
|
998
439
|
canPullRemoteSettingsOnFirstSync,
|