@narumitw/pi-sync 0.34.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/README.md CHANGED
@@ -62,7 +62,7 @@ The canonical private user file is:
62
62
  ~/.pi/agent/pi-sync.json
63
63
  ```
64
64
 
65
- Pi's configured agent directory replaces `~/.pi/agent` when applicable. On POSIX, pi-sync creates and replaces this file with mode `0600`. Credentials stay in this canonical private file and are never shown in menus, reviews, status, notifications, errors, logs, or completion metadata.
65
+ Pi's configured agent directory replaces `~/.pi/agent` when applicable. Missing settings load as unconfigured without creating an agent directory, file, temporary, or lock. An explicit setup creates the file atomically. On POSIX, pi-sync creates and replaces it with mode `0600`. Pi-sync processes coordinate settings access through `pi-sync.json.mutation-lock`; lock-unaware editors are outside that serialization boundary and should not save the file while a pi-sync settings operation is running. Credentials stay in this canonical private file and are never shown in menus, reviews, status, notifications, errors, logs, or completion metadata.
66
66
 
67
67
  An existing private `pi-sync.local.json` containing a valid version 3 document is copied byte-for-byte to `pi-sync.json`; the old file remains as a recovery copy. If both paths exist, `pi-sync.json` wins and the other path remains untouched.
68
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.34.0",
3
+ "version": "0.35.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",
@@ -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
  }
@@ -117,9 +144,10 @@ export function consumeLocalConfigMigrationNotice() {
117
144
  export async function readActiveLocalConfigDocumentForRepair(): Promise<
118
145
  LocalConfigDocument | undefined
119
146
  > {
120
- return withLocalConfigFileLock(async () => {
121
- const canonicalPath = localConfigPath();
122
- const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyLocalConfigPath();
147
+ const canonicalPath = localConfigPath();
148
+ const legacyPath = legacyLocalConfigPath();
149
+ return withLocalConfigReadLockIfNeeded(async () => {
150
+ const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyPath;
123
151
  const snapshot = await readConfigSnapshotIfExists(filePath);
124
152
  return snapshot ? { path: filePath, ...snapshot } : undefined;
125
153
  });
@@ -128,13 +156,22 @@ export async function readActiveLocalConfigDocumentForRepair(): Promise<
128
156
  export async function readMigratingLocalConfigDocument(
129
157
  validateForMigration: (settings: Record<string, unknown>) => void,
130
158
  ): Promise<LocalConfigDocument | undefined> {
131
- return withLocalConfigFileLock(async () => {
159
+ return withLocalConfigReadLockIfNeeded(async () => {
132
160
  const configPath = await prepareLocalConfigPath(validateForMigration);
133
161
  const snapshot = await readConfigSnapshotIfExists(configPath);
134
162
  return snapshot ? { path: configPath, ...snapshot } : undefined;
135
163
  });
136
164
  }
137
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
+
138
175
  export function updateLocalConfigDocument<T extends Record<string, unknown>>(
139
176
  defaultValue: T,
140
177
  update: (current: T) => T,
@@ -154,7 +191,7 @@ export function updateLocalConfigDocument<T extends Record<string, unknown>>(
154
191
  signal?.throwIfAborted();
155
192
  if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
156
193
  if (document) await replaceLocalConfigDocumentUnlocked(document, next);
157
- else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next), true);
194
+ else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next));
158
195
  return next;
159
196
  });
160
197
  }
@@ -163,7 +200,7 @@ export function createLocalConfigDocument(value: Record<string, unknown>) {
163
200
  return withLocalConfigFileLock(async () => {
164
201
  const bytes = serializedConfig(value);
165
202
  try {
166
- await installPrivateConfigExclusively(localConfigPath(), bytes, true);
203
+ await installPrivateConfigExclusively(localConfigPath(), bytes);
167
204
  } catch (error) {
168
205
  if ((error as NodeJS.ErrnoException).code === "EEXIST") {
169
206
  throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
@@ -190,7 +227,7 @@ async function replaceLocalConfigDocumentUnlocked(
190
227
  if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
191
228
  let installed: FileIdentity;
192
229
  try {
193
- installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, true);
230
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
194
231
  } catch (error) {
195
232
  if ((error as NodeJS.ErrnoException).code === "EEXIST") {
196
233
  throw new Error("Canonical settings were created concurrently; no settings were replaced.");
@@ -207,7 +244,7 @@ async function replaceLocalConfigDocumentUnlocked(
207
244
  const quarantinePath = await claimCanonicalConfigDocument(document);
208
245
  let installed: FileIdentity;
209
246
  try {
210
- installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, true);
247
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
211
248
  } catch (error) {
212
249
  await restoreQuarantinedConfig(canonicalPath, quarantinePath);
213
250
  if ((error as NodeJS.ErrnoException).code === "EEXIST") {
@@ -372,11 +409,7 @@ function parseConfigObject(bytes: Buffer, filePath: string) {
372
409
  return parsed as Record<string, unknown>;
373
410
  }
374
411
 
375
- async function installPrivateConfigExclusively(
376
- filePath: string,
377
- bytes: Buffer,
378
- allowCopyFallback = false,
379
- ) {
412
+ async function installPrivateConfigExclusively(filePath: string, bytes: Buffer) {
380
413
  await fs.mkdir(path.dirname(filePath), { recursive: true });
381
414
  const temporaryPath = path.join(
382
415
  path.dirname(filePath),
@@ -390,23 +423,34 @@ async function installPrivateConfigExclusively(
390
423
  await handle.sync();
391
424
  await handle.close();
392
425
  handle = undefined;
393
- try {
394
- await linkConfigFile(temporaryPath, filePath);
395
- } catch (error) {
396
- if (!allowCopyFallback || !isUnsupportedLinkError(error)) throw error;
397
- await fs.copyFile(temporaryPath, filePath, fsConstants.COPYFILE_EXCL);
398
- }
426
+ await publishConfigFile(temporaryPath, filePath);
399
427
  const installed = await fs.lstat(filePath);
428
+ let publishedHandle: fs.FileHandle | undefined;
429
+ let publicationError: unknown;
400
430
  try {
401
- if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
402
- 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);
403
442
  } catch (error) {
443
+ publicationError = error;
444
+ } finally {
445
+ await publishedHandle?.close().catch(() => undefined);
446
+ }
447
+ if (publicationError) {
404
448
  await quarantineAndRemoveConfigIfMatchesUnlocked(
405
449
  filePath,
406
450
  { dev: installed.dev, ino: installed.ino },
407
451
  bytes,
408
452
  );
409
- throw error;
453
+ throw publicationError;
410
454
  }
411
455
  return { dev: installed.dev, ino: installed.ino };
412
456
  } finally {
@@ -446,11 +490,6 @@ function settingsChangedError() {
446
490
  return new Error("pi-sync settings changed during migration; no settings were replaced.");
447
491
  }
448
492
 
449
- function isUnsupportedLinkError(error: unknown) {
450
- const code = (error as NodeJS.ErrnoException).code;
451
- return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EXDEV" || code === "EPERM";
452
- }
453
-
454
493
  async function configSnapshotStillMatches(filePath: string, snapshot: ConfigSnapshot) {
455
494
  return fileIdentityAndContentsMatch(filePath, snapshot.identity, snapshot.bytes);
456
495
  }
@@ -519,45 +558,32 @@ async function fileIdentityAndContentsMatch(
519
558
 
520
559
  async function restoreQuarantinedConfig(filePath: string, quarantinePath: string) {
521
560
  try {
522
- await linkConfigFile(quarantinePath, filePath);
523
- await fs.rm(quarantinePath);
561
+ await renameFileWithoutReplacement(quarantinePath, filePath);
562
+ if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
524
563
  await syncParentDirectory(filePath);
525
564
  return;
526
565
  } catch (error) {
527
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
528
- try {
529
- await fs.copyFile(quarantinePath, filePath, fsConstants.COPYFILE_EXCL);
530
- if (process.platform !== "win32") {
531
- await fs.chmod(filePath, 0o600);
532
- await fs.chmod(quarantinePath, 0o600);
533
- }
534
- await syncParentDirectory(filePath);
535
- return;
536
- } catch (copyError) {
537
- if ((copyError as NodeJS.ErrnoException).code !== "EEXIST") return;
538
- }
539
- }
566
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") return;
540
567
  }
541
568
  try {
542
- const [current, quarantined] = await Promise.all([
543
- fs.readFile(filePath),
544
- fs.readFile(quarantinePath),
545
- ]);
546
- if (current.equals(quarantined)) await fs.rm(quarantinePath);
547
- else if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
569
+ if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
548
570
  await syncParentDirectory(filePath);
549
571
  } catch {
550
572
  // Preserve the quarantine rather than risk deleting settings that changed concurrently.
551
573
  }
552
574
  }
553
575
 
576
+ function configMutationLockPath() {
577
+ return `${localConfigPath()}.mutation-lock`;
578
+ }
579
+
554
580
  export async function withLocalConfigFileLock<T>(run: () => Promise<T>): Promise<T> {
555
581
  const configPath = localConfigPath();
556
582
  await fs.mkdir(path.dirname(configPath), { recursive: true });
557
583
  let compromisedError: Error | undefined;
558
584
  const release = await lockfile.lock(configPath, {
559
585
  fs: LOCKFILE_FS_ADAPTER,
560
- lockfilePath: `${configPath}.mutation-lock`,
586
+ lockfilePath: configMutationLockPath(),
561
587
  realpath: false,
562
588
  stale: CONFIG_LOCK_STALE_MS,
563
589
  update: CONFIG_LOCK_UPDATE_MS,
@@ -586,6 +612,18 @@ async function syncParentDirectory(filePath: string) {
586
612
  }
587
613
  }
588
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
+
589
627
  async function pathExists(filePath: string) {
590
628
  try {
591
629
  await fs.lstat(filePath);