@aron98/load-lock 1.4.0 → 1.6.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/README.md +61 -14
- package/dist/adapters/opencode/plugin.js +15 -1
- package/dist/adapters/opencode/tui.js +7 -2
- package/dist/cli/copies.d.ts +3 -10
- package/dist/cli/copies.js +32 -44
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +68 -14
- package/dist/cli/main.js +108 -211
- package/dist/cli/service.d.ts +56 -0
- package/dist/cli/service.js +261 -0
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +57 -0
- package/dist/cli/update.js +287 -0
- package/dist/core/daemon.d.ts +29 -3
- package/dist/core/daemon.js +111 -11
- package/dist/core/persist.d.ts +3 -0
- package/dist/core/persist.js +49 -10
- package/dist/core/state.d.ts +9 -0
- package/dist/core/state.js +41 -3
- package/dist/dashboard/feed.d.ts +23 -0
- package/dist/dashboard/feed.js +64 -2
- package/dist/dashboard/frame.js +2 -0
- package/dist/dashboard/view.d.ts +11 -0
- package/dist/dashboard/view.js +13 -0
- package/dist/mcp/server.d.ts +11 -3
- package/dist/mcp/server.js +38 -6
- package/dist/policy/text.d.ts +13 -3
- package/dist/policy/text.js +14 -3
- package/dist/shared/agent-guide.d.ts +3 -0
- package/dist/shared/agent-guide.js +16 -0
- package/dist/shared/client.js +16 -4
- package/dist/shared/drift.d.ts +23 -0
- package/dist/shared/drift.js +95 -0
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/installs.d.ts +39 -0
- package/dist/shared/installs.js +101 -0
- package/dist/shared/pkginfo.d.ts +25 -0
- package/dist/shared/pkginfo.js +70 -0
- package/dist/shared/protocol.d.ts +22 -0
- package/dist/shared/protocol.js +15 -0
- package/dist/shared/runtimes.d.ts +41 -0
- package/dist/shared/runtimes.js +81 -0
- package/dist/shared/version.d.ts +6 -0
- package/dist/shared/version.js +9 -1
- package/dist/test/ace-mcp.test.d.ts +1 -0
- package/dist/test/ace-mcp.test.js +157 -0
- package/dist/test/ace-opencode.test.d.ts +1 -0
- package/dist/test/ace-opencode.test.js +287 -0
- package/dist/test/check-pins.test.d.ts +1 -0
- package/dist/test/check-pins.test.js +108 -0
- package/dist/test/compat.test.d.ts +1 -0
- package/dist/test/compat.test.js +190 -0
- package/dist/test/hook.test.js +179 -1
- package/dist/test/identity.test.d.ts +1 -0
- package/dist/test/identity.test.js +155 -0
- package/dist/test/install.test.js +135 -1
- package/dist/test/launcher.test.d.ts +1 -0
- package/dist/test/launcher.test.js +222 -0
- package/dist/test/mcp.test.js +76 -7
- package/dist/test/plugin.test.d.ts +1 -0
- package/dist/test/plugin.test.js +144 -0
- package/dist/test/reconnect.test.d.ts +1 -0
- package/dist/test/reconnect.test.js +389 -0
- package/dist/test/runtimes.test.d.ts +1 -0
- package/dist/test/runtimes.test.js +282 -0
- package/dist/test/setup.test.d.ts +1 -0
- package/dist/test/setup.test.js +354 -0
- package/dist/test/update-command.test.d.ts +1 -0
- package/dist/test/update-command.test.js +679 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +48 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +156 -13
- package/docs/OPERATIONS.md +407 -21
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* the extra field (forward-compatible); newer daemons use it
|
|
12
12
|
* for the update handshake: a client strictly newer than the
|
|
13
13
|
* running daemon triggers a daemon self-restart (issue #34).
|
|
14
|
+
* Since #52 the envelope also carries "clientRoot" (the client
|
|
15
|
+
* copy's package root); the daemon records it as a known
|
|
16
|
+
* runtime copy (runtimes.json). Also ignored by older daemons.
|
|
14
17
|
* Response: {"id": <number>, "ok": true, "result": {...}}
|
|
15
18
|
* {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
|
|
16
19
|
* Watch push: {"event": true, ...event fields...}
|
|
@@ -139,6 +142,8 @@ export interface OwnerEntrySnapshot extends EntrySnapshot {
|
|
|
139
142
|
permitToken?: string;
|
|
140
143
|
}
|
|
141
144
|
export interface JournalEntry {
|
|
145
|
+
/** Persisted format version of the writer (issue #51); absent before versioning. */
|
|
146
|
+
formatVersion?: number;
|
|
142
147
|
requestId: string;
|
|
143
148
|
pool: string;
|
|
144
149
|
agentId: string;
|
|
@@ -438,3 +443,20 @@ export interface HolderDiag {
|
|
|
438
443
|
note: string;
|
|
439
444
|
};
|
|
440
445
|
}
|
|
446
|
+
export declare const HOOK_ONCE_DEFAULT_TTL_MS = 60000;
|
|
447
|
+
export declare const HOOK_ONCE_MAX_TTL_MS: number;
|
|
448
|
+
export interface HookOnceParams {
|
|
449
|
+
harness: string;
|
|
450
|
+
sessionId: string;
|
|
451
|
+
event: string;
|
|
452
|
+
/** Distinguishes legitimate repeats within one session (e.g. SessionStart source, offer token). */
|
|
453
|
+
occurrence?: string;
|
|
454
|
+
/** Key lifetime, ms (clamped to [1s, HOOK_ONCE_MAX_TTL_MS]); ignored with hold. */
|
|
455
|
+
ttlMs?: number;
|
|
456
|
+
/** Hold the key until this connection closes instead of a TTL. */
|
|
457
|
+
hold?: boolean;
|
|
458
|
+
}
|
|
459
|
+
export interface HookOnceResult {
|
|
460
|
+
/** True when the caller is the first for this key and should act. */
|
|
461
|
+
first: boolean;
|
|
462
|
+
}
|
package/dist/shared/protocol.js
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* the extra field (forward-compatible); newer daemons use it
|
|
12
12
|
* for the update handshake: a client strictly newer than the
|
|
13
13
|
* running daemon triggers a daemon self-restart (issue #34).
|
|
14
|
+
* Since #52 the envelope also carries "clientRoot" (the client
|
|
15
|
+
* copy's package root); the daemon records it as a known
|
|
16
|
+
* runtime copy (runtimes.json). Also ignored by older daemons.
|
|
14
17
|
* Response: {"id": <number>, "ok": true, "result": {...}}
|
|
15
18
|
* {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
|
|
16
19
|
* Watch push: {"event": true, ...event fields...}
|
|
@@ -25,3 +28,15 @@ export class ProtocolError extends Error {
|
|
|
25
28
|
this.details = details;
|
|
26
29
|
}
|
|
27
30
|
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// hook_once (issue #54): daemon-side dedupe for duplicate hook wiring.
|
|
33
|
+
// Loose hooks + plugin hooks (or two plugins) fire the same harness hook
|
|
34
|
+
// more than once. Each hook handler first claims the key
|
|
35
|
+
// (harness, sessionId, event, occurrence); only the first caller acts, the
|
|
36
|
+
// rest exit 0 silently. Keys are in-memory only (never persisted, gone on
|
|
37
|
+
// daemon restart) and expire after ttlMs — or, with hold, live exactly as
|
|
38
|
+
// long as the calling connection (long-running await-offer watchers).
|
|
39
|
+
// Daemons without the method answer `unknown method`; callers then act as
|
|
40
|
+
// before (no dedupe).
|
|
41
|
+
export const HOOK_ONCE_DEFAULT_TTL_MS = 60_000;
|
|
42
|
+
export const HOOK_ONCE_MAX_TTL_MS = 10 * 60_000;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface RuntimeCopy {
|
|
2
|
+
/** Real path of the copy's package root. */
|
|
3
|
+
path: string;
|
|
4
|
+
/** Version read from the copy's package.json (not from the client). */
|
|
5
|
+
version: string;
|
|
6
|
+
/** Epoch ms of the last handshake from this copy. */
|
|
7
|
+
lastSeen: number;
|
|
8
|
+
}
|
|
9
|
+
/** daemon.json contents (fields beyond pid/startedAt/socket since #52). */
|
|
10
|
+
export interface DaemonInfo {
|
|
11
|
+
pid: number;
|
|
12
|
+
startedAt: number;
|
|
13
|
+
socket: string;
|
|
14
|
+
/** Package root the daemon runs from (since #52). */
|
|
15
|
+
packageRoot?: string;
|
|
16
|
+
/** Package version the daemon runs (since #52). */
|
|
17
|
+
version?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Cap on remembered copies; the least recently seen are dropped first. */
|
|
20
|
+
export declare const MAX_RUNTIMES = 32;
|
|
21
|
+
export declare function runtimesPath(stateDir: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Validate a package root: it must hold a package.json naming
|
|
24
|
+
* @aron98/load-lock with a string version. Returns the real path and the
|
|
25
|
+
* on-disk version, or null.
|
|
26
|
+
*/
|
|
27
|
+
export declare function inspectPackageRoot(root: string): {
|
|
28
|
+
path: string;
|
|
29
|
+
version: string;
|
|
30
|
+
} | null;
|
|
31
|
+
/** Known copies from the state dir, dropping entries whose path is gone. */
|
|
32
|
+
export declare function loadRuntimes(stateDir: string): RuntimeCopy[];
|
|
33
|
+
export declare function saveRuntimes(stateDir: string, copies: RuntimeCopy[]): void;
|
|
34
|
+
/**
|
|
35
|
+
* Merge one sighting into the list (dedupe by real path, newest first,
|
|
36
|
+
* capped). Returns the new list, or null when the root is not a valid
|
|
37
|
+
* load-lock copy.
|
|
38
|
+
*/
|
|
39
|
+
export declare function mergeRuntime(copies: RuntimeCopy[], root: string, now: number): RuntimeCopy[] | null;
|
|
40
|
+
/** daemon.json contents, or null when missing/unreadable. */
|
|
41
|
+
export declare function readDaemonInfo(stateDir: string): DaemonInfo | null;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-copy bookkeeping (issue #52): which load-lock copies (package
|
|
3
|
+
* roots) have talked to this machine's daemon.
|
|
4
|
+
*
|
|
5
|
+
* The daemon records its own runtime in `daemon.json` and every copy seen
|
|
6
|
+
* via the client handshake (`clientRoot` in the request envelope) in
|
|
7
|
+
* `runtimes.json` next to it. Small entry points (skill, plugin launchers)
|
|
8
|
+
* and `doctor` read these to find an existing runtime without npx.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is best-effort: unreadable/missing files yield empty
|
|
11
|
+
* lists, never a throw. A client's path hint is never trusted for the
|
|
12
|
+
* version — it is re-read from the copy's own package.json on disk.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { PKG_NAME } from "./pkginfo.js";
|
|
17
|
+
/** Cap on remembered copies; the least recently seen are dropped first. */
|
|
18
|
+
export const MAX_RUNTIMES = 32;
|
|
19
|
+
export function runtimesPath(stateDir) {
|
|
20
|
+
return join(stateDir, "runtimes.json");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Validate a package root: it must hold a package.json naming
|
|
24
|
+
* @aron98/load-lock with a string version. Returns the real path and the
|
|
25
|
+
* on-disk version, or null.
|
|
26
|
+
*/
|
|
27
|
+
export function inspectPackageRoot(root) {
|
|
28
|
+
try {
|
|
29
|
+
const real = realpathSync(root);
|
|
30
|
+
const pkg = JSON.parse(readFileSync(join(real, "package.json"), "utf8"));
|
|
31
|
+
if (pkg?.name !== PKG_NAME || typeof pkg.version !== "string")
|
|
32
|
+
return null;
|
|
33
|
+
return { path: real, version: pkg.version };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Known copies from the state dir, dropping entries whose path is gone. */
|
|
40
|
+
export function loadRuntimes(stateDir) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(readFileSync(runtimesPath(stateDir), "utf8"));
|
|
43
|
+
const list = Array.isArray(raw?.copies) ? raw.copies : [];
|
|
44
|
+
return list.filter((c) => typeof c?.path === "string" &&
|
|
45
|
+
typeof c.version === "string" &&
|
|
46
|
+
typeof c.lastSeen === "number" &&
|
|
47
|
+
existsSync(join(c.path, "package.json")));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function saveRuntimes(stateDir, copies) {
|
|
54
|
+
const path = runtimesPath(stateDir);
|
|
55
|
+
const tmp = join(stateDir, ".runtimes.json.tmp-" + process.pid);
|
|
56
|
+
writeFileSync(tmp, JSON.stringify({ copies }, null, 2) + "\n", { mode: 0o644 });
|
|
57
|
+
renameSync(tmp, path);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Merge one sighting into the list (dedupe by real path, newest first,
|
|
61
|
+
* capped). Returns the new list, or null when the root is not a valid
|
|
62
|
+
* load-lock copy.
|
|
63
|
+
*/
|
|
64
|
+
export function mergeRuntime(copies, root, now) {
|
|
65
|
+
const found = inspectPackageRoot(root);
|
|
66
|
+
if (!found)
|
|
67
|
+
return null;
|
|
68
|
+
const rest = copies.filter((c) => c.path !== found.path);
|
|
69
|
+
return [{ path: found.path, version: found.version, lastSeen: now }, ...rest]
|
|
70
|
+
.sort((a, b) => b.lastSeen - a.lastSeen)
|
|
71
|
+
.slice(0, MAX_RUNTIMES);
|
|
72
|
+
}
|
|
73
|
+
/** daemon.json contents, or null when missing/unreadable. */
|
|
74
|
+
export function readDaemonInfo(stateDir) {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(join(stateDir, "daemon.json"), "utf8"));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/dist/shared/version.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package root of THIS copy (the directory holding its package.json):
|
|
3
|
+
* dist/shared/version.js → <package root>. Sent in the request envelope as
|
|
4
|
+
* `clientRoot` and recorded by the daemon (issue #52).
|
|
5
|
+
*/
|
|
6
|
+
export declare function packageRoot(): string;
|
|
1
7
|
/** Version of THIS copy of load-lock (the on-disk install running now). */
|
|
2
8
|
export declare function packageVersion(): string;
|
|
3
9
|
/**
|
package/dist/shared/version.js
CHANGED
|
@@ -13,6 +13,14 @@ import { readFileSync } from "node:fs";
|
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
let cached;
|
|
16
|
+
/**
|
|
17
|
+
* Package root of THIS copy (the directory holding its package.json):
|
|
18
|
+
* dist/shared/version.js → <package root>. Sent in the request envelope as
|
|
19
|
+
* `clientRoot` and recorded by the daemon (issue #52).
|
|
20
|
+
*/
|
|
21
|
+
export function packageRoot() {
|
|
22
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
23
|
+
}
|
|
16
24
|
/** Version of THIS copy of load-lock (the on-disk install running now). */
|
|
17
25
|
export function packageVersion() {
|
|
18
26
|
if (cached === undefined) {
|
|
@@ -20,7 +28,7 @@ export function packageVersion() {
|
|
|
20
28
|
// dist/shared/version.js → <package root>/package.json — resolves the
|
|
21
29
|
// same way in the repo checkout, a global CLI install, and an
|
|
22
30
|
// opencode cache copy. npm always ships package.json with the tarball.
|
|
23
|
-
const pkgPath = join(
|
|
31
|
+
const pkgPath = join(packageRoot(), "package.json");
|
|
24
32
|
cached = String(JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0");
|
|
25
33
|
}
|
|
26
34
|
catch {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract test for the ACE MCP artifact (issue #65): `ace/mcp/mcp.json`
|
|
3
|
+
* must validate against ACE's manifest schema, as found in the #64 spike,
|
|
4
|
+
* and the folder must pass ACE's file-level rules. Light: files only, no
|
|
5
|
+
* daemon. (`load-lock mcp` itself is exercised in mcp.test.ts.)
|
|
6
|
+
*
|
|
7
|
+
* Source of truth, copied verbatim below:
|
|
8
|
+
* https://github.com/TNG/ace/blob/b703410218833071800cc918cb95718e0404640a/backend/app/domain/kinds/mcp_manifest.schema.json
|
|
9
|
+
* Rules from the same revision: backend/app/domain/kinds/MCP.md (inline
|
|
10
|
+
* secrets), compliance_mcp_rule_checks_service.py (`_first_non_flag`,
|
|
11
|
+
* companion-file roots), artifact_import_zip_safety_service.py (forbidden
|
|
12
|
+
* names in a non-plugin bundle).
|
|
13
|
+
*/
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
17
|
+
import { join, dirname, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
20
|
+
const ACE_MCP_DIR = join(REPO_ROOT, "ace", "mcp");
|
|
21
|
+
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8"));
|
|
22
|
+
const ACE_MCP_SCHEMA = {
|
|
23
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
24
|
+
type: "object",
|
|
25
|
+
required: ["name", "transport"],
|
|
26
|
+
properties: {
|
|
27
|
+
name: { type: "string", minLength: 1 },
|
|
28
|
+
description: { type: "string" },
|
|
29
|
+
transport: { type: "string", enum: ["stdio", "http", "sse"] },
|
|
30
|
+
command: { type: "string", minLength: 1 },
|
|
31
|
+
args: { type: "array", items: { type: "string" } },
|
|
32
|
+
env: { type: "object", additionalProperties: { type: "string" } },
|
|
33
|
+
cwd: { type: "string" },
|
|
34
|
+
companion_files: { type: "array", items: { type: "string" } },
|
|
35
|
+
url: { type: "string", format: "uri", pattern: "^https://" },
|
|
36
|
+
headers: { type: "object", additionalProperties: { type: "string" } },
|
|
37
|
+
},
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
oneOf: [
|
|
40
|
+
{
|
|
41
|
+
title: "Stdio (local) MCP",
|
|
42
|
+
properties: { transport: { const: "stdio" } },
|
|
43
|
+
required: ["transport", "command"],
|
|
44
|
+
not: { anyOf: [{ required: ["url"] }, { required: ["headers"] }] },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
title: "HTTP/SSE (remote) MCP",
|
|
48
|
+
properties: { transport: { enum: ["http", "sse"] } },
|
|
49
|
+
required: ["transport", "url"],
|
|
50
|
+
not: {
|
|
51
|
+
anyOf: [
|
|
52
|
+
{ required: ["command"] },
|
|
53
|
+
{ required: ["args"] },
|
|
54
|
+
{ required: ["env"] },
|
|
55
|
+
{ required: ["cwd"] },
|
|
56
|
+
{ required: ["companion_files"] },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Minimal JSON Schema check covering exactly the keywords the ACE schema
|
|
64
|
+
* uses (`format` is annotation-only in 2020-12). Returns error paths.
|
|
65
|
+
*/
|
|
66
|
+
function validate(schema, v, path = "$") {
|
|
67
|
+
const errs = [];
|
|
68
|
+
const isObj = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
|
|
69
|
+
if (schema.type) {
|
|
70
|
+
const ok = schema.type === "array" ? Array.isArray(v) : schema.type === "object" ? isObj(v) : typeof v === schema.type;
|
|
71
|
+
if (!ok)
|
|
72
|
+
return [`${path}: expected ${schema.type}`];
|
|
73
|
+
}
|
|
74
|
+
if ("const" in schema && v !== schema.const)
|
|
75
|
+
errs.push(`${path}: expected ${JSON.stringify(schema.const)}`);
|
|
76
|
+
if (schema.enum && !schema.enum.includes(v))
|
|
77
|
+
errs.push(`${path}: not one of ${schema.enum.join("|")}`);
|
|
78
|
+
if (schema.minLength !== undefined && typeof v === "string" && v.length < schema.minLength)
|
|
79
|
+
errs.push(`${path}: too short`);
|
|
80
|
+
if (schema.pattern && typeof v === "string" && !new RegExp(schema.pattern, "u").test(v))
|
|
81
|
+
errs.push(`${path}: pattern`);
|
|
82
|
+
if (schema.items && Array.isArray(v))
|
|
83
|
+
v.forEach((x, i) => errs.push(...validate(schema.items, x, `${path}[${i}]`)));
|
|
84
|
+
if (isObj(v)) {
|
|
85
|
+
for (const k of schema.required ?? [])
|
|
86
|
+
if (!(k in v))
|
|
87
|
+
errs.push(`${path}: missing ${k}`);
|
|
88
|
+
for (const [k, x] of Object.entries(v)) {
|
|
89
|
+
if (schema.properties && k in schema.properties)
|
|
90
|
+
errs.push(...validate(schema.properties[k], x, `${path}.${k}`));
|
|
91
|
+
else if (schema.additionalProperties === false)
|
|
92
|
+
errs.push(`${path}.${k}: unknown field`);
|
|
93
|
+
else if (isObj(schema.additionalProperties))
|
|
94
|
+
errs.push(...validate(schema.additionalProperties, x, `${path}.${k}`));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (schema.anyOf && !schema.anyOf.some((s) => validate(s, v, path).length === 0))
|
|
98
|
+
errs.push(`${path}: anyOf`);
|
|
99
|
+
if (schema.not && validate(schema.not, v, path).length === 0)
|
|
100
|
+
errs.push(`${path}: matches not`);
|
|
101
|
+
if (schema.oneOf) {
|
|
102
|
+
const n = schema.oneOf.filter((s) => validate(s, v, path).length === 0).length;
|
|
103
|
+
if (n !== 1)
|
|
104
|
+
errs.push(`${path}: matches ${n} oneOf branches`);
|
|
105
|
+
}
|
|
106
|
+
return errs;
|
|
107
|
+
}
|
|
108
|
+
/** ACE's `_first_non_flag`: the package the npx shim will fetch, shown to reviewers. */
|
|
109
|
+
function firstNonFlag(args) {
|
|
110
|
+
return args.find((a) => !a.startsWith("-") && a !== "dlx");
|
|
111
|
+
}
|
|
112
|
+
const SECRET_KEY = /^(authorization|.*token|.*key|.*secret)$/i;
|
|
113
|
+
const manifest = JSON.parse(readFileSync(join(ACE_MCP_DIR, "mcp.json"), "utf8"));
|
|
114
|
+
test("ace mcp.json: validates against ACE's manifest schema", () => {
|
|
115
|
+
assert.deepEqual(validate(ACE_MCP_SCHEMA, manifest), []);
|
|
116
|
+
});
|
|
117
|
+
test("ace mcp.json: the validator rejects what ACE rejects", () => {
|
|
118
|
+
const ok = { name: "x", transport: "stdio", command: "npx" };
|
|
119
|
+
assert.deepEqual(validate(ACE_MCP_SCHEMA, ok), []);
|
|
120
|
+
assert.notDeepEqual(validate(ACE_MCP_SCHEMA, { ...ok, mode: "local" }), [], "unknown field (no `mode`)");
|
|
121
|
+
assert.notDeepEqual(validate(ACE_MCP_SCHEMA, { name: "x", transport: "stdio" }), [], "stdio without command");
|
|
122
|
+
assert.notDeepEqual(validate(ACE_MCP_SCHEMA, { ...ok, url: "https://x" }), [], "stdio with url");
|
|
123
|
+
assert.notDeepEqual(validate(ACE_MCP_SCHEMA, { ...ok, transport: "local" }), [], "bad transport");
|
|
124
|
+
assert.notDeepEqual(validate(ACE_MCP_SCHEMA, { ...ok, args: ["a", 1] }), [], "non-string arg");
|
|
125
|
+
assert.notDeepEqual(validate(ACE_MCP_SCHEMA, { ...ok, name: "" }), [], "empty name");
|
|
126
|
+
});
|
|
127
|
+
test("ace mcp.json: local npx server running `load-lock mcp` at the package.json pin", () => {
|
|
128
|
+
assert.equal(manifest.name, "load-lock");
|
|
129
|
+
assert.equal(manifest.transport, "stdio", "local only, never remote");
|
|
130
|
+
assert.equal(manifest.command, "npx");
|
|
131
|
+
assert.deepEqual(manifest.args, ["-y", `${pkg.name}@${pkg.version}`, "mcp"]);
|
|
132
|
+
// ACE's mcp_local_command_safe shows reviewers this as the fetched package.
|
|
133
|
+
assert.equal(firstNonFlag(manifest.args), `${pkg.name}@${pkg.version}`);
|
|
134
|
+
// npx runs the bin named after the unscoped package; it must be the CLI.
|
|
135
|
+
const unscoped = String(pkg.name).split("/").pop();
|
|
136
|
+
assert.equal(pkg.bin[unscoped], "dist/cli/main.js");
|
|
137
|
+
// `cwd` is dropped from ACE's install patch: never rely on it.
|
|
138
|
+
assert.equal(manifest.cwd, undefined);
|
|
139
|
+
for (const [k, v] of Object.entries(manifest.env ?? {})) {
|
|
140
|
+
if (SECRET_KEY.test(k))
|
|
141
|
+
assert.match(String(v), /\$\{[^}]+\}/, `env ${k} must be a \${VAR} placeholder`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
test("ace/mcp folder: only mcp.json + README, nothing ACE's bundle rules would flag", () => {
|
|
145
|
+
const files = readdirSync(ACE_MCP_DIR).sort();
|
|
146
|
+
assert.deepEqual(files, ["README.md", "mcp.json"]);
|
|
147
|
+
// companion_files roots (must match companion_files) and names forbidden in a non-plugin bundle.
|
|
148
|
+
for (const bad of ["bin", "scripts", "config", "references", ".claude-plugin", ".mcp.json", ".lsp.json", ".opencode"]) {
|
|
149
|
+
assert.ok(!files.includes(bad), bad);
|
|
150
|
+
}
|
|
151
|
+
assert.equal(manifest.companion_files, undefined);
|
|
152
|
+
});
|
|
153
|
+
test("ace mcp.json: pin is declared in scripts/check-pins.mjs", async () => {
|
|
154
|
+
const pins = (await import(pathToFileURL(join(REPO_ROOT, "scripts", "check-pins.mjs")).href));
|
|
155
|
+
assert.ok(pins.PINNED.some((e) => e.path === "ace/mcp/mcp.json" && e.kind === "spec"));
|
|
156
|
+
assert.deepEqual(pins.checkPins(REPO_ROOT, pkg.version), []);
|
|
157
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|