@narumitw/pi-sync 0.29.1 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ The extension uses immutable snapshot bundles, a `latest.json` publication point
10
10
 
11
11
  - Opens a goal-oriented `/sync` manager showing the current target, storage, auto-sync, session scope, and relevant next actions.
12
12
  - Supports multiple named **sync targets** and reusable R2/S3 **storage profiles**.
13
- - Switches targets without syncing or modifying files; only the current target runs automatic startup/shutdown sync.
13
+ - Asks to review a pull after switching targets by default, with settings to start that review automatically or switch only.
14
14
  - Previews concrete local or remote file changes before push, pull, force resolution, or rollback.
15
15
  - Uses transactional synced-content drafts with explicit Save, Discard, and Continue editing choices.
16
16
  - Keeps direct `help`, `use`, `init`, `config`, `files`, `status`, `diff`, `doctor`, `push`, `pull`, `sync`, `history`, `rollback`, and `unlock` routes for compatibility and automation.
@@ -86,9 +86,9 @@ Last known sync: Remote not checked
86
86
  Its primary actions are:
87
87
 
88
88
  - **Sync now** — conservatively decide whether to push, pull, or do nothing
89
- - **Switch target** — preview and change the current target without syncing
89
+ - **Switch target** — preview the target change, then follow the configured pull behavior
90
90
  - **Status & changes** — perform a cancellable read-only remote check
91
- - **Settings** — control automatic sync and synced content for the current target
91
+ - **Settings** — control post-switch pulling, automatic sync, and synced content
92
92
  - **Manage targets & storage** — add, edit, or remove local target/profile definitions
93
93
  - **History & recovery** — browse snapshots, preview rollback, diagnose setup, or recover a stale lock
94
94
  - **Help**
