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

498 lines • 24.1 kB
import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { platform } from "node:os"; import { resolveEffectiveComfyUICodeBaseLive, resolveInstallInterpreter, } from "./workspace-env.js"; import { queueUpdateAllCustomNodes } from "./node-management.js"; import { ProcessControlError } from "../utils/errors.js"; import { logger } from "../utils/logger.js"; import { recordPanelPendingOp, UPDATE_ALL_PENDING_MS, withPanelPinGuard, } from "./panel-pin-guard.js"; // --------------------------------------------------------------------------- // Cross-platform helpers // --------------------------------------------------------------------------- const IS_WIN = platform() === "win32"; /** * Run a command, capturing stdout+stderr. Throws ProcessControlError on * non-zero exit so callers can surface a clear failure. */ function runCommand(file, args, cwd) { const command = [file, ...args].join(" "); logger.info(`Running: ${command}`, { cwd }); try { const output = execFileSync(file, args, { cwd, encoding: "utf-8", timeout: 300_000, // Inherit env so PATH resolves git/uv/pip; merge stderr into stdout. stdio: ["ignore", "pipe", "pipe"], }); return { command, ok: true, output: (output ?? "").trim() }; } catch (err) { const e = err; const out = [e.stdout, e.stderr] .map((b) => (b == null ? "" : b.toString())) .join("") .trim(); throw new ProcessControlError(`Command failed: ${command}\n${out || e.message || "unknown error"}`); } } /** * Read-only git probe. Returns trimmed stdout, or null when the command fails * for ANY reason — not a git repo, no upstream, an unknown ref. Never throws: * these calls exist to describe the checkout, and a failed description must not * take down the update itself. */ function gitProbe(args, cwd) { try { const out = execFileSync("git", args, { cwd, encoding: "utf-8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], }); const t = (out ?? "").trim(); return t === "" ? null : t; } catch { return null; } } /** ComfyUI's historical default, then the generic git default. */ const UPDATE_BRANCHES = ["master", "main"]; /** * Stale remote-tracking ref vs new nested ref: `refs/remotes/origin/dev` exists * as a file, so git cannot create `refs/remotes/origin/dev/<child>` (#2524). * Git's own hint is `git remote prune origin`. Other lock failures (index.lock, * another process) must NOT match — those are not cured by pruning. */ export function isStaleRemoteRefLock(output) { const nestedLock = /cannot lock ref\s+['"]?refs\/remotes\/origin\//i.test(output); const parentExists = /exists/i.test(output); const pruneHint = /git remote prune origin/i.test(output); return (nestedLock && parentExists) || pruneHint; } /** `git pull` on a DETACHED HEAD: fetch may work, merge has no branch (#2524). */ export function isDetachedPullFailure(output) { return /you are not currently on a branch/i.test(output); } function localUpdateBranch(cwd) { for (const name of UPDATE_BRANCHES) { if (gitProbe(["show-ref", "--verify", `refs/heads/${name}`], cwd)) { return name; } } return null; } /** Empty porcelain = clean. A failed status is NOT clean — we cannot prove it. */ function workingTreeClean(cwd) { try { const out = execFileSync("git", ["status", "--porcelain"], { cwd, encoding: "utf-8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], }); return (out ?? "").trim() === ""; } catch { return false; } } /** * `install_comfyui(action:"update")` is an explicit request to move the * checkout. A detached release-tag HEAD (Comfy Desktop / `git checkout vX.Y.Z`) * has no branch for `git pull` to merge with. When a local master/main exists * and the tree is clean, switch onto it before pulling. Dirty trees and * missing local branches are left untouched — switching those is not ours. */ function tryAttachUpdateBranch(cwd, currentBranch, steps) { if (currentBranch) return { switched: false, branch: currentBranch, dirty: false }; const branch = localUpdateBranch(cwd); if (!branch) return { switched: false, branch: null, dirty: false }; const dirty = !workingTreeClean(cwd); if (dirty) return { switched: false, branch, dirty: true }; steps.push(runCommand("git", ["checkout", branch], cwd)); return { switched: true, branch, dirty: false }; } function detachedPullRefusalMessage(comfyuiPath, attach) { const intro = `ComfyUI core was NOT updated in ${comfyuiPath}: HEAD is DETACHED ` + `(a tag or bare commit, which is how release-tag and Comfy Desktop installs are set up), ` + `so \`git pull\` has no branch to merge with.`; const tail = "Python requirements were NOT reinstalled, because nothing changed that would need them."; if (attach.branch && attach.dirty) { return (`${intro} Local branch "${attach.branch}" exists, but the working tree is not clean ` + `so this tool will not switch branches. Commit or stash local changes, then ` + `\`git checkout ${attach.branch}\` and retry. ${tail}`); } if (attach.branch) { return (`${intro} Switch to "${attach.branch}" first ` + `(\`git checkout ${attach.branch} && git pull --ff-only\`) and retry. ${tail}`); } return (`${intro} Switch to a branch first, e.g. \`git checkout master && git pull --ff-only\`. ` + `Check for local edits before switching (\`git status\`). ${tail}`); } /** * `git pull`, recovering once from the stale origin/dev vs origin/dev/* ref-lock * by pruning origin. Other pull failures propagate unchanged. */ function runGitPullWithStaleRefRetry(cwd, steps) { try { const pulled = runCommand("git", ["pull"], cwd); steps.push(pulled); return pulled; } catch (err) { if (!(err instanceof ProcessControlError) || !isStaleRemoteRefLock(err.message)) { throw err; } steps.push({ command: "git pull", ok: false, output: err.message }); steps.push(runCommand("git", ["remote", "prune", "origin"], cwd)); const retried = runCommand("git", ["pull"], cwd); steps.push(retried); return retried; } } /** * Decide whether `git pull` actually updated the checkout (#945). * * A `git pull` on a DETACHED HEAD — which is how Comfy Desktop's release-tag * installs sit — prints "Already up to date." and exits 0 while moving nothing. * The same happens when `remote.origin.fetch` carries only a tag refspec, so * `origin/master` is never fetched and stays stale (or absent). Both were * reported as `updated: true`, and the requirements were then reinstalled for a * checkout that had not moved. The reporter's install stayed pinned at v0.30.2 * while master had advanced. * * The rule: a sha that CHANGED proves an update. A sha that did not change * proves nothing on its own — it is only "already current" if it can be shown * equal to the upstream tip. Everything else is reported as NOT updated, with * the reason, because the alternative is the false success above. */ export function classifyCoreUpdate(state) { // The sha moved: the pull did something. Nothing else needs proving. if (state.before && state.after && state.before !== state.after) { return { updated: true, matchesUpstream: null }; } // Not a git checkout at all (both probes failed) — say so rather than guess. if (!state.after) { return { updated: false, matchesUpstream: null, blocker: { reason: "unverifiable", message: "the ComfyUI directory's git revision could not be read, so whether the pull changed anything is UNKNOWN. " + "If this is not a git checkout, `git pull` cannot update it at all — update through the installer you used.", }, }; } if (!state.branch) { return { updated: false, matchesUpstream: null, blocker: { reason: "detached", message: "HEAD is DETACHED (a tag or bare commit, which is how release-tag and Comfy Desktop installs are set up), so " + "`git pull` has no branch to fast-forward and reports \"Already up to date.\" without moving anything. " + "Switch to a branch first, e.g. `git checkout master && git pull --ff-only`. " + "Check for local edits before switching (`git status`) — this tool will not move your HEAD for you.", }, }; } if (!state.upstream) { return { updated: false, matchesUpstream: null, blocker: { reason: "no-upstream", message: `branch "${state.branch}" has no upstream configured, so \`git pull\` has nothing to pull from and exits ` + `without changing the checkout. Set one with \`git branch --set-upstream-to=origin/${state.branch} ${state.branch}\`.`, }, }; } // Upstream is configured but its tip is unreadable — classically a // `remote.origin.fetch` that only carries `+refs/tags/*`, so the remote branch // ref was never created locally. This is the reported install's exact shape. if (!state.upstreamSha) { return { updated: false, matchesUpstream: null, blocker: { reason: "unverifiable", message: `the upstream "${state.upstream}" could not be resolved locally, so there is no way to tell whether this ` + `checkout is current. That usually means \`remote.origin.fetch\` carries only a tag refspec, so the remote ` + `branch is never fetched. Check it with \`git config --get-all remote.origin.fetch\`, and add the branch ` + `refspec if it is missing: \`git config --add remote.origin.fetch +refs/heads/${state.branch}:refs/remotes/origin/${state.branch}\`.`, }, }; } if (state.after === state.upstreamSha) { // The one honest "nothing to do": proven equal to the upstream tip. return { updated: true, matchesUpstream: true }; } return { updated: false, matchesUpstream: false, blocker: { reason: "behind-upstream", message: `the pull exited cleanly but the checkout is still at ${state.after.slice(0, 8)} while ${state.upstream} is at ` + `${state.upstreamSha.slice(0, 8)} — so it did NOT update. Fetch and fast-forward explicitly ` + `(\`git fetch origin && git merge --ff-only ${state.upstream}\`) and check \`git status\` for local changes that block it.`, }, }; } /** Read the checkout's git state. Every field is best-effort and may be null. */ function readGitState(cwd) { return { head: gitProbe(["rev-parse", "HEAD"], cwd), // --quiet + --short: prints the branch name, fails (→ null) when detached. branch: gitProbe(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd), upstream: gitProbe(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], cwd), }; } /** * Detect whether the ComfyUI install is managed by `uv` (a `.venv` created by * uv, or a uv lock present) versus plain pip. Falls back to checking whether * the `uv` binary is available on PATH. Defaults to pip. */ function detectPackageManager(comfyuiPath) { // A uv-managed project typically has a uv.lock or pyproject managed by uv. if (existsSync(join(comfyuiPath, "uv.lock")) || existsSync(join(comfyuiPath, ".venv", "uv-receipt.toml"))) { return "uv"; } // Otherwise see if `uv` is callable. try { execFileSync(IS_WIN ? "uv.exe" : "uv", ["--version"], { encoding: "utf-8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"], }); return "uv"; } catch { return "pip"; } } /** * Resolve the ComfyUI install path or throw a clear error explaining that core * updates require a local install (not available in remote --comfyui-url mode). */ async function requireComfyUICodePath() { const path = await resolveEffectiveComfyUICodeBaseLive(); if (!path) { throw new ProcessControlError("Cannot update ComfyUI core: no local code checkout could be resolved. " + "Core updates run git/pip against the ComfyUI directory and are not " + "available when targeting a remote instance via --comfyui-url / COMFYUI_URL. " + "Set COMFYUI_CODE_PATH to the local checkout (or COMFYUI_PATH for a conventional install) to enable this."); } if (!existsSync(path)) { throw new ProcessControlError(`Resolved ComfyUI code path does not exist: ${path}. Set COMFYUI_CODE_PATH (or COMFYUI_PATH) correctly.`); } return path; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Update ComfyUI core: resolve the running/local checkout, `git pull` there, * then reinstall its Python requirements via uv or pip. Mirrors `comfy-cli update`. */ export async function updateComfyUICore() { const comfyuiPath = await requireComfyUICodePath(); const pm = detectPackageManager(comfyuiPath); const steps = []; // Resolve the requirements install's interpreter BEFORE `git pull` mutates the // checkout: it must be the interpreter the RUNNING server imports from, never a // configured-workspace venv/PATH guess the server may not see (#651). Fail // closed when the live interpreter cannot be verified. const requirements = join(comfyuiPath, "requirements.txt"); let venvPython; let pythonReason = ""; if (existsSync(requirements)) { const resolved = await resolveInstallInterpreter(comfyuiPath); if (!resolved.python) { throw new ProcessControlError(`Cannot update ComfyUI core. ${resolved.reason} ` + "Set COMFYUI_PYTHON to the interpreter ComfyUI runs with, or restart ComfyUI through this MCP server and retry."); } venvPython = resolved.python; pythonReason = ` ${resolved.reason}`; } // 1. git pull the core repo — bracketed by a revision read, because a pull // that exits 0 is NOT evidence that anything moved (#945). // A detached version-tag checkout is attached to local master/main first // when the tree is clean (#2524); a stale origin/dev vs origin/dev/* // ref-lock is pruned once and the pull retried. const before = readGitState(comfyuiPath); const attach = tryAttachUpdateBranch(comfyuiPath, before.branch, steps); try { runGitPullWithStaleRefRetry(comfyuiPath, steps); } catch (err) { if (err instanceof ProcessControlError && isDetachedPullFailure(err.message)) { if (!steps.some((s) => s.command === "git pull" && !s.ok)) { steps.push({ command: "git pull", ok: false, output: err.message }); } const afterDetached = readGitState(comfyuiPath); return { updated: false, comfyui_path: comfyuiPath, package_manager: pm, steps, revision: { before: before.head, after: afterDetached.head, branch: afterDetached.branch, upstream: afterDetached.upstream, matches_upstream: null, }, message: detachedPullRefusalMessage(comfyuiPath, attach), }; } throw err; } const after = readGitState(comfyuiPath); const verdict = classifyCoreUpdate({ before: before.head, after: after.head, branch: after.branch, upstream: after.upstream, upstreamSha: after.upstream ? gitProbe(["rev-parse", after.upstream], comfyuiPath) : null, }); const revision = { before: before.head, after: after.head, branch: after.branch, upstream: after.upstream, matches_upstream: verdict.matchesUpstream, }; // The checkout did not move and could not be shown to be current. Reinstalling // requirements here is what made the old false success convincing — it looked // like work. Stop before it and report the reason, so nothing is done in the // name of an update that did not happen. if (!verdict.updated) { return { updated: false, comfyui_path: comfyuiPath, package_manager: pm, steps, revision, message: `ComfyUI core was NOT updated in ${comfyuiPath}: ${verdict.blocker?.message ?? "the checkout did not change."} ` + `\`git pull\` exited cleanly, which is why this used to be reported as a successful update — it is not one. ` + `Python requirements were NOT reinstalled, because nothing changed that would need them.`, }; } // 2. Reinstall requirements into the resolved interpreter's env (never this // server's Python). requirements.txt lives in the repo root. if (venvPython) { if (pm === "uv") { // `--python` pins uv to the resolved interpreter rather than an ambient env. steps.push(runCommand("uv", ["pip", "install", "--python", venvPython, "-r", "requirements.txt"], comfyuiPath)); } else { steps.push(runCommand(venvPython, ["-m", "pip", "install", "-r", "requirements.txt"], comfyuiPath)); } } else { logger.warn(`No requirements.txt found at ${requirements}; skipping dependency install`); } // Distinguish "moved" from "already at the upstream tip" — both are honest // successes, but they are different facts and the caller may care which. const moved = revision.before !== revision.after; const what = moved ? `updated ${String(revision.before).slice(0, 8)} → ${String(revision.after).slice(0, 8)}` : `already at ${after.upstream} (${String(revision.after).slice(0, 8)}) — nothing to pull`; return { updated: true, comfyui_path: comfyuiPath, package_manager: pm, steps, revision, message: `ComfyUI core ${what} in ${comfyuiPath} using ${pm}.${pythonReason}`, }; } /** * Update all installed custom nodes via the ComfyUI-Manager HTTP API. * Queues the update-all task then starts the queue worker (fire-and-forget — * the updates run asynchronously; unlike update_node with id "all", which * drains the queue). * * Routed through the Manager dialect machinery in node-management.ts (#656), * so the route follows the detected dialect instead of a hardcoded legacy * assumption: * legacy 3.x: POST /manager/queue/update_all (mode in the JSON body) * v4 / v2-batch: POST /v2/manager/queue/update_all (mode/client_id/ui_id * as query params) * then POST <same prefix>/start to kick the worker. */ export async function updateAllCustomNodes() { // PIN GUARD — "update everything" includes the sidebar panel pack, so this is // one of the doors into a pinned panel that does NOT pass through // install_comfyui(action:'panel')/runPanelAction. See panel-pin-guard.ts. The pin check AND the // queue/start calls run inside the panel mutation lock, so a pin cannot be // written between them (the pin-write path takes the same lock). The Manager // then drains the queue ASYNCHRONOUSLY — outside anything this process can // serialize — which is exactly why the result below reports "queued", never // "updated". return withPanelPinGuard("update", "all", async () => { // Persist and VERIFY the warning marker BEFORE the remote request. Once the // Manager has accepted update-all, a later pin cannot stop its worker; a // marker write after that point could fail and leave the later pin claiming // protection it cannot provide. If the request itself fails, retain this // conservative marker: a transport failure cannot prove Manager did not // accept the request. // // The marker starts BASE-UNKNOWN on purpose (#689 round 3): the base that // matters is the one the ENQUEUE actually uses (captured inside // queueUpdateAllCustomNodes), and guessing it here risks a retarget leaving // the marker naming the WRONG server — which a later pin would reset and // clear as "cancelled" while the update lands elsewhere. A base-unknown // marker routes to the unverified/no-reset path instead. const detail = `an update-all request may have been handed to ComfyUI-Manager and can update EVERY ` + `installed pack — the sidebar panel included — on the Manager's own schedule ` + `(usually seconds to minutes; a ComfyUI restart then loads the result)`; recordPanelPendingOp("update-all", detail, UPDATE_ALL_PENDING_MS); const result = await queueUpdateAllCustomNodes(); // Enrich the marker with what the ENQUEUE actually used: the base it // captured at invocation and the ui_id of the attempt that landed (a // self-heal retry mints a fresh one). On v4 the ui_id identifies the // update-all's per-pack tasks (each `${ui_id}_${pack}`) in the queue // history, which is what makes a later pin's cancel PROVABLE (#689 round // 3). keepRecordOnFailure: the operation is ALREADY with the Manager, so a // failed enrichment must leave the marker in place (the pre-handoff // rollback would delete the record of a live pending op — codex gate). The // marker then stays as last successfully written — possibly base-unknown, // never a stale base — so the pin-cancel path treats it as unverifiable // and sends no blind reset. Refusing now would punish a success. try { recordPanelPendingOp("update-all", detail, UPDATE_ALL_PENDING_MS, { base: result.base, uiId: result.uiId, keepRecordOnFailure: true, }); } catch (err) { logger.warn(`[panel] update-all is queued, but the enriched pending-op marker could ` + `not be confirmed durably written — the marker remains as last ` + `successfully written (possibly base-unknown), so a later pin may ` + `report it as unverifiable rather than cancel blindly: ${err instanceof Error ? err.message : String(err)}`); } return { // Queue acceptance is not proof a generic/bulk Manager update moved the // sidebar panel on disk; keep the result explicitly unverified. updated: false, endpoint: result.endpoint, queue_started: result.queueStarted, manager_response: result.managerResponse, message: result.queueStarted ? "Queued updates for all custom nodes via ComfyUI-Manager and started the queue worker. " + "Completion is unverified; the sidebar panel may still change later." : "Queued updates for all custom nodes via ComfyUI-Manager. " + "Could not confirm the queue worker started — check ComfyUI-Manager.", }; }); } //# sourceMappingURL=update-comfyui.js.map