@narumitw/pi-sync 0.24.0 → 0.28.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 +62 -30
- package/package.json +11 -2
- package/src/command.ts +61 -18
- package/src/config.ts +121 -122
- package/src/file-selection.ts +192 -0
- package/src/index.ts +1 -0
- package/src/lock.ts +301 -0
- package/src/snapshot-apply.ts +35 -11
- package/src/snapshot.ts +76 -95
- package/src/sync-policy.ts +129 -0
- package/src/sync-state.ts +167 -0
- package/src/sync.ts +164 -239
- package/src/types.ts +4 -0
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 /sync 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 /sync 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 /sync 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/snapshot-apply.ts
CHANGED
|
@@ -3,7 +3,14 @@ import type { Dirent } from "node:fs";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { agentDir } from "./config.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
assertWithinRoot,
|
|
8
|
+
isDeniedPath,
|
|
9
|
+
isPathInside,
|
|
10
|
+
parentPaths,
|
|
11
|
+
safeJoin,
|
|
12
|
+
toPosix,
|
|
13
|
+
} from "./paths.js";
|
|
7
14
|
import {
|
|
8
15
|
createSnapshot,
|
|
9
16
|
isSessionFilePath,
|
|
@@ -25,11 +32,12 @@ function fileHashMap(snapshot: Snapshot) {
|
|
|
25
32
|
export async function applySnapshot(
|
|
26
33
|
snapshot: Snapshot,
|
|
27
34
|
protectedRelativePaths = new Set<string>(),
|
|
28
|
-
options: Pick<SnapshotOptions, "sessionDir" | "extraFiles"> = {},
|
|
35
|
+
options: Pick<SnapshotOptions, "syncFiles" | "sessionDir" | "extraFiles"> = {},
|
|
29
36
|
) {
|
|
30
37
|
const root = agentDir();
|
|
31
38
|
const { sessionDir } = options;
|
|
32
39
|
const current = await createSnapshot(snapshot.profile, {
|
|
40
|
+
syncFiles: options.syncFiles,
|
|
33
41
|
syncSessions: snapshotIncludesSessions(snapshot),
|
|
34
42
|
sessionDir,
|
|
35
43
|
extraFiles: options.extraFiles,
|
|
@@ -79,7 +87,8 @@ export function preflightSnapshotApply(
|
|
|
79
87
|
|
|
80
88
|
const target = snapshotTarget(root, normalized, options.sessionDir);
|
|
81
89
|
const content = decodeBase64Strict(file.contentBase64, normalized);
|
|
82
|
-
if (sha256(content) !== file.sha256)
|
|
90
|
+
if (sha256(content) !== file.sha256)
|
|
91
|
+
throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
|
|
83
92
|
writes.push({ target, content });
|
|
84
93
|
}
|
|
85
94
|
|
|
@@ -108,7 +117,9 @@ export function protectSnapshotApplyPlan(
|
|
|
108
117
|
): SnapshotApplyPlan {
|
|
109
118
|
if (protectedRelativePaths.size === 0) return plan;
|
|
110
119
|
const protectedTargets = new Set(
|
|
111
|
-
[...protectedRelativePaths].map((relativePath) =>
|
|
120
|
+
[...protectedRelativePaths].map((relativePath) =>
|
|
121
|
+
snapshotTarget(root, relativePath, sessionDir),
|
|
122
|
+
),
|
|
112
123
|
);
|
|
113
124
|
return {
|
|
114
125
|
writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
|
|
@@ -141,7 +152,11 @@ export async function addTopLevelCaseVariantDeletes(
|
|
|
141
152
|
const deletes = new Set(plan.deletes);
|
|
142
153
|
for (const entry of entries) {
|
|
143
154
|
const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
|
|
144
|
-
if (
|
|
155
|
+
if (
|
|
156
|
+
canonicalPath &&
|
|
157
|
+
entry.name !== canonicalPath &&
|
|
158
|
+
(entry.isFile() || entry.isSymbolicLink())
|
|
159
|
+
) {
|
|
145
160
|
deletes.add(safeJoin(root, entry.name));
|
|
146
161
|
}
|
|
147
162
|
}
|
|
@@ -198,7 +213,11 @@ async function preflightSnapshotMutations(
|
|
|
198
213
|
await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
|
|
199
214
|
}
|
|
200
215
|
for (const item of plan.writes) {
|
|
201
|
-
await prepareSnapshotWrite(
|
|
216
|
+
await prepareSnapshotWrite(
|
|
217
|
+
rootForTarget(root, item.target, sessionDir),
|
|
218
|
+
item.target,
|
|
219
|
+
deletePaths,
|
|
220
|
+
);
|
|
202
221
|
}
|
|
203
222
|
}
|
|
204
223
|
|
|
@@ -212,7 +231,8 @@ async function prepareSnapshotWrite(root: string, target: string, deletePaths: S
|
|
|
212
231
|
await ensureSafeDirectory(root, path.dirname(target));
|
|
213
232
|
try {
|
|
214
233
|
const stat = await fs.lstat(target);
|
|
215
|
-
if (stat.isSymbolicLink())
|
|
234
|
+
if (stat.isSymbolicLink())
|
|
235
|
+
throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
|
|
216
236
|
if (stat.isDirectory() && !deletePaths.has(target)) {
|
|
217
237
|
throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
|
|
218
238
|
}
|
|
@@ -230,8 +250,10 @@ async function ensureSafeDirectory(root: string, directory: string) {
|
|
|
230
250
|
current = path.join(current, part);
|
|
231
251
|
try {
|
|
232
252
|
const stat = await fs.lstat(current);
|
|
233
|
-
if (stat.isSymbolicLink())
|
|
234
|
-
|
|
253
|
+
if (stat.isSymbolicLink())
|
|
254
|
+
throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
255
|
+
if (!stat.isDirectory())
|
|
256
|
+
throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
235
257
|
} catch (error) {
|
|
236
258
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
237
259
|
await fs.mkdir(current);
|
|
@@ -249,8 +271,10 @@ async function assertNoSymlinkParents(root: string, target: string) {
|
|
|
249
271
|
current = path.join(current, part);
|
|
250
272
|
try {
|
|
251
273
|
const stat = await fs.lstat(current);
|
|
252
|
-
if (stat.isSymbolicLink())
|
|
253
|
-
|
|
274
|
+
if (stat.isSymbolicLink())
|
|
275
|
+
throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
|
|
276
|
+
if (!stat.isDirectory())
|
|
277
|
+
throw new Error(`Snapshot path parent is not a directory: ${current}`);
|
|
254
278
|
} catch (error) {
|
|
255
279
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
256
280
|
throw error;
|