@aron98/load-lock 1.5.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/cli/copies.d.ts +3 -2
- package/dist/cli/copies.js +32 -3
- package/dist/cli/hook.js +48 -4
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +67 -13
- package/dist/cli/main.js +62 -5
- package/dist/cli/setup.d.ts +131 -0
- package/dist/cli/setup.js +942 -0
- package/dist/cli/update.d.ts +5 -1
- package/dist/cli/update.js +16 -3
- 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/mcp/server.d.ts +4 -3
- package/dist/mcp/server.js +29 -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 +5 -3
- package/dist/shared/drift.d.ts +4 -2
- package/dist/shared/drift.js +24 -6
- package/dist/shared/identity.d.ts +51 -0
- package/dist/shared/identity.js +116 -0
- package/dist/shared/pkginfo.d.ts +9 -0
- package/dist/shared/pkginfo.js +16 -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/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.js +71 -0
- package/dist/test/update.test.js +33 -1
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +36 -2
- package/docs/ARCHITECTURE.md +122 -7
- package/docs/COMPATIBILITY.md +142 -13
- package/docs/OPERATIONS.md +292 -14
- package/package.json +3 -2
- package/skills/load-lock/SKILL.md +62 -0
|
@@ -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 {};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACE detection of the `ace/opencode/` pointer folder (issue #79).
|
|
3
|
+
*
|
|
4
|
+
* Reimplements ACE's repo-sync discovery as of TNG/ace@b703410
|
|
5
|
+
* (https://github.com/TNG/ace/tree/b703410218833071800cc918cb95718e0404640a):
|
|
6
|
+
* - backend/app/features/repo_ingestion/service/repo_ingest/
|
|
7
|
+
* repo_ingestion_archive_discovery_service.py: `_discover`,
|
|
8
|
+
* `_opencode_local_candidates`, `_npm_plugin_candidates`
|
|
9
|
+
* - backend/app/domain/kinds/__init__.py: `detect_kind`,
|
|
10
|
+
* `entry_point_filenames` (SPEC order skill, subagent, mcp, policy, plugin)
|
|
11
|
+
* - backend/app/domain/kinds/plugin_kind.py: `ENTRY_POINT_PATH`,
|
|
12
|
+
* `OPENCODE_LOCAL_ENTRY_POINT`, `OPENCODE_PLUGIN_DIRS`,
|
|
13
|
+
* `opencode_local_plugin_dir`, `_resolve_package_main`,
|
|
14
|
+
* `resolve_npm_entry_point`, `is_opencode_npm_candidate`,
|
|
15
|
+
* `derive_npm_plugin_name`
|
|
16
|
+
* and runs it over this repo's git tree (what a GitHub zipball contains).
|
|
17
|
+
* Light: files only, no daemon.
|
|
18
|
+
*/
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { join, dirname, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const REPO_ROOT = resolve(HERE, "..", "..");
|
|
28
|
+
// plugin_kind.py
|
|
29
|
+
const ENTRY_POINT_PATH = ".claude-plugin/plugin.json";
|
|
30
|
+
const OPENCODE_LOCAL_ENTRY_POINT = ".opencode/plugins/plugin.ts";
|
|
31
|
+
const OPENCODE_PLUGIN_DIRS = [".opencode/plugins", ".opencode/plugin"];
|
|
32
|
+
const JS_ENTRY_EXTENSIONS = [".ts", ".js", ".mjs", ".cjs"];
|
|
33
|
+
// kinds/__init__.py: each SPEC's entry_point_filename, in SPEC order.
|
|
34
|
+
const KIND_ENTRIES = [
|
|
35
|
+
["skill", ["SKILL.md"]],
|
|
36
|
+
["subagent", ["AGENT.md"]],
|
|
37
|
+
["mcp", ["mcp.json"]],
|
|
38
|
+
["policy", ["policy.md"]],
|
|
39
|
+
["plugin", [ENTRY_POINT_PATH, OPENCODE_LOCAL_ENTRY_POINT]],
|
|
40
|
+
];
|
|
41
|
+
function detectKind(names) {
|
|
42
|
+
const matched = KIND_ENTRIES.filter(([, entries]) => entries.some((e) => names.has(e))).map(([k]) => k);
|
|
43
|
+
return matched.length === 1 ? matched[0] : null;
|
|
44
|
+
}
|
|
45
|
+
function loadPackageJson(files) {
|
|
46
|
+
const raw = files.get("package.json");
|
|
47
|
+
if (raw === undefined)
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const m = JSON.parse(raw);
|
|
51
|
+
return m && typeof m === "object" && !Array.isArray(m) ? m : null;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const normaliseRelpath = (p) => {
|
|
58
|
+
p = p.replace(/\\/g, "/");
|
|
59
|
+
return p.startsWith("./") ? p.slice(2) : p;
|
|
60
|
+
};
|
|
61
|
+
function resolvePackageMain(m) {
|
|
62
|
+
const main = m.main;
|
|
63
|
+
if (typeof main === "string" && main.trim())
|
|
64
|
+
return normaliseRelpath(main);
|
|
65
|
+
const exp = m.exports;
|
|
66
|
+
let candidate = null;
|
|
67
|
+
if (typeof exp === "string")
|
|
68
|
+
candidate = exp;
|
|
69
|
+
else if (exp && typeof exp === "object") {
|
|
70
|
+
const root = exp["."];
|
|
71
|
+
if (typeof root === "string")
|
|
72
|
+
candidate = root;
|
|
73
|
+
else if (root && typeof root === "object") {
|
|
74
|
+
const r = root;
|
|
75
|
+
candidate = r.import || r.require || r.default; // Python `or` chain
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return typeof candidate === "string" ? normaliseRelpath(candidate) : null;
|
|
79
|
+
}
|
|
80
|
+
function opencodeLocalPluginDir(path) {
|
|
81
|
+
const p = path.replace(/\\/g, "/");
|
|
82
|
+
if (!JS_ENTRY_EXTENSIONS.some((e) => p.endsWith(e)))
|
|
83
|
+
return null;
|
|
84
|
+
for (const pdir of OPENCODE_PLUGIN_DIRS) {
|
|
85
|
+
const seg = pdir + "/";
|
|
86
|
+
if (p.startsWith(seg)) {
|
|
87
|
+
if (!p.slice(seg.length).includes("/"))
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const marker = "/" + seg;
|
|
92
|
+
const i = p.indexOf(marker);
|
|
93
|
+
if (i !== -1 && !p.slice(i + marker.length).includes("/"))
|
|
94
|
+
return p.slice(0, i);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
function resolveNpmEntryPoint(files) {
|
|
100
|
+
const m = loadPackageJson(files);
|
|
101
|
+
if (m === null)
|
|
102
|
+
return null;
|
|
103
|
+
const entry = resolvePackageMain(m);
|
|
104
|
+
return entry !== null && files.has(entry) ? entry : null;
|
|
105
|
+
}
|
|
106
|
+
function isOpencodeNpmCandidate(files, entry) {
|
|
107
|
+
if (!JS_ENTRY_EXTENSIONS.some((e) => entry.endsWith(e)))
|
|
108
|
+
return false;
|
|
109
|
+
const m = loadPackageJson(files);
|
|
110
|
+
if (m === null)
|
|
111
|
+
return false;
|
|
112
|
+
let haystack = typeof m.name === "string" ? m.name : "";
|
|
113
|
+
if (Array.isArray(m.keywords))
|
|
114
|
+
haystack += " " + m.keywords.filter((k) => typeof k === "string").join(" ");
|
|
115
|
+
return haystack.toLowerCase().includes("opencode");
|
|
116
|
+
}
|
|
117
|
+
function deriveNpmPluginName(files) {
|
|
118
|
+
const name = loadPackageJson(files)?.name;
|
|
119
|
+
if (typeof name !== "string" || !name.trim())
|
|
120
|
+
return null;
|
|
121
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || null;
|
|
122
|
+
}
|
|
123
|
+
function npmPluginCandidates(files, claimed) {
|
|
124
|
+
const dirs = new Set();
|
|
125
|
+
for (const p of files.keys()) {
|
|
126
|
+
if (p === "package.json")
|
|
127
|
+
dirs.add("");
|
|
128
|
+
else if (p.endsWith("/package.json"))
|
|
129
|
+
dirs.add(p.slice(0, -"/package.json".length));
|
|
130
|
+
}
|
|
131
|
+
const out = new Map();
|
|
132
|
+
for (const d of dirs) {
|
|
133
|
+
if (claimed.has(d))
|
|
134
|
+
continue;
|
|
135
|
+
const prefix = d === "" ? "" : d + "/";
|
|
136
|
+
const local = new Map();
|
|
137
|
+
for (const [p, data] of files)
|
|
138
|
+
if (p === d || p.startsWith(prefix))
|
|
139
|
+
local.set(p.slice(prefix.length), data);
|
|
140
|
+
const entry = resolveNpmEntryPoint(local);
|
|
141
|
+
if (entry !== null && isOpencodeNpmCandidate(local, entry))
|
|
142
|
+
out.set(d, entry);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function discover(files) {
|
|
147
|
+
const entries = KIND_ENTRIES.flatMap(([, e]) => e);
|
|
148
|
+
const roots = new Map();
|
|
149
|
+
const add = (d, e) => (roots.get(d) ?? roots.set(d, new Set()).get(d)).add(e);
|
|
150
|
+
for (const p of files.keys()) {
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (p === entry)
|
|
153
|
+
add("", entry);
|
|
154
|
+
else if (p.endsWith("/" + entry))
|
|
155
|
+
add(p.slice(0, -(entry.length + 1)), entry);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const discovered = [];
|
|
159
|
+
const skips = [];
|
|
160
|
+
for (const [d, present] of roots) {
|
|
161
|
+
const kind = detectKind(present);
|
|
162
|
+
if (kind === null) {
|
|
163
|
+
skips.push(`${d || "<root>"}: ambiguous entry-point files ${[...present].sort().join(",")}`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
let format = null;
|
|
167
|
+
if (kind === "plugin") {
|
|
168
|
+
const claude = present.has(ENTRY_POINT_PATH);
|
|
169
|
+
if (claude && present.has(OPENCODE_LOCAL_ENTRY_POINT))
|
|
170
|
+
skips.push(`${d || "<root>"}: both Claude Code and OpenCode plugin entry points present`);
|
|
171
|
+
format = claude ? "claude" : "opencode";
|
|
172
|
+
}
|
|
173
|
+
discovered.push({ subpath: d, kind, format, npmEntryPoint: null });
|
|
174
|
+
}
|
|
175
|
+
let claimed = new Set(roots.keys());
|
|
176
|
+
const claudeDirs = new Set(discovered.filter((x) => x.kind === "plugin" && x.format === "claude").map((x) => x.subpath));
|
|
177
|
+
const localDirs = new Set();
|
|
178
|
+
for (const p of files.keys()) {
|
|
179
|
+
const d = opencodeLocalPluginDir(p);
|
|
180
|
+
if (d !== null)
|
|
181
|
+
localDirs.add(d);
|
|
182
|
+
}
|
|
183
|
+
for (const d of [...localDirs].filter((x) => !claimed.has(x)).sort()) {
|
|
184
|
+
discovered.push({ subpath: d, kind: "plugin", format: "opencode", npmEntryPoint: null });
|
|
185
|
+
skips.push(`${d || "<root>"}: detected as a local OpenCode plugin (.opencode/plugins/) — verify before registering`);
|
|
186
|
+
}
|
|
187
|
+
for (const d of [...localDirs].filter((x) => claudeDirs.has(x)).sort()) {
|
|
188
|
+
skips.push(`${d || "<root>"}: both Claude Code and OpenCode plugin entry points present`);
|
|
189
|
+
}
|
|
190
|
+
claimed = new Set([...claimed, ...localDirs]);
|
|
191
|
+
for (const [d, entry] of npmPluginCandidates(files, claimed)) {
|
|
192
|
+
discovered.push({ subpath: d, kind: "plugin", format: "opencode", npmEntryPoint: entry });
|
|
193
|
+
skips.push(`${d || "<root>"}: detected as an npm-distributed OpenCode plugin via package.json heuristic — verify before registering`);
|
|
194
|
+
}
|
|
195
|
+
return { discovered, skips };
|
|
196
|
+
}
|
|
197
|
+
// ------------------------------------------------------------ fixtures
|
|
198
|
+
/** The repo's git tree (tracked + untracked-not-ignored), as a GitHub zipball would carry it. */
|
|
199
|
+
function repoTree() {
|
|
200
|
+
const r = spawnSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: REPO_ROOT, encoding: "utf8" });
|
|
201
|
+
if (r.status !== 0)
|
|
202
|
+
return null;
|
|
203
|
+
const files = new Map();
|
|
204
|
+
for (const p of r.stdout.split("\0")) {
|
|
205
|
+
if (!p || !existsSync(join(REPO_ROOT, p)))
|
|
206
|
+
continue; // deleted-but-staged
|
|
207
|
+
files.set(p, p.endsWith(".json") ? readFileSync(join(REPO_ROOT, p), "utf8") : "");
|
|
208
|
+
}
|
|
209
|
+
return files;
|
|
210
|
+
}
|
|
211
|
+
const opencodePlugins = (d) => d.filter((x) => x.kind === "plugin" && x.format === "opencode");
|
|
212
|
+
// ------------------------------------------------------------ tests
|
|
213
|
+
test("ace-opencode: mirror matches ACE on known shapes", () => {
|
|
214
|
+
// Root package.json whose entry is gitignored build output: not detected (#64 finding).
|
|
215
|
+
const root = discover(new Map([["package.json", JSON.stringify({ name: "@aron98/load-lock", keywords: ["opencode"], exports: { ".": { default: "./dist/x.js" } } })]]));
|
|
216
|
+
assert.deepEqual(opencodePlugins(root.discovered), []);
|
|
217
|
+
// Resolvable entry but no "opencode" naming signal: not detected.
|
|
218
|
+
const noSignal = discover(new Map([["p/package.json", JSON.stringify({ name: "x", main: "i.js" })], ["p/i.js", ""]]));
|
|
219
|
+
assert.deepEqual(opencodePlugins(noSignal.discovered), []);
|
|
220
|
+
// A folder claimed by a Claude plugin manifest is never an npm candidate.
|
|
221
|
+
const claimed = discover(new Map([
|
|
222
|
+
["p/package.json", JSON.stringify({ name: "x-opencode", main: "i.js" })],
|
|
223
|
+
["p/i.js", ""],
|
|
224
|
+
["p/.claude-plugin/plugin.json", "{}"],
|
|
225
|
+
]));
|
|
226
|
+
assert.deepEqual(opencodePlugins(claimed.discovered), []);
|
|
227
|
+
// exports "." import condition wins over require/default.
|
|
228
|
+
const exp = discover(new Map([
|
|
229
|
+
["p/package.json", JSON.stringify({ name: "y", keywords: ["OpenCode"], exports: { ".": { import: "./a.mjs", default: "./b.js" } } })],
|
|
230
|
+
["p/a.mjs", ""],
|
|
231
|
+
]));
|
|
232
|
+
assert.deepEqual(opencodePlugins(exp.discovered).map((x) => [x.subpath, x.npmEntryPoint]), [["p", "a.mjs"]]);
|
|
233
|
+
});
|
|
234
|
+
test("ace-opencode: this repo yields exactly one OpenCode plugin, at ace/opencode", (t) => {
|
|
235
|
+
const files = repoTree();
|
|
236
|
+
if (files === null)
|
|
237
|
+
return t.skip("not a git checkout");
|
|
238
|
+
assert.ok(files.has("ace/opencode/package.json"), "ace/opencode/package.json must be tracked");
|
|
239
|
+
const { discovered, skips } = discover(files);
|
|
240
|
+
const oc = opencodePlugins(discovered);
|
|
241
|
+
assert.deepEqual(oc.map((x) => [x.subpath, x.npmEntryPoint]), [["ace/opencode", "index.js"]], JSON.stringify(discovered));
|
|
242
|
+
// Detected only via the npm heuristic, never via a flat entry point or a local .opencode/plugins/ file.
|
|
243
|
+
assert.ok(!discovered.some((x) => x.subpath === "ace/opencode" && x.npmEntryPoint === null));
|
|
244
|
+
// No ambiguity / dual-format skip for the folder; ACE's only note is the informational "verify before registering".
|
|
245
|
+
const ours = skips.filter((s) => s.startsWith("ace/opencode:"));
|
|
246
|
+
assert.equal(ours.length, 1, ours.join("\n"));
|
|
247
|
+
assert.match(ours[0], /npm-distributed OpenCode plugin .* verify before registering/);
|
|
248
|
+
assert.ok(!skips.some((s) => s.includes("ambiguous") || s.includes("both Claude Code and OpenCode")), skips.join("\n"));
|
|
249
|
+
// Artifact name ACE derives from package.json.
|
|
250
|
+
const bundle = new Map();
|
|
251
|
+
for (const [p, d] of files)
|
|
252
|
+
if (p.startsWith("ace/opencode/"))
|
|
253
|
+
bundle.set(p.slice("ace/opencode/".length), d);
|
|
254
|
+
assert.equal(deriveNpmPluginName(bundle), "load-lock-opencode");
|
|
255
|
+
});
|
|
256
|
+
test("ace-opencode: folder is a thin pointer to the published package, pinned to package.json", () => {
|
|
257
|
+
const dir = join(REPO_ROOT, "ace", "opencode");
|
|
258
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
259
|
+
const version = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")).version;
|
|
260
|
+
assert.equal(pkg.private, true, "never publishable by accident");
|
|
261
|
+
assert.equal(pkg.dependencies["@aron98/load-lock"], version);
|
|
262
|
+
const code = readFileSync(join(dir, pkg.main), "utf8")
|
|
263
|
+
.split("\n")
|
|
264
|
+
.filter((l) => l.trim() && !l.trim().startsWith("//"));
|
|
265
|
+
assert.deepEqual(code, [`export { default } from "@aron98/load-lock";`]);
|
|
266
|
+
});
|
|
267
|
+
test("ace-opencode: pin is declared in check-pins and --write rewrites it", async () => {
|
|
268
|
+
const script = join(REPO_ROOT, "scripts", "check-pins.mjs");
|
|
269
|
+
const pins = await import(pathToFileURL(script).href);
|
|
270
|
+
const entry = pins.PINNED.find((e) => e.path === "ace/opencode/package.json");
|
|
271
|
+
assert.deepEqual(entry, { path: "ace/opencode/package.json", kind: "json", key: "dependencies.@aron98/load-lock" });
|
|
272
|
+
const root = mkdtempSync(join(tmpdir(), "ll-aceoc-"));
|
|
273
|
+
try {
|
|
274
|
+
mkdirSync(join(root, "ace", "opencode"), { recursive: true });
|
|
275
|
+
const src = readFileSync(join(REPO_ROOT, "ace", "opencode", "package.json"), "utf8");
|
|
276
|
+
writeFileSync(join(root, "ace", "opencode", "package.json"), src.replace(/"@aron98\/load-lock": "[^"]+"/, `"@aron98/load-lock": "0.0.1"`));
|
|
277
|
+
assert.equal(pins.checkPins(root, "9.9.9", [entry]).length, 1);
|
|
278
|
+
assert.deepEqual(pins.writePins(root, "9.9.9", [entry]), ["ace/opencode/package.json"]);
|
|
279
|
+
assert.deepEqual(pins.checkPins(root, "9.9.9", [entry]), []);
|
|
280
|
+
const after = JSON.parse(readFileSync(join(root, "ace", "opencode", "package.json"), "utf8"));
|
|
281
|
+
assert.equal(after.dependencies["@aron98/load-lock"], "9.9.9");
|
|
282
|
+
assert.equal(after.name, "load-lock-opencode");
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
rmSync(root, { recursive: true, force: true });
|
|
286
|
+
}
|
|
287
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|