@narumitw/pi-sync 0.29.1 → 0.30.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.
@@ -0,0 +1,545 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ constants as fsConstants,
4
+ mkdir,
5
+ mkdirSync,
6
+ realpath,
7
+ realpathSync,
8
+ rmdir,
9
+ rmdirSync,
10
+ stat,
11
+ statSync,
12
+ utimes,
13
+ utimesSync,
14
+ } from "node:fs";
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
18
+ import lockfile from "proper-lockfile";
19
+
20
+ const CONFIG_FILE_NAME = "pi-sync.json";
21
+ const LEGACY_CONFIG_FILE_NAME = "pi-sync.local.json";
22
+ const configMigrationNotices = new Map<string, string>();
23
+ const legacyPresenceNoticed = new Set<string>();
24
+ const CONFIG_LOCK_STALE_MS = 30_000;
25
+ const CONFIG_LOCK_UPDATE_MS = 10_000;
26
+ const LOCKFILE_FS_ADAPTER = {
27
+ mkdir,
28
+ mkdirSync,
29
+ realpath,
30
+ realpathSync,
31
+ rmdir,
32
+ rmdirSync,
33
+ stat,
34
+ statSync,
35
+ utimes,
36
+ utimesSync,
37
+ };
38
+
39
+ type LinkFile = (source: string, destination: string) => Promise<void>;
40
+ let linkConfigFile: LinkFile = (source, destination) => fs.link(source, destination);
41
+ let afterReplacementInstalledHook: () => Promise<void> = async () => undefined;
42
+ let afterConfigQuarantinedHook: () => Promise<void> = async () => undefined;
43
+
44
+ export type FileIdentity = { dev: number; ino: number };
45
+
46
+ export async function withConfigFileLinkForTest<T>(
47
+ link: LinkFile,
48
+ run: () => Promise<T>,
49
+ ): Promise<T> {
50
+ const previous = linkConfigFile;
51
+ linkConfigFile = link;
52
+ try {
53
+ return await run();
54
+ } finally {
55
+ linkConfigFile = previous;
56
+ }
57
+ }
58
+
59
+ export async function withConfigReplacementInstalledHookForTest<T>(
60
+ hook: () => Promise<void>,
61
+ run: () => Promise<T>,
62
+ ): Promise<T> {
63
+ const previous = afterReplacementInstalledHook;
64
+ afterReplacementInstalledHook = hook;
65
+ try {
66
+ return await run();
67
+ } finally {
68
+ afterReplacementInstalledHook = previous;
69
+ }
70
+ }
71
+
72
+ export async function withConfigQuarantinedHookForTest<T>(
73
+ hook: () => Promise<void>,
74
+ run: () => Promise<T>,
75
+ ): Promise<T> {
76
+ const previous = afterConfigQuarantinedHook;
77
+ afterConfigQuarantinedHook = hook;
78
+ try {
79
+ return await run();
80
+ } finally {
81
+ afterConfigQuarantinedHook = previous;
82
+ }
83
+ }
84
+
85
+ type ConfigSnapshot = {
86
+ bytes: Buffer;
87
+ identity: FileIdentity;
88
+ parsed: Record<string, unknown>;
89
+ };
90
+
91
+ export type LocalConfigDocument = ConfigSnapshot & { path: string };
92
+
93
+ export function localConfigPath() {
94
+ return path.join(getAgentDir(), CONFIG_FILE_NAME);
95
+ }
96
+
97
+ export function legacyLocalConfigPath() {
98
+ return path.join(getAgentDir(), LEGACY_CONFIG_FILE_NAME);
99
+ }
100
+
101
+ export async function activeLocalConfigPath() {
102
+ return withLocalConfigFileLock(async () => {
103
+ const canonicalPath = localConfigPath();
104
+ if (await pathExists(canonicalPath)) return canonicalPath;
105
+ const legacyPath = legacyLocalConfigPath();
106
+ return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
107
+ });
108
+ }
109
+
110
+ export function consumeLocalConfigMigrationNotice() {
111
+ const configPath = localConfigPath();
112
+ const notice = configMigrationNotices.get(configPath);
113
+ configMigrationNotices.delete(configPath);
114
+ return notice;
115
+ }
116
+
117
+ export async function readMigratingLocalConfigDocument(
118
+ validateForMigration: (settings: Record<string, unknown>) => void,
119
+ ): Promise<LocalConfigDocument | undefined> {
120
+ return withLocalConfigFileLock(async () => {
121
+ const configPath = await prepareLocalConfigPath(validateForMigration);
122
+ const snapshot = await readConfigSnapshotIfExists(configPath);
123
+ return snapshot ? { path: configPath, ...snapshot } : undefined;
124
+ });
125
+ }
126
+
127
+ export function replaceLocalConfigDocument(
128
+ document: LocalConfigDocument,
129
+ value: Record<string, unknown>,
130
+ ) {
131
+ return withLocalConfigFileLock(() => replaceLocalConfigDocumentUnlocked(document, value));
132
+ }
133
+
134
+ async function replaceLocalConfigDocumentUnlocked(
135
+ document: LocalConfigDocument,
136
+ value: Record<string, unknown>,
137
+ ) {
138
+ const nextBytes = Buffer.from(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
139
+ const canonicalPath = localConfigPath();
140
+ if (document.path !== canonicalPath) {
141
+ if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
142
+ let installed: FileIdentity;
143
+ try {
144
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, true);
145
+ } catch (error) {
146
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
147
+ throw new Error("Canonical settings were created concurrently; no settings were replaced.");
148
+ }
149
+ throw error;
150
+ }
151
+ if (!(await configDocumentStillMatches(document))) {
152
+ await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
153
+ throw settingsChangedError();
154
+ }
155
+ return;
156
+ }
157
+
158
+ const quarantinePath = await claimCanonicalConfigDocument(document);
159
+ let installed: FileIdentity;
160
+ try {
161
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, true);
162
+ } catch (error) {
163
+ await restoreQuarantinedConfig(canonicalPath, quarantinePath);
164
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
165
+ throw new Error("Canonical settings changed concurrently; no settings were replaced.");
166
+ }
167
+ throw error;
168
+ }
169
+ await afterReplacementInstalledHook();
170
+ if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
171
+ await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
172
+ await restoreQuarantinedConfig(canonicalPath, quarantinePath);
173
+ throw settingsChangedError();
174
+ }
175
+ if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
176
+ await syncParentDirectory(canonicalPath).catch(() => undefined);
177
+ }
178
+
179
+ async function prepareLocalConfigPath(
180
+ validateForMigration: (settings: Record<string, unknown>) => void,
181
+ ) {
182
+ const canonicalPath = localConfigPath();
183
+ const legacyPath = legacyLocalConfigPath();
184
+ if (await pathExists(canonicalPath)) {
185
+ const legacyStatus = await secureIgnoredLegacyIfPresent(legacyPath);
186
+ if (legacyStatus !== "missing" && !legacyPresenceNoticed.has(canonicalPath)) {
187
+ legacyPresenceNoticed.add(canonicalPath);
188
+ recordConfigMigrationNotice(
189
+ canonicalPath,
190
+ legacyStatus === "private"
191
+ ? `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence. Delete ${LEGACY_CONFIG_FILE_NAME} after confirming your settings.`
192
+ : `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} takes precedence, but pi-sync could not verify them as a private regular file. Secure or delete the legacy path after confirming your settings.`,
193
+ );
194
+ }
195
+ return canonicalPath;
196
+ }
197
+
198
+ const legacy = await readConfigSnapshotIfExists(legacyPath);
199
+ if (!legacy) return canonicalPath;
200
+ validateForMigration(legacy.parsed);
201
+
202
+ let installedIdentity: FileIdentity;
203
+ try {
204
+ installedIdentity = await installPrivateConfigExclusively(canonicalPath, legacy.bytes);
205
+ } catch (error) {
206
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
207
+ recordConfigMigrationNotice(
208
+ canonicalPath,
209
+ `${LEGACY_CONFIG_FILE_NAME} legacy settings were ignored because ${CONFIG_FILE_NAME} was created concurrently and takes precedence.`,
210
+ );
211
+ return canonicalPath;
212
+ }
213
+ recordConfigMigrationNotice(
214
+ canonicalPath,
215
+ `Could not migrate ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the legacy settings were used for this session and were not changed.`,
216
+ );
217
+ return legacyPath;
218
+ }
219
+
220
+ if (!(await configSnapshotStillMatches(legacyPath, legacy))) {
221
+ const removed = await quarantineAndRemoveConfigIfMatchesUnlocked(
222
+ canonicalPath,
223
+ installedIdentity,
224
+ legacy.bytes,
225
+ );
226
+ recordConfigMigrationNotice(
227
+ canonicalPath,
228
+ removed
229
+ ? `${LEGACY_CONFIG_FILE_NAME} changed during migration; the stale ${CONFIG_FILE_NAME} copy was removed and the legacy settings were used for this session.`
230
+ : `${LEGACY_CONFIG_FILE_NAME} changed during migration, but ${CONFIG_FILE_NAME} was replaced concurrently and takes precedence.`,
231
+ );
232
+ return removed ? legacyPath : canonicalPath;
233
+ }
234
+
235
+ legacyPresenceNoticed.add(canonicalPath);
236
+ recordConfigMigrationNotice(
237
+ canonicalPath,
238
+ `pi-sync settings migrated from ${LEGACY_CONFIG_FILE_NAME} to ${CONFIG_FILE_NAME}; the private legacy file was retained as a recovery copy and can be deleted after verification.`,
239
+ );
240
+ return canonicalPath;
241
+ }
242
+
243
+ async function secureIgnoredLegacyIfPresent(filePath: string) {
244
+ let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
245
+ try {
246
+ pathStat = await fs.lstat(filePath);
247
+ } catch (error) {
248
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing" as const;
249
+ return "unsafe" as const;
250
+ }
251
+ if (pathStat.isSymbolicLink() || !pathStat.isFile()) return "unsafe" as const;
252
+
253
+ let handle: fs.FileHandle | undefined;
254
+ try {
255
+ handle = await fs.open(filePath, "r");
256
+ const openedStat = await handle.stat();
257
+ if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
258
+ return "unsafe" as const;
259
+ }
260
+ if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
261
+ await handle.chmod(0o600);
262
+ }
263
+ return "private" as const;
264
+ } catch {
265
+ return "unsafe" as const;
266
+ } finally {
267
+ await handle?.close().catch(() => undefined);
268
+ }
269
+ }
270
+
271
+ async function readConfigSnapshotIfExists(filePath: string): Promise<ConfigSnapshot | undefined> {
272
+ let pathStat: Awaited<ReturnType<typeof fs.lstat>>;
273
+ try {
274
+ pathStat = await fs.lstat(filePath);
275
+ } catch (error) {
276
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
277
+ throw error;
278
+ }
279
+ if (pathStat.isSymbolicLink()) {
280
+ throw new Error(`Refusing to read symlinked pi-sync config: ${filePath}`);
281
+ }
282
+ if (!pathStat.isFile()) throw new Error(`pi-sync config is not a regular file: ${filePath}`);
283
+
284
+ const handle = await fs.open(filePath, "r");
285
+ try {
286
+ const openedStat = await handle.stat();
287
+ if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
288
+ throw new Error(`pi-sync config changed while opening: ${filePath}`);
289
+ }
290
+ if (process.platform !== "win32" && (openedStat.mode & 0o777) !== 0o600) {
291
+ await handle.chmod(0o600);
292
+ }
293
+ const bytes = await handle.readFile();
294
+ return {
295
+ bytes,
296
+ identity: { dev: openedStat.dev, ino: openedStat.ino },
297
+ parsed: parseConfigObject(bytes, filePath),
298
+ };
299
+ } finally {
300
+ await handle.close();
301
+ }
302
+ }
303
+
304
+ function parseConfigObject(bytes: Buffer, filePath: string) {
305
+ let parsed: unknown;
306
+ try {
307
+ parsed = JSON.parse(bytes.toString("utf8"));
308
+ } catch {
309
+ throw new SyntaxError(`Invalid JSON in pi-sync config: ${filePath}`);
310
+ }
311
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
312
+ throw new Error(`pi-sync config must contain a JSON object: ${filePath}`);
313
+ }
314
+ return parsed as Record<string, unknown>;
315
+ }
316
+
317
+ async function installPrivateConfigExclusively(
318
+ filePath: string,
319
+ bytes: Buffer,
320
+ allowCopyFallback = false,
321
+ ) {
322
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
323
+ const temporaryPath = path.join(
324
+ path.dirname(filePath),
325
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.migrate`,
326
+ );
327
+ let handle: fs.FileHandle | undefined;
328
+ try {
329
+ handle = await fs.open(temporaryPath, "wx", 0o600);
330
+ await handle.writeFile(bytes);
331
+ if (process.platform !== "win32") await handle.chmod(0o600);
332
+ await handle.sync();
333
+ await handle.close();
334
+ 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
+ }
341
+ const installed = await fs.lstat(filePath);
342
+ try {
343
+ if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
344
+ await syncParentDirectory(filePath).catch(() => undefined);
345
+ } catch (error) {
346
+ await quarantineAndRemoveConfigIfMatchesUnlocked(
347
+ filePath,
348
+ { dev: installed.dev, ino: installed.ino },
349
+ bytes,
350
+ );
351
+ throw error;
352
+ }
353
+ return { dev: installed.dev, ino: installed.ino };
354
+ } finally {
355
+ await handle?.close().catch(() => undefined);
356
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
357
+ }
358
+ }
359
+
360
+ async function configDocumentStillMatches(document: LocalConfigDocument) {
361
+ return fileIdentityAndContentsMatch(document.path, document.identity, document.bytes);
362
+ }
363
+
364
+ async function claimCanonicalConfigDocument(document: LocalConfigDocument) {
365
+ const quarantinePath = path.join(
366
+ path.dirname(document.path),
367
+ `.${path.basename(document.path)}.${randomUUID()}.schema-migration-source`,
368
+ );
369
+ try {
370
+ await fs.rename(document.path, quarantinePath);
371
+ await syncParentDirectory(document.path);
372
+ } catch (error) {
373
+ await restoreQuarantinedConfig(document.path, quarantinePath);
374
+ throw error;
375
+ }
376
+ if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
377
+ await restoreQuarantinedConfig(document.path, quarantinePath);
378
+ throw settingsChangedError();
379
+ }
380
+ if (await pathExists(document.path)) {
381
+ await restoreQuarantinedConfig(document.path, quarantinePath);
382
+ throw new Error("Canonical settings changed concurrently; no settings were replaced.");
383
+ }
384
+ return quarantinePath;
385
+ }
386
+
387
+ function settingsChangedError() {
388
+ return new Error("pi-sync settings changed during migration; no settings were replaced.");
389
+ }
390
+
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
+ async function configSnapshotStillMatches(filePath: string, snapshot: ConfigSnapshot) {
397
+ return fileIdentityAndContentsMatch(filePath, snapshot.identity, snapshot.bytes);
398
+ }
399
+
400
+ export function quarantineAndRemoveConfigIfMatches(
401
+ filePath: string,
402
+ identity: FileIdentity,
403
+ expectedBytes: Buffer,
404
+ ) {
405
+ return withLocalConfigFileLock(() =>
406
+ quarantineAndRemoveConfigIfMatchesUnlocked(filePath, identity, expectedBytes),
407
+ );
408
+ }
409
+
410
+ async function quarantineAndRemoveConfigIfMatchesUnlocked(
411
+ filePath: string,
412
+ identity: FileIdentity,
413
+ expectedBytes: Buffer,
414
+ ) {
415
+ const quarantinePath = path.join(
416
+ path.dirname(filePath),
417
+ `.${path.basename(filePath)}.${randomUUID()}.migration-retired`,
418
+ );
419
+ try {
420
+ await fs.rename(filePath, quarantinePath);
421
+ } catch {
422
+ return false;
423
+ }
424
+ await afterConfigQuarantinedHook();
425
+ try {
426
+ await syncParentDirectory(filePath);
427
+ } catch {
428
+ await restoreQuarantinedConfig(filePath, quarantinePath);
429
+ return false;
430
+ }
431
+
432
+ const matches = await fileIdentityAndContentsMatch(quarantinePath, identity, expectedBytes);
433
+ if (!matches) {
434
+ await restoreQuarantinedConfig(filePath, quarantinePath);
435
+ return false;
436
+ }
437
+ if (await pathExists(filePath)) {
438
+ await fs.rm(quarantinePath, { force: true });
439
+ await syncParentDirectory(filePath);
440
+ return false;
441
+ }
442
+ await fs.rm(quarantinePath);
443
+ await syncParentDirectory(filePath);
444
+ return true;
445
+ }
446
+
447
+ async function fileIdentityAndContentsMatch(
448
+ filePath: string,
449
+ identity: FileIdentity,
450
+ expectedBytes: Buffer,
451
+ ) {
452
+ try {
453
+ const current = await fs.lstat(filePath);
454
+ if (current.isSymbolicLink()) return false;
455
+ if (current.dev !== identity.dev || current.ino !== identity.ino) return false;
456
+ return (await fs.readFile(filePath)).equals(expectedBytes);
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+
462
+ async function restoreQuarantinedConfig(filePath: string, quarantinePath: string) {
463
+ try {
464
+ await linkConfigFile(quarantinePath, filePath);
465
+ await fs.rm(quarantinePath);
466
+ await syncParentDirectory(filePath);
467
+ return;
468
+ } 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
+ }
484
+ }
485
+ 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);
492
+ await syncParentDirectory(filePath);
493
+ } catch {
494
+ // Preserve the quarantine rather than risk deleting settings that changed concurrently.
495
+ }
496
+ }
497
+
498
+ export async function withLocalConfigFileLock<T>(run: () => Promise<T>): Promise<T> {
499
+ const configPath = localConfigPath();
500
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
501
+ let compromisedError: Error | undefined;
502
+ const release = await lockfile.lock(configPath, {
503
+ fs: LOCKFILE_FS_ADAPTER,
504
+ lockfilePath: `${configPath}.mutation-lock`,
505
+ realpath: false,
506
+ stale: CONFIG_LOCK_STALE_MS,
507
+ update: CONFIG_LOCK_UPDATE_MS,
508
+ retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 },
509
+ onCompromised: (error) => {
510
+ compromisedError = error;
511
+ },
512
+ });
513
+ try {
514
+ const result = await run();
515
+ if (compromisedError) throw compromisedError;
516
+ return result;
517
+ } finally {
518
+ await release();
519
+ }
520
+ }
521
+
522
+ async function syncParentDirectory(filePath: string) {
523
+ if (process.platform === "win32") return;
524
+ let handle: fs.FileHandle | undefined;
525
+ try {
526
+ handle = await fs.open(path.dirname(filePath), "r");
527
+ await handle.sync();
528
+ } finally {
529
+ await handle?.close().catch(() => undefined);
530
+ }
531
+ }
532
+
533
+ async function pathExists(filePath: string) {
534
+ try {
535
+ await fs.lstat(filePath);
536
+ return true;
537
+ } catch (error) {
538
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
539
+ throw error;
540
+ }
541
+ }
542
+
543
+ function recordConfigMigrationNotice(configPath: string, notice: string) {
544
+ if (!configMigrationNotices.has(configPath)) configMigrationNotices.set(configPath, notice);
545
+ }