@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/README.md +38 -15
- package/package.json +1 -1
- package/src/command.ts +1 -1
- package/src/config-file.ts +545 -0
- package/src/config.ts +130 -19
- package/src/file-selection.ts +0 -1
- package/src/manager-ui.ts +125 -98
- package/src/paths.ts +6 -1
- package/src/settings-management.ts +24 -9
- package/src/settings-ui.ts +199 -0
- package/src/sync.ts +21 -16
- package/src/target-switch.ts +119 -0
- package/src/types.ts +2 -0
|
@@ -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
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { constants as fsConstants } from "node:fs";
|
|
2
1
|
import fs from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { promisify } from "node:util";
|
|
@@ -17,7 +16,9 @@ import {
|
|
|
17
16
|
validateCommandOptions,
|
|
18
17
|
} from "./command.js";
|
|
19
18
|
import {
|
|
19
|
+
activeLocalConfigPath,
|
|
20
20
|
configuredTargetNames,
|
|
21
|
+
consumeLocalConfigMigrationNotice,
|
|
21
22
|
deprecatedPiSyncEnvironmentWarnings,
|
|
22
23
|
ensureStateDir,
|
|
23
24
|
isEnabled,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
localConfigTemplate,
|
|
30
31
|
normalizeExtraFiles,
|
|
31
32
|
normalizeSyncFiles,
|
|
33
|
+
readLocalConfigObject,
|
|
32
34
|
readStateForConfig,
|
|
33
35
|
sessionDirForApply,
|
|
34
36
|
sessionTokenWarnings,
|
|
@@ -39,7 +41,7 @@ import {
|
|
|
39
41
|
} from "./config.js";
|
|
40
42
|
import { showFileSelection } from "./file-selection.js";
|
|
41
43
|
import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
|
|
42
|
-
import { showSetupWizard, showSyncManager
|
|
44
|
+
import { showSetupWizard, showSyncManager } from "./manager-ui.js";
|
|
43
45
|
import {
|
|
44
46
|
historyKey,
|
|
45
47
|
latestKey,
|
|
@@ -86,6 +88,7 @@ import {
|
|
|
86
88
|
snapshotsMatch,
|
|
87
89
|
syncPolicyChanged,
|
|
88
90
|
} from "./sync-state.js";
|
|
91
|
+
import { TargetPullRequiresUiError, useSyncTarget } from "./target-switch.js";
|
|
89
92
|
import type {
|
|
90
93
|
CommandOptions,
|
|
91
94
|
LatestPointer,
|
|
@@ -96,9 +99,7 @@ import type {
|
|
|
96
99
|
SyncState,
|
|
97
100
|
} from "./types.js";
|
|
98
101
|
|
|
99
|
-
const gzipAsync = promisify(gzip);
|
|
100
|
-
const gunzipAsync = promisify(gunzip);
|
|
101
|
-
|
|
102
|
+
const [gzipAsync, gunzipAsync] = [promisify(gzip), promisify(gunzip)];
|
|
102
103
|
const STATUS_KEY = "sync";
|
|
103
104
|
const VERSION = 1;
|
|
104
105
|
const DEFAULT_PROFILE = "default";
|
|
@@ -143,6 +144,8 @@ export default function sync(pi: ExtensionAPI) {
|
|
|
143
144
|
} catch {
|
|
144
145
|
setSyncTargetCompletions([]);
|
|
145
146
|
}
|
|
147
|
+
const migrationNotice = consumeLocalConfigMigrationNotice();
|
|
148
|
+
if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
|
|
146
149
|
const warnings = deprecatedPiSyncEnvironmentWarnings();
|
|
147
150
|
if (warnings.length > 0) ctx.ui.notify(warnings.join("\n"), "warning");
|
|
148
151
|
await autoSync(ctx);
|
|
@@ -159,8 +162,8 @@ export default function sync(pi: ExtensionAPI) {
|
|
|
159
162
|
async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
|
|
160
163
|
if (!rawArgs.trim()) {
|
|
161
164
|
try {
|
|
162
|
-
await showSyncManager(ctx, (route, signal, onCommit) =>
|
|
163
|
-
executeCommand(route, ctx, signal, onCommit),
|
|
165
|
+
await showSyncManager(ctx, (route, signal, onCommit, target) =>
|
|
166
|
+
executeCommand(route, ctx, signal, onCommit, target),
|
|
164
167
|
);
|
|
165
168
|
} catch (error) {
|
|
166
169
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -176,12 +179,14 @@ async function executeCommand(
|
|
|
176
179
|
ctx: ExtensionCommandContext,
|
|
177
180
|
signal?: AbortSignal,
|
|
178
181
|
onCommit?: () => void,
|
|
182
|
+
target?: string,
|
|
179
183
|
) {
|
|
180
184
|
try {
|
|
181
185
|
const command = await resolveSyncCommand(rawArgs, ctx);
|
|
182
186
|
if (!command) return;
|
|
183
187
|
const { subcommand, rest } = command;
|
|
184
188
|
const options = parseOptions(rest);
|
|
189
|
+
if (target !== undefined) options.target = target;
|
|
185
190
|
if (signal) options.signal = signal;
|
|
186
191
|
if (onCommit) options.onCommit = onCommit;
|
|
187
192
|
validateCommandOptions(subcommand, options);
|
|
@@ -191,7 +196,9 @@ async function executeCommand(
|
|
|
191
196
|
ctx.ui.notify(usage(), "info");
|
|
192
197
|
return;
|
|
193
198
|
case "use":
|
|
194
|
-
await useSyncTarget(ctx, options.args[0] ?? "")
|
|
199
|
+
await useSyncTarget(ctx, options.args[0] ?? "", (selectedTarget) =>
|
|
200
|
+
withLock("pull", () => pull(ctx, { ...options, target: selectedTarget })),
|
|
201
|
+
);
|
|
195
202
|
return;
|
|
196
203
|
case "init":
|
|
197
204
|
await initConfig(ctx);
|
|
@@ -215,8 +222,7 @@ async function executeCommand(
|
|
|
215
222
|
await withLock("push", () => push(ctx, options));
|
|
216
223
|
return;
|
|
217
224
|
case "pull":
|
|
218
|
-
await withLock("pull", () => pull(ctx, options));
|
|
219
|
-
return;
|
|
225
|
+
return await withLock("pull", () => pull(ctx, options));
|
|
220
226
|
case "sync":
|
|
221
227
|
await withLock("sync", () => syncBoth(ctx, options));
|
|
222
228
|
return;
|
|
@@ -235,6 +241,7 @@ async function executeCommand(
|
|
|
235
241
|
} catch (error) {
|
|
236
242
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
237
243
|
if (signal?.aborted && error instanceof Error && error.name === "AbortError") return;
|
|
244
|
+
if (error instanceof TargetPullRequiresUiError) throw error;
|
|
238
245
|
ctx.ui.notify(errorMessage(error), "error");
|
|
239
246
|
}
|
|
240
247
|
}
|
|
@@ -276,12 +283,9 @@ async function autoPushSessions(ctx: ExtensionContext) {
|
|
|
276
283
|
|
|
277
284
|
async function initConfig(ctx: ExtensionCommandContext) {
|
|
278
285
|
const configPath = localConfigPath();
|
|
279
|
-
|
|
280
|
-
await
|
|
281
|
-
ctx.ui.notify(`Config already exists: ${configPath}`, "info");
|
|
286
|
+
if (await readLocalConfigObject()) {
|
|
287
|
+
ctx.ui.notify(`Config already exists: ${await activeLocalConfigPath()}`, "info");
|
|
282
288
|
return;
|
|
283
|
-
} catch {
|
|
284
|
-
// Create or guide setup below.
|
|
285
289
|
}
|
|
286
290
|
|
|
287
291
|
if (ctx.mode === "tui") {
|
|
@@ -552,7 +556,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
552
556
|
) {
|
|
553
557
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
554
558
|
ctx.ui.notify("Pull cancelled.", "info");
|
|
555
|
-
return;
|
|
559
|
+
return "cancelled" as const;
|
|
556
560
|
}
|
|
557
561
|
|
|
558
562
|
options.onCommit?.();
|
|
@@ -586,6 +590,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
|
|
|
586
590
|
);
|
|
587
591
|
}
|
|
588
592
|
if (options.reload) await maybeReload(ctx);
|
|
593
|
+
return "applied" as const;
|
|
589
594
|
}
|
|
590
595
|
|
|
591
596
|
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;
|