<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://wpvilla.in/feed.xml" rel="self" type="application/atom+xml" /><link href="https://wpvilla.in/" rel="alternate" type="text/html" /><updated>2026-10-13T01:41:00+00:00</updated><id>https://wpvilla.in/feed.xml</id><title type="html">wp villain</title><subtitle>A blog about modern WordPress development using Gutenberg blocks,  Roots Sage theme, Ollie WP, ACF, and other cutting-edge WordPress  development techniques and tools.</subtitle><entry><title type="html">Safe Plugin Updates on a Plesk Site: WP Toolkit’s WP-CLI, an FTP Download and Batches</title><link href="https://wpvilla.in/plesk-wp-toolkit-wp-cli-backup-and-plugin-updates/" rel="alternate" type="text/html" title="Safe Plugin Updates on a Plesk Site: WP Toolkit’s WP-CLI, an FTP Download and Batches" /><published>2026-10-13T03:00:00+00:00</published><updated>2026-10-13T03:00:00+00:00</updated><id>https://wpvilla.in/plesk-wp-toolkit-wp-cli-backup-and-plugin-updates</id><content type="html" xml:base="https://wpvilla.in/plesk-wp-toolkit-wp-cli-backup-and-plugin-updates/"><![CDATA[<p>A client site had 20 pending updates. I had WordPress admin, a Plesk login and FTP, but no SSH. The site ran a custom theme that leans on ACF Pro and Gravity Forms, and two of the pending updates were major versions. I didn’t want to click “Update all” and hope.</p>

<p>This is the routine I followed: a database backup with the WP-CLI console built into Plesk’s WP Toolkit, an FTP download, a quick check that the dump is complete, a version inventory to roll back from, and then updates in batches by risk. It takes about 15 minutes before the first update, and all of it can be repeated on the next site.</p>

<h2 id="why-not-just-use-plesks-backups">Why not just use Plesk’s backups?</h2>

<p>Plesk already had nightly incremental backups for this account, which is good, but they weren’t the right tool for rolling back a plugin update:</p>

<ul>
  <li><strong>They cover the whole subscription.</strong> The subscription hosted more than one site. Restoring it to undo a broken plugin risks rolling back a site you never touched, unless you pick through the restore options very carefully.</li>
  <li><strong>They’re from last night.</strong> Anything changed since then, such as a content edit made this morning, would be lost in a restore.</li>
  <li><strong>No per-site snapshot.</strong> I went looking for WP Toolkit’s per-site backup. On this server, every backup button led to the full subscription backup in Backup Manager.</li>
</ul>

<p>What a plugin update changes is the database (migrations, new options) and the plugin’s own files. So that’s what to secure.</p>

<h2 id="step-1-export-the-database-with-wp-toolkits-wp-cli">Step 1: Export the database with WP Toolkit’s WP-CLI</h2>

<p>In Plesk, open the site in WP Toolkit and click <strong>WP-CLI</strong>. You get a single command field with <code class="language-plaintext highlighter-rouge">wp</code> already filled in, so you type everything after it. There’s no shell, so no pipes, <code class="language-plaintext highlighter-rouge">gzip</code> or <code class="language-plaintext highlighter-rouge">ls</code>. That’s fine, because <code class="language-plaintext highlighter-rouge">wp db export</code> does the whole job:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>db export ../site-pre-updates-2026-10-13.sql
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">../</code> matters. It writes the dump one level <strong>above the web root</strong>, so it can’t be downloaded by URL, whatever it’s called. If your account can’t write there, drop the <code class="language-plaintext highlighter-rouge">../</code> and remember to delete the file from the web root afterwards.</p>

<p>Expected output:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Success: Exported to '../site-pre-updates-2026-10-13.sql'.

WARNING: option --ssl-verify-server-cert is disabled, because of an insecure passwordless login.
</code></pre></div></div>

<p>The warning comes from the MariaDB client about a local socket connection and is harmless here.</p>

<h2 id="step-2-download-it-over-ftp">Step 2: Download it over FTP</h2>

<p>Connect over FTP, go one level up from the web root, and download the <code class="language-plaintext highlighter-rouge">.sql</code> file to your machine. Now you have a copy that doesn’t depend on the server.</p>

<p>If the folder you download into is a git repo, as my per-client notes folder is, ignore the dump <strong>before</strong> you forget:</p>

<pre><code class="language-gitignore">backups/
*.sql
*.sql.gz
</code></pre>

<p>A database dump contains user accounts, password hashes and form entries. It should never be committed, private repo or not.</p>

<h2 id="step-3-check-that-the-dump-is-complete">Step 3: Check that the dump is complete</h2>

<p>Don’t trust a backup you haven’t looked at. Three quick checks on your machine:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span> <span class="nt">-lh</span> backups/site-pre-updates-2026-10-13.sql          <span class="c"># not zero, plausible size</span>
<span class="nb">grep</span> <span class="nt">-c</span> <span class="s1">'CREATE TABLE'</span> backups/site-pre-updates-2026-10-13.sql   <span class="c"># table count</span>
<span class="nb">tail</span> <span class="nt">-c</span> 200 backups/site-pre-updates-2026-10-13.sql     <span class="c"># ends with "Dump completed on ..."</span>
</code></pre></div></div>

<p>Mine was 13.7 MB with 35 tables and ended with <code class="language-plaintext highlighter-rouge">-- Dump completed on 2026-10-13  3:26:20</code>. A missing “Dump completed” line means the export was cut off. If the table count looks low, compare it with <code class="language-plaintext highlighter-rouge">db tables</code> in the WP-CLI console.</p>

<h2 id="step-4-record-versions-before-changing-anything">Step 4: Record versions before changing anything</h2>

<p>Back in the WP-CLI console, run three read-only commands:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>plugin list --fields=name,status,version,update_version
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>theme list --fields=name,status,version,update_version
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>core check-update
</code></pre></div></div>

<p>Paste the output into your notes. That list is your file-level rollback. For anything from wordpress.org, going back is one command:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>plugin install &lt;slug&gt; --version=&lt;old-version&gt; --force
</code></pre></div></div>

<p><strong>Paid plugins are the exception.</strong> ACF Pro, Gravity Forms and its add-ons can’t be reinstalled from wordpress.org at an older version. Before updating them, zip their folders in Plesk’s File Manager (<code class="language-plaintext highlighter-rouge">wp-content/plugins/gravityforms</code> → <em>Add to archive</em>), or download the current version from the vendor’s account.</p>

<h2 id="step-5-sort-the-updates-by-risk">Step 5: Sort the updates by risk</h2>

<p>The version list tells you what’s pending. The theme code tells you what’s dangerous. On this site:</p>

<ul>
  <li><strong>Major version jumps:</strong> Gravity Forms 2.10 → 3.1, Smush 3 → 4, GTM4WP 1 → 2, and Yoast 25 → 28.</li>
  <li><strong>Plugins the theme calls directly.</strong> A grep through the theme turned up custom Gravity Forms submit buttons. A <code class="language-plaintext highlighter-rouge">gform_submit_button_{id}</code> filter replaced the button markup with HTML that calls <code class="language-plaintext highlighter-rouge">window.gform.submission.handleButtonClick()</code>. If a major Gravity Forms release changes that JavaScript API, every form on the site stops submitting, and nobody notices until enquiries stop coming in.</li>
  <li><strong>Plugins that hold content structure.</strong> The theme registered no post types itself. The <code class="language-plaintext highlighter-rouge">team-members</code> post type behind the agent photos came from Custom Post Type UI, so an innocent-looking minor update to it could still break the front end.</li>
  <li><strong>The custom theme’s name.</strong> A custom theme whose folder name happens to match a theme on wordpress.org can show an “update”. Never apply it, or your theme gets replaced with someone else’s. Here the custom theme showed no update, but check every time.</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"gform_</span><span class="se">\|</span><span class="s2">register_post_type</span><span class="se">\|</span><span class="s2">acf_</span><span class="se">\|</span><span class="s2">get_field"</span> <span class="nt">--include</span><span class="o">=</span><span class="s1">'*.php'</span> wp-content/themes/your-theme | <span class="nb">head</span>
</code></pre></div></div>

<p>The result is a set of batches:</p>

<table>
  <thead>
    <tr>
      <th>Batch</th>
      <th>What</th>
      <th>Check afterwards</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Inactive default themes, small utility plugins (WP Crontrol, Duplicate Page, SVG Support, WP Mail SMTP, CPT UI), inactive plugins</td>
      <td>Front end loads, custom post types still render</td>
    </tr>
    <tr>
      <td>2</td>
      <td>ACF Pro (minor version, but the whole theme depends on it)</td>
      <td>Blocks, theme options, listing pages</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Yoast, Smush, GTM4WP, one at a time</td>
      <td>Images lazy-load, <code class="language-plaintext highlighter-rouge">GTM-</code> still in page source</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Gravity Forms + reCAPTCHA + related add-ons, together and last</td>
      <td>A real test submission, notification email, entry in admin</td>
    </tr>
  </tbody>
</table>

<h2 id="step-6-update-a-batch-then-check-from-outside">Step 6: Update a batch, then check from outside</h2>

<p>Batch 1 in the WP-CLI console:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>theme update twentytwentyfive twentytwentyfour twentytwentyone twentytwentythree twentytwentytwo
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>plugin update wp-crontrol duplicate-page svg-support wp-mail-smtp custom-post-type-ui google-site-kit updraftplus
</code></pre></div></div>

<p>Then flush the page cache (LiteSpeed in this case) and check the live site from your own machine, bypassing any cache with a query string:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>u <span class="k">in</span> <span class="s2">""</span> some-listing-page/ another-key-page/<span class="p">;</span> <span class="k">do
  </span>curl <span class="nt">-sL</span> <span class="nt">-o</span> /tmp/p.html <span class="nt">-w</span> <span class="s2">"</span><span class="nv">$u</span><span class="s2"> HTTP %{http_code} %{time_total}s</span><span class="se">\n</span><span class="s2">"</span> <span class="se">\</span>
    <span class="s2">"https://example.com/</span><span class="nv">$u</span><span class="s2">?nocache=</span><span class="si">$(</span><span class="nb">date</span> +%s<span class="si">)</span><span class="s2">"</span>
  <span class="nb">grep</span> <span class="nt">-qi</span> <span class="s2">"critical error</span><span class="se">\|</span><span class="s2">Fatal error"</span> /tmp/p.html <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"  PHP ERROR on /</span><span class="nv">$u</span><span class="s2">"</span>
<span class="k">done</span>
</code></pre></div></div>

<p>A 200 status with no “critical error” is the minimum. Then check what this site specifically depends on. Here that was the agent name and photo on each listing card and the team section, so I counted those in the HTML.</p>

<h2 id="step-7-confirm-the-versions-with-wp-cli-not-the-admin-badge">Step 7: Confirm the versions with WP-CLI, not the admin badge</h2>

<p>After batch 1, the admin still said <strong>20 updates</strong> and listed WP Crontrol at its old version. WP-CLI said otherwise:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>plugin list --fields=name,version,update_version
</code></pre></div></div>

<p>Every plugin in the batch showed its new version and an empty <code class="language-plaintext highlighter-rouge">update_version</code>. The admin was showing an old count from before the updates, and reloading the Updates screen cleared it. Trust <code class="language-plaintext highlighter-rouge">plugin list</code>, not the badge.</p>

<h2 id="step-8-get-approval-before-the-risky-batches">Step 8: Get approval before the risky batches</h2>

<p>This site is managed through an agency, and extra work only gets paid if it was agreed beforehand. Batch 1 was small enough to include in time already being billed. For batches 2–4 I sent a short message instead: which updates are pending, why Gravity Forms 3 needs care, a time estimate, and a request to go ahead. Do the risky work after a yes, not before.</p>

<h2 id="cleanup">Cleanup</h2>

<p>When the updates are done, or if they’re postponed, delete the server-side dump:</p>

<ul>
  <li>In Plesk’s File Manager, go one level above the web root and delete <code class="language-plaintext highlighter-rouge">site-pre-updates-*.sql</code>.</li>
  <li>Keep the local copy until the site has run cleanly for a few days.</li>
</ul>

<h2 id="the-checklist">The checklist</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. WP Toolkit → WP-CLI:  db export ../site-pre-updates-YYYY-MM-DD.sql
2. FTP: download the dump, keep it out of git
3. Verify: size, CREATE TABLE count, "Dump completed" footer
4. WP-CLI: plugin list / theme list / core check-update → save as rollback reference
5. Archive paid plugin folders (File Manager) before updating them
6. Grep the theme for plugin hooks; sort updates into risk batches
7. Per batch: update → flush cache → curl check → plugin list to confirm
8. Ask before major or billable updates
9. Delete the server-side dump
</code></pre></div></div>

<p>If you only have FTP and no control panel at all, the database step is harder. I covered a PHP-script approach in <a href="/wordpress-database-backup-ftp-only-no-ssh/">WordPress Database Backup When You Only Have FTP</a>.</p>

<p>This is the kind of routine maintenance we do for client WordPress sites at <a href="https://imagewize.com">Imagewize</a>. If your sites are carrying a pile of pending updates that nobody dares to click, <a href="https://imagewize.com/contact-us/">get in touch</a>.</p>

<hr />

<p><em>Do you do plugin updates differently on Plesk? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a>.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="plesk" /><category term="wp-cli" /><category term="devops" /><category term="wordpress" /><category term="plesk" /><category term="wp-toolkit" /><category term="wp-cli" /><category term="backup" /><category term="database" /><category term="plugins" /><category term="updates" /><category term="ftp" /><summary type="html"><![CDATA[A client site had 20 pending updates. I had WordPress admin, a Plesk login and FTP, but no SSH. The site ran a custom theme that leans on ACF Pro and Gravity Forms, and two of the pending updates were major versions. I didn’t want to click “Update all” and hope.]]></summary></entry><entry><title type="html">Refreshing Three Local WordPress Sites From Production Without Leaving .com URLs Behind</title><link href="https://wpvilla.in/refresh-local-wordpress-from-production-wp-ops/" rel="alternate" type="text/html" title="Refreshing Three Local WordPress Sites From Production Without Leaving .com URLs Behind" /><published>2026-10-03T03:00:00+00:00</published><updated>2026-10-03T03:00:00+00:00</updated><id>https://wpvilla.in/refresh-local-wordpress-from-production-wp-ops</id><content type="html" xml:base="https://wpvilla.in/refresh-local-wordpress-from-production-wp-ops/"><![CDATA[<p>Every few weeks my local copies drift from production far enough that I stop trusting them: stale content, missing uploads, a plugin version that no longer matches. The fix is to replace the local databases with fresh production copies. The part that bit me once is doing it <em>half</em> right: importing a production dump but not rewriting the URLs, so every link on <code class="language-plaintext highlighter-rouge">example.test</code> quietly points at <code class="language-plaintext highlighter-rouge">example.com</code>.</p>

<p>This post is the checklist I now follow, written down after refreshing three sites in one sitting (a main site, a multisite demo network and a small marketing site, all on Trellis with a Lima VM).</p>

<h2 id="pick-the-command-that-does-the-whole-job">Pick the command that does the whole job</h2>

<p><a href="https://github.com/imagewize/wp-ops">wp-ops</a> has two families of database commands, and they are not equivalent:</p>

<table>
  <thead>
    <tr>
      <th>Command</th>
      <th>Backs up local first</th>
      <th>Imports prod</th>
      <th>Rewrites URLs</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">wp-ops database-backup</code></td>
      <td>n/a</td>
      <td>no (just exports remote)</td>
      <td>no</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">wp-ops database-pull</code></td>
      <td>yes</td>
      <td>yes</td>
      <td>yes (from <code class="language-plaintext highlighter-rouge">group_vars</code>)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">wp-ops db-pull</code></td>
      <td>yes</td>
      <td>yes (streamed over SSH)</td>
      <td>yes, plus multisite fixups</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">db-pull</code> is the one I reach for now. It streams <code class="language-plaintext highlighter-rouge">wp db export</code> from the remote straight into the VM’s database with no intermediate file, and it runs <code class="language-plaintext highlighter-rouge">wp search-replace</code> twice (the full URL, then the home URL, covering <code class="language-plaintext highlighter-rouge">https://</code> and a stray <code class="language-plaintext highlighter-rouge">http://</code>). The thing I had previously done wrong was using a plain backup/download command and importing by hand, which skips the replace step entirely.</p>

<h2 id="before-you-pull">Before you pull</h2>

<ol>
  <li><strong>Is the VM running?</strong> <code class="language-plaintext highlighter-rouge">trellis vm shell</code> drops you into a shell if so, and errors if not. The local databases live in the VM.</li>
  <li><strong>Check what “local” means.</strong> The replace target comes from <code class="language-plaintext highlighter-rouge">trellis/group_vars/development/wordpress_sites.yml</code>. If <code class="language-plaintext highlighter-rouge">site_hosts.0.canonical</code> is wrong there, the pull will faithfully rewrite everything to the wrong hostname.</li>
  <li><strong>Export <code class="language-plaintext highlighter-rouge">TRELLIS_DIR</code></strong> to your Trellis directory so the playbooks find their inventory:</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">TRELLIS_DIR</span><span class="o">=</span><span class="nv">$PWD</span>/trellis
</code></pre></div></div>

<h2 id="the-pulls">The pulls</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops db-pull example.com production <span class="nt">--yes</span>
wp-ops db-pull demo.example.com production <span class="nt">--multisite</span> <span class="nt">--yes</span>
wp-ops db-pull small-site.com production <span class="nt">--host</span> example.com <span class="nt">--yes</span>
</code></pre></div></div>

<p>Two flags are easy to forget:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">--multisite</code></strong> for a network. It also fixes the <code class="language-plaintext highlighter-rouge">wp_blogs</code> and <code class="language-plaintext highlighter-rouge">wp_site</code> domain rows and scopes the search-replace with <code class="language-plaintext highlighter-rouge">--url</code>. Without it, subsites keep their production domains and the network breaks. On my demo network that was 9 rows corrected plus 93,000-odd link replacements in one run.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">--host</code></strong> when the site lives on a server whose SSH name differs from the site name. One of mine is hosted on another site’s server, and SSH to its own hostname failed host-key verification, so the default (derive the host from the site name) can’t work.</li>
</ul>

<p>I run them one at a time. Each prints numbered steps (read dev URL, back up local, pull, import, replace, flush cache), and the “Made N replacements” lines are a quick sanity check: a main site with 88 replacements, a tiny site with 37.</p>

<p>Each run also writes <code class="language-plaintext highlighter-rouge">dev_backup_&lt;timestamp&gt;.sql.gz</code> into the site’s <code class="language-plaintext highlighter-rouge">database_backup/</code> folder <em>before</em> importing. That’s your undo button. Check that folder isn’t tracked by git.</p>

<h2 id="dont-forget-the-uploads">Don’t forget the uploads</h2>

<p>The database pull doesn’t touch <code class="language-plaintext highlighter-rouge">wp-content/uploads</code>. Without it you get correct pages with broken images:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops files-pull example.com production
</code></pre></div></div>

<p>Run it per site. It’s rsync, so repeat pulls are cheap. I confirmed it worked by counting files afterwards (about 17,000 for the main site), because the Ansible recap says <code class="language-plaintext highlighter-rouge">skipped=4</code> even on success, which looks alarming and isn’t.</p>

<h2 id="verify-dont-assume">Verify, don’t assume</h2>

<p>Three checks per site, all through WP-CLI inside the VM:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp option get home
wp option get siteurl
wp db query <span class="s2">"SELECT COUNT(*) FROM wp_posts WHERE post_content LIKE '%://example.com%'"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">home</code> and <code class="language-plaintext highlighter-rouge">siteurl</code> should be the <code class="language-plaintext highlighter-rouge">.test</code> URLs, and the count should be <strong>0</strong>. A looser search for just <code class="language-plaintext highlighter-rouge">example.com</code> still returned a few hundred hits on the main site, and every one was an email address or plain-text mention. Searching for the scheme (<code class="language-plaintext highlighter-rouge">://</code>) is what separates real links from noise. On a multisite network also run <code class="language-plaintext highlighter-rouge">wp site list</code> and confirm every subsite shows the local domain.</p>

<h2 id="side-effects-to-expect">Side effects to expect</h2>

<ul>
  <li><strong>Production-only plugins come along for the ride.</strong> The pulled database re-activates everything production had active, including a 2FA plugin that has no business running locally. While active, it wrote its encryption key into <code class="language-plaintext highlighter-rouge">wp-config.php</code>, an uncommitted change and a secret. I reverted the file with <code class="language-plaintext highlighter-rouge">git checkout</code>, then deactivated the plugin on every local site so it wouldn’t happen again: <code class="language-plaintext highlighter-rouge">wp plugin deactivate &lt;plugin&gt;</code>, or with <code class="language-plaintext highlighter-rouge">--network</code> on a multisite where it is network-active (plain deactivate refuses with a warning). Make this a post-pull step.</li>
  <li><strong>Shell gotcha.</strong> I separated two commands with <code class="language-plaintext highlighter-rouge">echo =====</code> and zsh answered <code class="language-plaintext highlighter-rouge">=====: not found</code>, which aborted the rest of the line. The second pull silently didn’t run until I noticed. Use <code class="language-plaintext highlighter-rouge">&amp;&amp;</code>, or run each command separately.</li>
  <li><strong>Theme state.</strong> If your themes are Composer-installed and git-ignored, a database pull doesn’t change them, but production’s active theme and any page or template-part overrides now apply locally. If a recent theme fix doesn’t show up, the content in the database is probably shadowing the theme files.</li>
</ul>

<h2 id="the-short-version">The short version</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">TRELLIS_DIR</span><span class="o">=</span><span class="nv">$PWD</span>/trellis
wp-ops db-pull &lt;site&gt; production <span class="nt">--yes</span>            <span class="c"># + --multisite / --host as needed</span>
wp-ops files-pull &lt;site&gt; production
wp option get home <span class="o">&amp;&amp;</span> wp option get siteurl        <span class="c"># expect .test</span>
</code></pre></div></div>

