ADR-0014: Plugin & theme manager — full WP admin parity in WP-Pulse¶
Status: Proposed Date: 2026-05-31 Deciders: Ramón Kamibayashi + Claude Opus 4.7 Related: ADR-0010 WP-Pulse, ADR-0013 Operator QoL, Pending-changes unified flow (in-session 2026-05-31)
Technical story: WP-Pulse already covers macro ops (pull, push, version, quarantine bisect). Plugin/theme management still requires the operator to hop into WP admin. We bring this into WP-Pulse so every WP-admin operation has a preview-first / one-click-apply variant, including discovery (search WP.org from inside WP-Pulse), pre-install sandboxing, and CVE awareness.
Context¶
Today the operator: - Goes to wp-admin → Plugins → click-by-click update / deactivate / delete (no bulk in dev mode, no preview) - Installs new plugins via wp-admin's "Add new" search → installs DIRECTLY on prod (no preview) - Has no CVE awareness — discovers vulnerabilities reactively - Cannot diff two versions of a plugin before update - Cannot try a plugin in isolation before committing
All these are bad for a hosting operator running customer sites. The new view: WP-Pulse is the WP admin for plugin operations.
Decision¶
Ship two unified surfaces under the existing site detail page:
/sites/[slug]/plugins— plugin manager (already a stub route, now a real page)/sites/[slug]/themes— theme manager (new route)
Both follow the preview-first contract: every mutation lands in the local stack (worktree + DB); the SiteHero "Apply to prod" badge promotes when the operator decides.
A — Plugin manager¶
Features¶
| # | Feature | UX |
|---|---|---|
| A1 | Unified plugin list | Table: name, version, status, last-updated, vulns, has-update. Sortable + filterable. Inline action menu per row. |
| A2 | Inline actions | Update / Deactivate / Activate / Delete / Quarantine / View changelog. All write to worktree + DB column; pending-changes badge picks them up. |
| A3 | Bulk select + bulk verb | Checkbox column + "Bulk: Update / Deactivate / Delete" action bar. Multi-row preview write in one call. |
| A4 | Changelog popover | Click "View changelog" on a row with available updates → fetch from WP.org api.wordpress.org/plugins/info/1.0/{slug}.json (or GitHub releases if update_uri points to GH) → highlight breaking-change keywords. |
| A5 | Vulnerability chip | Cross-ref Patchstack open feed (daily cron). Red badge on affected plugin row + CVE detail + "Update to fixed version" deep-link. |
| A6 | Install from ZIP | Drag-drop a .zip onto the page → upload → extract into worktree wp-content/plugins/ → preview shows plugin available + plugin marker in pending. |
| A7 | Install from WP.org search | Search input → query api.wordpress.org/plugins/info/1.2/?action=query_plugins&search=... → result cards (name, author, version, rating, last-updated, active-installs, has-CVE) → click Install → fetch ZIP from download_link → install in preview. |
| A8 | Premium license vault | Per-site or fleet-wide JSONB column premium_licenses {plugin_slug: license_key} → when a premium plugin installs/updates, auto-write its key via wp option update in local stack. |
| A9 | Compatibility matrix | Each row chip: "Tested up to WP 6.4 / PHP 8.2". Red if current site versions exceed tested-up-to. Source: WP.org plugin metadata. |
| A10 | Stale-plugin badge | "Last updated 3.4 years ago — likely abandoned" + suggestion engine pulling alternatives by tag from WP.org. |
| A11 | Sandbox before install | When clicking Install on a search result, optional toggle "Sandbox first" → spin ephemeral stack (F4 infra, reuse tt-* lane) with current site + ONLY the new plugin active → operator browses → choose "Install on preview" or "Discard". |
Data model¶
sites.premium_licenses jsonb default '{}'(A8) — alembic 020.- New table
plugin_vulnerabilities(A5):CREATE TABLE plugin_vulnerabilities ( plugin_slug text NOT NULL, affected_versions text NOT NULL, -- e.g. "<= 2.1.4" fixed_in text, cve text, severity text, -- "low|medium|high|critical" title text NOT NULL, url text, fetched_at timestamptz DEFAULT now(), PRIMARY KEY (plugin_slug, cve) ); CREATE INDEX ON plugin_vulnerabilities (plugin_slug); - New cron
patchstack_sync.pydaily 05:00 — fetches feed, upserts.
API surface¶
GET /api/v1/sites/{slug}/plugins→ array of{slug, name, version, active, path, has_update, latest_version, tested_up_to_wp, tested_up_to_php, author, plugin_uri, last_updated_at, premium_detected, vulnerabilities: [{cve, severity, fixed_in, title}]}— server-side join across manifest.plugins + plugin_vulnerabilities.POST /api/v1/sites/{slug}/plugins/{path}/actionbody{action: "update|deactivate|activate|delete"}→ runswp plugin ...on local stack, returns new state.POST /api/v1/sites/{slug}/plugins/bulkbody{plugin_paths: [...], action: "update|...”}→ loops with progress events.POST /api/v1/sites/{slug}/plugins/install/wp-orgbody{slug, sandbox: bool=false}— fetches the ZIP, installs (or sandboxes first).POST /api/v1/sites/{slug}/plugins/install/zip— multipart, accepts a.zipfile.GET /api/v1/wp-org/plugins/search?q=...— proxy WP.org search.GET /api/v1/wp-org/plugins/{slug}/changelog— proxy WP.org info + readme.GET /api/v1/sites/{slug}/plugins/{slug}/sandbox→{preview_id, hostname}— wraps F4 time-travel infra with the staged install.
UI surface¶
- New page
web/app/sites/[slug]/plugins/page.tsx(replaces the existing stub). - Components:
PluginTable.tsx— sortable/filterable table.PluginRow.tsx— single row with inline action menu.PluginActionMenu.tsx— dropdown.PluginInstallSearchModal.tsx— WP.org search + install.PluginInstallZipDropzone.tsx— drag-drop.PluginChangelogPopover.tsx.PluginVulnerabilityChip.tsx.PluginSandboxModal.tsx— boots an ephemeral stack via the F4 path, surfaces the preview URL, "Promote to preview" button.
Edge cases¶
- ZIP upload size cap: 50 MB.
- WP.org rate limits: cache search results 30 s; cache plugin info 1 h.
- Premium plugin install: detected by lack of WP.org metadata + plugin file presence. License vault is opt-in.
- A plugin's
update_uripoints to GitHub (Plugin Update Checkerpattern): respect it, fetch from GH releases. - Sandbox cleanup: reuse F4 cron sweeper.
B — Theme manager¶
Features¶
| # | Feature | UX |
|---|---|---|
| B1 | Theme list | Table: name, status (active/installed), version, parent, screenshot, update available. Install/Activate/Delete inline. |
| B2 | Install from ZIP / WP.org search | Symmetric to plugins. WP.org theme directory has its own search endpoint. |
| B3 | Theme preview switcher | "Try this theme" button → switches active theme in preview ONLY → operator browses, screenshots auto-captured (reuse F4 visual-diff infra) → "Revert" or "Apply to prod". |
| B4 | Child theme diff | When operator has a child theme, show diff vs parent (file-by-file) — useful for "did I customize this?" tracking. |
API¶
GET /api/v1/sites/{slug}/themes→ list.POST /api/v1/sites/{slug}/themes/{slug}/activate— sets active theme in preview.POST /api/v1/sites/{slug}/themes/install/wp-org/install/zip— install.DELETE /api/v1/sites/{slug}/themes/{slug}— delete (refuse if active).POST /api/v1/sites/{slug}/themes/{slug}/preview-switch→ activates + captures screenshots → returns visual diff job_id.
UI¶
- New page
web/app/sites/[slug]/themes/page.tsx. - Components:
ThemeGallery.tsx,ThemeCard.tsx,ThemePreviewSwitcher.tsx,ThemeChildDiff.tsx.
Migration plan¶
Alembic 020:
- sites.premium_licenses jsonb default '{}'
- new plugin_vulnerabilities table
No data backfill (premium licenses start empty; vulnerabilities populate from first cron run).
Rollout¶
Single phase. Deploy:
1. Backend endpoints + alembic + Patchstack cron.
2. UI pages + components.
3. Existing /sites/[slug]/plugins stub route → replaced.
Sub-agent: 1 sub-agent for plugin manager (A1-A11), 1 sub-agent for theme manager (B1-B4). Independent file ownership. Run in parallel.
Open questions¶
- CVE feed source: Patchstack (free tier, restricted) vs WPVulnDB (deprecated) vs WPScan API (paid). Recommended: Patchstack feed for v1 (~80% coverage of CVEs), revisit if false-negatives bite.
- Sandbox isolation: each sandbox = a full F4 ephemeral stack (~150 MB RAM, ~30 s spin-up). Cap at 1 concurrent per server-wide to avoid sprawl.
- Plugin install via SFTP fallback: when a customer host blocks WP-Pulse agent file writes, fall back to direct SFTP. Out of scope v1 — defer to a future "low-priv install" ADR.