* 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>
879 lines
41 KiB
HTML
879 lines
41 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Curated Marketplaces — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
/* Override base.html's 800px .container cap for this wide table. */
|
|
.container:has(.marketplaces-page) { max-width: none; padding: 24px 16px; }
|
|
.marketplaces-page { max-width: 1400px; margin: 0 auto; padding: 0; }
|
|
.marketplaces-toolbar {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
gap: 16px; margin-bottom: 20px; flex-wrap: wrap;
|
|
}
|
|
.marketplaces-title { margin: 0; font-size: 22px; font-weight: 600; }
|
|
.marketplaces-search {
|
|
flex: 1; max-width: 360px;
|
|
padding: 8px 12px 8px 36px;
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
background: var(--surface, #fff) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>") no-repeat 12px center;
|
|
}
|
|
.marketplaces-search:focus { outline: 2px solid var(--primary, #6366f1); outline-offset: -1px; }
|
|
|
|
.marketplaces-table-wrap {
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 12px;
|
|
overflow-x: auto;
|
|
}
|
|
.marketplaces-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.marketplaces-table thead th {
|
|
text-align: left; padding: 12px 16px;
|
|
background: var(--border-light, #f9fafb);
|
|
border-bottom: 1px solid var(--border, #e5e7eb);
|
|
font-weight: 600; color: var(--text-secondary, #6b7280);
|
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px;
|
|
}
|
|
.marketplaces-table tbody td {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
|
vertical-align: middle;
|
|
}
|
|
.marketplaces-table tbody tr:last-child td { border-bottom: none; }
|
|
.marketplaces-table tbody tr:hover { background: var(--border-light, #fafafa); }
|
|
|
|
.mp-cell { display: flex; align-items: center; gap: 10px; }
|
|
.mp-avatar {
|
|
width: 32px; height: 32px; border-radius: 8px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: var(--primary-light, #eef2ff); color: var(--primary, #6366f1);
|
|
flex-shrink: 0;
|
|
}
|
|
.mp-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
.mp-meta .slug { font-weight: 500; color: var(--text-primary, #111827); font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
|
|
.mp-meta .name { font-size: 11px; color: var(--text-secondary, #6b7280); }
|
|
|
|
/* Curator cell — full name on top, email muted below. Mirrors the
|
|
Marketplace cell two-line treatment so visually paired. */
|
|
.mp-curator-cell { display: flex; flex-direction: column; gap: 2px; min-width: 0; max-width: 220px; }
|
|
.mp-curator-cell .name { font-size: 12px; color: var(--text-primary, #111827); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.mp-curator-cell .email { font-size: 11px; color: var(--text-secondary, #6b7280); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
.mp-url {
|
|
font-family: var(--font-mono, ui-monospace, monospace);
|
|
font-size: 12px; color: var(--text-secondary, #6b7280);
|
|
max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block;
|
|
vertical-align: middle;
|
|
}
|
|
.mp-branch-pill {
|
|
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
|
font-size: 11px; font-weight: 500;
|
|
background: #e0e7ff; color: #3730a3;
|
|
font-family: var(--font-mono, ui-monospace, monospace);
|
|
}
|
|
.mp-sha { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; color: var(--text-primary, #111827); }
|
|
.mp-muted { color: var(--text-secondary, #9ca3af); }
|
|
.mp-err-badge {
|
|
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
|
background: #fee2e2; color: #b91c1c;
|
|
font-size: 11px; font-weight: 500;
|
|
cursor: help;
|
|
}
|
|
.mp-ok-dot { color: #047857; font-weight: 600; }
|
|
.mp-token-dot {
|
|
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
|
background: #cbd5e1;
|
|
}
|
|
.mp-token-dot.has-token { background: #10b981; }
|
|
|
|
.date-cell { color: var(--text-secondary, #6b7280); font-size: 12px; white-space: nowrap; }
|
|
|
|
.row-actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: nowrap; white-space: nowrap; }
|
|
.icon-btn {
|
|
background: transparent; border: 1px solid var(--border, #e5e7eb); border-radius: 6px;
|
|
padding: 5px 10px; font-size: 12px; cursor: pointer;
|
|
color: var(--text-secondary, #6b7280); transition: all 0.15s;
|
|
text-decoration: none; line-height: 1.4;
|
|
white-space: nowrap;
|
|
}
|
|
.icon-btn:hover { color: var(--text-primary, #111827); border-color: #cbd5e1; background: #f9fafb; }
|
|
.icon-btn.primary { color: var(--primary, #6366f1); border-color: #c7d2fe; }
|
|
.icon-btn.primary:hover { background: #eef2ff; }
|
|
.icon-btn.danger:hover { color: #b91c1c; border-color: #fecaca; background: #fef2f2; }
|
|
.icon-btn[disabled] { opacity: 0.5; cursor: wait; }
|
|
|
|
.mp-empty, .mp-loading {
|
|
text-align: center; padding: 48px 16px;
|
|
color: var(--text-secondary, #6b7280); font-size: 13px;
|
|
}
|
|
.mp-empty .big { font-size: 15px; color: var(--text-primary, #111827); margin-bottom: 6px; font-weight: 500; }
|
|
|
|
/* Modal — identical to admin_users.html */
|
|
.modal-backdrop {
|
|
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55);
|
|
display: none; align-items: center; justify-content: center; z-index: 1000;
|
|
padding: 16px;
|
|
}
|
|
.modal-backdrop.is-open { display: flex; }
|
|
.modal-card {
|
|
background: var(--surface, #fff); border-radius: 12px;
|
|
padding: 24px; width: 100%; max-width: 480px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
|
max-height: 90vh; overflow-y: auto;
|
|
}
|
|
.modal-card h3 { margin: 0 0 6px; font-size: 17px; font-weight: 600; }
|
|
.modal-card p.sub { margin: 0 0 18px; font-size: 13px; color: var(--text-secondary, #6b7280); }
|
|
.modal-card label { display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary, #6b7280); margin: 12px 0 6px; }
|
|
.modal-card .help { font-size: 11px; color: var(--text-secondary, #9ca3af); margin-top: 4px; }
|
|
.modal-card input[type="text"], .modal-card input[type="url"], .modal-card input[type="email"], .modal-card input[type="password"], .modal-card textarea {
|
|
width: 100%; padding: 9px 12px; border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 8px; font-size: 13px; box-sizing: border-box;
|
|
background: var(--surface, #fff); color: var(--text-primary, #111827);
|
|
font-family: inherit;
|
|
}
|
|
.modal-card textarea { min-height: 60px; resize: vertical; }
|
|
.modal-card input:focus, .modal-card textarea:focus { outline: 2px solid var(--primary, #6366f1); outline-offset: -1px; }
|
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
|
.modal-btn {
|
|
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
|
|
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
|
|
cursor: pointer; transition: all 0.15s;
|
|
}
|
|
.modal-btn:hover { background: var(--border-light, #f9fafb); }
|
|
.modal-btn.primary { background: var(--primary, #6366f1); color: #fff; border-color: var(--primary, #6366f1); }
|
|
.modal-btn.primary:hover { filter: brightness(1.05); }
|
|
.modal-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
|
.modal-btn.danger:hover { filter: brightness(1.05); }
|
|
|
|
.sync-result {
|
|
margin: 12px 0;
|
|
padding: 12px; border-radius: 8px;
|
|
background: #f0fdf4; border: 1px solid #bbf7d0;
|
|
font-family: var(--font-mono, ui-monospace, monospace);
|
|
font-size: 12px; word-break: break-all;
|
|
}
|
|
.sync-result.err { background: #fef2f2; border-color: #fecaca; }
|
|
|
|
.mp-plugin-count {
|
|
display: inline-block; min-width: 22px; padding: 2px 8px;
|
|
border-radius: 999px; background: #ede9fe; color: #5b21b6;
|
|
font-size: 11px; font-weight: 600; text-align: center;
|
|
}
|
|
|
|
/* Details modal — plugin list */
|
|
.plugin-list {
|
|
margin: 12px 0 4px;
|
|
max-height: 60vh; overflow-y: auto;
|
|
border: 1px solid var(--border, #e5e7eb); border-radius: 8px;
|
|
background: var(--surface, #fff);
|
|
}
|
|
.plugin-list .empty { padding: 24px; text-align: center; color: var(--text-secondary, #6b7280); font-size: 13px; }
|
|
.plugin-item {
|
|
padding: 12px 14px;
|
|
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
|
}
|
|
.plugin-item:last-child { border-bottom: none; }
|
|
.plugin-item-head {
|
|
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
|
|
}
|
|
.plugin-name { font-weight: 600; color: var(--text-primary, #111827); font-size: 13px; }
|
|
.plugin-version {
|
|
font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px;
|
|
color: var(--text-secondary, #6b7280);
|
|
}
|
|
.plugin-source {
|
|
display: inline-block; padding: 1px 6px; border-radius: 4px;
|
|
background: #f3f4f6; color: #374151; font-size: 10px; font-weight: 500;
|
|
text-transform: uppercase; letter-spacing: 0.3px;
|
|
}
|
|
.plugin-desc {
|
|
margin-top: 4px; font-size: 12px; color: var(--text-secondary, #4b5563);
|
|
line-height: 1.45;
|
|
}
|
|
.plugin-meta {
|
|
margin-top: 6px; display: flex; gap: 12px; flex-wrap: wrap;
|
|
font-size: 11px; color: var(--text-secondary, #6b7280);
|
|
}
|
|
.plugin-meta a { color: var(--primary, #6366f1); text-decoration: none; }
|
|
.plugin-meta a:hover { text-decoration: underline; }
|
|
|
|
/* v39: SYSTEM pill + per-plugin toggle button. Pill uses the same
|
|
amber palette as the .origin-system chip on /admin/groups so the
|
|
"system" semantic reads consistently across the admin surface. */
|
|
.plugin-system-pill {
|
|
display: inline-block; padding: 1px 7px; border-radius: 999px;
|
|
background: #fef3c7; color: #92400e;
|
|
font-size: 10px; font-weight: 600;
|
|
text-transform: uppercase; letter-spacing: 0.4px;
|
|
}
|
|
.plugin-item-head .spacer { flex: 1; }
|
|
/* Toggle-style chip: same size/weight in both states, differentiated
|
|
only by tonal fill (outlined ↔ tinted). Off state reads as "click to
|
|
enable", on state reads as "currently on, click to revert". The
|
|
amber palette is shared with the SYSTEM pill + Required badge so
|
|
the org-policy semantic stays visually consistent across surfaces. */
|
|
.plugin-system-btn {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
background: #fff; border: 1px solid #fbbf24;
|
|
border-radius: 6px; padding: 5px 11px; font-size: 11px;
|
|
font-weight: 600; cursor: pointer;
|
|
color: #92400e; white-space: nowrap;
|
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
}
|
|
.plugin-system-btn svg { display: block; flex-shrink: 0; }
|
|
.plugin-system-btn:hover {
|
|
background: #fffbeb; border-color: #f59e0b; color: #78350f;
|
|
}
|
|
.plugin-system-btn.is-system {
|
|
background: #fef3c7; border-color: #fcd34d; color: #92400e;
|
|
}
|
|
.plugin-system-btn.is-system:hover {
|
|
background: #fde68a; border-color: #f59e0b; color: #78350f;
|
|
}
|
|
.plugin-system-btn[disabled] { opacity: 0.5; cursor: wait; }
|
|
|
|
/* Amber primary modal action — pairs with the system-confirm modal so
|
|
the OK button matches the trigger button's visual weight. */
|
|
.modal-btn.warning {
|
|
background: #f59e0b; color: #fff; border-color: #d97706;
|
|
}
|
|
.modal-btn.warning:hover { filter: brightness(1.06); }
|
|
|
|
/* Toast */
|
|
.toast-stack {
|
|
position: fixed; bottom: 24px; right: 24px; z-index: 2000;
|
|
display: flex; flex-direction: column; gap: 8px;
|
|
pointer-events: none;
|
|
}
|
|
.toast {
|
|
background: #111827; color: #fff; padding: 10px 16px;
|
|
border-radius: 8px; font-size: 13px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
|
opacity: 0; transform: translateY(8px);
|
|
transition: opacity 0.2s, transform 0.2s;
|
|
pointer-events: auto; max-width: 380px;
|
|
}
|
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
.toast.success { background: #047857; }
|
|
.toast.error { background: #b91c1c; }
|
|
</style>
|
|
|
|
<div class="marketplaces-page">
|
|
<div class="marketplaces-toolbar">
|
|
<h2 class="marketplaces-title">Curated Marketplaces</h2>
|
|
<input id="mp-search" type="search" class="marketplaces-search" placeholder="Filter by slug, name, or URL…" autocomplete="off">
|
|
<a class="icon-btn" href="/marketplace/format-guide" target="_blank" rel="noopener"
|
|
title="Open the marketplace-metadata.json format guide in a new tab">
|
|
Format guide →
|
|
</a>
|
|
<button class="modal-btn primary" id="open-create-btn">+ Add marketplace</button>
|
|
</div>
|
|
|
|
<div class="marketplaces-table-wrap">
|
|
<table class="marketplaces-table" id="marketplaces-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Marketplace</th>
|
|
<th title="Person accountable for this marketplace's content">Curator</th>
|
|
<th>URL</th>
|
|
<th title="Plugins discovered in .claude-plugin/marketplace.json on last sync">Plugins</th>
|
|
<th>Last sync</th>
|
|
<th>Commit</th>
|
|
<th title="Auth token persisted to .env_overlay (not the DB)">Token</th>
|
|
<th style="text-align:right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="mp-tbody"></tbody>
|
|
</table>
|
|
<div id="mp-loading" class="mp-loading">Loading marketplaces…</div>
|
|
<div id="mp-empty" class="mp-empty" style="display:none;">
|
|
<div class="big">No marketplaces registered</div>
|
|
<div>Click <strong>Add marketplace</strong> to register the first git repo.</div>
|
|
<div style="margin-top:8px; font-size:12px;">They are cloned to <code>$DATA_DIR/marketplaces/<slug>/</code> every night at 03:00 UTC and can be re-synced manually.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create modal -->
|
|
<div class="modal-backdrop" id="create-modal" role="dialog" aria-modal="true" aria-labelledby="create-modal-title">
|
|
<div class="modal-card">
|
|
<h3 id="create-modal-title">Add marketplace</h3>
|
|
<p class="sub">Register a git repository. It will be cloned into <code>$DATA_DIR/marketplaces/<slug>/</code> and fast-forwarded every night at 03:00 UTC.</p>
|
|
|
|
<label for="new-name">Display name</label>
|
|
<input id="new-name" type="text" placeholder="e.g. Acme Marketplace" required autocomplete="off">
|
|
|
|
<label for="new-slug">Slug (directory name)</label>
|
|
<input id="new-slug" type="text" placeholder="e.g. acme" required autocomplete="off" pattern="[a-z0-9][a-z0-9_-]{0,63}">
|
|
<div class="help">Lower-case alphanumerics, hyphens, and underscores. 1-64 chars, must start with a letter or digit.</div>
|
|
|
|
<label for="new-url">Git URL (https://)</label>
|
|
<input id="new-url" type="url" placeholder="https://github.com/org/repo.git" required autocomplete="off">
|
|
|
|
<label for="new-branch">Branch (optional)</label>
|
|
<input id="new-branch" type="text" placeholder="main (leave empty for remote HEAD)" autocomplete="off">
|
|
|
|
<label for="new-description">Description (optional)</label>
|
|
<textarea id="new-description" autocomplete="off"></textarea>
|
|
|
|
<label for="new-curator-name">Curator full name <span style="color:#b91c1c">*</span></label>
|
|
<input id="new-curator-name" type="text" placeholder="e.g. Jane Doe" required autocomplete="off">
|
|
<div class="help">The named person accountable for what's in this marketplace. Surfaced on plugin cards and detail pages.</div>
|
|
|
|
<label for="new-curator-email">Curator email <span style="color:#b91c1c">*</span></label>
|
|
<input id="new-curator-email" type="email" placeholder="e.g. jane.doe@example.com" required autocomplete="off">
|
|
|
|
<label for="new-token">GitHub PAT (optional — private repos only)</label>
|
|
<input id="new-token" type="password" placeholder="ghp_… or ghs_… (leave empty for public repos)" autocomplete="off">
|
|
<div class="help">Stored in <code>$DATA_DIR/state/.env_overlay</code> (chmod 600) on the data volume. Never written to the database or committed to git.</div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" data-close-modal="create-modal">Cancel</button>
|
|
<button class="modal-btn primary" id="confirm-create-btn">Register</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit modal -->
|
|
<div class="modal-backdrop" id="edit-modal" role="dialog" aria-modal="true" aria-labelledby="edit-modal-title">
|
|
<div class="modal-card">
|
|
<h3 id="edit-modal-title">Edit marketplace</h3>
|
|
<p class="sub" id="edit-slug-label"></p>
|
|
|
|
<label for="edit-name">Display name</label>
|
|
<input id="edit-name" type="text" autocomplete="off">
|
|
|
|
<label for="edit-url">Git URL</label>
|
|
<input id="edit-url" type="url" autocomplete="off">
|
|
|
|
<label for="edit-branch">Branch</label>
|
|
<input id="edit-branch" type="text" placeholder="main (leave empty for remote HEAD)" autocomplete="off">
|
|
|
|
<label for="edit-description">Description</label>
|
|
<textarea id="edit-description" autocomplete="off"></textarea>
|
|
|
|
<label for="edit-curator-name">Curator full name</label>
|
|
<input id="edit-curator-name" type="text" placeholder="e.g. Jane Doe" autocomplete="off">
|
|
<div class="help">Leave unchanged to keep the current curator. Empty strings are ignored — to clear the curator, fill it in with the new owner instead.</div>
|
|
|
|
<label for="edit-curator-email">Curator email</label>
|
|
<input id="edit-curator-email" type="email" placeholder="e.g. jane.doe@example.com" autocomplete="off">
|
|
|
|
<label for="edit-token">GitHub PAT</label>
|
|
<input id="edit-token" type="password" placeholder="Leave empty to keep current. Type a new token to rotate." autocomplete="off">
|
|
<div class="help">
|
|
<label style="display:inline-flex; align-items:center; gap:6px; margin:6px 0 0; font-weight:400; color: var(--text-secondary, #6b7280);">
|
|
<input id="edit-clear-token" type="checkbox"> Remove the stored token (revert to public access)
|
|
</label>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" data-close-modal="edit-modal">Cancel</button>
|
|
<button class="modal-btn primary" id="confirm-edit-btn">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sync result modal -->
|
|
<div class="modal-backdrop" id="sync-modal" role="dialog" aria-modal="true" aria-labelledby="sync-title">
|
|
<div class="modal-card">
|
|
<h3 id="sync-title">Sync result</h3>
|
|
<p class="sub" id="sync-target"></p>
|
|
<div id="sync-result-body" class="sync-result"></div>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn primary" data-close-modal="sync-modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Marketplace details modal -->
|
|
<div class="modal-backdrop" id="details-modal" role="dialog" aria-modal="true" aria-labelledby="details-title">
|
|
<div class="modal-card" style="max-width:720px;">
|
|
<h3 id="details-title">Marketplace details</h3>
|
|
<p class="sub" id="details-sub"></p>
|
|
<div id="details-body" class="plugin-list"></div>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn primary" data-close-modal="details-modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Confirm dialog -->
|
|
<div class="modal-backdrop" id="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
|
<div class="modal-card">
|
|
<h3 id="confirm-title">Are you sure?</h3>
|
|
<p class="sub" id="confirm-text"></p>
|
|
<label style="display:flex; align-items:center; gap:8px; margin-top:6px; font-size:13px; color: var(--text-primary, #111827); font-weight:500;">
|
|
<input id="confirm-purge" type="checkbox" checked> Also delete the working copy from disk
|
|
</label>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" data-close-modal="confirm-modal">Cancel</button>
|
|
<button class="modal-btn danger" id="confirm-ok-btn">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- v39: confirm dialog for Mark/Unmark system. Replaces the native
|
|
confirm() because operators need a clear, scannable summary of
|
|
what fanout actually does to existing + future principals before
|
|
they hit OK. Title + body are populated dynamically so the same
|
|
modal handles both directions. -->
|
|
<div class="modal-backdrop" id="system-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="system-confirm-title">
|
|
<div class="modal-card" style="max-width: 520px;">
|
|
<h3 id="system-confirm-title">Mark as system plugin?</h3>
|
|
<p class="sub" id="system-confirm-plugin" style="margin: 0 0 14px; font-size: 13px; color: var(--text-primary, #111827); font-weight: 500;"></p>
|
|
<div id="system-confirm-body" style="font-size: 13px; line-height: 1.55; color: var(--text-secondary, #374151);"></div>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" data-close-modal="system-confirm-modal">Cancel</button>
|
|
<button class="modal-btn warning" id="system-confirm-ok-btn">Confirm</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
|
|
|
|
<script>
|
|
const API = "/api/marketplaces";
|
|
|
|
function esc(s) {
|
|
const d = document.createElement("div");
|
|
d.textContent = s == null ? "" : String(s);
|
|
return d.innerHTML;
|
|
}
|
|
function fmtDate(s) { return s ? s.slice(0, 16).replace("T", " ") : "—"; }
|
|
function shortSha(s) { return s ? s.slice(0, 7) : "—"; }
|
|
|
|
// ── Toast ──
|
|
function toast(msg, kind = "") {
|
|
const el = document.createElement("div");
|
|
el.className = "toast " + kind;
|
|
el.textContent = msg;
|
|
document.getElementById("toast-stack").appendChild(el);
|
|
requestAnimationFrame(() => el.classList.add("show"));
|
|
setTimeout(() => {
|
|
el.classList.remove("show");
|
|
setTimeout(() => el.remove(), 250);
|
|
}, 3500);
|
|
}
|
|
|
|
// ── Modal helpers ──
|
|
function openModal(id) {
|
|
document.getElementById(id).classList.add("is-open");
|
|
const focusable = document.querySelector(`#${id} input, #${id} button.primary`);
|
|
focusable && focusable.focus();
|
|
}
|
|
function closeModal(id) {
|
|
document.getElementById(id).classList.remove("is-open");
|
|
}
|
|
document.querySelectorAll("[data-close-modal]").forEach(el =>
|
|
el.addEventListener("click", () => closeModal(el.dataset.closeModal)));
|
|
document.querySelectorAll(".modal-backdrop").forEach(el => {
|
|
el.addEventListener("click", e => { if (e.target === el) el.classList.remove("is-open"); });
|
|
});
|
|
document.addEventListener("keydown", e => {
|
|
if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.is-open").forEach(m => m.classList.remove("is-open"));
|
|
});
|
|
|
|
// ── State ──
|
|
let allMarketplaces = [];
|
|
let filterText = "";
|
|
|
|
function renderMarketplaces() {
|
|
const tbody = document.getElementById("mp-tbody");
|
|
const loading = document.getElementById("mp-loading");
|
|
const empty = document.getElementById("mp-empty");
|
|
loading.style.display = "none";
|
|
|
|
const ft = filterText.trim().toLowerCase();
|
|
const filtered = ft
|
|
? allMarketplaces.filter(m => (m.id || "").toLowerCase().includes(ft)
|
|
|| (m.name || "").toLowerCase().includes(ft)
|
|
|| (m.url || "").toLowerCase().includes(ft))
|
|
: allMarketplaces;
|
|
|
|
if (allMarketplaces.length === 0) {
|
|
empty.style.display = "block";
|
|
tbody.innerHTML = "";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
|
|
if (filtered.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="8" class="mp-loading">No matches for "${esc(filterText)}"</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = "";
|
|
for (const m of filtered) {
|
|
const tr = document.createElement("tr");
|
|
const lastSync = m.last_error
|
|
? `<span class="mp-err-badge" title="${esc(m.last_error)}">failed ${esc(fmtDate(m.last_synced_at))}</span>`
|
|
: (m.last_synced_at ? `<span class="mp-ok-dot">●</span> ${esc(fmtDate(m.last_synced_at))}` : `<span class="mp-muted">never</span>`);
|
|
tr.innerHTML = `
|
|
<td>
|
|
<div class="mp-cell">
|
|
<div class="mp-avatar">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
|
|
</div>
|
|
<div class="mp-meta">
|
|
<span class="slug">${esc(m.id)}</span>
|
|
<span class="name">${esc(m.name || "")}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>${m.curator_name
|
|
? `<div class="mp-curator-cell"><span class="name">${esc(m.curator_name)}</span>${m.curator_email ? `<span class="email">${esc(m.curator_email)}</span>` : ""}</div>`
|
|
: `<span class="mp-muted" title="Open the Edit modal to fill in curator name + email">unset</span>`}</td>
|
|
<td><span class="mp-url" title="${esc(m.url)}${m.branch ? ` (branch: ${esc(m.branch)})` : ""}">${esc(m.url)}</span></td>
|
|
<td>${m.plugin_count > 0
|
|
? `<span class="mp-plugin-count">${m.plugin_count}</span>`
|
|
: `<span class="mp-muted">0</span>`}</td>
|
|
<td class="date-cell">${lastSync}</td>
|
|
<td class="mp-sha">${m.last_commit_sha ? esc(shortSha(m.last_commit_sha)) : `<span class="mp-muted">—</span>`}</td>
|
|
<td><span class="mp-token-dot ${m.has_token ? "has-token" : ""}" title="${m.has_token ? "Authenticated (PAT present in environment)" : "Public / no token"}"></span></td>
|
|
<td>
|
|
<div class="row-actions">
|
|
<button class="icon-btn primary" data-action="sync" data-id="${esc(m.id)}">Sync now</button>
|
|
<button class="icon-btn" data-action="details" data-id="${esc(m.id)}" data-name="${esc(m.name || m.id)}">Details</button>
|
|
<button class="icon-btn" data-action="edit" data-id="${esc(m.id)}">Edit</button>
|
|
<button class="icon-btn danger" data-action="delete" data-id="${esc(m.id)}" data-name="${esc(m.name || m.id)}">Delete</button>
|
|
</div>
|
|
</td>`;
|
|
tbody.appendChild(tr);
|
|
}
|
|
|
|
tbody.querySelectorAll('[data-action="sync"]').forEach(el =>
|
|
el.addEventListener("click", () => syncNow(el.dataset.id, el)));
|
|
tbody.querySelectorAll('[data-action="details"]').forEach(el =>
|
|
el.addEventListener("click", () => openDetails(el.dataset.id, el.dataset.name)));
|
|
tbody.querySelectorAll('[data-action="edit"]').forEach(el =>
|
|
el.addEventListener("click", () => openEdit(el.dataset.id)));
|
|
tbody.querySelectorAll('[data-action="delete"]').forEach(el =>
|
|
el.addEventListener("click", () => delMarketplace(el.dataset.id, el.dataset.name)));
|
|
}
|
|
|
|
async function loadMarketplaces() {
|
|
try {
|
|
const r = await fetch(API, { credentials: "include" });
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
allMarketplaces = await r.json();
|
|
renderMarketplaces();
|
|
} catch (e) {
|
|
document.getElementById("mp-loading").textContent = "Failed to load marketplaces: " + e.message;
|
|
toast("Failed to load marketplaces", "error");
|
|
}
|
|
}
|
|
|
|
document.getElementById("mp-search").addEventListener("input", e => {
|
|
filterText = e.target.value;
|
|
renderMarketplaces();
|
|
});
|
|
|
|
// ── Sync now ──
|
|
async function syncNow(id, btn) {
|
|
if (btn) { btn.disabled = true; btn.textContent = "Syncing…"; }
|
|
let body, ok = false;
|
|
try {
|
|
const r = await fetch(`${API}/${encodeURIComponent(id)}/sync`, { method: "POST", credentials: "include" });
|
|
body = await r.json().catch(() => ({}));
|
|
ok = r.ok;
|
|
} catch (e) {
|
|
body = { error: e.message };
|
|
} finally {
|
|
if (btn) { btn.disabled = false; btn.textContent = "Sync now"; }
|
|
}
|
|
document.getElementById("sync-target").textContent = id;
|
|
const rb = document.getElementById("sync-result-body");
|
|
rb.classList.toggle("err", !ok);
|
|
if (ok) {
|
|
const plugLine = body.plugin_count != null ? `\nplugins: ${body.plugin_count}` : "";
|
|
rb.textContent = `action: ${body.action}\ncommit: ${body.commit}\npath: ${body.path}${plugLine}`;
|
|
toast("Sync OK", "success");
|
|
} else {
|
|
rb.textContent = body.detail || body.error || "Sync failed";
|
|
toast("Sync failed", "error");
|
|
}
|
|
openModal("sync-modal");
|
|
loadMarketplaces();
|
|
}
|
|
|
|
// ── Details ──
|
|
async function openDetails(id, name) {
|
|
const m = allMarketplaces.find(x => x.id === id);
|
|
document.getElementById("details-title").textContent = `${name || id}`;
|
|
const sub = document.getElementById("details-sub");
|
|
sub.innerHTML = `<code>${esc(id)}</code>${m && m.url ? ` · <span class="mp-url" title="${esc(m.url)}">${esc(m.url)}</span>` : ""}`;
|
|
const body = document.getElementById("details-body");
|
|
body.innerHTML = `<div class="empty">Loading plugins…</div>`;
|
|
openModal("details-modal");
|
|
try {
|
|
const r = await fetch(`${API}/${encodeURIComponent(id)}/plugins`, { credentials: "include" });
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
const plugins = await r.json();
|
|
if (!plugins.length) {
|
|
body.innerHTML = `<div class="empty">No plugins found.<br>
|
|
Either the marketplace has never been synced, or its
|
|
<code>.claude-plugin/marketplace.json</code> does not list any plugins.</div>`;
|
|
return;
|
|
}
|
|
body.innerHTML = plugins.map(p => {
|
|
const src = p.source_type ? `<span class="plugin-source">${esc(p.source_type)}</span>` : "";
|
|
const ver = p.version ? `<span class="plugin-version">v${esc(p.version)}</span>` : "";
|
|
const desc = p.description ? `<div class="plugin-desc">${esc(p.description)}</div>` : "";
|
|
const meta = [];
|
|
if (p.author_name) meta.push(`by ${esc(p.author_name)}`);
|
|
if (p.category) meta.push(`category: ${esc(p.category)}`);
|
|
if (p.homepage) meta.push(`<a href="${esc(p.homepage)}" target="_blank" rel="noopener">homepage ↗</a>`);
|
|
const metaHtml = meta.length ? `<div class="plugin-meta">${meta.join(" · ")}</div>` : "";
|
|
const sysPill = p.is_system
|
|
? `<span class="plugin-system-pill" title="Mandatory for every user — managed below">SYSTEM</span>`
|
|
: "";
|
|
const sysBtnLabel = p.is_system ? "Unmark system" : "Mark as system";
|
|
const sysBtnTitle = p.is_system
|
|
? "Remove the system mark. Existing per-group grants and per-user subscriptions remain — admin can clean them up via /admin/access."
|
|
: "Make this plugin mandatory for every user. Adds RBAC grants for every group and subscriptions for every user.";
|
|
// Shield icon on both states — it's the semantic anchor for
|
|
// "org policy / system" and dropping it on unmark broke visual
|
|
// parity between the two toggle states.
|
|
const sysBtnIcon =
|
|
`<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M12 2 4 6v6c0 5 3.4 9.5 8 10 4.6-.5 8-5 8-10V6l-8-4z"/>
|
|
</svg>`;
|
|
return `<div class="plugin-item">
|
|
<div class="plugin-item-head">
|
|
<span class="plugin-name">${esc(p.name)}</span>${ver}${src}${sysPill}
|
|
<span class="spacer"></span>
|
|
<button class="plugin-system-btn ${p.is_system ? "is-system" : ""}"
|
|
data-action="toggle-system"
|
|
data-marketplace="${esc(id)}"
|
|
data-plugin="${esc(p.name)}"
|
|
data-is-system="${p.is_system ? "1" : "0"}"
|
|
title="${esc(sysBtnTitle)}">${sysBtnIcon}${esc(sysBtnLabel)}</button>
|
|
</div>${desc}${metaHtml}
|
|
</div>`;
|
|
}).join("");
|
|
body.querySelectorAll('[data-action="toggle-system"]').forEach(btn =>
|
|
btn.addEventListener("click", () => toggleSystem(btn, id, name)));
|
|
} catch (e) {
|
|
body.innerHTML = `<div class="empty" style="color:#b91c1c;">Failed to load plugins: ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function toggleSystem(btn, marketplaceId, marketplaceName) {
|
|
// Two-step: render a populated confirm modal, then on OK perform the
|
|
// PUT/DELETE. The native confirm() lost the operator's eye in a tiny
|
|
// OS popup that didn't make the fanout consequences obvious.
|
|
const pluginName = btn.dataset.plugin;
|
|
const isSystem = btn.dataset.isSystem === "1";
|
|
const titleEl = document.getElementById("system-confirm-title");
|
|
const pluginEl = document.getElementById("system-confirm-plugin");
|
|
const bodyEl = document.getElementById("system-confirm-body");
|
|
const okBtn = document.getElementById("system-confirm-ok-btn");
|
|
|
|
if (isSystem) {
|
|
titleEl.textContent = "Unmark system plugin?";
|
|
pluginEl.textContent = pluginName;
|
|
bodyEl.innerHTML = `
|
|
<p style="margin: 0 0 10px;">This removes the <strong>system</strong> flag.
|
|
The plugin is no longer required.</p>
|
|
<ul style="margin: 0; padding-left: 20px;">
|
|
<li>Existing per-group RBAC grants <strong>stay</strong>.</li>
|
|
<li>Existing per-user subscriptions <strong>stay</strong>.</li>
|
|
<li>Operators can revoke either via <a href="/admin/access" style="color: var(--primary, #6366f1);">/admin/access</a> or by uninstalling on each user's stack.</li>
|
|
</ul>`;
|
|
okBtn.textContent = "Unmark system";
|
|
okBtn.classList.remove("warning");
|
|
okBtn.classList.add("primary");
|
|
} else {
|
|
titleEl.textContent = "Mark as system plugin?";
|
|
pluginEl.textContent = pluginName;
|
|
bodyEl.innerHTML = `
|
|
<p style="margin: 0 0 10px;">After confirming, <strong>every Agnes user</strong> will
|
|
receive this plugin as <strong>required</strong>:</p>
|
|
<ul style="margin: 0 0 10px; padding-left: 20px;">
|
|
<li>RBAC grants are added for <strong>every existing group</strong>.</li>
|
|
<li>Subscriptions are created for <strong>every existing user</strong>.</li>
|
|
<li>Every <strong>new user</strong> and <strong>new group</strong> inherits it automatically.</li>
|
|
<li>Users see it as <strong>Required</strong> on /marketplace and cannot uninstall it from /marketplace?tab=my.</li>
|
|
<li>Admins cannot revoke it from /admin/access while the system flag is on.</li>
|
|
</ul>
|
|
<p style="margin: 0; font-size: 12px; color: var(--text-secondary, #6b7280);">
|
|
Unmarking later does not auto-clean the materialized grants — they stay until manually removed.
|
|
</p>`;
|
|
okBtn.textContent = "Mark as system";
|
|
okBtn.classList.remove("primary");
|
|
okBtn.classList.add("warning");
|
|
}
|
|
|
|
// Single-shot handler — re-bind on every open so we don't leak across
|
|
// toggles and so the closure captures the current btn / ids.
|
|
const onConfirm = async () => {
|
|
okBtn.removeEventListener("click", onConfirm);
|
|
closeModal("system-confirm-modal");
|
|
await performToggleSystem(btn, marketplaceId, marketplaceName, isSystem);
|
|
};
|
|
okBtn.addEventListener("click", onConfirm);
|
|
// Cancel cleanup — strip the listener so the next open starts fresh.
|
|
const onCancel = (e) => {
|
|
if (e && e.target === okBtn) return;
|
|
okBtn.removeEventListener("click", onConfirm);
|
|
};
|
|
document.querySelectorAll('[data-close-modal="system-confirm-modal"]').forEach(el =>
|
|
el.addEventListener("click", onCancel, { once: true }));
|
|
|
|
openModal("system-confirm-modal");
|
|
}
|
|
|
|
async function performToggleSystem(btn, marketplaceId, marketplaceName, isSystem) {
|
|
const pluginName = btn.dataset.plugin;
|
|
const method = isSystem ? "DELETE" : "POST";
|
|
btn.disabled = true;
|
|
const prevHtml = btn.innerHTML;
|
|
btn.textContent = "Working…";
|
|
try {
|
|
const url = `${API}/${encodeURIComponent(marketplaceId)}/plugins/${encodeURIComponent(pluginName)}/system`;
|
|
const r = await fetch(url, { method, credentials: "include" });
|
|
if (!r.ok) {
|
|
const err = await r.json().catch(() => ({}));
|
|
toast(`Toggle failed: ${err.detail || r.status}`, "error");
|
|
btn.disabled = false;
|
|
btn.innerHTML = prevHtml;
|
|
return;
|
|
}
|
|
const result = await r.json();
|
|
if (!isSystem) {
|
|
toast(`Marked ${pluginName} as system (${result.affected_groups} groups, ${result.affected_users} users)`, "success");
|
|
} else {
|
|
toast(`Unmarked ${pluginName} from system`, "success");
|
|
}
|
|
// Reload the modal so the pill + button label refresh from a fresh fetch.
|
|
openDetails(marketplaceId, marketplaceName);
|
|
} catch (e) {
|
|
toast(`Network error: ${e.message}`, "error");
|
|
btn.disabled = false;
|
|
btn.innerHTML = prevHtml;
|
|
}
|
|
}
|
|
|
|
// ── Edit ──
|
|
function openEdit(id) {
|
|
const m = allMarketplaces.find(x => x.id === id);
|
|
if (!m) return;
|
|
document.getElementById("edit-slug-label").textContent = `Editing ${m.id}`;
|
|
document.getElementById("edit-name").value = m.name || "";
|
|
document.getElementById("edit-url").value = m.url || "";
|
|
document.getElementById("edit-branch").value = m.branch || "";
|
|
document.getElementById("edit-description").value = m.description || "";
|
|
document.getElementById("edit-curator-name").value = m.curator_name || "";
|
|
document.getElementById("edit-curator-email").value = m.curator_email || "";
|
|
document.getElementById("edit-token").value = "";
|
|
document.getElementById("edit-clear-token").checked = false;
|
|
|
|
const btn = document.getElementById("confirm-edit-btn");
|
|
btn.onclick = async () => {
|
|
const payload = {
|
|
name: document.getElementById("edit-name").value.trim() || null,
|
|
url: document.getElementById("edit-url").value.trim() || null,
|
|
branch: document.getElementById("edit-branch").value.trim(), // empty string cleared by API (None=untouched)
|
|
description: document.getElementById("edit-description").value,
|
|
curator_name: document.getElementById("edit-curator-name").value.trim() || null,
|
|
curator_email: document.getElementById("edit-curator-email").value.trim() || null,
|
|
};
|
|
// branch: null means untouched; explicit "" clears to HEAD. Treat empty input as clear only if user typed then erased.
|
|
// Simpler UX: always send current value (empty = HEAD).
|
|
if (payload.branch === "") payload.branch = null; // untouched
|
|
const tokenVal = document.getElementById("edit-token").value;
|
|
const clearTok = document.getElementById("edit-clear-token").checked;
|
|
if (clearTok) payload.token = "";
|
|
else if (tokenVal) payload.token = tokenVal;
|
|
const r = await fetch(`${API}/${encodeURIComponent(id)}`, {
|
|
method: "PATCH", credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!r.ok) { toast("Update failed: " + (await r.text()), "error"); return; }
|
|
closeModal("edit-modal");
|
|
toast("Marketplace updated", "success");
|
|
loadMarketplaces();
|
|
};
|
|
openModal("edit-modal");
|
|
}
|
|
|
|
// ── Delete ──
|
|
function delMarketplace(id, name) {
|
|
document.getElementById("confirm-text").textContent =
|
|
`Delete marketplace "${name}" (${id})? Unregisters it and stops future nightly syncs.`;
|
|
document.getElementById("confirm-purge").checked = true;
|
|
const okBtn = document.getElementById("confirm-ok-btn");
|
|
okBtn.onclick = async () => {
|
|
const purge = document.getElementById("confirm-purge").checked;
|
|
const r = await fetch(`${API}/${encodeURIComponent(id)}?purge=${purge}`, {
|
|
method: "DELETE", credentials: "include",
|
|
});
|
|
closeModal("confirm-modal");
|
|
if (!r.ok) { toast("Delete failed: " + (await r.text()), "error"); return; }
|
|
toast("Marketplace removed", "success");
|
|
loadMarketplaces();
|
|
};
|
|
openModal("confirm-modal");
|
|
}
|
|
|
|
// ── Create ──
|
|
document.getElementById("open-create-btn").addEventListener("click", () => {
|
|
document.getElementById("new-name").value = "";
|
|
document.getElementById("new-slug").value = "";
|
|
document.getElementById("new-url").value = "";
|
|
document.getElementById("new-branch").value = "";
|
|
document.getElementById("new-description").value = "";
|
|
document.getElementById("new-curator-name").value = "";
|
|
document.getElementById("new-curator-email").value = "";
|
|
document.getElementById("new-token").value = "";
|
|
openModal("create-modal");
|
|
});
|
|
document.getElementById("confirm-create-btn").addEventListener("click", async () => {
|
|
const curatorName = document.getElementById("new-curator-name").value.trim();
|
|
const curatorEmail = document.getElementById("new-curator-email").value.trim();
|
|
const payload = {
|
|
name: document.getElementById("new-name").value.trim(),
|
|
slug: document.getElementById("new-slug").value.trim().toLowerCase(),
|
|
url: document.getElementById("new-url").value.trim(),
|
|
branch: document.getElementById("new-branch").value.trim() || null,
|
|
description: document.getElementById("new-description").value.trim() || null,
|
|
curator_name: curatorName,
|
|
curator_email: curatorEmail,
|
|
token: document.getElementById("new-token").value || null,
|
|
};
|
|
if (!payload.name || !payload.slug || !payload.url) {
|
|
toast("Name, slug, and URL are required", "error");
|
|
return;
|
|
}
|
|
if (!curatorName || !curatorEmail) {
|
|
toast("Curator full name and email are required", "error");
|
|
return;
|
|
}
|
|
const r = await fetch(API, {
|
|
method: "POST", credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!r.ok) {
|
|
const err = await r.json().catch(() => ({}));
|
|
toast("Create failed: " + (err.detail || r.status), "error");
|
|
return;
|
|
}
|
|
closeModal("create-modal");
|
|
toast("Marketplace registered", "success");
|
|
loadMarketplaces();
|
|
});
|
|
|
|
// Auto-derive slug from name on first focus
|
|
document.getElementById("new-slug").addEventListener("focus", e => {
|
|
if (e.target.value) return;
|
|
const name = document.getElementById("new-name").value.trim().toLowerCase();
|
|
e.target.value = name.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
});
|
|
|
|
loadMarketplaces();
|
|
</script>
|
|
{% endblock %}
|