<p>Use the command that backs up, imports <em>and</em> replaces, pass the multisite and host flags where they apply, pull the uploads separately, and verify with a scheme-anchored search. That’s the whole difference between a refresh I trust and one I have to redo.</p>]]></content><author><name></name></author><category term="wordpress" /><category term="trellis" /><category term="wp-cli" /><category term="devops" /><category term="wordpress" /><category term="trellis" /><category term="wp-cli" /><category term="wp-ops" /><category term="bedrock" /><category term="multisite" /><category term="database" /><category term="search-replace" /><summary type="html"><![CDATA[Every few weeks my local copies drift from production far enough that I stop trusting them: stale content, missing uploads, a plugin version that no longer matches. The fix is to replace the local databases with fresh production copies. The part that bit me once is doing it half right: importing a production dump but not rewriting the URLs, so every link on example.test quietly points at example.com.]]></summary></entry><entry><title type="html">WordPress Mobile Menu Overlay Styling and the Headless Chrome Trap</title><link href="https://wpvilla.in/wordpress-mobile-menu-overlay-styling-and-the-headless-chrome-trap/" rel="alternate" type="text/html" title="WordPress Mobile Menu Overlay Styling and the Headless Chrome Trap" /><published>2026-09-23T03:00:00+00:00</published><updated>2026-09-23T03:00:00+00:00</updated><id>https://wpvilla.in/wordpress-mobile-menu-overlay-styling-and-the-headless-chrome-trap</id><content type="html" xml:base="https://wpvilla.in/wordpress-mobile-menu-overlay-styling-and-the-headless-chrome-trap/"><![CDATA[<p>If you’ve ever styled a WordPress block theme’s mobile navigation overlay and wondered why your 375px-wide screenshot shows a hamburger clipped off-screen while the live site works fine, this one’s for you. A recent fix for the Ecotuin theme — matching the light header’s mobile overlay to the approved v3e design — turned into a lesson about viewport testing, CTA placement in <code class="language-plaintext highlighter-rouge">core/navigation</code>, and a Chrome flag that silently lies to you.</p>

<h2 id="the-visual-problem">The Visual Problem</h2>

<p>Issue #9 was straightforward on the surface: on phones, the light header’s mobile menu overlay rendered with core’s default unstyled look — white background, black text, right-aligned links, no dividers — none of which matched the rest of the site’s chalk-and-ivy palette. The design called for a <code class="language-plaintext highlighter-rouge">base</code> (chalk) background, <code class="language-plaintext highlighter-rouge">secondary</code> (ivy-green) Roggenkamp typography, left-aligned links with rule-coloured (<code class="language-plaintext highlighter-rouge">border-light</code>) dividers between them, and a full-width Contact CTA button below the links.</p>

<p>Simple enough. The overlay needed restyling, the links needed left-aligning, and the CTA needed appending. What wasn’t obvious: headless Chrome would spend the better part of a debugging session convincing us the header itself was broken.</p>

<h2 id="the-first-hurdle-testing-the-overlay-at-all">The First Hurdle: Testing the Overlay at All</h2>

<p>Mobile overlays are stateful — the hamburger has to be clicked to open them, and core’s responsive container only switches into its fixed-position modal once the viewport is narrow enough for <code class="language-plaintext highlighter-rouge">overlayMenu: "mobile"</code> to kick in. That means:</p>

<ul>
  <li>You can’t test the <em>closed</em> state at desktop width and infer the open one</li>
  <li>You can’t force the state with a class toggle and trust the CSS is identical to what the real click produces</li>
  <li>You need an actual narrow viewport <em>and</em> a real click</li>
</ul>

<p>Static HTML snapshots or DOM hacks that toggle <code class="language-plaintext highlighter-rouge">is-menu-open</code> miss any JavaScript that runs during the real transition. A real interaction test, at a real phone width, was the only reliable check.</p>

<h2 id="enter-headless-chrome">Enter Headless Chrome</h2>

<p>My first instinct was the usual headless Chrome one-liner:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chrome <span class="nt">--headless</span><span class="o">=</span>new <span class="nt">--window-size</span><span class="o">=</span>390,700 <span class="nt">--screenshot</span><span class="o">=</span>/tmp/ecotuin-390.png https://ecotuin.test/
</code></pre></div></div>

<p>Then click the hamburger via DevTools Protocol or a small Puppeteer script, wait for the overlay to open, screenshot again. Standard workflow.</p>

<p>What I got back looked alarming: between 375px and 430px, the header’s right edge overflowed the viewport and the hamburger icon was clipped off-screen. The overlay itself was fine once open, but the trigger to open it was unreachable. That suggested the header’s padding or the icon’s positioning was fundamentally broken at those widths.</p>

<p>Except it wasn’t. A manual check on a real iPhone showed the hamburger sitting exactly where it should. Retesting with <a href="https://playwright.dev/">Playwright</a> at the same viewport widths confirmed: no overflow, no clipping, hamburger fully visible and tappable. The bug didn’t reproduce at all.</p>

<h2 id="the-footgun---window-size-below-500px">The Footgun: <code class="language-plaintext highlighter-rouge">--window-size</code> Below ~500px</h2>

<p>The culprit, documented in the project’s internal CLAUDE.md notes: <strong>headless Chrome silently ignores viewport widths below roughly 500px</strong> when launched via <code class="language-plaintext highlighter-rouge">--window-size</code>. It renders at a wider width instead (around 500px minimum, inconsistently), and doesn’t tell you it’s doing so. The screenshot you get back <em>looks</em> plausible — it’s a real render, just not at the width you asked for — and if your layout has a breakpoint or overflow issue between 375px and 500px, you’ll never see it in that test.</p>

<p>In our case, the lie was the opposite direction: the screenshot showed an overflow that didn’t exist at the requested 375px, because Chrome was actually rendering at ~500px where the header <em>did</em> fit. The phantom bug sent us down a rabbit hole of header padding checks and icon positioning that all looked fine in isolation, because the real problem was the testing tool itself.</p>

<p>Playwright’s <code class="language-plaintext highlighter-rouge">viewport: { width, height }</code> parameter, by contrast, is exact — pass 390 and it renders at 390, every time. And because <code class="language-plaintext highlighter-rouge">node_modules/playwright</code> already exists (pulled in by <a href="https://github.com/imagewize/wp-pattern-sentinel">wp-pattern-sentinel</a>), there’s no extra install step:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">{</span> <span class="nx">chromium</span> <span class="p">}</span> <span class="o">=</span> <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">/Users/…/ecotuin/node_modules/playwright</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">browser</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">chromium</span><span class="p">.</span><span class="nf">launch</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">page</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">browser</span><span class="p">.</span><span class="nf">newPage</span><span class="p">({</span> <span class="na">viewport</span><span class="p">:</span> <span class="p">{</span> <span class="na">width</span><span class="p">:</span> <span class="mi">390</span><span class="p">,</span> <span class="na">height</span><span class="p">:</span> <span class="mi">700</span> <span class="p">}</span> <span class="p">});</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">goto</span><span class="p">(</span><span class="dl">'</span><span class="s1">https://ecotuin.test/</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">waitUntil</span><span class="p">:</span> <span class="dl">'</span><span class="s1">networkidle</span><span class="dl">'</span> <span class="p">});</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">click</span><span class="p">(</span><span class="dl">'</span><span class="s1">.wp-block-navigation__responsive-container-open</span><span class="dl">'</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">screenshot</span><span class="p">({</span> <span class="na">path</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/tmp/overlay-open-390.png</span><span class="dl">'</span> <span class="p">});</span>
</code></pre></div></div>

<p>That’s the pattern we now use for any check where the actual viewport width matters below ~500px.</p>

<h2 id="the-second-hurdle-cta-placement">The Second Hurdle: CTA Placement</h2>

<p>The design’s mobile dropdown ends with a full-width Contact button directly below the navigation links. In the Site Editor, those links live in a <strong>separately-stored Navigation menu</strong> (the “Diensten / Werkwijze / Projecten / …” items), not inline in the template part. <code class="language-plaintext highlighter-rouge">core/navigation</code> has a quirk: it only renders its referenced menu when the block has <strong>no inner blocks of its own</strong>. If you add inner blocks to the nav in <code class="language-plaintext highlighter-rouge">parts/header.html</code> — say, a <code class="language-plaintext highlighter-rouge">core/buttons</code> block for the CTA — WordPress <em>replaces</em> the referenced menu with those inner blocks instead of appending to it. The real links disappear.</p>

<p>That meant the CTA couldn’t be part of the template part’s markup. Two options remained:</p>

<ol>
  <li>
    <p><strong>Add the CTA link to the Navigation menu itself</strong> in the WordPress admin. Simple, but it pollutes the menu’s desktop presentation (an extra “Contact” item in the inline nav at desktop width) and the client would see it as a menu item they could reorder or remove.</p>
  </li>
  <li>
    <p><strong>Append the CTA via a filter after rendering.</strong> <code class="language-plaintext highlighter-rouge">render_block_core/navigation</code> fires after WordPress has assembled the block’s HTML from its referenced menu, so we can inject additional markup at that point without touching what the block stores.</p>
  </li>
</ol>

<p>We went with option 2. The function scopes itself to the light header’s nav via a className (<code class="language-plaintext highlighter-rouge">ecotuin-header__nav</code>), appends a full-width button block right after the <code class="language-plaintext highlighter-rouge">&lt;/ul&gt;</code>, and lets CSS hide it everywhere except the open overlay:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">ecotuin_header_nav_cta</span><span class="p">(</span> <span class="nv">$block_content</span><span class="p">,</span> <span class="nv">$block</span> <span class="p">)</span> <span class="p">{</span>
    <span class="nv">$class_name</span> <span class="o">=</span> <span class="nv">$block</span><span class="p">[</span><span class="s1">'attrs'</span><span class="p">][</span><span class="s1">'className'</span><span class="p">]</span> <span class="o">??</span> <span class="s1">''</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span> <span class="kc">false</span> <span class="o">===</span> <span class="nb">strpos</span><span class="p">(</span> <span class="nv">$class_name</span><span class="p">,</span> <span class="s1">'ecotuin-header__nav'</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nv">$block_content</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="nv">$cta</span> <span class="o">=</span> <span class="nb">sprintf</span><span class="p">(</span>
        <span class="s1">'&lt;div class="wp-block-buttons ecotuin-header__overlay-cta"&gt;&lt;div class="wp-block-button wp-block-button__width-100"&gt;&lt;a class="wp-block-button__link wp-element-button" href="%s"&gt;%s&lt;/a&gt;&lt;/div&gt;&lt;/div&gt;'</span><span class="p">,</span>
        <span class="nf">esc_url</span><span class="p">(</span> <span class="nf">home_url</span><span class="p">(</span> <span class="s1">'/contact/'</span> <span class="p">)</span> <span class="p">),</span>
        <span class="nf">esc_html__</span><span class="p">(</span> <span class="s1">'Contact'</span><span class="p">,</span> <span class="s1">'ecotuin'</span> <span class="p">)</span>
    <span class="p">);</span>

    <span class="k">return</span> <span class="nb">preg_replace</span><span class="p">(</span> <span class="s1">'#&lt;/ul&gt;#'</span><span class="p">,</span> <span class="s1">'&lt;/ul&gt;'</span> <span class="mf">.</span> <span class="nv">$cta</span><span class="p">,</span> <span class="nv">$block_content</span><span class="p">,</span> <span class="mi">1</span> <span class="p">);</span>
<span class="p">}</span>
<span class="nf">add_filter</span><span class="p">(</span> <span class="s1">'render_block_core/navigation'</span><span class="p">,</span> <span class="k">__NAMESPACE__</span> <span class="mf">.</span> <span class="s1">'\ecotuin_header_nav_cta'</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">2</span> <span class="p">);</span>
</code></pre></div></div>

<p>CSS then hides it by default and only shows it inside the open overlay:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.ecotuin-header__overlay-cta</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="p">}</span>

<span class="nc">.ecotuin-header</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.ecotuin-header--dark</span><span class="o">)</span> <span class="nc">.wp-block-navigation__responsive-container.is-menu-open</span> <span class="nc">.ecotuin-header__overlay-cta</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">flex</span><span class="p">;</span>
    <span class="nl">margin-top</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="l">--wp--preset--spacing--small</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="the-css-fixes-alignment-and-full-width-links">The CSS Fixes: Alignment and Full-Width Links</h2>

<p>With the CTA in place, the styling itself fell into three pieces:</p>

<h3 id="1-raising-the-breakpoint">1. Raising the Breakpoint</h3>

<p>Core hardcodes <code class="language-plaintext highlighter-rouge">overlayMenu: "mobile"</code> to collapse at 600px. Between 600px and 1024px, the inline nav competed with the wordmark, cart/account icons, and CTA, wrapping the masthead to 105px tall on tablets. We raised the effective breakpoint to 1024px by re-declaring the two core rules that switch the container between overlay and inline:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@media</span> <span class="p">(</span><span class="n">min-width</span><span class="p">:</span> <span class="m">600px</span><span class="p">)</span> <span class="n">and</span> <span class="p">(</span><span class="n">max-width</span><span class="p">:</span> <span class="m">1023px</span><span class="p">)</span> <span class="p">{</span>
    <span class="nc">.ecotuin-header</span> <span class="nc">.wp-block-navigation__responsive-container</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.hidden-by-default</span><span class="o">)</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.is-menu-open</span><span class="o">)</span> <span class="p">{</span>
        <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="nc">.ecotuin-header</span> <span class="nc">.wp-block-navigation__responsive-container-open</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.always-shown</span><span class="o">)</span> <span class="p">{</span>
        <span class="nl">display</span><span class="p">:</span> <span class="nb">flex</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="2-left-aligning-links-and-full-width-dividers">2. Left-Aligning Links and Full-Width Dividers</h3>

<p>Core’s overlay right-aligns the link list via <code class="language-plaintext highlighter-rouge">align-items: flex-end</code> in two places: on the container-content and on each navigation item. That shrink-wraps each <code class="language-plaintext highlighter-rouge">&lt;li&gt;</code> to its text width and pins it to the right edge — fine for a right-aligned desktop nav, but not what the design called for.</p>

<p>Design v3e uses <code class="language-plaintext highlighter-rouge">display: block</code> for mobile list items, so each one spans the full width and the divider border runs edge-to-edge. We reproduced that with <code class="language-plaintext highlighter-rouge">align-items: stretch</code> in both locations, then added <code class="language-plaintext highlighter-rouge">text-align: left</code> to position the text within the now full-width box:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.ecotuin-header</span> <span class="nc">.wp-block-navigation__responsive-container.is-menu-open</span> <span class="nc">.wp-block-navigation__responsive-container-content</span><span class="o">,</span>
<span class="nc">.ecotuin-header</span> <span class="nc">.wp-block-navigation__responsive-container.is-menu-open</span> <span class="nc">.wp-block-navigation__container</span><span class="o">,</span>
<span class="nc">.ecotuin-header</span> <span class="nc">.wp-block-navigation__responsive-container.is-menu-open</span> <span class="nc">.wp-block-navigation-item</span> <span class="p">{</span>
    <span class="nl">align-items</span><span class="p">:</span> <span class="nb">stretch</span><span class="p">;</span>
<span class="p">}</span>

<span class="nc">.ecotuin-header</span> <span class="nc">.wp-block-navigation__responsive-container.is-menu-open</span> <span class="nc">.wp-block-navigation__container</span> <span class="p">{</span>
    <span class="nl">text-align</span><span class="p">:</span> <span class="nb">left</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="3-styling-to-match-v3e-design">3. Styling to Match v3e Design</h3>

<p>Finally, the visual restyling to match the design’s mobile dropdown:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Chalk background, ivy text */</span>
<span class="nc">.ecotuin-header</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.ecotuin-header--dark</span><span class="o">)</span> <span class="nc">.wp-block-navigation__responsive-container.is-menu-open</span> <span class="p">{</span>
    <span class="nl">background-color</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="l">--wp--preset--color--base</span><span class="p">)</span> <span class="cp">!important</span><span class="p">;</span>
    <span class="nl">color</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="l">--wp--preset--color--secondary</span><span class="p">)</span> <span class="cp">!important</span><span class="p">;</span>
<span class="p">}</span>

<span class="c">/* Rule-coloured dividers between links */</span>
<span class="nc">.ecotuin-header</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.ecotuin-header--dark</span><span class="o">)</span> <span class="nc">.wp-block-navigation__responsive-container.is-menu-open</span> <span class="nc">.wp-block-navigation-item__content</span> <span class="p">{</span>
    <span class="nl">border-bottom</span><span class="p">:</span> <span class="m">1px</span> <span class="nb">solid</span> <span class="nf">var</span><span class="p">(</span><span class="l">--wp--preset--color--border-light</span><span class="p">);</span>
    <span class="nl">font-size</span><span class="p">:</span> <span class="m">1.25rem</span><span class="p">;</span>
    <span class="py">padding-block</span><span class="p">:</span> <span class="m">0.75rem</span><span class="p">;</span>
<span class="p">}</span>

