Compare commits

...

104 Commits

Author SHA1 Message Date
Christian Byrne
ec8d004839
Merge da87651e53 into 43b200dc91 2025-12-09 01:50:54 +05:30
Dr.Lt.Data
43b200dc91 update DB
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
2025-12-08 23:28:26 +09:00
Dr.Lt.Data
29014699bb update DB
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
2025-12-08 22:04:11 +09:00
Dr.Lt.Data
5576672957 update DB 2025-12-08 21:45:05 +09:00
DayMan84
5002606861
Add ComfyUI-Ugromana node with details (#2361)
Hello,

I would like to add my new custom node ComfyUI-Usromana to the registry.

Node Details:

Name: ComfyUI-UmeAiRT-Sync
Description: The next-generation security, governance, permissions, and multi‑user control system for ComfyUI.

Repository: https://github.com/DayMan84/ComfyUI-Usgromana

Verification:
I have verified the JSON syntax locally using the "Use local DB" option in ComfyUI Manager. The node appears correctly in the list and installs without issues.

Thank you!
2025-12-08 21:44:11 +09:00
Dr.Lt.Data
ba0fb343ff update DB 2025-12-08 21:33:00 +09:00
UmeAiRT
17e5ae6bc2
Add ComfyUI-UmeAiRT-Sync (#2360) 2025-12-08 21:31:52 +09:00
akawana
7a0186efc8
Modify 'Keybinding Extra' to 'Folded prompts' (#2359)
Updated the title, reference, description, and tags for the 'Keybinding Extra' entry in the custom-node-list.json file.
2025-12-08 21:30:29 +09:00
Dr.Lt.Data
de64af4a68 update DB
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
2025-12-07 21:32:58 +09:00
Dr.Lt.Data
4a852ac8a8 update DB 2025-12-07 21:30:06 +09:00
shootthesound
6784bfb98c
Add Realtime LoRA Trainer node to custom-node-list (#2352)
Added a new node for Realtime LoRA Trainer with details.
2025-12-07 21:29:07 +09:00
Dr.Lt.Data
c8f246d344 update DB 2025-12-07 21:28:09 +09:00
The Kraken
8b3d31a936
Add Kraken Discord Bot custom node (#2358) 2025-12-07 21:27:17 +09:00
Dr.Lt.Data
5e88d6445b update DB 2025-12-07 21:25:30 +09:00
ds
fd7dff88df
Add ComfyUI_DashuaiTools to custom-node-list.json (#2319)
This PR adds the ComfyUI_DashuaiTools custom node pack to the ComfyUI-Manager node list.
2025-12-07 21:24:06 +09:00
Dr.Lt.Data
8cfee1f483 update DB
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
2025-12-07 07:36:12 +09:00
Matthew-X
cf4d8e6125
Workflow Importer (#2356)
* Add Workflow Importer node to custom-node-list.json

* Update custom-node-list.json

* Add entry for SDXL_sizing by Ser-Hilary

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-12-07 07:35:14 +09:00
Dr.Lt.Data
c0e8a41d2a update DB 2025-12-07 07:32:45 +09:00
Antonio Covelo
a02c27b1af
Add ShakaNodes utility tools for ComfyUI (#2353)
Added new ShakaNodes project with author and description to ComfyUI-Maanager
2025-12-07 07:32:07 +09:00
Dr.Lt.Data
712e1bac0d update DB 2025-12-07 07:30:31 +09:00
ameyukisora
513ea46cbe
Add ComfyUI-Empty-Latent-Advanced (#2351) 2025-12-07 07:28:19 +09:00
Dr.Lt.Data
b1919b6f95 update DB 2025-12-07 07:28:06 +09:00
Jean Kássio
43561d209b
Add ComfyUI_MusicTools (#2350) 2025-12-07 07:25:47 +09:00
Dr.Lt.Data
16dcbc5412 update DB 2025-12-07 07:25:11 +09:00
HALXP
c8dd2d5cad
Added new file to existing HALXP-Comfy custom node (#2341)
* Added HALXP-Comfy to the custom node lists

* Update custom-node-list.json

* Added new file (HALXP Monitor) to current custom node

HALXP Monitor lets you run a custom script on workflow Success or Error

* Added one new file to the custom tools (runmonitor)

Lets you run custom scripts on workflow success or error

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-12-07 07:22:18 +09:00
Dr.Lt.Data
4b37777066 update DB 2025-12-07 07:15:35 +09:00
Antonio Sorrentini
95ecd85a12
Add ComfyUI-LegionPower node with description (#2326) 2025-12-07 07:11:33 +09:00
Dr.Lt.Data
5c475e3c15 update DB
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
2025-12-05 19:03:46 +09:00
Dr.Lt.Data
f705ee6863 update DB
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
2025-12-05 13:00:22 +09:00
akawana
1f67c18989
Add RGBYP Mask Editor to custom-node-list.json (#2347)
* Add RGBYP Mask Editor to custom-node-list.json

Added RGBYP Mask Editor entry with details and tags.

* Fix description formatting in custom-node-list.json
2025-12-05 12:53:08 +09:00
rjgoif
de6d451c5b
Update custom-node-list.json (#2346)
Adding a small node collection "Img Label Tools" to help users add text labels easily to their images and grids, making for better sharing on Reddit and other communities. 
Small pack, no extra files, no restrictions.
2025-12-05 12:52:04 +09:00
Dr.Lt.Data
580296d6f3 update DB 2025-12-05 12:51:41 +09:00
vramfcker
a9e28fbce3
Add Random Prompt Builder node to custom-node-list (#2340)
Added new node for Random Prompt Builder with detailed description.
2025-12-05 12:50:40 +09:00
Dr.Lt.Data
311779cb20 update DB 2025-12-05 12:50:28 +09:00
llikethat
d2f8a89e87
Add faceExtractor node for ComfyUI (#2339)
Added faceExtractor node for ComfyUI which identifies based on input image reference
2025-12-05 12:49:28 +09:00
Dr.Lt.Data
84c95bf322 update DB 2025-12-05 12:48:44 +09:00
Fatih Eke
f75c801955
Add ComfyUI-Hunyuan3D-v3 - Tencent Hunyuan 3D Global API support (#2345)
Co-authored-by: exedesign <exedesign@github.com>
2025-12-05 12:46:37 +09:00
fredlef
faa2f54371
Add ComfyUI FSL Nodes — Gemini chat & image generation, utilities (#2316)
* Add ComfyUI FSL Nodes to custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
Co-authored-by: Fred LeFevre <fred@example.com>
2025-12-05 12:41:08 +09:00
Dr.Lt.Data
4249ac193a improved: display a more user-friendly message 2025-12-05 07:01:01 +09:00
Dr.Lt.Data
c709274a28 update DB
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
2025-12-03 01:46:58 +09:00
Dr.Lt.Data
c8f05e79db update DB 2025-12-03 01:35:55 +09:00
ah-kun
4d2887e99f
Add ComfyUI-FailSafe-Translate-Node to custom-node-list (#2330) 2025-12-03 01:35:14 +09:00
Dr.Lt.Data
29256a5154 update DB 2025-12-03 01:33:18 +09:00
luxdelux7
82d42e4094
Add Forbidden Vision custom node pack (#2328)
* Add Forbidden Vision custom node pack

Custom face detection/segmentation models with enhancement nodes for ComfyUI.
Supports realistic, anime, and NSFW content.

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-12-03 01:32:34 +09:00
Dr.Lt.Data
53850fb627 update DB 2025-12-03 01:29:30 +09:00
Pondowner857
34b4c8ce46
Update custom-node-list.json (#2325) 2025-12-03 01:28:25 +09:00
Dr.Lt.Data
e944841054 update DB 2025-12-03 01:25:51 +09:00
llikethat
f6a5ff5552
added iSeeBetter Custom Node (#2324)
* added iSeeBetter Custom Node

iSeeBetter Custom Node for upscaling

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-12-03 01:24:38 +09:00
Dr.Lt.Data
01763b59d4 update DB 2025-12-03 01:24:18 +09:00
BuddyBytes.co
044173b2a1
Add Smart Resolution Toolkit aspect-aware, snap64-safe resolution and latent generator nodes (#2322)
Smart Resolution Toolkit for ComfyUI

A lightweight resolution assistant for ComfyUI that provides human-friendly dropdowns to pick:

- Resolution presets: HD, FullHD, 2K, 4K, 8K
- Aspect ratios: 1:1, 9:16, 4:5, 21:9, 16:9, 2:3, etc.

Key Features:
• Auto width & height calculation (INT output)
• Latent-safe – automatically snaps to nearest multiple of 64
• Perfect for EmptyLatentImage, KSampler, AnimateDiff, ControlNet, Video formats
• Includes two nodes:
   - Smart Resolution Picker → returns width & height (INT)
   - Smart Latent Generator → directly creates empty LATENT tensor

Popular use cases:
TikTok video, IG story, portrait photography, ultra-wide banners, cinematic 21:9, album covers.

GitHub: https://github.com/buddy-bytes/ComfyUI-SmartResolutionToolkit
2025-12-03 01:20:12 +09:00
Dr.Lt.Data
99e7a88dbd update DB 2025-12-03 01:19:09 +09:00
HALXP
01cd9fbb0e
Add HALXP-Comfy to custom-node-list.json (#2320)
* Added HALXP-Comfy to the custom node lists

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-12-03 01:18:02 +09:00
Dr.Lt.Data
aaed1dc3d5
feat(security): Support System User Protection API with security migration (V3.38) (#2338)
- Migrate Manager data path: default/ComfyUI-Manager → __manager
- Force security_level=strong on outdated ComfyUI (block installations)
- Auto-migrate config.ini only; backup legacy files for manual verification
- Raise weak/normal- to normal during migration
- Add /manager/startup_alerts API for UI warnings
- Differentiate 403 responses: comfyui_outdated vs security_level
- Block startup scripts execution on old ComfyUI

Requires ComfyUI v0.3.76+ for full functionality.
Backward compatible with older versions (uses legacy path).
2025-12-03 00:42:12 +09:00
Dr.Lt.Data
c8dce94c03 update DB
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
2025-12-01 12:23:52 +09:00
Dr.Lt.Data
06496d07b3 update DB
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
2025-11-29 01:43:52 +09:00
painter890602
a97f98c9cc
Add PainterFLF2V custom node (#2311)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-29 01:23:25 +09:00
Dr.Lt.Data
8d0406f74f update DB 2025-11-28 18:32:02 +09:00
Dr.Lt.Data
c64d14701d update DB
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
2025-11-28 08:59:09 +09:00
Dr.Lt.Data
00332ae444 update DB 2025-11-28 08:02:05 +09:00
akawana
e8deb3d8fe
Add Utils Extra custom node to the list (#2313)
* Add Utils Extra custom node to the list

Added a new custom node entry for Utils Extra with details.

* Update description in custom-node-list.json

Expanded the description to include additional functionalities of the utility tools.
2025-11-28 08:00:38 +09:00
obvirm
8b234c99cf
Add ComfyUI-WhisperXX custom node entry (#2314)
* Add ComfyUI-WhisperXX custom node entry

Added a new custom node entry for ComfyUI-WhisperXX with details.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-28 07:59:25 +09:00
Rzgar
1f986d9c45
Add entry for Qwen Image Size Picker (#2312) 2025-11-28 07:56:02 +09:00
Dr.Lt.Data
bacb8fb3cd update DB
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
2025-11-27 00:23:54 +09:00
Dr.Lt.Data
e4a90089ab fixed: a bug where updating ComfyUI using Update: ComfyUI Stable Version did not updating ComfyUI's dependencies 2025-11-26 21:54:28 +09:00
Dr.Lt.Data
674b9f3705 update DB 2025-11-26 21:41:55 +09:00
Dr.Lt.Data
4941fb8aa0 fixed: scanner.py
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
2025-11-26 08:58:02 +09:00
Dr.Lt.Data
183af0dfa5 update DB
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
2025-11-25 12:59:01 +09:00
Dr.Lt.Data
45ac5429f8 "update DB" 2025-11-25 12:46:44 +09:00
Dr.Lt.Data
c771977a95 update DB
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
2025-11-24 23:10:06 +09:00
Dr.Lt.Data
668d7bbb2c update DB 2025-11-24 22:56:38 +09:00
akawana
926cfabb58
Add Keybinding Extra (keyboard shortcut extension) (#2306)
* Add Keybinding Extra custom node

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

* Enhance description for Keybinding Extra

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

* Update custom-node-list.json

---------

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

* Update custom-node-list.json

---------

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

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

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

* Update custom-node-list.json

---------

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

Added my node to the JSON

* Update custom-node-list.json

---------

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

* Update custom-node-list.json

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

---------

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

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-19 22:37:20 +09:00
Dr.Lt.Data
a8a7024a84 update DB 2025-11-19 18:46:14 +09:00
bymyself
da87651e53 [tests] Add API test suite 2025-05-20 16:35:40 -07:00
46 changed files with 20852 additions and 9872 deletions

View File

@ -5,6 +5,7 @@
![menu](https://raw.githubusercontent.com/ltdrdata/ComfyUI-extension-tutorials/refs/heads/Main/ComfyUI-Manager/images/dialog.jpg)
## NOTICE
* V3.38: **Security patch** - Manager data migrated to protected path. See [Migration Guide](docs/en/v3.38-userdata-security-migration.md).
* 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
@ -140,20 +141,27 @@ This repository provides Colab notebooks that allow you to install and use Comfy
## Paths
In `ComfyUI-Manager` V3.0 and later, configuration files and dynamically generated files are located under `<USER_DIRECTORY>/default/ComfyUI-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>.
* <USER_DIRECTORY>
* If executed without any options, the path defaults to ComfyUI/user.
* It can be set using --user-directory <USER_DIRECTORY>.
* Basic config files: `<USER_DIRECTORY>/default/ComfyUI-Manager/config.ini`
* Configurable channel lists: `<USER_DIRECTORY>/default/ComfyUI-Manager/channels.ini`
* Configurable pip overrides: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_overrides.json`
* Configurable pip blacklist: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_blacklist.list`
* Configurable pip auto fix: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_auto_fix.list`
* Saved snapshot files: `<USER_DIRECTORY>/default/ComfyUI-Manager/snapshots`
* Startup script files: `<USER_DIRECTORY>/default/ComfyUI-Manager/startup-scripts`
* Component files: `<USER_DIRECTORY>/default/ComfyUI-Manager/components`
| 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

File diff suppressed because it is too large Load Diff

View File

@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -40,10 +40,11 @@ import cnr_utils
import manager_util
import git_utils
import manager_downloader
import manager_migration
from node_package import InstalledNodePackage
version_code = [3, 37, 1]
version_code = [3, 38, 1]
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
@ -214,9 +215,10 @@ def update_user_directory(user_dir):
global manager_pip_blacklist_path
global manager_components_path
manager_files_path = os.path.abspath(os.path.join(user_dir, 'default', 'ComfyUI-Manager'))
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):
@ -1719,7 +1721,7 @@ 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', ''),
@ -1739,6 +1741,8 @@ def read_config():
'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:
import importlib.util
@ -1746,7 +1750,7 @@ def read_config():
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': '',
@ -1766,6 +1770,8 @@ def read_config():
'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():
@ -2533,6 +2539,7 @@ def update_to_stable_comfyui(repo_path):
else:
logging.info(f"[ComfyUI-Manager] Updating ComfyUI: {current_tag} -> {latest_tag}")
repo.git.checkout(latest_tag)
execute_install_script("ComfyUI", repo_path, instant_execution=False, no_deps=False)
return 'updated', latest_tag
except:
traceback.print_exc()
@ -3359,8 +3366,8 @@ def get_comfyui_versions(repo=None):
repo = git.Repo(comfy_path)
try:
remote = get_remote_name(repo)
repo.remotes[remote].fetch()
remote = get_remote_name(repo)
repo.remotes[remote].fetch()
except:
logging.error("[ComfyUI-Manager] Failed to fetch ComfyUI")

356
glob/manager_migration.py Normal file
View File

@ -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

View File

@ -22,6 +22,7 @@ import asyncio
import queue
import manager_downloader
import manager_migration
logging.info(f"### Loading: ComfyUI-Manager ({core.version_str})")
@ -276,6 +277,13 @@ import zipfile
import urllib.request
def security_403_response():
"""Return appropriate 403 response based on ComfyUI version."""
if not manager_migration.has_system_user_api():
return web.json_response({"error": "comfyui_outdated"}, status=403)
return web.json_response({"error": "security_level"}, status=403)
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]
@ -732,7 +740,7 @@ async def fetch_updates(request):
async def update_all(request):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
return web.Response(status=403)
return security_403_response()
with task_worker_lock:
is_processing = task_worker_thread is not None and task_worker_thread.is_alive()
@ -965,7 +973,7 @@ async def get_snapshot_list(request):
async def remove_snapshot(request):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
return web.Response(status=403)
return security_403_response()
try:
target = request.rel_url.query["target"]
@ -983,7 +991,7 @@ async def remove_snapshot(request):
async def restore_snapshot(request):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
return web.Response(status=403)
return security_403_response()
try:
target = request.rel_url.query["target"]
@ -1302,7 +1310,7 @@ async def fix_custom_node(request):
async def install_custom_node_git_url(request):
if not is_allowed_security_level('high'):
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
return web.Response(status=403)
return security_403_response()
url = await request.text()
res = await core.gitclone_install(url)
@ -1322,7 +1330,7 @@ async def install_custom_node_git_url(request):
async def install_custom_node_pip(request):
if not is_allowed_security_level('high'):
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
return web.Response(status=403)
return security_403_response()
packages = await request.text()
core.pip_install(packages.split(' '))
@ -1594,6 +1602,16 @@ async def get_notice(request):
except:
pass
# Prepend startup notices from manager_migration
for message, level in reversed(manager_migration.startup_notices):
if level == 'error':
style = 'color:red; background-color:white; font-weight:bold'
elif level == 'warning':
style = 'color:orange; background-color:white; font-weight:bold'
else:
style = 'color:blue; background-color:white'
markdown_content = f'<P style="{style}">{message}</P>' + markdown_content
return web.Response(text=markdown_content, status=200)
else:
return web.Response(text="Unable to retrieve Notice", status=200)
@ -1601,11 +1619,35 @@ async def get_notice(request):
return web.Response(text="Unable to retrieve Notice", status=200)
@routes.get("/manager/startup_alerts")
async def get_startup_alerts(request):
"""Return startup alerts for customAlert display on page load.
Returns JSON array of alerts that should be shown to user immediately.
All startup notices (error, warning, info) are returned.
"""
alerts = []
# Return all startup notices for alert display
for message, level in manager_migration.startup_notices:
# Convert HTML BR to newlines for customAlert
text = message.replace('<BR>', '\n').replace('<br>', '\n')
# Add [ComfyUI-Manager] prefix for customAlert (notice board shows in Manager UI anyway)
text = text.replace('[Security Alert]', '[ComfyUI-Manager] Security Alert:')
text = text.replace('[MIGRATION]', '[ComfyUI-Manager] Migration:')
alerts.append({
'message': text,
'level': level
})
return web.json_response(alerts)
@routes.get("/manager/reboot")
def restart(self):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
return web.Response(status=403)
return security_403_response()
try:
sys.stdout.close_log()

View File

@ -55,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
@ -64,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):

View File

@ -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';
@ -42,7 +42,7 @@ async function tryInstallCustomNode(event) {
});
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) {
@ -54,7 +54,7 @@ async function tryInstallCustomNode(event) {
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;
}

View File

@ -14,7 +14,7 @@ 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
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";
@ -753,9 +753,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();
}
});
}
@ -780,8 +780,13 @@ async function updateAll(update_comfyui) {
const response = await api.fetchApi(`/manager/queue/update_all?mode=${mode}`);
if (response.status == 401) {
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;
@ -1453,6 +1458,31 @@ app.registerExtension({
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");

View File

@ -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("/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;
}
@ -216,7 +232,7 @@ export async function install_pip(packages) {
});
if(res.status == 403) {
show_message('This action is not allowed with this security level configuration.');
await handle403Response(res);
return;
}
@ -251,7 +267,7 @@ export async function install_via_git_url(url, manager_dialog) {
});
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();
}
});
}

View File

@ -7,7 +7,7 @@ import {
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
showPopover, hidePopover
showPopover, hidePopover, handle403Response
} from "./common.js";
// https://cenfun.github.io/turbogrid/api.html
@ -1528,7 +1528,16 @@ export class CustomNodesManager {
errorMsg = `'${item.title}': `;
if(res.status == 403) {
errorMsg += `This action is not allowed with this security level configuration.\n`;
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 {

View File

@ -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
storeColumnWidth, restoreColumnWidth, loadCss, handle403Response
} from "./common.js";
import { api } from "../../scripts/api.js";
@ -477,7 +477,16 @@ export class ModelManager {
errorMsg = `'${item.name}': `;
if(res.status == 403) {
errorMsg += `This action is not allowed with this security level configuration.\n`;
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';
}

View File

@ -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 } from "./common.js";
import { manager_instance, rebootAPI, show_message, handle403Response } from "./common.js";
async function restore_snapshot(target) {
@ -10,7 +10,7 @@ async function restore_snapshot(target) {
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;
}
@ -38,7 +38,7 @@ async function remove_snapshot(target) {
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;
}
@ -145,8 +145,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();
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -169,6 +169,16 @@
],
"install_type": "git-clone",
"description": "A fork of KJNodes for ComfyUI.\nVarious quality of life -nodes for ComfyUI, mostly just visual stuff to improve usability"
},
{
"author": "huixingyun",
"title": "ComfyUI-SoundFlow",
"reference": "https://github.com/huixingyun/ComfyUI-SoundFlow",
"files": [
"https://github.com/huixingyun/ComfyUI-SoundFlow"
],
"install_type": "git-clone",
"description": "forked from https://github.com/fredconex/ComfyUI-SoundFlow (removed)"
}
]
}

View File

@ -1,5 +1,255 @@
{
"custom_nodes": [
{
"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]",
"reference": "https://github.com/cedarconnor/ComfyUI-GEN3C-Gsplat",
"files": [
"https://github.com/cedarconnor/ComfyUI-GEN3C-Gsplat"
],
"install_type": "git-clone",
"description": "A custom ComfyUI node pack that bridges Cosmos/GEN3C video generation with in-graph Gaussian Splat (3DGS) training. It adds camera/trajectory tooling, dataset exporters, and two training backends (Nerfstudio CLI wrapper and an in-process gsplat optimizer) so artists can go from prompt to splat entirely inside ComfyUI.\nNOTE: The files in the repo are not organized."
},
{
"author": "dowa-git",
"title": "comfyui-dowa [REMOVED]",
"reference": "https://github.com/dowa-git/comfyui-dowa",
"files": [
"https://github.com/dowa-git/comfyui-dowa"
],
"install_type": "git-clone",
"description": "Professional navigation bar widget for ComfyUI with JWT-based user authentication, workflow templates, and team collaboration features in a purple gradient design."
},
{
"author": "Fablestarexpanse",
"title": "Timer-Node-Comfyui [REMOVED]",
"reference": "https://github.com/Fablestarexpanse/Timer-Node-Comfyui",
"files": [
"https://github.com/Fablestarexpanse/Timer-Node-Comfyui"
],
"install_type": "git-clone",
"description": "A custom ComfyUI node that displays live processing time in a red digital countdown clock format, perfect for monitoring image generation times and tracking performance between workflow nodes."
},
{
"author": "cedarconnor",
"title": "ComfyUI-OmniX [REMOVED]",
"reference": "https://github.com/cedarconnor/ComfyUI-OmniX",
"files": [
"https://github.com/cedarconnor/ComfyUI-OmniX"
],
"install_type": "git-clone",
"description": "Extract comprehensive scene properties from 360-degree equirectangular panoramas, including depth, normals, and PBR materials, using OmniX adapters with Flux."
},
{
"author": "cedarconnor",
"title": "ComfyUI-DiT360 [REMOVED]",
"reference": "https://github.com/cedarconnor/ComfyUI-DiT360",
"files": [
"https://github.com/cedarconnor/ComfyUI-DiT360"
],
"install_type": "git-clone",
"description": "Generate high-fidelity 360-degree panoramic images using the DiT360 diffusion transformer model in ComfyUI."
},
{
"author": "PozzettiAndrea",
"title": "ComfyUI-AnyTop [REMOVED]",
"reference": "https://github.com/PozzettiAndrea/ComfyUI-AnyTop",
"files": [
"https://github.com/PozzettiAndrea/ComfyUI-AnyTop"
],
"install_type": "git-clone",
"description": "Standalone ComfyUI custom nodes for AnyTop - Universal Motion Generation for Any Skeleton Topology."
},
{
"author": "penposs",
"title": "ComfyUI-Banana-Node [REMOVED]",
"reference": "https://github.com/penposs/ComfyUI-Banana-Node",
"files": [
"https://github.com/penposs/ComfyUI-Banana-Node"
],
"install_type": "git-clone",
"description": "A custom node for ComfyUI that generates images using Googles Gemini 2.5 Flash Image Preview API."
},
{
"author": "spiralmountain",
"title": "ComfyUI_HDNodes [REMOVED]",
"reference": "https://github.com/spiralmountain/ComfyUI_HDNodes",
"files": [
"https://github.com/spiralmountain/ComfyUI_HDNodes"
],
"install_type": "git-clone",
"description": "Custom nodes for ComfyUI that enable video generation using ByteDance's Seedance model via [a/Fal.ai](https://fal.ai/)."
},
{
"author": "fredconex",
"title": "Sync Edit [REMOVED]",
"reference": "https://github.com/fredconex/ComfyUI-SyncEdit",
"files": [
"https://github.com/fredconex/ComfyUI-SyncEdit"
],
"install_type": "git-clone",
"description": "This node allow to intercept changes on the input string and choose between use the current one or sync with incoming new one."
},
{
"author": "fredconex",
"title": "ComfyUI-SoundFlow [REMOVED]",
"reference": "https://github.com/fredconex/ComfyUI-SoundFlow",
"files": [
"https://github.com/fredconex/ComfyUI-SoundFlow"
],
"install_type": "git-clone",
"description": "This is a bunch of nodes for ComfyUI to help with sound work."
},
{
"author": "fredconex",
"title": "SongBloom [REMOVED]",
"reference": "https://github.com/fredconex/ComfyUI-SongBloom",
"files": [
"https://github.com/fredconex/ComfyUI-SongBloom"
],
"install_type": "git-clone",
"description": "ComfyUI Nodes for SongBloom"
},
{
"author": "EQXai",
"title": "ComfyUI_EQX [REMOVED]",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -85,7 +85,15 @@ cm_global.register_api('cm.is_import_failed_extension', is_import_failed_extensi
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
custom_nodes_base_path = folder_paths.get_folder_paths('custom_nodes')[0]
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), 'default', 'ComfyUI-Manager'))
# 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")
@ -516,7 +524,8 @@ check_bypass_ssl()
# Perform install
processed_install = set()
script_list_path = os.path.join(folder_paths.user_directory, "default", "ComfyUI-Manager", "startup-scripts", "install-scripts.txt")
# 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)
@ -793,7 +802,11 @@ def execute_startup_script():
# 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()

View File

@ -1,7 +1,7 @@
[project]
name = "comfyui-manager"
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
version = "3.37.1"
version = "3.38.1"
license = { file = "LICENSE.txt" }
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet"]

View File

@ -78,36 +78,14 @@ Examples:
return args
# Parse arguments
args = parse_arguments()
# Determine mode
scan_only_mode = args.scan_only is not None
url_list_file = args.scan_only if scan_only_mode else None
# Determine temp_dir
if args.temp_dir:
temp_dir = args.temp_dir
elif args.temp_dir_positional:
temp_dir = args.temp_dir_positional
else:
temp_dir = os.path.join(os.getcwd(), ".tmp")
if not os.path.exists(temp_dir):
os.makedirs(temp_dir)
# Determine skip flags
skip_update = args.skip_update or args.skip_all
skip_stat_update = args.skip_stat_update or args.skip_all or scan_only_mode
if not skip_stat_update:
auth = Auth.Token(os.environ.get('GITHUB_TOKEN'))
g = Github(auth=auth)
else:
g = None
print(f"TEMP DIR: {temp_dir}")
# Module-level variables (will be set in main if running as script)
args = None
scan_only_mode = False
url_list_file = None
temp_dir = None
skip_update = False
skip_stat_update = True
g = None
parse_cnt = 0
@ -127,10 +105,17 @@ def extract_nodes(code_text):
warnings.filterwarnings('ignore', category=DeprecationWarning)
parsed_code = ast.parse(code_text)
assignments = (node for node in parsed_code.body if isinstance(node, ast.Assign))
# Support both ast.Assign and ast.AnnAssign (for type-annotated assignments)
assignments = (node for node in parsed_code.body if isinstance(node, (ast.Assign, ast.AnnAssign)))
for assignment in assignments:
if isinstance(assignment.targets[0], ast.Name) and assignment.targets[0].id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
# Handle ast.AnnAssign (e.g., NODE_CLASS_MAPPINGS: Type = {...})
if isinstance(assignment, ast.AnnAssign):
if isinstance(assignment.target, ast.Name) and assignment.target.id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
node_class_mappings = assignment.value
break
# Handle ast.Assign (e.g., NODE_CLASS_MAPPINGS = {...})
elif isinstance(assignment.targets[0], ast.Name) and assignment.targets[0].id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
node_class_mappings = assignment.value
break
else:
@ -250,7 +235,8 @@ def scan_in_file(filename, is_builtin=False):
with open(filename, encoding='utf-8', errors='ignore') as file:
code = file.read()
pattern = r"_CLASS_MAPPINGS\s*=\s*{([^}]*)}"
# Support type annotations (e.g., NODE_CLASS_MAPPINGS: Type = {...}) and line continuations (\)
pattern = r"_CLASS_MAPPINGS\s*(?::\s*\w+\s*)?=\s*(?:\\\s*)?{([^}]*)}"
regex = re.compile(pattern, re.MULTILINE | re.DOTALL)
nodes = set()
@ -482,21 +468,21 @@ def update_custom_nodes(scan_only_mode=False, url_list_file=None):
raise ValueError("url_list_file is required in scan-only mode")
git_url_titles_preemptions = get_urls_from_list_file(url_list_file)
print(f"\n[Scan-Only Mode]")
print("\n[Scan-Only Mode]")
print(f" - URL source: {url_list_file}")
print(f" - GitHub stats: DISABLED")
print(" - GitHub stats: DISABLED")
print(f" - Git clone/pull: {'ENABLED' if not skip_update else 'DISABLED'}")
print(f" - Metadata: EMPTY")
print(" - Metadata: EMPTY")
else:
if not os.path.exists('custom-node-list.json'):
raise FileNotFoundError("custom-node-list.json not found")
git_url_titles_preemptions = get_git_urls_from_json('custom-node-list.json')
print(f"\n[Standard Mode]")
print(f" - URL source: custom-node-list.json")
print("\n[Standard Mode]")
print(" - URL source: custom-node-list.json")
print(f" - GitHub stats: {'ENABLED' if not skip_stat_update else 'DISABLED'}")
print(f" - Git clone/pull: {'ENABLED' if not skip_update else 'DISABLED'}")
print(f" - Metadata: FULL")
print(" - Metadata: FULL")
def process_git_url_title(url, title, preemptions, node_pattern):
name = os.path.basename(url)
@ -689,7 +675,14 @@ def gen_json(node_info, scan_only_mode=False):
data[git_url] = (nodes, metadata)
else:
print(f"WARN: {dirname} is removed from custom-node-list.json")
# Scan-only mode: Repository not in node_info (expected behavior)
# Construct URL from dirname (author_repo format)
if '_' in dirname:
parts = dirname.split('_', 1)
git_url = f"https://github.com/{parts[0]}/{parts[1]}"
data[git_url] = (nodes, metadata)
else:
print(f"WARN: {dirname} is removed from custom-node-list.json")
for file in node_files:
nodes, metadata = scan_in_file(file)
@ -775,24 +768,53 @@ def gen_json(node_info, scan_only_mode=False):
json.dump(data, file, indent=4, sort_keys=True)
print("### ComfyUI Manager Node Scanner ###")
if __name__ == "__main__":
# Parse arguments
args = parse_arguments()
if scan_only_mode:
print(f"\n# [Scan-Only Mode] Processing URL list: {url_list_file}\n")
else:
print("\n# [Standard Mode] Updating extensions\n")
# Determine mode
scan_only_mode = args.scan_only is not None
url_list_file = args.scan_only if scan_only_mode else None
# Update/clone repositories and collect node info
updated_node_info = update_custom_nodes(scan_only_mode, url_list_file)
# Determine temp_dir
if args.temp_dir:
temp_dir = args.temp_dir
elif args.temp_dir_positional:
temp_dir = args.temp_dir_positional
else:
temp_dir = os.path.join(os.getcwd(), ".tmp")
print("\n# Generating 'extension-node-map.json'...\n")
if not os.path.exists(temp_dir):
os.makedirs(temp_dir)
# Generate extension-node-map.json
gen_json(updated_node_info, scan_only_mode)
# Determine skip flags
skip_update = args.skip_update or args.skip_all
skip_stat_update = args.skip_stat_update or args.skip_all or scan_only_mode
print("\n✅ DONE.\n")
if not skip_stat_update:
auth = Auth.Token(os.environ.get('GITHUB_TOKEN'))
g = Github(auth=auth)
else:
g = None
if scan_only_mode:
print("Output: extension-node-map.json (node mappings only)")
else:
print("Output: extension-node-map.json (full metadata)")
print("### ComfyUI Manager Node Scanner ###")
if scan_only_mode:
print(f"\n# [Scan-Only Mode] Processing URL list: {url_list_file}\n")
else:
print("\n# [Standard Mode] Updating extensions\n")
# Update/clone repositories and collect node info
updated_node_info = update_custom_nodes(scan_only_mode, url_list_file)
print("\n# Generating 'extension-node-map.json'...\n")
# Generate extension-node-map.json
gen_json(updated_node_info, scan_only_mode)
print("\n✅ DONE.\n")
if scan_only_mode:
print("Output: extension-node-map.json (node mappings only)")
else:
print("Output: extension-node-map.json (full metadata)")

19
tests-api/.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# Python cache files
__pycache__/
*.py[cod]
*$py.class
# Pytest cache
.pytest_cache/
# Coverage reports
.coverage
htmlcov/
# Virtual environments
venv/
env/
ENV/
# Test-specific resources
resources/tmp/

91
tests-api/README.md Normal file
View File

@ -0,0 +1,91 @@
# ComfyUI-Manager API Tests
This directory contains tests for the ComfyUI-Manager API endpoints, validating the OpenAPI specification and ensuring API functionality.
## Setup
1. Install test dependencies:
```bash
pip install -r requirements-test.txt
```
2. Ensure ComfyUI is running with ComfyUI-Manager installed:
```bash
# Start ComfyUI with the default server
python main.py
```
## Running Tests
### Run all tests
```bash
pytest -xvs
```
### Run specific test files
```bash
# Run only the spec validation tests
pytest -xvs test_spec_validation.py
# Run only the custom node API tests
pytest -xvs test_customnode_api.py
```
### Run specific test functions
```bash
# Run a specific test
pytest -xvs test_customnode_api.py::test_get_custom_node_list
```
## Test Configuration
The tests use the following default configuration:
- Server URL: `http://localhost:8188`
- Server timeout: 2 seconds
- Wait between requests: 0.5 seconds
- Maximum retries: 3
You can override these settings with environment variables:
```bash
# Use a different server URL
COMFYUI_SERVER_URL=http://localhost:8189 pytest -xvs
```
## Test Categories
The tests are organized into the following categories:
1. **Spec Validation** (`test_spec_validation.py`): Validates that the OpenAPI specification is correct and complete.
2. **Custom Node API** (`test_customnode_api.py`): Tests for custom node management endpoints.
3. **Snapshot API** (`test_snapshot_api.py`): Tests for snapshot management endpoints.
4. **Queue API** (`test_queue_api.py`): Tests for queue management endpoints.
5. **Config API** (`test_config_api.py`): Tests for configuration endpoints.
6. **Model API** (`test_model_api.py`): Tests for model management endpoints (minimal as these are being deprecated).
## Test Implementation Details
### Fixtures
- `test_config`: Provides the test configuration
- `server_url`: Returns the server URL from the configuration
- `openapi_spec`: Loads the OpenAPI specification
- `api_client`: Creates a requests Session for API calls
- `api_request`: Helper function for making consistent API requests
### Utilities
- `validation.py`: Functions for validating responses against the OpenAPI schema
- `schema_utils.py`: Utilities for extracting and manipulating schemas
## Notes
- Some tests are skipped with `@pytest.mark.skip` to avoid modifying state in automated testing
- Security-level restricted endpoints have minimal tests to avoid security issues
- Tests focus on read operations rather than write operations where possible

1
tests-api/__init__.py Normal file
View File

@ -0,0 +1 @@
# Make tests-api directory a proper package

237
tests-api/conftest.py Normal file
View File

@ -0,0 +1,237 @@
"""
PyTest configuration and fixtures for API tests.
"""
import os
import sys
import json
import pytest
import requests
import tempfile
import time
import yaml
from pathlib import Path
from typing import Dict, Generator, Optional, Tuple
# Import test utilities
import sys
import os
from pathlib import Path
# Get the absolute path to the current file (conftest.py)
current_file = Path(os.path.abspath(__file__))
# Get the directory containing the current file (the tests-api directory)
tests_api_dir = current_file.parent
# Add the tests-api directory to the Python path
if str(tests_api_dir) not in sys.path:
sys.path.insert(0, str(tests_api_dir))
# Apply mocks for ComfyUI imports
from mocks.patch import apply_mocks
apply_mocks()
# Now we can import from utils.validation
from utils.validation import load_openapi_spec
# Default test configuration
DEFAULT_TEST_CONFIG = {
"server_url": "http://localhost:8188",
"server_timeout": 2, # seconds
"wait_between_requests": 0.5, # seconds
"max_retries": 3,
}
@pytest.fixture(scope="session")
def test_config() -> Dict:
"""
Load test configuration from environment variables or use defaults.
"""
config = DEFAULT_TEST_CONFIG.copy()
# Override from environment variables if present
if "COMFYUI_SERVER_URL" in os.environ:
config["server_url"] = os.environ["COMFYUI_SERVER_URL"]
return config
@pytest.fixture(scope="session")
def server_url(test_config: Dict) -> str:
"""
Get the server URL from the test configuration.
"""
return test_config["server_url"]
@pytest.fixture(scope="session")
def openapi_spec() -> Dict:
"""
Load the OpenAPI specification.
"""
return load_openapi_spec()
@pytest.fixture(scope="session")
def api_client(server_url: str, test_config: Dict) -> requests.Session:
"""
Create a requests Session for API calls.
"""
session = requests.Session()
# Check if the server is running
try:
response = session.get(f"{server_url}/", timeout=test_config["server_timeout"])
response.raise_for_status()
except (requests.ConnectionError, requests.Timeout, requests.HTTPError):
pytest.skip("ComfyUI server is not running or not accessible")
return session
@pytest.fixture(scope="function")
def temp_dir() -> Generator[Path, None, None]:
"""
Create a temporary directory for test files.
"""
with tempfile.TemporaryDirectory() as temp_dir:
yield Path(temp_dir)
class SecurityLevelContext:
"""
Context manager for setting and restoring security levels.
"""
def __init__(self, api_client: requests.Session, server_url: str, security_level: str):
self.api_client = api_client
self.server_url = server_url
self.security_level = security_level
self.original_level = None
async def __aenter__(self):
# Get the current security level (not directly exposed in API, would require more setup)
# For now, we'll just set the new level
# Set the new security level
# Note: In a real implementation, we would need a way to set this
# This is a placeholder - the actual implementation would depend on how
# security levels are managed in ComfyUI-Manager
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
# Restore the original security level if needed
pass
@pytest.fixture
def security_level_context(api_client: requests.Session, server_url: str):
"""
Create a context manager for setting security levels.
"""
return lambda level: SecurityLevelContext(api_client, server_url, level)
def make_api_url(server_url: str, path: str) -> str:
"""
Construct a full API URL from the server URL and path.
"""
# Ensure the path starts with a slash
if not path.startswith("/"):
path = f"/{path}"
# Remove trailing slash from server_url if present
if server_url.endswith("/"):
server_url = server_url[:-1]
return f"{server_url}{path}"
@pytest.fixture
def api_request(api_client: requests.Session, server_url: str, test_config: Dict):
"""
Helper function for making API requests with consistent behavior.
"""
def _request(
method: str,
path: str,
params: Optional[Dict] = None,
json_data: Optional[Dict] = None,
headers: Optional[Dict] = None,
expected_status: int = 200,
retry_on_error: bool = True,
) -> Tuple[requests.Response, Optional[Dict]]:
"""
Make an API request with automatic validation.
Args:
method: HTTP method
path: API path
params: Query parameters
json_data: JSON request body
headers: HTTP headers
expected_status: Expected HTTP status code
retry_on_error: Whether to retry on connection errors
Returns:
Tuple of (Response object, JSON response data or None)
"""
method = method.lower()
url = make_api_url(server_url, path)
if headers is None:
headers = {}
# Add common headers
headers.setdefault("Accept", "application/json")
# Sleep between requests to avoid overwhelming the server
time.sleep(test_config["wait_between_requests"])
retries = test_config["max_retries"] if retry_on_error else 0
last_exception = None
for attempt in range(retries + 1):
try:
if method == "get":
response = api_client.get(url, params=params, headers=headers)
elif method == "post":
response = api_client.post(url, params=params, json=json_data, headers=headers)
elif method == "put":
response = api_client.put(url, params=params, json=json_data, headers=headers)
elif method == "delete":
response = api_client.delete(url, params=params, headers=headers)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
# Check status code
assert response.status_code == expected_status, (
f"Expected status code {expected_status}, got {response.status_code}"
)
# Parse JSON response if possible
json_response = None
if response.headers.get("Content-Type", "").startswith("application/json"):
try:
json_response = response.json()
except json.JSONDecodeError:
if expected_status == 200:
raise ValueError("Response was not valid JSON")
return response, json_response
except (requests.ConnectionError, requests.Timeout) as e:
last_exception = e
if attempt < retries:
# Wait before retrying
time.sleep(1)
continue
break
if last_exception:
raise last_exception
raise RuntimeError("Failed to make API request")
return _request

View File

@ -0,0 +1 @@
# Make tests-api/mocks directory a proper package

View File

@ -0,0 +1,26 @@
"""
Mock CustomNodeManager for testing purposes
"""
class CustomNodeManager:
"""
Mock implementation of the CustomNodeManager class
"""
instance = None
def __init__(self):
self.custom_nodes = {}
self.node_paths = []
self.refresh_timeout = None
def get_node_path(self, node_class):
"""
Mock implementation to get the path for a node class
"""
return self.custom_nodes.get(node_class, None)
def update_node_paths(self):
"""
Mock implementation to update node paths
"""
pass

116
tests-api/mocks/patch.py Normal file
View File

@ -0,0 +1,116 @@
"""
Patch module to mock imports for testing
"""
import sys
import importlib.util
import os
from pathlib import Path
# Import mock modules
from mocks.prompt_server import PromptServer
from mocks.custom_node_manager import CustomNodeManager
# Current directory
current_dir = Path(__file__).parent.parent # tests-api directory
# Define mocks
class MockModule:
"""Base class for mock modules"""
pass
# Create server mock module with PromptServer
server_mock = MockModule()
server_mock.PromptServer = PromptServer
prompt_server_instance = PromptServer()
server_mock.PromptServer.instance = prompt_server_instance
server_mock.PromptServer.inst = prompt_server_instance
# Create app mock module with custom_node_manager submodule
app_mock = MockModule()
app_custom_node_manager = MockModule()
app_custom_node_manager.CustomNodeManager = CustomNodeManager
app_custom_node_manager.CustomNodeManager.instance = CustomNodeManager()
# Create utils mock module with json_util submodule
utils_mock = MockModule()
utils_json_util = MockModule()
# Create utils.validation and utils.schema_utils submodules
utils_validation = MockModule()
utils_schema_utils = MockModule()
# Import actual modules (make sure path is set up correctly)
sys.path.insert(0, str(current_dir))
try:
# Import the validation module
from utils.validation import load_openapi_spec
utils_validation.load_openapi_spec = load_openapi_spec
# Import all schema_utils functions
from utils.schema_utils import (
get_all_paths,
get_grouped_paths,
get_methods_for_path,
find_paths_with_security,
get_content_types_for_response,
get_required_parameters
)
utils_schema_utils.get_all_paths = get_all_paths
utils_schema_utils.get_grouped_paths = get_grouped_paths
utils_schema_utils.get_methods_for_path = get_methods_for_path
utils_schema_utils.find_paths_with_security = find_paths_with_security
utils_schema_utils.get_content_types_for_response = get_content_types_for_response
utils_schema_utils.get_required_parameters = get_required_parameters
except ImportError as e:
print(f"Error importing test utilities: {e}")
# Define dummy functions if imports fail
def dummy_load_openapi_spec():
"""Dummy function for testing"""
return {"paths": {}}
utils_validation.load_openapi_spec = dummy_load_openapi_spec
def dummy_get_all_paths(spec):
return list(spec.get("paths", {}).keys())
utils_schema_utils.get_all_paths = dummy_get_all_paths
def dummy_get_grouped_paths(spec):
return {}
utils_schema_utils.get_grouped_paths = dummy_get_grouped_paths
def dummy_get_methods_for_path(spec, path):
return []
utils_schema_utils.get_methods_for_path = dummy_get_methods_for_path
def dummy_find_paths_with_security(spec, security_scheme=None):
return []
utils_schema_utils.find_paths_with_security = dummy_find_paths_with_security
def dummy_get_content_types_for_response(spec, path, method, status_code="200"):
return []
utils_schema_utils.get_content_types_for_response = dummy_get_content_types_for_response
def dummy_get_required_parameters(spec, path, method):
return []
utils_schema_utils.get_required_parameters = dummy_get_required_parameters
# Add merge_json_recursive from our mock utils
from mocks.utils import merge_json_recursive
utils_json_util.merge_json_recursive = merge_json_recursive
# Apply the mocks to sys.modules
def apply_mocks():
"""Apply all mocks to sys.modules"""
sys.modules['server'] = server_mock
sys.modules['app'] = app_mock
sys.modules['app.custom_node_manager'] = app_custom_node_manager
sys.modules['utils'] = utils_mock
sys.modules['utils.json_util'] = utils_json_util
sys.modules['utils.validation'] = utils_validation
sys.modules['utils.schema_utils'] = utils_schema_utils
# Make sure our actual utils module is importable
if current_dir not in sys.path:
sys.path.insert(0, str(current_dir))

View File

@ -0,0 +1,71 @@
"""
Mock PromptServer for testing purposes
"""
class MockRoutes:
"""
Mock routing class with method decorators
"""
def __init__(self):
self.routes = {}
def get(self, path):
"""Decorator for GET routes"""
def decorator(f):
self.routes[('GET', path)] = f
return f
return decorator
def post(self, path):
"""Decorator for POST routes"""
def decorator(f):
self.routes[('POST', path)] = f
return f
return decorator
def put(self, path):
"""Decorator for PUT routes"""
def decorator(f):
self.routes[('PUT', path)] = f
return f
return decorator
def delete(self, path):
"""Decorator for DELETE routes"""
def decorator(f):
self.routes[('DELETE', path)] = f
return f
return decorator
class PromptServer:
"""
Mock implementation of the PromptServer class
"""
instance = None
inst = None
def __init__(self):
self.routes = MockRoutes()
self.registered_paths = set()
self.base_url = "http://127.0.0.1:8188" # Assuming server is running on default port
self.queue_lock = None
def add_route(self, method, path, handler, *args, **kwargs):
"""
Add a mock route to the server
"""
self.routes.routes[(method.upper(), path)] = handler
self.registered_paths.add(path)
async def send_msg(self, message, data=None):
"""
Mock send_msg method (does nothing in the mock)
"""
pass
def send_sync(self, message, data=None):
"""
Mock send_sync method (does nothing in the mock)
"""
pass

20
tests-api/mocks/utils.py Normal file
View File

@ -0,0 +1,20 @@
"""
Mock utils module for testing purposes
"""
def merge_json_recursive(a, b):
"""
Mock implementation of merge_json_recursive
"""
if isinstance(a, dict) and isinstance(b, dict):
result = a.copy()
for key, value in b.items():
if key in result and isinstance(result[key], (dict, list)) and isinstance(value, (dict, list)):
result[key] = merge_json_recursive(result[key], value)
else:
result[key] = value
return result
elif isinstance(a, list) and isinstance(b, list):
return a + b
else:
return b

382
tests-api/openapi.yaml Normal file
View File

@ -0,0 +1,382 @@
openapi: 3.0.3
info:
title: ComfyUI-Manager API
description: API for managing ComfyUI extensions, custom nodes, and models
version: 1.0.0
contact:
name: ComfyUI Community
url: https://github.com/comfyanonymous/ComfyUI
servers:
- url: http://localhost:8188
description: Local ComfyUI server
paths:
/customnode/getlist:
get:
summary: Get the list of custom nodes
description: Returns the list of custom nodes from all configured channels
parameters:
- name: mode
in: query
description: "The mode to retrieve (local=installed nodes, remote=available nodes)"
schema:
type: string
enum: [local, remote]
default: remote
responses:
'200':
description: List of custom nodes
content:
application/json:
schema:
type: object
properties:
nodes:
type: array
items:
$ref: '#/components/schemas/CustomNode'
'500':
description: Server error
/customnode/get_node_mappings:
get:
summary: Get mappings between node class names and their custom nodes
description: Returns mappings that help identify which custom node package provides specific node classes
parameters:
- name: mode
in: query
description: "The mode for mappings (local=installed nodes, nickname=node nicknames)"
schema:
type: string
enum: [local, nickname]
default: local
required: true
responses:
'200':
description: Node mappings
content:
application/json:
schema:
type: object
additionalProperties:
type: string
'500':
description: Server error
/customnode/get_node_alternatives:
get:
summary: Get alternative nodes for specific node classes
description: Returns alternative implementations of node classes from different custom node packages
parameters:
- name: mode
in: query
description: "The mode to retrieve alternatives (local=installed nodes, remote=all available nodes)"
schema:
type: string
enum: [local, remote]
default: remote
responses:
'200':
description: Node alternatives
content:
application/json:
schema:
type: object
additionalProperties:
type: array
items:
type: string
'500':
description: Server error
/externalmodel/getlist:
get:
summary: Get the list of external models
description: Returns the list of models from all configured channels
parameters:
- name: mode
in: query
description: "The mode to retrieve (local=installed models, remote=available models)"
schema:
type: string
enum: [local, remote]
default: remote
responses:
'200':
description: List of external models
content:
application/json:
schema:
type: object
properties:
models:
type: array
items:
$ref: '#/components/schemas/ExternalModel'
'500':
description: Server error
/manager/get_config:
get:
summary: Get manager configuration
description: Returns the current configuration of ComfyUI-Manager
parameters:
- name: key
in: query
description: "The configuration key to retrieve"
schema:
type: string
required: true
responses:
'200':
description: Configuration value
content:
application/json:
schema:
type: object
properties:
value:
type: string
'400':
description: Invalid key or missing parameter
'500':
description: Server error
/manager/set_config:
post:
summary: Set manager configuration
description: Updates the configuration of ComfyUI-Manager
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- key
- value
properties:
key:
type: string
description: "The configuration key to update"
value:
type: string
description: "The new value for the configuration key"
responses:
'200':
description: Configuration updated successfully
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
'400':
description: Invalid key or value
'500':
description: Server error
/snapshot/getlist:
get:
summary: Get the list of snapshots
description: Returns the list of saved snapshots
responses:
'200':
description: List of snapshots
content:
application/json:
schema:
type: object
properties:
snapshots:
type: array
items:
$ref: '#/components/schemas/Snapshot'
'500':
description: Server error
/comfyui_manager/queue/status:
get:
summary: Get queue status
description: Returns the current status of the operation queue
responses:
'200':
description: Queue status
content:
application/json:
schema:
$ref: '#/components/schemas/QueueStatus'
'500':
description: Server error
components:
schemas:
CustomNode:
type: object
required:
- name
- title
- reference
properties:
name:
type: string
description: "Internal name/ID of the custom node"
title:
type: string
description: "Display title of the custom node"
reference:
type: string
description: "Reference URL (usually GitHub repository URL)"
description:
type: string
description: "Description of what the custom node does"
install_type:
type: string
enum: [git, pip, copy]
description: "Installation method for the custom node"
files:
type: array
items:
type: string
description: "List of files provided by this custom node"
node_class_names:
type: array
items:
type: string
description: "List of node class names provided by this custom node"
installed:
type: boolean
description: "Whether the custom node is installed"
version:
type: string
description: "Version of the custom node"
tags:
type: array
items:
type: string
description: "Tags associated with the custom node"
ExternalModel:
type: object
required:
- name
- type
- url
properties:
name:
type: string
description: "Name of the model"
type:
type: string
description: "Type of the model (checkpoint, lora, embedding, etc.)"
url:
type: string
description: "Download URL for the model"
description:
type: string
description: "Description of the model"
size:
type: integer
description: "Size of the model in bytes"
installed:
type: boolean
description: "Whether the model is installed"
version:
type: string
description: "Version of the model"
tags:
type: array
items:
type: string
description: "Tags associated with the model"
Snapshot:
type: object
required:
- name
- date
properties:
name:
type: string
description: "Name of the snapshot"
date:
type: string
format: date-time
description: "Date when the snapshot was created"
description:
type: string
description: "Description of the snapshot"
nodes:
type: array
items:
type: string
description: "List of custom nodes in the snapshot"
models:
type: array
items:
type: string
description: "List of models in the snapshot"
QueueStatus:
type: object
properties:
pending:
type: array
items:
$ref: '#/components/schemas/QueueItem'
description: "List of pending operations in the queue"
completed:
type: array
items:
$ref: '#/components/schemas/QueueItem'
description: "List of completed operations in the queue"
failed:
type: array
items:
$ref: '#/components/schemas/QueueItem'
description: "List of failed operations in the queue"
running:
type: boolean
description: "Whether the queue is currently running"
QueueItem:
type: object
required:
- id
- type
- target
properties:
id:
type: string
description: "Unique ID of the queue item"
type:
type: string
enum: [install, update, uninstall]
description: "Type of operation"
target:
type: string
description: "Target of the operation (e.g., custom node name, model name)"
status:
type: string
enum: [pending, processing, completed, failed]
description: "Current status of the operation"
error:
type: string
description: "Error message if the operation failed"
created_at:
type: string
format: date-time
description: "Time when the operation was added to the queue"
completed_at:
type: string
format: date-time
description: "Time when the operation was completed"
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
description: "API key for authentication"

View File

@ -0,0 +1,6 @@
pytest>=7.3.1
requests>=2.31.0
openapi-spec-validator>=0.6.0
jsonschema>=4.17.3
pytest-asyncio>=0.21.0
pyyaml>=6.0

View File

@ -0,0 +1,270 @@
"""
Tests for configuration endpoints.
"""
import pytest
from typing import Callable, Dict, List, Tuple
from utils.validation import validate_response
def test_get_preview_method(
api_request: Callable
):
"""
Test getting the current preview method.
"""
# Make the API request
path = "/manager/preview_method"
response, _ = api_request(
method="get",
path=path,
expected_status=200,
)
# Verify the response is one of the valid preview methods
assert response.text in ["auto", "latent2rgb", "taesd", "none"]
def test_get_db_mode(
api_request: Callable
):
"""
Test getting the current database mode.
"""
# Make the API request
path = "/manager/db_mode"
response, _ = api_request(
method="get",
path=path,
expected_status=200,
)
# Verify the response is one of the valid database modes
assert response.text in ["channel", "local", "remote"]
def test_get_component_policy(
api_request: Callable
):
"""
Test getting the current component policy.
"""
# Make the API request
path = "/manager/policy/component"
response, _ = api_request(
method="get",
path=path,
expected_status=200,
)
# Component policy could be any string
assert response.text is not None
def test_get_update_policy(
api_request: Callable
):
"""
Test getting the current update policy.
"""
# Make the API request
path = "/manager/policy/update"
response, _ = api_request(
method="get",
path=path,
expected_status=200,
)
# Verify the response is one of the valid update policies
assert response.text in ["stable", "nightly", "nightly-comfyui"]
def test_get_channel_url_list(
api_request: Callable,
openapi_spec: Dict
):
"""
Test getting the channel URL list.
"""
# Make the API request
path = "/manager/channel_url_list"
response, json_data = api_request(
method="get",
path=path,
expected_status=200,
)
# Validate response structure against the schema
assert json_data is not None
validate_response(
response_data=json_data,
path=path,
method="get",
spec=openapi_spec,
)
# Verify the response contains the expected fields
assert "selected" in json_data
assert "list" in json_data
assert isinstance(json_data["list"], list)
# Each channel should have a name and URL
if json_data["list"]:
first_channel = json_data["list"][0]
assert "name" in first_channel
assert "url" in first_channel
def test_get_manager_version(
api_request: Callable
):
"""
Test getting the manager version.
"""
# Make the API request
path = "/manager/version"
response, _ = api_request(
method="get",
path=path,
expected_status=200,
)
# Verify the response is a version string
assert response.text.startswith("V") # Version strings start with V
def test_get_manager_notice(
api_request: Callable
):
"""
Test getting the manager notice.
"""
# Make the API request
path = "/manager/notice"
response, _ = api_request(
method="get",
path=path,
expected_status=200,
)
# Verify the response is HTML content
assert response.headers.get("Content-Type", "").startswith("text/html") or "ComfyUI" in response.text
@pytest.mark.skip(reason="State-modifying operations")
class TestConfigChanges:
"""
Tests for changing configuration settings.
These are skipped to avoid modifying state in automated tests.
"""
@pytest.fixture(scope="class", autouse=True)
def save_original_config(self, api_request: Callable):
"""
Save the original configuration to restore after tests.
"""
# Save original values
response, _ = api_request(
method="get",
path="/manager/preview_method",
expected_status=200,
)
self.original_preview_method = response.text
response, _ = api_request(
method="get",
path="/manager/db_mode",
expected_status=200,
)
self.original_db_mode = response.text
response, _ = api_request(
method="get",
path="/manager/policy/update",
expected_status=200,
)
self.original_update_policy = response.text
yield
# Restore original values
api_request(
method="get",
path="/manager/preview_method",
params={"value": self.original_preview_method},
expected_status=200,
)
api_request(
method="get",
path="/manager/db_mode",
params={"value": self.original_db_mode},
expected_status=200,
)
api_request(
method="get",
path="/manager/policy/update",
params={"value": self.original_update_policy},
expected_status=200,
)
def test_set_preview_method(self, api_request: Callable):
"""
Test setting the preview method.
"""
# Set to a different value (taesd)
api_request(
method="get",
path="/manager/preview_method",
params={"value": "taesd"},
expected_status=200,
)
# Verify it was changed
response, _ = api_request(
method="get",
path="/manager/preview_method",
expected_status=200,
)
assert response.text == "taesd"
def test_set_db_mode(self, api_request: Callable):
"""
Test setting the database mode.
"""
# Set to local mode
api_request(
method="get",
path="/manager/db_mode",
params={"value": "local"},
expected_status=200,
)
# Verify it was changed
response, _ = api_request(
method="get",
path="/manager/db_mode",
expected_status=200,
)
assert response.text == "local"
def test_set_update_policy(self, api_request: Callable):
"""
Test setting the update policy.
"""
# Set to stable
api_request(
method="get",
path="/manager/policy/update",
params={"value": "stable"},
expected_status=200,
)
# Verify it was changed
response, _ = api_request(
method="get",
path="/manager/policy/update",
expected_status=200,
)
assert response.text == "stable"

View File

@ -0,0 +1,200 @@
"""
Tests for custom node management endpoints.
"""
import pytest
from pathlib import Path
from typing import Callable, Dict, Tuple
from utils.validation import validate_response
@pytest.mark.parametrize(
"mode",
["local", "remote"]
)
def test_get_custom_node_list(
api_request: Callable,
openapi_spec: Dict,
mode: str
):
"""
Test the endpoint for listing custom nodes.
"""
# Make the API request
path = "/customnode/getlist"
response, json_data = api_request(
method="get",
path=path,
params={"mode": mode, "skip_update": "true"},
expected_status=200,
)
# Validate response structure against the schema
assert json_data is not None
validate_response(
response_data=json_data,
path=path,
method="get",
spec=openapi_spec,
)
# Verify the response contains the expected fields
assert "channel" in json_data
assert "node_packs" in json_data
assert isinstance(json_data["node_packs"], dict)
# If there are any node packs, verify they have the expected structure
if json_data["node_packs"]:
# Take the first node pack to validate
first_node_pack = next(iter(json_data["node_packs"].values()))
assert "title" in first_node_pack
assert "name" in first_node_pack
def test_get_installed_nodes(
api_request: Callable,
openapi_spec: Dict
):
"""
Test the endpoint for listing installed nodes.
"""
# Make the API request
path = "/customnode/installed"
response, json_data = api_request(
method="get",
path=path,
expected_status=200,
)
# Validate response structure against the schema
assert json_data is not None
validate_response(
response_data=json_data,
path=path,
method="get",
spec=openapi_spec,
)
# Verify the response is a dictionary of node packs
assert isinstance(json_data, dict)
@pytest.mark.parametrize(
"mode",
["local", "nickname"]
)
def test_get_node_mappings(
api_request: Callable,
openapi_spec: Dict,
mode: str
):
"""
Test the endpoint for getting node-to-package mappings.
"""
# Make the API request
path = "/customnode/getmappings"
response, json_data = api_request(
method="get",
path=path,
params={"mode": mode},
expected_status=200,
)
# Validate response structure against the schema
assert json_data is not None
validate_response(
response_data=json_data,
path=path,
method="get",
spec=openapi_spec,
)
# Verify the response is a dictionary mapping extension IDs to node info
assert isinstance(json_data, dict)
# If there are any mappings, verify they have the expected structure
if json_data:
# Take the first mapping to validate
first_mapping = next(iter(json_data.values()))
assert isinstance(first_mapping, list)
assert len(first_mapping) == 2
assert isinstance(first_mapping[0], list) # List of node classes
assert isinstance(first_mapping[1], dict) # Metadata
@pytest.mark.parametrize(
"mode",
["local", "remote"]
)
def test_get_node_alternatives(
api_request: Callable,
openapi_spec: Dict,
mode: str
):
"""
Test the endpoint for getting alternative node options.
"""
# Make the API request
path = "/customnode/alternatives"
response, json_data = api_request(
method="get",
path=path,
params={"mode": mode},
expected_status=200,
)
# Validate response structure against the schema
assert json_data is not None
validate_response(
response_data=json_data,
path=path,
method="get",
spec=openapi_spec,
)
# Verify the response is a dictionary
assert isinstance(json_data, dict)
def test_fetch_updates(
api_request: Callable
):
"""
Test the endpoint for fetching updates.
This might modify state, so we just check for a valid response.
"""
# Make the API request with skip_update=true to avoid actual updates
path = "/customnode/fetch_updates"
response, _ = api_request(
method="get",
path=path,
params={"mode": "local"},
# Don't validate JSON since this endpoint doesn't return JSON
expected_status=200,
retry_on_error=False, # Don't retry as this might have side effects
)
# Just check the status code is as expected (covered by api_request)
assert response.status_code in [200, 201]
@pytest.mark.skip(reason="Queue endpoints are better tested with queue operations")
def test_queue_update_all(
api_request: Callable
):
"""
Test the endpoint for queuing updates for all nodes.
Skipping as this would actually modify the installation.
"""
pass
@pytest.mark.skip(reason="Security-restricted endpoint")
def test_install_node_via_git_url(
api_request: Callable
):
"""
Test the endpoint for installing a node via Git URL.
Skipping as this requires high security level and would modify the installation.
"""
pass

23
tests-api/test_import.py Normal file
View File

@ -0,0 +1,23 @@
import os
import sys
# Print current working directory
print(f"Current directory: {os.getcwd()}")
# Print module search path
print(f"System path: {sys.path}")
# Try to import
try:
from utils.validation import load_openapi_spec
print("Import successful!")
except ImportError as e:
print(f"Import error: {e}")
# Try direct import
try:
sys.path.insert(0, os.path.join(os.getcwd(), "custom_nodes/ComfyUI-Manager/tests-api"))
from utils.validation import load_openapi_spec
print("Direct import successful!")
except ImportError as e:
print(f"Direct import error: {e}")

View File

@ -0,0 +1,62 @@
"""
Tests for model management endpoints.
These features are scheduled for deprecation, so tests are minimal.
"""
import pytest
from typing import Callable, Dict
from utils.validation import validate_response
@pytest.mark.parametrize(
"mode",
["local", "remote"]
)
def test_get_external_model_list(
api_request: Callable,
openapi_spec: Dict,
mode: str
):
"""
Test the endpoint for listing external models.
"""
# Make the API request
path = "/externalmodel/getlist"
response, json_data = api_request(
method="get",
path=path,
params={"mode": mode},
expected_status=200,
)
# Validate response structure against the schema
assert json_data is not None
validate_response(
response_data=json_data,
path=path,
method="get",
spec=openapi_spec,
)
# Verify the response contains the expected fields
assert "models" in json_data
assert isinstance(json_data["models"], list)
# If there are any models, verify they have the expected structure
if json_data["models"]:
first_model = json_data["models"][0]
assert "name" in first_model
assert "type" in first_model
assert "url" in first_model
assert "filename" in first_model
assert "installed" in first_model
@pytest.mark.skip(reason="State-modifying operation that requires auth")
def test_install_model():
"""
Test queuing a model installation.
Skipped to avoid modifying state and requires authentication.
This feature is also scheduled for deprecation.
"""
pass

213
tests-api/test_queue_api.py Normal file
View File

@ -0,0 +1,213 @@
"""
Tests for queue management endpoints.
"""
import pytest
import time
from pathlib import Path
from typing import Callable, Dict, Tuple
from utils.validation import validate_response
def test_get_queue_status(
api_request: Callable,
openapi_spec: Dict
):
"""
Test the endpoint for getting queue status.
"""
# Make the API request
path = "/manager/queue/status"
response, json_data = api_request(
method="get",
path=path,
expected_status=200,
)
# Validate response structure against the schema
assert json_data is not None
validate_response(
response_data=json_data,
path=path,
method="get",
spec=openapi_spec,
)
# Verify the response contains the expected fields
assert "total_count" in json_data
assert "done_count" in json_data
assert "in_progress_count" in json_data
assert "is_processing" in json_data
# Type checks
assert isinstance(json_data["total_count"], int)
assert isinstance(json_data["done_count"], int)
assert isinstance(json_data["in_progress_count"], int)
assert isinstance(json_data["is_processing"], bool)
def test_reset_queue(
api_request: Callable
):
"""
Test the endpoint for resetting the queue.
"""
# Make the API request
path = "/manager/queue/reset"
response, _ = api_request(
method="get",
path=path,
expected_status=200,
)
# Now check the queue status to verify it was reset
response2, json_data = api_request(
method="get",
path="/manager/queue/status",
expected_status=200,
)
# Queue should be empty after reset
assert json_data["total_count"] == json_data["done_count"] + json_data["in_progress_count"]
@pytest.mark.skip(reason="State-modifying operation that requires auth")
def test_queue_install_node():
"""
Test queuing a node installation.
Skipped to avoid modifying state and requires authentication.
"""
pass
@pytest.mark.skip(reason="State-modifying operation that requires auth")
def test_queue_update_node():
"""
Test queuing a node update.
Skipped to avoid modifying state and requires authentication.
"""
pass
@pytest.mark.skip(reason="State-modifying operation that requires auth")
def test_queue_uninstall_node():
"""
Test queuing a node uninstallation.
Skipped to avoid modifying state and requires authentication.
"""
pass
@pytest.mark.skip(reason="State-modifying operation")
def test_queue_start():
"""
Test starting the queue.
Skipped to avoid modifying state.
"""
pass
class TestQueueOperations:
"""
Test a complete queue workflow.
These tests are grouped to ensure proper sequencing but are still skipped
to avoid modifying state in automated tests.
"""
@pytest.fixture(scope="class")
def node_data(self) -> Dict:
"""
Create test data for a node operation.
"""
# This would be replaced with actual data for a known safe node
return {
"ui_id": "test_node_1",
"id": "comfyui-manager", # Manager itself
"version": "latest",
"channel": "default",
"mode": "local",
}
@pytest.mark.skip(reason="State-modifying operation")
def test_queue_operation_sequence(
self,
api_request: Callable,
node_data: Dict
):
"""
Test the queue operation sequence.
"""
# 1. Reset the queue
api_request(
method="get",
path="/manager/queue/reset",
expected_status=200,
)
# 2. Queue a node operation (we'll use the manager itself)
api_request(
method="post",
path="/manager/queue/update",
json_data=node_data,
expected_status=200,
)
# 3. Check queue status - should have one operation
response, json_data = api_request(
method="get",
path="/manager/queue/status",
expected_status=200,
)
assert json_data["total_count"] > 0
assert not json_data["is_processing"] # Queue hasn't started yet
# 4. Start the queue
api_request(
method="get",
path="/manager/queue/start",
expected_status=200,
)
# 5. Check queue status again - should be processing
response, json_data = api_request(
method="get",
path="/manager/queue/status",
expected_status=200,
)
# Queue should be processing or already done
assert json_data["is_processing"] or json_data["done_count"] == json_data["total_count"]
# 6. Wait for queue to complete (with timeout)
max_wait_time = 60 # seconds
start_time = time.time()
completed = False
while time.time() - start_time < max_wait_time:
response, json_data = api_request(
method="get",
path="/manager/queue/status",
expected_status=200,
)
if json_data["done_count"] == json_data["total_count"] and not json_data["is_processing"]:
completed = True
break
time.sleep(2) # Wait before checking again
assert completed, "Queue did not complete within timeout period"
@pytest.mark.skip(reason="State-modifying operation")
def test_concurrent_queue_operations(
self,
api_request: Callable,
node_data: Dict
):
"""
Test concurrent queue operations.
"""
# This would test adding multiple operations to the queue
# and verifying they all complete correctly
pass

View File

@ -0,0 +1,198 @@
"""
Tests for snapshot management endpoints.
"""
import pytest
import time
from datetime import datetime
from pathlib import Path
from typing import Callable, Dict, List, Optional
from utils.validation import validate_response
def test_get_snapshot_list(
api_request: Callable,
openapi_spec: Dict
):
"""
Test the endpoint for listing snapshots.
"""
# Make the API request
path = "/snapshot/getlist"
response, json_data = api_request(
method="get",
path=path,
expected_status=200,
)
# Validate response structure against the schema
assert json_data is not None
validate_response(
response_data=json_data,
path=path,
method="get",
spec=openapi_spec,
)
# Verify the response contains the expected fields
assert "items" in json_data
assert isinstance(json_data["items"], list)
def test_get_current_snapshot(
api_request: Callable,
openapi_spec: Dict
):
"""
Test the endpoint for getting the current snapshot.
"""
# Make the API request
path = "/snapshot/get_current"
response, json_data = api_request(
method="get",
path=path,
expected_status=200,
)
# Validate response structure against the schema
assert json_data is not None
validate_response(
response_data=json_data,
path=path,
method="get",
spec=openapi_spec,
)
# Check for basic snapshot structure
assert "snapshot_date" in json_data
assert "custom_nodes" in json_data
@pytest.mark.skip(reason="This test creates a snapshot which is a state-modifying operation")
def test_save_snapshot(
api_request: Callable
):
"""
Test the endpoint for saving a new snapshot.
Skipped to avoid modifying state in tests.
"""
pass
@pytest.mark.skip(reason="This test removes a snapshot which is a destructive operation")
def test_remove_snapshot(
api_request: Callable
):
"""
Test the endpoint for removing a snapshot.
Skipped to avoid modifying state in tests.
"""
pass
@pytest.mark.skip(reason="This test restores a snapshot which is a state-modifying operation")
def test_restore_snapshot(
api_request: Callable
):
"""
Test the endpoint for restoring a snapshot.
Skipped to avoid modifying state in tests.
"""
pass
class TestSnapshotWorkflow:
"""
Test the complete snapshot workflow (create, list, get, remove).
These tests are grouped to ensure proper sequencing but are still skipped
to avoid modifying state in automated tests.
"""
@pytest.fixture(scope="class")
def snapshot_name(self) -> str:
"""
Generate a unique snapshot name for testing.
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"test_snapshot_{timestamp}"
@pytest.mark.skip(reason="State-modifying test")
def test_create_snapshot(
self,
api_request: Callable,
snapshot_name: str
):
"""
Test creating a snapshot.
"""
# Make the API request to save a snapshot
response, _ = api_request(
method="get",
path="/snapshot/save",
expected_status=200,
)
# Verify a snapshot was created (would need to check the snapshot list)
response2, json_data = api_request(
method="get",
path="/snapshot/getlist",
expected_status=200,
)
# The most recently created snapshot should be first in the list
assert json_data["items"]
# Store the snapshot name for later tests
self.actual_snapshot_name = json_data["items"][0]
@pytest.mark.skip(reason="State-modifying test")
def test_get_snapshot_details(
self,
api_request: Callable,
openapi_spec: Dict
):
"""
Test getting details of the created snapshot.
"""
# This would check the current snapshot, not a specific one
# since there's no direct API to get a specific snapshot
response, json_data = api_request(
method="get",
path="/snapshot/get_current",
expected_status=200,
)
# Validate the snapshot data
assert json_data is not None
validate_response(
response_data=json_data,
path="/snapshot/get_current",
method="get",
spec=openapi_spec,
)
@pytest.mark.skip(reason="State-modifying test")
def test_remove_test_snapshot(
self,
api_request: Callable
):
"""
Test removing the test snapshot.
"""
# Make the API request to remove the snapshot
response, _ = api_request(
method="get",
path="/snapshot/remove",
params={"target": self.actual_snapshot_name},
expected_status=200,
)
# Verify the snapshot was removed
response2, json_data = api_request(
method="get",
path="/snapshot/getlist",
expected_status=200,
)
# The snapshot should no longer be in the list
assert self.actual_snapshot_name not in json_data["items"]

View File

@ -0,0 +1,150 @@
"""
Tests for validating the OpenAPI specification.
"""
import json
import pytest
import yaml
from typing import Dict, Any, List, Tuple
from pathlib import Path
from openapi_spec_validator import validate_spec
from utils.validation import load_openapi_spec
from utils.schema_utils import (
get_all_paths,
get_methods_for_path,
find_paths_with_security,
get_required_parameters
)
def test_spec_is_valid():
"""
Test that the OpenAPI specification is valid according to the spec validator.
"""
spec = load_openapi_spec()
validate_spec(spec)
def test_spec_has_info():
"""
Test that the OpenAPI specification has basic info.
"""
spec = load_openapi_spec()
assert "info" in spec
assert "title" in spec["info"]
assert "version" in spec["info"]
assert spec["info"]["title"] == "ComfyUI-Manager API"
def test_spec_has_paths():
"""
Test that the OpenAPI specification has paths defined.
"""
spec = load_openapi_spec()
assert "paths" in spec
assert len(spec["paths"]) > 0
def test_paths_have_responses():
"""
Test that all paths have responses defined.
"""
spec = load_openapi_spec()
for path, path_item in spec["paths"].items():
for method, operation in path_item.items():
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
continue
assert "responses" in operation, f"Path {path} method {method} has no responses"
assert len(operation["responses"]) > 0, f"Path {path} method {method} has empty responses"
def test_responses_have_schemas():
"""
Test that responses with application/json content type have schemas.
"""
spec = load_openapi_spec()
for path, path_item in spec["paths"].items():
for method, operation in path_item.items():
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
continue
for status, response in operation["responses"].items():
if "content" not in response:
continue
if "application/json" in response["content"]:
assert "schema" in response["content"]["application/json"], (
f"Path {path} method {method} status {status} "
f"application/json content has no schema"
)
def test_required_parameters_have_schemas():
"""
Test that all required parameters have schemas.
"""
spec = load_openapi_spec()
for path, path_item in spec["paths"].items():
for method, operation in path_item.items():
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
continue
if "parameters" not in operation:
continue
for param in operation["parameters"]:
if param.get("required", False):
assert "schema" in param, (
f"Path {path} method {method} required parameter {param.get('name')} has no schema"
)
def test_security_schemes_defined():
"""
Test that security schemes are properly defined.
"""
spec = load_openapi_spec()
# Get paths requiring security
secure_paths = find_paths_with_security(spec)
if secure_paths:
assert "components" in spec, "Spec has secure paths but no components"
assert "securitySchemes" in spec["components"], "Spec has secure paths but no securitySchemes"
# Check each security reference is defined
for path, method in secure_paths:
operation = spec["paths"][path][method]
for security_req in operation["security"]:
for scheme_name in security_req:
assert scheme_name in spec["components"]["securitySchemes"], (
f"Security scheme {scheme_name} used by {method.upper()} {path} "
f"is not defined in components.securitySchemes"
)
def test_common_endpoint_groups_present():
"""
Test that the spec includes the main endpoint groups.
"""
spec = load_openapi_spec()
paths = get_all_paths(spec)
# Define the expected endpoint prefixes
expected_prefixes = [
"/customnode/",
"/externalmodel/",
"/manager/",
"/snapshot/",
"/comfyui_manager/",
]
# Check that at least one path exists for each expected prefix
for prefix in expected_prefixes:
matching_paths = [p for p in paths if p.startswith(prefix)]
assert matching_paths, f"No endpoints found with prefix {prefix}"

View File

@ -0,0 +1 @@
# Make utils directory a proper package

View File

@ -0,0 +1,174 @@
"""
Schema utilities for extracting and manipulating OpenAPI schemas.
"""
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from .validation import load_openapi_spec
def get_all_paths(spec: Dict[str, Any]) -> List[str]:
"""
Get all paths defined in the OpenAPI specification.
Args:
spec: The OpenAPI specification
Returns:
List of all paths
"""
return list(spec.get("paths", {}).keys())
def get_grouped_paths(spec: Dict[str, Any]) -> Dict[str, List[str]]:
"""
Group paths by their top-level segment.
Args:
spec: The OpenAPI specification
Returns:
Dictionary mapping top-level segments to lists of paths
"""
result = {}
for path in get_all_paths(spec):
segments = path.strip("/").split("/")
if not segments:
continue
top_segment = segments[0]
if top_segment not in result:
result[top_segment] = []
result[top_segment].append(path)
return result
def get_methods_for_path(spec: Dict[str, Any], path: str) -> List[str]:
"""
Get all HTTP methods defined for a path.
Args:
spec: The OpenAPI specification
path: The API path
Returns:
List of HTTP methods (lowercase)
"""
if path not in spec.get("paths", {}):
return []
return [
method.lower()
for method in spec["paths"][path].keys()
if method.lower() in {"get", "post", "put", "delete", "patch", "options", "head"}
]
def find_paths_with_security(
spec: Dict[str, Any],
security_scheme: Optional[str] = None
) -> List[Tuple[str, str]]:
"""
Find all paths that require security.
Args:
spec: The OpenAPI specification
security_scheme: Optional specific security scheme to filter by
Returns:
List of (path, method) tuples that require security
"""
result = []
for path, path_item in spec.get("paths", {}).items():
for method, operation in path_item.items():
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
continue
if "security" in operation:
if security_scheme is None:
result.append((path, method.lower()))
else:
# Check if this security scheme is required
for security_req in operation["security"]:
if security_scheme in security_req:
result.append((path, method.lower()))
break
return result
def get_content_types_for_response(
spec: Dict[str, Any],
path: str,
method: str,
status_code: str = "200"
) -> List[str]:
"""
Get content types defined for a response.
Args:
spec: The OpenAPI specification
path: The API path
method: The HTTP method
status_code: The HTTP status code
Returns:
List of content types
"""
method = method.lower()
if path not in spec["paths"]:
return []
if method not in spec["paths"][path]:
return []
if "responses" not in spec["paths"][path][method]:
return []
if status_code not in spec["paths"][path][method]["responses"]:
return []
response_def = spec["paths"][path][method]["responses"][status_code]
if "content" not in response_def:
return []
return list(response_def["content"].keys())
def get_required_parameters(
spec: Dict[str, Any],
path: str,
method: str
) -> List[Dict[str, Any]]:
"""
Get all required parameters for a path/method.
Args:
spec: The OpenAPI specification
path: The API path
method: The HTTP method
Returns:
List of parameter objects that are required
"""
method = method.lower()
if path not in spec["paths"]:
return []
if method not in spec["paths"][path]:
return []
if "parameters" not in spec["paths"][path][method]:
return []
return [
param for param in spec["paths"][path][method]["parameters"]
if param.get("required", False)
]

View File

@ -0,0 +1,155 @@
"""
Validation utilities for API tests.
"""
import json
import jsonschema
import yaml
from pathlib import Path
from typing import Any, Dict, Optional, Union
def load_openapi_spec(spec_path: Union[str, Path] = None) -> Dict[str, Any]:
"""
Load the OpenAPI specification document.
Args:
spec_path: Path to the OpenAPI specification file
Returns:
The OpenAPI specification as a dictionary
"""
if spec_path is None:
# Default to the root openapi.yaml file
spec_path = Path(__file__).parents[2] / "openapi.yaml"
with open(spec_path, "r") as f:
if str(spec_path).endswith(".yaml") or str(spec_path).endswith(".yml"):
return yaml.safe_load(f)
else:
return json.load(f)
def get_schema_for_path(
spec: Dict[str, Any],
path: str,
method: str,
status_code: str = "200",
content_type: str = "application/json"
) -> Optional[Dict[str, Any]]:
"""
Extract the response schema for a specific path, method, and status code.
Args:
spec: The OpenAPI specification
path: The API path (e.g., "/customnode/getlist")
method: The HTTP method (e.g., "get", "post")
status_code: The HTTP status code (default: "200")
content_type: The response content type (default: "application/json")
Returns:
The schema for the specified path and method, or None if not found
"""
method = method.lower()
if path not in spec["paths"]:
return None
if method not in spec["paths"][path]:
return None
if "responses" not in spec["paths"][path][method]:
return None
if status_code not in spec["paths"][path][method]["responses"]:
return None
response_def = spec["paths"][path][method]["responses"][status_code]
if "content" not in response_def:
return None
if content_type not in response_def["content"]:
return None
if "schema" not in response_def["content"][content_type]:
return None
return response_def["content"][content_type]["schema"]
def validate_response_schema(
response_data: Any,
schema: Dict[str, Any],
spec: Dict[str, Any] = None
) -> bool:
"""
Validate a response against a schema from the OpenAPI specification.
Args:
response_data: The response data to validate
schema: The schema to validate against
spec: The complete OpenAPI specification (for resolving references)
Returns:
True if validation succeeds, raises an exception otherwise
"""
if spec is None:
spec = load_openapi_spec()
# Create a resolver for references within the schema
resolver = jsonschema.RefResolver.from_schema(spec)
# Validate the response against the schema
jsonschema.validate(
instance=response_data,
schema=schema,
resolver=resolver
)
return True
def validate_response(
response_data: Any,
path: str,
method: str,
status_code: str = "200",
content_type: str = "application/json",
spec: Dict[str, Any] = None
) -> bool:
"""
Validate a response against the schema defined in the OpenAPI specification.
Args:
response_data: The response data to validate
path: The API path
method: The HTTP method
status_code: The HTTP status code (default: "200")
content_type: The response content type (default: "application/json")
spec: The OpenAPI specification (loaded from default location if None)
Returns:
True if validation succeeds, raises an exception otherwise
"""
if spec is None:
spec = load_openapi_spec()
schema = get_schema_for_path(
spec=spec,
path=path,
method=method,
status_code=status_code,
content_type=content_type
)
if schema is None:
raise ValueError(
f"No schema found for {method.upper()} {path} "
f"with status {status_code} and content type {content_type}"
)
return validate_response_schema(
response_data=response_data,
schema=schema,
spec=spec
)