@narumitw/pi-sync 0.30.0 → 0.34.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 +159 -236
- package/package.json +14 -6
- package/src/backend-factory.ts +20 -0
- package/src/command.ts +39 -39
- package/src/config-errors.ts +7 -0
- package/src/config-file.ts +64 -8
- package/src/config.ts +605 -476
- package/src/file-selection.ts +121 -140
- package/src/git-backend.ts +832 -0
- package/src/git-config.ts +136 -0
- package/src/git-runner.ts +276 -0
- package/src/git-storage.ts +288 -0
- package/src/git-ui.ts +307 -0
- package/src/manager-helpers.ts +64 -0
- package/src/manager-ui.ts +537 -542
- package/src/paths.ts +4 -1
- package/src/s3-backend.ts +498 -0
- package/src/s3-client.ts +147 -64
- package/src/s3-credentials-ui.ts +92 -0
- package/src/secret-input.ts +199 -0
- package/src/settings-management.ts +191 -277
- package/src/settings-ui.ts +94 -115
- package/src/setup-switch.ts +150 -0
- package/src/snapshot-apply.ts +13 -7
- package/src/snapshot-codec.ts +48 -0
- package/src/snapshot-transaction.ts +3 -1
- package/src/snapshot.ts +63 -32
- package/src/storage-connections-ui.ts +308 -0
- package/src/sync-backend.ts +97 -0
- package/src/sync-format.ts +31 -33
- package/src/sync-operations.ts +912 -0
- package/src/sync-policy.ts +137 -61
- package/src/sync-setups-ui.ts +177 -0
- package/src/sync-state.ts +48 -48
- package/src/sync.ts +180 -739
- package/src/types.ts +177 -53
- package/src/webdav-backend.ts +678 -0
- package/src/webdav-client.ts +390 -0
- package/src/webdav-config.ts +92 -0
- package/src/webdav-ui.ts +421 -0
- package/src/target-switch.ts +0 -119
package/src/settings-ui.ts
CHANGED
|
@@ -3,88 +3,77 @@ import {
|
|
|
3
3
|
getSettingsListTheme,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
|
|
6
|
+
import { loadConfig, localConfigPath } from "./config.js";
|
|
7
|
+
import { updateSyncSetup } from "./settings-management.js";
|
|
6
8
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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";
|
|
9
|
+
SETUP_SWITCH_ACTION_OPTIONS,
|
|
10
|
+
type SetupPullOutcome,
|
|
11
|
+
saveOnSwitch,
|
|
12
|
+
setupSwitchActionFromLabel,
|
|
13
|
+
setupSwitchActionLabel,
|
|
14
|
+
} from "./setup-switch.js";
|
|
15
|
+
import { safeTerminalText } from "./sync-format.js";
|
|
25
16
|
|
|
26
17
|
export type SyncSettingsRoute = (
|
|
27
18
|
route: string,
|
|
28
19
|
signal?: AbortSignal,
|
|
29
20
|
onCommit?: () => void,
|
|
30
|
-
|
|
31
|
-
) => Promise<
|
|
21
|
+
setup?: string,
|
|
22
|
+
) => Promise<SetupPullOutcome | undefined>;
|
|
32
23
|
|
|
33
|
-
export async function showSyncSettings(
|
|
24
|
+
export async function showSyncSettings(
|
|
25
|
+
ctx: ExtensionCommandContext,
|
|
26
|
+
runRoute: SyncSettingsRoute,
|
|
27
|
+
signal?: AbortSignal,
|
|
28
|
+
) {
|
|
34
29
|
if (ctx.mode !== "tui") {
|
|
35
30
|
ctx.ui.notify(`Edit pi-sync settings manually: ${safeTerminalText(localConfigPath())}`, "info");
|
|
36
31
|
return;
|
|
37
32
|
}
|
|
38
|
-
while (
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
await runRoute("files", undefined, undefined, version2 ? partial.target : undefined);
|
|
33
|
+
while (!signal?.aborted) {
|
|
34
|
+
const config = await loadConfig();
|
|
35
|
+
if (signal?.aborted) return;
|
|
36
|
+
const action = await showSettingsList(ctx, config, signal);
|
|
37
|
+
if (signal?.aborted || action !== "files") return;
|
|
38
|
+
await runRoute("files", signal, undefined, config.setupName);
|
|
45
39
|
}
|
|
46
40
|
}
|
|
47
41
|
|
|
48
42
|
async function showSettingsList(
|
|
49
43
|
ctx: ExtensionCommandContext,
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
45
|
+
signal?: AbortSignal,
|
|
52
46
|
) {
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const automaticSyncValue = isEnabled(partial.autoSync, true) ? "On" : "Off";
|
|
56
|
-
const targetSwitchAction = await loadTargetSwitchAction();
|
|
57
|
-
const targetSwitchValue = targetSwitchActionLabel(targetSwitchAction);
|
|
47
|
+
const automaticValue = config.automatic ? "On" : "Off";
|
|
48
|
+
const switchValue = setupSwitchActionLabel(config.onSwitch);
|
|
58
49
|
let saveQueue = Promise.resolve();
|
|
59
50
|
const latestRequested = new Map<string, string>();
|
|
51
|
+
const mutationController = new AbortController();
|
|
52
|
+
const mutationSignal = signal
|
|
53
|
+
? AbortSignal.any([signal, mutationController.signal])
|
|
54
|
+
: mutationController.signal;
|
|
60
55
|
|
|
61
56
|
return ctx.ui.custom<"files" | undefined>((tui, theme, _keybindings, done) => {
|
|
62
57
|
const items: SettingItem[] = [
|
|
63
58
|
{
|
|
64
|
-
id: "
|
|
65
|
-
label:
|
|
66
|
-
description:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
currentValue: automaticSyncValue,
|
|
70
|
-
...(automaticSyncOverridden ? {} : { values: ["On", "Off"] }),
|
|
59
|
+
id: "automatic",
|
|
60
|
+
label: "Automatic sync",
|
|
61
|
+
description: "Run conservative synchronization at session startup and shutdown.",
|
|
62
|
+
currentValue: automaticValue,
|
|
63
|
+
values: ["On", "Off"],
|
|
71
64
|
},
|
|
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
65
|
{
|
|
85
|
-
id: "
|
|
86
|
-
label: "
|
|
87
|
-
description:
|
|
66
|
+
id: "onSwitch",
|
|
67
|
+
label: "After switching setup",
|
|
68
|
+
description:
|
|
69
|
+
"Ask before a reviewed pull, start a reviewed pull, or switch without checking remote files.",
|
|
70
|
+
currentValue: switchValue,
|
|
71
|
+
values: SETUP_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "include",
|
|
75
|
+
label: "Included content",
|
|
76
|
+
description: `${config.include.length} selected path${config.include.length === 1 ? "" : "s"}. Opens the reviewed content-selection draft.`,
|
|
88
77
|
currentValue: "Open editor",
|
|
89
78
|
values: ["Open editor"],
|
|
90
79
|
},
|
|
@@ -95,7 +84,7 @@ async function showSettingsList(
|
|
|
95
84
|
new Text(
|
|
96
85
|
theme.fg(
|
|
97
86
|
"muted",
|
|
98
|
-
`
|
|
87
|
+
`Sync setup: ${safeTerminalText(config.setupName)} · Storage connection: ${safeTerminalText(config.connectionName)}`,
|
|
99
88
|
),
|
|
100
89
|
1,
|
|
101
90
|
0,
|
|
@@ -103,50 +92,66 @@ async function showSettingsList(
|
|
|
103
92
|
);
|
|
104
93
|
let settingsList: SettingsList;
|
|
105
94
|
let closing = false;
|
|
95
|
+
let disposed = false;
|
|
106
96
|
const closeAfterSaves = (result: "files" | undefined) => {
|
|
107
|
-
if (closing) return;
|
|
97
|
+
if (closing || disposed) return;
|
|
108
98
|
closing = true;
|
|
109
|
-
void saveQueue.then(() =>
|
|
99
|
+
void saveQueue.then(() => {
|
|
100
|
+
if (!disposed) done(signal?.aborted ? undefined : result);
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
const onAbort = () => {
|
|
104
|
+
if (!closing && !disposed) done(undefined);
|
|
110
105
|
};
|
|
106
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
111
107
|
settingsList = new SettingsList(
|
|
112
108
|
items,
|
|
113
109
|
Math.min(items.length + 2, 15),
|
|
114
110
|
getSettingsListTheme(),
|
|
115
111
|
(id, newValue) => {
|
|
116
|
-
if (id === "
|
|
112
|
+
if (id === "include") {
|
|
117
113
|
closeAfterSaves("files");
|
|
118
114
|
return;
|
|
119
115
|
}
|
|
120
116
|
latestRequested.set(id, newValue);
|
|
121
|
-
const
|
|
117
|
+
const fallback = id === "automatic" ? automaticValue : switchValue;
|
|
122
118
|
const operation = saveQueue.then(async () => {
|
|
123
|
-
|
|
119
|
+
if (disposed || signal?.aborted) return;
|
|
120
|
+
let previousValue = fallback;
|
|
124
121
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
122
|
+
const latest = await loadConfig(config.setupName);
|
|
123
|
+
if (disposed || signal?.aborted) return;
|
|
124
|
+
if (id === "automatic") {
|
|
125
|
+
previousValue = latest.automatic ? "On" : "Off";
|
|
126
|
+
const automatic = newValue === "On";
|
|
127
|
+
await updateSyncSetup(
|
|
128
|
+
config.setupName,
|
|
129
|
+
(setup) => ({
|
|
130
|
+
...setup,
|
|
131
|
+
sync: { ...setup.sync, automatic },
|
|
132
|
+
}),
|
|
133
|
+
{ signal: mutationSignal },
|
|
134
|
+
);
|
|
135
|
+
if (disposed || signal?.aborted) return;
|
|
133
136
|
ctx.ui.notify(
|
|
134
|
-
`Automatic sync ${
|
|
137
|
+
`Automatic sync ${automatic ? "enabled" : "disabled"} for “${safeTerminalText(config.setupName)}”.`,
|
|
135
138
|
"info",
|
|
136
139
|
);
|
|
137
|
-
} else
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
ctx.ui.notify(`After
|
|
140
|
+
} else {
|
|
141
|
+
previousValue = setupSwitchActionLabel(latest.onSwitch);
|
|
142
|
+
const action = setupSwitchActionFromLabel(newValue);
|
|
143
|
+
if (!action) throw new Error(`Invalid setup-switch action: ${newValue}`);
|
|
144
|
+
await saveOnSwitch(action, mutationSignal);
|
|
145
|
+
if (disposed || signal?.aborted) return;
|
|
146
|
+
ctx.ui.notify(`After switching setup: ${newValue}.`, "info");
|
|
144
147
|
}
|
|
145
148
|
} catch (error) {
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
if (disposed || signal?.aborted) return;
|
|
150
|
+
if (latestRequested.get(id) === newValue) settingsList.updateValue(id, previousValue);
|
|
151
|
+
ctx.ui.notify(
|
|
152
|
+
`Pi Sync settings save failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
153
|
+
"error",
|
|
154
|
+
);
|
|
150
155
|
tui.requestRender();
|
|
151
156
|
}
|
|
152
157
|
});
|
|
@@ -159,41 +164,15 @@ async function showSettingsList(
|
|
|
159
164
|
render: (width: number) => container.render(width),
|
|
160
165
|
invalidate: () => container.invalidate(),
|
|
161
166
|
handleInput(data: string) {
|
|
167
|
+
if (disposed) return;
|
|
162
168
|
settingsList.handleInput?.(data);
|
|
163
169
|
tui.requestRender();
|
|
164
170
|
},
|
|
171
|
+
dispose() {
|
|
172
|
+
disposed = true;
|
|
173
|
+
mutationController.abort(new DOMException("Settings UI disposed", "AbortError"));
|
|
174
|
+
signal?.removeEventListener("abort", onAbort);
|
|
175
|
+
},
|
|
165
176
|
};
|
|
166
177
|
});
|
|
167
178
|
}
|
|
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
|
-
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { setSyncSetupCompletions } from "./command.js";
|
|
3
|
+
import {
|
|
4
|
+
configuredSyncSetupNames,
|
|
5
|
+
loadConfig,
|
|
6
|
+
normalizeOnSwitch,
|
|
7
|
+
syncConfigReviewIdentity,
|
|
8
|
+
syncSetupReviewIdentity,
|
|
9
|
+
updateLocalConfig,
|
|
10
|
+
} from "./config.js";
|
|
11
|
+
import { safeTerminalText } from "./sync-format.js";
|
|
12
|
+
import type { OnSwitchAction } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export const SETUP_SWITCH_ACTION_OPTIONS: ReadonlyArray<{
|
|
15
|
+
label: string;
|
|
16
|
+
value: OnSwitchAction;
|
|
17
|
+
}> = [
|
|
18
|
+
{ label: "Ask before pull", value: "ask-before-pull" },
|
|
19
|
+
{ label: "Start pull", value: "pull-after-switch" },
|
|
20
|
+
{ label: "Switch only", value: "switch-only" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function setupSwitchActionLabel(action: OnSwitchAction) {
|
|
24
|
+
return SETUP_SWITCH_ACTION_OPTIONS.find((option) => option.value === action)?.label ?? action;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function setupSwitchActionFromLabel(label: string): OnSwitchAction | undefined {
|
|
28
|
+
return SETUP_SWITCH_ACTION_OPTIONS.find((option) => option.label === label)?.value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function saveOnSwitch(action: OnSwitchAction, signal?: AbortSignal) {
|
|
32
|
+
await updateLocalConfig((settings) => ({ ...settings, onSwitch: action }), signal);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type SetupPullOutcome = "applied" | "cancelled";
|
|
36
|
+
|
|
37
|
+
export class SetupPullRequiresUiError extends Error {}
|
|
38
|
+
|
|
39
|
+
export interface SetupSwitchResult {
|
|
40
|
+
pullApplied: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function useSyncSetup(
|
|
44
|
+
ctx: ExtensionCommandContext,
|
|
45
|
+
name: string,
|
|
46
|
+
pullCurrentSetup?: (setup: string) => Promise<SetupPullOutcome | undefined>,
|
|
47
|
+
expectedAction?: OnSwitchAction,
|
|
48
|
+
signal?: AbortSignal,
|
|
49
|
+
expectedSetupIdentity?: string,
|
|
50
|
+
): Promise<SetupSwitchResult> {
|
|
51
|
+
const normalized = name.trim();
|
|
52
|
+
if (!normalized) throw new Error("Usage: /sync use <setup>");
|
|
53
|
+
const loadedConfig = await loadConfig(normalized);
|
|
54
|
+
const reviewedSetupIdentity = expectedSetupIdentity ?? syncConfigReviewIdentity(loadedConfig);
|
|
55
|
+
throwIfAborted(signal);
|
|
56
|
+
const switchResult: { action: OnSwitchAction; switched: boolean } = {
|
|
57
|
+
action: "ask-before-pull",
|
|
58
|
+
switched: false,
|
|
59
|
+
};
|
|
60
|
+
await updateLocalConfig((current) => {
|
|
61
|
+
throwIfAborted(signal);
|
|
62
|
+
const setup = current.syncSetups[normalized];
|
|
63
|
+
if (!setup) {
|
|
64
|
+
throw new Error(`Sync setup “${safeTerminalText(normalized)}” no longer exists.`);
|
|
65
|
+
}
|
|
66
|
+
const connectionName = setup.storage.connection;
|
|
67
|
+
const connection = current.storageConnections[connectionName];
|
|
68
|
+
if (
|
|
69
|
+
!connection ||
|
|
70
|
+
syncSetupReviewIdentity(normalized, setup, connectionName, connection) !==
|
|
71
|
+
reviewedSetupIdentity
|
|
72
|
+
) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Sync setup “${safeTerminalText(normalized)}” changed while the switch preview was open; reopen it and review the current destination.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
switchResult.action = normalizeOnSwitch(current.onSwitch);
|
|
78
|
+
if (expectedAction !== undefined && switchResult.action !== expectedAction) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"Setup-switch behavior changed while the preview was open; reopen it and retry.",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (switchResult.action === "pull-after-switch" && !ctx.hasUI) {
|
|
84
|
+
throw new SetupPullRequiresUiError(
|
|
85
|
+
`Automatic setup pulls require interactive confirmation; sync setup “${safeTerminalText(normalized)}” was not switched. Use TUI or RPC mode.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (current.activeSyncSetup === normalized) return current;
|
|
89
|
+
switchResult.switched = true;
|
|
90
|
+
return { ...current, activeSyncSetup: normalized };
|
|
91
|
+
}, signal);
|
|
92
|
+
throwIfAborted(signal);
|
|
93
|
+
if (!switchResult.switched) {
|
|
94
|
+
ctx.ui.notify(`Sync setup “${safeTerminalText(normalized)}” is already current.`, "info");
|
|
95
|
+
return { pullApplied: false };
|
|
96
|
+
}
|
|
97
|
+
setSyncSetupCompletions(await configuredSyncSetupNames());
|
|
98
|
+
throwIfAborted(signal);
|
|
99
|
+
|
|
100
|
+
if (switchResult.action === "switch-only") {
|
|
101
|
+
ctx.ui.notify(`Switched to “${safeTerminalText(normalized)}”. No files were pulled.`, "info");
|
|
102
|
+
return { pullApplied: false };
|
|
103
|
+
}
|
|
104
|
+
if (switchResult.action === "ask-before-pull") {
|
|
105
|
+
if (ctx.mode !== "tui") {
|
|
106
|
+
ctx.ui.notify(
|
|
107
|
+
`Switched to “${safeTerminalText(normalized)}”. No files were pulled because confirmation requires TUI mode; run /sync pull to apply this setup.`,
|
|
108
|
+
"info",
|
|
109
|
+
);
|
|
110
|
+
return { pullApplied: false };
|
|
111
|
+
}
|
|
112
|
+
const confirmed = await ctx.ui.confirm(
|
|
113
|
+
`Review a pull for sync setup “${safeTerminalText(normalized)}” now?`,
|
|
114
|
+
"pi-sync will check the remote snapshot and show the exact local writes and deletions before applying anything.",
|
|
115
|
+
{ signal },
|
|
116
|
+
);
|
|
117
|
+
throwIfAborted(signal);
|
|
118
|
+
if (!confirmed) {
|
|
119
|
+
ctx.ui.notify(
|
|
120
|
+
`Switched to “${safeTerminalText(normalized)}”; files were not pulled.`,
|
|
121
|
+
"info",
|
|
122
|
+
);
|
|
123
|
+
return { pullApplied: false };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ctx.ui.notify(
|
|
128
|
+
`Switched to “${safeTerminalText(normalized)}”. Checking remote files for a reviewed pull…`,
|
|
129
|
+
"info",
|
|
130
|
+
);
|
|
131
|
+
if (!pullCurrentSetup) {
|
|
132
|
+
throw new Error(`Switched to “${safeTerminalText(normalized)}”, but pull is unavailable.`);
|
|
133
|
+
}
|
|
134
|
+
const pullOutcome = await pullCurrentSetup(normalized);
|
|
135
|
+
throwIfAborted(signal);
|
|
136
|
+
if (pullOutcome === "cancelled") {
|
|
137
|
+
ctx.ui.notify(
|
|
138
|
+
`Pull cancelled; sync setup “${safeTerminalText(normalized)}” remains current and synced files were not changed.`,
|
|
139
|
+
"info",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return { pullApplied: pullOutcome === "applied" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function throwIfAborted(signal?: AbortSignal) {
|
|
146
|
+
if (!signal?.aborted) return;
|
|
147
|
+
throw signal.reason instanceof Error
|
|
148
|
+
? signal.reason
|
|
149
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
150
|
+
}
|
package/src/snapshot-apply.ts
CHANGED
|
@@ -36,16 +36,18 @@ function fileHashMap(snapshot: Snapshot) {
|
|
|
36
36
|
export async function applySnapshot(
|
|
37
37
|
snapshot: Snapshot,
|
|
38
38
|
protectedRelativePaths = new Set<string>(),
|
|
39
|
-
options: Pick<
|
|
39
|
+
options: Pick<
|
|
40
|
+
SnapshotOptions,
|
|
41
|
+
"include" | "sessionDir" | "syncFiles" | "syncSessions" | "extraFiles"
|
|
42
|
+
> = {},
|
|
40
43
|
) {
|
|
41
44
|
const root = agentDir();
|
|
42
45
|
const { sessionDir } = options;
|
|
43
46
|
await recoverPendingSnapshotTransactions();
|
|
44
47
|
const current = await createSnapshot(snapshot.profile, {
|
|
45
|
-
|
|
46
|
-
syncSessions: snapshotIncludesSessions(snapshot),
|
|
48
|
+
...options,
|
|
49
|
+
...(options.include === undefined ? { syncSessions: snapshotIncludesSessions(snapshot) } : {}),
|
|
47
50
|
sessionDir,
|
|
48
|
-
extraFiles: options.extraFiles,
|
|
49
51
|
});
|
|
50
52
|
const plan = await addTopLevelCaseVariantDeletes(
|
|
51
53
|
root,
|
|
@@ -228,7 +230,8 @@ function rootForTarget(root: string, target: string, sessionDir?: string) {
|
|
|
228
230
|
}
|
|
229
231
|
|
|
230
232
|
async function prepareSnapshotWrite(root: string, target: string, deletePaths: Set<string>) {
|
|
231
|
-
await ensureSafeDirectory(root, path.dirname(target));
|
|
233
|
+
const parentWillBeReplaced = await ensureSafeDirectory(root, path.dirname(target), deletePaths);
|
|
234
|
+
if (parentWillBeReplaced) return;
|
|
232
235
|
try {
|
|
233
236
|
const stat = await fs.lstat(target);
|
|
234
237
|
if (stat.isSymbolicLink())
|
|
@@ -241,7 +244,7 @@ async function prepareSnapshotWrite(root: string, target: string, deletePaths: S
|
|
|
241
244
|
}
|
|
242
245
|
}
|
|
243
246
|
|
|
244
|
-
async function ensureSafeDirectory(root: string, directory: string) {
|
|
247
|
+
async function ensureSafeDirectory(root: string, directory: string, deletePaths: Set<string>) {
|
|
245
248
|
assertWithinRoot(root, directory);
|
|
246
249
|
const rootPath = path.resolve(root);
|
|
247
250
|
const relative = path.relative(rootPath, path.resolve(directory));
|
|
@@ -252,13 +255,16 @@ async function ensureSafeDirectory(root: string, directory: string) {
|
|
|
252
255
|
const stat = await fs.lstat(current);
|
|
253
256
|
if (stat.isSymbolicLink())
|
|
254
257
|
throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
255
|
-
if (!stat.isDirectory())
|
|
258
|
+
if (!stat.isDirectory()) {
|
|
259
|
+
if (deletePaths.has(current)) return true;
|
|
256
260
|
throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
261
|
+
}
|
|
257
262
|
} catch (error) {
|
|
258
263
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
259
264
|
await fs.mkdir(current);
|
|
260
265
|
}
|
|
261
266
|
}
|
|
267
|
+
return false;
|
|
262
268
|
}
|
|
263
269
|
|
|
264
270
|
async function assertNoSymlinkParents(root: string, target: string) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Readable, Writable } from "node:stream";
|
|
2
|
+
import { pipeline } from "node:stream/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { createGunzip, gzip } from "node:zlib";
|
|
5
|
+
import type { Snapshot } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const VERSION = 1;
|
|
8
|
+
const MAX_DECOMPRESSED_SNAPSHOT_BYTES = 512 * 1024 * 1024;
|
|
9
|
+
const gzipAsync = promisify(gzip);
|
|
10
|
+
|
|
11
|
+
export async function encodeSnapshot(snapshot: Snapshot) {
|
|
12
|
+
return gzipAsync(Buffer.from(JSON.stringify(snapshot), "utf8"));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function decodeSnapshot(
|
|
16
|
+
buffer: Buffer,
|
|
17
|
+
options: { signal?: AbortSignal; maxOutputLength?: number } = {},
|
|
18
|
+
): Promise<Snapshot> {
|
|
19
|
+
throwIfAborted(options.signal);
|
|
20
|
+
const limit = options.maxOutputLength ?? MAX_DECOMPRESSED_SNAPSHOT_BYTES;
|
|
21
|
+
const chunks: Buffer[] = [];
|
|
22
|
+
let total = 0;
|
|
23
|
+
const sink = new Writable({
|
|
24
|
+
write(chunk: Buffer, _encoding, callback) {
|
|
25
|
+
total += chunk.byteLength;
|
|
26
|
+
if (total > limit) {
|
|
27
|
+
callback(new Error(`Decompressed snapshot exceeds the ${limit}-byte limit.`));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
chunks.push(Buffer.from(chunk));
|
|
31
|
+
callback();
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
await pipeline(Readable.from([buffer]), createGunzip(), sink, { signal: options.signal });
|
|
35
|
+
throwIfAborted(options.signal);
|
|
36
|
+
const parsed = JSON.parse(Buffer.concat(chunks, total).toString("utf8")) as Snapshot;
|
|
37
|
+
if (parsed.version !== VERSION || !Array.isArray(parsed.files)) {
|
|
38
|
+
throw new Error("Unsupported snapshot format.");
|
|
39
|
+
}
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function throwIfAborted(signal?: AbortSignal) {
|
|
44
|
+
if (!signal?.aborted) return;
|
|
45
|
+
throw signal.reason instanceof Error
|
|
46
|
+
? signal.reason
|
|
47
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
48
|
+
}
|
|
@@ -34,6 +34,7 @@ export async function applySnapshotTransaction(
|
|
|
34
34
|
await fs.rm(target, { force: true, recursive: true });
|
|
35
35
|
}
|
|
36
36
|
for (const item of plan.writes) {
|
|
37
|
+
await fs.mkdir(path.dirname(item.target), { recursive: true });
|
|
37
38
|
await fs.writeFile(item.target, item.content);
|
|
38
39
|
}
|
|
39
40
|
await fs.rm(transaction.directory, { recursive: true, force: true });
|
|
@@ -125,7 +126,8 @@ async function prepareTransaction(plan: SnapshotApplyPlan, sessionDir?: string)
|
|
|
125
126
|
throw new Error(`Unsupported existing snapshot target: ${target}`);
|
|
126
127
|
}
|
|
127
128
|
} catch (error) {
|
|
128
|
-
|
|
129
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
130
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
129
131
|
entries.push({ target, backupName, kind: "missing" });
|
|
130
132
|
}
|
|
131
133
|
}
|