@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 +1 -1
- package/package.json +1 -1
- package/src/config-file.ts +98 -60
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
|
|
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
package/src/config-file.ts
CHANGED
|
@@ -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
|
|
40
|
-
let
|
|
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
|
|
47
|
-
|
|
47
|
+
export async function withConfigFilePublicationForTest<T>(
|
|
48
|
+
publish: PublishFile,
|
|
48
49
|
run: () => Promise<T>,
|
|
49
50
|
): Promise<T> {
|
|
50
|
-
const previous =
|
|
51
|
-
|
|
51
|
+
const previous = publishConfigFile;
|
|
52
|
+
publishConfigFile = publish;
|
|
52
53
|
try {
|
|
53
54
|
return await run();
|
|
54
55
|
} finally {
|
|
55
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
402
|
-
|
|
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
|
|
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
|
|
523
|
-
await fs.
|
|
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
|
-
|
|
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:
|
|
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);
|