<span class="c">/* Remove the underline that works as a divider on desktop */</span>
<span class="nc">.ecotuin-header</span> <span class="nc">.wp-block-navigation__responsive-container.is-menu-open</span> <span class="nc">.wp-block-navigation-item__content</span><span class="nd">::after</span> <span class="p">{</span>
    <span class="nl">content</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">!important</code> flags are necessary because core emits its own <code class="language-plaintext highlighter-rouge">!important</code> selectors for color handling on navigation blocks without explicit background/text colors.</p>

<h2 id="the-takeaways">The Takeaways</h2>

<ol>
  <li>
    <p><strong>Don’t trust headless Chrome below ~500px width.</strong> Use Playwright for any narrow-viewport check where the exact width matters. It’s already a dependency in many WordPress projects via pattern validation or other tooling, so there’s often no extra setup.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">core/navigation</code> replaces referenced menus with inner blocks.</strong> If your nav references a separately-stored menu and you need to add content to it (like a CTA button that only appears in the overlay), do it via <code class="language-plaintext highlighter-rouge">render_block_core/navigation</code>, not by editing the template part’s markup.</p>
  </li>
  <li>
    <p><strong>Overlay state needs real interaction.</strong> Static snapshots or class toggles miss JavaScript that runs during the real transition. A real click test at the real width is the only way to be sure.</p>
  </li>
  <li>
    <p><strong>Core’s overlay CSS makes assumptions about alignment.</strong> It right-aligns for a right-anchored desktop nav, which doesn’t translate cleanly to left-aligned mobile designs. Overriding <code class="language-plaintext highlighter-rouge">align-items</code> and <code class="language-plaintext highlighter-rouge">text-align</code> together gets you full-width items with left-aligned text.</p>
  </li>
  <li>
    <p><strong>Document the footguns.</strong> We added the headless Chrome warning to the project’s internal CLAUDE.md notes so the next person (or the next project) doesn’t repeat the same time sink. Small notes like that pay for themselves the first time someone else hits the same issue.</p>
  </li>
</ol>

<h2 id="what-this-looked-like-in-practice">What This Looked Like in Practice</h2>

<p>The PR that shipped this (#10) touched eight files across six commits:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">parts/header.html</code> — added <code class="language-plaintext highlighter-rouge">ecotuin-header__nav</code> className for scoping</li>
  <li><code class="language-plaintext highlighter-rouge">functions.php</code> — the <code class="language-plaintext highlighter-rouge">render_block_core/navigation</code> filter for CTA injection</li>
  <li><code class="language-plaintext highlighter-rouge">style.css</code> — breakpoint raise, alignment fixes, color palette application, CTA visibility toggles</li>
  <li><code class="language-plaintext highlighter-rouge">CLAUDE.md</code> — the headless Chrome footgun documentation</li>
  <li>Version files: <code class="language-plaintext highlighter-rouge">style.css</code> header, <code class="language-plaintext highlighter-rouge">readme.txt</code>, <code class="language-plaintext highlighter-rouge">package.json</code>, <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code></li>
</ul>

<p>Total: ~185 lines added/modified, with the actual overlay styling being a minority of the effort. The rest was testing infrastructure and placement mechanics.</p>

<p>This is the kind of careful, edge-case-aware WordPress work we do at <a href="https://imagewize.com">Imagewize</a>. If you’re building block themes and hitting surprising layout or testing issues — or just want to avoid the headless Chrome trap on your next responsive check — <a href="https://imagewize.com/contact-us/">get in touch</a>.</p>

<hr />

<p><em>Questions about block theme navigation or mobile overlay testing? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a>.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="css" /><category term="devops" /><category term="testing" /><category term="wordpress" /><category term="gutenberg" /><category term="block-theme" /><category term="mobile-menu" /><category term="overlay" /><category term="css" /><category term="headless-chrome" /><category term="playwright" /><category term="testing" /><summary type="html"><![CDATA[If you’ve ever styled a WordPress block theme’s mobile navigation overlay and wondered why your 375px-wide screenshot shows a hamburger clipped off-screen while the live site works fine, this one’s for you. A recent fix for the Ecotuin theme — matching the light header’s mobile overlay to the approved v3e design — turned into a lesson about viewport testing, CTA placement in core/navigation, and a Chrome flag that silently lies to you.]]></summary></entry><entry><title type="html">WordPress Database Backup When You Only Have FTP (No SSH, No CPanel, No Plesk)</title><link href="https://wpvilla.in/wordpress-database-backup-ftp-only-no-ssh/" rel="alternate" type="text/html" title="WordPress Database Backup When You Only Have FTP (No SSH, No CPanel, No Plesk)" /><published>2026-09-21T07:00:00+00:00</published><updated>2026-09-21T07:00:00+00:00</updated><id>https://wpvilla.in/wordpress-database-backup-ftp-only-no-ssh</id><content type="html" xml:base="https://wpvilla.in/wordpress-database-backup-ftp-only-no-ssh/"><![CDATA[<p>You have FTP access to a WordPress site, a password for <code class="language-plaintext highlighter-rouge">wp-config.php</code>, and nothing else — no SSH, no CPanel, no Plesk, no phpMyAdmin. The database needs a backup right now. This is the DIY PHP script method that works when every other door is locked.</p>

<p><strong>Scope note:</strong> this backs up the <em>database</em> only — posts, options, users, terms, everything in MySQL. It does not touch <code class="language-plaintext highlighter-rouge">wp-content/uploads</code>, themes, or plugin files. If you need a full-site backup, use FTP to also download those directories; the database is just the part you can’t grab as a folder copy.</p>

<h2 id="the-constraints">The Constraints</h2>

<p>When you only have FTP, the usual tools are off the table:</p>

<ul>
  <li><strong>No <code class="language-plaintext highlighter-rouge">wp db export</code></strong> — WP-CLI requires shell access</li>
  <li><strong>No <code class="language-plaintext highlighter-rouge">mysqldump</code></strong> — command line only</li>
  <li><strong>No phpMyAdmin</strong> — not installed, and you can’t install it</li>
  <li><strong>No adminer.php</strong> — same problem: no upload path for a new tool</li>
  <li><strong>No CPanel/Plesk backups</strong> — no control panel at all</li>
</ul>

<p>What you <em>do</em> have: the ability to upload a PHP file to the site root, trigger it via a browser, and download the result. That is enough.</p>

<h2 id="the-diy-php-script-method">The DIY PHP Script Method</h2>

<p>Create a file named <code class="language-plaintext highlighter-rouge">backup-db.php</code> with this content:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>
<span class="k">require</span><span class="p">(</span><span class="s1">'wp-load.php'</span><span class="p">);</span>

<span class="nv">$db</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">wpdb</span><span class="p">(</span><span class="no">DB_USER</span><span class="p">,</span> <span class="no">DB_PASSWORD</span><span class="p">,</span> <span class="no">DB_NAME</span><span class="p">,</span> <span class="no">DB_HOST</span><span class="p">);</span>
<span class="nv">$tables</span> <span class="o">=</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">get_results</span><span class="p">(</span><span class="s1">'SHOW TABLES'</span><span class="p">,</span> <span class="no">ARRAY_N</span><span class="p">);</span>
<span class="nv">$output</span> <span class="o">=</span> <span class="s1">''</span><span class="p">;</span>

<span class="k">foreach</span> <span class="p">(</span><span class="nv">$tables</span> <span class="k">as</span> <span class="nv">$table</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$table_name</span> <span class="o">=</span> <span class="nv">$table</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
    
    <span class="c1">// Table structure</span>
    <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="s2">"--</span><span class="se">\n</span><span class="s2">-- Table: </span><span class="nv">$table_name</span><span class="se">\n</span><span class="s2">--</span><span class="se">\n</span><span class="s2">"</span><span class="p">;</span>
    <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="s2">"DROP TABLE IF EXISTS `</span><span class="nv">$table_name</span><span class="s2">`;</span><span class="se">\n</span><span class="s2">"</span><span class="p">;</span>
    <span class="nv">$create</span> <span class="o">=</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">get_row</span><span class="p">(</span><span class="s2">"SHOW CREATE TABLE `</span><span class="nv">$table_name</span><span class="s2">`"</span><span class="p">);</span>
    <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="nv">$create</span><span class="o">-&gt;</span><span class="p">{</span><span class="s1">'Create Table'</span><span class="p">}</span> <span class="mf">.</span> <span class="s2">";</span><span class="se">\n\n</span><span class="s2">"</span><span class="p">;</span>
    
    <span class="c1">// Table data</span>
    <span class="nv">$rows</span> <span class="o">=</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">get_results</span><span class="p">(</span><span class="s2">"SELECT * FROM `</span><span class="nv">$table_name</span><span class="s2">`"</span><span class="p">);</span>
    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$rows</span> <span class="k">as</span> <span class="nv">$row</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="s2">"INSERT INTO `</span><span class="nv">$table_name</span><span class="s2">` VALUES("</span><span class="p">;</span>
        <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="nb">implode</span><span class="p">(</span><span class="s1">','</span><span class="p">,</span> <span class="nb">array_map</span><span class="p">(</span><span class="k">function</span><span class="p">(</span><span class="nv">$val</span><span class="p">)</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$db</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nb">is_null</span><span class="p">(</span><span class="nv">$val</span><span class="p">)</span> <span class="o">?</span> <span class="s1">'NULL'</span> <span class="o">:</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">prepare</span><span class="p">(</span><span class="s1">'%s'</span><span class="p">,</span> <span class="nv">$val</span><span class="p">);</span>
        <span class="p">},</span> <span class="p">(</span><span class="k">array</span><span class="p">)</span><span class="nv">$row</span><span class="p">));</span>
        <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="s2">");</span><span class="se">\n</span><span class="s2">"</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="nv">$filename</span> <span class="o">=</span> <span class="s1">'wp-content/backup-'</span> <span class="mf">.</span> <span class="nb">date</span><span class="p">(</span><span class="s1">'Y-m-d-His'</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'.sql'</span><span class="p">;</span>
<span class="nb">file_put_contents</span><span class="p">(</span><span class="nv">$filename</span><span class="p">,</span> <span class="nv">$output</span><span class="p">);</span>

<span class="k">echo</span> <span class="s2">"Backup complete. Download: &lt;code&gt;/wp-content/</span><span class="si">{</span><span class="nv">$filename</span><span class="si">}</span><span class="s2">&lt;/code&gt;"</span><span class="p">;</span>
</code></pre></div></div>

<h3 id="step-by-step">Step-by-Step</h3>

<ol>
  <li><strong>Create the file</strong> locally with the code above</li>
  <li><strong>Upload it</strong> to the site root via FTP (same directory as <code class="language-plaintext highlighter-rouge">wp-config.php</code>)</li>
  <li><strong>Access it in your browser:</strong> <code class="language-plaintext highlighter-rouge">https://example.com/backup-db.php</code></li>
  <li><strong>Download the backup</strong> from <code class="language-plaintext highlighter-rouge">/wp-content/backup-YYYY-MM-DD-HHMMSS.sql</code> via FTP</li>
  <li><strong>Delete the script immediately</strong> — this is critical (see Security Notes below)</li>
</ol>

<h3 id="what-it-does">What It Does</h3>

<p>The script connects using the same credentials WordPress itself uses (<code class="language-plaintext highlighter-rouge">DB_USER</code>, <code class="language-plaintext highlighter-rouge">DB_PASSWORD</code>, <code class="language-plaintext highlighter-rouge">DB_NAME</code>, <code class="language-plaintext highlighter-rouge">DB_HOST</code> from <a href="https://developer.wordpress.org/apis/wp-config-php/"><code class="language-plaintext highlighter-rouge">wp-config.php</code></a>), via WordPress’s own <a href="https://developer.wordpress.org/reference/classes/wpdb/"><code class="language-plaintext highlighter-rouge">wpdb</code></a> class, then:</p>

<ol>
  <li>Lists every table in the database</li>
  <li>For each table: writes a <code class="language-plaintext highlighter-rouge">DROP TABLE IF EXISTS</code> statement, the full <code class="language-plaintext highlighter-rouge">CREATE TABLE</code> definition, and every row as an <code class="language-plaintext highlighter-rouge">INSERT</code> statement</li>
  <li>Escapes every value with <a href="https://developer.wordpress.org/reference/classes/wpdb/prepare/"><code class="language-plaintext highlighter-rouge">wpdb::prepare()</code></a> before writing it, so quotes, newlines, and other special characters in post content don’t break the dump</li>
  <li>Saves the complete SQL dump to <code class="language-plaintext highlighter-rouge">wp-content/backup-YYYY-MM-DD-HHMMSS.sql</code></li>
  <li>Outputs a message with the exact filename to download</li>
</ol>

<p>None of this touches the live database — it’s a read-only export. The <code class="language-plaintext highlighter-rouge">DROP TABLE IF EXISTS</code> line is just text written into the <code class="language-plaintext highlighter-rouge">.sql</code> file; it only runs if you later <em>import</em> that file somewhere. This matches standard <code class="language-plaintext highlighter-rouge">mysqldump</code> behavior and exists so a restore is a clean, exact replacement rather than a merge. The catch: if you import this dump into a database where those tables already hold data you want to keep, that data is dropped first. Restore into an empty/target database, or a copy, not over live data you haven’t backed up separately.</p>

<p>This produces a standard MySQL dump that can be restored with <code class="language-plaintext highlighter-rouge">mysql -u user -p db_name &lt; backup.sql</code> or imported via phpMyAdmin, Adminer, or any other MySQL client.</p>

<h3 id="verify-the-backup-before-you-delete-anything">Verify the Backup Before You Delete Anything</h3>

<p>Don’t trust the dump blind. Before deleting the script or the original data, do a quick sanity check via FTP or by eyeballing the file:</p>

<ul>
  <li><strong>File size isn’t zero or suspiciously tiny</strong> for the site’s size</li>
  <li><strong>Table count matches</strong> — the number of <code class="language-plaintext highlighter-rouge">-- Table:</code> comment lines should equal the number of tables you’d expect (check against <code class="language-plaintext highlighter-rouge">SHOW TABLES</code> output if unsure)</li>
  <li><strong>It ends cleanly</strong> — open the last few KB and confirm the final <code class="language-plaintext highlighter-rouge">INSERT</code> statement looks complete, not cut off mid-row (a sign the script hit a timeout or memory limit)</li>
</ul>

<p>If any of these look wrong, don’t delete the live data or overwrite an older backup — re-run with the per-table or gzip variant below instead.</p>

<h2 id="security-notes--read-this">Security Notes — READ THIS</h2>

<p><strong>This script is a live SQL credential exposure while it exists on the server.</strong></p>

<ul>
  <li>Anyone who guesses or finds the URL <code class="language-plaintext highlighter-rouge">example.com/backup-db.php</code> can trigger a backup — and in the process, they confirm that <code class="language-plaintext highlighter-rouge">wp-config.php</code> credentials work, which is valuable reconnaissance</li>
  <li>The script itself contains no credentials (it pulls them from <code class="language-plaintext highlighter-rouge">wp-config.php</code>), but it <em>proves</em> those credentials are valid to anyone who can reach it</li>
  <li>The backup file lands in a web-accessible directory (<code class="language-plaintext highlighter-rouge">wp-content/</code>). If the filename is predictable, an attacker can download the entire database without authentication</li>
</ul>

<p><strong>Mitigation:</strong></p>

<ul>
  <li>Use a <strong>non-obvious filename</strong> — not <code class="language-plaintext highlighter-rouge">backup-db.php</code>, not <code class="language-plaintext highlighter-rouge">dump.php</code>, not <code class="language-plaintext highlighter-rouge">backup.php</code>. Something like <code class="language-plaintext highlighter-rouge">x73k2p90.php</code> that won’t be guessed</li>
  <li><strong>Write the dump outside the web root if you can.</strong> Many shared hosts put <code class="language-plaintext highlighter-rouge">public_html</code> (or <code class="language-plaintext highlighter-rouge">htdocs</code>/<code class="language-plaintext highlighter-rouge">www</code>) as a subfolder of your FTP home directory, with sibling folders that aren’t served over HTTP. If your FTP account can navigate one level up, point <code class="language-plaintext highlighter-rouge">$filename</code> at something like <code class="language-plaintext highlighter-rouge">../backup-db.sql</code> instead of <code class="language-plaintext highlighter-rouge">wp-content/backup-db.sql</code> — the dump is then unreachable by URL no matter what it’s named</li>
  <li><strong>Delete the script immediately</strong> after the backup completes and you’ve downloaded the file</li>
  <li><strong>Download the backup immediately</strong> and then delete it from the server as well — don’t leave SQL dumps in web-accessible directories</li>
  <li>Consider <strong>adding a simple authentication gate</strong> (see Variations below)</li>
</ul>

<p>See also the <a href="https://wordpress.org/documentation/article/hardening-wordpress/">Hardening WordPress</a> guide for the broader set of practices this fits into.</p>

<h2 id="variations">Variations</h2>

<h3 id="faster-for-large-databases-mysqldump-via-php-exec">Faster for Large Databases: mysqldump via PHP exec()</h3>

<p>If the host has <a href="https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html"><code class="language-plaintext highlighter-rouge">mysqldump</code></a> available on the system path and PHP’s <a href="https://www.php.net/manual/en/function.exec.php"><code class="language-plaintext highlighter-rouge">exec()</code></a> or <a href="https://www.php.net/manual/en/function.shell-exec.php"><code class="language-plaintext highlighter-rouge">shell_exec()</code></a> is not disabled, a one-liner is faster and more reliable for large databases:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>
<span class="k">require</span><span class="p">(</span><span class="s1">'wp-load.php'</span><span class="p">);</span>
<span class="nv">$filename</span> <span class="o">=</span> <span class="s1">'wp-content/backup-'</span> <span class="mf">.</span> <span class="nb">date</span><span class="p">(</span><span class="s1">'Y-m-d-His'</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'.sql'</span><span class="p">;</span>
<span class="nb">exec</span><span class="p">(</span><span class="s2">"mysqldump --user="</span> <span class="mf">.</span> <span class="nb">escapeshellarg</span><span class="p">(</span><span class="no">DB_USER</span><span class="p">)</span> <span class="mf">.</span> <span class="s2">" --password="</span> <span class="mf">.</span> <span class="nb">escapeshellarg</span><span class="p">(</span><span class="no">DB_PASSWORD</span><span class="p">)</span> <span class="mf">.</span> <span class="s2">" --host="</span> <span class="mf">.</span> <span class="nb">escapeshellarg</span><span class="p">(</span><span class="no">DB_HOST</span><span class="p">)</span> <span class="mf">.</span> <span class="s2">" "</span> <span class="mf">.</span> <span class="nb">escapeshellarg</span><span class="p">(</span><span class="no">DB_NAME</span><span class="p">)</span> <span class="mf">.</span> <span class="s2">" &gt; "</span> <span class="mf">.</span> <span class="nb">escapeshellarg</span><span class="p">(</span><span class="nv">$filename</span><span class="p">));</span>
<span class="k">echo</span> <span class="s2">"Backup complete. Download: &lt;code&gt;/wp-content/</span><span class="si">{</span><span class="nv">$filename</span><span class="si">}</span><span class="s2">&lt;/code&gt;"</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Note:</strong> Many shared hosts disable <code class="language-plaintext highlighter-rouge">exec()</code> and the shell functions for security reasons. Test it first — if you get an empty file or a 500 error, fall back to the <code class="language-plaintext highlighter-rouge">wpdb</code> method.</p>

<h3 id="basic-authentication-gate">Basic Authentication Gate</h3>

<p>Add a simple check to prevent unauthorized triggering:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>
<span class="c1">// ADD THIS AT THE TOP</span>
<span class="nv">$valid_token</span> <span class="o">=</span> <span class="s1">'your-secret-token-here-12345'</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="k">empty</span><span class="p">(</span><span class="nv">$_GET</span><span class="p">[</span><span class="s1">'token'</span><span class="p">])</span> <span class="o">||</span> <span class="nv">$_GET</span><span class="p">[</span><span class="s1">'token'</span><span class="p">]</span> <span class="o">!==</span> <span class="nv">$valid_token</span><span class="p">)</span> <span class="p">{</span>
    <span class="nb">header</span><span class="p">(</span><span class="s1">'HTTP/1.0 404 Not Found'</span><span class="p">);</span>
    <span class="k">exit</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">require</span><span class="p">(</span><span class="s1">'wp-load.php'</span><span class="p">);</span>
<span class="c1">// ... rest of the script unchanged</span>
</code></pre></div></div>

<p>Then access via: <code class="language-plaintext highlighter-rouge">https://example.com/backup-db.php?token=your-secret-token-here-12345</code></p>

<p><strong>Warning:</strong> This is <em>not</em> cryptographic security — the token is visible in the URL and in server logs. But it stops casual guessing and bots.</p>

<h3 id="gzip-compression-for-large-databases">Gzip Compression for Large Databases</h3>

<p>For large sites where the SQL dump might exceed PHP memory limits or FTP upload limits, add gzip compression with PHP’s <a href="https://www.php.net/manual/en/function.gzopen.php"><code class="language-plaintext highlighter-rouge">gzopen()</code></a>/<a href="https://www.php.net/manual/en/function.gzwrite.php"><code class="language-plaintext highlighter-rouge">gzwrite()</code></a>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>
<span class="k">require</span><span class="p">(</span><span class="s1">'wp-load.php'</span><span class="p">);</span>
<span class="nv">$db</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">wpdb</span><span class="p">(</span><span class="no">DB_USER</span><span class="p">,</span> <span class="no">DB_PASSWORD</span><span class="p">,</span> <span class="no">DB_NAME</span><span class="p">,</span> <span class="no">DB_HOST</span><span class="p">);</span>
<span class="nv">$tables</span> <span class="o">=</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">get_results</span><span class="p">(</span><span class="s1">'SHOW TABLES'</span><span class="p">,</span> <span class="no">ARRAY_N</span><span class="p">);</span>

<span class="nv">$filename</span> <span class="o">=</span> <span class="s1">'wp-content/backup-'</span> <span class="mf">.</span> <span class="nb">date</span><span class="p">(</span><span class="s1">'Y-m-d-His'</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'.sql.gz'</span><span class="p">;</span>
<span class="nv">$fp</span> <span class="o">=</span> <span class="nb">gzopen</span><span class="p">(</span><span class="nv">$filename</span><span class="p">,</span> <span class="s1">'w9'</span><span class="p">);</span>

<span class="k">foreach</span> <span class="p">(</span><span class="nv">$tables</span> <span class="k">as</span> <span class="nv">$table</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$table_name</span> <span class="o">=</span> <span class="nv">$table</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
    <span class="nb">gzwrite</span><span class="p">(</span><span class="nv">$fp</span><span class="p">,</span> <span class="s2">"--</span><span class="se">\n</span><span class="s2">-- Table: </span><span class="nv">$table_name</span><span class="se">\n</span><span class="s2">--</span><span class="se">\n</span><span class="s2">"</span><span class="p">);</span>
    <span class="nb">gzwrite</span><span class="p">(</span><span class="nv">$fp</span><span class="p">,</span> <span class="s2">"DROP TABLE IF EXISTS `</span><span class="nv">$table_name</span><span class="s2">`;</span><span class="se">\n</span><span class="s2">"</span><span class="p">);</span>
    <span class="nv">$create</span> <span class="o">=</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">get_row</span><span class="p">(</span><span class="s2">"SHOW CREATE TABLE `</span><span class="nv">$table_name</span><span class="s2">`"</span><span class="p">);</span>
    <span class="nb">gzwrite</span><span class="p">(</span><span class="nv">$fp</span><span class="p">,</span> <span class="nv">$create</span><span class="o">-&gt;</span><span class="p">{</span><span class="s1">'Create Table'</span><span class="p">}</span> <span class="mf">.</span> <span class="s2">";</span><span class="se">\n\n</span><span class="s2">"</span><span class="p">);</span>
    
    <span class="nv">$rows</span> <span class="o">=</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">get_results</span><span class="p">(</span><span class="s2">"SELECT * FROM `</span><span class="nv">$table_name</span><span class="s2">`"</span><span class="p">);</span>
    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$rows</span> <span class="k">as</span> <span class="nv">$row</span><span class="p">)</span> <span class="p">{</span>
        <span class="nb">gzwrite</span><span class="p">(</span><span class="nv">$fp</span><span class="p">,</span> <span class="s2">"INSERT INTO `</span><span class="nv">$table_name</span><span class="s2">` VALUES("</span><span class="p">);</span>
        <span class="nb">gzwrite</span><span class="p">(</span><span class="nv">$fp</span><span class="p">,</span> <span class="nb">implode</span><span class="p">(</span><span class="s1">','</span><span class="p">,</span> <span class="nb">array_map</span><span class="p">(</span><span class="k">function</span><span class="p">(</span><span class="nv">$val</span><span class="p">)</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$db</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nb">is_null</span><span class="p">(</span><span class="nv">$val</span><span class="p">)</span> <span class="o">?</span> <span class="s1">'NULL'</span> <span class="o">:</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">prepare</span><span class="p">(</span><span class="s1">'%s'</span><span class="p">,</span> <span class="nv">$val</span><span class="p">);</span>
        <span class="p">},</span> <span class="p">(</span><span class="k">array</span><span class="p">)</span><span class="nv">$row</span><span class="p">)));</span>
        <span class="nb">gzwrite</span><span class="p">(</span><span class="nv">$fp</span><span class="p">,</span> <span class="s2">");</span><span class="se">\n</span><span class="s2">"</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="nb">gzclose</span><span class="p">(</span><span class="nv">$fp</span><span class="p">);</span>
<span class="k">echo</span> <span class="s2">"Backup complete. Download: &lt;code&gt;/wp-content/</span><span class="si">{</span><span class="nv">$filename</span><span class="si">}</span><span class="s2">&lt;/code&gt;"</span><span class="p">;</span>
</code></pre></div></div>

<p>This reduces the file size by ~70-80%, which can mean the difference between a downloadable backup and one that times out or exceeds FTP limits.</p>

<h3 id="per-table-backup-for-memory-constrained-hosts">Per-Table Backup for Memory-Constrained Hosts</h3>

<p>If you hit memory limits on very large tables, process tables one at a time and flush:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>
<span class="k">require</span><span class="p">(</span><span class="s1">'wp-load.php'</span><span class="p">);</span>
<span class="nv">$db</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">wpdb</span><span class="p">(</span><span class="no">DB_USER</span><span class="p">,</span> <span class="no">DB_PASSWORD</span><span class="p">,</span> <span class="no">DB_NAME</span><span class="p">,</span> <span class="no">DB_HOST</span><span class="p">);</span>
<span class="nv">$tables</span> <span class="o">=</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">get_results</span><span class="p">(</span><span class="s1">'SHOW TABLES'</span><span class="p">,</span> <span class="no">ARRAY_N</span><span class="p">);</span>

<span class="k">foreach</span> <span class="p">(</span><span class="nv">$tables</span> <span class="k">as</span> <span class="nv">$table</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$table_name</span> <span class="o">=</span> <span class="nv">$table</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
    <span class="nv">$filename</span> <span class="o">=</span> <span class="s1">'wp-content/backup-'</span> <span class="mf">.</span> <span class="nv">$table_name</span> <span class="mf">.</span> <span class="s1">'-'</span> <span class="mf">.</span> <span class="nb">date</span><span class="p">(</span><span class="s1">'Y-m-d-His'</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'.sql'</span><span class="p">;</span>
    
    <span class="nv">$output</span> <span class="o">=</span> <span class="s2">"--</span><span class="se">\n</span><span class="s2">-- Table: </span><span class="nv">$table_name</span><span class="se">\n</span><span class="s2">--</span><span class="se">\n</span><span class="s2">"</span><span class="p">;</span>
    <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="s2">"DROP TABLE IF EXISTS `</span><span class="nv">$table_name</span><span class="s2">`;</span><span class="se">\n</span><span class="s2">"</span><span class="p">;</span>
    <span class="nv">$create</span> <span class="o">=</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">get_row</span><span class="p">(</span><span class="s2">"SHOW CREATE TABLE `</span><span class="nv">$table_name</span><span class="s2">`"</span><span class="p">);</span>
    <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="nv">$create</span><span class="o">-&gt;</span><span class="p">{</span><span class="s1">'Create Table'</span><span class="p">}</span> <span class="mf">.</span> <span class="s2">";</span><span class="se">\n\n</span><span class="s2">"</span><span class="p">;</span>
    
    <span class="nv">$rows</span> <span class="o">=</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">get_results</span><span class="p">(</span><span class="s2">"SELECT * FROM `</span><span class="nv">$table_name</span><span class="s2">`"</span><span class="p">);</span>
    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$rows</span> <span class="k">as</span> <span class="nv">$row</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="s2">"INSERT INTO `</span><span class="nv">$table_name</span><span class="s2">` VALUES("</span><span class="p">;</span>
        <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="nb">implode</span><span class="p">(</span><span class="s1">','</span><span class="p">,</span> <span class="nb">array_map</span><span class="p">(</span><span class="k">function</span><span class="p">(</span><span class="nv">$val</span><span class="p">)</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$db</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="nb">is_null</span><span class="p">(</span><span class="nv">$val</span><span class="p">)</span> <span class="o">?</span> <span class="s1">'NULL'</span> <span class="o">:</span> <span class="nv">$db</span><span class="o">-&gt;</span><span class="nf">prepare</span><span class="p">(</span><span class="s1">'%s'</span><span class="p">,</span> <span class="nv">$val</span><span class="p">);</span>
        <span class="p">},</span> <span class="p">(</span><span class="k">array</span><span class="p">)</span><span class="nv">$row</span><span class="p">));</span>
        <span class="nv">$output</span> <span class="mf">.</span><span class="o">=</span> <span class="s2">");</span><span class="se">\n</span><span class="s2">"</span><span class="p">;</span>
    <span class="p">}</span>
    
    <span class="nb">file_put_contents</span><span class="p">(</span><span class="nv">$filename</span><span class="p">,</span> <span class="nv">$output</span><span class="p">);</span>
    <span class="k">echo</span> <span class="s2">"Saved: &lt;code&gt;/wp-content/</span><span class="si">{</span><span class="nv">$filename</span><span class="si">}</span><span class="s2">&lt;/code&gt;&lt;br&gt;"</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">echo</span> <span class="s2">"Done. Download all files from /wp-content/"</span><span class="p">;</span>
</code></pre></div></div>

<p>This creates one file per table, avoiding the memory spike of holding the entire database in a single string.</p>

<h3 id="raising-limits-fighting-timeouts-and-memory-caps">Raising Limits: Fighting Timeouts and Memory Caps</h3>

<p>Before reaching for the per-table variant, it’s often enough to ask PHP for more room at the top of the script — if the host’s PHP configuration allows overrides:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>
<span class="k">require</span><span class="p">(</span><span class="s1">'wp-load.php'</span><span class="p">);</span>
<span class="nb">ini_set</span><span class="p">(</span><span class="s1">'memory_limit'</span><span class="p">,</span> <span class="s1">'512M'</span><span class="p">);</span>
<span class="nb">set_time_limit</span><span class="p">(</span><span class="mi">300</span><span class="p">);</span> <span class="c1">// 5 minutes; some hosts ignore this if safe mode-style restrictions are in place</span>
</code></pre></div></div>

<p>See <a href="https://www.php.net/manual/en/function.ini-set.php"><code class="language-plaintext highlighter-rouge">ini_set()</code></a> and <a href="https://www.php.net/manual/en/function.set-time-limit.php"><code class="language-plaintext highlighter-rouge">set_time_limit()</code></a>. Some shared hosts hard-cap these via <code class="language-plaintext highlighter-rouge">php.ini</code> or <code class="language-plaintext highlighter-rouge">.user.ini</code> regardless of what the script requests — if the dump still cuts off, that’s your signal to switch to the per-table or gzip approach instead.</p>

<h2 id="alternatives-that-dont-work-in-this-scenario">Alternatives That <em>Don’t</em> Work in This Scenario</h2>

<p>It’s worth noting what you <em>can’t</em> do with only FTP:</p>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>Why It Fails</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://developer.wordpress.org/cli/commands/db/export/"><code class="language-plaintext highlighter-rouge">wp db export</code></a></td>
      <td>Requires WP-CLI, requires shell access</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">mysqldump</code> directly</td>
      <td>Requires shell access</td>
    </tr>
    <tr>
      <td>phpMyAdmin</td>
      <td>Requires installation or pre-existing access</td>
    </tr>
    <tr>
      <td>Adminer</td>
      <td>Same — needs to be uploaded and configured</td>
    </tr>
    <tr>
      <td>CPanel/Plesk backup</td>
      <td>No control panel available</td>
    </tr>
    <tr>
      <td>Host-provided backups</td>
      <td>Often unavailable or outdated on cheap shared hosts</td>
    </tr>
  </tbody>
</table>

<p>The PHP script method is one of the few that works within the FTP-only constraint.</p>

<h2 id="real-world-use-cases">Real-World Use Cases</h2>

<p><strong>Emergency backup before a migration:</strong> You’re moving a client site to new hosting, the old host only provides FTP, and you need a fresh database snapshot before pointing DNS. This gets you the dump in under 5 minutes.</p>

<p><strong>Debugging a live site:</strong> Something is wrong with the database and you need to inspect it locally. You can’t get SSH access quickly, but FTP works. Upload the script, grab the dump, restore to a local MySQL instance, and debug without touching the live site.</p>

<p><strong>Recovery after a hack:</strong> A site has been compromised and you suspect the database was tampered with. The host won’t grant SSH access. This method lets you pull a clean dump (assuming the attack didn’t also compromise <code class="language-plaintext highlighter-rouge">wp-config.php</code> credentials) for forensic analysis.</p>

<p><strong>Client with restrictive hosting:</strong> A client is on a budget host that only provides FTP. They need a database backup before a plugin update. This is the only method that doesn’t require upgrading their hosting plan.</p>

<h2 id="limitations">Limitations</h2>

<ul>
  <li><strong>Speed:</strong> The <code class="language-plaintext highlighter-rouge">wpdb</code> method is slow for large databases (100MB+). Each row is fetched and serialized individually in PHP. The <code class="language-plaintext highlighter-rouge">mysqldump</code> variant is much faster, but it’s often blocked.</li>
  <li><strong>Memory:</strong> Large tables can exhaust PHP memory limits. The per-table variant helps, or use gzip to reduce in-memory size.</li>
  <li><strong>Reliability:</strong> If the script times out mid-execution, the backup may be incomplete. For hosts with short PHP <code class="language-plaintext highlighter-rouge">max_execution_time</code>, the per-table approach is more resilient.</li>
  <li><strong>Character encoding:</strong> The script uses <code class="language-plaintext highlighter-rouge">wpdb-&gt;prepare()</code> which handles escaping, but ensure your database and PHP are using the same character set (usually utf8mb4).</li>
</ul>

<h2 id="the-takeaway">The Takeaway</h2>

<p>When all you have is FTP, the DIY PHP script is your lifeline. It’s not elegant, it’s not fast for huge databases, and it demands strict cleanup discipline — but it works, and it’s saved more than one project from a hosting dead-end.</p>

<p>The complete pattern:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Create backup-db.php with the wpdb script
2. Upload to site root via FTP
3. Visit https://example.com/backup-db.php
4. Download the .sql from /wp-content/
5. DELETE backup-db.php immediately
</code></pre></div></div>

<p>This is exactly the kind of constraint-driven problem solving we do day-to-day in WordPress operations work at <a href="https://imagewize.com">Imagewize</a>. If you’re managing WordPress sites on restrictive hosting and want help building tooling that works within those limits, <a href="https://imagewize.com/contact-us/">get in touch</a>.</p>

<hr />

<p><em>Hit a hosting scenario this doesn’t cover? Or found a better FTP-only backup method? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a>.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="backup" /><category term="security" /><category term="devops" /><summary type="html"><![CDATA[You have FTP access to a WordPress site, a password for wp-config.php, and nothing else — no SSH, no CPanel, no Plesk, no phpMyAdmin. The database needs a backup right now. This is the DIY PHP script method that works when every other door is locked.]]></summary></entry><entry><title type="html">WP-CLI One-Liner: Generate and Set a Secure Password Without the Shell History Risk</title><link href="https://wpvilla.in/wp-cli-generate-password-one-liner/" rel="alternate" type="text/html" title="WP-CLI One-Liner: Generate and Set a Secure Password Without the Shell History Risk" /><published>2026-09-21T02:00:00+00:00</published><updated>2026-09-21T02:00:00+00:00</updated><id>https://wpvilla.in/wp-cli-generate-password-one-liner</id><content type="html" xml:base="https://wpvilla.in/wp-cli-generate-password-one-liner/"><![CDATA[<p>The command reads like a haiku, but it solves a problem that bites everybody at least once:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PASS</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 16<span class="si">)</span> <span class="o">&amp;&amp;</span> wp user update admin <span class="nt">--user_pass</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"New password: </span><span class="nv">$PASS</span><span class="s2">"</span>
</code></pre></div></div>

<p>It generates a strong random password, sets it for the <code class="language-plaintext highlighter-rouge">admin</code> user, and prints it to your terminal — without ever persisting it in your shell history, a script log, or a process list where a shoulder-surfer might spot it.</p>

<h2 id="the-problem-it-solves">The Problem It Solves</h2>

<p>The first time most people need to reset a WordPress password from the command line, they do this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user update admin <span class="nt">--user_pass</span><span class="o">=</span><span class="s1">'correct-horse-battery-staple'</span>
</code></pre></div></div>

<p>That works. It also writes the password to <code class="language-plaintext highlighter-rouge">~/.bash_history</code>, <code class="language-plaintext highlighter-rouge">~/.zsh_history</code>, or whatever your shell’s history file is named. From there it can end up:</p>

<ul>
  <li>In a backup that gets restored months later</li>
  <li>In a shared CI log if you forget you’re in a script</li>
  <li>Visible to anyone with momentary access to your terminal session</li>
  <li>Grepped accidentally by a teammate looking for “admin” in a log file</li>
</ul>

<p>The second time most people need to do it, they remember the first time and reach for <code class="language-plaintext highlighter-rouge">--prompt</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user update admin <span class="nt">--prompt</span><span class="o">=</span>user_pass
</code></pre></div></div>

<p>That’s better — the password is typed interactively and never hits the history. But it still has friction: it won’t run in a script, it’s awkward over SSH to a remote host, and you need to manually type a strong password or copy-paste one from a generator, which puts it back on the clipboard where it can leak.</p>

<h2 id="how-the-one-liner-works">How the One-Liner Works</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PASS</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 16<span class="si">)</span> <span class="o">&amp;&amp;</span> wp user update admin <span class="nt">--user_pass</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"New password: </span><span class="nv">$PASS</span><span class="s2">"</span>
</code></pre></div></div>

<p>Breaking it down:</p>

<ol>
  <li>
    <p><strong><a href="https://docs.openssl.org/master/man1/openssl-rand/"><code class="language-plaintext highlighter-rouge">openssl rand -base64 16</code></a></strong> generates 16 random bytes and <a href="https://datatracker.ietf.org/doc/html/rfc4648#section-4">base64-encodes</a> them, producing a 24-character password like <code class="language-plaintext highlighter-rouge">FYSB+DvNIUNZCw2wYekdGA==</code>. Note this is <em>standard</em> base64, not the URL-safe variant — it can contain <code class="language-plaintext highlighter-rouge">+</code>, <code class="language-plaintext highlighter-rouge">/</code>, and trailing <code class="language-plaintext highlighter-rouge">=</code> padding, which is why the <code class="language-plaintext highlighter-rouge">--user_pass="$PASS"</code> quoting in step 3 matters.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">PASS=$(...)</code></strong> captures that output into a shell variable. Variables don’t survive the shell session — once you close the terminal or the script exits, the password is gone.</p>
  </li>
  <li>
    <p><strong><a href="https://developer.wordpress.org/cli/commands/user/update/"><code class="language-plaintext highlighter-rouge">wp user update</code></a> <code class="language-plaintext highlighter-rouge">admin --user_pass="$PASS"</code></strong> updates the user. The quotes around <code class="language-plaintext highlighter-rouge">$PASS</code> keep the shell from word-splitting or globbing parts of the password.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">echo "New password: $PASS"</code></strong> prints it once so you can see it and copy it to your password manager. This is the only time it’s visible.</p>
  </li>
</ol>

<p>The <code class="language-plaintext highlighter-rouge">&amp;&amp;</code> chains ensure that if any step fails (OpenSSL missing, <code class="language-plaintext highlighter-rouge">wp</code> not found, user doesn’t exist), the whole thing stops and the password never gets set to a half-finished value.</p>

<h2 id="security-notes">Security Notes</h2>

<p>This is secure against <strong>history</strong>, but not against <strong>shoulder surfing</strong> or <strong>screen capture</strong>. The password is still displayed on screen for anyone looking at your terminal. For environments where that’s a risk, consider:</p>

<ul>
  <li>Running the command over SSH and only showing the output locally</li>
  <li>Using <a href="https://developer.wordpress.org/cli/commands/user/reset-password/"><code class="language-plaintext highlighter-rouge">wp user reset-password</code></a> <code class="language-plaintext highlighter-rouge">admin --skip-email --porcelain</code> instead, which also avoids history but only prints the password</li>
</ul>

<p>It is also <strong>not</strong> secure against <strong>process listing</strong>. For a brief moment the variable’s value exists in the process environment. On a shared system where other users can run <code class="language-plaintext highlighter-rouge">ps eww</code>, this is a vulnerability. For most local or single-user deploy contexts it’s fine.</p>

<p>If you need the highest level of paranoia, use WP-CLI’s own password generator:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user reset-password admin <span class="nt">--porcelain</span> <span class="nt">--skip-email</span>
</code></pre></div></div>

<p>This generates a 12-character password internally (WordPress’s <a href="https://developer.wordpress.org/reference/functions/wp_generate_password/"><code class="language-plaintext highlighter-rouge">wp_generate_password()</code></a> default) and prints only the result. It never touches your shell, so it can’t leak through history, environment, or process listing. The trade-off is you can’t control the length or the character set.</p>

<h2 id="variations">Variations</h2>

<p><strong>Different user:</strong> Replace <code class="language-plaintext highlighter-rouge">admin</code> with the username or user ID:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PASS</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 16<span class="si">)</span> <span class="o">&amp;&amp;</span> wp user update 42 <span class="nt">--user_pass</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"New password: </span><span class="nv">$PASS</span><span class="s2">"</span>
</code></pre></div></div>

<p><strong>Longer password:</strong> Base64 encodes 6 bits per character, so 16 bytes becomes 24 characters. For a longer password, increase the byte count:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PASS</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 24<span class="si">)</span>  <span class="c"># ~32 characters</span>
<span class="nv">PASS</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 32<span class="si">)</span>  <span class="c"># ~44 characters</span>
</code></pre></div></div>

<p><strong>Alphanumeric only:</strong> Some systems reject special characters. Use <code class="language-plaintext highlighter-rouge">tr</code> to strip them:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PASS</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 24 | <span class="nb">tr</span> <span class="nt">-dc</span> <span class="s1">'a-zA-Z0-9'</span><span class="si">)</span> <span class="o">&amp;&amp;</span> wp user update admin <span class="nt">--user_pass</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"New password: </span><span class="nv">$PASS</span><span class="s2">"</span>
</code></pre></div></div>

<p><strong>Copy to clipboard automatically</strong> (macOS):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PASS</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 16<span class="si">)</span> <span class="o">&amp;&amp;</span> wp user update admin <span class="nt">--user_pass</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$PASS</span><span class="s2">"</span> | pbcopy <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"New password copied to clipboard"</span>
</code></pre></div></div>

