@aron98/load-lock 1.3.0 → 1.5.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.
Files changed (47) hide show
  1. package/dist/adapters/opencode/plugin.d.ts +2 -1
  2. package/dist/adapters/opencode/plugin.js +100 -6
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +1 -9
  5. package/dist/cli/copies.js +2 -43
  6. package/dist/cli/install.js +1 -1
  7. package/dist/cli/main.js +82 -217
  8. package/dist/cli/service.d.ts +56 -0
  9. package/dist/cli/service.js +261 -0
  10. package/dist/cli/update.d.ts +53 -0
  11. package/dist/cli/update.js +274 -0
  12. package/dist/core/daemon.d.ts +24 -0
  13. package/dist/core/daemon.js +221 -0
  14. package/dist/core/state.d.ts +5 -0
  15. package/dist/core/state.js +16 -0
  16. package/dist/dashboard/feed.d.ts +23 -0
  17. package/dist/dashboard/feed.js +64 -2
  18. package/dist/dashboard/frame.js +2 -0
  19. package/dist/dashboard/view.d.ts +11 -0
  20. package/dist/dashboard/view.js +13 -0
  21. package/dist/mcp/server.d.ts +7 -0
  22. package/dist/mcp/server.js +117 -8
  23. package/dist/policy/text.d.ts +1 -1
  24. package/dist/policy/text.js +6 -0
  25. package/dist/shared/client.js +11 -1
  26. package/dist/shared/drift.d.ts +21 -0
  27. package/dist/shared/drift.js +77 -0
  28. package/dist/shared/installs.d.ts +39 -0
  29. package/dist/shared/installs.js +101 -0
  30. package/dist/shared/pkginfo.d.ts +16 -0
  31. package/dist/shared/pkginfo.js +54 -0
  32. package/dist/shared/protocol.d.ts +32 -0
  33. package/dist/shared/wait-completion.d.ts +67 -0
  34. package/dist/shared/wait-completion.js +505 -0
  35. package/dist/test/mcp.test.js +115 -1
  36. package/dist/test/reconnect.test.d.ts +1 -0
  37. package/dist/test/reconnect.test.js +389 -0
  38. package/dist/test/update-command.test.d.ts +1 -0
  39. package/dist/test/update-command.test.js +608 -0
  40. package/dist/test/wait-completion.test.d.ts +1 -0
  41. package/dist/test/wait-completion.test.js +418 -0
  42. package/docs/ADAPTERS.md +3 -2
  43. package/docs/AGENT_GUIDE.md +61 -3
  44. package/docs/ARCHITECTURE.md +15 -0
  45. package/docs/COMPATIBILITY.md +16 -2
  46. package/docs/OPERATIONS.md +118 -9
  47. package/package.json +1 -1
