feat(sidebar): one-click install of missing nodes via ComfyUI-Manager (#69)

Adds an "Install missing for picks" toolbar button to the Nodes section.
On click it walks the user's curated picks against Manager's
/customnode/getmappings, buckets them into already-installed /
will-install / unresolved, queues each unique git URL through
/customnode/install/git_url, polls /manager/queue/status to drive a
progress bar, and prompts to reboot — collapsing what was previously
a per-node "open node, let Manager prompt, click install, restart"
loop into a single confirm-and-go flow.

New `web/sidebar/installer.js` holds the Manager-API client (detect /
mappings / queue / poll / reboot) plus the picks → installs resolver.
The modal in `web/sidebar/modals.js` drives the four-phase UI
(discovery → confirm → progress → result) and surfaces 403s as
actionable language ("your security level forbids git-URL installs")
rather than raw HTTP. Falls back to a clipboard URL list (with a
`comfy node install` hint) if Manager isn't reachable.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-05 11:21:07 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 8ebe0af210
commit b89355ded1
5 changed files with 453 additions and 1 deletions
+15
View File
@@ -6,6 +6,21 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased]
### Added
- **"Install missing for picks" toolbar button** in the Nodes section
(`pi-cloud-download` icon, next to Add and Export). Walks the user's
picks against ComfyUI-Manager's `/customnode/getmappings` mapping,
buckets into already-installed / will-install / unresolved, queues
unique git URLs through `/customnode/install/git_url`, polls
`/manager/queue/status` to drive a progress bar, and prompts to
reboot. Works on any install with ComfyUI-Manager loaded; falls back
to a clipboard URL list (with a `comfy node install` hint) if Manager
isn't reachable. New module `web/sidebar/installer.js` holds the
Manager-API client + resolver; the modal in `web/sidebar/modals.js`
drives the four-phase UI (discovery → confirm → progress → result).
403s from Manager's security gate are surfaced as actionable language
("your security level forbids git-URL installs") rather than raw HTTP.
### Changed
- **Save selection toast distinguishes "no selection" from "selection
points at deleted nodes."** Previously both produced the generic
+6
View File
@@ -114,6 +114,12 @@ export function ensureStyle() {
.koolook-tag-chip-x:hover { opacity: 1; color: #ff7777; background: rgba(255,255,255,0.06); }
.koolook-tag-add-row { display: flex; gap: 6px; align-items: center; }
.koolook-tag-add-row .koolook-modal-input { flex: 1; }
.koolook-install-stat-line { font-size: 12px; line-height: 1.5; padding: 1px 0; }
.koolook-install-stat-line.koolook-install-stat-fail { color: #ff8888; }
.koolook-install-unresolved { font-size: 11px; opacity: 0.75; margin-top: 4px; padding: 6px 8px; background: rgba(255,255,255,0.04); border-radius: 4px; word-break: break-all; max-height: 110px; overflow-y: auto; }
.koolook-install-unresolved-summary { cursor: pointer; font-size: 11px; opacity: 0.75; margin-top: 4px; }
.koolook-install-progress { width: 100%; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; margin: 10px 0 4px; overflow: hidden; }
.koolook-install-progress-bar { height: 100%; background: rgba(80,140,235,0.6); width: 0%; transition: width 200ms ease; }
`;
document.head.appendChild(s);
}
+187
View File
@@ -0,0 +1,187 @@
// =============================================================================
// ComfyUI-Manager API client + pick → install resolver.
//
// Detects whether Manager is reachable, walks the user's curated picks
// against /customnode/getmappings to find which packs need installing,
// queues git-URL installs, polls the install queue for progress, and
// reboots. UI-free — modals.js drives the user-facing flow on top of the
// functions exported here.
//
// Routes referenced (Manager source pins):
// GET /manager/queue/status → reachability probe + progress poll
// GET /customnode/getmappings?mode → unified node-id ↔ git-url mapping
// POST /customnode/install/git_url → queue one repo for install (text body)
// POST /manager/queue/start → kick off the worker thread
// POST /manager/reboot → restart ComfyUI to load installed code
//
// `?mode=local` is REQUIRED on getmappings — Manager errors with KeyError if
// the query param is absent (manager_server.py: `request.rel_url.query["mode"]`).
// =============================================================================
const ENDPOINT_QUEUE_STATUS = "/manager/queue/status";
const ENDPOINT_QUEUE_START = "/manager/queue/start";
const ENDPOINT_GETMAPPINGS = "/customnode/getmappings?mode=local";
const ENDPOINT_INSTALL_GIT_URL = "/customnode/install/git_url";
const ENDPOINT_REBOOT = "/manager/reboot";
export const POLL_INTERVAL_MS = 1000;
// =============================================================================
// Detection
// =============================================================================
// Reachability probe. The status endpoint is unauthenticated and side-effect-
// free, so a 200 here is a strong signal Manager is loaded and serving routes.
// Anything else (network error, 404 — e.g. user disabled Manager) routes the
// caller into the copy-URL fallback path.
export async function detectManager() {
try {
const r = await fetch(ENDPOINT_QUEUE_STATUS, { method: "GET" });
return r.ok;
} catch (e) {
console.warn("[Koolook] Manager reachability probe failed:", e);
return false;
}
}
// =============================================================================
// Mapping load + pick resolution
// =============================================================================
// /customnode/getmappings response shape (mirrors extension-node-map.json):
// { "<git_url>": [[node_id1, node_id2, ...], { title, ... }] }
// We invert it to a node-id → git-url Map so a list of picks can be resolved
// in O(picks). First-write-wins on duplicate node IDs across repos — rare,
// but if it happens the user can override by editing picks; either repo is a
// valid install target.
export async function loadMappings() {
const r = await fetch(ENDPOINT_GETMAPPINGS);
if (!r.ok) throw new Error(`getmappings ${r.status}`);
const data = await r.json();
const urlByNodeId = new Map();
for (const [gitUrl, val] of Object.entries(data || {})) {
if (!Array.isArray(val) || !Array.isArray(val[0])) continue;
for (const id of val[0]) {
if (typeof id !== "string") continue;
if (!urlByNodeId.has(id)) urlByNodeId.set(id, gitUrl);
}
}
return { urlByNodeId };
}
// Bucket the picks by what we need to do with them. The `byUrl` Map dedupes
// repos when several picks live in the same pack — we install once per repo,
// not once per node ID.
//
// `unresolved` collects picks the mapping doesn't know about. Common causes:
// • a private/local node pack (never published to ComfyUI-Manager's index)
// • a pack so new it isn't in the cached extension-node-map.json yet
// • a renamed node that the upstream mapping hasn't caught up with
// We surface those by ID so the user can act manually instead of silently
// dropping them.
export function resolvePicksToInstall(picks, urlByNodeId) {
const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {};
const alreadyInstalled = [];
const unresolved = [];
const byUrl = new Map();
for (const id of picks) {
if (registry[id]) {
alreadyInstalled.push(id);
continue;
}
const url = urlByNodeId.get(id);
if (!url) {
unresolved.push(id);
continue;
}
if (!byUrl.has(url)) byUrl.set(url, []);
byUrl.get(url).push(id);
}
return { alreadyInstalled, willInstall: { byUrl }, unresolved };
}
// =============================================================================
// Install queue dispatch
// =============================================================================
// One git URL → one queue entry. Body is plain text (per the route's
// `await request.text()` on the server); fetch defaults to a text/plain
// content-type for a string body, which Manager accepts.
//
// 403 is the structured "your security level forbids this" response — we map
// it to user-facing language because raw HTTP codes don't help the user act.
// Other non-2xx are surfaced with their status so the result modal can still
// show what failed.
export async function queueInstallGitUrl(gitUrl) {
try {
const r = await fetch(ENDPOINT_INSTALL_GIT_URL, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: gitUrl,
});
if (r.ok) return { ok: true };
const message = r.status === 403
? "Manager rejected the install — your security level forbids git-URL installs. Install via Manager UI, or relax the security_level setting."
: `Install request failed (HTTP ${r.status}).`;
return { ok: false, status: r.status, message };
} catch (e) {
return { ok: false, status: 0, message: `Network error: ${e.message}` };
}
}
// Start the worker thread that drains the queue. 200 = started, 201 = already
// processing (e.g. the user kicked off another install moments earlier).
// Both are success from our perspective — the queue is being worked.
export async function startQueue() {
try {
const r = await fetch(ENDPOINT_QUEUE_START, { method: "POST" });
return r.ok || r.status === 201;
} catch (e) {
console.warn("[Koolook] startQueue failed:", e);
return false;
}
}
export async function getQueueStatus() {
const r = await fetch(ENDPOINT_QUEUE_STATUS);
if (!r.ok) throw new Error(`queue/status ${r.status}`);
return await r.json();
}
// Poll status until `is_processing` flips false. `signal.aborted = true` lets
// the caller bail without canceling installs already in flight (we can't —
// Manager has no per-task cancel) — it just disengages the UI loop.
//
// Returns the final status snapshot, or null if aborted / a poll fetch threw.
export async function pollUntilDone({ intervalMs = POLL_INTERVAL_MS, onTick, signal } = {}) {
while (true) {
if (signal && signal.aborted) return null;
let status;
try {
status = await getQueueStatus();
} catch (e) {
console.warn("[Koolook] queue status poll failed:", e);
return null;
}
if (typeof onTick === "function") onTick(status);
if (!status.is_processing) return status;
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}
// =============================================================================
// Restart
// =============================================================================
// Manager's reboot endpoint signals the launcher (via a sentinel file) that
// ComfyUI should restart. The HTTP request itself usually fails partway —
// the server is going down — so we treat a network error here as "request
// likely accepted" rather than a hard failure.
export async function reboot() {
try {
const r = await fetch(ENDPOINT_REBOOT, { method: "POST" });
return r.ok;
} catch (e) {
return true;
}
}
+231 -1
View File
@@ -3,8 +3,17 @@
// click / Cancel / OK dismissal paths and ties the document-level keydown
// listener to the overlay's lifetime so opening many modals doesn't leak.
// =============================================================================
import { compareNames } from "./constants.js";
import { compareNames, toast } from "./constants.js";
import { listDirectoryNames, dirOf } from "./workflows_store.js";
import {
detectManager,
loadMappings,
resolvePicksToInstall,
queueInstallGitUrl,
startQueue,
pollUntilDone,
reboot,
} from "./installer.js";
function makeModalShell({ title, body, actions }) {
const overlay = document.createElement("div");
@@ -623,6 +632,227 @@ export function showTagsModal({ wfName, getCurrentTags, onAddTag, onRemoveTag })
setTimeout(() => { if (!input.disabled) input.focus(); }, 0);
}
// =============================================================================
// Install missing nodes modal — three phases driven by ComfyUI-Manager's HTTP
// API.
//
// 1. Discovery — probe Manager, fetch the node-id → git-url mapping,
// bucket picks into already-installed / will-install /
// unresolved.
// 2. Confirm — show the counts + per-bucket detail; user clicks Install
// or Copy URL list.
// 3. Progress — POST /customnode/install/git_url for each unique URL,
// POST /manager/queue/start, then poll /manager/queue/status
// while updating a progress bar.
// 4. Result — summarize successes/failures and offer Reboot.
//
// We don't render the unresolved list as a hard-fail block — installs can
// still proceed for the resolved subset, and the unresolved IDs surface
// inside a collapsible <details> so the user can act on them separately.
// =============================================================================
export function showInstallMissingModal({ picks }) {
const body = document.createElement("div");
const message = document.createElement("div");
message.className = "koolook-modal-message";
message.textContent = "Checking ComfyUI-Manager…";
body.appendChild(message);
const stats = document.createElement("div");
body.appendChild(stats);
const progress = document.createElement("div");
progress.className = "koolook-install-progress";
progress.style.display = "none";
const progressBar = document.createElement("div");
progressBar.className = "koolook-install-progress-bar";
progress.appendChild(progressBar);
body.appendChild(progress);
// Track the polling abort so the Cancel button (or modal dismiss) stops
// pumping `pollUntilDone` even if installs are still chugging on the
// server side. We can't *cancel* an install — Manager doesn't expose a
// per-task cancel — so this just disengages our UI loop, not the work.
const pollAbort = { aborted: false };
let overlay;
const closeBtn = makeModalButton({
label: "Cancel",
onClick: () => { pollAbort.aborted = true; overlay.remove(); },
});
const copyBtn = makeModalButton({ label: "Copy URL list", onClick: () => {} });
copyBtn.style.display = "none";
const installBtn = makeModalButton({ label: "Install via Manager", primary: true, onClick: () => {} });
installBtn.style.display = "none";
const rebootBtn = makeModalButton({ label: "Reboot now", primary: true, onClick: () => {} });
rebootBtn.style.display = "none";
({ overlay } = makeModalShell({
title: "Install missing nodes",
body,
actions: [closeBtn, copyBtn, installBtn, rebootBtn],
}));
function setStatLines(lines) {
stats.innerHTML = "";
for (const line of lines) {
const div = document.createElement("div");
div.className = "koolook-install-stat-line";
if (line.fail) div.classList.add("koolook-install-stat-fail");
div.textContent = line.text;
stats.appendChild(div);
}
}
function appendUnresolvedDetail(unresolvedIds) {
if (unresolvedIds.length === 0) return;
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.className = "koolook-install-unresolved-summary";
summary.textContent = `Show unresolved (${unresolvedIds.length})`;
details.appendChild(summary);
const list = document.createElement("div");
list.className = "koolook-install-unresolved";
list.textContent = unresolvedIds.join(", ");
details.appendChild(list);
stats.appendChild(details);
}
// ---- Phase 1: Discovery ----
(async () => {
const managerOk = await detectManager();
if (!managerOk) {
message.textContent = "ComfyUI-Manager isn't reachable. Install it via the official ComfyUI installer, or run `comfy node install <pack>` from the CLI for each missing pack.";
return;
}
let mappings;
try {
mappings = await loadMappings();
} catch (e) {
console.warn("[Koolook] loadMappings failed:", e);
message.textContent = `Could not load Manager's mapping database (${e.message}). In Manager → click "Update DB" or restart ComfyUI, then retry.`;
return;
}
const result = resolvePicksToInstall(picks, mappings.urlByNodeId);
const urlCount = result.willInstall.byUrl.size;
const willInstallNodeCount = [...result.willInstall.byUrl.values()]
.reduce((acc, ids) => acc + ids.length, 0);
message.textContent = "";
const lines = [
{ text: `Already installed: ${result.alreadyInstalled.length} pick${result.alreadyInstalled.length === 1 ? "" : "s"}` },
{ text: `Will install: ${urlCount} pack${urlCount === 1 ? "" : "s"} (${willInstallNodeCount} pick${willInstallNodeCount === 1 ? "" : "s"})` },
];
if (result.unresolved.length > 0) {
lines.push({ text: `Unresolved (no mapping found): ${result.unresolved.length}` });
}
setStatLines(lines);
appendUnresolvedDetail(result.unresolved);
if (urlCount === 0) {
message.textContent = result.alreadyInstalled.length === picks.length && picks.length > 0
? "Every pick is already installed."
: "Nothing to install.";
closeBtn.textContent = "Close";
return;
}
// ---- Phase 2: Confirm ----
const urls = [...result.willInstall.byUrl.keys()];
copyBtn.style.display = "";
installBtn.style.display = "";
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(urls.join("\n") + "\n");
toast(`Copied ${urls.length} git URL${urls.length === 1 ? "" : "s"} to clipboard.`);
} catch (e) {
console.warn("[Koolook] clipboard write failed:", e);
toast("Clipboard write failed — see console for the URL list.");
console.log("[Koolook] git URLs:\n" + urls.join("\n"));
}
});
installBtn.addEventListener("click", async () => {
// Lock buttons so a double-click can't double-queue.
installBtn.disabled = true;
copyBtn.disabled = true;
// ---- Phase 3: Progress ----
progress.style.display = "";
stats.style.display = "none";
message.textContent = "Queueing installs…";
const queueResults = [];
for (const url of urls) {
if (pollAbort.aborted) break;
const r = await queueInstallGitUrl(url);
queueResults.push({ url, ...r });
}
const queuedOk = queueResults.filter(r => r.ok).length;
if (queuedOk === 0) {
progress.style.display = "none";
stats.style.display = "";
message.textContent = "No installs were accepted.";
setStatLines(queueResults.map(r => ({
text: `${r.url} — ${r.message}`,
fail: true,
})));
closeBtn.textContent = "Close";
installBtn.style.display = "none";
copyBtn.style.display = "none";
return;
}
await startQueue();
const finalStatus = await pollUntilDone({
onTick: (s) => {
const pct = s.total_count > 0 ? (s.done_count / s.total_count) * 100 : 0;
progressBar.style.width = `${pct}%`;
message.textContent = `Installing… ${s.done_count} of ${s.total_count}`;
},
signal: pollAbort,
});
// ---- Phase 4: Result ----
progress.style.display = "none";
stats.style.display = "";
installBtn.style.display = "none";
copyBtn.style.display = "none";
const resultLines = [];
if (finalStatus) {
resultLines.push({
text: `Installed ${finalStatus.done_count} of ${finalStatus.total_count} pack${finalStatus.total_count === 1 ? "" : "s"}.`,
});
} else {
resultLines.push({ text: "Stopped polling — installs may still complete in the background." });
}
for (const r of queueResults.filter(r => !r.ok)) {
resultLines.push({ text: `Failed to queue: ${r.url} — ${r.message}`, fail: true });
}
if (queuedOk > 0) {
resultLines.push({ text: "Restart ComfyUI to load the newly installed nodes." });
}
message.textContent = "";
setStatLines(resultLines);
appendUnresolvedDetail(result.unresolved);
closeBtn.textContent = "Close";
if (queuedOk > 0) {
rebootBtn.style.display = "";
rebootBtn.addEventListener("click", async () => {
rebootBtn.disabled = true;
await reboot();
overlay.remove();
toast("Reboot requested. Reload the page in a few seconds.");
});
}
});
})();
}
// =============================================================================
// Context menu helper
// =============================================================================
+14
View File
@@ -63,6 +63,7 @@ import {
showSaveWorkflowModal,
showContextMenu,
showTagsModal,
showInstallMissingModal,
} from "./modals.js";
// =============================================================================
@@ -1379,6 +1380,19 @@ export function renderPanel(container) {
onClick: exportPicks,
}));
// "Install missing for picks" — always enabled. The modal itself decides
// whether anything actually needs installing (after probing Manager and
// walking picks against the live LiteGraph registry), so leaving the
// button enabled lets the user trigger the discovery flow whenever they
// want a status check, including the "everything's already installed"
// confirmation. Click handlers don't read picks here — the modal pulls
// a fresh `loadUserPicks()` snapshot at open-time.
nodesRow.appendChild(makeToolbarButton({
iconClass: "pi pi-cloud-download",
title: "Install missing custom nodes for current picks (via ComfyUI-Manager)",
onClick: () => showInstallMissingModal({ picks: loadUserPicks() }),
}));
container.appendChild(nodesRow);
// Divider between the two action bars