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),
612 lines • 37.5 kB
JavaScript
/**
* #1616 — REFUSE THE AMBIGUOUS FROM-SOURCE INSTALL INSTEAD OF PICKING A REPOSITORY.
*
* #1611 measured the hazard and DISCLOSED it: ComfyUI-Manager v4 resolves a from-source
* ("nightly") install by the pack's BARE REPO NAME against one channel's list, then clones
* the URL recorded in THAT entry — the `repository` the caller passed is stored in the task
* params and never read on that path. So a caller who names one author's repo can get
* another author's, and the install reports success. Read off Manager 4.2.2's own source:
*
* load_nightly: res[y.split('/')[-1]] = (x, False) # key = bare repo name
* get_custom_nodes: NormalizedKeyDict, get() -> key.strip().lower()
* install_by_id: repo_url = the_node['repository'] # the CHANNEL's URL
*
* Disclosure was the right call for #1611 (it was scoped to the channel bug) but it leaves
* the tool picking silently between two candidate repositories — the exact thing the
* standing rule says to refuse. This module supplies the pre-dispatch refusal.
*
* WHAT WAS MEASURED, 2026-08-15, against all six published channel lists. #1616 reported
* 65 colliding bare names (35 between `default` and `dev`). That count is CASE-SENSITIVE
* and therefore low: `get_custom_nodes` stores into a `NormalizedKeyDict` whose `get`
* normalizes `key.strip().lower()`, so `comfyui_tagger` and `ComfyUI_tagger` are one key.
* Counting the way the lookup counts gives **111** ambiguous names — and surfaces a class
* #1616 does not mention at all: **28** names are ambiguous WITHIN a single channel. Two
* entries under one name collapse onto one key — `load_nightly` overwrites on an
* exact-case duplicate, `NormalizedKeyDict` on a case-variant one — and the survivor is
* decided by the order of an upstream JSON file. Those need no second channel to hand back
* the wrong repository: `default` alone lists both `1038lab/ComfyUI-Pollinations` and
* `ciga2011/ComfyUI-Pollinations`.
*
* WHAT THE GUARD CANNOT SEE, stated rather than papered over:
*
* * WHICH MANAGER GENERATION IS ON THE OTHER END. Bare-name resolution is a v4 property.
* The 3.x dialects send `files:[url]` and clone the URL as passed, so for them this
* refusal is a false positive — and it still fires, because the PANEL chooses the
* dialect from what it detected on its own ComfyUI, after the request leaves here.
* The orchestrator's dialect cache is keyed to the base URL IT talks to, which need not
* be the ComfyUI the panel drives, so consulting it would gate one machine's install on
* another machine's Manager. A 3.x caller clears this with the same explicit `channel`
* everyone else uses, and the refusal text says so.
*
* * CNR RE-KEYING, WHICH ALSO DEFEATS THE EXEMPTION BELOW — the one limit here that is
* a WRONG ALLOW rather than an over-refusal, so it is stated first and plainly.
* `get_custom_nodes` does not key a channel entry by its bare name when that entry's
* repo is registered in the Comfy Registry; it keys it by the CNR id:
*
* cnr = self.get_cnr_by_repo(v['files'][0])
* if cnr: v['id'] = cnr['id']; node_id = v['id'] # <- the CNR ID, not the name
* else: node_id = v['files'][0].split('/')[-1] # <- the bare name
* res[node_id] = v
*
* So when the repo a channel lists under a name is registered under a DIFFERENT id,
* that entry is not reachable by the bare name at all: the lookup misses and falls
* through to the CNR fallback below. `bareNameAmbiguity` exempts a call when the
* channel carries exactly the caller's repo — and for a re-keyed entry that exemption
* is unsound. Verified end to end against the live registry, 2026-08-15:
*
* caller: https://github.com/wildminder/ComfyUI-Chatterbox (`default` carries it,
* so this is exempted here and dispatches)
* but: that repo is registered as id "ComfyUI-ChatterboxTTS", so the sent
* "ComfyUI-Chatterbox" misses, and cnr_map["comfyui-chatterbox"] is
* sm079/comfyui-chatterbox — which is what gets cloned.
*
* An earlier version of this comment claimed that could never happen, and then that it
* was unfixable here. Both were wrong: the re-keying is MEASURABLE, and `REKEYED_REPOS`
* now records exactly which candidates a bare-name lookup cannot reach, so the
* exemption is applied to what the lookup can actually return rather than to what the
* list appears to say. `REGISTRY_TARGETS` records what `cnr_map[name]` then clones, so
* the refusal names that repository outright instead of guessing.
*
* NOW ALSO COVERED WITHOUT A CHANNEL COLLISION (#1624). The paragraph above used to
* end "still not closed in general": this table is keyed by CONTESTED names, and the
* hazard needs no contest — a pack whose entry is re-keyed and whose bare name answers
* to a different registry id is exposed on its own. That class is enumerated now, in
* `REKEYED_SUBSTITUTIONS` and `rekeyedRepoAmbiguity` below: 98 repositories, 56 of them
* invisible to this table. What remains uncovered is stated there.
*
* * A pip-installed Manager. `get_data_by_mode` reads the cache file for the configured
* channel URL, else the snapshot BUNDLED in the package — a stale, default-flavoured
* list — so on that host the list consulted is not the one measured here. The bundled
* list is default-flavoured, which is the channel this guard is most accurate about.
* * Staleness. The table is a snapshot. A collision added after it was measured is simply
* not caught (today's behaviour), and an entry that has since been corrected upstream
* causes an unnecessary refusal — which the caller clears by naming a channel, since
* the refusal only fires on the channel THIS code defaulted to.
*
* GATE ROUND 6 — A CHANNEL MISS IS NOT A DEAD END, AND TREATING IT AS ONE WAS A HOLE.
* This module's first shape returned "no ambiguity" whenever the asked channel carried
* nothing under the name, on the reasoning that Manager then answers "not found" and no
* substitution can occur. That reasoning is wrong, and 4.2.2's own source says so —
* `install_by_id`, on the `nightly` branch, when the channel lookup misses:
*
* cnr_fallback = self.cnr_map.get(node_id) # NormalizedKeyDict, lowercased
* if cnr_fallback is not None and cnr_fallback.get('repository'):
* repo_url = cnr_fallback['repository'] # <- CLONES THE REGISTRY'S REPO
* else:
* return result.fail(f"Node '{node_id}@{version_spec}' not found in ...")
*
* So a miss falls back to the COMFY REGISTRY entry whose id is the bare name, and clones
* whatever repository that id is registered to — the caller's `repository` is not read
* there either. "Not found" is only what happens when the registry ALSO lacks the id.
* Measured against this snapshot: 18 of the 111 contested names are absent from `default`,
* so on the channel this code defaults to they reached that fallback with nothing checked.
* A miss on a contested name is therefore an ambiguity in its own right — resolved by
* registry id rather than by the channel — and is refused with that named as the reason.
*/
import { AMBIGUOUS_BARE_NAMES, AMBIGUOUS_BARE_NAMES_MEASURED, MANAGER_CHANNEL_NAMES, REGISTRY_TARGETS, REKEYED_REPOS, REKEYED_SUBSTITUTIONS, } from "./manager-bare-name-data.js";
export { AMBIGUOUS_BARE_NAMES, AMBIGUOUS_BARE_NAMES_MEASURED, MANAGER_CHANNEL_NAMES, REGISTRY_TARGETS, REKEYED_REPOS, REKEYED_SUBSTITUTIONS, };
/**
* The pack name Manager will resolve by — EXACTLY the panel's `gitRepoName`
* (web/js/lib/manager-install.js), because that is the value the panel puts in `id` and
* therefore the value `install_by_id` looks up. Deriving it any other way here would check
* a different string than the one that gets sent.
*/
export function managerBareName(url) {
const pathPart = url.includes(":") && !url.includes("://") ? url.slice(url.lastIndexOf(":") + 1) : url;
const clean = pathPart.replace(/[?#].*$/, "").replace(/\/+$/, "");
const base = clean.slice(clean.lastIndexOf("/") + 1);
return base.replace(/\.git$/i, "");
}
/**
* `owner/repo` from a git URL, or undefined when the URL does not carry one.
*
* The HOST is never the owner. A last-two-path-segments regex reads
* `https://github.com/ComfyUI-BiRefNet` as `github.com/ComfyUI-BiRefNet`, which then
* matches no candidate and gets quoted back to the caller as a repository they never
* named. An owner-less URL has to come back `undefined` so the refusal says "the URL you
* passed" instead of inventing one.
*/
export function ownerRepoOf(url) {
const noGit = url.trim().replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
let path;
const scp = /^(?:[^/@]+@)?[^/:]+:(?!\/\/)(.+)$/.exec(noGit); // git@host:owner/repo
if (scp) {
path = scp[1];
}
else {
const proto = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(.*)$/.exec(noGit);
if (proto) {
const afterHost = proto[1].replace(/^[^/]*\//, "");
if (afterHost === proto[1])
return undefined; // host with no path
path = afterHost;
}
else {
path = noGit;
}
}
const segs = path.split("/").filter(Boolean);
if (segs.length < 2)
return undefined;
return `${segs[segs.length - 2]}/${segs[segs.length - 1]}`;
}
/**
* The host a git URL points at, lowercased, or undefined when it names none — a bare
* `author/repo` shorthand, which the panel expands to github.com.
*/
export function gitHostOf(url) {
const trimmed = url.trim();
const scp = /^(?:[^/@]+@)?([^/:]+):(?!\/\/)/.exec(trimmed);
if (scp)
return scp[1].toLowerCase();
const proto = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)/.exec(trimmed);
return proto ? proto[1].toLowerCase() : undefined;
}
const sameRepo = (a, b) => a.toLowerCase() === b.toLowerCase();
/**
* Read a key that the object OWNS, or `undefined`.
*
* Both lookups below are indexed by a caller-supplied string — the bare name off their URL
* and the `channel` they passed — against object literals, which inherit from
* `Object.prototype`. A plain `obj[key]` therefore ANSWERS for `__proto__`, `constructor`
* and `toString`: `entry["__proto__"]` hands back `Object.prototype`, whose `.length` is
* `undefined`, so neither the falsy check nor the `length === 0` check stops it and the
* inherited object rides all the way into `listRepos`, where `.map` is not a function and
* the tool throws instead of answering. `channel:"constructor"` is worse — `Object.length`
* is 1, so it reaches `sameRepo(undefined, …)`. Not a trust boundary (this is one machine),
* just a name nobody's table contains being treated as a hit. An own-property check is the
* whole fix, and it keeps the promise the unknown-channel path already makes: a channel
* this table has never heard of is NOT checked, rather than throwing.
*/
function ownCandidates(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
}
/**
* Is this URL comparable to a snapshot candidate at all? The snapshot holds github.com
* repositories only — `load_nightly` reaches an entry through a `github.com` file — so an
* `owner/repo` that came off ANOTHER host is a different repository that merely shares a
* path. `https://gitlab.com/0dot77/comfyui-annotations` must not read as a match for
* `github.com/0dot77/comfyui-annotations`: v4 would still resolve the bare name against
* the channel and clone the GitHub one, which is the substitution this guard exists for.
*/
function comparableToSnapshot(url) {
const host = gitHostOf(url);
return host === undefined || host === "github.com" || host === "www.github.com";
}
/** The channels `normalize_channel` accepts — anything else never reaches a list. */
const KNOWN_CHANNELS = new Set(MANAGER_CHANNEL_NAMES);
/**
* Would asking `channel` for this URL's bare name resolve to a repository other than the
* one the caller named? Returns the candidates when so, `undefined` when the name is not
* known to collide, when the channel is not one Manager accepts, or when the channel lists
* exactly the caller's repo and nothing else under it.
*
* A channel that does NOT list the name is an ambiguity rather than an exemption — v4's
* `nightly` path falls back to the Comfy Registry id (gate round 6, quoted in the header),
* so the caller's `repository` goes unread there just the same.
*
* Absence FROM THE SNAPSHOT is NOT proof of uniqueness — an unlisted name is one this
* snapshot has not seen collide, which is precisely today's behaviour for it.
*/
export function bareNameAmbiguity(url, channel) {
const bare = managerBareName(url);
const entry = ownCandidates(AMBIGUOUS_BARE_NAMES, bare.trim().toLowerCase());
if (!entry)
return undefined;
const asked = channel.trim().toLowerCase();
// A channel name Manager does not accept is not reasoned about at all: `normalize_channel`
// raises InvalidChannel and no list is ever consulted, so there is no substitution to
// warn about and no CNR fallback to reach. This is also what keeps an INHERITED key
// (`__proto__`, `constructor`) from being read as a miss and refused on that basis.
if (!KNOWN_CHANNELS.has(asked))
return undefined;
const nameKey = bare.trim().toLowerCase();
// GATE ROUND 8 — WHAT THE CHANNEL LISTS IS NOT WHAT THE LOOKUP CAN REACH. A repo that
// is registered in the Comfy Registry under some other id is keyed by that id, so the
// bare name cannot return it however plainly the list "carries" it.
const rekeyed = REKEYED_REPOS[nameKey] ?? [];
const reachableIn = (cands) => (cands ?? []).filter((r) => !rekeyed.some((k) => k === r));
const listed = ownCandidates(entry, asked) ?? [];
const channelCandidates = reachableIn(listed);
const unreachableCandidates = listed.filter((r) => !channelCandidates.includes(r));
const callerRepo = comparableToSnapshot(url) ? ownerRepoOf(url) : undefined;
if (channelCandidates.length === 1 && callerRepo && sameRepo(channelCandidates[0], callerRepo)) {
// EXEMPT, and now soundly: the one entry the lookup can reach under this name IS the
// caller's repo, so there is nothing to pick between and nothing to fall back to.
return undefined;
}
const channelsResolvingToCaller = callerRepo
? MANAGER_CHANNEL_NAMES.filter((c) => {
const cands = reachableIn(ownCandidates(entry, c));
return cands.length === 1 && sameRepo(cands[0], callerRepo);
})
: [];
const registryTarget = REGISTRY_TARGETS[nameKey];
const allCandidates = [
...new Set([
...MANAGER_CHANNEL_NAMES.flatMap((c) => reachableIn(ownCandidates(entry, c))),
...(registryTarget ? [registryTarget] : []),
]),
];
return {
bare,
callerUrl: url.trim(),
callerRepo,
channel,
channelCandidates,
unreachableCandidates,
registryTarget,
channelsResolvingToCaller,
resolvedVia: channelCandidates.length === 0 ? "cnr-fallback" : "channel",
allCandidates,
};
}
/** The caller's repo, looked up the way the table is keyed, or undefined. */
function rekeyedRecordFor(url) {
// An `owner/repo` that came off another host is a DIFFERENT repository that merely
// shares a path, exactly as `bareNameAmbiguity` reasons — matching it here would refuse
// a gitlab URL by quoting a github repository the caller never named.
if (!comparableToSnapshot(url))
return undefined;
const repo = ownerRepoOf(url);
if (!repo)
return undefined;
const rec = ownCandidates(REKEYED_SUBSTITUTIONS, repo.toLowerCase());
return rec ? { repo, rec } : undefined;
}
/**
* Would a from-source install of this URL land on a repository other than the caller's,
* because theirs is filed under a Comfy Registry id rather than under its own name?
*
* `undefined` when the repository is not one the snapshot measured as re-keyed, when the
* channel is not one Manager accepts, or when the name resolves to NOTHING on that channel
* — the last being Manager's "not found", which substitutes nobody's code and is a
* different complaint than this one.
*/
export function rekeyedRepoAmbiguity(url, channel) {
const found = rekeyedRecordFor(url);
if (!found)
return undefined;
const asked = channel.trim().toLowerCase();
// Same reasoning as `bareNameAmbiguity`: `normalize_channel` raises InvalidChannel on a
// name Manager does not accept, so no list is consulted and there is no CNR fallback to
// reach. This is also what keeps an INHERITED key from being read as a miss.
if (!KNOWN_CHANNELS.has(asked))
return undefined;
const { repo, rec } = found;
const channelCandidates = ownCandidates(rec.channelTargets, asked) ?? [];
if (channelCandidates.length === 0 && rec.registryTarget === undefined)
return undefined;
return {
bare: managerBareName(url),
callerUrl: url.trim(),
callerRepo: repo,
registeredRepo: rec.repo,
registryId: rec.id,
channel,
channelCandidates,
registryTarget: rec.registryTarget,
resolvedVia: channelCandidates.length === 0 ? "cnr-fallback" : "channel",
};
}
/**
* The one sentence both messages below are built on, kept in one place so the refusal and
* the warning cannot drift into saying different things about the same repository.
*/
function rekeyedMechanism(a) {
return (`ComfyUI-Manager v4 does not look a from-source install up by the URL you pass — it ` +
`files every channel entry under the pack's Comfy Registry id when the repo is ` +
`registered, and under the bare repo name only when it is not (get_custom_nodes: ` +
`\`cnr = get_cnr_by_repo(files[0]); if cnr: node_id = v['id']\`). ` +
`https://github.com/${a.registeredRepo} is registered as "${a.registryId}", so it is ` +
`filed under THAT id and a lookup for "${a.bare}" cannot return it — on any channel, ` +
`however plainly a channel's list carries it.`);
}
/** What actually lands instead, for either message. */
function rekeyedLanding(a) {
return a.resolvedVia === "channel"
? `The "${a.channel}" channel's list DOES answer to "${a.bare}" — with ` +
`${listRepos(a.channelCandidates)} — so that is what would be cloned`
: `Nothing in the "${a.channel}" channel's list answers to "${a.bare}", which is not ` +
`the end of it: on a "nightly" spec v4 falls back to the registry map ` +
`(install_by_id → cnr_map) and clones what the id "${a.bare}" is registered to, ` +
`which is https://github.com/${a.registryTarget}`;
}
/**
* The pre-dispatch refusal. Unlike `ambiguousBareNameRefusal` there is no channel that
* resolves this name to the caller's repository — not "none in the snapshot", but none
* that can exist, because the id it is filed under is not its name — so the remedy is the
* verbatim-clone route and the message says only that.
*/
export function rekeyedRepoRefusal(a) {
return (`REFUSED before dispatch — this install would have fetched a repository other than ` +
`the one you named. ${rekeyedMechanism(a)} You asked for ` +
`https://github.com/${a.callerRepo}. ${rekeyedLanding(a)}, and the install would have ` +
`reported success. Not picked for you: no \`channel\` argument can rescue this one — ` +
`changing the list consulted cannot make your repository reachable by a name it is ` +
`not filed under — so use install_custom_node (source:"git"), which clones the URL ` +
`you pass verbatim instead of resolving a name. ` +
`(This check ran because no \`channel\` was named, against a snapshot of the Comfy ` +
`Registry and the six published channel lists measured ` +
`${AMBIGUOUS_BARE_NAMES_MEASURED}; naming a \`channel\` explicitly does still bypass ` +
`it, but it cannot aim the install — the one reason to take that bypass is a ComfyUI ` +
`running Manager 3.x, where the from-source request carries the URL in \`files\` and ` +
`IS cloned as passed, and this check cannot tell the generations apart because the ` +
`panel picks the dialect after the request leaves here.)`);
}
/**
* The same finding for a caller who DID name the channel. It dispatches — their choice,
* and on 3.x the URL is what gets cloned — but it must not repeat `ambiguousBareNameWarning`'s
* "you chose the channel" framing, because here the channel choice decided nothing at all.
*/
export function rekeyedRepoWarning(a) {
return (`RE-KEYED PACK: you asked for https://github.com/${a.callerRepo}, and naming a ` +
`channel did not aim this install. ${rekeyedMechanism(a)} ${rekeyedLanding(a)}. ` +
`Dispatched because you named a channel, and because on Manager 3.x the URL you ` +
`passed IS what gets cloned; if this is v4 and you need the repository you typed, ` +
`install_custom_node (source:"git") clones it directly instead of resolving a name. ` +
`Verify with panel_list_nodes before you restart or report success. ` +
`(Snapshot measured ${AMBIGUOUS_BARE_NAMES_MEASURED}.)`);
}
/**
* Does this version spec put the install on the FROM-SOURCE path — the one where Manager
* reads a channel's list at all? `install_by_id` consults `get_custom_nodes` only for
* "unknown"/"nightly" and otherwise goes straight to `cnr_install` (quoted in full above
* `RegistryVersionAmbiguity`).
*
* One predicate for every caller, because the two things it decides are opposites: the
* channel-based messages are only TRUE here, and the registry-id ones are only true where
* this is false. Two copies that drifted would not fail loudly — each side would simply
* describe the wrong route to somebody.
*
* An absent version counts as from-source: `normalizeGitUrlInstallArgs` sends "nightly"
* for it.
*/
export function isFromSourceSpec(version) {
const spec = version?.trim().toLowerCase() ?? "";
return spec === "nightly" || spec === "unknown" || spec.length === 0;
}
export function rekeyedRegistryVersionAmbiguity(url, version) {
if (isFromSourceSpec(version))
return undefined;
const found = rekeyedRecordFor(url);
if (!found?.rec.registryTarget)
return undefined;
return {
bare: managerBareName(url),
callerUrl: url.trim(),
callerRepo: found.repo,
registeredRepo: found.rec.repo,
registryId: found.rec.id,
version: version.trim(),
registryTarget: found.rec.registryTarget,
};
}
/**
* The refusal for that route. Dropping `version` is NOT the remedy here — unlike
* `registryVersionRefusal`, where the from-source path does honour the channel's entry —
* because the from-source path cannot reach this repository by name either.
*/
export function rekeyedRegistryVersionRefusal(a) {
return (`REFUSED before dispatch — the version you passed takes this install off the ` +
`from-source path, and on the path it lands on your \`repository\` is not read at ` +
`all. ComfyUI-Manager only consults a channel's list for version "nightly" or ` +
`"unknown"; with an explicit version ("${a.version}") it installs the Comfy Registry ` +
`pack whose ID is the bare repo name — here "${a.bare}" — which is registered to ` +
`https://github.com/${a.registryTarget}, not to you. You asked for ` +
`https://github.com/${a.callerRepo}, and that repository is registered under the id ` +
`"${a.registryId}" instead, so the name you sent belongs to somebody else's pack. ` +
`Not picked for you: dropping \`version\` does NOT rescue this one — the from-source ` +
`route resolves the same name and cannot reach a repository filed under a different ` +
`id — so use install_custom_node (source:"git"), which clones the URL you pass ` +
`verbatim. If you specifically want the registry pack, pass its id as \`id\` instead ` +
`of a URL, so what you are asking for is the thing that gets installed. ` +
`(Snapshot measured ${AMBIGUOUS_BARE_NAMES_MEASURED}.)`);
}
/**
* Is this a registry-version install of a name measured to be contested? `version` is the
* value that will actually be sent. `nightly` and `unknown` are the from-source specs that
* DO consult the channel — those belong to `bareNameAmbiguity`, not here.
*/
export function registryVersionAmbiguity(url, version) {
if (isFromSourceSpec(version))
return undefined;
const bare = managerBareName(url);
const entry = ownCandidates(AMBIGUOUS_BARE_NAMES, bare.trim().toLowerCase());
if (!entry)
return undefined;
const candidates = [
...new Set(MANAGER_CHANNEL_NAMES.flatMap((c) => ownCandidates(entry, c) ?? [])),
];
if (candidates.length === 0)
return undefined;
return {
bare,
callerUrl: url.trim(),
callerRepo: comparableToSnapshot(url) ? ownerRepoOf(url) : undefined,
version: version.trim(),
candidates,
};
}
/**
* The refusal for that case. The remedy is not a channel — a channel changes nothing on
* this route — it is DROPPING the version, which puts the call back on the from-source
* path where the channel does decide and where this guard can actually reason about it.
*/
export function registryVersionRefusal(a) {
const asked = a.callerRepo ? `https://github.com/${a.callerRepo}` : `"${a.callerUrl}"`;
return (`REFUSED before dispatch — the version you passed takes this install off the ` +
`from-source path entirely, and on the path it lands on your \`repository\` is not ` +
`read at all. ComfyUI-Manager only consults a channel's list for version "nightly" ` +
`or "unknown"; with an explicit version ("${a.version}") it installs the Comfy ` +
`Registry pack whose ID is the bare repo name — here "${a.bare}" — and that registry ` +
`entry belongs to whoever published the id, not to you. You asked for ${asked}, and ` +
`"${a.bare}" is one of the names measured to be claimed by more than one repository ` +
`(${listRepos(a.candidates)}), so the id may well not resolve to yours. ` +
`Not picked for you: drop \`version\` (or pass "nightly") and this becomes the ` +
`from-source install that DOES honour the channel's entry for that name — which is ` +
`the only route where naming a repository means anything. If you specifically want ` +
`the registry pack, pass its id as \`id\` instead of a URL, so what you are asking ` +
`for is the thing that gets installed. Naming a \`channel\` does NOT clear this one — ` +
`Manager never reads a channel on this route, so there is no channel argument that ` +
`could make it fetch what you named. (Snapshot measured ` +
`${AMBIGUOUS_BARE_NAMES_MEASURED}.)`);
}
/** "X, Y and Z" — the candidates are NAMED, never reduced to a count. */
function listRepos(repos) {
const urls = repos.map((r) => `https://github.com/${r}`);
if (urls.length === 1)
return urls[0];
return `${urls.slice(0, -1).join(", ")} and ${urls[urls.length - 1]}`;
}
/**
* The pre-dispatch refusal. Names BOTH sides — what the caller asked for and what the
* channel would actually clone — and gives the one argument that proceeds, because a
* refusal a caller cannot get past is a worse tool than the hazard it prevents.
*/
export function ambiguousBareNameRefusal(a) {
// Quote the URL verbatim when `callerRepo` is unset — an off-host or owner-less URL has
// no github owner to name, and inventing one would report a repository they never typed.
const asked = a.callerRepo ? `https://github.com/${a.callerRepo}` : `"${a.callerUrl}"`;
// When the channel DOES list the caller's repo but Manager cannot reach it by name, say
// so explicitly — otherwise "the channel does not carry that name" reads as flatly wrong
// to anyone who just looked the pack up in that list.
const shadowed = a.unreachableCandidates.length
? ` (it does list ${listRepos(a.unreachableCandidates)}, but that entry is registered ` +
`in the Comfy Registry under a different id, so Manager keyed it by THAT id and a ` +
`lookup for "${a.bare}" cannot return it)`
: "";
const within = a.resolvedVia === "cnr-fallback"
? `no entry reachable by that name exists in the "${a.channel}" channel's list` +
`${shadowed} — which is NOT the end of it: on a "nightly" spec v4 then falls back ` +
`to the Comfy Registry map (install_by_id → cnr_map) and clones ` +
(a.registryTarget
? `what the id "${a.bare}" is registered to, which is ` +
`https://github.com/${a.registryTarget}`
: `whatever repository is registered under the id "${a.bare}"`)
: a.channelCandidates.length > 1
? `the "${a.channel}" channel's list carries that name TWICE — under ${listRepos(a.channelCandidates)} — and which one v4 returns depends on the order of that list`
: `the "${a.channel}" channel's list carries that name under ${listRepos(a.channelCandidates)}`;
const escape = a.channelsResolvingToCaller.length
? `The repository you named is what ${a.channelsResolvingToCaller
.map((c) => `channel:"${c}"`)
.join(" or ")} resolves "${a.bare}" to — pass that and this proceeds.`
: `No channel in the snapshot resolves "${a.bare}" to the repository you named, so a ` +
`Manager install cannot be made to fetch it: use install_custom_node (source:"git"), ` +
`which clones the URL directly instead of resolving a name.`;
// GATE ROUND 2 — THE BYPASS SENTENCE USED TO CONTRADICT THE REMEDY ABOVE IT. It read
// "naming any `channel` explicitly makes the choice yours and bypasses it" for EVERY
// refusal, including the ones whose `escape` had just said no channel resolves this name
// to the caller's repository at all. A caller who followed the last thing they read
// passed `channel:"default"`, dispatched, and got the other author's code reported as a
// success — the exact install this guard exists to stop, reached through the guard's own
// advice. Measured: for all 65 repositories that sit inside a multi-candidate channel,
// NO channel resolves them uniquely, so that sentence was wrong for every one of them.
//
// The bypass still has to exist — those 65 have no other `channel` to be sent to, and on
// Manager 3.x the install is correct — so it is kept and re-described rather than
// removed: it dispatches, it cannot aim, and 3.x is the one reason to take it.
const bypass = a.channelsResolvingToCaller.length
? `naming any \`channel\` explicitly makes the choice yours and bypasses it. Bypass it ` +
`that way also if this ComfyUI runs Manager 3.x, where the from-source request ` +
`carries the URL in \`files\` and IS cloned as passed`
: `naming a \`channel\` explicitly does still bypass this check — but do not read that ` +
`as the remedy here, because no channel resolves "${a.bare}" to the repository you ` +
`named. The bypass would dispatch an install you have no way to aim, and on v4 it ` +
`lands on whichever entry that channel's list order happens to keep. The one reason ` +
`to take it is a ComfyUI running Manager 3.x, where the from-source request carries ` +
`the URL in \`files\` and IS cloned as passed`;
// The opener must not assert the mechanism the NEXT clause rules out: on a miss there is
// no channel entry and nothing is cloned "from there".
const opener = a.resolvedVia === "cnr-fallback"
? `ComfyUI-Manager v4 resolves a from-source install by the BARE REPO NAME ` +
`("${a.bare}"), ignoring the \`repository\` field entirely.`
: `ComfyUI-Manager v4 resolves a from-source install by the BARE REPO NAME ` +
`("${a.bare}") against ONE channel's list and clones the URL recorded there, ` +
`ignoring the \`repository\` field entirely.`;
return (`REFUSED before dispatch — this install would not necessarily have installed the ` +
`repository you named. ${opener} You asked for ${asked}, and ${within}. ` +
`So this would have cloned someone else's code and reported success. Not picked for ` +
`you: ${escape} ` +
`(This check ran because no \`channel\` was named, against a snapshot of the six ` +
`published channel lists measured ${AMBIGUOUS_BARE_NAMES_MEASURED}; ${bypass} — this ` +
`check cannot tell the generations apart, because the panel picks the dialect after ` +
`the request leaves here.)`);
}
/**
* The same collision, for a caller who DID name the channel. They made that choice, so
* this dispatches — but it names the concrete repository their channel carries instead of
* the generic "111 bare names collide" warning that would otherwise ride along.
*
* GATE ROUND 2 — "BECAUSE YOU CHOSE THE CHANNEL" IS FALSE FOR AN INTRA-CHANNEL COLLISION.
* When the named channel carries the name once, the caller really did choose between the
* channels and this dispatches on their choice. When it carries the name TWICE, both
* entries live in the list they named: their channel argument selected nothing, no other
* channel argument could, and v4 falls back on upstream list order. Telling that caller
* their choice was honoured invites them to trust an install they never actually aimed —
* so it says instead that nothing here disambiguated, and names the route that clones the
* URL verbatim.
*/
export function ambiguousBareNameWarning(a) {
// Quote the URL verbatim when `callerRepo` is unset — an off-host or owner-less URL has
// no github owner to name, and inventing one would report a repository they never typed.
const asked = a.callerRepo ? `https://github.com/${a.callerRepo}` : `"${a.callerUrl}"`;
const ambiguousAlone = a.channelCandidates.length > 1;
const carries = a.resolvedVia === "cnr-fallback"
? `has nothing reachable under "${a.bare}"` +
(a.unreachableCandidates.length
? ` (it lists ${listRepos(a.unreachableCandidates)}, but that entry is keyed by ` +
`its Comfy Registry id, not by the repo name)`
: "") +
` — so v4 falls back to the registry id of that name and clones ` +
(a.registryTarget
? `https://github.com/${a.registryTarget}`
: `whatever repository it is registered to`)
: ambiguousAlone
? `carries "${a.bare}" TWICE — under ${listRepos(a.channelCandidates)} — and v4 keeps whichever comes last in that list`
: `resolves "${a.bare}" to ${listRepos(a.channelCandidates)}`;
const why = a.resolvedVia === "cnr-fallback"
? `Naming that channel did NOT aim this one either — it lists nothing under that ` +
`name, so v4 never reads your channel at all and resolves the registry id ` +
`instead; any other channel that also misses lands in exactly the same place. ` +
`Dispatched because you named a channel, and because on Manager 3.x the URL you ` +
`passed IS what gets cloned; if this is v4 and you need the repository you typed, ` +
`install_custom_node (source:"git") clones it directly instead of resolving a name`
: ambiguousAlone
? `Naming that channel did NOT disambiguate this one — both entries are in the list ` +
`you named, so no \`channel\` argument can separate them and on v4 the winner is ` +
`that list's order. Dispatched because you named a channel, and because on Manager ` +
`3.x the URL you passed IS what gets cloned; if this is v4 and you need the ` +
`repository you typed, install_custom_node (source:"git") clones it directly ` +
`instead of resolving a name`
: `Dispatched anyway because you chose the channel`;
const resolves = a.resolvedVia === "cnr-fallback"
? `v4 resolves this install by the bare name, and with no channel entry to use it ` +
`takes the registry's repository for that id`
: `v4 resolves this install by the bare name and clones the URL from ITS list`;
return (`NAMED-CHANNEL COLLISION: you asked for ${asked}, but the "${a.channel}" channel you ` +
`named ${carries}. ${resolves}, so what lands is very likely not what you passed. ` +
`${why} — verify with panel_list_nodes before you restart or report success. ` +
`(Snapshot measured ${AMBIGUOUS_BARE_NAMES_MEASURED}.)`);
}
//# sourceMappingURL=manager-bare-name-collisions.js.map