@@ -0,0 +1,608 @@
1
+ /**
2
+ * Integration tests for issue #38:
3
+ * - `load-lock update`: classify-self (pure, planted layouts), registry
4
+ * check, --check exit codes, refusals (repo checkout, opencode cache),
5
+ * and a full apply+restart e2e against a planted npm-global copy
6
+ * - `service restart`: compositing the hardened stop + honest start,
7
+ * both phases reported, failures naming the phase
8
+ * - adapter-init drift notice: pure detectCliDrift with planted PATH
9
+ * copies, plus real MCP-server and OpenCode-plugin init e2e
10
+ *
11
+ * The npm registry is NEVER contacted and nothing is ever installed: npm
12
+ * is stubbed via a fake `npm` first on PATH (reads STUB_* env vars), the
13
+ * same inject/stub seam philosophy as src/shared/transport.ts. Real
14
+ * daemons run in temp state dirs under LL_TEST_TMP — never the user's dir.
15
+ *
16
+ * Modeled after update.test.ts (issue #34).
17
+ */
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { spawn } from "node:child_process";
21
+ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { connect } from "node:net";
24
+ import { dirname, join } from "node:path";
25
+ import { classifySelf, planUpdate } from "../cli/update.js";
26
+ import { detectCliDrift } from "../shared/drift.js";
27
+ import { packageVersion } from "../shared/version.js";
28
+ import { cliMainPath, mcpMainPath, startDaemon, probe, delay } from "./testutil.js";
29
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
30
+ mkdirSync(TMP_BASE, { recursive: true });
31
+ const DIST_DIR = join(dirname(cliMainPath), "..");
32
+ const PLUGIN_DIST = join(DIST_DIR, "adapters", "opencode", "plugin.js");
33
+ const REPO_ROOT = join(DIST_DIR, "..");
34
+ /** Run the compiled CLI (or a planted copy of it) with an explicit env. */
35
+ function runCliEnv(env, args, cliPath = cliMainPath, timeoutMs = 60_000) {
36
+ return new Promise((resolve, reject) => {
37
+ const child = spawn(process.execPath, [cliPath, ...args], { env });
38
+ let stdout = "";
39
+ let stderr = "";
40
+ child.stdout.on("data", (b) => (stdout += b));
41
+ child.stderr.on("data", (b) => (stderr += b));
42
+ child.stdin.end();
43
+ const timer = setTimeout(() => {
44
+ child.kill("SIGKILL");
45
+ reject(new Error(`CLI timed out: load-lock ${args.join(" ")}`));
46
+ }, timeoutMs);
47
+ child.once("exit", (code) => {
48
+ clearTimeout(timer);
49
+ resolve({ code, stdout, stderr });
50
+ });
51
+ child.once("error", (e) => {
52
+ clearTimeout(timer);
53
+ reject(e);
54
+ });
55
+ });
56
+ }
57
+ /** Kill whatever daemon currently owns the state dir (respawns are not our child). */
58
+ function killDaemonFromJson(dir) {
59
+ try {
60
+ const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
61
+ if (typeof info.pid === "number") {
62
+ try {
63
+ process.kill(info.pid, "SIGKILL");
64
+ }
65
+ catch {
66
+ /* already gone */
67
+ }
68
+ }
69
+ }
70
+ catch {
71
+ /* no daemon.json — nothing to kill */
72
+ }
73
+ }
74
+ /** Send a raw envelope line; resolve the first response (null on failure). */
75
+ function rawRequest(sockPath, envelope, timeoutMs = 3000) {
76
+ return new Promise((resolve) => {
77
+ let settled = false;
78
+ let buf = "";
79
+ const done = (v) => {
80
+ if (settled)
81
+ return;
82
+ settled = true;
83
+ try {
84
+ s.destroy();
85
+ }
86
+ catch {
87
+ /* ignore */
88
+ }
89
+ resolve(v);
90
+ };
91
+ const s = connect(sockPath, () => {
92
+ s.write(JSON.stringify(envelope) + "\n");
93
+ });
94
+ s.on("data", (chunk) => {
95
+ buf += chunk.toString("utf8");
96
+ const idx = buf.indexOf("\n");
97
+ if (idx === -1)
98
+ return;
99
+ try {
100
+ done(JSON.parse(buf.slice(0, idx)));
101
+ }
102
+ catch {
103
+ done(null);
104
+ }
105
+ });
106
+ s.on("error", () => done(null));
107
+ setTimeout(() => done(null), timeoutMs).unref?.();
108
+ });
109
+ }
110
+ /** Version-less hello (must never order a self-restart). */
111
+ const helloFrom = () => ({ id: 1, method: "hello", params: {} });
112
+ /** A fake `npm` first on PATH: view/root serve env vars, `i` writes a marker. */
113
+ function makeStubNpm(binDir) {
114
+ mkdirSync(binDir, { recursive: true });
115
+ const script = "#!/bin/sh\n" +
116
+ 'case "$1" in\n' +
117
+ " view)\n" +
118
+ ' [ -n "$STUB_FAIL" ] && exit 1\n' +
119
+ ' echo "$STUB_VERSION"\n' +
120
+ " ;;\n" +
121
+ " root)\n" +
122
+ ' echo "$STUB_ROOT"\n' +
123
+ " ;;\n" +
124
+ " i|install)\n" +
125
+ ' echo "stubbed $*" > "$STUB_MARK" || exit 1\n' +
126
+ ' echo "stub npm: installed $3"\n' +
127
+ " ;;\n" +
128
+ " *)\n" +
129
+ " exit 64\n" +
130
+ " ;;\n" +
131
+ "esac\n";
132
+ writeFileSync(join(binDir, "npm"), script);
133
+ chmodSync(join(binDir, "npm"), 0o755);
134
+ }
135
+ /**
136
+ * Plant a load-lock package copy (package.json + dist) at `dest` and return
137
+ * its CLI entry path. The version is rewritten so `latest` can outrank it.
138
+ */
139
+ function plantCopy(dest, version) {
140
+ mkdirSync(dest, { recursive: true });
141
+ cpSync(DIST_DIR, join(dest, "dist"), { recursive: true });
142
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8"));
143
+ pkg.version = version;
144
+ writeFileSync(join(dest, "package.json"), JSON.stringify(pkg, null, 2));
145
+ return join(dest, "dist", "cli", "main.js");
146
+ }
147
+ /** Plant a minimal load-lock package (just package.json + bin) for drift detection. */
148
+ function plantBinCopy(dest, version) {
149
+ mkdirSync(join(dest, "bin"), { recursive: true });
150
+ writeFileSync(join(dest, "package.json"), JSON.stringify({ name: "@aron98/load-lock", version }));
151
+ writeFileSync(join(dest, "bin", "load-lock"), "#!/bin/sh\nexit 0\n");
152
+ chmodSync(join(dest, "bin", "load-lock"), 0o755);
153
+ return join(dest, "bin");
154
+ }
155
+ // --------------------------------------------------- classifySelf / planUpdate (pure)
156
+ test("classifySelf: bun global / npm global / opencode cache / repo / unknown", (t) => {
157
+ const base = mkdtempSync(join(TMP_BASE, "classify-"));
158
+ try {
159
+ const home = join(base, "home");
160
+ const ctx = { home, npmGlobalRoot: join(base, "npm-root"), cacheRoot: join(base, "cache") };
161
+ // bun global: under <home>/.bun/install/global/node_modules (default root)
162
+ const bunRoot = classifySelf(join(home, ".bun", "install", "global", "node_modules", "@aron98", "load-lock"), ctx);
163
+ assert.equal(bunRoot.kind, "bun-global");
164
+ // npm global: under the given `npm root -g`
165
+ const npmRoot = classifySelf(join(base, "npm-root", "@aron98", "load-lock"), ctx);
166
+ assert.equal(npmRoot.kind, "npm-global");
167
+ // opencode cache copy: under <cacheRoot>/opencode/npm/...
168
+ const cache = classifySelf(join(base, "cache", "opencode", "npm", "@aron98/load-lock@latest", "123", "node_modules", "@aron98", "load-lock"), ctx);
169
+ assert.equal(cache.kind, "opencode-cache");
170
+ // repo checkout: a .git above the package root (planted, like the real walk)
171
+ mkdirSync(join(base, "repo", "pkg"), { recursive: true });
172
+ mkdirSync(join(base, "repo", ".git"), { recursive: true });
173
+ const repo = classifySelf(join(base, "repo", "pkg"), ctx);
174
+ assert.equal(repo.kind, "repo-checkout");
175
+ assert.ok(repo.detail.includes("git"), "detail names git");
176
+ // unknown: nothing matches and no .git above (temp dirs under TMP_BASE)
177
+ let hasGitAbove = false;
178
+ for (let dir = base;;) {
179
+ if (existsSync(join(dir, ".git"))) {
180
+ hasGitAbove = true;
181
+ break;
182
+ }
183
+ const parent = dirname(dir);
184
+ if (parent === dir)
185
+ break;
186
+ dir = parent;
187
+ }
188
+ if (hasGitAbove) {
189
+ t.skip("an ancestor of the temp dir contains .git — cannot test the unknown location honestly here");
190
+ return;
191
+ }
192
+ const unknown = classifySelf(join(base, "loose"), ctx);
193
+ assert.equal(unknown.kind, "unknown");
194
+ }
195
+ finally {
196
+ rmSync(base, { recursive: true, force: true });
197
+ }
198
+ });
199
+ test("planUpdate: install argvs for bun/npm; honest refusals otherwise", () => {
200
+ const mk = (kind, path = "/x") => ({ kind, path, detail: "" });
201
+ const bun = planUpdate(mk("bun-global"));
202
+ assert.equal(bun.action, "install");
203
+ assert.deepEqual(bun.argv, ["bun", "i", "-g", "@aron98/load-lock@latest"]);
204
+ const npm = planUpdate(mk("npm-global"));
205
+ assert.equal(npm.action, "install");
206
+ assert.deepEqual(npm.argv, ["npm", "i", "-g", "@aron98/load-lock@latest"]);
207
+ const cache = planUpdate(mk("opencode-cache"));
208
+ assert.equal(cache.action, "refuse");
209
+ assert.ok(cache.reason.includes("opencode"), "cache refusal explains opencode manages it");
210
+ const repo = planUpdate(mk("repo-checkout"));
211
+ assert.equal(repo.action, "refuse");
212
+ assert.ok(repo.reason.includes("git"), "repo refusal points at the git workflow");
213
+ const unknown = planUpdate(mk("unknown"));
214
+ assert.equal(unknown.action, "refuse");
215
+ assert.ok(unknown.reason.includes("cannot tell"), "unknown refusal admits it cannot tell");
216
+ });
217
+ // --------------------------------------------------------- detectCliDrift (pure)
218
+ test("detectCliDrift: older PATH copy -> one notice with the exact command", () => {
219
+ const base = mkdtempSync(join(TMP_BASE, "drift-"));
220
+ try {
221
+ const binDir = plantBinCopy(join(base, "copy"), "0.9.0");
222
+ const d = detectCliDrift("9.9.9", { PATH: binDir }, "linux");
223
+ assert.ok(d, "older PATH copy is drift");
224
+ assert.equal(d.cliVersion, "0.9.0");
225
+ assert.equal(d.ownVersion, "9.9.9");
226
+ assert.ok(d.message.includes("load-lock update"), "notice names the exact command");
227
+ assert.ok(d.message.includes("0.9.0"), "notice names the old version");
228
+ assert.ok(d.message.includes("auto-install"), "notice states nothing is auto-installed");
229
+ }
230
+ finally {
231
+ rmSync(base, { recursive: true, force: true });
232
+ }
233
+ });
234
+ test("detectCliDrift: same/newer PATH copy, no PATH entry, foreign bin -> no drift", () => {
235
+ const base = mkdtempSync(join(TMP_BASE, "nodrift-"));
236
+ try {
237
+ const same = plantBinCopy(join(base, "same"), "9.9.9");
238
+ assert.equal(detectCliDrift("9.9.9", { PATH: same }, "linux"), null, "same version is not drift");
239
+ const newer = plantBinCopy(join(base, "newer"), "99.0.0");
240
+ assert.equal(detectCliDrift("9.9.9", { PATH: newer }, "linux"), null, "newer PATH CLI is not drift");
241
+ assert.equal(detectCliDrift("9.9.9", { PATH: "" }, "linux"), null, "no PATH entry -> nothing to compare");
242
+ // A load-lock bin with no enclosing @aron98/load-lock package.json
243
+ // cannot be compared honestly -> no drift, no crash.
244
+ mkdirSync(join(base, "bare"), { recursive: true });
245
+ writeFileSync(join(base, "bare", "load-lock"), "#!/bin/sh\nexit 0\n");
246
+ chmodSync(join(base, "bare", "load-lock"), 0o755);
247
+ assert.equal(detectCliDrift("9.9.9", { PATH: join(base, "bare") }, "linux"), null, "foreign bin -> null, never throws");
248
+ }
249
+ finally {
250
+ rmSync(base, { recursive: true, force: true });
251
+ }
252
+ });
253
+ // ----------------------------------------------------------------- version flag
254
+ test("cli: --version flag and `version` command print this copy's version", async () => {
255
+ const r1 = await runCliEnv({ ...process.env }, ["--version"]);
256
+ assert.equal(r1.code, 0);
257
+ assert.equal(r1.stdout.trim(), packageVersion());
258
+ const r2 = await runCliEnv({ ...process.env }, ["version"]);
259
+ assert.equal(r2.code, 0);
260
+ assert.equal(r2.stdout.trim(), packageVersion());
261
+ });
262
+ // ------------------------------------------------------------- update (repo copy)
263
+ test("update --check: repo checkout refuses honestly (exit 1, reason names git)", async () => {
264
+ const base = mkdtempSync(join(TMP_BASE, "upd-repo-"));
265
+ try {
266
+ makeStubNpm(join(base, "bin"));
267
+ const env = {
268
+ ...process.env,
269
+ PATH: join(base, "bin") + ":" + (process.env.PATH ?? ""),
270
+ STUB_VERSION: "9.9.9",
271
+ STUB_ROOT: join(base, "no-such-npm-root"),
272
+ };
273
+ const r = await runCliEnv(env, ["update", "--check", "--json"]);
274
+ assert.equal(r.code, 1, "an update exists; this copy just cannot self-apply it");
275
+ const report = JSON.parse(r.stdout);
276
+ assert.equal(report.state, "refused");
277
+ assert.equal(report.current_version, packageVersion());
278
+ assert.equal(report.latest_version, "9.9.9");
279
+ assert.ok(report.reason.includes("git"), "refusal points at the git workflow");
280
+ }
281
+ finally {
282
+ rmSync(base, { recursive: true, force: true });
283
+ }
284
+ });
285
+ test("update: registry unreachable -> exit 70 with an honest message", async () => {
286
+ const base = mkdtempSync(join(TMP_BASE, "upd-fail-"));
287
+ try {
288
+ makeStubNpm(join(base, "bin"));
289
+ const env = {
290
+ ...process.env,
291
+ PATH: join(base, "bin") + ":" + (process.env.PATH ?? ""),
292
+ STUB_FAIL: "1",
293
+ STUB_ROOT: join(base, "no-such-npm-root"),
294
+ };
295
+ const r = await runCliEnv(env, ["update", "--check"]);
296
+ assert.equal(r.code, 70);
297
+ assert.ok(r.stderr.includes("npm view"), "failure names the registry check");
298
+ }
299
+ finally {
300
+ rmSync(base, { recursive: true, force: true });
301
+ }
302
+ });
303
+ test("update --check: up to date -> exit 0", async () => {
304
+ const base = mkdtempSync(join(TMP_BASE, "upd-uptodate-"));
305
+ try {
306
+ makeStubNpm(join(base, "bin"));
307
+ const env = {
308
+ ...process.env,
309
+ PATH: join(base, "bin") + ":" + (process.env.PATH ?? ""),
310
+ STUB_VERSION: packageVersion(),
311
+ STUB_ROOT: join(base, "no-such-npm-root"),
312
+ };
313
+ const r = await runCliEnv(env, ["update", "--check", "--json"]);
314
+ assert.equal(r.code, 0);
315
+ const report = JSON.parse(r.stdout);
316
+ assert.equal(report.state, "up-to-date");
317
+ assert.equal(report.latest_version, packageVersion());
318
+ }
319
+ finally {
320
+ rmSync(base, { recursive: true, force: true });
321
+ }
322
+ });
323
+ // ------------------------------------------------------- update (planted npm copy)
324
+ test("update --check: npm-global copy reports update-available, touches nothing", async () => {
325
+ const base = mkdtempSync(join(TMP_BASE, "upd-check-"));
326
+ let d = null;
327
+ try {
328
+ const plantedCli = plantCopy(join(base, "nglobal", "node_modules", "@aron98", "load-lock"), packageVersion());
329
+ makeStubNpm(join(base, "bin"));
330
+ const mark = join(base, "marker");
331
+ d = await startDaemon();
332
+ const oldPid = d.child.pid;
333
+ const env = {
334
+ ...d.env,
335
+ PATH: join(base, "bin") + ":" + (process.env.PATH ?? ""),
336
+ STUB_VERSION: "9.9.9",
337
+ STUB_ROOT: join(base, "nglobal", "node_modules"),
338
+ STUB_MARK: mark,
339
+ };
340
+ const r = await runCliEnv(env, ["update", "--check", "--json"], plantedCli);
341
+ assert.equal(r.code, 1, "--check signals update-available");
342
+ const report = JSON.parse(r.stdout);
343
+ assert.equal(report.state, "update-available");
344
+ assert.equal(report.location.kind, "npm-global");
345
+ assert.deepEqual(report.command, ["npm", "i", "-g", "@aron98/load-lock@latest"]);
346
+ assert.ok(!existsSync(mark), "dry run installs nothing");
347
+ // daemon untouched: same pid, still answering
348
+ const h = await rawRequest(d.sock, helloFrom());
349
+ assert.ok(h && h.ok, "daemon still answering");
350
+ assert.equal(h.result.pid, oldPid, "daemon was not restarted by a dry run");
351
+ }
352
+ finally {
353
+ if (d) {
354
+ killDaemonFromJson(d.dir);
355
+ rmSync(d.dir, { recursive: true, force: true });
356
+ }
357
+ rmSync(base, { recursive: true, force: true });
358
+ }
359
+ });
360
+ test("update: npm-global apply installs via the stub and restarts the service", async () => {
361
+ const base = mkdtempSync(join(TMP_BASE, "upd-apply-"));
362
+ let d = null;
363
+ try {
364
+ const plantedCli = plantCopy(join(base, "nglobal", "node_modules", "@aron98", "load-lock"), packageVersion());
365
+ makeStubNpm(join(base, "bin"));
366
+ const mark = join(base, "marker");
367
+ d = await startDaemon();
368
+ const oldPid = d.child.pid;
369
+ const env = {
370
+ ...d.env,
371
+ PATH: join(base, "bin") + ":" + (process.env.PATH ?? ""),
372
+ STUB_VERSION: "9.9.9",
373
+ STUB_ROOT: join(base, "nglobal", "node_modules"),
374
+ STUB_MARK: mark,
375
+ };
376
+ const r = await runCliEnv(env, ["update", "--json"], plantedCli);
377
+ assert.equal(r.code, 0, `update should succeed; stderr: ${r.stderr}`);
378
+ assert.ok(existsSync(mark), "the detected package manager ran (stubbed)");
379
+ assert.match(r.stdout, /"state": "updated"/);
380
+ assert.match(r.stdout, /"restarted": true/);
381
+ assert.match(r.stdout, /stopped/, "stop phase reported");
382
+ assert.match(r.stdout, /running on pid/, "start phase reported");
383
+ // the restarted daemon answers on the same socket with a fresh pid
384
+ let newPid = 0;
385
+ const deadline = Date.now() + 15_000;
386
+ for (;;) {
387
+ const h = await rawRequest(d.sock, helloFrom());
388
+ if (h && h.ok && h.result.pid !== oldPid) {
389
+ newPid = h.result.pid;
390
+ break;
391
+ }
392
+ if (Date.now() > deadline)
393
+ assert.fail("restarted daemon did not answer");
394
+ await delay(200);
395
+ }
396
+ assert.notEqual(newPid, oldPid);
397
+ assert.ok(newPid > 0);
398
+ }
399
+ finally {
400
+ if (d) {
401
+ killDaemonFromJson(d.dir);
402
+ rmSync(d.dir, { recursive: true, force: true });
403
+ }
404
+ rmSync(base, { recursive: true, force: true });
405
+ }
406
+ });
407
+ // ------------------------------------------------------------- service restart
408
+ test("service restart: running daemon -> stop + start, new daemon answers", async () => {
409
+ const d = await startDaemon();
410
+ try {
411
+ const oldPid = d.child.pid;
412
+ const r = await runCliEnv(d.env, ["service", "restart"]);
413
+ assert.equal(r.code, 0, `restart should succeed; stderr: ${r.stderr}`);
414
+ assert.match(r.stdout, /stopped/);
415
+ assert.match(r.stdout, /running on pid/);
416
+ let newPid = 0;
417
+ const deadline = Date.now() + 15_000;
418
+ for (;;) {
419
+ const h = await rawRequest(d.sock, helloFrom());
420
+ if (h && h.ok && h.result.pid !== oldPid) {
421
+ newPid = h.result.pid;
422
+ break;
423
+ }
424
+ if (Date.now() > deadline)
425
+ assert.fail("restarted daemon did not answer");
426
+ await delay(200);
427
+ }
428
+ assert.notEqual(newPid, oldPid);
429
+ }
430
+ finally {
431
+ killDaemonFromJson(d.dir);
432
+ rmSync(d.dir, { recursive: true, force: true });
433
+ }
434
+ });
435
+ test("service restart: nothing running -> stop reports honestly, start spawns fresh", async () => {
436
+ const dir = mkdtempSync(join(TMP_BASE, "restart-empty-"));
437
+ const sock = join(dir, "daemon.sock");
438
+ try {
439
+ const env = {
440
+ ...process.env,
441
+ LOAD_LOCK_STATE_DIR: dir,
442
+ LOAD_LOCK_SOCKET: sock,
443
+ };
444
+ const r = await runCliEnv(env, ["service", "restart"]);
445
+ assert.equal(r.code, 0);
446
+ assert.match(r.stdout, /no running daemon found/);
447
+ assert.match(r.stdout, /running on pid/);
448
+ assert.ok(await probe(sock), "fresh daemon is answering");
449
+ }
450
+ finally {
451
+ killDaemonFromJson(dir);
452
+ rmSync(dir, { recursive: true, force: true });
453
+ }
454
+ });
455
+ test("service restart: unidentifiable socket owner -> exit 70 naming the STOP phase", async () => {
456
+ const dir = mkdtempSync(join(TMP_BASE, "restart-hung-"));
457
+ const sock = join(dir, "daemon.sock");
458
+ // A fake "hung daemon": accepts connections, never answers, and writes
459
+ // no daemon.json — so stop cannot identify a pid to signal.
460
+ const child = spawn(process.execPath, [
461
+ "-e",
462
+ `const net = require("node:net"); const s = net.createServer(() => {}); s.listen(${JSON.stringify(sock)}, () => { process.stdout.write("hung\\n"); });`,
463
+ ], { stdio: ["ignore", "ignore", "ignore"] });
464
+ try {
465
+ const deadline = Date.now() + 10_000;
466
+ while (!(await probe(sock))) {
467
+ if (Date.now() > deadline)
468
+ throw new Error("fake hung daemon did not come up");
469
+ await delay(100);
470
+ }
471
+ const env = {
472
+ ...process.env,
473
+ LOAD_LOCK_STATE_DIR: dir,
474
+ LOAD_LOCK_SOCKET: sock,
475
+ };
476
+ const r = await runCliEnv(env, ["service", "restart"], cliMainPath, 30_000);
477
+ assert.equal(r.code, 70);
478
+ assert.match(r.stderr, /STOP phase/, "failure names the failed phase");
479
+ }
480
+ finally {
481
+ child.kill("SIGKILL");
482
+ rmSync(dir, { recursive: true, force: true });
483
+ }
484
+ });
485
+ // ------------------------------------------------------- adapter-init drift (MCP)
486
+ test("mcp init: older PATH CLI -> drift notice in stderr and initialize instructions", async () => {
487
+ const base = mkdtempSync(join(TMP_BASE, "drift-mcp-"));
488
+ let d = null;
489
+ try {
490
+ const binDir = plantBinCopy(join(base, "copy"), "0.9.0");
491
+ // runStdioMcp connects a real daemon at startup; give it one in a
492
+ // temp state dir (never the user's), with the planted old copy on PATH.
493
+ d = await startDaemon();
494
+ const env = {
495
+ ...d.env,
496
+ PATH: binDir + ":" + (d.env.PATH ?? ""),
497
+ };
498
+ const child = spawn(process.execPath, [mcpMainPath], { env, stdio: ["pipe", "pipe", "pipe"] });
499
+ let stderr = "";
500
+ child.stderr.on("data", (b) => (stderr += b));
501
+ const resp = await new Promise((resolve, reject) => {
502
+ let buf = "";
503
+ const timer = setTimeout(() => reject(new Error("mcp server did not answer initialize")), 15_000);
504
+ child.stdout.on("data", (chunk) => {
505
+ buf += chunk.toString("utf8");
506
+ const idx = buf.indexOf("\n");
507
+ if (idx === -1)
508
+ return;
509
+ clearTimeout(timer);
510
+ try {
511
+ resolve(JSON.parse(buf.slice(0, idx)));
512
+ }
513
+ catch (e) {
514
+ reject(e);
515
+ }
516
+ });
517
+ child.once("exit", () => reject(new Error("mcp server exited before answering")));
518
+ child.stdin.write(JSON.stringify({
519
+ jsonrpc: "2.0",
520
+ id: 1,
521
+ method: "initialize",
522
+ params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "0" } },
523
+ }) + "\n");
524
+ });
525
+ child.kill("SIGKILL");
526
+ assert.ok(resp.result, `initialize succeeded: ${JSON.stringify(resp)}`);
527
+ assert.equal(resp.error, undefined, `initialize had no error: ${JSON.stringify(resp)}`);
528
+ const instructions = resp.result?.instructions ?? "";
529
+ assert.ok(instructions.includes("load-lock update"), "instructions carry the drift notice with the exact command");
530
+ await delay(300);
531
+ assert.ok(stderr.includes("load-lock update"), "stderr carries the drift notice at init");
532
+ }
533
+ finally {
534
+ if (d) {
535
+ killDaemonFromJson(d.dir);
536
+ rmSync(d.dir, { recursive: true, force: true });
537
+ }
538
+ rmSync(base, { recursive: true, force: true });
539
+ }
540
+ });
541
+ // ---------------------------------------------------- adapter-init drift (plugin)
542
+ test("plugin init: older PATH CLI -> drift notice on stderr and in injected context", async () => {
543
+ const base = mkdtempSync(join(TMP_BASE, "drift-plugin-"));
544
+ try {
545
+ const binDir = plantBinCopy(join(base, "copy"), "0.9.0");
546
+ // Child harness: load the real plugin, run setup with a fake ctx that
547
+ // records the context hook, then invoke it and print the injected text.
548
+ const script = "const hooks = {};\n" +
549
+ "const ctx = {\n" +
550
+ " tool: { transform(cb) { cb({ namespace() {}, add() {} }); } },\n" +
551
+ " skill: { transform(cb) { cb({ add() {} }); } },\n" +
552
+ " session: { hook(n, cb) { (hooks[n] ??= []).push(cb); }, synthetic() {} },\n" +
553
+ "};\n" +
554
+ `const mod = await import(${JSON.stringify(PLUGIN_DIST)});\n` +
555
+ "const cleanup = await mod.default.setup(ctx);\n" +
556
+ "const cbs = hooks['context'] ?? [];\n" +
557
+ "if (!cbs.length) { process.stderr.write('NO_CONTEXT_HOOK\\n'); process.exit(1); }\n" +
558
+ "const event = { system: [] };\n" +
559
+ "await cbs[0](event);\n" +
560
+ "process.stdout.write(JSON.stringify(event.system));\n" +
561
+ "if (typeof cleanup === 'function') cleanup();\n" +
562
+ "process.exit(0);\n";
563
+ const scriptPath = join(base, "child.mjs");
564
+ writeFileSync(scriptPath, script);
565
+ const env = {
566
+ ...process.env,
567
+ PATH: binDir + ":" + (process.env.PATH ?? ""),
568
+ LOAD_LOCK_STATE_DIR: join(base, "state"),
569
+ LOAD_LOCK_SOCKET: join(base, "state", "daemon.sock"),
570
+ LOAD_LOCK_NO_SPAWN: "1",
571
+ };
572
+ const r = await runScript(scriptPath, env);
573
+ assert.equal(r.code, 0, `plugin child failed; stderr: ${r.stderr}`);
574
+ const system = JSON.parse(r.stdout);
575
+ assert.ok(Array.isArray(system) && system.length > 0, "context hook pushed a system message");
576
+ const text = system.map((m) => m.text).join("\n");
577
+ assert.ok(text.includes("load-lock"), "policy brief is present");
578
+ assert.ok(text.includes("load-lock update"), "injected context carries the drift notice");
579
+ assert.ok(r.stderr.includes("load-lock update"), "stderr carries the drift notice at init");
580
+ assert.ok(r.stderr.includes("adapter copy"), "notice identifies which copy is newer");
581
+ }
582
+ finally {
583
+ rmSync(base, { recursive: true, force: true });
584
+ }
585
+ });
586
+ /** Run an .mjs child script with an explicit env. */
587
+ function runScript(scriptPath, env, timeoutMs = 60_000) {
588
+ return new Promise((resolve, reject) => {
589
+ const child = spawn(process.execPath, [scriptPath], { env });
590
+ let stdout = "";
591
+ let stderr = "";
592
+ child.stdout.on("data", (b) => (stdout += b));
593
+ child.stderr.on("data", (b) => (stderr += b));
594
+ child.stdin.end();
595
+ const timer = setTimeout(() => {
596
+ child.kill("SIGKILL");
597
+ reject(new Error(`child script timed out: ${scriptPath}`));
598
+ }, timeoutMs);
599
+ child.once("exit", (code) => {
600
+ clearTimeout(timer);
601
+ resolve({ code, stdout, stderr });
602
+ });
603
+ child.once("error", (e) => {
604
+ clearTimeout(timer);
605
+ reject(e);
606
+ });
607
+ });
608
+ }
@@ -0,0 +1 @@
1
+ export {};