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

1,398 lines • 79.5 kB
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { platform } from "node:os";
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
import { z } from "zod";
import { getComfyUIBaseUrl, getComfyuiTargetGeneration, isRemoteMode, } from "../config.js";
import { installCustomNode, installModelViaManager, listInstalledNodes, } from "./node-management.js";
import { targetsPanelPackExactly } from "./panel-pin-guard.js";
import { listLocalModels, liveListingHasEntry, liveListingHasBasename, isUnderLiveModelRoots, currentLiveModelsRoot, resolveExistingModelFile, managerModelDestination, MODEL_SUBDIRS, shouldDispatchDownloadToManager, } from "./model-resolver.js";
import { startDownloadJob, describePlacement } from "./download-jobs.js";
import { resolveModelsDir } from "./output-dir.js";
import { resolveEffectiveComfyUIBaseLive, resolveEffectiveComfyUICodeBaseLive, resolveCustomNodesScanBaseLiveStrict, resolveInstallInterpreter, } from "./workspace-env.js";
import { ValidationError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { buildManifestPartial, createManifestPartialOperation, describeManifestSource, formatLocalFallbackMessage, formatNotStartedMessage, formatStillInstallingMessage, } from "./manifest-partial.js";
/** Grace window (ms) to await a manifest model download before handing back a
 *  background job handle. Keeps apply_manifest under the MCP tools/call timeout
 *  on large model packs (#362); small files still complete inline. */
function manifestDownloadGraceMs() {
    const raw = Number(process.env.COMFYUI_MCP_DOWNLOAD_GRACE_MS);
    return Number.isFinite(raw) && raw >= 0 ? raw : 15_000;
}
/** Overall wall-clock budget (ms) for the custom-node install phase. Node installs
 *  drive the ComfyUI-Manager queue, which can legitimately run for minutes on a
 *  big pack. Blocking apply_manifest until it drains blows past the MCP tools/call
 *  timeout (300s) and the caller sees a FALSE failure while the Manager keeps
 *  installing (#489). Bounding the phase well under that cap lets us hand back a
 *  "pending" result (poll the Manager queue when the operation really is
 *  server-side). Env-tunable; default 240s. */
function manifestNodeBudgetMs() {
    const raw = Number(process.env.COMFYUI_MCP_MANIFEST_NODE_BUDGET_MS);
    return Number.isFinite(raw) && raw > 0 ? raw : 240_000;
}
/** Sentinel returned by raceDeadline when the wall-clock deadline wins. */
const BUDGET_TIMEOUT = Symbol("manifest-node-budget-timeout");
/**
 * Await `p` but never past the absolute `deadline` (Date.now() ms). Returns the
 * resolved value, or BUDGET_TIMEOUT if the deadline elapses first. Bounds EVERY
 * async wait in the custom-node phase (#489) — the install itself AND the
 * surrounding listInstalledNodes round-trips — so a slow Manager can never push
 * apply_manifest past the MCP tools/call timeout; it hands back "pending" instead.
 *
 * CAVEAT (single-threaded runtime): this bounds ASYNC work only. A SYNCHRONOUS
 * subprocess install unit — the git-clone / cm-cli fallback (execFileSync), like
 * pip — blocks the event loop, so the timer cannot preempt one already running;
 * each is instead bounded by its OWN execFileSync timeout. The race is what fixes
 * #489's actual case: the ASYNC Manager-queue polling that keeps running for
 * minutes. `p` must not reject (callers pass a pre-caught promise).
 */
async function raceDeadline(p, deadline) {
    const remaining = deadline - Date.now();
    if (remaining <= 0)
        return BUDGET_TIMEOUT;
    let timer;
    const timeout = new Promise((resolve) => {
        timer = setTimeout(() => resolve(BUDGET_TIMEOUT), remaining);
    });
    try {
        return await Promise.race([p, timeout]);
    }
    finally {
        if (timer)
            clearTimeout(timer);
    }
}
const IS_WIN = platform() === "win32";
const MAX_MANIFEST_BYTES = 1024 * 1024;
const YAML_MAX_ALIAS_COUNT = 50;
const ASCII_CONTROL_RE = /[\x00-\x1F\x7F]/;
const WHITESPACE_RE = /\s/;
const modelTypeSchema = z.enum(MODEL_SUBDIRS);
export const manifestSchema = z
    .object({
    apt: z.array(z.string().min(1)).optional().default([]),
    pip: z.array(z.string().min(1)).optional().default([]),
    custom_nodes: z.array(z.string().min(1)).optional().default([]),
    models: z
        .array(z
        .object({
        url: z.string().url(),
        model_type: modelTypeSchema.optional(),
        filename: z.string().min(1).optional(),
        local_path: z.string().min(1).optional(),
    })
        .strict())
        .optional()
        .default([]),
})
    .strict();
// ---------------------------------------------------------------------------
// #1568 — bundled pack manifests, resolved by name rather than by a path that expires
//
// `list_packs` hands out an absolute `manifest_path` built from the running package root.
// Under npx that root is `…/_npx/<hash>/node_modules/comfyui-mcp`, and the next respawn
// gets a different <hash> — so a path captured earlier stats ENOENT while the pack is
// still sitting there under its name.
//
// The lookup was never the broken part. `packsDir()` already derives from the current
// root; what goes stale is the string we publish and invite the caller to hand back.
// ---------------------------------------------------------------------------
/** A single safe path segment — a pack directory name with no traversal or separators.
 *  Mirrors skills-access.ts's SAFE_NAME, which guards the same directory. */
const SAFE_PACK_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
/** The bundled packs directory of the CURRENTLY RUNNING build. */
function bundledPacksDir() {
    return join(fileURLToPath(new URL("../../", import.meta.url)), "packs");
}
/**
 * The manifest file of a bundled pack, or null when there is no such pack.
 *
 * Name-guarded and existence-checked: the name reaches the filesystem, so traversal must
 * not. Returns the `.yaml` or `.yml` form, whichever the pack actually ships.
 */
export function resolvePackManifestFile(packName) {
    const name = String(packName ?? "").trim();
    if (!SAFE_PACK_NAME.test(name))
        return null;
    const root = bundledPacksDir();
    const packDir = join(root, name);
    // Belt and braces over the name test: never leave the packs directory.
    if (!packDir.startsWith(root))
        return null;
    for (const file of ["manifest.yaml", "manifest.yml"]) {
        const candidate = join(packDir, file);
        if (existsSync(candidate))
            return candidate;
    }
    return null;
}
/**
 * Which bundled pack a MISSING path was almost certainly naming, or null.
 *
 * This deliberately does NOT substitute the file. An earlier version of this returned the
 * live path and applied it, and review was right to attack that: `apply_manifest` installs
 * custom nodes and downloads model weights, so running a file the caller did not name is a
 * much worse failure than the ENOENT it replaces — and the recogniser authenticated only
 * the last four segments, so a user's own `~/dev/comfyui-mcp/packs/<name>/manifest.yaml`
 * would have been retargeted at a bundled pack that merely shares a directory name.
 *
 * So it answers a question instead: "is there a bundled pack this dead path was pointing
 * at?" The caller turns that into an actionable refusal naming `pack:<name>`, which is one
 * call away and leaves the choice with the user. `pack:` is the actual fix for #1568; this
 * only stops the failure being a bare ENOENT that reads like a missing pack.
 *
 * Still requires `node_modules` in the path, because the reported failure is an INSTALLED
 * copy under an npx cache — that keeps a source checkout out of it entirely.
 */
export function bundledPackNamedByStalePath(candidate) {
    const raw = String(candidate ?? "");
    if (!raw.trim())
        return null;
    // Only a path that resolves to nothing is a candidate. This is not a security boundary —
    // nothing is substituted on the strength of it — so an ambiguous stat (a permission
    // error, a dangling symlink) costing us a suggestion is the harmless direction.
    if (existsSync(raw))
        return null;
    const parts = raw.replace(/\\/g, "/").split("/").filter(Boolean);
    if (parts.length < 5)
        return null;
    const file = parts[parts.length - 1];
    const name = parts[parts.length - 2];
    const packs = parts[parts.length - 3];
    const owner = parts[parts.length - 4];
    const container = parts[parts.length - 5];
    if (file !== "manifest.yaml" && file !== "manifest.yml")
        return null;
    if (packs !== "packs")
        return null;
    if (owner !== "comfyui-mcp")
        return null;
    // An INSTALLED copy, which is what an npx cache holds. A working checkout at
    // `~/dev/comfyui-mcp/packs/…` is not this, and its missing file is its own business.
    if (container !== "node_modules")
        return null;
    if (!SAFE_PACK_NAME.test(name))
        return null;
    // …and only if this build actually ships it, so the hint never names a pack that is not
    // there to apply.
    return resolvePackManifestFile(name) ? name : null;
}
function parseManifestText(path, text) {
    const ext = extname(path).toLowerCase();
    if (ext === ".json")
        return JSON.parse(text);
    if (ext === ".yaml" || ext === ".yml") {
        return parseYaml(text, { maxAliasCount: YAML_MAX_ALIAS_COUNT });
    }
    throw new ValidationError(`Unsupported manifest file extension "${ext || "(none)"}". Use .json, .yaml, or .yml.`);
}
export async function loadManifestFile(path) {
    const info = await stat(path);
    if (info.size > MAX_MANIFEST_BYTES) {
        throw new ValidationError(`Manifest file is too large (${info.size} bytes). Maximum is ${MAX_MANIFEST_BYTES} bytes.`);
    }
    const text = await readFile(path, "utf-8");
    const raw = parseManifestText(path, text);
    return manifestSchema.parse(raw);
}
async function resolveManifest(opts) {
    const hasInline = opts.manifest !== undefined;
    const hasPath = opts.path !== undefined && opts.path.trim().length > 0;
    const hasPack = opts.pack !== undefined && opts.pack.trim().length > 0;
    const given = [hasInline, hasPath, hasPack].filter(Boolean).length;
    if (given !== 1) {
        throw new ValidationError("Provide exactly one of `manifest`, `path`, or `pack` to apply_manifest.");
    }
    if (hasInline)
        return manifestSchema.parse(opts.manifest);
    // #1568 — a NAME, resolved against the running package root. This is the durable form: a
    // pack name cannot expire the way a path captured under an npx cache hash does.
    if (hasPack) {
        const name = opts.pack.trim();
        const file = resolvePackManifestFile(name);
        if (!file) {
            throw new ValidationError(`No bundled pack named "${name}" ships a manifest in this build. ` +
                `List the available packs with list_packs (action:"list") and use the \`name\` it reports.`);
        }
        return loadManifestFile(file);
    }
    // The path is opened EXACTLY as given. Trimming was only ever meant to decide whether an
    // input was present; trimming what gets opened would silently retarget a legitimate
    // POSIX path with leading or trailing whitespace (review).
    const requested = opts.path;
    // #1568 — the reported failure: a path handed out by an EARLIER npx process, whose cache
    // directory no longer exists. Turn the bare ENOENT into the one-call remedy rather than
    // applying a file nobody named.
    // ATTEMPT THE OPEN FIRST, and only then explain (review, round 2). Deciding "this is a
    // stale npx path" before trying it replaced the loader's real error — an ordinary npm
    // install under node_modules, or a permission failure, would both have been relabelled,
    // hiding the actual ENOENT/EACCES and pointing at a pack the caller never asked for.
    //
    // So the real error is what surfaces, and the remedy is appended to it.
    try {
        return await loadManifestFile(requested);
    }
    catch (err) {
        const stalePack = bundledPackNamedByStalePath(requested);
        if (!stalePack)
            throw err;
        const cause = err instanceof Error ? err.message : String(err);
        throw new ValidationError(`${cause}\n\nThat path points inside an installed comfyui-mcp package directory. If it came ` +
            `from an earlier npx run, that directory is replaced on each respawn, which is why a ` +
            `manifest_path captured earlier stops resolving (#1568). This build does ship a pack ` +
            `called "${stalePack}" — if that is the one you meant, re-run with pack: "${stalePack}" ` +
            `instead of a path: it resolves against the running build and cannot go stale. ` +
            `(Nothing was installed.)`);
    }
}
function commandExists(cmd) {
    try {
        execFileSync(IS_WIN ? "where" : cmd, IS_WIN ? [cmd] : ["--version"], {
            encoding: "utf-8",
            stdio: "ignore",
            timeout: 5000,
            shell: false,
        });
        return true;
    }
    catch {
        return false;
    }
}
async function resolveWorkspacePython(comfyuiPath) {
    return resolveInstallInterpreter(comfyuiPath);
}
function validatePipPackageSpec(pkg) {
    if (pkg.startsWith("-")) {
        throw new ValidationError(`Manifest pip entry must be a package spec, not an option: ${redactUrlCredentials(pkg)}`);
    }
    if (ASCII_CONTROL_RE.test(pkg)) {
        throw new ValidationError("Manifest pip entry cannot contain ASCII control characters.");
    }
    if (WHITESPACE_RE.test(pkg)) {
        throw new ValidationError(`Manifest pip entry cannot contain whitespace: ${redactUrlCredentials(pkg)}`);
    }
}
function runPythonPipInstall(python, pkg, comfyuiPath) {
    const out = execFileSync(python, ["-m", "pip", "install", pkg], {
        cwd: comfyuiPath,
        encoding: "utf-8",
        timeout: 600_000,
        stdio: ["ignore", "pipe", "pipe"],
    });
    return (out ?? "").trim();
}
/** uv refuses to install into an interpreter that is not a virtual environment
 *  unless told to. A ComfyUI running from a system Python (no .venv) hits exactly
 *  this — `uv pip install --python <sys python>` fails with "No virtual
 *  environment found ... pass --system" (#377). Recognize that message so we can
 *  fall back to the interpreter's own pip, which installs correctly with no venv. */
function isUvNonVenvError(text) {
    return /No virtual environment found|not a virtual environment|pass `?--system|run `?uv venv/i.test(text);
}
/**
 * #1508 — PEP 668: the interpreter declares itself EXTERNALLY MANAGED, so its own
 * pip refuses to install into it. Stability Matrix's uv-managed CPython does
 * exactly this:
 *
 *   error: externally-managed-environment
 *   This Python installation is managed by uv and should not be modified.
 *
 * Keyed on the PEP's own error id first (`externally-managed-environment`, the
 * literal pip emits) and the prose form second, because the second line is
 * supplied by the distributor's EXTERNALLY-MANAGED file and therefore says
 * something different on Debian, Homebrew, and uv. Matching only the uv wording
 * would have fixed this reporter and no one else.
 */
function isExternallyManagedError(text) {
    return (
    // pip's canonical PEP 668 error IDENTIFIER. Stable across distributors —
    // the line under it is the distributor's prose and says something different
    // on Debian, Homebrew and uv, so keying on that would have fixed one
    // reporter and nobody else.
    // Anchored to the START OF A LINE, which is where pip prints it:
    //   error: externally-managed-environment
    // (measured). An unanchored match would fire on any output that merely
    // CONTAINS the token — a file path, a package name, a vendored log line —
    // and rewrite an unrelated failure as this one, discarding its real cause
    // (codex P2).
    /^\s*error: externally-managed-environment/im.test(text) ||
        // uv's own refusal, which uses no error id:
        //   error: The interpreter at <path> is externally managed, and indicates ...
        // Anchored on "interpreter at ... is externally managed" rather than the bare
        // phrase (codex P2): plain "externally managed" appears in ordinary build and
        // dependency output, and matching it would rewrite an unrelated failure as a
        // managed-environment story — discarding the real cause behind a remedy that
        // does not apply. Both forms below were MEASURED against uv 0.11.21 and a
        // uv-managed CPython 3.12.13, not transcribed from documentation.
        /interpreter at .+ is externally managed/i.test(text));
    // WHICH WAY THIS FAILS MATTERS, and it is chosen deliberately (codex P2, the
    // opposite direction). PEP 668 requires only that the installer error out; it
    // does not mandate pip's renderer token, so a distributor-patched or localized
    // installer can refuse in wording neither pattern above matches.
    //
    // That miss is the SAFE failure. It surfaces the installer's own error
    // unchanged — exactly today's behaviour, no regression — and that error already
    // names PEP 668 itself. The reader loses this message's extra guidance and
    // nothing else. The opposite error, matching too eagerly, REPLACES a real cause
    // with a remedy that does not apply, which is strictly worse than saying
    // nothing. So this stays tight and is widened only against a refusal someone
    // has actually seen.
}
/** Cap the installer's own output carried into a refusal. Enough to diagnose,
 *  bounded so a verbose failure cannot swamp the actionable part. */
/**
 * Mask credentials embedded in a URL's userinfo (`https://user:token@host/...`).
 *
 * A pip entry may legitimately BE such a URL — `validatePipPackageSpec` only
 * rejects options, control characters and whitespace — so the spec itself is a
 * secret-bearing string, not just the command line built around it. Applied to
 * everything this refusal renders: the package name it echoes AND the installer
 * output it carries, since pip prints the URL it is fetching.
 */
function redactUrlCredentials(text) {
    // Userinfo runs from `://` to the LAST `@` before the path, and may contain
    // BOTH ':' and '@' in the password — `https://u:alpha@beta@host` is a valid URL
    // whose password is `alpha@beta`. An earlier version stopped at the FIRST `@`
    // ([^/\s@]+@) and so rendered that as `https://***:***@beta@host`, leaking half
    // the secret while looking redacted (codex P1).
    //
    // `[^/\s]*` is GREEDY and cannot cross `/` or whitespace, so it runs to the
    // last `@` within the authority and no further.
    return text.replace(/(\w+:\/\/)[^/\s]*@/g, (_m, scheme) => `${scheme}***:***@`);
}
function clipInstallerOutput(text) {
    const cleaned = text.replace(/\r/g, "").split("\n").filter((l) => l.trim()).join("\n").trim();
    return cleaned.length > 1200 ? `${cleaned.slice(0, 1200)}\n… (truncated)` : cleaned;
}
/** Both pip and uv write the refusal to stderr, but a non-zero exit from
 *  execFileSync carries it across `stderr`, `stdout` and `message` inconsistently
 *  depending on how the child died — so every failure is read from all three. */
function errorText(err) {
    const e = err;
    return `${e?.stderr?.toString() ?? ""}\n${e?.stdout?.toString() ?? ""}\n${e?.message ?? ""}`;
}
/**
 * The installer's OWN output — stderr and stdout only, deliberately WITHOUT
 * `Error.message`.
 *
 * Node builds that message as `Command failed: <the whole argv>`, so it embeds
 * the package spec. A pip spec may legitimately be a direct URL, and a direct URL
 * may carry credentials (`https://user:token@host/pkg.whl`) — that entry passes
 * `validatePipPackageSpec`, which only rejects options, control characters and
 * whitespace. Echoing the command back into a user-facing refusal would copy the
 * token into it.
 *
 * `errorText` still includes the message, because DETECTION wants the breadth (a
 * child that dies before writing to a stream can leave the reason only there).
 * What gets shown is narrower than what gets matched, which is the right way
 * round.
 */
function installerOutput(err) {
    const e = err;
    return `${e?.stderr?.toString() ?? ""}\n${e?.stdout?.toString() ?? ""}`;
}
/**
 * #1508 — the refusal, said in a way that can be acted on.
 *
 * Deliberately does NOT reach for `--break-system-packages`, which is the
 * reporter's own caution and the right one: on a uv-managed interpreter that
 * writes into an environment uv owns and may later reset, converting a clean
 * refusal into a silent, delayed breakage of their ComfyUI install. A refusal
 * that names the two supported routes is worth more than a write that appears to
 * work.
 */
function externallyManagedRefusal(pkg, python, output = "") {
    // The installer's OWN output is carried through (codex P2). This refusal
    // replaces the underlying error rather than wrapping it, and apply_manifest
    // reports only `err.message` per item — so without this the actual diagnostic
    // is gone, and an earlier draft even claimed it was "above" when nothing had
    // been printed. Anything the tool said that this message does not anticipate
    // would have been lost silently.
    // REDACT, then clip — never the other way round (codex P1). Clipping first can
    // sever the `@` that the redaction pattern anchors on, so a secret sitting near
    // the cut survives as `https://u:token` in a message that looks sanitised.
    const detail = clipInstallerOutput(redactUrlCredentials(output));
    return ((detail ? `${detail}\n\n` : "") +
        `Refusing to install "${redactUrlCredentials(pkg)}": the Python that ComfyUI runs (${python}) declares itself ` +
        `EXTERNALLY MANAGED (PEP 668), so nothing may install into it directly. This is normal for a ` +
        `uv-managed install (Stability Matrix) and for distro Pythons on Linux — a deliberate guard, ` +
        `not a broken interpreter.\n\n` +
        `uv does NOT get around this: \`uv pip install --python "${python}"\` is refused by uv too, ` +
        `with "the interpreter ... is externally managed". Measured, not assumed — so do not spend ` +
        `time on it.\n\n` +
        `What actually works — the environment needs to be a VIRTUAL one:\n` +
        `  - if your launcher owns this install (Stability Matrix), add the package through its own ` +
        `Python-packages UI, which installs into the environment it manages for ComfyUI;\n` +
        `  - or create a venv and run ComfyUI from it: \`uv venv <dir>\` (uv's own suggestion for ` +
        `exactly this error), install there, then set COMFYUI_PYTHON to that interpreter and restart ` +
        `ComfyUI through this server so the manifest installs into it.\n\n` +
        `WORTH CHECKING FIRST: if ComfyUI already runs from a virtual environment, then the ` +
        `interpreter above is the wrong one — a base install picked up instead of the venv — and the ` +
        `fix is to point COMFYUI_PYTHON at the venv rather than to change anything about packaging. ` +
        `The path in this message is the one to compare against.\n\n` +
        `Not doing it for you: pip offers \`--break-system-packages\` and this refuses to pass it. On ` +
        `a uv-managed interpreter that writes into an environment uv owns and may later reset, which ` +
        `turns a clear failure now into a broken ComfyUI later. That stays a decision you make ` +
        `deliberately, not one inherited from a manifest.`);
}
async function installPipPackage(pkg, comfyuiPath) {
    validatePipPackageSpec(pkg);
    const resolved = await resolveWorkspacePython(comfyuiPath);
    if (!resolved.python) {
        throw new ValidationError(`Refusing to install "${redactUrlCredentials(pkg)}". ${resolved.reason} Set COMFYUI_PYTHON to the interpreter ComfyUI runs with, or restart ComfyUI through this MCP server and retry.`);
    }
    const python = resolved.python;
    const useUv = commandExists("uv");
    if (!useUv) {
        logger.info("Installing manifest Python package", { package: redactUrlCredentials(pkg), installer: "pip" });
        try {
            runPythonPipInstall(python, pkg, comfyuiPath);
        }
        catch (err) {
            // #1508 — THE reporter's path. Stability Matrix bundles uv inside its own
            // directory rather than on PATH, so `useUv` is false and we come straight
            // here — into an interpreter whose pip refuses by design (PEP 668).
            if (isExternallyManagedError(errorText(err))) {
                throw new ValidationError(externallyManagedRefusal(pkg, python, installerOutput(err)));
            }
            throw err;
        }
        return resolved;
    }
    logger.info("Installing manifest Python package", { package: redactUrlCredentials(pkg), installer: "uv" });
    try {
        const out = execFileSync("uv", ["pip", "install", "--python", python, pkg], {
            cwd: comfyuiPath,
            encoding: "utf-8",
            timeout: 600_000,
            stdio: ["ignore", "pipe", "pipe"],
        });
        void out;
        return resolved;
    }
    catch (err) {
        const detail = errorText(err);
        // #1508 — checked BEFORE the #377 fallback. uv's non-venv message and a PEP
        // 668 refusal can both appear on a managed interpreter, and falling back to
        // bare pip there walks straight into the same wall one step later, burying
        // the real reason under a second failure.
        if (isExternallyManagedError(detail)) {
            throw new ValidationError(externallyManagedRefusal(pkg, python, installerOutput(err)));
        }
        if (isUvNonVenvError(detail)) {
            // System-Python ComfyUI (no venv): uv can't use the interpreter directly.
            // The interpreter's own pip installs into that exact environment, which is
            // what we want (#377).
            logger.info("uv rejected the non-venv ComfyUI interpreter; falling back to `python -m pip install`", { package: redactUrlCredentials(pkg) });
            try {
                runPythonPipInstall(python, pkg, comfyuiPath);
            }
            catch (err2) {
                // The fallback's OWN refusal (#1508). Reported as the managed-environment
                // case it is, not as a bare pip error, so the caller is not left reading
                // uv's non-venv complaint as the cause.
                // `err2`, NOT `err`. The output carried into the refusal must be the one
                // that actually refused — pip's. Attaching uv's earlier non-venv complaint
                // here is precisely the misdirection this branch exists to prevent, and it
                // would send the reader off to create a venv for the wrong reason.
                if (isExternallyManagedError(errorText(err2))) {
                    throw new ValidationError(externallyManagedRefusal(pkg, python, installerOutput(err2)));
                }
                throw err2;
            }
            return resolved;
        }
        throw err;
    }
}
function normalizeId(value) {
    return value.trim().toLowerCase();
}
function maybeGitModuleName(value) {
    if (!/^(https?:\/\/|git@|git\+)/i.test(value) && !value.endsWith(".git")) {
        return undefined;
    }
    const withoutRef = value.replace(/(?<!^)@[^@/]+$/, "");
    const stripped = withoutRef.replace(/[?#].*$/, "").replace(/\/+$/, "");
    const pathPart = stripped.includes(":") && !stripped.includes("://")
        ? stripped.slice(stripped.lastIndexOf(":") + 1)
        : stripped;
    return basename(pathPart).replace(/\.git$/i, "").toLowerCase();
}
/**
 * `owner/repo` from a git URL or an aux_id (`teskor-hub/comfyui-teskors-utils`).
 * Used so a Manager listing of a different author is not treated as the
 * requested origin (#2523).
 */
function gitOwnerRepo(value) {
    if (!value)
        return undefined;
    const trimmed = value.trim().replace(/\.git$/i, "").replace(/\/+$/, "");
    if (!trimmed)
        return undefined;
    const hosted = /(?:github\.com|gitlab\.com|bitbucket\.org)[/:]([^/]+)\/([^/#?]+)/i.exec(trimmed);
    if (hosted)
        return `${hosted[1]}/${hosted[2]}`.toLowerCase();
    const short = /^([^/]+)\/([^/]+)$/.exec(trimmed);
    return short ? `${short[1]}/${short[2]}`.toLowerCase() : undefined;
}
function nodeAlreadyInstalled(id, installed) {
    const wanted = normalizeId(id);
    const gitModule = maybeGitModuleName(id);
    const wantedOrigin = gitOwnerRepo(id);
    return installed.some((node) => {
        const candidates = [
            node.module,
            node.cnrId,
            node.auxId,
        ]
            .filter((v) => Boolean(v))
            .map(normalizeId);
        const nameMatch = candidates.includes(wanted) || (gitModule ? candidates.includes(gitModule) : false);
        if (!nameMatch)
            return false;
        // A git URL that names an owner is not "already installed" when Manager
        // listed a different author's repository under the same bare name.
        // A registry/CNR identity can intentionally point at a differently named
        // source checkout; only a bare from-source Manager entry's aux_id is the
        // origin evidence relevant to this pack alias case (#2523).
        if (wantedOrigin && !node.cnrId) {
            const installedOrigin = gitOwnerRepo(node.auxId);
            if (installedOrigin && installedOrigin !== wantedOrigin)
                return false;
        }
        return true;
    });
}
function defaultFilenameForUrl(url) {
    return basename(new URL(url).pathname) || "model.safetensors";
}
/** Resolve a manifest destination without needing a local models root. */
function manifestModelTarget(model) {
    if (model.local_path) {
        if (isAbsolute(model.local_path)) {
            throw new ValidationError(`Model local_path must be relative to models/: ${model.local_path}`);
        }
        const segments = model.local_path.split(/[/\\]+/).filter(Boolean);
        if (segments.length === 0 || segments.includes("..")) {
            throw new ValidationError(`Model local_path escapes the models directory: ${model.local_path}`);
        }
        if (segments.length < 2) {
            throw new ValidationError(`Model local_path must include a category subfolder (e.g. 'checkpoints/foo.safetensors'): ${model.local_path}`);
        }
        return {
            targetSubfolder: segments.slice(0, -1).join("/"),
            filename: segments[segments.length - 1],
        };
    }
    return {
        targetSubfolder: model.model_type ?? "checkpoints",
        filename: model.filename ?? defaultFilenameForUrl(model.url),
    };
}
async function resolveLocalModelPath(model) {
    // Root the target — and therefore the local_path containment guard below — at
    // the SAME directory the downloader actually writes to: the live connected
    // server's models dir (resolveModelsDir, live-first), NOT <COMFYUI_PATH>/models.
    // When the connected install differs from a stale COMFYUI_PATH (#490), a target
    // computed against COMFYUI_PATH/models is not the path the write uses at all.
    const root = resolve(await resolveModelsDir());
    if (model.local_path) {
        if (isAbsolute(model.local_path)) {
            throw new ValidationError(`Model local_path must be relative to models/: ${model.local_path}`);
        }
        const targetPath = resolve(root, model.local_path);
        const rel = relative(root, targetPath);
        if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
            throw new ValidationError(`Model local_path escapes the models directory: ${model.local_path}`);
        }
        if (!targetPath.startsWith(root + sep)) {
            throw new ValidationError(`Model local_path escapes the models directory: ${model.local_path}`);
        }
        const filename = basename(targetPath);
        if (filename === "." || filename === ".." || filename.length === 0) {
            throw new ValidationError(`Model local_path must include a filename: ${model.local_path}`);
        }
        // NO symlink/realpath policy here (#870). This function used to walk the
        // parents itself and refuse any that resolved outside the models root — the
        // pre-#870 rule, kept alive in a second copy. #919 replaced that rule in the
        // canonical resolver (an in-tree symlink's redirect is authorized by its
        // LOCATION inside the user's own models tree, because ComfyUI's own scanner
        // follows it), but this copy was never updated, so apply_manifest kept
        // refusing the mainstream StabilityMatrix layout — `models/vae` and
        // `models/diffusion_models` are junctions to shared storage while
        // `models/text_encoders` is a real directory, which is exactly the
        // some-categories-work asymmetry #870 reports.
        //
        // The guard is not lost, it is de-duplicated: EVERY model this function
        // resolves is downloaded through startDownloadJob → resolveDownloadTarget →
        // resolveModelSubfolderWithLiveRoot, which runs assertNoEscapingSymlinkAncestor
        // on the same destination before a single byte is written — with the
        // custom_nodes code-root veto and the dangling-symlink refusal intact, and
        // with the base-install dirs and live snapshot from the SAME /system_stats
        // call that resolved the root (which this layer does not have). Duplicating
        // the policy here is what let the two drift; model-resolver.ts says so at the
        // guard itself ("so it can never diverge from a caller's separate
        // pre-validation (e.g. apply_manifest resolving the root a second time)").
        return {
            targetSubfolder: dirname(rel),
            filename,
            targetPath,
        };
    }
    const targetSubfolder = model.model_type ?? "checkpoints";
    const filename = model.filename ?? defaultFilenameForUrl(model.url);
    return {
        targetSubfolder,
        filename,
        targetPath: join(root, targetSubfolder, filename),
    };
}
async function fileExists(path) {
    try {
        const info = await stat(path);
        return info.isFile();
    }
    catch {
        return false;
    }
}
/**
 * Decide whether a manifest model already exists locally, honoring EVERY model
 * root ComfyUI loads from — not just the single computed target under
 * `<COMFYUI_PATH>/models`. ComfyUI resolves models across the primary install
 * PLUS the extra roots declared in extra_model_paths.yaml / extra_models_config
 * (commonly on another drive, e.g. E:\). Checking only the computed path
 * re-downloads a large model the user already has under an alternate root.
 *
 * Returns the path where the file was found (absolute for filesystem hits, or a
 * ComfyUI-relative `category/name` for HTTP hits), or undefined if it exists in
 * no root. Best-effort and NEVER throws: any resolver failure (ComfyUI
 * unreachable, no extra roots, cloud mode) is swallowed and we fall back to the
 * single-path check, so a legitimate install is never blocked.
 *
 * Reuses the existing multi-root machinery:
 *  - `resolveExistingModelFile` (model-resolver, #58/#60) — exact relative-path
 *    lookup across the primary root AND every extra_model_paths root, scoped per
 *    category, with the project's containment/symlink guards.
 *  - `listLocalModels` — the category listing ComfyUI actually serves over HTTP
 *    (aggregates symlinked/extra roots and nested subfolders; also works in
 *    remote mode), with a filesystem fallback.
 */
async function findExistingModel(target) {
    // 1. Baseline single-path check (the original behavior). Always safe and is
    //    the source of truth when no extra roots / HTTP are reachable.
    if (await fileExists(target.targetPath))
        return target.targetPath;
    // 2. Exact relative-path lookup across the primary models/ root AND every
    //    extra_model_paths root. The category scoping inside the resolver keeps a
    //    `.safetensors` checkpoint from matching a same-named file under loras/.
    const relativePath = (target.targetSubfolder && target.targetSubfolder !== "."
        ? `${target.targetSubfolder}/${target.filename}`
        : target.filename).replace(/\\/g, "/");
    try {
        const found = await resolveExistingModelFile(relativePath);
        if (found.info.isFile())
            return found.path;
    }
    catch {
        // Not found in any root, comfyuiPath unset, or traversal — fall through.
    }
    // 3. Match within the category across all roots ComfyUI serves (when running,
    //    the HTTP-aggregated view of extra/symlinked roots; remote setups; and a
    //    filesystem fallback). Category-scoped, so cross-category same-name files
    //    are never mistaken for a match.
    //
    //    The match precision depends on where the target lives:
    //    - CATEGORY ROOT target (e.g. model_type: checkpoints, or local_path
    //      "checkpoints/foo.safetensors"): the intent is "do I already have this
    //      file anywhere in the served category?", so match by basename anywhere
    //      (also catches a model the user stored under a nested subfolder).
    //    - NESTED target (e.g. local_path "checkpoints/foo/model.safetensors"):
    //      the manifest asks for that EXACT relative location. Matching by
    //      basename here would false-skip when only a same-named file under a
    //      DIFFERENT subfolder exists, leaving the requested file absent. So
    //      require an exact category-relative path match instead.
    const subSegments = target.targetSubfolder.split(/[/\\]+/).filter(Boolean);
    const category = subSegments[0];
    if (category && MODEL_SUBDIRS.includes(category)) {
        const isCategoryRoot = subSegments.length === 1;
        // The category-relative path implied by the target (strip the leading
        // category segment), normalized to forward slashes for comparison.
        const relWithinCategory = [...subSegments.slice(1), target.filename].join("/");
        try {
            const local = await listLocalModels(category);
            const hit = local.find((m) => {
                const name = m.name.replace(/\\/g, "/");
                return isCategoryRoot
                    ? basename(name) === target.filename
                    : name === relWithinCategory;
            });
            if (hit)
                return hit.path;
        }
        catch {
            // Listing unavailable — fall through to download.
        }
    }
    return undefined;
}
function report(action, item, status, message) {
    // `item` is echoed back verbatim from the manifest, and a pip entry or a model
    // URL may legitimately carry credentials in its userinfo. Redacting only the
    // MESSAGE left the secret in the structured result — which is what actually
    // travels into transcripts and logs (codex, and my own test asserted the
    // narrower claim while believing the wider one).
    //
    // The entry stays identifiable: only the userinfo is masked, so the host and
    // path a reader matches on are untouched.
    //
    // `message` too, and this is the CHOKE POINT that closes the class rather than
    // the instances (codex r4). An ordinary, non-PEP-668 failure is rethrown raw by
    // design — that is the right call for diagnosis — and the caller reports
    // `err.message`, which Node builds as `Command failed: <whole argv>`. So every
    // pip failure that ISN'T the case this issue is about was still echoing the
    // spec. Redacting here covers those, the model and custom-node paths, and any
    // future one, instead of requiring each new call site to remember.
    return {
        action,
        item: redactUrlCredentials(item),
        status,
        message: redactUrlCredentials(message),
    };
}
/**
 * Derive ComfyUI-Manager install-model params from a manifest model entry for
 * REMOTE mode (no local filesystem). Honors `local_path` (its first segment is
 * the model dir, the rest is the relative save path) and `model_type`/`filename`
 * the same way the local resolver does, with the same anti-traversal guards.
 */
function remoteModelTarget(model) {
    const { targetSubfolder, filename } = manifestModelTarget(model);
    const category = targetSubfolder.split("/")[0];
    const { type, save_path } = managerModelDestination(category, targetSubfolder === category ? undefined : targetSubfolder);
    return { name: filename, type, save_path, filename, category };
}
async function installedNodesOrEmpty(managerBase) {
    try {
        return await listInstalledNodes({}, managerBase);
    }
    catch (err) {
        logger.warn("Could not list installed custom nodes before manifest apply", {
            error: err instanceof Error ? err.message : String(err),
        });
        return [];
    }
}
export async function applyManifest(opts) {
    // Capture the HTTP target and its monotonic generation before the first
    // await. Live root discovery below is asynchronous; if a panel retargets
    // while it runs, the install path must refuse rather than pair the old root
    // with a newly recaptured Manager target.
    const managerBase = getComfyUIBaseUrl();
    const targetGeneration = getComfyuiTargetGeneration();
    const manifest = await resolveManifest(opts);
    // Resolve the LOCAL ComfyUI data base this call should target, WITHOUT mutating the
    // process-global config.comfyuiPath (a global temp-set would leak the path to
    // every concurrent tab/tool and could be clobbered by an interleaved restore —
    // #490/#463 codex review). We thread it explicitly to applyManifestSections
    // instead. Precedence, local mode only:
    // Both roots are live-first so one apply_manifest cannot split mutations
    // across two unrelated installs when config and the connected server differ:
    //   dataBase — live --base-directory, then configuration, then main.py
    //              for ordinary local data/model availability.
    //   customNodesBase — the live custom_nodes scan root; unlike dataBase, this
    //              does not fall back to a saved default when COMFYUI_PATH is unset.
    //   codeBase — live main.py, then COMFYUI_CODE_PATH, then the data base.
    //              Pip/venv land here.
    // The actual on-disk model DESTINATION is always re-derived live-first by the
    // downloader (resolveModelsDir), so dataBase only governs data-filesystem
    // availability, never where a model lands.
    const localDataBase = await resolveLocalManifestBase();
    // A custom node is visible to ComfyUI under the running process's actual
    // scan root: its live --base-directory, or the live main.py checkout when no
    // base-directory is set. This must not fall back to a saved default workspace
    // when COMFYUI_PATH is unset (#463).
    const localCustomNodesBase = await resolveLocalManifestCustomNodesBase();
    const localCodeBase = isRemoteMode()
        ? undefined
        : (await resolveEffectiveComfyUICodeBaseLive()) ?? localDataBase;
    return applyManifestSections(manifest, {
        dataBase: localDataBase,
        customNodesBase: localCustomNodesBase,
        codeBase: localCodeBase,
        managerBase,
        targetGeneration,
    }, describeManifestSource(opts));
}
/**
 * The effective LOCAL ComfyUI base for an apply_manifest call. Never mutates
 * global config. Returns undefined in remote/cloud mode or when no local install
 * can be found (COMFYUI_PATH unset, no saved default, no reachable live server).
 */
async function resolveLocalManifestBase() {
    // Same precedence as node_pack: live --base-directory, then configuration,
    // then the main.py root when it looks like an install. Config-first here
    // used to let one apply_manifest write packs to COMFYUI_PATH while pip
    // targeted the live checkout of a different install.
    return resolveEffectiveComfyUIBaseLive();
}
function looksLikeGitManifestSource(id) {
    const candidate = id.trim();
    return /^(?:https?:\/\/|git@|git\+)/i.test(candidate) || /\.git(?:[?#]|$)/i.test(candidate);
}
function manifestOutcomeTargetGeneration(fallback) {
    const configured = Number(process.env.COMFYUI_MCP_TARGET_GENERATION);
    return Number.isSafeInteger(configured) && configured >= 0 ? configured : fallback;
}
async function resolveLocalManifestCustomNodesBase() {
    if (isRemoteMode())
        return undefined;
    try {
        // With no COMFYUI_PATH, a saved/default config root is only a local
        // workspace candidate. It is not an authorized clone target unless the
        // panel-connected local server provides live evidence for its scan root.
        return await resolveCustomNodesScanBaseLiveStrict({
            requireLive: !process.env.COMFYUI_PATH,
        });
    }
    catch {
        // An unavailable/ambiguous live root must not block the Manager attempt.
        // It only removes the optional local-clone route.
        return undefined;
    }
}
/**
 * Manager can report a successful-but-empty enqueue or an empty queue/status
 * response after a warm dialect cache. For apply_manifest, these are
 * deliberately tagged UNKNOWN by node-management: the request may already
 * have reached the host, so no caller may reissue it or authorize a local
 * clone. Keep that meaning intact when apply_manifest assembles its
 * structured per-item result.
 */
function isUnknownManagerInstallOutcome(err) {
    if (!err || typeof err !== "object")
        return false;
    const details = err.details;
    if (!details || typeof details !== "object")
        return false;
    const kind = details.kind;
    return (kind === "manager-enqueue-empty-success" ||
        kind === "manager-enqueue-empty-unverified" ||
        kind === "manager-queue-empty-status");
}
async function applyManifestSections(manifest, localRoots, source) {
    const results = [];
    // Per-section mode handling. Data and code filesystem availability are
    // independent: COMFYUI_PATH / live --base-directory name models/user/output
    // and custom_nodes, while COMFYUI_CODE_PATH (or the live main.py root) names .venv.
    // Neither local path is usable in remote mode. Keying off isRemoteMode()
    // (rather than mere path presence) matters because a remote target can coexist
    // with unrelated local paths on this machine — in that case we must still
    // route pip/model handling remotely instead of touching either local tree.
    // custom_nodes and models can still be handled remotely through ComfyUI-Manager's
    // HTTP API, but pip/apt have no remote equivalent.
    const { dataBase, customNodesBase, codeBase, managerBase, targetGeneration } = localRoots;
    const localMode = !isRemoteMode();
    const hasLocalDataFs = localMode && Boolean(dataBase);
    const hasLocalCodeFs = localMode && Boolean(codeBase);
    // Register the operation before the first install promise is created. The
    // local fallback can select synchronously, before apply_manifest reaches its
    // final aggregate record; its immutable binding keeps that early callback
    // attached to this operation and target until the final record is written.
    const partialOperation = createManifestPartialOperation({
        source,
        scope: process.env.COMFYUI_MCP_TAB?.trim() || `stdio:${process.pid}`,
        target: managerBase,
        targetGeneration: manifestOutcomeTargetGeneration(targetGeneration),
    });
    // The data root answers where custom_nodes and the ordinary models/ tree live,
    // but it is not the complete answer for model downloads. A connected local
    // ComfyUI may name its models directory independently with --models-directory,
    // or the live download resolver may derive it from the serving install while the
    // generic data-root resolver cannot. Reuse the same live-first route predicate
    // the download_model path uses before resolving a local target (#2089). Keep
    // this separate from dataBase: using the models directory as the custom-node
    // root would target a different filesystem contract.
    let hasLocalModelFs = hasLocalDataFs;
    let localModelResolutionError;
    // Any local session can be Manager-routed: an explicit --models-directory may
    // be unresolvable even while COMFYUI_PATH exists, and reconnects can change the
    // answer between calls. Capture the route BEFORE attempting local model
    // resolution: a retarget in between must not leave a stale local preflight
    // deciding the model branch. The override is threaded to startDownloadJob below
    // so its identity key and writer use this same call-scoped decision.
    let dispatchModelsToManager = isRemoteMode();
    const routeOverrideNeeded = localMode && manifest.models.length > 0;
    if (routeOverrideNeeded) {
        try {
            dispatchModelsToManager = await shouldDispatchDownloadToManager();
        }
        catch (err) {
            localModelResolutionError ??= err instanceof Error ? err.message : String(err);
        }
        if (!dispatchModelsToManager) {
            try {
                await resolveModelsDir();
                hasLocalModelFs = true;
            }
            catch (err) {
                localModelResolutionError ??= err instanceof Error ? err.message : String(err);
            }
        }
    }
    for (const pkg of manifest.apt) {
        results.push(report("apt", pkg, "skipped", hasLocalDataFs || hasLocalCodeFs
            ? "System packages must be installed manually or with root privileges; apply_manifest does not run apt."
            : "System packages are not supported against a remote ComfyUI (no local shell/root access); install them on the ComfyUI host."));
    }
    for (const pkg of manifest.pip) {
        if (!hasLocalCodeFs) {
            results.push(report("pip", pkg, "skipped", localMode
                ? "Python package install needs a local ComfyUI code root/venv. Set COMFYUI_CODE_PATH (or COMFYUI_PATH for a single-root install)."
                : "Python package install is not supported against a remote ComfyUI (no local filesystem/venv); install it on the ComfyUI host."));
            continue;
        }
        try {
            const resolved = await installPipPackage(pkg, codeBase);
            results.push(report("pip", pkg, "applied", `Python package installed. ${resolved.reason}`));
        }
        catch (err) {
            results.push(report("pip", pkg, "failed", err instanceof Error ? err.message : String(err)));
        }
    }
    // #489: bound the whole custom-node phase by a wall-clock budget. Each install
    // drives the shared ComfyUI-Manager queue, which can legitimately run for
    // minutes on a big pack; blocking until every one drains overruns the MCP
    // tools/call timeout (300s) and the caller sees a FALSE failure while the
    // Manager keeps installing. So we mirror the model-download grace pattern:
    // install sequentially, but RACE each install against the remaining budget. If
    // the budget wins, the install keeps running and its late result is swallowed;
    // before that result settles, only the already-fired local-fallback callback can
    // establish a local route. Otherwise the outcome is explicitly UNKNOWN and is
    // reported "pending" — never failed — and
    // every not-yet-started node is reported "pending" too.
    //
    // #1699: a "not started" pending is NOT on the Manager queue. Telling the
    // caller to poll panel_node_queue_status then produced a false complete
    // (drained queue) while those packs were never submitted. Those entries are
    // named as a PARTIAL INSTALL; the queue is only the in-flight ones. Re-run
    // apply_manifest to submit the rest. A node that SETTLES within budget is
    // verified and reported applied/failed exactly as before.
    const nodeDeadline = Date.now() + manifestNodeBudgetMs();
    const notStarted = [];
    const stillInstalling = [];
    const outcomeUnknown = [];
    let nodeBudgetSpent = false;
    // Even the INITIAL installed-list probe is budget-bounded — a hung Manager here
    // must not blow the tools/call timeout before we can report anything.
    const initialList = await raceDeadline(installedNodesOrEmpty(managerBase), nodeDeadline);
    const installedNodes = initialList === BUDGET_TIMEOUT ? [] : initialList;
    if (initialList === BUDGET_TIMEOUT)
        nodeBudgetSpent = true;
    for (const id of manifest.custom_nodes) {
        const normalizedId = id.trim();
        const isGitManifestSource = looksLikeGitManifestSource(normalizedId);
        if (getComfyuiTargetGeneration() !== targetGeneration) {
            results.push(report("custom_node", id, "failed", "ComfyUI's target changed while apply_manifest was checking installed nodes. " +
                "No install or local clone was attempted; retry after the target settles."));
            continue;
        }
        const isPanelTarget = targetsPanelPackExactly(normalizedId);
        if (isPanelTarget) {
            // `apply_manifest` has a Manager target but no authoritative association
            // between that target and a local served-panel root. In particular,
            // config.comfyuiPath may name loopback instance A while Manager targets B.
            // Refuse rather than pre/post-scan A around a mutation of B, or report a
            // Manager-installed-list entry as browser-visible success.
            results.push(report("custom_node", id, "failed", "apply_manifest does not manage the sidebar panel because it cannot prove that its " +
                "ComfyUI-Manager target and local served-panel filesystem are the same instance. " +
                "Use install_comfyui(action:'panel') on the selected ComfyUI host, which verifies the served version and .bak shadows."));
            continue;
        }
        if (nodeAlreadyInstalled(normalizedId, installedNodes)) {
            results.push(report("custom_node", id, "skipped", "Custom node is already installed."));
            continue;
        }
        if (nodeBudgetSpent || Date.now() >= nodeDeadline) {
            nodeBudgetSpent = true;
            notStarted.push(id);
            results.push(report("custom_node", id, "pending", formatNotStartedMessage(id)));
            continue;
        }
        // Never REJECTS: fold success/failure into a tagged value so the un-awaited
        // promise (when the deadline wins) can't surface as an unhandled rejection.
        // Thread the call-scoped DATA/base (no global config mutation) so the
        // git-clone / ref-checkout fallback writes into the tree the runtime scans
        // (#1715/#1770). Pip uses codeBase independently above.
        let localFallbackSelected = false;
        const fallbackBinding = partialOperation.bindItem(normalizedId);
        const installOutcome = installCustomNode({
            id: normalizedId,
            ...(customNodesBase ? { comfyuiPath: customNodesBase } : {}),
            ...(isGitManifestSource
                ? { localCloneFallback: "verified-only" }
                : {}),
            // Manager v4 can accept a git install with an empty 2xx body. Let the
            // install path explicitly start and track that queue, but keep the
            // apply_manifest budget boundary fail-closed: an empty-ack task with no
            // matching post-state may not authorize a late background clone.
            ...(isGitManifestSource
                ? {
                    allowEmptyV2Enqueue: true,
                    allowLocalFallbackAfterEmptyV2Enqueue: false,
                }
                : {}),
            managerBase,
            targetGeneration,
            localFallbackBinding: fallbackBinding,
            onLocalFallback: (binding) => {
                localFallbackSelected = partialOperation.reconcile(binding, "selected");
            },
            onLocalFallbackSettled: (binding, state) => {
                partialOperation.reconcile(binding, state);
            },
        })
            .then((res) => ({ kind: "settled", res }))
            .catch((err) => ({ kind: "error", err }));
        const outcome = await raceDeadline(installOutcome, nodeDeadline);
        if (outcome === BUDGET_TIMEOUT) {
            // Budget spent mid-install: leave it running, but distinguish a local
            // direct fallback only after its callback has actually fired. If a Git
            // install is still unresolved, its eventual Manager response may be
            // empty/null or otherwise fail closed; local mode/root/Git syntax alone
            // cannot authorize cloning. Both promises already have a .catch, so
            // their eventual settle is safely ignored.
            nodeBudgetSpent = true;
            if (localFallbackSelected) {
                results.push(report("custom_node", id, "pending", formatLocalFallbackMessage()));
            }
            else {
                stillInstalling.push(id);
                const outcomeIsUnknown = isGitManifestSource;
                if (outcomeIsUnknown)
                    outcomeUnknown.push(id);
                results.push(report("custom_node", id, "pending", formatStillInstallingMessage({ outcomeUnknown: outcomeIsUnknown })));
            }
            continue;
        }
        if (outcome.kind === "error") {
            const message = outcome.err instanceof Error ? outcome.err.message : String(outcome.err);
            if (isUnknownManagerInstallOutcome(outcome.err)) {
                // UNKNOWN is an unsettled, fail-closed result, not a hard failure. The
                // Manager may have accepted the task; preserve the no-reissue/no-clone
                // disclosure and keep it visible to panel_node_queue_status.
                stillInstalling.push(id);
                outcomeUnknown.push(id);
                results.push(report("custom_node", id, "pending", message));
                continue;
            }
            results.push(report("custom_node", id, "failed", message));
            continue;
        }
        if (outcome.res.mechanism === "git-clone") {
            // The fallback has already verified the clone's destination and pack
            // shape. Manager's installed-list endpoint cannot report an unregistered
            // direct clone, especially when Manager itself is absent (#463).
            results.push(report("custom_node", id, "applied", outcome.res.message));
            continue;
        }
        // ComfyUI-Manager marks a git-URL task "done" (queue drained) even when it
        // cloned NOTHING — e.g. a repo not in its registry resolves to nothing, no
        // dir is created, but the queue still empties cleanly. So a successful
        // installCustomNode() call is NOT proof of install. Re-query the installed
        // list (it reflects on-disk custom_nodes via Manager's
        // /v2/customnode/installed, so a freshly-cloned node shows up even before a
        // reboot) and confirm the node is actually present before reporting success.
        // Budget-bounded too: if the confirm can't complete in time we report pending
        // (conservative — the install itself already settled) rather than overrun.
        const verified = await raceDeadline(installedNodesOrEmpty(managerBase), nodeDeadline);
        if (verified === BUDGET_TIMEOUT) {
            nodeBudgetSpent = true;
            stillInstalling.push(id);
            results.push(report("custom_node", id, "pending", formatStillInstallingMessage()));
            continue;
        }
        if (getComfyuiTargetGeneration() !== targetGeneration) {
            results.push(report("custom_node", id, "failed", "ComfyUI's target changed while apply_manifest was verifying the install. " +
                "The result was not reported as applied; retry after the target settles."));
            continue;
        }
        if (nodeAlreadyInstalled(id, verified)) {
            installedNodes.length = 0;
            installedNodes.push(...verified);
            results.push(report("custom_node", id, "applied", outcome.res.message));
        }
        else {
            results.push(report("custom_node", id, "failed", `ComfyUI-Manager reported the install as queued/done, but the node is not present afterward — the source likely resolved to nothing (a git URL that isn't in the Manager registry won't clone). Install it directly (git clone into custom_nodes) or use a registry id. ${outcome.res.message}`));
        }
    }
    // #362: enqueue ALL local model downloads to the background job registry FIRST
    // (no per-model blocking), then await ONE short grace window across the whole
    // batch. A big pack has many multi-GB files; awaiting each sequentially — even
    // with a per-file grace — scales with the number of models and blows past the
    // 300s tools/call timeout. A single batch-wide grace is bounded regardless of
    // count: quick/local files finish inside it (reported "applied"), the rest keep
    // streaming and are reported "pending" with a job id to poll via
    // download_model action:"status". A still-running download is NEVER counted as "applied", so
    // top-level success never claims an unfinished download succeeded.
    const enqueued = [];
    for (const model of manifest.models) {
        const item = model.local_path ?? model.filename ?? model.url;
        try {
            if (!hasLocalModelFs && !dispatchModelsToManager && !isRemoteMode()) {
                results.push(report("model", item, "skipped", "Model install could not resolve a local models directory for the connected ComfyUI, and no Manager route was available. " +
                    (localModelResolutionError ??
                        "Set COMFYUI_PATH, save a default workspace, or connect to a ComfyUI whose models directory can be detected.")));
                continue;
            }
            if (isRemoteMode()) {
                // REMOTE: no local filesystem to scan/write. Route the download to the
                // ComfyUI host via ComfyUI-Manager's install-model task (server-side
                // fetch, returns at handoff). Cloud mode has no Manager, so report it as
                // unsupported before this branch.
                const { name, type, save_path, filename, category } = remoteModelTarget(model);
                const res = await installModelViaManager({
                    name,
                    url: model.url,
                    filename,
                    type,
                    save_path,
                    // Panel tray: watch the canonical category for the file to land (#143).
                    trayCategory: category,
                });
                // A Manager dispatch is ACCEPTED, not landed — Manager reports its queue task
                // done even on failure, and there is no local file to verify. Reporting that
                // as "applied" is the same fabricated success #369 is about, so it is PENDING
                // with the caveat spelled out (codex gate, round 2).
                results.push(report("model", item, "pending", `${res.message} NOTE: the dispatch was ACCEPTED, NOT verified as landed — ` +
                    "ComfyUI-Manager reports its queue task 'done' even on failure. Confirm with " +
                    "list_local_models before relying on it."));
                continue;
            }
            if (dispatchModelsToManager) {
                // A local target can still be Manager-routed when the live server is
                // reachable but its models directory cannot be safely resolved. Use the
                // background job path so the accepted-vs-landed verdict and progress
                // polling match download_model. `manifestModelTarget` supplies the
                // Manager identity without touching a local root.
                const { targetSubfolder, filename } = manifestModelTarget(model);
                const { job, settled } = await startDownloadJob(model.url, targetSubfolder, filename, undefined, undefined, true);
                enqueued.push({ item, job, settled });
                continue;
            }
            const target = await resolveLocalModelPath(model);
            const existing = await findExistingModel(target);
            // #1298 — set when a found file is PROVEN to be outside every tree the
            // live server reads. It is then irrelevant, and rides along as a note on
            // the download rather than vetoing it.
            let staleOutsideLiveRoots;
            if (existing) {
                // "Already exists" is only a legitimate SKIP if the running ComfyUI can
                // actually load it. On a locally-configured (non-live-resolved) models root
                // an old file in a STALE install would otherwise satisfy the manifest while
                // the live server has never seen it — the #369 failure, reached through the
                // existing-file shortcut instead of a download (codex gate, round 3).
                // Prefer the DECISIVE test when it is available: is the file we found
                // physically inside a tree the running server reads? A name-only listing
                // check cannot tell a stale `C:\Stale\...\x.safetensors` from the live
                // server's own `D:\Live\...\x.safetensors` (codex gate, round 5).
                // ONLY a positive containment result licenses the skip. A name-only listing
                // hit cannot tell a stale `C:\Stale\...\x.safetensors` from the live server's
                // OWN `D:\Live\...\x.safetensors`, so it may never promote an unconfirmed
                // answer to "installed" (codex gate, round 8) — it is reported as a
                // diagnostic on the pending item instead.
                const visible = (await isUnderLiveModelRoots(existing, target.targetSubfolder.split(/[\\/]+/).filter(Boolean)[0])).inRoots;
                // #1298 — a PROVEN-irrelevant file is not evidence about anything.
                //
                // #369 made this FAIL, which fixed a false SKIP: a stale same-named file
                // used to satisfy the manifest while the live server had never seen it,
                // and the render failed later. That was the right call about SKIPPING.
                // But failing is a third thing, and it vetoes a download that has
                // nothing wrong with it — the live models root resolved correctly, and a
                // leftover in an install the server does not read cannot make writing to
                // the right place unsafe. The old remedy even told the user to repoint
                // COMFYUI_PATH, which they cannot act on when it is already correct.
                //
                // So: fall through to the download, carrying the stale path as a NOTE.
                // #369's requirement is preserved — the item is satisfied only if the
                // DOWNLOAD succeeds, never by the mere existence of a file somewhere.
                if (visible === false) {
                    staleOutsideLiveRoots = existing;
                }
                else if (visible === undefined) {
                    // #1587 — containment could not answer, and about THIS question it never
                    // could. `isUnderLiveModelRoots` compares `existing` against a models root
                    // the running server did not name (`modelsDirNamedByServer`); it never
                    // consults the server's own listing. Deriving `pending` from that silence
                    // overruled the one source that CAN answer — /models — with a source
                    // structurally blind to it, and then said so in a message that told the
                    // user their install root "could only be inferred from local
                    // configuration". That is not what `undefined` means here: an
                    // OS-process-observed root (ComfyUI Desktop's shape, where the interpreter
                    // is identified from the process table serving our port) lands in exactly
                    // this bucket, and the file was often FOUND by the server's own listing in
                    // the first place (findExistingModel step 3).
                    //
                    // So ask what the manifest actually needs to know — does the running server
                    // already have this model? — and let the server answer it. Precision
                    // mirrors findExistingModel: a category-root target is satisfied by the
                    // basename anywhere in the category, a nested target by the exact
                    // category-relative path.
                    //
                    // This does NOT weaken #369. #369's failure is a stale same-named file
                    // satisfying the manifest while the live server has never seen it — there
                    // the server does NOT list the entry, so this branch still reports pending
                    // and the download still runs. What is dropped is only the claim that the
                    // local copy is the served one, which the note now STATES instead of the
                    // verdict asserting its opposite.
                    const nested = target.targetSubfolder.split(/[\\/]+/).filter(Boolean).length > 1;
                    const listed = nested
                        ? await liveListingHasEntry(target.targetSubfolder, target.filename)
                        : await liveListingHasBasename(target.targetSubfolder, target.filename);
                    results.push(listed === true
                        ? report("model", item, "skipped", `The connected ComfyUI already serves "${target.filename}" in ` +
                            `"${target.targetSubfolder}", so the manifest's model is present for it. ` +
                            "NOTE: containment could not be established — the running server did not " +
                            `name its models root — so it is NOT confirmed that the copy at ${existing} ` +
                            "is the one it serves.")
                        : report("model", item, "pending", `A file exists at ${existing}, but it could NOT be established that the ` +
                            "connected ComfyUI reads models from there — the running server did not " +
                            "name its models root, so the root had to be inferred — and " +
                            (listed === false
                                ? `the server does NOT list "${target.filename}" under "${target.targetSubfolder}".`
                                : `the server could not be asked whether it lists "${target.filename}" under "${target.targetSubfolder}".`) +
                            " Check list_local_models."));
                    continue;
                }
                // Containment can still be satisfied by a HOST path that merely shares its
                // name with the server's — a container reporting `--models-directory /models`
                // while this host has its own unrelated `/models` (codex gate, round 15). So
                // a SKIP additionally requires the running server to really serve a file of
                // that name in the category. Matched by BASENAME, because findExistingModel
                // accepts a nested hit within the served category and an exact-path check
                // would fail legitimate installs.
                const servedByLive = await liveListingHasBasename(target.targetSubfolder, target.filename);
                // NOTE: `servedByLive` above is deliberately computed and unused on the
                // stale path. Keeping the call costs one cheap listing GET per stale item
                // and avoids churn on this branch; it is NOT load-bearing here, and it
                // reads as though it feeds the guard below, which it does not.
                if (!staleOutsideLiveRoots) {
                    results.push(servedByLive === true
                        ? report("model", item, "skipped", `Model already exists at ${existing}.`)
                        : servedByLive === false
                            ? report("model", item, "failed", `A file exists at ${existing}, but the connected ComfyUI does not list ` +
                                `"${target.filename}" under "${target.targetSubfolder}" at all — the models ` +
                                "directory it reported is not the one this file is in (this happens when " +
                                "ComfyUI runs in a container or behind a port-forward and its path also " +
                                "exists on this host). Confirm with list_local_models.")
                            : report("model", item, "pending", `A file exists at ${existing}, but the connected ComfyUI could not be asked ` +
                                `whether it serves "${target.filename}", so this is not confirmed as ` +
                                "installed. Check list_local_models."));
                    continue;
                }
            }
            const { job, settled } = await startDownloadJob(model.url, target.targetSubfolder, target.filename, undefined, undefined, routeOverrideNeeded ? dispatchModelsToManager : undefined);
            enqueued.push({ item, job, settled, staleOutsideLiveRoots });
        }
        catch (err) {
            results.push(report("model", item, "failed", err instanceof Error ? err.message : String(err)));
        }
    }
    if (enqueued.length > 0) {
        // Single bounded grace over the whole batch (settled never rejects — the job
        // registry captures errors onto job.status). allSettled resolves early if
        // every download finishes fast; otherwise the timer caps the wait.
        let graceTimer;
        await Promise.race([
            Promise.allSettled(enqueued.map((e) => e.settled)),
            new Promise((r) => {
                graceTimer = setTimeout(r, manifestDownloadGraceMs());
            }),
        ]);
        if (graceTimer)
            clearTimeout(graceTimer);
        // ONE resolution for the batch: a verdict made against a DIFFERENT ComfyUI than
        // the one connected now must not be re-asserted as an apply (#369).
        const liveRootNow = await currentLiveModelsRoot();
        for (const { item, job, staleOutsideLiveRoots } of enqueued) {
            // #1298 — the stale-copy note belongs on EVERY outcome, not just the happy
            // one. Review measured it rendering on 1 of 6: a download slower than the
            // 15s grace reports `pending` and the path was lost permanently, because
            // it lives only in this local array and the `download_model status` poll
            // the user is told to run cannot see it. That is the reporter's own case.
            // Worst was `wrongPlace`: two same-named copies on disk and neither
            // message naming the other.
            const staleNote = staleOutsideLiveRoots
                ? ` (Note: a same-named file also exists at ${staleOutsideLiveRoots}, which was NOT in ` +
                    "any directory the connected ComfyUI reported reading when this item was checked — it " +
                    "belongs to a different install and was ignored.)"
                : "";
            if (job.status === "error") {
                results.push(report("model", item, "failed", (job.error ?? "Model download failed.") + staleNote));
            }
            else if (job.status === "done") {
                // "applied" must mean the running ComfyUI can actually load it (#369). Same
                // shared placement policy as download_model: an unconfirmed or demonstrably
                // wrong placement is reported as such, never as a clean apply.
                const placement = describePlacement(job, { liveModelsDir: liveRootNow });
                results.push(
                // "applied" must mean the running ComfyUI can actually load it (#369) —
                // that is the whole failure, just wearing a manifest's clothes. A
                // demonstrably unreadable placement is a FAILURE; an unconfirmed one is
                // PENDING (the transfer finished, the placement has not been established).
                placement.confirmed
                    ? report("model", item, "applied", `Model downloaded to ${job.path}${placement.pathQualifier}.` + staleNote)
                    : placement.wrongPlace
                        ? report("model", item, "failed", `Model ${placement.pathLabel} ${job.path}, but it is NOT usable by the connected ComfyUI: ${placement.warning}` +
                            staleNote)
                        : report("model", item, "pending", `Model ${placement.pathLabel} ${job.path}. ${placement.warning}` + staleNote));
            }
            else {
                results.push(report("model", item, "pending", `Model download is RUNNING in the background (job ${job.id}) — NOT yet complete, ` +
                    `and NOT a failure. Poll download_model action:"status" with this id; the file lands on its own. ` +
                    `Do not re-issue the download.` + staleNote));
            }
        }
    }
    const summary = {
        applied: 0,
        skipped: 0,
        failed: 0,
        pending: 0,
    };
    for (const result of results)
        summary[result.status]++;
    // #1699 — name the PARTIAL INSTALL (unsubmitted custom_nodes) so a drained
    // Manager queue cannot be read as completion. Replacing the leftover record
    // on every apply keeps queue-status honest about THIS call only.
    const partial = buildManifestPartial({
        source,
        notStarted,
        stillInstalling,
        outcomeUnknown,
    });
    const recordedPartial = partialOperation.record(partial);
    return {
        // `success` means the manifest is APPLIED AND VERIFIED — nothing failed AND
        // nothing is still unsettled. A pending item is a real "not done": a transfer
        // still running, a ComfyUI-Manager dispatch that was accepted but not verified
        // as landed, or an existing file whose placement could not be established. All
        // three used to fold into a `true` here, which is the same fabricate-success
        // shape #369 is about, one level up (codex gate, round 15). Callers that only
        // care about hard failures read `summary.failed`; the per-item results say
        // exactly what is outstanding and how to confirm it. Unsubmitted custom_nodes
        // are also named on `partial` so the outstanding work cannot hide in the
        // per-item array (#1699).
        success: summary.failed === 0 && summary.pending === 0,
        summary,
        results,
        ...(recordedPartial ? { partial: recordedPartial } : {}),
    };
}
//# sourceMappingURL=manifest.js.map