Compare commits

..

11 Commits

Author SHA1 Message Date
Ethanfel 6d433ba371 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>
2026-07-04 17:16:29 +02:00
Ethanfel 7ca7d95ef3 feat(workflow): make auto-open on load opt-in; default off
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Loading a workflow with missing/disabled nodes no longer pops the Node
Stats dialog every time. Gated behind a new ComfyUI setting
"Auto-open Node Stats on workflow load" (off by default); the scan still
runs so the Workflow tab is accurate when opened manually. Bump to 1.7.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:11:54 +02:00
Ethanfel 2692bb1752 docs: note built-in manager (--enable-manager-legacy-ui) support; bump to 1.7.1
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:24:30 +02:00
Ethanfel e043ec865c fix(manager): support ComfyUI core's bundled manager (v4 /v2 API)
ComfyUI core now bundles comfyui_manager v4 (enabled via --enable-manager),
which serves all Manager HTTP routes under a /v2 prefix and replaces the
per-item /manager/queue/install and /manager/queue/disable endpoints with a
single /v2/manager/queue/batch call. The extension called the old unprefixed
paths, so every request 404'd, fetchManagerInfo() returned null, and all
disable/enable/install/trial features went inert ("can't find manager").

Add detectManagerApi() to probe once (cheap queue/status) and cache which
generation answers: v3 (old git Manager, unprefixed, per-item) or v4-legacy
(/v2 + batch). Route every read through a prefix-aware mgrFetch(), and branch
install/disable on the batch transport via a shared runManagerBatch() helper.
Payload shapes are unchanged — v4's _install_custom_node/_disable_node read the
same fields. Verified against a live v4-legacy server on :8189.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:23:35 +02:00
Ethanfel 4ffaddef7c docs: document 7-day trial-install for missing nodes; bump to 1.7.0
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
2026-06-22 17:57:46 +02:00
Ethanfel 19b96de322 feat(install): wire Install 7d / Install buttons 2026-06-22 17:56:41 +02:00
Ethanfel 406a4bf9ff feat(install): Missing rows offer Install 7d + Install 2026-06-22 17:48:37 +02:00
Ethanfel 0707a76768 feat(install): handleTrialInstall orchestrator with Manager fallback 2026-06-22 17:48:10 +02:00
Ethanfel a67b628140 feat(install): install-target resolution helpers for missing packs 2026-06-22 17:47:46 +02:00
Ethanfel 73185ad638 docs: implementation plan for 7-day trial-install of missing nodes
5-task frontend-only plan: install-resolution helpers, handleTrialInstall
orchestrator (reusing the Manager install queue + existing trial routes +
processExpiredTrials expiry), Install 7d / Install buttons + wiring, and
docs/version bump to 1.7.0. Manual verification + node --check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:37:02 +02:00
Ethanfel cc1369a38b docs: design for 7-day trial-install of missing nodes
Extend the rolling 7-day trial from disabled packs to missing (not-installed)
ones: Workflow tab Missing rows get Install 7d (real install + trial) and
Install (permanent). Expiry auto-disables (reuses processExpiredTrials, zero
new expiry code). Install spec resolved from not-installed getlist entries;
resulting dir discovered by re-reading getlist to key the trial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:35:20 +02:00
10 changed files with 1389 additions and 78 deletions
+22 -7
View File
@@ -15,8 +15,9 @@ A ComfyUI custom node package that silently tracks which nodes, packages, and mo
- **Smart aging** — items gradually move from "recently unused" to "safe to remove" over time - **Smart aging** — items gradually move from "recently unused" to "safe to remove" over time
- **Uninstall detection** — removed packages/models are flagged separately, historical data preserved - **Uninstall detection** — removed packages/models are flagged separately, historical data preserved
- **Expandable detail** — click any package to see individual node-level stats - **Expandable detail** — click any package to see individual node-level stats
- **One-click disable** — disable unused packages straight from the dialog via ComfyUI Manager (per-package or in bulk), reversible at any time - **One-click disable** — disable unused packages straight from the dialog (per-package or in bulk), reversible at any time. Uses ComfyUI Manager when it manages the pack, and **falls back to a native disable** (moving the pack into `custom_nodes/.disabled/`) for hand-cloned packs, loose single-file nodes, or when Manager isn't installed. Natively-disabled packs get an **Enable** button in the *Uninstalled* tier to move them back — no Manager needed (restart to apply)
- **Workflow tab** — on loading a workflow, splits unresolved nodes into *Missing* (install via Manager) and *Disabled*, with a temporary **Enable 7d** trial that auto-disables packages left unused - **Whitelist** — click the ☆ star on any package to protect it: whitelisted packs move into their own pinned group, never show a Disable button, and are skipped by the 7-day trial auto-disable
- **Workflow tab** — on loading a workflow, splits unresolved nodes into *Missing* (install permanently or on a trial) and *Disabled* (enable permanently or on a trial), with a rolling **7-day trial** that auto-disables packages left unused
- **Mirror search** — a standalone palette (⌕ button / `Ctrl/Cmd+Shift+D`) that searches nodes belonging to currently-disabled packages, draws an imitation node box (real inputs/widgets/outputs, parsed from source), and re-enables the pack on the spot - **Mirror search** — a standalone palette (⌕ button / `Ctrl/Cmd+Shift+D`) that searches nodes belonging to currently-disabled packages, draws an imitation node box (real inputs/widgets/outputs, parsed from source), and re-enables the pack on the spot
- **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution - **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution
@@ -81,21 +82,35 @@ package, plus a **Disable all** button per section. Disabling:
If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before. If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before.
> **Manager compatibility:** works with both the standalone
> [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) custom node and
> ComfyUI core's built-in manager. For the built-in manager, launch ComfyUI with
> `--enable-manager-legacy-ui` (the extension detects the active manager and its
> API automatically). Without a reachable manager, all enable/disable/install
> actions are simply omitted.
### Workflow tab & temporary enable ### Workflow tab & temporary enable
Whenever you load a workflow, the extension scans for node types the running Whenever you load a workflow, the extension scans for node types the running
ComfyUI can't resolve and, if any are found, opens the dialog on the **Workflow** ComfyUI can't resolve and, if any are found, opens the dialog on the **Workflow**
tab. Unresolved nodes are split into two groups: tab. Unresolved nodes are split into two groups:
- **Missing** — the owning package isn't installed. Install is handled by - **Missing** — the owning package isn't installed. Each row offers:
[ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) like always: the - **Install 7d** — really install the package (via
**Install** button opens Manager's Custom Nodes Manager (use its *Missing* [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager)) and start a
filter). *temporary trial*, so trying out someone else's workflow stays
non-committal — anything you don't actually use auto-disables.
- **Install** — install permanently (no trial).
Both take effect after a ComfyUI restart. If the install can't be resolved or
Manager refuses it (e.g. a blocked git URL), the buttons fall back to opening
Manager's Custom Nodes Manager (use its *Missing* filter).
- **Disabled** — the package is installed but currently disabled. Each row offers: - **Disabled** — the package is installed but currently disabled. Each row offers:
- **Enable 7d** — re-enable the package and start a *temporary trial*. - **Enable 7d** — re-enable the package and start a *temporary trial*.
- **Enable** — re-enable permanently (no trial). - **Enable** — re-enable permanently (no trial).
**The temporary trial** is a rolling budget of **7 distinct boot-days**. A **The temporary trial** (started by either *Install 7d* or *Enable 7d*) is a
rolling budget of **7 distinct boot-days**. A
"boot-day" is counted at most once per calendar day, the first time ComfyUI "boot-day" is counted at most once per calendar day, the first time ComfyUI
starts that day — so the clock measures days you actually run ComfyUI, not wall starts that day — so the clock measures days you actually run ComfyUI, not wall
time. **Any execution that uses the package resets the counter to zero.** If a time. **Any execution that uses the package resets the counter to zero.** If a
+95
View File
@@ -7,6 +7,7 @@ from server import PromptServer
from .mapper import NodePackageMapper, ModelMapper from .mapper import NodePackageMapper, ModelMapper
from .node_introspect import find_disabled_pack_path, get_node_schema from .node_introspect import find_disabled_pack_path, get_node_schema
from .pack_fs import disable_pack_native, enable_pack_native, list_disabled_packs
from .tracker import UsageTracker from .tracker import UsageTracker
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -144,6 +145,100 @@ async def get_node_schema_route(request):
return web.json_response({"error": "internal error"}, status=500) return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/native-disable")
async def native_disable(request):
"""Disable a pack by moving it into custom_nodes/.disabled/ (no Manager).
Fallback for packs ComfyUI Manager doesn't manage. A restart is required for
ComfyUI to unload the pack.
"""
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
ok, message = await asyncio.get_event_loop().run_in_executor(
None, disable_pack_native, package
)
if not ok:
return web.json_response({"status": "error", "message": message}, status=409)
mapper.invalidate()
return web.json_response({"status": "ok", "message": message})
except Exception:
logger.error("nodes-stats: error in native disable", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/native-enable")
async def native_enable(request):
"""Re-enable a pack by moving it out of custom_nodes/.disabled/ (no Manager)."""
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
ok, message = await asyncio.get_event_loop().run_in_executor(
None, enable_pack_native, package
)
if not ok:
return web.json_response({"status": "error", "message": message}, status=409)
mapper.invalidate()
return web.json_response({"status": "ok", "message": message})
except Exception:
logger.error("nodes-stats: error in native enable", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/disabled-packs")
async def get_disabled_packs(request):
"""List packs present in custom_nodes/.disabled/ (re-enable candidates)."""
try:
names = await asyncio.get_event_loop().run_in_executor(None, list_disabled_packs)
return web.json_response(sorted(names))
except Exception:
logger.error("nodes-stats: error listing disabled packs", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/whitelist")
async def get_whitelist(request):
try:
return web.json_response(sorted(tracker.get_whitelist()))
except Exception:
logger.error("nodes-stats: error getting whitelist", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/whitelist/add")
async def whitelist_add(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.add_to_whitelist(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error adding to whitelist", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/whitelist/remove")
async def whitelist_remove(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.remove_from_whitelist(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error removing from whitelist", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/trials") @routes.get("/nodes-stats/trials")
async def get_trials(request): async def get_trials(request):
try: try:
@@ -0,0 +1,133 @@
# Design: Trial-install (7-day) for missing nodes on workflow load
Date: 2026-06-22
Status: Approved
## Summary
Extend the existing 7-day rolling trial from *disabled* packages to *missing*
(not-installed) ones. When a loaded workflow references node types whose owning
pack isn't installed, the Workflow tab's **Missing** section gains:
- **Install 7d** — really install the pack via ComfyUI Manager, then register a
7-day trial. If it isn't used (executed) within 7 distinct boot-days, it
auto-disables (moves to `.disabled`, reversible) via the existing expiry path.
- **Install** — really install the pack permanently (no trial).
This makes trying out someone else's workflow non-committal: pull in what it
needs, and anything you don't actually use gets parked in `.disabled` instead of
permanently bloating the install (which slows boot — the whole point of this
extension).
## Decisions (from clarification)
- **Expiry action:** auto-**disable** (not uninstall). This is free: the trial
table is keyed by package and `processExpiredTrials()` already disables any
expired trial pack and clears its row. No new expiry code.
- **Missing-row actions:** both **Install 7d** and **Install** (permanent),
symmetric with the Disabled section's Enable 7d / Enable. Both perform real
installs from our UI; the current "open ComfyUI Manager" behavior becomes the
failure fallback.
## Current state it builds on (v1.6.0, verified)
- `tracker.py`: `trial_packages` table + `start_trial` / `tick_boot_days` /
`reset_trials_for` / `stop_trial` / `get_trials`. Boot tick + usage reset +
routes (`/nodes-stats/trials[/start|/stop]`) wired in `__init__.py`.
- `js/nodes_stats.js`:
- `classifyUnresolved()` (≈624) → `{ disabled[], missing[] }`; missing entries
are `{ type, pkg: <getmappings packKey> }` where packKey is a dir name,
registry id, or repo/gist URL.
- `handleInstall(pkg, dialog)` (≈1155) currently only opens Manager's Custom
Nodes Manager.
- `runManagerEnable(payload)` (≈701) = reset → POST `/manager/queue/install`
→ start → `waitForQueue` (install and enable share this endpoint; only the
payload differs).
- `processExpiredTrials()` (≈1192) disables expired trial packs via Manager,
then `stopTrial`.
- `fetchManagerInfo()` (≈483) returns installed/disabled packs only — it
**skips** `state==="not-installed"`, so registry data for missing packs is
not in hand and must be fetched separately.
## Install resolution (the only real new logic)
Not-installed `getlist` entries expose enough to install (verified live):
- CNR pack: `id` = cnr_id, `version` = semver, `files` = `[git url]`.
- Git-only pack: no `id`, `version` = `"unknown"`, `files` = `[git url]`.
Plan:
1. **`resolveInstallTarget(packKey)`** — fetch
`/customnode/getlist?mode=local&skip_update=true`, index `not-installed`
entries by every identifier (key, `id`, `files`, `repository`) with the same
normalize used in `classifyUnresolved` (lowercase, strip trailing `/`,
`.git`), and return the matching entry (or null).
2. **`installPayload(entry)`** — mirror Manager's installNodes:
`{ id: entry.id || <key>, version: entry.version, files: entry.files,
channel:"default", mode:"cache", selected_version: entry.version==="unknown"
? "unknown" : "latest", skip_post_install:false, ui_id:<key> }`.
3. Install via the existing `runManagerEnable(payload)` (same queue endpoint).
4. **`findInstalledDir(entry)`** — re-fetch getlist; find the now-installed entry
matching `entry` by id/files/repo; its **key is the directory name** (needed
to key the trial). Fallback: repo basename of `files[0]` (strip `.git`).
5. For Install 7d: `POST /nodes-stats/trials/start { package: <dir name> }`.
6. Show the restart banner: "Installed X — restart to load it" (+ " for a 7-day
trial").
## Components (all in `js/nodes_stats.js`)
- `resolveInstallTarget(packKey)`, `installPayload(entry)`, `findInstalledDir(entry)`
— small, mostly-pure helpers.
- `handleTrialInstall(pkg, dialog, temporary)` — orchestrates resolve → install →
dir discovery → (trial start if temporary) → restart banner; on any failure
falls back to the existing open-Manager behavior of `handleInstall`.
- Missing rows render `[Install 7d]` + `[Install]`; wire them in
`wireWorkflowButtons`. Reuse `setWorkflowButtonsBusy`, `notify`,
`showRestartBanner`, `managerIsBusy`.
## Data flow
```
Workflow tab (missing row)
Install 7d -> resolveInstallTarget(packKey) -> installPayload
-> runManagerEnable (POST install, start, wait)
-> findInstalledDir -> trials/start{dir}
-> restart banner "installed for 7-day trial"
Install -> same, minus trials/start
later boots -> tracker.tick_boot_days (distinct days)
use in prompt-> tracker.reset_trials_for (counter -> 0)
expiry -> processExpiredTrials (UI load) -> Manager disable + stopTrial
```
## Error handling
- ComfyUI Manager absent → Missing actions inert (as today).
- `resolveInstallTarget` miss, install HTTP error, or git-url install blocked by
Manager security level → toast + fall back to `handleInstall`'s open-Manager
guidance. No crash.
- `findInstalledDir` returns nothing (install didn't land) → don't register a
trial; toast "installed; couldn't register trial — enable/disable manually".
- `managerIsBusy()` → ask the user to retry (same guard as enable/disable).
## Testing
- Backend unchanged → no new pytest; existing `tests/` stays green.
- Pure helpers (`resolveInstallTarget` matching, `installPayload`,
`findInstalledDir` matching) written standalone; `node --check` for syntax.
- Manual: load a workflow needing a not-installed CNR pack → Install 7d installs
it, `/nodes-stats/trials` shows the resulting dir, restart banner appears;
Install (permanent) installs without a trial row; a git-url-only/blocked pack
falls back to opening Manager; Manager-absent path inert.
## Files touched
- `js/nodes_stats.js` — resolution helpers, `handleTrialInstall`, Missing-row
buttons + wiring.
- `README.md`, `pyproject.toml` — docs + version bump (1.6.0 → 1.7.0).
## Out of scope (YAGNI)
- Auto-uninstall on expiry (chose disable; reversible + free).
- Bulk "install all missing 7d" (start per-row; revisit if wanted).
- Replicating Manager's full dependency/security UX (fall back to Manager).
+246
View File
@@ -0,0 +1,246 @@
# 7-Day Trial-Install for Missing Nodes — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Let the Workflow tab really install a workflow's missing (not-installed) packs — either permanently or on the existing 7-day rolling trial that auto-disables unused packs.
**Architecture:** Frontend-only (`js/nodes_stats.js`). Resolve a missing pack's install spec from its not-installed `getlist` entry, install via ComfyUI Manager's queue (reusing `runManagerEnable`, which posts to `/manager/queue/install`), discover the resulting directory name by re-reading `getlist`, and register a trial via the existing `/nodes-stats/trials/start`. Expiry (auto-disable) is already handled by `processExpiredTrials()` — no new expiry code. On any failure, fall back to the current open-Manager behavior.
**Tech Stack:** Vanilla JS ComfyUI extension; ComfyUI Manager HTTP endpoints; the trial machinery already shipped in v1.6.0.
**Design doc:** `docs/plans/2026-06-22-trial-install-design.md`
**Testing note:** No JS test harness. After every edit run:
`cp js/nodes_stats.js /tmp/c.mjs && node --check /tmp/c.mjs && echo OK && rm /tmp/c.mjs`
Behavior is verified manually in ComfyUI (hard-refresh after each change).
**Confirmed reuse points (v1.6.0, real line numbers):** `classifyUnresolved` (≈624, builds `missing[]`), `buildWorkflowTabContent` Missing section (≈410-422), `wireWorkflowButtons` (≈698), `handleInstall` (≈1155, open-Manager fallback), `runManagerEnable` (≈701, install-queue helper), `processExpiredTrials` (≈1192), `setWorkflowButtonsBusy` (≈1172), `managerIsBusy`, `showRestartBanner`, `notify`, `escapeHtml`, `escapeAttr`.
---
### Task 1: Shared `normKey` + install-resolution helpers
**Files:** Modify `js/nodes_stats.js` (add helpers near `classifyUnresolved`, ~line 620).
**Step 1: Add a module-level `normKey`** (and point `classifyUnresolved`'s local `norm` at it for DRY):
```js
// Normalize an identifier (dir name, registry id, or repo URL) for matching.
function normKey(s) {
return String(s).trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
}
```
In `classifyUnresolved`, replace the local
`const norm = (s) => String(s).trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();`
with `const norm = normKey;`.
**Step 2: Add the resolver, payload builder, and dir finder:**
```js
// Find the not-installed getlist entry for a missing pack key (from getmappings).
// Returns { key, id?, version, files, repository, ... } or null.
async function resolveInstallTarget(packKey) {
let packs;
try {
const r = await fetch("/customnode/getlist?mode=local&skip_update=true");
if (!r.ok) return null;
packs = (await r.json()).node_packs;
} catch { return null; }
if (!packs) return null;
const want = normKey(packKey);
for (const [key, v] of Object.entries(packs)) {
if (!v || v.state !== "not-installed") continue;
const ids = [key, v.id, v.repository, ...(v.files || [])].filter(Boolean).map(normKey);
if (ids.includes(want)) return { key, ...v };
}
return null;
}
// Build the /manager/queue/install payload, mirroring Manager's installNodes.
function installPayload(entry, packKey) {
const unknown = !entry.version || entry.version === "unknown";
const id = entry.id || packKey;
return {
id, version: entry.version || "unknown", files: entry.files,
channel: "default", mode: "cache",
selected_version: unknown ? "unknown" : "latest",
skip_post_install: false, ui_id: id,
};
}
// After install, find the now-installed directory name (the trial key) by
// re-reading getlist and matching the entry; fall back to the repo basename.
async function findInstalledDir(entry) {
let packs = null;
try {
const r = await fetch("/customnode/getlist?mode=local&skip_update=true");
if (r.ok) packs = (await r.json()).node_packs;
} catch { /* fall through to basename */ }
if (packs) {
const want = [entry.id, entry.repository, ...(entry.files || [])].filter(Boolean).map(normKey);
for (const [key, v] of Object.entries(packs)) {
if (!v || v.state === "not-installed") continue;
const cand = [key, v.id, v.repository, ...(v.files || [])].filter(Boolean).map(normKey);
if (cand.some((c) => want.includes(c))) return key; // key = directory name
}
}
const url = (entry.files && entry.files[0]) || entry.repository || "";
return url.replace(/\/+$/, "").replace(/\.git$/i, "").split("/").pop() || null;
}
```
**Step 3: Verify syntax** — run the `node --check` line. Expected `OK`.
**Step 4: Verify resolver (browser console, after hard-refresh)** — pick a known not-installed pack key from `await (await fetch('/customnode/getlist?mode=local&skip_update=true')).json()` and confirm `resolveInstallTarget(key)` returns it. Expected: the entry with `files`/`version`.
**Step 5: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(install): install-target resolution helpers for missing packs"
```
---
### Task 2: `handleTrialInstall` orchestrator
**Files:** Modify `js/nodes_stats.js` (add next to `handleInstall`, ~line 1155).
**Step 1: Add the orchestrator** (keep `handleInstall` as the fallback):
```js
// Real install of a missing pack, optionally on a 7-day trial. Resolves the
// install spec, installs via Manager, discovers the resulting directory, and
// (for a trial) registers it. Falls back to opening Manager on any failure.
async function handleTrialInstall(pkg, dialog, temporary) {
if (await managerIsBusy()) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
return;
}
const target = await resolveInstallTarget(pkg);
if (!target) { await handleInstall(pkg, dialog); return; }
setWorkflowButtonsBusy(dialog, true);
try {
await runManagerEnable(installPayload(target, pkg)); // shared /manager/queue/install flow
const dir = temporary ? await findInstalledDir(target) : null;
if (temporary && dir) {
await fetch("/nodes-stats/trials/start", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: dir }),
});
}
showRestartBanner(dialog);
if (temporary && !dir) {
notify(`Installed ${pkg}, but couldn't register the trial — manage it manually. Restart to load.`, "warn");
} else {
notify(`Installed ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to load it.`, "success");
}
} catch (e) {
notify("Install failed: " + e.message + " — opening ComfyUI Manager.", "warn");
await handleInstall(pkg, dialog);
} finally {
setWorkflowButtonsBusy(dialog, false);
}
}
```
**Step 2: Verify syntax**`node --check`. Expected `OK`.
**Step 3: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(install): handleTrialInstall orchestrator with Manager fallback"
```
---
### Task 3: Missing rows — `Install 7d` + `Install` buttons
**Files:** Modify `js/nodes_stats.js``buildWorkflowTabContent` Missing section (~410-422).
**Step 1: Update the subtitle and the action cell.** Replace the section header subtitle "Not installed — install via ComfyUI Manager" with "Not installed — install, optionally on a 7-day trial", and replace the single-button cell:
```js
<td style="padding:6px 8px;text-align:right;white-space:nowrap;">
${m.pkg
? `<button class="ns-btn ns-install-temp-btn" data-pkg="${escapeAttr(m.pkg)}">Install 7d</button>
<button class="ns-btn ns-install-perm-btn" data-pkg="${escapeAttr(m.pkg)}" style="margin-left:6px;">Install</button>`
: "&mdash;"}
</td>
```
**Step 2: Verify syntax**`node --check`. Expected `OK`.
**Step 3: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(install): Missing rows offer Install 7d + Install"
```
---
### Task 4: Wire the new buttons + busy state
**Files:** Modify `js/nodes_stats.js``wireWorkflowButtons` (~698) and `setWorkflowButtonsBusy` (~1172).
**Step 1:** In `wireWorkflowButtons`, replace the `.ns-install-btn` wiring with:
```js
dialog.querySelectorAll(".ns-install-temp-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleTrialInstall(b.dataset.pkg, dialog, true); }));
dialog.querySelectorAll(".ns-install-perm-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleTrialInstall(b.dataset.pkg, dialog, false); }));
```
**Step 2:** In `setWorkflowButtonsBusy`, update the selector to the new classes:
```js
dialog.querySelectorAll(".ns-enable-temp-btn, .ns-enable-perm-btn, .ns-install-temp-btn, .ns-install-perm-btn").forEach((b) => {
b.disabled = busy;
});
```
**Step 3: Verify syntax**`node --check`. Expected `OK`.
**Step 4: Verify (manual)** — hard-refresh; load a workflow that needs a not-installed **CNR** pack (small one, e.g. a simple utility pack). Click **Install 7d** → it installs, restart banner appears, and `await (await fetch('/nodes-stats/trials')).json()` lists the installed dir. Click **Install** on another → installs, no trial row. Try a pack that fails / git-url-blocked → falls back to opening Manager with a toast.
**Step 5: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(install): wire Install 7d / Install buttons"
```
---
### Task 5: Docs + version bump
**Files:** Modify `README.md`, `pyproject.toml`.
**Step 1:** In the README's Workflow-tab section, document that Missing nodes can now be installed permanently or on a 7-day trial (auto-disables if unused, takes effect after restart, falls back to ComfyUI Manager on failure). Update any feature bullet.
**Step 2:** Bump `version` in `pyproject.toml` from `1.6.0` to `1.7.0`.
**Step 3: Verify**`python -m pytest -q` (unchanged, green) and the `node --check` line (`OK`).
**Step 4: Commit**
```bash
git add README.md pyproject.toml
git commit -m "docs: document 7-day trial-install for missing nodes; bump to 1.7.0"
```
---
## Done criteria
- Workflow tab Missing rows show **Install 7d** and **Install**.
- Install 7d really installs the pack (CNR via registry; git-url where Manager allows) and registers a trial keyed by the installed directory; Install does the same without a trial.
- Unused trial-installed packs auto-disable on a later UI load via the existing `processExpiredTrials` (no new expiry code); using one resets its 7-day counter.
- Failures (unresolvable spec, HTTP error, git-url blocked, Manager absent) fall back to opening ComfyUI Manager — no crash.
- `python -m pytest -q` green; `node --check` clean.
```
+478 -67
View File
@@ -24,9 +24,35 @@ const STATUS_META = {
// Tiers that may offer a "Disable" action (when ComfyUI Manager is available). // Tiers that may offer a "Disable" action (when ComfyUI Manager is available).
const DISABLEABLE_TIERS = new Set(["safe_to_remove", "consider_removing"]); const DISABLEABLE_TIERS = new Set(["safe_to_remove", "consider_removing"]);
// Setting id for the auto-open-on-load behavior. Defaults to off so loading a
// workflow with missing/disabled nodes no longer pops the dialog every time.
const SETTING_AUTO_OPEN = "comfyui.nodes_stats.autoOpenOnLoad";
function autoOpenEnabled() {
try {
const v = app.extensionManager?.setting?.get(SETTING_AUTO_OPEN);
if (v !== undefined) return !!v;
return !!app.ui?.settings?.getSettingValue?.(SETTING_AUTO_OPEN, false);
} catch {
return false;
}
}
app.registerExtension({ app.registerExtension({
name: "comfyui.nodes_stats", name: "comfyui.nodes_stats",
settings: [
{
id: SETTING_AUTO_OPEN,
name: "Auto-open Node Stats on workflow load",
tooltip:
"Pop the Node Stats dialog (Workflow tab) automatically when a loaded workflow has missing or disabled nodes. Off by default — open it manually from the toolbar instead.",
type: "boolean",
defaultValue: false,
category: ["Node Stats", "Behavior", "Auto-open on load"],
},
],
async setup() { async setup() {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.innerHTML = STATS_ICON; btn.innerHTML = STATS_ICON;
@@ -93,9 +119,16 @@ function unresolvedNodeTypes() {
// Latest workflow scan, shared so showStatsDialog can render the Workflow tab. // Latest workflow scan, shared so showStatsDialog can render the Workflow tab.
let _lastWorkflowScan = { disabled: [], missing: [] }; 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() { async function onWorkflowLoaded() {
const types = unresolvedNodeTypes(); const types = unresolvedNodeTypes();
_lastWorkflowScan = await classifyUnresolved(types); _lastWorkflowScan = await classifyUnresolved(types);
// Only auto-open when the user has opted in; the scan is still kept up to date
// so the Workflow tab is accurate when opened manually from the toolbar.
if (!autoOpenEnabled()) return;
if (_lastWorkflowScan.disabled.length || _lastWorkflowScan.missing.length) { if (_lastWorkflowScan.disabled.length || _lastWorkflowScan.missing.length) {
showStatsDialog("workflow"); // auto-open on the Workflow tab showStatsDialog("workflow"); // auto-open on the Workflow tab
} }
@@ -104,12 +137,14 @@ async function onWorkflowLoaded() {
async function showStatsDialog(initialTab = "nodes") { async function showStatsDialog(initialTab = "nodes") {
let data, modelData, managerInfo, trials = []; let data, modelData, managerInfo, trials = [];
try { 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/packages"),
fetch("/nodes-stats/models"), fetch("/nodes-stats/models"),
fetchManagerInfo(), fetchManagerInfo(),
fetch("/nodes-stats/trials").catch(() => null), fetch("/nodes-stats/trials").catch(() => null),
fetchDisabledPacks(),
]); ]);
_disabledPacksSet = disabledPacks;
if (!pkgResp.ok) { alert("Failed to load node stats: HTTP " + pkgResp.status); return; } 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; } if (!modelResp.ok) { alert("Failed to load model stats: HTTP " + modelResp.status); return; }
data = await pkgResp.json(); data = await pkgResp.json();
@@ -219,6 +254,8 @@ async function showStatsDialog(initialTab = "nodes") {
wireDisableButtons(dialog, managerInfo); wireDisableButtons(dialog, managerInfo);
wireWorkflowButtons(dialog); wireWorkflowButtons(dialog);
wireWhitelistButtons(dialog);
wireNativeEnableButtons(dialog);
switchTab(TABS.includes(initialTab) ? initialTab : "nodes"); switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
@@ -256,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: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-btn:disabled{opacity:0.5;cursor:default;}
#nodes-stats-dialog .ns-disable-all-btn{border-color:#a33;color:#e88;} #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} ${rows}
</style>`; </style>`;
} }
@@ -277,7 +315,11 @@ function summaryBar(items) {
} }
function buildNodesTabContent(custom, managerInfo) { 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 safeToRemove = byStatus("safe_to_remove");
const considerRemoving = byStatus("consider_removing"); const considerRemoving = byStatus("consider_removing");
const unusedNew = byStatus("unused_new"); const unusedNew = byStatus("unused_new");
@@ -291,6 +333,7 @@ function buildNodesTabContent(custom, managerInfo) {
{ count: used.length, status: "used", label: "used", id: "nodes-stats-used-badge" }, { 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("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("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); html += renderSection("Recently Unused", "Unused for less than 1 month", "unused_new", unusedNew, managerInfo);
@@ -304,8 +347,12 @@ function renderSection(title, subtitle, status, packages, managerInfo) {
if (packages.length === 0) return ""; if (packages.length === 0) return "";
const color = STATUS_META[status].color; const color = STATUS_META[status].color;
const withActions = !!managerInfo && DISABLEABLE_TIERS.has(status); // Disable actions no longer require Manager: an installed pack Manager doesn't
const eligible = withActions // 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) ? packages.filter((p) => isDisableEligible(p, managerInfo)).map((p) => p.package)
: []; : [];
@@ -323,12 +370,48 @@ function renderSection(title, subtitle, status, packages, managerInfo) {
return html; return html;
} }
// A package can be disabled only if ComfyUI Manager knows it (by directory // How a package can be disabled: "manager" when ComfyUI Manager knows it (by
// name) and it is currently active (any state other than already-disabled). // 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) { function isDisableEligible(pkg, managerInfo) {
if (!managerInfo || !pkg.installed) return false; return disableMode(pkg, managerInfo) !== null;
const info = managerInfo[pkg.package]; }
return !!(info && info.state && info.state !== "disabled");
// 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) { function buildModelsTabContent(modelData) {
@@ -408,14 +491,17 @@ function buildWorkflowTabContent({ disabled, missing }, trials) {
html += `</tbody></table>`; html += `</tbody></table>`;
} }
if (missing.length) { if (missing.length) {
html += sectionHeader("Missing", "Not installed — install via ComfyUI Manager", "#e44"); html += sectionHeader("Missing", "Not installed — install, optionally on a 7-day trial", "#e44");
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"><tbody>`; html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"><tbody>`;
for (const m of missing) { for (const m of missing) {
html += `<tr class="ns-row-safe_to_remove" style="border-bottom:1px solid #222;"> html += `<tr class="ns-row-safe_to_remove" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(m.type)}</td> <td style="padding:6px 8px;color:#fff;">${escapeHtml(m.type)}</td>
<td style="padding:6px 8px;color:#888;">${m.pkg ? escapeHtml(m.pkg) : "unknown"}</td> <td style="padding:6px 8px;color:#888;">${m.pkg ? escapeHtml(m.pkg) : "unknown"}</td>
<td style="padding:6px 8px;text-align:right;"> <td style="padding:6px 8px;text-align:right;white-space:nowrap;">
${m.pkg ? `<button class="ns-btn ns-install-btn" data-pkg="${escapeAttr(m.pkg)}">Install</button>` : "&mdash;"} ${m.pkg
? `<button class="ns-btn ns-install-temp-btn" data-pkg="${escapeAttr(m.pkg)}">Install 7d</button>
<button class="ns-btn ns-install-perm-btn" data-pkg="${escapeAttr(m.pkg)}" style="margin-left:6px;">Install</button>`
: "&mdash;"}
</td></tr>`; </td></tr>`;
} }
html += `</tbody></table>`; html += `</tbody></table>`;
@@ -430,6 +516,15 @@ function sectionHeader(title, subtitle, color) {
return html; 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) { function buildTable(packages, status, withActions, managerInfo) {
const colspan = withActions ? 7 : 6; const colspan = withActions ? 7 : 6;
@@ -448,19 +543,23 @@ function buildTable(packages, status, withActions, managerInfo) {
const hasNodes = pkg.nodes && pkg.nodes.length > 0; const hasNodes = pkg.nodes && pkg.nodes.length > 0;
const lastSeen = pkg.last_seen ? new Date(pkg.last_seen).toLocaleDateString() : "—"; 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;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.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.used_nodes}/${pkg.total_nodes}</td>
<td style="padding:6px 8px;text-align:right;">${pkg.total_executions}</td> <td style="padding:6px 8px;text-align:right;">${pkg.total_executions}</td>
<td style="padding:6px 8px;color:#888;">${lastSeen}</td>`; <td style="padding:6px 8px;color:#888;">${lastSeen}</td>`;
if (withActions) { if (withActions) {
const eligible = isDisableEligible(pkg, managerInfo); let cell;
const cell = eligible if (nativeEnableEligible(pkg)) {
? `<button class="ns-btn ns-disable-btn" data-pkg="${escapeAttr(pkg.package)}">Disable</button>` cell = `<button class="ns-btn ns-enable-native-btn" data-pkg="${escapeAttr(pkg.package)}" style="border-color:#3a6;color:#8d8;">Enable</button>`;
: `<span style="color:#555;">—</span>`; } 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 += `<td class="ns-action-cell" data-pkg="${escapeAttr(pkg.package)}" style="padding:6px 8px;text-align:right;">${cell}</td>`;
} }
html += `</tr>`; html += `</tr>`;
@@ -488,6 +587,43 @@ function buildTable(packages, status, withActions, managerInfo) {
// ComfyUI Manager integration: disable unused node packages // ComfyUI Manager integration: disable unused node packages
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ComfyUI Manager ships in two HTTP API generations we support:
// • v3 — the standalone git custom_nodes/ComfyUI-Manager. Unprefixed paths
// (/customnode/..., /manager/...) and per-item queue endpoints
// (/manager/queue/install, /manager/queue/disable).
// • v4-legacy — the `comfyui_manager` package bundled with ComfyUI core, run
// with `--enable-manager-legacy-ui`. Same routes under a /v2 prefix, but the
// per-item install/disable endpoints are gone: everything is queued through
// one /v2/manager/queue/batch call ({install:[...], disable:[...]}) that
// also starts the worker.
// We probe once (cheap queue/status, present in both) and cache which generation
// answers, so every later call targets the right prefix + transport. null means
// no Manager is reachable — the disable/enable/install UI is then omitted.
let _managerApiProbe = null;
function detectManagerApi() {
if (!_managerApiProbe) {
_managerApiProbe = (async () => {
for (const prefix of ["/v2", ""]) { // prefer v4-legacy; fall back to v3
try {
const r = await fetch(`${prefix}/manager/queue/status`);
if (r.ok) return { prefix, batch: prefix === "/v2" };
} catch { /* try next generation */ }
}
return null;
})();
}
return _managerApiProbe;
}
// Fetch a Manager endpoint on whichever generation is active, prefixing the path
// as needed. Returns the Response, or null when no Manager is reachable (callers
// treat null exactly like a failed/absent Manager).
async function mgrFetch(path, opts) {
const api = await detectManagerApi();
if (!api) return null;
return fetch(api.prefix + path, opts);
}
// Map of installed packages from ComfyUI Manager, keyed by directory name: // Map of installed packages from ComfyUI Manager, keyed by directory name:
// { <dir name>: { id, version, files, state }, ... } // { <dir name>: { id, version, files, state }, ... }
// We read the unified list (/customnode/getlist) rather than /customnode/installed // We read the unified list (/customnode/getlist) rather than /customnode/installed
@@ -499,8 +635,8 @@ function buildTable(packages, status, withActions, managerInfo) {
// omitted entirely. // omitted entirely.
async function fetchManagerInfo() { async function fetchManagerInfo() {
try { try {
const resp = await fetch("/customnode/getlist?mode=local&skip_update=true"); const resp = await mgrFetch("/customnode/getlist?mode=local&skip_update=true");
if (!resp.ok) return null; if (!resp || !resp.ok) return null;
const data = await resp.json(); const data = await resp.json();
const packs = data && data.node_packs; const packs = data && data.node_packs;
if (!packs || typeof packs !== "object") return null; if (!packs || typeof packs !== "object") return null;
@@ -586,8 +722,8 @@ async function ensureDisabledCatalog(forceRefresh = false) {
if (!managerInfo) return null; // Manager absent if (!managerInfo) return null; // Manager absent
let mappings = {}; let mappings = {};
try { try {
const r = await fetch("/customnode/getmappings?mode=local"); const r = await mgrFetch("/customnode/getmappings?mode=local");
if (r.ok) mappings = await r.json(); if (r && r.ok) mappings = await r.json();
} catch { /* fall through -> empty catalog */ } } catch { /* fall through -> empty catalog */ }
_disabledCatalog = buildDisabledCatalog(mappings, managerInfo); _disabledCatalog = buildDisabledCatalog(mappings, managerInfo);
return _disabledCatalog; return _disabledCatalog;
@@ -621,15 +757,20 @@ function filterCatalog(catalog, query, limit = 50) {
// Split unresolved node types into packages that are installed-but-disabled // Split unresolved node types into packages that are installed-but-disabled
// (re-enable to use) vs not installed (install via Manager). Reconciles // (re-enable to use) vs not installed (install via Manager). Reconciles
// ComfyUI Manager's getmappings (class_type -> pack key) against getlist state. // ComfyUI Manager's getmappings (class_type -> pack key) against getlist state.
// Normalize an identifier (dir name, registry id, or repo URL) for matching.
function normKey(s) {
return String(s).trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
}
async function classifyUnresolved(types) { async function classifyUnresolved(types) {
if (!types.length) return { disabled: [], missing: [] }; if (!types.length) return { disabled: [], missing: [] };
let mappings = {}, managerInfo = null; let mappings = {}, managerInfo = null;
try { try {
const [mResp, gi] = await Promise.all([ const [mResp, gi] = await Promise.all([
fetch("/customnode/getmappings?mode=local"), mgrFetch("/customnode/getmappings?mode=local"),
fetchManagerInfo(), // getlist -> {dir: {id, cnr_id, aux_id, version, files, state}} fetchManagerInfo(), // getlist -> {dir: {id, cnr_id, aux_id, version, files, state}}
]); ]);
if (mResp.ok) mappings = await mResp.json(); if (mResp && mResp.ok) mappings = await mResp.json();
managerInfo = gi; managerInfo = gi;
} catch { /* manager absent */ } } catch { /* manager absent */ }
@@ -643,7 +784,7 @@ async function classifyUnresolved(types) {
// Index installed/disabled packs by every identifier they expose (dir name, // Index installed/disabled packs by every identifier they expose (dir name,
// id, cnr_id, aux_id, and each repo URL) so a getmappings key in any of those // id, cnr_id, aux_id, and each repo URL) so a getmappings key in any of those
// forms resolves. URLs are normalized (drop trailing slash / .git, lowercase). // forms resolves. URLs are normalized (drop trailing slash / .git, lowercase).
const norm = (s) => String(s).trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); const norm = normKey;
const byAnyKey = {}; const byAnyKey = {};
if (managerInfo) for (const [dir, info] of Object.entries(managerInfo)) { if (managerInfo) for (const [dir, info] of Object.entries(managerInfo)) {
const rec = { ...info, _dir: dir }; const rec = { ...info, _dir: dir };
@@ -662,6 +803,57 @@ async function classifyUnresolved(types) {
return { disabled, missing }; return { disabled, missing };
} }
// Find the not-installed getlist entry for a missing pack key (from getmappings).
// Returns { key, id?, version, files, repository, ... } or null.
async function resolveInstallTarget(packKey) {
let packs;
try {
const r = await mgrFetch("/customnode/getlist?mode=local&skip_update=true");
if (!r || !r.ok) return null;
packs = (await r.json()).node_packs;
} catch { return null; }
if (!packs) return null;
const want = normKey(packKey);
for (const [key, v] of Object.entries(packs)) {
if (!v || v.state !== "not-installed") continue;
const ids = [key, v.id, v.repository, ...(v.files || [])].filter(Boolean).map(normKey);
if (ids.includes(want)) return { key, ...v };
}
return null;
}
// Build the /manager/queue/install payload, mirroring Manager's installNodes.
function installPayload(entry, packKey) {
const unknown = !entry.version || entry.version === "unknown";
const id = entry.id || packKey;
return {
id, version: entry.version || "unknown", files: entry.files,
channel: "default", mode: "cache",
selected_version: unknown ? "unknown" : "latest",
skip_post_install: false, ui_id: id,
};
}
// After install, find the now-installed directory name (the trial key) by
// re-reading getlist and matching the entry; fall back to the repo basename.
async function findInstalledDir(entry) {
let packs = null;
try {
const r = await mgrFetch("/customnode/getlist?mode=local&skip_update=true");
if (r && r.ok) packs = (await r.json()).node_packs;
} catch { /* fall through to basename */ }
if (packs) {
const want = [entry.id, entry.repository, ...(entry.files || [])].filter(Boolean).map(normKey);
for (const [key, v] of Object.entries(packs)) {
if (!v || v.state === "not-installed") continue;
const cand = [key, v.id, v.repository, ...(v.files || [])].filter(Boolean).map(normKey);
if (cand.some((c) => want.includes(c))) return key; // key = directory name
}
}
const url = (entry.files && entry.files[0]) || entry.repository || "";
return url.replace(/\/+$/, "").replace(/\.git$/i, "").split("/").pop() || null;
}
// Build the payload ComfyUI Manager's /manager/queue/disable expects, mirroring // Build the payload ComfyUI Manager's /manager/queue/disable expects, mirroring
// Manager's own frontend: id = directory name, version = install state // Manager's own frontend: id = directory name, version = install state
// ("nightly" / semver / "unknown"), and files (repo URL) only for "unknown". // ("nightly" / semver / "unknown"), and files (repo URL) only for "unknown".
@@ -674,8 +866,8 @@ function disablePayload(dirName, info) {
} }
function wireDisableButtons(dialog, managerInfo) { 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) => { dialog.querySelectorAll(".ns-disable-btn").forEach((btn) => {
btn.addEventListener("click", (e) => { btn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -700,52 +892,162 @@ function wireWorkflowButtons(dialog) {
b.addEventListener("click", (e) => { e.stopPropagation(); handleEnable(b.dataset.pkg, true, dialog); })); b.addEventListener("click", (e) => { e.stopPropagation(); handleEnable(b.dataset.pkg, true, dialog); }));
dialog.querySelectorAll(".ns-enable-perm-btn").forEach((b) => dialog.querySelectorAll(".ns-enable-perm-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleEnable(b.dataset.pkg, false, dialog); })); b.addEventListener("click", (e) => { e.stopPropagation(); handleEnable(b.dataset.pkg, false, dialog); }));
dialog.querySelectorAll(".ns-install-btn").forEach((b) => dialog.querySelectorAll(".ns-install-temp-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleInstall(b.dataset.pkg, dialog); })); b.addEventListener("click", (e) => { e.stopPropagation(); handleTrialInstall(b.dataset.pkg, dialog, true); }));
dialog.querySelectorAll(".ns-install-perm-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleTrialInstall(b.dataset.pkg, dialog, false); }));
}
// 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");
});
});
}
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) { async function handleDisable(pkgNames, dialog, managerInfo) {
// Only act on packages Manager still reports as active (guards against // Partition into packages Manager can disable (still reported active) vs. the
// double-clicks and stale buttons after a partial batch). // rest, which we disable natively by moving them into custom_nodes/.disabled.
pkgNames = pkgNames.filter((n) => managerInfo[n] && managerInfo[n].state !== "disabled"); // The eligible lists already excluded whitelisted/uninstalled packs.
if (pkgNames.length === 0) return; 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 what = pkgNames.length === 1 ? `"${pkgNames[0]}"` : `${pkgNames.length} packages`; 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 = 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 ` + `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; if (!confirm(confirmMsg)) return;
setDisableButtonsBusy(dialog, true); setDisableButtonsBusy(dialog, true);
const succeeded = [];
const failed = [];
try { try {
const pre = await fetch("/manager/queue/status").then((r) => (r.ok ? r.json() : null)).catch(() => null); 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) { if (pre && pre.is_processing) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn"); notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
setDisableButtonsBusy(dialog, false); failed.push(...managerNames);
return; } else {
} try {
await runManagerDisable(managerNames.map((n) => disablePayload(n, managerInfo[n])));
const payloads = pkgNames.map((n) => disablePayload(n, managerInfo[n])); // Reconcile against Manager's actual state: disabled only if no longer
await runManagerDisable(payloads); // reported as active on disk.
// 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 after = await fetchManagerInfo();
const isStillActive = (n) => after && after[n] && after[n].state !== "disabled"; const isStillActive = (n) => after && after[n] && after[n].state !== "disabled";
const succeeded = after ? pkgNames.filter((n) => !isStillActive(n)) : pkgNames; for (const n of managerNames) {
const failed = pkgNames.filter((n) => !succeeded.includes(n)); 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");
}
}
}
if (nativeNames.length) {
const res = await runNativeDisable(nativeNames);
succeeded.push(...res.succeeded);
failed.push(...res.failed);
}
succeeded.forEach((n) => { if (managerInfo[n]) managerInfo[n].state = "disabled"; });
markPackagesDisabled(dialog, succeeded); markPackagesDisabled(dialog, succeeded);
updateBulkButtons(dialog, managerInfo); updateBulkButtons(dialog, new Set(succeeded));
if (succeeded.length > 0) { if (succeeded.length > 0) {
showRestartBanner(dialog); showRestartBanner(dialog);
notify(`Disabled ${succeeded.length} package${succeeded.length !== 1 ? "s" : ""}. Restart ComfyUI to apply.`, "success"); notify(`Disabled ${succeeded.length} package${succeeded.length !== 1 ? "s" : ""}. Restart ComfyUI to apply.`, "success");
} }
if (failed.length > 0) { if (failed.length > 0) {
notify(`ComfyUI Manager could not disable: ${failed.join(", ")}`, "error"); notify(`Could not disable: ${failed.join(", ")}`, "error");
} }
} catch (e) { } catch (e) {
notify("Failed to disable: " + e.message, "error"); notify("Failed to disable: " + e.message, "error");
@@ -754,9 +1056,52 @@ 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).
async function runManagerBatch(api, body) {
await fetch(api.prefix + "/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } });
const r = await fetch(api.prefix + "/manager/queue/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`queue batch failed (HTTP ${r.status})`);
await waitForQueue();
}
// Queue the disable tasks and run them, then wait for the Manager worker to // Queue the disable tasks and run them, then wait for the Manager worker to
// finish. /manager/queue/start returns 201 if a worker is already running. // finish. v4-legacy batches every item through one /v2/manager/queue/batch call;
// v3 posts each item to /manager/queue/disable then /manager/queue/start (which
// returns 201 if a worker is already running).
async function runManagerDisable(payloads) { async function runManagerDisable(payloads) {
const api = await detectManagerApi();
if (!api) throw new Error("ComfyUI Manager not available");
if (api.batch) {
await runManagerBatch(api, { disable: payloads });
return;
}
await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } }); await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } });
for (const payload of payloads) { for (const payload of payloads) {
@@ -803,8 +1148,8 @@ function enablePayload(dirName, info) {
// the same way before calling runManagerDisable). // the same way before calling runManagerDisable).
async function managerIsBusy() { async function managerIsBusy() {
try { try {
const r = await fetch("/manager/queue/status"); const r = await mgrFetch("/manager/queue/status");
if (!r.ok) return false; if (!r || !r.ok) return false;
const st = await r.json(); const st = await r.json();
return !!(st && st.is_processing); return !!(st && st.is_processing);
} catch { } catch {
@@ -812,7 +1157,17 @@ async function managerIsBusy() {
} }
} }
// Install a pack or re-enable a disabled one (both go through Manager's install
// queue). v4-legacy batches it as {install:[payload]}; v3 posts the payload to
// /manager/queue/install then /manager/queue/start.
async function runManagerEnable(payload) { async function runManagerEnable(payload) {
const api = await detectManagerApi();
if (!api) throw new Error("ComfyUI Manager not available");
if (api.batch) {
await runManagerBatch(api, { install: [payload] });
return;
}
await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } }); await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } });
const r = await fetch("/manager/queue/install", { const r = await fetch("/manager/queue/install", {
@@ -1169,8 +1524,43 @@ async function handleInstall(pkg, dialog) {
); );
} }
// Real install of a missing pack, optionally on a 7-day trial. Resolves the
// install spec, installs via Manager, discovers the resulting directory, and
// (for a trial) registers it. Falls back to opening Manager on any failure.
async function handleTrialInstall(pkg, dialog, temporary) {
if (await managerIsBusy()) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
return;
}
const target = await resolveInstallTarget(pkg);
if (!target) { await handleInstall(pkg, dialog); return; }
setWorkflowButtonsBusy(dialog, true);
try {
await runManagerEnable(installPayload(target, pkg)); // shared /manager/queue/install flow
const dir = temporary ? await findInstalledDir(target) : null;
if (temporary && dir) {
await fetch("/nodes-stats/trials/start", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: dir }),
});
}
showRestartBanner(dialog);
if (temporary && !dir) {
notify(`Installed ${pkg}, but couldn't register the trial — manage it manually. Restart to load.`, "warn");
} else {
notify(`Installed ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to load it.`, "success");
}
} catch (e) {
notify("Install failed: " + e.message + " — opening ComfyUI Manager.", "warn");
await handleInstall(pkg, dialog);
} finally {
setWorkflowButtonsBusy(dialog, false);
}
}
function setWorkflowButtonsBusy(dialog, busy) { function setWorkflowButtonsBusy(dialog, busy) {
dialog.querySelectorAll(".ns-enable-temp-btn, .ns-enable-perm-btn, .ns-install-btn").forEach((b) => { dialog.querySelectorAll(".ns-enable-temp-btn, .ns-enable-perm-btn, .ns-install-temp-btn, .ns-install-perm-btn").forEach((b) => {
b.disabled = busy; b.disabled = busy;
}); });
} }
@@ -1196,26 +1586,39 @@ async function processExpiredTrials() {
if (r.ok) trials = await r.json(); if (r.ok) trials = await r.json();
} catch { return; } } 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; if (!expired.length) return;
const mgr = await fetchManagerInfo(); 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 // 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. // (e.g. startup install work); the expired rows persist and retry next session.
if (await managerIsBusy()) return; if (mgr && await managerIsBusy()) return;
const done = []; const done = [];
for (const t of expired) { for (const t of expired) {
const info = mgr[t.package]; const info = mgr && mgr[t.package];
if (!info || info.state === "disabled") { if (info && info.state === "disabled") {
await stopTrial(t.package); await stopTrial(t.package);
done.push(t.package); done.push(t.package);
continue; continue;
} }
try { try {
if (info) {
await runManagerDisable([disablePayload(t.package, 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); await stopTrial(t.package);
done.push(t.package); done.push(t.package);
} catch { /* keep the row; retry next session */ } } catch { /* keep the row; retry next session */ }
@@ -1232,8 +1635,8 @@ async function waitForQueue(timeoutMs = 60000) {
while (Date.now() < deadline) { while (Date.now() < deadline) {
let st = null; let st = null;
try { try {
const r = await fetch("/manager/queue/status"); const r = await mgrFetch("/manager/queue/status");
if (r.ok) st = await r.json(); if (r && r.ok) st = await r.json();
} catch { /* transient; retry */ } } catch { /* transient; retry */ }
if (st && !st.is_processing && st.in_progress_count === 0) return; if (st && !st.is_processing && st.in_progress_count === 0) return;
await sleep(500); await sleep(500);
@@ -1259,13 +1662,13 @@ function markPackagesDisabled(dialog, pkgNames) {
} }
} }
// Recompute "Disable all (N)" counts after a batch; hide buttons with nothing // Recompute "Disable all (N)" counts after a batch, dropping packages that were
// left to disable. // just disabled; hide buttons with nothing left. Mode-agnostic (Manager+native).
function updateBulkButtons(dialog, managerInfo) { function updateBulkButtons(dialog, succeededSet) {
dialog.querySelectorAll(".ns-disable-all-btn").forEach((btn) => { dialog.querySelectorAll(".ns-disable-all-btn").forEach((btn) => {
let names = []; let names = [];
try { names = JSON.parse(btn.dataset.pkgs); } catch { 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) { if (remaining.length === 0) {
btn.style.display = "none"; btn.style.display = "none";
} else { } else {
@@ -1297,10 +1700,18 @@ function showRestartBanner(dialog) {
} }
async function rebootComfy() { 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; if (!confirm("Restart ComfyUI now? The server will go down briefly and the page will reconnect.")) return;
notify("Restarting ComfyUI…", "info"); notify("Restarting ComfyUI…", "info");
try { try {
await fetch("/manager/reboot", { method: "POST", headers: { "Content-Type": "application/json" } }); await mgrFetch("/manager/reboot", { method: "POST", headers: { "Content-Type": "application/json" } });
} catch { } catch {
// The reboot tears down the connection, so a network error here is expected. // The reboot tears down the connection, so a network error here is expected.
} }
+138
View File
@@ -0,0 +1,138 @@
"""Native enable/disable of custom-node packages, independent of ComfyUI Manager.
Disabling a pack is just a filesystem convention that ComfyUI core honors: a
directory (or ``*.py`` file) moved into ``custom_nodes/.disabled/`` is skipped at
boot, because core ignores any entry named ``.disabled`` / starting with a dot.
Manager uses the same convention. We only *move* files here — never import,
delete, or re-clone — so the operation is reversible and safe.
This is the fallback the extension uses when ComfyUI Manager is absent or does
not recognize a pack (a hand-cloned repo, an oddly-installed pack, or a loose
single-file node). A restart is required for ComfyUI to pick up the change,
exactly as with a Manager disable.
"""
import logging
import os
import shutil
logger = logging.getLogger(__name__)
# find_disabled_pack_path already knows how to locate a pack under .disabled/,
# including Manager's @version suffix. Import it in a way that works both as a
# package (runtime) and as a top-level module (tests add the root to sys.path).
try:
from .node_introspect import find_disabled_pack_path
except ImportError: # pragma: no cover - exercised via top-level import in tests
from node_introspect import find_disabled_pack_path
def _custom_node_roots():
import folder_paths
return [os.path.normpath(r) for r in folder_paths.get_folder_paths("custom_nodes")]
def _valid_name(pack_name):
"""Reject path-y input; pack names are plain directory/file names."""
return bool(pack_name) and not any(c in pack_name for c in ("/", "\\")) and ".." not in pack_name
def list_disabled_packs():
"""Return clean names of packs sitting in any custom_nodes/.disabled/ dir.
Strips a Manager ``@version`` suffix and a ``.py`` extension so the names
match package names as reported in the stats (and accepted by
``enable_pack_native``).
"""
names = set()
for root in _custom_node_roots():
ddir = os.path.join(root, ".disabled")
try:
entries = os.listdir(ddir)
except Exception:
continue
for e in entries:
if e.startswith("."):
continue
base = e.split("@", 1)[0]
stem = base[:-3] if base.endswith(".py") else base
if stem:
names.add(stem)
return names
def find_active_pack_path(pack_name):
"""Locate an installed (active) pack directly under a custom_nodes root.
Returns an absolute path to the pack directory or single ``.py`` file, or
None. Matches case-insensitively and tolerates a Manager ``@version`` suffix.
Only real packs (a directory, or a ``.py`` file) qualify.
"""
if not _valid_name(pack_name):
return None
target = pack_name.lower()
for root in _custom_node_roots():
try:
entries = os.listdir(root)
except Exception:
continue
for e in entries:
if e.startswith("."):
continue
base = e.split("@", 1)[0]
stem = base[:-3] if base.endswith(".py") else base
if target not in (stem.lower(), base.lower(), e.lower()):
continue
full = os.path.join(root, e)
if os.path.isdir(full) or (os.path.isfile(full) and full.endswith(".py")):
return os.path.normpath(full)
return None
def disable_pack_native(pack_name):
"""Move an active pack into ``custom_nodes/.disabled/``.
Returns ``(ok, message)``. Never raises for expected conditions (pack not
found, collision, permission error); those come back as ``(False, reason)``.
"""
src = find_active_pack_path(pack_name)
if not src:
return False, "pack not found on disk"
root = os.path.dirname(src)
ddir = os.path.join(root, ".disabled")
dest = os.path.join(ddir, os.path.basename(src))
if os.path.exists(dest):
return False, "a disabled copy already exists at " + dest
try:
os.makedirs(ddir, exist_ok=True)
shutil.move(src, dest)
except Exception as e:
logger.warning("nodes-stats: native disable failed for %s", pack_name, exc_info=True)
return False, str(e)
return True, "disabled"
def enable_pack_native(pack_name):
"""Move a pack from ``custom_nodes/.disabled/`` back to its root.
Returns ``(ok, message)``. Drops any Manager ``@version`` suffix from the
destination so the pack lands as a clean, importable directory name — the
same shape Manager itself restores on enable (``ComfyMath@nightly`` ->
``ComfyMath``). A single ``.py`` file keeps its extension.
"""
src = find_disabled_pack_path(pack_name)
if not src:
return False, "disabled pack not found"
ddir = os.path.dirname(src) # .../custom_nodes/.disabled
root = os.path.dirname(ddir) # .../custom_nodes
dest_name = os.path.basename(src).split("@", 1)[0]
dest = os.path.join(root, dest_name)
if os.path.exists(dest):
return False, "an active copy already exists at " + dest
try:
shutil.move(src, dest)
except Exception as e:
logger.warning("nodes-stats: native enable failed for %s", pack_name, exc_info=True)
return False, str(e)
return True, "enabled"
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-nodes-stats" name = "comfyui-nodes-stats"
description = "Track usage statistics for all ComfyUI nodes and packages" description = "Track usage statistics for all ComfyUI nodes and packages"
version = "1.6.0" version = "1.8.0"
license = "MIT" license = "MIT"
[project.urls] [project.urls]
+150
View File
@@ -0,0 +1,150 @@
import os
import pytest
import pack_fs
@pytest.fixture
def custom_nodes(tmp_path):
"""A custom_nodes root wired into the mocked folder_paths."""
import folder_paths
root = tmp_path / "custom_nodes"
root.mkdir()
folder_paths.get_folder_paths.return_value = [str(root)]
return root
def _make_pack(root, name):
pack = root / name
pack.mkdir()
(pack / "__init__.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
return pack
# --- find_active_pack_path ------------------------------------------------
def test_find_active_dir(custom_nodes):
_make_pack(custom_nodes, "MyPack")
found = pack_fs.find_active_pack_path("MyPack")
assert found == os.path.normpath(str(custom_nodes / "MyPack"))
def test_find_active_case_insensitive(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.find_active_pack_path("mypack") is not None
def test_find_active_single_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
found = pack_fs.find_active_pack_path("loose_node")
assert found == os.path.normpath(str(custom_nodes / "loose_node.py"))
def test_find_active_ignores_disabled_dir(custom_nodes):
(custom_nodes / ".disabled").mkdir()
_make_pack(custom_nodes / ".disabled", "MyPack")
assert pack_fs.find_active_pack_path("MyPack") is None
def test_find_active_rejects_path_traversal(custom_nodes):
assert pack_fs.find_active_pack_path("../evil") is None
assert pack_fs.find_active_pack_path("a/b") is None
assert pack_fs.find_active_pack_path("") is None
# --- disable_pack_native --------------------------------------------------
def test_disable_moves_dir_into_disabled(custom_nodes):
_make_pack(custom_nodes, "MyPack")
ok, msg = pack_fs.disable_pack_native("MyPack")
assert ok, msg
assert not (custom_nodes / "MyPack").exists()
assert (custom_nodes / ".disabled" / "MyPack" / "__init__.py").exists()
def test_disable_moves_single_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("x = 1\n")
ok, msg = pack_fs.disable_pack_native("loose_node")
assert ok, msg
assert not (custom_nodes / "loose_node.py").exists()
assert (custom_nodes / ".disabled" / "loose_node.py").exists()
def test_disable_missing_pack_fails(custom_nodes):
ok, msg = pack_fs.disable_pack_native("Ghost")
assert not ok
assert "not found" in msg
def test_disable_collision_fails(custom_nodes):
_make_pack(custom_nodes, "MyPack")
(custom_nodes / ".disabled").mkdir()
(custom_nodes / ".disabled" / "MyPack").mkdir() # pre-existing disabled copy
ok, msg = pack_fs.disable_pack_native("MyPack")
assert not ok
assert "already exists" in msg
# original left untouched
assert (custom_nodes / "MyPack").exists()
# --- enable_pack_native ---------------------------------------------------
def test_enable_moves_back(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.disable_pack_native("MyPack")[0]
ok, msg = pack_fs.enable_pack_native("MyPack")
assert ok, msg
assert (custom_nodes / "MyPack" / "__init__.py").exists()
assert not (custom_nodes / ".disabled" / "MyPack").exists()
def test_enable_missing_fails(custom_nodes):
ok, msg = pack_fs.enable_pack_native("Ghost")
assert not ok
assert "not found" in msg
def test_enable_strips_version_suffix(custom_nodes):
# A Manager-disabled pack on disk carries an @version suffix; enabling should
# restore it as a clean, importable directory name.
ddir = custom_nodes / ".disabled"
ddir.mkdir()
pack = ddir / "ComfyMath@nightly"
pack.mkdir()
(pack / "__init__.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
ok, msg = pack_fs.enable_pack_native("ComfyMath")
assert ok, msg
assert (custom_nodes / "ComfyMath" / "__init__.py").exists()
assert not (custom_nodes / "ComfyMath@nightly").exists()
assert not (ddir / "ComfyMath@nightly").exists()
def test_disable_then_enable_roundtrip_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("x = 1\n")
assert pack_fs.disable_pack_native("loose_node")[0]
assert pack_fs.enable_pack_native("loose_node")[0]
assert (custom_nodes / "loose_node.py").exists()
# --- list_disabled_packs --------------------------------------------------
def test_list_disabled_empty(custom_nodes):
assert pack_fs.list_disabled_packs() == set()
def test_list_disabled_strips_version_and_ext(custom_nodes):
ddir = custom_nodes / ".disabled"
ddir.mkdir()
(ddir / "ComfyMath@nightly").mkdir() # Manager-style @version suffix
(ddir / "PlainPack").mkdir()
(ddir / "loose_node.py").write_text("x = 1\n") # single-file pack
assert pack_fs.list_disabled_packs() == {"ComfyMath", "PlainPack", "loose_node"}
def test_list_disabled_reflects_a_native_disable(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.disable_pack_native("MyPack")[0]
assert "MyPack" in pack_fs.list_disabled_packs()
+72
View File
@@ -0,0 +1,72 @@
import pytest
from tracker import UsageTracker
@pytest.fixture
def tracker(tmp_path):
return UsageTracker(db_path=str(tmp_path / "test.db"))
def test_whitelist_starts_empty(tracker):
assert tracker.get_whitelist() == set()
def test_add_and_get(tracker):
tracker.add_to_whitelist("My-Pack")
assert tracker.get_whitelist() == {"My-Pack"}
def test_add_is_idempotent(tracker):
tracker.add_to_whitelist("My-Pack")
tracker.add_to_whitelist("My-Pack")
assert tracker.get_whitelist() == {"My-Pack"}
def test_remove(tracker):
tracker.add_to_whitelist("My-Pack")
tracker.remove_from_whitelist("My-Pack")
assert tracker.get_whitelist() == set()
def test_remove_absent_is_noop(tracker):
tracker.remove_from_whitelist("Nope") # must not raise
assert tracker.get_whitelist() == set()
def test_reset_clears_whitelist(tracker):
tracker.add_to_whitelist("My-Pack")
tracker.reset()
assert tracker.get_whitelist() == set()
class _Mapper:
"""Minimal stand-in for NodePackageMapper with a fixed mapping."""
def __init__(self, mapping):
self.mapping = mapping
def get_package(self, ct):
return self.mapping.get(ct, "__unknown__")
def get_all_packages(self):
return set(self.mapping.values()) - {"__builtin__"}
def test_package_stats_flags_whitelisted(tracker):
mapper = _Mapper({"NodeA": "Pack-A", "NodeB": "Pack-B"})
tracker.record_usage(["NodeA", "NodeB"], mapper)
tracker.add_to_whitelist("Pack-A")
stats = {p["package"]: p for p in tracker.get_package_stats(mapper)}
assert stats["Pack-A"]["whitelisted"] is True
assert stats["Pack-B"]["whitelisted"] is False
def test_package_stats_whitelist_is_case_insensitive(tracker):
mapper = _Mapper({"NodeA": "Pack-A"})
tracker.record_usage(["NodeA"], mapper)
tracker.add_to_whitelist("pack-a") # different case than the package name
stats = {p["package"]: p for p in tracker.get_package_stats(mapper)}
assert stats["Pack-A"]["whitelisted"] is True
+51
View File
@@ -57,6 +57,11 @@ CREATE TABLE IF NOT EXISTS trial_packages (
budget INTEGER NOT NULL DEFAULT 7 budget INTEGER NOT NULL DEFAULT 7
); );
CREATE TABLE IF NOT EXISTS whitelist_packages (
package TEXT PRIMARY KEY,
added_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_node_usage_package ON node_usage(package); CREATE INDEX IF NOT EXISTS idx_node_usage_package ON node_usage(package);
CREATE INDEX IF NOT EXISTS idx_prompt_log_timestamp ON prompt_log(timestamp); CREATE INDEX IF NOT EXISTS idx_prompt_log_timestamp ON prompt_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_model_usage_type ON model_usage(model_type); CREATE INDEX IF NOT EXISTS idx_model_usage_type ON model_usage(model_type);
@@ -286,6 +291,13 @@ class UsageTracker:
tracking_start, one_month_ago, two_months_ago, "unused_new" tracking_start, one_month_ago, two_months_ago, "unused_new"
) )
# Flag whitelisted packages so the UI can pull them into their own group
# and suppress disable actions. Whitelist entries are kept verbatim; match
# case-insensitively since directory names vary by how users clone/symlink.
whitelist = {w.lower() for w in self.get_whitelist()}
for entry in packages.values():
entry["whitelisted"] = entry["package"].lower() in whitelist
result = [p for p in packages.values() if p["package"].lower() not in EXCLUDED_PACKAGES] result = [p for p in packages.values() if p["package"].lower() not in EXCLUDED_PACKAGES]
result.sort(key=lambda p: p["total_executions"]) result.sort(key=lambda p: p["total_executions"])
return result return result
@@ -480,6 +492,44 @@ class UsageTracker:
finally: finally:
conn.close() conn.close()
def add_to_whitelist(self, package):
"""Protect a package: keep it out of disable actions and its own group."""
now = datetime.now(timezone.utc).isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""INSERT INTO whitelist_packages (package, added_at) VALUES (?, ?)
ON CONFLICT(package) DO NOTHING""",
(package, now),
)
conn.commit()
finally:
conn.close()
def remove_from_whitelist(self, package):
"""Remove a package from the whitelist."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute("DELETE FROM whitelist_packages WHERE package = ?", (package,))
conn.commit()
finally:
conn.close()
def get_whitelist(self):
"""Return the set of whitelisted package names."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
rows = conn.execute("SELECT package FROM whitelist_packages").fetchall()
return {r[0] for r in rows}
finally:
conn.close()
def reset(self): def reset(self):
"""Clear all tracked data.""" """Clear all tracked data."""
with self._lock: with self._lock:
@@ -490,6 +540,7 @@ class UsageTracker:
conn.execute("DELETE FROM prompt_log") conn.execute("DELETE FROM prompt_log")
conn.execute("DELETE FROM model_usage") conn.execute("DELETE FROM model_usage")
conn.execute("DELETE FROM trial_packages") conn.execute("DELETE FROM trial_packages")
conn.execute("DELETE FROM whitelist_packages")
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()