loopress★ GitHub

changelog

What shipped.

Alpha, moving fast. Every release of the CLI, the WordPress plugin and the MCP server, newest first.

@loopress/cli

0.33.0

Minor Changes

  • 0bf479c: Dynamic blocks (Loopress Full): lps block push | pull | list | diff | rollback | rm and the block_* MCP tools deploy blocks/<slug>/ folders (block.json + render.php, optionally style.css and view.js) registered as the server-rendered block loopress/<slug>. Each block’s CSS and JS load only where it renders, it works inside a Query Loop, and a render.php that throws renders nothing instead of breaking the page. lps push, lps pull, lps diff and lps validate include them.
  • 4613ded: Loopress Light no longer breaks the aggregate commands. GET loopress/v1/capabilities now lists the site’s modules, and lps pull, lps push (dry run included), lps diff and so lps promote skip a Full-only step (“needs Loopress Full”) when the project has no files for it. When it does, lps push refuses before pushing anything, lps pull and lps diff report it. lps push and lps theme push skip templates and parts when the project has none, even with activateChildTheme (use lps theme activate for an empty child), and lps theme template push alone refuses with “Nothing to push”. The removed SEO module warning is gone.
  • da7d681: The plugin now exposes loopress/v1/capabilities (edition, version, environment type, ACF/form/snippet integrations, active theme and Loopress child theme), and the CLI requires it: an older plugin is asked to update. lps push refuses upfront, with the fix, when the project uses snippets, forms or ACF the site can’t hold, instead of failing at that step after pushing everything before it. New lps theme activate (the Loopress child theme by default, which starts from its parent’s theme mods and Global Styles) and "activateChildTheme": true in loopress.json to keep the child active on lps theme template push. Global Styles keep the parent’s file name while the child is active. lps content copy activates locally the theme and plugins active on the source. lps doctor reports the site’s capabilities.

