@narumitw/pi-sync 0.31.0 → 0.35.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.
@@ -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
- isCloudflareR2Endpoint,
8
- isEnabled,
9
- loadPartialConfig,
10
- loadTargetSwitchAction,
11
- localConfigPath,
12
- normalizeExtraFiles,
13
- normalizeSyncFiles,
14
- readLocalConfigObject,
15
- updateLocalConfig,
16
- } from "./config.js";
17
- import { errorMessage, safeTerminalText } from "./sync-format.js";
18
- import {
19
- saveTargetSwitchAction,
20
- TARGET_SWITCH_ACTION_OPTIONS,
21
- type TargetPullOutcome,
22
- targetSwitchActionFromLabel,
23
- targetSwitchActionLabel,
24
- } from "./target-switch.js";
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
- target?: string,
31
- ) => Promise<TargetPullOutcome | undefined>;
21
+ setup?: string,
22
+ ) => Promise<SetupPullOutcome | undefined>;
32
23
 
33
- export async function showSyncSettings(ctx: ExtensionCommandContext, runRoute: SyncSettingsRoute) {
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 (true) {
39
- const raw = await readLocalConfigObject();
40
- const version2 = raw?.version === 2;
41
- const partial = await loadPartialConfig();
42
- const action = await showSettingsList(ctx, partial, version2);
43
- if (action !== "files") return;
44
- await runRoute("files", undefined, undefined, version2 ? partial.target : undefined);
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
- partial: Awaited<ReturnType<typeof loadPartialConfig>>,
51
- version2: boolean,
44
+ config: Awaited<ReturnType<typeof loadConfig>>,
45
+ signal?: AbortSignal,
52
46
  ) {
53
- const targetName = version2 ? partial.target : undefined;
54
- const automaticSyncOverridden = Object.hasOwn(process.env, "PI_SYNC_AUTO_SYNC");
55
- const automaticSyncValue = isEnabled(partial.autoSync, true) ? "On" : "Off";
56
- const targetSwitchAction = await loadTargetSwitchAction();
57
- const targetSwitchValue = targetSwitchActionLabel(targetSwitchAction);
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: "automaticSync",
65
- label: automaticSyncOverridden ? "Automatic sync (environment override)" : "Automatic sync",
66
- description: automaticSyncOverridden
67
- ? "Read-only while deprecated PI_SYNC_AUTO_SYNC overrides this target. Move the value into target settings before the future major removal."
68
- : "Run conservative synchronization automatically at session startup and shutdown.",
69
- currentValue: automaticSyncValue,
70
- ...(automaticSyncOverridden ? {} : { values: ["On", "Off"] }),
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: "syncFiles",
86
- label: "Synced content",
87
- description: `${normalizeSyncFiles(partial.syncFiles).length} built-in groups and ${normalizeExtraFiles(partial.extraFiles).length} extra files. Opens the reviewed content-selection draft.`,
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
- `Target: ${safeTerminalText(partial.target ?? "default")} · ${storageDescription(partial.storageKind, partial.endpoint, partial.bucket)}`,
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(() => done(result));
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 === "syncFiles") {
112
+ if (id === "include") {
117
113
  closeAfterSaves("files");
118
114
  return;
119
115
  }
120
116
  latestRequested.set(id, newValue);
121
- const fallbackValue = id === "automaticSync" ? automaticSyncValue : targetSwitchValue;
117
+ const fallback = id === "automatic" ? automaticValue : switchValue;
122
118
  const operation = saveQueue.then(async () => {
123
- let previousValue = fallbackValue;
119
+ if (disposed || signal?.aborted) return;
120
+ let previousValue = fallback;
124
121
  try {
125
- if (id === "automaticSync") {
126
- const latest = await loadPartialConfig(targetName);
127
- previousValue = isEnabled(latest.autoSync, true) ? "On" : "Off";
128
- const enabled = newValue === "On";
129
- await updateSettingsTarget(targetName, (target) => ({
130
- ...target,
131
- autoSync: enabled,
132
- }));
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 ${enabled ? "enabled" : "disabled"} for “${safeTerminalText(partial.target ?? "default")}”.`,
137
+ `Automatic sync ${automatic ? "enabled" : "disabled"} for “${safeTerminalText(config.setupName)}”.`,
135
138
  "info",
136
139
  );
137
- } else if (id === "targetSwitchAction") {
138
- const latest = await loadTargetSwitchAction();
139
- previousValue = targetSwitchActionLabel(latest);
140
- const action = targetSwitchActionFromLabel(newValue);
141
- if (!action) throw new Error(`Invalid target-switch action: ${newValue}`);
142
- await saveTargetSwitchAction(action);
143
- ctx.ui.notify(`After target switch: ${newValue}.`, "info");
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 (latestRequested.get(id) === newValue) {
147
- settingsList.updateValue(id, previousValue);
148
- }
149
- ctx.ui.notify(`Pi Sync settings save failed: ${errorMessage(error)}`, "error");
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
+ }
@@ -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<SnapshotOptions, "syncFiles" | "sessionDir" | "extraFiles"> = {},
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
- syncFiles: options.syncFiles,
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
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
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
  }