@narumitw/pi-sync 0.24.0 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Pi extension that syncs Pi configuration through Cloudflare R2 or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,9 +28,13 @@
28
28
  "format": "biome check --write .",
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
+ "dependencies": {
32
+ "proper-lockfile": "^4.1.2"
33
+ },
31
34
  "devDependencies": {
32
35
  "@biomejs/biome": "2.5.3",
33
36
  "@earendil-works/pi-coding-agent": "0.80.10",
37
+ "@types/proper-lockfile": "^4.1.4",
34
38
  "typescript": "7.0.2"
35
39
  },
36
40
  "repository": {
package/src/config.ts CHANGED
@@ -1,17 +1,15 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import type { Dirent } from "node:fs";
3
2
  import fs from "node:fs/promises";
4
3
  import os from "node:os";
5
4
  import path from "node:path";
6
5
  import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
6
  import { isDeniedPath, safeName } from "./paths.js";
8
- import type { LockFile, PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
7
+ import type { PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
9
8
 
10
9
  const VERSION = 1;
11
10
  const DEFAULT_PROFILE = "default";
12
11
  const DEFAULT_PREFIX = "pi-sync";
13
12
  const DEFAULT_REGION = "auto";
14
- const LOCK_STALE_MS = 30 * 60 * 1000;
15
13
  const TOP_LEVEL_FILES = new Set(["settings.json", "keybindings.json", "models.json", "AGENTS.md", "APPEND_SYSTEM.md"]);
16
14
  const TOP_LEVEL_FILE_NAMES = new Set([...TOP_LEVEL_FILES].map((name) => name.toLowerCase()));
17
15
  const TOP_LEVEL_DIRS = new Set(["skills", "prompts", "themes", "extensions"]);
@@ -42,37 +40,6 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
42
40
  : undefined;
43
41
  }
44
42
 
45
- export async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
46
- await ensureStateDir();
47
- const lock: LockFile = {
48
- id: randomUUID(),
49
- pid: process.pid,
50
- command,
51
- startedAt: new Date().toISOString(),
52
- };
53
- let handle: fs.FileHandle | undefined;
54
- try {
55
- handle = await fs.open(lockPath(), "wx");
56
- await handle.writeFile(JSON.stringify(lock, null, "\t"));
57
- await handle.close();
58
- handle = undefined;
59
- return await fn();
60
- } catch (error) {
61
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
62
- const current = await readLock();
63
- if (current && isStaleLock(current)) {
64
- throw new Error(`pi-sync lock is stale (pid ${current.pid}). Run /pisync unlock --stale, then retry.`);
65
- }
66
- throw new Error(`pi-sync is already running${current ? ` (${current.command}, pid ${current.pid}, started ${current.startedAt})` : ""}.`);
67
- }
68
- throw error;
69
- } finally {
70
- await handle?.close();
71
- const current = await readLock();
72
- if (current?.id === lock.id) await fs.rm(lockPath(), { force: true });
73
- }
74
- }
75
-
76
43
  export async function loadConfigInternal(): Promise<SyncConfig> {
77
44
  const partial = await loadPartialConfig();
78
45
  const endpoint = partial.endpoint;
@@ -204,21 +171,6 @@ export async function ensureStateDir() {
204
171
  await fs.mkdir(stateDir(), { recursive: true });
205
172
  }
206
173
 
207
- export async function readLock() {
208
- return readJsonIfExists<LockFile>(lockPath());
209
- }
210
-
211
- export function isStaleLock(lock: LockFile) {
212
- if (!Number.isInteger(lock.pid) || lock.pid <= 0) return true;
213
- try {
214
- process.kill(lock.pid, 0);
215
- return false;
216
- } catch (error) {
217
- if ((error as NodeJS.ErrnoException).code === "ESRCH") return true;
218
- return Date.now() - Date.parse(lock.startedAt) > LOCK_STALE_MS;
219
- }
220
- }
221
-
222
174
  async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
223
175
  try {
224
176
  return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
package/src/lock.ts ADDED
@@ -0,0 +1,301 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ mkdir,
4
+ mkdirSync,
5
+ realpath,
6
+ realpathSync,
7
+ rmdir,
8
+ rmdirSync,
9
+ stat,
10
+ statSync,
11
+ utimes,
12
+ utimesSync,
13
+ } from "node:fs";
14
+ import fs from "node:fs/promises";
15
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
+ import lockfile from "proper-lockfile";
17
+ import { ensureStateDir, lockPath } from "./config.js";
18
+ import type { CommandOptions, LockFile } from "./types.js";
19
+
20
+ const LOCK_STALE_MS = 30 * 60 * 1000;
21
+ const GUARD_STALE_MS = 30_000;
22
+ const GUARD_UPDATE_MS = 10_000;
23
+ const MAX_PROCESS_ID = 2_147_483_647;
24
+
25
+ type LockfileFsAdapter = {
26
+ mkdir: typeof mkdir;
27
+ mkdirSync: typeof mkdirSync;
28
+ realpath: typeof realpath;
29
+ realpathSync: typeof realpathSync;
30
+ rmdir: typeof rmdir;
31
+ rmdirSync: typeof rmdirSync;
32
+ stat: typeof stat;
33
+ statSync: typeof statSync;
34
+ utimes: typeof utimes;
35
+ utimesSync: typeof utimesSync;
36
+ };
37
+
38
+ const LOCKFILE_FS_ADAPTER: LockfileFsAdapter = {
39
+ mkdir,
40
+ mkdirSync,
41
+ realpath,
42
+ realpathSync,
43
+ rmdir,
44
+ rmdirSync,
45
+ stat,
46
+ statSync,
47
+ utimes,
48
+ utimesSync,
49
+ };
50
+
51
+ interface Guard {
52
+ release: () => Promise<void>;
53
+ throwIfCompromised: () => void;
54
+ isCompromised: () => boolean;
55
+ }
56
+
57
+ export type LockInspection =
58
+ | { status: "missing" }
59
+ | { status: "unreadable" }
60
+ | { status: "valid"; lock: LockFile };
61
+
62
+ export async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
63
+ await ensureStateDir();
64
+ const lock: LockFile = {
65
+ id: randomUUID(),
66
+ pid: process.pid,
67
+ command,
68
+ startedAt: new Date().toISOString(),
69
+ };
70
+ let guard: Guard | undefined;
71
+ let result: T | undefined;
72
+ let failed = false;
73
+ let failure: unknown;
74
+ try {
75
+ try {
76
+ guard = await acquireGuard();
77
+ } catch (error) {
78
+ if (!isLockHeldError(error)) throw error;
79
+ throw await describeHeldLock();
80
+ }
81
+
82
+ const inspection = await inspectLock();
83
+ if (inspection.status === "valid" && isStaleLock(inspection.lock)) {
84
+ throw new Error(
85
+ `pi-sync lock is stale (pid ${inspection.lock.pid}). Run /pisync unlock --stale, then retry.`,
86
+ );
87
+ }
88
+ if (inspection.status === "valid") {
89
+ throw new Error(
90
+ `pi-sync is already running (${inspection.lock.command}, pid ${inspection.lock.pid}, started ${inspection.lock.startedAt}).`,
91
+ );
92
+ }
93
+ if (inspection.status === "unreadable") {
94
+ throw new Error(
95
+ "pi-sync lock metadata is unreadable. Run /pisync unlock --stale after verifying no sync is running.",
96
+ );
97
+ }
98
+
99
+ await fs.writeFile(lockPath(), JSON.stringify(lock, null, "\t"), { flag: "wx" });
100
+ guard.throwIfCompromised();
101
+ result = await fn();
102
+ guard.throwIfCompromised();
103
+ } catch (error) {
104
+ failed = true;
105
+ failure = error;
106
+ }
107
+
108
+ try {
109
+ const current = await readLock();
110
+ if (current?.id === lock.id) await fs.rm(lockPath(), { force: true });
111
+ } catch (error) {
112
+ if (!failed) {
113
+ failed = true;
114
+ failure = error;
115
+ }
116
+ }
117
+ if (guard) {
118
+ const releaseError = await releaseGuard(guard);
119
+ if (releaseError && !failed) {
120
+ failed = true;
121
+ failure = releaseError;
122
+ }
123
+ }
124
+ if (failed) throw failure;
125
+ return result as T;
126
+ }
127
+
128
+ export async function inspectLock(): Promise<LockInspection> {
129
+ try {
130
+ const text = await fs.readFile(lockPath(), "utf8");
131
+ if (text.trim().length === 0) return { status: "unreadable" };
132
+ const parsed = JSON.parse(text) as unknown;
133
+ return isLockFile(parsed) ? { status: "valid", lock: parsed } : { status: "unreadable" };
134
+ } catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" };
136
+ if (error instanceof SyntaxError) return { status: "unreadable" };
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ export async function readLock(): Promise<LockFile | undefined> {
142
+ const inspection = await inspectLock();
143
+ return inspection.status === "valid" ? inspection.lock : undefined;
144
+ }
145
+
146
+ export async function lockFileExists(): Promise<boolean> {
147
+ try {
148
+ await fs.stat(lockPath());
149
+ return true;
150
+ } catch (error) {
151
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ export function isLockGuardHeld() {
157
+ return lockfile.check(lockPath(), {
158
+ fs: LOCKFILE_FS_ADAPTER,
159
+ lockfilePath: `${lockPath()}.guard`,
160
+ realpath: false,
161
+ stale: GUARD_STALE_MS,
162
+ });
163
+ }
164
+
165
+ export function isStaleLock(lock: LockFile) {
166
+ try {
167
+ process.kill(lock.pid, 0);
168
+ return false;
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return true;
171
+ return Date.now() - Date.parse(lock.startedAt) > LOCK_STALE_MS;
172
+ }
173
+ }
174
+
175
+ export async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
176
+ await ensureStateDir();
177
+ let guard: Guard;
178
+ try {
179
+ guard = await acquireGuard();
180
+ } catch (error) {
181
+ if (!isLockHeldError(error)) throw error;
182
+ ctx.ui.notify((await describeHeldLock()).message, "warning");
183
+ return;
184
+ }
185
+
186
+ let failed = false;
187
+ let failure: unknown;
188
+ try {
189
+ await unlockGuarded(ctx, options);
190
+ } catch (error) {
191
+ failed = true;
192
+ failure = error;
193
+ }
194
+ const releaseError = await releaseGuard(guard);
195
+ if (releaseError && !failed) {
196
+ failed = true;
197
+ failure = releaseError;
198
+ }
199
+ if (failed) throw failure;
200
+ }
201
+
202
+ async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptions) {
203
+ let inspection = await inspectLock();
204
+ if (inspection.status === "missing") {
205
+ ctx.ui.notify("No pi-sync lock is present.", "info");
206
+ return;
207
+ }
208
+ if (inspection.status === "unreadable") {
209
+ if (!options.stale) {
210
+ ctx.ui.notify(
211
+ "Pi-sync lock metadata is unreadable. Use /pisync unlock --stale only after verifying no sync is running.",
212
+ "warning",
213
+ );
214
+ return;
215
+ }
216
+ inspection = await inspectLock();
217
+ if (inspection.status === "unreadable") {
218
+ // Legacy writers expose an empty file before writing owner metadata, so no
219
+ // automatic test can prove this file is abandoned. The explicit --stale
220
+ // flag is the user's confirmation that no legacy sync is still running.
221
+ await fs.rm(lockPath(), { force: true });
222
+ ctx.ui.notify("Removed unreadable pi-sync lock.", "info");
223
+ return;
224
+ }
225
+ if (inspection.status === "missing") {
226
+ ctx.ui.notify("No pi-sync lock is present.", "info");
227
+ return;
228
+ }
229
+ }
230
+ if (!isStaleLock(inspection.lock)) {
231
+ ctx.ui.notify("Lock owner is still live; refusing to remove it.", "warning");
232
+ return;
233
+ }
234
+ await fs.rm(lockPath(), { force: true });
235
+ ctx.ui.notify("Removed stale pi-sync lock.", "info");
236
+ }
237
+
238
+ async function releaseGuard(guard: Guard) {
239
+ try {
240
+ await guard.release();
241
+ return undefined;
242
+ } catch (error) {
243
+ return guard.isCompromised() ? undefined : error;
244
+ }
245
+ }
246
+
247
+ async function acquireGuard(): Promise<Guard> {
248
+ let compromisedError: Error | undefined;
249
+ const release = await lockfile.lock(lockPath(), {
250
+ fs: LOCKFILE_FS_ADAPTER,
251
+ lockfilePath: `${lockPath()}.guard`,
252
+ realpath: false,
253
+ stale: GUARD_STALE_MS,
254
+ update: GUARD_UPDATE_MS,
255
+ onCompromised: (error) => {
256
+ compromisedError = error;
257
+ },
258
+ });
259
+ return {
260
+ release,
261
+ throwIfCompromised: () => {
262
+ if (compromisedError) throw compromisedError;
263
+ },
264
+ isCompromised: () => compromisedError !== undefined,
265
+ };
266
+ }
267
+
268
+ async function describeHeldLock() {
269
+ const current = await readLock();
270
+ if (current && isStaleLock(current)) {
271
+ return new Error("pi-sync lock owner exited; retry shortly while the lock guard expires.");
272
+ }
273
+ if (current) {
274
+ return new Error(
275
+ `Pi-sync is currently running (${current.command}, pid ${current.pid}, started ${current.startedAt}).`,
276
+ );
277
+ }
278
+ return new Error(
279
+ "Pi-sync is currently running (lock metadata is unreadable or still being written).",
280
+ );
281
+ }
282
+
283
+ function isLockHeldError(error: unknown) {
284
+ return (error as NodeJS.ErrnoException).code === "ELOCKED";
285
+ }
286
+
287
+ function isLockFile(value: unknown): value is LockFile {
288
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
289
+ const lock = value as Partial<LockFile>;
290
+ return (
291
+ typeof lock.id === "string" &&
292
+ lock.id.length > 0 &&
293
+ Number.isInteger(lock.pid) &&
294
+ (lock.pid ?? 0) > 0 &&
295
+ (lock.pid ?? 0) <= MAX_PROCESS_ID &&
296
+ typeof lock.command === "string" &&
297
+ lock.command.length > 0 &&
298
+ typeof lock.startedAt === "string" &&
299
+ Number.isFinite(Date.parse(lock.startedAt))
300
+ );
301
+ }
package/src/sync.ts CHANGED
@@ -12,12 +12,9 @@ import {
12
12
  ensureStateDir,
13
13
  extraFilePathsByLower,
14
14
  isMissingConfigError,
15
- isStaleLock,
16
15
  loadPartialConfig,
17
16
  localConfigPath,
18
- lockPath,
19
17
  normalizeExtraFiles,
20
- readLock,
21
18
  stateDir,
22
19
  writeJson,
23
20
  configuredSessionDir,
@@ -29,9 +26,9 @@ import {
29
26
  sessionDirForApply,
30
27
  sessionTokenWarnings,
31
28
  syncSessionsWarnings,
32
- withLock,
33
29
  writeState,
34
30
  } from "./config.js";
31
+ import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
35
32
  import { encodeKey, posixJoin, safeJoin, safeName, toPosix } from "./paths.js";
36
33
  import {
37
34
  historyKey,
@@ -346,8 +343,25 @@ async function doctor(ctx: ExtensionCommandContext) {
346
343
  messages.push(`secret scan: ok (${local.files.length} files checked)`);
347
344
  }
348
345
 
349
- const lock = await readLock();
350
- messages.push(lock ? `lock: held by pid ${lock.pid} since ${lock.startedAt}` : "lock: free");
346
+ const lock = await inspectLock();
347
+ if (lock.status === "valid" && isStaleLock(lock.lock)) {
348
+ level = "warning";
349
+ messages.push(
350
+ `lock: stale (pid ${lock.lock.pid}); run /pisync unlock after verifying no sync is running`,
351
+ );
352
+ } else if (lock.status === "valid") {
353
+ messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
354
+ } else if (lock.status === "unreadable") {
355
+ level = "warning";
356
+ messages.push(
357
+ "lock: unreadable; use /pisync unlock --stale only after verifying no sync is running",
358
+ );
359
+ } else if (await isLockGuardHeld()) {
360
+ level = "warning";
361
+ messages.push("lock: guard active while metadata is missing or still being initialized");
362
+ } else {
363
+ messages.push("lock: free");
364
+ }
351
365
  ctx.ui.notify(messages.join("\n"), level);
352
366
  }
353
367
 
@@ -619,20 +633,6 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
619
633
  await maybeReload(ctx);
620
634
  }
621
635
 
622
- async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
623
- const lock = await readLock();
624
- if (!lock) {
625
- ctx.ui.notify("No pi-sync lock is present.", "info");
626
- return;
627
- }
628
- if (!options.stale && !isStaleLock(lock)) {
629
- ctx.ui.notify("Lock is not stale. Use /pisync unlock --stale only after verifying no sync is running.", "warning");
630
- return;
631
- }
632
- await fs.rm(lockPath(), { force: true });
633
- ctx.ui.notify("Removed stale pi-sync lock.", "info");
634
- }
635
-
636
636
  function protectedSessionPaths(ctx: ExtensionCommandContext | ExtensionContext) {
637
637
  const getSessionFile = ctx.sessionManager.getSessionFile;
638
638
  if (typeof getSessionFile !== "function") return new Set<string>();