* Rename agnes-metadata.json to marketplace-metadata.json
Curated marketplace enrichment file (.claude-plugin/agnes-metadata.json)
becomes marketplace-metadata.json. Clean cut, no fallback — curators of
upstream marketplace repos must rename the file on their side.
Python API renames mirror the file rename: read_agnes_metadata →
read_marketplace_metadata, AGNES_METADATA_REL → MARKETPLACE_METADATA_REL,
AGNES_METADATA_MAX_BYTES → MARKETPLACE_METADATA_MAX_BYTES. Synth Claude
Code marketplace strip rule (.agnes/** + the metadata file) follows the
new filename.
* Marketplace detail polish: window cover + 715:310 aspect + helper alignment
- Plugin & item (skill/agent) detail hero: 160x160 square cover replaced
with a macOS-style window frame (3 traffic-light dots + titlebar label
showing the entity name). Body is constrained to 715:310 so curator-
uploaded covers no longer crop to a square. Window is 380px wide; meta
column and absolutely-positioned top-right install/remove actions stay
put. Fallback when no cover_photo_url (translucent gradient + PL/SK/AG
initials) is unchanged, just inside the window body.
- Inner skill/agent cards in the plugin detail's Internal structure
section adopt the same 715:310 aspect (was fixed 78px tall). No window
chrome on inner cards — just the matching proportions so covers read
consistently across hero, grid tiles, and listing cards.
- Curated nested item helper text ("This skill is part of ... — add the
bundle to your stack to use it") now stacks UNDER the "Open parent
plugin" button instead of being a side-by-side flex sibling in the
actions-row. Added align-self: flex-end so the 260px helper box
anchors at the right edge of the 300px actions column, matching the
button's right edge.
* Marketplace My tab: surface the same category + type filters as Flea
- Frontend: mp-cat-row and mp-type-row now show on tab=my (previously
hidden — type was flea-only, category was flea/curated-only). Curated
browse stays plugin-only and continues to hide the type pills.
fetchOne() sends the `type` param for tab=my too, so the items
endpoint's existing my-branch filter actually receives it.
- Backend categories endpoint, tab=my branch: when the type filter is
set to skill/agent, skip counting curated subscriptions. Curated
plugins are always type='plugin', so they wouldn't survive the items
endpoint's type filter; including them in the category counts made
the pill numbers overstate what users could actually see in the
grid. type=None or type='plugin' keeps the previous behaviour.
- CHANGELOG entry under [Unreleased].
* Marketplace plugin detail: render rich content from marketplace-metadata.json
Adds five optional plugin-level fields to marketplace-metadata.json and
renders them on the curated plugin detail page + listing card:
* display_name — friendly h1 / listing-card name / mac-window titlebar
label (overrides the technical plugin id)
* tagline — punchy 1-line value prop for the hero subtitle and the
listing card description (replacing the verbose marketplace.json
description on cards)
* description — multi-paragraph markdown body, server-side rendered
through markdown-it-py and sanitized through nh3 with a
description-scoped allowlist (no iframes / no raw HTML / no
javascript: links). Powers the "What it does" panel.
* use_cases[] — {title, description, prompt} entries that render as a
3-column "When to use it" card grid; each card shows the literal
prompt as a code chip so users can copy-paste into Claude Code.
* sample_interaction — {user, assistant} dialog rendered in a Claude
Code-style dark Catppuccin Mocha transcript panel: monospace user
row with a green ">" prompt indicator + sans-serif assistant body
with markdown formatting (peach bold, yellow italic, pink inline
code, mantle-dark fenced code blocks).
All five fields are optional; UI sections only render when populated,
so plugins without enrichment look identical to before. Fields are
read on-demand from the working tree (cached by mtime per marketplace
slug) so curator edits land at the next request without waiting for
a sync cycle — same pattern as the existing inner-skill/agent
enrichment path. No DB schema bump.
Skill / agent rich-content rendering is deferred to a later phase
(needs a source-of-truth decision: extend plugin.yml? LLM-generate
from SKILL.md / agent.md?). The schema accepts the same fields at
skill/agent level today for forward compatibility but the UI ignores
them for now.
Also: stripped a stale `background-color: var(--bg)` from the global
`code` rule in style.css (was making inline code visually disappear
on the page background).
* Skill / agent detail: render rich content from marketplace-metadata.json
Brings the skill/agent detail pages to parity with the plugin detail
page. Same rich-content schema (display_name, tagline, description as
markdown, use_cases[], sample_interaction) plus two per-item additions:
* invocation — curator-provided literal command string. When set,
overrides the computed "<manifest_name>:<inner_name>" chip and
cleanly supports both "/" skill prefix and "@" agent prefix (the
hardcoded "/" in the chip markup is hidden when the curator provides
the invocation, so /grpn-eng:query <q> and @grpn-eng:cto-architect
both render correctly).
* when_to_use — markdown disambiguation block ("Use this for X. For
similar Y, see /other-skill") rendered into a new "When to use this"
panel below the Example section.
Skill / agent category is now per-item overridable in
marketplace-metadata.json. When absent, the API keeps the parent
plugin's category as the badge so existing items don't lose their
category until curators opt in to per-item categorization.
The new "Example" Q&A panel uses the same Claude Code-style dark
Catppuccin Mocha transcript treatment as the plugin detail —
monospace user row with a green ">" prompt indicator + sans-serif
assistant body with markdown formatting.
All new fields are optional and read on-demand from the working tree.
Skills / agents whose marketplace-metadata.json doesn't carry rich
content render exactly the same way they did before (frontmatter
description + computed slash command + cover from existing v32
enrichment). No DB schema bump.
* Fix TypeError in skill / agent detail when curator sets per-item category
`curated_skill_detail` and `curated_agent_detail` were passing both
`**parent` (from `_curated_inner_parent_fields`, which returns the
parent plugin's category as a fallback) and `**enrichment` (from
`_curated_inner_enrichment`, which returns the per-item category
override when the curator set one) into `InnerDetailResponse(...)`.
Python function-call kwargs unpacking with overlapping keys raises
`TypeError: got multiple values for keyword argument 'category'`
— it doesn't merge like a literal dict does. The bug only surfaced
when the marketplace-metadata.json carried a `category` field at
skill / agent level (curator opting into per-item categorization);
items without that override hit the endpoint cleanly because only
parent provided the key.
Fix: build `merged = {**parent, **enrichment}` first (literal-dict
syntax DOES merge, with the right-hand-side winning) and unpack the
merged dict. Curator override still wins via the merge order, and
the same pattern is future-proof for any other field that lands in
both layers later.
Plus a regression test in test_marketplace_metadata.py asserting
that the inner-resolver carries `category` for downstream merging.
* Marketplace detail: tolerate partial curator JSON
Server constructed UseCase / SampleInteraction via raw dict indexing
(uc["title"], sample["assistant"]), so a curator commit missing any
required Pydantic field crashed the whole plugin / skill / agent detail
endpoint with a 500. Route both constructions through _safe_use_case /
_safe_sample_interaction helpers — partial input silently drops the
malformed card / section instead of breaking the page.
Regression test in test_marketplace_api.py covers the three shapes:
use_case missing a key, use_case with an empty string, and
sample_interaction with only user (no assistant). Sibling rich fields
still render.
* Address PR-251 review (must-fixes + S2/S3 polish) + release-cut 0.50.0
Five must-fixes from the review pass (3 from @cvrysanek's two-stage
review, 2 from my independent pass), plus the 0.50.0 release-cut as the
last commit on this PR per CLAUDE.md (CLAUDE.md "Release-cut belongs
to the PR" rule added in v0.49.1).
Must-fixes
----------
1. Cache eviction: bounded LRU instead of per-marketplace predicate.
The previous predicate (`k[0] == marketplace_id and k[1] != mtime_ns`)
only swept stale entries for the CURRENT marketplace; with N>100
distinct marketplaces each holding one mtime key, the cap silently
failed and memory grew linearly. Replaced with OrderedDict-backed
bounded LRU at cap=256, drop oldest insert on overflow.
Cache stress test pinned in test_marketplace_metadata.py.
2. Render CPU cap: per-field byte cap on description / when_to_use /
sample_interaction.assistant via MARKETPLACE_METADATA_FIELD_MAX_BYTES
(= 64 KiB). Without this, a 1 MiB curator markdown body × QPS =
curator-controlled CPU burn through pure-Python markdown-it-py.
Truncation respects UTF-8 boundaries and logs a warning so the
curator sees the cap fire on the next sync. Test for cap +
UTF-8-boundary preservation.
3. Inner-detail bypassed the metadata cache. _curated_inner_enrichment,
_curated_inner_cover, and curated_detail all called
read_marketplace_metadata directly, defeating the mtime cache the
plugin listing already shared. Routed all three through
_read_metadata_cached so skill/agent detail hits are O(1) re-parses
per marketplace per mtime instead of O(QPS).
4. Truthy-vs-presence trap in plugin/inner enrichment merge. API-layer
writers used `if resolved.get(k):` which silently dropped any
future falsy-but-valid resolver field (bool featured=False, int
priority=0, str category=''). Switched to presence check
(`if k in resolved`) so the resolver is the authority on field
presence; `{**parent, **enrichment}` merge respects whatever the
resolver decided to ship.
5. Vendor-agnostic OSS cleanup. Removed operator-specific token
references (/grpn-eng:, @grpn-eng:, .foundryai/) from
src/marketplace_metadata.py docstring, app/web/templates/
marketplace_item_detail.html JS comment, docs/curated-marketplace-
format.md, and tests/test_marketplace_metadata.py fixtures. Replaced
with generic /my-plugin:tool / @my-agent:role / .example/ placeholders.
CHANGELOG
---------
- New "### Fixed (PR #251 follow-ups)" section documenting all 4
code-side must-fixes
- New "### Internal" section noting the vendor cleanup + new tests
- BREAKING bullet for the file rename now covers operator-side
migration: running instances see plugin enrichment disappear from
the UI until upstream curator renames + nightly sync overwrites the
working tree; POST /api/marketplaces/{id}/sync forces refresh sooner
- Stripped /grpn-eng: leaks from the existing skill/agent rich-content
bullet
Tests
-----
128 targeted tests pass (test_marketplace_metadata, test_marketplace_api,
test_marketplace, test_markdown_render, test_marketplace_synth_strip,
test_marketplace_filter). New tests added:
- 6 XSS regression tests on render_safe (javascript:/data:/vbscript:
schemes via autolink, reference link, and mixed-case + positive
http/https/mailto + noopener noreferrer rel)
- 3 byte-cap tests (truncation + UTF-8 boundary + under-cap pass-through)
- 1 cache eviction stress test (>256 marketplaces -> bounded at cap)
- 1 truthy-vs-presence resolver-contract test
Release-cut
-----------
- pyproject.toml 0.49.1 -> 0.50.0 (minor; BREAKING file rename per
pre-1.0 CHANGELOG note: "breaking changes called out under Changed
or Removed with the BREAKING marker")
- CHANGELOG [Unreleased] -> [0.50.0] - 2026-05-12, new empty
[Unreleased] on top.
---------
Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
494 lines
20 KiB
Python
494 lines
20 KiB
Python
"""Nightly sync of marketplace git repos onto the data volume.
|
|
|
|
Each row in the `marketplace_registry` DuckDB table is cloned (first run)
|
|
or fast-forwarded (subsequent runs) into ${DATA_DIR}/marketplaces/<slug>/.
|
|
FastAPI reads the working copies via the filesystem — this module has no
|
|
HTTP surface.
|
|
|
|
Callable from:
|
|
- the scheduler (in-process, daily 03:00 UTC) via sync_marketplaces()
|
|
- the admin API (POST /api/marketplaces/{id}/sync) via sync_one()
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
from urllib.parse import urlparse, urlunparse
|
|
|
|
from app.utils import get_marketplace_cache_dir, get_marketplaces_dir
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
GIT_TIMEOUT_SEC = 300
|
|
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
_lock = threading.Lock()
|
|
|
|
PLUGIN_MANIFEST_REL = Path(".claude-plugin") / "marketplace.json"
|
|
|
|
|
|
class MarketplaceNotFound(Exception):
|
|
"""Raised when a marketplace id is not present in the registry."""
|
|
|
|
|
|
def is_valid_slug(slug: str) -> bool:
|
|
return bool(_SLUG_RE.match(slug or ""))
|
|
|
|
|
|
def _authenticated_url(repo_url: str, token: str) -> str:
|
|
"""Inject a PAT into an HTTPS URL as the x-access-token user.
|
|
|
|
Non-HTTPS URLs (file://, ssh://, http://) and empty tokens pass through
|
|
unchanged. Result is only ever used as a git remote — never logged.
|
|
"""
|
|
if not token:
|
|
return repo_url
|
|
parts = urlparse(repo_url)
|
|
if parts.scheme != "https" or not parts.hostname:
|
|
return repo_url
|
|
netloc = f"x-access-token:{token}@{parts.hostname}"
|
|
if parts.port:
|
|
netloc = f"{netloc}:{parts.port}"
|
|
return urlunparse(
|
|
(parts.scheme, netloc, parts.path, parts.params, parts.query, parts.fragment)
|
|
)
|
|
|
|
|
|
def _redact(s: str, token: str) -> str:
|
|
return s.replace(token, "***") if token and s else s
|
|
|
|
|
|
def _run_git(
|
|
args: List[str], cwd: Optional[Path] = None
|
|
) -> subprocess.CompletedProcess:
|
|
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
|
return subprocess.run(
|
|
["git", *args],
|
|
cwd=str(cwd) if cwd else None,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=GIT_TIMEOUT_SEC,
|
|
check=True,
|
|
)
|
|
|
|
|
|
def _sync_spec(spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Perform the clone/update for a single marketplace spec.
|
|
|
|
Raises RuntimeError on git failure (with token-redacted message).
|
|
Raises ValueError on invalid slug.
|
|
"""
|
|
slug = (spec.get("id") or "").strip()
|
|
name = spec.get("name") or slug
|
|
url = (spec.get("url") or "").strip()
|
|
branch = (spec.get("branch") or "").strip() or None
|
|
token_env = (spec.get("token_env") or "").strip()
|
|
token = os.environ.get(token_env, "") if token_env else ""
|
|
|
|
if not is_valid_slug(slug):
|
|
raise ValueError(
|
|
f"marketplace id {slug!r} invalid (must match [a-z0-9][a-z0-9_-]{{0,63}})"
|
|
)
|
|
if not url:
|
|
raise ValueError(f"marketplace {slug!r}: url is required")
|
|
|
|
target = get_marketplaces_dir() / slug
|
|
auth_url = _authenticated_url(url, token)
|
|
is_git = (target / ".git").is_dir()
|
|
action = "update" if is_git else "clone"
|
|
|
|
try:
|
|
if not is_git:
|
|
if target.exists():
|
|
shutil.rmtree(target)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
clone_args = ["clone", "--depth", "1"]
|
|
if branch:
|
|
clone_args += ["--branch", branch]
|
|
clone_args += [auth_url, str(target)]
|
|
_run_git(clone_args)
|
|
else:
|
|
_run_git(["remote", "set-url", "origin", auth_url], cwd=target)
|
|
ref = branch or "HEAD"
|
|
_run_git(["fetch", "--depth", "1", "origin", ref], cwd=target)
|
|
_run_git(["reset", "--hard", "FETCH_HEAD"], cwd=target)
|
|
sha = _run_git(["rev-parse", "HEAD"], cwd=target).stdout.strip()
|
|
except subprocess.CalledProcessError as e:
|
|
stderr = _redact(e.stderr or "", token).strip()
|
|
raise RuntimeError(f"git {action} failed: {stderr}") from None
|
|
except subprocess.TimeoutExpired:
|
|
raise RuntimeError(f"git {action} timed out after {GIT_TIMEOUT_SEC}s") from None
|
|
|
|
logger.info("marketplace %s %s -> %s", slug, action, sha)
|
|
return {"id": slug, "name": name, "action": action, "commit": sha, "path": str(target)}
|
|
|
|
|
|
def read_plugins(slug: str) -> List[Dict[str, Any]]:
|
|
"""Read the plugin list from a cloned marketplace's manifest.
|
|
|
|
Returns the `plugins` array from `.claude-plugin/marketplace.json` at
|
|
the root of the working copy. Returns an empty list if the manifest
|
|
is missing, unreadable, or has no plugins. Malformed JSON is logged
|
|
and treated as empty — a broken manifest must not take the sync
|
|
operation down.
|
|
"""
|
|
if not is_valid_slug(slug):
|
|
raise ValueError(f"invalid slug: {slug!r}")
|
|
manifest = get_marketplaces_dir() / slug / PLUGIN_MANIFEST_REL
|
|
if not manifest.is_file():
|
|
return []
|
|
try:
|
|
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as e:
|
|
logger.warning("marketplace %s: unreadable manifest %s: %s", slug, manifest, e)
|
|
return []
|
|
plugins = data.get("plugins") if isinstance(data, dict) else None
|
|
if not isinstance(plugins, list):
|
|
return []
|
|
return [p for p in plugins if isinstance(p, dict) and p.get("name")]
|
|
|
|
|
|
def _refresh_plugin_cache(slug: str) -> int:
|
|
"""Reload plugins from disk into marketplace_plugins. Returns plugin count.
|
|
|
|
Failures here are logged but never re-raised: the primary sync result
|
|
(git commit) has already succeeded at this point and must still be
|
|
reported.
|
|
|
|
Two-channel read:
|
|
|
|
* ``.claude-plugin/marketplace.json`` (the Claude Code spec) is the
|
|
authoritative source for plugin existence, source spec, and the bare
|
|
Claude Code-shaped metadata.
|
|
* ``.claude-plugin/marketplace-metadata.json`` (Agnes-only) supplies cover
|
|
photo, video URL, doc links, and category overrides per plugin. Missing
|
|
file → no enrichment, plugins still cached at the bare shape.
|
|
|
|
External URLs referenced from marketplace-metadata are fed through the asset
|
|
mirror (`src.marketplace_asset_mirror.sync_assets`) before the DB write
|
|
so the persisted ``cover_photo_url`` / ``doc_links`` already point at the
|
|
final served URL. Mirror failures degrade gracefully — failed external
|
|
URLs surface as plain external links in the served data, never as 404s.
|
|
"""
|
|
from src.marketplace_asset_mirror import sync_assets
|
|
from src.marketplace_metadata import (
|
|
collect_all_external_urls,
|
|
read_marketplace_metadata,
|
|
resolve_plugin_metadata,
|
|
)
|
|
from src.marketplace_urls import (
|
|
internal_asset_url,
|
|
internal_doc_url,
|
|
mirrored_url,
|
|
)
|
|
from src.repositories.marketplace_plugins import MarketplacePluginsRepository
|
|
|
|
try:
|
|
plugins = read_plugins(slug)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("marketplace %s: plugin read failed: %s", slug, e)
|
|
return 0
|
|
|
|
repo_root = get_marketplaces_dir() / slug
|
|
metadata = read_marketplace_metadata(repo_root)
|
|
|
|
# Resolve per-plugin enrichment + collect every external URL the mirror
|
|
# needs to fetch this round. Internal references skip the mirror.
|
|
resolved_per_plugin: Dict[str, Dict[str, Any]] = {}
|
|
fetch_requests: List[tuple] = []
|
|
for p in plugins:
|
|
name = (p.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
resolved = resolve_plugin_metadata(metadata, name)
|
|
resolved_per_plugin[name] = resolved
|
|
# collect_all_external_urls walks plugin + skills + agents so the
|
|
# mirror caches every external URL, not just plugin-level. Inner-
|
|
# level skill/agent detail enrichment then looks up entries in the
|
|
# same manifest at request time.
|
|
for kind, url in collect_all_external_urls(metadata, name):
|
|
fetch_requests.append((name, kind, url))
|
|
|
|
# Mirror external URLs (best-effort — see _refresh_asset_mirror docstring
|
|
# for the failure-mode contract). Keyed by ``(plugin_name, url)`` so two
|
|
# plugins referencing the same external URL each get their own served
|
|
# path under their own plugin subdir — RBAC-safe (a user with grant on
|
|
# plugin B never receives a URL pointing under plugin A's tree).
|
|
served_url_for: Dict[Tuple[str, str], Optional[str]] = {}
|
|
mirror_status: Dict[Tuple[str, str], str] = {}
|
|
if fetch_requests:
|
|
cache_dir = get_marketplace_cache_dir() / slug
|
|
try:
|
|
report = sync_assets(cache_dir=cache_dir, requests=fetch_requests)
|
|
for (plugin_name, url), entry in report.entries.items():
|
|
mirror_status[(plugin_name, url)] = entry.status
|
|
if entry.status == "ok" and entry.local:
|
|
# /mirrored/{key} where key encodes plugin + kind + filename.
|
|
# The local relpath is already in the right shape.
|
|
served_url_for[(plugin_name, url)] = mirrored_url(
|
|
slug, entry.plugin_name, entry.local.split("/", 1)[1],
|
|
) if "/" in entry.local else mirrored_url(
|
|
slug, entry.plugin_name, entry.local,
|
|
)
|
|
else:
|
|
# Failed / rejected → fall back to the original URL so the
|
|
# frontend can still link out (b1).
|
|
served_url_for[(plugin_name, url)] = url
|
|
logger.info(
|
|
"marketplace %s: mirror summary fetched=%d not_modified=%d "
|
|
"failed=%d rejected=%d removed=%d",
|
|
slug, report.fetched, report.not_modified, report.failed,
|
|
report.rejected, report.removed,
|
|
)
|
|
except Exception as e: # noqa: BLE001 — never abort the sync
|
|
logger.warning("marketplace %s: asset mirror crashed: %s", slug, e)
|
|
# On total mirror crash, every (plugin, url) pair falls back to
|
|
# the original URL so the strict-drop logic downstream marks it
|
|
# as un-served and removes it from the rendered metadata.
|
|
for plugin_name, _, url in fetch_requests:
|
|
served_url_for.setdefault((plugin_name, url), url)
|
|
mirror_status.setdefault((plugin_name, url), "failed_recent")
|
|
|
|
# Compose the enriched plugin dicts and write to DB.
|
|
enriched: List[Dict[str, Any]] = []
|
|
for p in plugins:
|
|
name = (p.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
merged = dict(p)
|
|
resolved = resolved_per_plugin.get(name) or {}
|
|
|
|
# Direct serialization to avoid mutating the frozen DocLinkRef.
|
|
# External docs that mirroring rejected (e.g. HTML page, oversized,
|
|
# SSRF-blocked) or failed to fetch (404, timeout, never seen before)
|
|
# are DROPPED from the served list entirely. Internal links whose
|
|
# path doesn't exist on disk at sync time are dropped too. This
|
|
# matches the operator contract: any doc_link Agnes can't deliver
|
|
# as a real downloadable PDF / Markdown / plain text is treated as
|
|
# if it weren't in marketplace-metadata.json at all.
|
|
serialized_links: List[Dict[str, str]] = []
|
|
for link in resolved.get("doc_links") or []:
|
|
if not hasattr(link, "kind"):
|
|
continue
|
|
if link.kind == "internal":
|
|
local_path = repo_root / link.path
|
|
if not local_path.is_file():
|
|
logger.info(
|
|
"marketplace %s plugin=%s: dropping internal doc_link "
|
|
"%r (file not found in working tree)",
|
|
slug, name, link.path,
|
|
)
|
|
continue
|
|
serialized_links.append({
|
|
"name": link.name,
|
|
"url": internal_doc_url(slug, name, link.path),
|
|
})
|
|
continue
|
|
# external — keep ONLY when the mirror succeeded for THIS plugin.
|
|
status = mirror_status.get((name, link.url), "")
|
|
served = served_url_for.get((name, link.url))
|
|
if status != "ok" or not served or served == link.url:
|
|
logger.info(
|
|
"marketplace %s plugin=%s: dropping external doc_link "
|
|
"%r (mirror status=%s)",
|
|
slug, name, link.url, status or "no_attempt",
|
|
)
|
|
continue
|
|
serialized_links.append({
|
|
"name": link.name,
|
|
"url": served,
|
|
})
|
|
|
|
# Build the column-shape payload inline — strict-drop semantics
|
|
# need access to mirror status + on-disk existence per reference,
|
|
# which is decided here rather than in a generic translator.
|
|
# Internal covers are dropped when the file doesn't exist on disk;
|
|
# external covers are dropped when mirroring rejected/failed (no
|
|
# successful mirror means the served URL is the original external
|
|
# URL, which we don't trust to render — better to fall through to
|
|
# the gradient placeholder).
|
|
if isinstance(resolved.get("cover_photo_ref"), tuple):
|
|
kind, target = resolved["cover_photo_ref"]
|
|
if kind == "internal":
|
|
local_path = repo_root / target
|
|
if local_path.is_file():
|
|
merged["cover_photo_url"] = internal_asset_url(
|
|
slug, name, target,
|
|
)
|
|
else:
|
|
logger.info(
|
|
"marketplace %s plugin=%s: dropping internal "
|
|
"cover_photo %r (file not found in working tree)",
|
|
slug, name, target,
|
|
)
|
|
elif kind == "external":
|
|
status = mirror_status.get((name, target), "")
|
|
served = served_url_for.get((name, target))
|
|
if status == "ok" and served and served != target:
|
|
merged["cover_photo_url"] = served
|
|
else:
|
|
logger.info(
|
|
"marketplace %s plugin=%s: dropping external "
|
|
"cover_photo %r (mirror status=%s)",
|
|
slug, name, target, status or "no_attempt",
|
|
)
|
|
if "video_url" in resolved:
|
|
merged["video_url"] = resolved["video_url"]
|
|
if "category" in resolved:
|
|
# Override marketplace.json category when marketplace-metadata supplies one.
|
|
merged["category"] = resolved["category"]
|
|
if serialized_links:
|
|
merged["doc_links"] = serialized_links
|
|
|
|
enriched.append(merged)
|
|
|
|
conn = _get_conn()
|
|
try:
|
|
return MarketplacePluginsRepository(conn).replace_for_marketplace(slug, enriched)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("marketplace %s: plugin cache write failed: %s", slug, e)
|
|
return 0
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _get_conn():
|
|
"""Lazy import to avoid circular deps with src.db at module load."""
|
|
from src.db import get_system_db
|
|
|
|
return get_system_db()
|
|
|
|
|
|
def sync_one(marketplace_id: str) -> Dict[str, Any]:
|
|
"""Sync a single marketplace by id. Updates registry row with result.
|
|
|
|
Raises:
|
|
MarketplaceNotFound: if the id isn't registered.
|
|
RuntimeError: if the git operation failed (token-redacted).
|
|
"""
|
|
from src.repositories.marketplace_registry import MarketplaceRegistryRepository
|
|
|
|
conn = _get_conn()
|
|
try:
|
|
repo = MarketplaceRegistryRepository(conn)
|
|
spec = repo.get(marketplace_id)
|
|
if not spec:
|
|
raise MarketplaceNotFound(marketplace_id)
|
|
|
|
with _lock:
|
|
try:
|
|
result = _sync_spec(spec)
|
|
repo.update_sync_status(
|
|
marketplace_id,
|
|
commit_sha=result["commit"],
|
|
synced_at=datetime.now(timezone.utc),
|
|
)
|
|
result["plugin_count"] = _refresh_plugin_cache(marketplace_id)
|
|
return result
|
|
except (RuntimeError, ValueError) as e:
|
|
repo.update_sync_status(
|
|
marketplace_id,
|
|
synced_at=datetime.now(timezone.utc),
|
|
error=str(e),
|
|
)
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def sync_marketplaces() -> Dict[str, Any]:
|
|
"""Sync every registered marketplace. Empty registry = no-op.
|
|
|
|
One failure does not abort the rest; errors are collected per entry.
|
|
"""
|
|
from src.repositories.marketplace_registry import MarketplaceRegistryRepository
|
|
|
|
conn = _get_conn()
|
|
try:
|
|
repo = MarketplaceRegistryRepository(conn)
|
|
specs = repo.list_all()
|
|
finally:
|
|
conn.close()
|
|
|
|
if not specs:
|
|
logger.info("No marketplaces registered; nothing to sync.")
|
|
return {"synced": [], "errors": []}
|
|
|
|
synced: List[Dict[str, Any]] = []
|
|
errors: List[Dict[str, Any]] = []
|
|
|
|
with _lock:
|
|
for spec in specs:
|
|
slug = spec.get("id", "")
|
|
try:
|
|
result = _sync_spec(spec)
|
|
# Persist success per entry on its own connection (short-lived).
|
|
conn = _get_conn()
|
|
try:
|
|
MarketplaceRegistryRepository(conn).update_sync_status(
|
|
slug,
|
|
commit_sha=result["commit"],
|
|
synced_at=datetime.now(timezone.utc),
|
|
)
|
|
finally:
|
|
conn.close()
|
|
result["plugin_count"] = _refresh_plugin_cache(slug)
|
|
synced.append(result)
|
|
except (RuntimeError, ValueError) as e:
|
|
err = {"id": slug, "error": str(e)}
|
|
errors.append(err)
|
|
logger.error("marketplace %s sync failed: %s", slug, e)
|
|
conn = _get_conn()
|
|
try:
|
|
MarketplaceRegistryRepository(conn).update_sync_status(
|
|
slug,
|
|
synced_at=datetime.now(timezone.utc),
|
|
error=str(e),
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
# Drop cached etags so the next /marketplace.zip request re-hashes against
|
|
# the freshly-synced content rather than waiting for TTL expiry. Late
|
|
# import: keeps src.marketplace decoupled from the FastAPI app surface.
|
|
if synced:
|
|
try:
|
|
from app.marketplace_server import packager as _packager
|
|
_packager.invalidate_etag_cache()
|
|
except ImportError:
|
|
pass
|
|
|
|
return {"synced": synced, "errors": errors}
|
|
|
|
|
|
def delete_marketplace_dir(slug: str) -> bool:
|
|
"""Remove on-disk working copy + asset-mirror cache for a marketplace.
|
|
|
|
Two directories are scoped per marketplace slug:
|
|
* ``${DATA_DIR}/marketplaces/<slug>/`` — git working copy
|
|
* ``${DATA_DIR}/marketplace-cache/<slug>/`` — external-asset mirror
|
|
|
|
Removed together so a re-registered slug starts from a clean cache.
|
|
Returns True iff at least one of the directories existed and was removed.
|
|
"""
|
|
if not is_valid_slug(slug):
|
|
raise ValueError(f"invalid slug: {slug!r}")
|
|
removed = False
|
|
work_path = get_marketplaces_dir() / slug
|
|
if work_path.exists():
|
|
shutil.rmtree(work_path)
|
|
removed = True
|
|
cache_path = get_marketplace_cache_dir() / slug
|
|
if cache_path.exists():
|
|
shutil.rmtree(cache_path, ignore_errors=True)
|
|
removed = True
|
|
return removed
|