sdocs-dev 1.15.0 → 1.19.2

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.
@@ -1,40 +1,39 @@
1
- // Per-file refresh of the SDocs agent block.
1
+ // SmallDocs skill install + legacy-block migration.
2
2
  //
3
- // Operates on AGENT_TARGETS in $HOME. Atomic writes via tmp + rename,
4
- // short-lived exclusive locks to avoid two `sdoc` runs stomping on each
5
- // other, and a backup file beside each agent file before we modify it.
6
- // Symlinks are skipped unless explicitly followed.
3
+ // One canonical SKILL.md lives at ~/.agents/skills/smalldocs/SKILL.md.
4
+ // Every other supported agent gets a relative symlink from its own skills
5
+ // directory into the canonical dir (single source of truth; one write
6
+ // updates every agent). Universal agents already discover ~/.agents/skills,
7
+ // so they are skipped. Windows falls back to a junction (absolute target),
8
+ // then a copy if symlinks are unavailable.
9
+ //
10
+ // On setup/refresh we also strip any recognised always-on block from the
11
+ // historical agent config files so the reference is not loaded twice.
7
12
 
8
13
  const fs = require('fs');
9
14
  const os = require('os');
10
15
  const path = require('path');
11
16
 
12
17
  const {
13
- AGENT_BLOCK_VERSION,
14
- AGENT_BLOCK_BODY,
15
- AGENT_BLOCK_LEGACY_OPEN,
16
- AGENT_TARGETS,
17
- formatAgentBlock,
18
+ SKILL_VERSION,
19
+ SKILL_NAME,
20
+ formatSkill,
21
+ readSkillVersion,
22
+ readSkillEdition,
23
+ canonicalSkillDir,
24
+ canonicalSkillFile,
25
+ resolveSkillAgents,
26
+ legacyBlockTargets,
18
27
  findBookendedBlock,
19
- refreshContent,
28
+ findLegacyBlock,
29
+ removeBlockContent,
20
30
  } = require('./agent-block');
21
31
 
22
32
  const { AGENT_CHANGES_URL } = require('./constants');
23
33
 
24
- function detectAgents() {
25
- const home = os.homedir();
26
- return AGENT_TARGETS
27
- .map(t => ({ ...t, dirPath: path.join(home, t.detectDir || t.dir), filePath: path.join(home, t.dir, t.file) }))
28
- .filter(t => fs.existsSync(t.dirPath));
29
- }
34
+ const IS_WIN = process.platform === 'win32';
30
35
 
31
- function fileHasBlock(filePath) {
32
- try {
33
- const content = fs.readFileSync(filePath, 'utf-8');
34
- return findBookendedBlock(content) !== null
35
- || content.includes(AGENT_BLOCK_LEGACY_OPEN);
36
- } catch (_) { return false; }
37
- }
36
+ // ── generic file helpers ───────────────────────────────────
38
37
 
39
38
  function isSymlink(filePath) {
40
39
  try { return fs.lstatSync(filePath).isSymbolicLink(); }
@@ -84,79 +83,318 @@ function acquireLock(filePath) {
84
83
  }
85
84
  }
86
85
 
87
- function writeBookendedBlock(filePath) {
88
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
89
- const block = formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY);
90
- if (!fs.existsSync(filePath)) {
91
- atomicWrite(filePath, block);
92
- return;
86
+ function copyDirRecursive(src, dest) {
87
+ fs.mkdirSync(dest, { recursive: true });
88
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
89
+ const s = path.join(src, entry.name);
90
+ const d = path.join(dest, entry.name);
91
+ if (entry.isDirectory()) copyDirRecursive(s, d);
92
+ else fs.copyFileSync(s, d);
93
93
  }
94
- const existing = fs.readFileSync(filePath, 'utf-8');
95
- const prefix = existing.endsWith('\n') ? '\n' : '\n\n';
96
- atomicWrite(filePath, existing + prefix + block);
97
94
  }
98
95
 
