@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.
package/src/command.ts CHANGED
@@ -7,10 +7,10 @@ const YES_FLAG_COMPLETIONS: readonly CommandArgumentCompletion[] = [
7
7
  ];
8
8
  export const SYNC_COMMANDS = [
9
9
  { name: "help", description: "Show command usage" },
10
- { name: "use", description: "Switch the current sync target", usageSuffix: " <target>" },
10
+ { name: "use", description: "Switch the current sync setup", usageSuffix: " <setup>" },
11
11
  { name: "init", description: "Create local config template" },
12
12
  { name: "config", description: "Show resolved configuration" },
13
- { name: "files", description: "Choose synced files" },
13
+ { name: "files", description: "Choose included content" },
14
14
  { name: "status", description: "Show sync status" },
15
15
  { name: "diff", description: "Show local/remote diff" },
16
16
  { name: "doctor", description: "Check config, secrets, and lock state" },
@@ -27,39 +27,39 @@ export type SyncCommandName = (typeof SYNC_COMMANDS)[number]["name"];
27
27
  const SYNC_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = SYNC_COMMANDS.map(
28
28
  ({ name, description }) => ({ value: name, label: name, description }),
29
29
  );
30
- let targetCompletionNames: string[] = [];
30
+ let setupCompletionNames: string[] = [];
31
31
 
32
- export function setSyncTargetCompletions(names: readonly string[]) {
33
- targetCompletionNames = [...new Set(names)].sort((left, right) => left.localeCompare(right));
32
+ export function setSyncSetupCompletions(names: readonly string[]) {
33
+ setupCompletionNames = [...new Set(names)].sort((left, right) => left.localeCompare(right));
34
34
  }
35
- const TARGET_FLAG_COMPLETION = {
36
- value: "--target",
37
- label: "--target",
38
- description: "Address a target without switching",
35
+ const SETUP_FLAG_COMPLETION = {
36
+ value: "--setup",
37
+ label: "--setup",
38
+ description: "Address a sync setup without switching",
39
39
  } as const;
40
40
  const SYNC_FLAG_COMPLETIONS: Record<string, readonly CommandArgumentCompletion[]> = {
41
- config: [TARGET_FLAG_COMPLETION],
42
- files: [TARGET_FLAG_COMPLETION],
43
- status: [TARGET_FLAG_COMPLETION],
44
- diff: [TARGET_FLAG_COMPLETION],
45
- doctor: [TARGET_FLAG_COMPLETION],
41
+ config: [SETUP_FLAG_COMPLETION],
42
+ files: [SETUP_FLAG_COMPLETION],
43
+ status: [SETUP_FLAG_COMPLETION],
44
+ diff: [SETUP_FLAG_COMPLETION],
45
+ doctor: [SETUP_FLAG_COMPLETION],
46
46
  push: [
47
47
  ...YES_FLAG_COMPLETIONS,
48
48
  { value: "--force", label: "--force", description: "Overwrite visible remote changes" },
49
- TARGET_FLAG_COMPLETION,
49
+ SETUP_FLAG_COMPLETION,
50
50
  ],
51
51
  pull: [
52
52
  ...YES_FLAG_COMPLETIONS,
53
53
  { value: "--force", label: "--force", description: "Overwrite local changes" },
54
- TARGET_FLAG_COMPLETION,
54
+ SETUP_FLAG_COMPLETION,
55
55
  ],
56
56
  sync: [
57
57
  ...YES_FLAG_COMPLETIONS,
58
58
  { value: "--force", label: "--force", description: "Resolve conflicts by forcing action" },
59
- TARGET_FLAG_COMPLETION,
59
+ SETUP_FLAG_COMPLETION,
60
60
  ],
61
- history: [TARGET_FLAG_COMPLETION],
62
- rollback: [...YES_FLAG_COMPLETIONS, TARGET_FLAG_COMPLETION],
61
+ history: [SETUP_FLAG_COMPLETION],
62
+ rollback: [...YES_FLAG_COMPLETIONS, SETUP_FLAG_COMPLETION],
63
63
  unlock: [{ value: "--stale", label: "--stale", description: "Remove only a stale lock" }],
64
64
  };
65
65
 
@@ -75,17 +75,17 @@ export function parseOptions(args: string[]): CommandOptions {
75
75
  let yes = false;
76
76
  let force = false;
77
77
  let stale = false;
78
- let target: string | undefined;
78
+ let setup: string | undefined;
79
79
  for (let index = 0; index < args.length; index += 1) {
80
80
  const arg = args[index];
81
81
  if (arg === "--yes" || arg === "-y") yes = true;
82
82
  else if (arg === "--force") force = true;
83
83
  else if (arg === "--stale") stale = true;
84
- else if (arg === "--target") {
84
+ else if (arg === "--setup") {
85
85
  const name = args[index + 1];
86
- if (!name || name.startsWith("-")) throw new Error("--target requires a target name.");
87
- if (target !== undefined) throw new Error("--target may be provided only once.");
88
- target = name;
86
+ if (!name || name.startsWith("-")) throw new Error("--setup requires a sync setup name.");
87
+ if (setup !== undefined) throw new Error("--setup may be provided only once.");
88
+ setup = name;
89
89
  index += 1;
90
90
  } else if (arg.startsWith("-")) {
91
91
  throw new Error(`Unknown sync option: ${arg}`);
@@ -98,13 +98,13 @@ export function parseOptions(args: string[]): CommandOptions {
98
98
  silent: false,
99
99
  reload: true,
100
100
  auto: false,
101
- ...(target === undefined ? {} : { target }),
101
+ ...(setup === undefined ? {} : { setup }),
102
102
  args: values,
103
103
  };
104
104
  }
105
105
 
106
106
  export function validateCommandOptions(command: string, options: CommandOptions) {
107
- const targetAllowed = new Set([
107
+ const setupAllowed = new Set([
108
108
  "config",
109
109
  "files",
110
110
  "status",
@@ -116,8 +116,8 @@ export function validateCommandOptions(command: string, options: CommandOptions)
116
116
  "history",
117
117
  "rollback",
118
118
  ]);
119
- if (options.target && !targetAllowed.has(command)) {
120
- throw new Error(`--target is not supported by /sync ${command}.`);
119
+ if (options.setup && !setupAllowed.has(command)) {
120
+ throw new Error(`--setup is not supported by /sync ${command}.`);
121
121
  }
122
122
  if ((options.yes || options.force) && !["push", "pull", "sync", "rollback"].includes(command)) {
123
123
  throw new Error(`Confirmation/force options are not supported by /sync ${command}.`);
@@ -128,8 +128,8 @@ export function validateCommandOptions(command: string, options: CommandOptions)
128
128
  const expectedValues = command === "rollback" || command === "use" ? 1 : 0;
129
129
  if (options.args.length !== expectedValues) {
130
130
  if (command === "rollback")
131
- throw new Error("Usage: /sync rollback <snapshot-id> [--yes] [--target <name>]");
132
- if (command === "use") throw new Error("Usage: /sync use <target>");
131
+ throw new Error("Usage: /sync rollback <snapshot-id> [--yes] [--setup <name>]");
132
+ if (command === "use") throw new Error("Usage: /sync use <setup>");
133
133
  throw new Error(`Unexpected argument for /sync ${command}: ${options.args.join(" ")}`);
134
134
  }
135
135
  }
@@ -151,12 +151,12 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
151
151
  const args = tokens.slice(1);
152
152
  if (command === "use") {
153
153
  if (args.length > 1 || (trailingSpace && args.length > 0)) return null;
154
- return completeTargetValue(prefix, trailingSpace ? "" : (args[0] ?? ""));
154
+ return completeSetupValue(prefix, trailingSpace ? "" : (args[0] ?? ""));
155
155
  }
156
- const targetFlagIndex = args.lastIndexOf("--target");
157
- if (targetFlagIndex >= 0 && targetFlagIndex === args.length - (trailingSpace ? 1 : 2)) {
158
- const currentTarget = trailingSpace ? "" : (args.at(-1) ?? "");
159
- if (!currentTarget.startsWith("-")) return completeTargetValue(prefix, currentTarget);
156
+ const setupFlagIndex = args.lastIndexOf("--setup");
157
+ if (setupFlagIndex >= 0 && setupFlagIndex === args.length - (trailingSpace ? 1 : 2)) {
158
+ const currentSetup = trailingSpace ? "" : (args.at(-1) ?? "");
159
+ if (!currentSetup.startsWith("-")) return completeSetupValue(prefix, currentSetup);
160
160
  }
161
161
 
162
162
  const flagCompletions = SYNC_FLAG_COMPLETIONS[command];
@@ -181,15 +181,15 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
181
181
  : null;
182
182
  }
183
183
 
184
- function completeTargetValue(prefix: string, current: string) {
184
+ function completeSetupValue(prefix: string, current: string) {
185
185
  const currentRaw = current ? (prefix.match(/\S+$/u)?.[0] ?? "") : "";
186
186
  const completionPrefix = currentRaw ? prefix.slice(0, prefix.length - currentRaw.length) : prefix;
187
- const matches = targetCompletionNames.filter((name) => name.startsWith(current));
187
+ const matches = setupCompletionNames.filter((name) => name.startsWith(current));
188
188
  return matches.length > 0
189
189
  ? matches.map((name) => ({
190
190
  value: `${completionPrefix}${name}`,
191
191
  label: name,
192
- description: "Sync target",
192
+ description: "Sync setup",
193
193
  }))
194
194
  : null;
195
195
  }
@@ -230,6 +230,6 @@ export function usage() {
230
230
  return [
231
231
  "Usage: /sync <command>",
232
232
  `Commands: ${commands}`,
233
- "Settings: use /sync init or edit profiles and targets in ~/.pi/agent/pi-sync.json (or $PI_CODING_AGENT_DIR/pi-sync.json). Existing PI_SYNC_* overrides still work but are deprecated for future major-version removal.",
233
+ "Settings: use /sync init or edit storage connections and sync setups in ~/.pi/agent/pi-sync.json (or the configured Pi agent directory). Version 1 and version 2 settings are unsupported and are never rewritten.",
234
234
  ].join("\n");
235
235
  }
@@ -0,0 +1,7 @@
1
+ export function isMissingConfigError(error: unknown) {
2
+ return (
3
+ error instanceof Error &&
4
+ (error.message.startsWith("Missing pi-sync settings.") ||
5
+ error.message === "No sync setups are configured.")
6
+ );
7
+ }
@@ -1,6 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import {
3
- constants as fsConstants,
4
3
  mkdir,
5
4
  mkdirSync,
6
5
  realpath,
@@ -36,23 +35,51 @@ const LOCKFILE_FS_ADAPTER = {
36
35
  utimesSync,
37
36
  };
38
37
 
39
- type LinkFile = (source: string, destination: string) => Promise<void>;
40
- let linkConfigFile: LinkFile = (source, destination) => fs.link(source, destination);
38
+ type PublishFile = (source: string, destination: string) => Promise<void>;
39
+ let publishConfigFile: PublishFile = publishFileWithoutReplacement;
40
+ let beforeConfigPublicationHook: () => Promise<void> = async () => undefined;
41
+ let afterMissingConfigReadProbeHook: () => Promise<void> = async () => undefined;
41
42
  let afterReplacementInstalledHook: () => Promise<void> = async () => undefined;
42
43
  let afterConfigQuarantinedHook: () => Promise<void> = async () => undefined;
43
44
 
44
45
  export type FileIdentity = { dev: number; ino: number };
45
46
 
46
- export async function withConfigFileLinkForTest<T>(
47
- link: LinkFile,
47
+ export async function withConfigFilePublicationForTest<T>(
48
+ publish: PublishFile,
48
49
  run: () => Promise<T>,
49
50
  ): Promise<T> {
50
- const previous = linkConfigFile;
51
- linkConfigFile = link;
51
+ const previous = publishConfigFile;
52
+ publishConfigFile = publish;
52
53
  try {
53
54
  return await run();
54
55
  } finally {
55
- linkConfigFile = previous;
56
+ publishConfigFile = previous;
57
+ }
58
+ }
59
+
60
+ export async function withConfigPublicationReadyHookForTest<T>(
61
+ hook: () => Promise<void>,
62
+ run: () => Promise<T>,
63
+ ): Promise<T> {
64
+ const previous = beforeConfigPublicationHook;
65
+ beforeConfigPublicationHook = hook;
66
+ try {
67
+ return await run();
68
+ } finally {
69
+ beforeConfigPublicationHook = previous;
70
+ }
71
+ }
72
+
73
+ export async function withMissingConfigReadProbeHookForTest<T>(
74
+ hook: () => Promise<void>,
75
+ run: () => Promise<T>,
76
+ ): Promise<T> {
77
+ const previous = afterMissingConfigReadProbeHook;
78
+ afterMissingConfigReadProbeHook = hook;
79
+ try {
80
+ return await run();
81
+ } finally {
82
+ afterMissingConfigReadProbeHook = previous;
56
83
  }
57
84
  }
58
85
 
@@ -99,10 +126,10 @@ export function legacyLocalConfigPath() {
99
126
  }
100
127
 
101
128
  export async function activeLocalConfigPath() {
102
- return withLocalConfigFileLock(async () => {
103
- const canonicalPath = localConfigPath();
129
+ const canonicalPath = localConfigPath();
130
+ const legacyPath = legacyLocalConfigPath();
131
+ return withLocalConfigReadLockIfNeeded(async () => {
104
132
  if (await pathExists(canonicalPath)) return canonicalPath;
105
- const legacyPath = legacyLocalConfigPath();
106
133
  return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
107
134
  });
108
135
  }
@@ -114,16 +141,75 @@ export function consumeLocalConfigMigrationNotice() {
114
141
  return notice;
115
142
  }
116
143
 
144
+ export async function readActiveLocalConfigDocumentForRepair(): Promise<
145
+ LocalConfigDocument | undefined
146
+ > {
147
+ const canonicalPath = localConfigPath();
148
+ const legacyPath = legacyLocalConfigPath();
149
+ return withLocalConfigReadLockIfNeeded(async () => {
150
+ const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyPath;
151
+ const snapshot = await readConfigSnapshotIfExists(filePath);
152
+ return snapshot ? { path: filePath, ...snapshot } : undefined;
153
+ });
154
+ }
155
+
117
156
  export async function readMigratingLocalConfigDocument(
118
157
  validateForMigration: (settings: Record<string, unknown>) => void,
119
158
  ): Promise<LocalConfigDocument | undefined> {
120
- return withLocalConfigFileLock(async () => {
159
+ return withLocalConfigReadLockIfNeeded(async () => {
121
160
  const configPath = await prepareLocalConfigPath(validateForMigration);
122
161
  const snapshot = await readConfigSnapshotIfExists(configPath);
123
162
  return snapshot ? { path: configPath, ...snapshot } : undefined;
124
163
  });
125
164
  }
126
165
 
166
+ async function withLocalConfigReadLockIfNeeded<T>(read: () => Promise<T>): Promise<T> {
167
+ if ((await pathExists(localConfigPath())) || (await pathExists(legacyLocalConfigPath()))) {
168
+ return withLocalConfigFileLock(read);
169
+ }
170
+ await afterMissingConfigReadProbeHook();
171
+ if (await pathExists(configMutationLockPath())) return withLocalConfigFileLock(read);
172
+ return read();
173
+ }
174
+
175
+ export function updateLocalConfigDocument<T extends Record<string, unknown>>(
176
+ defaultValue: T,
177
+ update: (current: T) => T,
178
+ validate: (value: Record<string, unknown>) => void,
179
+ signal?: AbortSignal,
180
+ ): Promise<T> {
181
+ return withLocalConfigFileLock(async () => {
182
+ signal?.throwIfAborted();
183
+ const configPath = await prepareLocalConfigPath(validate);
184
+ const snapshot = await readConfigSnapshotIfExists(configPath);
185
+ const document = snapshot ? { path: configPath, ...snapshot } : undefined;
186
+ const current = document
187
+ ? (structuredClone(document.parsed) as T)
188
+ : structuredClone(defaultValue);
189
+ const next = update(current);
190
+ validate(next);
191
+ signal?.throwIfAborted();
192
+ if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
193
+ if (document) await replaceLocalConfigDocumentUnlocked(document, next);
194
+ else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next));
195
+ return next;
196
+ });
197
+ }
198
+
199
+ export function createLocalConfigDocument(value: Record<string, unknown>) {
200
+ return withLocalConfigFileLock(async () => {
201
+ const bytes = serializedConfig(value);
202
+ try {
203
+ await installPrivateConfigExclusively(localConfigPath(), bytes);
204
+ } catch (error) {
205
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
206
+ throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
207
+ }
208
+ throw error;
209
+ }
210
+ });
211
+ }
212
+
127
213
  export function replaceLocalConfigDocument(
128
214
  document: LocalConfigDocument,
129
215
  value: Record<string, unknown>,
@@ -135,13 +221,13 @@ async function replaceLocalConfigDocumentUnlocked(
135
221
  document: LocalConfigDocument,
136
222
  value: Record<string, unknown>,
137
223
  ) {
138
- const nextBytes = Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
224
+ const nextBytes = serializedConfig(value);
139
225
  const canonicalPath = localConfigPath();
140
226
  if (document.path !== canonicalPath) {
141
227
  if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
142
228
  let installed: FileIdentity;
143
229
  try {
144
- installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, true);
230
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
145
231
  } catch (error) {
146
232
  if ((error as NodeJS.ErrnoException).code === "EEXIST") {
147
233
  throw new Error("Canonical settings were created concurrently; no settings were replaced.");
@@ -158,7 +244,7 @@ async function replaceLocalConfigDocumentUnlocked(
158
244
  const quarantinePath = await claimCanonicalConfigDocument(document);
159
245
  let installed: FileIdentity;
160
246
  try {
161
- installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, true);
247
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
162
248
  } catch (error) {
163
249
  await restoreQuarantinedConfig(canonicalPath, quarantinePath);
164
250
  if ((error as NodeJS.ErrnoException).code === "EEXIST") {
@@ -166,13 +252,18 @@ async function replaceLocalConfigDocumentUnlocked(
166
252
  }
167
253
  throw error;
168
254
  }
169
- await afterReplacementInstalledHook();
170
- if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
255
+ try {
256
+ await afterReplacementInstalledHook();
257
+ if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
258
+ throw settingsChangedError();
259
+ }
260
+ if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
261
+ } catch (error) {
171
262
  await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
172
263
  await restoreQuarantinedConfig(canonicalPath, quarantinePath);
173
- throw settingsChangedError();
264
+ throw error;
174
265
  }
175
- if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
266
+ await fs.rm(quarantinePath).catch(() => undefined);
176
267
  await syncParentDirectory(canonicalPath).catch(() => undefined);
177
268
  }
178
269
 
@@ -301,6 +392,10 @@ async function readConfigSnapshotIfExists(filePath: string): Promise<ConfigSnaps
301
392
  }
302
393
  }
303
394
 
395
+ function serializedConfig(value: Record<string, unknown>) {
396
+ return Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
397
+ }
398
+
304
399
  function parseConfigObject(bytes: Buffer, filePath: string) {
305
400
  let parsed: unknown;
306
401
  try {
@@ -314,11 +409,7 @@ function parseConfigObject(bytes: Buffer, filePath: string) {
314
409
  return parsed as Record<string, unknown>;
315
410
  }
316
411
 
317
- async function installPrivateConfigExclusively(
318
- filePath: string,
319
- bytes: Buffer,
320
- allowCopyFallback = false,
321
- ) {
412
+ async function installPrivateConfigExclusively(filePath: string, bytes: Buffer) {
322
413
  await fs.mkdir(path.dirname(filePath), { recursive: true });
323
414
  const temporaryPath = path.join(
324
415
  path.dirname(filePath),
@@ -332,23 +423,34 @@ async function installPrivateConfigExclusively(
332
423
  await handle.sync();
333
424
  await handle.close();
334
425
  handle = undefined;
335
- try {
336
- await linkConfigFile(temporaryPath, filePath);
337
- } catch (error) {
338
- if (!allowCopyFallback || !isUnsupportedLinkError(error)) throw error;
339
- await fs.copyFile(temporaryPath, filePath, fsConstants.COPYFILE_EXCL);
340
- }
426
+ await publishConfigFile(temporaryPath, filePath);
341
427
  const installed = await fs.lstat(filePath);
428
+ let publishedHandle: fs.FileHandle | undefined;
429
+ let publicationError: unknown;
342
430
  try {
343
- if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
344
- await syncParentDirectory(filePath).catch(() => undefined);
431
+ if (installed.isSymbolicLink() || !installed.isFile()) {
432
+ throw new Error(`Published pi-sync settings are not a regular file: ${filePath}`);
433
+ }
434
+ publishedHandle = await fs.open(filePath, "r+");
435
+ const published = await publishedHandle.stat();
436
+ if (published.dev !== installed.dev || published.ino !== installed.ino) {
437
+ throw new Error(`Published pi-sync settings changed while opening: ${filePath}`);
438
+ }
439
+ if (process.platform !== "win32") await publishedHandle.chmod(0o600);
440
+ await publishedHandle.sync();
441
+ await syncParentDirectory(filePath);
345
442
  } catch (error) {
443
+ publicationError = error;
444
+ } finally {
445
+ await publishedHandle?.close().catch(() => undefined);
446
+ }
447
+ if (publicationError) {
346
448
  await quarantineAndRemoveConfigIfMatchesUnlocked(
347
449
  filePath,
348
450
  { dev: installed.dev, ino: installed.ino },
349
451
  bytes,
350
452
  );
351
- throw error;
453
+ throw publicationError;
352
454
  }
353
455
  return { dev: installed.dev, ino: installed.ino };
354
456
  } finally {
@@ -388,11 +490,6 @@ function settingsChangedError() {
388
490
  return new Error("pi-sync settings changed during migration; no settings were replaced.");
389
491
  }
390
492
 
391
- function isUnsupportedLinkError(error: unknown) {
392
- const code = (error as NodeJS.ErrnoException).code;
393
- return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EXDEV" || code === "EPERM";
394
- }
395
-
396
493
  async function configSnapshotStillMatches(filePath: string, snapshot: ConfigSnapshot) {
397
494
  return fileIdentityAndContentsMatch(filePath, snapshot.identity, snapshot.bytes);
398
495
  }
@@ -461,47 +558,32 @@ async function fileIdentityAndContentsMatch(
461
558
 
462
559
  async function restoreQuarantinedConfig(filePath: string, quarantinePath: string) {
463
560
  try {
464
- await linkConfigFile(quarantinePath, filePath);
465
- await fs.rm(quarantinePath);
561
+ await renameFileWithoutReplacement(quarantinePath, filePath);
562
+ if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
466
563
  await syncParentDirectory(filePath);
467
564
  return;
468
565
  } catch (error) {
469
- if (isUnsupportedLinkError(error)) {
470
- try {
471
- await fs.copyFile(quarantinePath, filePath, fsConstants.COPYFILE_EXCL);
472
- if (process.platform !== "win32") {
473
- await fs.chmod(filePath, 0o600);
474
- await fs.chmod(quarantinePath, 0o600);
475
- }
476
- await syncParentDirectory(filePath);
477
- return;
478
- } catch (copyError) {
479
- if ((copyError as NodeJS.ErrnoException).code !== "EEXIST") return;
480
- }
481
- } else if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
482
- return;
483
- }
566
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") return;
484
567
  }
485
568
  try {
486
- const [current, quarantined] = await Promise.all([
487
- fs.readFile(filePath),
488
- fs.readFile(quarantinePath),
489
- ]);
490
- if (current.equals(quarantined)) await fs.rm(quarantinePath);
491
- else if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
569
+ if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
492
570
  await syncParentDirectory(filePath);
493
571
  } catch {
494
572
  // Preserve the quarantine rather than risk deleting settings that changed concurrently.
495
573
  }
496
574
  }
497
575
 
576
+ function configMutationLockPath() {
577
+ return `${localConfigPath()}.mutation-lock`;
578
+ }
579
+
498
580
  export async function withLocalConfigFileLock<T>(run: () => Promise<T>): Promise<T> {
499
581
  const configPath = localConfigPath();
500
582
  await fs.mkdir(path.dirname(configPath), { recursive: true });
501
583
  let compromisedError: Error | undefined;
502
584
  const release = await lockfile.lock(configPath, {
503
585
  fs: LOCKFILE_FS_ADAPTER,
504
- lockfilePath: `${configPath}.mutation-lock`,
586
+ lockfilePath: configMutationLockPath(),
505
587
  realpath: false,
506
588
  stale: CONFIG_LOCK_STALE_MS,
507
589
  update: CONFIG_LOCK_UPDATE_MS,
@@ -530,6 +612,18 @@ async function syncParentDirectory(filePath: string) {
530
612
  }
531
613
  }
532
614
 
615
+ async function publishFileWithoutReplacement(source: string, destination: string) {
616
+ await beforeConfigPublicationHook();
617
+ await renameFileWithoutReplacement(source, destination);
618
+ }
619
+
620
+ async function renameFileWithoutReplacement(source: string, destination: string) {
621
+ if (await pathExists(destination)) {
622
+ throw Object.assign(new Error(`Settings already exist: ${destination}`), { code: "EEXIST" });
623
+ }
624
+ await fs.rename(source, destination);
625
+ }
626
+
533
627
  async function pathExists(filePath: string) {
534
628
  try {
535
629
  await fs.lstat(filePath);