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
- **Uninstall detection** — removed packages/models are flagged separately, historical data preserved
- **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
- **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
- **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)
- **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
- **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.
> **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
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**
tab. Unresolved nodes are split into two groups:
- **Missing** — the owning package isn't installed. Install is handled by
[ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) like always: the
**Install** button opens Manager's Custom Nodes Manager (use its *Missing*
filter).
- **Missing** — the owning package isn't installed. Each row offers:
- **Install 7d** — really install the package (via
[ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager)) and start a
*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:
- **Enable 7d** — re-enable the package and start a *temporary 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
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
+95
View File
@@ -7,6 +7,7 @@ from server import PromptServer
from .mapper import NodePackageMapper, ModelMapper
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
logger = logging.getLogger(__name__)
@@ -144,6 +145,100 @@ async def get_node_schema_route(request):
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")
async def get_trials(request):
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).
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({
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() {
const btn = document.createElement("button");
btn.innerHTML = STATS_ICON;
@@ -93,9 +119,16 @@ 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);
// 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) {
showStatsDialog("workflow"); // auto-open on the Workflow tab
}
@@ -104,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();
@@ -219,6 +254,8 @@ async function showStatsDialog(initialTab = "nodes") {
wireDisableButtons(dialog, managerInfo);
wireWorkflowButtons(dialog);
wireWhitelistButtons(dialog);
wireNativeEnableButtons(dialog);
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: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>`;
}
@@ -277,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");
@@ -291,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);
@@ -304,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)
: [];
@@ -323,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) {
@@ -408,14 +491,17 @@ function buildWorkflowTabContent({ disabled, missing }, trials) {
html += `</tbody></table>`;
}
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>`;
for (const m of missing) {
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:#888;">${m.pkg ? escapeHtml(m.pkg) : "unknown"}</td>
<td style="padding:6px 8px;text-align:right;">
${m.pkg ? `<button class="ns-btn ns-install-btn" data-pkg="${escapeAttr(m.pkg)}">Install</button>` : "&mdash;"}
<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></tr>`;
}
html += `</tbody></table>`;
@@ -430,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;
@@ -448,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>`;
@@ -488,6 +587,43 @@ function buildTable(packages, status, withActions, managerInfo) {
// 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:
// { <dir name>: { id, version, files, state }, ... }
// We read the unified list (/customnode/getlist) rather than /customnode/installed
@@ -499,8 +635,8 @@ function buildTable(packages, status, withActions, managerInfo) {
// omitted entirely.
async function fetchManagerInfo() {
try {
const resp = await fetch("/customnode/getlist?mode=local&skip_update=true");
if (!resp.ok) return null;
const resp = await mgrFetch("/customnode/getlist?mode=local&skip_update=true");
if (!resp || !resp.ok) return null;
const data = await resp.json();
const packs = data && data.node_packs;
if (!packs || typeof packs !== "object") return null;
@@ -586,8 +722,8 @@ async function ensureDisabledCatalog(forceRefresh = false) {
if (!managerInfo) return null; // Manager absent
let mappings = {};
try {
const r = await fetch("/customnode/getmappings?mode=local");
if (r.ok) mappings = await r.json();
const r = await mgrFetch("/customnode/getmappings?mode=local");
if (r && r.ok) mappings = await r.json();
} catch { /* fall through -> empty catalog */ }
_disabledCatalog = buildDisabledCatalog(mappings, managerInfo);
return _disabledCatalog;
@@ -621,15 +757,20 @@ function filterCatalog(catalog, query, limit = 50) {
// Split unresolved node types into packages that are installed-but-disabled
// (re-enable to use) vs not installed (install via Manager). Reconciles
// 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) {
if (!types.length) return { disabled: [], missing: [] };
let mappings = {}, managerInfo = null;
try {
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}}
]);
if (mResp.ok) mappings = await mResp.json();
if (mResp && mResp.ok) mappings = await mResp.json();
managerInfo = gi;
} catch { /* manager absent */ }
@@ -643,7 +784,7 @@ async function classifyUnresolved(types) {
// 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
// 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 = {};
if (managerInfo) for (const [dir, info] of Object.entries(managerInfo)) {
const rec = { ...info, _dir: dir };
@@ -662,6 +803,57 @@ async function classifyUnresolved(types) {
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
// Manager's own frontend: id = directory name, version = install state
// ("nightly" / semver / "unknown"), and files (repo URL) only for "unknown".
@@ -674,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();
@@ -700,52 +892,162 @@ function wireWorkflowButtons(dialog) {
b.addEventListener("click", (e) => { e.stopPropagation(); handleEnable(b.dataset.pkg, true, dialog); }));
dialog.querySelectorAll(".ns-enable-perm-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleEnable(b.dataset.pkg, false, dialog); }));
dialog.querySelectorAll(".ns-install-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleInstall(b.dataset.pkg, dialog); }));
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); }));
}
// 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) {
// 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;
// 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 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 =
`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 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) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
setDisableButtonsBusy(dialog, false);
return;
}
const payloads = pkgNames.map((n) => disablePayload(n, managerInfo[n]));
await runManagerDisable(payloads);
// Reconcile against Manager's actual state: a package is considered
// disabled only if it's no longer reported as active on disk.
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";
const succeeded = after ? pkgNames.filter((n) => !isStillActive(n)) : pkgNames;
const failed = pkgNames.filter((n) => !succeeded.includes(n));
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");
}
}
}
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);
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");
@@ -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
// 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) {
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" } });
for (const payload of payloads) {
@@ -803,8 +1148,8 @@ function enablePayload(dirName, info) {
// the same way before calling runManagerDisable).
async function managerIsBusy() {
try {
const r = await fetch("/manager/queue/status");
if (!r.ok) return false;
const r = await mgrFetch("/manager/queue/status");
if (!r || !r.ok) return false;
const st = await r.json();
return !!(st && st.is_processing);
} 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) {
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" } });
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) {
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;
});
}
@@ -1196,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 {
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 */ }
@@ -1232,8 +1635,8 @@ async function waitForQueue(timeoutMs = 60000) {
while (Date.now() < deadline) {
let st = null;
try {
const r = await fetch("/manager/queue/status");
if (r.ok) st = await r.json();
const r = await mgrFetch("/manager/queue/status");
if (r && r.ok) st = await r.json();
} catch { /* transient; retry */ }
if (st && !st.is_processing && st.in_progress_count === 0) return;
await sleep(500);
@@ -1259,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 {
@@ -1297,10 +1700,18 @@ 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 {
await fetch("/manager/reboot", { method: "POST", headers: { "Content-Type": "application/json" } });
await mgrFetch("/manager/reboot", { method: "POST", headers: { "Content-Type": "application/json" } });
} catch {
// 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]
name = "comfyui-nodes-stats"
description = "Track usage statistics for all ComfyUI nodes and packages"
version = "1.6.0"
version = "1.8.0"
license = "MIT"
[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
);
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_prompt_log_timestamp ON prompt_log(timestamp);
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"
)
# 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.sort(key=lambda p: p["total_executions"])
return result
@@ -480,6 +492,44 @@ class UsageTracker:
finally:
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):
"""Clear all tracked data."""
with self._lock:
@@ -490,6 +540,7 @@ class UsageTracker:
conn.execute("DELETE FROM prompt_log")
conn.execute("DELETE FROM model_usage")
conn.execute("DELETE FROM trial_packages")
conn.execute("DELETE FROM whitelist_packages")
conn.commit()
finally:
conn.close()