Skip to content

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.py daily 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}/action body {action: "update|deactivate|activate|delete"} → runs wp plugin ... on local stack, returns new state.
  • POST /api/v1/sites/{slug}/plugins/bulk body {plugin_paths: [...], action: "update|...”} → loops with progress events.
  • POST /api/v1/sites/{slug}/plugins/install/wp-org body {slug, sandbox: bool=false} — fetches the ZIP, installs (or sandboxes first).
  • POST /api/v1/sites/{slug}/plugins/install/zip — multipart, accepts a .zip file.
  • 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_uri points to GitHub (Plugin Update Checker pattern): 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

  1. 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.
  2. 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.
  3. 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.