99
- // Refresh a single agent file.
100
- // Returns { path, name?, changed, fromVersion?, toVersion?, reason?, error? }.
101
- function refreshAgentFile(filePath, opts = {}) {
102
- if (!fs.existsSync(filePath)) return { path: filePath, changed: false, reason: 'absent' };
103
- if (isSymlink(filePath) && !opts.followSymlinks) return { path: filePath, changed: false, reason: 'symlink' };
96
+ // Compare filesystem identity, not just path spelling. Agent skill parents
97
+ // are sometimes symlinked to ~/.agents/skills; in that case two different
98
+ // path strings can name the exact same skill directory.
99
+ function pathsResolveSame(a, b) {
100
+ try { return fs.realpathSync(a) === fs.realpathSync(b); }
101
+ catch (_) {}
102
+
103
+ function withRealParent(p) {
104
+ const resolved = path.resolve(p);
105
+ try { return path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved)); }
106
+ catch (_) { return resolved; }
107
+ }
108
+ return withRealParent(a) === withRealParent(b);
109
+ }
104
110
 
105
- const release = acquireLock(filePath);
106
- if (!release) return { path: filePath, changed: false, reason: 'locked' };
111
+ // ── canonical skill ────────────────────────────────────────
107
112
 
113
+ function refreshCanonicalSkill(home, opts = {}) {
114
+ home = home || os.homedir();
115
+ const file = canonicalSkillFile(home);
116
+ let existing = null;
108
117
  try {
109
- const content = fs.readFileSync(filePath, 'utf-8');
110
- const result = refreshContent(content);
111
- if (!result.changed) return { path: filePath, changed: false, reason: result.reason };
112
- backupFile(filePath);
113
- atomicWrite(filePath, result.content);
118
+ const st = fs.lstatSync(file);
119
+ if (!st.isFile()) {
120
+ return {
121
+ changed: false, reason: 'conflict', path: file,
122
+ error: 'existing canonical SKILL.md is not a regular SmallDocs-managed file; left untouched',
123
+ };
124
+ }
125
+ existing = fs.readFileSync(file, 'utf-8');
126
+ } catch (e) {
127
+ if (e.code !== 'ENOENT') {
128
+ return { changed: false, reason: 'error', path: file, error: e.message };
129
+ }
130
+ }
131
+ const currentVersion = existing ? readSkillVersion(existing) : null;
132
+ const currentEdition = existing ? readSkillEdition(existing) : 'standard';
133
+ const requestedEdition = opts.edition === 'cloud' || opts.edition === 'standard'
134
+ ? opts.edition : null;
135
+ const desiredEdition = requestedEdition || currentEdition;
136
+ if (existing !== null && currentVersion === null) {
114
137
  return {
115
- path: filePath, changed: true,
116
- fromVersion: result.fromVersion, toVersion: result.toVersion,
138
+ changed: false, reason: 'conflict', path: file,
139
+ error: 'existing canonical SKILL.md is not managed by SmallDocs; left untouched',
117
140
  };
141
+ }
142
+ if (currentVersion === SKILL_VERSION && currentEdition === desiredEdition) {
143
+ return { changed: false, reason: 'current', path: file };
144
+ }
145
+ if (currentVersion !== null && currentVersion > SKILL_VERSION) {
146
+ return { changed: false, reason: 'newer', path: file };
147
+ }
148
+ fs.mkdirSync(path.dirname(file), { recursive: true });
149
+ atomicWrite(file, formatSkill(SKILL_VERSION, { cloud: desiredEdition === 'cloud' }));
150
+ return {
151
+ changed: true, path: file,
152
+ fromVersion: currentVersion || 0, toVersion: SKILL_VERSION,
153
+ fromEdition: currentEdition, toEdition: desiredEdition,
154
+ };
155
+ }
156
+
157
+ // ── symlink install ────────────────────────────────────────
158
+
159
+ // A real directory at an agent skills path is only safe to replace if it looks
160
+ // like one of our own copy-fallback installs (a prior Windows run, or a system
161
+ // where symlinks are unavailable): it must contain a SKILL.md carrying our
162
+ // sdocs-skill marker. Anything else is user content we must not destroy.
163
+ function looksLikeOurSkillDir(dir) {
164
+ try {
165
+ const content = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf-8');
166
+ return readSkillVersion(content) !== null;
167
+ } catch (_) { return false; }
168
+ }
169
+
170
+ // Ensure <linkDir> resolves to <canonicalDir>. Idempotent: a link already
171
+ // pointing at canonical is a no-op; a wrong/stale symlink is replaced. A real
172
+ // directory is replaced only if it is recognisably one of our copy-fallback
173
+ // installs. Other directories and files are left untouched (returns an error)
174
+ // so the user's own content is never clobbered. Returns { method } where
175
+ // method is symlink | copy | noop | error.
176
+ function ensureSkillLink(canonicalDir, linkDir) {
177
+ if (pathsResolveSame(canonicalDir, linkDir)) return { method: 'noop' };
178
+
179
+ try {
180
+ const st = fs.lstatSync(linkDir);
181
+ if (st.isSymbolicLink()) {
182
+ const tgt = fs.readlinkSync(linkDir);
183
+ const resolved = path.resolve(path.dirname(linkDir), tgt);
184
+ if (resolved === canonicalDir) return { method: 'noop' };
185
+ // Stale/wrong symlink: safe to remove (a link holds no user data).
186
+ fs.rmSync(linkDir, { recursive: true, force: true });
187
+ } else if (st.isDirectory()) {
188
+ if (!looksLikeOurSkillDir(linkDir)) {
189
+ return { method: 'error', error: 'exists and is not a SmallDocs skill directory; left untouched' };
190
+ }
191
+ fs.rmSync(linkDir, { recursive: true, force: true });
192
+ } else {
193
+ return { method: 'error', error: 'exists and is not a SmallDocs skill directory; left untouched' };
194
+ }
118
195
  } catch (e) {
119
- return { path: filePath, changed: false, error: e.message };
120
- } finally {
121
- release();
196
+ if (e.code !== 'ENOENT') return { method: 'error', error: e.message };
197
+ }
198
+
199
+ try { fs.mkdirSync(path.dirname(linkDir), { recursive: true }); }
200
+ catch (e) { return { method: 'error', error: e.message }; }
201
+
202
+ // Relative symlink on POSIX so it survives a home-dir move; junction on
203
+ // Windows requires an absolute target.
204
+ try {
205
+ let target = path.resolve(canonicalDir);
206
+ let linkParent = path.resolve(path.dirname(linkDir));
207
+ try { target = fs.realpathSync(canonicalDir); } catch (_) {}
208
+ try { linkParent = fs.realpathSync(path.dirname(linkDir)); } catch (_) {}
209
+ if (IS_WIN) {
210
+ fs.symlinkSync(target, linkDir, 'junction');
211
+ } else {
212
+ const rel = path.relative(linkParent, target);
213
+ fs.symlinkSync(rel, linkDir, 'dir');
214
+ }
215
+ return { method: 'symlink' };
216
+ } catch (_) {
217
+ // Permission / non-admin Windows: fall back to a real copy.
218
+ try {
219
+ copyDirRecursive(canonicalDir, linkDir);
220
+ return { method: 'copy' };
221
+ } catch (e) {
222
+ return { method: 'error', error: e.message };
223
+ }
224
+ }
225
+ }
226
+
227
+ function detectSkillAgents(home, env) {
228
+ home = home || os.homedir();
229
+ return resolveSkillAgents(home, env || process.env)
230
+ .filter(a => a.detect.some(p => { try { return fs.existsSync(p); } catch (_) { return false; } }));
231
+ }
232
+
233
+ // Evidence that the user previously opted into SmallDocs setup: a skill file
234
+ // already on disk (prior skill install), or a recognised always-on block in one
235
+ // of the historical config files (pre-skill install). maybeAutoRefresh uses
236
+ // this so a brand-new user is NOT auto-installed without consent - they get the
237
+ // interactive first-run prompt (or run `sdoc setup --yes`) instead.
238
+ function hasSetupEvidence(home, env) {
239
+ home = home || os.homedir();
240
+ env = env || process.env;
241
+ try {
242
+ const content = fs.readFileSync(canonicalSkillFile(home), 'utf-8');
243
+ if (readSkillVersion(content) !== null) return true;
244
+ } catch (_) {}
245
+ for (const t of legacyBlockTargets(home, env)) {
246
+ let content;
247
+ try { content = fs.readFileSync(t.file, 'utf-8'); } catch (_) { continue; }
248
+ if (findBookendedBlock(content) || findLegacyBlock(content)) return true;
249
+ }
250
+ return false;
251
+ }
252
+
253
+ // ── legacy block stripping ─────────────────────────────────
254
+
255
+ function stripLegacyBlocks(home, env) {
256
+ home = home || os.homedir();
257
+ const out = [];
258
+
259
+ function skippedTarget(t, reason) {
260
+ try {
261
+ const content = fs.readFileSync(t.file, 'utf-8');
262
+ const hasBlock = !!(findBookendedBlock(content) || findLegacyBlock(content));
263
+ return {
264
+ name: t.name, file: t.file, changed: false, reason,
265
+ error: hasBlock ? `${reason}; recognised legacy SmallDocs block left untouched` : undefined,
266
+ };
267
+ } catch (e) {
268
+ return { name: t.name, file: t.file, changed: false, reason, error: e.message };
269
+ }
270
+ }
271
+
272
+ for (const t of legacyBlockTargets(home, env || process.env)) {
273
+ if (!fs.existsSync(t.file)) { out.push({ name: t.name, file: t.file, changed: false, reason: 'absent' }); continue; }
274
+ if (isSymlink(t.file)) { out.push(skippedTarget(t, 'symlink')); continue; }
275
+
276
+ const release = acquireLock(t.file);
277
+ if (!release) { out.push(skippedTarget(t, 'locked')); continue; }
278
+ try {
279
+ const content = fs.readFileSync(t.file, 'utf-8');
280
+ const r = removeBlockContent(content);
281
+ if (!r.changed) { out.push({ name: t.name, file: t.file, changed: false, reason: r.reason }); continue; }
282
+ backupFile(t.file);
283
+ atomicWrite(t.file, r.content);
284
+ out.push({ name: t.name, file: t.file, changed: true, fromVersion: r.version });
285
+ } catch (e) {
286
+ out.push({ name: t.name, file: t.file, changed: false, error: e.message });
287
+ } finally {
288
+ release();
289
+ }
122
290
  }
291
+ return out;
123
292
  }
