loadout-ai 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/MASTER_PLAN.md +1711 -0
- package/README.md +453 -0
- package/SECURITY.md +18 -0
- package/SIMPLE_PLAN.md +44 -0
- package/catalog/discovered.json +10469 -0
- package/catalog/packages.json +1150 -0
- package/dashboard/app.js +607 -0
- package/dashboard/index.html +249 -0
- package/dashboard/styles.css +384 -0
- package/dist/src/cli.js +2735 -0
- package/dist/src/core/active-policy.js +233 -0
- package/dist/src/core/active-set.js +382 -0
- package/dist/src/core/adapters.js +254 -0
- package/dist/src/core/adopt.js +92 -0
- package/dist/src/core/agent-health-score.js +475 -0
- package/dist/src/core/agent-inspection.js +152 -0
- package/dist/src/core/agent-versions.js +131 -0
- package/dist/src/core/api.js +90 -0
- package/dist/src/core/atomic-file.js +22 -0
- package/dist/src/core/audit.js +174 -0
- package/dist/src/core/benchmark-campaign.js +496 -0
- package/dist/src/core/benchmark-evidence.js +458 -0
- package/dist/src/core/benchmark-fixtures.js +1157 -0
- package/dist/src/core/benchmark-runner.js +301 -0
- package/dist/src/core/benchmark-trust.js +795 -0
- package/dist/src/core/canary.js +77 -0
- package/dist/src/core/candidate-intelligence.js +661 -0
- package/dist/src/core/catalog-coverage.js +85 -0
- package/dist/src/core/catalog-install.js +186 -0
- package/dist/src/core/catalog-release.js +286 -0
- package/dist/src/core/catalog.js +327 -0
- package/dist/src/core/codex-mcp.js +122 -0
- package/dist/src/core/community.js +86 -0
- package/dist/src/core/compatibility-intelligence.js +465 -0
- package/dist/src/core/completion.js +160 -0
- package/dist/src/core/components.js +138 -0
- package/dist/src/core/conversion.js +56 -0
- package/dist/src/core/credentials.js +377 -0
- package/dist/src/core/demo.js +136 -0
- package/dist/src/core/diff.js +83 -0
- package/dist/src/core/discovery-connector.js +165 -0
- package/dist/src/core/doctor.js +95 -0
- package/dist/src/core/ecosystem-import.js +1072 -0
- package/dist/src/core/evaluate.js +72 -0
- package/dist/src/core/file-lock.js +140 -0
- package/dist/src/core/freshness-alerts.js +230 -0
- package/dist/src/core/github-discovery.js +97 -0
- package/dist/src/core/github.js +148 -0
- package/dist/src/core/head-to-head.js +437 -0
- package/dist/src/core/health-score-evidence.js +169 -0
- package/dist/src/core/health.js +129 -0
- package/dist/src/core/improve.js +223 -0
- package/dist/src/core/install.js +233 -0
- package/dist/src/core/intelligence-feed-build.js +59 -0
- package/dist/src/core/intelligence-feed.js +353 -0
- package/dist/src/core/loadout-badge.js +55 -0
- package/dist/src/core/loadout-card.js +87 -0
- package/dist/src/core/manifest.js +307 -0
- package/dist/src/core/mcp-recipes.js +419 -0
- package/dist/src/core/mcp-registry-discovery.js +455 -0
- package/dist/src/core/mcp.js +287 -0
- package/dist/src/core/model-config.js +102 -0
- package/dist/src/core/observations.js +87 -0
- package/dist/src/core/outcomes.js +164 -0
- package/dist/src/core/package.js +225 -0
- package/dist/src/core/paths.js +214 -0
- package/dist/src/core/portable.js +130 -0
- package/dist/src/core/private-discovery.js +45 -0
- package/dist/src/core/profiles.js +255 -0
- package/dist/src/core/provenance.js +278 -0
- package/dist/src/core/ranking.js +104 -0
- package/dist/src/core/recommend.js +181 -0
- package/dist/src/core/registry-api.js +97 -0
- package/dist/src/core/registry.js +316 -0
- package/dist/src/core/release-claims.js +175 -0
- package/dist/src/core/remove.js +122 -0
- package/dist/src/core/review-queue.js +200 -0
- package/dist/src/core/runtime-tool-recipe.js +315 -0
- package/dist/src/core/runtime-tools.js +453 -0
- package/dist/src/core/safety.js +234 -0
- package/dist/src/core/sandbox.js +78 -0
- package/dist/src/core/scheduler.js +264 -0
- package/dist/src/core/share-report.js +85 -0
- package/dist/src/core/signing.js +115 -0
- package/dist/src/core/skill-compare.js +239 -0
- package/dist/src/core/skill-inventory.js +193 -0
- package/dist/src/core/skill-security.js +340 -0
- package/dist/src/core/skills-sh-discovery.js +336 -0
- package/dist/src/core/skills.js +208 -0
- package/dist/src/core/snapshot.js +210 -0
- package/dist/src/core/source.js +305 -0
- package/dist/src/core/state.js +300 -0
- package/dist/src/core/sync.js +348 -0
- package/dist/src/core/terminal.js +53 -0
- package/dist/src/core/transaction.js +211 -0
- package/dist/src/core/update-watch.js +39 -0
- package/dist/src/core/update.js +216 -0
- package/dist/src/core/upgrade.js +207 -0
- package/dist/src/dashboard.js +418 -0
- package/dist/src/shared/schemas.js +336 -0
- package/dist/src/shared/types.js +1 -0
- package/docs/ACTIVE_SET.md +53 -0
- package/docs/ACTIVE_SET_POLICY.md +45 -0
- package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
- package/docs/CATALOG.md +78 -0
- package/docs/CATALOG_POLICY.md +41 -0
- package/docs/COMMUNITY_DISCOVERY.md +14 -0
- package/docs/COMPATIBILITY_POLICY.md +22 -0
- package/docs/CONVERSION_AND_SANDBOX.md +27 -0
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
- package/docs/DISCOVERED.md +280 -0
- package/docs/EVALUATION_PROTOCOL.md +40 -0
- package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
- package/docs/FEATURE_TEST_MATRIX.md +802 -0
- package/docs/GITHUB_AUTHORIZATION.md +38 -0
- package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
- package/docs/PROVIDER_CONFIGURATION.md +45 -0
- package/docs/RELEASE_REVIEW.md +117 -0
- package/docs/SAFE_UPDATE_DEMO.md +25 -0
- package/docs/SCHEMA_DECISIONS.md +25 -0
- package/docs/TEAM_POLICY.md +18 -0
- package/docs/TESTING.md +190 -0
- package/package.json +82 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { detectAgents, executableCandidates, userHome } from "./paths.js";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
function versionEnvironment() {
|
|
6
|
+
const names = [
|
|
7
|
+
"PATH",
|
|
8
|
+
"Path",
|
|
9
|
+
"PATHEXT",
|
|
10
|
+
"SystemRoot",
|
|
11
|
+
"WINDIR",
|
|
12
|
+
"TMPDIR",
|
|
13
|
+
"TMP",
|
|
14
|
+
"TEMP",
|
|
15
|
+
];
|
|
16
|
+
return {
|
|
17
|
+
...Object.fromEntries(names.flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name]]])),
|
|
18
|
+
HOME: userHome(),
|
|
19
|
+
USERPROFILE: userHome(),
|
|
20
|
+
NO_COLOR: "1",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const defaultRunner = async (command, args, options) => {
|
|
24
|
+
try {
|
|
25
|
+
const result = await execFileAsync(command, [...args], {
|
|
26
|
+
env: options.env,
|
|
27
|
+
timeout: options.timeoutMs,
|
|
28
|
+
windowsHide: true,
|
|
29
|
+
maxBuffer: 64 * 1024,
|
|
30
|
+
});
|
|
31
|
+
return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 };
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const failure = error;
|
|
35
|
+
return {
|
|
36
|
+
stdout: failure.stdout ?? "",
|
|
37
|
+
stderr: failure.killed
|
|
38
|
+
? "version command timed out"
|
|
39
|
+
: (failure.stderr ?? ""),
|
|
40
|
+
exitCode: typeof failure.code === "number" ? failure.code : 1,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
export function parseAgentVersion(output) {
|
|
45
|
+
const bounded = output.trim().slice(0, 1024);
|
|
46
|
+
const version = bounded.match(/(?:^|[^0-9A-Za-z.])v?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?)(?:$|[^0-9A-Za-z.-])/);
|
|
47
|
+
return version?.[1];
|
|
48
|
+
}
|
|
49
|
+
export async function inspectAgentVersions(options = {}) {
|
|
50
|
+
const agents = options.agents ?? (await detectAgents());
|
|
51
|
+
const runner = options.runner ?? defaultRunner;
|
|
52
|
+
const env = versionEnvironment();
|
|
53
|
+
return Promise.all(agents.map(async (agent) => {
|
|
54
|
+
if (!agent.installed)
|
|
55
|
+
return {
|
|
56
|
+
agent: agent.id,
|
|
57
|
+
displayName: agent.displayName,
|
|
58
|
+
installed: false,
|
|
59
|
+
...(agent.binary ? { binary: agent.binary } : {}),
|
|
60
|
+
status: "not-installed",
|
|
61
|
+
message: "Agent is not detected.",
|
|
62
|
+
};
|
|
63
|
+
if (!agent.binary)
|
|
64
|
+
return {
|
|
65
|
+
agent: agent.id,
|
|
66
|
+
displayName: agent.displayName,
|
|
67
|
+
installed: true,
|
|
68
|
+
status: "no-version-command",
|
|
69
|
+
message: "Agent is detected from local configuration, but no reviewed CLI version command is available.",
|
|
70
|
+
};
|
|
71
|
+
const candidates = executableCandidates(agent.binary, options.platform ?? process.platform);
|
|
72
|
+
const attempts = [];
|
|
73
|
+
for (const candidate of candidates) {
|
|
74
|
+
const result = await runner(candidate, ["--version"], {
|
|
75
|
+
env,
|
|
76
|
+
timeoutMs: options.timeoutMs ?? 5_000,
|
|
77
|
+
});
|
|
78
|
+
attempts.push({ command: candidate, ...result });
|
|
79
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
80
|
+
const version = parseAgentVersion(output);
|
|
81
|
+
if (result.exitCode === 0 && version) {
|
|
82
|
+
const prerelease = version.includes("-");
|
|
83
|
+
return {
|
|
84
|
+
agent: agent.id,
|
|
85
|
+
displayName: agent.displayName,
|
|
86
|
+
installed: true,
|
|
87
|
+
binary: candidate,
|
|
88
|
+
status: "detected",
|
|
89
|
+
version,
|
|
90
|
+
releaseChannel: prerelease ? "prerelease" : "stable",
|
|
91
|
+
command: [candidate, "--version"],
|
|
92
|
+
message: prerelease
|
|
93
|
+
? "Local prerelease detected. Compatibility notices may be incomplete unless they explicitly include prereleases."
|
|
94
|
+
: "Local version detected. No latest-version or compatibility claim is made without a signed compatibility feed.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const timedOut = attempts.find((attempt) => /timed out|timeout/i.test(attempt.stderr));
|
|
99
|
+
const failed = attempts.find((attempt) => attempt.exitCode !== 0);
|
|
100
|
+
const last = attempts.at(-1);
|
|
101
|
+
const errorKind = timedOut
|
|
102
|
+
? "timeout"
|
|
103
|
+
: failed
|
|
104
|
+
? "command-failed"
|
|
105
|
+
: "malformed-output";
|
|
106
|
+
return {
|
|
107
|
+
agent: agent.id,
|
|
108
|
+
displayName: agent.displayName,
|
|
109
|
+
installed: true,
|
|
110
|
+
binary: last.command,
|
|
111
|
+
status: "error",
|
|
112
|
+
command: [last.command, "--version"],
|
|
113
|
+
errorKind,
|
|
114
|
+
message: errorKind === "timeout"
|
|
115
|
+
? `Version command timed out after ${options.timeoutMs ?? 5_000}ms.`
|
|
116
|
+
: errorKind === "command-failed"
|
|
117
|
+
? `Version command failed with exit code ${failed.exitCode}.`
|
|
118
|
+
: "Version command returned no recognized version.",
|
|
119
|
+
};
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
export function formatAgentVersions(items) {
|
|
123
|
+
return [
|
|
124
|
+
"Agent versions",
|
|
125
|
+
...items.map((item) => {
|
|
126
|
+
const marker = item.status === "detected" ? "✓" : item.status === "error" ? "!" : "•";
|
|
127
|
+
return `${marker} ${item.displayName}: ${item.version ?? item.status} — ${item.message}`;
|
|
128
|
+
}),
|
|
129
|
+
"Compatibility updates require a separately verified signed feed; this command does not guess from model memory.",
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
|
+
import { loadEffectiveCatalog, rankCatalog } from "./catalog.js";
|
|
3
|
+
import { detectAgents } from "./paths.js";
|
|
4
|
+
import { buildUpdatePlan } from "./update.js";
|
|
5
|
+
import { buildHealthReport } from "./health.js";
|
|
6
|
+
function json(response, statusCode, body) {
|
|
7
|
+
const payload = JSON.stringify(body);
|
|
8
|
+
response.writeHead(statusCode, {
|
|
9
|
+
"content-type": "application/json; charset=utf-8",
|
|
10
|
+
"content-length": Buffer.byteLength(payload),
|
|
11
|
+
"cache-control": "no-store",
|
|
12
|
+
});
|
|
13
|
+
response.end(payload);
|
|
14
|
+
}
|
|
15
|
+
function success(data) {
|
|
16
|
+
return { ok: true, data };
|
|
17
|
+
}
|
|
18
|
+
function errorBody(code, message) {
|
|
19
|
+
return { ok: false, error: { code, message } };
|
|
20
|
+
}
|
|
21
|
+
function pathOf(request) {
|
|
22
|
+
try {
|
|
23
|
+
return new URL(request.url ?? "/", "http://127.0.0.1").pathname;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "/";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function startApiServer(options = {}) {
|
|
30
|
+
const host = options.host ?? "127.0.0.1";
|
|
31
|
+
const port = options.port ?? 0;
|
|
32
|
+
if (host !== "127.0.0.1" && host !== "::1") {
|
|
33
|
+
throw new Error("Loadout API is loopback-only; use 127.0.0.1 or ::1");
|
|
34
|
+
}
|
|
35
|
+
const status = options.status ?? (async () => detectAgents());
|
|
36
|
+
const catalog = options.catalog ?? (async () => rankCatalog(await loadEffectiveCatalog()));
|
|
37
|
+
const updates = options.updates ?? (async () => buildUpdatePlan());
|
|
38
|
+
const health = options.health ?? (async () => buildHealthReport());
|
|
39
|
+
const server = createServer(async (request, response) => {
|
|
40
|
+
if (request.method !== "GET") {
|
|
41
|
+
json(response, 405, errorBody("METHOD_NOT_ALLOWED", "Only GET requests are supported."));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const path = pathOf(request);
|
|
45
|
+
const handler = path === "/status"
|
|
46
|
+
? status
|
|
47
|
+
: path === "/catalog"
|
|
48
|
+
? catalog
|
|
49
|
+
: path === "/health"
|
|
50
|
+
? health
|
|
51
|
+
: path === "/update" || path === "/updates"
|
|
52
|
+
? updates
|
|
53
|
+
: undefined;
|
|
54
|
+
if (!handler) {
|
|
55
|
+
json(response, 404, errorBody("NOT_FOUND", "Endpoint not found."));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
json(response, 200, success(await handler()));
|
|
60
|
+
}
|
|
61
|
+
catch (cause) {
|
|
62
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
63
|
+
json(response, 500, errorBody("INTERNAL_ERROR", message));
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
await new Promise((resolve, reject) => {
|
|
67
|
+
const onError = (cause) => {
|
|
68
|
+
server.off("listening", onListening);
|
|
69
|
+
reject(cause);
|
|
70
|
+
};
|
|
71
|
+
const onListening = () => {
|
|
72
|
+
server.off("error", onError);
|
|
73
|
+
resolve();
|
|
74
|
+
};
|
|
75
|
+
server.once("error", onError);
|
|
76
|
+
server.once("listening", onListening);
|
|
77
|
+
server.listen(port, host);
|
|
78
|
+
});
|
|
79
|
+
const address = server.address();
|
|
80
|
+
if (!address || typeof address === "string") {
|
|
81
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
82
|
+
throw new Error("Could not determine API server address");
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
server,
|
|
86
|
+
host,
|
|
87
|
+
port: address.port,
|
|
88
|
+
close: () => new Promise((resolve, reject) => server.close((cause) => (cause ? reject(cause) : resolve()))),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
/**
|
|
5
|
+
* Replace one file using a temporary sibling and rename. Keeping the temporary
|
|
6
|
+
* file in the destination directory makes the replacement a single filesystem
|
|
7
|
+
* operation on supported local filesystems, instead of exposing a truncated
|
|
8
|
+
* JSON file while it is being written.
|
|
9
|
+
*/
|
|
10
|
+
export async function writeFileAtomically(path, content, mode = 0o600) {
|
|
11
|
+
const directory = dirname(path);
|
|
12
|
+
await mkdir(directory, { recursive: true });
|
|
13
|
+
const temporary = join(directory, `.${basename(path)}.loadout-${randomUUID()}.tmp`);
|
|
14
|
+
try {
|
|
15
|
+
await writeFile(temporary, content, { mode });
|
|
16
|
+
await rename(temporary, path);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
await rm(temporary, { force: true });
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { formatSchemaError, loadoutLockfileSchema } from "../shared/schemas.js";
|
|
5
|
+
import { readManifest } from "./manifest.js";
|
|
6
|
+
import { readInstallState } from "./state.js";
|
|
7
|
+
import { managedFileReadPath } from "./active-set.js";
|
|
8
|
+
export function parseLockfile(value) {
|
|
9
|
+
const result = loadoutLockfileSchema.safeParse(value);
|
|
10
|
+
if (!result.success)
|
|
11
|
+
throw new Error(`Lockfile schema is invalid: ${formatSchemaError(result.error)}`);
|
|
12
|
+
return result.data;
|
|
13
|
+
}
|
|
14
|
+
export async function readLockfile(path = "loadout.lock") {
|
|
15
|
+
try {
|
|
16
|
+
return parseLockfile(JSON.parse(await readFile(resolve(path), "utf8")));
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
throw new Error(`Invalid Loadout lockfile at ${resolve(path)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function expectedPackages(manifest) {
|
|
23
|
+
return manifest.packages
|
|
24
|
+
.filter((pkg) => pkg.enabled !== false)
|
|
25
|
+
.map((pkg) => pkg.id)
|
|
26
|
+
.sort();
|
|
27
|
+
}
|
|
28
|
+
export async function auditLoadout(manifestPath = "loadout.json", lockPath = "loadout.lock") {
|
|
29
|
+
const [manifest, lockfile, state] = await Promise.all([
|
|
30
|
+
readManifest(manifestPath),
|
|
31
|
+
readLockfile(lockPath),
|
|
32
|
+
readInstallState(),
|
|
33
|
+
]);
|
|
34
|
+
const findings = [];
|
|
35
|
+
if (lockfile.manifestName !== manifest.name)
|
|
36
|
+
findings.push({
|
|
37
|
+
level: "error",
|
|
38
|
+
code: "manifest-name",
|
|
39
|
+
message: `Lockfile belongs to '${lockfile.manifestName}', not '${manifest.name}'.`,
|
|
40
|
+
});
|
|
41
|
+
const expected = expectedPackages(manifest);
|
|
42
|
+
const locked = lockfile.packages.map((pkg) => pkg.id).sort();
|
|
43
|
+
for (const id of expected.filter((id) => !locked.includes(id)))
|
|
44
|
+
findings.push({
|
|
45
|
+
level: "error",
|
|
46
|
+
code: "missing-lock",
|
|
47
|
+
message: `Package '${id}' is enabled but missing from the lockfile.`,
|
|
48
|
+
});
|
|
49
|
+
for (const id of locked.filter((id) => !expected.includes(id)))
|
|
50
|
+
findings.push({
|
|
51
|
+
level: "error",
|
|
52
|
+
code: "extra-lock",
|
|
53
|
+
message: `Lockfile contains package '${id}' that is not enabled in the manifest.`,
|
|
54
|
+
});
|
|
55
|
+
const stateById = new Map(state.installs.map((entry) => [entry.packageId, entry]));
|
|
56
|
+
for (const lockedPackage of lockfile.packages) {
|
|
57
|
+
const manifestPackage = manifest.packages.find((pkg) => pkg.id === lockedPackage.id);
|
|
58
|
+
if (manifestPackage &&
|
|
59
|
+
JSON.stringify(manifestPackage.source) !==
|
|
60
|
+
JSON.stringify(lockedPackage.source))
|
|
61
|
+
findings.push({
|
|
62
|
+
level: "error",
|
|
63
|
+
code: "source-mismatch",
|
|
64
|
+
message: `Package '${lockedPackage.id}' source differs between manifest and lockfile.`,
|
|
65
|
+
});
|
|
66
|
+
const installed = stateById.get(lockedPackage.id);
|
|
67
|
+
if (!installed) {
|
|
68
|
+
findings.push({
|
|
69
|
+
level: "warning",
|
|
70
|
+
code: "not-installed",
|
|
71
|
+
message: `Package '${lockedPackage.id}' is locked but not installed on this machine.`,
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if ((lockedPackage.resolvedCommit ?? "") !== (installed.resolvedCommit ?? ""))
|
|
76
|
+
findings.push({
|
|
77
|
+
level: "error",
|
|
78
|
+
code: "commit-mismatch",
|
|
79
|
+
message: `Package '${lockedPackage.id}' installed revision differs from the lockfile.`,
|
|
80
|
+
});
|
|
81
|
+
const lockedFiles = new Map(lockedPackage.files.map((file) => [file.path, file.sha256]));
|
|
82
|
+
const installedFiles = new Set(installed.files.map((file) => file.path));
|
|
83
|
+
for (const path of lockedFiles.keys())
|
|
84
|
+
if (!installedFiles.has(path))
|
|
85
|
+
findings.push({
|
|
86
|
+
level: "error",
|
|
87
|
+
code: "missing-state-file",
|
|
88
|
+
message: `Lockfile-managed path is missing from installed state: ${path}`,
|
|
89
|
+
});
|
|
90
|
+
for (const file of installed.files) {
|
|
91
|
+
if (lockedFiles.get(file.path) !== file.sha256)
|
|
92
|
+
findings.push({
|
|
93
|
+
level: "error",
|
|
94
|
+
code: "state-lock-mismatch",
|
|
95
|
+
message: `Managed file record differs from lockfile: ${file.path}`,
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
const digest = createHash("sha256")
|
|
99
|
+
.update(await readFile(managedFileReadPath(installed.packageId, file.path, state.activations ?? [])))
|
|
100
|
+
.digest("hex");
|
|
101
|
+
if (digest !== file.sha256)
|
|
102
|
+
findings.push({
|
|
103
|
+
level: "error",
|
|
104
|
+
code: "file-drift",
|
|
105
|
+
message: `Managed file changed or is corrupt: ${file.path}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
findings.push({
|
|
110
|
+
level: "error",
|
|
111
|
+
code: "file-missing",
|
|
112
|
+
message: `Managed file is missing: ${file.path}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const stateMcp = state.mcpInstalls ?? [];
|
|
118
|
+
for (const lockedMcp of lockfile.mcpServers ?? []) {
|
|
119
|
+
const installed = stateMcp.find((entry) => entry.packageId === lockedMcp.packageId &&
|
|
120
|
+
entry.configPath === lockedMcp.configPath &&
|
|
121
|
+
entry.serverName === lockedMcp.serverName);
|
|
122
|
+
if (!installed) {
|
|
123
|
+
findings.push({
|
|
124
|
+
level: "error",
|
|
125
|
+
code: "missing-mcp-state",
|
|
126
|
+
message: `MCP server '${lockedMcp.serverName}' for '${lockedMcp.packageId}' is missing from installed state.`,
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (installed.fingerprint !== lockedMcp.fingerprint)
|
|
131
|
+
findings.push({
|
|
132
|
+
level: "error",
|
|
133
|
+
code: "mcp-lock-mismatch",
|
|
134
|
+
message: `MCP server '${lockedMcp.serverName}' state differs from the lockfile.`,
|
|
135
|
+
});
|
|
136
|
+
try {
|
|
137
|
+
const config = JSON.parse(await readFile(lockedMcp.configPath, "utf8"));
|
|
138
|
+
const fingerprint = createHash("sha256")
|
|
139
|
+
.update(JSON.stringify(config.mcpServers?.[lockedMcp.serverName] ?? null))
|
|
140
|
+
.digest("hex");
|
|
141
|
+
if (fingerprint !== lockedMcp.fingerprint)
|
|
142
|
+
findings.push({
|
|
143
|
+
level: "error",
|
|
144
|
+
code: "mcp-drift",
|
|
145
|
+
message: `MCP server '${lockedMcp.serverName}' changed or is missing in ${lockedMcp.configPath}.`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
findings.push({
|
|
150
|
+
level: "error",
|
|
151
|
+
code: "mcp-config-missing",
|
|
152
|
+
message: `MCP configuration is missing or invalid: ${lockedMcp.configPath}`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!findings.length)
|
|
157
|
+
findings.push({
|
|
158
|
+
level: "ok",
|
|
159
|
+
code: "reproducible",
|
|
160
|
+
message: "Manifest, lockfile, installed state, and managed file hashes agree.",
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
valid: !findings.some((finding) => finding.level === "error"),
|
|
164
|
+
manifest: resolve(manifestPath),
|
|
165
|
+
lockfile: resolve(lockPath),
|
|
166
|
+
findings,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export function formatAuditReport(report) {
|
|
170
|
+
return [
|
|
171
|
+
`Loadout audit: ${report.valid ? "PASS" : "FAIL"}`,
|
|
172
|
+
...report.findings.map((finding) => `${finding.level === "ok" ? "✓" : finding.level === "warning" ? "!" : "✗"} ${finding.message}`),
|
|
173
|
+
].join("\n");
|
|
174
|
+
}
|