@aron98/load-lock 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +61 -14
  2. package/dist/adapters/opencode/plugin.js +15 -1
  3. package/dist/adapters/opencode/tui.js +7 -2
  4. package/dist/cli/copies.d.ts +3 -10
  5. package/dist/cli/copies.js +32 -44
  6. package/dist/cli/hook.js +48 -4
  7. package/dist/cli/install.d.ts +2 -0
  8. package/dist/cli/install.js +68 -14
  9. package/dist/cli/main.js +108 -211
  10. package/dist/cli/service.d.ts +56 -0
  11. package/dist/cli/service.js +261 -0
  12. package/dist/cli/setup.d.ts +131 -0
  13. package/dist/cli/setup.js +942 -0
  14. package/dist/cli/update.d.ts +57 -0
  15. package/dist/cli/update.js +287 -0
  16. package/dist/core/daemon.d.ts +29 -3
  17. package/dist/core/daemon.js +111 -11
  18. package/dist/core/persist.d.ts +3 -0
  19. package/dist/core/persist.js +49 -10
  20. package/dist/core/state.d.ts +9 -0
  21. package/dist/core/state.js +41 -3
  22. package/dist/dashboard/feed.d.ts +23 -0
  23. package/dist/dashboard/feed.js +64 -2
  24. package/dist/dashboard/frame.js +2 -0
  25. package/dist/dashboard/view.d.ts +11 -0
  26. package/dist/dashboard/view.js +13 -0
  27. package/dist/mcp/server.d.ts +11 -3
  28. package/dist/mcp/server.js +38 -6
  29. package/dist/policy/text.d.ts +13 -3
  30. package/dist/policy/text.js +14 -3
  31. package/dist/shared/agent-guide.d.ts +3 -0
  32. package/dist/shared/agent-guide.js +16 -0
  33. package/dist/shared/client.js +16 -4
  34. package/dist/shared/drift.d.ts +23 -0
  35. package/dist/shared/drift.js +95 -0
  36. package/dist/shared/identity.d.ts +51 -0
  37. package/dist/shared/identity.js +116 -0
  38. package/dist/shared/installs.d.ts +39 -0
  39. package/dist/shared/installs.js +101 -0
  40. package/dist/shared/pkginfo.d.ts +25 -0
  41. package/dist/shared/pkginfo.js +70 -0
  42. package/dist/shared/protocol.d.ts +22 -0
  43. package/dist/shared/protocol.js +15 -0
  44. package/dist/shared/runtimes.d.ts +41 -0
  45. package/dist/shared/runtimes.js +81 -0
  46. package/dist/shared/version.d.ts +6 -0
  47. package/dist/shared/version.js +9 -1
  48. package/dist/test/ace-mcp.test.d.ts +1 -0
  49. package/dist/test/ace-mcp.test.js +157 -0
  50. package/dist/test/ace-opencode.test.d.ts +1 -0
  51. package/dist/test/ace-opencode.test.js +287 -0
  52. package/dist/test/check-pins.test.d.ts +1 -0
  53. package/dist/test/check-pins.test.js +108 -0
  54. package/dist/test/compat.test.d.ts +1 -0
  55. package/dist/test/compat.test.js +190 -0
  56. package/dist/test/hook.test.js +179 -1
  57. package/dist/test/identity.test.d.ts +1 -0
  58. package/dist/test/identity.test.js +155 -0
  59. package/dist/test/install.test.js +135 -1
  60. package/dist/test/launcher.test.d.ts +1 -0
  61. package/dist/test/launcher.test.js +222 -0
  62. package/dist/test/mcp.test.js +76 -7
  63. package/dist/test/plugin.test.d.ts +1 -0
  64. package/dist/test/plugin.test.js +144 -0
  65. package/dist/test/reconnect.test.d.ts +1 -0
  66. package/dist/test/reconnect.test.js +389 -0
  67. package/dist/test/runtimes.test.d.ts +1 -0
  68. package/dist/test/runtimes.test.js +282 -0
  69. package/dist/test/setup.test.d.ts +1 -0
  70. package/dist/test/setup.test.js +354 -0
  71. package/dist/test/update-command.test.d.ts +1 -0
  72. package/dist/test/update-command.test.js +679 -0
  73. package/dist/test/update.test.js +33 -1
  74. package/docs/ADAPTERS.md +3 -2
  75. package/docs/AGENT_GUIDE.md +48 -2
  76. package/docs/ARCHITECTURE.md +122 -7
  77. package/docs/COMPATIBILITY.md +156 -13
  78. package/docs/OPERATIONS.md +407 -21
  79. package/package.json +3 -2
  80. package/skills/load-lock/SKILL.md +62 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * `load-lock service start|stop|restart|status` (issue #38): daemon
3
+ * lifecycle, extracted from main.ts so `service restart` and
4
+ * `load-lock update` (which restarts after applying) compose the same
5
+ * hardened stop + honest start paths instead of duplicating them.
6
+ *
7
+ * - stop (hardened): SIGTERM → bounded wait → SIGKILL → verify the
8
+ * socket is actually free; crash leftovers cleaned either way.
9
+ * - start (honest): auto-spawn via connect, hello confirmation;
10
+ * refusals name the conflicting pid.
11
+ * - restart: stop then start, reporting both phases; any failure names
12
+ * the phase and reason.
13
+ *
14
+ * Failures throw ServiceError (message + exit code); callers decide how
15
+ * to exit so composition can annotate ("restart failed in the STOP
16
+ * phase: ...").
17
+ */
18
+ import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { connect } from "node:net";
21
+ import { setTimeout as delay } from "node:timers/promises";
22
+ import { withClient } from "../shared/client.js";
23
+ import { isWindows, probeTransport } from "../shared/transport.js";
24
+ import { compareVersions, packageVersion } from "../shared/version.js";
25
+ import { stateDir, socketPath } from "../core/persist.js";
26
+ /** A service-phase failure with the exit code the CLI should use. */
27
+ export class ServiceError extends Error {
28
+ exitCode;
29
+ constructor(message, exitCode = 70) {
30
+ super(message);
31
+ this.exitCode = exitCode;
32
+ }
33
+ }
34
+ /** daemon.json contents, or null when missing/unreadable. */
35
+ export function readDaemonInfo(dir) {
36
+ try {
37
+ return JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /**
44
+ * Version-less hello over a raw socket connection: deliberately does NOT
45
+ * send clientVersion, so asking a daemon for its identity never orders it
46
+ * to self-restart (used by `service stop` and post-restart confirmation).
47
+ * Best-effort: resolves null on any failure.
48
+ */
49
+ export function rawHello(sockPath, timeoutMs = 2000) {
50
+ return new Promise((resolve) => {
51
+ let settled = false;
52
+ let buf = "";
53
+ const done = (v) => {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ try {
58
+ sock.destroy();
59
+ }
60
+ catch {
61
+ /* ignore */
62
+ }
63
+ resolve(v);
64
+ };
65
+ const sock = connect(sockPath, () => {
66
+ sock.write(JSON.stringify({ id: 1, method: "hello", params: {} }) + "\n");
67
+ });
68
+ sock.on("data", (chunk) => {
69
+ buf += chunk.toString("utf8");
70
+ const idx = buf.indexOf("\n");
71
+ if (idx === -1)
72
+ return;
73
+ try {
74
+ const msg = JSON.parse(buf.slice(0, idx));
75
+ done(msg.ok ? msg.result : null);
76
+ }
77
+ catch {
78
+ done(null);
79
+ }
80
+ });
81
+ sock.on("error", () => done(null));
82
+ const timer = setTimeout(() => done(null), timeoutMs);
83
+ timer.unref?.();
84
+ });
85
+ }
86
+ /** Poll the socket until nothing answers there (bounded). */
87
+ async function waitSocketFree(sockPath, timeoutMs) {
88
+ const deadline = Date.now() + Math.max(0, timeoutMs);
89
+ for (;;) {
90
+ if (!(await probeTransport(sockPath)))
91
+ return true;
92
+ if (Date.now() >= deadline)
93
+ return false;
94
+ await delay(100);
95
+ }
96
+ }
97
+ /**
98
+ * After ordering a self-restart, wait for the daemon to come back running
99
+ * at least `installed` (the version of this CLI copy). Resolves its hello
100
+ * result, or null if it did not come back (or an older/pre-handshake
101
+ * daemon answered again — e.g. the daemon's copy is not actually updated).
102
+ */
103
+ export async function awaitDaemonUpgraded(sockPath, timeoutMs, installed) {
104
+ const deadline = Date.now() + Math.max(0, timeoutMs);
105
+ for (;;) {
106
+ const h = await rawHello(sockPath, 1500);
107
+ if (h && typeof h.daemonVersion === "string" && compareVersions(h.daemonVersion, installed) >= 0)
108
+ return h;
109
+ if (Date.now() >= deadline)
110
+ return null;
111
+ await delay(250);
112
+ }
113
+ }
114
+ /**
115
+ * Hardened stop: signal the daemon, wait bounded for its socket to go
116
+ * silent, escalate to SIGKILL, and only report success once the endpoint
117
+ * is verifiably free. A leftover silent socket file or daemon.json (crash
118
+ * leftovers) is cleaned up either way. Returns the report line; throws
119
+ * ServiceError on failure.
120
+ */
121
+ export async function serviceStop(timeoutMs) {
122
+ const dir = stateDir();
123
+ const sock = socketPath();
124
+ const info = readDaemonInfo(dir);
125
+ const answering = await probeTransport(sock);
126
+ if (!answering) {
127
+ // Nothing is listening: no daemon to stop. Clean crash leftovers.
128
+ try {
129
+ if (!isWindows() && existsSync(sock))
130
+ unlinkSync(sock);
131
+ }
132
+ catch {
133
+ /* ignore */
134
+ }
135
+ try {
136
+ rmSync(join(dir, "daemon.json"), { force: true });
137
+ }
138
+ catch {
139
+ /* ignore */
140
+ }
141
+ return "no running daemon found (stale files cleaned)";
142
+ }
143
+ // Identify the daemon: prefer daemon.json, else ask it directly
144
+ // (version-less hello — stopping must not order a self-restart).
145
+ let pid = typeof info?.pid === "number" ? info.pid : undefined;
146
+ if (!pid) {
147
+ const hello = await rawHello(sock, 1500);
148
+ if (hello && typeof hello.pid === "number")
149
+ pid = hello.pid;
150
+ }
151
+ if (!pid) {
152
+ throw new ServiceError(`the socket ${sock} answers but no pid could be identified (daemon did not respond and daemon.json names none) ` +
153
+ `— cannot stop safely; run \`load-lock doctor\``);
154
+ }
155
+ let how = "SIGTERM";
156
+ let stopped = false;
157
+ try {
158
+ process.kill(pid, "SIGTERM");
159
+ }
160
+ catch (e) {
161
+ if (e?.code === "ESRCH")
162
+ stopped = true; // already gone
163
+ else
164
+ throw new ServiceError(`cannot signal daemon pid ${pid}: ${e?.message ?? e}`);
165
+ }
166
+ if (!stopped)
167
+ stopped = await waitSocketFree(sock, timeoutMs);
168
+ if (!stopped) {
169
+ how = "SIGKILL";
170
+ try {
171
+ process.kill(pid, "SIGKILL");
172
+ }
173
+ catch {
174
+ /* already dead */
175
+ }
176
+ stopped = await waitSocketFree(sock, timeoutMs);
177
+ }
178
+ if (!stopped) {
179
+ throw new ServiceError(`daemon pid ${pid} did not stop within ${timeoutMs}ms even after SIGKILL — run \`load-lock doctor\``);
180
+ }
181
+ try {
182
+ if (!isWindows() && existsSync(sock))
183
+ unlinkSync(sock);
184
+ }
185
+ catch {
186
+ /* ignore */
187
+ }
188
+ try {
189
+ rmSync(join(dir, "daemon.json"), { force: true });
190
+ }
191
+ catch {
192
+ /* ignore */
193
+ }
194
+ return `daemon (pid ${pid}) stopped${how === "SIGKILL" ? " (required SIGKILL after SIGTERM)" : ""}`;
195
+ }
196
+ /**
197
+ * Honest start: connecting auto-spawns a detached daemon (see
198
+ * shared/client.ts); hello confirms it is up. Refusals name the
199
+ * conflicting pid. Returns the report line; throws ServiceError on
200
+ * failure.
201
+ */
202
+ export async function serviceStart(timeoutMs) {
203
+ const sock = socketPath();
204
+ const installed = packageVersion();
205
+ try {
206
+ const r = (await withClient((c) => c.request("hello", {}, timeoutMs)));
207
+ const dv = typeof r.daemonVersion === "string" ? r.daemonVersion : undefined;
208
+ if (dv && compareVersions(dv, installed) < 0) {
209
+ // The handshake just ordered this old daemon to self-restart;
210
+ // confirm it comes back on updated code.
211
+ const up = await awaitDaemonUpgraded(sock, 8000, installed);
212
+ if (up) {
213
+ return `daemon self-restarted ${r.pid} -> ${up.pid} (v${dv} -> v${up.daemonVersion}), running on ${sock}`;
214
+ }
215
+ return (`daemon running on pid ${r.pid} (${sock}), daemon v${dv} — older than this CLI v${installed}; ` +
216
+ `self-restart ordered but not confirmed (its copy may not be updated in place — see docs/OPERATIONS.md)`);
217
+ }
218
+ return `daemon running on pid ${r.pid} (${sock})${dv ? `, daemon v${dv}` : ""}`;
219
+ }
220
+ catch (e) {
221
+ if (await probeTransport(sock)) {
222
+ // Something owns the endpoint. Name it.
223
+ const hello = await rawHello(sock, 1500);
224
+ if (hello && typeof hello.pid === "number") {
225
+ throw new ServiceError(`start refused: socket answered from pid ${hello.pid} — another daemon owns ${sock}; ` +
226
+ `if it is stale, run \`load-lock service stop\` first`);
227
+ }
228
+ const info = readDaemonInfo(stateDir());
229
+ if (info && typeof info.pid === "number") {
230
+ throw new ServiceError(`socket ${sock} answers but the daemon did not respond (daemon.json names pid ${info.pid}) — likely hung; ` +
231
+ `run \`load-lock service stop\``);
232
+ }
233
+ throw new ServiceError(`start refused: ${sock} is in use but nothing responsive owns it — run \`load-lock doctor\``);
234
+ }
235
+ throw e;
236
+ }
237
+ }
238
+ /**
239
+ * `service restart` (issue #38): hardened stop, then honest start — both
240
+ * phases reported; a failure names the phase and reason. Stopping when
241
+ * nothing runs is fine: the start phase simply spawns a fresh daemon.
242
+ */
243
+ export async function serviceRestart(stopTimeoutMs = 5000, startTimeoutMs = 10_000) {
244
+ let stop;
245
+ try {
246
+ stop = await serviceStop(stopTimeoutMs);
247
+ }
248
+ catch (e) {
249
+ const reason = e instanceof ServiceError ? e.message : String(e?.message ?? e);
250
+ throw new ServiceError(`restart failed in the STOP phase: ${reason}`, e instanceof ServiceError ? e.exitCode : 70);
251
+ }
252
+ let start;
253
+ try {
254
+ start = await serviceStart(startTimeoutMs);
255
+ }
256
+ catch (e) {
257
+ const reason = e instanceof ServiceError ? e.message : String(e?.message ?? e);
258
+ throw new ServiceError(`restart failed in the START phase (stop phase succeeded: ${stop}) — ${reason}`, e instanceof ServiceError ? e.exitCode : 70);
259
+ }
260
+ return { stop, start };
261
+ }
@@ -0,0 +1,131 @@
1
+ import { type InstalledCopy } from "./copies.js";
2
+ import { type RuntimeCopy } from "../shared/runtimes.js";
3
+ /**
4
+ * Name of the Claude Code plugin (decided on #61: the plugin is not called
5
+ * `load-lock`, so it never collides with the standalone skill). Matched as
6
+ * `<name>@<marketplace>` in `enabledPlugins`, whatever the marketplace.
7
+ */
8
+ export declare const CLAUDE_PLUGIN_NAME = "load-lock-plugin";
9
+ export declare const HARNESSES: readonly ["claude-code", "opencode", "codex", "cursor", "gemini-cli", "copilot-cli", "cline", "roo-code", "goose", "omac"];
10
+ export type Harness = (typeof HARNESSES)[number];
11
+ export type IntegrationKind = "plugin" | "hooks" | "mcp" | "policy" | "skill" | "grants";
12
+ /**
13
+ * Who wrote the entry: `installer` = `load-lock install` (exact shape it
14
+ * writes), `harness` = the harness's own plugin state (e.g. Claude Code
15
+ * `enabledPlugins`), `ace` = an ACE install (`.ace.json` stamp next to it),
16
+ * `other` = anything else (manual edits, npx entries, foreign tools).
17
+ */
18
+ export type Owner = "installer" | "harness" | "ace" | "other";
19
+ export interface Integration {
20
+ kind: IntegrationKind;
21
+ scope: "user" | "project";
22
+ /** File (or directory) the entry lives in. */
23
+ file: string;
24
+ /** Short description of the entry (key, events, ...). */
25
+ detail: string;
26
+ owner: Owner;
27
+ /** Package root of the load-lock copy the entry runs, when it names one. */
28
+ copy?: string;
29
+ /** True when that copy no longer exists on disk. */
30
+ stale?: boolean;
31
+ /** Plugins only: the harness's enabled flag. */
32
+ enabled?: boolean;
33
+ }
34
+ /**
35
+ * Rung on the ladder (#47): `plugin` = harness plugin active, `loose` =
36
+ * `load-lock install` wiring (hooks / policy / grants), `mcp` = only an MCP
37
+ * registration, `skill` = only the skill, `none` = nothing found.
38
+ */
39
+ export type Rung = "plugin" | "loose" | "mcp" | "skill" | "none";
40
+ export interface HarnessInventory {
41
+ harness: Harness;
42
+ rung: Rung;
43
+ integrations: Integration[];
44
+ /** Duplicates, stale entries and other problems worth fixing. */
45
+ findings: string[];
46
+ }
47
+ export interface Inventory {
48
+ home: string;
49
+ project: string;
50
+ harnesses: HarnessInventory[];
51
+ runtime: {
52
+ /** Copies detectable on disk (copies.ts). */
53
+ copies: InstalledCopy[];
54
+ /** The copy the daemon last recorded in daemon.json (#52). */
55
+ daemon: {
56
+ path: string;
57
+ version: string | null;
58
+ } | null;
59
+ /** Copies the daemon has seen via the client handshake (#52). */
60
+ known: RuntimeCopy[];
61
+ };
62
+ findings: string[];
63
+ }
64
+ export interface InventoryOptions {
65
+ project: string;
66
+ home?: string;
67
+ env?: NodeJS.ProcessEnv;
68
+ platform?: NodeJS.Platform;
69
+ }
70
+ /**
71
+ * Who owns a skill directory: `ace` when ACE stamped it (`.ace.json`),
72
+ * `installer` when it is the dir `load-lock install` writes (a real
73
+ * directory, not a symlink to someone's checkout), else `other`.
74
+ * `install` / `uninstall` only write or remove an `installer` dir (#89).
75
+ */
76
+ export declare function skillDirOwner(dir: string, installerDir?: string): Owner;
77
+ /** A plugin entry the harness will actually load (ACE skills-dir plugins are unverified, #63). */
78
+ export declare function activePlugin(i: Integration): boolean;
79
+ /** Inventory of one harness only (no runtime copies). */
80
+ export declare function inventoryHarness(h: Harness, opts: InventoryOptions): HarnessInventory;
81
+ /**
82
+ * Claude Code's mode (#62): `plugin` (the plugin is active), `loose`
83
+ * (`load-lock install` wiring: MCP server, hooks or CLAUDE.md section),
84
+ * `both`, or `none`. The skill doesn't count: the plugin ships none.
85
+ */
86
+ export type ClaudeCodeMode = "plugin" | "loose" | "both" | "none";
87
+ export declare function claudeCodeMode(h: HarnessInventory): ClaudeCodeMode;
88
+ export declare function inventory(opts: InventoryOptions): Inventory;
89
+ /** Human-readable inventory. */
90
+ export declare function formatInventory(inv: Inventory): string;
91
+ export interface PlanStep {
92
+ harness: Harness;
93
+ /**
94
+ * `install`: run `load-lock install --harness <h>` (after removing
95
+ * installer-shaped hooks of other/missing copies, and a stale codex MCP
96
+ * block, which `install` alone would keep). `remove-loose`: drop the
97
+ * loose Claude Code wiring an active plugin replaces (MCP server, hooks,
98
+ * CLAUDE.md section; the skill stays). `manual`: printed, never applied.
99
+ */
100
+ action: "install" | "remove-loose" | "manual";
101
+ detail: string;
102
+ }
103
+ export interface Plan {
104
+ steps: PlanStep[];
105
+ /** One-time hints (upgrade path), not actions. */
106
+ hints: string[];
107
+ }
108
+ export interface PlanOptions {
109
+ /** Package root of the copy running setup; null when unknown. */
110
+ selfCopy: string | null;
111
+ /** True when that copy lives in a cache npm/opencode may prune. */
112
+ selfEphemeral: boolean;
113
+ }
114
+ /** The running copy, and whether it sits in the npx / opencode cache. */
115
+ export declare function selfCopy(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): PlanOptions;
116
+ export declare function planConverge(inv: Inventory, opts: PlanOptions): Plan;
117
+ /**
118
+ * Drop the loose Claude Code wiring (installer-shaped entries only); keep
119
+ * the skill. Shared by `setup --yes` (`remove-loose`) and `install` when
120
+ * the plugin is enabled (#62).
121
+ */
122
+ export declare function removeClaudeLoose(h: HarnessInventory): string[];
123
+ export interface ApplyOptions {
124
+ project: string;
125
+ /** dist/ of the running copy (what `install` is called with). */
126
+ binDir?: string;
127
+ }
128
+ /** Apply the non-manual steps in order; returns what was done. */
129
+ export declare function applyPlan(inv: Inventory, plan: Plan, opts: ApplyOptions): Promise<string[]>;
130
+ /** Human-readable plan. */
131
+ export declare function formatPlan(plan: Plan, applied?: string[]): string;