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),
1,027 lines • 48.6 kB
JavaScript
// Self-update for the comfyui-mcp npm package.
//
// On MCP server START we check the npm registry; if a newer version is
// published we auto-update the on-disk package, then tell the agent/user to
// RECONNECT — the running Node process cannot hot-swap its own code, so the new
// version only takes effect after the MCP client reconnects (/mcp) or the
// orchestrator restarts.
//
// This mirrors the SAFETY model of the panel on-load ensure
// (src/services/panel-installer.ts):
// - NEVER mutate a dev install. `npm link` makes the package resolve to the
// developer's working checkout; updating it would clobber their repo. We
// detect that (install mode "linked") and refuse, exactly like the panel's
// dev-junction guard.
// - opt-out env COMFYUI_MCP_AUTOUPDATE=0/false/no/off disables auto-update.
// - every probe + the npm spawn is hard-timed-out, captures+swallows errors,
// and NEVER throws — it can be fired-and-forgotten from startup without ever
// blocking or crashing the server.
// - we NEVER kill/restart the running MCP process; we only surface a
// "reconnect to load vX" note.
//
// WINDOWS (#912): an in-place npm replace of the running install fails every
// time — this process holds its own sharp libvips DLL mapped, and Windows
// refuses to move a mapped file (EBUSY). A failed npm on win32 is therefore
// handed to a DETACHED helper (buildDeferredUpdateScript) that waits for the
// lock to clear and finishes the install after this process has fully
// stopped; the result is honestly reported as "scheduled", never "updated",
// and npm's own error output travels in the note either way (#912/#916-a).
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { execFile, spawn } from "node:child_process";
import { tmpdir } from "node:os";
import { dirname, join, resolve, posix as pathPosix, win32 as pathWin32 } from "node:path";
import { fileURLToPath } from "node:url";
import { unreachableHostMessage } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { npxUpdateNote } from "./npx-restart-scope.js";
/** npm package name — authoritative for the registry lookup and update command. */
export const PACKAGE_NAME = "comfyui-mcp";
/** Hard caps so nothing here can ever block startup. */
const REGISTRY_TIMEOUT_MS = 5_000;
const NPM_TIMEOUT_MS = 120_000;
function defaultPackageDir() {
// dist/services/self-update.js → packageDir = dist/.. (the package root).
const here = dirname(fileURLToPath(import.meta.url));
return resolve(here, "..", "..");
}
async function defaultGetLatestVersion() {
const url = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
let res;
try {
res = await fetch(url, {
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
headers: { accept: "application/json" },
});
}
catch (err) {
// `fetch` rejected: no response at all. That is the `unreachable` state --
// the three-way split is about whether we got an answer, not about which
// transport code we happened to see.
return { unreachable: unreachableHostMessage(err, url, "the version check").message };
}
if (!res.ok) {
// The registry ANSWERED. Whatever went wrong, the host was reachable, so
// saying otherwise would point the user at their network.
return {
undetermined: `registry.npmjs.org answered HTTP ${res.status} for the version check (the host IS reachable)`,
};
}
try {
const json = (await res.json());
if (typeof json.version !== "string" || json.version.trim() === "") {
// The trim matters. `if (!latest)` on the old string|undefined shape caught
// an empty string; `"version" in probe` does not, so without this a body of
// {"version":""} reaches the consumer and renders as up-to-date with to:"".
// That is a non-answer read as a reassuring answer -- the exact failure the
// undetermined branch exists to prevent, reintroduced by its own type.
return { undetermined: "the registry response carried no usable version field" };
}
return { version: json.version };
}
catch {
return { undetermined: "the registry response was not valid JSON" };
}
}
/** Where the helper writes its log (named in user-facing notes). */
const HELPER_LOG_NAME = "comfyui-mcp-self-update.log";
/**
* Helper scripts are named UNIQUELY per launch (`…-<pid>-<ms>.ps1`): a shared
* fixed name let two orchestrators stat-miss the same path and each spawn a
* helper running the OTHER caller's freshly-overwritten script — for a local
* install that is `npm i` in the wrong project (codex gate r1).
*/
const HELPER_SCRIPT_RE = /^comfyui-mcp-self-update-.+\.ps1$/;
/** Once per process: the auto-update tick must not pile up helpers. */
let helperScheduledThisProcess = false;
/**
* Per-wait budget inside the helper, and the staleness window for the dedupe
* below (a script younger than this is a helper still waiting/working — or one
* that died mid-wait, whose log says so — so a second helper is not stacked).
*/
const HELPER_WAIT_CAP_MINUTES = 120;
const HELPER_ATTEMPTS = 3;
const HELPER_STALE_MS = (HELPER_WAIT_CAP_MINUTES * HELPER_ATTEMPTS + 60) * 60_000;
/** PowerShell single-quoted literal. */
function psLiteral(s) {
return `'${s.replace(/'/g, "''")}'`;
}
/**
* The helper script text (exported for tests — its PowerShell syntax is
* validated against a real powershell.exe in the test suite on Windows).
*
* The helper must outlive THIS process and only run npm once the files npm
* replaces are no longer locked — on Windows that is sharp's libvips-42.dll,
* mapped by every running orchestrator. So each attempt first waits (bounded)
* for the DLL to open with NO sharing; a quick respawn that re-locks it
* mid-install just sends the next attempt back to the wait. Every outcome is
* logged, and the script deletes itself on the way out (a helper killed by a
* shutdown leaves the script behind; the staleness window above is what lets a
* later run schedule a fresh one).
*
* All interpolated values are psLiteral-quoted constants of ours (paths, the
* package name) — nothing user-controlled reaches the script.
*/
export function buildDeferredUpdateScript(opts, logPath) {
const dll = join(opts.packageDir, "node_modules", "@img", "sharp-win32-x64", "lib", "libvips-42.dll");
const npmArgs = opts.mode === "global"
? `i -g ${PACKAGE_NAME}@latest --no-audit --no-fund`
: `i ${PACKAGE_NAME}@latest --no-audit --no-fund`;
// Local installs run npm IN the project root — re-validated immediately before
// EVERY attempt: the helper can wait hours, and a project moved meanwhile must
// abort the helper, never let npm run in whatever directory it inherited
// (codex gate r1). Empty for global installs (npm -g has no cwd dependence).
const cwd = opts.mode === "local" && opts.projectRoot ? opts.projectRoot : "";
// #2671 — call the SAME npm the in-process attempt resolved. A node-adjacent
// launcher becomes `& '<node.exe>' '<npm-cli.js>' …`; both are psLiteral'd, so
// the default `C:\Program Files\nodejs` path survives its space. With no
// resolved launcher we keep the historical bare shim, which is still right
// whenever PATH carries npm by the time the helper actually runs.
const npmCall = opts.npm
? [opts.npm.file, ...opts.npm.prefixArgs].map(psLiteral).join(" ")
: "npm.cmd";
return [
`# comfyui-mcp deferred self-update helper (#912). Log: ${logPath}`,
`$ErrorActionPreference = 'Continue'`,
`$log = ${psLiteral(logPath)}`,
`$dll = ${psLiteral(dll)}`,
`$cwd = ${psLiteral(cwd)}`,
`function Wait-LockFree {`,
` $deadline = (Get-Date).AddMinutes(${HELPER_WAIT_CAP_MINUTES})`,
` while ((Get-Date) -lt $deadline) {`,
` if (-not (Test-Path -LiteralPath $dll)) { return $true } # unexpected layout — let npm's own error say so`,
` try {`,
` $fs = [System.IO.File]::Open($dll, 'Open', 'ReadWrite', 'None')`,
` $fs.Close()`,
` return $true`,
` } catch { Start-Sleep -Seconds 5 }`,
` }`,
` return $false`,
`}`,
`"$(Get-Date -Format o) helper started (mode=${opts.mode}, target=${opts.to})" | Out-File -Append -LiteralPath $log`,
`$ok = $false`,
`$aborted = ''`,
`for ($i = 0; $i -lt ${HELPER_ATTEMPTS} -and -not $ok -and -not $aborted; $i++) {`,
` if (-not (Wait-LockFree)) {`,
` $aborted = 'the sharp DLL stayed locked'`,
` } elseif ($cwd -and -not (Test-Path -LiteralPath $cwd -PathType Container)) {`,
` $aborted = "the project root $cwd no longer exists"`,
` } elseif ($cwd) {`,
` try { Set-Location -LiteralPath $cwd -ErrorAction Stop } catch {`,
` $aborted = "could not enter the project root $cwd ($($_.Exception.Message))"`,
` }`,
` }`,
` if ($aborted) { break }`,
` & ${npmCall} ${npmArgs} *>> $log`,
` if ($LASTEXITCODE -eq 0) { $ok = $true } else { Start-Sleep -Seconds 10 }`,
`}`,
`if ($aborted) {`,
` "$(Get-Date -Format o) ABORTED without running npm: $aborted" | Out-File -Append -LiteralPath $log`,
`} else {`,
` "$(Get-Date -Format o) npm update $(if ($ok) { 'SUCCEEDED' } else { 'FAILED' })" | Out-File -Append -LiteralPath $log`,
`}`,
`Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue`,
``,
]
.filter((l) => l !== "")
.join("\r\n");
}
/**
* The helper script the scheduler actually writes: #2671's npm resolution
* DEFAULTED in for this process when the caller supplied none.
*
* Exported as its own seam because the defaulting is the entire fix on this
* exit, and it is invisible to a test of buildDeferredUpdateScript alone — that
* one is handed a launcher and can only prove it renders one. Resolving at this
* single choke point also means both scheduler callers inherit it rather than
* each having to remember.
*/
export function deferredUpdateScriptFor(opts, logPath) {
return buildDeferredUpdateScript({ ...opts, npm: opts.npm ?? currentNpmLauncher() }, logPath);
}
/**
* Launch the detached helper. Resolves true only when the script was written
* AND powershell actually spawned — a false here must surface as "not
* scheduled", never as a claimed scheduled update. Never throws.
*
* Dedupe: once per process (the auto-update tick), and cross-process by
* helper-script freshness — a script younger than the staleness window is a
* helper still waiting/working (or one that died mid-wait; its log says
* which), so a second is not stacked. Stale leftovers from killed helpers are
* our own artifacts and are cleaned up as they are found.
*/
async function defaultScheduleDeferredUpdate(opts) {
if (process.platform !== "win32")
return false;
try {
const dir = tmpdir();
const logPath = join(dir, HELPER_LOG_NAME);
if (helperScheduledThisProcess)
return true;
for (const name of readdirSync(dir)) {
if (!HELPER_SCRIPT_RE.test(name))
continue;
try {
const st = statSync(join(dir, name));
if (Date.now() - st.mtimeMs < HELPER_STALE_MS)
return true; // already scheduled
unlinkSync(join(dir, name)); // stale leftover from a killed helper
}
catch {
/* vanished between listing and stat — fine */
}
}
const scriptPath = join(dir, `comfyui-mcp-self-update-${process.pid}-${Date.now()}.ps1`);
writeFileSync(scriptPath, deferredUpdateScriptFor(opts, logPath), "utf-8");
const spawned = await new Promise((resolveP) => {
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath], { detached: true, stdio: "ignore", windowsHide: true });
child.once("error", () => resolveP(false));
child.once("spawn", () => {
child.unref();
resolveP(true);
});
});
// A spawn failure leaves the script behind; remove it so the dedupe scan
// does not read it as a pending helper.
if (!spawned) {
try {
unlinkSync(scriptPath);
}
catch {
/* best effort */
}
return false;
}
helperScheduledThisProcess = true;
return true;
}
catch {
return false;
}
}
/** The log path the helper writes to — named in user-facing notes. */
export function deferredUpdateLogPath() {
return join(tmpdir(), HELPER_LOG_NAME);
}
/** The launcher shim name cmd.exe/sh would resolve for a bare `npm`. */
function npmShimName(platform) {
return platform === "win32" ? "npm.cmd" : "npm";
}
/**
* Resolve how to run npm (#2671). Pure over its injected IO so the Windows
* layouts are testable from POSIX and vice versa.
*
* Order matters:
* 1. PATH. When the shim is there we return the BARE name and keep the shell
* exactly as before, so the invocation is byte-identical to the
* pre-#2671 one on every host where it already worked. This change can
* only add reach, never move the working case onto a new code path.
* 2. npm's own JS beside the running node binary, run by `process.execPath`
* directly. Deliberately NOT the `npm.cmd` shim: a shim needs shell:true,
* and `execFile` with shell:true concatenates file+args unquoted, so the
* default install path `C:\Program Files\nodejs\npm.cmd` would be split
* at the space and cmd.exe would try to run `C:\Program`. Spawning node
* with the script as an argv entry needs no shell and no quoting.
*
* Returns undefined when npm is genuinely not locatable; callers still get one
* last-resort bare-name attempt before reporting that.
*/
export function resolveNpmLauncher(io) {
const isWin = io.platform === "win32";
const shim = npmShimName(io.platform);
// Path arithmetic must follow the INJECTED platform, never the host's (codex
// gate r3). Host-native `dirname` on a Windows execPath evaluated from Linux
// returns "." — there is no "/" in it — so the node-adjacent candidate
// silently collapses to a bare relative path and the launcher is missed. In
// production io.platform IS process.platform, so this is the native flavour
// and nothing changes; it only makes the seam mean what it says. Choosing the
// FLAVOUR retires the class, where the r1 fix (dropping one `resolve()`)
// removed a single instance of it.
const P = isWin ? pathWin32 : pathPosix;
const pathSep = isWin ? ";" : ":";
for (const raw of (io.pathEnv ?? "").split(pathSep)) {
// A Windows PATH entry may be quoted; an empty one means "cwd", which is
// not somewhere we are willing to pick an npm up from.
const entry = raw.trim().replace(/^"(.*)"$/, "$1");
if (!entry)
continue;
// Never stat a UNC path (codex gate r2). `exists` is synchronous, and a
// dead network share blocks on the SMB timeout — tens of seconds — inside
// a module whose whole contract is that it can be fired and forgotten from
// startup without blocking. Reach is not lost: npm genuinely living only on
// a share is still found by the last-resort bare-name spawn, whose own PATH
// walk is the shell's problem and already bounded by NPM_TIMEOUT_MS.
if (isWin && /^[\\/]{2}/.test(entry))
continue;
try {
if (io.exists(P.join(entry, shim))) {
return { file: shim, prefixArgs: [], shell: isWin, source: "path" };
}
}
catch {
/* an unreadable PATH entry is simply not where npm is */
}
}
// `P.join` normalizes the ".." itself and `io.execPath` is always absolute, so
// there is nothing left for a `resolve()` to do — and calling one would be
// actively wrong, since it resolves against the HOST's cwd (codex gate r1).
const nodeDir = P.dirname(io.execPath);
const cliCandidates = isWin
? // Windows: node.exe and node_modules/ sit in the same directory.
[P.join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js")]
: // POSIX: <prefix>/bin/node alongside <prefix>/lib/node_modules/npm.
[
P.join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
P.join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js"),
];
for (const cli of cliCandidates) {
try {
if (io.exists(cli)) {
return {
file: io.execPath,
prefixArgs: [cli],
shell: false,
source: "node-adjacent",
};
}
}
catch {
/* probe failure is not a location */
}
}
return undefined;
}
/** Resolve the npm launcher for THIS process. */
export function currentNpmLauncher() {
return resolveNpmLauncher({
platform: process.platform,
pathEnv: process.env.PATH ?? process.env.Path,
execPath: process.execPath,
exists: existsSync,
});
}
/** One npm spawn. Resolves and never throws; `ok` is a clean exit. */
function spawnNpm(file, spawnArgs, useShell, cwd) {
return new Promise((resolveP) => {
try {
// SAFE: every npm arg is a hard-coded constant (no interpolation of user
// input), so the shell path has no injection surface. `prefixArgs` is
// only ever populated on the shell:false node path.
const child = execFile(file, spawnArgs, { cwd, timeout: NPM_TIMEOUT_MS, windowsHide: true, shell: useShell }, (err, stdout, stderr) => resolveP({ ok: !err, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }));
child.on("error", () => resolveP({ ok: false }));
}
catch {
resolveP({ ok: false });
}
});
}
/**
* Ask the SHELL — not our own PATH scan — whether it can resolve npm at all
* (#2671, codex gate r1).
*
* This exists to keep "npm could not be found" from being asserted about a run
* that plainly happened. `resolveNpmLauncher` failing means only that OUR probe
* found nothing; the bare-name last resort still goes through cmd.exe/sh, whose
* resolver is not ours. If that attempt then fails because npm returned E404 or
* the registry timed out, calling it "npm is not installed" hands the user
* remediation for a problem they do not have.
*
* `where`/`command -v` answer with an EXIT CODE, so this is a structured
* question, not a match on cmd.exe's localized "is not recognized". Only a real
* exit code is an ANSWER; a probe that could not run, or that we killed on
* timeout, returns `undefined` — cannot tell — and callers must not read that
* as "missing".
*
* Discriminating on `err.code` being a NUMBER is measured, not assumed (codex
* gate r2). execFile reports all four outcomes through the same callback:
* found -> err === null
* exit 1 (no npm) -> { code: 1, killed: false }
* spawn failure -> { code: "ENOENT" } (a STRING)
* timeout kill -> { code: null, killed: true }
* An earlier draft answered `undefined` from a `child.on("error")` listener,
* which never fires in time: execFile's own internal error handler invokes the
* callback FIRST, so the promise was already settled as `false`. Both failure
* modes silently became "npm is not installed".
*/
export async function shellCanResolveNpm(
// Injectable so a test can drive the REAL discriminator against real spawn
// failures and real timeouts. A test that re-implements the classification
// locally proves only that the test agrees with itself: the first draft of
// these tests did exactly that, and reverting the discriminator left every
// one of them green.
probe) {
const isWin = process.platform === "win32";
const { file, args, timeoutMs } = probe ?? {
file: isWin ? "where" : "sh",
args: isWin ? [npmShimName("win32")] : ["-c", "command -v npm"],
};
return new Promise((resolveP) => {
try {
execFile(file, args, { timeout: timeoutMs ?? 15_000, windowsHide: true }, (err) => {
if (!err)
return resolveP(true);
const e = err;
resolveP(typeof e.code === "number" && !e.killed ? false : undefined);
});
}
catch {
resolveP(undefined);
}
});
}
/**
* The npm-running decision, over injected IO (#2671).
*
* Split out from `defaultRunNpm` so the unresolved-launcher branch is
* reachable from a test: on any machine that can run this suite npm IS
* resolvable, so the interesting path — the one the reporter hit — would
* otherwise never execute under test and its guard could not be pinned.
*/
export async function runNpmResolved(io, args, cwd) {
const { launcher } = io;
if (launcher) {
// Resolved: run it and report npm's own verdict. Never `npmMissing` — we
// are holding the launcher.
return io.spawn(launcher.file, [...launcher.prefixArgs, ...args], launcher.shell, cwd);
}
// LAST RESORT (#2671): npm is nowhere WE can see it, but our PATH scan is not
// the shell's resolver, so make the pre-#2671 bare-name attempt rather than
// turning a failed probe into a hard refusal. This is why the change can only
// add reach.
const res = await io.spawn(npmShimName(io.platform), args, io.platform === "win32", cwd);
if (res.ok)
return res;
// It failed, and we could not locate npm. Only NOW is it worth asking whether
// npm exists at all — and the answer has to come from the SHELL, because this
// failure is equally consistent with "npm ran and said no" (codex gate r1).
// Anything other than a definite no leaves the claim unmade.
const resolvable = await io.shellResolvesNpm();
return resolvable === false ? { ...res, npmMissing: true } : res;
}
function defaultRunNpm(args, cwd) {
return runNpmResolved({
platform: process.platform,
launcher: currentNpmLauncher(),
spawn: spawnNpm,
shellResolvesNpm: shellCanResolveNpm,
}, args, cwd);
}
export const defaultDeps = {
packageDir: defaultPackageDir,
env: () => process.env,
existsSync,
isSymlink: (p) => {
try {
return lstatSync(p).isSymbolicLink();
}
catch {
return false;
}
},
realpath: (p) => {
try {
return realpathSync(p);
}
catch {
return undefined;
}
},
readFile: (p) => readFileSync(p, "utf-8"),
getLatestVersion: defaultGetLatestVersion,
runNpm: defaultRunNpm,
platform: () => process.platform,
scheduleDeferredUpdate: defaultScheduleDeferredUpdate,
};
// ---------------------------------------------------------------------------
// semver compare (major.minor.patch with a basic prerelease ordering)
// ---------------------------------------------------------------------------
/** Compare two semver strings. Returns 1 if a>b, -1 if a<b, 0 if equal/unparseable. */
export function compareSemver(a, b) {
const parse = (v) => {
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v.trim());
if (!m)
return undefined;
return {
nums: [Number(m[1]), Number(m[2]), Number(m[3])],
pre: m[4] ?? "",
};
};
const pa = parse(a);
const pb = parse(b);
if (!pa || !pb)
return 0;
for (let i = 0; i < 3; i++) {
if (pa.nums[i] > pb.nums[i])
return 1;
if (pa.nums[i] < pb.nums[i])
return -1;
}
// Equal core. A version WITHOUT a prerelease tag outranks one WITH it.
if (pa.pre === pb.pre)
return 0;
if (pa.pre === "")
return 1;
if (pb.pre === "")
return -1;
return pa.pre > pb.pre ? 1 : -1;
}
/** True when `latest` is strictly newer than `current`. */
export function isNewer(latest, current) {
return compareSemver(latest, current) > 0;
}
// ---------------------------------------------------------------------------
// Install-mode detection
// ---------------------------------------------------------------------------
function readVersion(deps, dir) {
try {
const pkg = JSON.parse(deps.readFile(join(dir, "package.json")));
return typeof pkg.version === "string" ? pkg.version : undefined;
}
catch {
return undefined;
}
}
/**
* Split a path into segments, tolerant of mixed separators / Windows drives.
* Empty segments are KEPT so a leading "/" (POSIX absolute root) survives
* reconstruction (segs[0] === "" → rejoin yields "/home/...").
*/
function segmentsOf(p) {
return p.replace(/\\/g, "/").split("/");
}
/**
* Classify how this package is installed by inspecting the resolved package dir:
* - linked → `npm link` / run-from-checkout (NOT under node_modules, or the
* dir is itself a symlink). DEV install — never auto-update.
* - npx → resolved inside an `_npx` cache dir (npx -y comfyui-mcp).
* - local → under a project's node_modules; the OUTERMOST host dir with a
* package.json is the project root (correctly handles pnpm/yarn nested
* `node_modules/.pnpm/<pkg>/node_modules/<pkg>` virtual-store layouts).
* - global → the canonical single-`node_modules` global layout (package sits
* directly inside node_modules, no project package.json above it).
* - unknown → couldn't classify confidently → caller MUST NOT auto-update.
*
* SAFE-FAIL: any uncertainty (realpath resolution failed, nested/ambiguous
* virtual-store layout with no identifiable project root) returns "unknown"
* (notify only) — NEVER "global". The dangerous wrong-direction failure is
* classifying a local install as global and running `npm i -g` against it, so
* the classifier only ever errs toward "unknown".
*
* Uses realpath to follow an `npm link` symlink to the developer's checkout, and
* lstat to catch the --preserve-symlinks case where the dir is the symlink.
*/
export function detectInstallMode(deps = defaultDeps) {
let rawDir;
try {
rawDir = deps.packageDir();
}
catch {
return { mode: "unknown", packageDir: "", currentVersion: undefined, isDevLink: false };
}
// --preserve-symlinks: the dir we run from is itself the link → dev link.
const dirIsSymlink = deps.isSymlink(rawDir);
// Default Node resolves symlinks for import.meta.url, so a linked package's
// dir already points at the real checkout; realpath is a harmless no-op there.
// `undefined` means resolution FAILED — we must not trust the raw path for an
// updatable classification (safe-fail to "unknown" below).
const resolved = deps.realpath(rawDir);
const realpathFailed = resolved === undefined;
const real = resolved ?? rawDir;
const currentVersion = readVersion(deps, real) ?? readVersion(deps, rawDir);
const segs = segmentsOf(real);
const lower = segs.map((s) => s.toLowerCase());
// npx cache (npm's `_npx` directory) — always notify-only, so realpath
// uncertainty is harmless here.
if (lower.includes("_npx")) {
return { mode: "npx", packageDir: real, currentVersion, isDevLink: false };
}
// All `node_modules` segment indices (outermost → innermost).
const nmIndices = [];
for (let i = 0; i < lower.length; i++) {
if (lower[i] === "node_modules")
nmIndices.push(i);
}
if (dirIsSymlink || nmIndices.length === 0) {
// Not under node_modules (or it's a raw symlink): a working checkout, i.e.
// `npm link` / `npm run dev` / a git clone. DEV — never auto-update.
return { mode: "linked", packageDir: real, currentVersion, isDevLink: true };
}
// Under node_modules but realpath FAILED → we can't trust the on-disk
// location → safe-fail to unknown (notify only, never an update).
if (realpathFailed) {
return { mode: "unknown", packageDir: real, currentVersion, isDevLink: false };
}
// LOCAL: walk OUTWARD — the outermost node_modules whose containing dir has a
// package.json is the real project root. This makes a pnpm/yarn nested path
// <proj>/node_modules/.pnpm/<pkg>@x/node_modules/<pkg>
// resolve to <proj> (local), instead of the virtual-store folder (which has no
// package.json and would otherwise be misread as a global prefix).
for (const i of nmIndices) {
const host = segs.slice(0, i).join("/") || "/";
if (deps.existsSync(join(host, "package.json"))) {
return { mode: "local", packageDir: real, currentVersion, isDevLink: false, projectRoot: host };
}
}
// GLOBAL: only the canonical layout — a SINGLE node_modules with the package
// sitting directly inside it (`<prefix>/.../node_modules/comfyui-mcp`) and no
// project package.json above it (the npm prefix has none).
const lastNm = nmIndices[nmIndices.length - 1];
const packageIsDirectChild = lastNm === segs.length - 2;
if (nmIndices.length === 1 && packageIsDirectChild) {
return { mode: "global", packageDir: real, currentVersion, isDevLink: false };
}
// Ambiguous (nested/virtual-store layout with no identifiable project root)
// → safe-fail to unknown. NEVER "global".
return { mode: "unknown", packageDir: real, currentVersion, isDevLink: false };
}
/** Public, error-swallowing wrapper around the npm-registry probe. */
export async function getLatestPublishedVersion(deps = defaultDeps) {
try {
const probe = await deps.getLatestVersion();
return "version" in probe ? probe.version : undefined;
}
catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// Policy engine
// ---------------------------------------------------------------------------
/** Truthy env values for the DISABLE-style flag. */
function isTruthy(raw) {
const v = (raw ?? "").trim().toLowerCase();
return v === "1" || v === "true" || v === "yes" || v === "on";
}
/**
* Master auto-update kill switch. Default ON. Two spellings:
* - COMFYUI_MCP_AUTO_UPDATE_DISABLE=1 — the documented opt-out (default 0).
* - COMFYUI_MCP_AUTOUPDATE=0 — the original opt-out, kept for
* back-compat with existing setups.
* Disabling auto-update also disables the orchestrator's periodic check +
* self-restart (self-restart.ts consults this same switch).
*/
export function isAutoUpdateDisabled(env) {
if (isTruthy(env.COMFYUI_MCP_AUTO_UPDATE_DISABLE))
return true;
const v = (env.COMFYUI_MCP_AUTOUPDATE ?? "").trim().toLowerCase();
return v === "0" || v === "false" || v === "no" || v === "off";
}
const RECONNECT_NOTE = "The running MCP server is still on the OLD code — RECONNECT (/mcp) or restart " +
"the orchestrator to load the new version.";
/**
* Build the `npm install` argv for a self-update. Every token is a CONSTANT —
* no user/dynamic value is ever interpolated into the command (the Windows path
* runs npm.cmd via shell:true, so injection-safety relies on this). `--no-audit
* --no-fund` keep it non-interactive and cut extra network/output.
*/
function npmInstallArgs(mode) {
const base = mode === "global"
? ["i", "-g", `${PACKAGE_NAME}@latest`]
: ["i", `${PACKAGE_NAME}@latest`];
return [...base, "--no-audit", "--no-fund"];
}
/**
* The last few lines of npm's output, trimmed and capped so a tool result stays
* readable. stderr is preferred (npm writes its `npm error code …` block there);
* stdout is the fallback for a failure that logged nothing to stderr.
* DISPLAY ONLY — classification reads the FULL output (a lock error older than
* the last 8 lines must still classify, codex gate).
*/
function npmOutputTail(res) {
const raw = String(res.stderr ?? "").trim() || String(res.stdout ?? "").trim();
if (!raw)
return "";
const tail = raw.split(/\r?\n/).slice(-8).join("\n");
return tail.length > 1200 ? `…${tail.slice(-1200)}` : tail;
}
/** The manual-update command a remedy can name for this install mode. */
function manualUpdateCommand(info) {
return info.mode === "local" ? `npm i ${PACKAGE_NAME}@latest` : `npm i -g ${PACKAGE_NAME}@latest`;
}
/** Where the command runs — only meaningful for a local project install. */
function manualUpdateLocation(info) {
return info.mode === "local" && info.projectRoot ? ` in ${info.projectRoot}` : "";
}
/** npm's signature for "a file it must replace is held open by a process". */
const NPM_LOCK_RE = /EBUSY|EPERM|resource busy or locked/i;
/**
* Apply the npm update for a global/local install and classify the result
* (#912 / #916-a). A failure MUST carry why: npm's output tail goes into the
* note, and on Windows the deterministic EBUSY — this process holds its own
* sharp libvips DLL mapped, so npm cannot replace the package it is running
* from — is named as such and routed to the deferred post-exit helper. The
* helper is only offered as "scheduled" when it actually launched.
*/
async function applyNpmUpdate(deps, info, latest) {
const args = npmInstallArgs(info.mode);
const res = await deps.runNpm(args, info.mode === "local" ? info.projectRoot : undefined);
if (res.ok) {
return {
action: "updated",
mode: info.mode,
from: info.currentVersion,
to: latest,
note: `Updated ${PACKAGE_NAME} ${info.currentVersion} → ${latest}. ${RECONNECT_NOTE}`,
};
}
const tail = npmOutputTail(res);
// Classify on the FULL capture, not the display tail: the lock error can sit
// anywhere in npm's output (the tail is only the last few lines of stderr).
const locked = NPM_LOCK_RE.test(`${res.stderr ?? ""}\n${res.stdout ?? ""}`);
const isWin = (deps.platform?.() ?? process.platform) === "win32";
if (locked && isWin) {
// The lock is held by THIS process, so no in-process retry can succeed.
// The helper applies the update once the orchestrator has fully stopped;
// claim "scheduled" only when it genuinely launched.
const scheduled = deps.scheduleDeferredUpdate
? await deps.scheduleDeferredUpdate({
mode: info.mode,
projectRoot: info.projectRoot,
packageDir: info.packageDir,
to: latest,
})
: false;
if (scheduled) {
return {
action: "scheduled",
mode: info.mode,
from: info.currentVersion,
to: latest,
reason: "locked-by-running-process",
note: `npm could not install ${latest} in place: Windows keeps this running ` +
`orchestrator's own sharp libvips DLL locked, so the package cannot be ` +
`replaced while it runs. A detached helper was scheduled — it waits for ` +
`the lock to clear, then runs \`${manualUpdateCommand(info)}\`` +
`${manualUpdateLocation(info)} (log: ` +
`${deferredUpdateLogPath()}). The update lands once this orchestrator has ` +
`fully STOPPED — quit the MCP client or end the process; a /mcp reconnect ` +
`or auto-restart keeps an orchestrator holding the lock and the helper keeps ` +
`waiting — and takes effect at the next start. Staying on ` +
`${info.currentVersion} until then; verify with ` +
`install_comfyui (action:"self_update") using self_update_action:'status' after the next start.`,
};
}
return {
action: "unavailable",
mode: info.mode,
from: info.currentVersion,
to: latest,
reason: "locked-by-running-process",
note: `npm update to ${latest} failed: files of the RUNNING orchestrator are locked ` +
`(Windows holds this process's own sharp libvips DLL open), and the deferred ` +
`update helper could not be started. Staying on ${info.currentVersion}. Quit ` +
`the orchestrator fully (a /mcp reconnect or auto-restart keeps a process ` +
`holding the lock), then run: ${manualUpdateCommand(info)}${manualUpdateLocation(info)}` +
(tail ? `\nnpm said:\n${tail}` : ""),
};
}
// #2671 — npm was never LOCATED, so nothing here is npm's verdict on the
// update. Checked AFTER the lock branch on purpose: an EBUSY in the output is
// positive proof npm actually ran, and that proof outranks our own
// could-not-find-it probe (which cannot see every way a shell resolves a
// command). npm's raw output still travels in the note either way, so a
// misclassification can never hide evidence from the user.
if (res.npmMissing) {
const isDefaultWin = (deps.platform?.() ?? process.platform) === "win32";
return {
action: "unavailable",
mode: info.mode,
from: info.currentVersion,
to: latest,
reason: "npm-not-found",
note: `Cannot self-update to ${latest}: npm could not be found. It is not on ` +
`this server process's PATH, and there is no npm beside the Node binary ` +
`running it (${process.execPath}). Staying on ${info.currentVersion} — ` +
`nothing was changed.\nTo update, run this yourself in a terminal that ` +
`HAS npm: ${manualUpdateCommand(info)}${manualUpdateLocation(info)}` +
(isDefaultWin
? `\nIf that terminal also reports npm is not recognized, Node.js is ` +
`not installed for this user — install it from https://nodejs.org ` +
`and reopen the terminal.`
: `\nIf that terminal also cannot find npm, install Node.js ` +
`(https://nodejs.org) and retry.`) +
`\nWhen npm IS installed, this usually means the orchestrator was ` +
`launched by something that does not pass the Node directory on PATH; ` +
`relaunching it from a shell where \`npm -v\` works lets this action ` +
`run on its own.` +
(tail ? `\nThe launch failed with:\n${tail}` : ""),
};
}
return {
action: "unavailable",
mode: info.mode,
from: info.currentVersion,
to: latest,
reason: locked ? "locked-by-running-process" : "npm-failed",
note: `npm update to ${latest} failed; staying on ${info.currentVersion}.` +
(tail ? `\nnpm said:\n${tail}` : ""),
};
}
async function checkInner(deps) {
const info = detectInstallMode(deps);
// 1) DEV LINK — highest-priority safety guard. Never mutate a dev checkout,
// even if the user did not set the opt-out env.
if (info.isDevLink || info.mode === "linked") {
return {
action: "skipped-dev",
mode: info.mode,
from: info.currentVersion,
note: "Dev install (npm link / checkout) — self-update is disabled; update via git.",
};
}
// 2) Opt-out.
if (isAutoUpdateDisabled(deps.env())) {
return {
action: "skipped-disabled",
mode: info.mode,
from: info.currentVersion,
note: "Self-update disabled via COMFYUI_MCP_AUTOUPDATE.",
};
}
// 3) Registry probe.
const probe = await deps.getLatestVersion();
if (!("version" in probe)) {
// #1136 — say which of the two it was. "unreachable" is a claim about the
// user's network and must not be made on the strength of a 500 or a
// malformed body; those get "could not determine", which is what we know.
return {
action: "unavailable",
mode: info.mode,
from: info.currentVersion,
note: "unreachable" in probe
? probe.unreachable
: `Could not determine the latest published version — ${probe.undetermined}. This is NOT evidence that you are up to date.`,
};
}
const latest = probe.version;
// 4) Up to date (also covers an unreadable current version → don't churn).
if (!info.currentVersion || !isNewer(latest, info.currentVersion)) {
return {
action: "up-to-date",
mode: info.mode,
from: info.currentVersion,
to: latest,
};
}
// 5) Newer available. Only global/local can be safely self-replaced on disk.
if (info.mode === "global" || info.mode === "local") {
return applyNpmUpdate(deps, { ...info, mode: info.mode }, latest);
}
// 6) npx / unknown — can't safely self-replace; notify only.
const how = info.mode === "npx"
? "npx already fetches the latest on next run — restart to pick it up."
: "Could not classify the install; update manually.";
return {
action: "notify",
mode: info.mode,
from: info.currentVersion,
to: latest,
note: `${PACKAGE_NAME} ${latest} is available (current ${info.currentVersion}). ${how}`,
};
}
/**
* The on-load policy engine. Detects install mode, checks the registry, and (for
* global/local) self-updates on disk — then asks the user to RECONNECT. Swallows
* every error and NEVER throws, so it can be fired-and-forgotten from startup.
*/
export async function checkAndSelfUpdate(opts = {}) {
const deps = opts.deps ?? defaultDeps;
try {
return await checkInner(deps);
}
catch (err) {
logger.debug("self-update: check failed", {
err: err instanceof Error ? err.message : String(err),
});
let mode = "unknown";
try {
mode = detectInstallMode(deps).mode;
}
catch {
/* ignore */
}
return {
action: "unavailable",
mode,
note: err instanceof Error ? err.message : String(err),
};
}
}
/** status action — never throws. Reports mode + current vs latest + notes. */
export async function selfUpdateStatus(deps = defaultDeps) {
let info;
try {
info = detectInstallMode(deps);
}
catch {
info = { mode: "unknown", packageDir: "", currentVersion: undefined, isDevLink: false };
}
// #1136 — ask the PROBE. getLatestPublishedVersion flattens the three states
// back to string|undefined, which is the fold this change removes; reading it
// here made `action:"status"` report a 429 as "npm registry unreachable" AND
// discard the composed message on a genuine outage, losing the host name --
// the one thing #1136 asks for. `status` is the read-only action an agent
// reaches for while diagnosing exactly that situation.
const probe = await deps.getLatestVersion().catch((err) => ({
undetermined: `the version check threw: ${err instanceof Error ? err.message : String(err)}`,
}));
const latest = "version" in probe ? probe.version : undefined;
const autoUpdateDisabled = isAutoUpdateDisabled(deps.env());
const updateAvailable = !!latest && !!info.currentVersion && isNewer(latest, info.currentVersion);
let note;
if (info.isDevLink || info.mode === "linked") {
note = "Dev install (npm link / checkout) — self-update is disabled; update via git.";
}
else if (!latest) {
note =
"unreachable" in probe
? probe.unreachable
: `Could not determine the latest published version — ${"undetermined" in probe ? probe.undetermined : "the registry did not answer usably"}. This is NOT evidence that you are up to date.`;
}
else if (!updateAvailable) {
note = `Up to date (${info.currentVersion}).`;
}
else if (info.mode === "npx") {
note = npxUpdateNote(latest);
}
else if (info.mode === "unknown") {
note = `${latest} available — could not classify install; update manually.`;
}
else {
note =
`${latest} available (current ${info.currentVersion}). ` +
`Run install_comfyui (action:"self_update") with self_update_action:'update'${autoUpdateDisabled ? "" : " (or it auto-updates on start)"}. ${RECONNECT_NOTE}`;
// #912: on Windows the running orchestrator holds its own sharp DLL open, so
// an in-place npm replace fails (EBUSY) — the update is applied by the
// deferred helper AFTER this process stops, not on a mere reconnect. Say so
// up front so nobody promises an in-place update this platform can't do.
if ((deps.platform?.() ?? process.platform) === "win32") {
note +=
" On Windows the running orchestrator keeps its own files locked, so the " +
"update is applied by a deferred helper once this orchestrator has fully " +
"stopped, and loads at the next start.";
}
}
return {
mode: info.mode,
packageDir: info.packageDir,
currentVersion: info.currentVersion,
latestVersion: latest,
updateAvailable,
isDevLink: info.isDevLink || info.mode === "linked",
autoUpdateDisabled,
note,
};
}
export class SelfUpdateError extends Error {
constructor(message) {
super(message);
this.name = "SelfUpdateError";
}
}
/**
* Explicit `update` action. Refuses on a dev link. Returns the same result shape
* as the on-load check. Unlike the on-load path this IGNORES the opt-out env
* (the user explicitly asked), but still never updates a dev link, npx, or
* unknown install.
*/
export async function runSelfUpdate(deps = defaultDeps) {
const info = detectInstallMode(deps);
if (info.isDevLink || info.mode === "linked") {
throw new SelfUpdateError("Refusing to self-update a dev install (npm link / checkout). Update via git instead.");
}
// #1136 — ask the PROBE, not the flattening wrapper. getLatestPublishedVersion
// collapses the three states back into string|undefined, which is precisely
// the fold this change removes: it would report a 500 or an unparseable body
// as a network problem the user is expected to act on.
const probe = await deps.getLatestVersion().catch((err) => ({
undetermined: `the version check threw: ${err instanceof Error ? err.message : String(err)}`,
}));
if (!("version" in probe)) {
return {
action: "unavailable",
mode: info.mode,
from: info.currentVersion,
note: "unreachable" in probe
? probe.unreachable
: `Could not determine the latest published version — ${probe.undetermined}. This is NOT evidence that you are up to date.`,
};
}
const latest = probe.version;
if (!info.currentVersion || !isNewer(latest, info.currentVersion)) {
return { action: "up-to-date", mode: info.mode, from: info.currentVersion, to: latest };
}
if (info.mode === "global" || info.mode === "local") {
return applyNpmUpdate(deps, { ...info, mode: info.mode }, latest);
}
// npx / unknown
return {
action: "notify",
mode: info.mode,
from: info.currentVersion,
to: latest,
note: info.mode === "npx"
? npxUpdateNote(latest)
: `${latest} available — could not classify install; update manually.`,
};
}
//# sourceMappingURL=self-update.js.map