Patch Changes

  • 47d5a68: Consistency fixes from the 2026-10-27 audit. lps api and lps hook help examples pass the directory as a positional argument (there is no --path flag), and the public-route warning links to docs.loopress.dev. lps --help describes the app, form, hook, menu, option and page topics. The MCP server reports its real version, and its missing-project error names lps init. Docs: API route and hook pushes are all or nothing, the declared resources appear in the workflow page, plus the snippet sidecar fields, the project config prompts and the push exceptions that do delete.
  • cbe0bd9: Fix three CLI edge cases: lps plugin audit now aborts on a malformed vulnerability response instead of reporting it as clean, composer pull no longer treats a wpackagist theme as the provider of a plugin with the same slug (or the reverse), and "show_in_rest": null in a meta file is now rejected.
  • b038a67: lps plugin audit now reads WordPress.org’s last_updated date form, so abandoned plugins are flagged again. lps option list keeps a COLUMNS value you set. The top-level code check now catches code placed right after an attribute (#[Attr] foo();).
  • f24e50f: Narrow untyped data at the boundary instead of casting it. Malformed local files now fail with a clear error instead of crashing later: a composer.json with a non-string version, a non-object loopress.json or Global Styles file, an app package.json with non-string scripts. A malformed auth.json reads as logged out, an invalid snapshot file is skipped in listings, and an error thrown as a non-Error now shows its text instead of undefined.
  • b698800: Validate local option files, loopress.json plugin pins, composer.json sections and declared type arguments with zod. lps validate now reports a composer.json that is not a JSON object instead of crashing.

0.32.2

Patch Changes

  • ac984c0: lps diff, push dry runs and the outside-edit check no longer report drift on ACF field groups or Global Styles that are in sync. ACF stores every setting with its default (wrapper, aria-label…), which a hand- or agent-written field group leaves out: the local files are now compared as the site would store them, through a new read-only plugin route (POST loopress/v1/acf/normalize/<type>) that runs them through ACF’s own validation without saving anything. Global Styles now compare a preset list and its origin-keyed form (palette: [...] and palette: {custom: [...]}) as the same, and a var:preset|color|base reference as its var(--wp--preset--color--base) expansion, the two forms WordPress rewrites on save. With a plugin older than this release, ACF files are compared as written, as before. If the site can’t be read for the outside-edit check after an earlier push, the push now stops instead of skipping the check.

0.32.1

Patch Changes

  • 26fb36c: lps validate and lps hook push no longer reject a hook whose method outputs a template (?> <p>...</p> <?php inside the class) as “code outside the class”: only inline HTML at the top level of the file is flagged, as the plugin does.

0.32.0

Minor Changes

  • 3f3beb9: lps init no longer asks for a snippet provider. Most projects never use snippets, and the question came before any choice about them. To use snippets on a site that has neither Code Snippets nor WPCode, run lps plugin add code-snippets (or insert-headers-and-footers for WPCode).
  • 35310b3: The MCP server now tells a connecting agent what a Loopress project is and links the documentation of each file format, so an agent writes a Loopress hook instead of plain WordPress code. lps validate (and the MCP validate_local tool) now runs the same checks on hook and API route files as push: a valid file name, declare(strict_types=1); exactly once, and no code outside the class (a hand-written ABSPATH guard included, the plugin adds its own), and declare(strict_types=1); as the first statement. Those problems now surface before push; PHP syntax and class-name collisions are still checked by the server at push.
  • ad06a9a: lps page push can now take over a page with the same slug that Loopress did not create, instead of refusing it. It lists those pages and asks once, or takes them over directly with --overwrite (required in CI and through the MCP server). The page keeps its URL and ID, so menus, links and SEO data keep working, and its original content is saved as a WordPress revision first.
  • ad938a6: lps push and lps pull (and so lps promote, lps content copy, push_all and pull_all) now include Global Styles once the project tracks them, on a site whose active theme is a block theme, and skip them with a one-line note otherwise. Theme versions stay with lps theme push. ACF, forms and snippets are skipped when the project has no directory for them, so lps push --dry-run (and the push_all preview) no longer fails with a 409 on a site without a form plugin, and lps diff no longer reports such a resource as inconclusive.
  • df54b2d: A push’s dry run now says what it would change: each local item is created, changed or unchanged against the site, in the human output and under changes in --json (existing fields are kept). lps acf push also names the fields added, changed or removed in a changed field group, and lps theme template push lists templates, parts and CSS files separately, with removed for a file the child theme would drop. lps push and lps theme push now return each step’s own result. The MCP push tools’ previews are these results, and their descriptions now tell the agent so. The plugin’s child theme endpoint reports a hash of each CSS file, so the dry run can tell whether the theme CSS changed.
  • 515153d: lps scf is now an alias of lps acf, for sites running Secure Custom Fields instead of ACF.

0.31.0

Minor Changes

  • 60ef881: New lps content copy <from>: replaces the local site’s posts, pages, public custom post types and terms with those of <from>, then pushes the project to the local site so tracked configuration wins. Media stay served by the source site, users, comments and orders are never copied.

  • 5bb3a54: lps hook list shows the last error each hook file threw while running, and lps doctor gets a “Hooks load and run without errors” check that fails on a hook that couldn’t load or keeps throwing. Needs the matching plugin release.

  • ad4500c: A push no longer silently overwrites changes made on the site outside Loopress (wp-admin, another tool). Before writing, it compares the site with the state your last push from this machine left it in, using the existing rollback snapshots: an object changed there since, that the push would write a different version over, stops the push and is named (a terminal asks instead, a --dry-run only warns). Pass --overwrite to push anyway, --yes doesn’t imply it. Pages now keep snapshots too, for this check.

    lps push stops at the first resource that fails and lists the ones it didn’t attempt, instead of pushing the rest on top of a failed dependency (hooks without the Composer packages they use). Plugins and Composer are skipped when the project doesn’t manage them.

    A site that can’t be reached now says why (connection refused, host not found, timeout) with a hint to run lps doctor, instead of an empty reason, and lps diff prints an error shared by every resource once. Its header reads “local files” instead of “local → local”.

Patch Changes

  • 90779f2: lps snippet push keeps an HTML snippet’s inline <?php ... ?> block instead of stripping it, keeps the description from the snippet’s .json file instead of replacing it with “Imported from ”, and skips, with a failed push, every file whose id another file also uses, instead of letting the last one silently overwrite the other on the site. lps diff now shows a removed <?php on a non-PHP snippet as drift. lps validate accepts the {"version", "active"} plugin pin lps plugin pull writes for an inactive plugin. lps promote takes --overwrite, passed to its push, and says the local files may already hold part of <from> when its pull fails. lps hook list says “an action”, and lps doctor no longer repeats “failed to load”. lps push skips snippets on a project with no snippets/ directory instead of ending on an ENOENT error. lps form push writes the id WordPress assigned into a new form’s file, so the next push updates that form instead of creating a duplicate. lps theme version pull skips a theme with no version, the Loopress child theme included, instead of pinning it to an empty version lps theme version push then fails on. lps plugin audit reads the affected version range wpvulnerability.net actually returns (operator), so it no longer reports every advisory ever published for a plugin, audits a latest pin as the newest WordPress.org release, decodes HTML entities in advisory names, and keeps its --json output when it exits with 1. lps composer push names the WordPress.org plugins and themes the site has and composer.json lacks, which the push uninstalls, on a dry run too, and asks before uninstalling them in a terminal. lps pull (and so lps promote) skips ACF, forms and snippets, instead of failing, on a site without the plugin they need when the project has no directory for them.

0.30.0

Minor Changes

  • a1a5147: New lps cpt commands for custom post types declared as cpt/<slug>.json files: push, pull, list (every post type on the site and its source), rm, diff and rollback. lps push and lps pull include them, lps validate checks the file names and refuses arguments that would run code. Needs the matching plugin release.
  • aa60465: Commands no longer remember an environment. Without --env, they target the project’s local environment, or its only environment when it has just one. A project with several environments and no local one needs --env on every command. lps project switch now picks a project only, and lps project list tags the default environment with [default] instead of an arrow on the switched one (isDefault replaces isCurrent per environment in --json). An existing config.json keeps working: the env field of currentProject is ignored and dropped on the next write.
  • 049d127: New lps meta commands for post meta keys declared as meta/<post-type>.json files: push, pull, list (every registered meta key per post type, and whether Loopress declares it), rm, diff and rollback. Values are never synced. lps push, lps pull and lps validate include them. Needs the matching plugin release.
  • 22894f6: Breaking: the lps seo command group (pull, push, list, diff, rollback) is removed, along with its step in lps pull, lps push and lps diff and the seoDir key in loopress.json. Loopress versions configuration, never content: per-post SEO meta and redirects belong to the content editors manage in wp-admin, and most of that meta is data the SEO plugin generates itself. The site-wide SEO settings are one option row, tracked like any other option. To migrate, run lps option add rank-math-options-titles (Rank Math) or lps option add wpseo_titles (Yoast SEO), then delete the seo/ folder and any seoDir key. lps pull and lps push warn while either is still there.
  • 03d3da0: A successful push now tells the site it happened (once per lps <resource> push, never on a dry run), so the plugin’s Overview tab can show what was pushed, when, and whether it changed since. An older plugin that doesn’t know about it is ignored silently.
  • cd4b340: New lps role commands for user roles declared as roles/<slug>.json files, and lps pattern commands for block patterns written as patterns/<slug>.html files (block markup under a title/categories header, registered as loopress/<slug>, Loopress Full only): push, pull, list, rm, diff and rollback for both. lps push, lps pull and lps validate include them, patterns pushed before templates and pages. Needs the matching plugin release.
  • 66790cb: New lps taxonomy commands for taxonomies declared as taxonomies/<slug>.json files: push, pull, list (every taxonomy on the site, its source and the post types it attaches to), rm, diff and rollback. Terms are never synced. lps push, lps pull and lps validate include them. Needs the matching plugin release.
  • 7139fd8: lps theme template push now also pushes the theme CSS, bundled with esbuild: theme/css/style.css (the entry, it @imports the rest of theme/css/ in cascade order) becomes the Loopress child theme’s style.css, loaded on every page, and each theme/blocks/<namespace>/<block>.css is loaded only on pages that render that block. Relative url() fonts and images are copied next to the CSS. The push warns about a block file named after no block registered on the site. New cssDir and blockCssDir keys in loopress.json, and lps dev watches both folders (css, blocks). Needs the matching plugin release.
  • 5fdd7ee: Block templates and template parts now live under theme/, next to the Global Styles file: theme/templates/ and theme/parts/ by default instead of templates/ and parts/ at the project root. Everything lps theme manages sits in one directory. A project that keeps the old layout sets templateDir: "templates" and partDir: "parts" in loopress.json.

Patch Changes

  • 95d9f8c: lps dev runs a push once per batch when several changed types share it: saving a template and a stylesheet together (templates, parts, css, blocks) rewrites the child theme once instead of once per type.
  • 3bbe20d: Error reports sent to Sentry no longer include HTTP headers, bodies, cookies, query params or stack frame variables, which Sentry 11 now collects by default.
  • b87fa13: lps project config now removes any temporary administrator account left behind by an interrupted Loopress Full install (a killed process, a lost connection), and lps doctor reports one as a failed check. Only accounts matching both the lps-temp- username prefix and the @lps-temp.invalid email are touched.

0.29.0

Minor Changes

  • cc5ede1: Options that store a page ID (EDD’s checkout pages in edd_settings, WooCommerce’s woocommerce_shop_page_id…) can now be synced between environments. Declare them in the option file with "refs": {"purchase_page": "page"}, then lps option pull writes the page’s path instead of its ID, and lps option push turns it back into the ID of that page on the target. A page missing on the target fails the push for that option with a clear message.

  • cc5ede1: lps plugin pull no longer adds every plugin installed on the site. Plugins the project already tracks are refreshed, and the others are offered as a list to tick, unticked by default, so a pull from production doesn’t bring its cache, security or backup plugins into the project. Without a terminal (--yes, --json, CI, the MCP server), only tracked plugins and those named with the new --plugin <slug> flag are written; the MCP plugin_pull tool takes them as plugins.

    Plugins under require-dev in composer.json are now installed on the local environment by lps plugin push and lps composer push, and still never on a remote site: use it for Query Monitor, or for a premium plugin’s free edition while production runs the paid one.

  • bee1b73: Breaking: everything theme related now lives under lps theme. lps template push/list/diff is now lps theme template push/list/diff, and the versions-only lps theme push/pull is now lps theme version push/pull. lps theme push now pushes the whole topic in order (versions, then templates and parts, then Global Styles), and lps theme pull pulls versions and Global Styles. lps theme style push accepts --yes. In the MCP server, theme_push/theme_pull now wrap the whole topic, the new theme_version_push/theme_version_pull keep the versions-only behavior, and the template_* tool names are unchanged. The template and part resource names for lps diff --only/--skip are unchanged.

Patch Changes

  • cc5ede1: A push’s --dry-run now reads the target site, the same way lps diff does, instead of staying local. It used to announce ACF, SEO, forms or snippets as pushed on a site that would refuse them (plugin missing), and the real push then failed; the dry run now fails the same way. The MCP server’s preview and pre-apply check, built on the dry run, benefit too. lps push --dry-run also says “would push” instead of “pushed”.
  • 426df4b: A refused WordPress or Loopress API request no longer ends its error with the raw Caused by: HTTPError ... Code: ERR_NON_2XX_3XX_RESPONSE lines: the server’s own reason, already in the message, is now the last thing printed. LPS_DEBUG=1 still shows the whole chain.
  • 69ed247: lps acf, lps seo, lps form, lps menu and lps theme style push/pull now accept --json. The MCP server runs every command with --json, so acf_push, acf_pull, seo_push, seo_pull, form_push, form_pull, menu_push, menu_pull, theme_styles_push and theme_styles_pull always failed with “Nonexistent flag: –json”. A test now checks that every command the MCP server can call supports --json.
  • 69ed247: The MCP server now refuses a change to production even when env is omitted and production is the active environment. Before, only an explicit env: "production" was refused, so hook_rm, api_rm, a *_rollback or a push with prune could reach production without any human confirmation. A mutating tool called without env now resolves the active environment first and pins it, so the confirmed call also applies to the environment that was previewed, even if the active one is switched in between. lps status --json reports the resolved environment name for this.
  • 69ed247: The examples of lps snippet push, lps snippet pull, lps snippet publish and lps api publish no longer show a --path flag that does not exist: the directory is a positional argument (lps snippet push ./snippets).
  • b77aafe: lps api remove and lps hook remove are now aliases of lps api rm and lps hook rm, matching lps app remove and lps option remove. rm keeps working.
  • e57f3a0: lps init no longer asks for a snippets directory or a list of feature directories. It writes only projectId and rootDir, every resource uses its default directory, and a custom name is a <kind>Dir key in loopress.json. The suggested next step is now lps pull.
  • 426df4b: Template and part file names now accept _, like the post type and taxonomy slugs the template hierarchy embeds: templates/taxonomy-download_tag.html or templates/single-my_type.html no longer stop lps theme template push. Page file names keep their stricter rule.
  • a3c5097: lps project config: “Authorize in my browser” works again. It was always rejected with “Rejected a cross-origin request to the login callback server”, then fell back to manual entry. WordPress now redirects straight to the CLI on 127.0.0.1, without going through api.loopress.dev, so the Application Password never transits through a Loopress server. This needs WordPress 7.0 or later: on an older site, detected from its public version tag, the command goes straight to manual entry and says why.

0.28.0

Minor Changes

  • cd9479d: lps app push now builds each app before pushing it: when apps/<name>/package.json has a build script, it runs with the app’s package manager (from packageManager, else the nearest lockfile, else npm), on --dry-run too, so a forgotten rebuild no longer ships a stale dist/ reported as “up to date”. A failing build fails that app and uploads nothing. Pass --no-build (MCP: noBuild) when CI already built. Dependencies are never installed for you.

  • 4d1c8af: Apps can now stop other sites from embedding their files: set "crossOrigin": "same-origin" (or "same-site") in loopress.app.json, and on lps app push the plugin writes an .htaccess in the app’s folder that sends Cross-Origin-Resource-Policy with every file. It overrides a site-wide cross-origin value set by a security plugin, and is removed when the setting is dropped. lps app pull keeps the setting, and lps app push warns when it was not applied (plugin too old, or an app .htaccess the site owner manages, which is left untouched). Apache and LiteSpeed only (nginx ignores .htaccess).

  • 20a406c: Block templates and parts: templates/<slug>.html and parts/<slug>.html are written by lps template push as the files of a child theme of the active block theme, <parent>-loopress, the parent staying untouched. Any template of the hierarchy can be overridden, and a template with a title/postTypes header becomes a page template a static page picks with its template header (no header or footer part gives a bare page). Edits made in the Site Editor are never overwritten and show up as drift in lps template diff and lps diff. Part of lps push (before pages), lps dev, lps init and lps validate. MCP gains template_push, template_list and template_diff.

  • b1c46ac: lps plugin pull and lps theme pull no longer stop when the project has a composer.json: they pin the live versions into its require as wpackagist-plugin/<slug> / wpackagist-theme/<slug> entries, leaving every other key untouched. A version constraint you wrote (^9.4) is kept, only exact pins move; packages in require-dev, provided by another package, or absent from WordPress.org (premium, custom) are skipped and listed in the output. loopress.json is not modified in that case. The file keeps its original indentation. If the WordPress.org check fails, the pull stops with the reason and leaves composer.json untouched.

    loopress.json now records whether each plugin is active: lps plugin pull writes an inactive plugin as {"version": "1.7.2", "active": false}, lps plugin push keeps it inactive (and deactivates it if it was switched on), and lps plugin status reports it as drift when it is active. A plain version string still means active, so existing files keep working. Before, a pull followed by a push activated every plugin that was inactive on the site.

    lps plugin push no longer stops on a project with a composer.json: it reads the wpackagist-plugin/* entries, pushes the whole file (themes and libraries included) and keeps its safety around the file swap, but activates nothing unless --activate is passed. lps push then skips composer push so Composer runs once. Composer constraints like ^9.4 are no longer reported as version drift.

  • 61350a3: Breaking: lps theme-styles pull/push/diff/rollback is now lps theme style pull/push/diff/rollback, grouped with the other theme commands. The theme-styles resource name for lps diff --only/--skip and the MCP tool names (theme_styles_*) are unchanged, and existing rollback snapshots still work.

Patch Changes

  • 4d1c8af: lps app pull no longer overwrites an existing loopress.app.json: files go into its assetsDir and its entry is kept, so an app with a custom build folder or an explicit entry can still be pushed after a pull. The .htaccess files Loopress writes under wp-content/loopress/ (apps/, vendor/) are now updated when a new plugin version changes them (for example the nosniff header on apps), instead of only being written once. The rewrite is atomic (temp file + rename), so a request never sees half-written rules. A file whose first # Loopress: line was removed is left alone.
  • eb417c3: lps promote <from> <to> --dry-run now previews what <from> would really push: it pulls <from> into a throwaway copy of the project and dry-runs the push from there, instead of previewing your current local files. App password auto-rotation is skipped in CI (the runner can’t persist the new password, so rotating would revoke the one stored in the CI secret), with a warning to run lps project rotate where the config lives. A 403 with an empty body now shows the underlying error instead of ending on a bare colon.

0.27.0

Minor Changes

  • d043549: lps api push refuses a root api/index.php before any network call, with a message pointing to a named or nested file (orders/index.php is the /orders route). lps page push says when a pushed pages/home.html became, or stopped being, the site’s front page.
  • bb32133: The CLI now requires Node.js 22 or later (previously declared as 18+, but lps validate already relied on Node 20.12+ APIs). Node 22 is the oldest release line still receiving security fixes.

Patch Changes

  • 1cd51fc: lps api push now follows a symlink to a route file instead of silently skipping it (“Found 0 route files to push”). Broken symlinks are still ignored.

  • bb32133: lps dev now also watches hooks/ and pushes hook files on change. A request the Loopress plugin refuses on purpose with a 403 (for example reading a secret-looking option) now shows the plugin’s own reason instead of “Authentication failed, check your credentials”, and HTML entities in server messages (&quot;) are decoded. Errors from the Loopress cloud API now show the API’s own message (validation errors included) instead of a generic “Request failed with status code 400”.

    lps validate now checks JSON files at any depth (ACF’s acf/<type>/, SEO’s post-meta/ and redirects/ were silently skipped before), tracked option files (a missing name, autoload or value is reported), and the theme styles directory.

    lps plugin add and lps theme add support --json, and the MCP server gets matching plugin_add and theme_add tools (local loopress.json edit only).

    Rollback snapshots (.loopress/snapshots/, inside your project) hold the environment’s full remote state; the .loopress/ directory now writes its own .gitignore so they are never committed by accident.

    lps push, lps pull and lps promote with --json now print a single valid JSON document: the progress output of the resource commands they run goes to stderr instead of polluting stdout, which broke the MCP push_all, pull_all and project_promote tools. A failed aggregate run now lists each failed resource and its reason.

  • e042cde: Fix the MCP server on Windows: every tool failed with ENOENT because it spawned lps without a shell, and Windows installs it as lps.cmd, which Node refuses to run directly. The server now starts the installed CLI’s bin/run.js with its own Node, falling back to lps on PATH through tinyexec, which also works on Windows. LPS_BIN also accepts a .js file. @loopress/cli now exports its package.json, and the MCP server now declares Node 22 or later, like the CLI.

    lps init now adds * text=auto eol=lf to .gitattributes, so a checkout on Windows (CRLF by default) keeps the same bytes as what pull writes.

0.26.0

Minor Changes

  • b60df46: Adds lps page push [SLUG], lps page list and lps page diff for static HTML pages (pages/<slug>.html, pageDir in loopress.json), with an optional <!-- title: ... / status: draft|publish --> header. Pages are part of lps push, lps diff --only page, lps dev and lps init, and are checked by lps validate. The MCP server gets matching page_push, page_list and page_diff tools, and a CLI command that exits non-zero with a regular JSON payload (a diff reporting drift) is now returned as a result instead of an error. Requires Loopress Full.

0.25.0

Minor Changes

  • c15b4b9: Makes api push and hook push atomic (#236). Previously, pushFile() sent one independent HTTP PUT per file: a push of N files that failed partway through (network error, a stale expectedRevision, a class collision, a syntax error on file K) left files 1..K-1 already live on the site and K..N untouched, a mixed state discoverable only after the fact via lps diff.

    AbstractFilesDirectory gains a batch staging API (beginBatch/stageWrite/stageDelete/commitBatch/abortBatch): a batch stages into an inactive sibling directory seeded with a copy of every currently live file, and commitBatch() swaps it into place with a directory rename(), atomic on the filesystems WordPress actually runs on. AbstractFilesController gains a POST {routePath}/batch endpoint that validates and stages every file (and every --pruned filename) of one push, and only calls commitBatch() once every single one has succeeded; any failure aborts the batch and leaves the live directory completely untouched.

    api push/hook push now send one batch request instead of N sequential PUTs, folding --prune into the same atomic swap rather than a separate round of DELETEs after the fact. The single-file PUT/DELETE endpoints are unchanged, still used by lps <resource> rm and any direct API integration.

  • 3ec619c: lps doctor now supports --json, returning a structured {project, environment, url, checks, ok, pluginVersion} result instead of throwing on failure (its human-readable output and exit code are unchanged).

    The MCP server gains three read-only tools that were missing from CLI parity: project_diff (drift between local files and an environment, or between two environments), project_doctor (connectivity, plugin and credential diagnostics), and validate_local (checks local tracked files without contacting WordPress).

  • 1607fc5: Adds a menu resource for syncing WordPress navigation menus and menu locations. The plugin exposes loopress/v1/menus and loopress/v1/menu-locations REST endpoints, resolving menu items by identity (post type/taxonomy + slug) rather than raw object ids so they remain portable between environments. The CLI gains lps menu list/pull/push/diff, wired into lps push/lps pull/lps diff/lps init and local file validation, and the MCP server gains matching menu_push/menu_pull/menu_list tools.

  • 10d18ec: lps push, lps pull, and lps promote now support --json (each returns a structured per-resource result instead of erroring with “Nonexistent flag: –json”). This was a real bug in the MCP server’s push_all and pull_all tools, which always shell out with --json and so always failed.

    The MCP server also gains project_promote, the tool for lps promote that CLI parity was missing.

  • ce19505: Adds lps <resource> rollback for the 9 resource-state-backed resources (snippet, form, acf, api, hook, seo, menu, option, theme-styles). lps <resource> push now writes a snapshot of the environment’s state (before and about-to-be-pushed) to a local, gitignored cache (.loopress/snapshots/<resource>/, capped at the last 10 per resource) right before every real push. lps <resource> rollback restores the most recent snapshot (or --to <id>), --list shows what’s available. It refuses to overwrite a change made to the environment since the original push unless explicitly confirmed (interactive prompt, or --yes), the same shape as the production-push guard. No WordPress-side capability is required: restoring materializes the archived state into the resource’s own local file layout, then delegates to the existing push command.

    The MCP server gains a matching <resource>_rollback tool per resource, following the existing confirmToken handshake (list: true bypasses it as a plain read).

  • 4432466: New lps theme-styles pull/push/diff command group syncs the active block theme’s Global Styles customizations (Site Editor > Styles: colors, typography, spacing) as a local theme/<stylesheet>-global-styles.json file, reusing WordPress core’s own wp/v2/themes and wp/v2/global-styles/<id> REST endpoints (no new PHP controller needed). Classic (non-block) themes fail the command clearly instead of syncing nothing silently. This resource is included automatically in lps diff, but deliberately left out of the aggregate lps push/lps pull (like lps theme), so a classic-theme site’s routine sync isn’t broken by this opt-in feature.

    The MCP server gains matching theme_styles_pull and theme_styles_push tools, and project_diff now covers theme-styles too.

Patch Changes

  • 6f5bc6b: Extends #234’s conditional write to the acf resource: lps acf push now closes the WordPress-side race a write can still land in for field groups, post types, taxonomies, and options pages, the same way it was already closed for option in #235.

    GET /acf/{type}/{key} now returns a revision (a content hash of the exported object, excluding fields ACF itself computes such as modified). acf push reads it right before writing each object, and sends it back as expectedRevision; POST /acf/{type} refuses the write (412) if the object’s revision no longer matches, instead of silently overwriting whatever changed it in between. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap: it shrinks the window down to this one request’s own execution time, not to zero. The remaining 7 resource-state-backed resources (snippet, form, api, hook, seo, menu, theme-styles) aren’t covered yet, tracked in #234 as the remaining rollout.

  • d2be612: Extends #234’s conditional-write pattern (introduced for option in #235) to the two file-based resources, api and hook, which share the same AbstractFilesController/AbstractFilesDirectory base classes.

    GET /api-files and GET /hook-files now return a revision (a content hash) per file, and PUT returns one too on a successful push. api push and hook push compute each file’s expected revision from the remote listing already read for the push’s own rollback snapshot, no extra network round trip, and send it back as expectedRevision; the server refuses the write (412) if the file’s revision no longer matches, instead of silently overwriting whatever changed it in between. A file with no remote counterpart yet (a first push) has nothing to condition on, so it falls back to today’s unconditional create. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap: it shrinks the window down to this one request’s own execution time, not to zero.

    The remaining 6 resource-state-backed resources (snippet, form, acf, seo, menu, theme-styles) aren’t covered yet, tracked in #234 as the remaining rollout.

  • 9bd5f61: Extends #234’s conditional write to form: lps form push now closes the same WordPress-side race that option push already closed.

    GET /forms/{id} (and the create/update responses) now return a revision (a content hash of the form’s canonical state as reported by whichever form plugin is currently active, minus its id and its own save-bookkeeping fields). form push reads it right before writing each existing form, and sends it back as expectedRevision; PUT /forms/{id} refuses the write (412) if the form’s revision no longer matches, instead of silently overwriting whatever changed it in between. A form with no local id yet (a first push) has nothing to condition on, so it falls back to today’s unconditional create, same as option push. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap: it shrinks the window down to this one request’s own execution time, not to zero. The remaining resource-state-backed resources (snippet, acf, api, hook, seo, menu, theme-styles) aren’t covered yet, tracked in #234 as the remaining rollout.

  • 1d05edd: Continues #234’s rollout: lps menu push now closes the same WordPress-side race that option push closed in #235, this time for the menu resource.

    GET /menus/{slug} (and the upsert response) now return a revision (a content hash of the menu’s name and items). menu push reads it right before pushing each menu, and sends it back as expectedRevision; POST /menus refuses the write (412) if the menu’s revision no longer matches, instead of silently overwriting whatever changed it in between. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap: it shrinks the window down to this one request’s own execution time, not to zero. A menu that doesn’t exist remotely yet has nothing to condition on, so a first push is unchanged. The other 7 resource-state-backed resources (snippet, form, acf, api, hook, seo, theme-styles) aren’t covered yet, tracked in #234 as the remaining rollout.

  • bc2359c: Proof of concept for #234: lps option push now closes the WordPress-side race a write can still land in, on the one resource this lands on first (option).

    GET /options/{name} now returns a revision (a content hash of the value). option push reads it right before writing each option, and sends it back as expectedRevision; PUT /options/{name} refuses the write (412) if the option’s revision no longer matches, instead of silently overwriting whatever changed it in between. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap: it shrinks the window down to this one request’s own execution time, not to zero. The other 8 resource-state-backed resources (snippet, form, acf, api, hook, seo, menu, theme-styles) aren’t covered yet, tracked in #234 as the remaining rollout.

  • e4f4b0d: Fixes lps plugin push never activating a plugin it just installed for the first time (plugin add followed by push), even though the site ends up with the plugin present but inactive. diffPlugins() only ever routed a not-yet-installed plugin through toInstall, and push only activated toActivate plus whatever it defensively deactivated for a file swap, never toInstall. push now re-reads the site’s plugin list after a successful Composer sync to resolve the newly installed plugins’ WordPress core ids, and activates them alongside everything else.

  • e4f4b0d: Fixes the non-interactive drift error from lps <resource> rollback mentioning “without –dry-run/–yes to leave it as is”, advice that can never apply in that branch since it is only reached once --dry-run is already known to be false. The message now only mentions the --yes option that actually resolves it.

  • 2e05f74: Closes the same race for seo that PR #235 closed for option (#234): lps seo push now conditions its writes on the active SEO plugin’s (RankMath or Yoast) state at read time, instead of silently overwriting a change a different actor made on WordPress in between.

    GET /seo/settings now returns {revision, settings} (a content hash of the settings, wrapped so the hash never gets written back into wp_options as if it were a real Titles & Meta field), and GET /seo/post-meta/{type}/{slug} now includes a revision alongside meta, slug, and title (a content hash of that post’s provider-prefixed SEO meta as a whole, since a write replaces the whole set, not just the keys a particular push happens to mention). seo push reads the relevant revision right before writing each settings file or post-meta file, and sends it back as expectedRevision; PUT /seo/settings and POST /seo/post-meta/{type} refuse the write (412) if the revision no longer matches, the same optimistic-concurrency check option uses. The revision is bookkeeping, never persisted to local files: settings.json and post-meta files on disk are unchanged in shape.

    The remaining resources tracked in #234 (snippet, form, acf, api, hook, menu, theme-styles) aren’t covered yet.

  • f1e8e61: Closes the WordPress-side race lps snippet push could still land in (#234), extending the conditional write introduced for option to the snippet resource.

    GET /snippets/{id} (and the create/update responses) now return a revision, a content hash of every field an update can change (code, active state, location, priority, and the rest). snippet push reads it right before writing each already-known snippet, and sends it back as expectedRevision; PUT /snippets/{id} refuses the write (412) if the snippet’s revision no longer matches, instead of silently overwriting whatever changed it in between. A first push (no known remote id yet) is unaffected, it still falls through to a plain create. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap. The remaining resource-state-backed resources (form, acf, api, hook, seo, menu, theme-styles) aren’t covered yet, tracked in #234.

  • e4f4b0d: Fixes lps option add, lps menu pull, and lps form pull writing server-only bookkeeping fields (revision, and warnings for menus) into the local tracked files, despite the documented “never persisted locally” contract on RemoteOption.revision/Menu.revision/Menu.warnings/RemoteForm.revision. Each command now writes only the fields that belong to the tracked configuration.

0.24.1

Patch Changes

  • 406eae1: lps composer push now reflects that the server resolves composer.json itself and never installs from the uploaded composer.lock. When the server’s resolution moves a version your local lock had pinned, the command prints the drift and tells you to run lps composer pull. The --json output gains a lockDrift array. The MCP composer_push tool description is updated to match.

  • b28d36c: lps api pull / lps hook pull now skip a file the server declined to return (e.g. one over the new size limit): they print a warning and leave any local copy untouched, instead of overwriting it with nothing. lps api list / lps hook list show the server’s reason next to such a file.

  • d198555: Add lps api rm <slug> / lps hook rm <slug> to delete one route or hook file from WordPress (confirmation prompt, --yes for CI, --dry-run), and lps api push --prune / lps hook push --prune to delete server-side files with no local counterpart after a push.

    --prune and rm both refuse to delete in a non-interactive shell without --yes: unlike pull’s local cleanup, a pruned file on the server is not recoverable from the repo. The MCP server gains matching api_rm / hook_rm tools and a prune option on api_push / hook_push, both behind the confirm-token handshake.

  • 69bdc0c: lps api push now warns for each pushed route that runs with no authentication (#[Permission(public: true)]), and lps api list badges those routes [PUBLIC] (with a trailing warning). lps api list --json includes "public" per route. The MCP api_list tool exposes the same field.

  • de7f283: lps option add now defaults readonly: true for the behaviour-changing core options the server refuses to write (default_role, users_can_register, mailserver_*, uninstall_plugins, initial_db_version), alongside the ones that were already read-only by default (siteurl, home, cron, …). lps option push skips read-only options, so tracking one of these for diff visibility no longer leads to a 403 on push.

  • b43b2c7: lps form push and lps seo push gain opt-in flags for the sync operations the server now gates:

    • lps form push --allow-notifications also pushes each form’s notification and confirmation settings (off by default, so a stray push cannot redirect submissions).
    • lps seo push --allow-external-redirects permits pushing a redirect whose target is on another site (rejected by default).

    The MCP seo_push tool gains allowExternalRedirects. form_push via MCP always uses the safe default (notifications preserved); the opt-in is CLI-only by design.

  • a52c231: Security: harden the lps login / lps project config browser authorization flow (F24, F25).

    • The loopback callback server now generates a 32-byte state, threads it through the authorization URL, and requires it back (constant-time compare) on any request that carries credentials. A local process or a web page open during the wait can no longer POST forged credentials into config.json / auth.json.
    • The server also enforces an Origin allowlist (https://api.loopress.dev for site auth, https://console.loopress.dev for console login) and shuts down on the first callback-shaped request, valid or not, instead of staying open for the whole 5 minute window.
    • authorizeWithBrowser no longer reads the Application Password or username from the URL query string, only from the relay’s form POST body, so they can no longer land in shell history, proxy logs or the browser address bar.

    No API or console change is required: both relays copy the callback URL, and its state, through verbatim.

  • c2fb026: Security: strip WordPress site URLs and server response bodies from CLI crash reports (F26).

    A failed WordPress request throws an Error whose message is built by formatWpError: it carries the full request URL and, for a 4xx/5xx, the server’s raw response body appended after a newline (a Composer trace, a WordPress fatal, absolute server paths, package URLs). finally.ts passed that straight to Sentry.captureException.

    A beforeSend scrubber now runs on every event: it keeps the first line of each exception message (the “which endpoint, which status” summary), blanks the site host while keeping the /wp-json/... REST path, drops everything after the first line, and removes any request context. Chained cause errors are covered too. The terminal still prints the full, unmodified message.

0.24.0

Minor Changes

  • 755b390: Added hooks/, a new resource for declaring WordPress actions, filters, and scheduled (cron) tasks as plain PHP files, deployed with lps hook push/pull/list (and lps push/pull/diff, hook_push/hook_pull/hook_list MCP tools). One file, one class, public methods attributed with #[Action(hook, priority, acceptedArgs)], #[Filter(hook, priority, acceptedArgs)], or #[Cron(recurrence, hook?)] bind straight to add_action()/add_filter()/WP-Cron (a #[Cron] job is just an action bound to a schedule instead of an existing WordPress event). Unlike a REST route, a bound hook runs unconditionally for every visitor with no permission check of its own, so every callback is wrapped to catch and log rather than propagate; a filter additionally fails open, returning the original value on a throw. Loopress Full only, same as custom API routes.

  • 0ec5ebe: Add lps diff: show what differs between your local tracked files and a WordPress environment, or between two environments with --against. Covers snippets, forms, ACF, API routes, SEO, and Composer, comparing the normalized representation each resource round-trips (so serialization quirks like key order or a re-added <?php tag never show up as drift). Prints a git-style summary (+ local-only, - remote-only, ~ changed, with a per-field list for objects and a line diff for file contents) and exits non-zero when anything differs, so it doubles as a CI drift gate. Plugins and themes keep their existing lps plugin status / lps theme status.

  • ad89acb: Add per-resource diff commands, scoping flags, and harden lps diff for CI use.

    • lps snippet diff, lps form diff, lps acf diff, lps api diff, lps seo diff: run the same normalized comparison as lps diff for a single resource, accept the resource’s [PATH] override, and support --env / --against / --json.
    • lps diff gains --only <resource>... and --skip <resource>... to narrow the run (a project that only tracks some resource types can stop the rest showing as drift).
    • Form and ACF comparisons now ignore server-rewritten save timestamps (modified, modified_gmt), the same way redirect hits is already ignored, so a diff between two independently-configured environments reports real differences only.
    • Exit code is now 0 in sync, 1 on drift, 2 when a resource could not be compared (a fetch failure no longer looks like “no drift” to a CI gate). --json change entries now carry a structured fields list for object resources. Resources are compared concurrently. A [PATH] argument combined with --against is rejected rather than silently ignored.
  • f0fe762: Added options/, a new resource for tracking individual WordPress options (wp_options rows) directly, agnostic of which plugin owns them: no adapter to write, unlike seo/ which has to know Yoast’s vs RankMath’s option names and shape. lps option list shows every option name and autoload flag on the site (never values, so browsing stays cheap and never leaks a value by accident), lps option add <name> starts tracking one as a local file, and lps option pull/push/diff/remove work the tracked set from there (push is upsert-only, it never deletes an untracked option). Two safety rails: active_plugins/template/stylesheet are refused outright (already owned by the plugin/theme resources), and environment-owned or WordPress-generated options (siteurl, home, db_version, cron, rewrite_rules, WPLANG) default to "readonly": true in their local file, tracked and diffable but skipped by push unless explicitly overridden. Mirrored as option_push/option_pull/option_list/option_add/option_remove MCP tools.

  • 6b0fda3: Breaking: removed the page resource. lps page pull/push/list/diff, the page_pull/page_push/page_list MCP tools, the pageDir setting in loopress.json, and the pages entry for lps dev / lps diff --only are all gone. lps push/pull no longer touch pages. WordPress pages are core content editable in wp-admin or over wp/v2/pages directly; keep them under version control with a general-purpose WP-CLI export if needed.

0.23.0

Minor Changes

  • d811b3f: Add lps app push [name], lps app pull [path], lps app list and lps app remove <name> for syncing built single-page app bundles between a local apps/<name>/ directory (a loopress.app.json plus a built dist/) and WordPress. push builds a sha256 manifest of dist/, asks the site which files it already has, uploads only the differences, then commits the new build in one step; the front end keeps serving the old build until that commit lands. pull writes each committed app back to apps/<name>/dist/ and removes local app directories that no longer exist remotely (confirmed in a terminal, reported in CI). Adds an appsDir key to loopress.json and an “apps” entry to lps init.

    New MCP tools mirror the commands: app_push, app_pull, app_list, app_remove (app_push/app_remove use the dry-run/confirmToken handshake).

    This is a Loopress Full plugin feature; the CLI and MCP surface is edition-agnostic.

  • dfb33a3: Pin WordPress.org plugin and theme versions through a lockfile, installed on the site with Composer + WPackagist.

    loopress.json plugins (and the new themes key) now take an exact version or "latest", instead of always "latest". lps plugin push no longer calls WordPress’s core wp/v2/plugins endpoint (which only installs the current release); it sends an intent to the site, where Loopress Full renders a plugin-owned composer.json and runs Composer against the WPackagist repository, landing plugins in wp-content/plugins/ at the pinned version, with no SSH and no composer.json in your repo.

    • lps plugin pull / lps theme pull now pin every plugin/theme to the version running on the site.
    • lps plugin add --version <v> pins an exact version; lps theme add mirrors it.
    • New lps plugin push flags: --force (allow downgrades, and take over a plugin installed by hand, replacing its files) and --prune (deactivate active plugins absent from loopress.json). Removing an entry from loopress.json uninstalls the plugin on the next push (shown in the plan, confirmed).
    • New lps plugin status / lps theme status: report drift (missing, wrong version, inactive, untracked) and exit non-zero for CI.
    • New lps plugin audit: check pinned plugins against a WordPress vulnerability database (wpvulnerability.net) and the WordPress.org plugin API for health signals.
    • New lps theme command group (add, pull, push, status). Loopress manages installed theme versions only, it never switches the active theme.
    • When a repo has a composer.json (from lps composer init), it is authoritative for plugins/themes and the plugin/theme commands defer to lps composer. lps composer push now sends an intent (libraries + WPackagist plugins/themes) instead of a verbatim composer.json; the plugin owns the file’s shape.

    New MCP tools: plugin_status, plugin_audit, theme_push, theme_pull, theme_status. plugin_push gains force and prune.

    Limits: WordPress.org plugins/themes only (premium plugins are tracked but never touched); a downgrade replaces files only and does not undo database migrations; multisite is not supported.

    This relies on a Loopress Full plugin feature; see the companion changeset there.

Patch Changes

  • b3e9d88: Fix lps plugin pull recording the wrong slug for a single-file plugin whose WordPress.org slug differs from its bare file id (e.g. Hello Dolly: hello.php has slug hello-dolly, not hello). The wrong slug made a later lps plugin push --force fail atomically with a Composer “could not be found” error instead of installing anything. The slug is now read from the plugin’s Plugin URI header when it points to a wordpress.org listing, falling back to the bare file id otherwise.

0.22.0

Minor Changes

  • 1c3e617: Add lps promote <from> <to>, which copies every tracked resource from one environment to another by running lps pull from <from> then lps push to <to> in one step. It confirms once up front (local tracked files are overwritten with <from> in the process, and the prompt calls out production targets), forwards --dry-run to both halves, and stops before pushing if the pull fails.
  • 14874f8: Add lps pull, a top-level command that pulls plugins, composer dependencies, ACF, API routes, forms, pages, SEO, and snippets from WordPress in one run instead of calling each resource’s pull command separately. It’s the counterpart of lps push. composer is pulled before plugins so plugin detection sees the fresh composer.json, and --yes is forwarded only to the resource pulls that prompt before deleting orphaned local files.
  • 672395e: Add lps push, a top-level command that pushes plugins, composer dependencies, ACF, API routes, forms, pages, SEO, and snippets to WordPress in one run instead of calling each resource’s push command separately. Production pushes are confirmed once up front rather than once per resource.
  • 74cb1ab: Add lps validate, an offline check that the local tracked files are push-ready: every resource JSON parses and is an object, snippet sidecars use a known type and an integer id, API route files are non-empty, loopress.json points at a configured project, and composer.json is well formed. It contacts nothing, prints one line per problem, exits non-zero when any is found, and supports --json. Handy as a pre-commit or CI gate before lps push.
  • 50fae55: lps project list now supports --json, like every other list command.
  • 3bfe6a8: lps acf list, lps form list, and lps seo list now use the same --json mechanism as every other command instead of a one-off -j/--json flag. Breaking: the -j short flag is removed from these three commands, use --json instead.

Patch Changes

  • b2fcf46: Internal refactoring: extract a shared pullDirectory helper for the write-then-reconcile-orphans loop duplicated across acf/api/form/page/seo/snippet pull commands, and a shared guardProductionPush helper for the production-push confirmation duplicated between PushCommand and the top-level lps push. No visible behavior change.
  • cb993db: Internal refactoring: extract shared helpers for the id-based PUT-then-create-on-404 push dance (form, page, seo redirects, snippet), the Listr push-task loop, ENOENT-tolerant directory listing, and count pluralization, previously duplicated across each resource’s push/pull command. No visible behavior change.

0.21.0

Minor Changes

  • 182ae2a: api/ route files can now use a bracketed segment, [order_id], anywhere in their path (e.g. api/invoice-pdf/[order_id].php, api/orders/[order_id]/items/[item_id].php) to capture a dynamic value into $request->get_param(...), the same convention as Astro/Next.js dynamic routes, without a catch-all segment. The segment name must start with a letter or underscore (it becomes a PHP identifier internally). The generated class name PascalCases each path segment and joins them with _ (InvoicePdf_OrderId, not InvoicePdfOrderId), so two differently nested files can never collide on the same class name.

    lps api push/pull/list now support route files nested in subdirectories, needed for the above. Internal, breaking: the upload endpoint (PUT loopress/v1/api-files) now takes filename as a body field instead of a URL path segment (avoids depending on how a given host handles a percent-encoded slash in a URL), so the CLI and the WordPress plugin must be upgraded together, an old CLI against a new plugin (or the reverse) will fail to push.

  • c365613: Add lps dev, which watches snippets, pages, API routes, and the plugin manifest, pushing each change to the local environment automatically. Supports --only/--skip to limit which resource types are watched.

  • 421a1e8: --json now works consistently across snippet/page/api/plugin/composer push/pull/list and status: each command returns a structured result (shape documented per command) instead of the interactive progress UI, and errors come through as {error: {message, name}} with a non-zero exit code. snippet list/page list/api list previously had their own one-off --json/-j flag; they now use the same oclif mechanism as everything else. First step towards a @loopress/mcp server that wraps lps instead of duplicating its logic, see obsidian/Product/Loopress MCP.md.

Patch Changes

  • 67a932e: lps api push now rejects a file missing declare(strict_types=1); (or containing it more than once) before making any network call, mirroring the server’s own check instead of failing only after the round-trip.
  • 182ae2a: lps api push’s server-side PHP syntax check now distinguishes “verified, no error” from “couldn’t verify here” (exec() disabled, common on managed hosts, or another local condition preventing the check from running), instead of treating both as silent success. The CLI now reports when the check was skipped for the second case instead of staying indistinguishable from a fully verified push.

0.20.1

Patch Changes

  • 6091e29: Bug fixes from the QA backlog (5th pass), lot 1:

    • lps doctor and lps project config now detect Application Passwords being disabled on the target site by reading the wp-json/ index instead of probing wp-admin/authorize-application.php, which sits behind the admin login wall and never reached the check that would report the feature disabled.
    • lps page push can now recreate a page whose local id no longer exists on the site (deleted, or pushed to a site where that id never existed): the fallback create no longer sends the stale id, which WordPress core previously rejected with “Cannot create existing post”.
    • Server error messages are no longer hidden on a 404: a legitimate applicative 404 from a Loopress controller (e.g. lps composer pull on a site with no composer.lock pushed yet) now shows the real server message instead of a generic “is the plugin installed?” one, and lps composer pull treats that specific case as “no lock yet” and writes composer.json alone instead of failing.

0.20.0

Minor Changes

  • 886fa43: Add lps page pull, lps page push, and lps page list to sync WordPress pages as files in Git. Talks directly to WordPress core’s own REST API (wp/v2/pages), no Loopress plugin required. Each page is a pair of files: <id>-<slug>.html for the raw Gutenberg block content, a real file you can open and edit directly, and <id>-<slug>.json for everything else (title, slug, status, parent, menu_order, template, meta, excerpt…), filtered down to only the fields WordPress actually accepts back on write so readonly/computed noise that changes on every edit regardless of actual content (_links, guid, modified) doesn’t produce a diff on every pull. parent round-trips as the source site’s raw page id and isn’t remapped across environments.
  • 9bbd03f: lps project config now detects when Loopress Full isn’t installed on the target site and offers to install it automatically. Since Loopress Full is never distributed on wordpress.org and normally requires a manual zip upload in wp-admin, this closes that gap: after confirming, the CLI downloads the latest release, creates a temporary administrator account (the only way to get plugin-install rights from an application password), drives the wp-admin upload flow headlessly to install and activate the plugin, then removes the temporary account. If the automated install fails for any reason, the temporary account is still cleaned up and the CLI falls back to printing the downloaded zip’s local path plus the direct upload URL so the install can be finished by hand.
  • 9a0bb9d: Add lps project rotate to replace the WordPress application password for the current (or --env) environment: it creates a new one, verifies it authenticates on its own, then revokes the old one, never the other way around, so a bad new credential can’t lock you out. Every other command now also does this silently in the background once the stored credential is older than 90 days: best-effort, skipped during --dry-run, and a failed attempt (site unreachable) just retries on the next run instead of blocking the current command.

0.19.0

Minor Changes

  • 7fa0350: Explicit environment targeting and safer destructive operations (CLI backlog lot 3)

    • New --env <name> flag on every project-aware command: targets an environment by name for a single run, taking priority over lps project switch, with an error listing the available environments when the name does not exist. lps status --env <name> previews what would be targeted.
    • New --yes (-y) flag on commands that ask for confirmation.
    • Production guard: push commands targeting an environment named production now ask for confirmation in a terminal, and require --yes in non-interactive runs.
    • Pull commands now list local files that would be deleted (because they no longer exist on WordPress) and ask before removing them. --yes skips the question; without a TTY the previous behavior (remove and warn) is kept so existing scripts do not break.
    • The CLI never hangs on a prompt in CI: confirmations take their default answer and log it, and commands that require interactive input (lps init, lps project config) fail immediately with instructions.
  • 01300ca: Diagnostics and onboarding (CLI backlog lot 4)

    • New lps doctor command: checks that the site is reachable, the Loopress plugin installed, and the credentials valid, each with a corrective action, and shows the plugin version when exposed. Exits non-zero when a check fails, so it can guard a CI deploy.
    • lps init now offers to run lps project config inline when no project is configured yet, proposes the other feature directories (ACF, SEO, Forms, custom API routes) via an optional multi-select, and ends with a summary of everything configured plus the next useful command.
    • The CLI now warns when a newer version is available (background npm check, at most once a day, never blocking), pointing to the npm update command.

Patch Changes

  • 827af5b: lps api push now rejects a route file whose name contains characters outside [a-z0-9-] (e.g. uppercase letters, underscores) with a clear “Invalid filename” error before attempting the push. Previously the request reached WordPress, whose REST router matches the filename against the same allowlist and returned a generic 404 that the CLI reported as “Is the required plugin installed and up to date on the site?”, a misleading message for what was actually an invalid filename.

  • d529f5e: Timeouts and dependency cleanup (CLI backlog lot 1)

    • Calls to the Loopress cloud API now time out after 30s with an actionable error message instead of hanging indefinitely.
    • lps composer push waits up to 10 minutes for the server-side composer install instead of failing after 30s, shows a wait message, and explains on a real timeout that the install may still be running on the server.
    • Removed unused dependencies: @oclif/plugin-plugins and @oclif/test.
  • adc5b73: Internal refactoring, shared helpers (CLI backlog lot 2). No visible behavior change.

    • One shared orphan-file detection used by the five pull commands (acf, api, form, seo, snippet).
    • One shared directory loader (per-file parse and validation, corrupted files skipped with a warning, missing directory means empty) used by acf push and api push.
    • PushCommand now carries failedCount and the Listr task failure reporting used by the six push commands.
    • Single API_URL definition, shared toSlug() helper, snippet and seo file-format functions moved to utils/.
  • 5658d6c: Fix three bugs found during manual QA. Pushing a snippet with a location unsupported by the active provider (WPCode) used to create the snippet anyway with the location silently defaulted, while still reporting failure to the CLI; since the CLI never learned the resulting id, retrying the push (the natural reaction to a reported failure) created another duplicate snippet each time. WPCodeSnippetProvider now validates the location before any write, so an invalid location is rejected with nothing created. A pushed api/ route file with a real PHP parse error was accepted and listed as present by lps api push/list, while the route silently 404d at request time; ApiFilesController::push_file() now runs a real PHP syntax check before writing, rejecting with a clear 400. SEO endpoints (lps seo) returned a generic 500 for client-actionable conditions (multiple SEO plugins active, redirects unsupported by the active plugin) instead of the 409/400 used by the equivalent snippets/forms guards; dedicated exceptions now map these correctly, and get_settings()/update_settings() (which had no error handling at all before this) are covered too.

    Also fixes lps composer init’s generated scaffold, which required composer/installers without allow-listing it, so Composer 2.2+’s non-interactive plugin-trust gate blocked every real composer push through it. Error messages surfaced from a WordPress REST failure now include the server’s full detail (e.g. the actual Composer trace) instead of just a generic summary, making failures like this one diagnosable from the CLI’s own output.

0.18.0

Minor Changes

  • 2b6420c: Adds custom API routes: a versioned api/ folder (lps api push/pull/list on the CLI) lets a project expose its own WP REST endpoints without hand-writing a plugin, deployed straight to wp-content/loopress/api/ on Loopress Full and registered under loopress-api/v1/<filename>. One file, one class, one method per HTTP verb (get/post/put/patch/delete), resolved by filename convention (kebab-case -> PascalCase). Defaults to manage_options + Application Password auth like every other sync route, with explicit per-file overrides for permission() (e.g. an anonymous headless form endpoint) and headers() (CORS, including the OPTIONS preflight). Filenames are allowlisted against path traversal, deployed files get an auto-injected ABSPATH guard against direct HTTP access, and a bad file (parse error, class collision, throwing permission()/headers()) is skipped and logged rather than fataling rest_api_init or breaking other routes. Full only, like snippet sync: Light stays ACF+SEO only.
  • 9383f6e: Adds a WordPress form-sync feature: lps form list/pull/push on the CLI, backed by new REST routes under loopress/v1/forms on Loopress Full (Light stays ACF+SEO only). The plugin side introduces a generic FormProvider abstraction, mirroring the existing snippet sync (Code Snippets/WPCode), with WPForms as the first supported plugin; more WordPress form plugins can be added as additional providers later. Forms are addressed by numeric id (no ACF-style stable key), pulled/pushed as one <id>-<slug>.json file per form, with orphan cleanup on pull and the same PUT-then-404-fallback-to-create dance as lps snippet push.

0.17.0

Minor Changes

  • 31790ce: Add lps seo pull, lps seo push, and lps seo list to sync SEO configuration as JSON files in Git: site-wide Titles & Meta settings (including per-post-type schema defaults), per-post SEO meta, and redirects. Works with either RankMath or Yoast SEO, whichever is active on the site, exactly one must be active, if both are active at once or neither is, every seo command fails with a clear error instead of guessing which plugin’s data is authoritative. Redirects are only available when RankMath is active: Yoast’s equivalent is a Premium-only feature this doesn’t support, seo pull skips them quietly when unsupported and seo push fails clearly per file if you have local redirect files the active plugin can’t take.

0.16.0

Minor Changes

  • 6f5712c: Add lps acf pull/lps acf push/lps acf list, which sync ACF (Advanced Custom Fields) field groups, post types, taxonomies, and options pages between the WordPress site and local JSON files, the same git-based workflow already available for snippets and Composer dependencies.

    Backed by new loopress/v1/acf/* endpoints on the WordPress plugin. Requires ACF to be installed and active; options pages additionally require ACF PRO.

  • 6c557ec: Add lps composer init, which scaffolds a composer.json wired to WPackagist (repository, composer/installers, installer-paths) so WordPress.org plugins and themes can be added and installed through Composer instead of the native plugin API.

    lps composer pull now also pulls composer.json (previously only composer.lock), backed by a new GET loopress/v1/composer/json endpoint on the WordPress plugin, so local composer.json stays in sync with packages added or removed through the Loopress admin page.

Patch Changes

  • 9589ec1: From Beta to Alpha to reflect more status of the tool

0.15.0

Minor Changes

  • 69b5050: Replaced lps project sync with lps project push and lps project pull, matching the push/pull naming used by snippet, plugin and composer. lps project push creates/links local projects and environments on your Loopress account and pushes credentials (what sync did for local-to-remote). lps project pull fetches projects and environments already on your account that aren’t configured locally yet, now works even when no project is configured locally.

Patch Changes

  • 83a65be: Stopped sending personal data to Sentry when reporting a crash. Command-line argument values (WordPress URLs, application passwords, tokens, emails) are now redacted, only flag names are kept for debugging context. Also disabled sendDefaultPii explicitly and set a static serverName instead of the machine’s real hostname.

0.14.0

Minor Changes

  • 42c3956: Use oclif native config system
  • 9a8f6b5: lps plugin commands now use WordPress core’s native wp/v2/plugins REST API instead of a custom Loopress endpoint. As a result, plugin version pinning is no longer supported: lps plugin add no longer accepts a [version] argument, and loopress.json always stores "latest" for managed plugins. Pin an exact version through the composer command group and wpackagist instead.
  • b27bd0c: Delay sentry load to improve performances

Patch Changes

  • 13e0495: Move WordPress app-password relay from website to API

0.13.0

Minor Changes

  • 6a18588: Add application password creation with open browser
  • 01b3005: Add snippet publish command

0.12.0

Minor Changes

  • 8a3e52f: Sync projects and snippet files during push/pull

0.11.0

Minor Changes

  • 9754a14: Remove snippet provider and put it directl in plugins during init
  • 0ab8400: Improve remove UX and synchronization with backend
  • 616b2ab: Add lps project sync to push locally configured projects, environments and credentials to your Loopress account
  • 908875c: Add upsert mechanism on snippet push
  • 85b2fcf: Improve ux of project switch with separators

0.10.0

Minor Changes

  • Publish schemas on NPM

0.9.0

Minor Changes

  • f5bae31: Add list2 to improve UX
  • 34a4a22: Setup sentry to monitor the CLI

0.8.0

Minor Changes

  • 540c702: Improve structure with fable
  • 6122ddc: Align better sidecar file with WPCode api

0.7.0

Minor Changes

  • 56cca02: Refine command descriptions. Improve compatibility with WPCode. Add unit tests

0.6.0

Minor Changes

  • 18edf51: Add composer command

0.5.0

Minor Changes

  • 138acfd: Remove style commands in favor of snippets for custom CSS
  • 6aba757: Use sidekick files to store snippets state
  • 249b128: Add init command
  • 7f4ac7c: CLI can read projectId in loopress.json
  • 6aba757: Record deployments in Loopress console

0.4.0

Minor Changes

  • 2d33230: Add commands to manage plugins
  • 2d33230: Add cli-plugins command as wrapper around oclif plugin plugins
  • a3e2a67: Activate plugin after installation

0.3.0

Minor Changes

  • Add detection by id for push/pull of snippets

0.2.0

Minor Changes

  • 7a3642f: Add full snippet pull with various files

Patch Changes

  • 7b8b35d: Add ascii art of the logo

0.1.1

Patch Changes

  • Re-gnerate readme

0.1.0

Minor Changes

  • f66babd: Initial release

@loopress/wordpress-plugin

2026.18.0

Minor Changes

  • 0bf479c: Dynamic blocks (Loopress Full): lps block push | pull | list | diff | rollback | rm and the block_* MCP tools deploy blocks/<slug>/ folders (block.json + render.php, optionally style.css and view.js) registered as the server-rendered block loopress/<slug>. Each block’s CSS and JS load only where it renders, it works inside a Query Loop, and a render.php that throws renders nothing instead of breaking the page. lps push, lps pull, lps diff and lps validate include them.
  • 4613ded: Loopress Light no longer breaks the aggregate commands. GET loopress/v1/capabilities now lists the site’s modules, and lps pull, lps push (dry run included), lps diff and so lps promote skip a Full-only step (“needs Loopress Full”) when the project has no files for it. When it does, lps push refuses before pushing anything, lps pull and lps diff report it. lps push and lps theme push skip templates and parts when the project has none, even with activateChildTheme (use lps theme activate for an empty child), and lps theme template push alone refuses with “Nothing to push”. The removed SEO module warning is gone.
  • da7d681: The plugin now exposes loopress/v1/capabilities (edition, version, environment type, ACF/form/snippet integrations, active theme and Loopress child theme), and the CLI requires it: an older plugin is asked to update. lps push refuses upfront, with the fix, when the project uses snippets, forms or ACF the site can’t hold, instead of failing at that step after pushing everything before it. New lps theme activate (the Loopress child theme by default, which starts from its parent’s theme mods and Global Styles) and "activateChildTheme": true in loopress.json to keep the child active on lps theme template push. Global Styles keep the parent’s file name while the child is active. lps content copy activates locally the theme and plugins active on the source. lps doctor reports the site’s capabilities.

2026.17.1

Patch Changes

  • ac984c0: lps diff, push dry runs and the outside-edit check no longer report drift on ACF field groups or Global Styles that are in sync. ACF stores every setting with its default (wrapper, aria-label…), which a hand- or agent-written field group leaves out: the local files are now compared as the site would store them, through a new read-only plugin route (POST loopress/v1/acf/normalize/<type>) that runs them through ACF’s own validation without saving anything. Global Styles now compare a preset list and its origin-keyed form (palette: [...] and palette: {custom: [...]}) as the same, and a var:preset|color|base reference as its var(--wp--preset--color--base) expansion, the two forms WordPress rewrites on save. With a plugin older than this release, ACF files are compared as written, as before. If the site can’t be read for the outside-edit check after an earlier push, the push now stops instead of skipping the check.

2026.17.0

Minor Changes

  • fcf44bc: A Loopress link in the WordPress top bar, for administrators. Hovering it shows the plugin version, the environment type, and the last push.
  • ad06a9a: lps page push can now take over a page with the same slug that Loopress did not create, instead of refusing it. It lists those pages and asks once, or takes them over directly with --overwrite (required in CI and through the MCP server). The page keeps its URL and ID, so menus, links and SEO data keep working, and its original content is saved as a WordPress revision first.
  • df54b2d: A push’s dry run now says what it would change: each local item is created, changed or unchanged against the site, in the human output and under changes in --json (existing fields are kept). lps acf push also names the fields added, changed or removed in a changed field group, and lps theme template push lists templates, parts and CSS files separately, with removed for a file the child theme would drop. lps push and lps theme push now return each step’s own result. The MCP push tools’ previews are these results, and their descriptions now tell the agent so. The plugin’s child theme endpoint reports a hash of each CSS file, so the dry run can tell whether the theme CSS changed.
  • 33baa84: Loopress Full now discloses its anonymous usage statistics in a wp-admin notice right after activation, with equal “Keep it on” and “Turn it off” buttons, instead of only in the Settings tab. The notice disappears once an administrator answers, either here or in Settings.

2026.16.0

Minor Changes

  • 60ef881: Content export and import endpoints for lps content copy, in both editions. The import deletes the site’s posts and terms, so it only runs on a site whose environment type is local. After an import, images missing locally are served from the source site’s uploads URL.
  • 5bb3a54: The last error a hook method threw while running is now kept per file (method, message, time), and returned as runtimeError by the hook list endpoint while the file is still the version that threw it. The same error is written at most once a minute. Until now it only went to the PHP error log.

Patch Changes

  • ef30ef3: Admin page redesign:

    • Every block of every tab now sits in its own white card, so all tabs share one look. Recent pushes on Overview is a table, Settings groups both data-sharing toggles in one card, and Dependencies stacks “Add a package” above “Installed packages” (the table no longer overflows its card).
    • Overview now repeats every issue the other tabs raise: server configuration, Composer platform problems, security advisories and abandoned packages, code files that failed to load, and Loopress declarations skipped because something registered the same slug first.
    • Code tab: “View source” on API routes and hooks opens the file in a drawer on the right, with a copy button, instead of expanding it inline, with PHP syntax colors.
    • Config tab: Loopress-owned post types, taxonomies, roles and meta keys stand out in the Source column. When ACF or a form plugin isn’t installed, its section shows a quiet note instead of a red error, and the admin page no longer retries requests the server refused with a 4xx.
    • The page header no longer slides under the WordPress admin bar when scrolling.
    • The API routes namespace moves from under the routes list to the Settings tab, and to a gear popover in the API routes card header.
    • A Check for updates button in the page header asks GitHub right away instead of waiting for the twice-daily check, and offers the update when there is one. Dashboard → Updates’ Check again now really asks GitHub too.
  • b4e6151: Post types from cpt/ and taxonomies from taxonomies/ are now registered on init priority 9 and 8 (was 20 and 19), before the default 10. Plugins that read post types during init and cache them, like Rank Math for its sitemap settings, now see them. A slug another plugin registers again later is still reported as a conflict.

  • 90779f2: API route and hook files are now syntax-checked with PHP’s own parser before they are written, so a broken file is refused at push time on every host, Local by Flywheel and managed hosts included, instead of only where a php binary is on the PATH. A menu item pointing at a page or term missing on the site answers 422 instead of 500, and an empty menu item description no longer reads back as a single space, which showed as drift after every push. A static page holding an empty enclosing shortcode ([tabs][/tabs]) that runs the_content on its inner text no longer recurses until a 502: a nested the_content run for the page being rendered gets nothing back instead of the page. A form in the trash now counts as gone for lps form push, which recreates it, instead of updating it in the trash and reporting a successful push. A Composer sync with no package at all, on a site that never had one, no longer fails with a 500 (“require : Array value found, but an object is required”): an empty require is written as {}.

2026.15.0

Minor Changes

  • 03d3da0: The Loopress admin page opens on a new Overview tab, in both editions: what was pushed to the site from your repository, when, by whom and through which application password, and whether each resource changed on the site since its last push, outside the CLI. Before the first push it shows how to connect the CLI, with the site’s URL ready to copy. Once a resource has been pushed, its own WordPress screens (ACF, Menus, WPForms, Code Snippets, WPCode) warn that changes made there are overwritten by the next push. A new Config tab lists, read-only, the configuration Loopress syncs (ACF objects, menus, forms), next to the Code tab for deployed code. Loopress Light’s page gets the Overview and Config tabs instead of a static introduction.
  • 33df017: The Loopress admin page is reorganized into three tabs: Dependencies, Code (API routes, hooks and single-page apps, read-only) and Settings. The site’s environment type now shows next to the title, and on a production site installing, updating or removing a Composer package asks for a confirmation first. DISALLOW_FILE_MODS is now respected: Composer dependencies become read-only in the admin, and every Composer endpoint that writes files answers 403, lps composer push included. Platform and webserver checks moved out of the removed Diagnostics tab into Tools > Site Health, and only show on the Dependencies tab when they find something. The Code tab adds a source view and a copyable URL for each API route, a copyable shortcode for each app, and when each named cron runs next. The update banner is gone, since updates already appear on the Plugins page.
  • a1a5147: Custom post types can now be declared in code: each cpt/<slug>.json file in your repository holds the register_post_type() arguments, and the plugin registers it on every request, in both editions. Arguments WordPress would run as code (register_meta_box_cb, the REST controller classes) are refused, and a slug already registered by a theme or another plugin is skipped, never overwritten. Permalinks are refreshed once after a change. The Config tab lists every post type on the site with where it comes from (Loopress, WordPress, ACF, CPT UI, a theme or plugin), its published count, and a Conflict badge for a skipped Loopress one.
  • 049d127: Post meta keys can now be declared in code too, in both editions: each meta/<post-type>.json file maps meta keys to their register_post_meta() arguments (type, single, default, REST exposure), post and page included. Arguments that would run code (sanitize_callback, auth_callback, show_in_rest.prepare_callback) are refused, so are an unknown type, a default of the wrong type, and an array or object exposed in REST without a schema. A key already registered elsewhere is skipped. Only the keys are synced, never their values. The Config tab lists the registered meta keys per post type, and flags a key sharing its name with an ACF field.
  • 22894f6: Breaking: the SEO module is removed from both editions, with its loopress/v1/seo/* REST routes and the redirects list in the Config tab. The plugin no longer reads or writes Rank Math or Yoast SEO data directly. Their site-wide settings stay syncable as a regular tracked option (rank-math-options-titles or wpseo_titles) through the options endpoints.
  • cd4b340: Custom user roles can now be declared in code, in both editions: each roles/<slug>.json file holds a role’s name and capabilities. Loopress adds the role in memory on every request and never writes it into the saved roles, so the file stays the only source of truth and removing it really removes the role. Built-in roles are reserved, and a role already saved on the site is skipped. The Config tab lists every role with its number of users. Loopress Full also registers block patterns from patterns/<slug>.html files as loopress/<slug>, so a <!-- wp:pattern {"slug":"loopress/<slug>"} /--> reference in a page, template or part renders the current version.
  • 66790cb: Taxonomies can now be declared in code too, in both editions: each taxonomies/<slug>.json file holds the register_taxonomy() arguments plus object_type, the post types it attaches to. Same rules as custom post types: arguments that would run code are refused, so are values of the wrong type, a slug already registered elsewhere is skipped, and permalinks are refreshed once after a change. Only the taxonomy is synced, never its terms. The Config tab lists every taxonomy on the site with its source, the post types it’s attached to and its number of terms.
  • b87fa13: Loopress Full now removes, every hour, any temporary administrator account left behind by an interrupted lps project config install, once it is more than 15 minutes old. Until then, administrators see a wp-admin notice naming it. Only accounts matching both the lps-temp- username prefix and the @lps-temp.invalid email are touched.
  • 7139fd8: The Loopress child theme now carries the project’s theme CSS. Its style.css is loaded on the site and in the editor, inlined when small, and each blocks/<namespace>/<block>.css is loaded with wp_enqueue_block_style(), only on pages that render that block. Only while the child is the active theme. Fonts and images go to the child’s assets/, nothing else can be written there.

Patch Changes

  • cb1fb49: The usage ping now sends the site’s UTC offset (for example +02:00) when no city is picked under Settings → General, instead of an empty timezone. WordPress defaults to an offset, so most sites sent nothing.

2026.14.0

Minor Changes

  • cb29aee: Form sync (WPForms) now ships in Loopress Light too, not only Loopress Full: lps form pull / push / list work against a site running the wordpress.org edition. Every value in a pushed form (field labels, descriptions, the HTML field’s content) is now stripped of active content, the same way ACF and SEO already were: script and style tags, inline event handlers, and javascript: URLs are removed, other HTML is kept as is.
  • cc5ede1: Options that store a page ID (EDD’s checkout pages in edd_settings, WooCommerce’s woocommerce_shop_page_id…) can now be synced between environments. Declare them in the option file with "refs": {"purchase_page": "page"}, then lps option pull writes the page’s path instead of its ID, and lps option push turns it back into the ID of that page on the target. A page missing on the target fails the push for that option with a clear message.
  • bc3e474: Loopress Full now sends an anonymous usage ping, at most twice a day, alongside its update check: a random site identifier, the Loopress, WordPress and PHP versions, the site language, timezone, and environment type. Never the site URL, an email, content, or the plugin list. On by default, switch it off under Settings → Send anonymous usage statistics. Loopress Light is unchanged and sends nothing.

Patch Changes

  • cc5ede1: Menus and SEO meta now find child pages. A menu item pointing to account/profile used to fail on push with No "page" post with slug "profile" was found, and SEO meta for a child page did too. lps menu pull now writes a child page’s full path (account/profile), so menu files pulled before this change get a one-time diff. A bare slug still works when exactly one page has it; when several do (account/profile and team/profile), the push fails with a 409 listing the paths to choose from.
  • 426df4b: full-width: true now reaches the true edge on Twenty Twenty-Five’s page template: the side padding of the template’s own wrappers around the content (<main> and its group) is dropped too, on any block theme. The header and footer keep theirs, and the template’s vertical spacing is unchanged.
  • 72457e5: Listing options no longer fails with a fatal error when WordPress cannot reach its database at that moment: the list comes back empty instead.
  • a40d6da: Composer package search (Loopress Full) now says “Unable to fetch versions for this package.” when the version lookup fails, instead of the misleading “No stable versions found.”
  • 426df4b: Template and part file names now accept _, like the post type and taxonomy slugs the template hierarchy embeds: templates/taxonomy-download_tag.html or templates/single-my_type.html no longer stop lps theme template push. Page file names keep their stricter rule.
  • a40d6da: Marked as tested up to WordPress 7.1, and cleared the remaining WordPress Plugin Check errors so the plugin keeps showing up in wordpress.org searches.

2026.13.0

Minor Changes

  • 4d1c8af: Apps can now stop other sites from embedding their files: set "crossOrigin": "same-origin" (or "same-site") in loopress.app.json, and on lps app push the plugin writes an .htaccess in the app’s folder that sends Cross-Origin-Resource-Policy with every file. It overrides a site-wide cross-origin value set by a security plugin, and is removed when the setting is dropped. lps app pull keeps the setting, and lps app push warns when it was not applied (plugin too old, or an app .htaccess the site owner manages, which is left untouched). Apache and LiteSpeed only (nginx ignores .htaccess).
  • 20a406c: Block templates and parts: templates/<slug>.html and parts/<slug>.html are written by lps template push as the files of a child theme of the active block theme, <parent>-loopress, the parent staying untouched. Any template of the hierarchy can be overridden, and a template with a title/postTypes header becomes a page template a static page picks with its template header (no header or footer part gives a bare page). Edits made in the Site Editor are never overwritten and show up as drift in lps template diff and lps diff. Part of lps push (before pages), lps dev, lps init and lps validate. MCP gains template_push, template_list and template_diff.

Patch Changes

  • 4d1c8af: lps app pull no longer overwrites an existing loopress.app.json: files go into its assetsDir and its entry is kept, so an app with a custom build folder or an explicit entry can still be pushed after a pull. The .htaccess files Loopress writes under wp-content/loopress/ (apps/, vendor/) are now updated when a new plugin version changes them (for example the nosniff header on apps), instead of only being written once. The rewrite is atomic (temp file + rename), so a request never sees half-written rules. A file whose first # Loopress: line was removed is left alone.
  • eb417c3: The Loopress Light readme now lists menu and option sync, which Light already ships, along with the option read/write safeguards.

2026.12.0

Minor Changes

  • d043549: A nested api/orders/index.php is now the /orders route instead of being silently ignored after a successful push. A root api/index.php is refused at push time (it’s the directory’s anti-listing guard, and / is the namespace discovery index), and two files resolving to the same route (orders.php and orders/index.php) are refused at push, or skipped and reported by lps api list when deployed another way. Pushing a published pages/home.html now makes it the site’s front page (Settings > Reading), replacing the previous one; switching it back to draft reverts the site to its latest posts.

Patch Changes

  • bb32133: Serializes every write to wp-content/loopress/api/ and hooks/ (batch pushes, single-file pushes, deletions) behind a per-directory lock. Two overlapping lps api push/lps hook push runs shared one staging directory: the second could end up committing a staging directory holding only its own files, deleting every other route or hook from the site. A second push now waits for the first and starts from its result. If the lock itself can’t be taken (for example wp-content/loopress/ not writable), the request now fails with a JSON 500 carrying the reason instead of a PHP fatal error.

2026.11.0

Minor Changes

  • b60df46: Adds static pages (Loopress Full only): a loopress/v1/pages endpoint that creates or updates a WordPress page from hand-written HTML, stored in a hidden post meta and rendered inside the active theme through the_content (no wpautop). Pages managed this way get a “Managed by Loopress” badge and can no longer be edited in wp-admin; trashing stays possible. A push is refused, with nothing written, on a page Loopress didn’t create, a trashed page, a slug WordPress would rename, or HTML over the loopress_max_file_bytes / loopress_max_files_total_bytes limits.

Patch Changes

  • b60df46: Adds #[Hidden], a class-level attribute for route files: every verb in the file is excluded from the /wp-json/{namespace} discovery index (show_in_index => false, WP core’s own flag) while the route keeps dispatching normally. Useful for a route whose response shape isn’t a fixed REST resource, a GraphQL endpoint or an HTML-serving page.

2026.10.1

Patch Changes

  • 71e7243: Wraps route verb callbacks (get(), post(), etc.) in a try/catch so an uncaught exception returns a clean 500 response instead of fataling the request. WP core’s own dispatch (WP_REST_Server::respond_to_request()) has no try/catch around the route callback, unlike permission_callback and headers(), which RouteLoader already wrapped for the same reason.

2026.10.0

Minor Changes

  • c15b4b9: Makes api push and hook push atomic (#236). Previously, pushFile() sent one independent HTTP PUT per file: a push of N files that failed partway through (network error, a stale expectedRevision, a class collision, a syntax error on file K) left files 1..K-1 already live on the site and K..N untouched, a mixed state discoverable only after the fact via lps diff.

    AbstractFilesDirectory gains a batch staging API (beginBatch/stageWrite/stageDelete/commitBatch/abortBatch): a batch stages into an inactive sibling directory seeded with a copy of every currently live file, and commitBatch() swaps it into place with a directory rename(), atomic on the filesystems WordPress actually runs on. AbstractFilesController gains a POST {routePath}/batch endpoint that validates and stages every file (and every --pruned filename) of one push, and only calls commitBatch() once every single one has succeeded; any failure aborts the batch and leaves the live directory completely untouched.

    api push/hook push now send one batch request instead of N sequential PUTs, folding --prune into the same atomic swap rather than a separate round of DELETEs after the fact. The single-file PUT/DELETE endpoints are unchanged, still used by lps <resource> rm and any direct API integration.

  • 9b3647d: Loopress Full now updates through WordPress’s native Plugins page flow (the “update available” notice, “Update now” link, and bulk updater), instead of requiring a manual re-upload of loopress-full.zip. WordPress fetches the zip directly from the matching GitHub release, using the same GitHub lookup the update-available notice already relied on.

  • 1607fc5: Adds a menu resource for syncing WordPress navigation menus and menu locations. The plugin exposes loopress/v1/menus and loopress/v1/menu-locations REST endpoints, resolving menu items by identity (post type/taxonomy + slug) rather than raw object ids so they remain portable between environments. The CLI gains lps menu list/pull/push/diff, wired into lps push/lps pull/lps diff/lps init and local file validation, and the MCP server gains matching menu_push/menu_pull/menu_list tools.

Patch Changes

  • 6f5bc6b: Extends #234’s conditional write to the acf resource: lps acf push now closes the WordPress-side race a write can still land in for field groups, post types, taxonomies, and options pages, the same way it was already closed for option in #235.

    GET /acf/{type}/{key} now returns a revision (a content hash of the exported object, excluding fields ACF itself computes such as modified). acf push reads it right before writing each object, and sends it back as expectedRevision; POST /acf/{type} refuses the write (412) if the object’s revision no longer matches, instead of silently overwriting whatever changed it in between. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap: it shrinks the window down to this one request’s own execution time, not to zero. The remaining 7 resource-state-backed resources (snippet, form, api, hook, seo, menu, theme-styles) aren’t covered yet, tracked in #234 as the remaining rollout.

  • d2be612: Extends #234’s conditional-write pattern (introduced for option in #235) to the two file-based resources, api and hook, which share the same AbstractFilesController/AbstractFilesDirectory base classes.

    GET /api-files and GET /hook-files now return a revision (a content hash) per file, and PUT returns one too on a successful push. api push and hook push compute each file’s expected revision from the remote listing already read for the push’s own rollback snapshot, no extra network round trip, and send it back as expectedRevision; the server refuses the write (412) if the file’s revision no longer matches, instead of silently overwriting whatever changed it in between. A file with no remote counterpart yet (a first push) has nothing to condition on, so it falls back to today’s unconditional create. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap: it shrinks the window down to this one request’s own execution time, not to zero.

    The remaining 6 resource-state-backed resources (snippet, form, acf, seo, menu, theme-styles) aren’t covered yet, tracked in #234 as the remaining rollout.

  • e4f4b0d: Fixes a fatal error in lps snippet list/pull/push whenever the Code Snippets plugin is active. CodeSnippetsSnippetProvider called Snippet::is_trashed(), a method the current WPackagist release of Code Snippets (3.10.2) no longer has: trash state is exposed as the trashed property on Code_Snippets\Model\Snippet instead. Both call sites (isTrashed() and trashedIds()) now read the property, and the static-analysis stub is updated to match the real class.

  • 9bd5f61: Extends #234’s conditional write to form: lps form push now closes the same WordPress-side race that option push already closed.

    GET /forms/{id} (and the create/update responses) now return a revision (a content hash of the form’s canonical state as reported by whichever form plugin is currently active, minus its id and its own save-bookkeeping fields). form push reads it right before writing each existing form, and sends it back as expectedRevision; PUT /forms/{id} refuses the write (412) if the form’s revision no longer matches, instead of silently overwriting whatever changed it in between. A form with no local id yet (a first push) has nothing to condition on, so it falls back to today’s unconditional create, same as option push. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap: it shrinks the window down to this one request’s own execution time, not to zero. The remaining resource-state-backed resources (snippet, acf, api, hook, seo, menu, theme-styles) aren’t covered yet, tracked in #234 as the remaining rollout.

  • 1d05edd: Continues #234’s rollout: lps menu push now closes the same WordPress-side race that option push closed in #235, this time for the menu resource.

    GET /menus/{slug} (and the upsert response) now return a revision (a content hash of the menu’s name and items). menu push reads it right before pushing each menu, and sends it back as expectedRevision; POST /menus refuses the write (412) if the menu’s revision no longer matches, instead of silently overwriting whatever changed it in between. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap: it shrinks the window down to this one request’s own execution time, not to zero. A menu that doesn’t exist remotely yet has nothing to condition on, so a first push is unchanged. The other 7 resource-state-backed resources (snippet, form, acf, api, hook, seo, theme-styles) aren’t covered yet, tracked in #234 as the remaining rollout.

  • bc2359c: Proof of concept for #234: lps option push now closes the WordPress-side race a write can still land in, on the one resource this lands on first (option).

    GET /options/{name} now returns a revision (a content hash of the value). option push reads it right before writing each option, and sends it back as expectedRevision; PUT /options/{name} refuses the write (412) if the option’s revision no longer matches, instead of silently overwriting whatever changed it in between. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap: it shrinks the window down to this one request’s own execution time, not to zero. The other 8 resource-state-backed resources (snippet, form, acf, api, hook, seo, menu, theme-styles) aren’t covered yet, tracked in #234 as the remaining rollout.

  • 2e05f74: Closes the same race for seo that PR #235 closed for option (#234): lps seo push now conditions its writes on the active SEO plugin’s (RankMath or Yoast) state at read time, instead of silently overwriting a change a different actor made on WordPress in between.

    GET /seo/settings now returns {revision, settings} (a content hash of the settings, wrapped so the hash never gets written back into wp_options as if it were a real Titles & Meta field), and GET /seo/post-meta/{type}/{slug} now includes a revision alongside meta, slug, and title (a content hash of that post’s provider-prefixed SEO meta as a whole, since a write replaces the whole set, not just the keys a particular push happens to mention). seo push reads the relevant revision right before writing each settings file or post-meta file, and sends it back as expectedRevision; PUT /seo/settings and POST /seo/post-meta/{type} refuse the write (412) if the revision no longer matches, the same optimistic-concurrency check option uses. The revision is bookkeeping, never persisted to local files: settings.json and post-meta files on disk are unchanged in shape.

    The remaining resources tracked in #234 (snippet, form, acf, api, hook, menu, theme-styles) aren’t covered yet.

  • f1e8e61: Closes the WordPress-side race lps snippet push could still land in (#234), extending the conditional write introduced for option to the snippet resource.

    GET /snippets/{id} (and the create/update responses) now return a revision, a content hash of every field an update can change (code, active state, location, priority, and the rest). snippet push reads it right before writing each already-known snippet, and sends it back as expectedRevision; PUT /snippets/{id} refuses the write (412) if the snippet’s revision no longer matches, instead of silently overwriting whatever changed it in between. A first push (no known remote id yet) is unaffected, it still falls through to a plain create. This is optimistic concurrency control within a single request, not a database-level atomic compare-and-swap. The remaining resource-state-backed resources (form, acf, api, hook, seo, menu, theme-styles) aren’t covered yet, tracked in #234.

  • e4f4b0d: Fixes the stale-revision (412) error message showing literal &quot; instead of quotes in the CLI’s terminal output. The message is only ever thrown into a REST JSON body and then read as lps stderr, never rendered as HTML, so it should not have been run through esc_html(). Affects options, snippets, forms, ACF objects, menus, and SEO post meta/settings.

2026.9.2

Patch Changes

  • 406eae1: Security: POST /loopress/v1/composer/sync no longer installs from a client-supplied composer.lock.

    The endpoint used to write the uploaded lock to disk verbatim and run composer install, which replayed it as-is: a crafted lock could point dist.url / source.url at attacker-controlled hosts (server-side request forgery) and regenerate vendor/composer/autoload_files.php from those archives, which the plugin then loads on every request (remote code execution). The lock is now advisory only. Composer always runs update and resolves the plugin-rendered composer.json against its own Packagist and WPackagist repositories; the uploaded lock is only parsed afterwards to report which resolved versions moved (lockDrift in the sync response).

    Also: every Composer invocation now passes --no-scripts, and the lock sync argument is capped at 5 MB.

  • b28d36c: Security: bound the size of api/ and hooks/ files so one file can no longer take down /wp-json/.

    AbstractFileLoader tokenises (token_get_all()), then requires, every file in the directory on every request. With no size limit, a large enough file (or a large enough pile of them) exhausted memory, an uncatchable E_ERROR that 500s every REST route on the site, and the same blob would 500 the push that wrote it (LP-SEC-02 / F19 / F20).

    Now:

    • PUT /api-files and /hook-files reject a content over 512 KB with 413, before anything tokenises it.
    • The loader skips any on-disk file over 512 KB (recorded in the load-errors option, surfaced in the admin tab), so a file planted outside the CLI breaks only itself.
    • The loader also stops once the directory as a whole passes an 8 MB budget, a last-resort guard against a runaway directory.
    • Both limits are filterable: loopress_max_file_bytes and loopress_max_files_total_bytes (each passed the subdir, 'api' or 'hooks').
    • list_files returns {filename, error} without content for an over-limit file instead of reading it back.
    • FileWriter::withGuard() fails cleanly if the guard-placement regex bails on its backtrack limit, rather than misplacing the guard.
  • d198555: Security: add DELETE /loopress/v1/api-files and /hook-files so a deployed route or hook can be removed through the product.

    Route and hook files live under wp-content/, outside the plugin directory, so deactivating the plugin never removed them. Before this, a file pushed by a leaked application password could only be cleared over SSH/SFTP (F3). DELETE takes filename in the query string, validated by the same rules as PUT (including the hooks-only “last segment must not be index”), returns 404 when the file is absent and 200 {"filename", "deleted": true} otherwise, and clears any stale boot-time load-error entry for the removed slug.

    uninstall.php (Full edition) already removed the entire wp-content/loopress/ tree; a comment now spells out that this is deliberate (api, hooks, vendor, apps, everything), so nothing Loopress wrote survives an uninstall.

  • 69bdc0c: Security: surface which custom API routes run with no authentication (F1).

    #[Permission(public: true)] on a route’s class or a verb method makes that code callable by anyone, with no login. There was no signal for it anywhere. Now the GET /api-files list and the PUT /api-files push response carry public: true for such a route (detected lexically by a new PermissionScanner, the file is never executed), and the plugin’s API Routes admin tab shows a red Public badge on the row.

    Hooks are unaffected: they have no permission concept, so HookFilesController does not add the flag.

    Detection has two documented blind spots it does not flag: an aliased import of the attribute, and a permission() method that returns true.

  • de7f283: Security: guard the generic /loopress/v1/options/{name} resource so a deployment token is not “read every secret / rewrite site behaviour over HTTP” (F10, F11).

    • GET /options/{name} now returns 403 for a name that looks like a stored secret (matches secret, password, _pass, token, _key, _api_key, apikey, ^auth_, nonce, salt, private_key, credential). This is a best-effort denylist; the real control for a specific site is the new loopress_option_readable filter, or tracking only the options it needs.
    • PUT / DELETE /options/{name} now return 403 for a curated set of behaviour-changing core options (default_role, users_can_register, siteurl, home, cron, uninstall_plugins, mailserver_*, db_version, initial_db_version) and for any loopress_* option (owned by the plugin’s own settings). Re-allow a specific name with the new loopress_option_writable filter.
    • New ProtectedOptionException (403), distinct from ReservedOptionNameException (409, still used only for the 3 names owned by the plugin/theme resources).

    A server-enforced allowlist (“only options declared in loopress.json are readable/writable”) is the stronger fix and is tracked separately: it needs the tracked list synced to the server.

  • b43b2c7: Security: the SEO, Forms and ACF sync resources no longer write unvalidated request bodies straight into storage other plugins render (F12, F13).

    • New SyncSanitizer::stripActiveContent() neutralises script/style blocks, inline event handlers and javascript:/data:text-html URLs in a string, leaving benign HTML and text byte-for-byte so a pull/edit/push round trip stays stable. Applied recursively to PUT /seo/settings, POST /seo/post-meta, and every string in an /acf import object, all of which are rendered into the public <head> or the block editor.
    • POST / PUT /forms no longer overwrites a form’s settings.notifications / settings.confirmations unless the body carries "allowNotifications": true. When allowed, recipient addresses are validated with is_email and sender_address must be on the site’s own domain, and message bodies are stripped of active content. New FormNotificationException (422). This closes the confirmed notification-hijack: repointing every submission to an attacker inbox with a spoofed sender.
    • POST / PUT /seo/redirects rejects a urlTo that points off this site unless the body carries "allowExternal": true, and constrains status to active / inactive. New InvalidRedirectException (422).

2026.9.1

Patch Changes

  • 755b390: Added hooks/, a new resource for declaring WordPress actions, filters, and scheduled (cron) tasks as plain PHP files, deployed with lps hook push/pull/list (and lps push/pull/diff, hook_push/hook_pull/hook_list MCP tools). One file, one class, public methods attributed with #[Action(hook, priority, acceptedArgs)], #[Filter(hook, priority, acceptedArgs)], or #[Cron(recurrence, hook?)] bind straight to add_action()/add_filter()/WP-Cron (a #[Cron] job is just an action bound to a schedule instead of an existing WordPress event). Unlike a REST route, a bound hook runs unconditionally for every visitor with no permission check of its own, so every callback is wrapped to catch and log rather than propagate; a filter additionally fails open, returning the original value on a throw. Loopress Full only, same as custom API routes.
  • f0fe762: Added options/, a new resource for tracking individual WordPress options (wp_options rows) directly, agnostic of which plugin owns them: no adapter to write, unlike seo/ which has to know Yoast’s vs RankMath’s option names and shape. lps option list shows every option name and autoload flag on the site (never values, so browsing stays cheap and never leaks a value by accident), lps option add <name> starts tracking one as a local file, and lps option pull/push/diff/remove work the tracked set from there (push is upsert-only, it never deletes an untracked option). Two safety rails: active_plugins/template/stylesheet are refused outright (already owned by the plugin/theme resources), and environment-owned or WordPress-generated options (siteurl, home, db_version, cron, rewrite_rules, WPLANG) default to "readonly": true in their local file, tracked and diffable but skipped by push unless explicitly overridden. Mirrored as option_push/option_pull/option_list/option_add/option_remove MCP tools.
  • 55b5d6d: Removed the snippet provider migration feature: the Snippets admin tab and the GET/POST /loopress/v1/snippets/migration/{direction} REST routes are gone. Code Snippets and WPCode each ship their own import/export, so moving snippets between them no longer needs a Loopress screen. Snippet sync (lps snippet pull/push/list and the /loopress/v1/snippets routes) is unchanged.
  • 1da6d44: Internal refactor: deduplicated REST error-response handling (AcfController, part of ComposerController), the filesystem bootstrap snippets shared by ApiDirectory/AppsDirectory/LoopressEnvironment, composer.lock/JSON-output parsing in ComposerService, and the “exactly one active provider” arbitration duplicated identically across the SEO/Snippets/Forms services. No behavior change.

2026.9.0

Minor Changes

  • e505f00: Add single-page app hosting (Loopress Full only). A built SPA bundle uploaded over the REST API lands in wp-content/loopress/apps/<name>/: assets are PUT one at a time (per-file size cap, only changed files sent), then one POST /commit flips the loopress_apps option so the front end serves the new build atomically. The [loopress_app name="..."] shortcode enqueues the build’s content-hashed entry files and prints the mount point the SPA attaches to. Files from the immediately previous build are kept one generation so an in-flight visitor does not 404 on a lazy chunk. Hash routing only; static assets only (.php and other server-executable extensions are rejected). A read-only “Apps” tab in the plugin admin lists what has been deployed.
  • d213206: Add Composer-backed plugin/theme installs (Loopress Full only). POST /loopress/v1/composer/sync takes { intent, lock, force }, renders a plugin-owned composer.json from the intent (libraries + WPackagist plugins/themes), runs Composer against the WPackagist repository, and returns the effective composer.json, composer.lock, and the list of removed packages. It returns 422 (unmanaged_plugins_present) when the intent references a plugin/theme folder installed by hand, unless force is set. LoopressEnvironment scaffolds and migrates the site composer.json with the WPackagist repository, composer/installers, and the installer-paths that place plugins/themes under wp-content/.

Patch Changes

  • eb79278: Fixed wp-content/loopress/vendor/ (the site-wide Composer dependencies managed by the Dependencies feature) being fully reachable over HTTP: unlike the plugin’s own bundled vendor/, it had no .htaccess or anti-listing index.php at all, exposing vendor/composer/installed.json (the full dependency tree and exact versions) to anyone who requested it. It now gets the same deny-all .htaccess written on first use. Since .htaccess only works where the webserver actually reads it (nginx ignores it outright, and some Apache hosts disable AllowOverride), the Composer diagnostics panel now also makes a live HTTP check against that file and surfaces a vendor_publicly_accessible warning if it’s still reachable.

2026.8.1

Patch Changes

  • b48b4b5: api/ route files no longer need their class name to match a formula derived from the filename (kebab-case filename -> PascalCase class): the class is now discovered by reading the file itself (PHP’s own tokenizer, never executed to find out), so it can be named anything. Internal, breaking: this replaces the old naming convention entirely, no transition period.

    lps api push now also rejects a file immediately (before anything is written) if it declares zero or more than one class, or if its class name collides with WordPress core, another active plugin, or another api/ file already on the site, instead of only failing silently at the next site boot. A file that still fails to load at boot (a stale collision from a plugin activated later, for instance) now shows a warning in the plugin’s API Routes admin tab, with the actual reason, clearing itself automatically once the file loads clean again.

  • 8e43b47: Admin page styles are now enqueued (wp_add_inline_style) instead of echoed as raw <style> tags, and the Loopress top-level menu item moved from position 6 to 100 so it stops competing with WordPress core’s own menu hierarchy. Both were flagged by the wordpress.org plugin review.

2026.8.0

Minor Changes

  • 182ae2a: api/ route files: the anti-listing index.php is now recreated on every boot if missing, not only when lps api push happens to write a file, so a Git-based deploy that never goes through lps api push still gets it. Also logs (without blocking the route) when a file is missing its ABSPATH guard, which is expected for any file deployed outside lps api push since the guard is only ever injected at push time and stripped again on pull.

  • 182ae2a: api/ route files can now use a bracketed segment, [order_id], anywhere in their path (e.g. api/invoice-pdf/[order_id].php, api/orders/[order_id]/items/[item_id].php) to capture a dynamic value into $request->get_param(...), the same convention as Astro/Next.js dynamic routes, without a catch-all segment. The segment name must start with a letter or underscore (it becomes a PHP identifier internally). The generated class name PascalCases each path segment and joins them with _ (InvoicePdf_OrderId, not InvoicePdfOrderId), so two differently nested files can never collide on the same class name.

    lps api push/pull/list now support route files nested in subdirectories, needed for the above. Internal, breaking: the upload endpoint (PUT loopress/v1/api-files) now takes filename as a body field instead of a URL path segment (avoids depending on how a given host handles a percent-encoded slash in a URL), so the CLI and the WordPress plugin must be upgraded together, an old CLI against a new plugin (or the reverse) will fail to push.

  • 182ae2a: New wp-content/loopress/lib/ directory, autoloaded under the LoopressLib\ namespace (via the same Composer setup as wp-content/loopress/vendor/), for code shared between api/ route files, permission checks and formatters reused across several files, without turning that shared code into a route itself. Existing sites get the new autoload entry migrated into composer.json automatically, with a dump-autoload to make it take effect immediately rather than only on the next unrelated Composer operation.

  • 182ae2a: api/ route files can now declare #[Permission] on a verb method or on the class, for per-verb authorization (a public get() next to an admin-only post(), for example) instead of one permission() covering every verb. Resolution order: attribute on the verb, attribute on the class, the file’s permission() method, the closed manage_options default. #[Permission(callback: ...)] can point to a shared static method, reusable across several route files, with the same fail-closed behavior on a throw as permission(). Combining more than one of public, capability, or callback on the same attribute is rejected (the route fails to register, logged, rather than silently picking one).

  • 67a932e: api/ route files: permission() is now called directly by WordPress as the route’s permission_callback (permission(WP_REST_Request $request): bool), instead of being called at registration to produce a callable. Breaking: the old permission(): callable { return fn(): bool => ...; } form no longer works, update any custom route that overrides permission(). A throw inside permission() now fails closed (denies that request) instead of skipping the whole route file at boot.

    Also logs when an api/ file has no public HTTP verb method (get/post/put/patch/delete), previously silent and indistinguishable from a route that intentionally has none yet.

  • 182ae2a: lps api push’s server-side PHP syntax check now distinguishes “verified, no error” from “couldn’t verify here” (exec() disabled, common on managed hosts, or another local condition preventing the check from running), instead of treating both as silent success. The CLI now reports when the check was skipped for the second case instead of staying indistinguishable from a fully verified push.

2026.7.16

Patch Changes

  • af9c109: Bug fixes from the QA backlog (5th pass), lot 1:

    • SEO sync (YoastService/RankMathService) no longer writes arbitrary metadata keys from the request body: the write loop is now bounded to the provider’s own prefix, symmetrically with the delete loop right below it. Not exploitable beyond the existing manage_options trust boundary, but closes a defense-in-depth gap where a bug elsewhere, or a future feature reusing this endpoint, could silently overwrite metadata belonging to another plugin (ACF, FluentCRM, etc.) on a published post or page.
    • uninstall.php no longer fatals when vendor/ is missing (a dev checkout symlinked into wp-content/plugins/ without composer install). The cleanup was already best-effort, it’s now skipped gracefully instead of fataling.
    • Corrected an outdated claim that ACF options pages require ACF PRO, in both a code comment and the error message shown when an object type isn’t registered. Confirmed working with Secure Custom Fields (the free fork recommended by WordPress.org) during the 5th QA pass; the error message now points at the real fix (acf_add_options_page()) instead of a PRO requirement.

2026.7.15

Patch Changes

  • 6dac6c3: Fixed a fatal error on every Loopress Light install (Class "DI\ContainerBuilder" not found): the Light build shipped an empty Composer require, but shared code (ContainerFactory, WpHttpClient) depends on php-di/php-di, nyholm/psr7, and psr/http-client. The Light build now keeps those, and only excludes the genuinely Full-only packages (composer/composer, sentry/sentry).
  • 6dac6c3: LOOPRESS_VERSION is now read from the plugin’s own Version: header via get_file_data() instead of a hardcoded literal, removing a second copy that scripts/sync-version.js had to keep in sync and could drift from the header.

2026.7.14

Patch Changes

  • dd36268: LOOPRESS_VERSION is now read from the plugin’s own Version: header via get_file_data() instead of a hardcoded literal, removing a second copy that scripts/sync-version.js had to keep in sync and could drift from the header.

2026.7.13

Patch Changes

  • fae069f: Fixed errors reported by the WordPress Plugin Check tool: escaped the exception message in WpHttpClient::sendRequest(), and bumped the readme’s “Requires at least” header to 6.2 so the RankMath redirects queries can keep using the %i prepare placeholder.

2026.7.12

Patch Changes

  • 7e28650: api/ route files can now use packages installed via the Composer feature (wp-content/loopress/vendor/) directly, without a manual require_once of the autoloader. Previously this only happened to work by coincidence, as a side effect of the unrelated Dependencies feature loading it first for its own diagnostics; RouteLoader now loads it explicitly and independently, and a broken user autoloader is caught and logged instead of breaking every api/ route.

  • 827af5b: The REST namespace api/ route files register under (loopress-api/v1 by default, e.g. hello.php becomes loopress-api/v1/hello) is now configurable from the plugin’s Settings tab instead of hardcoded. Existing sites keep the same default namespace and routes; only sites that explicitly set a custom value change behavior.

  • 5658d6c: Fix three bugs found during manual QA. Pushing a snippet with a location unsupported by the active provider (WPCode) used to create the snippet anyway with the location silently defaulted, while still reporting failure to the CLI; since the CLI never learned the resulting id, retrying the push (the natural reaction to a reported failure) created another duplicate snippet each time. WPCodeSnippetProvider now validates the location before any write, so an invalid location is rejected with nothing created. A pushed api/ route file with a real PHP parse error was accepted and listed as present by lps api push/list, while the route silently 404d at request time; ApiFilesController::push_file() now runs a real PHP syntax check before writing, rejecting with a clear 400. SEO endpoints (lps seo) returned a generic 500 for client-actionable conditions (multiple SEO plugins active, redirects unsupported by the active plugin) instead of the 409/400 used by the equivalent snippets/forms guards; dedicated exceptions now map these correctly, and get_settings()/update_settings() (which had no error handling at all before this) are covered too.

    Also fixes lps composer init’s generated scaffold, which required composer/installers without allow-listing it, so Composer 2.2+’s non-interactive plugin-trust gate blocked every real composer push through it. Error messages surfaced from a WordPress REST failure now include the server’s full detail (e.g. the actual Composer trace) instead of just a generic summary, making failures like this one diagnosable from the CLI’s own output.

2026.7.11

Patch Changes

  • 2b6420c: Adds custom API routes: a versioned api/ folder (lps api push/pull/list on the CLI) lets a project expose its own WP REST endpoints without hand-writing a plugin, deployed straight to wp-content/loopress/api/ on Loopress Full and registered under loopress-api/v1/<filename>. One file, one class, one method per HTTP verb (get/post/put/patch/delete), resolved by filename convention (kebab-case -> PascalCase). Defaults to manage_options + Application Password auth like every other sync route, with explicit per-file overrides for permission() (e.g. an anonymous headless form endpoint) and headers() (CORS, including the OPTIONS preflight). Filenames are allowlisted against path traversal, deployed files get an auto-injected ABSPATH guard against direct HTTP access, and a bad file (parse error, class collision, throwing permission()/headers()) is skipped and logged rather than fataling rest_api_init or breaking other routes. Full only, like snippet sync: Light stays ACF+SEO only.
  • 063616e: Loopress Full’s Sentry error reporting is now opt-in. Until an admin decides either way, a banner (“Send crash reports to Loopress?”) shows on every tab of the admin page with Allow/Deny buttons; once decided, a switch in the new Settings tab reflects and lets you change the choice. The Sentry PHP SDK’s global handlers don’t install at all until consent is given. Backed by GET/PUT loopress/v1/sentry/consent, storing the choice in a WordPress option. A new “Reset all settings to default” button (DELETE loopress/v1/settings, global to all Loopress settings, not just Sentry) clears it and brings the banner back. Existing installs upgrading into this send nothing until an admin opts in.
  • 063616e: Loopress Full now initializes the Sentry PHP SDK on boot, reporting PHP errors and exceptions from the plugin’s own code so they can be triaged across every install. Filtered via a before_send callback that only keeps events whose stack trace passes through this plugin’s own files, so a site’s Sentry project never fills up with errors from its theme or other plugins. Loopress Light doesn’t have this: src/Sentry/ is stripped at build time like the other Full-only features. Currently a scaffold pending the real Sentry project DSN, an unset DSN is a documented no-op for the SDK, so this ships inert until then.
  • f542f91: Code snippet sync (Code Snippets, WPCode) moved from Loopress Light to Loopress Full, alongside Composer dependency management. wordpress.org’s final decision on the appeal rejected Loopress Light’s snippet sync REST endpoints as a remote arbitrary-code-deployment mechanism, regardless of the authentication and capability checks in front of them. Loopress Light now syncs only ACF field groups and SEO settings (Yoast, RankMath); lps snippet pull/push and the snippet migration UI require Loopress Full. REST routes are unchanged (loopress/v1/snippets*), so existing CLI versions keep working against Loopress Full.
  • 9383f6e: Adds a WordPress form-sync feature: lps form list/pull/push on the CLI, backed by new REST routes under loopress/v1/forms on Loopress Full (Light stays ACF+SEO only). The plugin side introduces a generic FormProvider abstraction, mirroring the existing snippet sync (Code Snippets/WPCode), with WPForms as the first supported plugin; more WordPress form plugins can be added as additional providers later. Forms are addressed by numeric id (no ACF-style stable key), pulled/pushed as one <id>-<slug>.json file per form, with orphan cleanup on pull and the same PUT-then-404-fallback-to-create dance as lps snippet push.

2026.7.10

Patch Changes

  • 512e72b: Loopress Full now checks GitHub for newer releases and shows an admin notice (“Loopress Full X is available, you are running Y”) with a link to the release when one exists. Read-only for now: no download or install, you still update manually via loopress.dev, same as before. Backed by a new GET loopress/v1/update endpoint. Loopress Light doesn’t have this: WordPress.org reserves update-checking for its own review-and-update flow, so it stays out of that edition.
  • 512e72b: Remove wp-admin’s default left padding on the Loopress admin page only (scoped to that page, every other wp-admin screen is unaffected), so the plugin’s own page layout controls its own spacing instead of being padded twice.

2026.7.9

Patch Changes

  • 6f5712c: Add lps acf pull/lps acf push/lps acf list, which sync ACF (Advanced Custom Fields) field groups, post types, taxonomies, and options pages between the WordPress site and local JSON files, the same git-based workflow already available for snippets and Composer dependencies.

    Backed by new loopress/v1/acf/* endpoints on the WordPress plugin. Requires ACF to be installed and active; options pages additionally require ACF PRO.

  • 6c557ec: Add lps composer init, which scaffolds a composer.json wired to WPackagist (repository, composer/installers, installer-paths) so WordPress.org plugins and themes can be added and installed through Composer instead of the native plugin API.

    lps composer pull now also pulls composer.json (previously only composer.lock), backed by a new GET loopress/v1/composer/json endpoint on the WordPress plugin, so local composer.json stays in sync with packages added or removed through the Loopress admin page.

2026.7.8

Patch Changes

  • 3296877: Loopress Light now links to the full documentation (https://docs.loopress.dev/wordpress-plugin/) instead of the bare loopress.dev when mentioning Loopress Full, in both the readme (description and FAQ) and a new note on the plugin’s own settings page, and states explicitly that Loopress Full is free. Also fixed ambiguous wording that read as “not distributed from loopress.dev” when the opposite is true.

2026.7.7

Patch Changes

  • 3acd857: Fixed a WordPress Plugin Check text domain mismatch on both editions: the source carried loopress, a leftover text domain from before the light/full split that matches neither edition’s real slug. scripts/build-flavor.cjs now rewrites the Text Domain header and translation calls to loopress-light / loopress-full per edition at build time. Also realigned both readme.txt changelogs with CHANGELOG.md, the source of truth: they had drifted with fabricated 2026.8.0 and 2026.6.0 entries that don’t exist in the real release history.

2026.7.6

Patch Changes

  • 31a82f6: The WordPress plugin now builds two editions from the same codebase: Loopress Light (loopress-light.zip, wordpress.org) keeps snippet synchronization (Code Snippets / WPCode); Loopress Full (loopress-full.zip, loopress.dev only) adds Composer dependency management (install, remove, audit, diagnostics, CLI sync) on top. Loopress Light contains no Composer code, even inactive; Loopress Full fully replaces Loopress Light on activation, both editions never run side by side. REST routes are unchanged (loopress/v1/snippets* and loopress/v1/composer*, the latter only present in Loopress Full), so existing CLI versions keep working.

2026.7.5

Patch Changes

  • 887b59f: Fixed all errors reported by the WordPress Plugin Check tool: escaped dynamic exception messages across the Composer and snippet provider services, added the missing ABSPATH direct-access guard in loopress.php, removed the tracked assets/.gitkeep hidden file (the prebuild script now creates the directory itself), and bumped the readme’s “Tested up to” header to 7.0.

2026.7.4

Patch Changes

  • a85959e: Fix two bugs in the Code Snippets provider: deleteSnippet() was missing the leading slash required by WP_REST_Server::dispatch(), so DELETE /wp-json/loopress/v1/snippets/{id} always failed with “Snippet not found” instead of deleting anything. Separately, getSnippets()/getSnippet() returned trashed snippets indistinguishably from active ones (Code Snippets’ own REST API doesn’t filter them out), causing lps snippet pull to re-import a snippet the user had just deleted from wp-admin.

2026.7.3

Patch Changes

  • 9a8f6b5: Removed PluginService, PluginController, and the loopress/v1/plugins* REST routes. Plugin management now relies entirely on WordPress core’s native wp/v2/plugins REST API, which the Loopress CLI calls directly.

2026.7.2

Patch Changes

  • 3f04ab4: Replace md5 hash function by native hash function with sha256
  • 13c5fb9: Add tab on the UI and diagnostics panel
  • 1dc1228: Unify snippet route

2026.7.1

Patch Changes

  • 6122ddc: Align better sidecar file with WPCode api

2026.7.0

Minor Changes

  • 25ece79: 1st release of the plugin containing basic UI fordependencies installation. It also contains endpoints to let the CLI interact with Wordpress

@loopress/mcp

0.33.0

Minor Changes

  • 0bf479c: Dynamic blocks (Loopress Full): lps block push | pull | list | diff | rollback | rm and the block_* MCP tools deploy blocks/<slug>/ folders (block.json + render.php, optionally style.css and view.js) registered as the server-rendered block loopress/<slug>. Each block’s CSS and JS load only where it renders, it works inside a Query Loop, and a render.php that throws renders nothing instead of breaking the page. lps push, lps pull, lps diff and lps validate include them.

Patch Changes

  • 47d5a68: Consistency fixes from the 2026-10-27 audit. lps api and lps hook help examples pass the directory as a positional argument (there is no --path flag), and the public-route warning links to docs.loopress.dev. lps --help describes the app, form, hook, menu, option and page topics. The MCP server reports its real version, and its missing-project error names lps init. Docs: API route and hook pushes are all or nothing, the declared resources appear in the workflow page, plus the snippet sidecar fields, the project config prompts and the push exceptions that do delete.
  • 4613ded: Loopress Light no longer breaks the aggregate commands. GET loopress/v1/capabilities now lists the site’s modules, and lps pull, lps push (dry run included), lps diff and so lps promote skip a Full-only step (“needs Loopress Full”) when the project has no files for it. When it does, lps push refuses before pushing anything, lps pull and lps diff report it. lps push and lps theme push skip templates and parts when the project has none, even with activateChildTheme (use lps theme activate for an empty child), and lps theme template push alone refuses with “Nothing to push”. The removed SEO module warning is gone.
  • Updated dependencies [47d5a68]
  • Updated dependencies [0bf479c]
  • Updated dependencies [cbe0bd9]
  • Updated dependencies [b038a67]
  • Updated dependencies [4613ded]
  • Updated dependencies [f24e50f]
  • Updated dependencies [da7d681]
  • Updated dependencies [b698800]
    • @loopress/cli@0.33.0

0.32.2

Patch Changes

  • ac984c0: lps diff, push dry runs and the outside-edit check no longer report drift on ACF field groups or Global Styles that are in sync. ACF stores every setting with its default (wrapper, aria-label…), which a hand- or agent-written field group leaves out: the local files are now compared as the site would store them, through a new read-only plugin route (POST loopress/v1/acf/normalize/<type>) that runs them through ACF’s own validation without saving anything. Global Styles now compare a preset list and its origin-keyed form (palette: [...] and palette: {custom: [...]}) as the same, and a var:preset|color|base reference as its var(--wp--preset--color--base) expansion, the two forms WordPress rewrites on save. With a plugin older than this release, ACF files are compared as written, as before. If the site can’t be read for the outside-edit check after an earlier push, the push now stops instead of skipping the check.
  • Updated dependencies [ac984c0]
    • @loopress/cli@0.32.2

0.32.1

Patch Changes

  • 26fb36c: lps validate and lps hook push no longer reject a hook whose method outputs a template (?> <p>...</p> <?php inside the class) as “code outside the class”: only inline HTML at the top level of the file is flagged, as the plugin does.
  • Updated dependencies [26fb36c]
    • @loopress/cli@0.32.1

0.32.0

Minor Changes

  • 35310b3: The MCP server now tells a connecting agent what a Loopress project is and links the documentation of each file format, so an agent writes a Loopress hook instead of plain WordPress code. lps validate (and the MCP validate_local tool) now runs the same checks on hook and API route files as push: a valid file name, declare(strict_types=1); exactly once, and no code outside the class (a hand-written ABSPATH guard included, the plugin adds its own), and declare(strict_types=1); as the first statement. Those problems now surface before push; PHP syntax and class-name collisions are still checked by the server at push.
  • ad06a9a: lps page push can now take over a page with the same slug that Loopress did not create, instead of refusing it. It lists those pages and asks once, or takes them over directly with --overwrite (required in CI and through the MCP server). The page keeps its URL and ID, so menus, links and SEO data keep working, and its original content is saved as a WordPress revision first.
  • ad938a6: lps push and lps pull (and so lps promote, lps content copy, push_all and pull_all) now include Global Styles once the project tracks them, on a site whose active theme is a block theme, and skip them with a one-line note otherwise. Theme versions stay with lps theme push. ACF, forms and snippets are skipped when the project has no directory for them, so lps push --dry-run (and the push_all preview) no longer fails with a 409 on a site without a form plugin, and lps diff no longer reports such a resource as inconclusive.
  • df54b2d: A push’s dry run now says what it would change: each local item is created, changed or unchanged against the site, in the human output and under changes in --json (existing fields are kept). lps acf push also names the fields added, changed or removed in a changed field group, and lps theme template push lists templates, parts and CSS files separately, with removed for a file the child theme would drop. lps push and lps theme push now return each step’s own result. The MCP push tools’ previews are these results, and their descriptions now tell the agent so. The plugin’s child theme endpoint reports a hash of each CSS file, so the dry run can tell whether the theme CSS changed.

Patch Changes

  • Updated dependencies [3f3beb9]
  • Updated dependencies [35310b3]
  • Updated dependencies [ad06a9a]
  • Updated dependencies [ad938a6]
  • Updated dependencies [df54b2d]
  • Updated dependencies [515153d]
    • @loopress/cli@0.32.0

0.31.0

Minor Changes

  • 60ef881: New content_copy tool, the equivalent of lps content copy.
  • ad4500c: push_all, page_push and every push tool with a matching _rollback tool take an optional overwrite boolean (the CLI’s --overwrite): without it, a confirmed push over a change made on the site outside Loopress fails and names what it would overwrite.

Patch Changes

  • 90779f2: project_promote takes an optional overwrite boolean, passed to lps promote --overwrite.
  • Updated dependencies [60ef881]
  • Updated dependencies [5bb3a54]
  • Updated dependencies [ad4500c]
  • Updated dependencies [90779f2]
    • @loopress/cli@0.31.0

0.30.0

Minor Changes

  • a1a5147: New cpt_push, cpt_pull, cpt_list, cpt_rm and cpt_rollback tools for custom post types, and project_diff accepts cpt.
  • 049d127: New meta_push, meta_pull, meta_list, meta_rm and meta_rollback tools, and project_diff accepts meta.
  • 22894f6: Breaking: the seo_list, seo_pull, seo_push and seo_rollback tools are removed, following the removal of lps seo. Track SEO plugin settings with option_add (rank-math-options-titles or wpseo_titles) instead.
  • cd4b340: New role_* and pattern_* tools (push, pull, list, rm, rollback), and project_diff accepts role and pattern.
  • 66790cb: New taxonomy_push, taxonomy_pull, taxonomy_list, taxonomy_rm and taxonomy_rollback tools, and project_diff accepts taxonomy.
  • 7139fd8: template_push now also pushes the theme CSS (theme/css/, theme/blocks/) to the Loopress child theme.

Patch Changes

  • aa60465: Commands no longer remember an environment. Without --env, they target the project’s local environment, or its only environment when it has just one. A project with several environments and no local one needs --env on every command. lps project switch now picks a project only, and lps project list tags the default environment with [default] instead of an arrow on the switched one (isDefault replaces isCurrent per environment in --json). An existing config.json keeps working: the env field of currentProject is ignored and dropped on the next write.
  • Updated dependencies [a1a5147]
  • Updated dependencies [95d9f8c]
  • Updated dependencies [aa60465]
  • Updated dependencies [049d127]
  • Updated dependencies [22894f6]
  • Updated dependencies [03d3da0]
  • Updated dependencies [cd4b340]
  • Updated dependencies [3bbe20d]
  • Updated dependencies [b87fa13]
  • Updated dependencies [66790cb]
  • Updated dependencies [7139fd8]
  • Updated dependencies [5fdd7ee]
    • @loopress/cli@0.30.0

0.29.0

Minor Changes

  • cc5ede1: lps plugin pull no longer adds every plugin installed on the site. Plugins the project already tracks are refreshed, and the others are offered as a list to tick, unticked by default, so a pull from production doesn’t bring its cache, security or backup plugins into the project. Without a terminal (--yes, --json, CI, the MCP server), only tracked plugins and those named with the new --plugin <slug> flag are written; the MCP plugin_pull tool takes them as plugins.

    Plugins under require-dev in composer.json are now installed on the local environment by lps plugin push and lps composer push, and still never on a remote site: use it for Query Monitor, or for a premium plugin’s free edition while production runs the paid one.

  • bee1b73: Breaking: everything theme related now lives under lps theme. lps template push/list/diff is now lps theme template push/list/diff, and the versions-only lps theme push/pull is now lps theme version push/pull. lps theme push now pushes the whole topic in order (versions, then templates and parts, then Global Styles), and lps theme pull pulls versions and Global Styles. lps theme style push accepts --yes. In the MCP server, theme_push/theme_pull now wrap the whole topic, the new theme_version_push/theme_version_pull keep the versions-only behavior, and the template_* tool names are unchanged. The template and part resource names for lps diff --only/--skip are unchanged.

Patch Changes

  • cc5ede1: A push’s --dry-run now reads the target site, the same way lps diff does, instead of staying local. It used to announce ACF, SEO, forms or snippets as pushed on a site that would refuse them (plugin missing), and the real push then failed; the dry run now fails the same way. The MCP server’s preview and pre-apply check, built on the dry run, benefit too. lps push --dry-run also says “would push” instead of “pushed”.
  • 69ed247: lps acf, lps seo, lps form, lps menu and lps theme style push/pull now accept --json. The MCP server runs every command with --json, so acf_push, acf_pull, seo_push, seo_pull, form_push, form_pull, menu_push, menu_pull, theme_styles_push and theme_styles_pull always failed with “Nonexistent flag: –json”. A test now checks that every command the MCP server can call supports --json.
  • 69ed247: The MCP server now refuses a change to production even when env is omitted and production is the active environment. Before, only an explicit env: "production" was refused, so hook_rm, api_rm, a *_rollback or a push with prune could reach production without any human confirmation. A mutating tool called without env now resolves the active environment first and pins it, so the confirmed call also applies to the environment that was previewed, even if the active one is switched in between. lps status --json reports the resolved environment name for this.
  • 69ed247: project_diff now accepts template, part and page in only/skip, like lps diff --only.
  • Updated dependencies [cc5ede1]
  • Updated dependencies [426df4b]
  • Updated dependencies [69ed247]
  • Updated dependencies [69ed247]
  • Updated dependencies [cc5ede1]
  • Updated dependencies [69ed247]
  • Updated dependencies [cc5ede1]
  • Updated dependencies [b77aafe]
  • Updated dependencies [e57f3a0]
  • Updated dependencies [426df4b]
  • Updated dependencies [bee1b73]
  • Updated dependencies [a3c5097]
    • @loopress/cli@0.29.0

0.28.0

Minor Changes

  • cd9479d: lps app push now builds each app before pushing it: when apps/<name>/package.json has a build script, it runs with the app’s package manager (from packageManager, else the nearest lockfile, else npm), on --dry-run too, so a forgotten rebuild no longer ships a stale dist/ reported as “up to date”. A failing build fails that app and uploads nothing. Pass --no-build (MCP: noBuild) when CI already built. Dependencies are never installed for you.
  • 20a406c: Block templates and parts: templates/<slug>.html and parts/<slug>.html are written by lps template push as the files of a child theme of the active block theme, <parent>-loopress, the parent staying untouched. Any template of the hierarchy can be overridden, and a template with a title/postTypes header becomes a page template a static page picks with its template header (no header or footer part gives a bare page). Edits made in the Site Editor are never overwritten and show up as drift in lps template diff and lps diff. Part of lps push (before pages), lps dev, lps init and lps validate. MCP gains template_push, template_list and template_diff.
  • 61350a3: Breaking: lps theme-styles pull/push/diff/rollback is now lps theme style pull/push/diff/rollback, grouped with the other theme commands. The theme-styles resource name for lps diff --only/--skip and the MCP tool names (theme_styles_*) are unchanged, and existing rollback snapshots still work.

Patch Changes

  • Updated dependencies [cd9479d]
  • Updated dependencies [4d1c8af]
  • Updated dependencies [4d1c8af]
  • Updated dependencies [eb417c3]
  • Updated dependencies [20a406c]
  • Updated dependencies [b1c46ac]
  • Updated dependencies [61350a3]
    • @loopress/cli@0.28.0

0.27.0

Patch Changes

  • bb32133: lps dev now also watches hooks/ and pushes hook files on change. A request the Loopress plugin refuses on purpose with a 403 (for example reading a secret-looking option) now shows the plugin’s own reason instead of “Authentication failed, check your credentials”, and HTML entities in server messages (&quot;) are decoded. Errors from the Loopress cloud API now show the API’s own message (validation errors included) instead of a generic “Request failed with status code 400”.

    lps validate now checks JSON files at any depth (ACF’s acf/<type>/, SEO’s post-meta/ and redirects/ were silently skipped before), tracked option files (a missing name, autoload or value is reported), and the theme styles directory.

    lps plugin add and lps theme add support --json, and the MCP server gets matching plugin_add and theme_add tools (local loopress.json edit only).

    Rollback snapshots (.loopress/snapshots/, inside your project) hold the environment’s full remote state; the .loopress/ directory now writes its own .gitignore so they are never committed by accident.

    lps push, lps pull and lps promote with --json now print a single valid JSON document: the progress output of the resource commands they run goes to stderr instead of polluting stdout, which broke the MCP push_all, pull_all and project_promote tools. A failed aggregate run now lists each failed resource and its reason.

  • e042cde: Fix the MCP server on Windows: every tool failed with ENOENT because it spawned lps without a shell, and Windows installs it as lps.cmd, which Node refuses to run directly. The server now starts the installed CLI’s bin/run.js with its own Node, falling back to lps on PATH through tinyexec, which also works on Windows. LPS_BIN also accepts a .js file. @loopress/cli now exports its package.json, and the MCP server now declares Node 22 or later, like the CLI.

    lps init now adds * text=auto eol=lf to .gitattributes, so a checkout on Windows (CRLF by default) keeps the same bytes as what pull writes.

  • Updated dependencies [1cd51fc]

  • Updated dependencies [bb32133]

  • Updated dependencies [d043549]

  • Updated dependencies [bb32133]

  • Updated dependencies [e042cde]

    • @loopress/cli@0.27.0

0.26.0

Minor Changes

  • b60df46: Adds lps page push [SLUG], lps page list and lps page diff for static HTML pages (pages/<slug>.html, pageDir in loopress.json), with an optional <!-- title: ... / status: draft|publish --> header. Pages are part of lps push, lps diff --only page, lps dev and lps init, and are checked by lps validate. The MCP server gets matching page_push, page_list and page_diff tools, and a CLI command that exits non-zero with a regular JSON payload (a diff reporting drift) is now returned as a result instead of an error. Requires Loopress Full.

0.25.0

Minor Changes

  • 3ec619c: lps doctor now supports --json, returning a structured {project, environment, url, checks, ok, pluginVersion} result instead of throwing on failure (its human-readable output and exit code are unchanged).

    The MCP server gains three read-only tools that were missing from CLI parity: project_diff (drift between local files and an environment, or between two environments), project_doctor (connectivity, plugin and credential diagnostics), and validate_local (checks local tracked files without contacting WordPress).

  • 1607fc5: Adds a menu resource for syncing WordPress navigation menus and menu locations. The plugin exposes loopress/v1/menus and loopress/v1/menu-locations REST endpoints, resolving menu items by identity (post type/taxonomy + slug) rather than raw object ids so they remain portable between environments. The CLI gains lps menu list/pull/push/diff, wired into lps push/lps pull/lps diff/lps init and local file validation, and the MCP server gains matching menu_push/menu_pull/menu_list tools.

  • 10d18ec: lps push, lps pull, and lps promote now support --json (each returns a structured per-resource result instead of erroring with “Nonexistent flag: –json”). This was a real bug in the MCP server’s push_all and pull_all tools, which always shell out with --json and so always failed.

    The MCP server also gains project_promote, the tool for lps promote that CLI parity was missing.

  • ce19505: Adds lps <resource> rollback for the 9 resource-state-backed resources (snippet, form, acf, api, hook, seo, menu, option, theme-styles). lps <resource> push now writes a snapshot of the environment’s state (before and about-to-be-pushed) to a local, gitignored cache (.loopress/snapshots/<resource>/, capped at the last 10 per resource) right before every real push. lps <resource> rollback restores the most recent snapshot (or --to <id>), --list shows what’s available. It refuses to overwrite a change made to the environment since the original push unless explicitly confirmed (interactive prompt, or --yes), the same shape as the production-push guard. No WordPress-side capability is required: restoring materializes the archived state into the resource’s own local file layout, then delegates to the existing push command.

    The MCP server gains a matching <resource>_rollback tool per resource, following the existing confirmToken handshake (list: true bypasses it as a plain read).

  • 4432466: New lps theme-styles pull/push/diff command group syncs the active block theme’s Global Styles customizations (Site Editor > Styles: colors, typography, spacing) as a local theme/<stylesheet>-global-styles.json file, reusing WordPress core’s own wp/v2/themes and wp/v2/global-styles/<id> REST endpoints (no new PHP controller needed). Classic (non-block) themes fail the command clearly instead of syncing nothing silently. This resource is included automatically in lps diff, but deliberately left out of the aggregate lps push/lps pull (like lps theme), so a classic-theme site’s routine sync isn’t broken by this opt-in feature.

    The MCP server gains matching theme_styles_pull and theme_styles_push tools, and project_diff now covers theme-styles too.

Patch Changes

  • d7aa371: The confirm-token handshake every mutating MCP tool uses now catches remote-state staleness, not just local-file staleness. Previously only a file swapped between the preview and confirmed calls was protected against (via a frozen working-tree copy); a remote change on WordPress in that same window (e.g. someone else editing the environment between a snippet_rollback preview and its confirm) could still be silently overwritten, since the confirmed call trusted the preview’s drift check without re-checking it.

    The confirmed call now re-runs the same --dry-run once more right before applying, and refuses (STALE_PREVIEW) if it reports anything different from what the preview reported, instead of applying against stale data. This applies to every mutating tool generically (push, rm, prune, rollback, …), not just rollback: each tool’s own --dry-run output is compared as-is, so tools whose preview doesn’t inspect remote state see no behavior change.

0.24.1

Patch Changes

  • cedc316: Security: the mutating-tool confirm handshake now applies from a frozen copy of the working tree, not a fresh disk read (F28).

    The two-call handshake bound the confirmToken to the CLI arguments, but the apply step re-read the files from disk, up to 5 minutes after the preview the caller approved. A file swapped in that window (a benign snippets/foo.php for a web shell) was pushed under an approval given against the old content.

    The preview call now copies the working directory to a temp dir (skipping node_modules, .git, vendor, dist), runs the dry-run against that copy, and the apply runs from the same copy. The snapshot is removed after the apply and on token expiry or eviction. Known gap, documented: an lps --path outside the working directory, or a loopress.json that maps a resource directory to an absolute path elsewhere, is still read live.

0.24.0

Minor Changes

  • 755b390: Added hooks/, a new resource for declaring WordPress actions, filters, and scheduled (cron) tasks as plain PHP files, deployed with lps hook push/pull/list (and lps push/pull/diff, hook_push/hook_pull/hook_list MCP tools). One file, one class, public methods attributed with #[Action(hook, priority, acceptedArgs)], #[Filter(hook, priority, acceptedArgs)], or #[Cron(recurrence, hook?)] bind straight to add_action()/add_filter()/WP-Cron (a #[Cron] job is just an action bound to a schedule instead of an existing WordPress event). Unlike a REST route, a bound hook runs unconditionally for every visitor with no permission check of its own, so every callback is wrapped to catch and log rather than propagate; a filter additionally fails open, returning the original value on a throw. Loopress Full only, same as custom API routes.
  • f0fe762: Added options/, a new resource for tracking individual WordPress options (wp_options rows) directly, agnostic of which plugin owns them: no adapter to write, unlike seo/ which has to know Yoast’s vs RankMath’s option names and shape. lps option list shows every option name and autoload flag on the site (never values, so browsing stays cheap and never leaks a value by accident), lps option add <name> starts tracking one as a local file, and lps option pull/push/diff/remove work the tracked set from there (push is upsert-only, it never deletes an untracked option). Two safety rails: active_plugins/template/stylesheet are refused outright (already owned by the plugin/theme resources), and environment-owned or WordPress-generated options (siteurl, home, db_version, cron, rewrite_rules, WPLANG) default to "readonly": true in their local file, tracked and diffable but skipped by push unless explicitly overridden. Mirrored as option_push/option_pull/option_list/option_add/option_remove MCP tools.
  • 6b0fda3: Breaking: removed the page resource. lps page pull/push/list/diff, the page_pull/page_push/page_list MCP tools, the pageDir setting in loopress.json, and the pages entry for lps dev / lps diff --only are all gone. lps push/pull no longer touch pages. WordPress pages are core content editable in wp-admin or over wp/v2/pages directly; keep them under version control with a general-purpose WP-CLI export if needed.

0.23.0

Minor Changes

  • d811b3f: Add lps app push [name], lps app pull [path], lps app list and lps app remove <name> for syncing built single-page app bundles between a local apps/<name>/ directory (a loopress.app.json plus a built dist/) and WordPress. push builds a sha256 manifest of dist/, asks the site which files it already has, uploads only the differences, then commits the new build in one step; the front end keeps serving the old build until that commit lands. pull writes each committed app back to apps/<name>/dist/ and removes local app directories that no longer exist remotely (confirmed in a terminal, reported in CI). Adds an appsDir key to loopress.json and an “apps” entry to lps init.

    New MCP tools mirror the commands: app_push, app_pull, app_list, app_remove (app_push/app_remove use the dry-run/confirmToken handshake).

    This is a Loopress Full plugin feature; the CLI and MCP surface is edition-agnostic.

  • dfb33a3: Pin WordPress.org plugin and theme versions through a lockfile, installed on the site with Composer + WPackagist.

    loopress.json plugins (and the new themes key) now take an exact version or "latest", instead of always "latest". lps plugin push no longer calls WordPress’s core wp/v2/plugins endpoint (which only installs the current release); it sends an intent to the site, where Loopress Full renders a plugin-owned composer.json and runs Composer against the WPackagist repository, landing plugins in wp-content/plugins/ at the pinned version, with no SSH and no composer.json in your repo.

    • lps plugin pull / lps theme pull now pin every plugin/theme to the version running on the site.
    • lps plugin add --version <v> pins an exact version; lps theme add mirrors it.
    • New lps plugin push flags: --force (allow downgrades, and take over a plugin installed by hand, replacing its files) and --prune (deactivate active plugins absent from loopress.json). Removing an entry from loopress.json uninstalls the plugin on the next push (shown in the plan, confirmed).
    • New lps plugin status / lps theme status: report drift (missing, wrong version, inactive, untracked) and exit non-zero for CI.
    • New lps plugin audit: check pinned plugins against a WordPress vulnerability database (wpvulnerability.net) and the WordPress.org plugin API for health signals.
    • New lps theme command group (add, pull, push, status). Loopress manages installed theme versions only, it never switches the active theme.
    • When a repo has a composer.json (from lps composer init), it is authoritative for plugins/themes and the plugin/theme commands defer to lps composer. lps composer push now sends an intent (libraries + WPackagist plugins/themes) instead of a verbatim composer.json; the plugin owns the file’s shape.

    New MCP tools: plugin_status, plugin_audit, theme_push, theme_pull, theme_status. plugin_push gains force and prune.

    Limits: WordPress.org plugins/themes only (premium plugins are tracked but never touched); a downgrade replaces files only and does not undo database migrations; multisite is not supported.

    This relies on a Loopress Full plugin feature; see the companion changeset there.

Patch Changes

  • b3e9d88: Fix lps plugin pull recording the wrong slug for a single-file plugin whose WordPress.org slug differs from its bare file id (e.g. Hello Dolly: hello.php has slug hello-dolly, not hello). The wrong slug made a later lps plugin push --force fail atomically with a Composer “could not be found” error instead of installing anything. The slug is now read from the plugin’s Plugin URI header when it points to a wordpress.org listing, falling back to the bare file id otherwise.

0.22.0

Minor Changes

  • 2867b83: The MCP server now exposes acf, seo and form tools (_push, _pull, _list), matching the CLI. acf_* takes an optional type array and seo_* an optional postType array to scope the operation.
  • 479525f: The MCP server now exposes push_all and pull_all, wrapping lps push and lps pull so an agent can sync every resource in one call instead of eight. push_all uses the same dry-run/confirmToken handshake as the other mutating tools.

0.21.0

Minor Changes

  • ddce917: Initial release of @loopress/mcp: an MCP (Model Context Protocol) stdio server exposing lps operations (snippet/page/api push/pull/list, plugin/composer push/pull, project_status) as tool calls for AI agents, plus a loopress://project/config resource. It wraps the existing lps binary as a subprocess rather than depending on @loopress/cli internals, see obsidian/Product/Loopress MCP.md.

    Mutating tools (*_push) use a two-call handshake: called without confirmToken, they run a --dry-run preview and return a single-use, 5-minute confirmToken; called again with that token, they apply exactly what was previewed. There is no way to skip the preview in a single call, including for a production environment.