bi-agent-kit 0.1.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.
@@ -0,0 +1,109 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+
5
+ export async function readFileIfExists(filePath) {
6
+ try {
7
+ return await fs.readFile(filePath, "utf8");
8
+ } catch (err) {
9
+ if (err.code === "ENOENT") return null;
10
+ throw err;
11
+ }
12
+ }
13
+
14
+ export class SymlinkRefusedError extends Error {
15
+ constructor(componentPath) {
16
+ super(
17
+ "Refusing to operate on " + componentPath +
18
+ " because it is a symbolic link (or junction). This can be used to escape the project " +
19
+ "directory and read or overwrite arbitrary files. Remove the symlink and retry with a " +
20
+ "real directory/file if this is expected."
21
+ );
22
+ this.name = "SymlinkRefusedError";
23
+ this.componentPath = componentPath;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Walks each path component from `stopDir` down to `filePath` (inclusive) and throws
29
+ * SymlinkRefusedError if any existing component is a symbolic link. On Windows, directory
30
+ * junctions are reported as symbolic links by fs.lstat, so this also catches junction attacks.
31
+ * Components that do not exist yet are skipped (nothing to follow).
32
+ */
33
+ export async function assertNoSymlinkedAncestors(filePath, stopDir) {
34
+ const resolvedFile = path.resolve(filePath);
35
+ const resolvedStop = path.resolve(stopDir);
36
+
37
+ const relative = path.relative(resolvedStop, resolvedFile);
38
+ const segments =
39
+ relative === "" || relative.startsWith("..") ? [resolvedFile] : relative.split(path.sep);
40
+
41
+ let current = relative === "" || relative.startsWith("..") ? null : resolvedStop;
42
+ const componentsToCheck = [];
43
+ if (current !== null) {
44
+ componentsToCheck.push(current);
45
+ for (const segment of segments) {
46
+ current = path.join(current, segment);
47
+ componentsToCheck.push(current);
48
+ }
49
+ } else {
50
+ componentsToCheck.push(resolvedFile);
51
+ }
52
+
53
+ for (const componentPath of componentsToCheck) {
54
+ let stats;
55
+ try {
56
+ stats = await fs.lstat(componentPath);
57
+ } catch (err) {
58
+ if (err.code === "ENOENT") continue;
59
+ throw err;
60
+ }
61
+ if (stats.isSymbolicLink()) {
62
+ throw new SymlinkRefusedError(componentPath);
63
+ }
64
+ }
65
+ }
66
+
67
+ export async function atomicWriteFile(filePath, contents) {
68
+ const dir = path.dirname(filePath);
69
+ await fs.mkdir(dir, { recursive: true });
70
+ const suffix = crypto.randomBytes(6).toString("hex");
71
+ const tmpPath = path.join(dir, "." + path.basename(filePath) + "." + suffix + ".tmp");
72
+ await fs.writeFile(tmpPath, contents, "utf8");
73
+ await fs.rename(tmpPath, filePath);
74
+ }
75
+
76
+ const BACKUP_DIR_NAME = ".bi-agent-kit-backups";
77
+
78
+ function backupPathFor(filePath) {
79
+ const backupDir = path.join(path.dirname(filePath), BACKUP_DIR_NAME);
80
+ return { backupDir, backupPath: path.join(backupDir, path.basename(filePath)) };
81
+ }
82
+
83
+ async function ensureBackupDirIgnored(backupDir) {
84
+ const gitignorePath = path.join(backupDir, ".gitignore");
85
+ const existing = await readFileIfExists(gitignorePath);
86
+ if (existing === null) {
87
+ await atomicWriteFile(gitignorePath, "*\n");
88
+ }
89
+ }
90
+
91
+ export function normalizePathForCompare(filePath) {
92
+ const resolved = path.resolve(filePath);
93
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
94
+ }
95
+
96
+ export async function backupIfMissing(filePath) {
97
+ const { backupDir, backupPath } = backupPathFor(filePath);
98
+ try {
99
+ await fs.access(backupPath);
100
+ return false;
101
+ } catch {
102
+ // no existing backup, proceed
103
+ }
104
+ const original = await readFileIfExists(filePath);
105
+ if (original === null) return false;
106
+ await ensureBackupDirIgnored(backupDir);
107
+ await atomicWriteFile(backupPath, original);
108
+ return true;
109
+ }
@@ -0,0 +1,537 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { resolveTargets } from "./targets.js";
5
+ import { detectExistingTargets } from "./detect.js";
6
+ import { addServerToTarget, removeServerFromTarget, CollisionError } from "./merge-config.js";
7
+ import { readManifest, removeManifestEntry, MANIFEST_FILENAME } from "./manifest.js";
8
+ import { acquireLock } from "./lock.js";
9
+ import { readFileIfExists, normalizePathForCompare } from "./atomic-fs.js";
10
+ import { hashValue } from "./hash.js";
11
+ import { readJsonServers } from "./json-adapter.js";
12
+ import { readTomlServers } from "./toml-adapter.js";
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url));
15
+ const templatesDir = path.join(here, "..", "templates");
16
+ const TEMPLATE_FILES = ["powerbi-mcp.json", "dataverse-mcp.json", "pac-cli-mcp.json", "fabric-mcp.json", "azure-mcp.json", "dbt-mcp.json", "sqlserver-mcp.json", "snowflake-mcp.json", "postgres-mcp.json"];
17
+
18
+ const INSTANCE_NAME_PATTERN = /^[a-z0-9-]+$/i;
19
+
20
+ export async function loadTemplates() {
21
+ const templates = [];
22
+ for (const file of TEMPLATE_FILES) {
23
+ const text = await fs.readFile(path.join(templatesDir, file), "utf8");
24
+ templates.push(JSON.parse(text));
25
+ }
26
+ return templates;
27
+ }
28
+
29
+ /**
30
+ * Parses a server spec string of the form "templateId" or "templateId:instanceName".
31
+ * instanceName must match /^[a-z0-9-]+$/i. Returns { templateId, instanceName, serverId },
32
+ * where serverId is templateId when there is no instance, else templateId + "-" + instanceName.
33
+ */
34
+ export function parseServerSpec(spec) {
35
+ const colonIndex = spec.indexOf(":");
36
+ if (colonIndex === -1) {
37
+ return { templateId: spec, instanceName: null, serverId: spec };
38
+ }
39
+ const templateId = spec.slice(0, colonIndex);
40
+ const instanceName = spec.slice(colonIndex + 1);
41
+ if (!INSTANCE_NAME_PATTERN.test(instanceName)) {
42
+ throw new Error(
43
+ "Invalid instance name \"" + instanceName + "\" in server spec \"" + spec +
44
+ "\": instance names must match /^[a-z0-9-]+$/i."
45
+ );
46
+ }
47
+ return { templateId, instanceName, serverId: templateId + "-" + instanceName };
48
+ }
49
+
50
+ export async function getDetectedTargetGroups({ dir, homedir, platform }) {
51
+ const resolved = resolveTargets(dir, homedir, platform);
52
+ return await detectExistingTargets(resolved);
53
+ }
54
+
55
+ export async function getCurrentSelections({ dir }) {
56
+ const manifest = await readManifest(dir);
57
+ if (!manifest) return [];
58
+ return manifest.entries.map((e) => ({
59
+ absPath: e.absPath,
60
+ serverId: e.serverKey,
61
+ templateId: e.templateId || e.serverKey,
62
+ }));
63
+ }
64
+
65
+ const PLACEHOLDER_TOKEN_PATTERN = /REPLACE_[A-Z0-9_]+/g;
66
+
67
+ /**
68
+ * Finds every REPLACE_ placeholder token occurring anywhere in the config,
69
+ * one entry per token occurrence (a single string may contain several tokens).
70
+ *
71
+ * Returns entries shaped: { path, value, token }
72
+ * - path: array path to the string within the config (as before)
73
+ * - value: the FULL original string at that path (unmodified, for context)
74
+ * - token: the exact matched marker text for this occurrence,
75
+ * e.g. "REPLACE_WITH_YOUR_DATABASE"
76
+ */
77
+ export function collectPlaceholders(value, pathPrefix) {
78
+ const prefix = pathPrefix || [];
79
+ const found = [];
80
+ if (typeof value === "string") {
81
+ const matches = value.match(PLACEHOLDER_TOKEN_PATTERN);
82
+ if (matches) {
83
+ for (const token of matches) {
84
+ found.push({ path: prefix, value, token });
85
+ }
86
+ }
87
+ return found;
88
+ }
89
+ if (Array.isArray(value)) {
90
+ value.forEach((item, index) => {
91
+ found.push(...collectPlaceholders(item, prefix.concat(index)));
92
+ });
93
+ return found;
94
+ }
95
+ if (value && typeof value === "object") {
96
+ for (const key of Object.keys(value)) {
97
+ found.push(...collectPlaceholders(value[key], prefix.concat(key)));
98
+ }
99
+ return found;
100
+ }
101
+ return found;
102
+ }
103
+
104
+ function getAtPath(target, pathSegments) {
105
+ let cursor = target;
106
+ for (const segment of pathSegments) {
107
+ cursor = cursor[segment];
108
+ }
109
+ return cursor;
110
+ }
111
+
112
+ function setAtPath(target, pathSegments, newValue) {
113
+ let cursor = target;
114
+ for (let i = 0; i < pathSegments.length - 1; i++) {
115
+ cursor = cursor[pathSegments[i]];
116
+ }
117
+ cursor[pathSegments[pathSegments.length - 1]] = newValue;
118
+ }
119
+
120
+ function spliceFirstTokenOccurrence(str, token, replacement) {
121
+ const index = str.indexOf(token);
122
+ if (index === -1) return str;
123
+ return str.slice(0, index) + replacement + str.slice(index + token.length);
124
+ }
125
+
126
+ /**
127
+ * Applies answers to a config, splicing each answer's replacement value into
128
+ * only the first remaining occurrence of its exact token within the string
129
+ * at answer.path. Multiple answers may target the same path (a string with
130
+ * several tokens) and are applied in order, each independently.
131
+ *
132
+ * answers: array of { path, token, value }
133
+ */
134
+ export function applyPlaceholderValues(config, answers) {
135
+ const cloned = JSON.parse(JSON.stringify(config));
136
+ for (const answer of answers) {
137
+ const current = getAtPath(cloned, answer.path);
138
+ const updated = spliceFirstTokenOccurrence(current, answer.token, answer.value);
139
+ setAtPath(cloned, answer.path, updated);
140
+ }
141
+ return cloned;
142
+ }
143
+
144
+ function humanizePlaceholder(token) {
145
+ const marker = token.indexOf("REPLACE_");
146
+ const raw = token.slice(marker + "REPLACE_".length);
147
+ const withoutWith = raw.startsWith("WITH_") ? raw.slice("WITH_".length) : raw;
148
+ return withoutWith.replace(/_/g, " ").trim().toLowerCase();
149
+ }
150
+
151
+ export function describePlaceholder(placeholderPath, placeholderToken) {
152
+ return "Enter a value for " + humanizePlaceholder(placeholderToken) + " (" + placeholderPath.join(".") + ")";
153
+ }
154
+
155
+ /**
156
+ * Merges newSelections with previousSelections, keyed by absPath + '::' + serverId.
157
+ * When prune is false (the default, additive semantics), returns the union of
158
+ * newSelections and previousSelections -- previous entries not re-listed in
159
+ * newSelections are preserved, and on key collision the newSelections entry wins
160
+ * (so a configOverride carried on the new entry flows through unchanged).
161
+ *
162
+ * When prune is true, returns newSelections plus any previous selection whose
163
+ * absPath is outside scopePaths (out-of-scope entries are preserved verbatim,
164
+ * so a scoped `--targets` prune cannot remove entries belonging to files
165
+ * outside that scope). scopePaths is a Set of absPaths, compared with
166
+ * normalizePathForCompare (case-insensitive on win32); pass scopePaths as
167
+ * null/undefined for the unscoped full-prune behavior (returns newSelections
168
+ * as-is, dropping every previous entry not re-listed).
169
+ */
170
+ export function mergeWithPrevious({ previousSelections, newSelections, prune, scopePaths }) {
171
+ if (!prune) {
172
+ const key = (s) => s.absPath + '::' + s.serverId;
173
+ const merged = new Map();
174
+ for (const selection of previousSelections) {
175
+ merged.set(key(selection), selection);
176
+ }
177
+ for (const selection of newSelections) {
178
+ merged.set(key(selection), selection);
179
+ }
180
+ return Array.from(merged.values());
181
+ }
182
+ if (!scopePaths) return newSelections;
183
+ const normalizedScope = new Set(Array.from(scopePaths).map(normalizePathForCompare));
184
+ const outOfScope = previousSelections.filter(
185
+ (s) => !normalizedScope.has(normalizePathForCompare(s.absPath))
186
+ );
187
+ return newSelections.concat(outOfScope);
188
+ }
189
+
190
+ export function computeDiff({ previousSelections, newSelections }) {
191
+ const key = (s) => s.absPath + "::" + s.serverId;
192
+ const newKeys = new Set(newSelections.map(key));
193
+ const previousKeys = new Set(previousSelections.map(key));
194
+ const toAdd = newSelections.filter((s) => !previousKeys.has(key(s)));
195
+ const toRemove = previousSelections.filter((s) => !newKeys.has(key(s)));
196
+ return { toAdd, toRemove };
197
+ }
198
+
199
+ function readExistingServers(group, fileText) {
200
+ if (fileText === null) return {};
201
+ try {
202
+ return group.shape === "toml"
203
+ ? readTomlServers(fileText)
204
+ : readJsonServers(fileText, group.rootKey);
205
+ } catch {
206
+ return {};
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Resolves a server key found in a target file (e.g. "dataverse" or "dataverse-dev")
212
+ * back to the template that owns it, matching either the bare template id or the
213
+ * "templateId-instanceName" form used for named instances.
214
+ */
215
+ function matchTemplateForServerKey(serverKey, templates) {
216
+ const exact = templates.find((t) => t.id === serverKey);
217
+ if (exact) return exact;
218
+ return templates.find((t) => serverKey.startsWith(t.id + "-")) || null;
219
+ }
220
+
221
+ export async function findExternallyManagedEntries({ dir, groups, templates }) {
222
+ const manifest = await readManifest(dir);
223
+ const ownedKeys = new Set(
224
+ (manifest ? manifest.entries : []).map((e) => e.absPath + "::" + e.serverKey)
225
+ );
226
+ const externallyManaged = [];
227
+ for (const group of groups) {
228
+ const fileText = await readFileIfExists(group.absPath);
229
+ const existingServers = readExistingServers(group, fileText);
230
+ for (const serverKey of Object.keys(existingServers)) {
231
+ const key = group.absPath + "::" + serverKey;
232
+ if (ownedKeys.has(key)) continue;
233
+ const template = matchTemplateForServerKey(serverKey, templates);
234
+ if (!template) continue;
235
+ externallyManaged.push({ absPath: group.absPath, serverId: serverKey, templateId: template.id });
236
+ }
237
+ }
238
+ return externallyManaged;
239
+ }
240
+
241
+ export async function applySelections({ dir, homedir, platform, templates, selections, dryRun }) {
242
+ const allGroups = resolveTargets(dir, homedir, platform);
243
+ const previousSelections = await getCurrentSelections({ dir });
244
+ const { toAdd, toRemove } = computeDiff({ previousSelections, newSelections: selections });
245
+
246
+ if (dryRun) {
247
+ const additions = toAdd.map((s) => ({ ...s, action: "add", status: "dry-run" }));
248
+ const removals = toRemove.map((s) => ({ ...s, action: "remove", status: "dry-run" }));
249
+ return additions.concat(removals);
250
+ }
251
+
252
+ const lockPath = path.join(dir, ".kae-bi-kit.lock");
253
+ const release = await acquireLock(lockPath);
254
+ const report = [];
255
+ try {
256
+ for (const selection of toAdd) {
257
+ const group = allGroups.find((g) => g.absPath === selection.absPath);
258
+ const templateId = selection.templateId || selection.serverId;
259
+ const template = templates.find((t) => t.id === templateId);
260
+ if (!group || !template) {
261
+ report.push({ ...selection, action: "add", status: "skipped-unknown" });
262
+ continue;
263
+ }
264
+ try {
265
+ const result = await addServerToTarget({
266
+ dir,
267
+ resolvedTarget: group,
268
+ serverKey: selection.serverId,
269
+ serverValue: selection.configOverride || template.config,
270
+ templateId: template.id,
271
+ });
272
+ report.push({ ...selection, action: "add", status: result.status });
273
+ } catch (err) {
274
+ if (err instanceof CollisionError) {
275
+ report.push({ ...selection, action: "add", status: "collision", message: err.message });
276
+ } else {
277
+ report.push({ ...selection, action: "add", status: "error", message: err.message });
278
+ }
279
+ }
280
+ }
281
+
282
+ for (const selection of toRemove) {
283
+ const group = allGroups.find((g) => g.absPath === selection.absPath);
284
+ if (!group) {
285
+ await removeManifestEntry(dir, selection.absPath, selection.serverId);
286
+ report.push({ ...selection, action: "remove", status: "pruned-unresolvable" });
287
+ continue;
288
+ }
289
+ try {
290
+ const result = await removeServerFromTarget({
291
+ dir,
292
+ resolvedTarget: group,
293
+ serverKey: selection.serverId,
294
+ });
295
+ report.push({ ...selection, action: "remove", status: result.status });
296
+ } catch (err) {
297
+ report.push({ ...selection, action: "remove", status: "error", message: err.message });
298
+ }
299
+ }
300
+ } finally {
301
+ await release();
302
+ }
303
+ return report;
304
+ }
305
+
306
+ export async function listInstalled({ dir }) {
307
+ const manifest = await readManifest(dir);
308
+ if (!manifest) return [];
309
+ return manifest.entries.map((e) => ({
310
+ absPath: e.absPath,
311
+ serverId: e.serverKey,
312
+ templateId: e.templateId || e.serverKey,
313
+ targetIds: e.targetIds,
314
+ installedAt: e.installedAt,
315
+ }));
316
+ }
317
+
318
+ function instanceSuffixFor(serverKey, templateId) {
319
+ if (serverKey === templateId) return "";
320
+ const prefix = templateId + "-";
321
+ if (serverKey.startsWith(prefix)) {
322
+ return " (" + serverKey.slice(prefix.length) + ")";
323
+ }
324
+ return "";
325
+ }
326
+
327
+ /**
328
+ * Returns the manifest entries enriched with display/lookup information:
329
+ * { absPath, serverId, templateId, label, hasPlaceholders }
330
+ * - label: template label + optional " (instanceName)" suffix + " -> " + target path
331
+ * - hasPlaceholders: whether the template config itself still contains REPLACE_ tokens
332
+ */
333
+ export async function getInstalledEntries({ dir, templates }) {
334
+ const manifest = await readManifest(dir);
335
+ if (!manifest) return [];
336
+ return manifest.entries.map((e) => {
337
+ const templateId = e.templateId || e.serverKey;
338
+ const template = templates.find((t) => t.id === templateId);
339
+ const templateLabel = template ? template.label : templateId;
340
+ const suffix = instanceSuffixFor(e.serverKey, templateId);
341
+ const label = templateLabel + suffix + " -> " + e.absPath;
342
+ const hasPlaceholders = template ? collectPlaceholders(template.config).length > 0 : false;
343
+ return {
344
+ absPath: e.absPath,
345
+ serverId: e.serverKey,
346
+ templateId,
347
+ label,
348
+ hasPlaceholders,
349
+ };
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Reconstructs the "templateId" or "templateId:instanceName" spec string for
355
+ * a manifest entry from its templateId + serverId, the inverse of
356
+ * parseServerSpec.
357
+ */
358
+ function specFor(templateId, serverId) {
359
+ if (serverId === templateId) return templateId;
360
+ const prefix = templateId + "-";
361
+ if (serverId.startsWith(prefix)) {
362
+ return templateId + ":" + serverId.slice(prefix.length);
363
+ }
364
+ return templateId;
365
+ }
366
+
367
+ /**
368
+ * Returns true if outPath (resolved) collides with the manifest, the manifest
369
+ * backup file, or any resolved target group's absPath -- case-insensitively
370
+ * on win32. Used to refuse `export --out` writes that would clobber the
371
+ * manifest or a live target config file instead of writing a portable export
372
+ * document to it.
373
+ */
374
+ export function isProtectedExportPath({ dir, outPath, groups }) {
375
+ const manifestPath = path.join(dir, MANIFEST_FILENAME);
376
+ const protectedPaths = [manifestPath, manifestPath + ".bak", ...groups.map((g) => g.absPath)];
377
+ const normalizedOut = normalizePathForCompare(outPath);
378
+ return protectedPaths.some((p) => normalizePathForCompare(p) === normalizedOut);
379
+ }
380
+
381
+ /**
382
+ * Builds a portable, machine-independent export document from the manifest:
383
+ * { version: 1, note, servers: [{ spec, targetIds }] }. Only the spec string
384
+ * (templateId or templateId:instanceName) and the logical targetIds are
385
+ * included -- never absPaths (those are machine-specific) and never
386
+ * configOverride/placeholder/secret values, since the manifest itself never
387
+ * stores answered placeholder values either (they live only in the target
388
+ * config files). Entries sharing the same spec across multiple absPaths on
389
+ * this machine are folded into one doc entry with the union of their
390
+ * targetIds.
391
+ */
392
+ export async function exportConfig({ dir, templates }) {
393
+ const installed = await listInstalled({ dir });
394
+ const validTemplateIds = new Set(templates.map((t) => t.id));
395
+ const bySpec = new Map();
396
+ for (const entry of installed) {
397
+ if (!validTemplateIds.has(entry.templateId)) continue;
398
+ const spec = specFor(entry.templateId, entry.serverId);
399
+ if (!bySpec.has(spec)) bySpec.set(spec, new Set());
400
+ for (const targetId of entry.targetIds || []) {
401
+ bySpec.get(spec).add(targetId);
402
+ }
403
+ }
404
+ const servers = Array.from(bySpec.entries()).map(([spec, targetIdSet]) => ({
405
+ spec,
406
+ targetIds: Array.from(targetIdSet),
407
+ }));
408
+ return {
409
+ version: 1,
410
+ note:
411
+ "Placeholder values and secrets are never exported. On import, any REPLACE_ tokens are " +
412
+ "re-prompted interactively, or left as literal placeholders (with a warning) in non-interactive mode.",
413
+ servers,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Resolves an export document (see exportConfig) against this machine's
419
+ * detected target groups, building additive selections ready to hand to
420
+ * mergeWithPrevious/applySelections. Each doc entry's targetIds are resolved
421
+ * independently against groups -- a targetId not detected on this machine is
422
+ * recorded in `skipped` (as { spec, targetId }) rather than throwing, so a
423
+ * partially-portable doc still imports what it can.
424
+ *
425
+ * Throws if doc is missing or its version is not the supported 1, or if any
426
+ * spec string is malformed (via parseServerSpec).
427
+ */
428
+ export function buildSelectionsFromConfig({ doc, groups }) {
429
+ if (!doc || typeof doc !== "object" || doc.version !== 1) {
430
+ throw new Error("Unsupported or missing config document version. Expected version 1.");
431
+ }
432
+ const selectionsByKey = new Map();
433
+ const skipped = [];
434
+ for (const server of doc.servers || []) {
435
+ const spec = parseServerSpec(server.spec);
436
+ for (const targetId of server.targetIds || []) {
437
+ const group = groups.find((g) => g.targetIds.includes(targetId));
438
+ if (!group) {
439
+ skipped.push({ spec: server.spec, targetId });
440
+ continue;
441
+ }
442
+ // A shared-file targetId (e.g. claude-code and copilot-cli-project both
443
+ // resolving to .mcp.json) yields one doc entry per targetId but the same
444
+ // (absPath, serverId) selection -- dedupe here so callers (init --prune
445
+ // in particular) never see the same key twice in one selections list.
446
+ const key = group.absPath + "::" + spec.serverId;
447
+ if (!selectionsByKey.has(key)) {
448
+ selectionsByKey.set(key, { absPath: group.absPath, serverId: spec.serverId, templateId: spec.templateId });
449
+ }
450
+ }
451
+ }
452
+ return { selections: Array.from(selectionsByKey.values()), skipped };
453
+ }
454
+
455
+ /**
456
+ * True if the live value currently stored under entry.serverKey in the target
457
+ * file no longer hashes to entry.contentHash -- i.e. it was hand-edited
458
+ * outside bi-agent-kit since it was last written. A key that is missing
459
+ * entirely from the live file is not considered drift here (nothing to
460
+ * compare against; addServerToTarget/reconfigureEntry will simply write it).
461
+ */
462
+ async function computeEntryDrift({ group, entry }) {
463
+ const fileText = await readFileIfExists(group.absPath);
464
+ const currentServers = readExistingServers(group, fileText);
465
+ const currentValue = currentServers[entry.serverKey];
466
+ if (currentValue === undefined) return false;
467
+ return hashValue(currentValue) !== entry.contentHash;
468
+ }
469
+
470
+ /**
471
+ * Reports whether an installed entry has drifted (been hand-edited outside
472
+ * bi-agent-kit) since it was last written, without mutating anything. Meant
473
+ * to be called by the CLI's reconfigure command BEFORE prompting for new
474
+ * placeholder values, so a user who declines to overwrite drifted config
475
+ * isn't asked questions first. Returns false (no drift) if the entry, or its
476
+ * target group, can't be resolved -- reconfigureEntry itself is the source of
477
+ * truth for those error cases.
478
+ */
479
+ export async function getEntryDrift({ dir, homedir, platform, absPath, serverId }) {
480
+ const manifest = await readManifest(dir);
481
+ const entry = manifest
482
+ ? manifest.entries.find((e) => e.absPath === absPath && e.serverKey === serverId)
483
+ : undefined;
484
+ if (!entry) return false;
485
+
486
+ const allGroups = resolveTargets(dir, homedir, platform);
487
+ const group = allGroups.find((g) => g.absPath === absPath);
488
+ if (!group) return false;
489
+
490
+ return await computeEntryDrift({ group, entry });
491
+ }
492
+
493
+ /**
494
+ * Re-applies an installed entry in place with a new configOverride: resolves the
495
+ * target group for absPath, finds the template by the manifest entry templateId
496
+ * (falling back to serverKey for old manifests), and re-adds it via addServerToTarget
497
+ * (owned-key overwrite is already permitted there), returning its status object
498
+ * plus a `drift` flag reporting whether the live value had already diverged
499
+ * from the manifest's recorded contentHash before this call overwrote it.
500
+ */
501
+ export async function reconfigureEntry({ dir, homedir, platform, templates, absPath, serverId, configOverride }) {
502
+ const manifest = await readManifest(dir);
503
+ const entry = manifest
504
+ ? manifest.entries.find((e) => e.absPath === absPath && e.serverKey === serverId)
505
+ : undefined;
506
+ if (!entry) {
507
+ throw new Error(
508
+ "No installed entry found for \"" + serverId + "\" at " + absPath + ". Install it first."
509
+ );
510
+ }
511
+
512
+ const allGroups = resolveTargets(dir, homedir, platform);
513
+ const group = allGroups.find((g) => g.absPath === absPath);
514
+ if (!group) {
515
+ throw new Error("Could not resolve a target group for " + absPath + ".");
516
+ }
517
+
518
+ const templateId = entry.templateId || entry.serverKey;
519
+ const template = templates.find((t) => t.id === templateId);
520
+ if (!template) {
521
+ throw new Error(
522
+ "Could not find template \"" + templateId + "\" for installed entry \"" + serverId + "\"."
523
+ );
524
+ }
525
+
526
+ const drift = await computeEntryDrift({ group, entry });
527
+
528
+ const result = await addServerToTarget({
529
+ dir,
530
+ resolvedTarget: group,
531
+ serverKey: serverId,
532
+ serverValue: configOverride,
533
+ templateId,
534
+ });
535
+
536
+ return { ...result, drift };
537
+ }
package/lib/detect.js ADDED
@@ -0,0 +1,27 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ async function pathExists(candidatePath) {
5
+ try {
6
+ await fs.access(candidatePath);
7
+ return true;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ export async function detectExistingTargets(resolvedTargets) {
14
+ const results = [];
15
+ for (const target of resolvedTargets) {
16
+ const fileExists = await pathExists(target.absPath);
17
+ if (target.rootLevel) {
18
+ if (fileExists) results.push(target);
19
+ continue;
20
+ }
21
+ const dirExists = await pathExists(path.dirname(target.absPath));
22
+ if (fileExists || dirExists) {
23
+ results.push(target);
24
+ }
25
+ }
26
+ return results;
27
+ }