<p><strong>Remote host via SSH:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh user@example.com <span class="s2">"PASS=</span><span class="se">\$</span><span class="s2">(openssl rand -base64 16) &amp;&amp; wp user update admin --user_pass=</span><span class="se">\"\\</span><span class="nv">$PASS</span><span class="se">\"</span><span class="s2"> &amp;&amp; echo </span><span class="se">\"</span><span class="s2">New password: </span><span class="se">\$</span><span class="s2">PASS</span><span class="se">\"</span><span class="s2">"</span>
</code></pre></div></div>

<p>That escaping is messy. A cleaner approach is to run the password generation locally and pass only the value:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PASS</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 16<span class="si">)</span> <span class="o">&amp;&amp;</span> ssh user@example.com <span class="s2">"wp user update admin --user_pass='</span><span class="nv">$PASS</span><span class="s2">'"</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"New password: </span><span class="nv">$PASS</span><span class="s2">"</span>
</code></pre></div></div>

<p><strong>Creating a new user with a generated password:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PASS</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 16<span class="si">)</span> <span class="o">&amp;&amp;</span> wp user create deploybot bot@example.com <span class="nt">--role</span><span class="o">=</span>administrator <span class="nt">--user_pass</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"New password: </span><span class="nv">$PASS</span><span class="s2">"</span>
</code></pre></div></div>

<h2 id="why-not-just-use---show-password-with-reset-password">Why Not Just Use <code class="language-plaintext highlighter-rouge">--show-password</code> with <code class="language-plaintext highlighter-rouge">reset-password</code>?</h2>

<p>WP-CLI’s <a href="https://developer.wordpress.org/cli/commands/user/reset-password/"><code class="language-plaintext highlighter-rouge">reset-password</code></a> command has a <code class="language-plaintext highlighter-rouge">--show-password</code> flag:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user reset-password admin <span class="nt">--show-password</span>
</code></pre></div></div>

<p>This also avoids shell history. The difference is mainly ergonomic: <code class="language-plaintext highlighter-rouge">reset-password</code> emails the user by default, so you need <code class="language-plaintext highlighter-rouge">--skip-email</code> if that’s not desired. And it uses WP-CLI’s own password generator, which may or may not match your preferred length or character set.</p>

<p>The OpenSSL version gives you more control, and it’s a pattern you can reuse in other contexts (database passwords, API keys, etc.).</p>

<h2 id="what-this-doesnt-do">What This Doesn’t Do</h2>

<ul>
  <li>
    <p><strong>It doesn’t expire old sessions.</strong> After changing a password, existing login sessions for that user remain valid until they expire or the user logs out. For incident response, add <a href="https://developer.wordpress.org/cli/commands/user/session/destroy/"><code class="language-plaintext highlighter-rouge">wp user session destroy</code></a> <code class="language-plaintext highlighter-rouge">admin --all</code>.</p>
  </li>
  <li>
    <p><strong>It doesn’t verify the password.</strong> Use <a href="https://developer.wordpress.org/cli/commands/user/check-password/"><code class="language-plaintext highlighter-rouge">wp user check-password</code></a> <code class="language-plaintext highlighter-rouge">admin 'the-password'</code> to verify without changing it.</p>
  </li>
  <li>
    <p><strong>It doesn’t rotate application passwords.</strong> For API access, use <a href="https://developer.wordpress.org/cli/commands/user/application-password/create/"><code class="language-plaintext highlighter-rouge">wp user application-password create</code></a> <code class="language-plaintext highlighter-rouge">admin 'deploy-pipeline'</code>.</p>
  </li>
</ul>

<h2 id="real-world-use-cases">Real-World Use Cases</h2>

<p><strong>Emergency lockout recovery:</strong> You’re handed a site where the admin password is lost, SSH is available, and you need to get back in fast. This is the two-second fix.</p>

<p><strong>Provisioning scripts:</strong> A deploy or setup script needs to create users with strong passwords. The variable approach keeps them out of script files and logs.</p>

<p><strong>Client handovers:</strong> You’re transferring a site to a new owner and need to set a temporary admin password. Generating it inline means it’s never written down anywhere permanent.</p>

<p><strong>Security incident response:</strong> A password may have been compromised. Rotating it with a one-liner gets it done immediately, and the echo lets you communicate the new password to the affected user through a separate secure channel.</p>

<h2 id="the-takeaway">The Takeaway</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PASS</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 16<span class="si">)</span> <span class="o">&amp;&amp;</span> wp user update admin <span class="nt">--user_pass</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PASS</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"New password: </span><span class="nv">$PASS</span><span class="s2">"</span>
</code></pre></div></div>

<p>It’s a small pattern with outsized utility: strong passwords, no history pollution, one copy-paste, and it works identically whether you’re local or on a remote server over SSH. Keep it in your back pocket for the next time you need to set a WordPress password from the command line.</p>

<p>This is the kind of small but critical detail we handle day-to-day in WordPress operations work at <a href="https://imagewize.com">Imagewize</a>. If you’re building or maintaining WordPress sites and want help with the tooling and workflow around them, <a href="https://imagewize.com/contact-us/">get in touch</a>.</p>

<hr />

<p><em>Found a better one-liner? Or hit a case this doesn’t cover? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a>.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="wp-cli" /><category term="devops" /><category term="security" /><category term="wordpress" /><category term="wp-cli" /><category term="cli" /><category term="security" /><category term="passwords" /><category term="bash" /><category term="openssl" /><summary type="html"><![CDATA[The command reads like a haiku, but it solves a problem that bites everybody at least once:]]></summary></entry><entry><title type="html">wp-ops malware-scan: Checking a Remote WordPress Site Without Leaving Anything Behind</title><link href="https://wpvilla.in/wp-ops-malware-scan-remote-security-tool/" rel="alternate" type="text/html" title="wp-ops malware-scan: Checking a Remote WordPress Site Without Leaving Anything Behind" /><published>2026-09-11T05:00:00+00:00</published><updated>2026-09-11T05:00:00+00:00</updated><id>https://wpvilla.in/wp-ops-malware-scan-remote-security-tool</id><content type="html" xml:base="https://wpvilla.in/wp-ops-malware-scan-remote-security-tool/"><![CDATA[<p>Malware scanning a WordPress site you can only reach over SSH used to mean a small ritual: <code class="language-plaintext highlighter-rouge">scp</code> the scanner script up to <code class="language-plaintext highlighter-rouge">/tmp</code>, run it, and remember to delete it before anyone else finds a security tool sitting in a web-adjacent directory. Forget that last step once and you’ve left a scanner — sometimes one with debug output revealing your file structure — reachable on a production box indefinitely. <a href="https://github.com/imagewize/wp-ops">wp-ops</a> 5.23.0 adds a command that skips the whole ritual, and getting it fully right took one more release after that. Here’s both halves.</p>

