UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

286 lines (271 loc) • 14 kB
#!/usr/bin/env node /** * Release smoke test: pack the package exactly as `npm publish` would, install * that tarball into a clean throwaway project, and verify the published * layout + entrypoint actually boot. * * Catches the class of bug that shipped in 0.17.0: a `files` allowlist that * dropped `scripts/` while `package.json` still declared a lifecycle script * pointing into it — so every `npm install` / `npx` crashed on a missing file. * Unit tests (`npm test`) never exercise the packed tarball, so this is the * gap. Run in CI and as a pre-publish gate. */ import { execSync, spawn, spawnSync } from "node:child_process"; import { mkdtempSync, writeFileSync, existsSync, readdirSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; /** * Ask the installed server for its tool list over MCP stdio. Resolves to the * names, or null if it never answers within the budget — a silence this script * reports rather than reading as "no tools". */ function listTools(entry) { return new Promise((resolve) => { const child = spawn(process.execPath, [entry], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, COMFYUI_URL: "http://127.0.0.1:59999" }, }); const send = (o) => child.stdin.write(JSON.stringify(o) + "\n"); let buf = ""; const done = (v) => { try { child.kill(); } catch { /* already gone */ } resolve(v); }; const timer = setTimeout(() => done(null), 60_000); child.stdout.on("data", (d) => { buf += d.toString(); let i; while ((i = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, i); buf = buf.slice(i + 1); if (!line.trim()) continue; let msg; try { msg = JSON.parse(line); } catch { continue; } if (msg.id === 1) send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); if (msg.id === 2) { clearTimeout(timer); done((msg.result?.tools ?? []).map((t) => t.name)); } } }); child.on("error", () => { clearTimeout(timer); done(null); }); send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "smoke", version: "1" } }, }); }); } const run = (cmd, opts = {}) => { console.log(`$ ${cmd}${opts.cwd ? ` (cwd: ${opts.cwd})` : ""}`); execSync(cmd, { stdio: "inherit", ...opts }); }; // 1. Pack exactly what `npm publish` would upload. const packDir = mkdtempSync(join(tmpdir(), "cmcp-pack-")); run(`npm pack --pack-destination "${packDir}"`); const tgz = readdirSync(packDir).find((f) => f.endsWith(".tgz")); if (!tgz) throw new Error("npm pack produced no tarball"); const tarball = join(packDir, tgz); // 2. Install the tarball in a clean project. const proj = mkdtempSync(join(tmpdir(), "cmcp-smoke-")); writeFileSync( join(proj, "package.json"), JSON.stringify({ name: "comfyui-mcp-smoke", private: true, version: "0.0.0" }), ); // --foreground-scripts surfaces dependency install-script output; a crashing // script exits non-zero. run(`npm install --foreground-scripts --no-audit --no-fund "${tarball}"`, { cwd: proj }); // 3. Verify the published layout is intact (files the runtime/install need). const pkg = join(proj, "node_modules", "comfyui-mcp"); for (const f of ["dist/index.js", "package.json"]) { if (!existsSync(join(pkg, f))) { console.error(`❌ published package is missing ${f}`); process.exit(1); } } if (!existsSync(join(proj, "node_modules", ".bin", "comfyui-mcp")) && !existsSync(join(proj, "node_modules", ".bin", "comfyui-mcp.cmd"))) { console.error("❌ comfyui-mcp bin was not linked"); process.exit(1); } // 4. Entrypoint parses (syntax) and boots without an immediate crash. run(`node --check "${join(pkg, "dist/index.js")}"`); const boot = spawnSync(process.execPath, [join(pkg, "dist/index.js")], { input: "", timeout: 8000, encoding: "utf8", }); // Pass if it stayed up until the timeout (SIGTERM) or exited cleanly on stdin EOF. if (boot.signal === "SIGTERM" || boot.status === 0) { console.log(`✅ entrypoint boots (signal=${boot.signal}, status=${boot.status})`); } else { console.error(`❌ entrypoint crashed on boot (status=${boot.status}, signal=${boot.signal})`); if (boot.stdout) console.error("stdout:\n" + boot.stdout); if (boot.stderr) console.error("stderr:\n" + boot.stderr); process.exit(1); } // 5. The published surface actually REGISTERS. Booting proves the process starts; // it does not prove the tools are there. A packaging or registration // regression that leaves the server up but short a tool would pass step 4 and // ship — the user finds out when the tool they wanted is simply absent. // // Driven over real MCP stdio against the INSTALLED tarball, so this is the // surface a user gets, not the one the repo builds. COMFYUI_URL points at a // dead port on purpose: registration must not depend on a reachable ComfyUI. // The ledger IS the list, not a ceiling: TOOL_NAMES holds every core tool, and // MAX_TOOLS beside it is a budget target that merely happens to match today. // Counting the list keeps this honest if the two ever diverge. const LEDGER = readFileSync(join(process.cwd(), "src/tools/vocabulary.ts"), "utf-8"); const EXPECTED_CORE = ( LEDGER.match(/TOOL_NAMES\s*=\s*\[[\s\S]*?\n\]/)?.[0].match(/"[a-z][a-z0-9_]*"/g) ?? [] ).length; const surface = await listTools(join(pkg, "dist/index.js")); if (surface === null) { console.error("❌ the installed server never answered tools/list"); process.exit(1); } // The three compact-mode meta tools (list_tools / describe_tool / call_tool) are // registered ALONGSIDE the core surface and are deliberately absent from the // ledger, so they are counted out rather than expected in it. const META = ["list_tools", "describe_tool", "call_tool"]; const core = surface.filter((n) => !META.includes(n)); const missingMeta = META.filter((n) => !surface.includes(n)); if (EXPECTED_CORE > 0 && core.length !== EXPECTED_CORE) { console.error( `❌ installed surface has ${core.length} core tools, ledger declares ${EXPECTED_CORE}` + `\n registered: ${core.join(", ")}`, ); process.exit(1); } if (missingMeta.length) { console.error(`❌ compact-mode tools missing from the installed surface: ${missingMeta.join(", ")}`); process.exit(1); } console.log(`✅ installed surface registers ${core.length} core tools + ${META.length} compact-mode tools`); // 6. The PANEL surface too. Those 91 tools are the whole reason the panel can // drive a live canvas, they ship in the same tarball, and nothing else here // checks that the published build still builds them. buildPanelToolDefs is // pure — no server, no socket — so this is a direct call into the INSTALLED // dist rather than another stdio round trip. // // Compared against THIS REPO'S build, not against docs/design/panel-surface.txt. // That baseline is frozen HISTORY, not the current surface: check-tool-vocabulary // requires live ⊆ baseline, so the baseline only ever grows, and a retired panel // tool keeps its line there forever. Counting against it passes today only // because nothing has been retired yet — the first legitimate retirement would // read as "installed build makes 90, ledger declares 92" and fail a RELEASE, the // one gate a false alarm is most expensive in. The ledger relationship is already // enforced on every PR by check:vocabulary, from both sides; what only this script // can see is whether the TARBALL carries the same surface the repo builds, which // is a packaging question and is what it now asks. // // `prepare: tsc` runs during npm pack in step 1, so ./dist is a fresh build of // this source. Two separate module instances, same process and env. async function panelNames(root, label) { try { const mod = await import(pathToFileURL(join(root, "dist/orchestrator/panel-tools.js")).href); if (typeof mod.buildPanelToolDefs !== "function") { console.error(`❌ the ${label} build exports no buildPanelToolDefs`); process.exit(1); } return mod.buildPanelToolDefs().map((d) => d.name); } catch (err) { console.error(`❌ could not load the ${label} panel tools: ${err?.message ?? err}`); process.exit(1); } } const installedPanel = await panelNames(pkg, "installed"); const repoPanel = await panelNames(process.cwd(), "repo"); // A comparison of two empty lists is equal and proves nothing — the vacuous pass // that the sibling gate tests each carry a floor assertion against. if (repoPanel.length < 50) { console.error(`❌ the repo build makes only ${repoPanel.length} panel tools — check is not looking at a real surface`); process.exit(1); } const onlyInstalled = installedPanel.filter((n) => !repoPanel.includes(n)); const onlyRepo = repoPanel.filter((n) => !installedPanel.includes(n)); if (onlyInstalled.length || onlyRepo.length) { console.error( `❌ the tarball's panel surface differs from this repo's build` + (onlyRepo.length ? `\n in the repo but NOT in the tarball: ${onlyRepo.join(", ")}` : "") + (onlyInstalled.length ? `\n in the tarball but NOT in the repo: ${onlyInstalled.join(", ")}` : ""), ); process.exit(1); } console.log(`✅ tarball's ${installedPanel.length} panel tools match this repo's build`); // 7. The SHIPPED build's data-loss guards actually guard. // // Steps 1-6 establish that the tarball installs, boots, and registers the // right names. None of them establish that it still BEHAVES — and the three // checks below are the ones where a silent regression is worst, because each // protects something the user cannot get back: // // • the phone pairing token (#875) — losing it silently breaks a paired // phone, and the user experiences it as "the update bricked my link"; // • the defaults config (#1087) — hand-written, and a read-modify-write // would replace it with one key; // • ~/.claude.json (#1091) — not even our file: every MCP server // the user has, plus credentials stored in a server's headers/env. // // Unit tests cover all three against the SOURCE. This is the only thing that // covers them against what we are about to publish, which is a different // claim — and cheap, because these paths take an env-redirected path and // touch nothing else. Every path below is under a temp dir; a bug here must // never be able to write to a real home directory. // // Kept to guards with a stable, one-call API. A behavioural check that needs // elaborate setup does not belong in a release gate — it becomes the reason // people start passing --no-verify. const guardDir = mkdtempSync(join(tmpdir(), "cmcp-guards-")); const guardEnv = { ...process.env, XDG_CONFIG_HOME: guardDir }; const guardScript = ` import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; // A file:// URL, not a bare path: on Windows \`import("C:/…")\` throws // ERR_UNSUPPORTED_ESM_URL_SCHEME. const P = ${JSON.stringify(pathToFileURL(join(pkg, "dist")).href)}; const dir = ${JSON.stringify(guardDir.replace(/\\/g, "/"))}; const fail = (m) => { console.error("GUARD FAILED: " + m); process.exit(1); }; // #875 — the pairing token must survive a restart (a second load). process.env.COMFYUI_MCP_PAIR_TOKEN_FILE = join(dir, "pair-token"); const { loadOrCreatePairToken } = await import(P + "/orchestrator/pair-token-store.js"); const a = loadOrCreatePairToken(), b = loadOrCreatePairToken(); if (!a.persisted || a.token !== b.token) fail("the pairing token did not persist across loads (#875)"); // #1087 — an unloadable defaults config is preserved, never overwritten. mkdirSync(join(dir, "comfyui-mcp"), { recursive: true }); const cfg = join(dir, "comfyui-mcp", "config.json"); const ORIGINAL = '{ "width": 1024, }'; writeFileSync(cfg, ORIGINAL); const { DefaultsManager } = await import(P + "/services/defaults-manager.js"); DefaultsManager.configure({ configPath: cfg, env: {} }); await DefaultsManager.load(); if (!DefaultsManager.configLoadError()) fail("an unloadable defaults config was not reported (#1087)"); await DefaultsManager.set({ height: 512 }, { persist: true }); const kept = readdirSync(join(dir, "comfyui-mcp")).filter((f) => f.includes(".unreadable-")); if (kept.length !== 1) fail("the unloadable defaults config was NOT preserved (#1087)"); if (readFileSync(join(dir, "comfyui-mcp", kept[0]), "utf-8") !== ORIGINAL) fail("the preserved defaults config was not byte-identical (#1087)"); // #1091 — an unreadable ~/.claude.json is REFUSED, not replaced. Not our file. const claude = join(dir, "claude.json"); writeFileSync(claude, ORIGINAL); process.env.COMFYUI_MCP_CLAUDE_JSON = claude; const umc = await import(P + "/services/user-mcp-config.js"); let refused = false; try { umc.addUserMcpServer("x", { command: "node" }); } catch { refused = true; } if (!refused) fail("an unreadable ~/.claude.json was not refused (#1091)"); if (readFileSync(claude, "utf-8") !== ORIGINAL) fail("~/.claude.json was modified despite the refusal (#1091)"); console.log("guards ok"); `; const guardFile = join(guardDir, "guards.mjs"); writeFileSync(guardFile, guardScript); const guards = spawnSync(process.execPath, [guardFile], { cwd: pkg, env: guardEnv, encoding: "utf8", timeout: 60_000, }); if (guards.status !== 0) { console.error("❌ the shipped build's data-loss guards did not hold"); if (guards.stdout) console.error(guards.stdout.trim()); if (guards.stderr) console.error(guards.stderr.trim()); process.exit(1); } console.log("✅ shipped build's data-loss guards hold (pair token, defaults config, ~/.claude.json)"); console.log("✅ pack/install smoke passed — tarball installs cleanly, boots, and registers its tools");