@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,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `load-lock setup` inventory (issue #58), run against a temp HOME / project /
|
|
3
|
+
* state dir so real user config is never read or damaged. Fixtures are
|
|
4
|
+
* planted with the real installers (`load-lock install --harness ...`) plus
|
|
5
|
+
* hand-written plugin / ACE / foreign entries.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join, dirname, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const cliMain = join(HERE, "..", "cli", "main.js");
|
|
16
|
+
const repoRoot = resolve(HERE, "..", "..");
|
|
17
|
+
const TMP_BASE = process.env.LL_TEST_TMP ?? tmpdir();
|
|
18
|
+
mkdirSync(TMP_BASE, { recursive: true });
|
|
19
|
+
function sandbox() {
|
|
20
|
+
const root = mkdtempSync(join(TMP_BASE, "ll-setup-"));
|
|
21
|
+
const home = join(root, "home");
|
|
22
|
+
const project = join(root, "proj");
|
|
23
|
+
mkdirSync(home, { recursive: true });
|
|
24
|
+
mkdirSync(project, { recursive: true });
|
|
25
|
+
const env = {};
|
|
26
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
27
|
+
if (v !== undefined && !["XDG_CONFIG_HOME", "CODEX_HOME", "CLINE_MCP_SETTINGS_PATH", "LOAD_LOCK_SOCKET"].includes(k))
|
|
28
|
+
env[k] = v;
|
|
29
|
+
}
|
|
30
|
+
Object.assign(env, {
|
|
31
|
+
HOME: home,
|
|
32
|
+
LOAD_LOCK_STATE_DIR: join(root, "state"),
|
|
33
|
+
XDG_CACHE_HOME: join(root, "cache"),
|
|
34
|
+
npm_config_cache: join(root, "npm-cache"),
|
|
35
|
+
});
|
|
36
|
+
return { root, home, project, env };
|
|
37
|
+
}
|
|
38
|
+
function runCli(sb, args) {
|
|
39
|
+
return new Promise((res, rej) => {
|
|
40
|
+
const child = spawn(process.execPath, [cliMain, ...args], { env: sb.env, cwd: sb.project });
|
|
41
|
+
let stdout = "";
|
|
42
|
+
let stderr = "";
|
|
43
|
+
child.stdout.on("data", (b) => (stdout += b));
|
|
44
|
+
child.stderr.on("data", (b) => (stderr += b));
|
|
45
|
+
child.stdin.end();
|
|
46
|
+
child.once("exit", (code) => res({ code, stdout, stderr }));
|
|
47
|
+
child.once("error", rej);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** `setup --json` output: { inventory, plan }. */
|
|
51
|
+
async function setupOut(sb) {
|
|
52
|
+
const r = await runCli(sb, ["setup", "--json", "--project", sb.project]);
|
|
53
|
+
assert.equal(r.code, 0, `setup failed: ${r.stderr}`);
|
|
54
|
+
return JSON.parse(r.stdout);
|
|
55
|
+
}
|
|
56
|
+
async function setupJson(sb) {
|
|
57
|
+
return (await setupOut(sb)).inventory;
|
|
58
|
+
}
|
|
59
|
+
async function install(sb, harness, ...extra) {
|
|
60
|
+
const r = await runCli(sb, ["install", "--harness", harness, "--project", sb.project, ...extra]);
|
|
61
|
+
assert.equal(r.code, 0, `install ${harness} failed: ${r.stderr}`);
|
|
62
|
+
}
|
|
63
|
+
/** Every file under dir with its bytes (to prove setup never writes). */
|
|
64
|
+
function snapshot(dir, out = {}) {
|
|
65
|
+
for (const n of readdirSync(dir)) {
|
|
66
|
+
const p = join(dir, n);
|
|
67
|
+
if (statSync(p).isDirectory())
|
|
68
|
+
snapshot(p, out);
|
|
69
|
+
else
|
|
70
|
+
out[p] = readFileSync(p, "utf8");
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function harness(inv, name) {
|
|
75
|
+
const h = inv.harnesses.find((x) => x.harness === name);
|
|
76
|
+
assert.ok(h, `harness ${name} missing from inventory`);
|
|
77
|
+
return h;
|
|
78
|
+
}
|
|
79
|
+
function writeJsonFile(file, data) {
|
|
80
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
81
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
82
|
+
}
|
|
83
|
+
const ALL = ["claude-code", "opencode", "codex", "cursor", "gemini-cli", "copilot-cli", "cline", "roo-code", "goose", "omac"];
|
|
84
|
+
test("setup: bare HOME lists every supported harness as not installed and writes nothing", async () => {
|
|
85
|
+
const sb = sandbox();
|
|
86
|
+
const before = snapshot(sb.root);
|
|
87
|
+
const inv = await setupJson(sb);
|
|
88
|
+
assert.deepEqual(inv.harnesses.map((h) => h.harness), ALL);
|
|
89
|
+
for (const h of inv.harnesses) {
|
|
90
|
+
assert.equal(h.rung, "none", `${h.harness} rung`);
|
|
91
|
+
assert.deepEqual(h.integrations, []);
|
|
92
|
+
assert.deepEqual(h.findings, []);
|
|
93
|
+
}
|
|
94
|
+
assert.ok(inv.runtime.copies.some((c) => c.source === "cli" && c.path === repoRoot), "lists the running copy");
|
|
95
|
+
const human = await runCli(sb, ["setup", "--project", sb.project]);
|
|
96
|
+
assert.equal(human.code, 0);
|
|
97
|
+
assert.match(human.stdout, /inventory \(read-only\)/);
|
|
98
|
+
assert.match(human.stdout, /goose\s+not installed/);
|
|
99
|
+
assert.deepEqual(snapshot(sb.root), before, "setup must not write anything");
|
|
100
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
test("setup: each installer's wiring is inventoried with its rung, owner and copy; setup stays read-only", async () => {
|
|
103
|
+
const sb = sandbox();
|
|
104
|
+
for (const h of ALL)
|
|
105
|
+
await install(sb, h);
|
|
106
|
+
const before = snapshot(sb.root);
|
|
107
|
+
const inv = await setupJson(sb);
|
|
108
|
+
const expect = {
|
|
109
|
+
"claude-code": { rung: "loose", kinds: ["hooks", "mcp", "policy", "skill"] },
|
|
110
|
+
opencode: { rung: "plugin", kinds: ["plugin"] },
|
|
111
|
+
codex: { rung: "loose", kinds: ["hooks", "mcp", "policy"] },
|
|
112
|
+
cursor: { rung: "loose", kinds: ["hooks", "mcp", "policy"] },
|
|
113
|
+
"gemini-cli": { rung: "loose", kinds: ["mcp", "policy"] },
|
|
114
|
+
"copilot-cli": { rung: "loose", kinds: ["mcp", "policy"] },
|
|
115
|
+
cline: { rung: "loose", kinds: ["mcp", "policy"] },
|
|
116
|
+
"roo-code": { rung: "loose", kinds: ["mcp", "policy"] },
|
|
117
|
+
goose: { rung: "loose", kinds: ["mcp", "policy"] },
|
|
118
|
+
omac: { rung: "loose", kinds: ["grants"] },
|
|
119
|
+
};
|
|
120
|
+
for (const [name, e] of Object.entries(expect)) {
|
|
121
|
+
const h = harness(inv, name);
|
|
122
|
+
assert.equal(h.rung, e.rung, `${name} rung`);
|
|
123
|
+
assert.deepEqual([...new Set(h.integrations.map((i) => i.kind))].sort(), e.kinds, `${name} kinds`);
|
|
124
|
+
for (const i of h.integrations) {
|
|
125
|
+
assert.equal(i.owner, "installer", `${name} ${i.kind} owner`);
|
|
126
|
+
if (i.copy !== undefined) {
|
|
127
|
+
assert.equal(i.copy, repoRoot, `${name} ${i.kind} points at this copy`);
|
|
128
|
+
assert.equal(i.stale, false);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
assert.deepEqual(h.findings, [], `${name}: a clean install has no findings`);
|
|
132
|
+
}
|
|
133
|
+
const cc = harness(inv, "claude-code");
|
|
134
|
+
assert.deepEqual(cc.integrations.find((i) => i.kind === "hooks").detail.split(", ").sort(), ["SessionStart:await-offer", "SessionStart:session-start", "Stop:stop"]);
|
|
135
|
+
assert.deepEqual(snapshot(sb.root), before, "setup must not write anything");
|
|
136
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
137
|
+
});
|
|
138
|
+
test("setup: flags loose + plugin double install and MCP registered more than once (Claude Code)", async () => {
|
|
139
|
+
const sb = sandbox();
|
|
140
|
+
await install(sb, "claude-code");
|
|
141
|
+
const settingsFile = join(sb.home, ".claude", "settings.json");
|
|
142
|
+
const s = JSON.parse(readFileSync(settingsFile, "utf8"));
|
|
143
|
+
s.enabledPlugins = { "load-lock-plugin@load-lock": true, "other@market": true };
|
|
144
|
+
writeJsonFile(settingsFile, s);
|
|
145
|
+
// an ACE-style project MCP patch (`servers` key, pinned npx)
|
|
146
|
+
writeJsonFile(join(sb.project, ".mcp.json"), {
|
|
147
|
+
servers: { "load-lock": { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] } },
|
|
148
|
+
});
|
|
149
|
+
const cc = harness(await setupJson(sb), "claude-code");
|
|
150
|
+
assert.equal(cc.rung, "plugin");
|
|
151
|
+
const plugin = cc.integrations.find((i) => i.kind === "plugin");
|
|
152
|
+
assert.equal(plugin.detail, 'enabledPlugins["load-lock-plugin@load-lock"]');
|
|
153
|
+
assert.equal(plugin.owner, "harness");
|
|
154
|
+
assert.equal(plugin.enabled, true);
|
|
155
|
+
const ace = cc.integrations.find((i) => i.kind === "mcp" && i.scope === "project");
|
|
156
|
+
assert.equal(ace.owner, "other");
|
|
157
|
+
assert.equal(ace.detail, "servers.load-lock");
|
|
158
|
+
assert.ok(cc.findings.some((f) => f.startsWith("loose + plugin double install")), cc.findings.join("\n"));
|
|
159
|
+
assert.ok(cc.findings.some((f) => f.startsWith("MCP registered 3 times")), cc.findings.join("\n"));
|
|
160
|
+
// a disabled plugin does not count as the active rung
|
|
161
|
+
s.enabledPlugins["load-lock-plugin@load-lock"] = false;
|
|
162
|
+
writeJsonFile(settingsFile, s);
|
|
163
|
+
const cc2 = harness(await setupJson(sb), "claude-code");
|
|
164
|
+
assert.equal(cc2.rung, "loose");
|
|
165
|
+
assert.ok(cc2.findings.some((f) => /installed but disabled/.test(f)));
|
|
166
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
167
|
+
});
|
|
168
|
+
test("setup: hooks from a second (missing) copy are flagged as stale duplicates", async () => {
|
|
169
|
+
const sb = sandbox();
|
|
170
|
+
await install(sb, "codex");
|
|
171
|
+
const hooksFile = join(sb.home, ".codex", "hooks.json");
|
|
172
|
+
const h = JSON.parse(readFileSync(hooksFile, "utf8"));
|
|
173
|
+
const gone = join(sb.root, "gone", "node_modules", "@aron98", "load-lock", "dist", "cli", "main.js");
|
|
174
|
+
h.hooks.Stop.push({ hooks: [{ type: "command", command: `/usr/bin/node ${JSON.stringify(gone)} hook codex stop` }] });
|
|
175
|
+
writeJsonFile(hooksFile, h);
|
|
176
|
+
const cx = harness(await setupJson(sb), "codex");
|
|
177
|
+
const hooks = cx.integrations.filter((i) => i.kind === "hooks");
|
|
178
|
+
assert.equal(hooks.length, 2, "one hooks line per copy");
|
|
179
|
+
const staleOne = hooks.find((i) => i.stale);
|
|
180
|
+
assert.equal(staleOne.copy, resolve(gone, "..", "..", ".."));
|
|
181
|
+
assert.equal(staleOne.owner, "installer");
|
|
182
|
+
assert.ok(cx.findings.some((f) => f.startsWith("hooks from 2 different sources")), cx.findings.join("\n"));
|
|
183
|
+
assert.ok(cx.findings.some((f) => f.startsWith("stale hooks entry")), cx.findings.join("\n"));
|
|
184
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
185
|
+
});
|
|
186
|
+
test("setup: OpenCode plugin listed twice plus an MCP entry; ACE skill and skills-dir plugin are attributed", async () => {
|
|
187
|
+
const sb = sandbox();
|
|
188
|
+
await install(sb, "opencode");
|
|
189
|
+
const cfgFile = join(sb.home, ".config", "opencode", "opencode.json");
|
|
190
|
+
const cfg = JSON.parse(readFileSync(cfgFile, "utf8"));
|
|
191
|
+
cfg.plugin = ["@aron98/load-lock@1.5.0"];
|
|
192
|
+
cfg.mcp = { "load-lock": { type: "local", command: ["npx", "-y", "@aron98/load-lock@1.5.0", "mcp"] }, other: { type: "local", command: ["x"] } };
|
|
193
|
+
writeJsonFile(cfgFile, cfg);
|
|
194
|
+
// ACE-installed skill and Claude plugin (each with an .ace.json stamp)
|
|
195
|
+
const skills = join(sb.home, ".claude", "skills");
|
|
196
|
+
mkdirSync(join(skills, "load-lock"), { recursive: true });
|
|
197
|
+
writeFileSync(join(skills, "load-lock", "SKILL.md"), "---\nname: load-lock\ndescription: x\n---\n# load-lock\n");
|
|
198
|
+
writeFileSync(join(skills, "load-lock", ".ace.json"), "{}\n");
|
|
199
|
+
writeJsonFile(join(skills, "load-lock-plugin", ".claude-plugin", "plugin.json"), { name: "load-lock-plugin" });
|
|
200
|
+
writeFileSync(join(skills, "load-lock-plugin", ".ace.json"), "{}\n");
|
|
201
|
+
const inv = await setupJson(sb);
|
|
202
|
+
const oc = harness(inv, "opencode");
|
|
203
|
+
assert.equal(oc.rung, "plugin");
|
|
204
|
+
assert.equal(oc.integrations.filter((i) => i.kind === "plugin").length, 2);
|
|
205
|
+
assert.equal(oc.integrations.filter((i) => i.kind === "mcp").length, 1, "only the load-lock MCP entry");
|
|
206
|
+
assert.ok(oc.findings.some((f) => f.startsWith("plugin registered 2 times")), oc.findings.join("\n"));
|
|
207
|
+
assert.ok(oc.findings.some((f) => f.startsWith("MCP registered 2 times")), oc.findings.join("\n"));
|
|
208
|
+
const cc = harness(inv, "claude-code");
|
|
209
|
+
assert.equal(cc.rung, "skill", "an ACE skills-dir plugin is not counted as an active plugin (unverified, #63)");
|
|
210
|
+
assert.equal(cc.integrations.find((i) => i.kind === "skill").owner, "ace");
|
|
211
|
+
assert.equal(cc.integrations.find((i) => i.kind === "plugin").owner, "ace");
|
|
212
|
+
assert.ok(cc.findings.some((f) => /ACE skills-dir plugin/.test(f)));
|
|
213
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
214
|
+
});
|
|
215
|
+
// ------------------------------------------------------------ converge (#59)
|
|
216
|
+
async function setupYes(sb) {
|
|
217
|
+
const r = await runCli(sb, ["setup", "--yes", "--json", "--project", sb.project]);
|
|
218
|
+
assert.equal(r.code, 0, `setup --yes failed: ${r.stderr}`);
|
|
219
|
+
return JSON.parse(r.stdout);
|
|
220
|
+
}
|
|
221
|
+
async function uninstall(sb, h) {
|
|
222
|
+
const r = await runCli(sb, ["uninstall", "--harness", h, "--project", sb.project]);
|
|
223
|
+
assert.equal(r.code, 0, `uninstall ${h} failed: ${r.stderr}`);
|
|
224
|
+
}
|
|
225
|
+
const steps = (out, h) => out.plan.steps.filter((s) => s.harness === h).map((s) => s.action);
|
|
226
|
+
test("setup converge: dry run is the default and writes nothing", async () => {
|
|
227
|
+
const sb = sandbox();
|
|
228
|
+
await install(sb, "codex");
|
|
229
|
+
const hooksFile = join(sb.home, ".codex", "hooks.json");
|
|
230
|
+
const h = JSON.parse(readFileSync(hooksFile, "utf8"));
|
|
231
|
+
h.hooks.Stop.push({ hooks: [{ type: "command", command: `/usr/bin/node ${JSON.stringify(join(sb.root, "gone", "dist", "cli", "main.js"))} hook codex stop` }] });
|
|
232
|
+
writeJsonFile(hooksFile, h);
|
|
233
|
+
const before = snapshot(sb.root);
|
|
234
|
+
const r = await runCli(sb, ["setup", "--project", sb.project]);
|
|
235
|
+
assert.equal(r.code, 0, r.stderr);
|
|
236
|
+
assert.match(r.stdout, /plan \(dry run, re-run with --yes to apply\)/);
|
|
237
|
+
assert.match(r.stdout, /codex\s+install\s+load-lock install --harness codex/);
|
|
238
|
+
const j = await setupOut(sb);
|
|
239
|
+
assert.deepEqual(steps(j, "codex"), ["install"]);
|
|
240
|
+
assert.deepEqual(snapshot(sb.root), before, "a dry run must not write anything");
|
|
241
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
242
|
+
});
|
|
243
|
+
test("setup converge: Claude Code plugin + loose install -> loose removed (skill kept); install/uninstall reverse it", async () => {
|
|
244
|
+
const sb = sandbox();
|
|
245
|
+
const claudeDir = join(sb.home, ".claude");
|
|
246
|
+
// the user's own config, including the plugin they enabled with /plugin
|
|
247
|
+
writeJsonFile(join(sb.home, ".claude.json"), { mcpServers: { fetch: { type: "stdio", command: "uvx", args: ["mcp-server-fetch"] } } });
|
|
248
|
+
writeJsonFile(join(claudeDir, "settings.json"), {
|
|
249
|
+
theme: "dark",
|
|
250
|
+
enabledPlugins: { "load-lock-plugin@load-lock": true },
|
|
251
|
+
hooks: { Stop: [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }] },
|
|
252
|
+
});
|
|
253
|
+
writeFileSync(join(claudeDir, "CLAUDE.md"), "# My rules\n");
|
|
254
|
+
// an ACE MCP patch the user merged into the project
|
|
255
|
+
writeJsonFile(join(sb.project, ".mcp.json"), { servers: { "load-lock": { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] } } });
|
|
256
|
+
const original = snapshot(sb.root);
|
|
257
|
+
// with the plugin enabled, only --legacy writes the loose wiring (#62)
|
|
258
|
+
await install(sb, "claude-code", "--legacy");
|
|
259
|
+
const out = await setupYes(sb);
|
|
260
|
+
assert.deepEqual(steps(out, "claude-code"), ["remove-loose", "manual"]);
|
|
261
|
+
assert.match(out.plan.steps.find((s) => s.action === "manual").detail, /servers\.load-lock/);
|
|
262
|
+
const cc = harness(out.inventory, "claude-code");
|
|
263
|
+
assert.equal(cc.rung, "plugin");
|
|
264
|
+
assert.deepEqual(cc.integrations.map((i) => `${i.kind}:${i.owner}`).sort(), ["mcp:other", "plugin:harness", "skill:installer"], "only the plugin, the skill and the foreign project entry remain");
|
|
265
|
+
assert.ok(!cc.findings.some((f) => f.startsWith("loose + plugin")));
|
|
266
|
+
const s = JSON.parse(readFileSync(join(claudeDir, "settings.json"), "utf8"));
|
|
267
|
+
assert.deepEqual(s.enabledPlugins, { "load-lock-plugin@load-lock": true }, "harness-owned plugin state untouched");
|
|
268
|
+
assert.equal(readFileSync(join(sb.project, ".mcp.json"), "utf8"), original[join(sb.project, ".mcp.json")], "foreign entry untouched");
|
|
269
|
+
// idempotent: a second run has only the manual step left
|
|
270
|
+
assert.deepEqual(steps(await setupYes(sb), "claude-code"), ["manual"]);
|
|
271
|
+
// reversible: install --legacy brings the loose wiring back ...
|
|
272
|
+
await install(sb, "claude-code", "--legacy");
|
|
273
|
+
assert.equal(harness(await setupJson(sb), "claude-code").integrations.filter((i) => i.owner === "installer").length, 4);
|
|
274
|
+
// ... and uninstall restores the user's original bytes
|
|
275
|
+
await uninstall(sb, "claude-code");
|
|
276
|
+
assert.deepEqual(snapshot(sb.root), original);
|
|
277
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
278
|
+
});
|
|
279
|
+
test("setup converge: codex with a second copy's hooks and a stale MCP block -> one current set; uninstall restores", async () => {
|
|
280
|
+
const sb = sandbox();
|
|
281
|
+
const codexDir = join(sb.home, ".codex");
|
|
282
|
+
writeJsonFile(join(codexDir, "hooks.json"), { hooks: { Stop: [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }] } });
|
|
283
|
+
mkdirSync(codexDir, { recursive: true });
|
|
284
|
+
writeFileSync(join(codexDir, "config.toml"), 'model = "o3"\n');
|
|
285
|
+
writeFileSync(join(codexDir, "AGENTS.md"), "# Mine\n");
|
|
286
|
+
const original = snapshot(sb.root);
|
|
287
|
+
await install(sb, "codex");
|
|
288
|
+
const gone = join(sb.root, "gone", "node_modules", "@aron98", "load-lock");
|
|
289
|
+
const hooksFile = join(codexDir, "hooks.json");
|
|
290
|
+
const h = JSON.parse(readFileSync(hooksFile, "utf8"));
|
|
291
|
+
h.hooks.Stop.push({ hooks: [{ type: "command", command: `/usr/bin/node ${JSON.stringify(join(gone, "dist", "cli", "main.js"))} hook codex stop` }] });
|
|
292
|
+
writeJsonFile(hooksFile, h);
|
|
293
|
+
const toml = join(codexDir, "config.toml");
|
|
294
|
+
const mcpMain = join(repoRoot, "dist", "mcp", "main.js");
|
|
295
|
+
writeFileSync(toml, readFileSync(toml, "utf8").replace(JSON.stringify(mcpMain), JSON.stringify(join(gone, "dist", "mcp", "main.js"))));
|
|
296
|
+
assert.ok(harness(await setupJson(sb), "codex").findings.length >= 2, "messy state is flagged");
|
|
297
|
+
const out = await setupYes(sb);
|
|
298
|
+
assert.deepEqual(steps(out, "codex"), ["install"]);
|
|
299
|
+
const cx = harness(out.inventory, "codex");
|
|
300
|
+
assert.deepEqual(cx.findings, [], cx.findings.join("\n"));
|
|
301
|
+
for (const i of cx.integrations.filter((x) => x.copy))
|
|
302
|
+
assert.equal(i.copy, repoRoot);
|
|
303
|
+
assert.equal(cx.integrations.filter((i) => i.kind === "hooks").length, 1);
|
|
304
|
+
assert.equal(readFileSync(toml, "utf8").split("[mcp_servers.load-lock]").length, 2, "exactly one MCP block");
|
|
305
|
+
await uninstall(sb, "codex");
|
|
306
|
+
assert.deepEqual(snapshot(sb.root), original);
|
|
307
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
308
|
+
});
|
|
309
|
+
test("setup converge: MCP-only cline is upgraded to the installer's wiring; the foreign entry is left for the user", async () => {
|
|
310
|
+
const sb = sandbox();
|
|
311
|
+
const file = join(sb.home, ".cline", "data", "settings", "cline_mcp_settings.json");
|
|
312
|
+
writeJsonFile(file, {
|
|
313
|
+
mcpServers: {
|
|
314
|
+
ll: { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] },
|
|
315
|
+
fetch: { command: "uvx", args: ["mcp-server-fetch"] },
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
const original = snapshot(sb.root);
|
|
319
|
+
assert.equal(harness(await setupJson(sb), "cline").rung, "mcp");
|
|
320
|
+
const out = await setupYes(sb);
|
|
321
|
+
assert.deepEqual(steps(out, "cline"), ["install", "manual"]);
|
|
322
|
+
assert.match(out.plan.steps.find((s) => s.action === "manual").detail, /remove mcpServers\.ll/);
|
|
323
|
+
const cl = harness(out.inventory, "cline");
|
|
324
|
+
assert.equal(cl.rung, "loose");
|
|
325
|
+
assert.ok(cl.integrations.some((i) => i.detail === "mcpServers.ll"), "foreign entry untouched");
|
|
326
|
+
await uninstall(sb, "cline");
|
|
327
|
+
assert.deepEqual(snapshot(sb.root), original);
|
|
328
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
329
|
+
});
|
|
330
|
+
test("setup converge: never overwrites a foreign entry in the installer's slot, an ACE skill, or from a cache copy", async () => {
|
|
331
|
+
const sb = sandbox();
|
|
332
|
+
writeJsonFile(join(sb.home, ".gemini", "settings.json"), {
|
|
333
|
+
mcpServers: { "load-lock": { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] } },
|
|
334
|
+
});
|
|
335
|
+
const skill = join(sb.home, ".claude", "skills", "load-lock");
|
|
336
|
+
mkdirSync(skill, { recursive: true });
|
|
337
|
+
writeFileSync(join(skill, "SKILL.md"), "---\nname: load-lock\ndescription: x\n---\n");
|
|
338
|
+
writeFileSync(join(skill, ".ace.json"), "{}\n");
|
|
339
|
+
const before = snapshot(sb.root);
|
|
340
|
+
const out = await setupYes(sb);
|
|
341
|
+
assert.deepEqual(steps(out, "gemini-cli"), ["manual"]);
|
|
342
|
+
assert.deepEqual(steps(out, "claude-code"), ["manual"]);
|
|
343
|
+
assert.deepEqual(out.applied, []);
|
|
344
|
+
assert.deepEqual(snapshot(sb.root), before);
|
|
345
|
+
// a copy running from the npx cache never wires harnesses to itself
|
|
346
|
+
const { inventory, planConverge } = await import("../cli/setup.js");
|
|
347
|
+
writeJsonFile(join(sb.home, ".copilot", "mcp-config.json"), { mcpServers: { x: { command: "npx", args: ["@aron98/load-lock", "mcp"] } } });
|
|
348
|
+
const inv = inventory({ project: sb.project, home: sb.home, env: sb.env });
|
|
349
|
+
const plan = planConverge(inv, { selfCopy: "/c/_npx/abc/node_modules/@aron98/load-lock", selfEphemeral: true });
|
|
350
|
+
const cp = plan.steps.filter((s) => s.harness === "copilot-cli");
|
|
351
|
+
assert.deepEqual(cp.map((s) => s.action), ["manual"]);
|
|
352
|
+
assert.match(cp[0].detail, /cache copy/);
|
|
353
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
354
|
+
});
|
|
@@ -214,6 +214,24 @@ test("planUpdate: install argvs for bun/npm; honest refusals otherwise", () => {
|
|
|
214
214
|
assert.equal(unknown.action, "refuse");
|
|
215
215
|
assert.ok(unknown.reason.includes("cannot tell"), "unknown refusal admits it cannot tell");
|
|
216
216
|
});
|
|
217
|
+
test("classifySelf + planUpdate: npx-cache copy refuses, naming the pin (issue #57)", () => {
|
|
218
|
+
const base = mkdtempSync(join(TMP_BASE, "classify-npx-"));
|
|
219
|
+
try {
|
|
220
|
+
const npxRoot = join(base, "home", ".npm", "_npx");
|
|
221
|
+
const ctx = { home: join(base, "home"), npmGlobalRoot: join(base, "npm-root"), cacheRoot: join(base, "cache"), npxRoot };
|
|
222
|
+
const loc = classifySelf(join(npxRoot, "0123abcd", "node_modules", "@aron98", "load-lock"), ctx);
|
|
223
|
+
assert.equal(loc.kind, "npx-cache");
|
|
224
|
+
const plan = planUpdate(loc);
|
|
225
|
+
assert.equal(plan.action, "refuse");
|
|
226
|
+
assert.ok(plan.action === "refuse" && plan.reason.includes("npx cache"), "refusal names the npx cache");
|
|
227
|
+
assert.ok(plan.action === "refuse" && plan.reason.includes("pinned version"), "refusal says to bump the pin");
|
|
228
|
+
// A sibling of the npx root is not misclassified.
|
|
229
|
+
assert.notEqual(classifySelf(join(base, "home", ".npm", "_npx-other", "x"), ctx).kind, "npx-cache");
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
rmSync(base, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
217
235
|
// --------------------------------------------------------- detectCliDrift (pure)
|
|
218
236
|
test("detectCliDrift: older PATH copy -> one notice with the exact command", () => {
|
|
219
237
|
const base = mkdtempSync(join(TMP_BASE, "drift-"));
|
|
@@ -231,6 +249,26 @@ test("detectCliDrift: older PATH copy -> one notice with the exact command", ()
|
|
|
231
249
|
rmSync(base, { recursive: true, force: true });
|
|
232
250
|
}
|
|
233
251
|
});
|
|
252
|
+
test("detectCliDrift: newer npx-cache copy vs older PATH CLI -> notice names the npx copy (issue #57)", () => {
|
|
253
|
+
const base = mkdtempSync(join(TMP_BASE, "drift-npx-"));
|
|
254
|
+
try {
|
|
255
|
+
const binDir = plantBinCopy(join(base, "copy"), "0.9.0");
|
|
256
|
+
const cache = join(base, "npm-cache");
|
|
257
|
+
const ownPath = join(cache, "_npx", "0123abcd", "node_modules", "@aron98", "load-lock");
|
|
258
|
+
const d = detectCliDrift("9.9.9", { PATH: binDir, npm_config_cache: cache }, "linux", ownPath);
|
|
259
|
+
assert.ok(d, "older PATH CLI is drift");
|
|
260
|
+
assert.equal(d.ownSource, "npx-cache");
|
|
261
|
+
assert.ok(d.message.includes("npx"), `notice names the npx copy: ${d.message}`);
|
|
262
|
+
assert.ok(d.message.includes("load-lock update"), "notice names the exact command");
|
|
263
|
+
// The same skew from a non-npx copy keeps the adapter wording.
|
|
264
|
+
const other = detectCliDrift("9.9.9", { PATH: binDir, npm_config_cache: cache }, "linux", join(base, "elsewhere"));
|
|
265
|
+
assert.equal(other.ownSource, "adapter");
|
|
266
|
+
assert.ok(!other.message.includes("npx"));
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
rmSync(base, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
234
272
|
test("detectCliDrift: same/newer PATH copy, no PATH entry, foreign bin -> no drift", () => {
|
|
235
273
|
const base = mkdtempSync(join(TMP_BASE, "nodrift-"));
|
|
236
274
|
try {
|
|
@@ -404,6 +442,39 @@ test("update: npm-global apply installs via the stub and restarts the service",
|
|
|
404
442
|
rmSync(base, { recursive: true, force: true });
|
|
405
443
|
}
|
|
406
444
|
});
|
|
445
|
+
test("update: run from a planted npx-cache copy refuses with a clear message (issue #57)", async () => {
|
|
446
|
+
const base = mkdtempSync(join(TMP_BASE, "upd-npx-"));
|
|
447
|
+
try {
|
|
448
|
+
const home = join(base, "home");
|
|
449
|
+
const plantedCli = plantCopy(join(home, ".npm", "_npx", "0123abcd", "node_modules", "@aron98", "load-lock"), packageVersion());
|
|
450
|
+
makeStubNpm(join(base, "bin"));
|
|
451
|
+
const mark = join(base, "marker");
|
|
452
|
+
const env = {
|
|
453
|
+
...process.env,
|
|
454
|
+
HOME: home,
|
|
455
|
+
PATH: join(base, "bin") + ":" + (process.env.PATH ?? ""),
|
|
456
|
+
STUB_VERSION: "9.9.9",
|
|
457
|
+
STUB_ROOT: join(base, "no-such-npm-root"),
|
|
458
|
+
STUB_MARK: mark,
|
|
459
|
+
};
|
|
460
|
+
// Resolve the npx cache from the temp HOME, never the real npm cache.
|
|
461
|
+
delete env.npm_config_cache;
|
|
462
|
+
delete env.NPM_CONFIG_CACHE;
|
|
463
|
+
const check = await runCliEnv(env, ["update", "--check", "--json"], plantedCli);
|
|
464
|
+
assert.equal(check.code, 1, `an update exists; this copy cannot self-apply it; stderr: ${check.stderr}`);
|
|
465
|
+
const report = JSON.parse(check.stdout);
|
|
466
|
+
assert.equal(report.state, "refused");
|
|
467
|
+
assert.equal(report.location.kind, "npx-cache");
|
|
468
|
+
assert.ok(report.reason.includes("npx cache") && report.reason.includes("pinned version"), report.reason);
|
|
469
|
+
const apply = await runCliEnv(env, ["update"], plantedCli);
|
|
470
|
+
assert.equal(apply.code, 70, "refusal in apply mode is a failure exit");
|
|
471
|
+
assert.ok(apply.stderr.includes("update refused") && apply.stderr.includes("npx cache"), apply.stderr);
|
|
472
|
+
assert.ok(!existsSync(mark), "no package manager ran");
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
rmSync(base, { recursive: true, force: true });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
407
478
|
// ------------------------------------------------------------- service restart
|
|
408
479
|
test("service restart: running daemon -> stop + start, new daemon answers", async () => {
|
|
409
480
|
const d = await startDaemon();
|
package/dist/test/update.test.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - `service stop` waits bounded, escalates to SIGKILL, verifies the
|
|
10
10
|
* socket is free, and reports honestly
|
|
11
11
|
* - `service start` refusals name the conflicting pid
|
|
12
|
-
* - `doctor` reports installed copies and warns on drift
|
|
12
|
+
* - `doctor` reports installed copies (incl. npx-cache, #57) and warns on drift
|
|
13
13
|
*
|
|
14
14
|
* Modeled after daemon.test.ts: real daemons over real sockets in temp
|
|
15
15
|
* state dirs (LL_TEST_TMP / $TMPDIR/opencode — never the user's real dir).
|
|
@@ -453,3 +453,35 @@ test("doctor: reports installed copies, versions, daemon skew, and copy drift",
|
|
|
453
453
|
rmSync(fakePkg, { recursive: true, force: true });
|
|
454
454
|
}
|
|
455
455
|
});
|
|
456
|
+
test("doctor: lists a planted npx-cache copy (issue #57)", async () => {
|
|
457
|
+
const d = await startDaemon();
|
|
458
|
+
const home = mkdtempSync(join(TMP_BASE, "ll-npxhome-"));
|
|
459
|
+
try {
|
|
460
|
+
// npx layout under the default npm cache: ~/.npm/_npx/<hash>/node_modules/@aron98/load-lock
|
|
461
|
+
const npxCopy = join(home, ".npm", "_npx", "0123abcd4567ef89", "node_modules", "@aron98", "load-lock");
|
|
462
|
+
mkdirSync(npxCopy, { recursive: true });
|
|
463
|
+
writeFileSync(join(npxCopy, "package.json"), JSON.stringify({ name: "@aron98/load-lock", version: "0.4.2" }));
|
|
464
|
+
// A foreign npx entry is ignored.
|
|
465
|
+
const foreign = join(home, ".npm", "_npx", "ffff", "node_modules", "@aron98", "load-lock");
|
|
466
|
+
mkdirSync(foreign, { recursive: true });
|
|
467
|
+
writeFileSync(join(foreign, "package.json"), JSON.stringify({ name: "something-else", version: "1.0.0" }));
|
|
468
|
+
const env = { ...d.env, HOME: home };
|
|
469
|
+
// npm sets npm_config_cache for scripts it runs (`npm test`); the test
|
|
470
|
+
// must resolve the planted temp HOME's default cache, never the real one.
|
|
471
|
+
delete env.npm_config_cache;
|
|
472
|
+
delete env.NPM_CONFIG_CACHE;
|
|
473
|
+
const r = await runCliEnv(env, ["doctor", "--json"]);
|
|
474
|
+
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
|
|
475
|
+
const report = JSON.parse(r.stdout);
|
|
476
|
+
const copies = report.copies;
|
|
477
|
+
const npx = copies.filter((c) => c.source === "npx-cache");
|
|
478
|
+
assert.equal(npx.length, 1, `exactly the planted npx copy: ${JSON.stringify(copies)}`);
|
|
479
|
+
assert.equal(npx[0].version, "0.4.2");
|
|
480
|
+
assert.equal(npx[0].path, npxCopy);
|
|
481
|
+
assert.ok(report.warnings.some((w) => w.includes("multiple load-lock versions") && w.includes("npx-cache=0.4.2")), `drift warning names the npx copy: ${JSON.stringify(report.warnings)}`);
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
await d.stop();
|
|
485
|
+
rmSync(home, { recursive: true, force: true });
|
|
486
|
+
}
|
|
487
|
+
});
|
package/docs/ADAPTERS.md
CHANGED
|
@@ -52,8 +52,8 @@ adapter does — see `src/adapters/opencode/plugin.ts` as the worked example):
|
|
|
52
52
|
## Tier 2 — hooks + MCP
|
|
53
53
|
|
|
54
54
|
For harnesses with lifecycle hooks but no in-process API (Claude Code and
|
|
55
|
-
Codex CLI use this): keep the stock MCP server (`load-lock-mcp` binary,
|
|
56
|
-
`node .../dist/mcp/main.js`) for tools, and add hook handlers that shell out
|
|
55
|
+
Codex CLI use this): keep the stock MCP server (`load-lock-mcp` binary,
|
|
56
|
+
`load-lock mcp`, or `node .../dist/mcp/main.js`) for tools, and add hook handlers that shell out
|
|
57
57
|
to the CLI:
|
|
58
58
|
|
|
59
59
|
- `load-lock hook <harness> session-start` → prints `POLICY_BRIEF` +
|
|
@@ -96,6 +96,7 @@ normally touches:
|
|
|
96
96
|
| `heartbeat {agentId}` | keep untracked leases alive |
|
|
97
97
|
| `watch {agentId\|*}` / `events {sinceEventId, agentId?}` | push/pull event streams (durable ids) |
|
|
98
98
|
| `offers_for_cwd {cwd}` | for hook scripts without session identity |
|
|
99
|
+
| `hook_once {harness, sessionId, event, occurrence?, ttlMs?, hold?}` | hook dedupe: `{first}` is true only for the first caller per key; keys expire (TTL, default 60s) or, with `hold`, live as long as the connection; in-memory only |
|
|
99
100
|
|
|
100
101
|
## Lifecycle contract (the part that must not be improvised)
|
|
101
102
|
|
package/docs/AGENT_GUIDE.md
CHANGED
|
@@ -6,8 +6,42 @@ wide capacity budget. Before resource-intensive work, acquire a permit. The
|
|
|
6
6
|
whole thing is cooperative: you decide what is heavy; the budget only works
|
|
7
7
|
if everyone participates.
|
|
8
8
|
|
|
9
|
-
This guide is also available as
|
|
10
|
-
supported harnesses.
|
|
9
|
+
This guide is also available as the `agent_guide` MCP tool, as
|
|
10
|
+
`load-lock agent-guide`, and as a skill in supported harnesses.
|
|
11
|
+
|
|
12
|
+
## 0. How to reach load-lock
|
|
13
|
+
|
|
14
|
+
Use the first of these that works; they all talk to the same daemon:
|
|
15
|
+
|
|
16
|
+
1. **load-lock tools** in your harness (MCP tools, or `load_lock_*` in
|
|
17
|
+
OpenCode). The rest of this guide names the tools.
|
|
18
|
+
2. **`load-lock` on PATH** (check with `load-lock version`).
|
|
19
|
+
3. **Pinned npx**: `npx -y @aron98/load-lock@<version> <command>`, with the
|
|
20
|
+
version the load-lock skill pins (Node >= 20). Read `load-lock` below as
|
|
21
|
+
this whole prefix.
|
|
22
|
+
|
|
23
|
+
The first call starts the shared daemon; you never start it yourself.
|
|
24
|
+
|
|
25
|
+
**CLI only (skill-only install, no tools).** Prefer `load-lock run`: it
|
|
26
|
+
acquires, runs the command, and releases when it exits, success or failure:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
load-lock run --reason "full test suite" --op-key test/all -- npm test
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Exit code **68** means "not granted yet" (queued, with the request id in
|
|
33
|
+
the message): don't start the work. Keep doing light work; when you run out,
|
|
34
|
+
`load-lock wait --id <request-id>` sleeps until your turn, claims the offer
|
|
35
|
+
and prints a `permit_token` for `load-lock run --permit <permit_token> -- <cmd>`.
|
|
36
|
+
A `wait` that exits 68 again is a checkpoint: call it again. Your own
|
|
37
|
+
requests and offers: `load-lock pending`.
|
|
38
|
+
|
|
39
|
+
Your agent id is derived from your harness session, so all calls from one
|
|
40
|
+
session count as one agent. If `pending` doesn't show a request you made,
|
|
41
|
+
pin the id yourself with `LOAD_LOCK_AGENT=<unique-name>` (or `--agent`) on
|
|
42
|
+
every call. Without tools nothing wakes you while you sit idle and nothing
|
|
43
|
+
re-teaches the policy after context compaction; `load-lock setup` shows how
|
|
44
|
+
to add the harness integration that does.
|
|
11
45
|
|
|
12
46
|
## 1. Deciding heavy vs light
|
|
13
47
|
|