Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ history.

### Fixed

- Search matches names however their separators are written. `comfy models search --text` on Cloud now finds `minimax_h3_ref2va_….safetensors` for `MiniMax H3` and `wan2.2_t2v_….safetensors` for `wan 2.2`: every word must appear, with `- _ . space` ignored, the rule local search already used. Before, Cloud sent the whole query as one substring and a multi-word name found nothing. `comfy templates ls` (and `templates get --where`) match `--model`, `--name` and `--provider` the same way, so `--model "Wan 2.2"` finds the gallery's `Wan2.2`, and `comfy nodes search` finds `LTX 2.5` nodes for `LTX-2.5`. On Cloud a word the filename splits (`sdxl` for `sd_xl_base_1.0.safetensors`) is found too: when the server's narrowed answer has no match, search scans without the server filter. A scan stops after 10,000 assets, and `total_is_lower_bound` is then true.

- `comfy build push --dry-run` now runs the push's registry-pin check, so a pin the builder cannot place fails the dry run with the push's own `build_registry_pin_missing` error instead of passing it. A version the registry has Flagged was the common case: the dry run passed, and the push was refused only after the plan had been disclosed. The check asks the builder's snapshot importer to place each public pack, a read that writes nothing, and the dry run still uploads and saves nothing. The `--json` payload of a push and a dry run carries `public_pins_checked`, the number of public pins the builder placed (0 when the spec has none and the builder was not asked), so a script can tell a checked pin set from a spec with nothing to check. A spec that pins a public pack now needs sign-in for its dry run (`build_not_signed_in` otherwise); a spec whose packs are all local still sends no request. Push and dry run also refuse when the registry did not answer for a pack (the importer's `unverifiedPins`), instead of treating the unchecked pin as resolved; refuse with `build_spec_invalid`, before sending anything, a `repository` that is not a public GitHub address (`https://github.com/<org>/<repo>`, `git@github.com:<org>/<repo>.git` or `ssh://git@github.com/<org>/<repo>`), so one that carries a user name or token, does not parse, or names another host never leaves the machine; and report a builder response that times out or drops mid-body as `build_builder_error` instead of an internal error.

- Parallel build and deploy requests rejected on an old access token now adopt a peer's refreshed token under the shared refresh lock instead of rotating it again. Each rejected request retries at most once. If session recovery still fails while a refreshable sign-in remains stored, the watch reports that the remote work may continue instead of telling you to sign in again. `comfy build release create --watch`, `comfy deploy up`, `promote`, `rollback` and `comfy deploy status --watch` now end with the new code `watch_auth_lost` and exit code 75, as `deploy_watch_lost` does. Its hint names the command that reads the release or deployment again. A 401 before anything was accepted, on a workspace API key, or when no refreshable sign-in remains still reports `build_not_signed_in` or `deploy_not_signed_in`.
Expand Down
107 changes: 94 additions & 13 deletions comfy_cli/command/models/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,35 +424,106 @@ def _cloud_search(
type_: str | None,
limit: int,
include_public: bool,
) -> tuple[list[dict[str, Any]], int]:
"""Page through /api/assets, returning (rows, total).
) -> tuple[list[dict[str, Any]], int, bool]:
"""Page through /api/assets, returning ``(rows, total, total_is_lower_bound)``.

The ``include_tags`` parameter is comma-separated, not repeated — cloud
rejects the repeated-key form (``include_tags=a&include_tags=b``) per its
OpenAPI spec (``style: form, explode: false``).

``name_contains`` is one case-insensitive substring over the whole name, so
a query the user writes with spaces ("MiniMax H3") can never match the
filename (``minimax_h3_…``). Search therefore follows the local rule
(``_name_matches``: every word, separators ignored) in up to two passes:

1. Ask the server for the query's longest fragment, the cheap narrowing
(for one plain word that is the whole query, sent unchanged), and check
every word client-side.
2. If that finds nothing, the fragment may be split in the filename
(``sdxl`` vs ``sd_xl_base_1.0``), which the server cannot see. Scan
without ``name_contains`` and match client-side.

