@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 +15 -5
- package/package.json +1 -1
- package/src/config.ts +24 -1
- package/src/file-selection.ts +0 -1
- package/src/manager-ui.ts +45 -86
- package/src/settings-management.ts +2 -0
- package/src/settings-ui.ts +199 -0
- package/src/sync.ts +14 -10
- package/src/target-switch.ts +119 -0
- package/src/types.ts +2 -0
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
|
-
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
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 {
|
|
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",
|
package/src/file-selection.ts
CHANGED
|
@@ -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 = (
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
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;
|