* feat(store): flea-market upload guardrails + soft delete + JOIN-based admin queue
Adds an end-to-end guardrails pipeline for store uploads (manifest +
static-security + LLM review), persists blocked bundles for forensics,
introduces soft-delete (Archive) semantics, consolidates the legacy
/store/{id} surface into /marketplace/flea/{id}, and reworks the admin
queue so lifecycle filters read live entity visibility via LEFT JOIN
rather than a denormalized submission column.
Schema v29 → v35:
* v29 store_submissions table + store_entities.visibility_status
* v30 file_size, bundle_sha256, bundle_purged_at on submissions
* v31 reshape store_submissions (drop legacy unique on entity_id)
* v32 store_entities.archived_at/by + 'archived' visibility value
* v33 drop store_submissions.retry_count (unused)
* v34 ensure idx_store_submissions_entity exists post column-drop
* v35 broaden visibility_status enum + JOIN architecture cutover
Pipeline (src/store_guardrails/):
* Inline checks: manifest_check, static_scan, quality_check
* LLM review configurable haiku|sonnet|opus (default haiku)
* BackgroundTasks-driven async path with structured-output JSON
* Per-submitter daily quota (default 50)
* 30-day TTL purge job (POST /api/admin/run-blocked-purge)
* Bundle SHA256 + size persisted; sha256 survives purge for forensics
Visibility model:
* pending | approved | hidden | archived
* _enforce_visibility returns 404 (no leak) for non-owner non-admin
* Owner sees own non-approved entries via include_owner_id widening
* Install refused with 409 entity_not_approved when not approved
Soft-delete (DELETE /api/store/entities/{id}):
* Default = soft (visibility_status='archived'); existing installs
keep getting served the bundle so users don't lose the plugin
* ?hard=true admin-only: drops bundle + cascades user_store_installs
* Hard-delete preserves entity_id on submission as tombstone so
audit_log linkage survives for the activity timeline
Admin queue lifecycle (the JOIN refactor):
* Verdict (store_submissions.status) is immutable forensic record
* Lifecycle (store_entities.visibility_status) is live state
* /admin/store/submissions Archived chip translates to
`e.visibility_status='archived'` via LEFT JOIN — any path that
flips visibility surfaces in the queue immediately
* Detail page renders Status (verdict) and Entity lifecycle side by
side so admins see "approved at review, now archived" at a glance
URL consolidation:
* /store/{id} deleted (no redirect, stale bookmarks 404)
* /marketplace/flea/{id} is the canonical detail surface
* Three in-tree callers (upload-success, my-stack card, store
listing card) updated to point at the new URL
* Quarantine banner extracted to _quarantine_banner.html partial,
self-guarded, included from both flea detail templates
* Banner JS auto-refreshes when the verdict lands by polling
/api/marketplace/flea/{id}/detail (visibility_status +
submission_status — the latter is needed because blocked_llm
keeps the entity at visibility_status='pending')
Audit log resource format:
* runner.py emits prefixed `store_submission:{id}` (post-fix)
* Detail-page timeline query handles three patterns: prefixed
submission, helper-emitted `store_entity:{sub_id}`, and bare-id
legacy rows — all surface in the activity timeline
UX fixes:
* Owner sees Under review / Quarantined / Hidden banner with status
* Install button gray-disabled (not blue) when non-approved
* Owner cannot delete quarantined entries (403); admin can
* Admin queue: filter chips, sortable columns, paging, page-size
* Auto-refresh queue every 5s while pending rows are visible
* Store upload page file picker no longer opens twice (label →
input default action collided with explicit JS handler)
Tests: 168 passed across the guardrails suites (admin submissions,
store API, inline / LLM / purge guardrails, store repositories,
marketplace filter, schema version). New regression coverage
includes: archive surfaces via JOIN even when API path is bypassed;
deleted submission renders activity timeline (tombstone); flea
detail surfaces submission_status only for owner/admin; detail page
renders Entity lifecycle row; audit log resource format covers both
helper and runner paths.
* fix(store-guardrails): PR #233 follow-up — prompt injection, atomic PUT, BG race, schema, reaper, sort whitelist
Addresses 9 of the 23 findings from the PR #233 review (spec at
docs/superpowers/specs/2026-05-09-pr233-guardrails-fixes-spec.md).
Merge-gate items #1-#6 plus high-value mediums #7, #9-#12, #23.
Architectural items (#8 enum split, #14 factory) and pure
maintainability (#15-#22) deferred to follow-ups.
Security:
* #1 prompt injection — SYSTEM_PROMPT now passed via the SDK's
dedicated system= parameter; bundle wrapped in <bundle>...</bundle>
sentinels declared data-only by the system prompt; literal
sentinel strings in user content are escaped so an adversarial
README can't forge a close tag.
* #6 static scan honesty — module docstring + admin copy + docs
declare static scan as signal not gate; .md/.txt/.rst/.html/.json/
.yaml/.yml/.toml skipped to avoid false positives on prose.
AST mode for Python deferred (separate flag, FP comparison work).
Correctness:
* #2 PUT atomicity — bundles bake into plugin.staging-<rand>/
alongside live, atomic-rename on success; failed checks leave
live tree byte-for-byte intact.
* #3 BG-task race — set_visibility_if_pending guards verdict flips
to the (pending, hidden) review window; admin archives during
review survive; skipped flips audit-logged.
* #4 v35 NOT NULL/DEFAULT — schema v35→v36 re-applies them on
store_entities.visibility_status. CHECK constraint enforced
application-side (DuckDB ADD CHECK on existing column unsupported).
* #7 stuck-review reaper — reap_stuck_llm_reviews flips pending_llm
rows older than guardrails.stuck_review_grace_seconds (default
1800) to review_error. Scheduler runs every 15 min via new
/api/admin/run-reap-stuck-reviews. Set knob to 0 to disable.
* #9 quota counter — count_blocked_for_submitter_since now counts
blocked_inline + blocked_llm + review_error so a submitter
triggering only LLM-blocked verdicts is bounded.
* #10 missing risk_level — surfaces as review_error with
error='missing_risk_level' instead of silently defaulting to
'medium' (which looked like a model-decided block).
* #11 archived_at clear — set_visibility nulls archived_at +
archived_by when transitioning out of 'archived' so a future
read doesn't show stale archive forensics on an approved row.
Maintainability:
* #12 FSM doc comment — accurate insert/transition/lifecycle
description in src/db.py near store_submissions schema.
* #23 sort-key whitelist — admin queue rejects unknown sort keys
with 400 invalid_sort_key; substring-replace footgun removed.
Deferred (separate PRs):
* #5 quota race — proper fix requires asyncio.Lock spanning the
full pipeline; threading.Lock blocks event loop, DuckDB MVCC
doesn't help. API-level slowapi bounds worst case for now.
* #6 part 3 (AST static scan), #8 (enum split), #13 (import
bundle docs), #14 (factory consolidation), #15-#22 (maint).
Tests:
* New: tests/test_store_guardrails_prompt_injection.py (corpus +
trust-boundary invariants), tests/test_store_put_atomic.py,
tests/test_store_guardrails_reaper.py.
* Extended: test_store_guardrails_llm.py (system param, missing
risk_level, BG race), test_admin_store_submissions.py (quota
counter widening, sort whitelist 400), test_store_repositories.py
(un-archive metadata clear), test_db_schema_version.py (v36).
* Full suite: 3738 passed; 17 pre-existing baseline failures
unchanged (db migration tests, cli binary rename, catalog export,
user mgmt v5 backfill — confirmed by stash + rerun on clean tree).
412 lines
16 KiB
HTML
412 lines
16 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}My AI Stack — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
/* Width + padding come from .page-shell (style-custom.css) — same
|
|
1280px container as /dashboard, /marketplace, /store, /admin/* peers. */
|
|
.container:has(.stack-page) > main { margin: 0; padding: 0; }
|
|
|
|
/* ── Page header (slim — no gradient) ──────────────────────────── */
|
|
/*
|
|
* Skipped the big blue hero used on Store / Upload because My AI Stack
|
|
* is a control/settings surface, not a browse one. A slim heading keeps
|
|
* the focus on the two long lists below.
|
|
*/
|
|
.stack-header {
|
|
margin-bottom: 20px;
|
|
padding-bottom: 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.stack-header h1 {
|
|
margin: 0; font-size: 24px; font-weight: var(--font-bold);
|
|
color: var(--text-primary); letter-spacing: -0.3px;
|
|
}
|
|
.stack-header .sub {
|
|
margin: 6px 0 0; font-size: 13px;
|
|
color: var(--text-secondary); line-height: 1.6;
|
|
}
|
|
.stack-header .sub a { color: var(--primary); text-decoration: none; }
|
|
.stack-header .sub a:hover { text-decoration: underline; }
|
|
|
|
/* ── Section card (same visual as store_detail.section) ────────── */
|
|
.section {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border); border-radius: 12px;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
|
margin-bottom: 18px; overflow: hidden;
|
|
}
|
|
.section-header {
|
|
padding: 18px 24px 0;
|
|
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
|
}
|
|
.section-header .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
.section-header .eyebrow {
|
|
font-size: 10px; font-weight: var(--font-semibold);
|
|
text-transform: uppercase; letter-spacing: 0.8px;
|
|
color: var(--primary);
|
|
}
|
|
.section-header h2 {
|
|
margin: 0; font-size: var(--text-md); font-weight: var(--font-semibold);
|
|
color: var(--text-primary);
|
|
}
|
|
.section-header .count {
|
|
font-size: 12px; color: var(--text-secondary);
|
|
background: var(--border-light);
|
|
padding: 2px 8px; border-radius: 999px;
|
|
}
|
|
.section-hint {
|
|
padding: 8px 24px 0;
|
|
font-size: 12px; color: var(--text-secondary); line-height: 1.5;
|
|
}
|
|
.section-body { padding: 14px 24px 22px; }
|
|
|
|
/* ── List items ────────────────────────────────────────────────── */
|
|
.stack-list { display: flex; flex-direction: column; gap: 10px; }
|
|
.stack-row {
|
|
display: flex; align-items: center; gap: 16px;
|
|
padding: 12px 14px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border); border-radius: 10px;
|
|
transition: all 0.15s ease;
|
|
}
|
|
.stack-row:hover { border-color: var(--primary); }
|
|
.stack-row .meta { flex: 1; min-width: 0; }
|
|
.stack-row .name {
|
|
font-weight: var(--font-semibold); color: var(--text-primary);
|
|
font-size: var(--text-base);
|
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
}
|
|
.stack-row .name a {
|
|
color: inherit; text-decoration: none;
|
|
}
|
|
.stack-row .name a:hover { color: var(--primary); }
|
|
.stack-row .pill {
|
|
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
|
font-size: 10px; font-weight: var(--font-semibold);
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
background: var(--primary-light); color: var(--primary);
|
|
}
|
|
.stack-row .pill.slug {
|
|
background: var(--border-light); color: var(--text-secondary);
|
|
font-family: var(--font-mono); text-transform: none; letter-spacing: 0;
|
|
}
|
|
.stack-row .desc {
|
|
font-size: 12px; color: var(--text-secondary);
|
|
margin-top: 4px; line-height: 1.5;
|
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.stack-row .invocation {
|
|
font-family: var(--font-mono); font-size: 11px;
|
|
color: var(--primary); margin-top: 4px;
|
|
}
|
|
.stack-row .by { font-size: 11px; color: var(--text-secondary); margin-top: 4px; }
|
|
|
|
/* ── Toggle switch ─────────────────────────────────────────────── */
|
|
.switch { position: relative; width: 40px; height: 22px; flex-shrink: 0; cursor: pointer; }
|
|
.switch input { opacity: 0; width: 0; height: 0; }
|
|
.switch .slider {
|
|
position: absolute; inset: 0;
|
|
background: var(--border); border-radius: 22px;
|
|
transition: 0.15s; cursor: pointer;
|
|
}
|
|
.switch .slider::before {
|
|
content: ""; position: absolute; height: 16px; width: 16px;
|
|
left: 3px; bottom: 3px;
|
|
background: #fff; border-radius: 50%; transition: 0.15s;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
|
}
|
|
.switch input:checked + .slider { background: var(--primary); }
|
|
.switch input:checked + .slider::before { transform: translateX(18px); }
|
|
|
|
/* ── Buttons ───────────────────────────────────────────────────── */
|
|
.btn-uninstall {
|
|
appearance: none; padding: 7px 14px;
|
|
border: 1px solid rgba(234, 88, 12, 0.3);
|
|
background: var(--surface); color: var(--error);
|
|
border-radius: 8px;
|
|
font-size: 12px; font-weight: var(--font-medium); cursor: pointer;
|
|
font-family: var(--font-primary);
|
|
transition: all 0.15s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
.btn-uninstall:hover {
|
|
background: rgba(234, 88, 12, 0.08);
|
|
border-color: var(--error);
|
|
}
|
|
|
|
/* ── Store cards (compact — 4 per row at full width) ───────────── */
|
|
.store-grid {
|
|
display: grid; gap: 12px;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
}
|
|
@media (max-width: 1024px) { .store-grid { grid-template-columns: repeat(3, 1fr); } }
|
|
@media (max-width: 760px) { .store-grid { grid-template-columns: repeat(2, 1fr); } }
|
|
@media (max-width: 480px) { .store-grid { grid-template-columns: 1fr; } }
|
|
|
|
.store-card {
|
|
display: flex; flex-direction: column;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border); border-radius: 10px;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
|
overflow: hidden; transition: all 0.15s ease;
|
|
cursor: pointer;
|
|
}
|
|
.store-card:hover {
|
|
border-color: var(--primary);
|
|
box-shadow: 0 4px 14px rgba(0, 115, 209, 0.1);
|
|
transform: translateY(-1px);
|
|
}
|
|
.store-card .photo {
|
|
width: 100%; height: 96px; object-fit: cover;
|
|
background: linear-gradient(135deg, var(--primary-light) 0%, #fce7f3 100%);
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--primary);
|
|
font-size: 22px; font-weight: var(--font-bold);
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.store-card .body {
|
|
padding: 10px 12px; flex: 1;
|
|
display: flex; flex-direction: column; gap: 4px;
|
|
}
|
|
.store-card .type-badge {
|
|
display: inline-block; padding: 1px 7px; border-radius: 4px;
|
|
background: var(--primary-light); color: var(--primary);
|
|
font-size: 9px; font-weight: var(--font-semibold);
|
|
text-transform: uppercase; letter-spacing: 0.4px;
|
|
align-self: flex-start;
|
|
}
|
|
.store-card .name {
|
|
font-weight: var(--font-semibold); color: var(--text-primary);
|
|
font-size: 13px; line-height: 1.3;
|
|
overflow: hidden; text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.store-card .by-line {
|
|
font-size: 10px; color: var(--text-secondary);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.store-card .desc {
|
|
font-size: 11px; color: var(--text-secondary);
|
|
line-height: 1.4;
|
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.store-card .invocation {
|
|
font-family: var(--font-mono); font-size: 10px;
|
|
color: var(--primary);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.store-card .footer {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
gap: 6px;
|
|
padding: 8px 12px; border-top: 1px solid var(--border-light);
|
|
font-size: 10px; color: var(--text-secondary);
|
|
}
|
|
.store-card .footer .meta {
|
|
flex: 1; min-width: 0;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.store-card .footer button {
|
|
appearance: none; padding: 5px 10px; border-radius: 6px;
|
|
border: 1px solid rgba(234, 88, 12, 0.3);
|
|
background: var(--surface); color: var(--error);
|
|
font-size: 11px; font-weight: var(--font-medium); cursor: pointer;
|
|
font-family: var(--font-primary);
|
|
transition: all 0.15s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
.store-card .footer button:hover {
|
|
background: rgba(234, 88, 12, 0.08);
|
|
border-color: var(--error);
|
|
}
|
|
|
|
/* ── Empty state ───────────────────────────────────────────────── */
|
|
.empty {
|
|
text-align: center; padding: 36px 20px;
|
|
color: var(--text-secondary); font-size: 13px;
|
|
background: var(--background);
|
|
border: 1px dashed var(--border); border-radius: 10px;
|
|
}
|
|
.empty a { color: var(--primary); text-decoration: none; font-weight: var(--font-medium); }
|
|
.empty a:hover { text-decoration: underline; }
|
|
</style>
|
|
|
|
<div class="stack-page page-shell">
|
|
<header class="stack-header">
|
|
<h1>My AI Stack</h1>
|
|
<p class="sub">What ships into your Claude Code marketplace right now.
|
|
Toggle a granted plugin off if you don't want it; uninstall a Store
|
|
entity to drop it from your stack. Browse the
|
|
<a href="/store">Store</a> for community uploads.</p>
|
|
</header>
|
|
|
|
<section class="section">
|
|
<div class="section-header">
|
|
<div class="titles">
|
|
<span class="eyebrow">Curated marketplace</span>
|
|
<h2>Granted plugins</h2>
|
|
</div>
|
|
<span class="count" id="curated-count">0</span>
|
|
</div>
|
|
<div class="section-hint">
|
|
Plugins your administrator has granted to one of your groups. Default is
|
|
enabled. Switching off writes a personal opt-out — admin revoking the
|
|
grant later resets everyone back to enabled.
|
|
</div>
|
|
<div class="section-body">
|
|
<div id="curated-list" class="stack-list"></div>
|
|
<div id="curated-empty" class="empty" hidden>No plugins granted yet.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section">
|
|
<div class="section-header">
|
|
<div class="titles">
|
|
<span class="eyebrow">Community store</span>
|
|
<h2>From the Store</h2>
|
|
</div>
|
|
<span class="count" id="store-count">0</span>
|
|
</div>
|
|
<div class="section-hint">
|
|
Skills, agents, and plugins you've installed from the community Store.
|
|
</div>
|
|
<div class="section-body">
|
|
<div id="store-list" class="store-grid"></div>
|
|
<div id="store-empty" class="empty" hidden>
|
|
Nothing installed yet — visit the <a href="/store">Store</a> to browse.
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
async function loadStack() {
|
|
const res = await fetch('/api/my-stack');
|
|
if (!res.ok) {
|
|
alert('Failed to load: ' + res.status);
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
renderCurated(data.curated);
|
|
renderStore(data.store);
|
|
}
|
|
|
|
function renderCurated(items) {
|
|
const list = document.getElementById('curated-list');
|
|
const empty = document.getElementById('curated-empty');
|
|
document.getElementById('curated-count').textContent = items.length;
|
|
list.innerHTML = '';
|
|
if (!items.length) { empty.hidden = false; return; }
|
|
empty.hidden = true;
|
|
for (const p of items) {
|
|
const row = document.createElement('div');
|
|
row.className = 'stack-row';
|
|
const versionBit = p.version ? `<span class="pill slug">v${escapeHtml(p.version)}</span>` : '';
|
|
row.innerHTML = `
|
|
<div class="meta">
|
|
<div class="name">
|
|
<span class="pill slug">${escapeHtml(p.marketplace_slug)}</span>
|
|
${escapeHtml(p.plugin_name)}
|
|
${versionBit}
|
|
</div>
|
|
<div class="desc">${escapeHtml(p.description || '')}</div>
|
|
</div>
|
|
<label class="switch" title="${p.enabled ? 'Enabled' : 'Opted out'}">
|
|
<input type="checkbox" ${p.enabled ? 'checked' : ''}
|
|
data-mp="${escapeAttr(p.marketplace_id)}"
|
|
data-name="${escapeAttr(p.plugin_name)}">
|
|
<span class="slider"></span>
|
|
</label>`;
|
|
list.appendChild(row);
|
|
}
|
|
list.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
|
cb.addEventListener('change', async (e) => {
|
|
const cb = e.target;
|
|
const enabled = cb.checked;
|
|
const mp = cb.dataset.mp;
|
|
const name = cb.dataset.name;
|
|
const res = await fetch(
|
|
`/api/my-stack/curated/${encodeURIComponent(mp)}/${encodeURIComponent(name)}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({enabled})
|
|
}
|
|
);
|
|
if (!res.ok) {
|
|
cb.checked = !enabled;
|
|
alert('Failed to update');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderStore(items) {
|
|
const list = document.getElementById('store-list');
|
|
const empty = document.getElementById('store-empty');
|
|
document.getElementById('store-count').textContent = items.length;
|
|
list.innerHTML = '';
|
|
if (!items.length) { empty.hidden = false; return; }
|
|
empty.hidden = true;
|
|
for (const e of items) {
|
|
const card = document.createElement('div');
|
|
card.className = 'store-card';
|
|
card.dataset.href = `/marketplace/flea/${encodeURIComponent(e.entity_id)}`;
|
|
const photoMarkup = e.photo_url
|
|
? `<img class="photo" src="${escapeAttr(e.photo_url)}" alt="">`
|
|
: `<div class="photo">${escapeHtml(typeIcon(e.type))}</div>`;
|
|
const ownerLabel = e.owner_display_name || e.owner_username;
|
|
// v35: archived plugins still appear in My AI Stack (bundle still
|
|
// served to existing installs) but get a subtle badge so the user
|
|
// knows the owner / admin retired it. They can still Uninstall.
|
|
const archivedBadge = (e.visibility_status === 'archived')
|
|
? `<div title="Owner archived this plugin. It's hidden from the public Store + uninstallable elsewhere, but your existing install keeps working." style="position:absolute;top:8px;left:8px;padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600;background:#e5e7eb;color:#374151;border:1px solid #d1d5db;z-index:2;">Archived by owner</div>`
|
|
: '';
|
|
card.innerHTML = `
|
|
${archivedBadge}
|
|
${photoMarkup}
|
|
<div class="body">
|
|
<span class="type-badge">${escapeHtml(e.type)}</span>
|
|
<div class="name">${escapeHtml(e.name)}</div>
|
|
<div class="by-line">by ${escapeHtml(ownerLabel)}</div>
|
|
<div class="desc">${escapeHtml(e.description || '')}</div>
|
|
<div class="invocation">/${escapeHtml(e.invocation_name)}</div>
|
|
</div>
|
|
<div class="footer">
|
|
<span class="meta">${e.install_count} installed${e.category ? ' · ' + escapeHtml(e.category) : ''}</span>
|
|
<button data-id="${escapeAttr(e.entity_id)}">Uninstall</button>
|
|
</div>`;
|
|
card.addEventListener('click', () => {
|
|
window.location = card.dataset.href;
|
|
});
|
|
list.appendChild(card);
|
|
}
|
|
list.querySelectorAll('button[data-id]').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.id;
|
|
if (!confirm('Uninstall this entity from your stack?')) return;
|
|
const res = await fetch(`/api/store/entities/${encodeURIComponent(id)}/install`, {method: 'DELETE'});
|
|
if (!res.ok) { alert('Failed'); return; }
|
|
loadStack();
|
|
});
|
|
});
|
|
}
|
|
|
|
function typeIcon(t) {
|
|
if (t === 'skill') return 'SK';
|
|
if (t === 'agent') return 'AG';
|
|
if (t === 'plugin') return 'PL';
|
|
return '?';
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
|
}
|
|
function escapeAttr(s) { return escapeHtml(s); }
|
|
|
|
loadStack();
|
|
</script>
|
|
{% endblock %}
|