@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/manager-ui.ts
CHANGED
|
@@ -1,120 +1,117 @@
|
|
|
1
1
|
import { BorderedLoader, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { setSyncSetupCompletions } from "./command.js";
|
|
3
3
|
import {
|
|
4
4
|
activeLocalConfigPath,
|
|
5
|
-
|
|
6
|
-
deprecatedPiSyncEnvironmentWarnings,
|
|
5
|
+
configuredSyncSetupNames,
|
|
7
6
|
isCloudflareR2Endpoint,
|
|
8
7
|
loadConfig,
|
|
8
|
+
loadOnSwitch,
|
|
9
9
|
loadPartialConfig,
|
|
10
|
-
loadTargetSwitchAction,
|
|
11
10
|
localConfigPath,
|
|
12
|
-
normalizeSyncFiles,
|
|
13
11
|
readLocalConfigObject,
|
|
14
12
|
readStateForConfig,
|
|
13
|
+
syncConfigReviewIdentity,
|
|
15
14
|
} from "./config.js";
|
|
15
|
+
import { showAddGitTarget, showEditGitTarget, showGitSetup } from "./git-ui.js";
|
|
16
16
|
import { inspectLock, isStaleLock } from "./lock.js";
|
|
17
17
|
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
errorMessage,
|
|
19
|
+
ownRecord,
|
|
20
|
+
requiredExistingBucket,
|
|
21
|
+
requiredInput,
|
|
22
|
+
safeTerminalText,
|
|
23
|
+
} from "./manager-helpers.js";
|
|
24
|
+
import { chooseS3Credentials } from "./s3-credentials-ui.js";
|
|
25
|
+
import {
|
|
26
|
+
addSyncSetup,
|
|
27
|
+
removeSyncSetup,
|
|
28
|
+
saveNewV3Settings,
|
|
29
|
+
updateSyncSetup,
|
|
26
30
|
} from "./settings-management.js";
|
|
27
31
|
import { showSyncSettings } from "./settings-ui.js";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import
|
|
32
|
+
import { type SetupPullOutcome, useSyncSetup } from "./setup-switch.js";
|
|
33
|
+
import { showAddStorageConnection, showStorageConnections } from "./storage-connections-ui.js";
|
|
34
|
+
import { DEFAULT_SYNC_INCLUDE, syncIncludeSelection } from "./sync-policy.js";
|
|
35
|
+
import { countValidSyncSetups, showSyncSetups } from "./sync-setups-ui.js";
|
|
36
|
+
import type { AnySyncConfig } from "./types.js";
|
|
37
|
+
import { showAddWebDavTarget, showEditWebDavTarget, showWebDavSetup } from "./webdav-ui.js";
|
|
31
38
|
|
|
32
39
|
export const MAIN_MENU_ACTIONS = [
|
|
33
40
|
"Sync now (recommended)",
|
|
34
|
-
"
|
|
35
|
-
"Push to remote",
|
|
36
|
-
"Switch target",
|
|
41
|
+
"Switch sync setup",
|
|
37
42
|
"Status & changes",
|
|
38
43
|
"Settings",
|
|
39
44
|
"More…",
|
|
40
45
|
] as const;
|
|
41
|
-
|
|
42
46
|
const MORE_MENU_ACTIONS = [
|
|
43
|
-
"
|
|
44
|
-
"
|
|
47
|
+
"Pull from remote…",
|
|
48
|
+
"Push to remote…",
|
|
49
|
+
"Sync setups…",
|
|
50
|
+
"Storage connections…",
|
|
51
|
+
"History & recovery…",
|
|
45
52
|
"Help",
|
|
46
53
|
"Back",
|
|
47
54
|
] as const;
|
|
48
55
|
const BACK = "Back";
|
|
49
56
|
|
|
50
57
|
type MainMenuAction = (typeof MAIN_MENU_ACTIONS)[number];
|
|
51
|
-
type ContextualMenuAction = "Manage targets & storage" | "History & recovery" | "Help";
|
|
52
58
|
type RunRoute = (
|
|
53
59
|
route: string,
|
|
54
60
|
signal?: AbortSignal,
|
|
55
61
|
onCommit?: () => void,
|
|
56
62
|
target?: string,
|
|
57
|
-
) => Promise<
|
|
63
|
+
) => Promise<SetupPullOutcome | undefined>;
|
|
58
64
|
|
|
59
65
|
export async function showSyncManager(
|
|
60
66
|
ctx: ExtensionCommandContext,
|
|
61
67
|
runRoute: RunRoute,
|
|
68
|
+
sessionSignal?: AbortSignal,
|
|
62
69
|
): Promise<void> {
|
|
63
70
|
if (!ctx.hasUI) {
|
|
64
71
|
await runRoute("help");
|
|
65
72
|
return;
|
|
66
73
|
}
|
|
67
74
|
while (true) {
|
|
68
|
-
const state = await describeManagerState();
|
|
69
|
-
|
|
75
|
+
const state = await describeManagerState(sessionSignal);
|
|
76
|
+
if (sessionSignal?.aborted) return;
|
|
77
|
+
const selected = await ctx.ui.select(state.title, state.actions, { signal: sessionSignal });
|
|
78
|
+
if (sessionSignal?.aborted) return;
|
|
70
79
|
if (!selected) return;
|
|
71
80
|
switch (
|
|
72
|
-
selected as
|
|
81
|
+
selected as
|
|
82
|
+
| MainMenuAction
|
|
83
|
+
| "Sync setups…"
|
|
84
|
+
| "Storage connections…"
|
|
85
|
+
| "History & recovery…"
|
|
86
|
+
| "Help"
|
|
87
|
+
| "Set up sync"
|
|
88
|
+
| "Use existing settings"
|
|
73
89
|
) {
|
|
74
90
|
case "Sync now (recommended)":
|
|
75
|
-
await runCancellableOperation(ctx, "Checking current
|
|
76
|
-
break;
|
|
77
|
-
case "Pull from remote": {
|
|
78
|
-
const result = await runCancellableOperation(
|
|
79
|
-
ctx,
|
|
80
|
-
"Checking remote changes…",
|
|
81
|
-
"pull",
|
|
82
|
-
runRoute,
|
|
83
|
-
true,
|
|
84
|
-
"Pull check cancelled; no local files were changed.",
|
|
85
|
-
);
|
|
86
|
-
if (result === "applied") return;
|
|
87
|
-
break;
|
|
88
|
-
}
|
|
89
|
-
case "Push to remote":
|
|
90
|
-
await runCancellableOperation(
|
|
91
|
-
ctx,
|
|
92
|
-
"Preparing push preview…",
|
|
93
|
-
"push",
|
|
94
|
-
runRoute,
|
|
95
|
-
true,
|
|
96
|
-
"Push preparation cancelled; no remote files were changed.",
|
|
97
|
-
);
|
|
91
|
+
await runCancellableOperation(ctx, "Checking current sync setup…", "sync", runRoute, true);
|
|
98
92
|
break;
|
|
99
|
-
case "Switch
|
|
100
|
-
const result = await
|
|
93
|
+
case "Switch sync setup": {
|
|
94
|
+
const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
|
|
101
95
|
if (result === "pull-attempted") return;
|
|
102
96
|
if (result === "switched") continue;
|
|
103
97
|
break;
|
|
104
98
|
}
|
|
105
99
|
case "Status & changes":
|
|
106
|
-
await runCancellableOperation(ctx, "Checking current
|
|
100
|
+
await runCancellableOperation(ctx, "Checking current sync setup…", "diff", runRoute);
|
|
107
101
|
break;
|
|
108
102
|
case "Settings":
|
|
109
|
-
await showSyncSettings(ctx, runRoute);
|
|
103
|
+
await showSyncSettings(ctx, runRoute, sessionSignal);
|
|
110
104
|
break;
|
|
111
105
|
case "More…":
|
|
112
|
-
if ((await showMoreMenu(ctx, runRoute)) === "exit") return;
|
|
106
|
+
if ((await showMoreMenu(ctx, runRoute, sessionSignal)) === "exit") return;
|
|
113
107
|
break;
|
|
114
|
-
case "
|
|
115
|
-
await
|
|
108
|
+
case "Sync setups…":
|
|
109
|
+
if ((await showSyncSetupManager(ctx, runRoute, sessionSignal)) === "exit") return;
|
|
116
110
|
break;
|
|
117
|
-
case "
|
|
111
|
+
case "Storage connections…":
|
|
112
|
+
await showStorageConnections(ctx, sessionSignal);
|
|
113
|
+
break;
|
|
114
|
+
case "History & recovery…":
|
|
118
115
|
await showRecoveryMenu(ctx, runRoute);
|
|
119
116
|
break;
|
|
120
117
|
case "Help":
|
|
@@ -128,11 +125,39 @@ export async function showSyncManager(
|
|
|
128
125
|
}
|
|
129
126
|
}
|
|
130
127
|
|
|
131
|
-
async function showMoreMenu(
|
|
132
|
-
|
|
128
|
+
async function showMoreMenu(
|
|
129
|
+
ctx: ExtensionCommandContext,
|
|
130
|
+
runRoute: RunRoute,
|
|
131
|
+
signal?: AbortSignal,
|
|
132
|
+
) {
|
|
133
|
+
const selected = await ctx.ui.select("More options", [...MORE_MENU_ACTIONS], { signal });
|
|
133
134
|
if (!selected || selected === BACK) return;
|
|
134
|
-
if (selected === "
|
|
135
|
-
|
|
135
|
+
if (selected === "Pull from remote…") {
|
|
136
|
+
const result = await runCancellableOperation(
|
|
137
|
+
ctx,
|
|
138
|
+
"Checking remote changes…",
|
|
139
|
+
"pull",
|
|
140
|
+
runRoute,
|
|
141
|
+
true,
|
|
142
|
+
"Pull check cancelled; no local files were changed.",
|
|
143
|
+
);
|
|
144
|
+
if (result === "applied") return "exit" as const;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (selected === "Push to remote…") {
|
|
148
|
+
await runCancellableOperation(
|
|
149
|
+
ctx,
|
|
150
|
+
"Preparing push preview…",
|
|
151
|
+
"push",
|
|
152
|
+
runRoute,
|
|
153
|
+
true,
|
|
154
|
+
"Push preparation cancelled; no remote files were changed.",
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (selected === "Sync setups…") return showSyncSetupManager(ctx, runRoute, signal);
|
|
159
|
+
else if (selected === "Storage connections…") await showStorageConnections(ctx, signal);
|
|
160
|
+
else if (selected === "History & recovery…") await showRecoveryMenu(ctx, runRoute);
|
|
136
161
|
else {
|
|
137
162
|
await runRoute("help");
|
|
138
163
|
return "exit" as const;
|
|
@@ -153,7 +178,7 @@ async function runCancellableOperation(
|
|
|
153
178
|
}
|
|
154
179
|
let commitStarted = false;
|
|
155
180
|
let operation: Promise<void> | undefined;
|
|
156
|
-
let routeResult:
|
|
181
|
+
let routeResult: SetupPullOutcome | undefined;
|
|
157
182
|
const result = await ctx.ui.custom<{ cancelled?: boolean; error?: unknown }>(
|
|
158
183
|
(tui, theme, _kb, done) => {
|
|
159
184
|
const loader = new BorderedLoader(tui, theme, message, { cancellable: true });
|
|
@@ -194,14 +219,16 @@ async function runCancellableOperation(
|
|
|
194
219
|
return routeResult;
|
|
195
220
|
}
|
|
196
221
|
|
|
197
|
-
async function describeManagerState(
|
|
222
|
+
async function describeManagerState(
|
|
223
|
+
signal?: AbortSignal,
|
|
224
|
+
): Promise<{ title: string; actions: string[] }> {
|
|
198
225
|
let raw: Record<string, unknown> | undefined;
|
|
199
226
|
try {
|
|
200
227
|
raw = await readLocalConfigObject();
|
|
201
228
|
} catch (error) {
|
|
202
229
|
return {
|
|
203
230
|
title: [
|
|
204
|
-
"
|
|
231
|
+
"Manage sync",
|
|
205
232
|
"",
|
|
206
233
|
"Settings file needs repair. Automatic sync and settings writes are paused.",
|
|
207
234
|
`Error: ${safeTerminalText(errorMessage(error))}`,
|
|
@@ -214,36 +241,37 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
|
|
|
214
241
|
}
|
|
215
242
|
if (!raw) {
|
|
216
243
|
return {
|
|
217
|
-
title: ["
|
|
244
|
+
title: ["Manage sync", "", "Not set up.", "", "What do you want to do?"].join("\n"),
|
|
218
245
|
actions: ["Set up sync", "Help"],
|
|
219
246
|
};
|
|
220
247
|
}
|
|
221
|
-
const configuredTargets = ownRecord(raw.
|
|
222
|
-
if (raw.version ===
|
|
248
|
+
const configuredTargets = ownRecord(raw.syncSetups);
|
|
249
|
+
if (raw.version === 3 && configuredTargets && Object.keys(configuredTargets).length === 0) {
|
|
223
250
|
return {
|
|
224
251
|
title: [
|
|
225
|
-
"
|
|
252
|
+
"Manage sync",
|
|
226
253
|
"",
|
|
227
|
-
"No sync
|
|
228
|
-
"Add a
|
|
254
|
+
"No sync setups are configured.",
|
|
255
|
+
"Add a sync setup using an existing storage connection.",
|
|
229
256
|
"",
|
|
230
257
|
"What do you want to do?",
|
|
231
258
|
].join("\n"),
|
|
232
|
-
actions: ["
|
|
259
|
+
actions: ["Sync setups…", "Storage connections…", "Help"],
|
|
233
260
|
};
|
|
234
261
|
}
|
|
235
262
|
try {
|
|
236
263
|
const config = await loadConfig();
|
|
237
|
-
const target = config.
|
|
238
|
-
const storage =
|
|
239
|
-
const warnings =
|
|
264
|
+
const target = config.setupName;
|
|
265
|
+
const storage = backendStorageDescription(config);
|
|
266
|
+
const warnings: string[] = [];
|
|
240
267
|
const lock = await inspectLock();
|
|
241
268
|
const liveLock = lock.status === "valid" && !isStaleLock(lock.lock);
|
|
242
269
|
const recoverableLock =
|
|
243
270
|
lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock));
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
const
|
|
271
|
+
const selection = syncIncludeSelection(config.include);
|
|
272
|
+
const builtInCount = selection.builtIns.length;
|
|
273
|
+
const extraFileCount = selection.custom.length;
|
|
274
|
+
const noSyncedContent = config.include.length === 0;
|
|
247
275
|
const stateReadDisabled = liveLock || recoverableLock;
|
|
248
276
|
const syncState = stateReadDisabled
|
|
249
277
|
? undefined
|
|
@@ -255,19 +283,26 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
|
|
|
255
283
|
: syncState
|
|
256
284
|
? "Never synced"
|
|
257
285
|
: "Unavailable";
|
|
286
|
+
const canSwitch = (await countValidSyncSetups(configuredTargets, signal)) > 1;
|
|
287
|
+
const mainActions = MAIN_MENU_ACTIONS.filter(
|
|
288
|
+
(action) => action !== "Switch sync setup" || canSwitch,
|
|
289
|
+
);
|
|
258
290
|
return {
|
|
259
291
|
title: [
|
|
260
|
-
"
|
|
292
|
+
"Manage sync",
|
|
261
293
|
"",
|
|
262
|
-
`Current
|
|
294
|
+
`Current sync setup: ${safeTerminalText(target)}`,
|
|
263
295
|
`Storage: ${storage}`,
|
|
264
|
-
`
|
|
265
|
-
`
|
|
266
|
-
`Last applied
|
|
267
|
-
"Remote
|
|
296
|
+
`Included: ${builtInCount} built-in group${builtInCount === 1 ? "" : "s"} · ${extraFileCount} extra path${extraFileCount === 1 ? "" : "s"} · Sessions ${selection.sessions ? "on" : "off"}`,
|
|
297
|
+
`Automatic sync: ${config.automatic ? "On" : "Off"}`,
|
|
298
|
+
`Last applied: ${lastAppliedSnapshot}`,
|
|
299
|
+
"Remote status: Not checked",
|
|
268
300
|
...warnings.map((warning) => `Warning: ${safeTerminalText(warning)}`),
|
|
269
301
|
...(noSyncedContent
|
|
270
|
-
? [
|
|
302
|
+
? [
|
|
303
|
+
"",
|
|
304
|
+
"No included content is selected. Choose included content in Settings before syncing.",
|
|
305
|
+
]
|
|
271
306
|
: []),
|
|
272
307
|
...(liveLock
|
|
273
308
|
? [
|
|
@@ -283,483 +318,492 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
|
|
|
283
318
|
].join("\n"),
|
|
284
319
|
actions:
|
|
285
320
|
liveLock || recoverableLock
|
|
286
|
-
? ["Status & changes", "History & recovery", "Help"]
|
|
321
|
+
? ["Status & changes", "History & recovery…", "Help"]
|
|
287
322
|
: noSyncedContent
|
|
288
|
-
? ["Settings", "Switch
|
|
289
|
-
:
|
|
323
|
+
? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
|
|
324
|
+
: mainActions,
|
|
290
325
|
};
|
|
291
326
|
} catch (error) {
|
|
292
|
-
|
|
327
|
+
if (signal?.aborted) throw error;
|
|
293
328
|
return {
|
|
294
329
|
title: [
|
|
295
|
-
"
|
|
330
|
+
"Manage sync",
|
|
296
331
|
"",
|
|
297
332
|
"Settings need attention. Automatic sync is paused.",
|
|
298
|
-
`Current
|
|
333
|
+
`Current sync setup: ${safeTerminalText(typeof raw.activeSyncSetup === "string" ? raw.activeSyncSetup : "none")}`,
|
|
299
334
|
`Error: ${safeTerminalText(errorMessage(error))}`,
|
|
300
335
|
`File: ${safeTerminalText(await activeLocalConfigPath())}`,
|
|
301
336
|
"",
|
|
302
337
|
"What do you want to do?",
|
|
303
338
|
].join("\n"),
|
|
304
|
-
actions: [
|
|
305
|
-
...(targets && Object.keys(targets).length > 1 ? ["Switch target"] : []),
|
|
306
|
-
"Manage targets & storage",
|
|
307
|
-
"Help",
|
|
308
|
-
],
|
|
339
|
+
actions: ["Sync setups…", "Storage connections…", "History & recovery…", "Help"],
|
|
309
340
|
};
|
|
310
341
|
}
|
|
311
342
|
}
|
|
312
343
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
"
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
344
|
+
function backendStorageDescription(config: AnySyncConfig) {
|
|
345
|
+
const connection = safeTerminalText(config.connectionName);
|
|
346
|
+
switch (config.backend.type) {
|
|
347
|
+
case "s3": {
|
|
348
|
+
const type =
|
|
349
|
+
config.backend.profile.kind === "r2" ||
|
|
350
|
+
isCloudflareR2Endpoint(config.backend.profile.endpoint)
|
|
351
|
+
? "Cloudflare R2"
|
|
352
|
+
: "S3-compatible";
|
|
353
|
+
return `${type} · ${connection} · ${safeTerminalText(config.backend.destination.bucket)}`;
|
|
354
|
+
}
|
|
355
|
+
case "webdav":
|
|
356
|
+
return `WebDAV · ${connection} · ${safeTerminalText(config.backend.destination.path)}`;
|
|
357
|
+
case "git":
|
|
358
|
+
return `Git · ${connection} · ${safeTerminalText(config.backend.destination.branch)}`;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function showSetupWizard(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
363
|
+
if (ctx.mode !== "tui") {
|
|
364
|
+
ctx.ui.notify(
|
|
365
|
+
`Guided sync setup requires TUI mode for masked credential input. Create version 3 settings in ${safeTerminalText(localConfigPath())}.`,
|
|
366
|
+
"warning",
|
|
367
|
+
);
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
const preset = await ctx.ui.select(
|
|
371
|
+
"Set up sync\n\nWhere will Pi settings be stored?",
|
|
372
|
+
["Cloudflare R2", "Other S3-compatible storage", "WebDAV", "Git", "Cancel"],
|
|
373
|
+
{ signal },
|
|
374
|
+
);
|
|
375
|
+
if (signal?.aborted || !preset || preset === "Cancel") return false;
|
|
376
|
+
const targetName = await chooseInitialTargetName(ctx, signal);
|
|
322
377
|
if (!targetName) return false;
|
|
378
|
+
if (preset === "WebDAV") {
|
|
379
|
+
const saved = await showWebDavSetup(ctx, targetName, signal);
|
|
380
|
+
if (signal?.aborted) return false;
|
|
381
|
+
if (saved) await refreshTargetCompletions();
|
|
382
|
+
return saved;
|
|
383
|
+
}
|
|
384
|
+
if (preset === "Git") {
|
|
385
|
+
const saved = await showGitSetup(ctx, targetName, signal);
|
|
386
|
+
if (signal?.aborted) return false;
|
|
387
|
+
if (saved) await refreshTargetCompletions();
|
|
388
|
+
return saved;
|
|
389
|
+
}
|
|
323
390
|
const endpoint = await requiredInput(
|
|
324
391
|
ctx,
|
|
325
392
|
preset === "Cloudflare R2" ? "Cloudflare R2 endpoint" : "S3-compatible endpoint",
|
|
326
393
|
preset === "Cloudflare R2"
|
|
327
394
|
? "https://<account-id>.r2.cloudflarestorage.com"
|
|
328
395
|
: "https://s3.example.com",
|
|
396
|
+
signal,
|
|
329
397
|
);
|
|
330
398
|
if (!endpoint) return false;
|
|
331
399
|
let region = "auto";
|
|
332
400
|
if (preset !== "Cloudflare R2") {
|
|
333
|
-
const selectedRegion = await requiredInput(ctx, "Storage region", "us-east-1");
|
|
401
|
+
const selectedRegion = await requiredInput(ctx, "Storage region", "us-east-1", signal);
|
|
334
402
|
if (!selectedRegion) return false;
|
|
335
403
|
region = selectedRegion;
|
|
336
404
|
}
|
|
337
|
-
const location = await chooseInitialRemoteLocation(ctx, preset, targetName);
|
|
405
|
+
const location = await chooseInitialRemoteLocation(ctx, preset, targetName, signal);
|
|
338
406
|
if (!location) return false;
|
|
339
|
-
const {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
407
|
+
const { connectionName, bucket, path: storagePath } = location;
|
|
408
|
+
const credentials = await chooseS3Credentials(ctx, signal);
|
|
409
|
+
if (!credentials) return false;
|
|
410
|
+
const contentChoice = await ctx.ui.select(
|
|
411
|
+
"Choose an initial sync preset",
|
|
412
|
+
["Recommended Pi settings", "Minimal settings", "Cancel"],
|
|
413
|
+
{ signal },
|
|
343
414
|
);
|
|
344
|
-
if (!
|
|
345
|
-
const contentChoice = await ctx.ui.select("Choose an initial sync preset", [
|
|
346
|
-
"Recommended Pi settings",
|
|
347
|
-
"Minimal settings",
|
|
348
|
-
"Cancel",
|
|
349
|
-
]);
|
|
350
|
-
if (!contentChoice || contentChoice === "Cancel") return false;
|
|
415
|
+
if (signal?.aborted || !contentChoice || contentChoice === "Cancel") return false;
|
|
351
416
|
const syncFiles =
|
|
352
|
-
contentChoice === "Minimal settings"
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
"
|
|
357
|
-
|
|
358
|
-
|
|
417
|
+
contentChoice === "Minimal settings"
|
|
418
|
+
? ["settings.json", "AGENTS.md"]
|
|
419
|
+
: [...DEFAULT_SYNC_INCLUDE];
|
|
420
|
+
const automaticChoice = await ctx.ui.select(
|
|
421
|
+
"Automatic sync for this setup",
|
|
422
|
+
["Enable automatic sync", "Keep automatic sync off", "Cancel"],
|
|
423
|
+
{ signal },
|
|
424
|
+
);
|
|
425
|
+
if (signal?.aborted || !automaticChoice || automaticChoice === "Cancel") return false;
|
|
359
426
|
const sessionChoice = await ctx.ui.select(
|
|
360
427
|
"Session conversations\n\nSessions can contain prompts, tool output, paths, screenshots, and secrets.",
|
|
361
428
|
["Keep sessions off (recommended)", "Include session conversations", "Cancel"],
|
|
429
|
+
{ signal },
|
|
362
430
|
);
|
|
363
|
-
if (!sessionChoice || sessionChoice === "Cancel") return false;
|
|
431
|
+
if (signal?.aborted || !sessionChoice || sessionChoice === "Cancel") return false;
|
|
364
432
|
const syncSessions = sessionChoice === "Include session conversations";
|
|
365
433
|
if (
|
|
366
434
|
syncSessions &&
|
|
367
435
|
!(await ctx.ui.confirm(
|
|
368
436
|
"Include session conversations?",
|
|
369
437
|
"I understand that session JSONL can contain prompts, tool output, paths, screenshots, and secrets.",
|
|
438
|
+
{ signal },
|
|
370
439
|
))
|
|
371
440
|
) {
|
|
372
441
|
return false;
|
|
373
442
|
}
|
|
374
443
|
const autoSync = automaticChoice === "Enable automatic sync";
|
|
375
|
-
const hasEnvironmentCredentials = Boolean(
|
|
376
|
-
(process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID) &&
|
|
377
|
-
(process.env.PI_SYNC_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY),
|
|
378
|
-
);
|
|
379
|
-
const credentialSummary =
|
|
380
|
-
credentialChoice === "Use environment credentials"
|
|
381
|
-
? hasEnvironmentCredentials
|
|
382
|
-
? "Environment credentials detected"
|
|
383
|
-
: "Environment credentials are currently missing"
|
|
384
|
-
: `Complete accessKeyId and secretAccessKey in ${localConfigPath()}`;
|
|
385
444
|
const choice = await ctx.ui.select(
|
|
386
445
|
[
|
|
387
|
-
"Review setup",
|
|
446
|
+
"Review sync setup",
|
|
388
447
|
"",
|
|
389
|
-
`
|
|
390
|
-
`Storage
|
|
448
|
+
`Sync setup: ${safeTerminalText(targetName)}`,
|
|
449
|
+
`Storage connection: ${safeTerminalText(connectionName)} (${preset})`,
|
|
391
450
|
`Endpoint: ${safeTerminalText(endpoint)}`,
|
|
392
451
|
`Bucket: ${safeTerminalText(bucket)}`,
|
|
393
|
-
`
|
|
452
|
+
`Storage location: ${safeTerminalText(storagePath)}`,
|
|
394
453
|
"Bucket must already exist. pi-sync will not create it.",
|
|
395
|
-
`
|
|
396
|
-
`
|
|
397
|
-
`Credentials: ${safeTerminalText(
|
|
454
|
+
`Included content: ${syncFiles.length} built-in groups · Sessions: ${syncSessions ? "On — privacy warning acknowledged" : "Off"}`,
|
|
455
|
+
`Automatic sync: ${autoSync ? "On" : "Off"}`,
|
|
456
|
+
`Credentials: ${safeTerminalText(credentials.summary)}`,
|
|
398
457
|
].join("\n"),
|
|
399
|
-
["Save setup", "Cancel"],
|
|
458
|
+
["Save sync setup", "Cancel"],
|
|
459
|
+
{ signal },
|
|
400
460
|
);
|
|
401
|
-
if (choice !== "Save setup") return false;
|
|
402
|
-
await
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
461
|
+
if (signal?.aborted || choice !== "Save sync setup") return false;
|
|
462
|
+
await saveNewV3Settings(
|
|
463
|
+
{
|
|
464
|
+
setupName: targetName,
|
|
465
|
+
connectionName,
|
|
466
|
+
connection: {
|
|
467
|
+
type: "s3",
|
|
468
|
+
endpoint,
|
|
469
|
+
region,
|
|
470
|
+
credentials: {
|
|
471
|
+
accessKeyId: credentials.profileFields.accessKeyId ?? "",
|
|
472
|
+
secretAccessKey: credentials.profileFields.secretAccessKey ?? "",
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
setup: {
|
|
476
|
+
storage: { connection: connectionName, bucket, path: storagePath },
|
|
477
|
+
sync: {
|
|
478
|
+
include: [...syncFiles, ...(syncSessions ? ["sessions"] : [])],
|
|
479
|
+
automatic: autoSync,
|
|
480
|
+
},
|
|
481
|
+
},
|
|
418
482
|
},
|
|
419
|
-
|
|
483
|
+
signal,
|
|
484
|
+
);
|
|
485
|
+
if (signal?.aborted) return false;
|
|
420
486
|
await refreshTargetCompletions();
|
|
487
|
+
if (signal?.aborted) return true;
|
|
421
488
|
ctx.ui.notify(
|
|
422
|
-
|
|
423
|
-
? `
|
|
424
|
-
: `Saved
|
|
489
|
+
credentials.ready
|
|
490
|
+
? `Sync setup “${safeTerminalText(targetName)}” is ready. Use Sync now when ready.`
|
|
491
|
+
: `Saved sync setup “${safeTerminalText(targetName)}”; add credentials before syncing.`,
|
|
425
492
|
"info",
|
|
426
493
|
);
|
|
427
494
|
return true;
|
|
428
495
|
}
|
|
429
496
|
|
|
430
|
-
async function
|
|
497
|
+
async function showSetupSwitcher(
|
|
498
|
+
ctx: ExtensionCommandContext,
|
|
499
|
+
runRoute: RunRoute,
|
|
500
|
+
selectedName?: string,
|
|
501
|
+
signal?: AbortSignal,
|
|
502
|
+
) {
|
|
431
503
|
const raw = await readLocalConfigObject();
|
|
432
|
-
if (
|
|
433
|
-
|
|
504
|
+
if (signal?.aborted) return false;
|
|
505
|
+
if (raw?.version !== 3) {
|
|
506
|
+
ctx.ui.notify("Add a second sync setup before switching setups.", "info");
|
|
434
507
|
return false;
|
|
435
508
|
}
|
|
436
|
-
const targets = ownRecord(raw.
|
|
509
|
+
const targets = ownRecord(raw.syncSetups);
|
|
437
510
|
if (!targets) {
|
|
438
|
-
ctx.ui.notify("No sync
|
|
511
|
+
ctx.ui.notify("No sync setups are configured.", "warning");
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
const active = typeof raw.activeSyncSetup === "string" ? raw.activeSyncSetup : undefined;
|
|
515
|
+
let name = selectedName;
|
|
516
|
+
if (!name) {
|
|
517
|
+
const profiles = ownRecord(raw.storageConnections);
|
|
518
|
+
const labels = new Map<string, string>();
|
|
519
|
+
for (const candidate of Object.keys(targets).sort((left, right) => left.localeCompare(right))) {
|
|
520
|
+
const target = ownRecord(targets[candidate]);
|
|
521
|
+
const storage = ownRecord(target?.storage);
|
|
522
|
+
const profileName = typeof storage?.connection === "string" ? storage.connection : undefined;
|
|
523
|
+
const profile = profileName && profiles ? ownRecord(profiles[profileName]) : undefined;
|
|
524
|
+
const location = profile
|
|
525
|
+
? profile.type === "git"
|
|
526
|
+
? `${String(storage?.branch ?? "missing branch")}:${String(storage?.path ?? "missing path")}`
|
|
527
|
+
: profile.type === "s3"
|
|
528
|
+
? `${String(storage?.bucket ?? "missing bucket")}/${String(storage?.path ?? "missing path")}`
|
|
529
|
+
: String(storage?.path ?? "missing path")
|
|
530
|
+
: `invalid: missing connection ${profileName ?? "reference"}`;
|
|
531
|
+
const label = `${safeTerminalText(candidate)}${candidate === active ? " (current)" : ""} · ${safeTerminalText(profileName ?? "unknown")} · ${safeTerminalText(location)}`;
|
|
532
|
+
labels.set(label, candidate);
|
|
533
|
+
}
|
|
534
|
+
const selected = await ctx.ui.select(
|
|
535
|
+
`Switch sync setup\n\nCurrent sync setup: ${safeTerminalText(active ?? "none")}`,
|
|
536
|
+
[...labels.keys(), BACK],
|
|
537
|
+
{ signal },
|
|
538
|
+
);
|
|
539
|
+
if (!selected || selected === BACK) return false;
|
|
540
|
+
name = labels.get(selected);
|
|
541
|
+
}
|
|
542
|
+
if (!name || !Object.hasOwn(targets, name)) {
|
|
543
|
+
ctx.ui.notify(
|
|
544
|
+
`Sync setup “${safeTerminalText(name ?? "unknown")}” no longer exists.`,
|
|
545
|
+
"warning",
|
|
546
|
+
);
|
|
439
547
|
return false;
|
|
440
548
|
}
|
|
441
|
-
const active = typeof raw.activeTarget === "string" ? raw.activeTarget : undefined;
|
|
442
|
-
const profiles = ownRecord(raw.profiles);
|
|
443
|
-
const names = Object.keys(targets).sort((left, right) => left.localeCompare(right));
|
|
444
|
-
const labels = new Map<string, string>();
|
|
445
|
-
for (const name of names) {
|
|
446
|
-
const target = ownRecord(targets[name]);
|
|
447
|
-
const profileName = typeof target?.profile === "string" ? target.profile : undefined;
|
|
448
|
-
const profile = profileName && profiles ? ownRecord(profiles[profileName]) : undefined;
|
|
449
|
-
const label = [
|
|
450
|
-
`${safeTerminalText(name)}${name === active ? " (current)" : ""}`,
|
|
451
|
-
profile
|
|
452
|
-
? `${safeTerminalText(profileName ?? "unknown")} · ${safeTerminalText(String(target?.bucket ?? "missing bucket"))}`
|
|
453
|
-
: `Invalid: missing storage profile ${safeTerminalText(profileName ?? "reference")}`,
|
|
454
|
-
`${normalizeSyncFiles(target?.syncFiles as string[] | undefined).length} groups · Sessions: ${target?.syncSessions === true ? "On" : "Off"}`,
|
|
455
|
-
].join(" · ");
|
|
456
|
-
labels.set(label, name);
|
|
457
|
-
}
|
|
458
|
-
const options = [...labels.keys(), BACK];
|
|
459
|
-
const selected = await ctx.ui.select(
|
|
460
|
-
`Switch target\n\nCurrent target: ${safeTerminalText(active ?? "none")}`,
|
|
461
|
-
options,
|
|
462
|
-
);
|
|
463
|
-
if (!selected || selected === BACK) return false;
|
|
464
|
-
const name = labels.get(selected) ?? selected.replace(/ \(current\)$/u, "");
|
|
465
549
|
if (name === active) {
|
|
466
|
-
ctx.ui.notify(`
|
|
550
|
+
ctx.ui.notify(`Sync setup “${safeTerminalText(name)}” is already current.`, "info");
|
|
467
551
|
return false;
|
|
468
552
|
}
|
|
469
|
-
let config:
|
|
553
|
+
let config: AnySyncConfig;
|
|
470
554
|
try {
|
|
471
555
|
config = await loadConfig(name);
|
|
556
|
+
if (signal?.aborted) return false;
|
|
472
557
|
} catch (error) {
|
|
473
558
|
ctx.ui.notify(
|
|
474
|
-
`Cannot use
|
|
559
|
+
`Cannot use sync setup “${safeTerminalText(name)}”: ${safeTerminalText(errorMessage(error))}`,
|
|
475
560
|
"error",
|
|
476
561
|
);
|
|
477
562
|
return false;
|
|
478
563
|
}
|
|
479
|
-
const
|
|
564
|
+
const onSwitch = await loadOnSwitch();
|
|
565
|
+
if (signal?.aborted) return false;
|
|
480
566
|
const switchEffect =
|
|
481
|
-
|
|
482
|
-
? "After switching, pi-sync will ask whether to review a pull for
|
|
483
|
-
:
|
|
484
|
-
? "After switching, pi-sync will check
|
|
567
|
+
onSwitch === "ask-before-pull"
|
|
568
|
+
? "After switching, pi-sync will ask whether to review a pull for this setup."
|
|
569
|
+
: onSwitch === "pull-after-switch"
|
|
570
|
+
? "After switching, pi-sync will check this setup and show exact changes before applying them."
|
|
485
571
|
: "After switching, pi-sync will not pull or modify synced files.";
|
|
486
572
|
const choice = await ctx.ui.select(
|
|
487
573
|
[
|
|
488
|
-
"Switch
|
|
574
|
+
"Switch sync setup",
|
|
489
575
|
"",
|
|
490
576
|
`From: ${safeTerminalText(active ?? "none")}`,
|
|
491
577
|
`To: ${safeTerminalText(name)}`,
|
|
492
|
-
`Storage: ${
|
|
493
|
-
`
|
|
494
|
-
`
|
|
578
|
+
`Storage: ${backendStorageDescription(config)}`,
|
|
579
|
+
`Included content: ${config.include.length} paths`,
|
|
580
|
+
`Automatic sync: ${config.automatic ? "On" : "Off"} · Sessions: ${config.include.includes("sessions") ? "On" : "Off"}`,
|
|
495
581
|
"",
|
|
496
582
|
switchEffect,
|
|
497
583
|
].join("\n"),
|
|
498
584
|
[`Switch to ${safeTerminalText(name)}`, "Cancel"],
|
|
585
|
+
{ signal },
|
|
499
586
|
);
|
|
500
|
-
if (choice !== `Switch to ${safeTerminalText(name)}`) return false;
|
|
501
|
-
|
|
502
|
-
|
|
587
|
+
if (signal?.aborted || choice !== `Switch to ${safeTerminalText(name)}`) return false;
|
|
588
|
+
try {
|
|
589
|
+
const result = await useSyncSetup(
|
|
503
590
|
ctx,
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
591
|
+
name,
|
|
592
|
+
(selectedTarget) =>
|
|
593
|
+
runCancellableOperation(
|
|
594
|
+
ctx,
|
|
595
|
+
`Pulling sync setup “${safeTerminalText(name)}”…`,
|
|
596
|
+
"pull",
|
|
597
|
+
runRoute,
|
|
598
|
+
true,
|
|
599
|
+
null,
|
|
600
|
+
selectedTarget,
|
|
601
|
+
),
|
|
602
|
+
onSwitch,
|
|
603
|
+
signal,
|
|
604
|
+
syncConfigReviewIdentity(config),
|
|
605
|
+
);
|
|
606
|
+
return result.pullApplied ? "pull-attempted" : "switched";
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (signal?.aborted) return false;
|
|
609
|
+
ctx.ui.notify(
|
|
610
|
+
`Sync setup “${safeTerminalText(name)}” was not switched: ${safeTerminalText(errorMessage(error))}`,
|
|
611
|
+
"error",
|
|
612
|
+
);
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
513
615
|
}
|
|
514
616
|
|
|
515
|
-
async function
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
617
|
+
async function showSyncSetupManager(
|
|
618
|
+
ctx: ExtensionCommandContext,
|
|
619
|
+
runRoute: RunRoute,
|
|
620
|
+
signal?: AbortSignal,
|
|
621
|
+
) {
|
|
622
|
+
return showSyncSetups(
|
|
623
|
+
ctx,
|
|
624
|
+
{
|
|
625
|
+
add: async (setupSignal) => {
|
|
626
|
+
await showAddTarget(ctx, setupSignal);
|
|
627
|
+
},
|
|
628
|
+
edit: async (name, setupSignal) => {
|
|
629
|
+
await showEditTarget(ctx, name, setupSignal);
|
|
630
|
+
},
|
|
631
|
+
makeCurrent: async (name, setupSignal) => {
|
|
632
|
+
const result = await showSetupSwitcher(ctx, runRoute, name, setupSignal);
|
|
633
|
+
return result === "pull-attempted" ? "exit" : undefined;
|
|
634
|
+
},
|
|
635
|
+
remove: async (name, setupSignal) => {
|
|
636
|
+
await showRemoveTarget(ctx, name, setupSignal);
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
signal,
|
|
640
|
+
);
|
|
528
641
|
}
|
|
529
642
|
|
|
530
|
-
async function showAddTarget(ctx: ExtensionCommandContext) {
|
|
643
|
+
async function showAddTarget(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
531
644
|
let raw = await readLocalConfigObject();
|
|
532
|
-
if (
|
|
533
|
-
|
|
534
|
-
if (raw.version !==
|
|
535
|
-
const choice = await ctx.ui.select(
|
|
536
|
-
[
|
|
537
|
-
"Upgrade settings for multiple targets",
|
|
538
|
-
"",
|
|
539
|
-
"The existing destination, unknown fields, and local sync state will be preserved.",
|
|
540
|
-
"An exact private backup is created before the atomic conversion.",
|
|
541
|
-
].join("\n"),
|
|
542
|
-
["Upgrade settings", "Cancel"],
|
|
543
|
-
);
|
|
544
|
-
if (choice !== "Upgrade settings") return;
|
|
545
|
-
const currentTarget = await requiredInput(ctx, "Name the existing sync target", "default");
|
|
546
|
-
if (!currentTarget) return;
|
|
547
|
-
const currentProfile = await requiredInput(ctx, "Name the existing storage profile", "default");
|
|
548
|
-
if (!currentProfile) return;
|
|
549
|
-
const migration = await migrateLegacySettings(currentTarget, currentProfile);
|
|
645
|
+
if (signal?.aborted) return;
|
|
646
|
+
if (!raw) return void ctx.ui.notify("Set up the first sync setup before adding another.", "info");
|
|
647
|
+
if (raw.version !== 3) {
|
|
550
648
|
ctx.ui.notify(
|
|
551
|
-
|
|
552
|
-
"
|
|
649
|
+
"Version 1 and version 2 settings are unsupported and are never migrated.",
|
|
650
|
+
"error",
|
|
553
651
|
);
|
|
554
|
-
raw = migration.settings;
|
|
555
|
-
}
|
|
556
|
-
const profiles = ownRecord(raw.profiles);
|
|
557
|
-
if (!profiles || Object.keys(profiles).length === 0) {
|
|
558
|
-
ctx.ui.notify("Add a storage profile before adding a sync target.", "warning");
|
|
559
652
|
return;
|
|
560
653
|
}
|
|
561
|
-
|
|
654
|
+
let profiles = ownRecord(raw.storageConnections) ?? {};
|
|
655
|
+
const name = await requiredInput(ctx, "Name the new sync setup", "work", signal);
|
|
562
656
|
if (!name) return;
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
"
|
|
566
|
-
|
|
657
|
+
const createConnection = "Add a new storage connection…";
|
|
658
|
+
let profile = await ctx.ui.select(
|
|
659
|
+
"Choose a storage connection",
|
|
660
|
+
[...Object.keys(profiles).sort(), createConnection, "Cancel"],
|
|
661
|
+
{ signal },
|
|
662
|
+
);
|
|
567
663
|
if (!profile || profile === "Cancel") return;
|
|
568
|
-
|
|
664
|
+
if (profile === createConnection) {
|
|
665
|
+
const previousNames = new Set(Object.keys(profiles));
|
|
666
|
+
if (!(await showAddStorageConnection(ctx, signal))) return;
|
|
667
|
+
if (signal?.aborted) return;
|
|
668
|
+
raw = (await readLocalConfigObject()) ?? raw;
|
|
669
|
+
if (signal?.aborted) return;
|
|
670
|
+
profiles = ownRecord(raw.storageConnections) ?? {};
|
|
671
|
+
profile = Object.keys(profiles).find((candidate) => !previousNames.has(candidate));
|
|
672
|
+
if (!profile) return;
|
|
673
|
+
}
|
|
674
|
+
const storageKind = ownRecord(profiles[profile])?.type;
|
|
675
|
+
if (storageKind === "webdav") {
|
|
676
|
+
const saved = await showAddWebDavTarget(ctx, name, profile, signal);
|
|
677
|
+
if (signal?.aborted) return;
|
|
678
|
+
if (saved) await refreshTargetCompletions();
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (storageKind === "git") {
|
|
682
|
+
const saved = await showAddGitTarget(ctx, name, profile, signal);
|
|
683
|
+
if (signal?.aborted) return;
|
|
684
|
+
if (saved) await refreshTargetCompletions();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const location = await chooseAdditionalRemoteLocation(ctx, raw, profile, name, signal);
|
|
569
688
|
if (!location) return;
|
|
570
|
-
const { bucket,
|
|
571
|
-
const preset = await ctx.ui.select(
|
|
572
|
-
"
|
|
573
|
-
"Minimal settings",
|
|
574
|
-
|
|
575
|
-
|
|
689
|
+
const { bucket, path: storagePath } = location;
|
|
690
|
+
const preset = await ctx.ui.select(
|
|
691
|
+
"Choose included content",
|
|
692
|
+
["Recommended Pi settings", "Minimal settings", "Cancel"],
|
|
693
|
+
{ signal },
|
|
694
|
+
);
|
|
576
695
|
if (!preset || preset === "Cancel") return;
|
|
577
696
|
const syncFiles =
|
|
578
|
-
preset === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...
|
|
579
|
-
const overlapsExistingTarget = Object.values(ownRecord(raw.
|
|
580
|
-
const existing =
|
|
581
|
-
|
|
582
|
-
const selected =
|
|
697
|
+
preset === "Minimal settings" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_INCLUDE];
|
|
698
|
+
const overlapsExistingTarget = Object.values(ownRecord(raw.syncSetups) ?? {}).some((value) => {
|
|
699
|
+
const existing = ownRecord(value);
|
|
700
|
+
const sync = ownRecord(existing?.sync);
|
|
701
|
+
const selected = syncIncludeSelection(
|
|
702
|
+
Array.isArray(sync?.include) ? sync.include : [],
|
|
703
|
+
).builtIns;
|
|
583
704
|
return selected.some((item) => syncFiles.includes(item));
|
|
584
705
|
});
|
|
585
706
|
const choice = await ctx.ui.select(
|
|
586
707
|
[
|
|
587
|
-
"Review new sync
|
|
708
|
+
"Review new sync setup",
|
|
588
709
|
"",
|
|
589
|
-
`
|
|
590
|
-
`Storage
|
|
710
|
+
`Sync setup: ${safeTerminalText(name)}`,
|
|
711
|
+
`Storage connection: ${safeTerminalText(profile)}`,
|
|
591
712
|
`Bucket: ${safeTerminalText(bucket)}`,
|
|
592
|
-
`
|
|
713
|
+
`Storage location: ${safeTerminalText(storagePath)}`,
|
|
593
714
|
"Bucket must already exist. pi-sync will not create it.",
|
|
594
|
-
`
|
|
715
|
+
`Included content: ${syncFiles.length} built-in groups · Sessions: Off`,
|
|
595
716
|
...(overlapsExistingTarget
|
|
596
717
|
? [
|
|
597
|
-
"Warning: this
|
|
718
|
+
"Warning: this setup shares local content with another setup; only the current setup syncs automatically.",
|
|
598
719
|
]
|
|
599
720
|
: []),
|
|
600
|
-
"
|
|
721
|
+
"Adding this setup does not sync or modify remote data.",
|
|
601
722
|
].join("\n"),
|
|
602
|
-
["Add
|
|
723
|
+
["Add sync setup", "Cancel"],
|
|
724
|
+
{ signal },
|
|
603
725
|
);
|
|
604
|
-
if (choice !== "Add
|
|
605
|
-
await
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
});
|
|
726
|
+
if (signal?.aborted || choice !== "Add sync setup") return;
|
|
727
|
+
await addSyncSetup(
|
|
728
|
+
name,
|
|
729
|
+
{
|
|
730
|
+
storage: { connection: profile, bucket, path: storagePath },
|
|
731
|
+
sync: { include: syncFiles, automatic: true },
|
|
732
|
+
},
|
|
733
|
+
signal,
|
|
734
|
+
);
|
|
735
|
+
if (signal?.aborted) return;
|
|
615
736
|
await refreshTargetCompletions();
|
|
616
|
-
ctx.ui.notify(`Added sync
|
|
737
|
+
ctx.ui.notify(`Added sync setup “${safeTerminalText(name)}”.`, "info");
|
|
617
738
|
}
|
|
618
739
|
|
|
619
|
-
async function
|
|
620
|
-
const partial = await loadPartialConfig();
|
|
621
|
-
if (
|
|
622
|
-
|
|
740
|
+
async function showEditTarget(ctx: ExtensionCommandContext, name: string, signal?: AbortSignal) {
|
|
741
|
+
const partial = await loadPartialConfig(name);
|
|
742
|
+
if (signal?.aborted) return;
|
|
743
|
+
if (!partial.setupName) {
|
|
744
|
+
ctx.ui.notify("Create version 3 settings before editing a named sync setup.", "info");
|
|
623
745
|
return;
|
|
624
746
|
}
|
|
625
|
-
|
|
747
|
+
if (partial.storageKind === "webdav") {
|
|
748
|
+
await showEditWebDavTarget(ctx, partial, signal);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (partial.storageKind === "git") {
|
|
752
|
+
await showEditGitTarget(ctx, partial, signal);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const bucket = await requiredInput(ctx, "Bucket", partial.bucket ?? "pi-sync", signal);
|
|
626
756
|
if (!bucket) return;
|
|
627
|
-
const
|
|
628
|
-
if (!
|
|
629
|
-
const
|
|
630
|
-
if (!namespace) return;
|
|
757
|
+
const storagePath = await requiredInput(ctx, "Storage path", partial.storagePath, signal);
|
|
758
|
+
if (!storagePath) return;
|
|
759
|
+
const normalizedPath = storagePath.replace(/^\/+|\/+$/gu, "");
|
|
631
760
|
const choice = await ctx.ui.select(
|
|
632
761
|
[
|
|
633
|
-
`Review
|
|
762
|
+
`Review sync setup “${safeTerminalText(partial.setupName)}”`,
|
|
634
763
|
"",
|
|
635
764
|
`Bucket: ${safeTerminalText(partial.bucket ?? "missing")} → ${safeTerminalText(bucket)}`,
|
|
636
|
-
`
|
|
637
|
-
|
|
638
|
-
"Saving changes future sync destination only; it does not move or delete remote data.",
|
|
765
|
+
`Storage path: ${safeTerminalText(partial.storagePath ?? "missing")} → ${safeTerminalText(normalizedPath)}`,
|
|
766
|
+
"Saving changes the future storage location only; it does not move or delete remote data.",
|
|
639
767
|
].join("\n"),
|
|
640
|
-
["Save
|
|
641
|
-
|
|
642
|
-
if (choice !== "Save target") return;
|
|
643
|
-
await updateSyncTarget(partial.target, (target) => ({ ...target, bucket, prefix, namespace }));
|
|
644
|
-
ctx.ui.notify(`Saved target “${safeTerminalText(partial.target)}”.`, "info");
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
async function showStorageProfiles(ctx: ExtensionCommandContext) {
|
|
648
|
-
const raw = await readLocalConfigObject();
|
|
649
|
-
if (raw?.version !== 2) {
|
|
650
|
-
ctx.ui.notify("Upgrade settings before managing storage profiles.", "info");
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
const profiles = ownRecord(raw.profiles) ?? {};
|
|
654
|
-
const selected = await ctx.ui.select("Storage profiles", [
|
|
655
|
-
"Add storage profile",
|
|
656
|
-
...Object.keys(profiles).sort(),
|
|
657
|
-
BACK,
|
|
658
|
-
]);
|
|
659
|
-
if (!selected || selected === BACK) return;
|
|
660
|
-
if (selected === "Add storage profile") {
|
|
661
|
-
await showAddStorageProfile(ctx);
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
const action = await ctx.ui.select(`Storage profile “${safeTerminalText(selected)}”`, [
|
|
665
|
-
"Edit connection",
|
|
666
|
-
"Remove storage profile",
|
|
667
|
-
BACK,
|
|
668
|
-
]);
|
|
669
|
-
if (!action || action === BACK) return;
|
|
670
|
-
if (action === "Remove storage profile") {
|
|
671
|
-
const confirmed = await ctx.ui.confirm(
|
|
672
|
-
"Remove storage profile?",
|
|
673
|
-
`Remove local profile “${safeTerminalText(selected)}”? Remote buckets and snapshots are not deleted.`,
|
|
674
|
-
);
|
|
675
|
-
if (!confirmed) return;
|
|
676
|
-
await removeStorageProfile(selected);
|
|
677
|
-
ctx.ui.notify(`Removed storage profile “${safeTerminalText(selected)}”.`, "info");
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
const profile = ownRecord(profiles[selected]);
|
|
681
|
-
if (!profile) return;
|
|
682
|
-
const endpoint = await requiredInput(
|
|
683
|
-
ctx,
|
|
684
|
-
"Endpoint",
|
|
685
|
-
String(profile.endpoint ?? "https://s3.example.com"),
|
|
686
|
-
);
|
|
687
|
-
if (!endpoint) return;
|
|
688
|
-
const region = await requiredInput(ctx, "Region", String(profile.region ?? "auto"));
|
|
689
|
-
if (!region) return;
|
|
690
|
-
const save = await ctx.ui.select(
|
|
691
|
-
`Review connection\n\nProfile: ${safeTerminalText(selected)}\nEndpoint: ${safeTerminalText(endpoint)}\nRegion: ${safeTerminalText(region)}\nCredentials remain unchanged and are never shown.`,
|
|
692
|
-
["Save profile", "Cancel"],
|
|
768
|
+
["Save sync setup", "Cancel"],
|
|
769
|
+
{ signal },
|
|
693
770
|
);
|
|
694
|
-
if (
|
|
695
|
-
await
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
ctx,
|
|
708
|
-
"Name the storage profile",
|
|
709
|
-
preset === "Cloudflare R2" ? "r2" : "s3",
|
|
710
|
-
);
|
|
711
|
-
if (!name) return;
|
|
712
|
-
const endpoint = await requiredInput(
|
|
713
|
-
ctx,
|
|
714
|
-
"Endpoint",
|
|
715
|
-
preset === "Cloudflare R2"
|
|
716
|
-
? "https://<account-id>.r2.cloudflarestorage.com"
|
|
717
|
-
: "https://s3.example.com",
|
|
718
|
-
);
|
|
719
|
-
if (!endpoint) return;
|
|
720
|
-
const region =
|
|
721
|
-
preset === "Cloudflare R2" ? "auto" : await requiredInput(ctx, "Region", "us-east-1");
|
|
722
|
-
if (!region) return;
|
|
723
|
-
const save = await ctx.ui.select(
|
|
724
|
-
`Review storage profile\n\nName: ${safeTerminalText(name)}\nType: ${preset}\nEndpoint: ${safeTerminalText(endpoint)}\nRegion: ${safeTerminalText(region)}\nAdd credentials privately in ${safeTerminalText(localConfigPath())} or use environment credentials.`,
|
|
725
|
-
["Add profile", "Cancel"],
|
|
771
|
+
if (signal?.aborted || choice !== "Save sync setup") return;
|
|
772
|
+
await updateSyncSetup(
|
|
773
|
+
partial.setupName,
|
|
774
|
+
(setup) => {
|
|
775
|
+
if (typeof setup.storage.bucket !== "string") {
|
|
776
|
+
throw new Error("Sync setup storage type changed; reopen it.");
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
...setup,
|
|
780
|
+
storage: { ...setup.storage, bucket, path: normalizedPath },
|
|
781
|
+
};
|
|
782
|
+
},
|
|
783
|
+
{ expectedStorage: partial, signal },
|
|
726
784
|
);
|
|
727
|
-
if (
|
|
728
|
-
|
|
729
|
-
kind: preset === "Cloudflare R2" ? "r2" : "s3-compatible",
|
|
730
|
-
endpoint,
|
|
731
|
-
region,
|
|
732
|
-
});
|
|
733
|
-
ctx.ui.notify(`Added storage profile “${safeTerminalText(name)}”.`, "info");
|
|
785
|
+
if (signal?.aborted) return;
|
|
786
|
+
ctx.ui.notify(`Saved sync setup “${safeTerminalText(partial.setupName)}”.`, "info");
|
|
734
787
|
}
|
|
735
788
|
|
|
736
|
-
async function showRemoveTarget(ctx: ExtensionCommandContext) {
|
|
737
|
-
const raw = await readLocalConfigObject();
|
|
738
|
-
const targets = ownRecord(raw?.targets);
|
|
739
|
-
if (raw?.version !== 2 || !targets || Object.keys(targets).length === 0) {
|
|
740
|
-
ctx.ui.notify("No named sync targets are available to remove.", "info");
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
const selected = await ctx.ui.select("Remove sync target", [
|
|
744
|
-
...Object.keys(targets).sort(),
|
|
745
|
-
"Cancel",
|
|
746
|
-
]);
|
|
747
|
-
if (!selected || selected === "Cancel") return;
|
|
789
|
+
async function showRemoveTarget(ctx: ExtensionCommandContext, name: string, signal?: AbortSignal) {
|
|
748
790
|
const confirmed = await ctx.ui.confirm(
|
|
749
|
-
"Remove sync
|
|
750
|
-
`Remove local
|
|
791
|
+
"Remove sync setup?",
|
|
792
|
+
`Remove local sync setup “${safeTerminalText(name)}”? Remote data and history are not deleted.`,
|
|
793
|
+
{ signal },
|
|
751
794
|
);
|
|
752
|
-
if (!confirmed) return;
|
|
753
|
-
await
|
|
795
|
+
if (signal?.aborted || !confirmed) return;
|
|
796
|
+
await removeSyncSetup(name, signal);
|
|
797
|
+
if (signal?.aborted) return;
|
|
754
798
|
await refreshTargetCompletions();
|
|
755
799
|
ctx.ui.notify(
|
|
756
|
-
`Removed sync
|
|
800
|
+
`Removed sync setup “${safeTerminalText(name)}”; remote data was not deleted.`,
|
|
757
801
|
"info",
|
|
758
802
|
);
|
|
759
803
|
}
|
|
760
804
|
|
|
761
805
|
async function refreshTargetCompletions() {
|
|
762
|
-
|
|
806
|
+
setSyncSetupCompletions(await configuredSyncSetupNames());
|
|
763
807
|
}
|
|
764
808
|
|
|
765
809
|
async function showRecoveryMenu(ctx: ExtensionCommandContext, runRoute: RunRoute) {
|
|
@@ -779,60 +823,59 @@ async function showRecoveryMenu(ctx: ExtensionCommandContext, runRoute: RunRoute
|
|
|
779
823
|
}
|
|
780
824
|
|
|
781
825
|
interface ChosenRemoteLocation {
|
|
782
|
-
|
|
826
|
+
connectionName: string;
|
|
783
827
|
bucket: string;
|
|
784
|
-
|
|
785
|
-
namespace: string;
|
|
828
|
+
path: string;
|
|
786
829
|
}
|
|
787
830
|
|
|
788
|
-
async function chooseInitialTargetName(ctx: ExtensionCommandContext) {
|
|
789
|
-
const purpose = await ctx.ui.select(
|
|
790
|
-
"
|
|
791
|
-
"Work",
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
]);
|
|
831
|
+
async function chooseInitialTargetName(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
832
|
+
const purpose = await ctx.ui.select(
|
|
833
|
+
"What will this sync setup be used for?",
|
|
834
|
+
["Personal / Home", "Work", "Custom", "Cancel"],
|
|
835
|
+
{ signal },
|
|
836
|
+
);
|
|
795
837
|
if (!purpose || purpose === "Cancel") return undefined;
|
|
796
838
|
if (purpose === "Personal / Home") return "home";
|
|
797
839
|
if (purpose === "Work") return "work";
|
|
798
|
-
return requiredInput(ctx, "Name this sync
|
|
840
|
+
return requiredInput(ctx, "Name this sync setup", "default", signal);
|
|
799
841
|
}
|
|
800
842
|
|
|
801
843
|
async function chooseInitialRemoteLocation(
|
|
802
844
|
ctx: ExtensionCommandContext,
|
|
803
845
|
preset: string,
|
|
804
|
-
|
|
846
|
+
setupName: string,
|
|
847
|
+
signal?: AbortSignal,
|
|
805
848
|
): Promise<ChosenRemoteLocation | undefined> {
|
|
806
|
-
const
|
|
849
|
+
const connectionName = preset === "Cloudflare R2" ? "r2" : "s3";
|
|
807
850
|
const suggested = {
|
|
808
|
-
|
|
851
|
+
connectionName,
|
|
809
852
|
bucket: "pi-sync",
|
|
810
|
-
|
|
811
|
-
namespace: targetName,
|
|
853
|
+
path: `pi-sync/${setupName}`,
|
|
812
854
|
};
|
|
813
855
|
if (preset === "Cloudflare R2") {
|
|
814
856
|
const choice = await ctx.ui.select(
|
|
815
857
|
[
|
|
816
|
-
"Choose
|
|
858
|
+
"Choose storage location",
|
|
817
859
|
"",
|
|
818
|
-
`Suggested storage
|
|
860
|
+
`Suggested storage connection: ${connectionName}`,
|
|
819
861
|
`Suggested bucket: ${suggested.bucket}`,
|
|
820
|
-
`Remote path: ${
|
|
862
|
+
`Remote path: ${safeTerminalText(suggested.path)}`,
|
|
821
863
|
"Bucket must already exist. pi-sync will not create it.",
|
|
822
864
|
].join("\n"),
|
|
823
865
|
["Use suggested location (recommended)", "Customize remote location", "Cancel"],
|
|
866
|
+
{ signal },
|
|
824
867
|
);
|
|
825
868
|
if (!choice || choice === "Cancel") return undefined;
|
|
826
869
|
if (choice === "Use suggested location (recommended)") return suggested;
|
|
827
|
-
return chooseCustomRemoteLocation(ctx,
|
|
870
|
+
return chooseCustomRemoteLocation(ctx, setupName, connectionName, true, signal);
|
|
828
871
|
}
|
|
829
872
|
|
|
830
873
|
const choice = await ctx.ui.select(
|
|
831
874
|
[
|
|
832
|
-
"Choose
|
|
875
|
+
"Choose storage location",
|
|
833
876
|
"",
|
|
834
|
-
`Suggested storage
|
|
835
|
-
`Suggested path: ${
|
|
877
|
+
`Suggested storage connection: ${connectionName}`,
|
|
878
|
+
`Suggested path: ${safeTerminalText(suggested.path)}`,
|
|
836
879
|
"S3 bucket names may need to be globally unique and the bucket must already exist.",
|
|
837
880
|
].join("\n"),
|
|
838
881
|
[
|
|
@@ -840,149 +883,101 @@ async function chooseInitialRemoteLocation(
|
|
|
840
883
|
"Customize remote location",
|
|
841
884
|
"Cancel",
|
|
842
885
|
],
|
|
886
|
+
{ signal },
|
|
843
887
|
);
|
|
844
888
|
if (!choice || choice === "Cancel") return undefined;
|
|
845
889
|
if (choice === "Customize remote location") {
|
|
846
|
-
return chooseCustomRemoteLocation(ctx,
|
|
890
|
+
return chooseCustomRemoteLocation(ctx, setupName, connectionName, true, signal);
|
|
847
891
|
}
|
|
848
|
-
const bucket = await requiredExistingBucket(ctx, "pi-sync-your-name");
|
|
892
|
+
const bucket = await requiredExistingBucket(ctx, "pi-sync-your-name", signal);
|
|
849
893
|
return bucket ? { ...suggested, bucket } : undefined;
|
|
850
894
|
}
|
|
851
895
|
|
|
852
896
|
async function chooseAdditionalRemoteLocation(
|
|
853
897
|
ctx: ExtensionCommandContext,
|
|
854
898
|
settings: Record<string, unknown>,
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
899
|
+
connectionName: string,
|
|
900
|
+
setupName: string,
|
|
901
|
+
signal?: AbortSignal,
|
|
902
|
+
): Promise<Omit<ChosenRemoteLocation, "connectionName"> | undefined> {
|
|
903
|
+
const setups = ownRecord(settings.syncSetups) ?? {};
|
|
904
|
+
const currentSetup =
|
|
905
|
+
typeof settings.activeSyncSetup === "string" ? settings.activeSyncSetup : undefined;
|
|
906
|
+
const candidates = Object.entries(setups)
|
|
907
|
+
.map(([name, value]) => ({ name, storage: ownRecord(ownRecord(value)?.storage) }))
|
|
863
908
|
.filter(
|
|
864
|
-
(item): item is { name: string;
|
|
865
|
-
item.
|
|
909
|
+
(item): item is { name: string; storage: Record<string, unknown> } =>
|
|
910
|
+
item.storage?.connection === connectionName && typeof item.storage.bucket === "string",
|
|
866
911
|
);
|
|
867
912
|
const source =
|
|
868
|
-
candidates.find((item) => item.name ===
|
|
913
|
+
candidates.find((item) => item.name === currentSetup) ??
|
|
869
914
|
candidates.sort((left, right) => left.name.localeCompare(right.name))[0];
|
|
870
915
|
if (source) {
|
|
871
|
-
const
|
|
872
|
-
typeof source.
|
|
916
|
+
const sourcePath =
|
|
917
|
+
typeof source.storage.path === "string" ? source.storage.path : "pi-sync/home";
|
|
918
|
+
const sourceParent = sourcePath.includes("/")
|
|
919
|
+
? sourcePath.slice(0, sourcePath.lastIndexOf("/"))
|
|
920
|
+
: "pi-sync";
|
|
921
|
+
const suggestedPath = `${sourceParent}/${setupName}`;
|
|
873
922
|
const sameBucketLabel = `Same bucket as “${safeTerminalText(source.name)}” (recommended)`;
|
|
874
923
|
const choice = await ctx.ui.select(
|
|
875
924
|
[
|
|
876
|
-
`
|
|
925
|
+
`Storage location for “${safeTerminalText(setupName)}”`,
|
|
877
926
|
"",
|
|
878
|
-
`Recommended bucket: ${safeTerminalText(String(source.
|
|
879
|
-
`Remote path: ${
|
|
880
|
-
"The
|
|
927
|
+
`Recommended bucket: ${safeTerminalText(String(source.storage.bucket))}`,
|
|
928
|
+
`Remote path: ${safeTerminalText(suggestedPath)}`,
|
|
929
|
+
"The complete path and local sync state remain separate.",
|
|
881
930
|
].join("\n"),
|
|
882
931
|
[sameBucketLabel, "Use a different bucket", "Customize remote location", "Cancel"],
|
|
932
|
+
{ signal },
|
|
883
933
|
);
|
|
884
934
|
if (!choice || choice === "Cancel") return undefined;
|
|
885
935
|
if (choice === sameBucketLabel) {
|
|
886
|
-
return {
|
|
887
|
-
bucket: String(source.target.bucket),
|
|
888
|
-
prefix: sourcePrefix,
|
|
889
|
-
namespace: targetName,
|
|
890
|
-
};
|
|
936
|
+
return { bucket: String(source.storage.bucket), path: suggestedPath };
|
|
891
937
|
}
|
|
892
938
|
if (choice === "Use a different bucket") {
|
|
893
|
-
const bucket = await requiredExistingBucket(ctx, "pi-sync");
|
|
894
|
-
return bucket ? { bucket,
|
|
939
|
+
const bucket = await requiredExistingBucket(ctx, "pi-sync", signal);
|
|
940
|
+
return bucket ? { bucket, path: `pi-sync/${setupName}` } : undefined;
|
|
895
941
|
}
|
|
896
|
-
const custom = await chooseCustomRemoteLocation(ctx,
|
|
897
|
-
return custom
|
|
898
|
-
? { bucket: custom.bucket, prefix: custom.prefix, namespace: custom.namespace }
|
|
899
|
-
: undefined;
|
|
942
|
+
const custom = await chooseCustomRemoteLocation(ctx, setupName, connectionName, false, signal);
|
|
943
|
+
return custom ? { bucket: custom.bucket, path: custom.path } : undefined;
|
|
900
944
|
}
|
|
901
945
|
|
|
902
|
-
const
|
|
903
|
-
const isR2 =
|
|
904
|
-
|
|
905
|
-
isCloudflareR2Endpoint(String(profileSettings?.endpoint ?? ""));
|
|
946
|
+
const connectionSettings = ownRecord(ownRecord(settings.storageConnections)?.[connectionName]);
|
|
947
|
+
const isR2 = isCloudflareR2Endpoint(String(connectionSettings?.endpoint ?? ""));
|
|
948
|
+
const suggestedPath = `pi-sync/${setupName}`;
|
|
906
949
|
const suggestedLabel = isR2
|
|
907
950
|
? "Use suggested location (recommended)"
|
|
908
951
|
: "Use existing bucket with suggested path (recommended)";
|
|
909
952
|
const choice = await ctx.ui.select(
|
|
910
|
-
`
|
|
953
|
+
`Storage location for “${safeTerminalText(setupName)}”\n\nSuggested path: ${safeTerminalText(suggestedPath)}`,
|
|
911
954
|
[suggestedLabel, "Customize remote location", "Cancel"],
|
|
955
|
+
{ signal },
|
|
912
956
|
);
|
|
913
957
|
if (!choice || choice === "Cancel") return undefined;
|
|
914
958
|
if (choice === "Customize remote location") {
|
|
915
|
-
const custom = await chooseCustomRemoteLocation(ctx,
|
|
916
|
-
return custom
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
const bucket = await requiredExistingBucket(ctx, "pi-sync-your-name");
|
|
922
|
-
return bucket ? { bucket, prefix: "pi-sync", namespace: targetName } : undefined;
|
|
959
|
+
const custom = await chooseCustomRemoteLocation(ctx, setupName, connectionName, false, signal);
|
|
960
|
+
return custom ? { bucket: custom.bucket, path: custom.path } : undefined;
|
|
961
|
+
}
|
|
962
|
+
if (isR2) return { bucket: "pi-sync", path: suggestedPath };
|
|
963
|
+
const bucket = await requiredExistingBucket(ctx, "pi-sync-your-name", signal);
|
|
964
|
+
return bucket ? { bucket, path: suggestedPath } : undefined;
|
|
923
965
|
}
|
|
924
966
|
|
|
925
967
|
async function chooseCustomRemoteLocation(
|
|
926
968
|
ctx: ExtensionCommandContext,
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
969
|
+
setupName: string,
|
|
970
|
+
initialConnectionName: string,
|
|
971
|
+
customizeConnectionName: boolean,
|
|
972
|
+
signal?: AbortSignal,
|
|
930
973
|
): Promise<ChosenRemoteLocation | undefined> {
|
|
931
|
-
const
|
|
932
|
-
? await requiredInput(ctx, "Storage
|
|
933
|
-
:
|
|
934
|
-
if (!
|
|
935
|
-
const bucket = await requiredExistingBucket(ctx, "pi-sync");
|
|
974
|
+
const connectionName = customizeConnectionName
|
|
975
|
+
? await requiredInput(ctx, "Storage connection name", initialConnectionName, signal)
|
|
976
|
+
: initialConnectionName;
|
|
977
|
+
if (!connectionName) return undefined;
|
|
978
|
+
const bucket = await requiredExistingBucket(ctx, "pi-sync", signal);
|
|
936
979
|
if (!bucket) return undefined;
|
|
937
|
-
const
|
|
938
|
-
if (!
|
|
939
|
-
|
|
940
|
-
return namespace ? { profileName, bucket, prefix, namespace } : undefined;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
function formatRemotePath(prefix: string, namespace: string) {
|
|
944
|
-
return safeTerminalText(`${prefix}/profiles/${namespace}/`);
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
async function requiredExistingBucket(ctx: ExtensionCommandContext, example: string) {
|
|
948
|
-
const value = await ctx.ui.input("Existing bucket", `Example: ${example}`);
|
|
949
|
-
if (value === undefined) return undefined;
|
|
950
|
-
const normalized = value.trim();
|
|
951
|
-
if (!normalized) {
|
|
952
|
-
ctx.ui.notify("Enter the name of an existing R2/S3 bucket, or cancel setup.", "warning");
|
|
953
|
-
return undefined;
|
|
954
|
-
}
|
|
955
|
-
return normalized;
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
async function requiredInput(ctx: ExtensionCommandContext, title: string, placeholder: string) {
|
|
959
|
-
const value = await ctx.ui.input(title, placeholder);
|
|
960
|
-
if (value === undefined) return undefined;
|
|
961
|
-
const normalized = value.trim() || placeholder;
|
|
962
|
-
return normalized.includes("<") || normalized.includes(">") ? undefined : normalized;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
function storageDescription(
|
|
966
|
-
kind: string | undefined,
|
|
967
|
-
endpoint: string | undefined,
|
|
968
|
-
bucket: string | undefined,
|
|
969
|
-
) {
|
|
970
|
-
const label =
|
|
971
|
-
kind === "r2" || isCloudflareR2Endpoint(endpoint) ? "Cloudflare R2" : "S3-compatible";
|
|
972
|
-
return `${label} · ${safeTerminalText(bucket ?? "bucket missing")}`;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
function ownRecord(value: unknown): Record<string, unknown> | undefined {
|
|
976
|
-
return value && typeof value === "object" && !Array.isArray(value)
|
|
977
|
-
? (value as Record<string, unknown>)
|
|
978
|
-
: undefined;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
function safeTerminalText(value: string) {
|
|
982
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
|
|
983
|
-
return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
function errorMessage(error: unknown) {
|
|
987
|
-
return error instanceof Error ? error.message : String(error);
|
|
980
|
+
const storagePath = await requiredInput(ctx, "Storage path", `pi-sync/${setupName}`, signal);
|
|
981
|
+
if (!storagePath) return undefined;
|
|
982
|
+
return { connectionName, bucket, path: storagePath.replace(/^\/+|\/+$/gu, "") };
|
|
988
983
|
}
|