diff --git a/comfy_cli/command/templates.py b/comfy_cli/command/templates.py index 92b9a264..7709304e 100644 --- a/comfy_cli/command/templates.py +++ b/comfy_cli/command/templates.py @@ -76,6 +76,18 @@ def _load_gallery( return json.loads(cache.read_bytes()) +def _as_str_list(value: Any) -> list[str]: + """Coerce a gallery field to a list of strings, tolerating the scalar-or-junk + variance in real data. A bare string is treated as a single-element list (not + split into characters); a non-list/non-string scalar becomes an empty list. + """ + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, list): + return [str(v) for v in value if v] + return [] + + def _flatten_templates(categories: list[dict[str, Any]]) -> list[dict[str, Any]]: """Walk the nested (category → templates) shape and flatten to a list. @@ -103,6 +115,7 @@ def _flatten_templates(categories: list[dict[str, Any]]) -> list[dict[str, Any]] "group_category": cat.get("category") or "", "tags": list(t.get("tags") or []), "models": list(t.get("models") or []), + "requires_custom_nodes": _as_str_list(t.get("requiresCustomNodes")), "providers": _flatten_providers(t.get("logos") or []), "date": t.get("date") or "", "open_source": bool(t.get("openSource", False)), @@ -487,7 +500,7 @@ def fetch_cmd( # node count in the envelope without re-reading. try: wf = json.loads(body) - except json.JSONDecodeError as e: + except (json.JSONDecodeError, UnicodeDecodeError) as e: renderer.error( code="template_workflow_invalid_json", message=f"upstream returned non-JSON for {name!r}: {e}", @@ -524,3 +537,387 @@ def fetch_cmd( if renderer.is_pretty() and out: rprint(f"[green]✓[/green] wrote {len(body):,} bytes ({payload['node_count']} nodes) to {target_repr}") renderer.emit(payload, command="templates fetch") + + +# --------------------------------------------------------------------------- +# templates check — per-template runnable/missing/api-required/unknown verdict +# --------------------------------------------------------------------------- + + +def _template_workflow_cache_path(name: str) -> Path: + """Where a fetched per-template workflow JSON is cached. Same base-dir logic + as :func:`_cache_path`, one file per template under ``gallery/templates``. + + ``name`` comes from the (untrusted) gallery index, so it's URL-encoded the + same way :func:`_fetch_template_workflow` encodes it for the fetch URL — + ``quote(..., safe="")`` turns any ``/`` or ``..`` segment into ``%2F``/``..`` + with no path separators, keeping the file strictly inside ``gallery/templates``. + """ + base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache") + safe_name = urllib.parse.quote(name, safe="") + return Path(base) / "comfy-cli" / "gallery" / "templates" / f"{safe_name}.json" + + +def _iter_workflow_nodes(wf: dict[str, Any]): + """Yield every node dict in a UI-format workflow: top-level ``nodes`` first, + then the nodes inside each subgraph *definition*. + + The subgraph walk is mandatory — modern templates (e.g. ``image_z_image_turbo``) + carry their model references only inside subgraph definitions, so a top-level + walk alone would report them as having zero model requirements. + """ + if not isinstance(wf, dict): + return + top = wf.get("nodes") + if isinstance(top, list): + for node in top: + if isinstance(node, dict): + yield node + definitions = wf.get("definitions") + subgraphs = definitions.get("subgraphs") if isinstance(definitions, dict) else None + if isinstance(subgraphs, list): + for sg in subgraphs: + if not isinstance(sg, dict): + continue + sg_nodes = sg.get("nodes") + if isinstance(sg_nodes, list): + for node in sg_nodes: + if isinstance(node, dict): + yield node + + +def _collect_model_requirements(wf: dict[str, Any]) -> list[dict[str, str]]: + """Gather every ``node["properties"]["models"]`` entry across top-level and + subgraph nodes. Each entry is normalized to ``{name, directory, url}`` and + deduped by ``(directory, name)`` preserving first-seen order. + """ + seen: set[tuple[str, str]] = set() + out: list[dict[str, str]] = [] + for node in _iter_workflow_nodes(wf): + props = node.get("properties") + if not isinstance(props, dict): + continue + models = props.get("models") + if not isinstance(models, list): + continue + for m in models: + if not isinstance(m, dict): + continue + name = str(m.get("name") or "") + directory = str(m.get("directory") or "") + if not name: + # A model ref with no filename isn't matchable against a folder + # listing — keeping it would always report a phantom empty-name + # "missing" model and wrongly flip the verdict to missing-models. + continue + key = (directory, name) + if key in seen: + continue + seen.add(key) + out.append({"name": name, "directory": directory, "url": str(m.get("url") or "")}) + return out + + +def _collect_node_class_types(wf: dict[str, Any]) -> list[str]: + """Distinct ``node["type"]`` values across top-level + subgraph nodes. + + For a regular node this is the ComfyUI class name (``CheckpointLoaderSimple``); + for a subgraph *instance* it's the definition UUID (never loader-ish, never in + object_info), which is why the loader/api heuristics simply skip those. + """ + seen: set[str] = set() + out: list[str] = [] + for node in _iter_workflow_nodes(wf): + t = node.get("type") + if isinstance(t, str) and t and t not in seen: + seen.add(t) + out.append(t) + return out + + +def _basename(path: str) -> str: + """Last path segment of a folder-relative listing entry. Model folder + listings return paths that may include subdirectories (``sdxl/model.safetensors``) + and always use ``/`` on the wire; also tolerate a stray backslash defensively. + """ + return path.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1] + + +def _list_local_folder(target, folder: str) -> list[str] | None: + """List file paths in ``/models/`` on the local server. + + Returns the folder-relative names, or ``None`` if the folder 404s (a + custom-node folder like ``SEEDVR2`` that ComfyUI doesn't register). Connection + errors (server not running) and other HTTP errors propagate to the caller. + """ + # Reuse the exact target/URL plumbing `comfy models list-folder` uses. + from comfy_cli.command.models.search import _http_get_json, _models_path_parts + + url = target.url(*_models_path_parts(target), folder) + try: + data = _http_get_json(url, target) + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + names: list[str] = [] + if isinstance(data, list): + for entry in data: + if isinstance(entry, dict): + n = entry.get("name", "") + elif isinstance(entry, str): + n = entry + else: + n = "" + if n: + names.append(n) + return names + + +def _template_is_api_by_index(name: str, tags: list[str]) -> bool: + """Index-only API heuristic: an ``api_*`` template name, or an ``API`` tag.""" + if name.startswith("api_"): + return True + return any(str(t).strip().lower() == "api" for t in tags) + + +def _compute_verdict(*, api_dependent: bool, missing: list, required_count: int, node_types: list[str]) -> str: + """Verdict precedence: api-required > missing-models > runnable > unknown.""" + if api_dependent: + return "api-required" + if missing: + return "missing-models" + if required_count > 0: + # Every referenced model resolved on disk. + return "runnable" + # Zero model references: runnable only if nothing looks like it *should* load + # a model; otherwise we genuinely can't tell (loader with no declared models). + loaderish = any("loader" in (t or "").lower() for t in node_types) + return "unknown" if loaderish else "runnable" + + +@app.command( + "check", + help=( + "Report whether a gallery template is runnable on THIS install: which of " + "its models are present vs missing locally, whether it needs partner-API " + "access, and any custom nodes it declares. Resolves the name against the " + "gallery, fetches the workflow, and intersects its model refs with the " + "local server's model folders." + ), +) +@tracking.track_command("templates") +def check_cmd( + name: Annotated[str, typer.Argument(help="Template name (matches `comfy templates ls` rows).")], + gallery_path: Annotated[ + str | None, + typer.Option("--gallery", show_default=False, help="Path to a local index.json (skips the cache + fetch)."), + ] = None, + refresh: Annotated[ + bool, + typer.Option("--refresh", help="Re-fetch the gallery index AND the template workflow before checking."), + ] = False, +): + from comfy_cli.target import resolve_target + + renderer = get_renderer() + + # 1. Resolve the name against the gallery index (same affordance as `fetch`). + try: + cats = _load_gallery(gallery_path, refresh=refresh) + except (urllib.error.URLError, OSError, json.JSONDecodeError) as e: + renderer.error(code="gallery_load_failed", message=str(e)) + raise typer.Exit(code=1) from e + + rows = _flatten_templates(cats) + match = next((r for r in rows if r["name"] == name), None) + if match is None: + lower = name.lower() + close = [r["name"] for r in rows if lower in r["name"].lower()][:5] + renderer.error( + code="template_not_found", + message=f"no template named {name!r} in the gallery", + hint="try `comfy templates ls --name ` to search", + details={"close_matches": close}, + ) + raise typer.Exit(code=1) + + # 2. Fetch (or read from cache) the per-template workflow JSON. + cache_path = _template_workflow_cache_path(name) + body: bytes | None = None + if not refresh and cache_path.exists(): + try: + body = cache_path.read_bytes() + except OSError: + body = None + if body is None: + try: + body = _fetch_template_workflow(name) + except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e: + status = getattr(e, "code", None) + renderer.error( + code="template_fetch_failed", + message=f"failed to fetch workflow for {name!r}: {e}", + hint=( + "the gallery index references a template whose workflow JSON " + "is missing upstream — report at " + "https://github.com/Comfy-Org/workflow_templates/issues" + if status == 404 + else "check network connectivity" + ), + details={"status": status} if status else None, + ) + raise typer.Exit(code=1) from e + # Write atomically: a truncated file (interrupted write / full disk) would + # otherwise be trusted by the read path above on the next non-refresh run + # and fail `template_workflow_invalid_json` until the user passed --refresh. + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = cache_path.parent / f"{cache_path.name}.{os.getpid()}.tmp" + try: + tmp_path.write_bytes(body) + os.replace(tmp_path, cache_path) + except OSError: + tmp_path.unlink(missing_ok=True) + raise + except OSError: + pass # a non-writable cache dir must not fail the check + + try: + wf = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + renderer.error( + code="template_workflow_invalid_json", + message=f"template workflow for {name!r} is not valid JSON: {e}", + hint="re-run with --refresh to re-fetch, or report upstream", + ) + raise typer.Exit(code=1) from e + if not isinstance(wf, dict): + renderer.error( + code="template_workflow_invalid_json", + message=f"template workflow for {name!r} is not a JSON object", + hint="re-run with --refresh to re-fetch, or report upstream", + ) + raise typer.Exit(code=1) + + # 3. Model requirements (top-level + subgraph walk) and node class types. + required = _collect_model_requirements(wf) + node_types = _collect_node_class_types(wf) + + # 4. API dependence. Tier (a) is the index heuristic; tier (b) upgrades it with + # object_info when a local server is reachable (best-effort — a down server + # just leaves the index answer in place). + api_dependent = _template_is_api_by_index(name, match.get("tags") or []) + api_source = "index" + api_nodes: list[str] = [] + try: + from comfy_cli.cql.engine import Graph + + graph = Graph.load(mode="local") + api_nodes = [cls for cls in node_types if (m := graph.node(cls)) is not None and m.is_api_node] + # Only attribute the signal to object_info when it actually found API nodes; + # an empty scan must leave `source` reflecting the index heuristic, not claim + # object_info as the source of an api-required verdict it didn't produce. + if api_nodes: + api_source = "object_info" + api_dependent = True + except Exception: + # Server down / object_info unavailable → index heuristic alone decides. + # Discard any partial object_info result so `source` and `api_nodes` agree. + api_nodes = [] + api_source = "index" + + # 5. Installed intersection: list each distinct folder once on the local server + # and match required files by basename. + warnings: list[str] = [] + present: list[str] = [] + missing: list[dict[str, str]] = [] + distinct_dirs = list(dict.fromkeys(req["directory"] for req in required)) + if required: + target = resolve_target(where="local") + listings: dict[str, list[str] | None] = {} + try: + for directory in distinct_dirs: + if not directory or ".." in directory or "/" in directory or "\\" in directory: + # Not addressable as a `/models/` segment — treat as absent. + listings[directory] = None + warnings.append( + f"model directory {directory!r} isn't a valid model folder — its files are reported missing" + ) + continue + folder_files = _list_local_folder(target, directory) + listings[directory] = folder_files + if folder_files is None: + warnings.append( + f"model folder {directory!r} not found on the local server " + f"(custom-node folder?) — its files are reported missing" + ) + except (urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as e: + renderer.error( + code="server_not_running", + message=f"local ComfyUI server is unreachable, cannot check installed models: {e}", + hint="run `comfy launch` to start a local server", + ) + raise typer.Exit(code=1) from e + + for req in required: + listing = listings.get(req["directory"]) + # Normalize BOTH sides: a model ref may itself carry a subfolder + # (e.g. ``SDXL/model.safetensors``, as ComfyUI loader widgets emit), + # so compare basenames on the required side too. + req_base = _basename(req["name"]) + if listing and any(_basename(entry) == req_base for entry in listing): + present.append(req["name"]) + else: + missing.append(dict(req)) + + # 6. Custom nodes are report-only in v1 (surfaced verbatim, not verified). + custom_nodes_required = list(match.get("requires_custom_nodes") or []) + + # 7. Verdict. + verdict = _compute_verdict( + api_dependent=api_dependent, + missing=missing, + required_count=len(required), + node_types=node_types, + ) + + # 8. Emit. + payload = { + "name": name, + "title": match["title"], + "verdict": verdict, + "models": {"required": len(required), "present": present, "missing": missing}, + "api": {"dependent": api_dependent, "source": api_source, "api_nodes": api_nodes}, + "custom_nodes_required": custom_nodes_required, + "warnings": warnings, + } + + if renderer.is_pretty(): + # `name`, node-class names, model names/urls and warnings are all untrusted + # (they come from the gallery index / workflow JSON), so escape them before + # Rich interprets them — a stray `[foo]` would otherwise raise MarkupError. + from rich.markup import escape + + mark = "[bold green]✓[/bold green]" if verdict == "runnable" else "[bold red]✗[/bold red]" + rprint(f"{mark} {escape(name)} — [bold]{verdict}[/bold]") + if api_dependent: + extra = f": {escape(', '.join(api_nodes))}" if api_nodes else "" + rprint(f" [yellow]needs partner-API access[/yellow] (via {api_source}){extra}") + if custom_nodes_required: + rprint(f" custom nodes: {escape(', '.join(custom_nodes_required))} [dim](not verified)[/dim]") + if missing: + from rich.table import Table + + tbl = Table(show_header=True, header_style="bold") + tbl.add_column("missing model") + tbl.add_column("directory", style="dim") + tbl.add_column("download url") + for m in missing: + tbl.add_row(escape(m["name"]), escape(m["directory"]), escape(m["url"]) or "[dim](none)[/dim]") + renderer.console().print(tbl) + elif required: + rprint(f" [dim]{len(present)}/{len(required)} model(s) present[/dim]") + for w in warnings: + rprint(f" [yellow]⚠[/yellow] {escape(w)}") + renderer.emit(payload, command="templates check") diff --git a/comfy_cli/discovery.py b/comfy_cli/discovery.py index f98e9493..06775d3c 100644 --- a/comfy_cli/discovery.py +++ b/comfy_cli/discovery.py @@ -87,6 +87,7 @@ "comfy templates show": "templates", "comfy templates fetch": "templates", "comfy templates refresh": "templates", + "comfy templates check": "templates", # file transfer "comfy upload": "transfer", "comfy download": "transfer", diff --git a/tests/comfy_cli/command/test_templates.py b/tests/comfy_cli/command/test_templates.py index faa5fe8d..a7ef1d85 100644 --- a/tests/comfy_cli/command/test_templates.py +++ b/tests/comfy_cli/command/test_templates.py @@ -274,3 +274,420 @@ def test_fetch_non_json_upstream_surfaces_workflow_invalid(gallery_file, monkeyp assert result.exit_code != 0 env = _envelope(result.output) assert env["error"]["code"] == "template_workflow_invalid_json" + + +# --------------------------------------------------------------------------- +# templates check — workflow-walker unit tests +# --------------------------------------------------------------------------- + +# `default`-shape workflow: a top-level CheckpointLoaderSimple carries its model +# in `properties.models`. +_TOP_LEVEL_WF = { + "nodes": [ + { + "id": 4, + "type": "CheckpointLoaderSimple", + "properties": { + "models": [ + { + "name": "v1-5-pruned-emaonly.safetensors", + "directory": "checkpoints", + "url": "https://example.test/ckpt", + } + ] + }, + }, + {"id": 3, "type": "KSampler", "properties": {}}, + ] +} + +# `image_z_image_turbo`-shape workflow: the ONLY model reference lives inside a +# subgraph definition, so a top-level-only walk would find nothing. +_SUBGRAPH_WF = { + "nodes": [ + {"id": 10, "type": "sg-uuid-1", "properties": {}}, + ], + "definitions": { + "subgraphs": [ + { + "id": "sg-uuid-1", + "nodes": [ + { + "id": 5, + "type": "UNETLoader", + "properties": { + "models": [ + { + "name": "z_image_turbo.safetensors", + "directory": "diffusion_models", + "url": "https://example.test/z", + } + ] + }, + } + ], + } + ] + }, +} + + +def test_collect_models_top_level(): + reqs = templates_cmd._collect_model_requirements(_TOP_LEVEL_WF) + assert reqs == [ + { + "name": "v1-5-pruned-emaonly.safetensors", + "directory": "checkpoints", + "url": "https://example.test/ckpt", + } + ] + + +def test_collect_models_subgraph_only(): + # The mandatory subgraph walk is what surfaces this — a top-level walk sees none. + reqs = templates_cmd._collect_model_requirements(_SUBGRAPH_WF) + assert reqs == [ + { + "name": "z_image_turbo.safetensors", + "directory": "diffusion_models", + "url": "https://example.test/z", + } + ] + + +def test_collect_models_dedupes_by_directory_and_name(): + wf = { + "nodes": [ + {"id": 1, "type": "A", "properties": {"models": [{"name": "m.ckpt", "directory": "checkpoints"}]}}, + {"id": 2, "type": "B", "properties": {"models": [{"name": "m.ckpt", "directory": "checkpoints"}]}}, + {"id": 3, "type": "C", "properties": {"models": [{"name": "m.ckpt", "directory": "loras"}]}}, + ] + } + reqs = templates_cmd._collect_model_requirements(wf) + # Same (directory, name) collapses; a different directory stays distinct. + assert len(reqs) == 2 + assert {(r["directory"], r["name"]) for r in reqs} == {("checkpoints", "m.ckpt"), ("loras", "m.ckpt")} + + +def test_basename_handles_subfoldered_listings(): + assert templates_cmd._basename("sdxl/model.safetensors") == "model.safetensors" + assert templates_cmd._basename("model.safetensors") == "model.safetensors" + assert templates_cmd._basename("a/b/c/model.safetensors") == "model.safetensors" + + +def test_collect_node_class_types_includes_subgraph_interior(): + types = templates_cmd._collect_node_class_types(_SUBGRAPH_WF) + # Top-level instance UUID + the interior loader class. + assert "UNETLoader" in types + assert "sg-uuid-1" in types + + +# --------------------------------------------------------------------------- +# templates check — verdict matrix (mocked folder listings + object_info) +# --------------------------------------------------------------------------- + + +def _stub_folder_listing(monkeypatch, mapping_or_exc): + """Patch the local folder listing. Pass a {folder: [names] | None} mapping + (absent folder → None, i.e. 404) or an Exception to raise (server down).""" + + def _impl(target, folder): + if isinstance(mapping_or_exc, Exception): + raise mapping_or_exc + return mapping_or_exc.get(folder) + + monkeypatch.setattr(templates_cmd, "_list_local_folder", _impl) + + +def _stub_object_info(monkeypatch, graph_or_exc): + """Patch Graph.load to return a prebuilt graph, or raise (no server).""" + from comfy_cli.cql import engine + + def _load(cls, *args, **kwargs): + if isinstance(graph_or_exc, Exception): + raise graph_or_exc + return graph_or_exc + + monkeypatch.setattr(engine.Graph, "load", classmethod(_load)) + + +def _no_local_server(monkeypatch): + from comfy_cli.cql import engine + + _stub_object_info(monkeypatch, engine.LoadError("no local server")) + + +def _run_check(gallery_file, name, tmp_path, monkeypatch, extra=None): + # Isolate the on-disk workflow cache so tests never read a stale body. + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + runner = CliRunner() + return runner.invoke(templates_cmd.app, ["check", "--gallery", gallery_file, name, *(extra or [])]) + + +def test_check_all_models_present_is_runnable(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) + _stub_template_workflow_fetch(monkeypatch, json.dumps(_TOP_LEVEL_WF).encode()) + _stub_folder_listing(monkeypatch, {"checkpoints": ["v1-5-pruned-emaonly.safetensors"]}) + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["verdict"] == "runnable" + assert env["data"]["models"]["present"] == ["v1-5-pruned-emaonly.safetensors"] + assert env["data"]["models"]["missing"] == [] + assert env["data"]["models"]["required"] == 1 + + +def test_check_one_missing_is_missing_models(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) + _stub_template_workflow_fetch(monkeypatch, json.dumps(_TOP_LEVEL_WF).encode()) + _stub_folder_listing(monkeypatch, {"checkpoints": ["something-else.safetensors"]}) + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["verdict"] == "missing-models" + missing = env["data"]["models"]["missing"] + assert len(missing) == 1 + # Missing entries carry the download URL so an agent can fetch them. + assert missing[0]["url"] == "https://example.test/ckpt" + assert missing[0]["directory"] == "checkpoints" + + +def test_check_404_folder_marks_missing_with_warning(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) + _stub_template_workflow_fetch(monkeypatch, json.dumps(_SUBGRAPH_WF).encode()) + # diffusion_models absent from the mapping → _list_local_folder returns None (404). + _stub_folder_listing(monkeypatch, {}) + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["verdict"] == "missing-models" + assert env["data"]["models"]["missing"][0]["name"] == "z_image_turbo.safetensors" + assert any("diffusion_models" in w for w in env["data"]["warnings"]) + + +def test_check_api_required_via_index_beats_present_models(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) + # image_flux2 carries the "API" tag in the gallery fixture; a zero-model + # workflow means we don't even need the server. + _stub_template_workflow_fetch(monkeypatch, json.dumps({"nodes": []}).encode()) + + result = _run_check(gallery_file, "image_flux2", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["verdict"] == "api-required" + assert env["data"]["api"]["dependent"] is True + assert env["data"]["api"]["source"] == "index" + + +def test_check_api_required_via_object_info(gallery_file, tmp_path, monkeypatch): + from comfy_cli.cql import engine + + _force_json_renderer() + # A non-API-by-index template (image_z_image) whose workflow node IS an api_node + # per object_info → the object_info tier is what flags it. + graph = engine.Graph.from_object_info( + {"SomeApiNode": {"input": {}, "output": [], "output_name": [], "api_node": True}} + ) + _stub_object_info(monkeypatch, graph) + _stub_template_workflow_fetch(monkeypatch, json.dumps({"nodes": [{"id": 1, "type": "SomeApiNode"}]}).encode()) + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["verdict"] == "api-required" + assert env["data"]["api"]["source"] == "object_info" + assert env["data"]["api"]["api_nodes"] == ["SomeApiNode"] + + +def test_check_zero_models_no_loaders_is_runnable(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) + wf = {"nodes": [{"id": 1, "type": "KSampler"}, {"id": 2, "type": "SaveImage"}]} + _stub_template_workflow_fetch(monkeypatch, json.dumps(wf).encode()) + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["verdict"] == "runnable" + assert env["data"]["models"]["required"] == 0 + + +def test_check_zero_models_with_loader_is_unknown(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) + # A loader-ish node but no declared properties.models → we can't tell. + wf = {"nodes": [{"id": 1, "type": "CheckpointLoaderSimple"}]} + _stub_template_workflow_fetch(monkeypatch, json.dumps(wf).encode()) + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["verdict"] == "unknown" + + +def test_check_basename_match_against_subfoldered_listing(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) + _stub_template_workflow_fetch(monkeypatch, json.dumps(_TOP_LEVEL_WF).encode()) + # The listing returns a folder-relative path with a subdirectory; basename matching + # must still count it as present. + _stub_folder_listing(monkeypatch, {"checkpoints": ["subdir/v1-5-pruned-emaonly.safetensors"]}) + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["verdict"] == "runnable" + assert env["data"]["models"]["present"] == ["v1-5-pruned-emaonly.safetensors"] + + +def test_check_server_down_surfaces_server_not_running(gallery_file, tmp_path, monkeypatch): + import urllib.error + + _force_json_renderer() + _no_local_server(monkeypatch) + _stub_template_workflow_fetch(monkeypatch, json.dumps(_TOP_LEVEL_WF).encode()) + _stub_folder_listing(monkeypatch, urllib.error.URLError("connection refused")) + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code != 0 + env = _envelope(result.output) + assert env["ok"] is False + assert env["error"]["code"] == "server_not_running" + + +def test_check_unknown_template_surfaces_template_not_found(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + + def _should_not_fire(name, timeout=15.0): + raise AssertionError("workflow fetch must not run for an unknown template") + + monkeypatch.setattr(templates_cmd, "_fetch_template_workflow", _should_not_fire) + result = _run_check(gallery_file, "no_such_template", tmp_path, monkeypatch) + assert result.exit_code != 0 + env = _envelope(result.output) + assert env["error"]["code"] == "template_not_found" + assert env["error"]["details"]["close_matches"] == [] + + +def test_check_custom_nodes_surfaced_verbatim(gallery_file, tmp_path, monkeypatch): + _force_json_renderer() + _no_local_server(monkeypatch) + _stub_template_workflow_fetch(monkeypatch, json.dumps({"nodes": []}).encode()) + + # Inject a requiresCustomNodes entry into the fixture on disk for this test. + import json as _json + + cats = _json.loads(Path(gallery_file).read_text()) + cats[0]["templates"][1]["requiresCustomNodes"] = ["ComfyUI-SEEDVR2"] # image_z_image + Path(gallery_file).write_text(_json.dumps(cats)) + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["custom_nodes_required"] == ["ComfyUI-SEEDVR2"] + + +# --------------------------------------------------------------------------- +# Hardening — malformed gallery / workflow inputs must not crash +# --------------------------------------------------------------------------- + + +def test_cache_path_never_escapes_templates_dir(monkeypatch, tmp_path): + # A gallery entry name carrying path separators / traversal must be encoded so + # the cache file stays strictly under gallery/templates (path-traversal guard). + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + base = tmp_path / "comfy-cli" / "gallery" / "templates" + for evil in ["../../etc/passwd", "a/b/c", "..", "sub/dir/name"]: + p = templates_cmd._template_workflow_cache_path(evil).resolve() + assert base.resolve() in p.parents, f"{evil!r} escaped to {p}" + + +def test_iter_workflow_nodes_tolerates_non_dict_definitions(): + # A truthy non-dict `definitions` (valid JSON, e.g. a list) must not raise. + wf = {"nodes": [{"id": 1, "type": "A"}], "definitions": ["not", "a", "dict"]} + types = templates_cmd._collect_node_class_types(wf) + assert types == ["A"] + + +def test_iter_workflow_nodes_tolerates_non_list_nodes(): + # Truthy non-list `nodes` / subgraph `nodes` must not raise TypeError. + wf = {"nodes": "oops", "definitions": {"subgraphs": [{"nodes": 5}]}} + assert templates_cmd._collect_model_requirements(wf) == [] + assert templates_cmd._collect_node_class_types(wf) == [] + + +def test_collect_models_skips_empty_name_requirement(): + # A model ref with a directory but no name is unmatchable; keeping it would + # produce a phantom empty-name "missing" model. + wf = {"nodes": [{"id": 1, "type": "A", "properties": {"models": [{"name": "", "directory": "checkpoints"}]}}]} + assert templates_cmd._collect_model_requirements(wf) == [] + + +def test_as_str_list_coerces_scalar_and_junk(): + assert templates_cmd._as_str_list(["a", "b"]) == ["a", "b"] + assert templates_cmd._as_str_list("solo") == ["solo"] # NOT split into chars + assert templates_cmd._as_str_list(None) == [] + assert templates_cmd._as_str_list(42) == [] + + +def test_check_present_when_required_name_carries_subfolder(gallery_file, tmp_path, monkeypatch): + # A model ref whose OWN name carries a subfolder (SDXL/model.safetensors) must + # still match a basename folder listing — both sides get normalized. + _force_json_renderer() + _no_local_server(monkeypatch) + wf = { + "nodes": [ + { + "id": 1, + "type": "CheckpointLoaderSimple", + "properties": {"models": [{"name": "SDXL/model.safetensors", "directory": "checkpoints"}]}, + } + ] + } + _stub_template_workflow_fetch(monkeypatch, json.dumps(wf).encode()) + _stub_folder_listing(monkeypatch, {"checkpoints": ["model.safetensors"]}) + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["verdict"] == "runnable" + + +def test_check_api_source_stays_index_when_object_info_finds_nothing(gallery_file, tmp_path, monkeypatch): + # image_flux2 is API-by-index. A reachable object_info scan that finds no api_node + # must NOT overwrite the source to object_info (it wasn't the signal's origin). + from comfy_cli.cql import engine + + _force_json_renderer() + graph = engine.Graph.from_object_info( + {"KSampler": {"input": {}, "output": [], "output_name": [], "api_node": False}} + ) + _stub_object_info(monkeypatch, graph) + _stub_template_workflow_fetch(monkeypatch, json.dumps({"nodes": [{"id": 1, "type": "KSampler"}]}).encode()) + + result = _run_check(gallery_file, "image_flux2", tmp_path, monkeypatch) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["verdict"] == "api-required" + assert env["data"]["api"]["source"] == "index" + assert env["data"]["api"]["api_nodes"] == [] + + +def test_check_invalid_utf8_workflow_surfaces_invalid_json(gallery_file, tmp_path, monkeypatch): + # Invalid UTF-8 in the cached/fetched body raises UnicodeDecodeError (not a + # subclass of JSONDecodeError) — it must be caught as a clean error, not crash. + _force_json_renderer() + _no_local_server(monkeypatch) + _stub_template_workflow_fetch(monkeypatch, b"\xff\xfe not valid utf-8") + + result = _run_check(gallery_file, "image_z_image", tmp_path, monkeypatch) + assert result.exit_code != 0 + env = _envelope(result.output) + assert env["error"]["code"] == "template_workflow_invalid_json"