@narumitw/pi-sync 0.29.1 → 0.30.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/config.ts CHANGED
@@ -7,9 +7,25 @@ import {
7
7
  type ExtensionContext,
8
8
  getAgentDir,
9
9
  } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ activeLocalConfigPath,
12
+ consumeLocalConfigMigrationNotice,
13
+ legacyLocalConfigPath,
14
+ localConfigPath,
15
+ quarantineAndRemoveConfigIfMatches,
16
+ readMigratingLocalConfigDocument,
17
+ replaceLocalConfigDocument,
18
+ withLocalConfigFileLock,
19
+ } from "./config-file.js";
10
20
  import { safeName } from "./paths.js";
11
21
  import { DEFAULT_SYNC_FILES, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
12
- import type { PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
22
+ import type {
23
+ PartialConfig,
24
+ Snapshot,
25
+ SyncConfig,
26
+ SyncState,
27
+ TargetSwitchAction,
28
+ } from "./types.js";
13
29
 
14
30
  export { extraFilePathsByLower, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
15
31
 
@@ -17,6 +33,16 @@ const VERSION = 1;
17
33
  const DEFAULT_PROFILE = "default";
18
34
  const DEFAULT_PREFIX = "pi-sync";
19
35
  const DEFAULT_REGION = "auto";
36
+ export const DEFAULT_TARGET_SWITCH_ACTION: TargetSwitchAction = "ask";
37
+
38
+ export {
39
+ activeLocalConfigPath,
40
+ consumeLocalConfigMigrationNotice,
41
+ legacyLocalConfigPath,
42
+ localConfigPath,
43
+ quarantineAndRemoveConfigIfMatches,
44
+ replaceLocalConfigDocument,
45
+ };
20
46
 
21
47
  export const DEPRECATED_PI_SYNC_ENV_NAMES = [
22
48
  "PI_SYNC_ENDPOINT",
@@ -121,6 +147,20 @@ export async function loadPartialConfig(targetName?: string): Promise<PartialCon
121
147
  return resolveLegacyPartialConfig(fileConfig as PartialConfig);
122
148
  }
123
149
 
150
+ export async function loadTargetSwitchAction(): Promise<TargetSwitchAction> {
151
+ const settings = await readLocalConfigObject();
152
+ if (!settings || !isV2SettingsObject(settings)) return DEFAULT_TARGET_SWITCH_ACTION;
153
+ return normalizeTargetSwitchAction(settings.targetSwitchAction);
154
+ }
155
+
156
+ export function normalizeTargetSwitchAction(value: unknown): TargetSwitchAction {
157
+ if (value === undefined) return DEFAULT_TARGET_SWITCH_ACTION;
158
+ if (value === "ask" || value === "pull" || value === "switch-only") return value;
159
+ throw new Error(
160
+ 'Invalid pi-sync settings: targetSwitchAction must be "ask", "pull", or "switch-only".',
161
+ );
162
+ }
163
+
124
164
  export function deprecatedPiSyncEnvironmentNames() {
125
165
  return DEPRECATED_PI_SYNC_ENV_NAMES.filter((name) => hasEnv(name));
126
166
  }
@@ -160,6 +200,7 @@ export function resolveV2PartialConfig(
160
200
  settings: Record<string, unknown>,
161
201
  targetName?: string,
162
202
  ): PartialConfig {
203
+ normalizeTargetSwitchAction(settings.targetSwitchAction);
163
204
  const targets = requireNamedObjectMap(settings.targets, "targets");
164
205
  const profiles = requireNamedObjectMap(settings.profiles, "profiles");
165
206
  validateUniqueRemoteTargets(targets);
@@ -469,14 +510,11 @@ export function stateDir() {
469
510
  return path.join(agentDir(), ".pisync");
470
511
  }
471
512
 
472
- export function localConfigPath() {
473
- return path.join(agentDir(), "pi-sync.local.json");
474
- }
475
-
476
513
  export function localConfigTemplate(): Record<string, unknown> {
477
514
  return {
478
515
  version: 2,
479
516
  activeTarget: DEFAULT_PROFILE,
517
+ targetSwitchAction: DEFAULT_TARGET_SWITCH_ACTION,
480
518
  profiles: {
481
519
  [DEFAULT_PROFILE]: {
482
520
  kind: "r2",
@@ -501,22 +539,91 @@ export function localConfigTemplate(): Record<string, unknown> {
501
539
  };
502
540
  }
503
541
 
542
+ export async function readLocalConfigDocument() {
543
+ return readMigratingLocalConfigDocument(validateConfigDocumentForMigration);
544
+ }
545
+
504
546
  export async function readLocalConfigObject(): Promise<Record<string, unknown> | undefined> {
505
- const configPath = localConfigPath();
506
- try {
507
- const stat = await fs.lstat(configPath);
508
- if (stat.isSymbolicLink())
509
- throw new Error(`Refusing to read symlinked pi-sync config: ${configPath}`);
510
- if (!stat.isFile()) throw new Error(`pi-sync config is not a regular file: ${configPath}`);
511
- } catch (error) {
512
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
513
- throw error;
547
+ return (await readLocalConfigDocument())?.parsed;
548
+ }
549
+
550
+ function validateConfigDocumentForMigration(settings: Record<string, unknown>) {
551
+ if (!isV2SettingsObject(settings)) {
552
+ for (const field of [
553
+ "endpoint",
554
+ "bucket",
555
+ "region",
556
+ "accessKeyId",
557
+ "secretAccessKey",
558
+ "sessionToken",
559
+ "profile",
560
+ "prefix",
561
+ ] as const) {
562
+ asOptionalString(settings[field]);
563
+ }
564
+ for (const field of ["autoSync", "syncSessions"] as const) {
565
+ const value = settings[field];
566
+ if (value !== undefined && typeof value !== "boolean" && typeof value !== "string") {
567
+ throw new Error(`Invalid pi-sync settings: ${field} must be a boolean or string.`);
568
+ }
569
+ }
570
+ normalizeSyncFiles(settings.syncFiles);
571
+ normalizeExtraFiles(settings.extraFiles);
572
+ return;
573
+ }
574
+
575
+ normalizeTargetSwitchAction(settings.targetSwitchAction);
576
+ const profiles = requireNamedObjectMap(settings.profiles, "profiles");
577
+ const targets = requireNamedObjectMap(settings.targets, "targets");
578
+ validateUniqueRemoteTargets(targets);
579
+ for (const [name, value] of Object.entries(profiles)) {
580
+ const profile = ownObject(profiles, name);
581
+ if (!profile || value !== profile) {
582
+ throw new Error(`Invalid pi-sync settings: storage profile "${name}" must be an object.`);
583
+ }
584
+ const kind = asOptionalString(profile.kind);
585
+ if (kind !== undefined && kind !== "r2" && kind !== "s3-compatible") {
586
+ throw new Error(
587
+ `Invalid pi-sync settings: profile "${name}" has unsupported kind "${kind}".`,
588
+ );
589
+ }
590
+ for (const field of [
591
+ "endpoint",
592
+ "region",
593
+ "accessKeyId",
594
+ "secretAccessKey",
595
+ "sessionToken",
596
+ ] as const) {
597
+ asOptionalString(profile[field]);
598
+ }
599
+ }
600
+ for (const [name, value] of Object.entries(targets)) {
601
+ const target = ownObject(targets, name);
602
+ if (!target || value !== target) {
603
+ throw new Error(`Invalid pi-sync settings: target "${name}" must be an object.`);
604
+ }
605
+ const profileName = normalizeOptionalString(asOptionalString(target.profile));
606
+ if (!profileName || !Object.hasOwn(profiles, profileName)) {
607
+ throw new Error(`Invalid pi-sync settings: target "${name}" references a missing profile.`);
608
+ }
609
+ for (const field of ["bucket", "prefix", "namespace"] as const) {
610
+ asOptionalString(target[field]);
611
+ }
612
+ asOptionalBoolean(target.autoSync);
613
+ asOptionalBoolean(target.syncSessions);
614
+ normalizeSyncFiles(target.syncFiles);
615
+ normalizeExtraFiles(target.extraFiles);
616
+ }
617
+ const activeTarget = normalizeOptionalString(asOptionalString(settings.activeTarget));
618
+ if (Object.keys(targets).length === 0) {
619
+ if (activeTarget) {
620
+ throw new Error("Invalid pi-sync settings: targetless settings cannot have activeTarget.");
621
+ }
622
+ return;
514
623
  }
515
- const parsed = JSON.parse(await fs.readFile(configPath, "utf8")) as unknown;
516
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
517
- throw new Error(`pi-sync config must contain a JSON object: ${configPath}`);
624
+ if (!activeTarget || !Object.hasOwn(targets, activeTarget)) {
625
+ throw new Error("Invalid pi-sync settings: activeTarget must reference an existing target.");
518
626
  }
519
- return parsed as Record<string, unknown>;
520
627
  }
521
628
 
522
629
  let configUpdateQueue: Promise<void> = Promise.resolve();
@@ -541,7 +648,11 @@ async function performLocalConfigUpdate(
541
648
  return next;
542
649
  }
543
650
 
544
- export async function writeLocalConfigObject(value: Record<string, unknown>) {
651
+ export function writeLocalConfigObject(value: Record<string, unknown>) {
652
+ return withLocalConfigFileLock(() => writeLocalConfigObjectUnlocked(value));
653
+ }
654
+
655
+ async function writeLocalConfigObjectUnlocked(value: Record<string, unknown>) {
545
656
  const configPath = localConfigPath();
546
657
  await fs.mkdir(path.dirname(configPath), { recursive: true });
547
658
  try {
@@ -145,7 +145,6 @@ async function showDraftEditor(
145
145
  getSettingsListTheme(),
146
146
  (id, newValue) => updateDraft(draft, id, newValue === INCLUDED),
147
147
  () => done(undefined),
148
- { enableSearch: true },
149
148
  );
150
149
  container.addChild(settingsList);
151
150
  container.addChild(hint);
package/src/manager-ui.ts CHANGED
@@ -1,17 +1,17 @@
1
1
  import { BorderedLoader, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { setSyncTargetCompletions } from "./command.js";
3
3
  import {
4
+ activeLocalConfigPath,
4
5
  configuredTargetNames,
5
6
  deprecatedPiSyncEnvironmentWarnings,
6
7
  isCloudflareR2Endpoint,
7
- isEnabled,
8
8
  loadConfig,
9
9
  loadPartialConfig,
10
+ loadTargetSwitchAction,
10
11
  localConfigPath,
11
- normalizeExtraFiles,
12
12
  normalizeSyncFiles,
13
13
  readLocalConfigObject,
14
- updateLocalConfig,
14
+ readStateForConfig,
15
15
  } from "./config.js";
16
16
  import { inspectLock, isStaleLock } from "./lock.js";
17
17
  import {
@@ -24,23 +24,37 @@ import {
24
24
  updateStorageProfile,
25
25
  updateSyncTarget,
26
26
  } from "./settings-management.js";
27
+ import { showSyncSettings } from "./settings-ui.js";
27
28
  import { DEFAULT_SYNC_FILES } from "./sync-policy.js";
29
+ import { type TargetPullOutcome, useSyncTarget } from "./target-switch.js";
28
30
  import type { SyncConfig } from "./types.js";
29
31
 
30
32
  export const MAIN_MENU_ACTIONS = [
31
- "Sync now",
33
+ "Sync now (recommended)",
34
+ "Pull from remote",
35
+ "Push to remote",
32
36
  "Switch target",
33
37
  "Status & changes",
34
38
  "Settings",
39
+ "More…",
40
+ ] as const;
41
+
42
+ const MORE_MENU_ACTIONS = [
35
43
  "Manage targets & storage",
36
44
  "History & recovery",
37
45
  "Help",
46
+ "Back",
38
47
  ] as const;
39
-
40
48
  const BACK = "Back";
41
49
 
42
50
  type MainMenuAction = (typeof MAIN_MENU_ACTIONS)[number];
43
- type RunRoute = (route: string, signal?: AbortSignal, onCommit?: () => void) => Promise<void>;
51
+ type ContextualMenuAction = "Manage targets & storage" | "History & recovery" | "Help";
52
+ type RunRoute = (
53
+ route: string,
54
+ signal?: AbortSignal,
55
+ onCommit?: () => void,
56
+ target?: string,
57
+ ) => Promise<TargetPullOutcome | undefined>;
44
58
 
45
59
  export async function showSyncManager(
46
60
  ctx: ExtensionCommandContext,
@@ -54,18 +68,48 @@ export async function showSyncManager(
54
68
  const state = await describeManagerState();
55
69
  const selected = await ctx.ui.select(state.title, state.actions);
56
70
  if (!selected) return;
57
- switch (selected as MainMenuAction | "Set up sync" | "Use existing settings") {
58
- case "Sync now":
71
+ switch (
72
+ selected as MainMenuAction | ContextualMenuAction | "Set up sync" | "Use existing settings"
73
+ ) {
74
+ case "Sync now (recommended)":
59
75
  await runCancellableOperation(ctx, "Checking current target…", "sync", runRoute, true);
60
76
  break;
61
- case "Switch target":
62
- if (await showTargetSwitcher(ctx)) continue;
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;
63
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
+ );
98
+ break;
99
+ case "Switch target": {
100
+ const result = await showTargetSwitcher(ctx, runRoute);
101
+ if (result === "pull-attempted") return;
102
+ if (result === "switched") continue;
103
+ break;
104
+ }
64
105
  case "Status & changes":
65
106
  await runCancellableOperation(ctx, "Checking current target…", "diff", runRoute);
66
107
  break;
67
108
  case "Settings":
68
- await showSettingsMenu(ctx, runRoute);
109
+ await showSyncSettings(ctx, runRoute);
110
+ break;
111
+ case "More…":
112
+ if ((await showMoreMenu(ctx, runRoute)) === "exit") return;
69
113
  break;
70
114
  case "Manage targets & storage":
71
115
  await showManageMenu(ctx, runRoute);
@@ -73,30 +117,43 @@ export async function showSyncManager(
73
117
  case "History & recovery":
74
118
  await showRecoveryMenu(ctx, runRoute);
75
119
  break;
120
+ case "Help":
121
+ await runRoute("help");
122
+ return;
76
123
  case "Set up sync":
77
124
  case "Use existing settings":
78
125
  await runRoute("init");
79
126
  continue;
80
- case "Help":
81
- await runRoute("help");
82
- return;
83
127
  }
84
128
  }
85
129
  }
86
130
 
131
+ async function showMoreMenu(ctx: ExtensionCommandContext, runRoute: RunRoute) {
132
+ const selected = await ctx.ui.select("More options", [...MORE_MENU_ACTIONS]);
133
+ 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);
136
+ else {
137
+ await runRoute("help");
138
+ return "exit" as const;
139
+ }
140
+ }
141
+
87
142
  async function runCancellableOperation(
88
143
  ctx: ExtensionCommandContext,
89
144
  message: string,
90
145
  route: string,
91
146
  runRoute: RunRoute,
92
147
  commitAware = false,
148
+ cancelledMessage: string | null = "Check cancelled; no settings or files were changed.",
149
+ target?: string,
93
150
  ) {
94
151
  if (ctx.mode !== "tui") {
95
- await runRoute(route);
96
- return;
152
+ return await runRoute(route, undefined, undefined, target);
97
153
  }
98
154
  let commitStarted = false;
99
155
  let operation: Promise<void> | undefined;
156
+ let routeResult: TargetPullOutcome | undefined;
100
157
  const result = await ctx.ui.custom<{ cancelled?: boolean; error?: unknown }>(
101
158
  (tui, theme, _kb, done) => {
102
159
  const loader = new BorderedLoader(tui, theme, message, { cancellable: true });
@@ -116,8 +173,10 @@ async function runCancellableOperation(
116
173
  route,
117
174
  loader.signal,
118
175
  commitAware ? () => (commitStarted = true) : undefined,
176
+ target,
119
177
  )
120
- .then(() => {
178
+ .then((result) => {
179
+ routeResult = result;
121
180
  if (!closed) done({});
122
181
  })
123
182
  .catch((error) => {
@@ -128,10 +187,11 @@ async function runCancellableOperation(
128
187
  );
129
188
  if (result?.cancelled) {
130
189
  await operation;
131
- ctx.ui.notify("Check cancelled; no settings or files were changed.", "info");
132
- return;
190
+ if (cancelledMessage) ctx.ui.notify(cancelledMessage, "info");
191
+ return "cancelled";
133
192
  }
134
193
  if (result?.error) throw result.error;
194
+ return routeResult;
135
195
  }
136
196
 
137
197
  async function describeManagerState(): Promise<{ title: string; actions: string[] }> {
@@ -145,7 +205,7 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
145
205
  "",
146
206
  "Settings file needs repair. Automatic sync and settings writes are paused.",
147
207
  `Error: ${safeTerminalText(errorMessage(error))}`,
148
- `File: ${safeTerminalText(localConfigPath())}`,
208
+ `File: ${safeTerminalText(await activeLocalConfigPath())}`,
149
209
  "",
150
210
  "Repair the JSON file, then reopen /sync.",
151
211
  ].join("\n"),
@@ -181,15 +241,34 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
181
241
  const liveLock = lock.status === "valid" && !isStaleLock(lock.lock);
182
242
  const recoverableLock =
183
243
  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;
247
+ const stateReadDisabled = liveLock || recoverableLock;
248
+ const syncState = stateReadDisabled
249
+ ? undefined
250
+ : await readStateForConfig(config).catch(() => undefined);
251
+ const lastAppliedSnapshot = stateReadDisabled
252
+ ? "Unavailable while operations are locked"
253
+ : syncState?.lastAppliedSnapshot
254
+ ? safeTerminalText(syncState.lastAppliedSnapshot)
255
+ : syncState
256
+ ? "Never synced"
257
+ : "Unavailable";
184
258
  return {
185
259
  title: [
186
260
  "Pi Sync",
187
261
  "",
188
262
  `Current target: ${safeTerminalText(target)}`,
189
263
  `Storage: ${storage}`,
190
- `Auto-sync: ${config.autoSync ? "On" : "Off"} · Sessions: ${config.syncSessions ? "On" : "Off"}`,
191
- "Last known sync: Remote not checked",
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",
192
268
  ...warnings.map((warning) => `Warning: ${safeTerminalText(warning)}`),
269
+ ...(noSyncedContent
270
+ ? ["", "No synced content is selected. Choose synced content in Settings before syncing."]
271
+ : []),
193
272
  ...(liveLock
194
273
  ? [
195
274
  "",
@@ -205,7 +284,9 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
205
284
  actions:
206
285
  liveLock || recoverableLock
207
286
  ? ["Status & changes", "History & recovery", "Help"]
208
- : [...MAIN_MENU_ACTIONS],
287
+ : noSyncedContent
288
+ ? ["Settings", "Switch target", "Status & changes", "More…"]
289
+ : [...MAIN_MENU_ACTIONS],
209
290
  };
210
291
  } catch (error) {
211
292
  const targets = ownRecord(raw.targets);
@@ -216,7 +297,7 @@ async function describeManagerState(): Promise<{ title: string; actions: string[
216
297
  "Settings need attention. Automatic sync is paused.",
217
298
  `Current target: ${safeTerminalText(typeof raw.activeTarget === "string" ? raw.activeTarget : "default")}`,
218
299
  `Error: ${safeTerminalText(errorMessage(error))}`,
219
- `File: ${safeTerminalText(localConfigPath())}`,
300
+ `File: ${safeTerminalText(await activeLocalConfigPath())}`,
220
301
  "",
221
302
  "What do you want to do?",
222
303
  ].join("\n"),
@@ -346,7 +427,7 @@ export async function showSetupWizard(ctx: ExtensionCommandContext) {
346
427
  return true;
347
428
  }
348
429
 
349
- async function showTargetSwitcher(ctx: ExtensionCommandContext) {
430
+ async function showTargetSwitcher(ctx: ExtensionCommandContext, runRoute: RunRoute) {
350
431
  const raw = await readLocalConfigObject();
351
432
  if (raw?.version !== 2) {
352
433
  ctx.ui.notify("Add a second sync target before switching targets.", "info");
@@ -395,6 +476,13 @@ async function showTargetSwitcher(ctx: ExtensionCommandContext) {
395
476
  );
396
477
  return false;
397
478
  }
479
+ const targetSwitchAction = await loadTargetSwitchAction();
480
+ 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."
485
+ : "After switching, pi-sync will not pull or modify synced files.";
398
486
  const choice = await ctx.ui.select(
399
487
  [
400
488
  "Switch target",
@@ -405,70 +493,23 @@ async function showTargetSwitcher(ctx: ExtensionCommandContext) {
405
493
  `Synced content: ${normalizeSyncFiles(config.syncFiles).length} built-in groups · ${config.extraFiles.length} extra files`,
406
494
  `Auto-sync: ${config.autoSync ? "On" : "Off"} · Sessions: ${config.syncSessions ? "On" : "Off"}`,
407
495
  "",
408
- "Switching does not sync or modify files now.",
496
+ switchEffect,
409
497
  ].join("\n"),
410
498
  [`Switch to ${safeTerminalText(name)}`, "Cancel"],
411
499
  );
412
500
  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",
501
+ const result = await useSyncTarget(ctx, name, (selectedTarget) =>
502
+ runCancellableOperation(
503
+ ctx,
504
+ `Pulling target “${safeTerminalText(name)}”…`,
505
+ "pull",
506
+ runRoute,
507
+ true,
508
+ null,
509
+ selectedTarget,
510
+ ),
471
511
  );
512
+ return result.pullApplied ? "pull-attempted" : "switched";
472
513
  }
473
514
 
474
515
  async function showManageMenu(ctx: ExtensionCommandContext, _runRoute: RunRoute) {
@@ -903,20 +944,6 @@ function formatRemotePath(prefix: string, namespace: string) {
903
944
  return safeTerminalText(`${prefix}/profiles/${namespace}/`);
904
945
  }
905
946
 
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
947
  async function requiredExistingBucket(ctx: ExtensionCommandContext, example: string) {
921
948
  const value = await ctx.ui.input("Existing bucket", `Example: ${example}`);
922
949
  if (value === undefined) return undefined;
package/src/paths.ts CHANGED
@@ -14,7 +14,12 @@ export function isDeniedPath(relativePath: string) {
14
14
  base.endsWith(".env") ||
15
15
  base.includes("secret") ||
16
16
  base.includes("token") ||
17
- base === "pi-sync.local.json"
17
+ base === "pi-sync.json" ||
18
+ base.startsWith("pi-sync.json.") ||
19
+ base.startsWith(".pi-sync.json.") ||
20
+ base === "pi-sync.local.json" ||
21
+ base.startsWith("pi-sync.local.json.") ||
22
+ base.startsWith(".pi-sync.local.json.")
18
23
  );
19
24
  }
20
25
 
@@ -2,9 +2,12 @@ import { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import {
5
+ DEFAULT_TARGET_SWITCH_ACTION,
5
6
  effectiveTargetRemoteIdentity,
6
7
  localConfigPath,
8
+ readLocalConfigDocument,
7
9
  readLocalConfigObject,
10
+ replaceLocalConfigDocument,
8
11
  resolveLegacyPartialConfig,
9
12
  resolveV2PartialConfig,
10
13
  stateDir,
@@ -14,6 +17,21 @@ import {
14
17
  import { withLock } from "./lock.js";
15
18
  import type { PartialConfig, StorageProfileSettings, SyncTargetSettings } from "./types.js";
16
19
 
20
+ let afterLegacySettingsReadHook: () => Promise<void> = async () => undefined;
21
+
22
+ export async function withLegacySettingsReadHookForTest<T>(
23
+ hook: () => Promise<void>,
24
+ run: () => Promise<T>,
25
+ ) {
26
+ const previous = afterLegacySettingsReadHook;
27
+ afterLegacySettingsReadHook = hook;
28
+ try {
29
+ return await run();
30
+ } finally {
31
+ afterLegacySettingsReadHook = previous;
32
+ }
33
+ }
34
+
17
35
  const LEGACY_FIELDS = new Set([
18
36
  "endpoint",
19
37
  "bucket",
@@ -41,21 +59,17 @@ export async function migrateLegacySettings(
41
59
  validateName(targetName, "target");
42
60
  validateName(storageProfileName, "storage profile");
43
61
  return withLock("settings-migration", async () => {
44
- const configPath = localConfigPath();
45
- const originalBytes = await fs.readFile(configPath);
46
- const current = await readLocalConfigObject();
47
- if (!current) throw new Error("No legacy pi-sync settings are available to migrate.");
62
+ const document = await readLocalConfigDocument();
63
+ if (!document) throw new Error("No legacy pi-sync settings are available to migrate.");
64
+ const { bytes: originalBytes, parsed: current } = document;
48
65
  if (current.version === 2) {
49
66
  throw new Error("pi-sync settings already use profiles and targets.");
50
67
  }
68
+ await afterLegacySettingsReadHook();
51
69
  const next = legacySettingsAsV2(current, targetName, storageProfileName);
52
70
  const backupPath = await writeMigrationBackup(originalBytes);
53
71
  await adoptLegacyState(current, next, targetName);
54
- const latestBytes = await fs.readFile(configPath);
55
- if (!latestBytes.equals(originalBytes)) {
56
- throw new Error("pi-sync settings changed during migration; no settings were replaced.");
57
- }
58
- await writeLocalConfigObject(next);
72
+ await replaceLocalConfigDocument(document, next);
59
73
  return { settings: next, backupPath };
60
74
  });
61
75
  }
@@ -112,6 +126,7 @@ export async function saveNewV2Settings(input: {
112
126
  const settings = {
113
127
  version: 2,
114
128
  activeTarget: input.targetName,
129
+ targetSwitchAction: DEFAULT_TARGET_SWITCH_ACTION,
115
130
  profiles: { [input.storageProfileName]: { ...input.profile } },
116
131
  targets: {
117
132
  [input.targetName]: { ...input.target, profile: input.storageProfileName },