<h2 id="the-scanners-briefly">The scanners, briefly</h2>

<p><code class="language-plaintext highlighter-rouge">wp-cli/security/scanner-targeted.php</code> and <code class="language-plaintext highlighter-rouge">scanner-general.php</code> are pattern-matching malware scanners for a WordPress install — targeted looks for site-specific threats, general does broader-spectrum detection, both checking PHP/JS/HTML files for known-bad signatures. Since 5.22.0 they also run <code class="language-plaintext highlighter-rouge">wp core verify-checksums</code> first and skip pattern-scanning any core or plugin file that hashes clean against WordPress.org’s official checksums — the single biggest source of false positives (an unmodified <code class="language-plaintext highlighter-rouge">wp-includes/SimplePie/...</code> file matching a <code class="language-plaintext highlighter-rouge">mysql.php</code>-shaped pattern by pure coincidence of its name, for instance) goes away automatically.</p>

<p>Running them locally, or via <code class="language-plaintext highlighter-rouge">wp eval-file</code> on a box the scanner already lives on, was always fine. Remote was the awkward case.</p>

<h2 id="the-old-remote-workflow-and-why-it-broke">The old remote workflow, and why it broke</h2>

<p>Before 5.22.0, scanning a remote site meant copying <code class="language-plaintext highlighter-rouge">scanner-targeted.php</code> up by hand and running it over SSH. Once the checksum module shipped, that stopped working outright — both scanners now do:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">require_once</span> <span class="nb">dirname</span><span class="p">(</span><span class="k">__FILE__</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'/checksum-verify.php'</span><span class="p">;</span>
</code></pre></div></div>

<p>A lone copied file has no sibling <code class="language-plaintext highlighter-rouge">checksum-verify.php</code> next to it, so it fatals on <code class="language-plaintext highlighter-rouge">Failed opening required '.../checksum-verify.php'</code>. The capability to run this properly against a remote or VM target only existed inside the project’s MCP server tool — not from the CLI.</p>

<h2 id="wp-ops-malware-scan">wp-ops malware-scan</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops malware-scan example.com production
wp-ops malware-scan example.com production <span class="nt">--mode</span> both
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--mode</code> picks <code class="language-plaintext highlighter-rouge">targeted</code>, <code class="language-plaintext highlighter-rouge">general</code>, or <code class="language-plaintext highlighter-rouge">both</code> (default <code class="language-plaintext highlighter-rouge">targeted</code>). What it does under the hood: it inlines <code class="language-plaintext highlighter-rouge">checksum-verify.php</code>’s source in place of that <code class="language-plaintext highlighter-rouge">require_once</code> line, then streams the assembled, self-contained script to <code class="language-plaintext highlighter-rouge">php /dev/stdin</code> on the target over SSH. Nothing is ever written to the server — there’s no file to <code class="language-plaintext highlighter-rouge">scp</code>, no file to forget, no file to delete.</p>

<p>A real run, both scanners, against a production Bedrock site:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>========================================
Malware Scan: example.com (production)
========================================

  Target: web@example.com:/srv/www/example.com/current/web/wp
  Mode:   both

--- TARGETED SCANNER ---

WordPress Core: VERIFIED
Plugins Verified: 6
Files Verified: 3338
Files Failed: 0

Found 1,955 files to scan
Skipping 1955 verified core/plugin files (checksum passed)
After checksum filtering: 0 files to pattern-scan

SCAN SUMMARY:
  Files with matches: 0
  Total matches: 0
  Checksum-verified files skipped: 1,955

✓ No threats detected!

--- GENERAL SCANNER ---

WordPress Core: VERIFIED
Plugins Verified: 6

SCAN SUMMARY:
  Suspicious filenames: 0
  Files with matches: 0
  Checksum-verified filename hits suppressed: 4

✓ No threats detected!
</code></pre></div></div>

<p>Both scanners agree, checksum verification did the heavy lifting (1,955 files skipped from pattern-matching because they’re provably unmodified core/plugin code), and the whole thing ran in about 11 seconds total.</p>

<p>Host and WordPress path default from a small site registry when one exists, so a site whose SSH host isn’t its own domain — several client sites sharing one box, which is exactly our own setup — needs no flags at all. Without a registry it falls back to the stock Trellis layout: <code class="language-plaintext highlighter-rouge">web@&lt;site-name&gt;</code>, <code class="language-plaintext highlighter-rouge">/srv/www/&lt;site&gt;/current/web/wp</code>.</p>

<h2 id="the-bug-the-first-release-shipped-with">The bug the first release shipped with</h2>

<p>Both scanners end with a block like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>============================================
  SECURITY WARNING
============================================

⚠ DELETE THIS FILE after use or move it outside the web root!
  This scanner should not remain accessible on a production server.
</code></pre></div></div>

<p>That warning is correct advice — for the FTP-upload, cPanel/Plesk-terminal, or scp’d-<code class="language-plaintext highlighter-rouge">wp eval-file</code> workflows, where the scanner genuinely does land as a file in or near the web root. But it kept printing on <code class="language-plaintext highlighter-rouge">malware-scan</code> runs too, where nothing was ever written to disk. Stale advice in a security tool’s own output is exactly the kind of thing that erodes trust in the rest of what it tells you, so it needed fixing before the next scan.</p>

<p>My first attempt checked <code class="language-plaintext highlighter-rouge">__FILE__</code> against a short list of expected stdin paths — <code class="language-plaintext highlighter-rouge">/dev/fd/0</code>, <code class="language-plaintext highlighter-rouge">/dev/stdin</code>, <code class="language-plaintext highlighter-rouge">php://stdin</code>. It passed clean on my Mac. It did not pass against the actual production target, which is Linux: <code class="language-plaintext highlighter-rouge">php /dev/stdin</code> there resolves <code class="language-plaintext highlighter-rouge">__FILE__</code> to <code class="language-plaintext highlighter-rouge">/proc/&lt;pid&gt;/fd/pipe:[18694749]</code> — a real pipe, just not one of the strings I’d guessed. String-matching platform-specific pipe representations was never going to be a complete list.</p>

<p>The fix that actually holds across platforms:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">scanner_file_is_on_disk</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nb">is_file</span><span class="p">(</span><span class="k">__FILE__</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">is_file()</code> asks the filesystem directly rather than pattern-matching a path. It returns <code class="language-plaintext highlighter-rouge">false</code> for a pipe on every platform I could test — macOS’s <code class="language-plaintext highlighter-rouge">/dev/fd/0</code> and Linux’s <code class="language-plaintext highlighter-rouge">/proc/.../fd/pipe:[...]</code> both fail it — and <code class="language-plaintext highlighter-rouge">true</code> for a script actually sitting on disk. Wrap the warning block in that check, and it fires exactly when there’s a real file for someone to go delete, on any host, regardless of how that platform happens to name its stdin pipe internally.</p>

<p>The lesson that actually generalizes here: a fix verified only against your own machine, for anything touching cross-platform filesystem or process behavior, isn’t verified. Re-running the real command against the real remote target — not a local approximation of it — is what caught the gap.</p>

<h2 id="manually-checking-a-file-the-scanner-skipped">Manually checking a file the scanner skipped</h2>

<p>Pattern-matching has a 5MB file-size cap for performance, so scan output sometimes includes a note like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>============================================
  SKIPPED FILES
============================================

  /srv/www/example.com/current/web/wp/wp-includes/js/dist/script-modules/vips/worker.min.js
    Reason: File too large (12.8 MB)
</code></pre></div></div>

<p>Worth knowing: that cap only applies to the pattern-matching pass. Checksum verification has no size limit — <code class="language-plaintext highlighter-rouge">wp core verify-checksums</code> hashes every core file regardless of size — so a skipped file is still very likely covered by the earlier “WordPress Core: VERIFIED” result, just not pattern-scanned on top of that. If you want to confirm one specific file yourself rather than take that on faith, WordPress.org’s checksums API makes it a two-line check:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh web@example.com <span class="s1">'
WP_VER=$(cd /srv/www/example.com/current &amp;&amp; wp core version --path=web/wp)
FILE="wp-includes/js/dist/script-modules/vips/worker.min.js"
curl -s "https://api.wordpress.org/core/checksums/1.0/?version=${WP_VER}&amp;locale=en_US" \
  | python3 -c "import json,sys; print(json.load(sys.stdin)[\"checksums\"].get(\"$FILE\", \"NOT FOUND\"))"
md5sum /srv/www/example.com/current/web/wp/$FILE
'</span>
</code></pre></div></div>

<p>The API returns every core file’s official md5 for that exact WordPress version, keyed by its relative path from the <code class="language-plaintext highlighter-rouge">wp</code> root. Fetch the one hash you care about, <code class="language-plaintext highlighter-rouge">md5sum</code> the live file, and compare by eye:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Official md5: 2710fbcfdcdf95a4ed07e251bc09c66c
Local md5:    2710fbcfdcdf95a4ed07e251bc09c66c
</code></pre></div></div>

<p>A match means the file is stock, unmodified core straight from wordpress.org for that version — no scanner, no size cap, no waiting required.</p>

<h2 id="where-this-leaves-things">Where this leaves things</h2>

<p><code class="language-plaintext highlighter-rouge">malware-scan</code> is on the tap now:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew upgrade <span class="nt">--cask</span> wp-ops
wp-ops malware-scan example.com production <span class="nt">--mode</span> both
</code></pre></div></div>

<p>Both scanners are the same ones you’d run locally or via <code class="language-plaintext highlighter-rouge">wp eval-file</code> — the remote path just gets you there without a file to clean up afterward, and without a leftover warning telling you to clean up something that was never there.</p>

<p>This is part of the tooling we run at <a href="https://imagewize.com">Imagewize</a> for client WordPress operations on Trellis. If your team is still doing the <code class="language-plaintext highlighter-rouge">scp</code>-and-remember-to-delete dance for remote security checks, <a href="https://imagewize.com/contact-us/">get in touch</a>.</p>

<hr />

<p><em>Questions about your own Trellis workflow? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a>.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="security" /><category term="wp-cli" /><category term="devops" /><category term="wordpress" /><category term="security" /><category term="wp-cli" /><category term="trellis" /><category term="cli" /><category term="malware" /><category term="checksums" /><summary type="html"><![CDATA[Malware scanning a WordPress site you can only reach over SSH used to mean a small ritual: scp the scanner script up to /tmp, run it, and remember to delete it before anyone else finds a security tool sitting in a web-adjacent directory. Forget that last step once and you’ve left a scanner — sometimes one with debug output revealing your file structure — reachable on a production box indefinitely. wp-ops 5.23.0 adds a command that skips the whole ritual, and getting it fully right took one more release after that. Here’s both halves.]]></summary></entry><entry><title type="html">Mistral Vibe Upwork MCP Server Setup with OAuth — Why the Docs Are Wrong and How to Fix It</title><link href="https://wpvilla.in/mistral-vibe-upwork-mcp-oauth-setup/" rel="alternate" type="text/html" title="Mistral Vibe Upwork MCP Server Setup with OAuth — Why the Docs Are Wrong and How to Fix It" /><published>2026-09-08T03:00:00+00:00</published><updated>2026-09-08T03:00:00+00:00</updated><id>https://wpvilla.in/mistral-vibe-upwork-mcp-oauth-setup</id><content type="html" xml:base="https://wpvilla.in/mistral-vibe-upwork-mcp-oauth-setup/"><![CDATA[<p>If you’ve tried to connect Mistral Vibe to the Upwork MCP server and hit a wall, you’ve probably read the <a href="https://docs.mistral.ai/vibe/code/cli/mcp-servers">official documentation on MCP servers</a> and come away confused. It states:</p>

<blockquote>
  <p>Known limitation: the CLI does not yet support MCP servers that require OAuth authentication. Use the stdio or http transport with an API key or other static credential.</p>
</blockquote>

<p>That statement is <strong>out of date</strong>. OAuth is fully implemented and working in Vibe today. The confusion stems from stale docs and a config gap that’s easy to miss. Here’s how to get the Upwork MCP server connected with OAuth in Vibe, what the docs get wrong, and why the fix is simpler than you think.</p>

<h2 id="the-documentation-gap-what-the-docs-say-vs-what-the-code-does">The documentation gap: what the docs say vs. what the code does</h2>

<p>The Mistral docs claim OAuth for MCP servers isn’t supported. The actual code tells a different story. In the current Vibe repository (commit <code class="language-plaintext highlighter-rouge">6c79ef0</code>, Sep 4, 2026), OAuth support is complete and production-ready:</p>

<ul>
  <li><a href="https://github.com/mistralai/mistral-vibe/blob/6c79ef0e1ee484d7069bc38590d5917d3914cd48/vibe/core/auth/mcp_oauth.py"><code class="language-plaintext highlighter-rouge">vibe/core/auth/mcp_oauth.py</code></a> — full PKCE authorization-code flow, loopback callback server, OS-keyring token storage, refresh-aware handling, dynamic client registration (DCR) support</li>
  <li><a href="https://github.com/mistralai/mistral-vibe/blob/6c79ef0e1ee484d7069bc38590d5917d3914cd48/vibe/cli/textual_ui/widgets/mcp_oauth_app.py"><code class="language-plaintext highlighter-rouge">vibe/cli/textual_ui/widgets/mcp_oauth_app.py</code></a> — the textual UI widget for the login flow</li>
</ul>

<p>So why the disconnect? The docs are stale, and there’s a subtle configuration detail that tripped up early attempts — including mine.</p>

<h2 id="the-actual-blocker-missing-auth-block-in-vibeconfigtoml">The actual blocker: missing auth block in <code class="language-plaintext highlighter-rouge">.vibe/config.toml</code></h2>

<p>If your Upwork MCP server shows as <strong>“enabled, no tools”</strong> in Vibe, it’s not because OAuth is unsupported. It’s because your <code class="language-plaintext highlighter-rouge">[[mcp_servers]]</code> entry is missing the <code class="language-plaintext highlighter-rouge">[mcp_servers.auth]</code> block entirely. When that block is absent, Vibe defaults to <code class="language-plaintext highlighter-rouge">MCPStaticAuth</code> (empty, no token). The code that would auto-promote a server to OAuth on a 401 response — <code class="language-plaintext highlighter-rouge">_late_bound_oauth</code> in <code class="language-plaintext highlighter-rouge">vibe/app_server/_mcp_auth.py</code> — <strong>explicitly excludes</strong> config-owned <code class="language-plaintext highlighter-rouge">[[mcp_servers]]</code> entries. The comment in the source is clear: <em>“a static entry there is a decision.”</em></p>

<p>In practice, this means:</p>

<ul>
  <li>Without <code class="language-plaintext highlighter-rouge">[mcp_servers.auth]</code>: Vibe treats the server as static-credential only. Attempting to authenticate via <code class="language-plaintext highlighter-rouge">/mcp</code> browser will fail with <code class="language-plaintext highlighter-rouge">MCP server 'upwork' is not configured for OAuth</code>.</li>
  <li>With <code class="language-plaintext highlighter-rouge">[mcp_servers.auth]</code> of <code class="language-plaintext highlighter-rouge">type = "oauth"</code>: Vibe starts the OAuth flow when you select the server in the <code class="language-plaintext highlighter-rouge">/mcp</code> browser and the server connects fully.</li>
</ul>

<h2 id="the-fix-three-lines-in-vibeconfigtoml">The fix: three lines in <code class="language-plaintext highlighter-rouge">.vibe/config.toml</code></h2>

<p>Here’s the working configuration I’m using in my SEO strategy project:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[[mcp_servers]]</span>
<span class="py">name</span> <span class="p">=</span> <span class="s">"upwork"</span>
<span class="py">transport</span> <span class="p">=</span> <span class="s">"http"</span>
<span class="py">url</span> <span class="p">=</span> <span class="s">"https://mcp.upwork.com/mcp"</span>

<span class="nn">[mcp_servers.auth]</span>
<span class="py">type</span> <span class="p">=</span> <span class="s">"oauth"</span>
<span class="py">scopes</span> <span class="p">=</span> <span class="p">[]</span>
</code></pre></div></div>

<p>That’s it. No <code class="language-plaintext highlighter-rouge">client_id</code>, no <code class="language-plaintext highlighter-rouge">client_metadata_url</code>. This relies on the Upwork MCP server supporting <strong>Dynamic Client Registration</strong> (RFC 7591) — and it does.</p>

<h2 id="how-dynamic-client-registration-dcr-works-here">How Dynamic Client Registration (DCR) works here</h2>

<p>DCR is the key to why this config is so minimal. Instead of requiring you to pre-register an OAuth client with Upwork and paste static credentials into your config, the Upwork MCP server:</p>

<ol>
  <li>Accepts a DCR request from Vibe at OAuth flow start</li>
  <li>Returns a temporary <code class="language-plaintext highlighter-rouge">client_id</code> and <code class="language-plaintext highlighter-rouge">client_secret</code> for that session</li>
  <li>Uses those ephemeral credentials for the duration of the auth flow</li>
  <li>Stores the resulting tokens in your OS keyring (via the <code class="language-plaintext highlighter-rouge">keyring</code> Python library)</li>
</ol>

<p>The result: zero pre-registration, zero static secrets in your config. From the Vibe CLI, run <code class="language-plaintext highlighter-rouge">/mcp</code> to open the MCP server browser, select the <strong>upwork</strong> server, choose <strong>Authenticate in browser</strong>, complete the consent flow, and the server flips from <code class="language-plaintext highlighter-rouge">[http] no tools ● enabled</code> to <code class="language-plaintext highlighter-rouge">[http] 48 tools ● connected</code> with the full Upwork tool list:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">upwork__find_jobs</code></li>
  <li><code class="language-plaintext highlighter-rouge">upwork__get_account</code></li>
  <li><code class="language-plaintext highlighter-rouge">upwork__boost_profile</code></li>
  <li>And the rest of the Upwork MCP catalog</li>
</ul>

<h2 id="transport-note-http-vs-streamable-http">Transport note: <code class="language-plaintext highlighter-rouge">http</code> vs. <code class="language-plaintext highlighter-rouge">streamable-http</code></h2>

<p>The docs mention transport types. In practice, both <code class="language-plaintext highlighter-rouge">transport = "http"</code> and <code class="language-plaintext highlighter-rouge">transport = "streamable-http"</code> route to the same code path in Vibe. From <code class="language-plaintext highlighter-rouge">registry.py:_discover_server</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">case</span> <span class="sh">"</span><span class="s">http</span><span class="sh">"</span> <span class="o">|</span> <span class="sh">"</span><span class="s">streamable-http</span><span class="sh">"</span><span class="p">:</span>
</code></pre></div></div>

<p>Both hit <code class="language-plaintext highlighter-rouge">_discover_http</code> → <code class="language-plaintext highlighter-rouge">streamable_http_client</code>. The distinction is a config-schema discriminator, not a functional difference. Use <code class="language-plaintext highlighter-rouge">http</code> unless you have a specific reason not to.</p>

<h2 id="verification-it-works">Verification: it works</h2>

<p>I applied the three-line auth block, then used the Vibe MCP browser to authenticate. From the Vibe CLI, run <code class="language-plaintext highlighter-rouge">/mcp</code> to open the MCP server browser, select the <strong>upwork</strong> server from the list, and choose <strong>Authenticate in browser</strong>. This launches your default browser to Upwork’s OAuth consent screen. Complete the consent flow there, and Vibe automatically receives the OAuth tokens and connects the server. No manual client registration, no static keys, no workarounds. DCR against Upwork’s MCP endpoint works with zero pre-registration.</p>

<h2 id="direct-terminal-usage-with-enabled-tools">Direct terminal usage with –enabled-tools</h2>

<p>Once OAuth is configured and connected, you can also use Upwork MCP tools directly from the terminal without entering the full Vibe CLI. Pass a single-shot prompt with <code class="language-plaintext highlighter-rouge">-p</code>, restrict to the specific tool via <code class="language-plaintext highlighter-rouge">--enabled-tools</code>, and set <code class="language-plaintext highlighter-rouge">--output text</code> for clean output:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vibe <span class="nt">-p</span> <span class="s2">"Call list_accounts and return ONLY the org_uid values, one per line, with no other text."</span> <span class="se">\</span>
    <span class="nt">--enabled-tools</span> <span class="s2">"upwork_upwork__list_accounts"</span> <span class="se">\</span>
    <span class="nt">--output</span> text <span class="se">\</span>
    <span class="nt">--workdir</span> <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">"</span> <span class="se">\</span>
    <span class="nt">--trust</span>
</code></pre></div></div>

<p>This pattern works for any Upwork MCP tool. Replace <code class="language-plaintext highlighter-rouge">upwork_upwork__list_accounts</code> with the tool you need (e.g., <code class="language-plaintext highlighter-rouge">upwork_upwork__get_account</code>, <code class="language-plaintext highlighter-rouge">upwork_upwork__find_jobs</code>, etc.) and adjust the prompt to match. The <code class="language-plaintext highlighter-rouge">--trust</code> flag allows the command to run non-interactively, and <code class="language-plaintext highlighter-rouge">--output text</code> strips the markdown formatting for easier parsing or scripting.</p>

<p>As documented in <a href="https://docs.mistral.ai/vibe/code/cli/mcp-servers#browse">Mistral’s MCP server docs under “Browse”</a>, the <code class="language-plaintext highlighter-rouge">/mcp</code> command opens an interactive MCP server browser that lists all configured servers and provides authentication options for each.</p>

<p>Separately, the Upwork MCP server also works headlessly via <strong>Claude Code 2.1.251+</strong> (verified Aug 31, 2026), which handles its own OAuth internally. That path is independent of Vibe’s DCR implementation and remains a proven alternative if you hit edge cases.</p>

<h2 id="what-the-docs-should-say">What the docs should say</h2>

<p>The current documentation limitation note should be updated to reflect reality:</p>

<blockquote>
  <p>OAuth is fully supported for MCP servers. For servers requiring OAuth, add a <code class="language-plaintext highlighter-rouge">[mcp_servers.auth]</code> block with <code class="language-plaintext highlighter-rouge">type = "oauth"</code> to your <code class="language-plaintext highlighter-rouge">[[mcp_servers]]</code> entry. Many MCP servers support Dynamic Client Registration (DCR), which requires no pre-registered client credentials. Use <code class="language-plaintext highlighter-rouge">/mcp</code> to open the MCP server browser, select your server, and choose <strong>Authenticate in browser</strong> to start the OAuth flow.</p>
</blockquote>

<p>And for servers that don’t support DCR, you’d add the static client credentials:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[mcp_servers.auth]</span>
<span class="py">type</span> <span class="p">=</span> <span class="s">"oauth"</span>
<span class="py">client_id</span> <span class="p">=</span> <span class="s">"your-pre-registered-client-id"</span>
<span class="py">client_secret</span> <span class="p">=</span> <span class="s">"your-client-secret"</span>  <span class="c"># if required by the server</span>
<span class="py">scopes</span> <span class="p">=</span> <span class="p">[</span><span class="s">"scope1"</span><span class="p">,</span> <span class="s">"scope2"</span><span class="p">]</span>
</code></pre></div></div>

<p>But for Upwork, the minimal DCR config above is all you need.</p>

<h2 id="the-seven-step-check-if-your-upwork-mcp-server-isnt-connecting">The seven-step check if your Upwork MCP server isn’t connecting</h2>

<p>If you’re still seeing “no tools” after adding the auth block:</p>

<ol>
  <li><strong>Verify the config</strong> — ensure <code class="language-plaintext highlighter-rouge">name</code>, <code class="language-plaintext highlighter-rouge">transport</code>, <code class="language-plaintext highlighter-rouge">url</code>, and <code class="language-plaintext highlighter-rouge">[mcp_servers.auth]</code> with <code class="language-plaintext highlighter-rouge">type = "oauth"</code> are all present</li>
  <li><strong>Restart Vibe</strong> — config changes require a full restart to take effect</li>
  <li><strong>Check <code class="language-plaintext highlighter-rouge">/mcp list</code></strong> — confirm the server appears with <code class="language-plaintext highlighter-rouge">[http]</code> prefix</li>
  <li><strong>Run <code class="language-plaintext highlighter-rouge">/mcp</code> to open the MCP browser</strong> — select the <strong>upwork</strong> server and choose <strong>Authenticate in browser</strong></li>
  <li><strong>Complete the consent flow</strong> — approve all requested permissions in your browser</li>
  <li><strong>Check <code class="language-plaintext highlighter-rouge">/mcp list</code> again</strong> — the server should now show tool count &gt; 0 (e.g., <code class="language-plaintext highlighter-rouge">[http] 48 tools ● connected</code>)</li>
  <li><strong>Test a tool call</strong> — try <code class="language-plaintext highlighter-rouge">upwork__get_account</code> to verify connectivity</li>
</ol>

<p>If step 4 fails with “not configured for OAuth”, double-check that the <code class="language-plaintext highlighter-rouge">[mcp_servers.auth]</code> block is directly under the <code class="language-plaintext highlighter-rouge">[[mcp_servers]]</code> entry for Upwork, not at the root level or under a different server. As noted in the <a href="https://docs.mistral.ai/vibe/code/cli/mcp-servers#browse">official docs</a>, the <code class="language-plaintext highlighter-rouge">/mcp</code> command provides the interactive browser interface for managing MCP server connections.</p>

<h2 id="why-this-matters-for-agency-workflows">Why this matters for agency workflows</h2>

<p>For my SEO strategy work, Upwork MCP access means:</p>

<ul>
  <li><strong>Freelancer discovery</strong> — programmatic access to find specialists for specific tasks (technical SEO audits, content writing, link building)</li>
  <li><strong>Job automation</strong> — posting, updating, and managing job listings without leaving the CLI</li>
  <li><strong>Profile integration</strong> — boosting visibility and managing proposals at scale</li>
  <li><strong>Data-driven hiring</strong> — pulling freelancer metrics and past work to inform hiring decisions</li>
</ul>

<p>Before this working OAuth setup, the only path was manual API key management or using the Upwork web interface. Now it’s a native part of the Vibe workflow: ask for a task, Vibe can query Upwork for the right freelancer, post the job, and manage the process end-to-end.</p>

<h2 id="bottom-line">Bottom line</h2>

<p>The Mistral docs are wrong on this one. OAuth for MCP servers isn’t a limitation — it’s fully implemented and working. The Upwork MCP server connects with three lines of config and Dynamic Client Registration. The confusion came from stale documentation and a config detail that’s easy to overlook.</p>

<p>If you’ve been holding off on MCP server integrations because of the “OAuth not supported” note, give it another try. The code has moved on.</p>

<hr />

<p><em>Questions about your own Vibe or MCP setup? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a>. The <code class="language-plaintext highlighter-rouge">.vibe/config.toml</code> used as the basis for this post is <a href="https://github.com/wpvillain/seo-strategy/blob/main/.vibe/config.toml">available here</a>.</em></p>]]></content><author><name></name></author><category term="vibe" /><category term="mcp" /><category term="upwork" /><category term="oauth" /><category term="ai" /><category term="vibe" /><category term="mcp" /><category term="upwork" /><category term="oauth" /><category term="authentication" /><category term="ai" /><category term="cli" /><category term="mistral" /><summary type="html"><![CDATA[If you’ve tried to connect Mistral Vibe to the Upwork MCP server and hit a wall, you’ve probably read the official documentation on MCP servers and come away confused. It states:]]></summary></entry><entry><title type="html">The Administration Email Field Doesn’t Change the Administration Email</title><link href="https://wpvilla.in/wordpress-admin-email-password-wp-cli/" rel="alternate" type="text/html" title="The Administration Email Field Doesn’t Change the Administration Email" /><published>2026-09-03T03:00:00+00:00</published><updated>2026-09-03T03:00:00+00:00</updated><id>https://wpvilla.in/wordpress-admin-email-password-wp-cli</id><content type="html" xml:base="https://wpvilla.in/wordpress-admin-email-password-wp-cli/"><![CDATA[<p>I opened Settings → General on a client site this week and found this sitting above the email field:</p>

