@narumitw/pi-sync 0.31.0 → 0.35.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/src/manager-ui.ts CHANGED
@@ -1,120 +1,117 @@
1
1
  import { BorderedLoader, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { setSyncTargetCompletions } from "./command.js";
2
+ import { setSyncSetupCompletions } from "./command.js";
3
3
  import {
4
4
  activeLocalConfigPath,
5
- configuredTargetNames,
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
- addStorageProfile,
19
- addSyncTarget,
20
- migrateLegacySettings,
21
- removeStorageProfile,
22
- removeSyncTarget,
23
- saveNewV2Settings,
24
- updateStorageProfile,
25
- updateSyncTarget,
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 { DEFAULT_SYNC_FILES } from "./sync-policy.js";
29
- import { type TargetPullOutcome, useSyncTarget } from "./target-switch.js";
30
- import type { SyncConfig } from "./types.js";
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
- "Pull from remote",
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
- "Manage targets & storage",
44
- "History & recovery",
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<TargetPullOutcome | undefined>;
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
- const selected = await ctx.ui.select(state.title, state.actions);
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 MainMenuAction | ContextualMenuAction | "Set up sync" | "Use existing settings"
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 target…", "sync", runRoute, true);
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 target": {
100
- const result = await showTargetSwitcher(ctx, runRoute);
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 target…", "diff", runRoute);
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 "Manage targets & storage":
115
- await showManageMenu(ctx, runRoute);
108
+ case "Sync setups…":
109
+ if ((await showSyncSetupManager(ctx, runRoute, sessionSignal)) === "exit") return;
116
110
  break;
117
- case "History & recovery":
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(ctx: ExtensionCommandContext, runRoute: RunRoute) {
132
- const selected = await ctx.ui.select("More options", [...MORE_MENU_ACTIONS]);
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 === "Manage targets & storage") await showManageMenu(ctx, runRoute);
135
- else if (selected === "History & recovery") await showRecoveryMenu(ctx, runRoute);
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: TargetPullOutcome | undefined;
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(): Promise<{ title: string; actions: string[] }> {
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
- "Pi Sync",
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: ["Pi Sync", "", "Not set up.", "", "What do you want to do?"].join("\n"),
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.targets);
222
- if (raw.version === 2 && configuredTargets && Object.keys(configuredTargets).length === 0) {
248
+ const configuredTargets = ownRecord(raw.syncSetups);
249
+ if (raw.version === 3 && configuredTargets && Object.keys(configuredTargets).length === 0) {
223
250
  return {
224
251
  title: [
225
- "Pi Sync",
252
+ "Manage sync",
226
253
  "",
227
- "No sync targets are configured.",
228
- "Add a target using an existing storage profile.",
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: ["Manage targets & storage", "Help"],
259
+ actions: ["Sync setups…", "Storage connections…", "Help"],
233
260
  };
234
261
  }
235
262
  try {
236
263
  const config = await loadConfig();
237
- const target = config.target ?? "default";
238
- const storage = storageDescription(config.storageKind, config.endpoint, config.bucket);
239
- const warnings = deprecatedPiSyncEnvironmentWarnings();
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 builtInCount = normalizeSyncFiles(config.syncFiles).length;
245
- const extraFileCount = config.extraFiles.length;
246
- const noSyncedContent = builtInCount === 0 && extraFileCount === 0 && !config.syncSessions;
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
- "Pi Sync",
292
+ "Manage sync",
261
293
  "",
262
- `Current target: ${safeTerminalText(target)}`,
294
+ `Current sync setup: ${safeTerminalText(target)}`,
263
295
  `Storage: ${storage}`,
264
- `Synced content: ${builtInCount} built-in group${builtInCount === 1 ? "" : "s"} · ${extraFileCount} extra file${extraFileCount === 1 ? "" : "s"} · Sessions: ${config.syncSessions ? "On" : "Off"}`,
265
- `Auto-sync: ${config.autoSync ? "On" : "Off"}`,
266
- `Last applied snapshot: ${lastAppliedSnapshot}`,
267
- "Remote changes: Not checked",
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
- ? ["", "No synced content is selected. Choose synced content in Settings before syncing."]
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 target", "Status & changes", "More…"]
289
- : [...MAIN_MENU_ACTIONS],
323
+ ? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
324
+ : mainActions,
290
325
  };
291
326
  } catch (error) {
292
- const targets = ownRecord(raw.targets);
327
+ if (signal?.aborted) throw error;
293
328
  return {
294
329
  title: [
295
- "Pi Sync",
330
+ "Manage sync",
296
331
  "",
297
332
  "Settings need attention. Automatic sync is paused.",
298
- `Current target: ${safeTerminalText(typeof raw.activeTarget === "string" ? raw.activeTarget : "default")}`,
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
- export async function showSetupWizard(ctx: ExtensionCommandContext) {
314
- if (ctx.mode !== "tui") return false;
315
- const preset = await ctx.ui.select("Set up sync\n\nWhere will Pi settings be stored?", [
316
- "Cloudflare R2",
317
- "Other S3-compatible storage",
318
- "Cancel",
319
- ]);
320
- if (!preset || preset === "Cancel") return false;
321
- const targetName = await chooseInitialTargetName(ctx);
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 { profileName, bucket, prefix, namespace } = location;
340
- const credentialChoice = await ctx.ui.select(
341
- "Credentials\n\nSecret values are never shown in pi-sync screens.",
342
- ["Use environment credentials", "Create private settings template", "Cancel"],
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 (!credentialChoice || credentialChoice === "Cancel") return false;
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" ? ["settings.json", "AGENTS.md"] : [...DEFAULT_SYNC_FILES];
353
- const automaticChoice = await ctx.ui.select("Automatic sync for this target", [
354
- "Enable automatic sync",
355
- "Keep automatic sync off",
356
- "Cancel",
357
- ]);
358
- if (!automaticChoice || automaticChoice === "Cancel") return false;
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
- `Target: ${safeTerminalText(targetName)}`,
390
- `Storage profile: ${safeTerminalText(profileName)} (${preset})`,
448
+ `Sync setup: ${safeTerminalText(targetName)}`,
449
+ `Storage connection: ${safeTerminalText(connectionName)} (${preset})`,
391
450
  `Endpoint: ${safeTerminalText(endpoint)}`,
392
451
  `Bucket: ${safeTerminalText(bucket)}`,
393
- `Remote path: ${formatRemotePath(prefix, namespace)}`,
452
+ `Storage location: ${safeTerminalText(storagePath)}`,
394
453
  "Bucket must already exist. pi-sync will not create it.",
395
- `Synced content: ${syncFiles.length} built-in groups · Sessions: ${syncSessions ? "On — privacy warning acknowledged" : "Off"}`,
396
- `Auto-sync: ${autoSync ? "On" : "Off"}`,
397
- `Credentials: ${safeTerminalText(credentialSummary)}`,
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 saveNewV2Settings({
403
- targetName,
404
- storageProfileName: profileName,
405
- profile: {
406
- kind: preset === "Cloudflare R2" ? "r2" : "s3-compatible",
407
- endpoint,
408
- region,
409
- },
410
- target: {
411
- bucket,
412
- prefix,
413
- namespace,
414
- autoSync,
415
- syncFiles,
416
- syncSessions,
417
- extraFiles: [],
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
- hasEnvironmentCredentials || credentialChoice !== "Use environment credentials"
423
- ? `Target “${safeTerminalText(targetName)}” is ready. Use Sync now when ready.`
424
- : `Saved target “${safeTerminalText(targetName)}”; add credentials before syncing.`,
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 showTargetSwitcher(ctx: ExtensionCommandContext, runRoute: RunRoute) {
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 (raw?.version !== 2) {
433
- ctx.ui.notify("Add a second sync target before switching targets.", "info");
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.targets);
509
+ const targets = ownRecord(raw.syncSetups);
437
510
  if (!targets) {
438
- ctx.ui.notify("No sync targets are configured.", "warning");
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(`Target “${safeTerminalText(name)}” is already current.`, "info");
550
+ ctx.ui.notify(`Sync setup “${safeTerminalText(name)}” is already current.`, "info");
467
551
  return false;
468
552
  }
469
- let config: SyncConfig;
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 target “${safeTerminalText(name)}”: ${safeTerminalText(errorMessage(error))}`,
559
+ `Cannot use sync setup “${safeTerminalText(name)}”: ${safeTerminalText(errorMessage(error))}`,
475
560
  "error",
476
561
  );
477
562
  return false;
478
563
  }
479
- const targetSwitchAction = await loadTargetSwitchAction();
564
+ const onSwitch = await loadOnSwitch();
565
+ if (signal?.aborted) return false;
480
566
  const switchEffect =
481
- targetSwitchAction === "ask"
482
- ? "After switching, pi-sync will ask whether to review a pull for the target."
483
- : targetSwitchAction === "pull"
484
- ? "After switching, pi-sync will check the target and show exact changes before applying them."
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 target",
574
+ "Switch sync setup",
489
575
  "",
490
576
  `From: ${safeTerminalText(active ?? "none")}`,
491
577
  `To: ${safeTerminalText(name)}`,
492
- `Storage: ${storageDescription(config.storageKind, config.endpoint, config.bucket)}`,
493
- `Synced content: ${normalizeSyncFiles(config.syncFiles).length} built-in groups · ${config.extraFiles.length} extra files`,
494
- `Auto-sync: ${config.autoSync ? "On" : "Off"} · Sessions: ${config.syncSessions ? "On" : "Off"}`,
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
- const result = await useSyncTarget(ctx, name, (selectedTarget) =>
502
- runCancellableOperation(
587
+ if (signal?.aborted || choice !== `Switch to ${safeTerminalText(name)}`) return false;
588
+ try {
589
+ const result = await useSyncSetup(
503
590
  ctx,
504
- `Pulling target “${safeTerminalText(name)}”…`,
505
- "pull",
506
- runRoute,
507
- true,
508
- null,
509
- selectedTarget,
510
- ),
511
- );
512
- return result.pullApplied ? "pull-attempted" : "switched";
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 showManageMenu(ctx: ExtensionCommandContext, _runRoute: RunRoute) {
516
- const selected = await ctx.ui.select("Manage targets & storage", [
517
- "Add sync target",
518
- "Edit current target",
519
- "Manage storage profiles",
520
- "Remove sync target",
521
- BACK,
522
- ]);
523
- if (!selected || selected === BACK) return;
524
- if (selected === "Add sync target") await showAddTarget(ctx);
525
- else if (selected === "Edit current target") await showEditCurrentTarget(ctx);
526
- else if (selected === "Manage storage profiles") await showStorageProfiles(ctx);
527
- else await showRemoveTarget(ctx);
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 (!raw)
533
- return void ctx.ui.notify("Set up the first sync target before adding another.", "info");
534
- if (raw.version !== 2) {
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
- `Upgraded pi-sync settings. Backup: ${safeTerminalText(migration.backupPath)}`,
552
- "info",
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
- const name = await requiredInput(ctx, "Name the new sync target", "work");
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 profile = await ctx.ui.select("Choose storage for this target", [
564
- ...Object.keys(profiles).sort(),
565
- "Cancel",
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
- const location = await chooseAdditionalRemoteLocation(ctx, raw, profile, name);
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, prefix, namespace } = location;
571
- const preset = await ctx.ui.select("Choose synced content", [
572
- "Recommended Pi settings",
573
- "Minimal settings",
574
- "Cancel",
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"] : [...DEFAULT_SYNC_FILES];
579
- const overlapsExistingTarget = Object.values(ownRecord(raw.targets) ?? {}).some((value) => {
580
- const existing =
581
- value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
582
- const selected = normalizeSyncFiles(existing?.syncFiles as string[] | undefined);
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 target",
708
+ "Review new sync setup",
588
709
  "",
589
- `Target: ${safeTerminalText(name)}`,
590
- `Storage profile: ${safeTerminalText(profile)}`,
710
+ `Sync setup: ${safeTerminalText(name)}`,
711
+ `Storage connection: ${safeTerminalText(profile)}`,
591
712
  `Bucket: ${safeTerminalText(bucket)}`,
592
- `Remote path: ${formatRemotePath(prefix, namespace)}`,
713
+ `Storage location: ${safeTerminalText(storagePath)}`,
593
714
  "Bucket must already exist. pi-sync will not create it.",
594
- `Synced content: ${syncFiles.length} built-in groups · Sessions: Off`,
715
+ `Included content: ${syncFiles.length} built-in groups · Sessions: Off`,
595
716
  ...(overlapsExistingTarget
596
717
  ? [
597
- "Warning: this target shares local content with another target; only the current target auto-syncs.",
718
+ "Warning: this setup shares local content with another setup; only the current setup syncs automatically.",
598
719
  ]
599
720
  : []),
600
- "Switching to this target later will not sync automatically.",
721
+ "Adding this setup does not sync or modify remote data.",
601
722
  ].join("\n"),
602
- ["Add target", "Cancel"],
723
+ ["Add sync setup", "Cancel"],
724
+ { signal },
603
725
  );
604
- if (choice !== "Add target") return;
605
- await addSyncTarget(name, {
606
- profile,
607
- bucket,
608
- prefix,
609
- namespace,
610
- autoSync: true,
611
- syncFiles,
612
- syncSessions: false,
613
- extraFiles: [],
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 target “${safeTerminalText(name)}”.`, "info");
737
+ ctx.ui.notify(`Added sync setup “${safeTerminalText(name)}”.`, "info");
617
738
  }
618
739
 
619
- async function showEditCurrentTarget(ctx: ExtensionCommandContext) {
620
- const partial = await loadPartialConfig();
621
- if (partial.settingsVersion !== 2 || !partial.target) {
622
- ctx.ui.notify("Upgrade settings before editing a named target.", "info");
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
- const bucket = await requiredInput(ctx, "Bucket", partial.bucket ?? "pi-sync");
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 prefix = await requiredInput(ctx, "Remote prefix", partial.prefix ?? "pi-sync");
628
- if (!prefix) return;
629
- const namespace = await requiredInput(ctx, "Remote namespace", partial.profile ?? partial.target);
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 target “${safeTerminalText(partial.target)}”`,
762
+ `Review sync setup “${safeTerminalText(partial.setupName)}”`,
634
763
  "",
635
764
  `Bucket: ${safeTerminalText(partial.bucket ?? "missing")} → ${safeTerminalText(bucket)}`,
636
- `Prefix: ${safeTerminalText(partial.prefix ?? "pi-sync")} → ${safeTerminalText(prefix)}`,
637
- `Namespace: ${safeTerminalText(partial.profile ?? partial.target)} → ${safeTerminalText(namespace)}`,
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 target", "Cancel"],
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 (save !== "Save profile") return;
695
- await updateStorageProfile(selected, (current) => ({ ...current, endpoint, region }));
696
- ctx.ui.notify(`Saved storage profile “${safeTerminalText(selected)}”.`, "info");
697
- }
698
-
699
- async function showAddStorageProfile(ctx: ExtensionCommandContext) {
700
- const preset = await ctx.ui.select("Storage type", [
701
- "Cloudflare R2",
702
- "Other S3-compatible storage",
703
- "Cancel",
704
- ]);
705
- if (!preset || preset === "Cancel") return;
706
- const name = await requiredInput(
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 (save !== "Add profile") return;
728
- await addStorageProfile(name, {
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 target?",
750
- `Remove local target “${safeTerminalText(selected)}”? Remote buckets and snapshots are not deleted.`,
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 removeSyncTarget(selected);
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 target “${safeTerminalText(selected)}”; remote data was not deleted.`,
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
- setSyncTargetCompletions(await configuredTargetNames());
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
- profileName: string;
826
+ connectionName: string;
783
827
  bucket: string;
784
- prefix: string;
785
- namespace: string;
828
+ path: string;
786
829
  }
787
830
 
788
- async function chooseInitialTargetName(ctx: ExtensionCommandContext) {
789
- const purpose = await ctx.ui.select("What will this target be used for?", [
790
- "Personal / Home",
791
- "Work",
792
- "Custom",
793
- "Cancel",
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 target", "default");
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
- targetName: string,
846
+ setupName: string,
847
+ signal?: AbortSignal,
805
848
  ): Promise<ChosenRemoteLocation | undefined> {
806
- const profileName = preset === "Cloudflare R2" ? "r2" : "s3";
849
+ const connectionName = preset === "Cloudflare R2" ? "r2" : "s3";
807
850
  const suggested = {
808
- profileName,
851
+ connectionName,
809
852
  bucket: "pi-sync",
810
- prefix: "pi-sync",
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 remote location",
858
+ "Choose storage location",
817
859
  "",
818
- `Suggested storage profile: ${profileName}`,
860
+ `Suggested storage connection: ${connectionName}`,
819
861
  `Suggested bucket: ${suggested.bucket}`,
820
- `Remote path: ${formatRemotePath(suggested.prefix, suggested.namespace)}`,
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, targetName, profileName, true);
870
+ return chooseCustomRemoteLocation(ctx, setupName, connectionName, true, signal);
828
871
  }
829
872
 
830
873
  const choice = await ctx.ui.select(
831
874
  [
832
- "Choose remote location",
875
+ "Choose storage location",
833
876
  "",
834
- `Suggested storage profile: ${profileName}`,
835
- `Suggested path: ${formatRemotePath("pi-sync", targetName)}`,
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, targetName, profileName, true);
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
- profileName: string,
856
- targetName: string,
857
- ): Promise<Omit<ChosenRemoteLocation, "profileName"> | undefined> {
858
- const targets = ownRecord(settings.targets) ?? {};
859
- const activeTarget =
860
- typeof settings.activeTarget === "string" ? settings.activeTarget : undefined;
861
- const candidates = Object.entries(targets)
862
- .map(([name, value]) => ({ name, target: ownRecord(value) }))
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; target: Record<string, unknown> } =>
865
- item.target?.profile === profileName && typeof item.target.bucket === "string",
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 === activeTarget) ??
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 sourcePrefix =
872
- typeof source.target.prefix === "string" ? source.target.prefix : "pi-sync";
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
- `Remote location for “${safeTerminalText(targetName)}”`,
925
+ `Storage location for “${safeTerminalText(setupName)}”`,
877
926
  "",
878
- `Recommended bucket: ${safeTerminalText(String(source.target.bucket))}`,
879
- `Remote path: ${formatRemotePath(sourcePrefix, targetName)}`,
880
- "The namespace and local sync state remain separate.",
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, prefix: "pi-sync", namespace: targetName } : undefined;
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, targetName, profileName, false);
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 profileSettings = ownRecord(ownRecord(settings.profiles)?.[profileName]);
903
- const isR2 =
904
- profileSettings?.kind === "r2" ||
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
- `Remote location for “${safeTerminalText(targetName)}”\n\nSuggested path: ${formatRemotePath("pi-sync", targetName)}`,
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, targetName, profileName, false);
916
- return custom
917
- ? { bucket: custom.bucket, prefix: custom.prefix, namespace: custom.namespace }
918
- : undefined;
919
- }
920
- if (isR2) return { bucket: "pi-sync", prefix: "pi-sync", namespace: targetName };
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
- targetName: string,
928
- initialProfileName: string,
929
- customizeProfileName: boolean,
969
+ setupName: string,
970
+ initialConnectionName: string,
971
+ customizeConnectionName: boolean,
972
+ signal?: AbortSignal,
930
973
  ): Promise<ChosenRemoteLocation | undefined> {
931
- const profileName = customizeProfileName
932
- ? await requiredInput(ctx, "Storage profile name", initialProfileName)
933
- : initialProfileName;
934
- if (!profileName) return undefined;
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 prefix = await requiredInput(ctx, "Remote prefix", "pi-sync");
938
- if (!prefix) return undefined;
939
- const namespace = await requiredInput(ctx, "Remote namespace", targetName);
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
  }