@mcsherrylabs/pi-meerkat 0.10.1 → 0.11.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.
@@ -0,0 +1,151 @@
1
+ // Single-SDK gate for the Meerkat derived images (FR-015/023/029, contracts/processor-image.md §2).
2
+ //
3
+ // The base image already carries exactly one @mcsherrylabs/evolver-client under /app/node_modules.
4
+ // A derived image must not shadow it with a second copy, so the build stage installs the Meerkat
5
+ // tree, proves it contains exactly one client at the pinned version == the base SDK's version, then
6
+ // DELETES it (−−strip) so the final stage merges node_modules over the base and the base's own copy
7
+ // is the only candidate. The final stage re-counts on the real image filesystem (−−count-image).
8
+ //
9
+ // Usage:
10
+ // node check-single-sdk.mjs <root> --expect <version> (--strip | --count-image) [--base-sdk /base-sdk.json]
11
+ //
12
+ // --strip assert exactly one node_modules/**/@mcsherrylabs/evolver-client/package.json under
13
+ // <root> at version == --expect == the base SDK's version, refuse any top-level
14
+ // hoisted base-SDK dependency (@noble/*) at a different version, then delete the
15
+ // client directory so the final merge cannot shadow the base's copy.
16
+ // --count-image walk / (skipping /proc, /sys, /dev) and require exactly one client package.json at
17
+ // /app/node_modules/@mcsherrylabs/evolver-client/package.json at version --expect.
18
+ //
19
+ // Plain Node ESM, no dependencies. Exits non-zero with a precise message on any violation.
20
+ import { lstat, readFile, readdir, rm } from "node:fs/promises";
21
+ import { join, resolve, sep } from "node:path";
22
+
23
+ const SDK = "@mcsherrylabs/evolver-client";
24
+
25
+ function fail(message) {
26
+ process.stderr.write(`check-single-sdk: ${message}\n`);
27
+ process.exit(1);
28
+ }
29
+
30
+ function parseArgs(argv) {
31
+ const args = argv.slice(2);
32
+ if (args.length < 4) fail("usage: node check-single-sdk.mjs <root> --expect <version> (--strip | --count-image) [--base-sdk /base-sdk.json]");
33
+ const root = args[0];
34
+ let expect, mode, baseSdk = "/base-sdk.json";
35
+ for (let i = 1; i < args.length; i++) {
36
+ if (args[i] === "--expect") { expect = args[++i]; }
37
+ else if (args[i] === "--strip") { mode = "strip"; }
38
+ else if (args[i] === "--count-image") { mode = "count-image"; }
39
+ else if (args[i] === "--base-sdk") { baseSdk = args[++i]; }
40
+ else fail(`unrecognized argument: ${args[i]}`);
41
+ }
42
+ if (!expect) fail("missing --expect <version>");
43
+ if (!mode) fail("missing required mode: --strip or --count-image");
44
+ return { root, expect, mode, baseSdk };
45
+ }
46
+
47
+ async function json(path) {
48
+ try { return JSON.parse(await readFile(path, "utf8")); }
49
+ catch (error) { fail(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`); }
50
+ }
51
+
52
+ async function isDir(path) {
53
+ try { return (await lstat(path)).isDirectory(); } catch { return false; }
54
+ }
55
+
56
+ // Recursively collect every file at a path ending in `suffix`, pruning symlinked directories and
57
+ // node_modules below an already-stripped client (never, since we strip only after collecting).
58
+ async function walkFiles(dir, suffix, seenPaths = []) {
59
+ let entries;
60
+ try { entries = await readdir(dir, { withFileTypes: true }); }
61
+ catch { return seenPaths; }
62
+ for (const entry of entries) {
63
+ const full = join(dir, entry.name);
64
+ if (entry.isSymbolicLink() || entry.isFile() && !full.endsWith(suffix)) continue;
65
+ if (entry.isDirectory()) {
66
+ await walkFiles(full, suffix, seenPaths);
67
+ } else if (full.endsWith(suffix)) {
68
+ seenPaths.push(full);
69
+ }
70
+ }
71
+ return seenPaths;
72
+ }
73
+
74
+ async function findSdkCopies(root) {
75
+ const index = `${sep}node_modules${sep}@mcsherrylabs${sep}evolver-client${sep}package.json`;
76
+ const hits = [];
77
+ async function descend(dir) {
78
+ let entries;
79
+ try { entries = await readdir(dir, { withFileTypes: true }); }
80
+ catch { return; }
81
+ for (const entry of entries) {
82
+ if (entry.isSymbolicLink()) continue;
83
+ const full = join(dir, entry.name);
84
+ if (entry.isDirectory()) {
85
+ if (entry.name === "node_modules") {
86
+ // Direct scan of the two candidate SDK locations is enough to find a client copy; a
87
+ // nested client inside another package's node_modules is a full descent.
88
+ await descend(full);
89
+ } else {
90
+ await descend(full);
91
+ }
92
+ } else if (entry.name === "package.json" && full.endsWith(index)) {
93
+ hits.push(full);
94
+ }
95
+ }
96
+ }
97
+ await descend(root);
98
+ return hits;
99
+ }
100
+
101
+ const { root, expect, mode, baseSdk } = parseArgs(process.argv);
102
+
103
+ if (mode === "strip") {
104
+ const base = await json(baseSdk);
105
+ if (typeof base.version !== "string" || !base.version) fail(`base SDK manifest ${baseSdk} has no version`);
106
+ if (base.version !== expect) fail(`base SDK version ${base.version} != pinned --expect ${expect}`);
107
+ const hits = await findSdkCopies(root);
108
+ if (hits.length !== 1) fail(`expected exactly one @mcsherrylabs/evolver-client under ${root}, found ${hits.length}${hits.length ? ":\n " + hits.join("\n ") : ""}`);
109
+ const sdk = await json(hits[0]);
110
+ if (sdk.version !== expect) fail(`build-stage SDK version ${sdk.version} != pinned --expect ${expect} (base ${base.version})`);
111
+ // Deleting the build-stage client also deletes its nested dependencies; after the final merge the
112
+ // base's client resolves @noble/* from /app/node_modules top level. A Meerkat dependency hoisting a
113
+ // different @noble/* version would silently replace the base SDK's crypto, so refuse that up front.
114
+ const deps = base.dependencies ?? {};
115
+ for (const [name, pin] of Object.entries(deps)) {
116
+ const top = join(root, "node_modules", name, "package.json");
117
+ if (!(await isDir(join(root, "node_modules", name)))) continue;
118
+ const dep = await json(top);
119
+ if (dep.version !== pin) fail(`top-level ${name}@${dep.version} would replace the base SDK's pin ${pin}; refusing to strip`);
120
+ }
121
+ // Delete exactly the client directory so the final stage resolves the base's copy.
122
+ const dir = join(root, "node_modules", "@mcsherrylabs", "evolver-client");
123
+ await rm(dir, { recursive: true, force: true });
124
+ process.stdout.write(`check-single-sdk: stripped ${dir} (version ${expect} == base; ${hits.length} copy)\n`);
125
+ } else {
126
+ // --count-image: the real image filesystem has exactly one client, at the app root.
127
+ const skipped = new Set(["/proc", "/sys", "/dev"]);
128
+ const hits = [];
129
+ async function scan(dir) {
130
+ if (skipped.has(dir)) return;
131
+ let entries;
132
+ try { entries = await readdir(dir, { withFileTypes: true }); }
133
+ catch { return; }
134
+ for (const entry of entries) {
135
+ if (entry.isSymbolicLink()) continue;
136
+ const full = join(dir, entry.name);
137
+ if (full.endsWith(`${sep}@mcsherrylabs${sep}evolver-client${sep}package.json`) && entry.isFile()) {
138
+ hits.push(full);
139
+ } else if (entry.isDirectory()) {
140
+ await scan(full);
141
+ }
142
+ }
143
+ }
144
+ await scan("/");
145
+ if (hits.length !== 1) fail(`expected exactly one @mcsherrylabs/evolver-client on the image filesystem, found ${hits.length}${hits.length ? ":\n " + hits.join("\n ") : ""}`);
146
+ if (hits[0] !== join(sep, "app", "node_modules", "@mcsherrylabs", "evolver-client", "package.json"))
147
+ fail(`the single SDK must live at /app/node_modules/@mcsherrylabs/evolver-client/package.json, found ${hits[0]}`);
148
+ const sdk = await json(hits[0]);
149
+ if (sdk.version !== expect) fail(`image SDK version ${sdk.version} != pinned --expect ${expect}`);
150
+ process.stdout.write(`check-single-sdk: exactly one SDK at ${hits[0]} (version ${expect})\n`);
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcsherrylabs/pi-meerkat",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Meerkat zero-parameter DevNet installer (local chain with --local), stdio MCP facade and registry tools, with a private pinned Evolver MCP child. Preserves the announcement contract and existing Pi extension.",
5
5
  "bin": {
6
6
  "meerkat-mcp": "./dist/mcp-cli.js"
@@ -29,9 +29,9 @@
29
29
  "check:plan-two:client": "tsx test/mcp-client-checks.ts"
30
30
  },
31
31
  "dependencies": {
32
- "@mcsherrylabs/evolver-client": "0.23.0",
33
- "@mcsherrylabs/evolver-core": "0.2.1",
34
- "@mcsherrylabs/evolver-mcp": "0.16.0",
32
+ "@mcsherrylabs/evolver-client": "0.24.0",
33
+ "@mcsherrylabs/evolver-core": "0.2.2",
34
+ "@mcsherrylabs/evolver-mcp": "0.17.0",
35
35
  "@modelcontextprotocol/sdk": "1.32.1",
36
36
  "typebox": "1.3.32"
37
37
  },
@@ -1,192 +0,0 @@
1
- // Meerkat composition root for the shared processor, not a second collector/runtime.
2
- // All paths/capabilities are operator-fixed here; requests can supply only a channel id.
3
- import { join } from "node:path";
4
- import { connect as netConnect } from "node:net";
5
- import { lstat, unlink } from "node:fs/promises";
6
- import { nodeControlRestarter, nodeObserver, nodePuller, startControlServer, startProcessor, startReadServer, subscriptionConfig, subscriptionController, } from "@mcsherrylabs/evolver-client/processor";
7
- import { ensureStateDir } from "./env.js";
8
- const productionSdk = {
9
- nodeControlRestarter, nodeObserver, nodePuller, startControlServer, startProcessor,
10
- startReadServer, subscriptionConfig, subscriptionController,
11
- };
12
- export function processorBinding() {
13
- return { chainId: process.env.EVOLVER_CHAIN_ID?.trim() ?? "", nodeId: process.env.APP_NODE_ID?.trim() ?? "" };
14
- }
15
- export function processorConfigPath(target) {
16
- return process.env.EVOLVER_SUBSCRIPTIONS_FILE?.trim() || join(target.stateDir, "subscriptions", "channels.conf");
17
- }
18
- export function processorStatePath(target) {
19
- return join(target.stateDir, "registry.db");
20
- }
21
- /** A stale control socket left by an unclean registry exit would make the SDK's control
22
- * server refuse to bind ("Never unlink/steal a pre-existing socket"). Only when the path is
23
- * a SOCKET and nobody is listening (ECONNREFUSED on a bounded connect) is it safe to unlink:
24
- * this process is the sole owner of its control directory. A live owner (connect succeeds) or
25
- * any other error, or a non-socket path, is left untouched so the SDK refuses as designed. */
26
- export async function removeStaleControlSocket(socketPath) {
27
- let stat;
28
- try {
29
- stat = await lstat(socketPath);
30
- }
31
- catch (error) {
32
- if (error.code === "ENOENT")
33
- return;
34
- throw error;
35
- }
36
- if (!stat.isSocket())
37
- return; // never unlink a non-socket; the node/SDK fails closed on it
38
- const result = await new Promise(resolve => {
39
- const socket = netConnect(socketPath);
40
- const timer = setTimeout(() => { socket.destroy(); resolve("error"); }, 1000);
41
- socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve("connected"); });
42
- socket.once("error", (error) => {
43
- clearTimeout(timer);
44
- resolve(error.code === "ECONNREFUSED" ? "refused" : "error");
45
- });
46
- });
47
- if (result === "refused")
48
- await unlink(socketPath);
49
- }
50
- export async function startMeerkatProcessor(args, sdk = productionSdk) {
51
- const binding = processorBinding();
52
- // Synchronous SDK validation of fixed expectations, NOT an online-node startup dependency.
53
- const observe = sdk.nodeObserver({ baseUrl: args.target.nodeUrl, binding });
54
- const config = sdk.subscriptionConfig(processorConfigPath(args.target));
55
- await config.read(); // missing/malformed declaration refuses before opening storage; never reseed
56
- let initialized = false;
57
- ensureStateDir(args.target);
58
- const runtime = await sdk.startProcessor({
59
- store: {
60
- path: processorStatePath(args.target), binding,
61
- modules: args.worker ? [args.worker] : [],
62
- limits: { maxEntriesPerChannel: 1000, maxBodyBytes: 64 * 1024 * 1024 },
63
- },
64
- puller: sdk.nodePuller(args.node), config,
65
- // SDK initialization normally attempts setup (which can pace). Defer that attempt until
66
- // the caller explicitly paces/runs: service composition binds recovery listeners first.
67
- // Every subsequent pace still uses the SDK's verified node/chain/ACTIVE gate.
68
- observe: async () => {
69
- if (!initialized)
70
- throw new Error("Meerkat listeners not started; collection deferred");
71
- return observe();
72
- },
73
- tip: async () => String(await args.node.currentRound()),
74
- modules: args.lifecycle ? [args.lifecycle] : [], say: args.say,
75
- }).catch(error => {
76
- if (error?.name === "ProcessorOwnerLockError") {
77
- args.say(error.message);
78
- args.say("MANUAL FORCE RECOVERY REQUIRED if the owner has died. Using the SAME Compose project/env/files/volumes: 1) docker compose stop app; stop any other collectors and disable all external automatic restarts. 2) Only when fenced: docker compose run --rm --no-deps app node dist/scripts/recover-owner.js --force. 3) docker compose up -d --no-deps app. For a host-run app, stop it and its supervisor first, then run node dist/scripts/recover-owner.js --force with the SAME target/state environment and restart normally. Ordinary restart or --force-recreate does NOT clear the lock. Never delete database/WAL or reinitialize subscriptions.");
79
- }
80
- throw error;
81
- });
82
- initialized = true;
83
- return runtime;
84
- }
85
- /** Always attempt every release, including when another close fails. */
86
- export async function closeMeerkat(actions) {
87
- const errors = [];
88
- for (const action of actions) {
89
- try {
90
- await action();
91
- }
92
- catch (error) {
93
- errors.push(error);
94
- }
95
- }
96
- if (errors.length)
97
- throw new AggregateError(errors, "Meerkat shutdown failed");
98
- }
99
- export async function startMeerkatServers(args, sdk = productionSdk) {
100
- const servers = [];
101
- try {
102
- servers.push(await sdk.startReadServer({
103
- store: args.runtime.store, port: args.port,
104
- host: process.env.PROCESSOR_READ_HOST?.trim() || "127.0.0.1", routes: args.routes, say: args.say,
105
- }));
106
- const socketPath = process.env.EVOLVER_PROCESSOR_CONTROL_SOCKET?.trim();
107
- if (socketPath) {
108
- await removeStaleControlSocket(socketPath);
109
- const binding = processorBinding();
110
- const controller = sdk.subscriptionController({
111
- binding, runtime: args.runtime, config: sdk.subscriptionConfig(processorConfigPath(args.target)),
112
- observe: sdk.nodeObserver({ baseUrl: args.target.nodeUrl, binding }),
113
- restart: sdk.nodeControlRestarter({ socketPath: "/node-control/node.sock", expected: binding }),
114
- });
115
- servers.push(await sdk.startControlServer({ socketPath, mode: 0o660, controller, say: args.say }));
116
- }
117
- return { port: servers[0].port, close: () => closeMeerkat([...servers].reverse().map(server => () => server.close())) };
118
- }
119
- catch (error) {
120
- try {
121
- await closeMeerkat(servers.reverse().map(server => () => server.close()));
122
- }
123
- catch (cleanup) {
124
- throw new AggregateError([error, cleanup], "Meerkat listener startup failed");
125
- }
126
- throw error;
127
- }
128
- }
129
- /** One service owner: bind listeners before run/setup, and release the worker on partial bind. */
130
- export async function startMeerkatService(args, sdk = productionSdk) {
131
- const runtime = await startMeerkatProcessor(args, sdk);
132
- let servers;
133
- try {
134
- servers = await startMeerkatServers({ ...args, runtime, routes: args.routes(runtime) }, sdk);
135
- runtime.run();
136
- let stopping;
137
- return { runtime, port: servers.port, stop() {
138
- return stopping ??= closeMeerkat([() => servers.close(), () => runtime.stop()]);
139
- } };
140
- }
141
- catch (error) {
142
- try {
143
- await closeMeerkat([...(servers ? [() => servers.close()] : []), () => runtime.stop()]);
144
- }
145
- catch (cleanup) {
146
- throw new AggregateError([error, cleanup], "Meerkat service startup failed");
147
- }
148
- throw error;
149
- }
150
- }
151
- /** Signals and normal/error completion share one cleanup path, including partial async startup. */
152
- export function meerkatShutdown(signals = process) {
153
- let stopping;
154
- const releases = [];
155
- const stop = () => stopping ??= closeMeerkat([...releases].reverse());
156
- const onSignal = () => { void stop().then(() => signals.exit(0), error => { console.error(error); signals.exit(1); }); };
157
- signals.once("SIGINT", onSignal);
158
- signals.once("SIGTERM", onSignal);
159
- return {
160
- async own(start, release) {
161
- if (stopping)
162
- throw new Error("Meerkat stopped during startup");
163
- const pending = start();
164
- // Register before awaiting so a signal DURING startup waits for and releases its result.
165
- releases.push(async () => {
166
- let resource;
167
- try {
168
- resource = await pending;
169
- }
170
- catch {
171
- return;
172
- } // startup owns its partial cleanup
173
- await release(resource);
174
- });
175
- const resource = await pending;
176
- if (stopping) {
177
- await stopping;
178
- throw new Error("Meerkat stopped during startup");
179
- }
180
- return resource;
181
- },
182
- async close() {
183
- try {
184
- await stop();
185
- }
186
- finally {
187
- signals.removeListener("SIGINT", onSignal);
188
- signals.removeListener("SIGTERM", onSignal);
189
- }
190
- },
191
- };
192
- }