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),
294 lines • 12.7 kB
JavaScript
import { execSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, statSync, } from "node:fs";
import { platform } from "node:os";
import { join, resolve } from "node:path";
import { ProcessControlError, ValidationError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
// ---------------------------------------------------------------------------
// Canonical URLs — verified against Comfy-Org/comfy-cli constants.py
// COMFY_GITHUB_URL = "https://github.com/comfyanonymous/ComfyUI"
// Current comfy-cli installs ComfyUI-Manager via `manager_requirements.txt`
// (recent ComfyUI ships it); a git clone is the fallback for older workspaces.
// The canonical Manager repo now lives under Comfy-Org (ltdrdata redirects).
// ---------------------------------------------------------------------------
export const COMFYUI_REPO_URL = "https://github.com/comfyanonymous/ComfyUI";
export const COMFYUI_MANAGER_REPO_URL = "https://github.com/Comfy-Org/ComfyUI-Manager";
/** Sub-path under the ComfyUI clone where the Manager lives (clone fallback). */
export const MANAGER_SUBDIR = join("custom_nodes", "comfyui-manager");
const IS_WIN = platform() === "win32";
function defaultRun(cmd, args, cwd) {
const result = spawnSync(cmd, args, {
cwd,
encoding: "utf-8",
// Merge: capture both streams; surface them on failure.
stdio: ["ignore", "pipe", "pipe"],
shell: false,
});
if (result.error) {
throw new ProcessControlError(`Failed to execute ${cmd}: ${result.error.message}`);
}
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
const combined = [stdout, stderr].filter(Boolean).join("\n").trim();
if (result.status !== 0) {
throw new ProcessControlError(`Command failed (exit ${result.status}): ${cmd} ${args.join(" ")}\n${combined}`);
}
return combined;
}
function defaultHasCommand(cmd) {
try {
const probe = IS_WIN ? `where ${cmd}` : `command -v ${cmd}`;
execSync(probe, { stdio: "ignore", timeout: 5000 });
return true;
}
catch {
return false;
}
}
function defaultIsNonEmptyDir(p) {
// Three answers, because the caller's never-clobber guard reads `false` as
// "safe to install here". There is NO existsSync pre-screen: it cannot tell
// "absent" from "cannot look", and folding an unreadable target into
// "empty/absent" would wave the installer into a directory it may be about
// to clobber. Only a proven-absent (ENOENT) target is empty.
try {
const st = statSync(p);
if (!st.isDirectory()) {
// A file occupying the target path is also a conflict.
return true;
}
return readdirSync(p).length > 0;
}
catch (err) {
if (err?.code === "ENOENT")
return false;
throw new ValidationError(`Could not inspect the target path ${p}: ${err instanceof Error ? err.message : String(err)}. ` +
`Refusing to install into a directory whose contents cannot be checked — it may not be empty.`);
}
}
const defaultDeps = {
run: defaultRun,
hasCommand: defaultHasCommand,
existsSync,
isNonEmptyDir: defaultIsNonEmptyDir,
mkdirp: (p) => {
mkdirSync(p, { recursive: true });
},
};
// ---------------------------------------------------------------------------
// Command builders — pure, so tests can assert on them directly.
// ---------------------------------------------------------------------------
/** Build the `git clone` argv (without the leading `git`). */
export function buildCloneArgs(url, dest, version) {
const args = ["clone"];
// For branches/tags we can clone directly with -b. For arbitrary commits a
// checkout after clone is required, handled separately by the caller.
if (version) {
args.push("--branch", version);
}
args.push(url, dest);
return args;
}
/** Path to the workspace venv's python interpreter. */
export function venvPythonPath(targetPath) {
return IS_WIN
? join(targetPath, ".venv", "Scripts", "python.exe")
: join(targetPath, ".venv", "bin", "python");
}
/** Build the argv that creates the workspace venv (`<target>/.venv`). */
export function buildVenvArgs(installer, targetPath) {
const venvDir = join(targetPath, ".venv");
if (installer === "uv") {
return { cmd: "uv", args: ["venv", venvDir] };
}
return { cmd: IS_WIN ? "python" : "python3", args: ["-m", "venv", venvDir] };
}
/**
* Build the pip/uv install argv for a requirements file, ALWAYS targeting the
* workspace venv's interpreter (`venvPython`) — never the Python running this
* MCP server. uv targets the venv explicitly via `--python`.
*/
export function buildPipInstallArgs(installer, venvPython, requirementsFile) {
if (installer === "uv") {
return {
cmd: "uv",
args: ["pip", "install", "--python", venvPython, "-r", requirementsFile],
};
}
return {
cmd: venvPython,
args: ["-m", "pip", "install", "-r", requirementsFile],
};
}
/**
* Validate the requested ComfyUI version, mirroring comfy-cli's
* `validate_version`: only "nightly", "latest", or a full semantic version
* (optionally v-prefixed). Raw git refs / branch names are rejected.
*/
export function validateVersion(version) {
const v = version.trim();
const lower = v.toLowerCase();
if (lower === "nightly")
return { kind: "nightly" };
if (lower === "latest")
return { kind: "latest" };
const sem = v.replace(/^v/i, "");
if (/^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$/.test(sem)) {
return { kind: "semver", tag: `v${sem}` };
}
throw new ValidationError(`Invalid version "${version}". Use "nightly", "latest", or a semantic ` +
`version like "0.3.40" (raw git refs/branches are not accepted).`);
}
// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------
/**
* Mirrors `comfy-cli install`: clones ComfyUI (and optionally ComfyUI-Manager)
* into a target workspace, then installs Python requirements via pip or uv.
*
* This is a LOCAL, subprocess-only operation. It never touches a remote
* ComfyUI server and ignores `config.comfyuiPath` in favour of the explicit
* `targetPath`.
*/
export function installComfyUI(options, deps = defaultDeps) {
const { skipManager = false, useUv = false, version } = options;
if (!options.targetPath || options.targetPath.trim() === "") {
throw new ValidationError("targetPath is required and cannot be empty.");
}
const targetPath = resolve(options.targetPath);
// --- Validate target: must be empty or non-existent. Never clobber. ---
if (deps.isNonEmptyDir(targetPath)) {
throw new ValidationError(`Target path is not empty: ${targetPath}. Refusing to overwrite an existing install. ` +
`Choose an empty or non-existent directory.`);
}
// --- Verify git is available. ---
if (!deps.hasCommand("git")) {
throw new ProcessControlError("git was not found on PATH. Install git before running install_comfyui.");
}
// --- Select Python installer (uv preferred only when requested AND present). ---
const installer = useUv && deps.hasCommand("uv") ? "uv" : "pip";
if (useUv && installer === "pip") {
logger.warn("use_uv requested but uv not found on PATH — falling back to pip.");
}
const steps = [];
const record = (step, cmd, args, cwd) => {
const command = `${cmd} ${args.join(" ")}`;
try {
const output = deps.run(cmd, args, cwd);
steps.push({ step, command, ok: true, output });
return output;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
steps.push({ step, command, ok: false, output: msg });
throw err;
}
};
// git clone creates targetPath itself; mkdirp on an empty target is harmless.
deps.mkdirp(targetPath);
// --- 1. Clone ComfyUI (default branch; a version is checked out next). ---
logger.info(`Cloning ComfyUI into ${targetPath}`, { version: version ?? "HEAD" });
record("clone_comfyui", "git", buildCloneArgs(COMFYUI_REPO_URL, targetPath));
// --- 2. Resolve & check out the requested version (comfy-cli semantics). ---
let resolvedVersion = null;
if (version) {
const v = validateVersion(version); // throws on a raw ref / invalid form
if (v.kind === "nightly") {
// Track the default branch HEAD — nothing to check out.
resolvedVersion = "nightly";
}
else if (v.kind === "latest") {
const tag = record("resolve_latest_tag", "git", [
"-C",
targetPath,
"describe",
"--tags",
"--abbrev=0",
]).trim();
if (tag) {
record("checkout_version", "git", [
"-C",
targetPath,
"checkout",
"--end-of-options",
tag,
]);
resolvedVersion = tag;
}
else {
resolvedVersion = "latest";
}
}
else {
// --end-of-options keeps a "-"-prefixed ref from being read as a flag.
record("checkout_version", "git", [
"-C",
targetPath,
"checkout",
"--end-of-options",
v.tag,
]);
resolvedVersion = v.tag;
}
}
// --- 3. Create the workspace virtualenv. Dependencies install into THIS
// interpreter, never the Python running this MCP server. ---
const venvPython = venvPythonPath(targetPath);
{
const { cmd, args } = buildVenvArgs(installer, targetPath);
logger.info(`Creating workspace venv at ${join(targetPath, ".venv")}`);
record("create_venv", cmd, args, targetPath);
}
// --- 4. Install ComfyUI Python requirements into the workspace venv. ---
{
const requirements = join(targetPath, "requirements.txt");
const { cmd, args } = buildPipInstallArgs(installer, venvPython, requirements);
logger.info(`Installing ComfyUI requirements via ${installer} into the workspace venv`);
record("install_requirements", cmd, args, targetPath);
}
// --- 5. Install ComfyUI-Manager. Current comfy-cli pip-installs it from
// manager_requirements.txt; fall back to a git clone for older
// workspaces that don't ship that file. ---
let managerInstalled = false;
let managerVia = null;
let managerUrl = null;
if (!skipManager) {
const managerReqRoot = join(targetPath, "manager_requirements.txt");
if (deps.existsSync(managerReqRoot)) {
const { cmd, args } = buildPipInstallArgs(installer, venvPython, managerReqRoot);
logger.info("Installing ComfyUI-Manager via manager_requirements.txt");
record("install_manager_requirements", cmd, args, targetPath);
managerInstalled = true;
managerVia = "requirements";
}
else {
const managerDest = join(targetPath, MANAGER_SUBDIR);
logger.info(`Cloning ComfyUI-Manager into ${managerDest} (legacy fallback)`);
record("clone_manager", "git", buildCloneArgs(COMFYUI_MANAGER_REPO_URL, managerDest));
managerUrl = COMFYUI_MANAGER_REPO_URL;
const managerReqLocal = join(targetPath, MANAGER_SUBDIR, "requirements.txt");
if (deps.existsSync(managerReqLocal)) {
const { cmd, args } = buildPipInstallArgs(installer, venvPython, managerReqLocal);
record("install_manager_requirements", cmd, args, targetPath);
}
managerInstalled = true;
managerVia = "git-clone";
}
}
return {
installed: true,
targetPath,
venvPath: venvPython,
comfyuiUrl: COMFYUI_REPO_URL,
managerUrl,
managerInstalled,
managerVia,
version: resolvedVersion,
pythonInstaller: installer,
steps,
message: `ComfyUI installed at ${targetPath} (deps in ${join(targetPath, ".venv")})` +
(managerInstalled ? ` with ComfyUI-Manager (via ${managerVia})` : "") +
` using ${installer}.`,
};
}
//# sourceMappingURL=install-comfyui.js.map