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.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/bin/cli.js +1054 -0
- package/lib/atomic-fs.js +109 -0
- package/lib/commands.js +537 -0
- package/lib/detect.js +27 -0
- package/lib/doctor.js +247 -0
- package/lib/hash.js +18 -0
- package/lib/installers.js +77 -0
- package/lib/json-adapter.js +45 -0
- package/lib/lock.js +55 -0
- package/lib/manifest.js +70 -0
- package/lib/merge-config.js +141 -0
- package/lib/prereq-check.js +14 -0
- package/lib/targets.js +216 -0
- package/lib/toml-adapter.js +79 -0
- package/package.json +57 -0
- package/templates/azure-mcp.json +11 -0
- package/templates/dataverse-mcp.json +11 -0
- package/templates/dbt-mcp.json +14 -0
- package/templates/fabric-mcp.json +11 -0
- package/templates/pac-cli-mcp.json +12 -0
- package/templates/postgres-mcp.json +14 -0
- package/templates/powerbi-mcp.json +11 -0
- package/templates/snowflake-mcp.json +12 -0
- package/templates/sqlserver-mcp.json +14 -0
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readManifest, ManifestCorruptError } from "./manifest.js";
|
|
3
|
+
import { resolveTargets } from "./targets.js";
|
|
4
|
+
import { readFileIfExists } from "./atomic-fs.js";
|
|
5
|
+
import { readJsonServers } from "./json-adapter.js";
|
|
6
|
+
import { readTomlServers } from "./toml-adapter.js";
|
|
7
|
+
import { hashValue } from "./hash.js";
|
|
8
|
+
import { loadTemplates } from "./commands.js";
|
|
9
|
+
import { isBinaryOnPath } from "./prereq-check.js";
|
|
10
|
+
|
|
11
|
+
const PLACEHOLDER_TOKEN_PATTERN = /REPLACE_[A-Z0-9_]+/;
|
|
12
|
+
const LOCKFILE_NAME = ".kae-bi-kit.lock";
|
|
13
|
+
|
|
14
|
+
function finding(level, area, message) {
|
|
15
|
+
return { level, area, message };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveGroupForEntry(entry, groups) {
|
|
19
|
+
const byPath = groups.find((g) => g.absPath === entry.absPath);
|
|
20
|
+
if (byPath) return byPath;
|
|
21
|
+
// Fall back to shape inference by extension when the entry's absPath no longer
|
|
22
|
+
// matches any currently-resolved target (e.g. platform/homedir changed since install).
|
|
23
|
+
const shape = entry.absPath.toLowerCase().endsWith(".toml") ? "toml" : "json";
|
|
24
|
+
return { absPath: entry.absPath, shape, rootKey: "mcpServers" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readServersForGroup(group, fileText) {
|
|
28
|
+
if (group.shape === "toml") return readTomlServers(fileText);
|
|
29
|
+
return readJsonServers(fileText, group.rootKey);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function containsPlaceholderToken(value) {
|
|
33
|
+
if (typeof value === "string") return PLACEHOLDER_TOKEN_PATTERN.test(value);
|
|
34
|
+
if (Array.isArray(value)) return value.some(containsPlaceholderToken);
|
|
35
|
+
if (value && typeof value === "object") {
|
|
36
|
+
return Object.values(value).some(containsPlaceholderToken);
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function checkLockfile(dir, findings) {
|
|
42
|
+
const lockPath = path.join(dir, LOCKFILE_NAME);
|
|
43
|
+
const text = await readFileIfExists(lockPath);
|
|
44
|
+
if (text === null) {
|
|
45
|
+
findings.push(finding("ok", "lockfile", "No stale lockfile present."));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let pid = "unknown";
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(text);
|
|
51
|
+
if (parsed && parsed.pid !== undefined) pid = parsed.pid;
|
|
52
|
+
} catch {
|
|
53
|
+
// fall through with pid "unknown"
|
|
54
|
+
}
|
|
55
|
+
findings.push(
|
|
56
|
+
finding(
|
|
57
|
+
"warn",
|
|
58
|
+
"lockfile",
|
|
59
|
+
"A lockfile exists at " + lockPath + " from pid " + pid +
|
|
60
|
+
". If no bi-agent-kit run is currently in progress, delete it and try again."
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function checkEntry(entry, group, findings, counters) {
|
|
66
|
+
const fileText = await readFileIfExists(entry.absPath);
|
|
67
|
+
if (fileText === null) {
|
|
68
|
+
findings.push(
|
|
69
|
+
finding(
|
|
70
|
+
"warn",
|
|
71
|
+
"target-file",
|
|
72
|
+
"Target file " + entry.absPath + " for " + entry.serverKey + " no longer exists."
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
counters.filesPresent++;
|
|
78
|
+
|
|
79
|
+
let servers;
|
|
80
|
+
try {
|
|
81
|
+
servers = readServersForGroup(group, fileText);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
findings.push(
|
|
84
|
+
finding(
|
|
85
|
+
"error",
|
|
86
|
+
"parse",
|
|
87
|
+
"Target file " + entry.absPath + " is malformed and could not be parsed: " + err.message
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
counters.filesParsed++;
|
|
93
|
+
|
|
94
|
+
const value = servers[entry.serverKey];
|
|
95
|
+
if (value === undefined) {
|
|
96
|
+
findings.push(
|
|
97
|
+
finding(
|
|
98
|
+
"warn",
|
|
99
|
+
"server-key",
|
|
100
|
+
"The " + entry.serverKey + " entry in " + entry.absPath +
|
|
101
|
+
" was removed by hand (bi-agent-kit still thinks it is installed)."
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
counters.keysPresent++;
|
|
107
|
+
|
|
108
|
+
const currentHash = hashValue(value);
|
|
109
|
+
if (currentHash !== entry.contentHash) {
|
|
110
|
+
findings.push(
|
|
111
|
+
finding(
|
|
112
|
+
"warn",
|
|
113
|
+
"drift",
|
|
114
|
+
"The " + entry.serverKey + " entry in " + entry.absPath +
|
|
115
|
+
" was edited outside bi-agent-kit and will be skipped on removal."
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
counters.hashesMatch++;
|
|
121
|
+
|
|
122
|
+
if (containsPlaceholderToken(value)) {
|
|
123
|
+
findings.push(
|
|
124
|
+
finding(
|
|
125
|
+
"warn",
|
|
126
|
+
"placeholder",
|
|
127
|
+
"The " + entry.serverKey + " entry in " + entry.absPath +
|
|
128
|
+
" still has an unfilled REPLACE_ placeholder and will not launch."
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
counters.placeholdersClean++;
|
|
134
|
+
|
|
135
|
+
counters.healthy++;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function checkRequiredBinaries(manifest, findings) {
|
|
139
|
+
const templates = await loadTemplates();
|
|
140
|
+
const templatesById = new Map(templates.map((t) => [t.id, t]));
|
|
141
|
+
const requiredBinaries = new Map();
|
|
142
|
+
for (const entry of manifest.entries) {
|
|
143
|
+
const templateId = entry.templateId || entry.serverKey;
|
|
144
|
+
const template = templatesById.get(templateId);
|
|
145
|
+
if (!template || !template.requiresBinary) continue;
|
|
146
|
+
if (!requiredBinaries.has(template.requiresBinary)) {
|
|
147
|
+
requiredBinaries.set(template.requiresBinary, []);
|
|
148
|
+
}
|
|
149
|
+
requiredBinaries.get(template.requiresBinary).push(entry.serverKey);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (requiredBinaries.size === 0) {
|
|
153
|
+
findings.push(finding("ok", "binary", "No installed server requires a binary on PATH."));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let allPresent = true;
|
|
158
|
+
for (const [binaryName, serverIds] of requiredBinaries) {
|
|
159
|
+
const onPath = await isBinaryOnPath(binaryName);
|
|
160
|
+
if (!onPath) {
|
|
161
|
+
allPresent = false;
|
|
162
|
+
findings.push(
|
|
163
|
+
finding(
|
|
164
|
+
"warn",
|
|
165
|
+
"binary",
|
|
166
|
+
"Required binary \"" + binaryName + "\" (for " + serverIds.join(", ") +
|
|
167
|
+
") was not found on PATH."
|
|
168
|
+
)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (allPresent) {
|
|
173
|
+
findings.push(
|
|
174
|
+
finding(
|
|
175
|
+
"ok",
|
|
176
|
+
"binary",
|
|
177
|
+
requiredBinaries.size + " required binary(ies) checked and found on PATH."
|
|
178
|
+
)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function pushEntrySummaries(findings, counters, total) {
|
|
184
|
+
if (counters.filesPresent === total) {
|
|
185
|
+
findings.push(finding("ok", "target-file", total + " target file(s) present."));
|
|
186
|
+
}
|
|
187
|
+
if (counters.filesParsed === counters.filesPresent && counters.filesPresent > 0) {
|
|
188
|
+
findings.push(finding("ok", "parse", counters.filesParsed + " target file(s) parsed successfully."));
|
|
189
|
+
}
|
|
190
|
+
if (counters.keysPresent === counters.filesParsed && counters.filesParsed > 0) {
|
|
191
|
+
findings.push(finding("ok", "server-key", counters.keysPresent + " entry key(s) still present."));
|
|
192
|
+
}
|
|
193
|
+
if (counters.hashesMatch === counters.keysPresent && counters.keysPresent > 0) {
|
|
194
|
+
findings.push(finding("ok", "drift", counters.hashesMatch + " entry(ies) match their installed content, no drift."));
|
|
195
|
+
}
|
|
196
|
+
if (counters.placeholdersClean === counters.hashesMatch && counters.hashesMatch > 0) {
|
|
197
|
+
findings.push(
|
|
198
|
+
finding("ok", "placeholder", counters.placeholdersClean + " entry(ies) have no unfilled placeholders.")
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function runDoctor({ dir, homedir, platform }) {
|
|
204
|
+
const findings = [];
|
|
205
|
+
|
|
206
|
+
let manifest;
|
|
207
|
+
try {
|
|
208
|
+
manifest = await readManifest(dir);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (err instanceof ManifestCorruptError) {
|
|
211
|
+
findings.push(finding("error", "manifest", err.message));
|
|
212
|
+
await checkLockfile(dir, findings);
|
|
213
|
+
return findings;
|
|
214
|
+
}
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (manifest === null || manifest.entries.length === 0) {
|
|
219
|
+
findings.push(finding("ok", "manifest", "No manifest found — nothing installed by bi-agent-kit yet."));
|
|
220
|
+
await checkLockfile(dir, findings);
|
|
221
|
+
return findings;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
findings.push(finding("ok", "manifest", "Manifest read successfully with " + manifest.entries.length + " entry(ies)."));
|
|
225
|
+
|
|
226
|
+
const groups = resolveTargets(dir, homedir, platform);
|
|
227
|
+
const counters = {
|
|
228
|
+
filesPresent: 0,
|
|
229
|
+
filesParsed: 0,
|
|
230
|
+
keysPresent: 0,
|
|
231
|
+
hashesMatch: 0,
|
|
232
|
+
placeholdersClean: 0,
|
|
233
|
+
healthy: 0,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
for (const entry of manifest.entries) {
|
|
237
|
+
const group = resolveGroupForEntry(entry, groups);
|
|
238
|
+
await checkEntry(entry, group, findings, counters);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
pushEntrySummaries(findings, counters, manifest.entries.length);
|
|
242
|
+
|
|
243
|
+
await checkRequiredBinaries(manifest, findings);
|
|
244
|
+
await checkLockfile(dir, findings);
|
|
245
|
+
|
|
246
|
+
return findings;
|
|
247
|
+
}
|
package/lib/hash.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function canonicalize(value) {
|
|
4
|
+
if (value === null || typeof value !== "object") {
|
|
5
|
+
return JSON.stringify(value);
|
|
6
|
+
}
|
|
7
|
+
if (Array.isArray(value)) {
|
|
8
|
+
return "[" + value.map(canonicalize).join(",") + "]";
|
|
9
|
+
}
|
|
10
|
+
const keys = Object.keys(value).sort();
|
|
11
|
+
const parts = keys.map((key) => JSON.stringify(key) + ":" + canonicalize(value[key]));
|
|
12
|
+
return "{" + parts.join(",") + "}";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hashValue(value) {
|
|
16
|
+
const canonical = canonicalize(value);
|
|
17
|
+
return crypto.createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
18
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-binary install commands, keyed by process.platform.
|
|
8
|
+
*
|
|
9
|
+
* pac (Power Platform CLI) and dab (Data API Builder CLI) both ship as
|
|
10
|
+
* .NET global tools, so the dotnet-tool install command is identical across
|
|
11
|
+
* platforms as long as the .NET SDK is present. uv/uvx has no dotnet
|
|
12
|
+
* equivalent and instead ships a platform-specific standalone installer
|
|
13
|
+
* script (astral.sh).
|
|
14
|
+
*/
|
|
15
|
+
export const INSTALLERS = {
|
|
16
|
+
pac: {
|
|
17
|
+
label: "Power Platform CLI (pac)",
|
|
18
|
+
docs: "https://learn.microsoft.com/en-us/power-platform/developer/cli/introduction",
|
|
19
|
+
commands: {
|
|
20
|
+
win32: { command: "dotnet", args: ["tool", "install", "--global", "Microsoft.PowerApps.CLI.Tool"] },
|
|
21
|
+
darwin: { command: "dotnet", args: ["tool", "install", "--global", "Microsoft.PowerApps.CLI.Tool"] },
|
|
22
|
+
linux: { command: "dotnet", args: ["tool", "install", "--global", "Microsoft.PowerApps.CLI.Tool"] },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
dab: {
|
|
26
|
+
label: "Data API Builder CLI (dab)",
|
|
27
|
+
docs: "https://learn.microsoft.com/en-us/azure/data-api-builder/how-to-install-cli",
|
|
28
|
+
commands: {
|
|
29
|
+
win32: { command: "dotnet", args: ["tool", "install", "--global", "Microsoft.DataApiBuilder"] },
|
|
30
|
+
darwin: { command: "dotnet", args: ["tool", "install", "--global", "Microsoft.DataApiBuilder"] },
|
|
31
|
+
linux: { command: "dotnet", args: ["tool", "install", "--global", "Microsoft.DataApiBuilder"] },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
uvx: {
|
|
35
|
+
label: "uv / uvx",
|
|
36
|
+
docs: "https://docs.astral.sh/uv/getting-started/installation/",
|
|
37
|
+
commands: {
|
|
38
|
+
win32: { command: "powershell", args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm https://astral.sh/uv/install.ps1 | iex"] },
|
|
39
|
+
darwin: { command: "sh", args: ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"] },
|
|
40
|
+
linux: { command: "sh", args: ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"] },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
async function defaultRunner(command, args) {
|
|
46
|
+
await execFileAsync(command, args);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Offers to install a missing prerequisite binary, running the platform
|
|
51
|
+
* appropriate command only after the caller-supplied confirm() resolves
|
|
52
|
+
* truthy. Accepts an injectable `runner(command, args)` so tests never
|
|
53
|
+
* actually shell out.
|
|
54
|
+
*
|
|
55
|
+
* Returns { attempted, installed, reason?, message? }.
|
|
56
|
+
*/
|
|
57
|
+
export async function offerInstall(binaryName, { confirm, runner, platform = process.platform } = {}) {
|
|
58
|
+
const installer = INSTALLERS[binaryName];
|
|
59
|
+
if (!installer) {
|
|
60
|
+
return { attempted: false, installed: false, reason: "no-installer" };
|
|
61
|
+
}
|
|
62
|
+
const commandSpec = installer.commands[platform];
|
|
63
|
+
if (!commandSpec) {
|
|
64
|
+
return { attempted: false, installed: false, reason: "unsupported-platform" };
|
|
65
|
+
}
|
|
66
|
+
const proceed = await confirm({ message: "Install " + installer.label + " now?" });
|
|
67
|
+
if (!proceed) {
|
|
68
|
+
return { attempted: false, installed: false, reason: "declined" };
|
|
69
|
+
}
|
|
70
|
+
const run = runner || defaultRunner;
|
|
71
|
+
try {
|
|
72
|
+
await run(commandSpec.command, commandSpec.args);
|
|
73
|
+
return { attempted: true, installed: true };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { attempted: true, installed: false, reason: "error", message: err.message };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { parse, modify, applyEdits } from "jsonc-parser";
|
|
2
|
+
|
|
3
|
+
const FORMATTING_OPTIONS = { tabSize: 2, insertSpaces: true, eol: "\n" };
|
|
4
|
+
|
|
5
|
+
export class MalformedConfigError extends Error {
|
|
6
|
+
constructor(detail) {
|
|
7
|
+
super("Config file content is not valid JSON: " + detail);
|
|
8
|
+
this.name = "MalformedConfigError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseStrict(fileText) {
|
|
13
|
+
if (fileText.charCodeAt(0) === 0xfeff) fileText = fileText.slice(1);
|
|
14
|
+
const errors = [];
|
|
15
|
+
const parsed = parse(fileText, errors, { allowTrailingComma: true });
|
|
16
|
+
if (errors.length > 0) {
|
|
17
|
+
throw new MalformedConfigError("parse error code " + errors[0].error + " at offset " + errors[0].offset);
|
|
18
|
+
}
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readJsonServers(fileText, rootKey) {
|
|
23
|
+
if (!fileText || fileText.trim().length === 0) return {};
|
|
24
|
+
const parsed = parseStrict(fileText);
|
|
25
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
26
|
+
const servers = parsed[rootKey];
|
|
27
|
+
if (!servers || typeof servers !== "object") return {};
|
|
28
|
+
return servers;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setJsonServer(fileText, rootKey, serverKey, serverValue) {
|
|
32
|
+
const base = fileText && fileText.trim().length > 0 ? fileText : "{}\n";
|
|
33
|
+
const edits = modify(base, [rootKey, serverKey], serverValue, {
|
|
34
|
+
formattingOptions: FORMATTING_OPTIONS,
|
|
35
|
+
});
|
|
36
|
+
return applyEdits(base, edits);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function removeJsonServer(fileText, rootKey, serverKey) {
|
|
40
|
+
if (!fileText) return fileText;
|
|
41
|
+
const edits = modify(fileText, [rootKey, serverKey], undefined, {
|
|
42
|
+
formattingOptions: FORMATTING_OPTIONS,
|
|
43
|
+
});
|
|
44
|
+
return applyEdits(fileText, edits);
|
|
45
|
+
}
|
package/lib/lock.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { readFileIfExists } from "./atomic-fs.js";
|
|
3
|
+
|
|
4
|
+
export class LockHeldError extends Error {
|
|
5
|
+
constructor(lockPath) {
|
|
6
|
+
super("Another bi-agent-kit run is already in progress, lock at " + lockPath);
|
|
7
|
+
this.name = "LockHeldError";
|
|
8
|
+
this.lockPath = lockPath;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function tryExclusiveCreate(lockPath, payload) {
|
|
13
|
+
const handle = await fs.open(lockPath, "wx");
|
|
14
|
+
try {
|
|
15
|
+
await handle.writeFile(payload, "utf8");
|
|
16
|
+
} finally {
|
|
17
|
+
await handle.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function acquireLock(lockPath, options) {
|
|
22
|
+
const ttlMs = options && options.ttlMs ? options.ttlMs : 30000;
|
|
23
|
+
const payload = JSON.stringify({ pid: process.pid, timestamp: Date.now() });
|
|
24
|
+
try {
|
|
25
|
+
await tryExclusiveCreate(lockPath, payload);
|
|
26
|
+
return async function release() {
|
|
27
|
+
await releaseLock(lockPath);
|
|
28
|
+
};
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code !== "EEXIST") throw err;
|
|
31
|
+
}
|
|
32
|
+
const existing = await readFileIfExists(lockPath);
|
|
33
|
+
if (existing !== null) {
|
|
34
|
+
const parsed = JSON.parse(existing);
|
|
35
|
+
const age = Date.now() - parsed.timestamp;
|
|
36
|
+
if (age < ttlMs) {
|
|
37
|
+
throw new LockHeldError(lockPath);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Reclaiming a stale lock is best effort, not fully atomic.
|
|
41
|
+
// Acceptable for a single user local CLI, not a distributed system.
|
|
42
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
43
|
+
await tryExclusiveCreate(lockPath, payload);
|
|
44
|
+
return async function release() {
|
|
45
|
+
await releaseLock(lockPath);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function releaseLock(lockPath) {
|
|
50
|
+
try {
|
|
51
|
+
await fs.unlink(lockPath);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (err.code !== "ENOENT") throw err;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/lib/manifest.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { atomicWriteFile, readFileIfExists } from "./atomic-fs.js";
|
|
3
|
+
|
|
4
|
+
export const MANIFEST_FILENAME = ".kae-bi-kit.json";
|
|
5
|
+
|
|
6
|
+
export class ManifestCorruptError extends Error {
|
|
7
|
+
constructor(manifestPath, cause) {
|
|
8
|
+
super("Manifest at " + manifestPath + " is corrupt. Re-run init to rebuild it.");
|
|
9
|
+
this.name = "ManifestCorruptError";
|
|
10
|
+
this.manifestPath = manifestPath;
|
|
11
|
+
this.cause = cause;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function manifestPathFor(dir) {
|
|
16
|
+
return path.join(dir, MANIFEST_FILENAME);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function emptyManifest() {
|
|
20
|
+
return { version: 1, entries: [] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readManifest(dir) {
|
|
24
|
+
const manifestPath = manifestPathFor(dir);
|
|
25
|
+
const text = await readFileIfExists(manifestPath);
|
|
26
|
+
if (text === null) return null;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(text);
|
|
29
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {
|
|
30
|
+
throw new Error("missing entries array");
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw new ManifestCorruptError(manifestPath, err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function persist(dir, manifest) {
|
|
39
|
+
const manifestPath = manifestPathFor(dir);
|
|
40
|
+
const currentContent = await readFileIfExists(manifestPath);
|
|
41
|
+
if (currentContent !== null) {
|
|
42
|
+
await atomicWriteFile(manifestPath + ".bak", currentContent);
|
|
43
|
+
}
|
|
44
|
+
await atomicWriteFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function writeManifestEntry(dir, entry) {
|
|
48
|
+
let manifest = await readManifest(dir);
|
|
49
|
+
if (manifest === null) manifest = emptyManifest();
|
|
50
|
+
const index = manifest.entries.findIndex(
|
|
51
|
+
(e) => e.absPath === entry.absPath && e.serverKey === entry.serverKey
|
|
52
|
+
);
|
|
53
|
+
if (index === -1) {
|
|
54
|
+
manifest.entries.push(entry);
|
|
55
|
+
} else {
|
|
56
|
+
manifest.entries[index] = entry;
|
|
57
|
+
}
|
|
58
|
+
await persist(dir, manifest);
|
|
59
|
+
return manifest;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function removeManifestEntry(dir, absPath, serverKey) {
|
|
63
|
+
const manifest = await readManifest(dir);
|
|
64
|
+
if (manifest === null) return null;
|
|
65
|
+
manifest.entries = manifest.entries.filter(
|
|
66
|
+
(e) => !(e.absPath === absPath && e.serverKey === serverKey)
|
|
67
|
+
);
|
|
68
|
+
await persist(dir, manifest);
|
|
69
|
+
return manifest;
|
|
70
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readJsonServers, setJsonServer, removeJsonServer } from "./json-adapter.js";
|
|
2
|
+
import { readTomlServers, setTomlServer, removeTomlServer } from "./toml-adapter.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readFileIfExists, atomicWriteFile, backupIfMissing, assertNoSymlinkedAncestors } from "./atomic-fs.js";
|
|
5
|
+
import { readManifest, writeManifestEntry, removeManifestEntry } from "./manifest.js";
|
|
6
|
+
import { hashValue } from "./hash.js";
|
|
7
|
+
|
|
8
|
+
export class CollisionError extends Error {
|
|
9
|
+
constructor(absPath, serverKey) {
|
|
10
|
+
super(
|
|
11
|
+
"A key named " + serverKey + " already exists in " + absPath +
|
|
12
|
+
" and is not owned by bi-agent-kit. Rename the server or remove the conflicting entry by hand first."
|
|
13
|
+
);
|
|
14
|
+
this.name = "CollisionError";
|
|
15
|
+
this.absPath = absPath;
|
|
16
|
+
this.serverKey = serverKey;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readServers(shape, fileText, rootKey) {
|
|
21
|
+
if (shape === "toml") return readTomlServers(fileText);
|
|
22
|
+
return readJsonServers(fileText, rootKey);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeServer(shape, fileText, rootKey, serverKey, serverValue) {
|
|
26
|
+
if (shape === "toml") return setTomlServer(fileText, serverKey, serverValue);
|
|
27
|
+
return setJsonServer(fileText, rootKey, serverKey, serverValue);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function deleteServer(shape, fileText, rootKey, serverKey) {
|
|
31
|
+
if (shape === "toml") return removeTomlServer(fileText, serverKey);
|
|
32
|
+
return removeJsonServer(fileText, rootKey, serverKey);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Every path component below this ancestor is checked for symlinks before we read/write it.
|
|
36
|
+
// For paths inside the project dir, that's the project dir itself. For paths outside it
|
|
37
|
+
// (e.g. user-home-scoped targets), fall back to the file's own grandparent directory so we
|
|
38
|
+
// still catch a symlinked immediate parent without following the entire host filesystem.
|
|
39
|
+
function symlinkCheckStopDir(dir, absPath) {
|
|
40
|
+
const resolvedDir = path.resolve(dir);
|
|
41
|
+
const resolvedAbsPath = path.resolve(absPath);
|
|
42
|
+
const relative = path.relative(resolvedDir, resolvedAbsPath);
|
|
43
|
+
if (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
44
|
+
return resolvedDir;
|
|
45
|
+
}
|
|
46
|
+
return path.dirname(path.dirname(resolvedAbsPath));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function addServerToTarget({ dir, resolvedTarget, serverKey, serverValue, templateId }) {
|
|
50
|
+
const manifest = await readManifest(dir);
|
|
51
|
+
const entries = manifest ? manifest.entries : [];
|
|
52
|
+
const ownedByUs = entries.some(
|
|
53
|
+
(e) => e.absPath === resolvedTarget.absPath && e.serverKey === serverKey
|
|
54
|
+
);
|
|
55
|
+
const fileHasAnyOwnedEntry = entries.some((e) => e.absPath === resolvedTarget.absPath);
|
|
56
|
+
|
|
57
|
+
await assertNoSymlinkedAncestors(
|
|
58
|
+
resolvedTarget.absPath,
|
|
59
|
+
symlinkCheckStopDir(dir, resolvedTarget.absPath)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const fileText = await readFileIfExists(resolvedTarget.absPath);
|
|
63
|
+
const currentServers = readServers(resolvedTarget.shape, fileText, resolvedTarget.rootKey);
|
|
64
|
+
|
|
65
|
+
if (Object.prototype.hasOwnProperty.call(currentServers, serverKey) && !ownedByUs) {
|
|
66
|
+
throw new CollisionError(resolvedTarget.absPath, serverKey);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!fileHasAnyOwnedEntry && fileText !== null) {
|
|
70
|
+
await backupIfMissing(resolvedTarget.absPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const newFileText = writeServer(
|
|
74
|
+
resolvedTarget.shape,
|
|
75
|
+
fileText,
|
|
76
|
+
resolvedTarget.rootKey,
|
|
77
|
+
serverKey,
|
|
78
|
+
serverValue
|
|
79
|
+
);
|
|
80
|
+
await assertNoSymlinkedAncestors(
|
|
81
|
+
resolvedTarget.absPath,
|
|
82
|
+
symlinkCheckStopDir(dir, resolvedTarget.absPath)
|
|
83
|
+
);
|
|
84
|
+
await atomicWriteFile(resolvedTarget.absPath, newFileText);
|
|
85
|
+
|
|
86
|
+
await writeManifestEntry(dir, {
|
|
87
|
+
absPath: resolvedTarget.absPath,
|
|
88
|
+
serverKey,
|
|
89
|
+
templateId: templateId || serverKey,
|
|
90
|
+
targetIds: resolvedTarget.targetIds,
|
|
91
|
+
contentHash: hashValue(serverValue),
|
|
92
|
+
installedAt: Date.now(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return { status: "written" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function removeServerFromTarget({ dir, resolvedTarget, serverKey }) {
|
|
99
|
+
const manifest = await readManifest(dir);
|
|
100
|
+
const entry = manifest
|
|
101
|
+
? manifest.entries.find((e) => e.absPath === resolvedTarget.absPath && e.serverKey === serverKey)
|
|
102
|
+
: undefined;
|
|
103
|
+
if (!entry) {
|
|
104
|
+
return { status: "not-installed" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await assertNoSymlinkedAncestors(
|
|
108
|
+
resolvedTarget.absPath,
|
|
109
|
+
symlinkCheckStopDir(dir, resolvedTarget.absPath)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const fileText = await readFileIfExists(resolvedTarget.absPath);
|
|
113
|
+
const currentServers = readServers(resolvedTarget.shape, fileText, resolvedTarget.rootKey);
|
|
114
|
+
const currentValue = currentServers[serverKey];
|
|
115
|
+
|
|
116
|
+
if (currentValue === undefined) {
|
|
117
|
+
await removeManifestEntry(dir, resolvedTarget.absPath, serverKey);
|
|
118
|
+
return { status: "already-removed" };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const currentHash = hashValue(currentValue);
|
|
122
|
+
if (currentHash !== entry.contentHash) {
|
|
123
|
+
return { status: "skipped-drift" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const newFileText = deleteServer(resolvedTarget.shape, fileText, resolvedTarget.rootKey, serverKey);
|
|
127
|
+
const remaining = readServers(resolvedTarget.shape, newFileText, resolvedTarget.rootKey);
|
|
128
|
+
if (Object.prototype.hasOwnProperty.call(remaining, serverKey)) {
|
|
129
|
+
// Removal did not actually take effect; do not write and do not update the
|
|
130
|
+
// manifest, so state stays truthful and the entry remains managed.
|
|
131
|
+
return { status: "remove-failed" };
|
|
132
|
+
}
|
|
133
|
+
await assertNoSymlinkedAncestors(
|
|
134
|
+
resolvedTarget.absPath,
|
|
135
|
+
symlinkCheckStopDir(dir, resolvedTarget.absPath)
|
|
136
|
+
);
|
|
137
|
+
await atomicWriteFile(resolvedTarget.absPath, newFileText);
|
|
138
|
+
await removeManifestEntry(dir, resolvedTarget.absPath, serverKey);
|
|
139
|
+
|
|
140
|
+
return { status: "removed" };
|
|
141
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function isBinaryOnPath(binaryName) {
|
|
7
|
+
const finderCommand = process.platform === "win32" ? "where" : "which";
|
|
8
|
+
try {
|
|
9
|
+
await execFileAsync(finderCommand, [binaryName]);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|