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),
701 lines • 33.1 kB
JavaScript
/**
* The ENVIRONMENT a restart must relaunch ComfyUI into (#776).
*
* `restart_comfyui` reconstructs the launch COMMAND (interpreter + argv) from the
* running server's `/system_stats`, but until this module existed it never
* reconstructed the launch ENVIRONMENT: `spawn()` with no `env` option inherits
* the ORCHESTRATOR's `process.env`, which is a DIFFERENT environment from the one
* a third-party launcher gave the server.
*
* That is exactly what broke #776. Stability Matrix does not put its bundled
* tools on the system PATH — it injects them into the child it launches:
* • `<Data>/PortableGit/cmd` → GitPython finds `git` (ComfyUI-Manager
* aborts with "Bad git executable" without it)
* • `<Data>/Assets/ffmpeg[/bin]` → nodes that shell out to ffmpeg/ffprobe
* Relaunching the same venv + argv WITHOUT those PATH entries produced an import
* failure at startup, ComfyUI never answered `/system_stats`, and the restart left
* the user's server DOWN.
*
* The rules here, hardest evidence first:
* 1. LIVE PROCESS — on Linux we can read the running server's real environment
* (`/proc/<pid>/environ`) while it is still alive. That is the launch
* environment, verbatim, whatever launcher produced it. Use it.
* 2. STABILITY MATRIX — a Stability Matrix-managed install is recognizable from
* its on-disk shape (`<Data>/Packages/<pkg>/…` beside `<Data>/PortableGit`
* and `<Data>/Assets`). Its injected environment is small, documented and
* VERIFIABLE on disk, so we reconstruct exactly those two PATH entries plus
* GIT_PYTHON_GIT_EXECUTABLE — the same recovery launch the #776 reporter
* verified by hand — and say so in the tool result.
* 3. AN OPAQUE LAUNCHER — a launcher we can recognize but whose environment we
* canNOT reproduce (Pinokio builds it from a per-app script). We do NOT
* guess: the caller REFUSES *before stopping anything*, because a refusal is
* always better than a stop we cannot undo.
* 4. PLAIN INSTALL — no launcher marker at all (a terminal / .bat / venv
* launch). Inheriting our environment is the long-standing behavior and the
* one these installs were already restarted with successfully; nothing is
* known to be missing, so nothing is reconstructed.
*
* Everything here is filesystem-evidence based: no shape is ever ASSUMED, and a
* marker that cannot be corroborated on disk downgrades to a refusal, never to a
* guess.
*/
import { readFileSync, statSync } from "node:fs";
// NOTE: readFileSync is used both for `/proc/<pid>/environ` and for the launcher's
// own config file (launcherConfigMentions).
import { delimiter } from "node:path";
// ---------------------------------------------------------------------------
// Separator-agnostic path helpers
//
// The paths we inspect come from the running ComfyUI's sys.argv, which is
// WINDOWS-flavored whenever ComfyUI runs on Windows — regardless of the host this
// orchestrator process runs on. node:path would mangle `C:\…` on POSIX, so parse
// with an explicit both-separators split and re-join with the separator the input
// itself used (the same approach resolveLaunchCommand already takes).
// ---------------------------------------------------------------------------
function separatorOf(p) {
return p.includes("\\") ? "\\" : "/";
}
function segmentsOf(p) {
return p.split(/[\\/]/);
}
function basenameOf(p) {
const segs = segmentsOf(p).filter((s) => s !== "");
return segs.length > 0 ? segs[segs.length - 1] : p;
}
/**
* Ancestor directories of `p`, NEAREST first. The filesystem root itself is
* skipped (nothing useful is ever detected there).
*/
function ancestorsOf(p) {
const sep = separatorOf(p);
const segs = segmentsOf(p);
const out = [];
for (let i = segs.length - 1; i >= 1; i--) {
const joined = segs.slice(0, i).join(sep);
if (joined === "")
continue; // POSIX root — nothing to detect there
out.push(joined);
}
return out;
}
function joinPath(base, ...parts) {
const sep = separatorOf(base);
return [base.replace(/[\\/]+$/, ""), ...parts].join(sep);
}
function probePath(p) {
try {
statSync(p);
return "present";
}
catch (err) {
const code = err.code;
// Only these mean "there is nothing here". EACCES/EPERM/ELOOP/EIO and an
// unset code all mean "we could not look".
if (code === "ENOENT" || code === "ENOTDIR")
return "absent";
return "inaccessible";
}
}
/** Convenience for the places where only "definitely there" matters. */
function pathExists(p) {
return probePath(p) === "present";
}
function firstExisting(candidates) {
for (const c of candidates) {
if (pathExists(c))
return c;
}
return undefined;
}
/** The directory holding `p` (its nearest ancestor). */
function dirOf(p) {
return ancestorsOf(p)[0];
}
// ---------------------------------------------------------------------------
// Stability Matrix detection
// ---------------------------------------------------------------------------
/**
* Recognize a Stability Matrix-managed install from the paths involved in the
* relaunch (script, interpreter, cwd, raw argv[0]).
*
* Stability Matrix keeps every package under `<Data>/Packages/<PackageName>` and
* its shared tooling as siblings of `Packages` (`<Data>/PortableGit`,
* `<Data>/Assets`). So: find an ancestor literally named `Packages` whose parent
* ACTUALLY HOLDS one of those tooling directories on disk.
*
* The tooling directory is REQUIRED corroboration, never inferred from a name: a
* perfectly ordinary install that happens to live under some `…/Data/Packages/…`
* path must NOT be mistaken for a Stability Matrix one, because that
* misclassification would refuse a restart that works today.
*/
/**
* Does the launcher's OWN configuration mention this component?
*
* The tie-breaker for "the component's directory is not on disk". That can mean
* the user never installed it — in which case the launcher injects nothing and we
* lose nothing by proceeding — or that it lives somewhere we do not know to look,
* in which case proceeding drops it. Stability Matrix records what it manages in
* `<Data>/settings.json`, so a mention there turns "absent" into "absent but
* expected", which is the ambiguous case that must refuse. Unreadable/missing
* config reads as "not mentioned": we never manufacture ambiguity.
*/
function launcherConfigMentions(dataRoot, needle) {
let sawUnreadable = false;
for (const name of ["settings.json", "settings.jsonc"]) {
const path = joinPath(dataRoot, name);
// MISSING and UNREADABLE are NOT the same answer. Folding them together is how
// a momentarily unreadable config would let a component be declared
// "not installed", the restart proceed without it, and ComfyUI-Manager abort at
// import — the original down-server bug, reached through the very rule meant to
// stop over-refusing.
//
// Which is why there is NO `existsSync` pre-screen here: `existsSync` answers
// FALSE for a path it cannot access, collapsing the two states before they can
// be told apart. Attempt the read and classify from the FAILURE MODE instead —
// only ENOENT/ENOTDIR mean "not there"; everything else means "could not look"
// (codex gate).
try {
const raw = readFileSync(path, "utf-8");
// Config files here are a few KB; cap the scan so a pathological file
// cannot cost anything meaningful.
if (raw.slice(0, 512 * 1024).toLowerCase().includes(needle))
return "mentions";
}
catch (err) {
const code = err.code;
if (code === "ENOENT" || code === "ENOTDIR")
continue;
// EACCES, EPERM, EISDIR, EBUSY, an unset code — all "we cannot look".
sawUnreadable = true;
}
}
return sawUnreadable ? "unreadable" : "absent";
}
function componentEvidence(resolved, rootProbe, dataRoot, configNeedle) {
if (resolved)
return "resolved";
// PRESENT: it is there but we could not point at the binary. INACCESSIBLE: we
// could not look at all. Both mean "this component may well be injected and we
// would silently drop it" — the ambiguous case.
if (rootProbe !== "absent")
return "ambiguous";
// "not-installed" requires BOTH halves of the evidence: the directory absent AND
// the launcher's own config silent about it. An unreadable config is neither, so
// it stays ambiguous.
return launcherConfigMentions(dataRoot, configNeedle) === "absent"
? "not-installed"
: "ambiguous";
}
export function detectStabilityMatrix(paths) {
for (const p of paths) {
if (!p)
continue;
for (const ancestor of ancestorsOf(p)) {
if (basenameOf(ancestor).toLowerCase() !== "packages")
continue;
const dataRoot = dirOf(ancestor);
if (!dataRoot)
continue;
const gitRoot = joinPath(dataRoot, "PortableGit");
const assetsRoot = joinPath(dataRoot, "Assets");
const ffmpegRoot = joinPath(assetsRoot, "ffmpeg");
const gitRootProbe = probePath(gitRoot);
const assetsRootProbe = probePath(assetsRoot);
const ffmpegRootProbe = probePath(ffmpegRoot);
const hasGitRoot = gitRootProbe === "present";
const hasFfmpegRoot = ffmpegRootProbe === "present";
// Corroboration must be POSITIVE: at least one piece of Stability Matrix
// tooling actually PRESENT beside `Packages`. `PortableGit` OR the `Assets`
// STORE — not `Assets/ffmpeg` specifically, since a real layout can have the
// store without that subdirectory.
//
// An INACCESSIBLE sibling is deliberately not corroboration. Treating it as
// such made a plain install at `C:\Work\Data\Packages\ComfyUI` refuse to
// restart at all merely because an unrelated `C:\Work\Data\Assets` was
// ACL-denied — unreadable evidence proving the OTHER shape, which is the same
// fold in the opposite direction. Where the layout IS corroborated, an
// unreadable component is still ambiguous and still refuses (below); where it
// is not, we proceed unverified like any other plain install.
if (!hasGitRoot && assetsRootProbe !== "present")
continue;
const gitExe = firstExisting([
joinPath(gitRoot, "cmd", "git.exe"),
joinPath(gitRoot, "cmd", "git"),
joinPath(gitRoot, "bin", "git.exe"),
joinPath(gitRoot, "bin", "git"),
]);
const ffmpegExe = firstExisting([
joinPath(ffmpegRoot, "bin", "ffmpeg.exe"),
joinPath(ffmpegRoot, "bin", "ffmpeg"),
joinPath(ffmpegRoot, "ffmpeg.exe"),
joinPath(ffmpegRoot, "ffmpeg"),
]);
return {
dataRoot,
gitExe,
gitDir: gitExe ? dirOf(gitExe) : undefined,
ffmpegDir: ffmpegExe ? dirOf(ffmpegExe) : undefined,
git: componentEvidence(!!gitExe, gitRootProbe, dataRoot, "portablegit"),
ffmpeg: componentEvidence(!!ffmpegExe, ffmpegRootProbe, dataRoot, "ffmpeg"),
};
}
}
return null;
}
/**
* Is `p` Stability Matrix's unused Assets CPython (#1704)?
*
* SM stores the *base* interpreter it created the package venv FROM under
* `<Data>/Assets/Python/cpython-…/python.exe`. That tree has no ComfyUI
* site-packages (`import sqlalchemy` fails immediately). Windows also reports
* this path as `Win32_Process.ExecutablePath` for a venv trampoline, so a
* relaunch that treats the OS image as the interpreter picks it by accident.
*
* Requires the same on-disk corroboration as `detectStabilityMatrix` so a
* random `…/Assets/Python/…` tree is never classified as SM.
*/
export function isStabilityMatrixAssetsPython(p) {
if (!p)
return false;
const segs = segmentsOf(p);
const lower = segs.map((s) => s.toLowerCase());
const assets = lower.lastIndexOf("assets");
if (assets < 0 || lower[assets + 1] !== "python")
return false;
// Parent of `Assets` is the Data root. The Assets path itself never walks
// through `Packages`, so `detectStabilityMatrix` cannot corroborate it —
// require the Packages sibling on disk instead.
const dataRoot = segs.slice(0, assets).join(separatorOf(p));
if (!dataRoot)
return false;
return pathExists(joinPath(dataRoot, "Packages"));
}
/** A PATH name, not a file we can verify — `python`, `python.exe`, `python3`. */
function isBarePythonName(p) {
if (/[\\/]/.test(p) || /^[a-zA-Z]:/.test(p))
return false;
const base = p.toLowerCase();
return (base === "python" ||
base === "python.exe" ||
base === "python3" ||
base === "python3.exe");
}
function samePath(a, b) {
const norm = (s) => s.replace(/[\\/]+$/, "").replace(/[\\/]/g, "/").toLowerCase();
return norm(a) === norm(b);
}
/**
* The Stability Matrix *package* interpreter (`<Packages>/<name>/venv`, then
* `.venv`) for the install named by `paths`.
*
* SM's official env is `venv` (not `.venv`, not the Assets CPython). Used as
* the relaunch fallback when the OS did not name a usable interpreter, and as
* the replacement when the chosen exe is the unused Assets CPython (#1704).
*/
export function resolveStabilityMatrixPackagePython(paths) {
const sm = detectStabilityMatrix(paths);
if (!sm)
return undefined;
const packagesRoot = joinPath(sm.dataRoot, "Packages");
for (const p of paths) {
if (!p)
continue;
for (const ancestor of [p, ...ancestorsOf(p)]) {
const parent = dirOf(ancestor);
if (!parent || !samePath(parent, packagesRoot))
continue;
if (!pathExists(joinPath(ancestor, "main.py")) &&
!pathExists(joinPath(ancestor, "main.pyw"))) {
continue;
}
const py = firstExisting([
joinPath(ancestor, "venv", "Scripts", "python.exe"),
joinPath(ancestor, "venv", "bin", "python"),
joinPath(ancestor, "venv", "bin", "python3"),
joinPath(ancestor, ".venv", "Scripts", "python.exe"),
joinPath(ancestor, ".venv", "bin", "python"),
joinPath(ancestor, ".venv", "bin", "python3"),
]);
if (py)
return py;
}
}
return undefined;
}
/**
* Replace an Assets CPython (or a bare PATH `python`) with the package venv
* when this is a Stability Matrix install. Any other observed interpreter is
* left alone (#1654 — the process we saw still wins).
*/
export function preferStabilityMatrixPackagePython(chosen, paths) {
const packagePy = resolveStabilityMatrixPackagePython(paths);
if (!packagePy)
return chosen;
if (!chosen || isBarePythonName(chosen) || isStabilityMatrixAssetsPython(chosen)) {
return packagePy;
}
return chosen;
}
// ---------------------------------------------------------------------------
// Opaque launchers — recognized, but NOT reproducible
// ---------------------------------------------------------------------------
/**
* Launchers that own the server's environment in a way we cannot rebuild from
* disk. Pinokio composes each app's environment in a per-app JS install script
* (its own conda/git/ffmpeg shims, per-app venvs, arbitrary exports); nothing on
* disk tells us what the running process actually got. Relaunching such a server
* with OUR environment is precisely the #776 failure mode, so these refuse.
*/
const OPAQUE_LAUNCHER_DIRS = [
// A Pinokio home holds BOTH `api/` (the installed apps) and `bin/` (its shims),
// and every app it manages lives under `<root>/api/…`.
{
dir: "pinokio",
name: "Pinokio",
requireDirs: ["api", "bin"],
requireInstallUnder: "api",
},
];
/** Is `child` inside `parent`? Separator- and (Windows-)case-insensitive. */
function isUnder(child, parent) {
const norm = (s) => segmentsOf(s)
.filter((seg) => seg !== "")
.map((seg) => seg.toLowerCase());
const c = norm(child);
const p = norm(parent);
if (p.length === 0 || c.length <= p.length)
return false;
return p.every((seg, i) => c[i] === seg);
}
/**
* A recognized launcher whose environment we cannot rebuild.
*
* A refusal here BLOCKS a restart, so the evidence bar is deliberately higher than
* for Stability Matrix (whose detection only unlocks a reconstruction): a
* false positive costs the user a restart that would have worked. A directory
* merely NAMED `pinokio`, or one that merely happens to contain a `bin`, is not
* enough (coordinator gate). We require three independent signals: the named root,
* ALL of the launcher's own top-level directories, and the install actually
* sitting inside the subtree the launcher keeps its apps in.
*/
export function detectOpaqueLauncher(paths) {
for (const p of paths) {
if (!p)
continue;
for (const ancestor of ancestorsOf(p)) {
const base = basenameOf(ancestor).toLowerCase();
const hit = OPAQUE_LAUNCHER_DIRS.find((l) => l.dir === base);
if (!hit)
continue;
if (!hit.requireDirs.every((d) => pathExists(joinPath(ancestor, d))))
continue;
if (!isUnder(p, joinPath(ancestor, hit.requireInstallUnder)))
continue;
return { name: hit.name, root: ancestor };
}
}
return null;
}
// ---------------------------------------------------------------------------
// Live process environment (Linux)
// ---------------------------------------------------------------------------
/**
* The RUNNING server's own environment, read from `/proc/<pid>/environ`.
*
* This is the launch environment verbatim — whatever launcher produced it — so it
* is the best possible answer when it is available. It MUST be read while the
* process is still alive (the whole `/proc/<pid>` tree vanishes the instant the
* stop kills the pid), which is why the caller captures it at gather-time next to
* the live cwd (#535). Linux only; every other platform has no supported way to
* read another process's environment block, and we do not guess.
*/
export function readLiveProcessEnv(pid) {
if (!pid || pid <= 0 || process.platform !== "linux")
return undefined;
try {
// /proc/<pid>/environ is a NUL-separated KEY=VALUE list. Build the separator
// from a char code rather than writing a raw control byte into this file.
const NUL = String.fromCharCode(0);
const raw = readFileSync(`/proc/${pid}/environ`, "utf-8");
const env = {};
let count = 0;
for (const entry of raw.split(NUL)) {
if (entry === "")
continue;
const eq = entry.indexOf("=");
if (eq <= 0)
continue;
env[entry.slice(0, eq)] = entry.slice(eq + 1);
count++;
}
// An empty read tells us nothing (permission-trimmed / raced) — treat it as
// "unknown" rather than relaunching into a blank environment.
return count > 0 ? env : undefined;
}
catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// Environment construction
// ---------------------------------------------------------------------------
/**
* Find an env key case-insensitively. Windows environment blocks are
* case-insensitive and Node's `process.env` emulates that, but a plain object
* copy does NOT — writing a fresh "PATH" next to an inherited "Path" would hand
* the child two competing PATHs.
*/
function findEnvKey(env, name) {
const wanted = name.toLowerCase();
for (const key of Object.keys(env)) {
if (key.toLowerCase() === wanted)
return key;
}
return undefined;
}
function pathAlreadyContains(current, dir) {
const target = dir.replace(/[\\/]+$/, "").toLowerCase();
return current
.split(delimiter)
.some((part) => part.trim().replace(/[\\/]+$/, "").toLowerCase() === target);
}
/**
* Copy `base` and PREPEND `additions` to its PATH (creating PATH if absent),
* skipping entries already present. Prepending — not appending — is what the
* launcher itself does, and it is what makes the bundled git/ffmpeg win over any
* different copy the orchestrator happens to have.
*/
function withPathAdditions(base, additions) {
const env = { ...base };
const pathKey = findEnvKey(env, "PATH") ?? "PATH";
const current = env[pathKey] ?? "";
const added = [];
for (const dir of additions) {
if (!dir || pathAlreadyContains(current, dir) || added.includes(dir))
continue;
added.push(dir);
}
if (added.length > 0) {
env[pathKey] = current
? [...added, current].join(delimiter)
: added.join(delimiter);
}
return { env, added };
}
/**
* Decide what environment a relaunch should use, and whether that environment is
* actually REPRODUCIBLE.
*
* `reproducible:false` is a hard STOP for a caller that is about to KILL a healthy
* server: it must refuse BEFORE the server is touched, never after (#776 cardinal
* rule — a refusal costs a restart, a bad relaunch costs the server). A caller
* whose server is ALREADY down must NOT refuse: keeping it down is the one
* outcome worse than a possibly-degraded launch, so it launches with the best
* environment available and warns.
*/
export function resolveLaunchEnvironment(input) {
const baseEnv = input.baseEnv ?? process.env;
// 1. The live process's REAL environment beats every inference.
if (input.liveEnv && Object.keys(input.liveEnv).length > 0) {
return {
reproducible: true,
env: { ...input.liveEnv },
info: {
source: "live-process",
reproducible: true,
note: "relaunched with the environment captured from the running ComfyUI " +
"process itself (read live before the stop)",
},
};
}
// 2. Stability Matrix — reconstruct exactly what it injects, from disk.
const sm = detectStabilityMatrix(input.paths);
if (sm) {
// PARTIAL reconstruction is NOT reconstruction — but "partial" has to mean
// something we would actually DROP, not merely something that isn't there.
// Each component is therefore classified by EVIDENCE:
//
// ambiguous — the component's own directory exists (or the launcher's
// config names it) but we cannot point at its binary. Then it
// plausibly IS injected and we would silently drop it, which
// is the #776 failure. REFUSE.
// not-installed — no directory, no mention anywhere. The launcher has nothing
// to inject either, so relaunching without it is exactly what
// the launcher itself would do. PROCEED — and say which
// component was not injected, because a permanent inability to
// restart is not a safer resting place than a launch that
// works (coordinator gate P2(4)).
const ambiguous = [];
if (sm.git === "ambiguous") {
ambiguous.push(`its bundled Git (looked under "${joinPath(sm.dataRoot, "PortableGit")}") — ` +
"without it ComfyUI-Manager aborts at import with \"Bad git executable\"");
}
if (sm.ffmpeg === "ambiguous") {
ambiguous.push(`its bundled FFmpeg (looked under "${joinPath(sm.dataRoot, "Assets", "ffmpeg")}")`);
}
if (ambiguous.length > 0) {
const missing = ambiguous;
return {
reproducible: false,
info: {
source: "inherited",
reproducible: false,
launcher: "Stability Matrix",
note: "Stability Matrix install detected, but part of the tooling it injects could " +
"not be located on disk — the launcher environment could NOT be reproduced",
},
reason: `This ComfyUI is managed by Stability Matrix (${sm.dataRoot}), which launches it ` +
`with its own bundled tooling on PATH, but this could not be located: ${missing.join("; ")}. ` +
"That environment cannot be reproduced here.",
advice: "Restart ComfyUI from Stability Matrix itself so it comes back with the " +
"environment its packages expect.",
};
}
// Everything still unresolved here is provably NOT INSTALLED, so there is
// nothing to drop. Inject what exists.
const additions = [sm.gitDir, sm.ffmpegDir].filter((d) => typeof d === "string" && d.length > 0);
const { env, added } = withPathAdditions(baseEnv, additions);
if (sm.gitExe) {
// GitPython (ComfyUI-Manager) resolves `git` through this variable first;
// Stability Matrix sets it, and without it Manager aborts at import with
// "Bad git executable" even when PATH is right (#776).
const gitKey = findEnvKey(env, "GIT_PYTHON_GIT_EXECUTABLE") ?? "GIT_PYTHON_GIT_EXECUTABLE";
env[gitKey] = sm.gitExe;
}
const injected = [];
if (sm.gitDir)
injected.push("PortableGit");
if (sm.ffmpegDir)
injected.push("FFmpeg");
// Name what this install simply does not have, so a later "ffmpeg not found"
// from a node is immediately traceable rather than mysterious.
const notInstalled = [];
if (sm.git === "not-installed")
notInstalled.push("PortableGit");
if (sm.ffmpeg === "not-installed")
notInstalled.push("FFmpeg");
return {
reproducible: true,
env,
info: {
source: "stability-matrix",
reproducible: true,
launcher: "Stability Matrix",
path_additions: added,
not_installed: notInstalled.length > 0 ? notInstalled : undefined,
note: `Stability Matrix install detected (${sm.dataRoot}) — relaunched with its ` +
(injected.length > 0
? `${injected.join(" + ")} on PATH${sm.gitExe ? " and GIT_PYTHON_GIT_EXECUTABLE set" : ""}`
: "environment") +
(notInstalled.length > 0
? `; this install has no bundled ${notInstalled.join(" or ")} anywhere under its Data folder, so none was injected (nodes that need ${notInstalled.join("/")} may not work — install it from Stability Matrix if you need it)`
: ", so ComfyUI-Manager and ffmpeg-dependent nodes keep working"),
},
};
}
// 3. A launcher we recognize but cannot reproduce — refuse before stopping.
const opaque = detectOpaqueLauncher(input.paths);
if (opaque) {
return {
reproducible: false,
info: {
source: "inherited",
reproducible: false,
launcher: opaque.name,
note: `${opaque.name} builds this server's environment at launch time — it could ` +
"NOT be reproduced here",
},
reason: `This ComfyUI is launched by ${opaque.name} (${opaque.root}), which builds the ` +
"server's environment (its own git/ffmpeg/python shims and exports) at launch " +
"time. That environment cannot be read from a process we did not start, so a " +
"relaunch from here would come back missing it.",
advice: `Restart ComfyUI from ${opaque.name} itself so it comes back with the ` +
"environment it was launched with.",
};
}
// 4. Plain install — inherit, exactly as before #776.
return {
reproducible: true,
info: {
source: "inherited",
reproducible: true,
note: "relaunched with this process's environment (no launcher-owned environment detected)",
},
};
}
/**
* Force UTF-8 on a relaunched Python whose stdio WE own (#2693).
*
* When this process relaunches ComfyUI it points the child's stdout and stderr at
* a LOG FILE (#1259), not at the user's console. Python chooses its stdout
* encoding from the locale when it is not writing to a terminal, so on a Windows
* install whose ANSI codepage is not UTF-8 the child comes up encoding to that
* legacy codepage — and the first custom node that prints an emoji kills startup:
*
* UnicodeEncodeError: 'cp949' codec can't encode character '\U0001f389'
*
* (rgthree-comfy's banner, on a Korean Windows install. It is not an unusual
* node, and a party popper is not an unusual thing to print.)
*
* This is OUR bug, not the node's and not the user's: their own console launch of
* the same command works. We changed where the bytes go, so the encoding on that
* destination is ours to choose, and a legacy codepage is the wrong choice for a
* file we then read back as text.
*
* SCOPE, deliberately narrow:
* - Windows only. That is where the report is, and where a non-UTF-8 ANSI
* codepage is the default rather than an opt-in. The same reasoning covers a
* POSIX box running under `LANG=C`, but nothing has reported one and widening
* this without a case to check against is how a fix acquires a second bug.
* - An EXPLICIT value always wins. A user who set PYTHONIOENCODING to something
* else means it, and overriding their choice to fix an encoding problem would
* be its own encoding problem.
* - Returns the input UNCHANGED when there is nothing to add, so a launch that
* was already fine keeps its exact previous behaviour — including inheriting
* (undefined) rather than being handed a materialised copy.
*/
export function withUtf8StdioEnv(env, opts = {}) {
const platform = opts.platform ?? process.platform;
if (platform !== "win32")
return env;
// `env: undefined` means "inherit this process's environment", so the values
// already in force are the base ones — that is what the child would have got.
const effective = env ?? opts.baseEnv ?? process.env;
// Windows environment names are CASE-INSENSITIVE, and `effective` is often a
// plain object rather than `process.env`. Node's `process.env` is a proxy that
// honours that (`process.env.PYTHONUTF8` reads a var set as `PythonUtf8`), but a
// SPREAD of it is an ordinary object and loses it — measured, not assumed:
//
// PythonUtf8=0 node -e '...'
// process.env.PYTHONUTF8 -> "0"
// ({...process.env}).PYTHONUTF8 -> undefined (key present as PythonUtf8)
//
// A case-sensitive read here would therefore miss a user's explicit opt-out on
// exactly the paths that materialise an env (the Stability Matrix
// reconstruction, or any caller passing one), and add a SECOND key differing
// only in case. That is the opposite of this function's stated rule, in a
// function that only ever runs on Windows.
const keyOf = (name) => {
const wanted = name.toLowerCase();
return Object.keys(effective).find((k) => k.toLowerCase() === wanted);
};
const set = (name) => {
const key = keyOf(name);
const v = key === undefined ? undefined : effective[key];
return typeof v === "string" && v.trim() !== "";
};
if (set("PYTHONUTF8") && set("PYTHONIOENCODING"))
return env;
const out = { ...effective };
// Write through the EXISTING key when one is present but empty, so a blank
// `PythonUtf8=` is filled in rather than shadowed by a second spelling.
const assign = (name, value) => {
out[keyOf(name) ?? name] = value;
};
if (!set("PYTHONUTF8"))
assign("PYTHONUTF8", "1");
if (!set("PYTHONIOENCODING"))
assign("PYTHONIOENCODING", "utf-8");
return out;
}
//# sourceMappingURL=launcher-env.js.map