Compare commits

..

22 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
Ethanfel acaa9f0168 feat(search): draw real node box from disabled-pack source
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Click a node name (or 'Draw this node') in the mirror-search palette to render
an imitation ComfyUI node box with its real input sockets, widget defaults, and
output sockets. Since disabled packs aren't loaded (no /object_info entry), a
new read-only backend module (node_introspect.py) AST-parses the pack on disk —
never importing or executing it — to recover INPUT_TYPES / RETURN_TYPES from a
literal NODE_CLASS_MAPPINGS. New GET /nodes-stats/node-schema endpoint resolves
the disabled pack dir (handling @version suffixes / case) and returns the schema
off the event loop. Frontend lazily fetches + caches per node, renders sockets
vs widgets with type-colored dots, and falls back to a placeholder for packs
that build their node list dynamically.

End-to-end against the live install: 67/68 disabled packs resolve on disk, ~92%
of nodes render a real box, the rest fall back cleanly. Adds 7 parser unit
tests (36 total green). Bump to 1.6.0.
2026-06-21 15:23:58 +02:00
Ethanfel 77c159a918 feat(search): side preview panel for mirror search + strip stray NUL byte
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Splits the palette into results (left) + a preview panel (right) that updates
on hover / arrow-key navigation, modern-search style. Since disabled-pack
nodes have no loaded definition (can't render a real node graphic), the panel
shows the pack metadata we do have: title, author, description, repo link,
version, and the sibling nodes in the pack (active node highlighted). Enable
7d / Enable available in both the row and the panel.

buildDisabledCatalog now attaches a shared per-pack meta object (from
getmappings entry[1]) to each catalog entry. Also removes a literal NUL byte
that had slipped into the dedup separator string, which made grep treat the
file as binary; replaced with a newline. Bump to 1.5.0.
2026-06-21 14:49:40 +02:00
Ethanfel 5860b232d4 fix(search): match disabled packs by all identifiers, not repo URL only
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
getmappings keys packs by dir name/registry id far more often than by repo
URL, so the url-only join found just 7 of 73 disabled packs (71 nodes).
Resolve each getmappings key against every identifier a pack exposes (dir,
id, cnr_id, aux_id, files) like classifyUnresolved does — now 69 packs /
2301 nodes. Bump to 1.4.1.
2026-06-21 14:38:47 +02:00
Ethanfel d8f94ca371 docs: document mirror search; bump to 1.4.0
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
2026-06-21 14:28:36 +02:00
Ethanfel a8fb5ae8b4 feat(search): toolbar button + hotkey to open mirror search 2026-06-21 14:27:46 +02:00
Ethanfel 0dfa14384d feat(search): mirror search palette UI + enable actions 2026-06-21 14:27:14 +02:00
Ethanfel 8c35ac6f09 feat(search): catalog ranking + filter helpers 2026-06-21 14:26:20 +02:00
Ethanfel b6635c9f3e feat(search): build disabled-node catalog from getmappings x getlist 2026-06-21 14:25:35 +02:00
Ethanfel fb3a785027 refactor: extract enablePackage core from handleEnable 2026-06-21 14:25:08 +02:00
Ethanfel 8cb0e32739 docs: implementation plan for disabled-node mirror search
6-task frontend-only plan: extract shared enablePackage core, build the
disabled-node catalog (getmappings x getlist), ranking/filter helpers, the
palette UI, toolbar button + hotkey, and docs/version. Manual verification
(no JS harness) + node --check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:15:16 +02:00
Ethanfel aa2f1f62bf docs: design for disabled-node mirror search
Approved design: a standalone search palette (toolbar button + hotkey) over
nodes belonging to disabled packs, built frontend-only by joining Manager's
getmappings x getlist, with Enable 7d / Enable actions reusing the trial
feature. Native-search injection is infeasible (bundled frontend), so this
runs alongside as a mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:12:25 +02:00
14 changed files with 2809 additions and 93 deletions
+57 -8
View File
@@ -15,8 +15,10 @@ 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
## Package Classification
@@ -80,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
@@ -107,6 +123,37 @@ Re-enabling and auto-disabling both go through ComfyUI Manager, so the whole
Workflow tab is inert when Manager is not installed (the backend still tracks
trial state, but no enable/disable actions are offered).
### Mirror search (disabled-pack nodes)
Sometimes you know the node you want exists in a package you've disabled, but you
don't want to dig through ComfyUI Manager to find it. The **mirror search**
palette searches across the `class_type` names of *every currently-disabled
package* and lets you re-enable the owning package right from the results.
- Open it with the **⌕** button in the top menu bar, or press
**`Ctrl/Cmd+Shift+D`** (ignored while typing in an input).
- Type to filter — results are ranked (node-name prefix first, then word-start,
substring, finally pack-name matches) and show the `class_type` and its pack.
- Hover a result (or use ↑/↓) to open a **preview panel** on the right with the
owning package's title, author, description, repo link, and the full list of
sibling nodes in that pack — the active node highlighted.
- **Click a node's name** (or *Draw this node*) to render an **imitation node
box** — the real input sockets, widget defaults, and output sockets, drawn from
a static parse of the disabled pack's source. Since the pack isn't loaded,
there's no live definition to render; the backend AST-parses the pack on disk
(read-only, never executed) to recover the schema. Works for most packs (~90%);
packs that build their node list dynamically fall back to a placeholder box.
- Each result offers **Enable 7d** (re-enable under a 7-day trial) and **Enable**
(re-enable permanently) — in the row and in the preview panel — the same enable
path as the Workflow tab.
- Enabling takes effect after a ComfyUI restart; enabled rows mark
*"✓ enabled · restart"*.
The catalog is built once per session by joining ComfyUI Manager's node→pack
mappings with the disabled packs (matched across dir name, registry id, and repo
URL), and cached; use the **↻** button to rebuild it. The palette is inert (with
a clear message) when ComfyUI Manager is absent or there are no disabled packages.
**Models tab**
- Summary bar with counts for each tier across all model types
- Sections per model type (checkpoints, vae, controlnet, …)
@@ -119,6 +166,7 @@ trial state, but no enable/disable actions are offered).
| `/nodes-stats/packages` | GET | Per-package aggregated stats with classification |
| `/nodes-stats/usage` | GET | Raw per-node usage data |
| `/nodes-stats/models` | GET | Per-type model stats with classification |
| `/nodes-stats/node-schema` | GET | Parsed input/output schema for one disabled-pack node — query `class_type`, `pack` (read-only AST parse) |
| `/nodes-stats/reset` | POST | Clear all tracked data |
| `/nodes-stats/trials` | GET | Active temporary-enable trials with `days_remaining`/`expired` |
| `/nodes-stats/trials/start` | POST | Begin/restart a trial — body `{"package": "<dir-name>"}` |
@@ -240,7 +288,8 @@ timeout so the kernel keeps the listing warm across restarts, e.g.
__init__.py Entry point: prompt handler, API routes
mapper.py class_type → package mapping; model filename → type mapping
tracker.py SQLite persistence and stats aggregation
js/nodes_stats.js Frontend: menu button + stats dialog (Nodes/Models/Workflow tabs)
node_introspect.py Read-only AST parse of disabled packs → node input/output schema
js/nodes_stats.js Frontend: menu button + stats dialog (Nodes/Models/Workflow tabs) + mirror search
tools/diagnose_model_scan.py Standalone: diagnose slow model-folder scans at boot
pyproject.toml Package metadata
tests/ Unit tests for tracker and mapper
+124
View File
@@ -1,3 +1,4 @@
import asyncio
import logging
import threading
@@ -5,6 +6,8 @@ from aiohttp import web
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__)
@@ -115,6 +118,127 @@ async def reset_stats(request):
return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/node-schema")
async def get_node_schema_route(request):
"""Parse a disabled pack's source and return one node's input/output schema.
Read-only and never imports/executes the pack. Used by the mirror-search
palette to draw a faithful node box for a node that isn't loaded.
"""
try:
class_type = request.query.get("class_type")
pack = request.query.get("pack")
if not class_type or not pack:
return web.json_response({"error": "class_type and pack required"}, status=400)
def _resolve():
path = find_disabled_pack_path(pack)
if not path:
return {"parseable": False, "reason": "source_not_found"}
return get_node_schema(class_type, path)
# AST-parsing a whole pack can take tens of ms; keep it off the event loop.
schema = await asyncio.get_event_loop().run_in_executor(None, _resolve)
return web.json_response(schema)
except Exception:
logger.error("nodes-stats: error parsing node schema", exc_info=True)
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,130 @@
# Design: Mirror search for disabled-pack nodes
Date: 2026-06-21
Status: Approved
## Summary
A standalone "mirror search" palette that lets you find nodes belonging to
currently-**disabled** custom-node packages — without loading those packages
(which slows ComfyUI boot/runtime). Each result has Enable buttons; enabling
re-enables the owning package (temporarily or permanently) and takes effect
after a ComfyUI restart, after which the node appears in ComfyUI's native search.
This runs *alongside* ComfyUI's native node search rather than inside it:
ComfyUI's frontend is compiled/bundled and exposes no search-provider or
node-def injection hook, so the native search cannot be extended. A separate
palette is the only viable design (and matches the request — a "mirror" search).
## Constraints discovered (reconnaissance)
- Native search injection is **not feasible**: bundled frontend, no public hook
(`app.registerExtension` offers `nodeCreated`/`beforeRegisterNodeDef` only),
and disabled packs never enter `NODE_CLASS_MAPPINGS` / `/object_info`.
- `/customnode/getmappings?mode=local` returns a registry-wide map keyed by repo
URL: `{ <repo_url>: [ [class_type, ...], { title_aux } ] }`. It **does**
include the class_type names for every pack, including disabled ones.
- `/customnode/getlist?mode=local&skip_update=true` lists packs with `state`
(`disabled`/`enabled`/`not-installed`), `id`, `version`, `files`,
`repository`. There are 73 disabled packs in the reference install.
- Available metadata for an unloaded node: **class_type name + pack name/title
only**. No categories, descriptions, or input/output ports (those require the
pack to be loaded).
## Decisions (from brainstorming)
- **Trigger:** dedicated palette opened by a toolbar button + a keyboard
shortcut. Not a tab in the Node Stats dialog.
- **Enable actions:** both "Enable 7d" (rolling trial) and "Enable" (permanent),
reusing the trial-enable feature. Takes effect after restart.
- **Catalog construction:** frontend-only. Fetch getmappings + getlist on first
open, join by repo URL, cache in memory for the session, with a refresh
affordance. No backend changes.
- **Scope:** disabled packs only (not the full not-installed registry — that's
Manager's job).
## Architecture
All in `js/nodes_stats.js`; no backend changes.
### Catalog
- `ensureDisabledCatalog()` (cached in a module variable):
1. `fetchManagerInfo()` (existing; getlist) → packs with `state === 'disabled'`.
2. `fetch('/customnode/getmappings?mode=local')` → build `repoUrl -> [class_types]`.
3. Normalize URLs (lowercase, strip trailing `/` and `.git`) on both sides.
4. For each disabled pack, look up class_types by its `files[0]`/`repository`;
emit `{ class_type, pack: <dir name>, title, enableInfo }` per node.
- A `refresh()` clears the cache and rebuilds.
### Search/filter (pure functions, for clarity + manual testability)
- `scoreEntry(entry, queryLower)` → null if no match; else a rank where
class_type prefix < word-start < substring, pack-name match ranked lower.
- `filterCatalog(catalog, query, limit=50)` → sorted, capped list + total count.
### Palette UI
- `openMirrorSearch()`: ensure catalog; render a modal overlay (reusing the
dialog styling helpers) with a text input (autofocused) and a results list.
- Input `keyup` → re-render rows via `filterCatalog`.
- Row: `class_type` · `(pack)` · `[Enable 7d]` `[Enable]`.
- Footer: "<shown>/<total> from <N> disabled packs · enabling needs a restart" +
"↻ refresh".
- Empty/error/inert states handled explicitly.
### Trigger
- Toolbar button (like the existing Node Stats button), title "Search disabled
nodes".
- Keyboard shortcut: prefer ComfyUI's extension command/keybinding API if
available; else a guarded `document` `keydown` listener (default
`Ctrl/Cmd+Shift+D`), ignored when focus is in an input/textarea.
### Actions
- Reuse `handleEnable(pkg, temporary)` from the trial-enable feature:
- `[Enable 7d]``temporary=true` (Manager enable → `trials/start`).
- `[Enable]``temporary=false` (Manager enable → `trials/stop`).
- Reuse the restart banner / toast. After enable, mark the row "enabled ·
restart".
## Data flow
```
setup() -> add toolbar button + register hotkey
trigger -> openMirrorSearch()
-> ensureDisabledCatalog() (1st time: getmappings + getlist, join, cache)
-> render modal (input + results)
type -> filterCatalog() -> render rows (instant, in-memory)
Enable -> handleEnable(pkg, temp?) -> Manager enable -> trials/start|stop
-> restart banner/toast
refresh -> clear cache -> ensureDisabledCatalog() -> re-render
```
## Error handling
- ComfyUI Manager absent or getlist/getmappings fails → palette shows a clear
message ("ComfyUI Manager not available" / "couldn't load disabled-node
list"); the button stays but the palette is inert. No crash.
- Zero disabled packs → "No disabled packages — nothing to search."
- Enable failure → existing error toast; row left actionable.
- URL-join misses for a pack → that pack contributes no rows (logged to
console); never throws.
## Testing
- No backend changes → no new pytest.
- Pure helpers (`scoreEntry`, `filterCatalog`, URL normalization, catalog join)
written as small standalone functions; `node --check` for syntax.
- Manual verification: open palette via button + hotkey; search a known disabled
pack's node (e.g. an Inspire-Pack node); Enable 7d → getlist flips to enabled
+ `/nodes-stats/trials` shows it + restart banner; Enable (permanent) → enabled,
no trial row; refresh rebuilds; Manager-absent path shows the inert message.
## Files touched
- `js/nodes_stats.js` — catalog, filter, palette UI, trigger, reuse enable.
- `README.md`, `pyproject.toml` — docs + version bump.
## Out of scope (YAGNI)
- Injecting results into ComfyUI's native search (not feasible).
- Rich node metadata (titles/categories/ports) for unloaded nodes (unavailable).
- Auto-placing the node into the graph after restart.
- Searching not-installed registry packs (Manager already does this).
+395
View File
@@ -0,0 +1,395 @@
# Disabled-Node Mirror Search Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a standalone "mirror search" palette (toolbar button + hotkey) that searches nodes belonging to currently-disabled custom-node packages and offers Enable 7d / Enable on each result, reusing the trial-enable code.
**Architecture:** Frontend-only, all in `js/nodes_stats.js`. Build an in-memory catalog by joining ComfyUI Manager's `getmappings` (repo-URL → class_type names, registry-wide) with `getlist` (packs whose `state === 'disabled'`), cached per session. A separate modal palette filters the catalog live. Enable actions reuse a shared `enablePackage()` core (extracted from the existing `handleEnable`). No backend changes.
**Tech Stack:** Vanilla JS (ComfyUI frontend extension via `app.registerExtension`), ComfyUI Manager HTTP endpoints, the trial-enable feature already in this file.
**Design doc:** `docs/plans/2026-06-21-mirror-search-design.md`
**Testing note:** No JS test harness exists. "Verify" steps use `node --check` for syntax and explicit browser-console / in-app checks. Pure helpers are written standalone so they can be exercised from the console. After every JS edit run:
`cp js/nodes_stats.js /tmp/c.mjs && node --check /tmp/c.mjs && echo OK && rm /tmp/c.mjs`
Reuse points already in `js/nodes_stats.js` (confirmed): `fetchManagerInfo()` (getlist → `{dir:{id,version,files,state}}`), `enablePayload()`, `runManagerEnable()`, `managerIsBusy()`, `handleEnable()`, `notify()`, `escapeHtml()`, `escapeAttr()`, `showRestartBanner()`, the toolbar-button mount in `setup()`.
---
### Task 1: Extract shared `enablePackage()` core (refactor, no behavior change)
**Why:** `handleEnable` is hard-wired to the Workflow tab's `_lastWorkflowScan` and `dialog`. The palette needs the same enable logic without that coupling. Extract the Manager-enable + trial-route + toast into `enablePackage(pkg, info, temporary)`; keep `handleEnable` as the Workflow-tab wrapper.
**Files:** Modify `js/nodes_stats.js` (around lines 717746).
**Step 1: Add the shared core** immediately above `handleEnable`:
```js
// Shared enable core used by the Workflow tab and the mirror search palette.
// Performs the Manager enable + trial bookkeeping + success toast.
// Returns true on success, false if Manager was busy. Throws on failure.
// Caller owns its own busy UI and restart affordance.
async function enablePackage(pkg, info, temporary) {
if (!info) throw new Error("no enable info for " + pkg);
if (await managerIsBusy()) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
return false;
}
await runManagerEnable(enablePayload(pkg, info));
const route = temporary ? "/nodes-stats/trials/start" : "/nodes-stats/trials/stop";
await fetch(route, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }),
});
notify(`Enabled ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to apply.`, "success");
return true;
}
```
**Step 2: Replace the body of `handleEnable`** to delegate:
```js
async function handleEnable(pkg, temporary, dialog) {
const entry = _lastWorkflowScan.disabled.find((d) => d.pkg === pkg);
const info = entry && entry.info;
if (!info) return;
setWorkflowButtonsBusy(dialog, true);
try {
if (await enablePackage(pkg, info, temporary)) {
entry.info.state = "enabled";
showRestartBanner(dialog);
}
} catch (e) {
notify("Failed to enable: " + e.message, "error");
} finally {
setWorkflowButtonsBusy(dialog, false);
}
}
```
**Step 3: Verify syntax** — run the `node --check` line above. Expected: `OK`.
**Step 4: Verify no behavior change (manual)** — hard-refresh ComfyUI, load a workflow with a disabled node, click Enable 7d in the Workflow tab → still enables + restart banner + `/nodes-stats/trials` shows it. (If you don't want to mutate state, just confirm the buttons still render and the console shows no errors on open.)
**Step 5: Commit**
```bash
git add js/nodes_stats.js
git commit -m "refactor: extract enablePackage core from handleEnable"
```
---
### Task 2: Catalog build — URL normalize + join + cache
**Files:** Modify `js/nodes_stats.js` (add near `fetchManagerInfo`).
**Step 1: Add pure helpers + cached loader:**
```js
// Normalize a repo URL for joining getmappings keys to getlist pack files.
function normalizeRepoUrl(url) {
return String(url || "").trim().toLowerCase().replace(/\.git$/, "").replace(/\/+$/, "");
}
// Join Manager's node->pack mappings with the disabled packs from getlist.
// mappings: { <repoUrl>: [ [class_type,...], {title_aux} ] } (from getmappings)
// managerInfo: { <dir>: {id,version,files,state,title?} } (from fetchManagerInfo)
// Returns [{ class_type, pack, title, info }] for disabled packs only.
function buildDisabledCatalog(mappings, managerInfo) {
const byUrl = {};
for (const [url, entry] of Object.entries(mappings || {})) {
const list = entry && entry[0];
if (Array.isArray(list)) byUrl[normalizeRepoUrl(url)] = list;
}
const catalog = [];
for (const [dir, info] of Object.entries(managerInfo || {})) {
if (!info || info.state !== "disabled") continue;
const urls = (info.files && info.files.length ? info.files : [info.repository]).filter(Boolean);
let nodes = null;
for (const u of urls) {
const hit = byUrl[normalizeRepoUrl(u)];
if (hit) { nodes = hit; break; }
}
if (!nodes) { console.debug("[Node Stats] no node map for disabled pack", dir); continue; }
const title = info.title || dir;
for (const ct of nodes) catalog.push({ class_type: ct, pack: dir, title, info });
}
return catalog;
}
let _disabledCatalog = null; // cached for the session
async function ensureDisabledCatalog(forceRefresh = false) {
if (_disabledCatalog && !forceRefresh) return _disabledCatalog;
const managerInfo = await fetchManagerInfo();
if (!managerInfo) return null; // Manager absent
let mappings = {};
try {
const r = await fetch("/customnode/getmappings?mode=local");
if (r.ok) mappings = await r.json();
} catch { /* fall through -> empty catalog */ }
_disabledCatalog = buildDisabledCatalog(mappings, managerInfo);
return _disabledCatalog;
}
```
**Step 2: Verify syntax**`node --check` line. Expected `OK`.
**Step 3: Verify the join (browser console)** — hard-refresh ComfyUI, open devtools console:
```js
// paste: pull the two sources and join, then sanity-check
const mi = await (await fetch("/customnode/getlist?mode=local&skip_update=true")).json();
const mp = await (await fetch("/customnode/getmappings?mode=local")).json();
```
Then confirm in the app once Task 4 wires it; for now just confirm `getmappings` returns an object and `getlist.node_packs` has `state:'disabled'` entries. Expected: yes (≈73 disabled packs in this install).
**Step 4: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(search): build disabled-node catalog from getmappings x getlist"
```
---
### Task 3: Search filter (pure)
**Files:** Modify `js/nodes_stats.js`.
**Step 1: Add ranking + filter:**
```js
// Rank a catalog entry against a lowercased query. Lower = better; null = no match.
// class_type prefix (0) < class_type word-start (1) < class_type substring (2)
// < pack-name match (3). No match -> null.
function scoreEntry(entry, q) {
const name = entry.class_type.toLowerCase();
if (name.startsWith(q)) return 0;
if (name.split(/[\s_\-./]/).some((w) => w.startsWith(q))) return 1;
if (name.includes(q)) return 2;
if (entry.pack.toLowerCase().includes(q)) return 3;
return null;
}
// Filter + rank a catalog. Returns { rows, total } where rows is capped at limit.
function filterCatalog(catalog, query, limit = 50) {
const q = String(query || "").trim().toLowerCase();
if (!q) return { rows: [], total: 0 };
const scored = [];
for (const e of catalog) {
const s = scoreEntry(e, q);
if (s !== null) scored.push([s, e]);
}
scored.sort((a, b) => a[0] - b[0] || a[1].class_type.localeCompare(b[1].class_type));
return { rows: scored.slice(0, limit).map((x) => x[1]), total: scored.length };
}
```
**Step 2: Verify syntax**`node --check`. Expected `OK`.
**Step 3: Verify logic (browser console, after Task 4 exposes catalog, or inline)** — confirm e.g. `filterCatalog([{class_type:"MaskComposite",pack:"masquerade"}], "mask").total === 1` and `scoreEntry({class_type:"MaskComposite",pack:"x"}, "mask") === 0`.
**Step 4: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(search): catalog ranking + filter helpers"
```
---
### Task 4: Mirror search palette UI
**Files:** Modify `js/nodes_stats.js`.
**Step 1: Add the palette open/render:**
```js
async function openMirrorSearch() {
const existing = document.getElementById("nodes-stats-mirror");
if (existing) { existing.querySelector("#ns-mirror-input")?.focus(); return; }
const overlay = document.createElement("div");
overlay.id = "nodes-stats-mirror";
overlay.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10001;display:flex;align-items:flex-start;justify-content:center;";
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") overlay.remove(); });
const box = document.createElement("div");
box.style.cssText =
"margin-top:10vh;background:#1e1e1e;color:#ddd;border:1px solid #444;border-radius:8px;width:90%;max-width:640px;max-height:70vh;display:flex;flex-direction:column;font-family:monospace;font-size:13px;overflow:hidden;";
box.innerHTML = `
<style>
#nodes-stats-mirror .ns-btn{font-family:monospace;font-size:11px;border:1px solid #555;background:#262626;color:#ddd;border-radius:4px;padding:3px 10px;cursor:pointer;white-space:nowrap;}
#nodes-stats-mirror .ns-btn:hover:not(:disabled){background:#203a20;border-color:#4a4;color:#fff;}
#nodes-stats-mirror .ns-btn:disabled{opacity:0.5;cursor:default;}
#nodes-stats-mirror .ns-mrow:hover{background:#262626;}
</style>
<div style="padding:12px;border-bottom:1px solid #333;display:flex;gap:8px;align-items:center;">
<input id="ns-mirror-input" placeholder="search disabled-pack nodes…" autocomplete="off"
style="flex:1;background:#111;border:1px solid #444;border-radius:4px;color:#fff;padding:8px 10px;font-family:monospace;font-size:14px;outline:none;">
<button id="ns-mirror-refresh" class="ns-btn" title="Rebuild catalog">↻</button>
</div>
<div id="ns-mirror-results" style="overflow-y:auto;padding:6px 0;"></div>
<div id="ns-mirror-footer" style="padding:8px 12px;border-top:1px solid #333;color:#666;font-size:11px;"></div>`;
overlay.appendChild(box);
document.body.appendChild(overlay);
const input = box.querySelector("#ns-mirror-input");
const results = box.querySelector("#ns-mirror-results");
const footer = box.querySelector("#ns-mirror-footer");
footer.textContent = "loading disabled-node catalog…";
let catalog = await ensureDisabledCatalog();
if (catalog === null) { footer.textContent = "ComfyUI Manager not available."; return; }
if (catalog.length === 0) { footer.textContent = "No disabled packages — nothing to search."; return; }
const packCount = new Set(catalog.map((e) => e.pack)).size;
footer.textContent = `${catalog.length} nodes across ${packCount} disabled packs · enabling needs a restart`;
function render() {
const { rows, total } = filterCatalog(catalog, input.value);
if (!input.value.trim()) {
results.innerHTML = `<div style="padding:14px;color:#666;">Type to search ${catalog.length} nodes in ${packCount} disabled packs.</div>`;
return;
}
if (total === 0) { results.innerHTML = `<div style="padding:14px;color:#666;">No disabled nodes match “${escapeHtml(input.value)}”.</div>`; return; }
let html = "";
for (const e of rows) {
html += `<div class="ns-mrow" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid #222;">
<div style="flex:1;min-width:0;">
<div style="color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(e.class_type)}</div>
<div style="color:#888;font-size:11px;">${escapeHtml(e.pack)}</div>
</div>
<button class="ns-btn ns-mirror-temp" data-pkg="${escapeAttr(e.pack)}">Enable 7d</button>
<button class="ns-btn ns-mirror-perm" data-pkg="${escapeAttr(e.pack)}">Enable</button>
</div>`;
}
if (total > rows.length) html += `<div style="padding:8px 12px;color:#666;">+${total - rows.length} more — refine your search.</div>`;
results.innerHTML = html;
results.querySelectorAll(".ns-mirror-temp").forEach((b) =>
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay)));
results.querySelectorAll(".ns-mirror-perm").forEach((b) =>
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay)));
}
input.addEventListener("input", render);
box.querySelector("#ns-mirror-refresh").addEventListener("click", async () => {
footer.textContent = "refreshing…";
catalog = await ensureDisabledCatalog(true) || [];
footer.textContent = `${catalog.length} nodes across ${new Set(catalog.map((e)=>e.pack)).size} disabled packs · enabling needs a restart`;
render();
});
render();
input.focus();
}
// Enable from the palette. Marks all rows for the pack as enabled on success.
async function mirrorEnable(pkg, temporary, overlay) {
const entry = (_disabledCatalog || []).find((e) => e.pack === pkg);
const info = entry && entry.info;
if (!info) return;
overlay.querySelectorAll(".ns-btn").forEach((b) => (b.disabled = true));
try {
if (await enablePackage(pkg, info, temporary)) {
(_disabledCatalog || []).forEach((e) => { if (e.pack === pkg) e.info.state = "enabled"; });
overlay.querySelectorAll(`.ns-mirror-temp[data-pkg="${cssEscape(pkg)}"], .ns-mirror-perm[data-pkg="${cssEscape(pkg)}"]`)
.forEach((b) => { b.replaceWith(Object.assign(document.createElement("span"), { textContent: "✓ enabled · restart", style: "color:#6a6;font-size:11px;" })); });
}
} catch (e) {
notify("Failed to enable: " + e.message, "error");
} finally {
overlay.querySelectorAll(".ns-btn").forEach((b) => (b.disabled = false));
}
}
```
> If `cssEscape` does not already exist in the file, add the small helper used elsewhere: `function cssEscape(s){return window.CSS&&CSS.escape?CSS.escape(s):String(s).replace(/["\\]/g,"\\$&");}` (check first — the disable feature may already define it).
**Step 2: Verify syntax**`node --check`. Expected `OK`.
**Step 3: Verify (manual)** — temporarily call `openMirrorSearch()` from the console after hard-refresh. Search a known disabled pack node (e.g. an Inspire-Pack class_type). Expected: results list; clicking Enable 7d enables the pack (verify via `/nodes-stats/trials` and getlist state flip), rows turn into "✓ enabled · restart".
**Step 4: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(search): mirror search palette UI + enable actions"
```
---
### Task 5: Toolbar button + keyboard shortcut
**Files:** Modify `js/nodes_stats.js` — inside the existing `setup()` (where the Node Stats button is mounted).
**Step 1: Add a second toolbar button** after the existing Node Stats button mount:
```js
const searchBtn = document.createElement("button");
searchBtn.textContent = "⌕";
searchBtn.title = "Search disabled-pack nodes (Ctrl/Cmd+Shift+D)";
searchBtn.className = "comfyui-button comfyui-menu-mobile-collapse";
searchBtn.style.cssText = "display:flex;align-items:center;justify-content:center;padding:6px;cursor:pointer;font-size:16px;";
searchBtn.onclick = () => openMirrorSearch();
if (app.menu?.settingsGroup?.element) app.menu.settingsGroup.element.before(searchBtn);
else document.querySelector(".comfy-menu")?.append(searchBtn);
```
**Step 2: Register the hotkey** (guarded `keydown`, ignores typing contexts) at the end of `setup()`:
```js
window.addEventListener("keydown", (e) => {
if (!(e.shiftKey && (e.ctrlKey || e.metaKey) && (e.key === "D" || e.key === "d"))) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
e.preventDefault();
openMirrorSearch();
});
```
> If `Ctrl/Cmd+Shift+D` conflicts with a ComfyUI binding in this build, change the key here (e.g. to `K`) and update both titles.
**Step 3: Verify syntax**`node --check`. Expected `OK`.
**Step 4: Verify (manual)** — hard-refresh ComfyUI; the ⌕ button appears in the top menu; clicking it and pressing Ctrl/Cmd+Shift+D both open the palette; Esc / click-outside closes it.
**Step 5: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(search): toolbar button + hotkey to open mirror search"
```
---
### Task 6: Docs + version bump
**Files:** Modify `README.md`, `pyproject.toml`.
**Step 1:** Add a "Mirror search (disabled-pack nodes)" subsection to the README: what it does, how to open it (⌕ button / Ctrl/Cmd+Shift+D), that results come from disabled packs, that Enable 7d/Enable take effect after restart, and that it's inert without ComfyUI Manager. Add a feature bullet.
**Step 2:** Bump `version` in `pyproject.toml` to `1.4.0`.
**Step 3: Verify**`python -m pytest -q` (unchanged, still green) and the `node --check` line (`OK`).
**Step 4: Commit**
```bash
git add README.md pyproject.toml
git commit -m "docs: document mirror search; bump to 1.4.0"
```
---
## Done criteria
- ⌕ button + Ctrl/Cmd+Shift+D open a palette that searches nodes of disabled packs (joined from getmappings × getlist), cached per session with a refresh.
- Typing filters instantly (ranked); results show `class_type` + pack with Enable 7d / Enable.
- Enabling reuses `enablePackage` → Manager enable + trial start/stop + "restart to apply" toast; rows mark "✓ enabled · restart".
- Workflow-tab enable still works (shared core, no regression).
- Inert + clear message when ComfyUI Manager is absent or there are no disabled packs.
- `python -m pytest -q` green; `node --check` clean.
@@ -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.
```
+888 -78
View File
File diff suppressed because it is too large Load Diff
+287
View File
@@ -0,0 +1,287 @@
"""Static (no-execution) introspection of disabled custom-node packages.
The mirror-search palette previews nodes that belong to *disabled* packs. Those
packs aren't imported by ComfyUI, so their INPUT_TYPES / RETURN_TYPES are not in
/object_info. To draw a faithful node box we parse the pack's Python source with
``ast`` — we never import or execute it (importing a disabled pack could have
side effects, pull heavy deps, or fail). This yields real inputs/outputs for the
~75% of packs that declare a literal ``NODE_CLASS_MAPPINGS``; packs that build
their mappings dynamically simply report ``parseable: False`` and the frontend
falls back to a placeholder box.
"""
import ast
import logging
import os
import warnings
logger = logging.getLogger(__name__)
# Input "types" that ComfyUI renders as in-node widgets rather than sockets.
_WIDGET_TYPES = {"INT", "FLOAT", "STRING", "BOOLEAN", "BOOL"}
# Cache parsed pack indexes for the session, keyed by source path. Disabled
# packs don't change while ComfyUI runs, so we never invalidate.
_INDEX_CACHE = {}
def _const_str(node):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def _parse_file(path):
try:
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
src = fh.read()
# Third-party sources often contain unescaped regex strings; ast.parse
# emits SyntaxWarning for those. Suppress to keep server logs clean.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return ast.parse(src)
except Exception:
return None
def _iter_py_files(root, limit=500):
"""Yield up to ``limit`` .py files under a pack dir (or the file itself)."""
if os.path.isfile(root):
if root.endswith(".py"):
yield root
return
count = 0
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [
d for d in dirnames
if not d.startswith(".") and d not in ("__pycache__", "node_modules", "js", "web", "dist")
]
for fn in filenames:
if fn.endswith(".py"):
yield os.path.join(dirpath, fn)
count += 1
if count >= limit:
return
def _string_tuple(node):
"""A RETURN_TYPES / RETURN_NAMES value -> list of strings ("*" for non-literal)."""
if isinstance(node, (ast.Tuple, ast.List)):
out = []
for e in node.elts:
s = _const_str(e)
out.append(s if s is not None else "*")
return out
s = _const_str(node)
return [s] if s is not None else None
def _extract_input_def(name, val):
"""One INPUT_TYPES entry -> {name, type, widget, default, options}."""
d = {"name": name, "type": "*", "widget": False, "default": None, "options": None}
type_node, opts_node = None, None
if isinstance(val, (ast.Tuple, ast.List)) and val.elts:
type_node = val.elts[0]
if len(val.elts) > 1:
opts_node = val.elts[1]
else:
type_node = val
s = _const_str(type_node)
if s is not None:
d["type"] = s
if s.upper() in _WIDGET_TYPES:
d["widget"] = True
elif isinstance(type_node, (ast.List, ast.Tuple)):
# Inline combo box: ["a", "b", ...]
opts = [_const_str(e) for e in type_node.elts]
opts = [o for o in opts if o is not None]
d["type"] = "COMBO"
d["widget"] = True
d["options"] = opts or None
elif isinstance(type_node, ast.Call):
# Dynamic list, e.g. folder_paths.get_filename_list(...) -> dropdown widget.
d["type"] = "COMBO"
d["widget"] = True
# else: a Name/Attribute (custom or wildcard socket type) -> keep "*", socket.
if isinstance(opts_node, ast.Dict):
for ok, ov in zip(opts_node.keys, opts_node.values):
if _const_str(ok) == "default":
try:
d["default"] = ast.literal_eval(ov)
except Exception:
d["default"] = _const_str(ov)
return d
def _extract_input_types(fn):
"""The INPUT_TYPES classmethod -> {"required": [...], "optional": [...]} or None."""
ret = None
for n in ast.walk(fn):
if isinstance(n, ast.Return) and isinstance(n.value, ast.Dict):
ret = n.value
break
if ret is None:
return None
result = {"required": [], "optional": []}
for cat_key, cat_val in zip(ret.keys, ret.values):
cat = _const_str(cat_key)
if cat not in ("required", "optional") or not isinstance(cat_val, ast.Dict):
continue
for nk, nv in zip(cat_val.keys, cat_val.values):
name = _const_str(nk)
if name is not None:
result[cat].append(_extract_input_def(name, nv))
return result
def _extract_class(cls):
info = {"input_types": None, "return_types": None, "return_names": None,
"category": None, "output_node": False}
for b in cls.body:
if isinstance(b, (ast.FunctionDef, ast.AsyncFunctionDef)) and b.name == "INPUT_TYPES":
info["input_types"] = _extract_input_types(b)
elif isinstance(b, ast.Assign):
for t in b.targets:
tn = getattr(t, "id", None)
if tn == "RETURN_TYPES":
info["return_types"] = _string_tuple(b.value)
elif tn == "RETURN_NAMES":
info["return_names"] = _string_tuple(b.value)
elif tn == "CATEGORY":
info["category"] = _const_str(b.value)
elif tn == "OUTPUT_NODE" and isinstance(b.value, ast.Constant):
info["output_node"] = bool(b.value.value)
return info
def _merge_mapping(out, dictnode):
if not isinstance(dictnode, ast.Dict):
return
for k, v in zip(dictnode.keys, dictnode.values):
key = _const_str(k)
if key is None:
continue
if isinstance(v, ast.Name):
out[key] = v.id
elif isinstance(v, ast.Call) and isinstance(v.func, ast.Name):
out[key] = v.func.id
def _merge_display(out, dictnode):
if not isinstance(dictnode, ast.Dict):
return
for k, v in zip(dictnode.keys, dictnode.values):
key, val = _const_str(k), _const_str(v)
if key is not None and val is not None:
out[key] = val
def build_pack_index(pack_path):
"""Parse a pack -> (classes, mappings, display).
classes: { ClassName: {input_types, return_types, return_names, category, output_node} }
mappings: { node_key: ClassName } (from literal NODE_CLASS_MAPPINGS / .update)
display: { node_key: "Pretty Name" }
"""
cached = _INDEX_CACHE.get(pack_path)
if cached is not None:
return cached
classes, mappings, display = {}, {}, {}
for f in _iter_py_files(pack_path):
tree = _parse_file(f)
if tree is None:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
classes.setdefault(node.name, _extract_class(node))
elif isinstance(node, ast.Assign):
for t in node.targets:
name = getattr(t, "id", None)
if name == "NODE_CLASS_MAPPINGS":
_merge_mapping(mappings, node.value)
elif name == "NODE_DISPLAY_NAME_MAPPINGS":
_merge_display(display, node.value)
elif isinstance(node, ast.Call):
fn = node.func
if (isinstance(fn, ast.Attribute) and fn.attr == "update"
and isinstance(fn.value, ast.Name) and node.args
and isinstance(node.args[0], ast.Dict)):
if fn.value.id == "NODE_CLASS_MAPPINGS":
_merge_mapping(mappings, node.args[0])
elif fn.value.id == "NODE_DISPLAY_NAME_MAPPINGS":
_merge_display(display, node.args[0])
result = (classes, mappings, display)
_INDEX_CACHE[pack_path] = result
return result
def get_node_schema(class_type, pack_path):
"""Return a render-ready schema for one node, or {parseable: False, reason}."""
classes, mappings, display = build_pack_index(pack_path)
cls_name = mappings.get(class_type)
if cls_name is None and class_type in classes:
cls_name = class_type # fall back: class_type IS the class name
if cls_name is None or cls_name not in classes:
return {"parseable": False, "reason": "dynamic_mapping" if mappings else "no_mapping"}
info = classes[cls_name]
inputs = []
it = info["input_types"]
if it:
for cat in ("required", "optional"):
for d in it[cat]:
inputs.append({**d, "required": cat == "required"})
rt = info["return_types"] or []
rn = info["return_names"] or []
outputs = []
for i, t in enumerate(rt):
nm = rn[i] if i < len(rn) and rn[i] else t
outputs.append({"name": nm or t, "type": t})
return {
"parseable": True,
"class_type": class_type,
"display_name": display.get(class_type) or class_type,
"category": info["category"],
"output_node": info["output_node"],
"inputs": inputs,
"outputs": outputs,
}
def find_disabled_pack_path(pack_name):
"""Locate a disabled pack's source under any custom_nodes/.disabled/ dir.
Matches case-insensitively and ignores any ``@version`` suffix that ComfyUI
Manager appends on disk (e.g. ``ComfyMath@nightly`` for pack ``comfymath``).
Returns an absolute path (dir or .py file) or None. Rejects path-y input.
"""
if not pack_name or any(c in pack_name for c in ("/", "\\")) or ".." in pack_name:
return None
try:
import folder_paths
roots = folder_paths.get_folder_paths("custom_nodes")
except Exception:
roots = []
target = pack_name.lower()
for root in roots:
ddir = os.path.join(root, ".disabled")
if not os.path.isdir(ddir):
continue
try:
entries = os.listdir(ddir)
except Exception:
continue
for e in entries:
base = e.split("@", 1)[0]
stem = base[:-3] if base.endswith(".py") else base
if target in (stem.lower(), base.lower()):
return os.path.join(ddir, e)
return None
+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.3.0"
version = "1.8.0"
license = "MIT"
[project.urls]
+131
View File
@@ -0,0 +1,131 @@
import os
import sys
from unittest.mock import MagicMock
import node_introspect as ni
_SAMPLE = '''
import folder_paths
class MyCoolNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"model": ("MODEL",),
"strength": ("FLOAT", {"default": 1.5, "min": 0.0}),
"mode": (["fast", "slow"], {"default": "slow"}),
"ckpt": (folder_paths.get_filename_list("checkpoints"),),
},
"optional": {
"mask": ("MASK",),
},
}
RETURN_TYPES = ("IMAGE", "LATENT")
RETURN_NAMES = ("out_image", "out_latent")
CATEGORY = "testing/cool"
FUNCTION = "run"
def run(self, image, model, strength, mode, ckpt, mask=None):
return (image, None)
class DynamicNode:
@classmethod
def INPUT_TYPES(cls):
d = {"required": {}}
return d
RETURN_TYPES = ("STRING",)
NODE_CLASS_MAPPINGS = {"My Cool Node": MyCoolNode}
NODE_CLASS_MAPPINGS.update({"Dynamic": DynamicNode})
NODE_DISPLAY_NAME_MAPPINGS = {"My Cool Node": "My Cool Node ✨"}
'''
def _write_pack(tmp_path, body=_SAMPLE, name="nodes.py"):
ni._INDEX_CACHE.clear()
f = tmp_path / name
f.write_text(body)
return str(tmp_path)
def test_inputs_sockets_widgets_and_defaults(tmp_path):
pack = _write_pack(tmp_path)
s = ni.get_node_schema("My Cool Node", pack)
assert s["parseable"] is True
assert s["display_name"] == "My Cool Node ✨"
assert s["category"] == "testing/cool"
by = {i["name"]: i for i in s["inputs"]}
# custom types are sockets
assert by["image"]["type"] == "IMAGE" and by["image"]["widget"] is False
assert by["model"]["type"] == "MODEL" and by["model"]["widget"] is False
# primitives + combos are widgets, with defaults
assert by["strength"]["widget"] is True and by["strength"]["default"] == 1.5
assert by["mode"]["type"] == "COMBO" and by["mode"]["options"] == ["fast", "slow"]
assert by["mode"]["default"] == "slow"
# folder_paths.get_filename_list(...) -> dynamic combo widget, options unknown
assert by["ckpt"]["type"] == "COMBO" and by["ckpt"]["widget"] is True
assert by["ckpt"]["options"] is None
# optional inputs are flagged
assert by["mask"]["required"] is False and by["image"]["required"] is True
def test_outputs_use_return_names(tmp_path):
pack = _write_pack(tmp_path)
s = ni.get_node_schema("My Cool Node", pack)
assert [(o["name"], o["type"]) for o in s["outputs"]] == [
("out_image", "IMAGE"),
("out_latent", "LATENT"),
]
def test_mapping_update_call_is_merged(tmp_path):
pack = _write_pack(tmp_path)
s = ni.get_node_schema("Dynamic", pack)
assert s["parseable"] is True
assert s["outputs"] == [{"name": "STRING", "type": "STRING"}]
assert s["inputs"] == []
def test_unknown_class_type_not_parseable(tmp_path):
pack = _write_pack(tmp_path)
s = ni.get_node_schema("Nope", pack)
assert s["parseable"] is False
assert s["reason"] == "dynamic_mapping"
def test_no_mapping_reason(tmp_path):
pack = _write_pack(tmp_path, body="class A:\n RETURN_TYPES = ('X',)\n")
s = ni.get_node_schema("A", pack)
# class_type falls back to the class name even without NODE_CLASS_MAPPINGS
assert s["parseable"] is True
s2 = ni.get_node_schema("Missing", pack)
assert s2["parseable"] is False and s2["reason"] == "no_mapping"
def test_find_disabled_pack_path_strips_version_and_case(tmp_path, monkeypatch):
cn = tmp_path / "custom_nodes"
disabled = cn / ".disabled"
disabled.mkdir(parents=True)
(disabled / "ComfyMath@nightly").mkdir()
fp = MagicMock()
fp.get_folder_paths.return_value = [str(cn)]
monkeypatch.setitem(sys.modules, "folder_paths", fp)
found = ni.find_disabled_pack_path("comfymath")
assert found == os.path.join(str(disabled), "ComfyMath@nightly")
assert ni.find_disabled_pack_path("not-there") is None
def test_find_disabled_pack_path_rejects_traversal(tmp_path):
assert ni.find_disabled_pack_path("../evil") is None
assert ni.find_disabled_pack_path("a/b") is None
assert ni.find_disabled_pack_path("") is None
+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()