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),

224 lines (214 loc) • 11.7 kB
#!/usr/bin/env node /** * The shared install step must say the same thing in every blog post. * * node scripts/check-blog-boilerplate.mjs * * ## Why * * One sentence — "the panel auto-starts a background agent on your Claude subscription" — * was copy-pasted into TEN posts. It was true when it was written. When the panel became a * pure-frontend pack that cannot spawn a process, it became false in ten places at the same * moment, and nothing noticed: no test covers prose, and each copy looked locally fine. * * `docs/snippets/panel-install.mdx` is now the single source of truth for that sentence. This * gate asserts every post that carries the step matches it exactly, so the copies cannot drift * apart again — and so fixing the snippet tells you, by name, which posts still need updating. * * ## Why not just import the snippet? * * That is the better end state and the snippet is written to support it: Mintlify renders * `/snippets/*.mdx` as importable components. The blocker is that the step lives inside a * NUMBERED LIST, and a block-level component inside a list item is exactly the kind of MDX * construct that renders differently than it reads. Converting ten live posts to it without a * local `mint dev` render check is a bet on the docs site, so the conversion is deliberately * left as a follow-up for whoever can preview it. Until then, this gate buys the same * anti-drift property at no rendering risk. */ import fs from 'node:fs'; import path from 'node:path'; const ROOT = path.resolve(import.meta.dirname, '..'); const BLOG = path.join(ROOT, 'docs', 'blog'); const SNIPPET = path.join(ROOT, 'docs', 'snippets', 'panel-install.mdx'); const eol = (s) => s.replace(/\r\n/g, '\n'); /** Collapse wrapping so a line-broken copy compares equal to a single-line one. */ const flat = (s) => eol(s).replace(/\s+/g, ' ').trim(); /** * Join SOFT line breaks, keep paragraph breaks. * * These posts are hard-wrapped at ~80 columns, so a retired claim routinely straddles a * newline: "the panel auto-starts a\nbackground agent" renders identically to the banned * sentence. Round 1 tested `flat(src)` (immune to wrapping, but a pattern could then match * across a paragraph boundary); round 2 moved to raw source and reintroduced the wrap hole in * the process. Neither is right — unwrap within a paragraph, preserve the blank line between. */ const unwrap = (s) => eol(s) .split(/\n{2,}/) .map((para) => para.replace(/\n[ \t]*/g, ' ')) .join('\n\n'); if (!fs.existsSync(SNIPPET)) { console.error(`missing ${path.relative(ROOT, SNIPPET)} — it is the source of truth for this step`); process.exit(1); } const canonical = flat(fs.readFileSync(SNIPPET, 'utf8')); // An empty source of truth silently disables the comparison: `"".includes("")` is true, so // every post would "match" and the gate would report green while checking nothing. Existence // is not enough — a truncated or cleared snippet is exactly how this dies quietly. if (canonical.length < 40) { console.error( `${path.relative(ROOT, SNIPPET)} is empty or too short (${canonical.length} chars) — ` + `with no canonical text every post trivially matches and this gate checks nothing`, ); process.exit(1); } /** * How we find posts carrying the SHARED step. * * Deliberately the sentence's distinctive tail, not its opening clause. Matching on * "Install comfyui-mcp and the Panel" caught two posts (train-lora-runpod, * video-extend-pusa-comfyui) that open the same way and then continue with their own * instructions — setting RUNPOD_API_KEY, applying a specific pack. Those carry a different * step, not a drifted copy of this one, and demanding they match would force unrelated * rewrites. The retired-claim check below still applies to every post regardless. */ const MARKER = /sign in with `claude` once/; /** * The exact SET of posts that carry the shared step — identity, not a count. * * Three attempts, each killed by the next mutation: * `>= 10` floor — one post opted out, count hit exactly 10, passed. A margin hides the * first defection, which is the only one that matters. * `=== 11` count — catches a lone departure, but a swap is invisible: one carrier edits its * marker and leaves while a new post adopts the step, and 11 still holds. * this set — names them. A departure, an arrival, or a swap all fail, by filename. * * The underlying weakness is that MARKER both selects and validates, so the marker text can * opt a post out. codex confirmed no content-only selector can infer intended membership; the * clean fix is importing a shared component, which is blocked on rendering a block component * inside a numbered list. Until then, membership is recorded here on purpose. */ const EXPECTED_CARRIERS = new Set([ 'anima-comfyui.mdx', 'comfy-kitchen.mdx', 'ernie-image-comfyui.mdx', 'ideogram-4-comfyui.mdx', 'lora-trainer-p1.mdx', 'ltx-2.3-comfyui.mdx', 'qwen-image-comfyui.mdx', 'video-extend-pusa-comfyui.mdx', 'wan-2.2-comfyui.mdx', 'wan-animate-comfyui.mdx', 'wan-transparent-comfyui.mdx', 'z-image-comfyui.mdx', ]); // Written from a MEASUREMENT, not from memory. The first version of this list was typed from // recall and was wrong three ways in eleven entries — it invented two carriers // (comfyui-agent-claude-or-chatgpt, krea2) and missed ltx-2.3. The gate caught all three on // its first run, which is the argument for identity over a count: a count cannot tell you // that the members are wrong, only that there are the right number of them. /** Claims this step must never make again, with why. */ const RETIRED = [ { pattern: /auto-starts a background agent/i, why: 'the panel is a pure-frontend pack and cannot spawn the orchestrator; the user runs `connect`', }, { pattern: /external\/local orchestrator/i, why: 'that setting has no UI row and the mode is unconditionally on', }, { // Caught in comfyui-agent-claude-or-chatgpt AFTER the first two patterns had already // cleaned ten posts: same false claim, different verb, so it sailed through. The panel // is a pure frontend extension and spawns nothing. // `[^.]` let the match run across a CLAUSE boundary, so correct prose like // "The panel starts disconnected; launch the orchestrator yourself" tripped it — a false // positive whose only fix is deleting accurate manual-launch guidance, which is the worst // way for a docs gate to be wrong. Bar `;`, `,`, `—` and newlines too: the false claim is // one clause ("the panel starts the orchestrator"), never a sentence spanning punctuation. // The object must be the orchestrator, not merely near it. A `[^.;,]{0,40}` allowance // still matched "The panel starts disconnected until you launch the orchestrator" — // correct guidance saying the OPPOSITE of the retired claim. Only determiners, possessives // and adjectives may sit between the verb and the noun. // Any adjective may sit between the verb and the noun, EXCEPT words that open a new // clause. The previous version whitelisted only "background" and possessives while its // comment claimed it allowed adjectives, so "the panel starts a local orchestrator" — a // genuinely false claim — passed. Whitelists of adjectives are unwinnable; the short // stoplist below is what actually separates the two meanings, because in the CORRECT // sentences a verb like "launch" or a conjunction always intervenes. pattern: /panel\s+(?:starts|spawns|launches|boots)\s+(?:(?!(?:until|unless|before|after|when|then|so|because|and|but|if|once|launch|run|start|connect|yourself|without|no|nothing|neither|instead)\b)[A-Za-z][A-Za-z’'-]*\s+){0,3}orchestrator/i, why: 'the panel is a pure frontend extension and cannot spawn a process; the user runs `connect`', }, { // Same post claimed a bridge port per backend. src/orchestrator/index.ts:1450 is explicit: // "Single-port multi-provider: ONE orchestrator on ONE bridge port (default 9180) serves // ALL providers" — the provider is chosen per TAB over the hello/set_backend handshake. pattern: /each backend runs its\s+\*{0,2}own\*{0,2}\s+orchestrator/i, why: 'one orchestrator on one bridge port serves every provider; the panel selects per tab', }, ]; let failures = 0; const carrying = new Set(); for (const file of fs.readdirSync(BLOG).filter((f) => f.endsWith('.mdx'))) { const src = fs.readFileSync(path.join(BLOG, file), 'utf8'); const f = flat(src); // Against the NORMALIZED-but-unflattened source. `flat()` collapses every newline to a // space, so excluding the newline character inside these patterns was a no-op — a retired // claim could still match across a paragraph break. Line structure is signal here, not noise. const prose = unwrap(src); for (const { pattern, why } of RETIRED) { // Look BACKWARDS from each match instead of blanket-stripping negated spans. The strip // approach deleted any 60 characters after a negation cue, so an unrelated correct // negation earlier in the sentence swallowed a real claim: "The user does not connect // because the panel auto-starts a background agent" erased the very phrase being checked. // Now the cue only excuses a claim it is actually attached to. const NEGATION = /\b(?:no longer|not|doesn['’]t|does not|cannot|can['’]t|never|used to)\s*$/i; // 'gi' outright — the source patterns already carry `i`, and appending produced 'igi', // which throws. matchAll requires the global flag. const hits = [...prose.matchAll(new RegExp(pattern.source, 'gi'))]; const affirmative = hits.filter((h) => !NEGATION.test(prose.slice(Math.max(0, h.index - 24), h.index))); if (affirmative.length) { failures++; console.error(` ✗ blog/${file}: retired claim "${pattern.source}" — ${why}`); } } if (!MARKER.test(f)) continue; carrying.add(file); if (!f.includes(canonical)) { failures++; console.error( ` ✗ blog/${file}: the install step has drifted from docs/snippets/panel-install.mdx`, ); } } // A FLOOR on carriers. The marker both selects and validates, so editing the marker text // opts a post out of coverage entirely — and if every post drifted, `carrying` would be 0 and // this gate would report success having compared nothing. codex called this fail-open twice; // a floor does not fix the selector, but it does make total collapse loud instead of green. const missingCarriers = [...EXPECTED_CARRIERS].filter((f) => !carrying.has(f)); const extraCarriers = [...carrying].filter((f) => !EXPECTED_CARRIERS.has(f)); for (const f of missingCarriers) { failures++; console.error( ` ✗ blog/${f}: expected to carry the shared install step but does not — its marker ` + `text was probably edited, which silently removes it from this gate`, ); } for (const f of extraCarriers) { failures++; console.error( ` ✗ blog/${f}: carries the shared install step but is not in EXPECTED_CARRIERS — ` + `add it once you have confirmed the step is right for this post`, ); } console.log(`${carrying.size} post(s) carry the shared install step`); if (failures) { console.error( `\n${failures} problem(s). Edit docs/snippets/panel-install.mdx, then make every post match ` + `it — that file is the source of truth precisely so this sentence cannot be wrong in ten ` + `places at once again.`, ); process.exit(1); } console.log('blog boilerplate OK');