<blockquote>
  <p><strong>There is a pending change of the admin email to <code class="language-plaintext highlighter-rouge">hallo@example.com</code>. Cancel</strong></p>
</blockquote>

<p>The change had been made 16 days earlier. My own notes recorded it as <em>done</em>. It wasn’t done — it had been sitting in limbo the entire time, and the site had been mailing its update notices and fatal-error alerts to the old address the whole while, silently.</p>

<p>The cause isn’t a bug. It’s that the input box labelled <strong>Administration Email Address</strong> does not write to the <code class="language-plaintext highlighter-rouge">admin_email</code> option, and never has. This post is the mental model I keep re-deriving, plus the WP-CLI commands for admin email and passwords that I keep half-remembering and looking up again.</p>

<h2 id="the-one-line-of-core-that-explains-it">The one line of core that explains it</h2>

<p>Here’s the field, from <code class="language-plaintext highlighter-rouge">wp-admin/options-general.php</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;th</span> <span class="na">scope=</span><span class="s">"row"</span><span class="nt">&gt;&lt;label</span> <span class="na">for=</span><span class="s">"new_admin_email"</span><span class="nt">&gt;</span>Administration Email Address<span class="nt">&lt;/label&gt;&lt;/th&gt;</span>
<span class="nt">&lt;td&gt;&lt;input</span> <span class="na">name=</span><span class="s">"new_admin_email"</span> <span class="na">type=</span><span class="s">"email"</span> <span class="na">id=</span><span class="s">"new_admin_email"</span>
     <span class="na">value=</span><span class="s">"</span><span class="cp">&lt;?php</span> <span class="nf">form_option</span><span class="p">(</span> <span class="s1">'admin_email'</span> <span class="p">);</span> <span class="cp">?&gt;</span><span class="s">"</span> <span class="na">class=</span><span class="s">"regular-text ltr"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<p>Look at the mismatch. The input is <strong>named</strong> <code class="language-plaintext highlighter-rouge">new_admin_email</code>. Its <strong>value</strong> is printed from <code class="language-plaintext highlighter-rouge">admin_email</code>. So the box shows you the live address, and saves to a completely different option.</p>

<p>Nothing about the UI signals that those are two different places. You type a new address into a field pre-filled with the current one, hit Save, and the page reloads showing… the old address, plus a notice. Most people read that notice once, assume it’ll sort itself out, and move on. It doesn’t sort itself out.</p>

<h2 id="what-actually-happens-on-save">What actually happens on save</h2>