A scan stops after ``_CLOUD_TOKEN_SCAN_MAX_PAGES`` pages; when it stops
with more assets left, ``total`` counts only what it saw and the third
value is True.
"""
tags = ["models"]
if type_:
tags.append(_TYPE_TO_FOLDER.get(type_, type_))
params: dict[str, Any] = {
base: dict[str, Any] = {
"include_tags": ",".join(tags),
"limit": min(max(limit, 1), 500),
"include_public": str(include_public).lower(),
}
if text:
params["name_contains"] = text
tokens = _search_tokens(text)
if tokens is None:
# No filter: one page, the server's own total.
body = _assets_page(target, {**base, "limit": min(max(limit, 1), 500)})
rows = [_asset_to_row(a) for a in body.get("assets") or [] if isinstance(a, dict)]
return rows, int(body.get("total") or len(rows)), False
if not tokens:
# Only separators: a filter nothing satisfies (same as local).
return [], 0, False

fragments = [f for f in _NAME_SEPARATORS.split(text.lower()) if f]
plain_word = len(fragments) == 1 and fragments[0] == text.lower().strip()
if plain_word:
# The server's substring match is already exact for a plain word:
# one page, and its total, when it finds anything.
body = _assets_page(target, {**base, "name_contains": text, "limit": min(max(limit, 1), 500)})
rows = [_asset_to_row(a) for a in body.get("assets") or [] if isinstance(a, dict)]
if rows:
return rows, int(body.get("total") or len(rows)), False
else:
found = _scan_assets(target, {**base, "name_contains": max(fragments, key=len)}, tokens, limit)
if found[1]:
return found
return _scan_assets(target, base, tokens, limit)


def _scan_assets(
target, params: dict[str, Any], tokens: list[tuple[str, str]], limit: int
) -> tuple[list[dict[str, Any]], int, bool]:
"""Page ``/api/assets`` with ``params``, keeping assets whose name or display name passes ``tokens``.

