diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 4662b788..d0afff01 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -657,6 +657,19 @@ def update( rprint(f"[yellow]Failed to update node id cache: {e}[/yellow]") +@app.command(help="Report installed-vs-latest versions for ComfyUI core and custom node packs (read-only).") +@tracking.track_command() +def outdated( + refresh: Annotated[ + bool, + typer.Option("--refresh", help="Bypass the 1h latest-version cache and re-query the network."), + ] = False, +): + from comfy_cli.command import outdated as outdated_command + + outdated_command.execute(get_renderer(), workspace_manager.workspace_path, refresh=refresh) + + @app.command(help="Run an API workflow. Submits and returns immediately by default; pass --wait to block.") def run( workflow: Annotated[ diff --git a/comfy_cli/command/outdated.py b/comfy_cli/command/outdated.py new file mode 100644 index 00000000..aa9e8ed5 --- /dev/null +++ b/comfy_cli/command/outdated.py @@ -0,0 +1,510 @@ +"""``comfy outdated`` — read-only installed-vs-latest report. + +Reports the installed vs. latest version for ComfyUI **core** and every +installed **custom node pack**, so a stale install can be spotted before it +silently degrades agents. Nothing here mutates the workspace — it only reads +git metadata / ``pyproject.toml`` and queries public APIs. + +Sources of "latest": +- **Core**: the GitHub ``releases/latest`` tag (reusing + :func:`comfy_cli.command.install.get_latest_release`). +- **Registry packs** (a pack whose ``pyproject.toml`` carries a registry node + id + version): the Comfy registry API (reusing + :class:`comfy_cli.registry.RegistryAPI`). +- **Git packs** (a bare git clone, no registry metadata): ``git ls-remote`` + HEAD compared against the local HEAD. + +Latest-version lookups are cached for 1h under +``~/.cache/comfy-cli/outdated.json`` so agents can poll cheaply; ``--refresh`` +bypasses the cache. Any network failure degrades to ``latest: null`` + a +warning and exit 0 — a report that can't reach the network is still useful. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import re +import subprocess +import sys +import time +from collections.abc import Callable +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from comfy_cli.registry import RegistryAPI, extract_node_configuration + + +def _read_pyproject(path: str): + """Parse a pack/core ``pyproject.toml`` via the shared registry parser. + + ``extract_node_configuration`` emits its own validation warnings through + ``typer.echo``/rich to *stdout*; in JSON mode that would corrupt the single + envelope on stdout. Route those side-messages to stderr where they belong. + """ + with contextlib.redirect_stdout(sys.stderr): + return extract_node_configuration(path) + + +CACHE_TTL_SECONDS = 3600 # 1 hour +GIT_TIMEOUT_SECONDS = 10 + +# Matches the trailing ``--g`` that ``git describe`` appends when the +# checkout is N commits past the nearest tag (e.g. ``v0.3.40-5-gdeadbee``). +_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9a-f]+$") + +# Matches a top-level ``__version__ = "x.y.z"`` in ComfyUI's comfyui_version.py. +_VERSION_ASSIGN_RE = re.compile( + r"""^__version__\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')""", + re.MULTILINE, +) + + +# --------------------------------------------------------------------------- +# Cache +# --------------------------------------------------------------------------- + + +def _cache_path() -> Path: + """Where the latest-version cache lives. XDG-respecting.""" + base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache") + return Path(base) / "comfy-cli" / "outdated.json" + + +def _load_cache() -> dict[str, Any]: + path = _cache_path() + try: + data = json.loads(path.read_bytes()) + except (OSError, ValueError): + # ValueError covers JSONDecodeError *and* the UnicodeDecodeError that a + # non-UTF-8 (corrupt) cache file raises — either way, start fresh. + return {} + return data if isinstance(data, dict) else {} + + +def _save_cache(cache: dict[str, Any]) -> None: + path = _cache_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(cache)) + except OSError: + # A read-only cache dir must never break a read-only report. + pass + + +def _cache_get(cache: dict[str, Any], key: str) -> Any | None: + """Return a fresh (< TTL) cached value for *key*, else ``None``.""" + entry = cache.get(key) + if not isinstance(entry, dict): + return None + ts = entry.get("ts") + if not isinstance(ts, int | float) or (time.time() - ts) > CACHE_TTL_SECONDS: + return None + return entry.get("value") + + +def _cache_set(cache: dict[str, Any], key: str, value: Any) -> None: + cache[key] = {"value": value, "ts": time.time()} + + +# --------------------------------------------------------------------------- +# Version helpers +# --------------------------------------------------------------------------- + + +def _normalize_version(v: str | None) -> str | None: + if not v: + return v + v = _DESCRIBE_SUFFIX_RE.sub("", v.strip()) + return v.lstrip("vV") + + +def _is_outdated(installed: str | None, latest: str | None) -> bool: + """True when *installed* is provably older than *latest*. + + Unknowns (either side missing) are never flagged outdated. Comparison: + + - both parse as PEP 440 versions → semantic ``<`` (a checkout *ahead* of the + latest tag, ``v0.3.41-5-g…`` vs ``v0.3.41``, normalizes equal → not flagged); + - neither parses (e.g. two git SHAs, for a git pack) → any difference means + the local HEAD is behind the remote HEAD → outdated; + - exactly one parses (e.g. a shallow/no-tag core checkout whose "installed" + is a bare SHA vs a version tag) → genuinely incomparable, so *not* flagged + rather than guessing a false positive. + """ + if not installed or not latest: + return False + ni, nl = _normalize_version(installed), _normalize_version(latest) + if ni == nl: + return False + + from packaging.version import InvalidVersion, parse + + pi = pl = None + try: + pi = parse(ni) + except InvalidVersion: + pass + try: + pl = parse(nl) + except InvalidVersion: + pass + + if pi is not None and pl is not None: + return pi < pl + if pi is None and pl is None: + return ni != nl + return False + + +# --------------------------------------------------------------------------- +# Git helpers +# --------------------------------------------------------------------------- + + +# Git runs with ``cwd`` inside a custom-node pack we do not control, so it honors +# that pack's ``.git/config`` and ``origin`` URL. A pack shipped as an archive +# with an embedded ``.git`` could otherwise turn a read-only version check into +# arbitrary code execution. Hardening, applied to *every* git call, neutralizes +# each knob a malicious repo config could use to run a program during a plain +# read, while keeping the legitimate https/ssh transports working: +# * ``GIT_ALLOW_PROTOCOL`` drops ``ext::`` (runs a shell) and ``git://`` (whose +# ``core.gitProxy`` runs a program), leaving file/http/https/ssh. +# * command-line ``-c`` overrides (which beat repo-local config) clear the +# ``credential.helper`` and ``core.fsmonitor`` program hooks. +# * ``GIT_SSH_COMMAND=ssh`` (env beats ``core.sshCommand``) pins ssh to the +# default client, so a pack can't hijack it — yet ssh remotes still resolve. +# * ``GIT_TERMINAL_PROMPT=0`` keeps a credential-needing remote from blocking. +_GIT_HARDENING: list[str] = [ + "-c", + "credential.helper=", + "-c", + "core.fsmonitor=", + "-c", + "protocol.ext.allow=never", + "-c", + "protocol.git.allow=never", +] +_GIT_SAFE_ENV = { + "GIT_ALLOW_PROTOCOL": "file:http:https:ssh", + "GIT_SSH_COMMAND": "ssh", + "GIT_TERMINAL_PROMPT": "0", +} + + +def _git_output(args: list[str], cwd: str) -> str | None: + """Run ``git `` in *cwd*, returning stripped stdout or ``None``. + + Hardened against a malicious pack ``.git/config``/``origin`` — see + ``_GIT_HARDENING`` / ``_GIT_SAFE_ENV``. + """ + try: + out = subprocess.run( + ["git", *_GIT_HARDENING, *args], + cwd=cwd, + capture_output=True, + text=True, + timeout=GIT_TIMEOUT_SECONDS, + check=True, + env={**os.environ, **_GIT_SAFE_ENV}, + ) + except (subprocess.SubprocessError, OSError): + return None + return out.stdout.strip() or None + + +def _is_git_checkout(path: str) -> bool: + return _git_output(["rev-parse", "--is-inside-work-tree"], path) == "true" + + +# --------------------------------------------------------------------------- +# Core +# --------------------------------------------------------------------------- + + +def _core_installed(comfy_path: str) -> tuple[str | None, str | None]: + """Return ``(installed_version, commit)`` for the core install. + + Prefers ``git describe --tags`` (+ short HEAD) for a git checkout, falling + back to ``comfyui_version.py``'s ``__version__`` then ``pyproject.toml``. + """ + if _is_git_checkout(comfy_path): + described = _git_output(["describe", "--tags", "--always"], comfy_path) + commit = _git_output(["rev-parse", "--short", "HEAD"], comfy_path) + if described: + return described, commit + + # Fall back to the packaged version marker. + version_file = Path(comfy_path) / "comfyui_version.py" + try: + m = _VERSION_ASSIGN_RE.search(version_file.read_text(encoding="utf-8")) + if m: + return (m.group(1) or m.group(2)), None + except OSError: + pass + + cfg = _read_pyproject(os.path.join(comfy_path, "pyproject.toml")) + if cfg is not None and cfg.project.name: + return cfg.project.version, None + return None, None + + +def _core_latest(cache: dict[str, Any], refresh: bool, warn: Callable[[str], None]) -> str | None: + if not refresh: + cached = _cache_get(cache, "core") + if cached is not None: + return cached + # Reuse install.py's rate-limit-aware fetcher (GITHUB_TOKEN, forks, 403/429). + from comfy_cli.command.install import get_latest_release + + try: + # get_latest_release prints its own error line to stdout via rich on a + # network failure; redirect that to stderr so it can't corrupt the + # single-line JSON envelope stdout contract (mirrors _read_pyproject). + with contextlib.redirect_stdout(sys.stderr): + release = get_latest_release("comfyanonymous", "ComfyUI") + except Exception as e: # noqa: BLE001 - never let a network hiccup abort the report + warn(f"could not fetch latest ComfyUI release: {e}") + return None + if release is None: + warn("could not fetch latest ComfyUI release (network or rate limit)") + return None + # get_latest_release returns a GithubRelease TypedDict (a plain dict). + tag = release.get("tag") + if not tag: + warn("latest ComfyUI release had no tag") + return None + _cache_set(cache, "core", tag) + return tag + + +# --------------------------------------------------------------------------- +# Custom node packs +# --------------------------------------------------------------------------- + + +def _iter_pack_dirs(custom_nodes_dir: Path) -> list[Path]: + if not custom_nodes_dir.is_dir(): + return [] + packs = [] + for entry in sorted(custom_nodes_dir.iterdir()): + if not entry.is_dir(): + continue + if entry.name.startswith(".") or entry.name == "__pycache__": + continue + packs.append(entry) + return packs + + +def _registry_latest( + node_id: str, + cache: dict[str, Any], + refresh: bool, + registry_api: RegistryAPI, + warn: Callable[[str], None], +) -> str | None: + key = f"pack:{node_id}" + if not refresh: + cached = _cache_get(cache, key) + if cached is not None: + return cached + try: + node_version = registry_api.install_node(node_id) + except Exception as e: # noqa: BLE001 - registry unreachable → unknown, not fatal + warn(f"could not fetch latest version for pack '{node_id}': {e}") + return None + latest = getattr(node_version, "version", None) + if latest: + _cache_set(cache, key, latest) + return latest + + +def _git_pack_info( + pack_dir: Path, + cache: dict[str, Any], + refresh: bool, + warn: Callable[[str], None], +) -> dict[str, Any]: + """Compare a git pack's local HEAD against its remote's HEAD.""" + name = pack_dir.name + installed = _git_output(["rev-parse", "HEAD"], str(pack_dir)) + latest = None + key = f"pack:git:{pack_dir}" + cached = _cache_get(cache, key) if not refresh else None + if cached is not None: + latest = cached + else: + remote = _git_output(["remote", "get-url", "origin"], str(pack_dir)) + if remote: + # ``--`` stops a ``origin`` URL starting with ``-`` from being + # parsed as a git option (option-injection); transports are + # further restricted in ``_git_output``. + ls = _git_output(["ls-remote", "--", remote, "HEAD"], str(pack_dir)) + if ls: + latest = ls.split()[0] + _cache_set(cache, key, latest) + else: + warn(f"could not reach git remote for pack '{name}'") + else: + warn(f"no origin remote for git pack '{name}'") + return { + "name": name, + "source": "git", + "installed": installed, + "latest": latest, + "outdated": _is_outdated(installed, latest), + } + + +def _pack_info( + pack_dir: Path, + cache: dict[str, Any], + refresh: bool, + registry_api: RegistryAPI, + warn: Callable[[str], None], +) -> dict[str, Any]: + name = pack_dir.name + pyproject = pack_dir / "pyproject.toml" + is_git = _is_git_checkout(str(pack_dir)) + + # Registry pack: pyproject carries a node id + version → registry API. + if pyproject.is_file(): + cfg = _read_pyproject(str(pyproject)) + if cfg is not None and cfg.project.name: + node_id = cfg.project.name + installed = cfg.project.version + latest = _registry_latest(node_id, cache, refresh, registry_api, warn) + # Many git-installed packs also ship a pyproject but aren't in the + # registry (latest unknown). Rather than report them as permanently + # "unknown", fall back to the git HEAD comparison when we can. + if latest is None and is_git: + return _git_pack_info(pack_dir, cache, refresh, warn) + return { + "name": node_id, + "source": "registry", + "installed": installed, + "latest": latest, + "outdated": _is_outdated(installed, latest), + } + + # Git pack: compare local HEAD against the remote's HEAD. + if is_git: + return _git_pack_info(pack_dir, cache, refresh, warn) + + # Neither: report what we can, latest unknown. + return { + "name": name, + "source": "unknown", + "installed": None, + "latest": None, + "outdated": False, + } + + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- + + +def build_report( + comfy_path: str | None, + *, + refresh: bool = False, + registry_api: RegistryAPI | None = None, + now: datetime | None = None, +) -> tuple[dict[str, Any], list[str]]: + """Build the outdated report. Returns ``(report, warnings)``. + + Pure enough to unit-test: inject ``registry_api`` (needs ``install_node``) + and ``now``; all network paths degrade to ``latest: null`` + a warning. + """ + warnings: list[str] = [] + warn = warnings.append + registry_api = registry_api or RegistryAPI() + cache = _load_cache() + + if comfy_path and os.path.isdir(comfy_path): + core_installed, core_commit = _core_installed(comfy_path) + core_latest = _core_latest(cache, refresh, warn) + packs = [ + _pack_info(p, cache, refresh, registry_api, warn) + for p in _iter_pack_dirs(Path(comfy_path) / "custom_nodes") + ] + else: + warn(f"ComfyUI workspace not found at {comfy_path!r}") + core_installed, core_commit, core_latest, packs = None, None, None, [] + + _save_cache(cache) + + checked_at = (now or datetime.now(timezone.utc)).isoformat() + report = { + "core": { + "installed": core_installed, + "commit": core_commit, + "latest": core_latest, + "outdated": _is_outdated(core_installed, core_latest), + }, + "packs": packs, + "checked_at": checked_at, + } + return report, warnings + + +def _render_pretty(renderer, report: dict[str, Any]) -> None: + from rich.markup import escape + from rich.table import Table + + def _fmt(installed: Any, latest: Any, outdated: bool) -> tuple[str, str]: + # Pack names/versions come from the filesystem and pyproject; escape any + # ``[...]`` so a value like ``foo[/]`` can't raise a rich MarkupError and + # crash the pretty report. + i = escape(str(installed)) if installed is not None else "[dim]?[/dim]" + latest_str = escape(str(latest)) if latest is not None else "[dim]unknown[/dim]" + if outdated: + return f"[yellow]{i}[/yellow]", f"[bold green]{latest_str}[/bold green]" + return i, latest_str + + tbl = Table(show_header=True, header_style="bold") + tbl.add_column("component") + tbl.add_column("installed") + tbl.add_column("latest") + tbl.add_column("status") + + core = report["core"] + ci, cl = _fmt(core["installed"], core["latest"], core["outdated"]) + tbl.add_row("[bold]ComfyUI (core)[/bold]", ci, cl, _status(core["outdated"], core["latest"])) + + for pack in report["packs"]: + pi, pl = _fmt(pack["installed"], pack["latest"], pack["outdated"]) + tbl.add_row(escape(pack["name"]), pi, pl, _status(pack["outdated"], pack["latest"])) + + renderer.console().print(tbl) + n_outdated = int(core["outdated"]) + sum(1 for p in report["packs"] if p["outdated"]) + if n_outdated: + renderer.print(f"[yellow]{n_outdated} component(s) outdated.[/yellow]") + else: + renderer.print("[green]Everything is up to date.[/green]") + + +def _status(outdated: bool, latest: Any) -> str: + if outdated: + return "[bold yellow]outdated[/bold yellow]" + if latest is None: + return "[dim]unknown[/dim]" + return "[green]up to date[/green]" + + +def execute(renderer, comfy_path: str | None, *, refresh: bool = False) -> None: + """Entry point wired from ``comfy outdated`` in cmdline.py.""" + from rich.markup import escape + + report, warnings = build_report(comfy_path, refresh=refresh) + if renderer.is_pretty(): + _render_pretty(renderer, report) + for w in warnings: + # Warnings embed pack names/error text; escape so a name like ``foo[/]`` + # can't trip renderer.warn's markup pass. + renderer.warn(escape(w)) + renderer.emit(report, command="outdated") diff --git a/comfy_cli/discovery.py b/comfy_cli/discovery.py index f98e9493..8c6b2a44 100644 --- a/comfy_cli/discovery.py +++ b/comfy_cli/discovery.py @@ -97,6 +97,8 @@ # config "comfy set-default": "set_default", "comfy version": "version", + # installed-vs-latest report + "comfy outdated": "outdated", # background server logs "comfy logs": "logs", } diff --git a/comfy_cli/registry/api.py b/comfy_cli/registry/api.py index 928b3606..6e7184bc 100644 --- a/comfy_cli/registry/api.py +++ b/comfy_cli/registry/api.py @@ -121,7 +121,10 @@ def install_node(self, node_id, version=None): else: url = f"{self.base_url}/nodes/{node_id}/install?version={version}" - response = requests.get(url) + # A stalled/blackholed registry must not hang callers indefinitely (e.g. + # `comfy outdated`, which promises to degrade gracefully on network + # failure). A Timeout surfaces as a RequestException for callers to catch. + response = requests.get(url, timeout=30) if response.status_code == 200: # Convert the API response to a NodeVersion object logging.debug(f"RegistryAPI install_node response: {response.json()}") diff --git a/comfy_cli/schemas/outdated.json b/comfy_cli/schemas/outdated.json new file mode 100644 index 00000000..d2930154 --- /dev/null +++ b/comfy_cli/schemas/outdated.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "comfy outdated", + "description": "Installed-vs-latest report for ComfyUI core and installed custom node packs. `latest` is null when the network lookup failed or the component has no known upstream; `outdated` is never true when `latest` is null.", + "type": "object", + "properties": { + "core": { + "type": "object", + "description": "ComfyUI core install status.", + "properties": { + "installed": { + "type": ["string", "null"], + "description": "Installed core version (git describe tag, comfyui_version, or pyproject)." + }, + "commit": { + "type": ["string", "null"], + "description": "Short HEAD sha when core is a git checkout, else null." + }, + "latest": { + "type": ["string", "null"], + "description": "Latest ComfyUI release tag, or null if the lookup failed." + }, + "outdated": { + "type": "boolean", + "description": "True when the installed version is provably older than latest." + } + }, + "required": ["installed", "latest", "outdated"] + }, + "packs": { + "type": "array", + "description": "One row per installed custom node pack.", + "items": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Pack directory name or registry node id." }, + "source": { + "type": "string", + "enum": ["registry", "git", "unknown"], + "description": "How the latest version was resolved." + }, + "installed": { "type": ["string", "null"], "description": "Installed pack version or local git HEAD." }, + "latest": { "type": ["string", "null"], "description": "Latest version/HEAD, or null if the lookup failed." }, + "outdated": { "type": "boolean", "description": "True when the pack is provably behind latest." } + }, + "required": ["name", "source", "installed", "latest", "outdated"] + } + }, + "checked_at": { + "type": "string", + "description": "ISO 8601 timestamp of when the report was generated." + } + }, + "required": ["core", "packs", "checked_at"] +} diff --git a/tests/comfy_cli/command/test_outdated.py b/tests/comfy_cli/command/test_outdated.py new file mode 100644 index 00000000..0b647dd2 --- /dev/null +++ b/tests/comfy_cli/command/test_outdated.py @@ -0,0 +1,434 @@ +"""Tests for ``comfy outdated`` — installed-vs-latest reporting. + +Builds a real fixture workspace (a git-checkout core + one registry pack + +one git pack) under tmp_path and mocks the GitHub/registry HTTP so nothing +touches the network. Covers the outdated verdicts, the JSON envelope shape, +the 1h cache (warm-cache serves latest even when the network is down), and the +network-failure path (``latest: null`` + exit 0). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from comfy_cli.caller import Caller +from comfy_cli.command import outdated as outdated_cmd +from comfy_cli.output.renderer import ( + OutputMode, + Renderer, + reset_renderer_for_testing, + set_renderer, +) + +# --------------------------------------------------------------------------- +# git helpers +# --------------------------------------------------------------------------- + + +def _git(cwd: Path, *args: str) -> str: + return subprocess.run( + [ + "git", + "-c", + "user.email=test@example.com", + "-c", + "user.name=Test", + "-c", + "commit.gpgsign=false", + *args, + ], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +def _commit(cwd: Path, filename: str, content: str, message: str) -> None: + (cwd / filename).write_text(content) + _git(cwd, "add", "-A") + _git(cwd, "commit", "-m", message) + + +# --------------------------------------------------------------------------- +# fixtures +# --------------------------------------------------------------------------- + + +class _FakeRegistry: + """Stand-in for RegistryAPI: install_node(id) -> object with .version.""" + + def __init__(self, versions: dict[str, str], raises: bool = False): + self._versions = versions + self._raises = raises + + def install_node(self, node_id: str, version=None): + if self._raises: + raise RuntimeError("registry unreachable") + + class _NV: + pass + + nv = _NV() + nv.version = self._versions.get(node_id, "0.0.0") + return nv + + +@pytest.fixture(autouse=True) +def _isolate_cache(tmp_path, monkeypatch): + """Pin the outdated cache under tmp so tests don't share disk state.""" + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +@pytest.fixture +def workspace(tmp_path) -> Path: + """A fixture ComfyUI workspace: git core + registry pack + git pack.""" + ws = tmp_path / "ComfyUI" + ws.mkdir() + + # --- core: git checkout on tag v0.3.40 --- + _git(ws, "init") + _commit(ws, "main.py", "# comfy\n", "initial") + _git(ws, "tag", "v0.3.40") + + custom_nodes = ws / "custom_nodes" + custom_nodes.mkdir() + + # --- registry pack: pyproject with a node id + version --- + reg = custom_nodes / "comfy-registry-pack" + reg.mkdir() + (reg / "pyproject.toml").write_text('[project]\nname = "comfy-registry-pack"\nversion = "1.0.0"\n') + + # --- git pack: local HEAD behind its remote HEAD --- + # Pin the bare repo's default branch to ``main`` so its HEAD symref resolves + # to the branch the seed pushes below. Without this, a runner whose + # ``init.defaultBranch`` is ``master`` (the git default) leaves the bare + # repo's HEAD dangling, so ``git ls-remote HEAD`` returns nothing + # and the git-pack latest lookup silently fails. + remote = tmp_path / "gitpack-remote.git" + _git(tmp_path, "init", "--bare", "-b", "main", str(remote)) + seed = tmp_path / "gitpack-seed" + _git(tmp_path, "clone", str(remote), str(seed)) + _commit(seed, "node.py", "v1\n", "pack v1") + _git(seed, "push", "origin", "HEAD:refs/heads/main") + + gitpack = custom_nodes / "git-pack" + _git(tmp_path, "clone", str(remote), str(gitpack)) + # advance the remote so the pack's local HEAD is now stale + _commit(seed, "node.py", "v2\n", "pack v2") + _git(seed, "push", "origin", "HEAD:refs/heads/main") + + return ws + + +def _force_json_renderer() -> Renderer: + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +def _packs_by_name(report: dict) -> dict[str, dict]: + return {p["name"]: p for p in report["packs"]} + + +# --------------------------------------------------------------------------- +# tests +# --------------------------------------------------------------------------- + + +def test_stale_install_flags_core_and_packs(workspace, monkeypatch): + """Acceptance: a stale install reports core.outdated true + pack rows.""" + + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: {"tag": "v0.3.41"}, + ) + registry = _FakeRegistry({"comfy-registry-pack": "1.2.0"}) + + report, warnings = outdated_cmd.build_report(str(workspace), registry_api=registry) + + assert report["core"]["installed"] == "v0.3.40" + assert report["core"]["latest"] == "v0.3.41" + assert report["core"]["outdated"] is True + assert report["core"]["commit"] # short HEAD present for a git checkout + + packs = _packs_by_name(report) + assert packs["comfy-registry-pack"]["source"] == "registry" + assert packs["comfy-registry-pack"]["installed"] == "1.0.0" + assert packs["comfy-registry-pack"]["latest"] == "1.2.0" + assert packs["comfy-registry-pack"]["outdated"] is True + + assert packs["git-pack"]["source"] == "git" + assert packs["git-pack"]["outdated"] is True + assert packs["git-pack"]["installed"] != packs["git-pack"]["latest"] + + assert "checked_at" in report + assert not warnings + + +def test_up_to_date_install_not_flagged(workspace, monkeypatch): + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: {"tag": "v0.3.40"}, + ) + registry = _FakeRegistry({"comfy-registry-pack": "1.0.0"}) + + report, _ = outdated_cmd.build_report(str(workspace), registry_api=registry) + + assert report["core"]["outdated"] is False + assert _packs_by_name(report)["comfy-registry-pack"]["outdated"] is False + + +def test_network_failure_yields_null_latest_and_no_warnings_crash(workspace, monkeypatch): + """GitHub + registry down → latest: null, outdated: false, warnings, no raise.""" + + def _boom(*a, **k): + raise RuntimeError("no network") + + monkeypatch.setattr("comfy_cli.command.install.get_latest_release", _boom) + registry = _FakeRegistry({}, raises=True) + + report, warnings = outdated_cmd.build_report(str(workspace), registry_api=registry) + + assert report["core"]["latest"] is None + assert report["core"]["outdated"] is False + packs = _packs_by_name(report) + assert packs["comfy-registry-pack"]["latest"] is None + assert packs["comfy-registry-pack"]["outdated"] is False + assert warnings # each failed lookup surfaced a warning + + +def test_warm_cache_serves_latest_without_network(workspace, monkeypatch): + """First call populates the 1h cache; second call serves it even offline.""" + + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: {"tag": "v0.3.41"}, + ) + registry = _FakeRegistry({"comfy-registry-pack": "1.2.0"}) + + outdated_cmd.build_report(str(workspace), registry_api=registry) # warms cache + + # Now sever the network; the warm cache must still yield the same verdicts. + def _boom(*a, **k): + raise RuntimeError("no network") + + monkeypatch.setattr("comfy_cli.command.install.get_latest_release", _boom) + report, warnings = outdated_cmd.build_report(str(workspace), registry_api=_FakeRegistry({}, raises=True)) + + assert report["core"]["latest"] == "v0.3.41" + assert report["core"]["outdated"] is True + assert _packs_by_name(report)["comfy-registry-pack"]["latest"] == "1.2.0" + assert not warnings + + +def test_refresh_bypasses_cache(workspace, monkeypatch): + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: {"tag": "v0.3.41"}, + ) + outdated_cmd.build_report(str(workspace), registry_api=_FakeRegistry({"comfy-registry-pack": "1.2.0"})) + + # --refresh must re-query even with a warm cache; a down network → null. + def _boom(*a, **k): + raise RuntimeError("no network") + + monkeypatch.setattr("comfy_cli.command.install.get_latest_release", _boom) + report, warnings = outdated_cmd.build_report( + str(workspace), refresh=True, registry_api=_FakeRegistry({}, raises=True) + ) + + assert report["core"]["latest"] is None + assert warnings + + +def test_is_outdated_incomparable_sha_vs_version_not_flagged(): + """A bare SHA (shallow/no-tag checkout) vs a version tag is incomparable.""" + # both versions → semantic + assert outdated_cmd._is_outdated("v0.3.40", "v0.3.41") is True + assert outdated_cmd._is_outdated("v0.3.41", "v0.3.41") is False + # checkout ahead of the latest tag → not flagged + assert outdated_cmd._is_outdated("v0.3.41-5-gabc1234", "v0.3.41") is False + # both opaque (git SHAs) → any difference is "behind" + assert outdated_cmd._is_outdated("abc1234", "def5678") is True + assert outdated_cmd._is_outdated("abc1234", "abc1234") is False + # exactly one parses → incomparable, must NOT false-positive + assert outdated_cmd._is_outdated("abc1234def", "v0.3.41") is False + assert outdated_cmd._is_outdated("v0.3.41", "abc1234def") is False + + +def test_shallow_core_checkout_not_falsely_flagged(tmp_path, monkeypatch): + """A no-tag core checkout resolves to a SHA; don't flag it vs a version.""" + ws = tmp_path / "ComfyUI" + ws.mkdir() + _git(ws, "init") + _commit(ws, "main.py", "# comfy\n", "initial") # no tag → describe --always = SHA + (ws / "custom_nodes").mkdir() + + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: {"tag": "v0.3.41"}, + ) + report, _ = outdated_cmd.build_report(str(ws), registry_api=_FakeRegistry({})) + assert report["core"]["latest"] == "v0.3.41" + assert report["core"]["installed"] # a SHA + assert report["core"]["outdated"] is False # SHA vs version is incomparable + + +def test_missing_workspace_warns_not_crashes(tmp_path): + report, warnings = outdated_cmd.build_report(str(tmp_path / "nope")) + assert report["core"]["installed"] is None + assert report["packs"] == [] + assert warnings + + +def test_git_pack_with_pyproject_falls_back_to_git_when_unregistered(tmp_path, monkeypatch): + """A git-installed pack shipping a pyproject that the registry doesn't know + must fall back to the git HEAD comparison, not report unknown/not-outdated.""" + ws = tmp_path / "ComfyUI" + (ws / "custom_nodes").mkdir(parents=True) + _git(ws, "init") + _commit(ws, "main.py", "# comfy\n", "initial") + + # git pack whose local HEAD is behind its remote HEAD, but which also ships + # a pyproject (so the registry branch is entered first). + remote = tmp_path / "gp-remote.git" + _git(tmp_path, "init", "--bare", "-b", "main", str(remote)) + seed = tmp_path / "gp-seed" + _git(tmp_path, "clone", str(remote), str(seed)) + _commit(seed, "node.py", "v1\n", "pack v1") + _git(seed, "push", "origin", "HEAD:refs/heads/main") + + pack = ws / "custom_nodes" / "hybrid-pack" + _git(tmp_path, "clone", str(remote), str(pack)) + (pack / "pyproject.toml").write_text('[project]\nname = "hybrid-pack"\nversion = "1.0.0"\n') + _commit(seed, "node.py", "v2\n", "pack v2") + _git(seed, "push", "origin", "HEAD:refs/heads/main") + + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: {"tag": "v0.3.40"}, + ) + # Registry doesn't know this pack (404 → install_node raises) → latest None + # → must fall back to git. + report, _ = outdated_cmd.build_report(str(ws), registry_api=_FakeRegistry({}, raises=True)) + + pack_row = _packs_by_name(report)["hybrid-pack"] + assert pack_row["source"] == "git" + assert pack_row["installed"] != pack_row["latest"] + assert pack_row["outdated"] is True + + +def test_git_pack_without_origin_warns(tmp_path, monkeypatch): + """A git pack with no ``origin`` remote surfaces an explicit warning.""" + ws = tmp_path / "ComfyUI" + (ws / "custom_nodes").mkdir(parents=True) + _git(ws, "init") + _commit(ws, "main.py", "# comfy\n", "initial") + + pack = ws / "custom_nodes" / "orphan-pack" + pack.mkdir() + _git(pack, "init") + _commit(pack, "node.py", "v1\n", "pack v1") # no origin remote configured + + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: {"tag": "v0.3.40"}, + ) + report, warnings = outdated_cmd.build_report(str(ws), registry_api=_FakeRegistry({})) + + assert _packs_by_name(report)["orphan-pack"]["latest"] is None + assert any("no origin remote" in w for w in warnings) + + +def test_git_pack_rejects_option_injecting_remote(tmp_path, monkeypatch): + """A malicious ``origin`` URL (``ext::``/``-``-prefixed) must not execute: + the transport allowlist + ``--`` separator degrade it to latest: null.""" + ws = tmp_path / "ComfyUI" + (ws / "custom_nodes").mkdir(parents=True) + _git(ws, "init") + _commit(ws, "main.py", "# comfy\n", "initial") + + pack = ws / "custom_nodes" / "evil-pack" + pack.mkdir() + _git(pack, "init") + _commit(pack, "node.py", "v1\n", "pack v1") + canary = tmp_path / "pwned" + _git(pack, "remote", "add", "origin", f"ext::sh -c 'touch {canary}; true'") + + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: {"tag": "v0.3.40"}, + ) + report, warnings = outdated_cmd.build_report(str(ws), registry_api=_FakeRegistry({})) + + assert not canary.exists(), "ext:: transport executed — RCE not blocked" + assert _packs_by_name(report)["evil-pack"]["latest"] is None + assert any("could not reach git remote" in w for w in warnings) + + +def test_report_validates_against_shipped_schema(workspace, monkeypatch): + """The report payload must satisfy comfy_cli/schemas/outdated.json.""" + import jsonschema + + from comfy_cli import discovery + + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: {"tag": "v0.3.41"}, + ) + schema = discovery._read_schema("outdated") + + # both a live report and a network-down report must validate + report_ok, _ = outdated_cmd.build_report( + str(workspace), registry_api=_FakeRegistry({"comfy-registry-pack": "1.2.0"}) + ) + jsonschema.validate(report_ok, schema) + + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("down")), + ) + report_down, _ = outdated_cmd.build_report( + str(workspace), refresh=True, registry_api=_FakeRegistry({}, raises=True) + ) + jsonschema.validate(report_down, schema) + + +def test_cli_json_envelope_shape(workspace, monkeypatch, capsys): + monkeypatch.setattr( + "comfy_cli.command.install.get_latest_release", + lambda *a, **k: {"tag": "v0.3.41"}, + ) + monkeypatch.setattr( + outdated_cmd, + "RegistryAPI", + lambda: _FakeRegistry({"comfy-registry-pack": "1.2.0"}), + ) + + r = _force_json_renderer() + outdated_cmd.execute(r, str(workspace)) + + out = capsys.readouterr().out.strip() + # Envelope contract: stdout must be exactly ONE line (the JSON envelope). + # The registry pack's pyproject has no license, which makes the shared + # parser warn — that side-message must land on stderr, not stdout. + assert len(out.splitlines()) == 1, f"stdout not a single envelope line:\n{out}" + envelope = json.loads(out) + assert envelope["ok"] is True + assert envelope["command"] == "outdated" + assert envelope["data"]["core"]["outdated"] is True + assert isinstance(envelope["data"]["packs"], list)