Compare commits

...
3 Commits
Author SHA1 Message Date
Dr.Lt.Data e129697151 bump version to the 4.1b6 2026-03-18 02:06:02 +09:00
Dr.Lt.Data cd60f33f6d fix(git_helper): remove comfyui_manager import to fix Windows subprocess crash (#2703)
git_helper.py is spawned as a standalone subprocess on Windows
(manager_core.py:1342). The import of comfyui_manager.common.timestamp_utils
triggered comfyui_manager/__init__.py which imports from comfy.cli_args —
unavailable in the subprocess environment, causing ModuleNotFoundError.

Inline get_backup_branch_name() using only stdlib (time, uuid), preserving
the original collision-detection and UUID-fallback semantics. Add 9 tests
covering standalone subprocess loading, behavioral equivalence, edge cases
(repo.heads exception, 99-suffix exhaustion with UUID fallback).
2026-03-18 02:04:48 +09:00
Dr.Lt.Data ddccefbc70 feat(cli): add update-cache command, purge reinstall fallback, and batch failure tracking (#2693)
Add `cm-cli update-cache` command that force-fetches all remote data
and populates local cache in blocking mode. Bypasses pip/offline guards
in get_data_by_mode and get_cnr_data by directly fetching channel JSON
files and calling reload('remote'). Solves permanent cold-start issue
where pip-installed cm-cli could never populate CNR cache without
running the web server first.

Add UnifiedManager.purge_node_state() to both glob and legacy packages
for reinstall categorization mismatch (e.g. unknown→nightly). Includes
path traversal protection via commonpath, root directory guard,
comfyui-manager self-protection, and finally-guarded dictionary cleanup.

Add NodeInstallError exception and batch failure tracking to
for_each_nodes: reinstall propagates install failures via
raise_on_fail, for_each_nodes catches NodeInstallError, tracks
failed nodes, reports aggregate summary, and exits non-zero.

Remove debug print in install_node.
Bump version to 4.1b5.

E2E verified:
- update-cache: empty cache (5 lines) → populated (7815 lines)
- reinstall batch: 2 packs, 1 failure → continues to 2nd → summary + exit 1
2026-03-15 09:45:25 +09:00
6 changed files with 446 additions and 13 deletions
+112 -11
View File
@@ -183,18 +183,22 @@ class Ctx:
cmd_ctx = Ctx()
class NodeInstallError(Exception):
"""Raised when a node installation fails and the caller requested failure propagation."""
pass
def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
exit_on_fail = kwargs.get('exit_on_fail', False)
print(f"install_node exit on fail:{exit_on_fail}...")
raise_on_fail = kwargs.get('raise_on_fail', False)
if core.is_valid_url(node_spec_str):
# install via urls
res = asyncio.run(core.gitclone_install(node_spec_str, no_deps=cmd_ctx.no_deps))
if not res.result:
print(res.msg)
print(f"[bold red]ERROR: An error occurred while installing '{node_spec_str}'.[/bold red]")
if exit_on_fail:
sys.exit(1)
if raise_on_fail:
raise NodeInstallError(node_spec_str)
else:
print(f"{cnt_msg} [INSTALLED] {node_spec_str:50}")
else:
@@ -229,8 +233,8 @@ def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
print("")
else:
print(f"[bold red]ERROR: An error occurred while installing '{node_name}'.\n{res.msg}[/bold red]")
if exit_on_fail:
sys.exit(1)
if raise_on_fail:
raise NodeInstallError(node_name)
def reinstall_node(node_spec_str, is_all=False, cnt_msg=''):
@@ -238,8 +242,14 @@ def reinstall_node(node_spec_str, is_all=False, cnt_msg=''):
node_name, version_spec, _ = node_spec
# Best-effort uninstall via normal path
unified_manager.unified_uninstall(node_name, version_spec == 'unknown')
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg)
# Fallback: purge all state and directories regardless of categorization
# Handles categorization mismatch between cm_cli invocations (e.g. unknown→nightly)
unified_manager.purge_node_state(node_name)
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg, raise_on_fail=True)
def fix_node(node_spec_str, is_all=False, cnt_msg=''):
@@ -613,14 +623,20 @@ def for_each_nodes(nodes, act, allow_all=True, **kwargs):
nodes = [x for x in nodes if x.lower() not in ['comfy', 'comfyui', 'all']]
total = len(nodes)
i = 1
for x in nodes:
failed = []
for i, x in enumerate(nodes, 1):
try:
act(x, is_all=is_all, cnt_msg=f'{i}/{total}', **kwargs)
except NodeInstallError:
failed.append(x)
except Exception as e:
print(f"ERROR: {e}")
traceback.print_exc()
i += 1
failed.append(x)
if failed:
print(f"\n[bold red]Failed nodes ({len(failed)}/{total}): {', '.join(str(x) for x in failed)}[/bold red]")
sys.exit(1)
app = typer.Typer()
@@ -1462,6 +1478,91 @@ def export_custom_node_ids(
print(f"{x['id']}@unknown", file=output_file)
@app.command("update-cache", help="Force-fetch all remote data and populate local cache (blocking)")
def update_cache(
channel: Annotated[
str,
typer.Option(
show_default=False,
help="Specify the channel"
),
] = None,
user_directory: str = typer.Option(
None,
help="user directory"
),
):
cmd_ctx.set_user_directory(user_directory)
if channel is not None:
cmd_ctx.channel = channel
asyncio.run(_force_update_cache(cmd_ctx.channel))
async def _force_update_cache(channel):
"""Fetch all remote data and save to cache, bypassing pip/offline guards."""
core.refresh_channel_dict()
config = core.get_config()
channel_url = config['channel_url']
os.makedirs(manager_util.cache_dir, exist_ok=True)
failed = []
# Step 1: Fetch channel JSON files directly (bypasses get_data_by_mode pip guard)
filenames = [
"custom-node-list.json",
"extension-node-map.json",
"model-list.json",
"alter-list.json",
"github-stats.json",
]
async def fetch_and_cache(filename):
try:
if config.get('default_cache_as_channel_url'):
uri = f"{channel_url}/{filename}"
else:
uri = f"{core.DEFAULT_CHANNEL}/{filename}"
cache_uri = str(manager_util.simple_hash(uri)) + '_' + filename
cache_uri = os.path.join(manager_util.cache_dir, cache_uri)
json_obj = await manager_util.get_data(uri, silent=True)
with manager_util.cache_lock:
with open(cache_uri, "w", encoding='utf-8') as file:
json.dump(json_obj, file, indent=4, sort_keys=True)
print(f" [CACHED] {filename}")
except Exception as e:
print(f" [bold red][FAILED] {filename}: {e}[/bold red]")
failed.append(filename)
print("Fetching channel data...")
await asyncio.gather(*[fetch_and_cache(f) for f in filenames])
# Step 2: Reload unified_manager with remote mode
# cache_mode='remote' makes cache_mode==False in get_cnr_data,
# which bypasses the dont_wait block and triggers blocking fetch_all()
print("Fetching CNR registry data...")
try:
await unified_manager.reload('remote', dont_wait=False)
except Exception as e:
print(f" [bold red][FAILED] CNR registry: {e}[/bold red]")
failed.append("CNR registry")
# Step 3: Load nightly data (cache files now exist from Step 1)
print("Loading nightly data...")
await unified_manager.load_nightly(channel or 'default', 'cache')
if failed:
print(f"\n[bold red]Cache update incomplete. Failed: {', '.join(failed)}[/bold red]")
sys.exit(1)
else:
print("[bold green]Cache update complete.[/bold green]")
def main():
app()
+30 -1
View File
@@ -9,7 +9,6 @@ import yaml
import requests
from tqdm.auto import tqdm
from git.remote import RemoteProgress
from comfyui_manager.common.timestamp_utils import get_backup_branch_name
comfy_path = os.environ.get('COMFYUI_PATH')
@@ -68,6 +67,36 @@ class GitProgress(RemoteProgress):
self.pbar.refresh()
def get_backup_branch_name(repo=None):
"""Get backup branch name with current timestamp.
Inlined from timestamp_utils to keep git_helper.py standalone — this script
runs as a subprocess on Windows and must not import from comfyui_manager.
"""
import time as _time
import uuid as _uuid
base_name = f'backup_{_time.strftime("%Y%m%d_%H%M%S")}'
if repo is None:
return base_name
try:
existing_branches = {b.name for b in repo.heads}
except Exception:
return base_name
if base_name not in existing_branches:
return base_name
for i in range(1, 100):
new_name = f'{base_name}_{i}'
if new_name not in existing_branches:
return new_name
return f'{base_name}_{_uuid.uuid4().hex[:6]}'
def gitclone(custom_nodes_path, url, target_hash=None, repo_path=None):
repo_name = os.path.splitext(os.path.basename(url))[0]
+65
View File
@@ -1142,6 +1142,71 @@ class UnifiedManager:
return result
def purge_node_state(self, node_id: str):
"""
Remove a node's directory and clean ALL internal dictionaries regardless of categorization.
Used by reinstall to guarantee clean state before re-installation.
"""
if 'comfyui-manager' in node_id.lower():
return
custom_nodes_dir = os.path.normcase(os.path.realpath(get_default_custom_nodes_path()))
paths_to_remove = set()
def _add_path(raw_path):
"""Normalize and validate a path before adding to removal set."""
if not raw_path:
return
resolved = os.path.normcase(os.path.realpath(raw_path))
if resolved == custom_nodes_dir:
logging.warning(f"[ComfyUI-Manager] purge_node_state: refusing to delete custom_nodes root: {raw_path}")
return
try:
if os.path.commonpath([custom_nodes_dir, resolved]) != custom_nodes_dir:
logging.warning(f"[ComfyUI-Manager] purge_node_state: path escapes custom_nodes scope, skipping: {raw_path}")
return
except ValueError:
logging.warning(f"[ComfyUI-Manager] purge_node_state: cannot verify containment, skipping: {raw_path}")
return
paths_to_remove.add(resolved)
# Collect paths from all dictionaries
entry = self.unknown_active_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
entry = self.active_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
entry = self.unknown_inactive_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
fullpath = self.nightly_inactive_nodes.get(node_id)
if fullpath is not None:
_add_path(fullpath)
ver_map = self.cnr_inactive_nodes.get(node_id)
if ver_map is not None:
for key, fp in ver_map.items():
_add_path(fp)
# Convention-based fallback path
_add_path(os.path.join(get_default_custom_nodes_path(), node_id))
# Remove all validated paths, then always clean dictionaries
try:
for path in paths_to_remove:
if os.path.exists(path):
try_rmtree(node_id, path)
finally:
self.unknown_active_nodes.pop(node_id, None)
self.active_nodes.pop(node_id, None)
self.unknown_inactive_nodes.pop(node_id, None)
self.nightly_inactive_nodes.pop(node_id, None)
self.cnr_inactive_nodes.pop(node_id, None)
def unified_uninstall(self, node_id: str, is_unknown: bool):
"""
Remove whole installed custom nodes including inactive nodes
+65
View File
@@ -1135,6 +1135,71 @@ class UnifiedManager:
return result
def purge_node_state(self, node_id: str):
"""
Remove a node's directory and clean ALL internal dictionaries regardless of categorization.
Used by reinstall to guarantee clean state before re-installation.
"""
if 'comfyui-manager' in node_id.lower():
return
custom_nodes_dir = os.path.normcase(os.path.realpath(get_default_custom_nodes_path()))
paths_to_remove = set()
def _add_path(raw_path):
"""Normalize and validate a path before adding to removal set."""
if not raw_path:
return
resolved = os.path.normcase(os.path.realpath(raw_path))
if resolved == custom_nodes_dir:
logging.warning(f"[ComfyUI-Manager] purge_node_state: refusing to delete custom_nodes root: {raw_path}")
return
try:
if os.path.commonpath([custom_nodes_dir, resolved]) != custom_nodes_dir:
logging.warning(f"[ComfyUI-Manager] purge_node_state: path escapes custom_nodes scope, skipping: {raw_path}")
return
except ValueError:
logging.warning(f"[ComfyUI-Manager] purge_node_state: cannot verify containment, skipping: {raw_path}")
return
paths_to_remove.add(resolved)
# Collect paths from all dictionaries
entry = self.unknown_active_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
entry = self.active_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
entry = self.unknown_inactive_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
fullpath = self.nightly_inactive_nodes.get(node_id)
if fullpath is not None:
_add_path(fullpath)
ver_map = self.cnr_inactive_nodes.get(node_id)
if ver_map is not None:
for key, fp in ver_map.items():
_add_path(fp)
# Convention-based fallback path
_add_path(os.path.join(get_default_custom_nodes_path(), node_id))
# Remove all validated paths, then always clean dictionaries
try:
for path in paths_to_remove:
if os.path.exists(path):
try_rmtree(node_id, path)
finally:
self.unknown_active_nodes.pop(node_id, None)
self.active_nodes.pop(node_id, None)
self.unknown_inactive_nodes.pop(node_id, None)
self.nightly_inactive_nodes.pop(node_id, None)
self.cnr_inactive_nodes.pop(node_id, None)
def unified_uninstall(self, node_id: str, is_unknown: bool):
"""
Remove whole installed custom nodes including inactive nodes
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "comfyui-manager"
license = { text = "GPL-3.0-only" }
version = "4.1b4"
version = "4.1b6"
requires-python = ">= 3.9"
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
readme = "README.md"
+173
View File
@@ -0,0 +1,173 @@
"""Minimal tests for git_helper standalone execution and get_backup_branch_name.
git_helper.py runs as a subprocess on Windows and must NOT import comfyui_manager.
Tests validate: (1) no forbidden imports via AST, (2) subprocess-level standalone
execution, (3) get_backup_branch_name behavioural correctness.
"""
import ast
import pathlib
import subprocess
import sys
import textwrap
import pytest
_GIT_HELPER_PATH = pathlib.Path(__file__).resolve().parents[2] / "comfyui_manager" / "common" / "git_helper.py"
# ---------------------------------------------------------------------------
# 1. No comfyui_manager imports (AST check — no execution)
# ---------------------------------------------------------------------------
def test_no_comfyui_manager_import():
"""git_helper.py must be free of comfyui_manager imports (Windows subprocess safety)."""
tree = ast.parse(_GIT_HELPER_PATH.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and "comfyui_manager" in node.module:
pytest.fail(f"Forbidden import at line {node.lineno}: from {node.module}")
# ---------------------------------------------------------------------------
# 2. Standalone subprocess execution (mirrors the actual Windows bug scenario)
# ---------------------------------------------------------------------------
def test_standalone_subprocess_can_load(tmp_path):
"""git_helper.py must load in a subprocess without comfyui_manager on sys.path."""
(tmp_path / "folder_paths.py").touch()
# Run git_helper.py in a clean subprocess with stripped sys.path.
# git_helper.py has a module-level sys.argv dispatcher that calls sys.exit,
# so we set argv to --check with a dummy path to avoid IndexError, then
# catch the SystemExit to verify the function loaded successfully.
script = textwrap.dedent(f"""\
import sys, os
sys.path = [p for p in sys.path
if "comfyui_manager" not in p and "comfyui-manager" not in p]
os.environ["COMFYUI_PATH"] = {str(tmp_path)!r}
import importlib.util
spec = importlib.util.spec_from_file_location("git_helper", {str(_GIT_HELPER_PATH)!r})
mod = importlib.util.module_from_spec(spec)
# Intercept sys.exit from module-level argv dispatcher
real_exit = sys.exit
exit_code = [None]
def fake_exit(code=0):
exit_code[0] = code
raise SystemExit(code)
sys.exit = fake_exit
try:
spec.loader.exec_module(mod)
except SystemExit:
pass
finally:
sys.exit = real_exit
# The function must be defined regardless of argv dispatcher outcome
assert hasattr(mod, "get_backup_branch_name"), "Function not defined"
name = mod.get_backup_branch_name()
assert name.startswith("backup_"), f"Bad name: {{name}}"
print(f"OK: {{name}}")
""")
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, f"stderr: {result.stderr}"
assert "OK: backup_" in result.stdout
# ---------------------------------------------------------------------------
# 3. get_backup_branch_name behavioural tests (function extracted via AST)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def _get_backup_branch_name():
"""Extract and compile get_backup_branch_name from git_helper.py source."""
source = _GIT_HELPER_PATH.read_text(encoding="utf-8")
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "get_backup_branch_name":
func_source = ast.get_source_segment(source, node)
assert func_source is not None
ns = {}
exec(compile(ast.parse(func_source), "<test>", "exec"), ns) # noqa: S102
return ns["get_backup_branch_name"]
pytest.fail("get_backup_branch_name not found in git_helper.py")
class _FakeHead:
def __init__(self, name):
self.name = name
class _FakeRepo:
def __init__(self, branch_names):
self.heads = [_FakeHead(n) for n in branch_names]
def test_basic_name_format(_get_backup_branch_name):
import time
name = _get_backup_branch_name()
assert name.startswith("backup_")
expected = f"backup_{time.strftime('%Y%m%d_%H%M%S')}"
assert name == expected
def test_no_collision(_get_backup_branch_name):
repo = _FakeRepo(["main", "dev"])
name = _get_backup_branch_name(repo)
assert name.startswith("backup_")
def test_single_collision(_get_backup_branch_name):
import time
ts = time.strftime("%Y%m%d_%H%M%S")
repo = _FakeRepo(["main", f"backup_{ts}"])
name = _get_backup_branch_name(repo)
assert name == f"backup_{ts}_1"
def test_multi_collision(_get_backup_branch_name):
import time
ts = time.strftime("%Y%m%d_%H%M%S")
repo = _FakeRepo([f"backup_{ts}", f"backup_{ts}_1", f"backup_{ts}_2"])
name = _get_backup_branch_name(repo)
assert name == f"backup_{ts}_3"
def test_repo_none_returns_base(_get_backup_branch_name):
name = _get_backup_branch_name(None)
assert name.startswith("backup_")
def test_repo_heads_exception_returns_base(_get_backup_branch_name):
"""When repo.heads raises, fall back to base name without suffix."""
class _BrokenRepo:
@property
def heads(self):
raise RuntimeError("simulated git error")
name = _get_backup_branch_name(_BrokenRepo())
assert name.startswith("backup_")
assert "_1" not in name # no suffix — exception path returns base
def test_uuid_fallback_on_full_collision(_get_backup_branch_name):
"""When all 99 suffixes are taken, fall back to UUID."""
import time
ts = time.strftime("%Y%m%d_%H%M%S")
# All names from backup_TS through backup_TS_99 are taken
taken = [f"backup_{ts}"] + [f"backup_{ts}_{i}" for i in range(1, 100)]
repo = _FakeRepo(taken)
name = _get_backup_branch_name(repo)
assert name.startswith(f"backup_{ts}_")
# UUID suffix is 6 hex chars
suffix = name[len(f"backup_{ts}_"):]
assert len(suffix) == 6, f"Expected 6-char UUID suffix, got: {suffix}"