@narumitw/pi-sync 0.46.0 → 0.49.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 +24 -0
- package/package.json +2 -2
- package/src/backend-factory.ts +11 -8
- package/src/cancellable-operation.ts +16 -7
- package/src/manager-ui.ts +52 -7
- package/src/settings-ui.ts +2 -7
- package/src/sync-decision.ts +113 -0
- package/src/sync-operations.ts +79 -27
- package/src/sync-resolution-ui.ts +166 -0
- package/src/sync.ts +89 -49
package/README.md
CHANGED
|
@@ -58,6 +58,29 @@ recovery. Secondary screens provide **Back**; Escape goes back and Ctrl+C closes
|
|
|
58
58
|
Destructive, credential-bearing, and externally visible operations retain exact specialized previews
|
|
59
59
|
and confirmations.
|
|
60
60
|
|
|
61
|
+
### Resolve conflicts in the manager
|
|
62
|
+
|
|
63
|
+
When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human
|
|
64
|
+
direction choice, the manager opens **Resolve sync conflict** instead of ending at a command-only
|
|
65
|
+
error. The flow names the current sync setup and explains whether local content, remote content, or
|
|
66
|
+
the included-content policy changed.
|
|
67
|
+
|
|
68
|
+
Choose **Review differences (recommended)** to inspect the exact affected paths without changing
|
|
69
|
+
local files, remote data, or sync state. Then choose one reviewed direction:
|
|
70
|
+
|
|
71
|
+
- **Keep local content and replace remote…** uses the existing forced-push path. It scans managed
|
|
72
|
+
local files for secrets, shows the exact remote publication effect, re-reads a changed remote head,
|
|
73
|
+
and asks again if the reviewed plan changed.
|
|
74
|
+
- **Use remote content and replace local…** uses the existing forced-pull path. It shows exact local
|
|
75
|
+
writes and deletions, protects the live session, and creates a local backup before applying.
|
|
76
|
+
- First sync uses **Use local as initial source…** and **Use remote as initial source…** labels. An
|
|
77
|
+
empty remote offers **Push local content…** only.
|
|
78
|
+
|
|
79
|
+
Cancelling a preparation or confirmation returns to conflict resolution with no side effects. Back
|
|
80
|
+
returns to the sync manager; Ctrl+C closes the complete flow. Automatic startup/shutdown sync never
|
|
81
|
+
opens this interactive flow and continues to report an actionable warning. Deterministic direct
|
|
82
|
+
routes remain available and keep their command-oriented error guidance.
|
|
83
|
+
|
|
61
84
|
## ⚙️ Settings
|
|
62
85
|
|
|
63
86
|
The canonical private user file is:
|
|
@@ -269,6 +292,7 @@ extensions/pi-sync/
|
|
|
269
292
|
│ ├── file-selection.ts
|
|
270
293
|
│ ├── sync-operations.ts
|
|
271
294
|
│ ├── sync-backend.ts
|
|
295
|
+
│ ├── backend-factory.ts # lazy selected-backend loader
|
|
272
296
|
│ ├── s3-backend.ts
|
|
273
297
|
│ ├── webdav-backend.ts
|
|
274
298
|
│ ├── git-backend.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Pi extension that syncs Pi configuration through Git, WebDAV, Cloudflare R2, or S3-compatible storage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"typecheck": "tsc --noEmit"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@narumitw/pi-tui-kit": "^0.
|
|
39
|
+
"@narumitw/pi-tui-kit": "^0.46.0",
|
|
40
40
|
"fast-xml-parser": "^5.10.1",
|
|
41
41
|
"proper-lockfile": "^4.1.2"
|
|
42
42
|
},
|
package/src/backend-factory.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
import { GitSyncBackend } from "./git-backend.js";
|
|
2
|
-
import { S3SyncBackend } from "./s3-backend.js";
|
|
3
1
|
import type { SyncBackend } from "./sync-backend.js";
|
|
4
2
|
import type { AnySyncConfig } from "./types.js";
|
|
5
|
-
import { WebDavSyncBackend } from "./webdav-backend.js";
|
|
6
3
|
|
|
7
4
|
export type SyncBackendFactory = {
|
|
8
|
-
bivarianceHack(config: AnySyncConfig): SyncBackend
|
|
5
|
+
bivarianceHack(config: AnySyncConfig): SyncBackend | Promise<SyncBackend>;
|
|
9
6
|
}["bivarianceHack"];
|
|
10
7
|
|
|
11
|
-
export const createSyncBackend: SyncBackendFactory = (config) => {
|
|
8
|
+
export const createSyncBackend: SyncBackendFactory = async (config) => {
|
|
12
9
|
switch (config.backend.type) {
|
|
13
|
-
case "s3":
|
|
10
|
+
case "s3": {
|
|
11
|
+
const { S3SyncBackend } = await import("./s3-backend.js");
|
|
14
12
|
return new S3SyncBackend(config.backend);
|
|
15
|
-
|
|
13
|
+
}
|
|
14
|
+
case "webdav": {
|
|
15
|
+
const { WebDavSyncBackend } = await import("./webdav-backend.js");
|
|
16
16
|
return new WebDavSyncBackend(config.backend);
|
|
17
|
-
|
|
17
|
+
}
|
|
18
|
+
case "git": {
|
|
19
|
+
const { GitSyncBackend } = await import("./git-backend.js");
|
|
18
20
|
return new GitSyncBackend(config.backend);
|
|
21
|
+
}
|
|
19
22
|
}
|
|
20
23
|
};
|
|
@@ -7,15 +7,24 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
|
7
7
|
import { runCustomInteraction } from "@narumitw/pi-tui-kit";
|
|
8
8
|
import { safeTerminalText } from "./manager-helpers.js";
|
|
9
9
|
import type { SetupPullOutcome } from "./setup-switch.js";
|
|
10
|
+
import type { SyncDecision } from "./sync-decision.js";
|
|
11
|
+
|
|
12
|
+
export type RunRouteResult =
|
|
13
|
+
| { kind: "completed"; outcome?: SetupPullOutcome }
|
|
14
|
+
| { kind: "decision-required"; decision: SyncDecision }
|
|
15
|
+
| { kind: "failed" };
|
|
10
16
|
|
|
11
17
|
export type RunRoute = (
|
|
12
18
|
route: string,
|
|
13
19
|
signal?: AbortSignal,
|
|
14
20
|
onCommit?: () => void,
|
|
15
21
|
target?: string,
|
|
16
|
-
) => Promise<
|
|
22
|
+
) => Promise<RunRouteResult | undefined>;
|
|
17
23
|
|
|
18
|
-
export type CancellableOperationResult =
|
|
24
|
+
export type CancellableOperationResult =
|
|
25
|
+
| RunRouteResult
|
|
26
|
+
| { kind: "closed" }
|
|
27
|
+
| { kind: "cancelled" };
|
|
19
28
|
|
|
20
29
|
interface CancellableOperationOptions {
|
|
21
30
|
commitAware?: boolean;
|
|
@@ -38,10 +47,10 @@ export async function runCancellableOperation(
|
|
|
38
47
|
signal,
|
|
39
48
|
} = options;
|
|
40
49
|
if (ctx.mode !== "tui") {
|
|
41
|
-
return await runRoute(route,
|
|
50
|
+
return (await runRoute(route, signal, undefined, target)) ?? { kind: "failed" };
|
|
42
51
|
}
|
|
43
52
|
let commitStarted = false;
|
|
44
|
-
let routeResult:
|
|
53
|
+
let routeResult: RunRouteResult | undefined;
|
|
45
54
|
const interaction = await runCustomInteraction<{ cancelled?: boolean; error?: unknown }>(ctx, {
|
|
46
55
|
signal,
|
|
47
56
|
isCurrent: () => !signal?.aborted,
|
|
@@ -89,13 +98,13 @@ export async function runCancellableOperation(
|
|
|
89
98
|
},
|
|
90
99
|
});
|
|
91
100
|
if (interaction.kind === "error") throw interaction.error;
|
|
92
|
-
if (interaction.kind !== "completed") return "closed";
|
|
101
|
+
if (interaction.kind !== "completed") return { kind: "closed" };
|
|
93
102
|
if (interaction.value.cancelled) {
|
|
94
103
|
if (cancelledMessage) ctx.ui.notify(cancelledMessage, "info");
|
|
95
|
-
return "cancelled";
|
|
104
|
+
return { kind: "cancelled" };
|
|
96
105
|
}
|
|
97
106
|
if (interaction.value.error) throw interaction.value.error;
|
|
98
|
-
return routeResult;
|
|
107
|
+
return routeResult ?? { kind: "failed" };
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
function keybindingText(
|
package/src/manager-ui.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type CancellableOperationResult,
|
|
5
|
+
type RunRoute,
|
|
6
|
+
runCancellableOperation,
|
|
7
|
+
} from "./cancellable-operation.js";
|
|
4
8
|
import { setSyncSetupCompletions } from "./command.js";
|
|
5
9
|
import {
|
|
6
10
|
configuredSyncSetupNames,
|
|
@@ -33,6 +37,7 @@ import { showSyncSettings } from "./settings-ui.js";
|
|
|
33
37
|
import { useSyncSetup } from "./setup-switch.js";
|
|
34
38
|
import { showAddStorageConnection, showStorageConnections } from "./storage-connections-ui.js";
|
|
35
39
|
import { DEFAULT_SYNC_INCLUDE, syncIncludeSelection } from "./sync-policy.js";
|
|
40
|
+
import { showSyncResolution } from "./sync-resolution-ui.js";
|
|
36
41
|
import { showSyncSetups } from "./sync-setups-ui.js";
|
|
37
42
|
import type { AnySyncConfig } from "./types.js";
|
|
38
43
|
import { showAddWebDavTarget, showEditWebDavTarget, showWebDavSetup } from "./webdav-ui.js";
|
|
@@ -120,7 +125,10 @@ export async function showSyncManager(
|
|
|
120
125
|
signal: sessionSignal,
|
|
121
126
|
},
|
|
122
127
|
);
|
|
123
|
-
|
|
128
|
+
const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
|
|
129
|
+
return result.kind === "closed" || resolution === "close"
|
|
130
|
+
? { kind: "close" }
|
|
131
|
+
: { kind: "stay" };
|
|
124
132
|
},
|
|
125
133
|
switch: async () => {
|
|
126
134
|
const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
|
|
@@ -136,7 +144,7 @@ export async function showSyncManager(
|
|
|
136
144
|
runRoute,
|
|
137
145
|
{ signal: sessionSignal },
|
|
138
146
|
);
|
|
139
|
-
return result === "closed" ? { kind: "close" } : { kind: "stay" };
|
|
147
|
+
return result.kind === "closed" ? { kind: "close" } : { kind: "stay" };
|
|
140
148
|
},
|
|
141
149
|
settings: async () => {
|
|
142
150
|
await showSyncSettings(ctx, runRoute, sessionSignal);
|
|
@@ -154,7 +162,12 @@ export async function showSyncManager(
|
|
|
154
162
|
signal: sessionSignal,
|
|
155
163
|
},
|
|
156
164
|
);
|
|
157
|
-
|
|
165
|
+
const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
|
|
166
|
+
return result.kind === "closed" ||
|
|
167
|
+
(result.kind === "completed" && result.outcome === "applied") ||
|
|
168
|
+
resolution === "close"
|
|
169
|
+
? { kind: "close" }
|
|
170
|
+
: { kind: "stay" };
|
|
158
171
|
},
|
|
159
172
|
push: async () => {
|
|
160
173
|
const result = await runCancellableOperation(
|
|
@@ -168,7 +181,10 @@ export async function showSyncManager(
|
|
|
168
181
|
signal: sessionSignal,
|
|
169
182
|
},
|
|
170
183
|
);
|
|
171
|
-
|
|
184
|
+
const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
|
|
185
|
+
return result.kind === "closed" || resolution === "close"
|
|
186
|
+
? { kind: "close" }
|
|
187
|
+
: { kind: "stay" };
|
|
172
188
|
},
|
|
173
189
|
setups: async () => {
|
|
174
190
|
const result = await showSyncSetupManager(ctx, runRoute, sessionSignal);
|
|
@@ -218,6 +234,21 @@ export async function showSyncManager(
|
|
|
218
234
|
});
|
|
219
235
|
}
|
|
220
236
|
|
|
237
|
+
async function resolveManagerDecision(
|
|
238
|
+
ctx: ExtensionCommandContext,
|
|
239
|
+
result: CancellableOperationResult,
|
|
240
|
+
runRoute: RunRoute,
|
|
241
|
+
signal?: AbortSignal,
|
|
242
|
+
) {
|
|
243
|
+
if (result.kind !== "decision-required") return undefined;
|
|
244
|
+
const resolution = await showSyncResolution(ctx, result.decision, runRoute, signal);
|
|
245
|
+
return resolution.kind === "resolved" ||
|
|
246
|
+
resolution.kind === "closed" ||
|
|
247
|
+
resolution.kind === "stale"
|
|
248
|
+
? "close"
|
|
249
|
+
: "stay";
|
|
250
|
+
}
|
|
251
|
+
|
|
221
252
|
function syncMainMenuItem(
|
|
222
253
|
label: string,
|
|
223
254
|
): ActionMenuItem<
|
|
@@ -522,11 +553,25 @@ async function showSetupSwitcher(
|
|
|
522
553
|
signal,
|
|
523
554
|
},
|
|
524
555
|
);
|
|
525
|
-
if (pullResult === "closed") {
|
|
556
|
+
if (pullResult.kind === "closed") {
|
|
526
557
|
pullClosed = true;
|
|
527
558
|
return undefined;
|
|
528
559
|
}
|
|
529
|
-
|
|
560
|
+
if (pullResult.kind === "decision-required") {
|
|
561
|
+
const resolution = await showSyncResolution(ctx, pullResult.decision, runRoute, signal);
|
|
562
|
+
if (
|
|
563
|
+
resolution.kind === "resolved" ||
|
|
564
|
+
resolution.kind === "closed" ||
|
|
565
|
+
resolution.kind === "stale"
|
|
566
|
+
) {
|
|
567
|
+
pullClosed = true;
|
|
568
|
+
}
|
|
569
|
+
return resolution.kind === "resolved" && resolution.direction === "pull"
|
|
570
|
+
? "applied"
|
|
571
|
+
: undefined;
|
|
572
|
+
}
|
|
573
|
+
if (pullResult.kind === "completed") return pullResult.outcome;
|
|
574
|
+
return pullResult.kind === "cancelled" ? "cancelled" : undefined;
|
|
530
575
|
},
|
|
531
576
|
onSwitch,
|
|
532
577
|
signal,
|
package/src/settings-ui.ts
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import type { RunRoute } from "./cancellable-operation.js";
|
|
3
4
|
import { loadConfig, localConfigPath } from "./config.js";
|
|
4
5
|
import { updateSyncSetup } from "./settings-management.js";
|
|
5
6
|
import {
|
|
6
7
|
SETUP_SWITCH_ACTION_OPTIONS,
|
|
7
|
-
type SetupPullOutcome,
|
|
8
8
|
saveOnSwitch,
|
|
9
9
|
setupSwitchActionFromLabel,
|
|
10
10
|
setupSwitchActionLabel,
|
|
11
11
|
} from "./setup-switch.js";
|
|
12
12
|
import { safeTerminalText } from "./sync-format.js";
|
|
13
13
|
|
|
14
|
-
export type SyncSettingsRoute =
|
|
15
|
-
route: string,
|
|
16
|
-
signal?: AbortSignal,
|
|
17
|
-
onCommit?: () => void,
|
|
18
|
-
setup?: string,
|
|
19
|
-
) => Promise<SetupPullOutcome | undefined>;
|
|
14
|
+
export type SyncSettingsRoute = RunRoute;
|
|
20
15
|
|
|
21
16
|
export async function showSyncSettings(
|
|
22
17
|
ctx: ExtensionCommandContext,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { syncConfigReviewIdentity } from "./config.js";
|
|
2
|
+
import { formatDiff, formatSnapshotOnlyDiff, safeTerminalText } from "./sync-format.js";
|
|
3
|
+
import { includeFromSelectionConfig } from "./sync-policy.js";
|
|
4
|
+
import { syncPolicyChanged } from "./sync-state.js";
|
|
5
|
+
import type { AnySyncConfig, Snapshot, SyncState } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export type SyncDecisionKind =
|
|
8
|
+
| "remote-or-policy-changed"
|
|
9
|
+
| "both-changed"
|
|
10
|
+
| "first-sync-settings-diverged"
|
|
11
|
+
| "first-sync-sessions-diverged"
|
|
12
|
+
| "remote-empty";
|
|
13
|
+
|
|
14
|
+
export type SyncResolutionDirection = "push" | "pull";
|
|
15
|
+
|
|
16
|
+
export interface SyncDecision {
|
|
17
|
+
kind: SyncDecisionKind;
|
|
18
|
+
setupName: string;
|
|
19
|
+
configIdentity: string;
|
|
20
|
+
causes: {
|
|
21
|
+
localChanged: boolean;
|
|
22
|
+
remoteChanged: boolean;
|
|
23
|
+
policyChanged: boolean;
|
|
24
|
+
};
|
|
25
|
+
previousInclude?: readonly string[];
|
|
26
|
+
currentInclude: readonly string[];
|
|
27
|
+
review: string;
|
|
28
|
+
directions: readonly SyncResolutionDirection[];
|
|
29
|
+
directMessage: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class SyncDecisionRequiredError extends Error {
|
|
33
|
+
readonly decision: SyncDecision;
|
|
34
|
+
|
|
35
|
+
constructor(decision: SyncDecision) {
|
|
36
|
+
super(decision.directMessage);
|
|
37
|
+
this.name = "SyncDecisionRequiredError";
|
|
38
|
+
this.decision = decision;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CreateSyncDecisionOptions {
|
|
43
|
+
kind: SyncDecisionKind;
|
|
44
|
+
config: AnySyncConfig;
|
|
45
|
+
state: SyncState;
|
|
46
|
+
local: Snapshot;
|
|
47
|
+
remote?: Snapshot;
|
|
48
|
+
localChanged: boolean;
|
|
49
|
+
remoteChanged: boolean;
|
|
50
|
+
directMessage: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createSyncDecision(options: CreateSyncDecisionOptions) {
|
|
54
|
+
const { config, state, local, remote, kind } = options;
|
|
55
|
+
const policyChanged = Boolean(state.lastAppliedSnapshot) && syncPolicyChanged(state, config);
|
|
56
|
+
const previousInclude = includeFromSelectionConfig(state);
|
|
57
|
+
const currentInclude = [...config.include];
|
|
58
|
+
const causes = {
|
|
59
|
+
localChanged: options.localChanged,
|
|
60
|
+
remoteChanged: options.remoteChanged,
|
|
61
|
+
policyChanged,
|
|
62
|
+
};
|
|
63
|
+
const causeLines =
|
|
64
|
+
kind === "first-sync-settings-diverged"
|
|
65
|
+
? ["This machine and the remote have different Pi settings on first sync."]
|
|
66
|
+
: kind === "first-sync-sessions-diverged"
|
|
67
|
+
? ["Pi settings match, but local and remote sessions differ on first sync."]
|
|
68
|
+
: kind === "remote-empty"
|
|
69
|
+
? ["The remote storage location is empty."]
|
|
70
|
+
: [
|
|
71
|
+
...(causes.localChanged ? ["Local content changed since the last sync."] : []),
|
|
72
|
+
...(causes.remoteChanged ? ["Remote content changed since the last sync."] : []),
|
|
73
|
+
...(causes.policyChanged ? ["Included content changed since the last sync."] : []),
|
|
74
|
+
];
|
|
75
|
+
const comparison = remote
|
|
76
|
+
? formatDiff(local, remote)
|
|
77
|
+
: formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local);
|
|
78
|
+
const review = [
|
|
79
|
+
`Sync setup: ${safeTerminalText(config.setupName)}`,
|
|
80
|
+
"",
|
|
81
|
+
"Why a decision is required:",
|
|
82
|
+
...causeLines,
|
|
83
|
+
...(policyChanged
|
|
84
|
+
? [
|
|
85
|
+
"",
|
|
86
|
+
`Previously included: ${safeList(previousInclude)}`,
|
|
87
|
+
`Currently included: ${safeList(currentInclude)}`,
|
|
88
|
+
]
|
|
89
|
+
: []),
|
|
90
|
+
"",
|
|
91
|
+
"Observed differences:",
|
|
92
|
+
...comparison.split("\n").map(safeTerminalText),
|
|
93
|
+
].join("\n");
|
|
94
|
+
return new SyncDecisionRequiredError({
|
|
95
|
+
kind,
|
|
96
|
+
setupName: config.setupName,
|
|
97
|
+
configIdentity: syncConfigReviewIdentity(config),
|
|
98
|
+
causes,
|
|
99
|
+
...(policyChanged ? { previousInclude: [...previousInclude] } : {}),
|
|
100
|
+
currentInclude,
|
|
101
|
+
review,
|
|
102
|
+
directions: kind === "remote-empty" ? ["push"] : ["push", "pull"],
|
|
103
|
+
directMessage: options.directMessage,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isSyncDecisionRequiredError(error: unknown): error is SyncDecisionRequiredError {
|
|
108
|
+
return error instanceof SyncDecisionRequiredError;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function safeList(values: readonly string[]) {
|
|
112
|
+
return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
|
|
113
|
+
}
|
package/src/sync-operations.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type RemoteHead,
|
|
30
30
|
type SyncBackend,
|
|
31
31
|
} from "./sync-backend.js";
|
|
32
|
+
import { createSyncDecision } from "./sync-decision.js";
|
|
32
33
|
import {
|
|
33
34
|
countPreservedRemoteFiles,
|
|
34
35
|
errorMessage,
|
|
@@ -113,7 +114,7 @@ export async function status(
|
|
|
113
114
|
const config = await loadConfig(options.setup);
|
|
114
115
|
throwIfAborted(options.signal);
|
|
115
116
|
ctx.ui.setStatus(STATUS_KEY, `checking ${config.setupName}`);
|
|
116
|
-
const backend = backendFor(config, factory);
|
|
117
|
+
const backend = await backendFor(config, factory);
|
|
117
118
|
const local = await createSnapshot(
|
|
118
119
|
config.snapshotIdentity,
|
|
119
120
|
snapshotOptionsForContext(ctx, config),
|
|
@@ -159,7 +160,7 @@ export async function diff(
|
|
|
159
160
|
const config = await loadConfig(options.setup);
|
|
160
161
|
throwIfAborted(options.signal);
|
|
161
162
|
ctx.ui.setStatus(STATUS_KEY, `checking ${config.setupName}`);
|
|
162
|
-
const backend = backendFor(config, factory);
|
|
163
|
+
const backend = await backendFor(config, factory);
|
|
163
164
|
const local = await createSnapshot(
|
|
164
165
|
config.snapshotIdentity,
|
|
165
166
|
snapshotOptionsForContext(ctx, config),
|
|
@@ -205,7 +206,7 @@ export async function doctor(
|
|
|
205
206
|
try {
|
|
206
207
|
const config = await loadConfig(options.setup);
|
|
207
208
|
throwIfAborted(options.signal);
|
|
208
|
-
backend = backendFor(config, factory);
|
|
209
|
+
backend = await backendFor(config, factory);
|
|
209
210
|
profile = config.snapshotIdentity;
|
|
210
211
|
snapshotOptions = snapshotOptionsForContext(ctx, config);
|
|
211
212
|
messages.push(
|
|
@@ -282,7 +283,7 @@ export async function push(
|
|
|
282
283
|
const config = input?.config ?? (await loadConfig(options.setup));
|
|
283
284
|
throwIfAborted(options.signal);
|
|
284
285
|
ctx.ui.setStatus(STATUS_KEY, `pushing ${config.setupName}`);
|
|
285
|
-
const backend = input?.backend ?? backendFor(config, factory);
|
|
286
|
+
const backend = input?.backend ?? (await backendFor(config, factory));
|
|
286
287
|
const state = input?.state ?? (await readStateForConfig(config));
|
|
287
288
|
throwIfAborted(options.signal);
|
|
288
289
|
const local =
|
|
@@ -308,9 +309,17 @@ export async function push(
|
|
|
308
309
|
? filterSnapshotForConfigPolicy(remoteForUpload, config)
|
|
309
310
|
: undefined;
|
|
310
311
|
if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
|
|
311
|
-
throw
|
|
312
|
-
|
|
313
|
-
|
|
312
|
+
throw createSyncDecision({
|
|
313
|
+
kind: head ? "remote-or-policy-changed" : "remote-empty",
|
|
314
|
+
config,
|
|
315
|
+
state,
|
|
316
|
+
local,
|
|
317
|
+
remote: remoteForConflict,
|
|
318
|
+
localChanged: hasLocalChanges(local, state, config),
|
|
319
|
+
remoteChanged: true,
|
|
320
|
+
directMessage:
|
|
321
|
+
"Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
|
|
322
|
+
});
|
|
314
323
|
}
|
|
315
324
|
}
|
|
316
325
|
|
|
@@ -330,7 +339,7 @@ export async function push(
|
|
|
330
339
|
}
|
|
331
340
|
|
|
332
341
|
if (!(await confirmPush(ctx, options, config, backend, local, upload, head, remoteForUpload))) {
|
|
333
|
-
return;
|
|
342
|
+
return "cancelled" as const;
|
|
334
343
|
}
|
|
335
344
|
|
|
336
345
|
if (options.force) {
|
|
@@ -361,7 +370,7 @@ export async function push(
|
|
|
361
370
|
"Remote changed during review. Push the refreshed plan?",
|
|
362
371
|
))
|
|
363
372
|
) {
|
|
364
|
-
return;
|
|
373
|
+
return "cancelled" as const;
|
|
365
374
|
}
|
|
366
375
|
}
|
|
367
376
|
}
|
|
@@ -395,6 +404,7 @@ export async function push(
|
|
|
395
404
|
result.warnings.length > 0 ? "warning" : "info",
|
|
396
405
|
);
|
|
397
406
|
}
|
|
407
|
+
return "applied" as const;
|
|
398
408
|
}
|
|
399
409
|
|
|
400
410
|
export async function pull(
|
|
@@ -405,7 +415,7 @@ export async function pull(
|
|
|
405
415
|
const config = await loadConfig(options.setup);
|
|
406
416
|
throwIfAborted(options.signal);
|
|
407
417
|
ctx.ui.setStatus(STATUS_KEY, `pulling ${config.setupName}`);
|
|
408
|
-
const backend = backendFor(config, factory);
|
|
418
|
+
const backend = await backendFor(config, factory);
|
|
409
419
|
const state = await readStateForConfig(config);
|
|
410
420
|
throwIfAborted(options.signal);
|
|
411
421
|
const local = await createSnapshot(
|
|
@@ -415,14 +425,32 @@ export async function pull(
|
|
|
415
425
|
throwIfAborted(options.signal);
|
|
416
426
|
const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
|
|
417
427
|
throwIfAborted(options.signal);
|
|
418
|
-
if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
|
|
419
|
-
|
|
420
428
|
const localChanged = hasLocalChanges(local, state, config);
|
|
429
|
+
if (!remote) {
|
|
430
|
+
throw createSyncDecision({
|
|
431
|
+
kind: "remote-empty",
|
|
432
|
+
config,
|
|
433
|
+
state,
|
|
434
|
+
local,
|
|
435
|
+
localChanged,
|
|
436
|
+
remoteChanged: false,
|
|
437
|
+
directMessage: "Remote is empty. Run /sync push from a configured machine first.",
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
421
441
|
const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
|
|
422
442
|
if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
|
|
423
|
-
throw
|
|
424
|
-
"
|
|
425
|
-
|
|
443
|
+
throw createSyncDecision({
|
|
444
|
+
kind: "both-changed",
|
|
445
|
+
config,
|
|
446
|
+
state,
|
|
447
|
+
local,
|
|
448
|
+
remote,
|
|
449
|
+
localChanged,
|
|
450
|
+
remoteChanged,
|
|
451
|
+
directMessage:
|
|
452
|
+
"Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
|
|
453
|
+
});
|
|
426
454
|
}
|
|
427
455
|
|
|
428
456
|
if (
|
|
@@ -492,7 +520,7 @@ export async function syncBoth(
|
|
|
492
520
|
) {
|
|
493
521
|
const config = await loadConfig(options.setup);
|
|
494
522
|
throwIfAborted(options.signal);
|
|
495
|
-
const backend = backendFor(config, factory);
|
|
523
|
+
const backend = await backendFor(config, factory);
|
|
496
524
|
const state = await readStateForConfig(config);
|
|
497
525
|
throwIfAborted(options.signal);
|
|
498
526
|
const local = await createSnapshot(
|
|
@@ -519,15 +547,31 @@ export async function syncBoth(
|
|
|
519
547
|
|
|
520
548
|
if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
|
|
521
549
|
if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
|
|
522
|
-
throw
|
|
523
|
-
"
|
|
524
|
-
|
|
550
|
+
throw createSyncDecision({
|
|
551
|
+
kind: "first-sync-settings-diverged",
|
|
552
|
+
config,
|
|
553
|
+
state,
|
|
554
|
+
local,
|
|
555
|
+
remote,
|
|
556
|
+
localChanged: true,
|
|
557
|
+
remoteChanged: true,
|
|
558
|
+
directMessage:
|
|
559
|
+
"Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
|
|
560
|
+
});
|
|
525
561
|
}
|
|
526
562
|
if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
|
|
527
563
|
if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
|
|
528
|
-
throw
|
|
529
|
-
"
|
|
530
|
-
|
|
564
|
+
throw createSyncDecision({
|
|
565
|
+
kind: "first-sync-sessions-diverged",
|
|
566
|
+
config,
|
|
567
|
+
state,
|
|
568
|
+
local,
|
|
569
|
+
remote,
|
|
570
|
+
localChanged: true,
|
|
571
|
+
remoteChanged: true,
|
|
572
|
+
directMessage:
|
|
573
|
+
"Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
|
|
574
|
+
});
|
|
531
575
|
}
|
|
532
576
|
await pull(ctx, options, factory);
|
|
533
577
|
return;
|
|
@@ -557,9 +601,17 @@ export async function syncBoth(
|
|
|
557
601
|
return;
|
|
558
602
|
}
|
|
559
603
|
if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
|
|
560
|
-
throw
|
|
561
|
-
"
|
|
562
|
-
|
|
604
|
+
throw createSyncDecision({
|
|
605
|
+
kind: "both-changed",
|
|
606
|
+
config,
|
|
607
|
+
state,
|
|
608
|
+
local,
|
|
609
|
+
remote,
|
|
610
|
+
localChanged,
|
|
611
|
+
remoteChanged,
|
|
612
|
+
directMessage:
|
|
613
|
+
"Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
|
|
614
|
+
});
|
|
563
615
|
}
|
|
564
616
|
if (remoteChanged) {
|
|
565
617
|
await pull(ctx, options, factory);
|
|
@@ -593,7 +645,7 @@ export async function history(
|
|
|
593
645
|
) {
|
|
594
646
|
const config = await loadConfig(options.setup);
|
|
595
647
|
throwIfAborted(options.signal);
|
|
596
|
-
const backend = backendFor(config, factory);
|
|
648
|
+
const backend = await backendFor(config, factory);
|
|
597
649
|
const snapshots = (await backend.listHistory(options.signal)).slice(-20).reverse();
|
|
598
650
|
throwIfAborted(options.signal);
|
|
599
651
|
if (snapshots.length === 0) {
|
|
@@ -643,7 +695,7 @@ export async function rollback(
|
|
|
643
695
|
|
|
644
696
|
const config = await loadConfig(options.setup);
|
|
645
697
|
throwIfAborted(options.signal);
|
|
646
|
-
const backend = backendFor(config, factory);
|
|
698
|
+
const backend = await backendFor(config, factory);
|
|
647
699
|
if (
|
|
648
700
|
expectedSelection &&
|
|
649
701
|
(backend.identity !== expectedSelection.backendIdentity ||
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
|
|
4
|
+
import { loadConfig, syncConfigReviewIdentity } from "./config.js";
|
|
5
|
+
import type { SyncDecision, SyncResolutionDirection } from "./sync-decision.js";
|
|
6
|
+
import { errorMessage, safeTerminalText } from "./sync-format.js";
|
|
7
|
+
|
|
8
|
+
export type SyncResolutionResult =
|
|
9
|
+
| { kind: "resolved"; direction: SyncResolutionDirection }
|
|
10
|
+
| { kind: "back" }
|
|
11
|
+
| { kind: "closed" }
|
|
12
|
+
| { kind: "stale" };
|
|
13
|
+
|
|
14
|
+
export async function showSyncResolution(
|
|
15
|
+
ctx: ExtensionCommandContext,
|
|
16
|
+
initialDecision: SyncDecision,
|
|
17
|
+
runRoute: RunRoute,
|
|
18
|
+
sessionSignal?: AbortSignal,
|
|
19
|
+
): Promise<SyncResolutionResult> {
|
|
20
|
+
type Screen = "resolve" | "review";
|
|
21
|
+
type Action = "push" | "pull" | "back";
|
|
22
|
+
let currentDecision = initialDecision;
|
|
23
|
+
let resolvedDirection: SyncResolutionDirection | undefined;
|
|
24
|
+
const menu = defineMenu<SyncDecision, Screen, Action, ExtensionCommandContext>({
|
|
25
|
+
start: "resolve",
|
|
26
|
+
screens: {
|
|
27
|
+
resolve: ({ state }) => ({
|
|
28
|
+
kind: "actions",
|
|
29
|
+
title: resolutionTitle(state),
|
|
30
|
+
lines: resolutionLines(state),
|
|
31
|
+
items: [
|
|
32
|
+
{ id: "review", label: "Review differences (recommended)", to: "review" },
|
|
33
|
+
...(state.directions.includes("push")
|
|
34
|
+
? [
|
|
35
|
+
{
|
|
36
|
+
id: "push",
|
|
37
|
+
label: pushLabel(state),
|
|
38
|
+
description: "Review an exact push before replacing the remote version.",
|
|
39
|
+
action: "push" as const,
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
: []),
|
|
43
|
+
...(state.directions.includes("pull")
|
|
44
|
+
? [
|
|
45
|
+
{
|
|
46
|
+
id: "pull",
|
|
47
|
+
label: pullLabel(state),
|
|
48
|
+
description: "Review exact local changes and create a backup before applying.",
|
|
49
|
+
action: "pull" as const,
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
: []),
|
|
53
|
+
{ id: "back", label: "Back", action: "back" },
|
|
54
|
+
],
|
|
55
|
+
hint: "back",
|
|
56
|
+
}),
|
|
57
|
+
review: ({ state }) => ({
|
|
58
|
+
kind: "review",
|
|
59
|
+
title: `Review differences · ${safeTerminalText(state.setupName)}`,
|
|
60
|
+
content: state.review,
|
|
61
|
+
format: { kind: "text" },
|
|
62
|
+
viewportSize: "adaptive",
|
|
63
|
+
hint: "back",
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
actions: {
|
|
67
|
+
push: ({ state, signal }) => resolveDirection("push", state, signal),
|
|
68
|
+
pull: ({ state, signal }) => resolveDirection("pull", state, signal),
|
|
69
|
+
back: async () => ({ kind: "back" }),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function resolveDirection(
|
|
74
|
+
direction: SyncResolutionDirection,
|
|
75
|
+
decision: SyncDecision,
|
|
76
|
+
actionSignal: AbortSignal,
|
|
77
|
+
) {
|
|
78
|
+
const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
|
|
79
|
+
const config = await loadConfig(decision.setupName);
|
|
80
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
81
|
+
if (syncConfigReviewIdentity(config) !== decision.configIdentity) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Sync setup “${safeTerminalText(decision.setupName)}” changed while conflict resolution was open; return to the sync manager and retry.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const result = await runCancellableOperation(
|
|
87
|
+
ctx,
|
|
88
|
+
direction === "push"
|
|
89
|
+
? "Preparing local-wins push preview…"
|
|
90
|
+
: "Preparing remote-wins pull preview…",
|
|
91
|
+
`${direction} --force`,
|
|
92
|
+
runRoute,
|
|
93
|
+
{
|
|
94
|
+
commitAware: true,
|
|
95
|
+
cancelledMessage:
|
|
96
|
+
direction === "push"
|
|
97
|
+
? "Push preparation cancelled; no remote files were changed."
|
|
98
|
+
: "Pull check cancelled; no local files were changed.",
|
|
99
|
+
target: decision.setupName,
|
|
100
|
+
signal,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
if (result.kind === "decision-required") {
|
|
104
|
+
currentDecision = result.decision;
|
|
105
|
+
return { kind: "stay" as const };
|
|
106
|
+
}
|
|
107
|
+
if (result.kind === "completed") {
|
|
108
|
+
if (result.outcome === "cancelled") return { kind: "stay" as const };
|
|
109
|
+
resolvedDirection = direction;
|
|
110
|
+
return { kind: "close" as const };
|
|
111
|
+
}
|
|
112
|
+
return result.kind === "closed" ? { kind: "close" as const } : { kind: "stay" as const };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = await runMenu(ctx, menu, {
|
|
116
|
+
getState: () => currentDecision,
|
|
117
|
+
signal: sessionSignal,
|
|
118
|
+
isCurrent: () => !sessionSignal?.aborted,
|
|
119
|
+
onError: (_menuCtx, error) => ctx.ui.notify(errorMessage(error), "error"),
|
|
120
|
+
});
|
|
121
|
+
if (sessionSignal?.aborted || result.kind === "stale") return { kind: "stale" };
|
|
122
|
+
if (resolvedDirection) return { kind: "resolved", direction: resolvedDirection };
|
|
123
|
+
if (result.kind === "closed") {
|
|
124
|
+
return result.reason === "back" ? { kind: "back" } : { kind: "closed" };
|
|
125
|
+
}
|
|
126
|
+
return { kind: "closed" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolutionTitle(decision: SyncDecision) {
|
|
130
|
+
return decision.kind === "remote-empty" ? "Remote is empty" : "Resolve sync conflict";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolutionLines(decision: SyncDecision) {
|
|
134
|
+
return [
|
|
135
|
+
`Sync setup: ${safeTerminalText(decision.setupName)}`,
|
|
136
|
+
...causeSummary(decision),
|
|
137
|
+
"No files have been changed by this failed operation.",
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function causeSummary(decision: SyncDecision) {
|
|
142
|
+
if (decision.kind === "first-sync-settings-diverged") {
|
|
143
|
+
return ["This machine and the remote have different Pi settings on first sync."];
|
|
144
|
+
}
|
|
145
|
+
if (decision.kind === "first-sync-sessions-diverged") {
|
|
146
|
+
return ["Pi settings match, but local and remote sessions differ on first sync."];
|
|
147
|
+
}
|
|
148
|
+
if (decision.kind === "remote-empty") return ["The remote storage location has no snapshot."];
|
|
149
|
+
return [
|
|
150
|
+
...(decision.causes.localChanged ? ["Local content changed since the last sync."] : []),
|
|
151
|
+
...(decision.causes.remoteChanged ? ["Remote content changed since the last sync."] : []),
|
|
152
|
+
...(decision.causes.policyChanged ? ["Included content changed since the last sync."] : []),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function pushLabel(decision: SyncDecision) {
|
|
157
|
+
if (decision.kind === "remote-empty") return "Push local content…";
|
|
158
|
+
if (decision.kind.startsWith("first-sync-")) return "Use local as initial source…";
|
|
159
|
+
return "Keep local content and replace remote…";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function pullLabel(decision: SyncDecision) {
|
|
163
|
+
return decision.kind.startsWith("first-sync-")
|
|
164
|
+
? "Use remote as initial source…"
|
|
165
|
+
: "Use remote content and replace local…";
|
|
166
|
+
}
|
package/src/sync.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ExtensionCommandContext,
|
|
4
4
|
ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { RunRouteResult } from "./cancellable-operation.js";
|
|
6
7
|
import {
|
|
7
8
|
completeSyncArguments,
|
|
8
9
|
parseOptions,
|
|
@@ -27,28 +28,30 @@ import {
|
|
|
27
28
|
sessionTokenWarnings,
|
|
28
29
|
syncSessionsWarnings,
|
|
29
30
|
} from "./config.js";
|
|
30
|
-
import { showFileSelection } from "./file-selection.js";
|
|
31
31
|
import { unlock, withLock } from "./lock.js";
|
|
32
|
-
import { showSetupWizard, showSyncManager } from "./manager-ui.js";
|
|
33
32
|
import { SetupPullRequiresUiError, useSyncSetup } from "./setup-switch.js";
|
|
34
33
|
import { createSnapshot } from "./snapshot.js";
|
|
35
34
|
import { recoverSnapshotTransactionsOnStartup } from "./snapshot-transaction.js";
|
|
35
|
+
import { isSyncDecisionRequiredError } from "./sync-decision.js";
|
|
36
36
|
import { errorMessage } from "./sync-format.js";
|
|
37
|
-
import {
|
|
38
|
-
diff,
|
|
39
|
-
doctor,
|
|
40
|
-
history,
|
|
41
|
-
pull,
|
|
42
|
-
push,
|
|
43
|
-
rollback,
|
|
44
|
-
status,
|
|
45
|
-
syncBoth,
|
|
46
|
-
} from "./sync-operations.js";
|
|
47
37
|
import { hasLocalChanges } from "./sync-state.js";
|
|
48
38
|
import type { AnySyncConfig, CommandOptions, SnapshotOptions } from "./types.js";
|
|
49
39
|
|
|
50
40
|
const STATUS_KEY = "sync";
|
|
51
41
|
|
|
42
|
+
type SyncOperations = typeof import("./sync-operations.js");
|
|
43
|
+
let syncOperationsPromise: Promise<SyncOperations> | undefined;
|
|
44
|
+
|
|
45
|
+
function loadSyncOperations(): Promise<SyncOperations> {
|
|
46
|
+
if (!syncOperationsPromise) {
|
|
47
|
+
syncOperationsPromise = import("./sync-operations.js").catch((error) => {
|
|
48
|
+
syncOperationsPromise = undefined;
|
|
49
|
+
throw error;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return syncOperationsPromise;
|
|
53
|
+
}
|
|
54
|
+
|
|
52
55
|
const AUTO_SYNC_OPTIONS: CommandOptions = {
|
|
53
56
|
yes: true,
|
|
54
57
|
force: false,
|
|
@@ -128,6 +131,8 @@ async function handleCommand(
|
|
|
128
131
|
) {
|
|
129
132
|
if (!rawArgs.trim()) {
|
|
130
133
|
try {
|
|
134
|
+
const { showSyncManager } = await import("./manager-ui.js");
|
|
135
|
+
if (sessionSignal.aborted) return;
|
|
131
136
|
await showSyncManager(
|
|
132
137
|
ctx,
|
|
133
138
|
(route, signal, onCommit, target) =>
|
|
@@ -141,7 +146,10 @@ async function handleCommand(
|
|
|
141
146
|
}
|
|
142
147
|
return;
|
|
143
148
|
}
|
|
144
|
-
await executeCommand(rawArgs, ctx, sessionSignal);
|
|
149
|
+
const result = await executeCommand(rawArgs, ctx, sessionSignal);
|
|
150
|
+
if (result.kind === "decision-required") {
|
|
151
|
+
ctx.ui.notify(result.decision.directMessage, "error");
|
|
152
|
+
}
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
async function executeCommand(
|
|
@@ -150,10 +158,10 @@ async function executeCommand(
|
|
|
150
158
|
signal?: AbortSignal,
|
|
151
159
|
onCommit?: () => void,
|
|
152
160
|
setup?: string,
|
|
153
|
-
) {
|
|
161
|
+
): Promise<RunRouteResult> {
|
|
154
162
|
try {
|
|
155
163
|
const command = await resolveSyncCommand(rawArgs, ctx);
|
|
156
|
-
if (signal?.aborted || !command) return;
|
|
164
|
+
if (signal?.aborted || !command) return { kind: "completed" };
|
|
157
165
|
const { subcommand, rest } = command;
|
|
158
166
|
const options = parseOptions(rest);
|
|
159
167
|
if (setup !== undefined) options.setup = setup;
|
|
@@ -164,60 +172,87 @@ async function executeCommand(
|
|
|
164
172
|
switch (subcommand) {
|
|
165
173
|
case "help":
|
|
166
174
|
ctx.ui.notify(usage(), "info");
|
|
167
|
-
return;
|
|
175
|
+
return { kind: "completed" };
|
|
168
176
|
case "use":
|
|
169
177
|
await useSyncSetup(
|
|
170
178
|
ctx,
|
|
171
179
|
options.args[0] ?? "",
|
|
172
|
-
(selectedSetup) =>
|
|
173
|
-
|
|
180
|
+
async (selectedSetup) => {
|
|
181
|
+
const operations = await loadSyncOperations();
|
|
182
|
+
throwIfAborted(options.signal);
|
|
183
|
+
return withLock("pull", () =>
|
|
184
|
+
operations.pull(ctx, { ...options, setup: selectedSetup }),
|
|
185
|
+
);
|
|
186
|
+
},
|
|
174
187
|
undefined,
|
|
175
188
|
options.signal,
|
|
176
189
|
);
|
|
177
|
-
return;
|
|
190
|
+
return { kind: "completed" };
|
|
178
191
|
case "init":
|
|
179
192
|
await initConfig(ctx, signal);
|
|
180
|
-
return;
|
|
193
|
+
return { kind: "completed" };
|
|
181
194
|
case "config":
|
|
182
195
|
await showConfig(ctx, options);
|
|
183
|
-
return;
|
|
196
|
+
return { kind: "completed" };
|
|
184
197
|
case "files":
|
|
185
|
-
await
|
|
186
|
-
|
|
198
|
+
await (await import("./file-selection.js")).showFileSelection(
|
|
199
|
+
ctx,
|
|
200
|
+
options.setup,
|
|
201
|
+
options.signal,
|
|
202
|
+
);
|
|
203
|
+
return { kind: "completed" };
|
|
187
204
|
case "status":
|
|
188
|
-
await status(ctx, options);
|
|
189
|
-
return;
|
|
205
|
+
await (await loadSyncOperations()).status(ctx, options);
|
|
206
|
+
return { kind: "completed" };
|
|
190
207
|
case "diff":
|
|
191
|
-
await diff(ctx, options);
|
|
192
|
-
return;
|
|
208
|
+
await (await loadSyncOperations()).diff(ctx, options);
|
|
209
|
+
return { kind: "completed" };
|
|
193
210
|
case "doctor":
|
|
194
|
-
await doctor(ctx, options);
|
|
195
|
-
return;
|
|
196
|
-
case "push":
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
211
|
+
await (await loadSyncOperations()).doctor(ctx, options);
|
|
212
|
+
return { kind: "completed" };
|
|
213
|
+
case "push": {
|
|
214
|
+
const operations = await loadSyncOperations();
|
|
215
|
+
throwIfAborted(options.signal);
|
|
216
|
+
const outcome = await withLock("push", () => operations.push(ctx, options));
|
|
217
|
+
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
218
|
+
}
|
|
219
|
+
case "pull": {
|
|
220
|
+
const operations = await loadSyncOperations();
|
|
221
|
+
throwIfAborted(options.signal);
|
|
222
|
+
const outcome = await withLock("pull", () => operations.pull(ctx, options));
|
|
223
|
+
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
224
|
+
}
|
|
225
|
+
case "sync": {
|
|
226
|
+
const operations = await loadSyncOperations();
|
|
227
|
+
throwIfAborted(options.signal);
|
|
228
|
+
await withLock("sync", () => operations.syncBoth(ctx, options));
|
|
229
|
+
return { kind: "completed" };
|
|
230
|
+
}
|
|
204
231
|
case "history":
|
|
205
|
-
await history(ctx, options);
|
|
206
|
-
return;
|
|
207
|
-
case "rollback":
|
|
208
|
-
|
|
209
|
-
|
|
232
|
+
await (await loadSyncOperations()).history(ctx, options);
|
|
233
|
+
return { kind: "completed" };
|
|
234
|
+
case "rollback": {
|
|
235
|
+
const operations = await loadSyncOperations();
|
|
236
|
+
throwIfAborted(options.signal);
|
|
237
|
+
await withLock("rollback", () => operations.rollback(ctx, options));
|
|
238
|
+
return { kind: "completed" };
|
|
239
|
+
}
|
|
210
240
|
case "unlock":
|
|
211
241
|
await unlock(ctx, options);
|
|
212
|
-
return;
|
|
242
|
+
return { kind: "completed" };
|
|
213
243
|
default:
|
|
214
244
|
ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
|
|
245
|
+
return { kind: "failed" };
|
|
215
246
|
}
|
|
216
247
|
} catch (error) {
|
|
217
|
-
if (signal?.aborted) return;
|
|
248
|
+
if (signal?.aborted) return { kind: "failed" };
|
|
218
249
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
219
250
|
if (error instanceof SetupPullRequiresUiError) throw error;
|
|
251
|
+
if (isSyncDecisionRequiredError(error)) {
|
|
252
|
+
return { kind: "decision-required", decision: error.decision };
|
|
253
|
+
}
|
|
220
254
|
ctx.ui.notify(errorMessage(error), "error");
|
|
255
|
+
return { kind: "failed" };
|
|
221
256
|
}
|
|
222
257
|
}
|
|
223
258
|
|
|
@@ -230,9 +265,11 @@ async function autoSync(ctx: ExtensionContext, signal: AbortSignal) {
|
|
|
230
265
|
throwIfAborted(signal);
|
|
231
266
|
await loadConfig();
|
|
232
267
|
throwIfAborted(signal);
|
|
268
|
+
const operations = await loadSyncOperations();
|
|
269
|
+
throwIfAborted(signal);
|
|
233
270
|
await withLock("auto-sync", () => {
|
|
234
271
|
throwIfAborted(signal);
|
|
235
|
-
return syncBoth(ctx, { ...AUTO_SYNC_OPTIONS, signal });
|
|
272
|
+
return operations.syncBoth(ctx, { ...AUTO_SYNC_OPTIONS, signal });
|
|
236
273
|
});
|
|
237
274
|
} catch (error) {
|
|
238
275
|
if (signal.aborted || isMissingConfigError(error)) return;
|
|
@@ -252,6 +289,8 @@ async function autoPushSessions(ctx: ExtensionContext, signal: AbortSignal) {
|
|
|
252
289
|
const config = await loadConfig();
|
|
253
290
|
throwIfAborted(signal);
|
|
254
291
|
if (!config.include.includes("sessions")) return;
|
|
292
|
+
const operations = await loadSyncOperations();
|
|
293
|
+
throwIfAborted(signal);
|
|
255
294
|
await withLock("auto-session-push", async () => {
|
|
256
295
|
throwIfAborted(signal);
|
|
257
296
|
const state = await readStateForConfig(config);
|
|
@@ -262,7 +301,7 @@ async function autoPushSessions(ctx: ExtensionContext, signal: AbortSignal) {
|
|
|
262
301
|
);
|
|
263
302
|
throwIfAborted(signal);
|
|
264
303
|
if (!hasLocalChanges(local, state, config)) return;
|
|
265
|
-
await push(ctx, { ...AUTO_SYNC_OPTIONS, signal }, { config, state, local });
|
|
304
|
+
await operations.push(ctx, { ...AUTO_SYNC_OPTIONS, signal }, { config, state, local });
|
|
266
305
|
});
|
|
267
306
|
} catch (error) {
|
|
268
307
|
if (signal.aborted || isMissingConfigError(error)) return;
|
|
@@ -279,6 +318,8 @@ async function initConfig(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
|
279
318
|
}
|
|
280
319
|
|
|
281
320
|
if (ctx.mode === "tui") {
|
|
321
|
+
const { showSetupWizard } = await import("./manager-ui.js");
|
|
322
|
+
throwIfAborted(signal);
|
|
282
323
|
await showSetupWizard(ctx, signal);
|
|
283
324
|
return;
|
|
284
325
|
}
|
|
@@ -370,8 +411,8 @@ function displayWebDavUrl(value: string | undefined, username: string | undefine
|
|
|
370
411
|
}
|
|
371
412
|
}
|
|
372
413
|
|
|
373
|
-
function throwIfAborted(signal
|
|
374
|
-
if (!signal
|
|
414
|
+
function throwIfAborted(signal?: AbortSignal) {
|
|
415
|
+
if (!signal?.aborted) return;
|
|
375
416
|
throw signal.reason instanceof Error
|
|
376
417
|
? signal.reason
|
|
377
418
|
: new DOMException("The operation was aborted", "AbortError");
|
|
@@ -433,7 +474,6 @@ export {
|
|
|
433
474
|
preflightSnapshotApply,
|
|
434
475
|
protectSnapshotApplyPlan,
|
|
435
476
|
} from "./snapshot-apply.js";
|
|
436
|
-
export { backupLocal } from "./sync-operations.js";
|
|
437
477
|
export {
|
|
438
478
|
canPullRemoteSessionsOnFirstSync,
|
|
439
479
|
canPullRemoteSettingsOnFirstSync,
|