diff --git a/cm_cli/__main__.py b/cm_cli/__main__.py index d4523f1a..b302872c 100644 --- a/cm_cli/__main__.py +++ b/cm_cli/__main__.py @@ -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() diff --git a/comfyui_manager/glob/manager_core.py b/comfyui_manager/glob/manager_core.py index 9b70136a..8b166cb3 100644 --- a/comfyui_manager/glob/manager_core.py +++ b/comfyui_manager/glob/manager_core.py @@ -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 diff --git a/comfyui_manager/legacy/manager_core.py b/comfyui_manager/legacy/manager_core.py index 065bf3ab..de6b29f3 100644 --- a/comfyui_manager/legacy/manager_core.py +++ b/comfyui_manager/legacy/manager_core.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 569afa5e..e485bdfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "comfyui-manager" license = { text = "GPL-3.0-only" } -version = "4.1b4" +version = "4.1b5" 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"