<p>Saving <code class="language-plaintext highlighter-rouge">new_admin_email</code> fires an action hooked in <code class="language-plaintext highlighter-rouge">wp-admin/includes/admin-filters.php</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">add_action</span><span class="p">(</span> <span class="s1">'add_option_new_admin_email'</span><span class="p">,</span>    <span class="s1">'update_option_new_admin_email'</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">2</span> <span class="p">);</span>
<span class="nf">add_action</span><span class="p">(</span> <span class="s1">'update_option_new_admin_email'</span><span class="p">,</span> <span class="s1">'update_option_new_admin_email'</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">2</span> <span class="p">);</span>
</code></pre></div></div>

<p>And that handler (<code class="language-plaintext highlighter-rouge">wp-admin/includes/misc.php</code>) does this:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">update_option_new_admin_email</span><span class="p">(</span> <span class="nv">$old_value</span><span class="p">,</span> <span class="nv">$value</span> <span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span> <span class="nf">get_option</span><span class="p">(</span> <span class="s1">'admin_email'</span> <span class="p">)</span> <span class="o">===</span> <span class="nv">$value</span> <span class="o">||</span> <span class="o">!</span> <span class="nf">is_email</span><span class="p">(</span> <span class="nv">$value</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="nv">$hash</span>            <span class="o">=</span> <span class="nb">md5</span><span class="p">(</span> <span class="nv">$value</span> <span class="mf">.</span> <span class="nb">time</span><span class="p">()</span> <span class="mf">.</span> <span class="nf">wp_rand</span><span class="p">()</span> <span class="p">);</span>
    <span class="nv">$new_admin_email</span> <span class="o">=</span> <span class="k">array</span><span class="p">(</span>
        <span class="s1">'hash'</span>     <span class="o">=&gt;</span> <span class="nv">$hash</span><span class="p">,</span>
        <span class="s1">'newemail'</span> <span class="o">=&gt;</span> <span class="nv">$value</span><span class="p">,</span>
    <span class="p">);</span>
    <span class="nf">update_option</span><span class="p">(</span> <span class="s1">'adminhash'</span><span class="p">,</span> <span class="nv">$new_admin_email</span><span class="p">,</span> <span class="kc">false</span> <span class="p">);</span>
    <span class="c1">// ... then emails a confirmation link to the NEW address</span>
</code></pre></div></div>

<p>So a “change” leaves you with <strong>three</strong> options in the database, not one:</p>

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>Holds</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">admin_email</code></td>
      <td>the live address</td>
      <td>what WordPress actually mails</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">new_admin_email</code></td>
      <td>the requested address</td>
      <td>pending, not yet active</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">adminhash</code></td>
      <td><code class="language-plaintext highlighter-rouge">array( hash, newemail )</code></td>
      <td>the token in the confirmation link</td>
    </tr>
  </tbody>
</table>

<p>The swap into <code class="language-plaintext highlighter-rouge">admin_email</code> only happens when somebody opens the confirmation email <strong>sent to the new address</strong> and clicks the link. If that inbox doesn’t exist yet, or belongs to a client who files it under “probably spam,” or the site can’t send mail reliably in the first place — the change never lands, and the only trace is a notice on a settings page nobody visits.</p>

<p>That last one is worth dwelling on. The confirmation goes to the new address, so a site with broken outbound mail can <em>never</em> complete an admin email change through the UI. And a broken-mail site is exactly the site whose admin address you’re most likely trying to fix.</p>

<h2 id="inspecting-the-real-state">Inspecting the real state</h2>

<p>Never trust the settings screen. Ask the database:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp option get admin_email
wp option get new_admin_email   <span class="c"># errors if not set — that's the healthy case</span>
wp option get adminhash
</code></pre></div></div>

<p>On the site above, that gave me:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>admin_email:     jasper@example.com
new_admin_email: hallo@example.com
adminhash:       array ( 'hash' =&gt; 'a2bd4e6c…', 'newemail' =&gt; 'hallo@example.com' )
</code></pre></div></div>

<p>Sixteen days of “done.” A one-liner for a fleet:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>site <span class="k">in </span>site-a site-b site-c<span class="p">;</span> <span class="k">do
  </span><span class="nb">printf</span> <span class="s1">'%-12s live=%-28s pending=%s\n'</span> <span class="s2">"</span><span class="nv">$site</span><span class="s2">"</span> <span class="se">\</span>
    <span class="s2">"</span><span class="si">$(</span>wp <span class="nt">--url</span><span class="o">=</span><span class="s2">"</span><span class="nv">$site</span><span class="s2">"</span> option get admin_email<span class="si">)</span><span class="s2">"</span> <span class="se">\</span>
    <span class="s2">"</span><span class="si">$(</span>wp <span class="nt">--url</span><span class="o">=</span><span class="s2">"</span><span class="nv">$site</span><span class="s2">"</span> option get new_admin_email 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'-'</span><span class="si">)</span><span class="s2">"</span>
<span class="k">done</span>
</code></pre></div></div>

<h2 id="cancelling-a-pending-change">Cancelling a pending change</h2>

<p>The <strong>Cancel</strong> link in the admin does exactly two deletes. From <code class="language-plaintext highlighter-rouge">wp-admin/options.php</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">}</span> <span class="k">elseif</span> <span class="p">(</span> <span class="o">!</span> <span class="k">empty</span><span class="p">(</span> <span class="nv">$_GET</span><span class="p">[</span><span class="s1">'dismiss'</span><span class="p">]</span> <span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="s1">'new_admin_email'</span> <span class="o">===</span> <span class="nv">$_GET</span><span class="p">[</span><span class="s1">'dismiss'</span><span class="p">]</span> <span class="p">)</span> <span class="p">{</span>
    <span class="nf">check_admin_referer</span><span class="p">(</span> <span class="s1">'dismiss-'</span> <span class="mf">.</span> <span class="nf">get_current_blog_id</span><span class="p">()</span> <span class="mf">.</span> <span class="s1">'-new_admin_email'</span> <span class="p">);</span>
    <span class="nf">delete_option</span><span class="p">(</span> <span class="s1">'adminhash'</span> <span class="p">);</span>
    <span class="nf">delete_option</span><span class="p">(</span> <span class="s1">'new_admin_email'</span> <span class="p">);</span>
</code></pre></div></div>

<p>So the CLI equivalent is:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp option delete new_admin_email
wp option delete adminhash
</code></pre></div></div>

<p><strong>Delete both.</strong> Dropping only <code class="language-plaintext highlighter-rouge">new_admin_email</code> leaves <code class="language-plaintext highlighter-rouge">adminhash</code> behind, and the confirmation link in that already-sent email stays live — so months later someone can dig it out of an archive folder, click it, and flip your production admin address. Deleting <code class="language-plaintext highlighter-rouge">adminhash</code> is what actually revokes the token.</p>

<h3 id="if-youre-already-looking-at-the-notice-just-click-cancel">If you’re already looking at the notice, just click Cancel</h3>

<p>Worth saying plainly, because it’s what I <em>didn’t</em> do: that link is one click, and it does both deletes for you, behind a nonce check. I was staring straight at it and reached for WP-CLI out of habit — two commands to accomplish what the UI had sitting right there under my cursor.</p>

<p>The CLI version earns its keep in the cases where the link isn’t an option:</p>

<ul>
  <li><strong>You can’t log in.</strong> A locked-out admin, or a fresh site handover where nobody has credentials yet.</li>
  <li><strong>The site can’t send mail.</strong> As above, the confirmation goes to the new address, so a broken-mail site can never complete the change through the UI at all. CLI is the only way through.</li>
  <li><strong>You’re auditing rather than fixing.</strong> The notice tells you an address is pending; <code class="language-plaintext highlighter-rouge">wp option get</code> tells you all three values, which is what you actually need to reason about the state.</li>
  <li><strong>More than one site.</strong> Nobody clicks through twelve dashboards.</li>
  <li><strong>It’s in a script.</strong> Provisioning, a migration, a handover checklist.</li>
</ul>

<p>Away from those, the button is the better tool. Knowing the option names is still what lets you <em>verify</em> the button did what you think — which is the real reason to know them.</p>

<h2 id="setting-it-for-real">Setting it for real</h2>

<p>If you want the change to take effect <em>now</em>, skip the request/confirm dance and write the live option directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp option update admin_email hallo@example.com
</code></pre></div></div>

<p>No confirmation, no token, no waiting. I verified this on a sandbox — the value changes immediately and no <code class="language-plaintext highlighter-rouge">adminhash</code> is created:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ wp option update admin_email direct-test@example.com
Success: Updated 'admin_email' option.
$ wp option get adminhash
Error: Could not get 'adminhash' option. Does it exist?
$ wp option get admin_email
direct-test@example.com
</code></pre></div></div>

<p>One side effect to know about: <code class="language-plaintext highlighter-rouge">update_option_admin_email</code> is hooked to <code class="language-plaintext highlighter-rouge">wp_site_admin_email_change_notification</code>, which sends a courtesy heads-up to the <strong>old</strong> address. That’s usually what you want. Suppress it with the <code class="language-plaintext highlighter-rouge">send_site_admin_email_change_email</code> filter if you’re scripting a bulk change.</p>

<h3 id="forcing-it-does-not-cancel-a-pending-change">Forcing it does not cancel a pending change</h3>

<p>This is the part that will bite you, and it’s the reason the two commands above belong together rather than being alternatives.</p>

<p>Writing <code class="language-plaintext highlighter-rouge">admin_email</code> directly changes the live address. It does <strong>not</strong> touch <code class="language-plaintext highlighter-rouge">new_admin_email</code> or <code class="language-plaintext highlighter-rouge">adminhash</code>. If a pending change was already sitting there, it survives untouched:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ wp option update new_admin_email pending@example.com   # pending change exists
$ wp option update admin_email forced@example.com        # force the live value

$ wp option get admin_email
forced@example.com          &lt;- changed
$ wp option get new_admin_email
pending@example.com         &lt;- survived
$ wp option get adminhash
array ( 'hash' =&gt; 'd8f404f5…', 'newemail' =&gt; 'pending@example.com' )   &lt;- survived
</code></pre></div></div>

<p>Now look at what redeems that token, in <code class="language-plaintext highlighter-rouge">wp-admin/options.php</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span> <span class="o">!</span> <span class="k">empty</span><span class="p">(</span> <span class="nv">$_GET</span><span class="p">[</span><span class="s1">'adminhash'</span><span class="p">]</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span>
    <span class="nv">$new_admin_details</span> <span class="o">=</span> <span class="nf">get_option</span><span class="p">(</span> <span class="s1">'adminhash'</span> <span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span> <span class="nb">is_array</span><span class="p">(</span> <span class="nv">$new_admin_details</span> <span class="p">)</span>
        <span class="o">&amp;&amp;</span> <span class="nb">hash_equals</span><span class="p">(</span> <span class="nv">$new_admin_details</span><span class="p">[</span><span class="s1">'hash'</span><span class="p">],</span> <span class="nv">$_GET</span><span class="p">[</span><span class="s1">'adminhash'</span><span class="p">]</span> <span class="p">)</span>
        <span class="o">&amp;&amp;</span> <span class="o">!</span> <span class="k">empty</span><span class="p">(</span> <span class="nv">$new_admin_details</span><span class="p">[</span><span class="s1">'newemail'</span><span class="p">]</span> <span class="p">)</span>
    <span class="p">)</span> <span class="p">{</span>
        <span class="nf">update_option</span><span class="p">(</span> <span class="s1">'admin_email'</span><span class="p">,</span> <span class="nv">$new_admin_details</span><span class="p">[</span><span class="s1">'newemail'</span><span class="p">]</span> <span class="p">);</span>
</code></pre></div></div>

<p><strong>There is no expiry check.</strong> No timestamp, no comparison against the current <code class="language-plaintext highlighter-rouge">admin_email</code>, no nonce. The only conditions are that <code class="language-plaintext highlighter-rouge">adminhash</code> exists and the hash matches. So the confirmation link stays redeemable indefinitely — and when a logged-in administrator eventually digs that mail out of an archive folder and clicks it out of curiosity, WordPress overwrites your forced address with the old pending one and reports success.</p>

<p>There’s a nastier variant. The admin notice only renders when the two differ:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span> <span class="nv">$new_admin_email</span> <span class="o">&amp;&amp;</span> <span class="nf">get_option</span><span class="p">(</span> <span class="s1">'admin_email'</span> <span class="p">)</span> <span class="o">!==</span> <span class="nv">$new_admin_email</span> <span class="p">)</span> <span class="p">{</span>
</code></pre></div></div>

<p>So if you force <code class="language-plaintext highlighter-rouge">admin_email</code> to the <em>same</em> address that’s pending, the warning disappears from Settings → General while the stale <code class="language-plaintext highlighter-rouge">adminhash</code> is still sitting in the database, still armed. The UI now looks completely clean and tells you nothing.</p>

<p>The fix is to treat “set the admin email” as three commands, not one:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp option update admin_email hallo@example.com
wp option delete new_admin_email
wp option delete adminhash
</code></pre></div></div>

<p>Set the live value, then revoke the token. Deleting <code class="language-plaintext highlighter-rouge">adminhash</code> is the part that actually disarms it.</p>

<p>And per the section above — if a pending change is already showing in the dashboard, <strong>click Cancel first</strong>. That clears both options in one click, and what’s left for the CLI is a single command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp option update admin_email hallo@example.com
</code></pre></div></div>

<p>Order matters slightly, and not in the direction you’d guess. Cancel <em>then</em> set is clean. Set <em>then</em> cancel also works, but if you force <code class="language-plaintext highlighter-rouge">admin_email</code> to the same address that’s pending, the notice vanishes on reload — so the Cancel link you were about to click disappears from the screen while the armed token is still in the database. Clear the pending change while you can still see it.</p>

<h3 id="the-assumption-i-had-wrong">The assumption I had wrong</h3>

<p>I went into this believing the confirmation flow was an admin-UI thing — that WP-CLI wrote options “raw” and bypassed it. That’s wrong, and I only found out by testing it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ wp option update new_admin_email trap-test@example.com
Success: Updated 'new_admin_email' option.
$ wp option get adminhash
array (
  'hash' =&gt; '464d11e35cea8eee7894c23d5922b7fb',
  'newemail' =&gt; 'trap-test@example.com',
)
</code></pre></div></div>

<p>WP-CLI loads the admin includes, so those hooks fire perfectly well from the command line. <strong>The dividing line is the option name, not the interface.</strong> <code class="language-plaintext highlighter-rouge">wp option update new_admin_email</code> queues a pending change and mails a confirmation link, exactly like the settings form. Only <code class="language-plaintext highlighter-rouge">admin_email</code> is immediate.</p>

<p>Which means it’s entirely possible to create this same stuck state from a deploy script, and never see the notice that would have told you.</p>

<h2 id="multisite">Multisite</h2>

<p>Network settings are a separate option in a separate table:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp site option get admin_email          <span class="c"># network admin email</span>
wp option get admin_email <span class="nt">--url</span><span class="o">=</span>sub.example.com   <span class="c"># per-site</span>
</code></pre></div></div>

<p>Network-level changes route through <code class="language-plaintext highlighter-rouge">update_network_option_new_admin_email</code> (<code class="language-plaintext highlighter-rouge">wp-admin/includes/ms-admin-filters.php</code>) with the same request/confirm shape. Changing a site’s admin email does not touch the network’s, and vice versa.</p>

<h2 id="the-sibling-trap-user-profile-emails">The sibling trap: user profile emails</h2>

<p>The exact same pattern guards a user changing <strong>their own</strong> email, which is where the second round of confusion usually comes from. It’s stored in user meta rather than options:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user meta get 5 _new_email
<span class="c"># array( 'hash' =&gt; '…', 'newemail' =&gt; 'new@example.com' )</span>

wp user meta delete 5 _new_email    <span class="c"># = the "Cancel" link on profile.php</span>
</code></pre></div></div>

<p>But there’s a catch worth internalizing, from <code class="language-plaintext highlighter-rouge">wp-admin/user-edit.php</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span> <span class="no">IS_PROFILE_PAGE</span> <span class="o">&amp;&amp;</span> <span class="k">isset</span><span class="p">(</span> <span class="nv">$_GET</span><span class="p">[</span><span class="s1">'newuseremail'</span><span class="p">]</span> <span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nv">$current_user</span><span class="o">-&gt;</span><span class="no">ID</span> <span class="p">)</span> <span class="p">{</span>
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">IS_PROFILE_PAGE</code> guard means the confirmation dance <strong>only applies to users editing themselves</strong>. An administrator editing somebody else’s account changes that email instantly, no confirmation. And from the CLI:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user update 5 <span class="nt">--user_email</span><span class="o">=</span>new@example.com   <span class="c"># immediate, always</span>
</code></pre></div></div>

<p>So “changing a user’s email needs confirmation” is only true in one specific case. Everywhere else it’s instant.</p>

<h2 id="passwords-while-were-here">Passwords, while we’re here</h2>

<p>The other thing I look up every time.</p>

<p><strong>Set a known password:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user update admin <span class="nt">--user_pass</span><span class="o">=</span><span class="s1">'correct-horse-battery-staple'</span>
</code></pre></div></div>

<p>That lands in your shell history in plain text. Use the <code class="language-plaintext highlighter-rouge">--prompt</code> global parameter to type it interactively instead:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user update admin <span class="nt">--prompt</span><span class="o">=</span>user_pass
</code></pre></div></div>

<p><strong>Generate a strong one instead of inventing it:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user reset-password admin <span class="nt">--show-password</span>
<span class="c"># or, to capture it in a script:</span>
<span class="nv">NEW_PASS</span><span class="o">=</span><span class="si">$(</span>wp user reset-password admin <span class="nt">--porcelain</span> <span class="nt">--skip-email</span><span class="si">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">reset-password</code> emails the user by default, which is usually right for a real person and wrong for a service account — hence <code class="language-plaintext highlighter-rouge">--skip-email</code>. <code class="language-plaintext highlighter-rouge">--porcelain</code> prints only the password, nothing else.</p>

<p><strong>Create a user with a password up front:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user create deploybot bot@example.com <span class="se">\</span>
  <span class="nt">--role</span><span class="o">=</span>administrator <span class="nt">--user_pass</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 24<span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p><strong>Verify a password without changing it</strong> — genuinely useful when a client insists their login is broken and you want to know whether it’s the password or something else entirely:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user check-password admin <span class="s1">'the-password-they-swear-works'</span>
</code></pre></div></div>

<p>Exit code 0 means valid, 1 means not. Nothing is modified.</p>

<p><strong>Kick everyone out after a password change.</strong> Changing the password does <em>not</em> invalidate existing sessions, which surprises people during an incident response:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user session destroy admin <span class="nt">--all</span>
</code></pre></div></div>

<p><strong>Application passwords</strong> for API and integration access, so you never hand out the real one:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp user application-password create admin <span class="s1">'deploy-pipeline'</span> <span class="nt">--porcelain</span>
wp user application-password list admin
wp user application-password delete admin &lt;uuid&gt;
</code></pre></div></div>

<h2 id="the-takeaway">The takeaway</h2>

<p>Two WordPress settings look like plain text fields and are actually two-phase state machines: the site’s administration email, and a user’s own email address. Both leave a pending record that is invisible unless you go looking, and both can sit unresolved indefinitely while everything <em>appears</em> fine.</p>

<p>So when you change either one, verify the live value afterward rather than trusting the success notice:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp option get admin_email <span class="o">&amp;&amp;</span> wp option get new_admin_email
</code></pre></div></div>

<p>If the second command errors, you’re clean. If it returns an address, you haven’t changed anything yet — you’ve only asked.</p>]]></content><author><name></name></author><category term="wordpress" /><category term="wp-cli" /><category term="devops" /><category term="wordpress" /><category term="wp-cli" /><category term="cli" /><category term="admin" /><category term="email" /><category term="passwords" /><category term="security" /><category term="devops" /><summary type="html"><![CDATA[I opened Settings → General on a client site this week and found this sitting above the email field:]]></summary></entry><entry><title type="html">The Seven Commands I Actually Run Every Day (And Why I Stopped Memorizing Them)</title><link href="https://wpvilla.in/wp-ops-daily-weekly-commands/" rel="alternate" type="text/html" title="The Seven Commands I Actually Run Every Day (And Why I Stopped Memorizing Them)" /><published>2026-08-29T03:00:00+00:00</published><updated>2026-08-29T03:00:00+00:00</updated><id>https://wpvilla.in/wp-ops-daily-weekly-commands</id><content type="html" xml:base="https://wpvilla.in/wp-ops-daily-weekly-commands/"><![CDATA[<p>For a long time, my daily WordPress ops routine lived in shell history. Pull a database, sync some uploads, eyeball a security log, glance at a client site’s traffic — each one a slightly-different <code class="language-plaintext highlighter-rouge">ssh</code> one-liner or <code class="language-plaintext highlighter-rouge">ansible-playbook</code> invocation I’d half-remember and then grep my own history for. Multiply that by a dozen client sites on Trellis, each with its own site key, and “half-remember” starts costing real minutes every single day.</p>

<p>The fix wasn’t a better memory. It was collapsing everything into one catalog: <a href="https://github.com/imagewize/wp-ops">wp-ops</a>, a single CLI that wraps the Ansible playbooks, WP-CLI scripts, and shell utilities I’d built up over time into one binary with real <code class="language-plaintext highlighter-rouge">--help</code>, search, and shell completions. Here’s the actual loop I run against it — daily and weekly — and what each command is doing underneath.</p>

<h2 id="why-one-binary-instead-of-a-folder-of-scripts">Why one binary instead of a folder of scripts</h2>

<p>Before <code class="language-plaintext highlighter-rouge">wp-ops</code> existed, this was a directory of ~74 things: Ansible playbooks under <code class="language-plaintext highlighter-rouge">trellis/</code>, standalone Bash under <code class="language-plaintext highlighter-rouge">scripts/</code>, WP-CLI PHP under <code class="language-plaintext highlighter-rouge">wp-cli/</code>. All useful, all discoverable only if you already knew the filename. The CLI auto-discovers all of it from manifest comments at the top of each script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># @desc     Back up a site's database from any environment</span>
<span class="c"># @category backup</span>
<span class="c"># @platform trellis</span>
<span class="c"># @requires ansible-playbook</span>
<span class="c"># @example  wp-ops database-backup imagewize.com production</span>
</code></pre></div></div>

<p>That manifest is the whole trick — <code class="language-plaintext highlighter-rouge">wp-ops --help</code>, <code class="language-plaintext highlighter-rouge">wp-ops search</code>, and shell completions are all generated from it, so there’s no second list to keep in sync. Run <code class="language-plaintext highlighter-rouge">wp-ops doctor</code> once after installing and it tells you which of the underlying tools (WP-CLI, Ansible, <code class="language-plaintext highlighter-rouge">gh</code>, ImageMagick…) are actually on your machine before a command fails halfway through.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>imagewize/tap/wp-ops
wp-ops init      <span class="c"># shell completions</span>
wp-ops doctor    <span class="c"># check dependencies</span>
</code></pre></div></div>

<p>With that in place, here’s the loop. The examples below use our own site keys — <code class="language-plaintext highlighter-rouge">imagewize.com</code> for the main site, <code class="language-plaintext highlighter-rouge">demo.imagewize.com</code> for our local pattern-showcase install — swap in whatever key matches your project’s entry in <code class="language-plaintext highlighter-rouge">wordpress_sites.yml</code>.</p>

<h2 id="1-database-backup">1. Database backup</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops database-backup imagewize.com production
</code></pre></div></div>

<p>Runs <code class="language-plaintext highlighter-rouge">trellis/backup/database-backup.yml</code> against the target environment: <code class="language-plaintext highlighter-rouge">wp db export</code> on the remote, gzipped, pulled down as a timestamped <code class="language-plaintext highlighter-rouge">imagewize_com_production_2026_08_29_10_00_00.sql.gz</code>. I run this before anything that touches production data, not just on a schedule.</p>

<h2 id="2-database-pull-with-url-search-replace-already-done">2. Database pull, with URL search-replace already done</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops database-pull imagewize.com production
</code></pre></div></div>

<p>This is the one that looks simple and isn’t. <code class="language-plaintext highlighter-rouge">database-pull.yml</code> doesn’t just fetch a dump — it backs up your <em>current local</em> database first, into a <code class="language-plaintext highlighter-rouge">database_backup/</code> folder inside the project, before it imports anything. That’s easy to miss because it’s buried in the middle of the playbook, but it’s the difference between “oops, wrong environment” being an inconvenience versus a lost afternoon.</p>

<p>After the import it runs the URL swap itself:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">url_from</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">wordpress_sites[site].site_hosts.0.canonical</span><span class="nv"> </span><span class="s">}}"</span>
<span class="na">url_to</span><span class="pi">:</span>   <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">dev_wordpress_sites.wordpress_sites[site].site_hosts.0.canonical</span><span class="nv"> </span><span class="s">}}"</span>
</code></pre></div></div>

<p>resolving both sides from <code class="language-plaintext highlighter-rouge">group_vars</code> — production’s canonical hostname and your local <code class="language-plaintext highlighter-rouge">local_path</code> config — so <code class="language-plaintext highlighter-rouge">wp search-replace</code> runs with the right values without you typing either URL. I wrote up the playbook’s internals (and two bugs I had to patch in the stock version) in <a href="/trellis-database-pull-playbook-explained/">an earlier post</a> if you want the full mechanics.</p>

<h2 id="3-files-pull-via-rsync">3. Files pull via rsync</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops files-pull imagewize.com production
</code></pre></div></div>

<p>Syncs <code class="language-plaintext highlighter-rouge">shared/uploads/</code> from the remote into <code class="language-plaintext highlighter-rouge">web/app/uploads/</code> locally. It’s additive by default — local-only files (test images, things since deleted from the remote media library) survive the sync. Add <code class="language-plaintext highlighter-rouge">--delete yes</code> only when you actually want local to mirror remote exactly; that flag is destructive on the local side, so I don’t reach for it without a reason.</p>

<h2 id="4-dropping-a-pattern-onto-a-page-to-test-it">4. Dropping a pattern onto a page to test it</h2>

<p>This one isn’t a single wp-ops command yet — it’s a <code class="language-plaintext highlighter-rouge">wp post update</code> run against the Trellis VM, and it’s the step I reach for most when a new block pattern needs a real page to render on before it ships. I do this against <code class="language-plaintext highlighter-rouge">demo.imagewize.com</code>, our local showcase site, rather than risk it on a client project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>trellis vm shell <span class="nt">--workdir</span> /srv/www/demo.imagewize.com/current <span class="nt">--</span> bash <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">VMEOF</span><span class="sh">'
cat &gt; /tmp/page-content.html &lt;&lt; 'EOF'
&lt;!-- wp:pattern {"slug":"nynaeve/pattern-slug"} /--&gt;
EOF
CONTENT=</span><span class="si">$(</span><span class="nb">cat</span> /tmp/page-content.html<span class="si">)</span><span class="sh">
wp post update PAGE_ID --post_content="</span><span class="nv">$CONTENT</span><span class="sh">" --url=https://demo.imagewize.test/ --path=web/wp
</span><span class="no">VMEOF
</span></code></pre></div></div>

<p>The trap here isn’t the command, it’s what happens after: any pattern using <code class="language-plaintext highlighter-rouge">get_template_directory_uri()</code> bakes an <em>environment-specific</em> URL straight into <code class="language-plaintext highlighter-rouge">post_content</code> — <code class="language-plaintext highlighter-rouge">http://demo.imagewize.test/app/themes/nynaeve/patterns/images/foo.webp</code>. That’s fine on local. It is not fine if that content ever reaches production without a search-replace pass first, since it’ll silently serve broken images or trip mixed-content warnings. I run a URL audit before anything with test patterns in it goes near a production database — worth building into your own checklist if you do content work this way.</p>

<h2 id="5-security-checkup">5. Security checkup</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops security-scan imagewize.com production
wp-ops security-scan imagewize.com production <span class="nt">--hours</span> 48 <span class="nt">--threshold</span> 50
</code></pre></div></div>

<p>Scans Nginx access logs on the target for attack signatures — repeated <code class="language-plaintext highlighter-rouge">wp-login.php</code> hits, SQL-injection strings, scanner user-agents — over a configurable window, flagging any IP over the request threshold. For file-level malware checks instead of log analysis, that’s a separate WP-CLI pass: <code class="language-plaintext highlighter-rouge">wp eval-file wp-cli/security/scanner-wrapper.php</code> run on the server, which is closer to a monthly job than a daily one.</p>

<h2 id="6-monitor-checkup">6. Monitor checkup</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops quick-status imagewize.com production
</code></pre></div></div>

<p>for the fast version — recent status codes, error counts, service status, good for “is this site okay right now.” The Nginx and PHP-FPM status checks used to fail outright with <code class="language-plaintext highlighter-rouge">Missing sudo password</code>: the playbook had <code class="language-plaintext highlighter-rouge">become: yes</code> on those two tasks, but <code class="language-plaintext highlighter-rouge">systemctl status</code> is a read-only query that doesn’t need root, and there’s no password source when the command runs non-interactively. Dropped <code class="language-plaintext highlighter-rouge">become</code> from both tasks so they run as the regular deploy user, matching the other read-only monitoring playbooks in the repo.</p>

<p>Fixing that surfaced a second, quieter bug in the same task: <code class="language-plaintext highlighter-rouge">systemctl status php*-fpm</code> was silently returning nothing. Systemd’s unit-name globbing needs the full suffix, so <code class="language-plaintext highlighter-rouge">php*-fpm</code> never matched <code class="language-plaintext highlighter-rouge">php8.4-fpm.service</code> even though the unit was loaded and running — and it exited <code class="language-plaintext highlighter-rouge">rc=0</code> with empty output instead of erroring, so it went unnoticed right alongside the sudo fix. Quoting the glob and adding <code class="language-plaintext highlighter-rouge">.service</code> (<code class="language-plaintext highlighter-rouge">'php*-fpm.service'</code>) fixed it for good.</p>

<p>For traffic alone — no security or AI-crawler noise mixed in — there’s a narrower command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops traffic-report imagewize.com production <span class="nt">--hours</span> 120
</code></pre></div></div>

<p>That’s the last five days, and on a real run against imagewize.com it surfaces exactly what you’d hope: the homepage and <code class="language-plaintext highlighter-rouge">/contact/</code> up top (436 and 187 hits), then whichever blog post is actually pulling organic traffic that week — <a href="https://imagewize.com/how-to-audit-your-woocommerce-store/"><code class="language-plaintext highlighter-rouge">/how-to-audit-your-woocommerce-store/</code></a> at 131 hits over that window, well ahead of the <a href="https://imagewize.com/wp-ops-wordpress-devops-cli-mcp-server/">wp-ops post</a> itself at 43. It’s the same Nginx log parsing as the combined <code class="language-plaintext highlighter-rouge">monitor.sh</code> run below, minus the security and error-log sections — the one I reach for when I just want a pulse check on what’s resonating that week, not a full report.</p>

<p>One gotcha: <code class="language-plaintext highlighter-rouge">traffic-report</code> runs through <code class="language-plaintext highlighter-rouge">ansible-playbook</code>, so it needs <code class="language-plaintext highlighter-rouge">TRELLIS_DIR</code> pointed at your Trellis project (<code class="language-plaintext highlighter-rouge">export TRELLIS_DIR=/path/to/trellis</code>), or to be run from inside it — unlike <code class="language-plaintext highlighter-rouge">quick-status</code> and the other read-only checks above, which resolve that on their own.</p>

<p>One more thing worth knowing before you trust that “real user” number: if you routinely hit the site over a VPN while testing, that traffic counts as human too — the UA-based bot filter has no way to catch it. Pulling a six-week traffic trend on imagewize.com surfaced this directly: the same three IPs (<code class="language-plaintext highlighter-rouge">146.70.14.43</code>–<code class="language-plaintext highlighter-rouge">45</code>, a ProtonVPN Indonesia exit-node range I test through) kept showing up in “Top IP Addresses,” inflating some weekly windows by up to 8%. <code class="language-plaintext highlighter-rouge">traffic-monitor.sh</code> 5.14.0 adds an <code class="language-plaintext highlighter-rouge">EXCLUDE_IP_PATTERN</code> for exactly this — set it once to your own VPN or dev-IP range and every section of the report (totals, top pages, top IPs, the SEO breakdowns) excludes it automatically, no extra filtering pass needed.</p>

<p>When I want the fuller picture — traffic, security, AI-crawler activity, and error logs together, saved as a timestamped report — that’s:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh web@imagewize.com <span class="s1">'bash -s'</span> &lt; scripts/monitoring/monitor.sh
</code></pre></div></div>

<p>streamed over SSH stdin rather than installed on the server, so there’s nothing left behind to clean up afterward.</p>

<h2 id="7-github-traffic-across-the-main-repos">7. GitHub traffic across the main repos</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops gh-traffic imagewize/wp-ops imagewize/nynaeve imagewize/aludra imagewize/elayne imagewize/aviendha imagewize/ixian imagewize/ollama-opencode-setup imagewize/sage-native-block <span class="nt">--all</span>
</code></pre></div></div>

<p>Pulls views, clones, and referrers for each repo via GitHub’s traffic API — <code class="language-plaintext highlighter-rouge">--all</code> gets you every section, <code class="language-plaintext highlighter-rouge">--quiet</code> drops table headers if you’re piping the output somewhere. GitHub only retains 14 days of this data, so I run it weekly rather than let a slow week’s numbers age out unseen. <code class="language-plaintext highlighter-rouge">imagewize.com</code> itself is private, so it’s left out here — the traffic API is really only worth checking on the public repos anyway.</p>

<p>That’s the per-repo detail; when I just want to know which of those eight is actually getting traction that fortnight, <code class="language-plaintext highlighter-rouge">--summary</code> collapses it to one table instead:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops gh-traffic imagewize/wp-ops imagewize/nynaeve imagewize/aludra imagewize/elayne imagewize/aviendha imagewize/ixian imagewize/ollama-opencode-setup imagewize/sage-native-block <span class="nt">--summary</span> <span class="nt">--all</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Summary (14-day totals, sorted by unique views)
+---------------------------------+-------+--------+--------+--------+
| Repo                            | Views | Unique | Clones | Unique |
+---------------------------------+-------+--------+--------+--------+
| imagewize/ollama-opencode-setup |   173 |     96 |     12 |     12 |
| imagewize/wp-ops                |   176 |      8 |    215 |     96 |
| imagewize/nynaeve               |   278 |      7 |    108 |     50 |
| imagewize/sage-native-block     |    12 |      3 |      6 |      6 |
| imagewize/aviendha              |    62 |      3 |     83 |     41 |
| imagewize/aludra                |   131 |      3 |    329 |    113 |
| imagewize/elayne                |    56 |      2 |    153 |     55 |
| imagewize/ixian                 |    98 |      1 |    121 |     29 |
+---------------------------------+-------+--------+--------+--------+
</code></pre></div></div>

<p>To rank by clone activity instead of views — useful when you care more about adoption than browsing — add <code class="language-plaintext highlighter-rouge">--sort-by unique-clones</code> (or <code class="language-plaintext highlighter-rouge">unique-views</code>, the default):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp-ops gh-traffic imagewize/wp-ops imagewize/nynaeve imagewize/aludra imagewize/elayne imagewize/aviendha imagewize/ixian imagewize/ollama-opencode-setup imagewize/sage-native-block imagewize/pt-cli imagewize/warder-cookie-consent imagewize/waygate imagewize/wp-cli-pattern-validate imagewize/trellis-wp-monitoring <span class="nt">--summary</span> <span class="nt">--all</span> <span class="nt">--sort-by</span> unique-clones
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Summary (14-day totals, sorted by unique clones)
+-----------------------------------+-------+--------+--------+--------+
| Repo                              | Views | Unique | Clones | Unique |
+-----------------------------------+-------+--------+--------+--------+
| imagewize/wp-ops                  |   230 |      8 |    287 |    101 |
| imagewize/elayne                  |   122 |      2 |    198 |     60 |
| imagewize/aviendha                |    69 |      3 |    135 |     52 |
| imagewize/warder-cookie-consent   |    38 |      1 |    104 |     51 |
| imagewize/aludra                  |    29 |      2 |    115 |     47 |
| imagewize/nynaeve                 |    88 |      5 |    117 |     45 |
| imagewize/ixian                   |    32 |      1 |     56 |     23 |
| imagewize/pt-cli                  |     3 |      1 |     18 |     14 |
| imagewize/waygate                 |     4 |      3 |     13 |     12 |
| imagewize/sage-native-block       |     5 |      2 |      9 |      8 |
| imagewize/trellis-wp-monitoring   |    13 |      3 |      9 |      7 |
| imagewize/ollama-opencode-setup   |   154 |     96 |      6 |      6 |
| imagewize/wp-cli-pattern-validate |     2 |      1 |      4 |      4 |
+-----------------------------------+-------+--------+--------+--------+
</code></pre></div></div>

<p>Sorted by unique views rather than raw views, which is the more honest ranking — <code class="language-plaintext highlighter-rouge">ollama-opencode-setup</code> had fewer total hits than <code class="language-plaintext highlighter-rouge">nynaeve</code> but far more distinct people looking, versus <code class="language-plaintext highlighter-rouge">wp-ops</code> itself pulling 176 views from what’s mostly the same handful of visitors returning. Clones tell a different story again: <code class="language-plaintext highlighter-rouge">aludra</code> and <code class="language-plaintext highlighter-rouge">wp-ops</code> both see heavy clone volume relative to their view counts, which reads as CI or dependency installs pulling the repo rather than people browsing it on GitHub.</p>

<h2 id="the-point-of-collapsing-this-into-a-cli">The point of collapsing this into a CLI</h2>

<p>None of these seven things were hard to run individually — every one of them already existed as a playbook or script before <code class="language-plaintext highlighter-rouge">wp-ops</code> did. What was hard was <em>remembering which one and with what flags</em>, across a dozen site keys, without pasting a domain into the wrong environment. Manifest-driven discovery means the catalog documents itself: <code class="language-plaintext highlighter-rouge">wp-ops search backup</code>, <code class="language-plaintext highlighter-rouge">wp-ops &lt;command&gt; --help</code>, and the interactive picker all read from the same source the commands themselves declare, so the list can’t drift out of sync with what’s actually runnable.</p>

<p>This is part of the tooling we run at <a href="https://imagewize.com">Imagewize</a> for client WordPress operations on Trellis. If your team is drowning in the same kind of scattered-script sprawl, <a href="https://imagewize.com/contact-us/">get in touch</a>.</p>

<hr />

<p><em>Questions about your own Trellis workflow? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a>.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="trellis" /><category term="wp-cli" /><category term="devops" /><category term="wordpress" /><category term="trellis" /><category term="wp-cli" /><category term="ansible" /><category term="cli" /><category term="monitoring" /><category term="security" /><category term="github" /><category term="bedrock" /><summary type="html"><![CDATA[For a long time, my daily WordPress ops routine lived in shell history. Pull a database, sync some uploads, eyeball a security log, glance at a client site’s traffic — each one a slightly-different ssh one-liner or ansible-playbook invocation I’d half-remember and then grep my own history for. Multiply that by a dozen client sites on Trellis, each with its own site key, and “half-remember” starts costing real minutes every single day.]]></summary></entry><entry><title type="html">Splitting a Sage Theme From Its Trellis Deploy: Nynaeve as a Composer Package</title><link href="https://wpvilla.in/sage-theme-composer-package-trellis-deploy/" rel="alternate" type="text/html" title="Splitting a Sage Theme From Its Trellis Deploy: Nynaeve as a Composer Package" /><published>2026-08-24T11:00:00+00:00</published><updated>2026-08-24T11:00:00+00:00</updated><id>https://wpvilla.in/sage-theme-composer-package-trellis-deploy</id><content type="html" xml:base="https://wpvilla.in/sage-theme-composer-package-trellis-deploy/"><![CDATA[<p>Our Sage 11 theme, Nynaeve, used to live two places at once: as its own standalone repo, and as a plain git-tracked copy pasted into the main site repo’s <code class="language-plaintext highlighter-rouge">web/app/themes/nynaeve</code>. A manual <code class="language-plaintext highlighter-rouge">rsync-theme.sh</code> script kept the copy in sync. That’s exactly the kind of setup that looks fine until the two copies quietly disagree — which is what happened. The theme repo’s docs described block <em>categories</em> (<code class="language-plaintext highlighter-rouge">nynaeve/hero</code>, <code class="language-plaintext highlighter-rouge">nynaeve/cta</code>, …) and block <em>namespaces</em> (<code class="language-plaintext highlighter-rouge">imagewize/hero</code>, <code class="language-plaintext highlighter-rouge">imagewize/cta</code>, …) correctly, but the copy that shipped to the site repo drifted, and content across the site ended up with broken <code class="language-plaintext highlighter-rouge">wp:nynaeve/*</code> markup where it should have been <code class="language-plaintext highlighter-rouge">wp:imagewize/*</code>. Found and fixed the same day we started this change.</p>

<p>That was the last straw. This post covers <a href="https://github.com/imagewize/imagewize.com/pull/397">PR #397</a>: moving Nynaeve from a git-tracked directory to a real Composer dependency — the same pattern the project already used for <code class="language-plaintext highlighter-rouge">imagewize/min</code> and <code class="language-plaintext highlighter-rouge">imagewize/callandor</code> — and the deploy-hook rework that made it work with Trellis.</p>

<h2 id="the-old-setup">The Old Setup</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">web/app/themes/nynaeve</code> was committed straight into the site repo: 411 files, full history, PHP source, compiled assets, fonts, icons — everything.</li>
  <li>The theme’s <em>actual</em> source of truth was a separate repo, <code class="language-plaintext highlighter-rouge">~/code/nynaeve</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">rsync-theme.sh</code> pushed changes from the site repo copy back to the standalone repo (or the reverse, depending on which way you’d last edited). Either direction relied on a human running it at the right time.</li>
  <li>Two copies, one intended source of truth, no enforcement. The block-namespace drift was the first visible symptom; it wouldn’t have been the last.</li>
</ul>

<h2 id="what-changed">What Changed</h2>

<p><code class="language-plaintext highlighter-rouge">site/composer.json</code> now requires the theme directly:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"require"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"imagewize/nynaeve"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^3.1.0"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Nynaeve’s own <code class="language-plaintext highlighter-rouge">composer.json</code> already declares <code class="language-plaintext highlighter-rouge">"type": "wordpress-theme"</code>, so Bedrock’s existing <code class="language-plaintext highlighter-rouge">installer-paths</code> mapping drops it into <code class="language-plaintext highlighter-rouge">web/app/themes/nynaeve/</code> automatically — no extra config needed on the consuming side. <code class="language-plaintext highlighter-rouge">site/.gitignore</code> gets <code class="language-plaintext highlighter-rouge">web/app/themes/nynaeve</code> added, matching the other Composer-managed themes already in the project (<code class="language-plaintext highlighter-rouge">twentytwentyfive</code>, <code class="language-plaintext highlighter-rouge">ianua</code>). The 411 tracked files come out of git in the same commit.</p>

<p>That’s the easy part. The part that took real thought was the deploy hooks.</p>

<h2 id="why-the-deploy-hook-had-to-split-in-two">Why the Deploy Hook Had to Split in Two</h2>

<p>Trellis builds a release in stages, and two of our theme-build steps depend on the theme’s <em>PHP source</em> being present on the <strong>remote</strong> release path — not just locally:</p>

<ol>
  <li>The theme’s own <code class="language-plaintext highlighter-rouge">composer install</code> (its acorn / illuminate / acf-composer dependencies — Sage themes carry their own theme-scoped <code class="language-plaintext highlighter-rouge">vendor/</code>, separate from the project root’s).</li>
  <li>Copying the compiled <code class="language-plaintext highlighter-rouge">public/build</code> assets up to the release.</li>
</ol>

<p>Before this change, Nynaeve’s source was git-tracked, so <code class="language-plaintext highlighter-rouge">git archive</code> — which is what actually populates a Trellis release directory — brought the theme’s PHP along for free. Once Nynaeve stopped being git-tracked, <code class="language-plaintext highlighter-rouge">git archive</code> had nothing to bring. The theme only exists on the release path <em>after</em> something runs <code class="language-plaintext highlighter-rouge">composer install</code> against the release’s <code class="language-plaintext highlighter-rouge">composer.json</code>.</p>

<p>That something is Trellis’s own core hook. <code class="language-plaintext highlighter-rouge">deploy_build_after</code> (<code class="language-plaintext highlighter-rouge">roles/deploy/defaults/main.yml</code>) runs the core <code class="language-plaintext highlighter-rouge">roles/deploy/hooks/build-after.yml</code> — which does the root-level <code class="language-plaintext highlighter-rouge">composer install</code> that fetches <code class="language-plaintext highlighter-rouge">imagewize/nynaeve</code> onto the release — <strong>before</strong> it runs our site’s own <code class="language-plaintext highlighter-rouge">build-after.yml</code> override. That ordering is the whole trick: our custom build-after hook can safely assume the theme source already exists on the release path, because Trellis’s own hook just put it there.</p>

<p>Which means the theme’s own <code class="language-plaintext highlighter-rouge">composer install</code> and the asset copy can’t live in <code class="language-plaintext highlighter-rouge">build-before.yml</code> — at build-before time, on a fresh deploy, the theme hasn’t been fetched onto the release yet. They have to move to <code class="language-plaintext highlighter-rouge">build-after.yml</code>, downstream of the core hook.</p>

<h3 id="build-beforeyml--local-concerns-only">build-before.yml — local concerns only</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install root Composer dependencies (pulls nynaeve theme package)</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">composer install</span>
  <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
  <span class="na">args</span><span class="pi">:</span>
    <span class="na">chdir</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">project_local_path</span><span class="nv"> </span><span class="s">}}"</span>
  <span class="na">when</span><span class="pi">:</span> <span class="s">site == 'imagewize.com'</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install npm dependencies</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">npm install</span>
  <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
  <span class="na">args</span><span class="pi">:</span>
    <span class="na">chdir</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">project_local_path</span><span class="nv"> </span><span class="s">}}/web/app/themes/nynaeve"</span>
  <span class="na">when</span><span class="pi">:</span> <span class="s">site == 'imagewize.com'</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Compile assets</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">npm run build</span>
  <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
  <span class="na">args</span><span class="pi">:</span>
    <span class="na">chdir</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">project_local_path</span><span class="nv"> </span><span class="s">}}/web/app/themes/nynaeve"</span>
  <span class="na">when</span><span class="pi">:</span> <span class="s">site == 'imagewize.com'</span>
</code></pre></div></div>

<p>Everything here runs on the machine doing the deploy, not the remote release. The local <code class="language-plaintext highlighter-rouge">composer install</code> pulls <code class="language-plaintext highlighter-rouge">imagewize/nynaeve</code> down locally too (so <code class="language-plaintext highlighter-rouge">npm install</code>/<code class="language-plaintext highlighter-rouge">npm run build</code> have theme source to compile against), and a <code class="language-plaintext highlighter-rouge">stat</code> + <code class="language-plaintext highlighter-rouge">fail</code> guard right after confirms <code class="language-plaintext highlighter-rouge">public/build/manifest.json</code> exists before the deploy is allowed to continue — no silently shipping a release with no compiled assets.</p>

<h3 id="build-afteryml--the-two-steps-that-need-the-remote-release">build-after.yml — the two steps that need the remote release</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install theme's own Composer dependencies</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">composer install --no-ansi --no-dev --no-interaction --no-progress --optimize-autoloader --no-scripts --classmap-authoritative</span>
  <span class="na">args</span><span class="pi">:</span>
    <span class="na">chdir</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">deploy_helper.new_release_path</span><span class="nv"> </span><span class="s">}}/web/app/themes/nynaeve"</span>
  <span class="na">when</span><span class="pi">:</span> <span class="s">site == 'imagewize.com'</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Copy compiled assets</span>
  <span class="na">synchronize</span><span class="pi">:</span>
    <span class="na">src</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">project_local_path</span><span class="nv"> </span><span class="s">}}/web/app/themes/nynaeve/public"</span>
    <span class="na">dest</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">deploy_helper.new_release_path</span><span class="nv"> </span><span class="s">}}/web/app/themes/nynaeve"</span>
    <span class="na">group</span><span class="pi">:</span> <span class="s">no</span>
    <span class="na">owner</span><span class="pi">:</span> <span class="s">no</span>
    <span class="na">rsync_opts</span><span class="pi">:</span> <span class="s">--chmod=Du=rwx,--chmod=Dg=rx,--chmod=Do=rx,--chmod=Fu=rw,--chmod=Fg=r,--chmod=Fo=r</span>
  <span class="na">when</span><span class="pi">:</span> <span class="s">site == 'imagewize.com'</span>
</code></pre></div></div>

<p>The compiled assets themselves are still built locally in build-before (rebuilding on the server would mean shipping npm/node as a production dependency, which we don’t want), then rsynced up from the local <code class="language-plaintext highlighter-rouge">public/build</code> directory onto the just-fetched theme on the release path. Both steps stay gated <code class="language-plaintext highlighter-rouge">when: site == 'imagewize.com'</code> — block themes like Elayne on <code class="language-plaintext highlighter-rouge">demo.imagewize.com</code> skip this whole file, same as before.</p>

<h2 id="switching-to-packagist">Switching to Packagist</h2>

<p>The PR started with a <code class="language-plaintext highlighter-rouge">vcs</code> repository entry pointing straight at <code class="language-plaintext highlighter-rouge">git@github.com:imagewize/nynaeve.git</code> on a <code class="language-plaintext highlighter-rouge">dev-main</code> pin, purely to prove the mechanism worked end to end. Once that was confirmed, <code class="language-plaintext highlighter-rouge">imagewize/nynaeve</code> got published to Packagist — same as Elayne, Aviendha, and Aludra — and the <code class="language-plaintext highlighter-rouge">vcs</code> entry came out, replaced with a version constraint:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"imagewize/nynaeve"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^3.1.0"</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">composer.lock</code> now records a genuine dist install: a GitHub zipball pulled through Packagist, not a live git clone. No <code class="language-plaintext highlighter-rouge">.git/</code> directory shows up in the installed copy, which is the tell that confirms it. On-disk <code class="language-plaintext highlighter-rouge">style.css</code> reports <code class="language-plaintext highlighter-rouge">Version: 3.1.0</code>, matching the lockfile.</p>

<h2 id="one-idea-that-didnt-survive-contact-with-lima">One Idea That Didn’t Survive Contact With Lima</h2>

<p>Before settling on the Composer-dependency approach, there was a tempting shortcut: add a <code class="language-plaintext highlighter-rouge">path</code> repository (<code class="language-plaintext highlighter-rouge">../../nynaeve</code>, <code class="language-plaintext highlighter-rouge">symlink: true</code>) ahead of the Packagist one, so Composer would symlink straight to <code class="language-plaintext highlighter-rouge">~/code/nynaeve</code> locally instead of downloading a dist copy. That would kill the rsync loop entirely and give live HMR against uncommitted theme edits — no publish step needed just to see a change.</p>

<p>It works fine on the raw macOS filesystem. It does not work inside the Lima VM that actually serves <code class="language-plaintext highlighter-rouge">imagewize.test</code>. Lima only mounts specific subdirectories of <code class="language-plaintext highlighter-rouge">~/code/imagewize.com</code> — confirmed with <code class="language-plaintext highlighter-rouge">limactl show-ssh imagewize.com</code> — and <code class="language-plaintext highlighter-rouge">~/code/nynaeve</code> isn’t one of them. From inside the VM, the symlink points at nothing. Reverted that part. A Lima mount for <code class="language-plaintext highlighter-rouge">~/code/nynaeve</code> is a plausible follow-up if the sync-script loop below turns out to be annoying in daily use, but it wasn’t worth blocking this PR on.</p>

<h2 id="local-dev-workflow-now">Local Dev Workflow Now</h2>

<p>Editing happens in <code class="language-plaintext highlighter-rouge">~/code/nynaeve</code>, same as before. Testing a change locally without cutting a full release uses <code class="language-plaintext highlighter-rouge">wp-ops</code>’s sync script, matching the pattern already in use for Elayne, Aviendha, and Aludra:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">SITE_ROOT</span><span class="o">=</span>~/code/imagewize.com/site/web/app <span class="se">\</span>
  wp-ops/scripts/sync/rsync-package-to-site.sh theme nynaeve ~/code/nynaeve
</code></pre></div></div>

<p>To publish for real: tag and push the theme repo, then pull it into the site with <code class="language-plaintext highlighter-rouge">composer update imagewize/nynaeve</code>. The old <code class="language-plaintext highlighter-rouge">rsync-theme.sh</code> — the one that synced imagewize.com’s copy <em>back</em> to the mirror — is obsolete and gone.</p>

<h2 id="a-tradeoff-were-leaving-as-is">A Tradeoff We’re Leaving As-Is</h2>

<p>Nynaeve’s own <code class="language-plaintext highlighter-rouge">composer.json</code> <code class="language-plaintext highlighter-rouge">require</code> (acorn, <code class="language-plaintext highlighter-rouge">illuminate/*</code>, acf-composer, etc.) now also gets resolved into the site’s <strong>root</strong> <code class="language-plaintext highlighter-rouge">vendor/</code> when Composer solves the whole project, on top of the theme’s own theme-scoped <code class="language-plaintext highlighter-rouge">vendor/</code> that <code class="language-plaintext highlighter-rouge">functions.php</code> actually loads via <code class="language-plaintext highlighter-rouge">__DIR__.'/vendor/autoload.php'</code>. The root copy is never used — it’s dead weight from Composer’s dependency solver, not a functional problem. This is a pre-existing property of the Sage/Acorn “theme carries its own vendor” convention, not something this change introduced, and not worth fighting.</p>

<h2 id="verifying-it">Verifying It</h2>

<p>Before the Packagist switch, we ran the full local equivalent of the new build-before → build-after sequence by hand against the Trellis VM:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>composer update imagewize/nynaeve <span class="nt">--with-dependencies</span>   <span class="c"># in site/ — pulled from GitHub, dev-main</span>
npm <span class="nb">install</span> <span class="o">&amp;&amp;</span> npm run build                              <span class="c"># in the theme dir — built clean</span>
composer <span class="nb">install</span> ...                                       <span class="c"># in the theme dir — own vendor/autoload.php</span>
wp cache flush <span class="o">&amp;&amp;</span> wp theme status nynaeve                  <span class="c"># → Status: Active, Version: 3.1.0</span>
curl http://imagewize.test/                                 <span class="c"># → 200, correct &lt;title&gt;</span>
</code></pre></div></div>

<p>After switching to Packagist, the re-check was static rather than a full VM run — same installed theme, just a different resolution path: <code class="language-plaintext highlighter-rouge">composer validate</code> passes, <code class="language-plaintext highlighter-rouge">composer.lock</code>/<code class="language-plaintext highlighter-rouge">installed.json</code> agree on <code class="language-plaintext highlighter-rouge">v3.1.0</code> resolved via Packagist’s GitHub zipball dist, no <code class="language-plaintext highlighter-rouge">.git/</code> present (confirming dist, not VCS), and <code class="language-plaintext highlighter-rouge">style.css</code> still reports <code class="language-plaintext highlighter-rouge">3.1.0</code>.</p>

<p>An actual <code class="language-plaintext highlighter-rouge">trellis deploy</code> to production was intentionally left for after merge — the plan was to review the build-before/build-after split first, then deploy and watch the theme’s Composer-install step and the asset-copy step specifically, since those are the two that moved.</p>

<h2 id="takeaway">Takeaway</h2>

<p>The mechanism worth remembering here isn’t Composer-specific — it’s the <strong>hook ordering</strong>. Trellis’s core <code class="language-plaintext highlighter-rouge">build-after.yml</code> hook runs before a site’s own override of the same file, and that ordering is what makes it possible to fetch a package onto the release <em>and</em> immediately depend on it being there in the very next hook, without any extra “wait for it” logic. Once that ordering clicked, the fix was mechanical: move the two steps that need the theme’s PHP present on the remote release from build-before to build-after, and let Trellis’s own hook do the fetching in between.</p>

<p>The bigger win is structural, not mechanical: one source of truth for the theme, published like any other dependency, with drift no longer possible by construction instead of caught after the fact — the way the block-namespace bug was.</p>

<p>This is part of the Trellis/Bedrock/Sage deployment stack we run at <a href="https://imagewize.com">Imagewize</a> for client projects. If you’re maintaining a Sage or Acorn theme alongside a Trellis deploy and want a second set of eyes on the setup, <a href="https://imagewize.com/contact-us/">get in touch</a>.</p>

<hr />

<p><em>Running a similar Composer-managed theme setup and hit a snag? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a>.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="trellis" /><category term="composer" /><category term="sage" /><category term="bedrock" /><category term="wordpress" /><category term="trellis" /><category term="composer" /><category term="sage" /><category term="bedrock" /><category term="deployment" /><category term="ansible" /><category term="roots" /><category term="acorn" /><summary type="html"><![CDATA[Our Sage 11 theme, Nynaeve, used to live two places at once: as its own standalone repo, and as a plain git-tracked copy pasted into the main site repo’s web/app/themes/nynaeve. A manual rsync-theme.sh script kept the copy in sync. That’s exactly the kind of setup that looks fine until the two copies quietly disagree — which is what happened. The theme repo’s docs described block categories (nynaeve/hero, nynaeve/cta, …) and block namespaces (imagewize/hero, imagewize/cta, …) correctly, but the copy that shipped to the site repo drifted, and content across the site ended up with broken wp:nynaeve/* markup where it should have been wp:imagewize/*. Found and fixed the same day we started this change.]]></summary></entry></feed>