comfy-cli 1.7.2__tar.gz → 1.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/PKG-INFO +1 -1
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/cmdline.py +17 -1
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/code_search.py +46 -14
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/command.py +23 -8
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/install.py +138 -9
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/models/models.py +11 -2
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/run.py +103 -23
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/constants.py +1 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/file_utils.py +38 -1
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/git_utils.py +20 -3
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/registry/config_parser.py +222 -6
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/tracking.py +12 -3
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/ui.py +4 -2
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/uv.py +7 -2
- comfy_cli-1.8.0/comfy_cli/workflow_to_api.py +1369 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/PKG-INFO +1 -1
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/SOURCES.txt +1 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/pyproject.toml +1 -1
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/tests/test_file_utils_network.py +332 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/LICENSE +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/README.md +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/__init__.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/__main__.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/__init__.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/__init__.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/bisect_custom_nodes.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/cm_cli_util.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/github/pr_info.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/launch.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/pr_command.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/config_manager.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/cuda_detect.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/env_checker.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/logging.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/pr_cache.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/registry/__init__.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/registry/api.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/registry/types.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/resolve_python.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/standalone.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/typing.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/update.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/utils.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/workspace_manager.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/dependency_links.txt +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/entry_points.txt +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/requires.txt +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/top_level.txt +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.8.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: comfy-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: A CLI tool for installing and using ComfyUI.
|
|
5
5
|
Maintainer-email: Yoland Yan <yoland@drip.art>, James Kwon <hongilkwon316@gmail.com>, Robin Huang <robin@drip.art>, "Dr.Lt.Data" <dr.lt.data@gmail.com>
|
|
6
6
|
License: GPL-3.0-only
|
|
@@ -446,7 +446,23 @@ def run(
|
|
|
446
446
|
int | None,
|
|
447
447
|
typer.Option(help="The timeout in seconds for the workflow execution."),
|
|
448
448
|
] = 30,
|
|
449
|
+
api_key: Annotated[
|
|
450
|
+
str | None,
|
|
451
|
+
typer.Option(
|
|
452
|
+
"--api-key",
|
|
453
|
+
envvar="COMFY_API_KEY",
|
|
454
|
+
help=(
|
|
455
|
+
"Comfy API key for API Nodes (Partner Nodes). "
|
|
456
|
+
"Embedded in the prompt body as extra_data.api_key_comfy_org on POST /prompt. "
|
|
457
|
+
"For scripting, prefer the COMFY_API_KEY environment variable so the secret "
|
|
458
|
+
"stays out of shell history."
|
|
459
|
+
),
|
|
460
|
+
),
|
|
461
|
+
] = None,
|
|
449
462
|
):
|
|
463
|
+
if api_key:
|
|
464
|
+
api_key = api_key.strip() or None
|
|
465
|
+
|
|
450
466
|
config = ConfigManager()
|
|
451
467
|
|
|
452
468
|
if host:
|
|
@@ -470,7 +486,7 @@ def run(
|
|
|
470
486
|
if not port:
|
|
471
487
|
port = 8188
|
|
472
488
|
|
|
473
|
-
run_inner.execute(workflow, host, port, wait, verbose, local_paths, timeout)
|
|
489
|
+
run_inner.execute(workflow, host, port, wait, verbose, local_paths, timeout, api_key=api_key)
|
|
474
490
|
|
|
475
491
|
|
|
476
492
|
def validate_comfyui(_env_checker):
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
|
+
import sys
|
|
5
6
|
from typing import Annotated
|
|
6
7
|
from urllib.parse import quote
|
|
7
8
|
|
|
@@ -20,12 +21,19 @@ DEFAULT_COUNT = 20
|
|
|
20
21
|
REQUEST_TIMEOUT = 30
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
_TYPE_FILTER_RE = re.compile(r"(^|\s)type:")
|
|
25
|
+
|
|
26
|
+
|
|
23
27
|
def _build_query(query: str, repo: str | None, count: int) -> str:
|
|
24
28
|
parts = []
|
|
25
29
|
if repo:
|
|
26
30
|
if "/" not in repo:
|
|
27
31
|
repo = f"Comfy-Org/{repo}"
|
|
28
32
|
parts.append(f"repo:^{re.escape(repo)}$")
|
|
33
|
+
# Only default to file matches when the user hasn't specified their own
|
|
34
|
+
# type: filter — otherwise respect whatever they passed (e.g. type:commit).
|
|
35
|
+
if not _TYPE_FILTER_RE.search(query):
|
|
36
|
+
parts.append("type:file")
|
|
29
37
|
parts.append(f"count:{count}")
|
|
30
38
|
parts.append(query)
|
|
31
39
|
return " ".join(parts)
|
|
@@ -56,20 +64,21 @@ def _format_results(search: dict) -> list[dict]:
|
|
|
56
64
|
commit_hash = (default_branch.get("target") or {}).get("commit", {}).get("oid", "")
|
|
57
65
|
ref = commit_hash or branch_name
|
|
58
66
|
|
|
67
|
+
encoded_path = quote(file_path, safe="/")
|
|
68
|
+
file_url = f"https://github.com/{clean_name}/blob/{ref}/{encoded_path}"
|
|
69
|
+
|
|
59
70
|
line_matches = result.get("lineMatches") or []
|
|
60
71
|
matches = []
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
line = m.get("lineNumber", 0) + 1
|
|
66
|
-
preview = m.get("preview", "").rstrip()
|
|
67
|
-
matches.append({"line": line, "preview": preview, "url": f"{base_url}#L{line}"})
|
|
72
|
+
for m in line_matches:
|
|
73
|
+
line = m.get("lineNumber", 0) + 1
|
|
74
|
+
preview = m.get("preview", "").rstrip()
|
|
75
|
+
matches.append({"line": line, "preview": preview, "url": f"{file_url}#L{line}"})
|
|
68
76
|
|
|
69
77
|
formatted.append(
|
|
70
78
|
{
|
|
71
79
|
"repository": clean_name,
|
|
72
80
|
"file": file_path,
|
|
81
|
+
"file_url": file_url,
|
|
73
82
|
"branch": branch_name,
|
|
74
83
|
"commit": commit_hash,
|
|
75
84
|
"matches": matches,
|
|
@@ -96,19 +105,34 @@ def _print_results(results: list[dict], stats: dict, json_output: bool) -> None:
|
|
|
96
105
|
console.print("[yellow]No results found.[/yellow]")
|
|
97
106
|
return
|
|
98
107
|
|
|
108
|
+
# Use raw isatty() rather than Rich's console.is_terminal: Rich treats
|
|
109
|
+
# FORCE_COLOR=1 / TTY_COMPATIBLE=1 as terminal-capable even when stdout
|
|
110
|
+
# is redirected, but OSC 8 escapes in a piped stream defeat the whole
|
|
111
|
+
# point of this branch (hiding URLs from humans, exposing them to AI).
|
|
112
|
+
is_tty = sys.stdout.isatty()
|
|
113
|
+
|
|
99
114
|
for file_result in results:
|
|
115
|
+
repo = file_result["repository"]
|
|
116
|
+
path = file_result["file"]
|
|
117
|
+
file_url = file_result["file_url"]
|
|
118
|
+
|
|
100
119
|
header = Text()
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
120
|
+
if is_tty:
|
|
121
|
+
# Humans: clickable OSC 8 hyperlink, URL hidden from visible output.
|
|
122
|
+
header.append(f"{repo} / {path}", style=f"bold cyan link {file_url}")
|
|
123
|
+
else:
|
|
124
|
+
# Non-TTY (pipes, AI agents): print the raw URL once per file so
|
|
125
|
+
# agents can synthesize #L<line> anchors themselves.
|
|
126
|
+
header.append(f"{repo} / {path}\n")
|
|
127
|
+
header.append(f" {file_url}", style="dim")
|
|
104
128
|
console.print(header)
|
|
105
129
|
|
|
106
130
|
for match in file_result["matches"]:
|
|
107
|
-
line_text = Text()
|
|
108
|
-
|
|
131
|
+
line_text = Text(" ")
|
|
132
|
+
line_style = f"green link {match['url']}" if is_tty else "green"
|
|
133
|
+
line_text.append(f"L{match['line']:>5}", style=line_style)
|
|
109
134
|
line_text.append(f" {match['preview']}")
|
|
110
135
|
console.print(line_text)
|
|
111
|
-
console.print(f" [dim]{match['url']}[/dim]")
|
|
112
136
|
|
|
113
137
|
console.print()
|
|
114
138
|
|
|
@@ -121,7 +145,15 @@ def _print_results(results: list[dict], stats: dict, json_output: bool) -> None:
|
|
|
121
145
|
@app.callback(invoke_without_command=True)
|
|
122
146
|
@tracking.track_command()
|
|
123
147
|
def code_search(
|
|
124
|
-
query: Annotated[
|
|
148
|
+
query: Annotated[
|
|
149
|
+
str,
|
|
150
|
+
typer.Argument(
|
|
151
|
+
help=(
|
|
152
|
+
"Search query (supports Sourcegraph syntax). Defaults to file matches; "
|
|
153
|
+
"pass your own `type:` filter (e.g. `type:commit`) to override."
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
],
|
|
125
157
|
repo: Annotated[
|
|
126
158
|
str | None,
|
|
127
159
|
typer.Option("--repo", "-r", help="Filter by repository (e.g. ComfyUI, Comfy-Org/ComfyUI)"),
|
|
@@ -18,6 +18,7 @@ from comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli, find_cm_c
|
|
|
18
18
|
from comfy_cli.config_manager import ConfigManager
|
|
19
19
|
from comfy_cli.constants import NODE_ZIP_FILENAME
|
|
20
20
|
from comfy_cli.file_utils import (
|
|
21
|
+
DownloadException,
|
|
21
22
|
download_file,
|
|
22
23
|
extract_package_as_zip,
|
|
23
24
|
upload_file_to_signed_url,
|
|
@@ -170,13 +171,11 @@ def execute_install_script(repo_path):
|
|
|
170
171
|
if os.path.exists(requirements_path):
|
|
171
172
|
print("Install: pip packages")
|
|
172
173
|
python = resolve_workspace_python(workspace_manager.workspace_path)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if package_name.strip() != "":
|
|
179
|
-
try_install_script(repo_path, install_cmd)
|
|
174
|
+
# Absolute path so pip doesn't re-resolve it against cwd=repo_path
|
|
175
|
+
# in try_install_script, which would double the path if repo_path
|
|
176
|
+
# is relative.
|
|
177
|
+
install_cmd = [python, "-m", "pip", "install", "-r", os.path.abspath(requirements_path)]
|
|
178
|
+
try_install_script(repo_path, install_cmd)
|
|
180
179
|
|
|
181
180
|
if os.path.exists(install_script_path):
|
|
182
181
|
print("Install: install script")
|
|
@@ -976,6 +975,17 @@ def validate_node_for_publishing():
|
|
|
976
975
|
# Perform some validation logic here
|
|
977
976
|
typer.echo("Validating node configuration...")
|
|
978
977
|
config = extract_node_configuration()
|
|
978
|
+
if config is None:
|
|
979
|
+
raise typer.Exit(code=1)
|
|
980
|
+
|
|
981
|
+
if not config.project.version:
|
|
982
|
+
# Escape `[` chars so rich doesn't parse `[tool.comfy.version]` and
|
|
983
|
+
# `["version"]` as markup tags; `]` doesn't need escaping.
|
|
984
|
+
print(
|
|
985
|
+
"[red]Error: project version is empty. Set `project.version` in pyproject.toml, "
|
|
986
|
+
r'or configure `\[tool.comfy.version].path` if using `dynamic = \["version"]`.[/red]'
|
|
987
|
+
)
|
|
988
|
+
raise typer.Exit(code=1)
|
|
979
989
|
|
|
980
990
|
# Run security checks first
|
|
981
991
|
typer.echo("Running security checks...")
|
|
@@ -1168,7 +1178,12 @@ def registry_install(
|
|
|
1168
1178
|
|
|
1169
1179
|
local_filename = node_specific_path / f"{node_id}-{node_version.version}.zip"
|
|
1170
1180
|
logging.debug(f"Start downloading the node {node_id} version {node_version.version} to {local_filename}")
|
|
1171
|
-
|
|
1181
|
+
try:
|
|
1182
|
+
download_file(node_version.download_url, local_filename)
|
|
1183
|
+
except DownloadException as e:
|
|
1184
|
+
logging.error(f"Failed to download node {node_id} version {node_version.version}: {e}")
|
|
1185
|
+
ui.display_error_message(f"Failed to download the custom node {node_id}: {e}")
|
|
1186
|
+
raise typer.Exit(code=1) from None
|
|
1172
1187
|
|
|
1173
1188
|
# Extract the downloaded archive to the custom_node directory on the workspace.
|
|
1174
1189
|
logging.debug(f"Start extracting the node {node_id} version {node_version.version} to {custom_nodes_path}")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import platform
|
|
3
|
+
import re
|
|
3
4
|
import subprocess
|
|
4
5
|
import sys
|
|
5
6
|
from typing import TypedDict
|
|
@@ -189,7 +190,7 @@ def execute(
|
|
|
189
190
|
|
|
190
191
|
if version != "nightly":
|
|
191
192
|
try:
|
|
192
|
-
checkout_stable_comfyui(version=version, repo_dir=repo_dir)
|
|
193
|
+
checkout_stable_comfyui(version=version, repo_dir=repo_dir, url=url)
|
|
193
194
|
except GitHubRateLimitError as e:
|
|
194
195
|
rprint(f"[bold red]Error checking out ComfyUI version: {e}[/bold red]")
|
|
195
196
|
sys.exit(1)
|
|
@@ -434,17 +435,136 @@ def clone_comfyui(url: str, repo_dir: str):
|
|
|
434
435
|
subprocess.run(["git", "clone", url, repo_dir], check=True)
|
|
435
436
|
|
|
436
437
|
|
|
437
|
-
def
|
|
438
|
+
def _resolve_latest_tag_from_local(repo_dir: str) -> tuple[str | None, bool]:
|
|
439
|
+
"""Pick the highest stable semver tag from the local clone.
|
|
440
|
+
|
|
441
|
+
Returns ``(tag, fetch_ok)``:
|
|
442
|
+
- ``tag``: the tag string (e.g. ``"v0.20.1"``), or ``None`` when no stable
|
|
443
|
+
semver tag is available (or the directory isn't a git repo).
|
|
444
|
+
- ``fetch_ok``: whether ``git fetch --tags`` succeeded. Callers can use this
|
|
445
|
+
to distinguish "no new releases" from "couldn't reach the remote", which
|
|
446
|
+
changes the right messaging when falling back to the API.
|
|
447
|
+
|
|
448
|
+
Pre-release tags (e.g. ``v1.2.3-rc1``) are skipped to mirror GitHub's
|
|
449
|
+
``releases/latest`` behavior. Note that this picks the highest semver tag,
|
|
450
|
+
which may differ from the release a maintainer has manually marked as
|
|
451
|
+
"Latest" on GitHub — acceptable trade-off given the unauthenticated API's
|
|
452
|
+
60 req/hr per-IP cap; users can pin a specific version with ``--version``
|
|
453
|
+
if needed.
|
|
454
|
+
|
|
455
|
+
``git_checkout_tag`` skips its own ``git fetch --tags`` when the resolved
|
|
456
|
+
tag is already present locally, so on the happy path we fetch exactly once
|
|
457
|
+
here. Crucially, that also lets the cached-tag offline path succeed: if
|
|
458
|
+
fetch above fails (``fetch_ok=False``) but a tag is found from disk,
|
|
459
|
+
``git_checkout_tag`` will not retry the unreachable fetch.
|
|
460
|
+
"""
|
|
461
|
+
fetch_ok = False
|
|
462
|
+
try:
|
|
463
|
+
completed = subprocess.run(
|
|
464
|
+
["git", "-C", repo_dir, "fetch", "--tags", "--quiet"],
|
|
465
|
+
capture_output=True,
|
|
466
|
+
text=True,
|
|
467
|
+
timeout=30,
|
|
468
|
+
)
|
|
469
|
+
fetch_ok = completed.returncode == 0
|
|
470
|
+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
|
471
|
+
# Tolerate timeout / OS-level failure; fall through with whatever's on disk.
|
|
472
|
+
pass
|
|
473
|
+
|
|
474
|
+
try:
|
|
475
|
+
result = subprocess.run(
|
|
476
|
+
["git", "-C", repo_dir, "tag", "--list"],
|
|
477
|
+
capture_output=True,
|
|
478
|
+
text=True,
|
|
479
|
+
check=True,
|
|
480
|
+
timeout=10,
|
|
481
|
+
)
|
|
482
|
+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
|
483
|
+
return None, fetch_ok
|
|
484
|
+
|
|
485
|
+
best: tuple[semver.VersionInfo, str] | None = None
|
|
486
|
+
for line in result.stdout.splitlines():
|
|
487
|
+
tag = line.strip()
|
|
488
|
+
if not tag:
|
|
489
|
+
continue
|
|
490
|
+
try:
|
|
491
|
+
parsed = semver.VersionInfo.parse(tag.lstrip("v"))
|
|
492
|
+
except ValueError:
|
|
493
|
+
continue
|
|
494
|
+
if parsed.prerelease:
|
|
495
|
+
continue
|
|
496
|
+
if best is None or parsed > best[0]:
|
|
497
|
+
best = (parsed, tag)
|
|
498
|
+
|
|
499
|
+
return (best[1] if best else None), fetch_ok
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
_GITHUB_REPO_RE = re.compile(
|
|
503
|
+
# `github.com[:/]<owner>/<repo>` with optional `.git` and optional setuptools-style
|
|
504
|
+
# `@branch` suffix (matching what ``clone_comfyui`` accepts via ``rsplit("@", 1)``).
|
|
505
|
+
# Branch names may contain slashes (`release/1.0`), so the `@<branch>` group is greedy
|
|
506
|
+
# to end-of-string. The repo segment forbids `@` and `/` to avoid eating those parts.
|
|
507
|
+
r"github\.com[/:]([^/\s]+)/([^/@\s]+?)(?:\.git)?(?:@.+)?/?$",
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _parse_github_owner_repo(url: str | None) -> tuple[str, str] | None:
|
|
512
|
+
"""Parse a GitHub repo URL into ``(owner, repo)``.
|
|
513
|
+
|
|
514
|
+
Handles the URL forms ``clone_comfyui`` accepts:
|
|
515
|
+
- ``https://github.com/owner/repo``
|
|
516
|
+
- ``https://github.com/owner/repo.git``
|
|
517
|
+
- ``https://github.com/owner/repo@branch`` (setuptools-style branch suffix)
|
|
518
|
+
- ``git@github.com:owner/repo`` (SSH form)
|
|
519
|
+
|
|
520
|
+
Returns ``None`` for empty input, local paths, or non-GitHub URLs (GitLab,
|
|
521
|
+
self-hosted, etc.) — the caller decides what to do with that.
|
|
522
|
+
"""
|
|
523
|
+
if not url:
|
|
524
|
+
return None
|
|
525
|
+
match = _GITHUB_REPO_RE.search(url)
|
|
526
|
+
return (match.group(1), match.group(2)) if match else None
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def checkout_stable_comfyui(version: str, repo_dir: str, url: str | None = None):
|
|
438
530
|
"""
|
|
439
531
|
Supports installing stable releases of Comfy (semantic versioning) or the 'latest' version.
|
|
532
|
+
|
|
533
|
+
For ``version="latest"`` we resolve the highest stable semver tag from the
|
|
534
|
+
local clone first to avoid burning the unauthenticated GitHub API budget
|
|
535
|
+
(60 req/hr per IP). The ``releases/latest`` API is only consulted when local
|
|
536
|
+
resolution turns up nothing.
|
|
537
|
+
|
|
538
|
+
The optional ``url`` is the install URL forwarded from ``execute``; it lets
|
|
539
|
+
the API fallback query the same repo we cloned from (forks included)
|
|
540
|
+
instead of always asking upstream. Non-GitHub URLs and missing URLs
|
|
541
|
+
fall back to ``comfyanonymous/ComfyUI`` so the prior behavior is preserved
|
|
542
|
+
for users who pass a local path or a non-GitHub remote.
|
|
440
543
|
"""
|
|
441
544
|
rprint(f"Looking for ComfyUI version '{version}'...")
|
|
442
545
|
if version == "latest":
|
|
443
|
-
|
|
444
|
-
if
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
546
|
+
tag, fetch_ok = _resolve_latest_tag_from_local(repo_dir)
|
|
547
|
+
if tag is None:
|
|
548
|
+
if not fetch_ok:
|
|
549
|
+
rprint(
|
|
550
|
+
"[yellow]Could not refresh tags from the remote (offline or auth failure); "
|
|
551
|
+
"trying GitHub API as a last resort.[/yellow]"
|
|
552
|
+
)
|
|
553
|
+
else:
|
|
554
|
+
rprint("[yellow]No stable release tags found locally; querying GitHub API.[/yellow]")
|
|
555
|
+
owner, repo = _parse_github_owner_repo(url) or ("comfyanonymous", "ComfyUI")
|
|
556
|
+
selected_release = get_latest_release(owner, repo)
|
|
557
|
+
if selected_release is None:
|
|
558
|
+
rprint(f"Error: No release found for version '{version}'.")
|
|
559
|
+
sys.exit(1)
|
|
560
|
+
tag = str(selected_release["tag"])
|
|
561
|
+
elif not fetch_ok:
|
|
562
|
+
# Tag list comes from a cached state — flag it so the user knows
|
|
563
|
+
# they may not be on the actual newest release.
|
|
564
|
+
rprint(
|
|
565
|
+
f"[yellow]Warning: could not refresh tags from remote; "
|
|
566
|
+
f"using cached tag {tag}. Re-run with network access to get the newest release.[/yellow]"
|
|
567
|
+
)
|
|
448
568
|
else:
|
|
449
569
|
# For specific versions, directly construct the tag (add 'v' prefix if needed)
|
|
450
570
|
tag = f"v{version}" if not version.startswith("v") else version
|
|
@@ -490,9 +610,18 @@ def get_latest_release(repo_owner: str, repo_name: str) -> GithubRelease | None:
|
|
|
490
610
|
|
|
491
611
|
data = response.json()
|
|
492
612
|
|
|
613
|
+
# Forks may use non-semver tags (e.g. "release-2026-04"); the caller
|
|
614
|
+
# only needs the raw tag string for git checkout, so let `version`
|
|
615
|
+
# fall back to None instead of crashing.
|
|
616
|
+
tag_name = data["tag_name"]
|
|
617
|
+
try:
|
|
618
|
+
parsed_version = semver.VersionInfo.parse(tag_name.lstrip("v"))
|
|
619
|
+
except ValueError:
|
|
620
|
+
parsed_version = None
|
|
621
|
+
|
|
493
622
|
return GithubRelease(
|
|
494
|
-
tag=
|
|
495
|
-
version=
|
|
623
|
+
tag=tag_name,
|
|
624
|
+
version=parsed_version,
|
|
496
625
|
download_url=data["zipball_url"],
|
|
497
626
|
)
|
|
498
627
|
|
|
@@ -8,6 +8,7 @@ from urllib.parse import parse_qs, unquote, urlparse
|
|
|
8
8
|
import requests
|
|
9
9
|
import typer
|
|
10
10
|
from rich import print
|
|
11
|
+
from rich.markup import escape
|
|
11
12
|
|
|
12
13
|
from comfy_cli import constants, tracking, ui
|
|
13
14
|
from comfy_cli.config_manager import ConfigManager
|
|
@@ -20,6 +21,8 @@ app = typer.Typer()
|
|
|
20
21
|
workspace_manager = WorkspaceManager()
|
|
21
22
|
config_manager = ConfigManager()
|
|
22
23
|
|
|
24
|
+
_CIVITAI_SUBDOMAIN_SUFFIXES = tuple(f".{h}" for h in constants.CIVITAI_ALLOWED_HOSTS)
|
|
25
|
+
|
|
23
26
|
|
|
24
27
|
model_path_map = {
|
|
25
28
|
"lora": "loras",
|
|
@@ -98,7 +101,7 @@ def check_civitai_url(url: str) -> tuple[bool, bool, int | None, int | None]:
|
|
|
98
101
|
try:
|
|
99
102
|
parsed = urlparse(url)
|
|
100
103
|
host = (parsed.hostname or "").lower()
|
|
101
|
-
if host
|
|
104
|
+
if host not in constants.CIVITAI_ALLOWED_HOSTS and not host.endswith(_CIVITAI_SUBDOMAIN_SUFFIXES):
|
|
102
105
|
return False, False, None, None
|
|
103
106
|
p_parts = [p for p in parsed.path.split("/") if p]
|
|
104
107
|
query = parse_qs(parsed.query)
|
|
@@ -354,7 +357,13 @@ def download(
|
|
|
354
357
|
print(f"Model downloaded successfully to: {output_path}")
|
|
355
358
|
else:
|
|
356
359
|
print(f"Start downloading URL: {url} into {local_filepath}")
|
|
357
|
-
|
|
360
|
+
try:
|
|
361
|
+
download_file(url, local_filepath, headers, downloader=resolved_downloader)
|
|
362
|
+
except DownloadException as e:
|
|
363
|
+
# escape() so a dynamic error message containing "[/]" or similar
|
|
364
|
+
# rich-markup syntax doesn't trigger MarkupError or get mis-rendered.
|
|
365
|
+
print(f"[bold red]{escape(str(e))}[/bold red]")
|
|
366
|
+
raise typer.Exit(code=1) from None
|
|
358
367
|
|
|
359
368
|
elapsed = time.monotonic() - start_time
|
|
360
369
|
print(f"Done in {_format_elapsed(elapsed)}")
|
|
@@ -14,28 +14,67 @@ from rich.progress import BarColumn, Column, Progress, Table, TimeElapsedColumn
|
|
|
14
14
|
from websocket import WebSocket, WebSocketException, WebSocketTimeoutException
|
|
15
15
|
|
|
16
16
|
from comfy_cli.env_checker import check_comfy_server_running
|
|
17
|
+
from comfy_cli.workflow_to_api import WorkflowConversionError, convert_ui_to_api
|
|
17
18
|
from comfy_cli.workspace_manager import WorkspaceManager
|
|
18
19
|
|
|
19
20
|
workspace_manager = WorkspaceManager()
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
workflow
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
def is_ui_workflow(workflow) -> bool:
|
|
24
|
+
return (
|
|
25
|
+
isinstance(workflow, dict)
|
|
26
|
+
and isinstance(workflow.get("nodes"), list)
|
|
27
|
+
and isinstance(workflow.get("links"), list)
|
|
28
|
+
)
|
|
28
29
|
|
|
29
|
-
# Try validating the first entry to ensure it has a node class property
|
|
30
|
-
node_id = next(iter(workflow))
|
|
31
|
-
node = workflow[node_id]
|
|
32
|
-
if "class_type" not in node:
|
|
33
|
-
return None
|
|
34
30
|
|
|
35
|
-
|
|
31
|
+
def _validate_api_workflow(workflow):
|
|
32
|
+
"""Return the workflow dict if it has the shape of API format, else None."""
|
|
33
|
+
if not isinstance(workflow, dict) or not workflow:
|
|
34
|
+
return None
|
|
35
|
+
node = workflow[next(iter(workflow))]
|
|
36
|
+
if not isinstance(node, dict) or "class_type" not in node:
|
|
37
|
+
return None
|
|
38
|
+
return workflow
|
|
36
39
|
|
|
37
40
|
|
|
38
|
-
def
|
|
41
|
+
def fetch_object_info(host: str, port: int, timeout: int) -> dict:
|
|
42
|
+
"""GET ``/object_info`` from the running ComfyUI server.
|
|
43
|
+
|
|
44
|
+
The response describes every loaded node class's input schema and is what
|
|
45
|
+
the converter uses to map widget values to input names, fill defaults, etc.
|
|
46
|
+
"""
|
|
47
|
+
url = f"http://{host}:{port}/object_info"
|
|
48
|
+
try:
|
|
49
|
+
with request.urlopen(url, timeout=timeout) as resp:
|
|
50
|
+
body = resp.read()
|
|
51
|
+
except urllib.error.HTTPError as e:
|
|
52
|
+
body = e.read().decode("utf-8", errors="replace").strip()
|
|
53
|
+
pprint(f"[bold red]Failed to fetch /object_info (HTTP {e.code}): {body[:500]}[/bold red]")
|
|
54
|
+
raise typer.Exit(code=1) from e
|
|
55
|
+
except urllib.error.URLError as e:
|
|
56
|
+
pprint(f"[bold red]Failed to fetch /object_info: {e.reason}[/bold red]")
|
|
57
|
+
raise typer.Exit(code=1) from e
|
|
58
|
+
except TimeoutError as e:
|
|
59
|
+
pprint(f"[bold red]Failed to fetch /object_info: timed out after {timeout}s[/bold red]")
|
|
60
|
+
raise typer.Exit(code=1) from e
|
|
61
|
+
try:
|
|
62
|
+
return json.loads(body)
|
|
63
|
+
except json.JSONDecodeError as e:
|
|
64
|
+
pprint("[bold red]Failed to fetch /object_info: server returned invalid JSON[/bold red]")
|
|
65
|
+
raise typer.Exit(code=1) from e
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def execute(
|
|
69
|
+
workflow: str,
|
|
70
|
+
host,
|
|
71
|
+
port,
|
|
72
|
+
wait=True,
|
|
73
|
+
verbose=False,
|
|
74
|
+
local_paths=False,
|
|
75
|
+
timeout=30,
|
|
76
|
+
api_key: str | None = None,
|
|
77
|
+
):
|
|
39
78
|
workflow_name = os.path.abspath(os.path.expanduser(workflow))
|
|
40
79
|
if not os.path.isfile(workflow):
|
|
41
80
|
pprint(
|
|
@@ -44,16 +83,51 @@ def execute(workflow: str, host, port, wait=True, verbose=False, local_paths=Fal
|
|
|
44
83
|
)
|
|
45
84
|
raise typer.Exit(code=1)
|
|
46
85
|
|
|
47
|
-
workflow = load_api_workflow(workflow)
|
|
48
|
-
|
|
49
|
-
if not workflow:
|
|
50
|
-
pprint("[bold red]Specified workflow does not appear to be an API workflow json file[/bold red]")
|
|
51
|
-
raise typer.Exit(code=1)
|
|
52
|
-
|
|
53
86
|
if not check_comfy_server_running(port, host):
|
|
54
87
|
pprint(f"[bold red]ComfyUI not running on specified address ({host}:{port})[/bold red]")
|
|
55
88
|
raise typer.Exit(code=1)
|
|
56
89
|
|
|
90
|
+
try:
|
|
91
|
+
with open(workflow_name, encoding="utf-8") as f:
|
|
92
|
+
raw_workflow = json.load(f)
|
|
93
|
+
except OSError as e:
|
|
94
|
+
pprint(f"[bold red]Unable to read workflow file: {e}[/bold red]")
|
|
95
|
+
raise typer.Exit(code=1) from e
|
|
96
|
+
except json.JSONDecodeError as e:
|
|
97
|
+
pprint(f"[bold red]Specified workflow file is not valid JSON: {e}[/bold red]")
|
|
98
|
+
raise typer.Exit(code=1) from e
|
|
99
|
+
|
|
100
|
+
if is_ui_workflow(raw_workflow):
|
|
101
|
+
pprint("[yellow]Detected UI-format workflow, converting to API format...[/yellow]")
|
|
102
|
+
object_info = fetch_object_info(host, port, timeout)
|
|
103
|
+
try:
|
|
104
|
+
workflow = convert_ui_to_api(raw_workflow, object_info)
|
|
105
|
+
except WorkflowConversionError as e:
|
|
106
|
+
pprint(f"[bold red]Workflow conversion failed: {e}[/bold red]")
|
|
107
|
+
raise typer.Exit(code=1) from e
|
|
108
|
+
except Exception as e:
|
|
109
|
+
# The converter is experimental; an unexpected crash here is a bug
|
|
110
|
+
# in our code, not user error. Show a clean message and a pointer.
|
|
111
|
+
pprint(
|
|
112
|
+
f"[bold red]Workflow conversion crashed unexpectedly: {type(e).__name__}: {e}[/bold red]\n"
|
|
113
|
+
"[yellow]The UI-to-API converter is experimental. Please report this at[/yellow]\n"
|
|
114
|
+
"[yellow] https://github.com/Comfy-Org/comfy-cli/issues[/yellow]\n"
|
|
115
|
+
"[yellow]and attach the workflow file if possible.[/yellow]"
|
|
116
|
+
)
|
|
117
|
+
if verbose:
|
|
118
|
+
import traceback
|
|
119
|
+
|
|
120
|
+
traceback.print_exc()
|
|
121
|
+
raise typer.Exit(code=1) from e
|
|
122
|
+
if not workflow:
|
|
123
|
+
pprint("[bold red]Workflow conversion produced no executable nodes[/bold red]")
|
|
124
|
+
raise typer.Exit(code=1)
|
|
125
|
+
else:
|
|
126
|
+
workflow = _validate_api_workflow(raw_workflow)
|
|
127
|
+
if not workflow:
|
|
128
|
+
pprint("[bold red]Specified workflow does not appear to be an API workflow json file[/bold red]")
|
|
129
|
+
raise typer.Exit(code=1)
|
|
130
|
+
|
|
57
131
|
progress = None
|
|
58
132
|
start = time.time()
|
|
59
133
|
if wait:
|
|
@@ -63,7 +137,7 @@ def execute(workflow: str, host, port, wait=True, verbose=False, local_paths=Fal
|
|
|
63
137
|
else:
|
|
64
138
|
print(f"Queuing workflow: {workflow_name}")
|
|
65
139
|
|
|
66
|
-
execution = WorkflowExecution(workflow, host, port, verbose, progress, local_paths, timeout)
|
|
140
|
+
execution = WorkflowExecution(workflow, host, port, verbose, progress, local_paths, timeout, api_key=api_key)
|
|
67
141
|
|
|
68
142
|
try:
|
|
69
143
|
if wait:
|
|
@@ -117,7 +191,7 @@ class ExecutionProgress(Progress):
|
|
|
117
191
|
|
|
118
192
|
|
|
119
193
|
class WorkflowExecution:
|
|
120
|
-
def __init__(self, workflow, host, port, verbose, progress, local_paths, timeout=30):
|
|
194
|
+
def __init__(self, workflow, host, port, verbose, progress, local_paths, timeout=30, api_key: str | None = None):
|
|
121
195
|
self.workflow = workflow
|
|
122
196
|
self.host = host
|
|
123
197
|
self.port = port
|
|
@@ -136,14 +210,20 @@ class WorkflowExecution:
|
|
|
136
210
|
self.prompt_id = None
|
|
137
211
|
self.ws = None
|
|
138
212
|
self.timeout = timeout
|
|
213
|
+
self.api_key = api_key
|
|
139
214
|
|
|
140
215
|
def connect(self):
|
|
141
216
|
self.ws = WebSocket()
|
|
142
217
|
self.ws.connect(f"ws://{self.host}:{self.port}/ws?clientId={self.client_id}")
|
|
143
218
|
|
|
144
219
|
def queue(self):
|
|
145
|
-
data = {"prompt": self.workflow, "client_id": self.client_id}
|
|
146
|
-
|
|
220
|
+
data: dict = {"prompt": self.workflow, "client_id": self.client_id}
|
|
221
|
+
if self.api_key:
|
|
222
|
+
data["extra_data"] = {"api_key_comfy_org": self.api_key}
|
|
223
|
+
req = request.Request(
|
|
224
|
+
f"http://{self.host}:{self.port}/prompt",
|
|
225
|
+
json.dumps(data).encode("utf-8"),
|
|
226
|
+
)
|
|
147
227
|
try:
|
|
148
228
|
resp = request.urlopen(req)
|
|
149
229
|
body = json.loads(resp.read())
|
|
@@ -47,6 +47,7 @@ CONFIG_KEY_UV_COMPILE_DEFAULT = "uv_compile_default"
|
|
|
47
47
|
|
|
48
48
|
CIVITAI_API_TOKEN_KEY = "civitai_api_token"
|
|
49
49
|
CIVITAI_API_TOKEN_ENV_KEY = "CIVITAI_API_TOKEN"
|
|
50
|
+
CIVITAI_ALLOWED_HOSTS: tuple[str, ...] = ("civitai.com", "civitai.red")
|
|
50
51
|
HF_API_TOKEN_KEY = "hf_api_token"
|
|
51
52
|
HF_API_TOKEN_ENV_KEY = "HF_API_TOKEN"
|
|
52
53
|
|