diff --git a/DEV_README.md b/DEV_README.md index ccc82867..9e2acd47 100644 --- a/DEV_README.md +++ b/DEV_README.md @@ -124,23 +124,28 @@ def remove(name: str): - For progress reporting, use either [`rich.progress`](https://rich.readthedocs.io/en/stable/progress.html) ## Develop comfy-cli and ComfyUI-Manager (cm-cli) together + +ComfyUI-Manager is now installed as a pip package (via `manager_requirements.txt` +in the ComfyUI root) rather than being git-cloned into `custom_nodes/`. + ### Making changes to both -1. Fork your own branches of `comfy-cli` and `ComfyUI-Manager`, make changes -2. Be sure to commit any changes to `ComfyUI-Manager` to a new branch, and push to remote +1. Fork your own branches of `comfy-cli` and `ComfyUI-Manager`, make changes. +2. Live-install `comfy-cli`: + - `pip install -e /path/to/comfy-cli` +3. Live-install your fork of `ComfyUI-Manager` in editable mode: + - `pip install -e /path/to/ComfyUI-Manager` +4. This makes the `cm-cli` entry point available and points it at your local source. ### Trying changes to both -1. clone the changed branch of `comfy-cli`, then live install `comfy-cli`: - - `pip install -e comfy-cli` +1. Install both packages in editable mode as described above. 2. Go to a test dir and run: - - `comfy --here install --manager-url=` -3. Run: - - `cd ComfyUI/custom_nodes/ComfyUI-Manager/ && git checkout && cd -` -4. Further changes can be pulled into these copies of the `comfy-cli` and `ComfyUI-Manager` repos + - `comfy --here install` +3. The `cm-cli` command will resolve to your locally installed editable package. ### Debugging both simultaneously -1. Follow instructions above to get working install with changes +1. Follow instructions above to get working install with changes. 2. Add breakpoints directly to code: `import ipdb; ipdb.set_trace()` -3. Execute relevant `comfy-cli` command +3. Execute relevant `comfy-cli` command. ## Contact diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 3699ffe9..f73c4ad8 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -159,13 +159,6 @@ def install( callback=validate_version, ), ] = "nightly", - manager_url: Annotated[ - str, - typer.Option( - show_default=False, - help="url or local path pointing to the ComfyUI-Manager git repo to be installed. A specific branch can optionally be specified using a setuptools-like syntax, eg https://foo.git@bar", - ), - ] = constants.COMFY_MANAGER_GITHUB_URL, restore: Annotated[ bool, typer.Option( @@ -237,10 +230,6 @@ def install( help="Use new fast dependency installer", ), ] = False, - manager_commit: Annotated[ - str | None, - typer.Option(help="Specify commit hash for ComfyUI-Manager"), - ] = None, pr: Annotated[ str | None, typer.Option( @@ -273,7 +262,6 @@ def install( rprint("[bold yellow]Installing for CPU[/bold yellow]") install_inner.execute( url, - manager_url, comfy_path, restore, skip_manager, @@ -285,7 +273,6 @@ def install( skip_torch_or_directml=skip_torch_or_directml, skip_requirement=skip_requirement, fast_deps=fast_deps, - manager_commit=manager_commit, ) rprint(f"ComfyUI is installed at: {comfy_path}") return None @@ -331,7 +318,6 @@ def install( install_inner.execute( url, - manager_url, comfy_path, restore, skip_manager, @@ -343,7 +329,6 @@ def install( skip_torch_or_directml=skip_torch_or_directml, skip_requirement=skip_requirement, fast_deps=fast_deps, - manager_commit=manager_commit, pr=pr, ) diff --git a/comfy_cli/command/custom_nodes/cm_cli_util.py b/comfy_cli/command/custom_nodes/cm_cli_util.py index 347313cf..37917224 100644 --- a/comfy_cli/command/custom_nodes/cm_cli_util.py +++ b/comfy_cli/command/custom_nodes/cm_cli_util.py @@ -1,9 +1,11 @@ from __future__ import annotations +import importlib.util import os import subprocess import sys import uuid +from functools import lru_cache import typer from rich import print @@ -21,6 +23,21 @@ } +@lru_cache(maxsize=1) +def find_cm_cli() -> bool: + """Check if cm_cli module is available in the current Python environment. + + Only checks the currently activated Python environment. + Does NOT fallback to PATH lookup to avoid using cm-cli from different environments. + + Results are cached for the session lifetime. + + Returns: + True if cm_cli module is importable, False otherwise. + """ + return importlib.util.find_spec("cm_cli") is not None + + def execute_cm_cli(args, channel=None, fast_deps=False, no_deps=False, mode=None, raise_on_error=False) -> str | None: _config_manager = ConfigManager() @@ -30,15 +47,14 @@ def execute_cm_cli(args, channel=None, fast_deps=False, no_deps=False, mode=None print("\n[bold red]ComfyUI path is not resolved.[/bold red]\n", file=sys.stderr) raise typer.Exit(code=1) - cm_cli_path = os.path.join(workspace_path, "custom_nodes", "ComfyUI-Manager", "cm-cli.py") - if not os.path.exists(cm_cli_path): + if not find_cm_cli(): print( - f"\n[bold red]ComfyUI-Manager not found: {cm_cli_path}[/bold red]\n", + "\n[bold red]ComfyUI-Manager not found. 'cm-cli' command is not available.[/bold red]\n", file=sys.stderr, ) raise typer.Exit(code=1) - cmd = [sys.executable, cm_cli_path] + args + cmd = [sys.executable, "-m", "cm_cli"] + args if channel is not None: cmd += ["--channel", channel] diff --git a/comfy_cli/command/custom_nodes/command.py b/comfy_cli/command/custom_nodes/command.py index 22a29d95..ef6c12ab 100644 --- a/comfy_cli/command/custom_nodes/command.py +++ b/comfy_cli/command/custom_nodes/command.py @@ -1,6 +1,7 @@ import os import pathlib import platform +import shutil import subprocess import sys import uuid @@ -11,9 +12,9 @@ from rich import print from rich.console import Console -from comfy_cli import logging, tracking, ui, utils +from comfy_cli import constants, logging, tracking, ui, utils from comfy_cli.command.custom_nodes.bisect_custom_nodes import bisect_app -from comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli +from comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli, find_cm_cli from comfy_cli.config_manager import ConfigManager from comfy_cli.constants import NODE_ZIP_FILENAME from comfy_cli.file_utils import ( @@ -48,21 +49,9 @@ class ShowTarget(str, Enum): SNAPSHOT_LIST = "snapshot-list" -def validate_comfyui_manager(_env_checker): - manager_path = _env_checker.get_comfyui_manager_path() - - if manager_path is None: - print("[bold red]If ComfyUI is not installed, this feature cannot be used.[/bold red]") - raise typer.Exit(code=1) - elif not os.path.exists(manager_path): - print( - f"[bold red]If ComfyUI-Manager is not installed, this feature cannot be used.[/bold red] \\[{manager_path}]" - ) - raise typer.Exit(code=1) - elif not os.path.exists(os.path.join(manager_path, ".git")): - print( - f"[bold red]The ComfyUI-Manager installation is invalid. This feature cannot be used.[/bold red] \\[{manager_path}]" - ) +def validate_comfyui_manager(_env_checker=None): + if not find_cm_cli(): + print("[bold red]ComfyUI-Manager is not installed. 'cm-cli' command is not available.[/bold red]") raise typer.Exit(code=1) @@ -234,16 +223,168 @@ def restore_dependencies(): execute_cm_cli(["restore-dependencies"]) -@manager_app.command("disable-gui", help="Disable GUI mode of ComfyUI-Manager") +@manager_app.command("disable", help="Disable ComfyUI-Manager completely") @tracking.track_command("node") -def disable_gui(): - execute_cm_cli(["cli-only-mode", "enable"]) +def disable_manager(): + """Disable ComfyUI-Manager. No manager flags will be passed to ComfyUI.""" + config_manager = ConfigManager() + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + print("[bold yellow]ComfyUI-Manager has been disabled.[/bold yellow]") + print("No manager flags will be passed to ComfyUI on next launch.") -@manager_app.command("enable-gui", help="Enable GUI mode of ComfyUI-Manager") +@manager_app.command("enable-gui", help="Enable ComfyUI-Manager with new GUI") @tracking.track_command("node") def enable_gui(): - execute_cm_cli(["cli-only-mode", "disable"]) + """Enable ComfyUI-Manager with new GUI.""" + config_manager = ConfigManager() + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") + print("[bold green]ComfyUI-Manager GUI has been enabled.[/bold green]") + print("[dim]ComfyUI will launch with: --enable-manager[/dim]") + + +@manager_app.command("disable-gui", help="Enable ComfyUI-Manager without GUI") +@tracking.track_command("node") +def disable_gui(): + """Enable ComfyUI-Manager but disable its GUI.""" + config_manager = ConfigManager() + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable-gui") + print("[bold green]ComfyUI-Manager enabled with GUI disabled.[/bold green]") + print("[dim]ComfyUI will launch with: --enable-manager --disable-manager-ui[/dim]") + + +@manager_app.command("enable-legacy-gui", help="Enable ComfyUI-Manager with legacy GUI") +@tracking.track_command("node") +def enable_legacy_gui(): + """Enable ComfyUI-Manager with legacy GUI.""" + config_manager = ConfigManager() + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-legacy-gui") + print("[bold green]ComfyUI-Manager legacy GUI has been enabled.[/bold green]") + print("[dim]ComfyUI will launch with: --enable-manager --enable-manager-legacy-ui[/dim]") + + +@manager_app.command("migrate-legacy", help="Migrate legacy git-cloned ComfyUI-Manager to .disabled") +@tracking.track_command("node") +def migrate_legacy( + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip confirmation prompt"), + ] = False, +): + """ + Migrate legacy ComfyUI-Manager from custom_nodes/ to custom_nodes/.disabled/ + + Detects .enable-cli-only-mode file to set appropriate mode: + - If .enable-cli-only-mode exists → mode = disable + - Otherwise → mode = enable-gui + """ + if not workspace_manager.workspace_path: + print("[bold red]ComfyUI workspace is not set.[/bold red]") + print("[dim]Use --workspace or run from a ComfyUI directory.[/dim]") + raise typer.Exit(code=1) + + custom_nodes_path = pathlib.Path(workspace_manager.workspace_path) / "custom_nodes" + + # Find legacy manager with case-insensitive matching (must be a real directory, not symlink) + legacy_manager_path = None + if custom_nodes_path.exists(): + for item in custom_nodes_path.iterdir(): + if item.is_dir() and not item.is_symlink() and item.name.lower() == "comfyui-manager": + legacy_manager_path = item + break + + # Check if legacy manager exists + if legacy_manager_path is None: + print("[bold yellow]No legacy ComfyUI-Manager found in custom_nodes/[/bold yellow]") + print("Nothing to migrate.") + return + + # Verify it's a git-cloned repository + git_dir = legacy_manager_path / ".git" + if not git_dir.exists(): + print(f"[bold yellow]Warning: {legacy_manager_path.name} does not appear to be a git repository.[/bold yellow]") + print("[dim]Expected a git-cloned ComfyUI-Manager. Skipping migration.[/dim]") + return + + # Detect CLI-only mode before any changes + cli_only_mode_file = legacy_manager_path / ".enable-cli-only-mode" + cli_only_mode = cli_only_mode_file.exists() + + # Show what will happen and ask for confirmation + print(f"[bold]Found legacy ComfyUI-Manager:[/bold] {legacy_manager_path}") + print(f"[dim]CLI-only mode: {cli_only_mode}[/dim]") + print() + print("[bold]This will:[/bold]") + print(f" 1. Move {legacy_manager_path.name} to custom_nodes/.disabled/") + print(f" 2. Set manager mode to: {'disable' if cli_only_mode else 'enable-gui'}") + print(" 3. Install manager_requirements.txt (if present)") + print() + + if not yes: + confirm = ui.prompt_confirm_action("Proceed with migration?", False) + if not confirm: + print("[dim]Migration cancelled.[/dim]") + return + + # Create .disabled directory + disabled_path = custom_nodes_path / ".disabled" + disabled_path.mkdir(exist_ok=True) + + # Check if target already exists (case-insensitive) + existing_target = None + for item in disabled_path.iterdir(): + if item.is_dir() and item.name.lower() == "comfyui-manager": + existing_target = item + break + + if existing_target is not None: + print(f"[bold red]Target path already exists: {existing_target}[/bold red]") + print("Please remove it manually and try again.") + raise typer.Exit(code=1) + + # Move legacy manager (preserve original directory name) + target_path = disabled_path / legacy_manager_path.name + try: + shutil.move(str(legacy_manager_path), str(target_path)) + except OSError as e: + print(f"[bold red]Failed to move legacy manager: {e}[/bold red]") + raise typer.Exit(code=1) + + # Install manager_requirements.txt if present + workspace_path = pathlib.Path(workspace_manager.workspace_path) + manager_req_path = workspace_path / constants.MANAGER_REQUIREMENTS_FILE + install_success = False # Default to failure, set True only on success + if manager_req_path.exists(): + print("[dim]Installing ComfyUI-Manager dependencies...[/dim]") + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "-r", str(manager_req_path)], + check=False, + ) + if result.returncode != 0: + print("[bold yellow]Warning: Failed to install ComfyUI-Manager dependencies.[/bold yellow]") + print("[dim]You may need to run: pip install -r manager_requirements.txt[/dim]") + else: + install_success = True + else: + print("[bold yellow]Warning: manager_requirements.txt not found (older ComfyUI version?).[/bold yellow]") + print("[dim]ComfyUI-Manager pip package not installed.[/dim]") + + # Set config mode + config_manager = ConfigManager() + if cli_only_mode or not install_success: + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + print("[bold green]Legacy ComfyUI-Manager migrated to .disabled/[/bold green]") + if cli_only_mode: + print("[dim]Detected .enable-cli-only-mode → Manager set to: disable[/dim]") + else: + print("[dim]Manager installation failed → Manager set to: disable[/dim]") + print("[dim]After fixing installation, run: comfy manager enable-gui[/dim]") + else: + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") + print("[bold green]Legacy ComfyUI-Manager migrated to .disabled/[/bold green]") + print("[dim]Manager set to: enable-gui (new GUI)[/dim]") + + print("\n[bold]The new pip-installed ComfyUI-Manager will be used on next launch.[/bold]") @manager_app.command(help="Clear reserved startup action in ComfyUI-Manager") @@ -479,14 +620,15 @@ def update_node_id_cache(): config_manager = ConfigManager() workspace_path = workspace_manager.workspace_path - cm_cli_path = os.path.join(workspace_path, "custom_nodes", "ComfyUI-Manager", "cm-cli.py") + if not find_cm_cli(): + raise FileNotFoundError("cm-cli not found") tmp_path = os.path.join(config_manager.get_config_path(), "tmp") if not os.path.exists(tmp_path): os.makedirs(tmp_path) cache_path = os.path.join(tmp_path, "node-cache.list") - cmd = [sys.executable, cm_cli_path, "export-custom-node-ids", cache_path] + cmd = [sys.executable, "-m", "cm_cli", "export-custom-node-ids", cache_path] new_env = os.environ.copy() new_env["COMFYUI_PATH"] = workspace_path diff --git a/comfy_cli/command/install.py b/comfy_cli/command/install.py index 9bae0b9b..8d304cf6 100755 --- a/comfy_cli/command/install.py +++ b/comfy_cli/command/install.py @@ -5,6 +5,7 @@ from typing import TypedDict from urllib.parse import urlparse +import git import requests import semver import typer @@ -179,21 +180,42 @@ def pip_install_comfyui_dependencies( sys.exit(1) -# install requirements for manager -def pip_install_manager_dependencies(repo_dir): - os.chdir(os.path.join(repo_dir, "custom_nodes", "ComfyUI-Manager")) - subprocess.run([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], check=True) +def pip_install_manager(repo_dir): + """Install ComfyUI-Manager via manager_requirements.txt.""" + from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli + + manager_req_path = os.path.join(repo_dir, constants.MANAGER_REQUIREMENTS_FILE) + if not os.path.exists(manager_req_path): + rprint( + f"[bold yellow]Warning: {constants.MANAGER_REQUIREMENTS_FILE} not found. " + "Skipping manager installation (older ComfyUI version?).[/bold yellow]" + ) + return False + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "-r", constants.MANAGER_REQUIREMENTS_FILE], + cwd=repo_dir, + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + rprint("[bold red]Failed to install ComfyUI-Manager.[/bold red]") + if result.stderr: + rprint(f"[dim]{result.stderr.strip()}[/dim]") + return False + + # Clear cache so find_cm_cli() picks up the newly installed module + find_cm_cli.cache_clear() + return True def execute( url: str, - manager_url: str, comfy_path: str, restore: bool, skip_manager: bool, version: str, commit: str | None = None, - manager_commit: str | None = None, gpu: constants.GPU_OPTION = None, cuda_version: constants.CUDAVersion = constants.CUDAVersion.v12_6, plat: constants.OS = None, @@ -238,9 +260,22 @@ def execute( sys.exit(1) elif not check_comfy_repo(repo_dir)[0]: - rprint( - f"[bold red]'{repo_dir}' already exists. But it is an invalid ComfyUI repository. Remove it and retry.[/bold red]" - ) + # Get actual remote URL for better error message + try: + repo = git.Repo(repo_dir) + remote_urls = [r.url for r in repo.remotes] + rprint( + f"[bold red]'{repo_dir}' exists but its remote URL is not a recognized ComfyUI repository.[/bold red]" + ) + if remote_urls: + rprint(f"[yellow]Found remotes: {', '.join(remote_urls)}[/yellow]") + rprint("[yellow]Recognized sources: Comfy-Org, comfyanonymous, drip-art, ltdrdata[/yellow]") + except git.InvalidGitRepositoryError: + rprint(f"[bold red]'{repo_dir}' exists but is not a valid git repository.[/bold red]") + except Exception: + rprint( + f"[bold red]'{repo_dir}' already exists. But it is an invalid ComfyUI repository. Remove it and retry.[/bold red]" + ) sys.exit(-1) # checkout specified commit @@ -259,43 +294,36 @@ def execute( # install ComfyUI-Manager if skip_manager: rprint("Skipping installation of ComfyUI-Manager. (by --skip-manager)") - else: - manager_repo_dir = os.path.join(repo_dir, "custom_nodes", "ComfyUI-Manager") + # Save to config so launch doesn't inject --enable-manager + from comfy_cli.config_manager import ConfigManager - if os.path.exists(manager_repo_dir): - if restore and not fast_deps: - pip_install_manager_dependencies(repo_dir) - else: - rprint( - f"Directory {manager_repo_dir} already exists. Skipping installation of ComfyUI-Manager.\nIf you want to restore dependencies, add the '--restore' option." - ) - else: - rprint("\nInstalling ComfyUI-Manager..") - - if "@" in manager_url: - # clone specific branch - manager_url, manager_branch = manager_url.rsplit("@", 1) - subprocess.run( - ["git", "clone", "-b", manager_branch, manager_url, manager_repo_dir], - check=True, - ) - else: - subprocess.run(["git", "clone", manager_url, manager_repo_dir], check=True) - if manager_commit is not None: - subprocess.run(["git", "checkout", manager_commit], check=True, cwd=manager_repo_dir) + ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + else: + rprint("\nInstalling ComfyUI-Manager..") + if not fast_deps: + if not pip_install_manager(repo_dir): + # Manager installation failed - disable to prevent launch issues + from comfy_cli.config_manager import ConfigManager - if not fast_deps: - pip_install_manager_dependencies(repo_dir) + ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + rprint("[yellow]Manager not installed. Launch will run without manager flags.[/yellow]") if fast_deps: depComp = DependencyCompiler(cwd=repo_dir, gpu=gpu) depComp.compile_deps() depComp.install_deps() + # Install manager separately (not included in DependencyCompiler) + if not skip_manager: + if not pip_install_manager(repo_dir): + from comfy_cli.config_manager import ConfigManager + + ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + rprint("[yellow]Manager not installed. Launch will run without manager flags.[/yellow]") if not skip_manager: try: update_node_id_cache() - except subprocess.CalledProcessError as e: + except (FileNotFoundError, subprocess.CalledProcessError) as e: rprint(f"Failed to update node id cache: {e}") os.chdir(repo_dir) diff --git a/comfy_cli/command/launch.py b/comfy_cli/command/launch.py index 30f92a8a..f71524b1 100644 --- a/comfy_cli/command/launch.py +++ b/comfy_cli/command/launch.py @@ -13,6 +13,7 @@ from rich.panel import Panel from comfy_cli import constants, utils +from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli from comfy_cli.config_manager import ConfigManager from comfy_cli.env_checker import check_comfy_server_running from comfy_cli.update import check_for_updates @@ -22,6 +23,48 @@ console = Console() +def _get_manager_flags() -> list[str]: + """Get manager flags based on config mode.""" + config_manager = ConfigManager() + mode = config_manager.get(constants.CONFIG_KEY_MANAGER_GUI_MODE) + + # Backward compatibility: migrate old config + if mode is None: + old_value = config_manager.get(constants.CONFIG_KEY_MANAGER_GUI_ENABLED) + if old_value is not None: + # Handle both string and boolean values + old_str = str(old_value).lower() + if old_str in ("false", "0", "off"): + mode = "disable" + elif old_str in ("true", "1", "on"): + mode = "enable-gui" + else: + # No config - check if cm-cli is available + if not find_cm_cli(): + return [] # Manager not available, no flags + mode = "enable-gui" + + if mode == "disable": + return [] + + # For enable-* modes, verify cm-cli is available + if not find_cm_cli(): + print( + "[bold yellow]Warning: ComfyUI-Manager (cm-cli) not found. " + "Manager flags will not be injected.[/bold yellow]" + ) + return [] + + if mode == "enable-gui": + return ["--enable-manager"] + elif mode == "disable-gui": + return ["--enable-manager", "--disable-manager-ui"] + elif mode == "enable-legacy-gui": + return ["--enable-manager", "--enable-manager-legacy-ui"] + else: + return ["--enable-manager"] # fallback to default + + def launch_comfyui(extra, frontend_pr=None): reboot_path = None @@ -147,6 +190,12 @@ def launch( workspace_manager.set_recent_workspace(resolved_workspace) os.chdir(resolved_workspace) + + # Inject manager flags based on config mode + manager_flags = _get_manager_flags() + if manager_flags: + extra = (extra or []) + manager_flags + if background: background_launch(extra, frontend_pr) else: diff --git a/comfy_cli/constants.py b/comfy_cli/constants.py index a939b72e..41f4a676 100644 --- a/comfy_cli/constants.py +++ b/comfy_cli/constants.py @@ -14,7 +14,8 @@ class PROC(str, Enum): COMFY_GITHUB_URL = "https://github.com/comfyanonymous/ComfyUI" -COMFY_MANAGER_GITHUB_URL = "https://github.com/ltdrdata/ComfyUI-Manager" + +MANAGER_REQUIREMENTS_FILE = "manager_requirements.txt" DEFAULT_COMFY_MODEL_PATH = "models" DEFAULT_COMFY_WORKSPACE = { @@ -40,6 +41,8 @@ class PROC(str, Enum): CONFIG_KEY_USER_ID = "user_id" CONFIG_KEY_INSTALL_EVENT_TRIGGERED = "install_event_triggered" CONFIG_KEY_BACKGROUND = "background" +CONFIG_KEY_MANAGER_GUI_ENABLED = "manager_gui_enabled" # Legacy, kept for backward compatibility +CONFIG_KEY_MANAGER_GUI_MODE = "manager_gui_mode" # Valid: "disable", "enable-gui", "disable-gui", "enable-legacy-gui" CIVITAI_API_TOKEN_KEY = "civitai_api_token" CIVITAI_API_TOKEN_ENV_KEY = "CIVITAI_API_TOKEN" @@ -55,12 +58,15 @@ class PROC(str, Enum): "git@github.com:Comfy-Org/ComfyUI.git", "git@github.com:comfyanonymous/ComfyUI.git", "git@github.com:drip-art/comfy.git", + "git@github.com:ltdrdata/ComfyUI.git", "https://github.com/Comfy-Org/ComfyUI.git", "https://github.com/comfyanonymous/ComfyUI.git", "https://github.com/drip-art/ComfyUI.git", + "https://github.com/ltdrdata/ComfyUI.git", "https://github.com/Comfy-Org/ComfyUI", "https://github.com/comfyanonymous/ComfyUI", "https://github.com/drip-art/ComfyUI", + "https://github.com/ltdrdata/ComfyUI", } diff --git a/comfy_cli/workspace_manager.py b/comfy_cli/workspace_manager.py index a8936b33..3e87ea48 100644 --- a/comfy_cli/workspace_manager.py +++ b/comfy_cli/workspace_manager.py @@ -257,21 +257,6 @@ def get_workspace_path(self) -> tuple[str, WorkspaceType]: default_workspace = utils.get_not_user_set_default_workspace() return default_workspace, WorkspaceType.DEFAULT - def get_comfyui_manager_path(self): - if self.workspace_path is None: - return None - - # To check more robustly, verify up to the `.git` path. - return os.path.join(self.workspace_path, "custom_nodes", "ComfyUI-Manager") - - def is_comfyui_manager_installed(self): - if self.workspace_path is None: - return False - - # To check more robustly, verify up to the `.git` path. - manager_git_path = os.path.join(self.workspace_path, "custom_nodes", "ComfyUI-Manager", ".git") - return os.path.exists(manager_git_path) - def scan_dir(self): if not self.workspace_path: return [] @@ -310,4 +295,39 @@ def save_metadata(self): save_yaml(file_path, self.metadata) def fill_print_table(self): - return [("Current selected workspace", f"[bold green]→ {self.workspace_path}[/bold green]")] + # Lazy import to avoid circular dependency + from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli + + config_manager = ConfigManager() + mode = config_manager.get(constants.CONFIG_KEY_MANAGER_GUI_MODE) + + # Backward compatibility - same logic as launch._get_manager_flags() + if mode is None: + old_value = config_manager.get(constants.CONFIG_KEY_MANAGER_GUI_ENABLED) + if old_value is not None: + # Handle both string and boolean values + old_str = str(old_value).lower() + if old_str in ("false", "0", "off"): + mode = "disable" + elif old_str in ("true", "1", "on"): + mode = "enable-gui" + else: + # No config - check if cm-cli is available + if not find_cm_cli(): + mode = "not-installed" + else: + mode = "enable-gui" + + status_map = { + "disable": "[bold red]Disabled[/bold red]", + "enable-gui": "[bold green]GUI Enabled[/bold green]", + "disable-gui": "[bold yellow]GUI Disabled[/bold yellow]", + "enable-legacy-gui": "[bold cyan]Legacy GUI[/bold cyan]", + "not-installed": "[dim]Not Installed[/dim]", + } + manager_status = status_map.get(mode, "[bold green]GUI Enabled[/bold green]") + + return [ + ("Current selected workspace", f"[bold green]→ {self.workspace_path}[/bold green]"), + ("Manager", manager_status), + ] diff --git a/tests/comfy_cli/command/test_command.py b/tests/comfy_cli/command/test_command.py index 40168bba..86486137 100644 --- a/tests/comfy_cli/command/test_command.py +++ b/tests/comfy_cli/command/test_command.py @@ -50,9 +50,8 @@ def test_install_here(cmd, runner, mock_execute, mock_prompt_select_enum): assert result.exit_code == 0, result.stdout args, _ = mock_execute.call_args - url, manager_url, comfy_path, *_ = args + url, comfy_path, *_ = args assert url == "https://github.com/comfyanonymous/ComfyUI" - assert manager_url == "https://github.com/ltdrdata/ComfyUI-Manager" assert comfy_path == os.path.join(os.getcwd(), "ComfyUI") diff --git a/tests/comfy_cli/command/test_manager_gui.py b/tests/comfy_cli/command/test_manager_gui.py new file mode 100644 index 00000000..d17665fa --- /dev/null +++ b/tests/comfy_cli/command/test_manager_gui.py @@ -0,0 +1,1025 @@ +import sys +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from comfy_cli import constants +from comfy_cli.command.launch import _get_manager_flags + + +@pytest.fixture() +def mock_config_manager(): + with patch("comfy_cli.command.custom_nodes.command.ConfigManager") as mock_cls: + instance = MagicMock() + mock_cls.return_value = instance + yield instance + + +@pytest.fixture() +def mock_launch_config_manager(): + with patch("comfy_cli.command.launch.ConfigManager") as mock_cls: + instance = MagicMock() + mock_cls.return_value = instance + yield instance + + +class TestManagerCommands: + def test_disable_manager_sets_config(self, mock_config_manager): + from comfy_cli.command.custom_nodes.command import disable_manager + + disable_manager() + + mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + + def test_enable_gui_sets_config(self, mock_config_manager): + from comfy_cli.command.custom_nodes.command import enable_gui + + enable_gui() + + mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") + + def test_disable_gui_sets_config(self, mock_config_manager): + from comfy_cli.command.custom_nodes.command import disable_gui + + disable_gui() + + mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable-gui") + + def test_enable_legacy_gui_sets_config(self, mock_config_manager): + from comfy_cli.command.custom_nodes.command import enable_legacy_gui + + enable_legacy_gui() + + mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-legacy-gui") + + +class TestGetManagerFlags: + def test_disable_mode_returns_empty(self, mock_launch_config_manager): + mock_launch_config_manager.get.return_value = "disable" + + result = _get_manager_flags() + + assert result == [] + mock_launch_config_manager.get.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE) + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) + def test_enable_gui_mode_returns_enable_manager(self, mock_find, mock_launch_config_manager): + mock_launch_config_manager.get.return_value = "enable-gui" + + result = _get_manager_flags() + + assert result == ["--enable-manager"] + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) + def test_disable_gui_mode_returns_both_flags(self, mock_find, mock_launch_config_manager): + mock_launch_config_manager.get.return_value = "disable-gui" + + result = _get_manager_flags() + + assert result == ["--enable-manager", "--disable-manager-ui"] + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) + def test_enable_legacy_gui_mode_returns_legacy_flags(self, mock_find, mock_launch_config_manager): + mock_launch_config_manager.get.return_value = "enable-legacy-gui" + + result = _get_manager_flags() + + assert result == ["--enable-manager", "--enable-manager-legacy-ui"] + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) + def test_unknown_mode_returns_default(self, mock_find, mock_launch_config_manager): + mock_launch_config_manager.get.return_value = "unknown-mode" + + result = _get_manager_flags() + + assert result == ["--enable-manager"] + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=False) + def test_enable_mode_without_cmcli_returns_empty(self, mock_find, mock_launch_config_manager): + """When config is enable-* but cm-cli is not available, return empty list.""" + mock_launch_config_manager.get.return_value = "enable-gui" + + result = _get_manager_flags() + + assert result == [] + mock_find.assert_called_once() + + +class TestBackwardCompatibility: + def test_old_config_false_migrates_to_disable(self, mock_launch_config_manager): + # New mode not set, old value is "False" + mock_launch_config_manager.get.side_effect = lambda key: { + constants.CONFIG_KEY_MANAGER_GUI_MODE: None, + constants.CONFIG_KEY_MANAGER_GUI_ENABLED: "False", + }.get(key) + + result = _get_manager_flags() + + assert result == [] + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) + def test_old_config_true_migrates_to_enable_gui(self, mock_find, mock_launch_config_manager): + # New mode not set, old value is "True" + mock_launch_config_manager.get.side_effect = lambda key: { + constants.CONFIG_KEY_MANAGER_GUI_MODE: None, + constants.CONFIG_KEY_MANAGER_GUI_ENABLED: "True", + }.get(key) + + result = _get_manager_flags() + + assert result == ["--enable-manager"] + + @patch("comfy_cli.command.launch.find_cm_cli") + def test_no_config_with_cmcli_defaults_to_enable_gui(self, mock_find_cm_cli, mock_launch_config_manager): + # Neither new nor old config is set, but cm-cli is available + mock_launch_config_manager.get.return_value = None + mock_find_cm_cli.return_value = True + + result = _get_manager_flags() + + assert result == ["--enable-manager"] + # Called twice: once to determine default mode, once to verify availability + assert mock_find_cm_cli.call_count == 2 + + @patch("comfy_cli.command.launch.find_cm_cli") + def test_no_config_no_cmcli_returns_empty(self, mock_find_cm_cli, mock_launch_config_manager): + # Neither new nor old config is set, and cm-cli is NOT available + mock_launch_config_manager.get.return_value = None + mock_find_cm_cli.return_value = False + + result = _get_manager_flags() + + assert result == [] + mock_find_cm_cli.assert_called_once() + + def test_old_config_boolean_false_migrates_to_disable(self, mock_launch_config_manager): + """Test backward compatibility with actual boolean False value.""" + mock_launch_config_manager.get.side_effect = lambda key: { + constants.CONFIG_KEY_MANAGER_GUI_MODE: None, + constants.CONFIG_KEY_MANAGER_GUI_ENABLED: False, # boolean, not string + }.get(key) + + result = _get_manager_flags() + + assert result == [] + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) + def test_old_config_boolean_true_migrates_to_enable_gui(self, mock_find, mock_launch_config_manager): + """Test backward compatibility with actual boolean True value.""" + mock_launch_config_manager.get.side_effect = lambda key: { + constants.CONFIG_KEY_MANAGER_GUI_MODE: None, + constants.CONFIG_KEY_MANAGER_GUI_ENABLED: True, # boolean, not string + }.get(key) + + result = _get_manager_flags() + + assert result == ["--enable-manager"] + + +class TestLaunchManagerFlagInjection: + @patch("comfy_cli.command.launch.launch_comfyui") + @patch("comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager"]) + @patch("comfy_cli.command.launch.workspace_manager") + @patch("comfy_cli.command.launch.check_for_updates") + @patch("os.chdir") + def test_launch_injects_enable_manager( + self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui + ): + mock_ws.workspace_path = "/fake/workspace" + mock_ws.workspace_type = "default" + mock_ws.config_manager.config = {"DEFAULT": {}} + + from comfy_cli.command.launch import launch + + launch(background=False, extra=["--port", "8188"]) + + args, kwargs = mock_launch_comfyui.call_args + extra_arg = args[0] + assert "--enable-manager" in extra_arg + assert "--port" in extra_arg + + @patch("comfy_cli.command.launch.launch_comfyui") + @patch("comfy_cli.command.launch._get_manager_flags", return_value=[]) + @patch("comfy_cli.command.launch.workspace_manager") + @patch("comfy_cli.command.launch.check_for_updates") + @patch("os.chdir") + def test_launch_no_inject_when_disabled( + self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui + ): + mock_ws.workspace_path = "/fake/workspace" + mock_ws.workspace_type = "default" + mock_ws.config_manager.config = {"DEFAULT": {}} + + from comfy_cli.command.launch import launch + + launch(background=False, extra=["--port", "8188"]) + + args, kwargs = mock_launch_comfyui.call_args + extra_arg = args[0] + assert "--enable-manager" not in extra_arg + + @patch("comfy_cli.command.launch.launch_comfyui") + @patch("comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager"]) + @patch("comfy_cli.command.launch.workspace_manager") + @patch("comfy_cli.command.launch.check_for_updates") + @patch("os.chdir") + def test_launch_injects_when_extra_is_none( + self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui + ): + mock_ws.workspace_path = "/fake/workspace" + mock_ws.workspace_type = "not_default" + mock_ws.config_manager.config = {"DEFAULT": {}} + + from comfy_cli.command.launch import launch + + launch(background=False, extra=None) + + args, kwargs = mock_launch_comfyui.call_args + extra_arg = args[0] + assert extra_arg == ["--enable-manager"] + + @patch("comfy_cli.command.launch.launch_comfyui") + @patch("comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager", "--disable-manager-ui"]) + @patch("comfy_cli.command.launch.workspace_manager") + @patch("comfy_cli.command.launch.check_for_updates") + @patch("os.chdir") + def test_launch_injects_disable_gui_flags( + self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui + ): + mock_ws.workspace_path = "/fake/workspace" + mock_ws.workspace_type = "not_default" + mock_ws.config_manager.config = {"DEFAULT": {}} + + from comfy_cli.command.launch import launch + + launch(background=False, extra=None) + + args, kwargs = mock_launch_comfyui.call_args + extra_arg = args[0] + assert "--enable-manager" in extra_arg + assert "--disable-manager-ui" in extra_arg + + @patch("comfy_cli.command.launch.launch_comfyui") + @patch( + "comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager", "--enable-manager-legacy-ui"] + ) + @patch("comfy_cli.command.launch.workspace_manager") + @patch("comfy_cli.command.launch.check_for_updates") + @patch("os.chdir") + def test_launch_injects_legacy_gui_flags( + self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui + ): + mock_ws.workspace_path = "/fake/workspace" + mock_ws.workspace_type = "not_default" + mock_ws.config_manager.config = {"DEFAULT": {}} + + from comfy_cli.command.launch import launch + + launch(background=False, extra=None) + + args, kwargs = mock_launch_comfyui.call_args + extra_arg = args[0] + assert "--enable-manager" in extra_arg + assert "--enable-manager-legacy-ui" in extra_arg + + +class TestMigrateLegacy: + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_no_workspace_exits(self, mock_ws, mock_config_manager): + """When workspace is not set, migrate-legacy should exit with error.""" + mock_ws.workspace_path = None + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + with pytest.raises(typer.Exit) as exc_info: + migrate_legacy(yes=True) + + assert exc_info.value.exit_code == 1 + mock_config_manager.set.assert_not_called() + + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_with_cli_only_mode(self, mock_ws, mock_config_manager, tmp_path): + # Setup: create legacy manager with .enable-cli-only-mode and .git + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + (legacy_manager / ".enable-cli-only-mode").touch() + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + # Verify moved to .disabled + assert not legacy_manager.exists() + assert (custom_nodes / ".disabled" / "ComfyUI-Manager").exists() + + @patch("comfy_cli.command.custom_nodes.command.subprocess.run") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_without_cli_only_mode(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path): + # Setup: create legacy manager with .git but without .enable-cli-only-mode + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + # Create manager_requirements.txt for successful install + (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text("comfyui-manager>=4.1b1") + + mock_ws.workspace_path = str(tmp_path) + mock_subprocess_run.return_value = MagicMock(returncode=0) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") + # Verify moved to .disabled + assert not legacy_manager.exists() + assert (custom_nodes / ".disabled" / "ComfyUI-Manager").exists() + + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_no_legacy_manager(self, mock_ws, mock_config_manager, tmp_path): + # Setup: no legacy manager + custom_nodes = tmp_path / "custom_nodes" + custom_nodes.mkdir(parents=True) + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + # Should not call set when no legacy manager found + mock_config_manager.set.assert_not_called() + + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_target_exists(self, mock_ws, mock_config_manager, tmp_path): + # Setup: both source and target exist + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + (custom_nodes / ".disabled" / "ComfyUI-Manager").mkdir(parents=True) + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + with pytest.raises(typer.Exit): + migrate_legacy(yes=True) + + @patch("comfy_cli.command.custom_nodes.command.subprocess.run") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_lowercase_directory(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path): + # Setup: create legacy manager with lowercase name and .git + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "comfyui-manager" # lowercase + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + # Create manager_requirements.txt for successful install + (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text("comfyui-manager>=4.1b1") + + mock_ws.workspace_path = str(tmp_path) + mock_subprocess_run.return_value = MagicMock(returncode=0) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") + # Verify moved to .disabled (preserving original case) + assert not legacy_manager.exists() + assert (custom_nodes / ".disabled" / "comfyui-manager").exists() + + @patch("comfy_cli.command.custom_nodes.command.subprocess.run") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_installs_manager_requirements( + self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path + ): + # Setup: create legacy manager with .git and manager_requirements.txt + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + # Create manager_requirements.txt in workspace root + (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text("comfyui-manager>=4.1b1") + + mock_ws.workspace_path = str(tmp_path) + mock_subprocess_run.return_value = MagicMock(returncode=0) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + # Verify pip install was called with correct command + mock_subprocess_run.assert_called_once() + call_args = mock_subprocess_run.call_args[0][0] + assert call_args[0] == sys.executable or "python" in call_args[0].lower() + assert "-m" in call_args + assert "pip" in call_args + assert "install" in call_args + assert "-r" in call_args + # Verify the requirements file path is included + assert any(constants.MANAGER_REQUIREMENTS_FILE in str(arg) for arg in call_args) + + @patch("comfy_cli.command.custom_nodes.command.subprocess.run") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_no_requirements_file(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path): + # Setup: create legacy manager with .git but NO manager_requirements.txt + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + # Verify pip install was NOT called (no requirements file) + mock_subprocess_run.assert_not_called() + # When requirements file is missing, install fails → set to disable + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_not_git_repo(self, mock_ws, mock_config_manager, tmp_path): + # Setup: create directory without .git (not a git repo) + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + # No .git directory + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + # Should not migrate non-git directories + mock_config_manager.set.assert_not_called() + # Directory should still exist (not moved) + assert legacy_manager.exists() + + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_skips_symlink(self, mock_ws, mock_config_manager, tmp_path): + # Setup: create a symlink instead of real directory + custom_nodes = tmp_path / "custom_nodes" + custom_nodes.mkdir(parents=True) + real_dir = tmp_path / "real-manager" + real_dir.mkdir() + (real_dir / ".git").mkdir() + symlink_path = custom_nodes / "ComfyUI-Manager" + symlink_path.symlink_to(real_dir) + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + # Should not migrate symlinks + mock_config_manager.set.assert_not_called() + # Symlink should still exist + assert symlink_path.is_symlink() + + @patch("comfy_cli.command.custom_nodes.command.shutil.move") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_move_error(self, mock_ws, mock_move, mock_config_manager, tmp_path): + # Setup: create legacy manager with .git + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + (custom_nodes / ".disabled").mkdir() + + mock_ws.workspace_path = str(tmp_path) + mock_move.side_effect = OSError("Permission denied") + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + with pytest.raises(typer.Exit): + migrate_legacy(yes=True) + + # Config should not be set on move failure + mock_config_manager.set.assert_not_called() + + @patch("comfy_cli.command.custom_nodes.command.ui.prompt_confirm_action") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_user_cancels(self, mock_ws, mock_confirm, mock_config_manager, tmp_path): + # Setup: create legacy manager with .git + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + + mock_ws.workspace_path = str(tmp_path) + mock_confirm.return_value = False # User cancels + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=False) + + # Should not migrate when user cancels + mock_config_manager.set.assert_not_called() + # Directory should still exist + assert legacy_manager.exists() + + +class TestInstallSkipManager: + """Tests for --skip-manager flag setting config to disable.""" + + @patch("comfy_cli.command.install.update_node_id_cache") + @patch("comfy_cli.command.install.pip_install_manager") + @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") + @patch("comfy_cli.command.install.workspace_manager") + @patch("comfy_cli.command.install.WorkspaceManager") + @patch("comfy_cli.command.install.check_comfy_repo") + @patch("comfy_cli.command.install.clone_comfyui") + @patch("comfy_cli.command.install.ui.prompt_confirm_action") + @patch("comfy_cli.config_manager.ConfigManager") + @patch("os.path.exists") + @patch("os.makedirs") + @patch("os.chdir") + def test_skip_manager_sets_disable_config( + self, + mock_chdir, + mock_makedirs, + mock_exists, + mock_config_manager_cls, + mock_confirm, + mock_clone, + mock_check_repo, + mock_ws_cls, + mock_ws, + mock_pip_deps, + mock_pip_manager, + mock_update_cache, + ): + """When --skip-manager is used, config should be set to disable.""" + # Setup mocks + mock_exists.side_effect = lambda p: p == "/fake/comfy" # repo exists + mock_check_repo.return_value = (True, None) + mock_ws.skip_prompting = True + mock_config_manager = MagicMock() + mock_config_manager_cls.return_value = mock_config_manager + + from comfy_cli.command.install import execute + + execute( + url="https://github.com/comfyanonymous/ComfyUI", + comfy_path="/fake/comfy", + restore=False, + skip_manager=True, # Key flag + version="nightly", + ) + + # Verify config was set to disable + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + # Verify pip_install_manager was NOT called + mock_pip_manager.assert_not_called() + + +class TestInstallManagerFailure: + """Tests for pip_install_manager failure handling.""" + + @patch("comfy_cli.command.install.update_node_id_cache") + @patch("comfy_cli.command.install.pip_install_manager") + @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") + @patch("comfy_cli.command.install.workspace_manager") + @patch("comfy_cli.command.install.WorkspaceManager") + @patch("comfy_cli.command.install.check_comfy_repo") + @patch("comfy_cli.command.install.clone_comfyui") + @patch("comfy_cli.command.install.ui.prompt_confirm_action") + @patch("comfy_cli.config_manager.ConfigManager") + @patch("os.path.exists") + @patch("os.makedirs") + @patch("os.chdir") + def test_manager_install_failure_sets_disable_config( + self, + mock_chdir, + mock_makedirs, + mock_exists, + mock_config_manager_cls, + mock_confirm, + mock_clone, + mock_check_repo, + mock_ws_cls, + mock_ws, + mock_pip_deps, + mock_pip_manager, + mock_update_cache, + ): + """When pip_install_manager fails, config should be set to disable.""" + # Setup mocks + mock_exists.side_effect = lambda p: p == "/fake/comfy" # repo exists + mock_check_repo.return_value = (True, None) + mock_ws.skip_prompting = True + mock_pip_manager.return_value = False # Manager installation fails + mock_config_manager = MagicMock() + mock_config_manager_cls.return_value = mock_config_manager + + from comfy_cli.command.install import execute + + execute( + url="https://github.com/comfyanonymous/ComfyUI", + comfy_path="/fake/comfy", + restore=False, + skip_manager=False, # Try to install manager + version="nightly", + ) + + # Verify pip_install_manager was called + mock_pip_manager.assert_called_once() + # Verify config was set to disable due to failure + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + + @patch("comfy_cli.command.install.update_node_id_cache") + @patch("comfy_cli.command.install.pip_install_manager") + @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") + @patch("comfy_cli.command.install.workspace_manager") + @patch("comfy_cli.command.install.WorkspaceManager") + @patch("comfy_cli.command.install.check_comfy_repo") + @patch("comfy_cli.command.install.clone_comfyui") + @patch("comfy_cli.command.install.ui.prompt_confirm_action") + @patch("comfy_cli.config_manager.ConfigManager") + @patch("os.path.exists") + @patch("os.makedirs") + @patch("os.chdir") + def test_manager_install_success_does_not_set_disable( + self, + mock_chdir, + mock_makedirs, + mock_exists, + mock_config_manager_cls, + mock_confirm, + mock_clone, + mock_check_repo, + mock_ws_cls, + mock_ws, + mock_pip_deps, + mock_pip_manager, + mock_update_cache, + ): + """When pip_install_manager succeeds, config should NOT be set to disable.""" + # Setup mocks + mock_exists.side_effect = lambda p: p == "/fake/comfy" # repo exists + mock_check_repo.return_value = (True, None) + mock_ws.skip_prompting = True + mock_pip_manager.return_value = True # Manager installation succeeds + mock_config_manager = MagicMock() + mock_config_manager_cls.return_value = mock_config_manager + + from comfy_cli.command.install import execute + + execute( + url="https://github.com/comfyanonymous/ComfyUI", + comfy_path="/fake/comfy", + restore=False, + skip_manager=False, + version="nightly", + ) + + # Verify pip_install_manager was called + mock_pip_manager.assert_called_once() + # Verify config was NOT set to disable + mock_config_manager.set.assert_not_called() + + @patch("comfy_cli.command.install.DependencyCompiler") + @patch("comfy_cli.command.install.update_node_id_cache") + @patch("comfy_cli.command.install.pip_install_manager") + @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") + @patch("comfy_cli.command.install.workspace_manager") + @patch("comfy_cli.command.install.WorkspaceManager") + @patch("comfy_cli.command.install.check_comfy_repo") + @patch("comfy_cli.command.install.clone_comfyui") + @patch("comfy_cli.command.install.ui.prompt_confirm_action") + @patch("comfy_cli.config_manager.ConfigManager") + @patch("os.path.exists") + @patch("os.makedirs") + @patch("os.chdir") + def test_fast_deps_manager_failure_sets_disable_config( + self, + mock_chdir, + mock_makedirs, + mock_exists, + mock_config_manager_cls, + mock_confirm, + mock_clone, + mock_check_repo, + mock_ws_cls, + mock_ws, + mock_pip_deps, + mock_pip_manager, + mock_update_cache, + mock_dep_compiler, + ): + """When fast_deps=True and pip_install_manager fails, config should be set to disable.""" + # Setup mocks + mock_exists.side_effect = lambda p: p == "/fake/comfy" + mock_check_repo.return_value = (True, None) + mock_ws.skip_prompting = True + mock_pip_manager.return_value = False # Manager installation fails + mock_config_manager = MagicMock() + mock_config_manager_cls.return_value = mock_config_manager + mock_dep_compiler_instance = MagicMock() + mock_dep_compiler.return_value = mock_dep_compiler_instance + + from comfy_cli.command.install import execute + + execute( + url="https://github.com/comfyanonymous/ComfyUI", + comfy_path="/fake/comfy", + restore=False, + skip_manager=False, + version="nightly", + fast_deps=True, # Use fast_deps path + ) + + # Verify pip_install_manager was called (fast_deps path) + mock_pip_manager.assert_called_once() + # Verify config was set to disable due to failure + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + + +class TestPipInstallManagerCacheClear: + """Tests for pip_install_manager cache clearing after successful install.""" + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli") + @patch("comfy_cli.command.install.subprocess.run") + @patch("os.path.exists", return_value=True) + def test_pip_install_manager_clears_cache_on_success(self, mock_exists, mock_run, mock_find_cm_cli): + """When pip install succeeds, find_cm_cli cache should be cleared.""" + from comfy_cli.command.install import pip_install_manager + + # Simulate successful pip install + mock_run.return_value = MagicMock(returncode=0, stderr="") + + # Call pip_install_manager + result = pip_install_manager("/fake/repo") + + # Verify success + assert result is True + # Verify cache_clear was called on the mock + mock_find_cm_cli.cache_clear.assert_called_once() + + @patch("comfy_cli.command.install.subprocess.run") + @patch("os.path.exists", return_value=True) + def test_pip_install_manager_no_cache_clear_on_failure(self, mock_exists, mock_run): + """When pip install fails, cache should not be affected (function returns early).""" + from comfy_cli.command.install import pip_install_manager + + # Simulate failed pip install + mock_run.return_value = MagicMock(returncode=1) + + # Call pip_install_manager + result = pip_install_manager("/fake/repo") + + # Verify failure + assert result is False + + +class TestFillPrintTable: + """Tests for WorkspaceManager.fill_print_table() method.""" + + @pytest.fixture() + def mock_workspace_config_manager(self): + with patch("comfy_cli.workspace_manager.ConfigManager") as mock_cls: + instance = MagicMock() + mock_cls.return_value = instance + yield instance + + def test_fill_print_table_disable_mode(self, mock_workspace_config_manager): + """When mode is 'disable', status should show Disabled.""" + mock_workspace_config_manager.get.return_value = "disable" + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert len(result) == 2 + assert result[0][0] == "Current selected workspace" + assert result[1][0] == "Manager" + assert "Disabled" in result[1][1] + + def test_fill_print_table_enable_gui_mode(self, mock_workspace_config_manager): + """When mode is 'enable-gui', status should show GUI Enabled.""" + mock_workspace_config_manager.get.return_value = "enable-gui" + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "GUI Enabled" in result[1][1] + + def test_fill_print_table_disable_gui_mode(self, mock_workspace_config_manager): + """When mode is 'disable-gui', status should show GUI Disabled.""" + mock_workspace_config_manager.get.return_value = "disable-gui" + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "GUI Disabled" in result[1][1] + + def test_fill_print_table_enable_legacy_gui_mode(self, mock_workspace_config_manager): + """When mode is 'enable-legacy-gui', status should show Legacy GUI.""" + mock_workspace_config_manager.get.return_value = "enable-legacy-gui" + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "Legacy GUI" in result[1][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=False) + def test_fill_print_table_not_installed(self, mock_find_cm_cli, mock_workspace_config_manager): + """When no config and cm-cli not found, status should show Not Installed.""" + mock_workspace_config_manager.get.return_value = None + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "Not Installed" in result[1][1] + mock_find_cm_cli.assert_called_once() + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) + def test_fill_print_table_backward_compat_false(self, mock_find_cm_cli, mock_workspace_config_manager): + """When new mode is None and old value is 'False', should show Disabled.""" + mock_workspace_config_manager.get.side_effect = lambda key: { + constants.CONFIG_KEY_MANAGER_GUI_MODE: None, + constants.CONFIG_KEY_MANAGER_GUI_ENABLED: "False", + }.get(key) + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "Disabled" in result[1][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) + def test_fill_print_table_backward_compat_true(self, mock_find_cm_cli, mock_workspace_config_manager): + """When new mode is None and old value is 'True', should show GUI Enabled.""" + mock_workspace_config_manager.get.side_effect = lambda key: { + constants.CONFIG_KEY_MANAGER_GUI_MODE: None, + constants.CONFIG_KEY_MANAGER_GUI_ENABLED: "True", + }.get(key) + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "GUI Enabled" in result[1][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) + def test_fill_print_table_no_config_with_cmcli(self, mock_find_cm_cli, mock_workspace_config_manager): + """When no config at all but cm-cli available, should default to GUI Enabled.""" + mock_workspace_config_manager.get.return_value = None + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "GUI Enabled" in result[1][1] + + def test_fill_print_table_unknown_mode_defaults_to_enable(self, mock_workspace_config_manager): + """When mode is unknown, status should default to GUI Enabled.""" + mock_workspace_config_manager.get.return_value = "unknown-mode" + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "GUI Enabled" in result[1][1] + + +class TestFindCmCli: + """Tests for find_cm_cli() function.""" + + def test_find_cm_cli_module_found(self): + """When cm_cli module exists, should return True.""" + with patch("importlib.util.find_spec") as mock_find_spec: + mock_find_spec.return_value = MagicMock() # Non-None means module exists + # Clear cache before test + from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli + + find_cm_cli.cache_clear() + + result = find_cm_cli() + + assert result is True + mock_find_spec.assert_called_with("cm_cli") + + def test_find_cm_cli_module_not_found(self): + """When cm_cli module doesn't exist, should return False.""" + with patch("importlib.util.find_spec") as mock_find_spec: + mock_find_spec.return_value = None # None means module not found + from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli + + find_cm_cli.cache_clear() + + result = find_cm_cli() + + assert result is False + mock_find_spec.assert_called_with("cm_cli") + + def test_find_cm_cli_cache_behavior(self): + """find_cm_cli should cache results and not call find_spec repeatedly.""" + with patch("importlib.util.find_spec") as mock_find_spec: + mock_find_spec.return_value = MagicMock() + from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli + + find_cm_cli.cache_clear() + + # Call multiple times + result1 = find_cm_cli() + result2 = find_cm_cli() + result3 = find_cm_cli() + + # All should return True + assert result1 is True + assert result2 is True + assert result3 is True + # find_spec should only be called once due to caching + assert mock_find_spec.call_count == 1 + + +class TestPipInstallManagerEdgeCases: + """Additional edge case tests for pip_install_manager().""" + + @patch("comfy_cli.command.install.subprocess.run") + @patch("os.path.exists", return_value=False) + def test_pip_install_manager_requirements_not_found(self, mock_exists, mock_run): + """When requirements file doesn't exist, should return False without calling pip.""" + from comfy_cli.command.install import pip_install_manager + + result = pip_install_manager("/fake/repo") + + assert result is False + # subprocess.run should NOT be called + mock_run.assert_not_called() + + +class TestValidateComfyuiManager: + """Tests for validate_comfyui_manager() function.""" + + @patch("comfy_cli.command.custom_nodes.command.find_cm_cli", return_value=False) + def test_validate_comfyui_manager_exits_when_not_found(self, mock_find_cm_cli): + """When cm-cli is not found, should raise typer.Exit with code 1.""" + from comfy_cli.command.custom_nodes.command import validate_comfyui_manager + + with pytest.raises(typer.Exit) as exc_info: + validate_comfyui_manager() + + assert exc_info.value.exit_code == 1 + mock_find_cm_cli.assert_called_once() + + @patch("comfy_cli.command.custom_nodes.command.find_cm_cli", return_value=True) + def test_validate_comfyui_manager_passes_when_found(self, mock_find_cm_cli): + """When cm-cli is found, should not raise any exception.""" + from comfy_cli.command.custom_nodes.command import validate_comfyui_manager + + # Should not raise + validate_comfyui_manager() + + mock_find_cm_cli.assert_called_once()