@@ -109,6 +109,7 @@ A version 2 example:
109
109
  {
110
110
  "version": 2,
111
111
  "activeTarget": "home",
112
+ "targetSwitchAction": "ask",
112
113
  "profiles": {
113
114
  "r2": {
114
115
  "kind": "r2",
@@ -155,15 +156,22 @@ A version 2 example:
155
156
  - A **storage profile** owns connection/authentication fields: `kind`, `endpoint`, `region`, `accessKeyId`, `secretAccessKey`, and optional `sessionToken`.
156
157
  - A **sync target** references a storage profile and owns `bucket`, `prefix`, `namespace`, `autoSync`, and its synced-content policy.
157
158
  - `activeTarget` is used by bare commands and automatic sync.
159
+ - `targetSwitchAction` controls what happens after a target switch: `ask` (default), `pull`, or `switch-only`.
158
160
  - `namespace` is the old flat `profile` concept. The remote layout and snapshot wire field remain named `profiles`/`profile` for compatibility.
159
161
 
160
162
  When adding another target with the same storage profile, pi-sync recommends reusing the current target's bucket and prefix while deriving a separate namespace from the new target name. For example, `home` and `work` may both use profile `r2`, bucket `pi-sync`, and prefix `pi-sync`, producing `pi-sync/profiles/home/` and `pi-sync/profiles/work/`.
161
163
 
162
164
  Two targets may intentionally overlap local files, but only one is automatic. pi-sync rejects duplicate effective target remote identities—including deprecated bucket/prefix/namespace environment overrides—to prevent independent local states from controlling the same remote pointer. Removing a target/profile removes local configuration only; it never deletes buckets or snapshots. A referenced profile cannot be removed, and users must switch away before removing one of several current targets.
163
165
 
166
+ ### Target switching
167
+
168
+ The default `targetSwitchAction: "ask"` changes `activeTarget` atomically, then asks in TUI mode whether to review a pull for that target. Choosing not to review leaves local files unchanged. Accepting starts the normal pull flow, which fetches the remote snapshot and shows the exact writes and deletions before apply. In print, JSON, or RPC mode, `ask` switches without pulling and directs interactive users to `/sync pull` where notifications are available.
169
+
170
+ Set `targetSwitchAction` to `"pull"` to start that reviewed pull automatically after a confirmed target switch. The exact pull summary and apply confirmation remain enabled, conflict detection still stops unsafe merges, a local backup is created before apply, and pi-sync never adds `--force`; Pi may separately ask whether to reload changed resources after a successful pull. Because that confirmation requires observable UI, `/sync use` rejects `"pull"` in print and JSON modes before changing `activeTarget`; use TUI or RPC mode instead. Set it to `"switch-only"` to retain the previous no-pull behavior. Selecting an already-current target is a no-op. If a pull fails or its concrete review is declined, the new target remains active and pi-sync reports that synced files were not changed before `/sync pull` is retried.
171
+
164
172
  ### Synced content
165
173
 
166
- The **Settings → Choose synced content** screen edits a draft. Enter or Space toggles, typing searches, and Escape opens the Save/Discard/Continue decision. A single Save atomically updates the target while preserving unknown fields and POSIX `0600` permissions. Saving settings never starts network sync.
174
+ The **Settings → Choose synced content** screen edits a draft. Arrow keys navigate, Enter or Space toggles, and Escape opens the Save/Discard/Continue decision. A single Save atomically updates the target while preserving unknown fields and POSIX `0600` permissions. Saving settings never starts network sync.
167
175
 
168
176
  Omitting `syncFiles` preserves the legacy default of every built-in item. An empty array is valid and means the target manages nothing; **Sync now** reports that state instead of publishing an empty sync. Unknown built-in names fail validation.
169
177
 
@@ -254,7 +262,7 @@ For the current target, startup auto-sync uses conservative decisions:
254
262
  - first-sync mismatch or both changed → stop and require review
255
263
  - no selected content → report/skip
256
264
 
257
- Switching targets changes only `activeTarget`; it never starts sync. The next startup uses the new target's `autoSync`, or the user can choose **Sync now** immediately.
265
+ Switching targets first changes `activeTarget`, then follows `targetSwitchAction`: ask before reviewing a pull in TUI by default, start a reviewed pull automatically when configured, or stop after switching. Pulls remain pinned to the selected target and retain exact summaries, locking, backups, and conflict safeguards. The next startup uses the new target's `autoSync` regardless of the switch action.
258
266
 
259
267
  Remote layout remains compatible:
260
268
 
@@ -313,6 +321,8 @@ extensions/pi-sync/
313
321
  │ ├── manager-ui.ts # Goal-oriented menus and setup/management flows
314
322
  │ ├── file-selection.ts # Transactional synced-content editor
315
323
  │ ├── settings-management.ts # Profiles, targets, migration, and atomic saves
324
+ │ ├── settings-ui.ts # SettingsList interaction and serialized saves
325
+ │ ├── target-switch.ts # Post-switch prompt, policy, and target handoff
316
326
  │ ├── snapshot-transaction.ts # Local apply journal, rollback, and recovery
317
327
  │ └── *.ts # Config, policy, snapshot, S3, lock, and format modules
318
328
  ├── test/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.29.1",
3
+ "version": "0.29.2",
4
4
  "description": "Pi extension that syncs Pi configuration through Cloudflare R2 or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -9,7 +9,13 @@ import {
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import { safeName } from "./paths.js";
11
11
  import { DEFAULT_SYNC_FILES, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
12
- import type { PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
12
+ import type {
13
+ PartialConfig,
14
+ Snapshot,
15
+ SyncConfig,
16
+ SyncState,
17
+ TargetSwitchAction,
18
+ } from "./types.js";
13
19
 
14
20
  export { extraFilePathsByLower, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
15
21
 
@@ -17,6 +23,7 @@ const VERSION = 1;
17
23
  const DEFAULT_PROFILE = "default";
18
24
  const DEFAULT_PREFIX = "pi-sync";
19
25
  const DEFAULT_REGION = "auto";
26
+ export const DEFAULT_TARGET_SWITCH_ACTION: TargetSwitchAction = "ask";
20
27
 
21
28
  export const DEPRECATED_PI_SYNC_ENV_NAMES = [
22
29
  "PI_SYNC_ENDPOINT",
@@ -121,6 +128,20 @@ export async function loadPartialConfig(targetName?: string): Promise<PartialCon
121
128
  return resolveLegacyPartialConfig(fileConfig as PartialConfig);
122
129
  }
123
130
 
131
+ export async function loadTargetSwitchAction(): Promise<TargetSwitchAction> {
132
+ const settings = await readLocalConfigObject();
133
+ if (!settings || !isV2SettingsObject(settings)) return DEFAULT_TARGET_SWITCH_ACTION;
134
+ return normalizeTargetSwitchAction(settings.targetSwitchAction);
135
+ }
136
+
137
+ export function normalizeTargetSwitchAction(value: unknown): TargetSwitchAction {
138
+ if (value === undefined) return DEFAULT_TARGET_SWITCH_ACTION;
139
+ if (value === "ask" || value === "pull" || value === "switch-only") return value;
140
+ throw new Error(
141
+ 'Invalid pi-sync settings: targetSwitchAction must be "ask", "pull", or "switch-only".',
142
+ );
143
+ }
144
+
124
145
  export function deprecatedPiSyncEnvironmentNames() {
125
146
  return DEPRECATED_PI_SYNC_ENV_NAMES.filter((name) => hasEnv(name));
126
147
  }
@@ -160,6 +181,7 @@ export function resolveV2PartialConfig(
160
181
  settings: Record<string, unknown>,
161
182
  targetName?: string,
162
183
  ): PartialConfig {
184
+ normalizeTargetSwitchAction(settings.targetSwitchAction);
163
185
  const targets = requireNamedObjectMap(settings.targets, "targets");
164
186
  const profiles = requireNamedObjectMap(settings.profiles, "profiles");
165
187
  validateUniqueRemoteTargets(targets);
@@ -477,6 +499,7 @@ export function localConfigTemplate(): Record<string, unknown> {
477
499
  return {
478
500
  version: 2,
479
501
  activeTarget: DEFAULT_PROFILE,
502
+ targetSwitchAction: DEFAULT_TARGET_SWITCH_ACTION,
480
503
  profiles: {
481
504
  [DEFAULT_PROFILE]: {
482
505
  kind: "r2",
@@ -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
@@ -4,14 +4,12 @@ import {
4
4
  configuredTargetNames,
5
5
  deprecatedPiSyncEnvironmentWarnings,
6
6
  isCloudflareR2Endpoint,
7
- isEnabled,
8
7
  loadConfig,
9
8
  loadPartialConfig,
9
+ loadTargetSwitchAction,
10
10
  localConfigPath,
11
- normalizeExtraFiles,
12
11
  normalizeSyncFiles,
13
12
  readLocalConfigObject,
14
- updateLocalConfig,
15
13
  } from "./config.js";
16
14
  import { inspectLock, isStaleLock } from "./lock.js";
17
15
  import {
@@ -24,7 +22,9 @@ import {
24
22
  updateStorageProfile,
25
23
  updateSyncTarget,
26
24
  } from "./settings-management.js";
25
+ import { showSyncSettings } from "./settings-ui.js";
27
26
  import { DEFAULT_SYNC_FILES } from "./sync-policy.js";
27
+ import { type TargetPullOutcome, useSyncTarget } from "./target-switch.js";
28
28
  import type { SyncConfig } from "./types.js";
29
29
 
30
30
  export const MAIN_MENU_ACTIONS = [
@@ -40,7 +40,12 @@ export const MAIN_MENU_ACTIONS = [
40
40
  const BACK = "Back";
41
41
 
42
42
  type MainMenuAction = (typeof MAIN_MENU_ACTIONS)[number];
43
- type RunRoute = (route: string, signal?: AbortSignal, onCommit?: () => void) => Promise<void>;
43
+ type RunRoute = (
44
+ route: string,
45
+ signal?: AbortSignal,
46
+ onCommit?: () => void,
47
+ target?: string,
48
+ ) => Promise<TargetPullOutcome | undefined>;
44
49
 
45
50
  export async function showSyncManager(
46
51
  ctx: ExtensionCommandContext,
@@ -58,14 +63,17 @@ export async function showSyncManager(
58
63
  case "Sync now":
59
64
  await runCancellableOperation(ctx, "Checking current target…", "sync", runRoute, true);
60
65
  break;
61
- case "Switch target":
62
- if (await showTargetSwitcher(ctx)) continue;
66
+ case "Switch target": {
67
+ const result = await showTargetSwitcher(ctx, runRoute);
68
+ if (result === "pull-attempted") return;
69
+ if (result === "switched") continue;
63
70
  break;
71
+ }
64
72
  case "Status & changes":
65
73
  await runCancellableOperation(ctx, "Checking current target…", "diff", runRoute);
66
74
  break;
67
75
  case "Settings":
68
- await showSettingsMenu(ctx, runRoute);
76
+ await showSyncSettings(ctx, runRoute);
69
77
  break;
70
78
  case "Manage targets & storage":
71
79
  await showManageMenu(ctx, runRoute);
@@ -90,13 +98,15 @@ async function runCancellableOperation(
90
98
  route: string,
91
99
  runRoute: RunRoute,
92
100
  commitAware = false,
101
+ cancelledMessage: string | null = "Check cancelled; no settings or files were changed.",
102
+ target?: string,
93
103
  ) {
94
104
  if (ctx.mode !== "tui") {
95
- await runRoute(route);
96
- return;
105
+ return await runRoute(route, undefined, undefined, target);
97
106
  }
98
107
  let commitStarted = false;
99
108
  let operation: Promise<void> | undefined;
109
+ let routeResult: TargetPullOutcome | undefined;
100
110
  const result = await ctx.ui.custom<{ cancelled?: boolean; error?: unknown }>(
101
111
  (tui, theme, _kb, done) => {
102
112
  const loader = new BorderedLoader(tui, theme, message, { cancellable: true });
@@ -116,8 +126,10 @@ async function runCancellableOperation(
116
126
  route,
117
127
  loader.signal,
118
128
  commitAware ? () => (commitStarted = true) : undefined,
129
+ target,
119
130
  )
120
- .then(() => {
131
+ .then((result) => {
132
+ routeResult = result;
121
133
  if (!closed) done({});
122
134
  })
123
135
  .catch((error) => {
@@ -128,10 +140,11 @@ async function runCancellableOperation(
128
140
  );
129
141
  if (result?.cancelled) {
130
142
  await operation;
131
- ctx.ui.notify("Check cancelled; no settings or files were changed.", "info");
132
- return;
143
+ if (cancelledMessage) ctx.ui.notify(cancelledMessage, "info");
144
+ return "cancelled";
133
145
  }
134
146
  if (result?.error) throw result.error;
147
+ return routeResult;
135
148
  }
136
149
 
137
150
  async function describeManagerState(): Promise<{ title: string; actions: string[] }> {
@@ -346,7 +359,7 @@ export async function showSetupWizard(ctx: ExtensionCommandContext) {
346
359
  return true;
347
360
  }
348
361
 
349
- async function showTargetSwitcher(ctx: ExtensionCommandContext) {
362
+ async function showTargetSwitcher(ctx: ExtensionCommandContext, runRoute: RunRoute) {
350
363
  const raw = await readLocalConfigObject();
351
364
  if (raw?.version !== 2) {
352
365
  ctx.ui.notify("Add a second sync target before switching targets.", "info");
@@ -395,6 +408,13 @@ async function showTargetSwitcher(ctx: ExtensionCommandContext) {
395
408
  );
396
409
  return false;
397
410
  }
411
+ const targetSwitchAction = await loadTargetSwitchAction();
412
+ const switchEffect =
413
+ targetSwitchAction === "ask"
414
+ ? "After switching, pi-sync will ask whether to review a pull for the target."
415
+ : targetSwitchAction === "pull"
416
+ ? "After switching, pi-sync will check the target and show exact changes before applying them."
417
+ : "After switching, pi-sync will not pull or modify synced files.";
398
418
  const choice = await ctx.ui.select(
399
419
  [
400
420
  "Switch target",
@@ -405,70 +425,23 @@ async function showTargetSwitcher(ctx: ExtensionCommandContext) {
405
425
  `Synced content: ${normalizeSyncFiles(config.syncFiles).length} built-in groups · ${config.extraFiles.length} extra files`,
406
426
  `Auto-sync: ${config.autoSync ? "On" : "Off"} · Sessions: ${config.syncSessions ? "On" : "Off"}`,
407
427
  "",
408
- "Switching does not sync or modify files now.",
428
+ switchEffect,
409
429
  ].join("\n"),
410
430
  [`Switch to ${safeTerminalText(name)}`, "Cancel"],
411
431
  );
412
432
  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",
433
+ const result = await useSyncTarget(ctx, name, (selectedTarget) =>
434
+ runCancellableOperation(
435
+ ctx,
436
+ `Pulling target “${safeTerminalText(name)}”…`,
437
+ "pull",
438
+ runRoute,
439
+ true,
440
+ null,
441
+ selectedTarget,
442
+ ),
471
443
  );
444
+ return result.pullApplied ? "pull-attempted" : "switched";
472
445
  }
473
446
 
474
447
  async function showManageMenu(ctx: ExtensionCommandContext, _runRoute: RunRoute) {
@@ -903,20 +876,6 @@ function formatRemotePath(prefix: string, namespace: string) {
903
876
  return safeTerminalText(`${prefix}/profiles/${namespace}/`);
904
877
  }
905
878
 
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
879
  async function requiredExistingBucket(ctx: ExtensionCommandContext, example: string) {
921
880
  const value = await ctx.ui.input("Existing bucket", `Example: ${example}`);
922
881
  if (value === undefined) return undefined;
@@ -2,6 +2,7 @@ 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,
7
8
  readLocalConfigObject,
@@ -112,6 +113,7 @@ export async function saveNewV2Settings(input: {
112
113
  const settings = {
113
114
  version: 2,
114
115
  activeTarget: input.targetName,
116
+ targetSwitchAction: DEFAULT_TARGET_SWITCH_ACTION,
115
117
  profiles: { [input.storageProfileName]: { ...input.profile } },
116
118
  targets: {
117
119
  [input.targetName]: { ...input.target, profile: input.storageProfileName },
@@ -0,0 +1,199 @@
1
+ import {
2
+ type ExtensionCommandContext,
3
+ getSettingsListTheme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
6
+ import {
7
+ isCloudflareR2Endpoint,
8
+ isEnabled,
9
+ loadPartialConfig,
10
+ loadTargetSwitchAction,
11
+ localConfigPath,
12
+ normalizeExtraFiles,
13
+ normalizeSyncFiles,
14
+ readLocalConfigObject,
15
+ updateLocalConfig,
16
+ } from "./config.js";
17
+ import { errorMessage, safeTerminalText } from "./sync-format.js";
18
+ import {
19
+ saveTargetSwitchAction,
20
+ TARGET_SWITCH_ACTION_OPTIONS,
21
+ type TargetPullOutcome,
22
+ targetSwitchActionFromLabel,
23
+ targetSwitchActionLabel,
24
+ } from "./target-switch.js";
25
+
26
+ export type SyncSettingsRoute = (
27
+ route: string,
28
+ signal?: AbortSignal,
29
+ onCommit?: () => void,
30
+ target?: string,
31
+ ) => Promise<TargetPullOutcome | undefined>;
32
+
33
+ export async function showSyncSettings(ctx: ExtensionCommandContext, runRoute: SyncSettingsRoute) {
34
+ if (ctx.mode !== "tui") {
35
+ ctx.ui.notify(`Edit pi-sync settings manually: ${safeTerminalText(localConfigPath())}`, "info");
36
+ return;
37
+ }
38
+ while (true) {
39
+ const raw = await readLocalConfigObject();
40
+ const version2 = raw?.version === 2;
41
+ const partial = await loadPartialConfig();
42
+ const action = await showSettingsList(ctx, partial, version2);
43
+ if (action !== "files") return;
44
+ await runRoute("files", undefined, undefined, version2 ? partial.target : undefined);
45
+ }
46
+ }
47
+
48
+ async function showSettingsList(
49
+ ctx: ExtensionCommandContext,
50
+ partial: Awaited<ReturnType<typeof loadPartialConfig>>,
51
+ version2: boolean,
52
+ ) {
53
+ const targetName = version2 ? partial.target : undefined;
54
+ const automaticSyncOverridden = Object.hasOwn(process.env, "PI_SYNC_AUTO_SYNC");
55
+ const automaticSyncValue = isEnabled(partial.autoSync, true) ? "On" : "Off";
56
+ const targetSwitchAction = await loadTargetSwitchAction();
57
+ const targetSwitchValue = targetSwitchActionLabel(targetSwitchAction);
58
+ let saveQueue = Promise.resolve();
59
+ const latestRequested = new Map<string, string>();
60
+
61
+ return ctx.ui.custom<"files" | undefined>((tui, theme, _keybindings, done) => {
62
+ const items: SettingItem[] = [
63
+ {
64
+ id: "automaticSync",
65
+ label: automaticSyncOverridden ? "Automatic sync (environment override)" : "Automatic sync",
66
+ description: automaticSyncOverridden
67
+ ? "Read-only while deprecated PI_SYNC_AUTO_SYNC overrides this target. Move the value into target settings before the future major removal."
68
+ : "Run conservative synchronization automatically at session startup and shutdown.",
69
+ currentValue: automaticSyncValue,
70
+ ...(automaticSyncOverridden ? {} : { values: ["On", "Off"] }),
71
+ },
72
+ ...(version2
73
+ ? [
74
+ {
75
+ id: "targetSwitchAction",
76
+ label: "After target switch",
77
+ description:
78
+ "Ask before starting a pull, start a pull review automatically, or switch without checking remote files. Every pull still shows exact changes before apply.",
79
+ currentValue: targetSwitchValue,
80
+ values: TARGET_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
81
+ },
82
+ ]
83
+ : []),
84
+ {
85
+ id: "syncFiles",
86
+ label: "Synced content",
87
+ description: `${normalizeSyncFiles(partial.syncFiles).length} built-in groups and ${normalizeExtraFiles(partial.extraFiles).length} extra files. Opens the reviewed content-selection draft.`,
88
+ currentValue: "Open editor",
89
+ values: ["Open editor"],
90
+ },
91
+ ];
92
+ const container = new Container();
93
+ container.addChild(new Text(theme.fg("accent", theme.bold("Pi Sync Settings")), 1, 0));
94
+ container.addChild(
95
+ new Text(
96
+ theme.fg(
97
+ "muted",
98
+ `Target: ${safeTerminalText(partial.target ?? "default")} · ${storageDescription(partial.storageKind, partial.endpoint, partial.bucket)}`,
99
+ ),
100
+ 1,
101
+ 0,
102
+ ),
103
+ );
104
+ let settingsList: SettingsList;
105
+ let closing = false;
106
+ const closeAfterSaves = (result: "files" | undefined) => {
107
+ if (closing) return;
108
+ closing = true;
109
+ void saveQueue.then(() => done(result));
110
+ };
111
+ settingsList = new SettingsList(
112
+ items,
113
+ Math.min(items.length + 2, 15),
114
+ getSettingsListTheme(),
115
+ (id, newValue) => {
116
+ if (id === "syncFiles") {
117
+ closeAfterSaves("files");
118
+ return;
119
+ }
120
+ latestRequested.set(id, newValue);
121
+ const fallbackValue = id === "automaticSync" ? automaticSyncValue : targetSwitchValue;
122
+ const operation = saveQueue.then(async () => {
123
+ let previousValue = fallbackValue;
124
+ try {
125
+ if (id === "automaticSync") {
126
+ const latest = await loadPartialConfig(targetName);
127
+ previousValue = isEnabled(latest.autoSync, true) ? "On" : "Off";
128
+ const enabled = newValue === "On";
129
+ await updateSettingsTarget(targetName, (target) => ({
130
+ ...target,
131
+ autoSync: enabled,
132
+ }));
133
+ ctx.ui.notify(
134
+ `Automatic sync ${enabled ? "enabled" : "disabled"} for “${safeTerminalText(partial.target ?? "default")}”.`,
135
+ "info",
136
+ );
137
+ } else if (id === "targetSwitchAction") {
138
+ const latest = await loadTargetSwitchAction();
139
+ previousValue = targetSwitchActionLabel(latest);
140
+ const action = targetSwitchActionFromLabel(newValue);
141
+ if (!action) throw new Error(`Invalid target-switch action: ${newValue}`);
142
+ await saveTargetSwitchAction(action);
143
+ ctx.ui.notify(`After target switch: ${newValue}.`, "info");
144
+ }
145
+ } catch (error) {
146
+ if (latestRequested.get(id) === newValue) {
147
+ settingsList.updateValue(id, previousValue);
148
+ }
149
+ ctx.ui.notify(`Pi Sync settings save failed: ${errorMessage(error)}`, "error");
150
+ tui.requestRender();
151
+ }
152
+ });
153
+ saveQueue = operation.catch(() => undefined);
154
+ },
155
+ () => closeAfterSaves(undefined),
156
+ );
157
+ container.addChild(settingsList);
158
+ return {
159
+ render: (width: number) => container.render(width),
160
+ invalidate: () => container.invalidate(),
161
+ handleInput(data: string) {
162
+ settingsList.handleInput?.(data);
163
+ tui.requestRender();
164
+ },
165
+ };
166
+ });
167
+ }
168
+
169
+ async function updateSettingsTarget(
170
+ targetName: string | undefined,
171
+ update: (target: Record<string, unknown>) => Record<string, unknown>,
172
+ ) {
173
+ await updateLocalConfig((current) => {
174
+ if (current.version !== 2) return update(current);
175
+ const targets = ownRecord(current.targets);
176
+ const selected =
177
+ targetName ?? (typeof current.activeTarget === "string" ? current.activeTarget : undefined);
178
+ if (!targets || !selected) throw new Error("Settings target is not configured.");
179
+ const target = ownRecord(targets[selected]);
180
+ if (!target) throw new Error(`Settings target “${selected}” is invalid.`);
181
+ return { ...current, targets: { ...targets, [selected]: update(target) } };
182
+ });
183
+ }
184
+
185
+ function storageDescription(
186
+ kind: string | undefined,
187
+ endpoint: string | undefined,
188
+ bucket: string | undefined,
189
+ ) {
190
+ const label =
191
+ kind === "r2" || isCloudflareR2Endpoint(endpoint) ? "Cloudflare R2" : "S3-compatible";
192
+ return `${label} · ${safeTerminalText(bucket ?? "bucket missing")}`;
193
+ }
194
+
195
+ function ownRecord(value: unknown): Record<string, unknown> | undefined {
196
+ return value && typeof value === "object" && !Array.isArray(value)
197
+ ? (value as Record<string, unknown>)
198
+ : undefined;
199
+ }
package/src/sync.ts CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  } from "./config.js";
40
40
  import { showFileSelection } from "./file-selection.js";
41
41
  import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
42
- import { showSetupWizard, showSyncManager, useSyncTarget } from "./manager-ui.js";
42
+ import { showSetupWizard, showSyncManager } from "./manager-ui.js";
43
43
  import {
44
44
  historyKey,
45
45
  latestKey,
@@ -86,6 +86,7 @@ import {
86
86
  snapshotsMatch,
87
87
  syncPolicyChanged,
88
88
  } from "./sync-state.js";
89
+ import { TargetPullRequiresUiError, useSyncTarget } from "./target-switch.js";
89
90
  import type {
90
91
  CommandOptions,
91
92
  LatestPointer,
@@ -96,9 +97,7 @@ import type {
96
97
  SyncState,
97
98
  } from "./types.js";
98
99
 
99
- const gzipAsync = promisify(gzip);
100
- const gunzipAsync = promisify(gunzip);
101
-
100
+ const [gzipAsync, gunzipAsync] = [promisify(gzip), promisify(gunzip)];
102
101
  const STATUS_KEY = "sync";
103
102
  const VERSION = 1;
104
103
  const DEFAULT_PROFILE = "default";
@@ -159,8 +158,8 @@ export default function sync(pi: ExtensionAPI) {
159
158
  async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
160
159
  if (!rawArgs.trim()) {
161
160
  try {
162
- await showSyncManager(ctx, (route, signal, onCommit) =>
163
- executeCommand(route, ctx, signal, onCommit),
161
+ await showSyncManager(ctx, (route, signal, onCommit, target) =>
162
+ executeCommand(route, ctx, signal, onCommit, target),
164
163
  );
165
164
  } catch (error) {
166
165
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -176,12 +175,14 @@ async function executeCommand(
176
175
  ctx: ExtensionCommandContext,
177
176
  signal?: AbortSignal,
178
177
  onCommit?: () => void,
178
+ target?: string,
179
179
  ) {
180
180
  try {
181
181
  const command = await resolveSyncCommand(rawArgs, ctx);
182
182
  if (!command) return;
183
183
  const { subcommand, rest } = command;
184
184
  const options = parseOptions(rest);
185
+ if (target !== undefined) options.target = target;
185
186
  if (signal) options.signal = signal;
186
187
  if (onCommit) options.onCommit = onCommit;
187
188
  validateCommandOptions(subcommand, options);
@@ -191,7 +192,9 @@ async function executeCommand(
191
192
  ctx.ui.notify(usage(), "info");
192
193
  return;
193
194
  case "use":
194
- await useSyncTarget(ctx, options.args[0] ?? "");
195
+ await useSyncTarget(ctx, options.args[0] ?? "", (selectedTarget) =>
196
+ withLock("pull", () => pull(ctx, { ...options, target: selectedTarget })),
197
+ );
195
198
  return;
196
199
  case "init":
197
200
  await initConfig(ctx);
@@ -215,8 +218,7 @@ async function executeCommand(
215
218
  await withLock("push", () => push(ctx, options));
216
219
  return;
217
220
  case "pull":
218
- await withLock("pull", () => pull(ctx, options));
219
- return;
221
+ return await withLock("pull", () => pull(ctx, options));
220
222
  case "sync":
221
223
  await withLock("sync", () => syncBoth(ctx, options));
222
224
  return;
@@ -235,6 +237,7 @@ async function executeCommand(
235
237
  } catch (error) {
236
238
  ctx.ui.setStatus(STATUS_KEY, undefined);
237
239
  if (signal?.aborted && error instanceof Error && error.name === "AbortError") return;
240
+ if (error instanceof TargetPullRequiresUiError) throw error;
238
241
  ctx.ui.notify(errorMessage(error), "error");
239
242
  }
240
243
  }
@@ -552,7 +555,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
552
555
  ) {
553
556
  ctx.ui.setStatus(STATUS_KEY, undefined);
554
557
  ctx.ui.notify("Pull cancelled.", "info");
555
- return;
558
+ return "cancelled" as const;
556
559
  }
557
560
 
558
561
  options.onCommit?.();
@@ -586,6 +589,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
586
589
  );
587
590
  }
588
591
  if (options.reload) await maybeReload(ctx);
592
+ return "applied" as const;
589
593
  }
590
594
 
591
595
  async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
@@ -0,0 +1,119 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { setSyncTargetCompletions } from "./command.js";
3
+ import {
4
+ configuredTargetNames,
5
+ loadConfig,
6
+ loadTargetSwitchAction,
7
+ readLocalConfigObject,
8
+ updateLocalConfig,
9
+ } from "./config.js";
10
+ import { safeTerminalText } from "./sync-format.js";
11
+ import type { TargetSwitchAction } from "./types.js";
12
+
13
+ export const TARGET_SWITCH_ACTION_OPTIONS: ReadonlyArray<{
14
+ label: string;
15
+ value: TargetSwitchAction;
16
+ }> = [
17
+ { label: "Ask before pull", value: "ask" },
18
+ { label: "Start pull", value: "pull" },
19
+ { label: "Switch only", value: "switch-only" },
20
+ ];
21
+
22
+ export function targetSwitchActionLabel(action: TargetSwitchAction) {
23
+ return TARGET_SWITCH_ACTION_OPTIONS.find((option) => option.value === action)?.label ?? action;
24
+ }
25
+
26
+ export function targetSwitchActionFromLabel(label: string): TargetSwitchAction | undefined {
27
+ return TARGET_SWITCH_ACTION_OPTIONS.find((option) => option.label === label)?.value;
28
+ }
29
+
30
+ export async function saveTargetSwitchAction(action: TargetSwitchAction) {
31
+ await updateLocalConfig((settings) => {
32
+ if (settings.version !== 2) {
33
+ throw new Error("Target-switch settings require version 2 settings.");
34
+ }
35
+ return { ...settings, targetSwitchAction: action };
36
+ });
37
+ }
38
+
39
+ export type TargetPullOutcome = "applied" | "cancelled";
40
+
41
+ export class TargetPullRequiresUiError extends Error {}
42
+
43
+ export interface TargetSwitchResult {
44
+ pullApplied: boolean;
45
+ }
46
+
47
+ export async function useSyncTarget(
48
+ ctx: ExtensionCommandContext,
49
+ name: string,
50
+ pullCurrentTarget?: (target: string) => Promise<TargetPullOutcome | undefined>,
51
+ ): Promise<TargetSwitchResult> {
52
+ const normalized = name.trim();
53
+ if (!normalized) throw new Error("Usage: /sync use <target>");
54
+ await loadConfig(normalized);
55
+ const before = await readLocalConfigObject();
56
+ if (before?.version === 2 && before.activeTarget === normalized) {
57
+ ctx.ui.notify(`Target “${safeTerminalText(normalized)}” is already current.`, "info");
58
+ return { pullApplied: false };
59
+ }
60
+ const action = await loadTargetSwitchAction();
61
+ if (action === "pull" && !ctx.hasUI) {
62
+ throw new TargetPullRequiresUiError(
63
+ `Automatic target pulls require interactive confirmation; target “${safeTerminalText(normalized)}” was not switched. Use TUI or RPC mode.`,
64
+ );
65
+ }
66
+ let switched = false;
67
+ await updateLocalConfig((current) => {
68
+ if (current.version !== 2) throw new Error("Target switching requires version 2 settings.");
69
+ if (current.activeTarget === normalized) return current;
70
+ switched = true;
71
+ return { ...current, activeTarget: normalized };
72
+ });
73
+ if (!switched) {
74
+ ctx.ui.notify(`Target “${safeTerminalText(normalized)}” is already current.`, "info");
75
+ return { pullApplied: false };
76
+ }
77
+ setSyncTargetCompletions(await configuredTargetNames());
78
+
79
+ if (action === "switch-only") {
80
+ ctx.ui.notify(`Switched to “${safeTerminalText(normalized)}”. No files were pulled.`, "info");
81
+ return { pullApplied: false };
82
+ }
83
+ if (action === "ask") {
84
+ if (ctx.mode !== "tui") {
85
+ ctx.ui.notify(
86
+ `Switched to “${safeTerminalText(normalized)}”. No files were pulled because confirmation requires TUI mode; run /sync pull to apply the target.`,
87
+ "info",
88
+ );
89
+ return { pullApplied: false };
90
+ }
91
+ const confirmed = await ctx.ui.confirm(
92
+ `Review a pull for target “${safeTerminalText(normalized)}” now?`,
93
+ "pi-sync will check the remote snapshot and show the exact local writes and deletions before applying anything.",
94
+ );
95
+ if (!confirmed) {
96
+ ctx.ui.notify(
97
+ `Switched to “${safeTerminalText(normalized)}”; files were not pulled.`,
98
+ "info",
99
+ );
100
+ return { pullApplied: false };
101
+ }
102
+ }
103
+
104
+ ctx.ui.notify(
105
+ `Switched to “${safeTerminalText(normalized)}”. Checking remote files for a reviewed pull…`,
106
+ "info",
107
+ );
108
+ if (!pullCurrentTarget) {
109
+ throw new Error(`Switched to “${safeTerminalText(normalized)}”, but pull is unavailable.`);
110
+ }
111
+ const pullOutcome = await pullCurrentTarget(normalized);
112
+ if (pullOutcome === "cancelled") {
113
+ ctx.ui.notify(
114
+ `Pull cancelled; target “${safeTerminalText(normalized)}” remains active and synced files were not changed.`,
115
+ "info",
116
+ );
117
+ }
118
+ return { pullApplied: pullOutcome === "applied" };
119
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type StorageProfileKind = "r2" | "s3-compatible";
2
+ export type TargetSwitchAction = "ask" | "pull" | "switch-only";
2
3
 
3
4
  export interface StorageProfileSettings {
4
5
  kind?: StorageProfileKind;
@@ -23,6 +24,7 @@ export interface SyncTargetSettings {
23
24
  export interface PiSyncSettingsV2 {
24
25
  version: 2;
25
26
  activeTarget?: string;
27
+ targetSwitchAction?: TargetSwitchAction;
26
28
  profiles?: Record<string, StorageProfileSettings>;
27
29
  targets?: Record<string, SyncTargetSettings>;
28
30
  [key: string]: unknown;