@oh-agent/dsh-mcp-manager 0.0.0-stage → 0.1.1

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/lib/scan.js ADDED
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Read-only discovery: walk every declared client location, decode what is
3
+ * there, and produce the matrix the UI renders.
4
+ *
5
+ * The scan never writes and never throws: a missing file is a client that is
6
+ * not installed, an unparsable file is a warning attached to that location, and
7
+ * one broken client never hides the rest. Everything a client cannot express
8
+ * becomes an explicit capability gap rather than a silently dropped field.
9
+ */
10
+
11
+ import { CLIENTS, resolveClientPaths } from './adapters.js';
12
+ import { decodeEntry, definitionHash, dshMountName, redactServer } from './canonical.js';
13
+ import { readContainer, readTomlServers, readYamlServers } from './surgical.js';
14
+ import { mcpEntries, decodeDshEntry } from './dsh.js';
15
+ import { readDshPatchServers } from './dsh-patch.js';
16
+
17
+ const READ_KINDS = new Set(['json', 'toml', 'yaml']);
18
+
19
+ /** Read one location and decode its servers. */
20
+ function readLocation(store, client, location) {
21
+ if (!READ_KINDS.has(client.format)) return { ok: true, exists: false, servers: {}, empty: true };
22
+ const read = store.readFile(location.path);
23
+ if (!read.ok) return { ok: false, exists: true, servers: {}, error: read.error };
24
+ if (!read.exists) return { ok: true, exists: false, servers: {}, hash: null, text: '' };
25
+ if (!read.text.trim()) return { ok: true, exists: true, servers: {}, hash: read.hash, text: read.text, empty: true };
26
+
27
+ let raw;
28
+ try {
29
+ if (client.format === 'toml') raw = readTomlServers(read.text);
30
+ else if (client.format === 'yaml') raw = readYamlServers(read.text);
31
+ else {
32
+ const container = readContainer(read.text, client.containerKey);
33
+ if (!container.ok) return { ok: false, exists: true, servers: {}, error: container.error, hash: read.hash, text: read.text };
34
+ raw = container.value ?? {};
35
+ }
36
+ } catch (error) {
37
+ return { ok: false, exists: true, servers: {}, error: String(error && error.message ? error.message : error), hash: read.hash, text: read.text };
38
+ }
39
+
40
+ const servers = {};
41
+ for (const [name, entry] of Object.entries(raw ?? {})) {
42
+ if (!entry || typeof entry !== 'object') continue;
43
+ const dialect = { ...client.dialect };
44
+ // Gemini/Qwen: `httpUrl` means streamable HTTP, `url` means SSE. Decode both.
45
+ if (client.id === 'gemini' || client.id === 'qwen') {
46
+ if (typeof entry.httpUrl === 'string' && entry.httpUrl) dialect.urlKey = 'httpUrl';
47
+ else if (typeof entry.url === 'string' && entry.url) { dialect.urlKey = 'url'; dialect.typeMap = { ...dialect.typeMap }; entry.__sseHint = 'sse'; }
48
+ }
49
+ servers[name] = decodeEntry(name, entry, dialect);
50
+ }
51
+ return { ok: true, exists: true, servers, hash: read.hash, text: read.text, mtimeMs: read.mtimeMs };
52
+ }
53
+
54
+ /**
55
+ * Decode DSH's own MCP servers: live rows plus anything still pending in the
56
+ * patch layer. Live rows win on a name clash — once the Loader has reconciled,
57
+ * the patch item and the live row are the same server.
58
+ */
59
+ function readDsh(configEditor, profileDir) {
60
+ const rows = mcpEntries(configEditor);
61
+ const servers = {};
62
+ for (const row of rows) {
63
+ const config = { ...row.inherited, ...row.override };
64
+ const decoded = decodeDshEntry(row.entry, config);
65
+ decoded.rowId = row.entry?.id;
66
+ servers[decoded.name] = decoded;
67
+ }
68
+ let pending = 0;
69
+ for (const item of readDshPatchServers(profileDir)) {
70
+ if (servers[item.name]) continue;
71
+ servers[item.name] = item;
72
+ pending += 1;
73
+ }
74
+ return { ok: true, exists: rows.length + pending > 0, servers, rows, pending };
75
+ }
76
+
77
+ /**
78
+ * Fold DSH mount names back onto the display names the user sees.
79
+ *
80
+ * A server mounted in the harness lives under `dshMountName(displayName)`,
81
+ * because `serverName` may only use `[A-Za-z0-9_-]` — a CJK name has to be
82
+ * mapped. Without this pass the mounted row appears as a SEVENTH server named
83
+ * `API-c2f500c4` next to `表单管理系统 - API 文档`, and the display-named node has
84
+ * no `dsh` target at all, so its switch reads "off" while the server is in fact
85
+ * mounted.
86
+ *
87
+ * Two sources are inverted into the mapping, because either can be the only one
88
+ * that knows the display name:
89
+ *
90
+ * 1. Every server another client declares. `dshMountName` is a pure function
91
+ * of the display name, so it can be run forward over those names and the
92
+ * result compared.
93
+ * 2. The ownership ledger, which stores the display name beside the mount name
94
+ * when we wrote the row. This covers a server mounted from a client config
95
+ * the user has since deleted, and a row mounted by hand.
96
+ *
97
+ * A row whose mount name matches neither keeps its own name — inventing a
98
+ * prettier name for someone else's hand-written row would hide the truth.
99
+ */
100
+ function foldDshNames(matrix, sources, store) {
101
+ const mounts = new Map();
102
+ for (const [key, node] of matrix.entries()) {
103
+ if (!node.targets.some((item) => item.clientId !== 'dsh')) continue;
104
+ const mount = dshMountName(key);
105
+ if (mount !== key && !mounts.has(mount)) mounts.set(mount, key);
106
+ }
107
+ try {
108
+ const owned = typeof store?.allOwnership === 'function' ? store.allOwnership() : null;
109
+ for (const [mountName, bucket] of Object.entries(owned ?? {})) {
110
+ const targets = bucket?.targets ?? {};
111
+ for (const value of Object.values(targets)) {
112
+ const display = value?.displayName;
113
+ if (typeof display === 'string' && display && display !== mountName && !mounts.has(mountName)) {
114
+ mounts.set(mountName, display);
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ } catch {
120
+ /* A ledger we cannot read must not break the scan; the file-based fold
121
+ above still covers every server a client declares. */
122
+ }
123
+
124
+ for (const [key, node] of [...matrix.entries()]) {
125
+ const display = mounts.get(key);
126
+ if (!display || display === key) continue;
127
+ if (!matrix.has(display)) {
128
+ /* Nothing else declared this display name, so rename the node in place
129
+ rather than moving targets onto a node that does not exist. */
130
+ matrix.delete(key);
131
+ node.name = display;
132
+ for (const item of node.targets) item.mountedAs = key;
133
+ matrix.set(display, node);
134
+ continue;
135
+ }
136
+ // Move this node's targets onto the display-named node.
137
+ for (const item of node.targets) {
138
+ item.mountedAs = key;
139
+ matrix.get(display).targets.push(item);
140
+ }
141
+ matrix.delete(key);
142
+ }
143
+
144
+ for (const source of sources) {
145
+ if (source.clientId !== 'dsh') continue;
146
+ for (const item of source.servers) {
147
+ const display = mounts.get(item.name);
148
+ if (display && display !== item.name) {
149
+ item.mountName = item.name;
150
+ item.name = display;
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Build the full snapshot.
158
+ *
159
+ * projectRoots — workspace directories whose project-scope files to include
160
+ * reveal — return credential literals instead of masked values
161
+ *
162
+ * Two projections are produced from one read, because they answer different
163
+ * questions and recomputing them separately would let them disagree:
164
+ * servers — one node per server name, listing every place it appears
165
+ * sources — one node per config FILE (the directory-first view), listing the
166
+ * servers that file holds. This is what the page groups by, since
167
+ * "which directory holds which MCP" is a per-file question.
168
+ */
169
+ export function scanAll({ store, configEditor, projectRoots = [], reveal = false, profileDir }) {
170
+ const warnings = [];
171
+ const clients = [];
172
+ const matrix = new Map();
173
+ const locations = [];
174
+ const sources = [];
175
+
176
+ const registerServer = (clientId, clientLabel, scope, path, server, fileHash) => {
177
+ if (!matrix.has(server.name)) {
178
+ matrix.set(server.name, { name: server.name, targets: [], canonical: server });
179
+ }
180
+ const node = matrix.get(server.name);
181
+ const target = {
182
+ clientId,
183
+ clientLabel,
184
+ scope,
185
+ path,
186
+ hash: fileHash,
187
+ fingerprint: definitionHash(server),
188
+ transport: server.transport,
189
+ enabled: server.enabled,
190
+ // Only a Loader row can be switched: the switch unmounts the DSH plugin.
191
+ entryId: server.entryId ?? null,
192
+ pending: server.pending === true,
193
+ server: redactServer(server, reveal),
194
+ };
195
+ node.targets.push(target);
196
+ // Prefer the stdio/DSH definition as the canonical shape when several exist.
197
+ if (clientId === 'dsh' || (node.canonical.transport !== 'stdio' && server.transport === 'stdio')) node.canonical = server;
198
+ };
199
+
200
+ for (const client of CLIENTS) {
201
+ const entry = {
202
+ id: client.id,
203
+ label: client.label,
204
+ order: client.order,
205
+ format: client.format,
206
+ managed: client.managed === true,
207
+ readOnly: client.readOnly === true,
208
+ caps: client.caps,
209
+ note: client.note,
210
+ installed: false,
211
+ locations: [],
212
+ serverCount: 0,
213
+ };
214
+ if (client.managed) {
215
+ const dsh = readDsh(configEditor, profileDir);
216
+ entry.installed = true;
217
+ entry.serverCount = Object.keys(dsh.servers).length;
218
+ if (dsh.pending) entry.pendingCount = dsh.pending;
219
+ entry.locations.push({ scope: 'profile', path: '(profile composition)', exists: dsh.exists, serverCount: entry.serverCount });
220
+ const sourceServers = Object.values(dsh.servers);
221
+ for (const server of sourceServers) registerServer(client.id, client.label, 'profile', '(profile composition)', server, null);
222
+ sources.push({
223
+ id: `${client.id}:profile:(profile composition)`,
224
+ clientId: client.id,
225
+ clientLabel: client.label,
226
+ scope: 'profile',
227
+ path: '(profile composition)',
228
+ format: 'composition',
229
+ managed: true,
230
+ readOnly: false,
231
+ switchable: true,
232
+ exists: true,
233
+ serverCount: sourceServers.length,
234
+ pendingCount: dsh.pending,
235
+ servers: sourceServers.map((server) => ({
236
+ name: server.name,
237
+ transport: server.transport,
238
+ enabled: server.enabled,
239
+ entryId: server.entryId ?? null,
240
+ pending: server.pending === true,
241
+ secrets: (server.secrets ?? []).map((item) => item.key),
242
+ timeoutMs: server.timeoutMs,
243
+ server: redactServer(server, reveal),
244
+ })),
245
+ });
246
+ clients.push(entry);
247
+ continue;
248
+ }
249
+ const paths = resolveClientPaths(client, projectRoots[0]);
250
+ for (const location of paths) {
251
+ if (location.scope === 'project' && projectRoots.length === 0) continue;
252
+ const targets = location.scope === 'project' ? projectRoots.map((root) => resolveClientPaths(client, root).find((item) => item.scope === 'project' && item.path.endsWith(location.relative))) : [location];
253
+ for (const target of targets) {
254
+ if (!target) continue;
255
+ const read = readLocation(store, client, target);
256
+ entry.locations.push({ scope: target.scope, path: target.path, exists: read.exists, serverCount: Object.keys(read.servers ?? {}).length, hash: read.hash ?? null });
257
+ locations.push({ clientId: client.id, scope: target.scope, path: target.path, hash: read.hash ?? null, exists: read.exists });
258
+ if (!read.ok) warnings.push({ code: 'error.scan.parse', clientId: client.id, path: target.path, error: read.error });
259
+ if (read.exists) entry.installed = true;
260
+ const names = Object.keys(read.servers ?? {});
261
+ entry.serverCount += names.length;
262
+ if (read.exists) {
263
+ sources.push({
264
+ id: `${client.id}:${target.scope}:${target.path}`,
265
+ clientId: client.id,
266
+ clientLabel: client.label,
267
+ scope: target.scope,
268
+ path: target.path,
269
+ format: client.format,
270
+ managed: false,
271
+ readOnly: client.readOnly === true,
272
+ // We never toggle another tool's own file: that would edit the
273
+ // original directory the user asked us to leave alone.
274
+ switchable: false,
275
+ exists: true,
276
+ hash: read.hash ?? null,
277
+ empty: read.empty === true,
278
+ serverCount: names.length,
279
+ servers: names.map((name) => {
280
+ const server = read.servers[name];
281
+ return {
282
+ name,
283
+ transport: server.transport,
284
+ enabled: server.enabled,
285
+ entryId: null,
286
+ pending: false,
287
+ secrets: (server.secrets ?? []).map((item) => item.key),
288
+ timeoutMs: server.timeoutMs,
289
+ server: redactServer(server, reveal),
290
+ };
291
+ }),
292
+ });
293
+ }
294
+ for (const name of names) {
295
+ registerServer(client.id, client.label, target.scope, target.path, read.servers[name], read.hash);
296
+ }
297
+ }
298
+ }
299
+ clients.push(entry);
300
+ }
301
+
302
+ // Reconcile harness mount names with the display names other clients use,
303
+ // BEFORE the servers projection is built: the fold rewrites matrix keys.
304
+ foldDshNames(matrix, sources, store);
305
+
306
+ const servers = [...matrix.values()].map((node) => {
307
+ const clientIds = new Set(node.targets.map((target) => target.clientId));
308
+ const canonical = node.canonical;
309
+ const drift = [];
310
+ for (const target of node.targets) {
311
+ const other = matrix.get(node.name);
312
+ if (!other) continue;
313
+ if (target.fingerprint !== definitionHash(canonical)) drift.push({ clientId: target.clientId, scope: target.scope, path: target.path });
314
+ }
315
+ return {
316
+ name: node.name,
317
+ transport: canonical.transport,
318
+ enabled: canonical.enabled,
319
+ secrets: canonical.secrets ?? [],
320
+ timeoutMs: canonical.timeoutMs,
321
+ clientCount: clientIds.size,
322
+ clients: [...clientIds],
323
+ targets: node.targets,
324
+ drift,
325
+ canonical: redactServer(canonical, reveal),
326
+ };
327
+ }).sort((a, b) => b.clientCount - a.clientCount || a.name.localeCompare(b.name));
328
+
329
+ // Which secrets are literal on disk, for the security banner.
330
+ const literalSecrets = [];
331
+ for (const node of matrix.values()) {
332
+ for (const item of node.canonical.secrets ?? []) {
333
+ const target = node.targets[0];
334
+ literalSecrets.push({
335
+ server: node.name,
336
+ where: item.where,
337
+ key: item.key,
338
+ clientId: target?.clientId ?? null,
339
+ path: target?.path ?? null,
340
+ });
341
+ }
342
+ }
343
+
344
+ return {
345
+ clients,
346
+ servers,
347
+ sources,
348
+ locations,
349
+ warnings,
350
+ literalSecrets,
351
+ scannedAt: new Date().toISOString(),
352
+ };
353
+ }
package/lib/store.js ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Durable state for the manager: the ownership ledger, backups, and the only
3
+ * code path that touches client files on disk.
4
+ *
5
+ * Two rules make writes safe on files we do not own:
6
+ *
7
+ * 1. A write is a compare-and-set. The scan records a fingerprint of the file
8
+ * it read; the write refuses when the file has changed since, unless the
9
+ * caller explicitly forces it. Clients keep these files open and rewrite
10
+ * them on exit (Cursor and VS Code are the notorious ones), so an
11
+ * unconditional write is a lost-update bug waiting to happen.
12
+ * 2. Every successful write leaves a timestamped backup plus a ledger entry
13
+ * carrying the previous bytes' hash, so a bad edit is one click from undo.
14
+ *
15
+ * The ledger lives in DSH_HOME so it survives plugin reinstalls and never sits
16
+ * inside a project the user might commit.
17
+ */
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync, readdirSync, rmSync, statSync } from 'node:fs';
21
+ import { dirname, join } from 'node:path';
22
+
23
+ function hashOf(text) {
24
+ return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
25
+ }
26
+
27
+ function stamp() {
28
+ return new Date().toISOString().replace(/[:.]/g, '-');
29
+ }
30
+
31
+ export function createStore({ root, backupsToKeep = 20 }) {
32
+ const ledgerPath = join(root, 'ledger.json');
33
+ const backupsDir = join(root, 'backups');
34
+
35
+ function ensureDirs() {
36
+ mkdirSync(root, { recursive: true });
37
+ mkdirSync(backupsDir, { recursive: true });
38
+ }
39
+
40
+ function readLedger() {
41
+ try {
42
+ const raw = readFileSync(ledgerPath, 'utf8');
43
+ const parsed = JSON.parse(raw);
44
+ return {
45
+ version: 1,
46
+ servers: parsed.servers && typeof parsed.servers === 'object' ? parsed.servers : {},
47
+ writes: Array.isArray(parsed.writes) ? parsed.writes : [],
48
+ };
49
+ } catch {
50
+ return { version: 1, servers: {}, writes: [] };
51
+ }
52
+ }
53
+
54
+ function writeLedger(ledger) {
55
+ ensureDirs();
56
+ const temp = `${ledgerPath}.tmp`;
57
+ writeFileSync(temp, JSON.stringify(ledger, null, 2), 'utf8');
58
+ renameSync(temp, ledgerPath);
59
+ }
60
+
61
+ /** Read one file, reporting absence distinctly from an empty file. */
62
+ function readFile(path) {
63
+ try {
64
+ const text = readFileSync(path, 'utf8');
65
+ return { ok: true, exists: true, text, hash: hashOf(text), mtimeMs: statSync(path).mtimeMs };
66
+ } catch (error) {
67
+ if (error && error.code === 'ENOENT') return { ok: true, exists: false, text: '', hash: null, mtimeMs: 0 };
68
+ return { ok: false, exists: false, text: '', hash: null, error: String(error && error.message ? error.message : error) };
69
+ }
70
+ }
71
+
72
+ /** Copy the current bytes aside before a mutation. */
73
+ function backup(path, text) {
74
+ if (text === undefined || text === null) return null;
75
+ ensureDirs();
76
+ const safe = path.replace(/[\\/:*?"<>|]+/g, '_');
77
+ const file = join(backupsDir, `${safe}.${stamp()}.bak`);
78
+ writeFileSync(file, text, 'utf8');
79
+ pruneBackups(safe);
80
+ return file;
81
+ }
82
+
83
+ function pruneBackups(safe) {
84
+ try {
85
+ const prefix = `${safe}.`;
86
+ const matches = readdirSync(backupsDir).filter((name) => name.startsWith(prefix) && name.endsWith('.bak')).sort();
87
+ const excess = matches.length - backupsToKeep;
88
+ for (let index = 0; index < excess; index += 1) {
89
+ try { rmSync(join(backupsDir, matches[index]), { force: true }); } catch { /* best effort */ }
90
+ }
91
+ } catch { /* backup pruning never blocks a write */ }
92
+ }
93
+
94
+ /**
95
+ * Atomically replace a file. The temp file lives in the same directory so the
96
+ * rename stays on one volume; a reader never observes a half-written config.
97
+ */
98
+ function writeFileAtomic(path, text) {
99
+ mkdirSync(dirname(path), { recursive: true });
100
+ const temp = `${path}.mcp-manager-${process.pid}-${Date.now()}.tmp`;
101
+ writeFileSync(temp, text, 'utf8');
102
+ try {
103
+ renameSync(temp, path);
104
+ } catch (error) {
105
+ try { rmSync(temp, { force: true }); } catch { /* ignore */ }
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Apply one guarded mutation.
112
+ *
113
+ * expectedHash — the fingerprint the caller believes is on disk (null when
114
+ * the file is expected to be absent); a mismatch is a 409.
115
+ * mutate(text) — pure text→text; returns the new text, or `{ text }`.
116
+ */
117
+ function mutate({ path, clientId, scope, serverName, expectedHash, force = false, mutate, label = '' }) {
118
+ const current = readFile(path);
119
+ if (!current.ok) return { ok: false, code: 'error.file.read', error: current.error };
120
+ const actualHash = current.hash;
121
+ if (!force && expectedHash !== undefined && expectedHash !== actualHash) {
122
+ return {
123
+ ok: false,
124
+ code: 'error.file.conflict',
125
+ error: 'file changed on disk since it was read',
126
+ conflict: { path, expected: expectedHash, actual: actualHash, mtimeMs: current.mtimeMs },
127
+ };
128
+ }
129
+ let next;
130
+ try {
131
+ next = mutate(current.text);
132
+ } catch (error) {
133
+ return { ok: false, code: 'error.write.render', error: String(error && error.message ? error.message : error) };
134
+ }
135
+ if (next && typeof next === 'object' && 'ok' in next && next.ok === false) return next;
136
+ const nextText = typeof next === 'string' ? next : next && typeof next.text === 'string' ? next.text : current.text;
137
+ if (nextText === current.text) {
138
+ return { ok: true, changed: false, path, hash: actualHash, backup: null };
139
+ }
140
+ const backupFile = current.exists ? backup(path, current.text) : null;
141
+ try {
142
+ writeFileAtomic(path, nextText);
143
+ } catch (error) {
144
+ return { ok: false, code: 'error.file.write', error: String(error && error.message ? error.message : error), backup: backupFile };
145
+ }
146
+ const nextHash = hashOf(nextText);
147
+ const ledger = readLedger();
148
+ ledger.writes.unshift({
149
+ at: new Date().toISOString(),
150
+ path,
151
+ clientId,
152
+ scope,
153
+ serverName,
154
+ label,
155
+ beforeHash: actualHash,
156
+ afterHash: nextHash,
157
+ backup: backupFile,
158
+ previousExisted: current.exists,
159
+ });
160
+ ledger.writes = ledger.writes.slice(0, 500);
161
+ writeLedger(ledger);
162
+ return { ok: true, changed: true, path, hash: nextHash, backup: backupFile };
163
+ }
164
+
165
+ function listBackups(limit = 50) {
166
+ try {
167
+ return readdirSync(backupsDir)
168
+ .filter((name) => name.endsWith('.bak'))
169
+ .sort()
170
+ .reverse()
171
+ .slice(0, limit)
172
+ .map((name) => {
173
+ const full = join(backupsDir, name);
174
+ const info = statSync(full);
175
+ return { name, path: full, size: info.size, at: info.mtime.toISOString() };
176
+ });
177
+ } catch {
178
+ return [];
179
+ }
180
+ }
181
+
182
+ /** Restore a backup file over its original path, itself backed up first. */
183
+ function restore(backupFile, targetPath) {
184
+ if (!existsSync(backupFile)) return { ok: false, code: 'error.backup.missing', error: 'backup file no longer exists' };
185
+ const text = readFileSync(backupFile, 'utf8');
186
+ const current = readFile(targetPath);
187
+ if (current.exists) backup(targetPath, current.text);
188
+ try {
189
+ writeFileAtomic(targetPath, text);
190
+ } catch (error) {
191
+ return { ok: false, code: 'error.file.write', error: String(error && error.message ? error.message : error) };
192
+ }
193
+ return { ok: true, path: targetPath, hash: hashOf(text) };
194
+ }
195
+
196
+ /* ---------------------------------------------------------- ownership -- */
197
+
198
+ /**
199
+ * Record that a server exists in a file we manage. `fingerprint` is the
200
+ * canonical server's hash, so a later scan can tell "the user edited this in
201
+ * the client" apart from "we wrote it and nothing moved".
202
+ *
203
+ * `displayName` is the name the user sees, when it differs from the ledger key
204
+ * (a harness mount is keyed by its `[A-Za-z0-9_-]` mount name). Keeping it lets
205
+ * a later scan fold the mounted row back under the name the page renders, even
206
+ * after the client that first declared it is gone.
207
+ */
208
+ function recordOwnership({ serverName, clientId, path, scope, fingerprint, source = 'managed', displayName }) {
209
+ const ledger = readLedger();
210
+ const bucket = ledger.servers[serverName] ?? (ledger.servers[serverName] = { targets: {}, createdAt: new Date().toISOString() });
211
+ bucket.targets[`${clientId}:${scope}:${path}`] = {
212
+ clientId, path, scope, fingerprint, source, displayName, lastWritten: new Date().toISOString(),
213
+ };
214
+ writeLedger(ledger);
215
+ return bucket;
216
+ }
217
+
218
+ function forgetOwnership({ serverName, clientId, scope, path }) {
219
+ const ledger = readLedger();
220
+ const bucket = ledger.servers[serverName];
221
+ if (!bucket) return false;
222
+ delete bucket.targets[`${clientId}:${scope}:${path}`];
223
+ if (Object.keys(bucket.targets).length === 0) delete ledger.servers[serverName];
224
+ writeLedger(ledger);
225
+ return true;
226
+ }
227
+
228
+ function ownershipOf(serverName) {
229
+ const ledger = readLedger();
230
+ return ledger.servers[serverName] ?? null;
231
+ }
232
+
233
+ function allOwnership() {
234
+ return readLedger().servers;
235
+ }
236
+
237
+ function recentWrites(limit = 50) {
238
+ return readLedger().writes.slice(0, limit);
239
+ }
240
+
241
+ return {
242
+ root,
243
+ ensureDirs,
244
+ readFile,
245
+ backup,
246
+ writeFileAtomic,
247
+ mutate,
248
+ listBackups,
249
+ restore,
250
+ recordOwnership,
251
+ forgetOwnership,
252
+ ownershipOf,
253
+ allOwnership,
254
+ recentWrites,
255
+ hashOf,
256
+ };
257
+ }