Returns ``(rows, total, total_is_lower_bound)``: ``rows`` holds at most
``limit`` matches, ``total`` counts every match seen, and the flag is True
when the page cap stopped the scan before the server ran out.
"""
rows: list[dict[str, Any]] = []
total = 0
offset = 0
for _ in range(_CLOUD_TOKEN_SCAN_MAX_PAGES):
body = _assets_page(target, {**params, "limit": _CLOUD_TOKEN_SCAN_PAGE, "offset": offset})
page = [a for a in body.get("assets") or [] if isinstance(a, dict)]
for asset in page:
names = (asset.get("name") or "", asset.get("display_name") or "")
if any(isinstance(n, str) and n and _name_matches(n, tokens) for n in names):
total += 1
if len(rows) < max(limit, 0):
rows.append(_asset_to_row(asset))
offset += len(page)
if not page or not body.get("has_more"):
return rows, total, False
return rows, total, True


# A scan pages until the server runs out, because the server's filter (one
# fragment, or none) returns rows the other words still reject. Capped so a
# broad query cannot page forever: 20 × 500 = 10k assets per scan.
_CLOUD_TOKEN_SCAN_PAGE = 500
_CLOUD_TOKEN_SCAN_MAX_PAGES = 20

qs = urllib.parse.urlencode(params)
url = target.url("assets") + "?" + qs

def _assets_page(target, params: dict[str, Any]) -> dict[str, Any]:
"""GET one page of ``/api/assets`` with ``params`` and return the JSON object."""
url = target.url("assets") + "?" + urllib.parse.urlencode(params)
body = _http_get_json(url, target)
if not isinstance(body, dict):
# Callers route JSONDecodeError to an envelope error; a non-object
# top-level body (list/scalar) must surface the same way, not crash
# on body.get().
raise json.JSONDecodeError(f"unexpected response shape (not an object) from {url}", "", 0)
assets = body.get("assets") or []
rows = [_asset_to_row(a) for a in assets if isinstance(a, dict)]
return rows, int(body.get("total") or len(rows))
return body


def _local_folder_names(target) -> list[str]:
Expand Down Expand Up @@ -670,9 +741,12 @@ def search_cmd(

try:
if target.is_cloud:
rows, total = _cloud_search(target, text=text, type_=type_, limit=limit, include_public=include_public)
rows, total, total_is_lower_bound = _cloud_search(
target, text=text, type_=type_, limit=limit, include_public=include_public
)
else:
rows, total = _local_search(target, text=text, type_=type_, limit=limit)
total_is_lower_bound = False
except urllib.error.HTTPError as e:
_emit_http_error(
e,
Expand All @@ -693,6 +767,9 @@ def search_cmd(
"mode": "cloud" if target.is_cloud else "local",
"filters": {"text": text, "type": type_, "include_public": include_public if target.is_cloud else None},
"total": total,
# True when the cloud scan hit its page cap: `total` counts only the
# assets it reached, so more matches may exist. Narrow the query.
"total_is_lower_bound": total_is_lower_bound,
"shown": len(rows),
"rows": rows,
}
Expand All @@ -715,7 +792,11 @@ def search_cmd(
sanitize_markup((r["source_url"] or "")[:48]),
)
renderer.console().print(tbl)
tail = f" (of {total} total)" if total != len(rows) else ""
tail = (
f" (of {total}{'+' if total_is_lower_bound else ''} total)"
if total != len(rows) or total_is_lower_bound
else ""
)
rprint(f"[dim]{len(rows)} model(s){tail} ({payload['mode']})[/dim]")
knowledge.attach(
payload,
Expand Down
31 changes: 27 additions & 4 deletions comfy_cli/command/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import difflib
import json
import re
import shlex
from typing import Annotated, Any

Expand Down Expand Up @@ -559,6 +560,28 @@ def show_cmd(
# ---------------------------------------------------------------------------


# Separators inside a word: 'LTX-2.5', 'ltx_2.5' and 'LTX 2.5' name one model.
_WORD_SEPARATORS = re.compile(r"[-_. ]+")


def _all_tokens_in(tokens: list[str], field: str) -> bool:
"""Every token is in ``field``, as written or with ``- _ . space`` removed
from both sides. A token made only of separators keeps its raw meaning, so
``-`` stays a literal rather than squashing to a match-everything ``""``."""
squashed_field = None
for t in tokens:
if t in field:
continue
st = _WORD_SEPARATORS.sub("", t)
if not st:
return False
if squashed_field is None:
squashed_field = _WORD_SEPARATORS.sub("", field)
if st not in squashed_field:
return False
return True


@app.command(
"search",
help=(
Expand Down Expand Up @@ -650,13 +673,13 @@ def search_cmd(
score = 0
elif name_l.startswith(q) or name_l.startswith(q_joined):
score = 1
elif all(t in name_l for t in tokens):
elif _all_tokens_in(tokens, name_l):
score = 2
elif all(t in display_l for t in tokens):
elif _all_tokens_in(tokens, display_l):
score = 3
elif all(t in cat_l for t in tokens):
elif _all_tokens_in(tokens, cat_l):
score = 4
elif all(t in blob for t in tokens):
elif _all_tokens_in(tokens, blob):
score = 5
else:
continue
Expand Down
26 changes: 23 additions & 3 deletions comfy_cli/command/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import json
import os
import re
import subprocess
import sys
import time
Expand Down Expand Up @@ -489,15 +490,34 @@ def _matches(
return False
if tag and not any((t or "").lower() == tag.lower() for t in row.get("tags") or []):
return False
if model and not any(model.lower() in (m or "").lower() for m in row.get("models") or []):
if model and not any(_contains(m, model) for m in row.get("models") or []):
return False
if provider and not any(provider.lower() in (p or "").lower() for p in row.get("providers") or []):
if provider and not any(_contains(p, provider) for p in row.get("providers") or []):
return False
if name_sub and name_sub.lower() not in (row.get("name") or "").lower():
if name_sub and not _contains(row.get("name"), name_sub):
return False
return True


# The gallery and its readers put separators in different places: the gallery
# says "Wan2.2" and "Z-Image-Turbo", a request says "Wan 2.2" and "z image".
_SEPARATORS = re.compile(r"[-_. ]+")


def _contains(haystack: Any, needle: str) -> bool:
"""Case-insensitive substring match that also ignores ``- _ . space``.

The raw match is tried first so a needle made only of separators (which
squashes to nothing) still means itself rather than "match everything".
"""
hay = (haystack or "").lower() if isinstance(haystack, str) else ""
n = needle.lower()
if n in hay:
return True
squashed = _SEPARATORS.sub("", n)
return bool(squashed) and squashed in _SEPARATORS.sub("", hay)


# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions comfy_cli/schemas/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
}
},
"total": { "type": "integer", "description": "Total items before capping (list-folder, search)." },
"total_is_lower_bound": { "type": "boolean", "description": "search: true when the Cloud scan stopped at its page cap, so `total` counts only the assets it reached and more matches may exist. Narrow the query. Always false on local." },
"shown": { "type": "integer", "description": "Number of items in the response after capping." },
"count": { "type": "integer", "description": "Count of folders (list-folders)." },
"folder": { "type": "string", "description": "Folder name that was listed (list-folder)." },
Expand Down
Loading
Loading