@narumitw/pi-sync 0.25.0 → 0.29.0

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