Files
muninn/docs/superpowers/specs/2026-07-19-wordpress-publish-design.md
T
Doar Mihai deb8bcce8b docs: add design spec for WordPress publishing command
Extends Sites with cms_type + WP Application Password credentials
and adds a posts:publish Artisan command to push a Post to a
WordPress site's REST API, mirroring the reddit-post sidecar's
role for a native, non-browser-automation CMS target.
2026-07-19 16:21:46 +03:00

7.1 KiB

WordPress Publishing — Design

Purpose

Extends the existing Sites & Posts data model so a Site can be marked as a WordPress-powered CMS with its own REST API credentials, and adds an Artisan command that publishes an existing draft/scheduled Post to that site via the WordPress REST API. This is the WordPress equivalent of the reddit-post sidecar, but implemented natively in Laravel rather than as an external Playwright process, since WordPress's REST API is a direct HTTP call and the Post/Site data already lives in this app's database.

Scope

  • Distinguish a Site's CMS by a cms_type field: wordpress, custom, or other.
  • Store WordPress Application Password credentials (wp_username + wp_app_password) on a Site.
  • Add php artisan posts:publish {post} — loads a Post by ID, publishes it to its Site's WordPress REST API, and updates the Post's status and published_url.
  • Update the Sites Livewire UI to manage cms_type and the WordPress credential fields.
  • Expose the new Site fields via the existing read-only Sites API, and the new Post field via the existing Posts API.

Out of scope:

  • Any CMS integration beyond WordPress — custom and other are labels only, with no publish path.
  • Updating/editing an already-published WP post, deleting a WP post, or any WP endpoint beyond creating a post.
  • Media/image uploads, categories, tags, excerpts, or any WP post field beyond title/content/status.
  • Scheduled publishing via WP's own future post status — this command always publishes immediately (status: publish) when invoked; scheduling is this app's own Post.scheduled_at + AI-driven polling, unchanged from the existing design.
  • Authentication/authorization on the command, UI, or API (consistent with the rest of the app — local network only).
  • Any plugin-based WP auth scheme (JWT, API-key plugins) — Application Passwords (built into WordPress core 5.6+) only.

Data Model

sites table — new columns

Column Type Constraints
cms_type string (enum) required, default custom — one of: wordpress, custom, other
wp_username string nullable
wp_app_password string nullable, plain text (no hashing/encryption — same precedent as social_accounts.password)

wp_username/wp_app_password are only meaningful when cms_type = wordpress; no DB-level constraint enforces this pairing (validated at the UI/command level instead).

posts table — new column

Column Type Constraints
published_url string nullable — set on successful publish; generic across future publish targets, not WordPress-specific

Command: php artisan posts:publish {post}

Arguments: one required argument, the Post id.

Flow:

  1. Load the Post by id (404/"post not found" error and exit 1 if missing). Eager-load its site relation.
  2. Validate, in order, failing fast with a descriptive message and exit code 1 on the first failure:
    • post.type === 'blog' — else "Post {id} has type '{type}', expected 'blog' to publish to a CMS."
    • post.site.cms_type === 'wordpress' — else "Site '{name}' is not configured as WordPress (cms_type={cms_type})."
    • post.site.wp_username and post.site.wp_app_password are both present — else "Site '{name}' is missing WordPress credentials."
    • These three pre-flight checks happen before any DB write to the Post — a mismatched post/site pairing is a usage error, not a publish failure, so the Post's status is left untouched in these cases.
  3. Send POST {site.url}/wp-json/wp/v2/posts via Guzzle, HTTP Basic Auth (wp_username / wp_app_password), JSON body:
    { "title": post.title, "content": post.body, "status": "publish" }
    
  4. On success (2xx response with a link field in the JSON body):
    • Update the Post: status = 'published', published_url = response.link.
    • Print {"success": true, "url": "<published_url>"} to stdout, exit 0.
  5. On failure (network/connection error, non-2xx response, or malformed response missing link):
    • Update the Post: status = 'failed'.
    • Print {"success": false, "error": "<description>"} to stdout, exit 1.
    • Error description includes the WP error message when the response body has one (WP REST errors return {code, message, data}), otherwise a generic description of the failure (e.g. "could not connect to site", "unexpected response from WordPress").

Title handling: Post.title is nullable in the existing schema, but WordPress requires a title. If post.title is null/empty at step 3, this is a pre-flight validation failure (same category as step 2): "Post {id} has no title, required to publish to WordPress." — exit 1, Post status untouched.

UI (Livewire)

Sites create/edit form gains:

  • CMS Type — dropdown: WordPress / Custom / Other (default Custom).
  • When WordPress is selected, two additional fields appear:
    • WP Username — text input.
    • WP Application Password — masked input with reveal toggle (same convention as the Social Accounts password field). Not required at the DB level, but the form validates both are present together when cms_type = wordpress is submitted (client-side/Livewire validation — DB stays nullable to avoid migration headaches if a site is later switched away from WordPress).

Sites table view gains a CMS Type column. No changes to the Posts UI (the new published_url field is visible as a link in the Posts table's existing "actions" area or a new column — small addition, decided at plan time).

API

No new endpoints.

  • GET /api/sites / GET /api/sites/{id} — SiteResource now also returns cms_type, wp_username, wp_app_password (plain text), consistent with the existing precedent that Social Accounts' API already returns plain-text passwords, since the AI agent needs credentials to act directly.
  • GET /api/posts / GET /api/posts/{id} — response shape gains published_url.

Testing / Edge Cases

  • Feature test: posts:publish on a post with type != 'blog' fails with the expected message, exit 1, Post status unchanged.
  • Feature test: posts:publish on a post whose site has cms_type != wordpress fails accordingly, Post status unchanged.
  • Feature test: posts:publish on a WordPress site missing credentials fails accordingly.
  • Feature test: posts:publish on a post with no title fails accordingly.
  • Feature test: successful publish (mocked HTTP via Guzzle's mock handler / Laravel's Http::fake equivalent) — asserts Post updated to status=published with correct published_url, and the printed JSON/exit code are correct.
  • Feature test: WP API returns an error response (e.g. 401 invalid credentials, 500) — asserts Post updated to status=failed, correct error message surfaced, exit 1.
  • Feature test: WP API unreachable (connection exception) — asserts graceful failure, status=failed, exit 1.
  • Livewire test: Sites form shows/hides WP fields based on cms_type selection, and validates the paired-fields rule.
  • Migration test/manual check: existing sites default to cms_type=custom with null WP fields (no backfill needed beyond the column defaults).