comfy-cli 1.7.3__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.3 → comfy_cli-1.8.0}/PKG-INFO +1 -1
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/cmdline.py +17 -1
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/code_search.py +46 -14
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/command.py +11 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/install.py +138 -9
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/run.py +103 -23
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/git_utils.py +20 -3
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/registry/config_parser.py +203 -5
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/tracking.py +9 -1
- comfy_cli-1.8.0/comfy_cli/workflow_to_api.py +1369 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/PKG-INFO +1 -1
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/SOURCES.txt +1 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/pyproject.toml +1 -1
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/LICENSE +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/README.md +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/__init__.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/__main__.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/__init__.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/__init__.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/bisect_custom_nodes.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/cm_cli_util.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/github/pr_info.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/launch.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/models/models.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/pr_command.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/config_manager.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/constants.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/cuda_detect.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/env_checker.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/file_utils.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/logging.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/pr_cache.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/registry/__init__.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/registry/api.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/registry/types.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/resolve_python.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/standalone.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/typing.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/ui.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/update.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/utils.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/uv.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/workspace_manager.py +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/dependency_links.txt +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/entry_points.txt +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/requires.txt +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/top_level.txt +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/setup.cfg +0 -0
- {comfy_cli-1.7.3 → comfy_cli-1.8.0}/tests/test_file_utils_network.py +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)"),
|
|
@@ -975,6 +975,17 @@ def validate_node_for_publishing():
|
|
|
975
975
|
# Perform some validation logic here
|
|
976
976
|
typer.echo("Validating node configuration...")
|
|
977
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)
|
|
978
989
|
|
|
979
990
|
# Run security checks first
|
|
980
991
|
typer.echo("Running security checks...")
|
|
@@ -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
|
|
|
@@ -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())
|
|
@@ -28,9 +28,16 @@ def git_checkout_tag(repo_path: str, tag: str) -> bool:
|
|
|
28
28
|
"""
|
|
29
29
|
Checkout a specific Git tag in the given repository.
|
|
30
30
|
|
|
31
|
+
Skips the network ``git fetch --tags`` when the tag already exists locally.
|
|
32
|
+
This avoids a redundant round-trip on the happy path (the caller usually
|
|
33
|
+
just cloned the repo or just ran a fetch via the resolver) and lets offline
|
|
34
|
+
installs proceed when the tag is already cached. Only when the tag is
|
|
35
|
+
absent locally do we attempt to fetch — and a failed fetch in that case is
|
|
36
|
+
a real, unrecoverable error (``check=True`` surfaces it as before).
|
|
37
|
+
|
|
31
38
|
:param repo_path: Path to the Git repository
|
|
32
39
|
:param tag: The tag to checkout
|
|
33
|
-
:return:
|
|
40
|
+
:return: True if the checkout succeeds, False if any git command failed.
|
|
34
41
|
"""
|
|
35
42
|
original_dir = os.getcwd()
|
|
36
43
|
try:
|
|
@@ -38,8 +45,18 @@ def git_checkout_tag(repo_path: str, tag: str) -> bool:
|
|
|
38
45
|
|
|
39
46
|
os.chdir(repo_path)
|
|
40
47
|
|
|
41
|
-
#
|
|
42
|
-
|
|
48
|
+
# Skip the network fetch when the tag is already present locally.
|
|
49
|
+
tag_present_locally = (
|
|
50
|
+
subprocess.run(
|
|
51
|
+
["git", "rev-parse", "--verify", f"refs/tags/{tag}"],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
check=False,
|
|
55
|
+
).returncode
|
|
56
|
+
== 0
|
|
57
|
+
)
|
|
58
|
+
if not tag_present_locally:
|
|
59
|
+
subprocess.run(["git", "fetch", "--tags"], check=True, capture_output=True, text=True)
|
|
43
60
|
|
|
44
61
|
# Checkout the specified tag
|
|
45
62
|
subprocess.run(["git", "checkout", tag], check=True, capture_output=True, text=True)
|