mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-03-22 09:23:32 +08:00
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
This commit is contained in:
parent
3bc2e18f88
commit
ddccefbc70
@ -183,18 +183,22 @@ class Ctx:
|
|||||||
cmd_ctx = 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):
|
def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
|
||||||
exit_on_fail = kwargs.get('exit_on_fail', False)
|
raise_on_fail = kwargs.get('raise_on_fail', False)
|
||||||
print(f"install_node exit on fail:{exit_on_fail}...")
|
|
||||||
|
|
||||||
if core.is_valid_url(node_spec_str):
|
if core.is_valid_url(node_spec_str):
|
||||||
# install via urls
|
# install via urls
|
||||||
res = asyncio.run(core.gitclone_install(node_spec_str, no_deps=cmd_ctx.no_deps))
|
res = asyncio.run(core.gitclone_install(node_spec_str, no_deps=cmd_ctx.no_deps))
|
||||||
if not res.result:
|
if not res.result:
|
||||||
print(res.msg)
|
print(res.msg)
|
||||||
print(f"[bold red]ERROR: An error occurred while installing '{node_spec_str}'.[/bold red]")
|
print(f"[bold red]ERROR: An error occurred while installing '{node_spec_str}'.[/bold red]")
|
||||||
if exit_on_fail:
|
if raise_on_fail:
|
||||||
sys.exit(1)
|
raise NodeInstallError(node_spec_str)
|
||||||
else:
|
else:
|
||||||
print(f"{cnt_msg} [INSTALLED] {node_spec_str:50}")
|
print(f"{cnt_msg} [INSTALLED] {node_spec_str:50}")
|
||||||
else:
|
else:
|
||||||
@ -229,8 +233,8 @@ def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
|
|||||||
print("")
|
print("")
|
||||||
else:
|
else:
|
||||||
print(f"[bold red]ERROR: An error occurred while installing '{node_name}'.\n{res.msg}[/bold red]")
|
print(f"[bold red]ERROR: An error occurred while installing '{node_name}'.\n{res.msg}[/bold red]")
|
||||||
if exit_on_fail:
|
if raise_on_fail:
|
||||||
sys.exit(1)
|
raise NodeInstallError(node_name)
|
||||||
|
|
||||||
|
|
||||||
def reinstall_node(node_spec_str, is_all=False, cnt_msg=''):
|
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
|
node_name, version_spec, _ = node_spec
|
||||||
|
|
||||||
|
# Best-effort uninstall via normal path
|
||||||
unified_manager.unified_uninstall(node_name, version_spec == 'unknown')
|
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=''):
|
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']]
|
nodes = [x for x in nodes if x.lower() not in ['comfy', 'comfyui', 'all']]
|
||||||
|
|
||||||
total = len(nodes)
|
total = len(nodes)
|
||||||
i = 1
|
failed = []
|
||||||
for x in nodes:
|
for i, x in enumerate(nodes, 1):
|
||||||
try:
|
try:
|
||||||
act(x, is_all=is_all, cnt_msg=f'{i}/{total}', **kwargs)
|
act(x, is_all=is_all, cnt_msg=f'{i}/{total}', **kwargs)
|
||||||
|
except NodeInstallError:
|
||||||
|
failed.append(x)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: {e}")
|
print(f"ERROR: {e}")
|
||||||
traceback.print_exc()
|
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()
|
app = typer.Typer()
|
||||||
@ -1462,6 +1478,91 @@ def export_custom_node_ids(
|
|||||||
print(f"{x['id']}@unknown", file=output_file)
|
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():
|
def main():
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|||||||
@ -1142,6 +1142,71 @@ class UnifiedManager:
|
|||||||
|
|
||||||
return result
|
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):
|
def unified_uninstall(self, node_id: str, is_unknown: bool):
|
||||||
"""
|
"""
|
||||||
Remove whole installed custom nodes including inactive nodes
|
Remove whole installed custom nodes including inactive nodes
|
||||||
|
|||||||
@ -1135,6 +1135,71 @@ class UnifiedManager:
|
|||||||
|
|
||||||
return result
|
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):
|
def unified_uninstall(self, node_id: str, is_unknown: bool):
|
||||||
"""
|
"""
|
||||||
Remove whole installed custom nodes including inactive nodes
|
Remove whole installed custom nodes including inactive nodes
|
||||||
|
|||||||
@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-manager"
|
name = "comfyui-manager"
|
||||||
license = { text = "GPL-3.0-only" }
|
license = { text = "GPL-3.0-only" }
|
||||||
version = "4.1b4"
|
version = "4.1b5"
|
||||||
requires-python = ">= 3.9"
|
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."
|
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"
|
readme = "README.md"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user