mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-03-07 18:17:36 +08:00
* feat(deps): add unified dependency resolver using uv pip compile - Add UnifiedDepResolver module with 7 FRs: collect, compile, install pipeline - Integrate startup batch resolution in prestartup_script.py (module scope) - Skip per-node pip install in execute_install_script() when unified mode active - Add use_unified_resolver config flag following use_uv pattern - Input sanitization: reject -r, -e, --find-links, @ file://, path separators - Handle --index-url/--extra-index-url separation with credential redaction - Fallback to per-node pip on resolver failure or uv unavailability - Add 98 unit tests across 20 test classes - Add PRD and Design docs with cm_global integration marked as DEFERRED * fix(deps): reset use_unified_resolver flag on startup fallback When the unified resolver fails at startup (compile error, install error, uv unavailable, or generic exception), the runtime flag was not being reset to False. This caused subsequent runtime installs to incorrectly defer pip dependencies instead of falling back to per-node pip install. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(deps): add manual test cases for unified dependency resolver Add environment setup guide and 16 test cases covering: - Normal batch resolution (TC-1), disabled state (TC-2) - Fallback paths: uv unavailable (TC-3), compile fail (TC-4), install fail (TC-5), generic exception (TC-16) - install.py preservation (TC-6), runtime defer (TC-13) - Input sanitization: dangerous patterns (TC-7), path separators (TC-8), index-url separation (TC-9), credential redaction (TC-10) - Disabled pack exclusion (TC-11), no-deps path (TC-12) - Both unified resolver guard paths (TC-14), post-fallback (TC-15) Includes API reference, traceability matrix, and out-of-scope items. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(deps): prevent read_config() from overriding resolver fallback state read_config() in manager_core.py unconditionally re-read use_unified_resolver from config.ini, undoing the False set by prestartup_script.py on resolver fallback. This caused runtime installs to still defer deps even after a startup batch failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(deps): support multiple index URLs per line and optimize downgrade check - Rewrite _split_index_url() to handle multiple --index-url / --extra-index-url options on a single requirements.txt line using regex-based parsing instead of single split. - Cache installed_packages snapshot in collect_requirements() to avoid repeated subprocess calls during downgrade blacklist checks. - Add unit tests for multi-URL lines and bare --index-url edge case. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(deps): add E2E scripts and update test documentation Add automated E2E test scripts for unified dependency resolver: - setup_e2e_env.sh: idempotent environment setup (clone ComfyUI, create venv, install deps, symlink Manager, write config.ini) - start_comfyui.sh: foreground-blocking launcher using tail -f | grep -q readiness detection - stop_comfyui.sh: graceful SIGTERM → SIGKILL shutdown Update test documentation reflecting E2E testing findings: - TEST-environment-setup.md: add automated script usage, document caveats (PYTHONPATH, config.ini path, Manager v4 /v2/ prefix, Blocked by policy, bash ((var++)) trap, git+https:// rejection) - TEST-unified-dep-resolver.md: add TC-17 (restart dependency detection), TC-18 (real node pack integration), Validated Behaviors section, normalize API port to 8199 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(deps): harden input sanitization, expand test coverage, bump version Security: - Add _INLINE_DANGEROUS_OPTIONS regex to catch pip options after package names (--find-links, --constraint, --requirement, --editable, --trusted-host, --global-option, --install-option and short forms) - Stage index URLs in pending_urls, commit only after full line validation to prevent URL injection from rejected lines Tests: - Add 50 new tests: inline sanitization, false-positive guards, parse helpers (_parse_conflicts, _parse_install_output), exception paths (91 → 141 total, all pass) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(cli): add uv-compile command and --uv-compile flag for batch dependency resolution Add two CLI entry points for the unified dependency resolver: - `cm_cli uv-compile`: standalone batch resolution of all installed node pack dependencies via uv pip compile - `cm_cli install --uv-compile`: skip per-node pip, batch-resolve all deps after install completes (mutually exclusive with --no-deps) Both use a shared `_run_unified_resolve()` helper that passes real cm_global values (pip_blacklist, pip_overrides, pip_downgrade_blacklist) and guarantees PIPFixer.fix_broken() runs via try/finally. Update DESIGN, PRD, and TEST docs for consistency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
856 lines
29 KiB
Python
856 lines
29 KiB
Python
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import atexit
|
|
import threading
|
|
import re
|
|
import locale
|
|
import platform
|
|
import json
|
|
import ast
|
|
import logging
|
|
import traceback
|
|
|
|
from .common import security_check
|
|
from .common import manager_util
|
|
from .common import cm_global
|
|
from .common import manager_downloader
|
|
from .common.timestamp_utils import current_timestamp
|
|
import folder_paths
|
|
|
|
manager_util.add_python_path_to_env()
|
|
|
|
|
|
cm_global.pip_blacklist = {'torch', 'torchaudio', 'torchsde', 'torchvision'}
|
|
cm_global.pip_downgrade_blacklist = ['torch', 'torchaudio', 'torchsde', 'torchvision', 'transformers', 'safetensors', 'kornia']
|
|
|
|
|
|
def skip_pip_spam(x):
|
|
return ('Requirement already satisfied:' in x) or ("DEPRECATION: Loading egg at" in x)
|
|
|
|
|
|
message_collapses = [skip_pip_spam]
|
|
import_failed_extensions = set()
|
|
cm_global.variables['cm.on_revision_detected_handler'] = []
|
|
enable_file_logging = True
|
|
|
|
|
|
def register_message_collapse(f):
|
|
global message_collapses
|
|
message_collapses.append(f)
|
|
|
|
|
|
def is_import_failed_extension(name):
|
|
global import_failed_extensions
|
|
return name in import_failed_extensions
|
|
|
|
|
|
comfy_path = os.environ.get('COMFYUI_PATH')
|
|
comfy_base_path = os.environ.get('COMFYUI_FOLDERS_BASE_PATH')
|
|
|
|
if comfy_path is None:
|
|
comfy_path = os.path.abspath(os.path.dirname(sys.modules['__main__'].__file__))
|
|
os.environ['COMFYUI_PATH'] = comfy_path
|
|
|
|
if comfy_base_path is None:
|
|
comfy_base_path = comfy_path
|
|
|
|
|
|
sys.__comfyui_manager_register_message_collapse = register_message_collapse
|
|
sys.__comfyui_manager_is_import_failed_extension = is_import_failed_extension
|
|
cm_global.register_api('cm.register_message_collapse', register_message_collapse)
|
|
cm_global.register_api('cm.is_import_failed_extension', is_import_failed_extension)
|
|
|
|
|
|
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
|
|
|
|
custom_nodes_base_path = folder_paths.get_folder_paths('custom_nodes')[0]
|
|
manager_files_path = folder_paths.get_system_user_directory("manager")
|
|
manager_pip_overrides_path = os.path.join(manager_files_path, "pip_overrides.json")
|
|
manager_pip_blacklist_path = os.path.join(manager_files_path, "pip_blacklist.list")
|
|
restore_snapshot_path = os.path.join(manager_files_path, "startup-scripts", "restore-snapshot.json")
|
|
manager_config_path = os.path.join(manager_files_path, 'config.ini')
|
|
|
|
default_conf = {}
|
|
|
|
def read_config():
|
|
global default_conf
|
|
try:
|
|
import configparser
|
|
config = configparser.ConfigParser(strict=False)
|
|
config.read(manager_config_path)
|
|
default_conf = config['default']
|
|
except Exception:
|
|
pass
|
|
|
|
def read_uv_mode():
|
|
if 'use_uv' in default_conf:
|
|
manager_util.use_uv = default_conf['use_uv'].lower() == 'true'
|
|
|
|
|
|
def read_unified_resolver_mode():
|
|
if 'use_unified_resolver' in default_conf:
|
|
manager_util.use_unified_resolver = default_conf['use_unified_resolver'].lower() == 'true'
|
|
|
|
def check_file_logging():
|
|
global enable_file_logging
|
|
if 'file_logging' in default_conf and default_conf['file_logging'].lower() == 'false':
|
|
enable_file_logging = False
|
|
|
|
|
|
read_config()
|
|
read_uv_mode()
|
|
read_unified_resolver_mode()
|
|
security_check.security_check()
|
|
check_file_logging()
|
|
|
|
# Module-level flag set by startup batch resolver when it succeeds.
|
|
# Used by execute_lazy_install_script() to skip per-node pip installs.
|
|
_unified_resolver_succeeded = False
|
|
|
|
cm_global.pip_overrides = {}
|
|
|
|
if os.path.exists(manager_pip_overrides_path):
|
|
with open(manager_pip_overrides_path, 'r', encoding="UTF-8", errors="ignore") as json_file:
|
|
cm_global.pip_overrides = json.load(json_file)
|
|
|
|
|
|
if os.path.exists(manager_pip_blacklist_path):
|
|
with open(manager_pip_blacklist_path, 'r', encoding="UTF-8", errors="ignore") as f:
|
|
for x in f.readlines():
|
|
y = x.strip()
|
|
if y != '':
|
|
cm_global.pip_blacklist.add(y)
|
|
|
|
|
|
def remap_pip_package(pkg):
|
|
if pkg in cm_global.pip_overrides:
|
|
res = cm_global.pip_overrides[pkg]
|
|
print(f"[ComfyUI-Manager] '{pkg}' is remapped to '{res}'")
|
|
return res
|
|
else:
|
|
return pkg
|
|
|
|
|
|
std_log_lock = threading.Lock()
|
|
|
|
|
|
def handle_stream(stream, prefix):
|
|
stream.reconfigure(encoding=locale.getpreferredencoding(), errors='replace')
|
|
for msg in stream:
|
|
if prefix == '[!]' and ('it/s]' in msg or 's/it]' in msg) and ('%|' in msg or 'it [' in msg):
|
|
if msg.startswith('100%'):
|
|
print('\r' + msg, end="", file=sys.stderr),
|
|
else:
|
|
print('\r' + msg[:-1], end="", file=sys.stderr),
|
|
else:
|
|
if prefix == '[!]':
|
|
print(prefix, msg, end="", file=sys.stderr)
|
|
else:
|
|
print(prefix, msg, end="")
|
|
|
|
|
|
def process_wrap(cmd_str, cwd_path, handler=None, env=None):
|
|
process = subprocess.Popen(cmd_str, cwd=cwd_path, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
|
|
|
|
if handler is None:
|
|
handler = handle_stream
|
|
|
|
stdout_thread = threading.Thread(target=handler, args=(process.stdout, ""))
|
|
stderr_thread = threading.Thread(target=handler, args=(process.stderr, "[!]"))
|
|
|
|
stdout_thread.start()
|
|
stderr_thread.start()
|
|
|
|
stdout_thread.join()
|
|
stderr_thread.join()
|
|
|
|
return process.wait()
|
|
|
|
|
|
original_stdout = sys.stdout
|
|
|
|
|
|
def try_get_custom_nodes(x):
|
|
for custom_nodes_dir in folder_paths.get_folder_paths('custom_nodes'):
|
|
if x.startswith(custom_nodes_dir):
|
|
relative_path = os.path.relpath(x, custom_nodes_dir)
|
|
next_segment = relative_path.split(os.sep)[0]
|
|
if next_segment.lower() != 'comfyui-manager':
|
|
return next_segment, os.path.join(custom_nodes_dir, next_segment)
|
|
return None
|
|
|
|
|
|
def extract_origin_module():
|
|
stack = traceback.extract_stack()[:-2]
|
|
for frame in reversed(stack):
|
|
info = try_get_custom_nodes(frame.filename)
|
|
if info is None:
|
|
continue
|
|
else:
|
|
return info
|
|
return None
|
|
|
|
def extract_origin_module_from_strings(file_paths):
|
|
for filepath in file_paths:
|
|
info = try_get_custom_nodes(filepath)
|
|
if info is None:
|
|
continue
|
|
else:
|
|
return info
|
|
return None
|
|
|
|
|
|
def finalize_startup():
|
|
res = {}
|
|
for k, v in cm_global.error_dict.items():
|
|
if v['path'] in import_failed_extensions:
|
|
res[k] = v
|
|
|
|
cm_global.error_dict = res
|
|
|
|
|
|
try:
|
|
if '--port' in sys.argv:
|
|
port_index = sys.argv.index('--port')
|
|
if port_index + 1 < len(sys.argv):
|
|
port = int(sys.argv[port_index + 1])
|
|
postfix = f"_{port}"
|
|
else:
|
|
postfix = ""
|
|
else:
|
|
postfix = ""
|
|
|
|
# Logger setup
|
|
log_path_base = None
|
|
if enable_file_logging:
|
|
log_path_base = os.path.join(folder_paths.user_directory, 'comfyui')
|
|
|
|
if not os.path.exists(folder_paths.user_directory):
|
|
os.makedirs(folder_paths.user_directory)
|
|
|
|
if os.path.exists(f"{log_path_base}{postfix}.log"):
|
|
if os.path.exists(f"{log_path_base}{postfix}.prev.log"):
|
|
if os.path.exists(f"{log_path_base}{postfix}.prev2.log"):
|
|
os.remove(f"{log_path_base}{postfix}.prev2.log")
|
|
os.rename(f"{log_path_base}{postfix}.prev.log", f"{log_path_base}{postfix}.prev2.log")
|
|
os.rename(f"{log_path_base}{postfix}.log", f"{log_path_base}{postfix}.prev.log")
|
|
|
|
log_file = open(f"{log_path_base}{postfix}.log", "w", encoding="utf-8", errors="ignore")
|
|
|
|
log_lock = threading.Lock()
|
|
|
|
original_stdout = sys.stdout
|
|
original_stderr = sys.stderr
|
|
|
|
if original_stdout.encoding.lower() == 'utf-8':
|
|
write_stdout = original_stdout.write
|
|
write_stderr = original_stderr.write
|
|
else:
|
|
def wrapper_stdout(msg):
|
|
original_stdout.write(msg.encode('utf-8').decode(original_stdout.encoding, errors="ignore"))
|
|
|
|
def wrapper_stderr(msg):
|
|
original_stderr.write(msg.encode('utf-8').decode(original_stderr.encoding, errors="ignore"))
|
|
|
|
write_stdout = wrapper_stdout
|
|
write_stderr = wrapper_stderr
|
|
|
|
pat_tqdm = r'\d+%.*\[(.*?)\]'
|
|
pat_import_fail = r'seconds \(IMPORT FAILED\):(.*)$'
|
|
|
|
is_start_mode = True
|
|
|
|
|
|
class ComfyUIManagerLogger:
|
|
def __init__(self, is_stdout):
|
|
self.is_stdout = is_stdout
|
|
self.encoding = "utf-8"
|
|
self.last_char = ''
|
|
|
|
def fileno(self):
|
|
try:
|
|
if self.is_stdout:
|
|
return original_stdout.fileno()
|
|
else:
|
|
return original_stderr.fileno()
|
|
except AttributeError:
|
|
# Handle error
|
|
raise ValueError("The object does not have a fileno method")
|
|
|
|
def isatty(self):
|
|
return False
|
|
|
|
def write(self, message):
|
|
global is_start_mode
|
|
|
|
if any(f(message) for f in message_collapses):
|
|
return
|
|
|
|
if is_start_mode:
|
|
match = re.search(pat_import_fail, message)
|
|
if match:
|
|
import_failed_extensions.add(match.group(1).strip())
|
|
|
|
if not self.is_stdout:
|
|
origin_info = extract_origin_module()
|
|
if origin_info is not None:
|
|
name, origin_path = origin_info
|
|
|
|
if name != 'comfyui-manager':
|
|
if name not in cm_global.error_dict:
|
|
cm_global.error_dict[name] = {'name': name, 'path': origin_path, 'msg': ''}
|
|
|
|
cm_global.error_dict[name]['msg'] += message
|
|
|
|
if not self.is_stdout:
|
|
match = re.search(pat_tqdm, message)
|
|
if match:
|
|
message = re.sub(r'([#|])\d', r'\1▌', message)
|
|
message = re.sub('#', '█', message)
|
|
if '100%' in message:
|
|
self.sync_write(message)
|
|
else:
|
|
write_stderr(message)
|
|
original_stderr.flush()
|
|
else:
|
|
self.sync_write(message)
|
|
else:
|
|
self.sync_write(message)
|
|
|
|
def sync_write(self, message, file_only=False):
|
|
with log_lock:
|
|
timestamp = current_timestamp()
|
|
if self.last_char != '\n':
|
|
log_file.write(message)
|
|
else:
|
|
log_file.write(f"[{timestamp}] {message}")
|
|
|
|
try:
|
|
log_file.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
self.last_char = message if message == '' else message[-1]
|
|
|
|
if not file_only:
|
|
with std_log_lock:
|
|
if self.is_stdout:
|
|
write_stdout(message)
|
|
original_stdout.flush()
|
|
else:
|
|
write_stderr(message)
|
|
original_stderr.flush()
|
|
|
|
def flush(self):
|
|
try:
|
|
log_file.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
with std_log_lock:
|
|
try:
|
|
if self.is_stdout:
|
|
original_stdout.flush()
|
|
else:
|
|
original_stderr.flush()
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
def close(self):
|
|
self.flush()
|
|
|
|
def reconfigure(self, *args, **kwargs):
|
|
pass
|
|
|
|
# You can close through sys.stderr.close_log()
|
|
def close_log(self):
|
|
sys.stderr = original_stderr
|
|
sys.stdout = original_stdout
|
|
log_file.close()
|
|
|
|
def close_log():
|
|
sys.stderr = original_stderr
|
|
sys.stdout = original_stdout
|
|
log_file.close()
|
|
|
|
|
|
if enable_file_logging:
|
|
sys.stdout = ComfyUIManagerLogger(True)
|
|
stderr_wrapper = ComfyUIManagerLogger(False)
|
|
sys.stderr = stderr_wrapper
|
|
|
|
atexit.register(close_log)
|
|
else:
|
|
sys.stdout.close_log = lambda: None
|
|
stderr_wrapper = None
|
|
|
|
|
|
class LoggingHandler(logging.Handler):
|
|
def emit(self, record):
|
|
global is_start_mode
|
|
|
|
try:
|
|
message = record.getMessage()
|
|
except Exception as e:
|
|
message = f"<<logging error>>: {record} - {e}"
|
|
original_stderr.write(message)
|
|
|
|
if is_start_mode:
|
|
match = re.search(pat_import_fail, message)
|
|
if match:
|
|
import_failed_extensions.add(match.group(1).strip())
|
|
|
|
if 'Traceback' in message:
|
|
file_lists = self._extract_file_paths(message)
|
|
origin_info = extract_origin_module_from_strings(file_lists)
|
|
if origin_info is not None:
|
|
name, origin_path = origin_info
|
|
|
|
if name != 'comfyui-manager':
|
|
if name not in cm_global.error_dict:
|
|
cm_global.error_dict[name] = {'name': name, 'path': origin_path, 'msg': ''}
|
|
|
|
cm_global.error_dict[name]['msg'] += message
|
|
|
|
if 'Starting server' in message:
|
|
is_start_mode = False
|
|
finalize_startup()
|
|
|
|
if stderr_wrapper:
|
|
stderr_wrapper.sync_write(message+'\n', file_only=True)
|
|
|
|
def _extract_file_paths(self, msg):
|
|
file_paths = []
|
|
for line in msg.split('\n'):
|
|
match = re.findall(r'File \"(.*?)\", line \d+', line)
|
|
for x in match:
|
|
if not x.startswith('<'):
|
|
file_paths.extend(match)
|
|
return file_paths
|
|
|
|
|
|
logging.getLogger().addHandler(LoggingHandler())
|
|
|
|
|
|
except Exception as e:
|
|
print(f"[ComfyUI-Manager] Logging failed: {e}")
|
|
|
|
|
|
print("** ComfyUI startup time:", current_timestamp())
|
|
print("** Platform:", platform.system())
|
|
print("** Python version:", sys.version)
|
|
print("** Python executable:", sys.executable)
|
|
print("** ComfyUI Path:", comfy_path)
|
|
print("** ComfyUI Base Folder Path:", comfy_base_path)
|
|
print("** User directory:", folder_paths.user_directory)
|
|
print("** ComfyUI-Manager config path:", manager_config_path)
|
|
|
|
|
|
if log_path_base is not None:
|
|
print("** Log path:", os.path.abspath(f'{log_path_base}.log'))
|
|
else:
|
|
print("** Log path: file logging is disabled")
|
|
|
|
|
|
def read_downgrade_blacklist():
|
|
try:
|
|
if 'downgrade_blacklist' in default_conf:
|
|
items = default_conf['downgrade_blacklist'].split(',')
|
|
items = [x.strip() for x in items if x != '']
|
|
cm_global.pip_downgrade_blacklist += items
|
|
cm_global.pip_downgrade_blacklist = list(set(cm_global.pip_downgrade_blacklist))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
read_downgrade_blacklist()
|
|
|
|
|
|
def check_bypass_ssl():
|
|
try:
|
|
import ssl
|
|
if 'bypass_ssl' in default_conf and default_conf['bypass_ssl'].lower() == 'true':
|
|
print(f"[ComfyUI-Manager] WARN: Unsafe - SSL verification bypass option is Enabled. (see {manager_config_path})")
|
|
ssl._create_default_https_context = ssl._create_unverified_context # SSL certificate error fix.
|
|
except Exception:
|
|
pass
|
|
|
|
check_bypass_ssl()
|
|
|
|
|
|
# Perform install
|
|
processed_install = set()
|
|
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)
|
|
|
|
|
|
def is_installed(name):
|
|
name = name.strip()
|
|
|
|
if name.startswith('#'):
|
|
return True
|
|
|
|
pattern = r'([^<>!~=]+)([<>!~=]=?)([0-9.a-zA-Z]*)'
|
|
match = re.search(pattern, name)
|
|
|
|
if match:
|
|
name = match.group(1)
|
|
|
|
if name in cm_global.pip_blacklist:
|
|
return True
|
|
|
|
if name in cm_global.pip_downgrade_blacklist:
|
|
pips = manager_util.get_installed_packages()
|
|
|
|
if match is None:
|
|
if name in pips:
|
|
return True
|
|
elif match.group(2) in ['<=', '==', '<', '~=']:
|
|
if name in pips:
|
|
if manager_util.StrictVersion(pips[name]) >= manager_util.StrictVersion(match.group(3)):
|
|
print(f"[ComfyUI-Manager] skip black listed pip installation: '{name}'")
|
|
return True
|
|
|
|
pkg = manager_util.get_installed_packages().get(name.lower())
|
|
if pkg is None:
|
|
return False # update if not installed
|
|
|
|
if match is None:
|
|
return True # don't update if version is not specified
|
|
|
|
if match.group(2) in ['>', '>=']:
|
|
if manager_util.StrictVersion(pkg) < manager_util.StrictVersion(match.group(3)):
|
|
return False
|
|
elif manager_util.StrictVersion(pkg) > manager_util.StrictVersion(match.group(3)):
|
|
print(f"[SKIP] Downgrading pip package isn't allowed: {name.lower()} (cur={pkg})")
|
|
|
|
if match.group(2) == '==':
|
|
if manager_util.StrictVersion(pkg) < manager_util.StrictVersion(match.group(3)):
|
|
return False
|
|
|
|
if match.group(2) == '~=':
|
|
if manager_util.StrictVersion(pkg) == manager_util.StrictVersion(match.group(3)):
|
|
return False
|
|
|
|
return True # prevent downgrade
|
|
|
|
|
|
if os.path.exists(restore_snapshot_path):
|
|
try:
|
|
cloned_repos = []
|
|
|
|
def msg_capture(stream, prefix):
|
|
stream.reconfigure(encoding=locale.getpreferredencoding(), errors='replace')
|
|
for msg in stream:
|
|
if msg.startswith("CLONE: "):
|
|
cloned_repos.append(msg[7:])
|
|
if prefix == '[!]':
|
|
print(prefix, msg, end="", file=sys.stderr)
|
|
else:
|
|
print(prefix, msg, end="")
|
|
|
|
elif prefix == '[!]' and ('it/s]' in msg or 's/it]' in msg) and ('%|' in msg or 'it [' in msg):
|
|
if msg.startswith('100%'):
|
|
print('\r' + msg, end="", file=sys.stderr),
|
|
else:
|
|
print('\r'+msg[:-1], end="", file=sys.stderr),
|
|
else:
|
|
if prefix == '[!]':
|
|
print(prefix, msg, end="", file=sys.stderr)
|
|
else:
|
|
print(prefix, msg, end="")
|
|
|
|
print("[ComfyUI-Manager] Restore snapshot.")
|
|
new_env = os.environ.copy()
|
|
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
|
|
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
|
|
|
|
if 'COMFYUI_PATH' not in new_env:
|
|
new_env['COMFYUI_PATH'] = os.path.dirname(folder_paths.__file__)
|
|
|
|
cmd_str = [sys.executable, '-m', 'cm_cli', 'restore-snapshot', restore_snapshot_path]
|
|
exit_code = process_wrap(cmd_str, custom_nodes_base_path, handler=msg_capture, env=new_env)
|
|
|
|
if exit_code != 0:
|
|
print("[ComfyUI-Manager] Restore snapshot failed.")
|
|
else:
|
|
print("[ComfyUI-Manager] Restore snapshot done.")
|
|
|
|
except Exception as e:
|
|
print(e)
|
|
print("[ComfyUI-Manager] Restore snapshot failed.")
|
|
|
|
os.remove(restore_snapshot_path)
|
|
|
|
|
|
def execute_lazy_install_script(repo_path, executable):
|
|
global processed_install
|
|
|
|
install_script_path = os.path.join(repo_path, "install.py")
|
|
requirements_path = os.path.join(repo_path, "requirements.txt")
|
|
|
|
if os.path.exists(requirements_path) and not _unified_resolver_succeeded:
|
|
# Per-node pip install: only runs if unified resolver is disabled or failed
|
|
print(f"Install: pip packages for '{repo_path}'")
|
|
|
|
lines = manager_util.robust_readlines(requirements_path)
|
|
for line in lines:
|
|
package_name = remap_pip_package(line.strip())
|
|
package_name = package_name.split('#')[0].strip()
|
|
if package_name and not is_installed(package_name):
|
|
if '--index-url' in package_name:
|
|
s = package_name.split('--index-url')
|
|
install_cmd = manager_util.make_pip_cmd(["install", s[0].strip(), '--index-url', s[1].strip()])
|
|
else:
|
|
install_cmd = manager_util.make_pip_cmd(["install", package_name])
|
|
|
|
process_wrap(install_cmd, repo_path)
|
|
|
|
if os.path.exists(install_script_path) and f'{repo_path}/install.py' not in processed_install:
|
|
processed_install.add(f'{repo_path}/install.py')
|
|
print(f"Install: install script for '{repo_path}'")
|
|
install_cmd = [executable, "install.py"]
|
|
|
|
new_env = os.environ.copy()
|
|
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
|
|
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
|
|
process_wrap(install_cmd, repo_path, env=new_env)
|
|
|
|
|
|
def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path):
|
|
import uuid
|
|
import shutil
|
|
|
|
# 1. download
|
|
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
|
|
download_path = os.path.join(custom_nodes_path, archive_name)
|
|
manager_downloader.download_url(zip_url, custom_nodes_path, archive_name)
|
|
|
|
# 2. extract files into <node_id>@<cur_ver>
|
|
extracted = manager_util.extract_package_as_zip(download_path, from_path)
|
|
os.remove(download_path)
|
|
|
|
if extracted is None:
|
|
if len(os.listdir(from_path)) == 0:
|
|
shutil.rmtree(from_path)
|
|
|
|
print(f'Empty archive file: {target}')
|
|
return False
|
|
|
|
|
|
# 3. calculate garbage files (.tracking - extracted)
|
|
tracking_info_file = os.path.join(from_path, '.tracking')
|
|
prev_files = set()
|
|
with open(tracking_info_file, 'r') as f:
|
|
for line in f:
|
|
prev_files.add(line.strip())
|
|
garbage = prev_files.difference(extracted)
|
|
garbage = [os.path.join(custom_nodes_path, x) for x in garbage]
|
|
|
|
# 4-1. remove garbage files
|
|
for x in garbage:
|
|
if os.path.isfile(x):
|
|
os.remove(x)
|
|
|
|
# 4-2. remove garbage dir if empty
|
|
for x in garbage:
|
|
if os.path.isdir(x):
|
|
if not os.listdir(x):
|
|
os.rmdir(x)
|
|
|
|
# 5. rename dir name <node_id>@<prev_ver> ==> <node_id>@<cur_ver>
|
|
print(f"'{from_path}' is moved to '{to_path}'")
|
|
shutil.move(from_path, to_path)
|
|
|
|
# 6. create .tracking file
|
|
tracking_info_file = os.path.join(to_path, '.tracking')
|
|
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
|
file.write('\n'.join(list(extracted)))
|
|
|
|
|
|
script_executed = False
|
|
|
|
def execute_startup_script():
|
|
global script_executed
|
|
print("\n#######################################################################")
|
|
print("[ComfyUI-Manager] Starting dependency installation/(de)activation for the extension\n")
|
|
|
|
custom_nodelist_cache = None
|
|
|
|
def get_custom_node_paths():
|
|
nonlocal custom_nodelist_cache
|
|
if custom_nodelist_cache is None:
|
|
custom_nodelist_cache = set()
|
|
for base in folder_paths.get_folder_paths('custom_nodes'):
|
|
for x in os.listdir(base):
|
|
fullpath = os.path.join(base, x)
|
|
if os.path.isdir(fullpath):
|
|
custom_nodelist_cache.add(fullpath)
|
|
|
|
return custom_nodelist_cache
|
|
|
|
def execute_lazy_delete(path):
|
|
# Validate to prevent arbitrary paths from being deleted
|
|
if path not in get_custom_node_paths():
|
|
logging.error(f"## ComfyUI-Manager: The scheduled '{path}' is not a custom node path, so the deletion has been canceled.")
|
|
return
|
|
|
|
if not os.path.exists(path):
|
|
logging.info(f"## ComfyUI-Manager: SKIP-DELETE => '{path}' (already deleted)")
|
|
return
|
|
|
|
try:
|
|
shutil.rmtree(path)
|
|
logging.info(f"## ComfyUI-Manager: DELETE => '{path}'")
|
|
except Exception as e:
|
|
logging.error(f"## ComfyUI-Manager: Failed to delete '{path}' ({e})")
|
|
|
|
executed = set()
|
|
# Read each line from the file and convert it to a list using eval
|
|
with open(script_list_path, 'r', encoding="UTF-8", errors="ignore") as file:
|
|
for line in file:
|
|
if line in executed:
|
|
continue
|
|
|
|
executed.add(line)
|
|
|
|
try:
|
|
script = ast.literal_eval(line)
|
|
|
|
if script[1].startswith('#') and script[1] != '#FORCE':
|
|
if script[1] == "#LAZY-INSTALL-SCRIPT":
|
|
execute_lazy_install_script(script[0], script[2])
|
|
|
|
elif script[1] == "#LAZY-CNR-SWITCH-SCRIPT":
|
|
execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6])
|
|
execute_lazy_install_script(script[3], script[7])
|
|
|
|
elif script[1] == "#LAZY-DELETE-NODEPACK":
|
|
execute_lazy_delete(script[2])
|
|
|
|
elif os.path.exists(script[0]):
|
|
if script[1] == "#FORCE":
|
|
del script[1]
|
|
else:
|
|
if 'pip' in script[1:] and 'install' in script[1:] and is_installed(script[-1]):
|
|
continue
|
|
|
|
print(f"\n## ComfyUI-Manager: EXECUTE => {script[1:]}")
|
|
print(f"\n## Execute management script for '{script[0]}'")
|
|
|
|
new_env = os.environ.copy()
|
|
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
|
|
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
|
|
exit_code = process_wrap(script[1:], script[0], env=new_env)
|
|
|
|
if exit_code != 0:
|
|
print(f"management script failed: {script[0]}")
|
|
else:
|
|
print(f"\n## ComfyUI-Manager: CANCELED => {script[1:]}")
|
|
|
|
except Exception as e:
|
|
print(f"[ERROR] Failed to execute management script: {line} / {e}")
|
|
|
|
# Remove the script_list_path file
|
|
if os.path.exists(script_list_path):
|
|
script_executed = True
|
|
os.remove(script_list_path)
|
|
|
|
print("\n[ComfyUI-Manager] Startup script completed.")
|
|
print("#######################################################################\n")
|
|
|
|
|
|
# --- Unified dependency resolver: batch resolution at startup ---
|
|
# Runs unconditionally when enabled, independent of install-scripts.txt existence.
|
|
if manager_util.use_unified_resolver:
|
|
try:
|
|
from .common.unified_dep_resolver import (
|
|
UnifiedDepResolver,
|
|
UvNotAvailableError,
|
|
collect_base_requirements,
|
|
collect_node_pack_paths,
|
|
)
|
|
|
|
_resolver = UnifiedDepResolver(
|
|
node_pack_paths=collect_node_pack_paths(folder_paths.get_folder_paths('custom_nodes')),
|
|
base_requirements=collect_base_requirements(comfy_path),
|
|
blacklist=set(),
|
|
overrides={},
|
|
downgrade_blacklist=[],
|
|
)
|
|
_result = _resolver.resolve_and_install()
|
|
if _result.success:
|
|
_unified_resolver_succeeded = True
|
|
logging.info("[UnifiedDepResolver] startup batch resolution succeeded")
|
|
else:
|
|
manager_util.use_unified_resolver = False
|
|
logging.warning("[UnifiedDepResolver] startup batch failed: %s, falling back to per-node pip", _result.error)
|
|
except UvNotAvailableError:
|
|
manager_util.use_unified_resolver = False
|
|
logging.warning("[UnifiedDepResolver] uv not available at startup, falling back to per-node pip")
|
|
except Exception as e:
|
|
manager_util.use_unified_resolver = False
|
|
logging.warning("[UnifiedDepResolver] startup error: %s, falling back to per-node pip", e)
|
|
|
|
# Check if script_list_path exists
|
|
if os.path.exists(script_list_path):
|
|
execute_startup_script()
|
|
|
|
|
|
pip_fixer.fix_broken()
|
|
|
|
del processed_install
|
|
del pip_fixer
|
|
manager_util.clear_pip_cache()
|
|
|
|
if script_executed:
|
|
# Restart
|
|
print("[ComfyUI-Manager] Restarting to reapply dependency installation.")
|
|
|
|
if '__COMFY_CLI_SESSION__' in os.environ:
|
|
with open(os.path.join(os.environ['__COMFY_CLI_SESSION__'] + '.reboot'), 'w'):
|
|
pass
|
|
|
|
print("--------------------------------------------------------------------------\n")
|
|
exit(0)
|
|
else:
|
|
sys_argv = sys.argv.copy()
|
|
|
|
if sys_argv[0].endswith("__main__.py"): # this is a python module
|
|
module_name = os.path.basename(os.path.dirname(sys_argv[0]))
|
|
cmds = [sys.executable, '-m', module_name] + sys_argv[1:]
|
|
elif sys.platform.startswith('win32'):
|
|
cmds = ['"' + sys.executable + '"', '"' + sys_argv[0] + '"'] + sys_argv[1:]
|
|
else:
|
|
cmds = [sys.executable] + sys_argv
|
|
|
|
print(f"Command: {cmds}", flush=True)
|
|
print("--------------------------------------------------------------------------\n")
|
|
|
|
os.execv(sys.executable, cmds)
|
|
|
|
|
|
def check_windows_event_loop_policy():
|
|
try:
|
|
import configparser
|
|
config = configparser.ConfigParser(strict=False)
|
|
config.read(manager_config_path)
|
|
default_conf = config['default']
|
|
|
|
if 'windows_selector_event_loop_policy' in default_conf and default_conf['windows_selector_event_loop_policy'].lower() == 'true':
|
|
try:
|
|
import asyncio
|
|
import asyncio.windows_events
|
|
asyncio.set_event_loop_policy(asyncio.windows_events.WindowsSelectorEventLoopPolicy())
|
|
print("[ComfyUI-Manager] Windows event loop policy mode enabled")
|
|
except Exception as e:
|
|
print(f"[ComfyUI-Manager] WARN: Windows initialization fail: {e}")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if platform.system() == 'Windows':
|
|
check_windows_event_loop_policy()
|