@cleocode/caamp 2026.10.8 → 2026.10.9-rc.ci.676
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/dist/atomic.d.ts +184 -0
- package/dist/atomic.js +20 -0
- package/dist/{chunk-2FQWBKEG.js → chunk-2RZGY4XT.js} +54 -166
- package/dist/chunk-2RZGY4XT.js.map +1 -0
- package/dist/{chunk-HDZYZTR2.js → chunk-5INWYMO4.js} +1 -8
- package/dist/{chunk-HDZYZTR2.js.map → chunk-5INWYMO4.js.map} +1 -1
- package/dist/{chunk-ZL6FLO2H.js → chunk-5UAFS46N.js} +9 -5
- package/dist/{chunk-ZL6FLO2H.js.map → chunk-5UAFS46N.js.map} +1 -1
- package/dist/chunk-D6CWC4BX.js +195 -0
- package/dist/chunk-D6CWC4BX.js.map +1 -0
- package/dist/chunk-IYSZL7N7.js +1239 -0
- package/dist/chunk-IYSZL7N7.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/cli.js +8 -6
- package/dist/cli.js.map +1 -1
- package/dist/hooks-HDXLOEX2.js +61 -0
- package/dist/hooks-HDXLOEX2.js.map +1 -0
- package/dist/hooks.d.ts +812 -0
- package/dist/{hooks-FHFZZENR.js → hooks.js} +8 -3
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +5 -916
- package/dist/index.js +15 -10
- package/dist/index.js.map +1 -1
- package/dist/{injector-FDRZD2DB.js → injector-3SMMI2FO.js} +5 -3
- package/dist/injector-3SMMI2FO.js.map +1 -0
- package/package.json +15 -7
- package/providers/hook-mappings.json +6 -2
- package/dist/chunk-2FQWBKEG.js.map +0 -1
- package/dist/chunk-YCAANESR.js +0 -294
- package/dist/chunk-YCAANESR.js.map +0 -1
- /package/dist/{hooks-FHFZZENR.js.map → atomic.js.map} +0 -0
- /package/dist/{injector-FDRZD2DB.js.map → chunk-PZ5AY32C.js.map} +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// src/core/fs/atomic.ts
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { lstat, mkdir, open, readFile, rm, stat } from "fs/promises";
|
|
4
|
+
import { dirname } from "path";
|
|
5
|
+
import { writeFileAtomic as canonicalWriteFileAtomic, readFileText } from "@cleocode/core/tools/fs";
|
|
6
|
+
import * as jsonc from "jsonc-parser";
|
|
7
|
+
var writeFileAtomic = canonicalWriteFileAtomic;
|
|
8
|
+
var DEFAULT_STALE_LOCK_MS = 3e4;
|
|
9
|
+
var DEFAULT_LOCK_RETRIES = 400;
|
|
10
|
+
var DEFAULT_LOCK_DELAY_MS = 25;
|
|
11
|
+
async function readGuardToken(path) {
|
|
12
|
+
try {
|
|
13
|
+
const metadata = await lstat(path);
|
|
14
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("HOOK_LOCK_INVALID");
|
|
15
|
+
return (await readFileText({ path, maxBytes: 1024 })).content;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
async function removeStaleGuard(guardPath, staleMs, expectedToken) {
|
|
25
|
+
try {
|
|
26
|
+
const info = await stat(guardPath);
|
|
27
|
+
if (Date.now() - info.mtimeMs <= staleMs) return false;
|
|
28
|
+
const current = await readGuardToken(guardPath);
|
|
29
|
+
if (expectedToken !== null && current !== null && current !== expectedToken) return false;
|
|
30
|
+
await rm(guardPath, { force: true });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
async function withFileLock(targetPath, fn, options = {}) {
|
|
37
|
+
const retries = options.retries ?? DEFAULT_LOCK_RETRIES;
|
|
38
|
+
const delayMs = options.delayMs ?? DEFAULT_LOCK_DELAY_MS;
|
|
39
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
|
|
40
|
+
const guardPath = `${targetPath}.lock`;
|
|
41
|
+
const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2, 12)}`;
|
|
42
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
43
|
+
let acquired = false;
|
|
44
|
+
for (let attempt = 0; attempt < retries && !acquired; attempt += 1) {
|
|
45
|
+
try {
|
|
46
|
+
const handle = await open(guardPath, "wx");
|
|
47
|
+
try {
|
|
48
|
+
await handle.writeFile(token, "utf-8");
|
|
49
|
+
} finally {
|
|
50
|
+
await handle.close();
|
|
51
|
+
}
|
|
52
|
+
acquired = true;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const code = error.code;
|
|
55
|
+
if (code !== "EEXIST") throw error;
|
|
56
|
+
const observed = await readGuardToken(guardPath);
|
|
57
|
+
if (await removeStaleGuard(guardPath, staleMs, observed)) continue;
|
|
58
|
+
await sleep(delayMs);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!acquired) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Timed out acquiring lock for ${targetPath} after ${retries} attempts (~${Math.round(retries * delayMs / 1e3)}s)`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return await fn();
|
|
68
|
+
} finally {
|
|
69
|
+
const current = await readGuardToken(guardPath);
|
|
70
|
+
if (current === null || current === token) {
|
|
71
|
+
await rm(guardPath, { force: true }).catch(() => {
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function assertNotTornRead(filePath, content, sizeOnDisk) {
|
|
77
|
+
if (content.length === 0 && sizeOnDisk > 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Refusing to rewrite ${filePath}: read 0 bytes but the file is ${sizeOnDisk} bytes on disk (torn read from a concurrent non-atomic writer).`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function isJsonObject(value) {
|
|
84
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
var JsonConfigParseError = class extends Error {
|
|
87
|
+
/** Path of the file that failed to parse. */
|
|
88
|
+
filePath;
|
|
89
|
+
/**
|
|
90
|
+
* @param filePath - Path of the file that failed to parse
|
|
91
|
+
* @param reason - Parser message
|
|
92
|
+
*/
|
|
93
|
+
constructor(filePath, reason) {
|
|
94
|
+
super(`Refusing to rewrite ${filePath}: it is not a valid JSON object (${reason}).`);
|
|
95
|
+
this.name = "JsonConfigParseError";
|
|
96
|
+
this.filePath = filePath;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
async function updateJsonConfigFile(filePath, mutate) {
|
|
100
|
+
return withFileLock(filePath, async () => {
|
|
101
|
+
let config = {};
|
|
102
|
+
if (existsSync(filePath)) {
|
|
103
|
+
const raw = await readFile(filePath, "utf-8");
|
|
104
|
+
if (raw.length === 0) assertNotTornRead(filePath, raw, (await stat(filePath)).size);
|
|
105
|
+
if (raw.trim().length > 0) {
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(raw);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new JsonConfigParseError(
|
|
111
|
+
filePath,
|
|
112
|
+
err instanceof Error ? err.message : String(err)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (!isJsonObject(parsed)) {
|
|
116
|
+
throw new JsonConfigParseError(filePath, "top-level value is not an object");
|
|
117
|
+
}
|
|
118
|
+
config = parsed;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!mutate(config)) return false;
|
|
122
|
+
await writeFileAtomic({ path: filePath, content: `${JSON.stringify(config, null, 2)}
|
|
123
|
+
` });
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function applyHookConfigEdit(body, change) {
|
|
128
|
+
const index = change.path.at(-1);
|
|
129
|
+
if (change.value === void 0 && typeof index === "number") {
|
|
130
|
+
const tree = jsonc.parseTree(body);
|
|
131
|
+
const array = tree ? jsonc.findNodeAtLocation(tree, change.path.slice(0, -1)) : void 0;
|
|
132
|
+
const nodes = array?.type === "array" ? array.children : void 0;
|
|
133
|
+
const removed = nodes?.[index];
|
|
134
|
+
const previous = nodes?.[index - 1];
|
|
135
|
+
if (nodes && index === nodes.length - 1 && removed && previous) {
|
|
136
|
+
const scanner = jsonc.createScanner(body, true);
|
|
137
|
+
scanner.setPosition(previous.offset + previous.length);
|
|
138
|
+
scanner.scan();
|
|
139
|
+
if (body[scanner.getTokenOffset()] !== "," || scanner.getTokenLength() !== 1)
|
|
140
|
+
throw new Error("HOOK_CONFIG_EDIT_INVALID");
|
|
141
|
+
return jsonc.applyEdits(body, [
|
|
142
|
+
{ offset: scanner.getTokenOffset(), length: scanner.getTokenLength(), content: "" },
|
|
143
|
+
{ offset: removed.offset, length: removed.length, content: "" }
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return jsonc.applyEdits(
|
|
148
|
+
body,
|
|
149
|
+
jsonc.modify(body, change.path, change.value, { isArrayInsertion: change.insert })
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
async function editJsonConfigFile(filePath, edit) {
|
|
153
|
+
return withFileLock(filePath, async () => {
|
|
154
|
+
const raw = existsSync(filePath) ? (await readFileText({ path: filePath, maxBytes: 262144 })).content : "{}";
|
|
155
|
+
if (raw.length === 0 && existsSync(filePath))
|
|
156
|
+
assertNotTornRead(filePath, raw, (await stat(filePath)).size);
|
|
157
|
+
const errors = [];
|
|
158
|
+
const config = jsonc.parse(raw || "{}", errors);
|
|
159
|
+
if (errors.length || !config || typeof config !== "object" || Array.isArray(config)) {
|
|
160
|
+
throw new JsonConfigParseError(filePath, "invalid JSONC object");
|
|
161
|
+
}
|
|
162
|
+
const edits = edit(config);
|
|
163
|
+
if (edits.length === 0) return false;
|
|
164
|
+
let body = raw || "{}";
|
|
165
|
+
for (const change of edits) body = applyHookConfigEdit(body, change);
|
|
166
|
+
const outputErrors = [];
|
|
167
|
+
jsonc.parse(body, outputErrors);
|
|
168
|
+
if (outputErrors.length) throw new JsonConfigParseError(filePath, "invalid JSONC edit result");
|
|
169
|
+
const current = existsSync(filePath) ? (await readFileText({ path: filePath, maxBytes: 262144 })).content : "{}";
|
|
170
|
+
if (current !== raw) throw new Error("HOOK_CONFIG_CONCURRENT_EDIT");
|
|
171
|
+
await writeFileAtomic({ path: filePath, content: body.endsWith("\n") ? body : body + "\n" });
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async function readManagedJsonConfigFile(filePath) {
|
|
176
|
+
if (!existsSync(filePath)) return {};
|
|
177
|
+
const { content: raw } = await readFileText({ path: filePath, maxBytes: 262144 });
|
|
178
|
+
const errors = [];
|
|
179
|
+
const parsed = jsonc.parse(raw, errors);
|
|
180
|
+
if (errors.length || !parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
181
|
+
throw new JsonConfigParseError(filePath, "invalid JSONC object");
|
|
182
|
+
}
|
|
183
|
+
return parsed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export {
|
|
187
|
+
writeFileAtomic,
|
|
188
|
+
withFileLock,
|
|
189
|
+
assertNotTornRead,
|
|
190
|
+
JsonConfigParseError,
|
|
191
|
+
updateJsonConfigFile,
|
|
192
|
+
editJsonConfigFile,
|
|
193
|
+
readManagedJsonConfigFile
|
|
194
|
+
};
|
|
195
|
+
//# sourceMappingURL=chunk-D6CWC4BX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/fs/atomic.ts"],"sourcesContent":["/**\n * Atomic file writes and cross-process file locking.\n *\n * CAAMP mutates files that are shared by *every* project on the machine — most\n * critically `~/.agents/AGENTS.md`, which every `cleo init`, `cleo upgrade` and\n * `cleo doctor` run rewrites regardless of which project it was invoked from.\n * A plain `writeFile` on such a path is `open(O_TRUNC)` followed by one or more\n * `write(2)` calls: two processes interleaving there can leave a caller reading\n * a half-written file, and a reader racing a writer can observe a truncated\n * one.\n *\n * Two primitives remove that class of failure:\n *\n * - `writeFileAtomic` — the canonical tmp-then-rename primitive from\n * `@cleocode/core/tools/fs.js`. `rename(2)` within a filesystem is atomic, so\n * a concurrent reader sees either the whole old file or the whole new one,\n * never a mixture. It is re-exported here for callers already importing this\n * module; the definition lives in core, per the tools-vs-skills boundary.\n * - {@link withFileLock} — serialise a whole read-modify-write cycle across\n * processes via an `O_EXCL` guard file, so two writers cannot both read the\n * pre-state and then clobber each other's result. Generalised from the\n * bespoke copy that lived inline in `lock-utils.ts` (now a caller).\n *\n * @task T12051\n */\n\nimport { existsSync } from 'node:fs';\nimport { lstat, mkdir, open, readFile, rm, stat } from 'node:fs/promises';\nimport { dirname } from 'node:path';\nimport type {\n HookConfigEdit,\n HookConfigObject,\n} from '@cleocode/contracts/project-hook-delivery.js';\nimport type { WriteFileAtomic } from '@cleocode/contracts/tools/atomic';\nimport { writeFileAtomic as canonicalWriteFileAtomic, readFileText } from '@cleocode/core/tools/fs';\nimport * as jsonc from 'jsonc-parser';\n\n/** Alias the canonical primitive through its shared contract, preserving runtime identity. */\nexport const writeFileAtomic: WriteFileAtomic = canonicalWriteFileAtomic;\n\n/**\n * A guard file older than this is assumed to belong to a crashed process.\n *\n * Generous on purpose. The critical sections here are a read, a string\n * transform and an atomic write — milliseconds. Reclaiming after a short\n * interval does not speed anything up, it just makes it likelier that a\n * *live but descheduled* holder gets its guard stolen, which is a correctness\n * failure rather than a performance one.\n */\nconst DEFAULT_STALE_LOCK_MS = 30_000;\n\n/**\n * How many times {@link withFileLock} retries before giving up.\n *\n * 400 × 25 ms ≈ 10 s. The previous 40 × 25 ms ≈ 1 s budget expired under\n * exactly the multi-session contention the lock exists to handle.\n */\nconst DEFAULT_LOCK_RETRIES = 400;\n\n/** Delay between lock acquisition attempts, in milliseconds. */\nconst DEFAULT_LOCK_DELAY_MS = 25;\n\n/** Read only bounded regular lock records; an unverified guard is never reclaimed. */\nasync function readGuardToken(path: string): Promise<string | null> {\n try {\n const metadata = await lstat(path);\n if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error('HOOK_LOCK_INVALID');\n return (await readFileText({ path, maxBytes: 1024 })).content;\n } catch (error) {\n if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null;\n throw error;\n }\n}\n\n/** Resolve after `ms` milliseconds. */\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Options controlling {@link withFileLock}.\n *\n * @public\n */\nexport interface FileLockOptions {\n /**\n * Number of acquisition attempts before throwing.\n *\n * @defaultValue 400\n */\n retries?: number;\n /**\n * Delay between attempts, in milliseconds.\n *\n * @defaultValue 25\n */\n delayMs?: number;\n /**\n * Age at which an existing guard file is treated as abandoned and removed.\n *\n * @defaultValue 30000\n */\n staleMs?: number;\n}\n\n/**\n * Remove a guard file that is older than `staleMs`.\n *\n * @param guardPath - Path of the guard file\n * @param staleMs - Age beyond which the guard is considered abandoned\n * @param expectedToken - Token observed in the guard before waiting. The guard\n * is only reclaimed if it still carries this token, so a guard that was\n * released and re-acquired by someone else in the meantime is never removed.\n * @returns `true` if a stale guard was removed\n */\nasync function removeStaleGuard(\n guardPath: string,\n staleMs: number,\n expectedToken: string | null,\n): Promise<boolean> {\n try {\n const info = await stat(guardPath);\n if (Date.now() - info.mtimeMs <= staleMs) return false;\n\n // Re-read: only reclaim the *same* guard we decided was stale.\n const current = await readGuardToken(guardPath);\n if (expectedToken !== null && current !== null && current !== expectedToken) return false;\n\n await rm(guardPath, { force: true });\n return true;\n } catch {\n // Missing or unreadable — nothing to reclaim.\n }\n return false;\n}\n\n/**\n * Run `fn` while holding an exclusive cross-process lock on `targetPath`.\n *\n * The lock is a `<targetPath>.lock` guard file created with `O_EXCL`, which is\n * atomic on POSIX and on Windows. A guard left behind by a crashed process is\n * reclaimed once it exceeds `staleMs`.\n *\n * The guard is always released, including when `fn` throws.\n *\n * @param targetPath - Path being protected (the guard is a sibling of it)\n * @param fn - Work to perform while holding the lock\n * @param options - Retry, delay and staleness tuning\n * @returns Whatever `fn` returns\n * @throws Error if the lock cannot be acquired within `retries` attempts\n *\n * @example\n * ```typescript\n * const action = await withFileLock(agentsMd, async () => {\n * const before = await readFile(agentsMd, \"utf-8\");\n * await writeFileAtomic(agentsMd, transform(before));\n * return \"updated\";\n * });\n * ```\n *\n * @public\n */\nexport async function withFileLock<T>(\n targetPath: string,\n fn: () => Promise<T>,\n options: FileLockOptions = {},\n): Promise<T> {\n const retries = options.retries ?? DEFAULT_LOCK_RETRIES;\n const delayMs = options.delayMs ?? DEFAULT_LOCK_DELAY_MS;\n const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;\n const guardPath = `${targetPath}.lock`;\n\n // Fencing token. Written into the guard so release can verify the guard it\n // is about to remove is still OURS. Without this, a holder whose guard was\n // reclaimed as stale would delete the *next* holder's guard on the way out,\n // letting two callers run their critical sections concurrently.\n const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2, 12)}`;\n\n await mkdir(dirname(targetPath), { recursive: true });\n\n let acquired = false;\n for (let attempt = 0; attempt < retries && !acquired; attempt += 1) {\n try {\n // Write through the exclusively created descriptor so a replaced path\n // cannot redirect the token write into another file.\n const handle = await open(guardPath, 'wx');\n try {\n await handle.writeFile(token, 'utf-8');\n } finally {\n await handle.close();\n }\n acquired = true;\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code !== 'EEXIST') throw error;\n\n // A guard may be orphaned by a crashed process. Snapshot whose it is,\n // then only reclaim it if the very same one is still there and stale.\n const observed = await readGuardToken(guardPath);\n if (await removeStaleGuard(guardPath, staleMs, observed)) continue;\n await sleep(delayMs);\n }\n }\n\n if (!acquired) {\n throw new Error(\n `Timed out acquiring lock for ${targetPath} after ${retries} attempts ` +\n `(~${Math.round((retries * delayMs) / 1000)}s)`,\n );\n }\n\n try {\n return await fn();\n } finally {\n // Only release a guard that is still ours. If it was reclaimed as stale\n // and re-acquired by another caller, removing it here would revoke THEIR\n // lock.\n const current = await readGuardToken(guardPath);\n if (current === null || current === token) {\n await rm(guardPath, { force: true }).catch(() => {\n // Best-effort release — a stale guard is reclaimed by the next caller.\n });\n }\n }\n}\n\n/**\n * Throw if a read looks like it landed inside another process's\n * truncate-then-write window.\n *\n * `writeFileAtomic` makes *our* writes indivisible, but callers outside this\n * package still rewrite instruction files with a plain `writeFile`, which is\n * `open(O_TRUNC)` followed by `write(2)`. A read landing between those two\n * returns an empty string for a file that is not empty on disk. Reconciling\n * from that observation would replace every byte of the user's content with a\n * lone CAAMP block.\n *\n * Failing closed is the right trade: the caller retries or reports, and the\n * file is left exactly as it was.\n *\n * @param filePath - Path that was read, for the error message\n * @param content - What the read returned\n * @param sizeOnDisk - `stat().size` for the same path\n * @throws Error when `content` is empty but `sizeOnDisk` is greater than zero\n *\n * @example\n * ```typescript\n * const text = await readFile(p, \"utf-8\");\n * if (text.length === 0) assertNotTornRead(p, text, (await stat(p)).size);\n * ```\n *\n * @public\n */\nexport function assertNotTornRead(filePath: string, content: string, sizeOnDisk: number): void {\n if (content.length === 0 && sizeOnDisk > 0) {\n throw new Error(\n `Refusing to rewrite ${filePath}: read 0 bytes but the file is ${sizeOnDisk} bytes on ` +\n 'disk (torn read from a concurrent non-atomic writer).',\n );\n }\n}\n\n/** Whether a parsed JSON value is a plain object (not an array or `null`). */\nfunction isJsonObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * A JSON config file could not be parsed, so it was left untouched.\n *\n * @remarks\n * Thrown by {@link updateJsonConfigFile} instead of treating the file as empty.\n * The pre-T12385 provider adapters caught the parse error and started from\n * `{}`, so a torn read of `~/.claude/settings.json` (Claude Code rewriting it\n * at the same moment) replaced the user's permissions, env, model and hooks\n * with CLEO's entries alone.\n *\n * @example\n * ```typescript\n * try {\n * await updateJsonConfigFile(path, (config) => false);\n * } catch (err) {\n * if (err instanceof JsonConfigParseError) console.error(err.filePath);\n * }\n * ```\n *\n * @public\n */\nexport class JsonConfigParseError extends Error {\n /** Path of the file that failed to parse. */\n readonly filePath: string;\n\n /**\n * @param filePath - Path of the file that failed to parse\n * @param reason - Parser message\n */\n constructor(filePath: string, reason: string) {\n super(`Refusing to rewrite ${filePath}: it is not a valid JSON object (${reason}).`);\n this.name = 'JsonConfigParseError';\n this.filePath = filePath;\n }\n}\n\n/**\n * Read-modify-write a JSON object file under the cross-process lock, writing\n * atomically, and never replacing an unparseable file.\n *\n * @remarks\n * Composes the two existing primitives — {@link withFileLock} and core's\n * `writeFileAtomic` — for provider config files such as\n * `~/.claude/settings.json` that the provider itself also rewrites (T12385):\n *\n * - The whole read → mutate → write cycle holds the `<file>.lock` guard, so\n * two CLEO writers cannot both read the pre-state and clobber each other.\n * - The write is tmp-then-rename, so a concurrent reader (including the\n * provider) sees the whole old file or the whole new one.\n * - A missing file starts from `{}`. A file that exists but is empty on read\n * while non-empty on disk, is not valid JSON, or is not a JSON object throws\n * ({@link assertNotTornRead} / {@link JsonConfigParseError}) and the file is\n * left byte-identical.\n *\n * @param filePath - JSON file to update\n * @param mutate - Edits the parsed object in place; returns `true` when it\n * changed something that must be written\n * @returns Whether the file was written\n * @throws {@link JsonConfigParseError} when the existing file cannot be parsed\n * as a JSON object; the file is not modified\n *\n * @example\n * ```typescript\n * const wrote = await updateJsonConfigFile(settingsPath, (settings) => {\n * if (settings.enabled === true) return false;\n * settings.enabled = true;\n * return true;\n * });\n * ```\n *\n * @public\n */\nexport async function updateJsonConfigFile(\n filePath: string,\n mutate: (config: Record<string, unknown>) => boolean,\n): Promise<boolean> {\n return withFileLock(filePath, async () => {\n let config: Record<string, unknown> = {};\n if (existsSync(filePath)) {\n const raw = await readFile(filePath, 'utf-8');\n if (raw.length === 0) assertNotTornRead(filePath, raw, (await stat(filePath)).size);\n if (raw.trim().length > 0) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n throw new JsonConfigParseError(\n filePath,\n err instanceof Error ? err.message : String(err),\n );\n }\n if (!isJsonObject(parsed)) {\n throw new JsonConfigParseError(filePath, 'top-level value is not an object');\n }\n config = parsed;\n }\n }\n\n if (!mutate(config)) return false;\n await writeFileAtomic({ path: filePath, content: `${JSON.stringify(config, null, 2)}\\n` });\n return true;\n });\n}\n\nfunction applyHookConfigEdit(body: string, change: HookConfigEdit): string {\n const index = change.path.at(-1);\n if (change.value === undefined && typeof index === 'number') {\n const tree = jsonc.parseTree(body);\n const array = tree ? jsonc.findNodeAtLocation(tree, change.path.slice(0, -1)) : undefined;\n const nodes = array?.type === 'array' ? array.children : undefined;\n const removed = nodes?.[index];\n const previous = nodes?.[index - 1];\n if (nodes && index === nodes.length - 1 && removed && previous) {\n // jsonc-parser's last-element removal leaves the final character of a compact value.\n // Remove only the separator and owned node; retain interstitial team comments.\n const scanner = jsonc.createScanner(body, true);\n scanner.setPosition(previous.offset + previous.length);\n scanner.scan();\n if (body[scanner.getTokenOffset()] !== ',' || scanner.getTokenLength() !== 1)\n throw new Error('HOOK_CONFIG_EDIT_INVALID');\n return jsonc.applyEdits(body, [\n { offset: scanner.getTokenOffset(), length: scanner.getTokenLength(), content: '' },\n { offset: removed.offset, length: removed.length, content: '' },\n ]);\n }\n }\n return jsonc.applyEdits(\n body,\n jsonc.modify(body, change.path, change.value, { isArrayInsertion: change.insert }),\n );\n}\n\n/** Apply surgical JSONC edits inside the existing cross-process atomic writer. */\nexport async function editJsonConfigFile(\n filePath: string,\n edit: (config: HookConfigObject) => HookConfigEdit[],\n): Promise<boolean> {\n return withFileLock(filePath, async () => {\n const raw = existsSync(filePath)\n ? (await readFileText({ path: filePath, maxBytes: 262144 })).content\n : '{}';\n if (raw.length === 0 && existsSync(filePath))\n assertNotTornRead(filePath, raw, (await stat(filePath)).size);\n const errors: jsonc.ParseError[] = [];\n const config = jsonc.parse(raw || '{}', errors) as HookConfigObject;\n if (errors.length || !config || typeof config !== 'object' || Array.isArray(config)) {\n throw new JsonConfigParseError(filePath, 'invalid JSONC object');\n }\n const edits = edit(config);\n if (edits.length === 0) return false;\n let body = raw || '{}';\n for (const change of edits) body = applyHookConfigEdit(body, change);\n const outputErrors: jsonc.ParseError[] = [];\n jsonc.parse(body, outputErrors);\n if (outputErrors.length) throw new JsonConfigParseError(filePath, 'invalid JSONC edit result');\n // Providers may write without our lock. Refuse to overwrite an observed intervening edit.\n const current = existsSync(filePath)\n ? (await readFileText({ path: filePath, maxBytes: 262144 })).content\n : '{}';\n if (current !== raw) throw new Error('HOOK_CONFIG_CONCURRENT_EDIT');\n await writeFileAtomic({ path: filePath, content: body.endsWith('\\n') ? body : body + '\\n' });\n return true;\n });\n}\n\n/** Read a bounded JSONC object without mutation, including in dry-run inspections. */\nexport async function readManagedJsonConfigFile(filePath: string): Promise<HookConfigObject> {\n if (!existsSync(filePath)) return {};\n const { content: raw } = await readFileText({ path: filePath, maxBytes: 262144 });\n const errors: jsonc.ParseError[] = [];\n const parsed = jsonc.parse(raw, errors) as HookConfigObject;\n if (errors.length || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new JsonConfigParseError(filePath, 'invalid JSONC object');\n }\n return parsed;\n}\n"],"mappings":"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","names":[]}
|