feat: native disable/enable fallback and package whitelist; bump to 1.8.0
Publish to Comfy registry / Publish Custom Node to registry (push) Waiting to run

Disable/enable no longer require ComfyUI Manager:
- New pack_fs.py moves packs in/out of custom_nodes/.disabled/ (no import,
  delete, or re-clone). Fallback for hand-cloned packs, loose single-file
  nodes, or when Manager is absent. enable strips the @version suffix so
  packs restore as clean, importable dir names.
- Routes: native-disable, native-enable, disabled-packs.
- Frontend routes each disable per-pack (Manager queue vs native move), and
  shows an Enable button on recoverable packs in the Uninstalled tier. The
  restart banner degrades to a manual-restart notice when no Manager exists.

Whitelist (packages-only): a star toggle protects a pack — pulled into its
own pinned group, no Disable button, skipped by the 7-day trial auto-disable.
- New whitelist_packages table; whitelisted flag on package stats.
- Routes: whitelist, whitelist/add, whitelist/remove.

Tests: test_pack_fs.py, test_whitelist.py (60 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 17:16:29 +02:00
parent 7ca7d95ef3
commit 6d433ba371
8 changed files with 777 additions and 54 deletions
+268 -52
View File
@@ -119,6 +119,10 @@ function unresolvedNodeTypes() {
// Latest workflow scan, shared so showStatsDialog can render the Workflow tab.
let _lastWorkflowScan = { disabled: [], missing: [] };
// Lowercased names of packs currently in custom_nodes/.disabled/ — the packs the
// extension can natively re-enable. Refreshed each time the dialog opens.
let _disabledPacksSet = new Set();
async function onWorkflowLoaded() {
const types = unresolvedNodeTypes();
_lastWorkflowScan = await classifyUnresolved(types);
@@ -133,12 +137,14 @@ async function onWorkflowLoaded() {
async function showStatsDialog(initialTab = "nodes") {
let data, modelData, managerInfo, trials = [];
try {
const [pkgResp, modelResp, mgr, trialsResp] = await Promise.all([
const [pkgResp, modelResp, mgr, trialsResp, disabledPacks] = await Promise.all([
fetch("/nodes-stats/packages"),
fetch("/nodes-stats/models"),
fetchManagerInfo(),
fetch("/nodes-stats/trials").catch(() => null),
fetchDisabledPacks(),
]);
_disabledPacksSet = disabledPacks;
if (!pkgResp.ok) { alert("Failed to load node stats: HTTP " + pkgResp.status); return; }
if (!modelResp.ok) { alert("Failed to load model stats: HTTP " + modelResp.status); return; }
data = await pkgResp.json();
@@ -248,6 +254,8 @@ async function showStatsDialog(initialTab = "nodes") {
wireDisableButtons(dialog, managerInfo);
wireWorkflowButtons(dialog);
wireWhitelistButtons(dialog);
wireNativeEnableButtons(dialog);
switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
@@ -285,6 +293,7 @@ function dialogStyle() {
#nodes-stats-dialog .ns-btn:hover:not(:disabled){background:#3a2020;border-color:#e44;color:#fff;}
#nodes-stats-dialog .ns-btn:disabled{opacity:0.5;cursor:default;}
#nodes-stats-dialog .ns-disable-all-btn{border-color:#a33;color:#e88;}
#nodes-stats-dialog .ns-enable-native-btn:hover:not(:disabled){background:#203a20;border-color:#4a4;color:#fff;}
${rows}
</style>`;
}
@@ -306,7 +315,11 @@ function summaryBar(items) {
}
function buildNodesTabContent(custom, managerInfo) {
const byStatus = (s) => custom.filter((p) => p.status === s);
// Whitelisted packs are pulled out of their usage tiers into a pinned group
// and never offered for disable. Everything else classifies as usual.
const whitelisted = custom.filter((p) => p.whitelisted);
const rest = custom.filter((p) => !p.whitelisted);
const byStatus = (s) => rest.filter((p) => p.status === s);
const safeToRemove = byStatus("safe_to_remove");
const considerRemoving = byStatus("consider_removing");
const unusedNew = byStatus("unused_new");
@@ -320,6 +333,7 @@ function buildNodesTabContent(custom, managerInfo) {
{ count: used.length, status: "used", label: "used", id: "nodes-stats-used-badge" },
]);
html += renderWhitelistSection(whitelisted, managerInfo);
html += renderSection("Safe to Remove", "Unused for 2+ months", "safe_to_remove", safeToRemove, managerInfo);
html += renderSection("Consider Removing", "Unused for 1-2 months", "consider_removing", considerRemoving, managerInfo);
html += renderSection("Recently Unused", "Unused for less than 1 month", "unused_new", unusedNew, managerInfo);
@@ -333,8 +347,12 @@ function renderSection(title, subtitle, status, packages, managerInfo) {
if (packages.length === 0) return "";
const color = STATUS_META[status].color;
const withActions = !!managerInfo && DISABLEABLE_TIERS.has(status);
const eligible = withActions
// Disable actions no longer require Manager: an installed pack Manager doesn't
// cover is disabled natively (moved into custom_nodes/.disabled by us).
// The Uninstalled tier gets an Enable action for packs recoverable from
// .disabled/ (also native, so it works with or without Manager).
const withActions = DISABLEABLE_TIERS.has(status) || sectionHasEnable(status, packages);
const eligible = DISABLEABLE_TIERS.has(status)
? packages.filter((p) => isDisableEligible(p, managerInfo)).map((p) => p.package)
: [];
@@ -352,12 +370,48 @@ function renderSection(title, subtitle, status, packages, managerInfo) {
return html;
}
// A package can be disabled only if ComfyUI Manager knows it (by directory
// name) and it is currently active (any state other than already-disabled).
// How a package can be disabled: "manager" when ComfyUI Manager knows it (by
// directory name) and it's currently active; "native" when it's installed but
// Manager doesn't cover it (hand-cloned repo, loose file, or Manager absent) —
// the extension moves it into custom_nodes/.disabled itself; null when it can't
// be disabled (uninstalled, or whitelisted/protected).
function disableMode(pkg, managerInfo) {
if (pkg.whitelisted || !pkg.installed) return null;
const info = managerInfo && managerInfo[pkg.package];
if (info) {
// Manager knows this pack: offer disable only while it's still active.
return info.state && info.state !== "disabled" ? "manager" : null;
}
// Manager doesn't cover it (hand-cloned, loose file, or Manager absent):
// the extension moves it into custom_nodes/.disabled itself.
return "native";
}
function isDisableEligible(pkg, managerInfo) {
if (!managerInfo || !pkg.installed) return false;
const info = managerInfo[pkg.package];
return !!(info && info.state && info.state !== "disabled");
return disableMode(pkg, managerInfo) !== null;
}
// An uninstalled pack can be natively re-enabled iff its source is still sitting
// in custom_nodes/.disabled/. (Whitelisted packs live in their own group.)
function nativeEnableEligible(pkg) {
return pkg.status === "uninstalled" && _disabledPacksSet.has(pkg.package.toLowerCase());
}
function sectionHasEnable(status, packages) {
return status === "uninstalled" && packages.some(nativeEnableEligible);
}
const WHITELIST_COLOR = "#e5c04b";
// Pinned group of protected packs. They keep their own row background (mixed
// statuses) but never show a Disable action — the star toggles protection off.
function renderWhitelistSection(packages, managerInfo) {
if (packages.length === 0) return "";
let html = `<div style="display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:16px 0 8px;">
<h3 style="color:${WHITELIST_COLOR};margin:0;font-size:14px;">★ Whitelist <span style="color:#666;font-size:12px;font-weight:normal;">— protected, never disabled</span></h3>
</div>`;
html += buildTable(packages, null, false, managerInfo);
return html;
}
function buildModelsTabContent(modelData) {
@@ -462,6 +516,15 @@ function sectionHeader(title, subtitle, color) {
return html;
}
// Clickable whitelist toggle rendered before a package name. Filled gold star
// when protected, hollow grey when not. Wired by wireWhitelistButtons.
function whitelistStar(pkg) {
const on = !!pkg.whitelisted;
return `<span class="ns-wl-btn" data-pkg="${escapeAttr(pkg.package)}" data-on="${on ? 1 : 0}" ` +
`title="${on ? "Remove from whitelist" : "Add to whitelist (protect from disable)"}" ` +
`style="cursor:pointer;margin-right:7px;color:${on ? WHITELIST_COLOR : "#555"};">${on ? "★" : "☆"}</span>`;
}
function buildTable(packages, status, withActions, managerInfo) {
const colspan = withActions ? 7 : 6;
@@ -480,19 +543,23 @@ function buildTable(packages, status, withActions, managerInfo) {
const hasNodes = pkg.nodes && pkg.nodes.length > 0;
const lastSeen = pkg.last_seen ? new Date(pkg.last_seen).toLocaleDateString() : "—";
html += `<tr class="pkg-row ns-row-${status}" style="cursor:${hasNodes ? "pointer" : "default"};border-bottom:1px solid #222;">
html += `<tr class="pkg-row ns-row-${pkg.status}" style="cursor:${hasNodes ? "pointer" : "default"};border-bottom:1px solid #222;">
<td style="padding:6px 8px;width:20px;"><span class="arrow" style="color:#666;">${hasNodes ? "▶" : " "}</span></td>
<td style="padding:6px 8px;color:#fff;">${escapeHtml(pkg.package)}</td>
<td style="padding:6px 8px;color:#fff;">${whitelistStar(pkg)}${escapeHtml(pkg.package)}</td>
<td style="padding:6px 8px;text-align:right;">${pkg.total_nodes}</td>
<td style="padding:6px 8px;text-align:right;">${pkg.used_nodes}/${pkg.total_nodes}</td>
<td style="padding:6px 8px;text-align:right;">${pkg.total_executions}</td>
<td style="padding:6px 8px;color:#888;">${lastSeen}</td>`;
if (withActions) {
const eligible = isDisableEligible(pkg, managerInfo);
const cell = eligible
? `<button class="ns-btn ns-disable-btn" data-pkg="${escapeAttr(pkg.package)}">Disable</button>`
: `<span style="color:#555;">—</span>`;
let cell;
if (nativeEnableEligible(pkg)) {
cell = `<button class="ns-btn ns-enable-native-btn" data-pkg="${escapeAttr(pkg.package)}" style="border-color:#3a6;color:#8d8;">Enable</button>`;
} else if (isDisableEligible(pkg, managerInfo)) {
cell = `<button class="ns-btn ns-disable-btn" data-pkg="${escapeAttr(pkg.package)}">Disable</button>`;
} else {
cell = `<span style="color:#555;">—</span>`;
}
html += `<td class="ns-action-cell" data-pkg="${escapeAttr(pkg.package)}" style="padding:6px 8px;text-align:right;">${cell}</td>`;
}
html += `</tr>`;
@@ -799,8 +866,8 @@ function disablePayload(dirName, info) {
}
function wireDisableButtons(dialog, managerInfo) {
if (!managerInfo) return;
// No early return on a missing Manager: native-disable buttons are rendered
// for installed packs Manager doesn't cover, and must work with no Manager.
dialog.querySelectorAll(".ns-disable-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
@@ -831,48 +898,156 @@ function wireWorkflowButtons(dialog) {
b.addEventListener("click", (e) => { e.stopPropagation(); handleTrialInstall(b.dataset.pkg, dialog, false); }));
}
async function handleDisable(pkgNames, dialog, managerInfo) {
// Only act on packages Manager still reports as active (guards against
// double-clicks and stale buttons after a partial batch).
pkgNames = pkgNames.filter((n) => managerInfo[n] && managerInfo[n].state !== "disabled");
if (pkgNames.length === 0) return;
// Star toggles on package rows: add/remove the pack from the whitelist, then
// re-render the Nodes tab so the pack moves in/out of the pinned group.
function wireWhitelistButtons(dialog) {
dialog.querySelectorAll(".ns-wl-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
toggleWhitelist(btn.dataset.pkg, btn.dataset.on !== "1");
});
});
}
const what = pkgNames.length === 1 ? `"${pkgNames[0]}"` : `${pkgNames.length} packages`;
async function toggleWhitelist(pkg, add) {
try {
const r = await fetch(add ? "/nodes-stats/whitelist/add" : "/nodes-stats/whitelist/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }),
});
if (!r.ok) throw new Error("HTTP " + r.status);
} catch (e) {
notify("Whitelist update failed: " + e.message, "error");
return;
}
showStatsDialog("nodes"); // re-fetch + rebuild so the pack regroups
}
async function fetchWhitelist() {
try {
const r = await fetch("/nodes-stats/whitelist");
if (r.ok) return new Set(await r.json());
} catch { /* treat as empty */ }
return new Set();
}
// Enable buttons on recoverable uninstalled packs: move the pack out of
// custom_nodes/.disabled via the native route. Restart is left to the user.
function wireNativeEnableButtons(dialog) {
dialog.querySelectorAll(".ns-enable-native-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
handleNativeEnable(btn.dataset.pkg, btn, dialog);
});
});
}
async function handleNativeEnable(pkg, btn, dialog) {
if (!confirm(
`Enable "${pkg}"?\n\nIt will be moved out of custom_nodes/.disabled and ` +
`load on the next ComfyUI restart.`
)) return;
btn.disabled = true;
try {
const r = await fetch("/nodes-stats/native-enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }),
});
if (!r.ok) {
let msg = "HTTP " + r.status;
try { const j = await r.json(); if (j && j.message) msg = j.message; } catch { /* keep status */ }
throw new Error(msg);
}
_disabledPacksSet.delete(pkg.toLowerCase());
const cell = dialog.querySelector(`.ns-action-cell[data-pkg="${cssEscape(pkg)}"]`);
if (cell) cell.innerHTML = `<span style="color:#6a6;font-size:11px;">✓ enabled · restart</span>`;
showRestartBanner(dialog);
notify(`Enabled ${pkg}. Restart ComfyUI to load it.`, "success");
} catch (e) {
btn.disabled = false;
notify("Failed to enable: " + e.message, "error");
}
}
// Lowercased set of pack names in custom_nodes/.disabled/ (native re-enable
// candidates). Lowercased so lookups match package names case-insensitively.
async function fetchDisabledPacks() {
try {
const r = await fetch("/nodes-stats/disabled-packs");
if (r.ok) return new Set((await r.json()).map((n) => n.toLowerCase()));
} catch { /* treat as empty */ }
return new Set();
}
async function handleDisable(pkgNames, dialog, managerInfo) {
// Partition into packages Manager can disable (still reported active) vs. the
// rest, which we disable natively by moving them into custom_nodes/.disabled.
// The eligible lists already excluded whitelisted/uninstalled packs.
const managerNames = pkgNames.filter(
(n) => managerInfo && managerInfo[n] && managerInfo[n].state && managerInfo[n].state !== "disabled"
);
const nativeNames = pkgNames.filter((n) => !managerNames.includes(n));
if (managerNames.length === 0 && nativeNames.length === 0) return;
const total = managerNames.length + nativeNames.length;
const what = total === 1 ? `"${managerNames[0] || nativeNames[0]}"` : `${total} packages`;
const via = managerNames.length && nativeNames.length
? "ComfyUI Manager / directly"
: managerNames.length ? "ComfyUI Manager" : "the extension";
const confirmMsg =
`Disable ${what} via ComfyUI Manager?\n\n` +
`Disable ${what} via ${via}?\n\n` +
`They will be moved to custom_nodes/.disabled and a ComfyUI restart is ` +
`required to take effect. You can re-enable them anytime from ComfyUI Manager.`;
`required to take effect. You can re-enable them anytime.`;
if (!confirm(confirmMsg)) return;
setDisableButtonsBusy(dialog, true);
const succeeded = [];
const failed = [];
try {
const pre = await mgrFetch("/manager/queue/status").then((r) => (r && r.ok ? r.json() : null)).catch(() => null);
if (pre && pre.is_processing) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
setDisableButtonsBusy(dialog, false);
return;
if (managerNames.length) {
const pre = await mgrFetch("/manager/queue/status").then((r) => (r && r.ok ? r.json() : null)).catch(() => null);
if (pre && pre.is_processing) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
failed.push(...managerNames);
} else {
try {
await runManagerDisable(managerNames.map((n) => disablePayload(n, managerInfo[n])));
// Reconcile against Manager's actual state: disabled only if no longer
// reported as active on disk.
const after = await fetchManagerInfo();
const isStillActive = (n) => after && after[n] && after[n].state !== "disabled";
for (const n of managerNames) {
if (after ? !isStillActive(n) : true) {
succeeded.push(n);
if (managerInfo[n]) managerInfo[n].state = "disabled";
} else {
failed.push(n);
}
}
} catch (e) {
failed.push(...managerNames);
notify("ComfyUI Manager disable failed: " + e.message, "error");
}
}
}
const payloads = pkgNames.map((n) => disablePayload(n, managerInfo[n]));
await runManagerDisable(payloads);
if (nativeNames.length) {
const res = await runNativeDisable(nativeNames);
succeeded.push(...res.succeeded);
failed.push(...res.failed);
}
// Reconcile against Manager's actual state: a package is considered
// disabled only if it's no longer reported as active on disk.
const after = await fetchManagerInfo();
const isStillActive = (n) => after && after[n] && after[n].state !== "disabled";
const succeeded = after ? pkgNames.filter((n) => !isStillActive(n)) : pkgNames;
const failed = pkgNames.filter((n) => !succeeded.includes(n));
succeeded.forEach((n) => { if (managerInfo[n]) managerInfo[n].state = "disabled"; });
markPackagesDisabled(dialog, succeeded);
updateBulkButtons(dialog, managerInfo);
updateBulkButtons(dialog, new Set(succeeded));
if (succeeded.length > 0) {
showRestartBanner(dialog);
notify(`Disabled ${succeeded.length} package${succeeded.length !== 1 ? "s" : ""}. Restart ComfyUI to apply.`, "success");
}
if (failed.length > 0) {
notify(`ComfyUI Manager could not disable: ${failed.join(", ")}`, "error");
notify(`Could not disable: ${failed.join(", ")}`, "error");
}
} catch (e) {
notify("Failed to disable: " + e.message, "error");
@@ -881,6 +1056,26 @@ async function handleDisable(pkgNames, dialog, managerInfo) {
}
}
// Disable packs by moving each into custom_nodes/.disabled via the extension's
// own route (no Manager). Returns { succeeded, failed } name lists.
async function runNativeDisable(pkgNames) {
const succeeded = [];
const failed = [];
for (const n of pkgNames) {
try {
const r = await fetch("/nodes-stats/native-disable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: n }),
});
(r.ok ? succeeded : failed).push(n);
} catch {
failed.push(n);
}
}
return { succeeded, failed };
}
// v4-legacy: queue a batch of operations ({install:[...]}, {disable:[...]}, ...)
// through the single endpoint that also starts the worker, then wait for it to
// finish. A reset first clears any stale queue (harmless if empty).
@@ -1391,26 +1586,39 @@ async function processExpiredTrials() {
if (r.ok) trials = await r.json();
} catch { return; }
const expired = trials.filter((t) => t.expired);
let expired = trials.filter((t) => t.expired);
if (!expired.length) return;
// Whitelisted packs are protected: never auto-disable them. Clear their trial
// row so they stop being tracked as temporary (they're now keepers).
const whitelist = await fetchWhitelist();
const protectedExpired = expired.filter((t) => whitelist.has(t.package));
for (const t of protectedExpired) await stopTrial(t.package);
expired = expired.filter((t) => !whitelist.has(t.package));
if (!expired.length) return;
const mgr = await fetchManagerInfo();
if (!mgr) return; // Manager unavailable — leave rows for a later session
// Don't reset Manager's queue out from under an in-progress operation
// (e.g. startup install work); the expired rows persist and retry next session.
if (await managerIsBusy()) return;
if (mgr && await managerIsBusy()) return;
const done = [];
for (const t of expired) {
const info = mgr[t.package];
if (!info || info.state === "disabled") {
const info = mgr && mgr[t.package];
if (info && info.state === "disabled") {
await stopTrial(t.package);
done.push(t.package);
continue;
}
try {
await runManagerDisable([disablePayload(t.package, info)]);
if (info) {
await runManagerDisable([disablePayload(t.package, info)]);
} else {
// Manager absent or doesn't know this pack — disable it natively.
const res = await runNativeDisable([t.package]);
if (!res.succeeded.includes(t.package)) throw new Error("native disable failed");
}
await stopTrial(t.package);
done.push(t.package);
} catch { /* keep the row; retry next session */ }
@@ -1454,13 +1662,13 @@ function markPackagesDisabled(dialog, pkgNames) {
}
}
// Recompute "Disable all (N)" counts after a batch; hide buttons with nothing
// left to disable.
function updateBulkButtons(dialog, managerInfo) {
// Recompute "Disable all (N)" counts after a batch, dropping packages that were
// just disabled; hide buttons with nothing left. Mode-agnostic (Manager+native).
function updateBulkButtons(dialog, succeededSet) {
dialog.querySelectorAll(".ns-disable-all-btn").forEach((btn) => {
let names = [];
try { names = JSON.parse(btn.dataset.pkgs); } catch { names = []; }
const remaining = names.filter((n) => managerInfo[n] && managerInfo[n].state !== "disabled");
const remaining = names.filter((n) => !succeededSet.has(n));
if (remaining.length === 0) {
btn.style.display = "none";
} else {
@@ -1492,6 +1700,14 @@ function showRestartBanner(dialog) {
}
async function rebootComfy() {
// The reboot endpoint is provided by ComfyUI Manager. Without it we can't
// restart the server ourselves — tell the user to do it manually (this is the
// native-only disable path).
const api = await detectManagerApi();
if (!api) {
notify("No ComfyUI Manager to trigger a restart — please restart ComfyUI manually to apply the changes.", "warn");
return;
}
if (!confirm("Restart ComfyUI now? The server will go down briefly and the page will reconnect.")) return;
notify("Restarting ComfyUI…", "info");
try {