124
293
 
125
- function refreshAllAgentFiles(opts = {}) {
126
- const home = os.homedir();
127
- return AGENT_TARGETS.map(t => {
128
- const filePath = path.join(home, t.dir, t.file);
129
- return { name: t.name, ...refreshAgentFile(filePath, opts) };
130
- });
294
+ // ── orchestration ──────────────────────────────────────────
295
+
296
+ // One call that does the full sync: refresh canonical skill, symlink every
297
+ // detected non-universal agent, strip legacy blocks. Returns a result the
298
+ // setup flow reports and derives setup-state from.
299
+ function syncAgentSkill(opts = {}) {
300
+ const home = opts.home || os.homedir();
301
+ const env = opts.env || process.env;
302
+ const canonicalDir = canonicalSkillDir(home);
303
+
304
+ const result = {
305
+ canonical: refreshCanonicalSkill(home, { edition: opts.edition }),
306
+ links: [],
307
+ stripped: [],
308
+ errors: [],
309
+ };
310
+
311
+ if (result.canonical.error) {
312
+ result.errors.push(`${result.canonical.path}: ${result.canonical.error}`);
313
+ return result;
314
+ }
315
+
316
+ for (const agent of detectSkillAgents(home, env)) {
317
+ if (agent.universal) continue; // canonical copy already covers them
318
+ const linkDir = path.join(agent.dir, SKILL_NAME);
319
+ const r = ensureSkillLink(canonicalDir, linkDir);
320
+ result.links.push({ name: agent.displayName, path: linkDir, ...r });
321
+ if (r.error) result.errors.push(`${agent.name}: ${r.error}`);
322
+ }
323
+
324
+ // Keep the legacy instructions in place unless every required skill link
325
+ // succeeded. They are the safe fallback if an agent-specific path collides
326
+ // with user content or cannot be written.
327
+ if (result.errors.length) return result;
328
+
329
+ result.stripped = stripLegacyBlocks(home, env);
330
+ for (const s of result.stripped) {
331
+ if (s.error) result.errors.push(`${s.file}: ${s.error}`);
332
+ }
333
+
334
+ return result;
335
+ }
336
+
337
+ // True if a sync changed anything (skill written/upgraded, a link created,
338
+ // or a block stripped). Drives the "nothing to do" message and setup state.
339
+ function syncChanged(result) {
340
+ if (result.canonical && result.canonical.changed) return true;
341
+ if (result.links.some(l => l.method === 'symlink' || l.method === 'copy')) return true;
342
+ if (result.stripped.some(s => s.changed)) return true;
343
+ return false;
344
+ }
345
+
346
+ // Flatten a sync result into the {path, changed, error?} shape that
347
+ // implicitConsentState expects.
348
+ function toImplicitResults(result) {
349
+ const arr = [];
350
+ if (result.canonical) {
351
+ arr.push({ path: result.canonical.path, changed: !!result.canonical.changed, error: result.canonical.error });
352
+ }
353
+ for (const l of result.links) {
354
+ arr.push({ path: l.path, changed: l.method === 'symlink' || l.method === 'copy', error: l.error });
355
+ }
356
+ for (const s of result.stripped) {
357
+ arr.push({ path: s.file, changed: s.changed, error: s.error });
358
+ }
359
+ return arr;
131
360
  }
132
361
 
133
- function printRefreshSummary(results) {
134
- const changed = results.filter(r => r.changed);
135
- if (changed.length > 0) {
136
- const n = changed.length;
137
- console.log(`✓ SDocs agent block updated to v${AGENT_BLOCK_VERSION} in ${n} ${n === 1 ? 'file' : 'files'}`);
138
- console.log(` Changes: ${AGENT_CHANGES_URL}#v${AGENT_BLOCK_VERSION}`);
362
+ function printSyncSummary(result) {
363
+ if (result.canonical && result.canonical.changed) {
364
+ console.log(`\u2713 SmallDocs skill updated to v${SKILL_VERSION} at ${result.canonical.path}`);
365
+ console.log(` Changes: ${AGENT_CHANGES_URL}#v${SKILL_VERSION}`);
139
366
  }
140
- for (const r of results.filter(r => r.error)) {
141
- console.log(`! ${r.path}: ${r.error}`);
367
+ if (result.canonical && result.canonical.error) {
368
+ console.log(`! ${result.canonical.path}: ${result.canonical.error}`);
142
369
  }
143
- for (const r of results.filter(r => r.reason === 'symlink')) {
144
- console.log(`! ${r.path}: symlink, skipped (run \`sdoc setup --follow-symlinks\` to follow)`);
370
+ for (const l of result.links) {
371
+ if (l.method === 'symlink' || l.method === 'copy') {
372
+ console.log(`\u2713 ${l.name}: ${l.path} (${l.method})`);
373
+ }
374
+ if (l.error) console.log(`! ${l.name}: ${l.error}`);
145
375
  }
146
- for (const r of results.filter(r => r.reason === 'hand_edited')) {
147
- console.log(`! ${r.path}: local edits detected, run \`sdoc setup\` to refresh manually`);
376
+ for (const s of result.stripped) {
377
+ if (s.changed) console.log(`\u2713 removed old SmallDocs block from ${s.file}`);
378
+ if (s.error) console.log(`! ${s.file}: ${s.error}`);
379
+ if (s.reason === 'hand_edited') console.log(`! ${s.file}: local edits detected, left untouched`);
148
380
  }
149
381
  }
150
382
 
151
383
  module.exports = {
152
- detectAgents,
153
- fileHasBlock,
384
+ IS_WIN,
154
385
  isSymlink,
155
386
  atomicWrite,
156
387
  backupFile,
157
388
  acquireLock,
158
- writeBookendedBlock,
159
- refreshAgentFile,
160
- refreshAllAgentFiles,
161
- printRefreshSummary,
389
+ copyDirRecursive,
390
+ refreshCanonicalSkill,
391
+ ensureSkillLink,
392
+ looksLikeOurSkillDir,
393
+ detectSkillAgents,
394
+ hasSetupEvidence,
395
+ stripLegacyBlocks,
396
+ syncAgentSkill,
397
+ syncChanged,
398
+ toImplicitResults,
399
+ printSyncSummary,
162
400
  };
@@ -86,6 +86,7 @@ function valuesFor(model, fxGrid) {
86
86
  if (fx.kind === 'number') row.push(String(fx.value));
87
87
  else if (fx.kind === 'error') row.push(fx.code);
88
88
  else if (fx.kind === 'text') row.push(String(fx.value));
89
+ else if (fx.kind === 'boolean') row.push(fx.value ? 'TRUE' : 'FALSE');
89
90
  else row.push('');
90
91
  } else {
91
92
  row.push(cell.raw);
@@ -0,0 +1,85 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const credentials = require('./cloud-credentials');
5
+
6
+ function bindingsFile() { return path.join(credentials.cloudDir(), 'bindings.json'); }
7
+ function pendingFile() { return path.join(credentials.cloudDir(), 'pending.json'); }
8
+ function basesDir() { return path.join(credentials.cloudDir(), 'bases'); }
9
+
10
+ function read(file) {
11
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return {}; }
12
+ }
13
+
14
+ function canonical(file) {
15
+ const absolute = path.resolve(file);
16
+ try { return fs.realpathSync(absolute); } catch (_) { return absolute; }
17
+ }
18
+
19
+ function key(accountId, file) { return accountId + '\0' + canonical(file); }
20
+ function get(accountId, file) { return read(bindingsFile())[key(accountId, file)] || null; }
21
+
22
+ function set(accountId, file, binding) {
23
+ const values = read(bindingsFile());
24
+ values[key(accountId, file)] = { ...binding, account_id: accountId, path: canonical(file) };
25
+ credentials.atomicWrite(bindingsFile(), values);
26
+ return values[key(accountId, file)];
27
+ }
28
+
29
+ function getPending(accountId, file) { return read(pendingFile())[key(accountId, file)] || null; }
30
+ function setPending(accountId, file, value) {
31
+ const values = read(pendingFile());
32
+ values[key(accountId, file)] = value;
33
+ credentials.atomicWrite(pendingFile(), values);
34
+ }
35
+ function clearPending(accountId, file) {
36
+ const values = read(pendingFile());
37
+ delete values[key(accountId, file)];
38
+ credentials.atomicWrite(pendingFile(), values);
39
+ }
40
+
41
+ function operationKey(accountId, operation, resourceId) {
42
+ return accountId + '\0operation:' + operation + '\0' + resourceId;
43
+ }
44
+ function getOperationPending(accountId, operation, resourceId) {
45
+ return read(pendingFile())[operationKey(accountId, operation, resourceId)] || null;
46
+ }
47
+ function setOperationPending(accountId, operation, resourceId, value) {
48
+ const values = read(pendingFile());
49
+ values[operationKey(accountId, operation, resourceId)] = value;
50
+ credentials.atomicWrite(pendingFile(), values);
51
+ }
52
+ function clearOperationPending(accountId, operation, resourceId) {
53
+ const values = read(pendingFile());
54
+ delete values[operationKey(accountId, operation, resourceId)];
55
+ credentials.atomicWrite(pendingFile(), values);
56
+ }
57
+
58
+ function hash(content) { return crypto.createHash('sha256').update(content).digest('hex'); }
59
+
60
+ function baseFile(accountId, documentId, revisionId) {
61
+ const root = path.resolve(basesDir());
62
+ const file = path.resolve(root, String(accountId), String(documentId), String(revisionId) + '.md');
63
+ if (file !== root && !file.startsWith(root + path.sep)) return null;
64
+ return file;
65
+ }
66
+
67
+ function cacheBase(accountId, documentId, revisionId, content) {
68
+ const file = baseFile(accountId, documentId, revisionId);
69
+ if (!file) throw new Error('Cloud base path is invalid');
70
+ const dir = path.dirname(file);
71
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
72
+ fs.writeFileSync(file, content, { mode: 0o600 });
73
+ fs.chmodSync(file, 0o600);
74
+ return file;
75
+ }
76
+
77
+ function readBase(accountId, documentId, revisionId) {
78
+ const file = baseFile(accountId, documentId, revisionId);
79
+ if (!file) return null;
80
+ try { return fs.readFileSync(file, 'utf8'); } catch (_) { return null; }
81
+ }
82
+
83
+ module.exports = { bindingsFile, pendingFile, canonical, get, set, getPending, setPending,
84
+ clearPending, getOperationPending, setOperationPending, clearOperationPending, hash, cacheBase,
85
+ readBase };