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