@refactco/refact-os 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ Notable changes to the refact-os standard and scaffolder.
4
4
 
5
5
  **Staying current:** a consumer repo updates with `npm run refact:update` (or `npx @refactco/refact-os init`). That refreshes the package-managed `agent/` payload, **prunes** skills this package has removed or renamed (safe — only skills the package previously shipped, never your own), regenerates `.cursor/` + `.claude/`, and flags `agent/AGENTS.md` drift for a manual merge. The version it last applied is recorded in `.refact-os.json` under `_scaffold.version`, and `init` prints the `old → new` transition so you can scan the entries below for anything needing manual action. Run `npm run refact:validate` afterward.
6
6
 
7
+ ## 1.6.0
8
+
9
+ ### Added
10
+ - **`wp-env pull wp-config`** — new sub-command reads the staging `wp-config.php` via SSH and classifies each `define()` into the right local destination: scalar non-secrets → `.wp-env.json` `config`, secrets → `.wp-env.override.json` (gitignored), PHP arrays → a guarded local mu-plugin. Restart + verify included.
11
+ - **Host-aware rsync excludes for `pull mu-plugins`.** The `pull mu-plugins` flow now reads `stack.wordpress.hosting` from `.refact-os.json` and automatically excludes host-injected system mu-plugins (Kinsta: `kinsta-mu-plugins/`; WP Engine: `wpengine-common/`, `mu-plugin.php`, etc.) so they don't end up in git.
12
+ - **SSH access migration to `.refact-os.json`.** `init` now migrates SSH connection details from legacy `agent/AGENTS.md` blocks into the structured `stack.wordpress.environments.<env>.ssh` config, making `.refact-os.json` the single source of truth for all deploy/pull flows.
13
+
14
+ ### Changed
15
+ - **Split `.gitignore` strategy for `apps/wordpress/`.** The wp-env skill now maintains a separate `apps/wordpress/.gitignore` for wp-content paths (local-only mu-plugins, override files) instead of appending to the root `.gitignore`.
16
+ - **Large DB fallback.** `pull db` now uses a two-step dump-to-file approach (SSH export → `docker cp` → `wp db import`) instead of piping directly, preventing stalls on databases larger than ~500 MB.
17
+ - **Release flow is now local-only.** Removed dependency on CI `NPM_TOKEN`; publishing uses the maintainer's interactive `npm login` session with 2FA OTP (see `agent/skills/release/SKILL.md`).
18
+
7
19
  ## 1.5.2
8
20
 
9
21
  ### Added
package/bin/refact-os.js CHANGED
@@ -72,6 +72,11 @@ async function runScaffold(args, { force }) {
72
72
  if (result.missingFields && result.missingFields.length > 0) {
73
73
  console.log(`Missing metadata (re-asked on next /refact run): ${result.missingFields.join(", ")}`);
74
74
  }
75
+ if (result.agentsMigration && result.agentsMigration.sshLifted) {
76
+ const { env, fields } = result.agentsMigration.sshLifted;
77
+ console.log(`Lifted SSH routing from agent/AGENTS.md → .refact-os.json (stack.wordpress.environments.${env}: ${fields.join(", ")}).`);
78
+ console.log(" You can now delete the '## SSH access' section from agent/AGENTS.md — .refact-os.json is the single source of truth.");
79
+ }
75
80
  if (result.contractChanged) {
76
81
  console.log("");
77
82
  console.log("⚠ agent/AGENTS.md template changed upstream. Your copy was preserved (skipIfExists).");
@@ -140,6 +140,63 @@ function _readIfExists(p) {
140
140
  }
141
141
  }
142
142
 
143
+ // Parse a legacy "## SSH access (staging)" block out of an AGENTS.md. Older
144
+ // scaffolds (pre-2026-05) wrote the staging SSH routing as a Markdown table:
145
+ //
146
+ // ## SSH access (staging)
147
+ // | Field | Value |
148
+ // |---|---|
149
+ // | Host | `stlmagstg.ssh.wpengine.net` |
150
+ // | User | `stlmagstg` |
151
+ // | Port | `22` |
152
+ // | Document root | `sites/stlmagstg/` |
153
+ // | Staging URL | `https://stlmagstg.wpenginepowered.com` |
154
+ //
155
+ // `.refact-os.json` is now the single source of truth (see the wp-env skill).
156
+ // This parser is the one-way migration lift: it pulls a filled block out of
157
+ // AGENTS.md so a consumer that hasn't touched the schema gets its `staging.ssh`
158
+ // populated automatically on the next `init`. Returns null if the block is
159
+ // absent, any required row is missing, or any value is still a `<placeholder>`.
160
+ function _parseAgentsSshBlock(agentsContent) {
161
+ if (typeof agentsContent !== "string" || agentsContent.length === 0) return null;
162
+ const headingMatch = agentsContent.match(/^##\s+SSH\s+access[^\n]*$/im);
163
+ if (!headingMatch) return null;
164
+ const startIdx = headingMatch.index + headingMatch[0].length;
165
+ const remainder = agentsContent.slice(startIdx);
166
+ const nextHeading = remainder.match(/^##\s+/m);
167
+ const block = nextHeading ? remainder.slice(0, nextHeading.index) : remainder;
168
+
169
+ const rowRe = /^\|\s*([^|]+?)\s*\|\s*`?([^`|\n]+?)`?\s*\|\s*$/gm;
170
+ const rows = {};
171
+ let m;
172
+ while ((m = rowRe.exec(block)) !== null) {
173
+ const label = m[1].toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
174
+ const value = m[2].trim();
175
+ if (!value) continue;
176
+ if (/^<.*>$/.test(value)) continue; // skip unfilled placeholders
177
+ if (/^[-:|\s]+$/.test(value)) continue; // skip the header separator row
178
+ rows[label] = value;
179
+ }
180
+
181
+ const host = rows.host;
182
+ const user = rows.user;
183
+ const portRaw = rows.port;
184
+ const docRoot = rows["document root"] || rows["doc root"];
185
+ const stagingUrl = rows["staging url"] || rows["url"];
186
+ if (!host || !user || !portRaw || !docRoot) return null;
187
+ const port = Number(portRaw);
188
+ if (!Number.isFinite(port) || port <= 0) return null;
189
+
190
+ const result = {
191
+ user,
192
+ host,
193
+ port,
194
+ path: docRoot.replace(/\/+$/, ""), // strip trailing slash to match new schema
195
+ };
196
+ if (stagingUrl) result.url = stagingUrl;
197
+ return result;
198
+ }
199
+
143
200
  function _stripScaffoldMetadataSection(agentsContent) {
144
201
  const lines = agentsContent.split("\n");
145
202
  const startIdx = lines.findIndex((line) => /^##\s+Scaffold Metadata\s*$/.test(line));
@@ -271,15 +328,60 @@ function migrateFromAgents(targetDir, config) {
271
328
  }
272
329
  }
273
330
 
331
+ // Lift a filled "## SSH access (staging)" block from AGENTS.md into
332
+ // .refact-os.json › stack.wordpress.environments.<staging-like>.ssh. One-way
333
+ // and idempotent: if the JSON already has the ssh routing for the staging-like
334
+ // env, do nothing. (The new wp-env skill reads SSH only from .refact-os.json.)
335
+ let sshLifted = null;
336
+ const wpEntry = config.stack && config.stack.wordpress;
337
+ if (wpEntry && proseSource) {
338
+ if (!wpEntry.environments || typeof wpEntry.environments !== "object" || Array.isArray(wpEntry.environments)) {
339
+ wpEntry.environments = {};
340
+ }
341
+ // Prefer an existing staging-like env to avoid creating a duplicate next to
342
+ // a project's `stage` (or similar) key. Fall back to creating `staging` only
343
+ // if nothing staging-shaped exists yet.
344
+ let stagingKey = null;
345
+ for (const candidate of ["staging", "stage"]) {
346
+ if (wpEntry.environments[candidate]) {
347
+ stagingKey = candidate;
348
+ break;
349
+ }
350
+ }
351
+ const existingEnv = stagingKey ? wpEntry.environments[stagingKey] : null;
352
+ const sshAbsent =
353
+ !existingEnv ||
354
+ !existingEnv.ssh ||
355
+ typeof existingEnv.ssh !== "object" ||
356
+ !existingEnv.ssh.host ||
357
+ !existingEnv.ssh.user ||
358
+ !existingEnv.ssh.port ||
359
+ !existingEnv.ssh.path;
360
+ if (sshAbsent) {
361
+ const parsed = _parseAgentsSshBlock(proseSource);
362
+ if (parsed) {
363
+ const targetKey = stagingKey || "staging";
364
+ const env =
365
+ wpEntry.environments[targetKey] ||
366
+ (wpEntry.environments[targetKey] = { branch: null, url: null });
367
+ env.ssh = { user: parsed.user, host: parsed.host, port: parsed.port, path: parsed.path };
368
+ if (parsed.url && !env.url) env.url = parsed.url;
369
+ sshLifted = { env: targetKey, fields: ["user", "host", "port", "path", ...(parsed.url && !existingEnv?.url ? ["url"] : [])] };
370
+ migrated = true;
371
+ }
372
+ }
373
+ }
374
+
274
375
  // Strip the legacy "## Scaffold Metadata" section from the root AGENTS.md.
376
+ let stripped = false;
275
377
  if (rootText != null) {
276
- const stripped = _stripScaffoldMetadataSection(rootText);
277
- if (stripped !== rootText) {
278
- fs.writeFileSync(rootPath, stripped, "utf8");
279
- return { migrated, stripped: true };
378
+ const strippedText = _stripScaffoldMetadataSection(rootText);
379
+ if (strippedText !== rootText) {
380
+ fs.writeFileSync(rootPath, strippedText, "utf8");
381
+ stripped = true;
280
382
  }
281
383
  }
282
- return { migrated, stripped: false };
384
+ return { migrated, stripped, sshLifted };
283
385
  }
284
386
 
285
387
  function setAsanaProjectId(config, raw) {
@@ -320,5 +422,5 @@ module.exports = {
320
422
  ensureStack,
321
423
  setAsanaProjectId,
322
424
  askAsanaProjectId,
323
- _internal: { _get, _set, _has, _parseProjectTypeFromAgents, _parseAgentsField, _stripScaffoldMetadataSection },
425
+ _internal: { _get, _set, _has, _parseProjectTypeFromAgents, _parseAgentsField, _parseAgentsSshBlock, _stripScaffoldMetadataSection },
324
426
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@refactco/refact-os",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Installable scaffolder for the agent-first repo standard: minimum-seed substrate + canonical agent/ skill catalog with generated tool adapters",
5
5
  "keywords": [
6
6
  "agent",
@@ -7,10 +7,24 @@ A Refact engagement scaffolded by [`refact-os`](https://github.com/refactco/refa
7
7
  ## Quickstart
8
8
 
9
9
  ```bash
10
- npm install
11
- cp .env.example .env # fill in per-user tokens
10
+ npm install # installs the @refactco/refact-os devDependency + others
11
+ cp .env.example .env # fill in per-user tokens
12
12
  ```
13
13
 
14
+ ## WordPress quickstart
15
+
16
+ If this is a WordPress engagement (`.refact-os.json` › `stack.wordpress` present), open the repo in Cursor / Claude Code and run, in order:
17
+
18
+ ```
19
+ /refact init # idempotent checklist: stack, hosting, per-env (url/branch/install/ssh), .env, git
20
+ /refact wp-env setup # one-shot: containers up + plugins/mu-plugins/DB from staging + local domain
21
+ /refact setup wpengine deploy # or: /refact setup kinsta deploy — generates .github/workflows/wordpress-deploy-*.yml
22
+ ```
23
+
24
+ Each command is **idempotent**: re-running on a fully-configured project is a no-op, so a teammate joining later just runs the same sequence and gets prompted for nothing the owner already filled in. Stack/hosting/SSH routing lives in `.refact-os.json`; per-user secrets (tokens, etc.) stay in `.env`. SSH **private keys** for auto-deploy live in GitHub Actions secrets, never in the repo.
25
+
26
+ Delete this section if this isn't a WordPress project.
27
+
14
28
  ## Where things live
15
29
 
16
30
  | Path | What's in it |
@@ -35,6 +35,7 @@ Produce a **plan** for bringing an existing repo to the agent-first standard. Th
35
35
  - Duplicate canonical map (`INDEX.md` vs `docs/index.md`) → which is canonical; merge the other.
36
36
  - Duplicate contract (root `AGENTS.md` vs `agent/AGENTS.md`) → consolidate into `agent/AGENTS.md`, leave a thin root pointer.
37
37
  - Variant dirs (`docs/tasks` vs `docs/task`) → consolidate onto the standard name.
38
+ - **Existing root `.gitignore` (when a codebase is moving into `apps/<slot>/`)** → propose moving the existing file wholesale to `apps/<slot>/.gitignore` (merge if one already exists there), and refresh the root `.gitignore` from the refact-os shipped template. The existing rules were written for code at the repo root; once the code lives at `apps/<slot>/`, the rules belong with the code. The refreshed root `.gitignore` is project-level only (refact-os hygiene: `.env`, OS junk, the generated `.claude/settings.local.json`, etc.). The user can pluck specific rules back to root afterward if they realize one is project-scoped (e.g. an org-wide editor pattern). Surface this as a single phase, not three — the move is one paired operation.
38
39
  - Forbidden `agent/workflows/` / `agent/evals/` → fold each into `agent/skills/<verb-object>/SKILL.md` (orchestrator/review pattern); propose names.
39
40
  - **Folders to delete** where a folder is genuinely unneeded (e.g. an empty `docs/deliverables/` on a repo that ships nothing) — list them explicitly so the user can approve.
40
41
  - Content not in a clear role → propose a role + destination.
@@ -57,7 +57,8 @@ Run each check in order. After handling any unmet item, re-check it before movin
57
57
  |---|---|---|
58
58
  | `url` | `"https://www.example.com/"` | Full URL including `https://`. |
59
59
  | `branch` | `"main"`, `"stage"`, `"develop"` | Git branch that auto-deploys to this environment. |
60
- | `ssh` | `{ "user": "example", "host": "1.2.3.4", "port": 12345, "path": "/www/<env>/public" }` | **SSH-push hosts only** (Kinsta, WP Engine). Omit entirely for git-integration hosts (Vercel, Netlify). Never store the private **key** here — that's a CI secret. |
60
+ | `install` | `"stlmagdev"` | **WP Engine only.** The WP Engine install name used in the deploy URL `git@git.wpengine.com:<install>.git`. Required when `hosting === "wpengine"`; omit otherwise. |
61
+ | `ssh` | `{ "user": "example", "host": "1.2.3.4", "port": 12345, "path": "/www/<env>/public" }` | **SSH hosts only** (Kinsta, WP Engine). Used by the `wp-env` skill to pull plugins/mu-plugins/DB from staging. Omit entirely for git-integration hosts (Vercel, Netlify). Never store the private **key** here — that's a CI secret. |
61
62
 
62
63
  **Handle (if unmet):** Ask the user, one focused question at a time, for each type's `hosting` + `runtime` and at least its `production` (usually also `staging`) environment. Accept "skip" — leave the field `null` (or omit `ssh`) so it stays visibly outstanding without blocking init. Write directly into `.refact-os.json` and show the proposed change first. Collect **non-secret routing only** — SSH keys, tokens, and passwords belong in `.env` or the CI secret store, never here.
63
64
 
@@ -93,13 +94,18 @@ Run each check in order. After handling any unmet item, re-check it before movin
93
94
 
94
95
  **Handle (if unmet):** Ask the user whether they want to install the curated WP skills now — it's optional and they may prefer to defer. If yes, delegate to `agent/skills/install-wp-skills/SKILL.md`. If no, mark the checkbox as user-deferred and move on; do not re-prompt on the next init run unless the user re-asks.
95
96
 
96
- ### 7. Kinsta auto-deploy workflows (WordPress + Kinsta only)
97
+ ### 7. Auto-deploy workflows (WordPress + SSH host only)
97
98
 
98
- **Skip this check** if `.refact-os.json` › `stack` has no `wordpress` entry, **or** if `stack.wordpress.hosting` is not `kinsta`.
99
+ **Skip this check** if `.refact-os.json` › `stack` has no `wordpress` entry, **or** if `stack.wordpress.hosting` is neither `kinsta` nor `wpengine`.
99
100
 
100
- **Check:** Both `.github/workflows/wordpress-deploy-stage.yml` and `.github/workflows/wordpress-deploy-main.yml` exist at the repo root.
101
+ **Check:** A `.github/workflows/wordpress-deploy-*.yml` file exists for each `stack.wordpress.environments.<env>` whose `branch` is set. (Concretely: a `kinsta` project usually has `wordpress-deploy-stage.yml` + `wordpress-deploy-main.yml`; a `wpengine` project usually adds `wordpress-deploy-develop.yml` as well.)
101
102
 
102
- **Handle (if unmet):** Ask the user whether they want to create the Kinsta workflows now — it requires Kinsta-side SSH keys and GitHub Actions secrets, so they may want to defer. If yes, delegate to `agent/skills/setup-kinsta-deploy/SKILL.md`. If no, mark as user-deferred and move on.
103
+ **Handle (if unmet):** Ask the user whether they want to create the auto-deploy workflows now — it requires host-side SSH keys and GitHub Actions secrets, so they may want to defer. If yes:
104
+
105
+ - `stack.wordpress.hosting === "kinsta"` → delegate to `agent/skills/setup-kinsta-deploy/SKILL.md`.
106
+ - `stack.wordpress.hosting === "wpengine"` → delegate to `agent/skills/setup-wpengine-deploy/SKILL.md`.
107
+
108
+ If no, mark as user-deferred and move on.
103
109
 
104
110
  ### 8. agent/AGENTS.md drift warning (post-update only)
105
111
 
@@ -119,7 +125,7 @@ init checklist:
119
125
  [x] git + GitHub remote
120
126
  [x] apps/wordpress/ (wordpress only)
121
127
  [-] WordPress agent skills (deferred by user)
122
- [x] Kinsta auto-deploy workflows (wordpress + kinsta only)
128
+ [x] Auto-deploy workflows (wordpress + kinsta/wpengine only)
123
129
  [x] agent/AGENTS.md drift (no pending warning)
124
130
  ```
125
131
 
@@ -181,7 +181,34 @@ touch apps/wordpress/wp-content/themes/.gitkeep
181
181
  touch apps/wordpress/wp-content/mu-plugins/.gitkeep
182
182
  ```
183
183
 
184
- ### B3. Write a stub README
184
+ ### B3. Write `apps/wordpress/.gitignore`
185
+
186
+ This file is load-bearing: it is the deploy filter for WP Engine and Kinsta workflows (only git-tracked files under `apps/wordpress/wp-content/` reach the server). WordPress-scoped ignore rules live here using relative paths; repo-level ignores (`.env`, `node_modules`, `.wp-env.override.json`) stay in the root `.gitignore`.
187
+
188
+ ```gitignore
189
+ # Ignore everything in the WordPress root except wp-content.
190
+ /*
191
+ !.gitignore
192
+ !wp-content/
193
+
194
+ # Inside wp-content, only track themes, plugins, and mu-plugins.
195
+ wp-content/*
196
+ !wp-content/themes/
197
+ !wp-content/plugins/
198
+ !wp-content/mu-plugins/
199
+
200
+ # Inside each, ignore everything by default — add explicit ! exceptions
201
+ # for tracked themes/plugins/mu-plugins as they are added to the project.
202
+ wp-content/themes/*
203
+ wp-content/plugins/*
204
+ wp-content/mu-plugins/*
205
+
206
+ # Local-only wp-env mu-plugins (never deploy)
207
+ wp-content/mu-plugins/00-wp-env-local-url.php
208
+ wp-content/mu-plugins/01-wp-env-local-config.php
209
+ ```
210
+
211
+ ### B4. Write a stub README
185
212
 
186
213
  Write `apps/wordpress/REFACT-SOURCE.md`:
187
214
 
@@ -193,9 +220,9 @@ Created fresh via `/refact add codebase wordpress` on <DATE>.
193
220
  This slot is an empty WordPress codebase shell. Drop the client's WordPress source here — plugins under `wp-content/plugins/`, themes under `wp-content/themes/`, mu-plugins under `wp-content/mu-plugins/`.
194
221
  ```
195
222
 
196
- ### B4. Report
223
+ ### B5. Report
197
224
 
198
- Tell the user the slot exists with empty `wp-content/{plugins,themes,mu-plugins}/` ready for content.
225
+ Tell the user the slot exists with empty `wp-content/{plugins,themes,mu-plugins}/` ready for content, and that `apps/wordpress/.gitignore` was created as the deploy filter (they should add `!` exceptions as they add tracked themes/plugins/mu-plugins).
199
226
 
200
227
  ---
201
228
 
@@ -184,6 +184,25 @@ Round-trip test that doesn't touch theme/plugin code:
184
184
  5. Visit the staging URL — bottom-right badge means the tree reached Kinsta and was checked out.
185
185
  6. Revert via a **new commit** (don't amend) — delete the file, remove the gitignore line, push.
186
186
 
187
+ ## Vendor policy
188
+
189
+ **The default for refact-os WordPress projects is: `vendor/` directories are tracked in git.** Each plugin (or mu-plugin) commits its `composer install` output alongside the source.
190
+
191
+ Consequences for this deploy flow:
192
+
193
+ - The deploy workflow **must not** run `composer install`. It commits exactly what's tracked under `apps/wordpress/` and force-pushes that tree to Kinsta.
194
+ - The bytes deployed to Kinsta equal the bytes in the merge commit — deploys are deterministic and there's no build step that can drift between environments.
195
+ - New plugin dependencies require a commit that includes the updated `vendor/` tree alongside the `composer.json` change.
196
+
197
+ Override path: a project that wants a build-in-CI flow must (a) add a `composer install --no-dev --optimize-autoloader` step in each plugin dir before the `git add .`, (b) add the matching `vendor/` ignore rules to the repo's `.gitignore`, and (c) document the deviation in `docs/decisions.md` with a responsible person.
198
+
199
+ ## `.gitignore` hard rules
200
+
201
+ Kinsta receives **only** what reaches the workflow's fresh-init tree, which is **only** what's tracked by git under `apps/wordpress/`. That means the `.gitignore` files in the repo are the deploy filter, not just a local hygiene tool.
202
+
203
+ - **Never use a root-whitelist `.gitignore` pattern (`/*` followed by `!apps/`, `!docs/`, …)** at the repo root. Whitelists are fragile across branch switches: a branch that doesn't carve out one of the allowed paths can quietly drop tracking on files the deploy depends on. Use blocklist semantics (ignore specific things; track everything else).
204
+ - **Scoped allow-lists belong in `apps/wordpress/.gitignore`**, not at the repo root.
205
+
187
206
  ## Hard rules
188
207
 
189
208
  1. **Force-push (`--force`) is required for the Kinsta endpoint and only the Kinsta endpoint.** Kinsta's bare repo can't fast-forward against a fresh-init tree. Never `--force` to GitHub's `main` or `stage`.
@@ -191,6 +210,7 @@ Round-trip test that doesn't touch theme/plugin code:
191
210
  3. **Never place workflows under `apps/wordpress/.github/`.** GitHub doesn't run nested workflows. If any exist there, delete them.
192
211
  4. **Never push to `main` directly.** Stage gets validated first, then `stage → main` via PR.
193
212
  5. **Don't bypass the path filter** (e.g. removing `paths: 'apps/wordpress/**'`). Doing so means every README edit redeploys to Kinsta.
213
+ 6. **Never run `composer install` (or any other build step) in the deploy workflow** without first changing the vendor policy as described above.
194
214
 
195
215
  ## When to stop and ask the user
196
216
 
@@ -0,0 +1,258 @@
1
+ ---
2
+ name: setup-wpengine-deploy
3
+ description: Create the WP Engine auto-deploy GitHub workflows for apps/wordpress. WordPress-on-WP-Engine only.
4
+ pattern: procedure
5
+ requires_approval: true
6
+ when_to_use: /refact setup wpengine auto-deploy | add wpengine deploy | create wpengine workflows.
7
+ when_not_to_use: Non-WP-Engine hosting.
8
+ next_skills: []
9
+ sub_agents: []
10
+ ---
11
+
12
+ # WP Engine Auto-Deploy Reference
13
+
14
+ Use this reference when the user invokes any of:
15
+
16
+ - `/refact setup wpengine auto-deploy`
17
+ - `/refact add wpengine deploy`
18
+ - `/refact enable wpengine auto-deploy`
19
+ - `/refact create wpengine workflows`
20
+
21
+ This reference both **documents** how the WP Engine auto-deploy works for projects scaffolded by this package, and **drives the agent** to create the GitHub Actions workflow files on demand. The workflow YAMLs are *not* pre-vendored into the project — they are generated by following Step 2 below, because not every engagement deploys to WP Engine.
22
+
23
+ ## TL;DR
24
+
25
+ ```
26
+ push to <env.branch> ──▶ .github/workflows/wordpress-deploy-<env>.yml ──▶ WP Engine <install>
27
+
28
+ (path-filtered on apps/wordpress/**; each workflow copies apps/wordpress/wp-content
29
+ into a fresh /tmp/deploy repo and force-pushes it to git@git.wpengine.com:<install>.git)
30
+ ```
31
+
32
+ Each workflow:
33
+
34
+ 1. Checks out the monorepo.
35
+ 2. Loads the env's SSH private key and trusts `git.wpengine.com`.
36
+ 3. `cp -r apps/wordpress/wp-content /tmp/deploy/wp-content` — only the wp-content tree leaves the monorepo.
37
+ 4. Inside `/tmp/deploy`: `git init -b master`, single commit, force-push to `git@git.wpengine.com:<install>.git HEAD:master`.
38
+ 5. WP Engine's git endpoint deploys the pushed tree to the install.
39
+
40
+ ## Step 1 — Preflight
41
+
42
+ ### 1a. Confirm this is a WordPress-on-WP-Engine project
43
+
44
+ Check `.refact-os.json`:
45
+
46
+ - `stack.wordpress` exists.
47
+ - `stack.wordpress.hosting === "wpengine"`.
48
+ - `apps/wordpress/wp-content/` exists.
49
+
50
+ If `stack.wordpress` is missing → stop, this flow is WordPress-specific. If `apps/wordpress/` is missing → delegate to `/refact add codebase wordpress` first. If `hosting` is `kinsta` → use `setup-kinsta-deploy.md` instead.
51
+
52
+ ### 1b. Confirm each environment has the fields we need
53
+
54
+ For every `stack.wordpress.environments.<env>` entry, the workflow needs:
55
+
56
+ | Field | Purpose | Source |
57
+ |---|---|---|
58
+ | `branch` | Which git branch's pushes trigger this env's workflow. | `.refact-os.json` |
59
+ | `install` | WP Engine install name; used in `git@git.wpengine.com:<install>.git`. | `.refact-os.json` |
60
+ | `url` | Public URL (informational; used in the report at Step 3). | `.refact-os.json` |
61
+ | `ssh.user` / `ssh.host` / `ssh.port` / `ssh.path` | Used by the `wp-env` SKILL for staging pulls, not by deploy. Not required here. | `.refact-os.json` |
62
+
63
+ If `branch` or `install` is missing for any env you're about to generate, stop and ask the user to run `/refact init` to fill them in — do not invent defaults.
64
+
65
+ The **non-secret** deploy routing lives in `.refact-os.json`. The SSH **private keys** are GitHub Actions secrets and live only there — never in `.refact-os.json` and never in `agent/AGENTS.md`.
66
+
67
+ ### 1c. Confirm with the user
68
+
69
+ Tell the user which workflow files are about to be created — one per env in the stack, e.g.:
70
+
71
+ - `.github/workflows/wordpress-deploy-develop.yml`
72
+ - `.github/workflows/wordpress-deploy-stage.yml`
73
+ - `.github/workflows/wordpress-deploy-main.yml`
74
+
75
+ And list the GitHub Actions secrets they will need to add **before** the workflows can succeed. One private key per env:
76
+
77
+ | Env | Secret name |
78
+ |---|---|
79
+ | `develop` (or whatever maps to the develop install) | `SSH_PRIV_KEY_DEV` |
80
+ | `stage` | `SSH_PRIV_KEY_STG` |
81
+ | `production` (the `main` branch env) | `SSH_PRIV_KEY_PROD` |
82
+
83
+ The matching public keys go in WP Engine's SSH allow-list for each install (User Portal → Sites → `<install>` → SSH gateway).
84
+
85
+ Confirm before writing files.
86
+
87
+ ## Step 2 — Create the workflow files
88
+
89
+ GitHub Actions only reads workflows from the **repo root** `/.github/workflows/`. Never place these under `apps/wordpress/.github/` — GitHub will not run them, and they would pollute WP Engine's checkout.
90
+
91
+ Generate one file per env present in `stack.wordpress.environments`. Substitute `<env-key>`, `<env.branch>`, `<env.install>`, and `<SECRET_NAME>` for each.
92
+
93
+ ### Template (one file per env)
94
+
95
+ ```yaml
96
+ name: <Env-Title> auto-deploy
97
+ on:
98
+ push:
99
+ branches: [<env.branch>]
100
+ paths:
101
+ - 'apps/wordpress/**'
102
+ workflow_dispatch:
103
+
104
+ jobs:
105
+ deploy:
106
+ runs-on: ubuntu-latest
107
+ steps:
108
+ - uses: actions/checkout@v4
109
+
110
+ - name: Push wp-content to WP Engine (<env-key>)
111
+ run: |
112
+ mkdir -p ~/.ssh
113
+ chmod 700 ~/.ssh
114
+ eval $(ssh-agent -s)
115
+ echo "${{ secrets.<SECRET_NAME> }}" | tr -d '\r' | ssh-add -
116
+ ssh-keyscan git.wpengine.com >> ~/.ssh/known_hosts
117
+ chmod 644 ~/.ssh/known_hosts
118
+
119
+ mkdir -p /tmp/deploy
120
+ cp -r apps/wordpress/wp-content /tmp/deploy/wp-content
121
+ cd /tmp/deploy
122
+ git init -b master
123
+ git config user.email "deploy@github.actions"
124
+ git config user.name "GitHub Actions"
125
+ git add .
126
+ git commit -m "Deploy ${{ github.sha }}"
127
+ git push git@git.wpengine.com:<env.install>.git HEAD:master --force
128
+ ```
129
+
130
+ Concrete example, for an env keyed `production` with `branch: "main"` and `install: "stlouismagazin"`:
131
+
132
+ ```yaml
133
+ name: Production auto-deploy
134
+ on:
135
+ push:
136
+ branches: [main]
137
+ paths:
138
+ - 'apps/wordpress/**'
139
+ workflow_dispatch:
140
+
141
+ jobs:
142
+ deploy:
143
+ runs-on: ubuntu-latest
144
+ steps:
145
+ - uses: actions/checkout@v4
146
+
147
+ - name: Push wp-content to WP Engine (production)
148
+ run: |
149
+ mkdir -p ~/.ssh
150
+ chmod 700 ~/.ssh
151
+ eval $(ssh-agent -s)
152
+ echo "${{ secrets.SSH_PRIV_KEY_PROD }}" | tr -d '\r' | ssh-add -
153
+ ssh-keyscan git.wpengine.com >> ~/.ssh/known_hosts
154
+ chmod 644 ~/.ssh/known_hosts
155
+
156
+ mkdir -p /tmp/deploy
157
+ cp -r apps/wordpress/wp-content /tmp/deploy/wp-content
158
+ cd /tmp/deploy
159
+ git init -b master
160
+ git config user.email "deploy@github.actions"
161
+ git config user.name "GitHub Actions"
162
+ git add .
163
+ git commit -m "Deploy ${{ github.sha }}"
164
+ git push git@git.wpengine.com:stlouismagazin.git HEAD:master --force
165
+ ```
166
+
167
+ ### Secret name mapping
168
+
169
+ Use the env key (not the branch) to pick the secret:
170
+
171
+ | Env key | Secret name |
172
+ |---|---|
173
+ | `production` | `SSH_PRIV_KEY_PROD` |
174
+ | `stage` / `staging` | `SSH_PRIV_KEY_STG` |
175
+ | `develop` / `development` / `dev` | `SSH_PRIV_KEY_DEV` |
176
+ | Anything else | `SSH_PRIV_KEY_<UPPER_ENV>` (e.g. `preview` → `SSH_PRIV_KEY_PREVIEW`) |
177
+
178
+ WP Engine accepts the same public key across installs, but a separate secret per env is the safer default — rotating one install's key doesn't disturb the others.
179
+
180
+ ## Step 3 — Report and hand off
181
+
182
+ Tell the user:
183
+
184
+ 1. Which files were created (one per env).
185
+ 2. The list of GitHub Actions secrets they must add at **Settings → Secrets and variables → Actions** before the first push. If a secret is missing, the workflow fails at the `ssh-add` step.
186
+ 3. The corresponding public keys need to be added to each WP Engine install's SSH gateway (User Portal → site → SSH keys).
187
+ 4. The next step: push to the `develop` branch first, watch the run in the Actions tab, then promote `develop → stage → main` via PRs.
188
+ 5. That the workflows are **path-filtered** on `apps/wordpress/**`, so edits elsewhere in the monorepo will not trigger a deploy or burn Actions minutes.
189
+
190
+ ## Vendor policy
191
+
192
+ **The default for refact-os WordPress projects is: `vendor/` directories are tracked in git.** Each plugin (or mu-plugin) commits its `composer install` output alongside the source.
193
+
194
+ Consequences for this deploy flow:
195
+
196
+ - The deploy workflow **must not** run `composer install`. It `cp -r`s exactly what was committed.
197
+ - The bytes deployed to WP Engine equal the bytes in the merge commit — deploys are deterministic and there's no build step that can drift between environments.
198
+ - New plugin dependencies require a commit that includes the updated `vendor/` tree alongside the `composer.json` change.
199
+
200
+ Override path: a project that wants a build-in-CI flow must (a) add a `composer install --no-dev --optimize-autoloader` step before the `cp -r` here, (b) add the matching `vendor/` ignore rules to the repo's `.gitignore`, and (c) document the deviation in `docs/decisions.md` with a responsible person.
201
+
202
+ ## `.gitignore` hard rules
203
+
204
+ WP Engine receives **only** what reaches `/tmp/deploy/wp-content`, which is **only** what's tracked by git under `apps/wordpress/wp-content/`. That means the `.gitignore` files in the repo are the deploy filter, not just a local hygiene tool.
205
+
206
+ - **Never use a root-whitelist `.gitignore` pattern (`/*` followed by `!apps/`, `!docs/`, …)** at the repo root. Whitelists are fragile across branch switches: a branch that doesn't carve out one of the allowed paths can quietly drop tracking on files the deploy depends on. Use blocklist semantics (ignore specific things; track everything else).
207
+ - **Scoped allow-lists belong in `apps/wordpress/.gitignore`**, not at the repo root. That's where the classic "ignore WP core; allow `wp-content/`; inside `wp-content/`, only track our theme + mu-plugin" pattern lives.
208
+ - **Never delete or aggressively rewrite `apps/wordpress/.gitignore`** — it's load-bearing. Without it, the `cp -r` could pick up untracked WordPress core, dump files, or local dev artifacts.
209
+
210
+ ## Adding new tracked files under `apps/wordpress/`
211
+
212
+ The nested `apps/wordpress/.gitignore` is the allow-list. New files in `wp-content/mu-plugins/`, `wp-content/plugins/`, or `wp-content/themes/` need an explicit `!` exception or git won't see them — and if git doesn't see them, neither does WP Engine.
213
+
214
+ Verify it works before committing:
215
+
216
+ ```bash
217
+ git check-ignore -v apps/wordpress/wp-content/mu-plugins/your-new-plugin.php
218
+ # Should print the `!...` rule line, NOT the wildcard rule.
219
+ ```
220
+
221
+ `git status` showing the file as untracked-but-eligible is the green-light.
222
+
223
+ ## Smoke-testing the auto-deploy
224
+
225
+ Round-trip test that doesn't touch theme/plugin code:
226
+
227
+ 1. On `develop`, create `apps/wordpress/wp-content/mu-plugins/deploy-test.php`:
228
+
229
+ ```php
230
+ <?php
231
+ /** Plugin Name: Deploy Test */
232
+ defined('ABSPATH') || exit;
233
+ add_action('wp_footer', function () {
234
+ echo '<div style="position:fixed;bottom:8px;right:8px;background:#222;color:#fff;padding:6px 10px;font:12px monospace;z-index:99999;">deploy ok &middot; ' . esc_html(gmdate('c')) . '</div>';
235
+ });
236
+ ```
237
+
238
+ 2. Add `!wp-content/mu-plugins/deploy-test.php` to `apps/wordpress/.gitignore`.
239
+ 3. Commit and `git push origin develop`.
240
+ 4. Watch `https://github.com/<org>/<repo>/actions` → "Development auto-deploy" run.
241
+ 5. Visit the dev install URL — bottom-right badge means the tree reached WP Engine.
242
+ 6. Revert via a **new commit** (don't amend) — delete the file, remove the gitignore line, push.
243
+
244
+ ## Hard rules
245
+
246
+ 1. **Force-push (`--force`) is required for the WP Engine endpoint and only the WP Engine endpoint.** WP Engine's git endpoint expects a fresh-init tree and cannot fast-forward against one. Never `--force` to GitHub's `main` (or any other GitHub branch).
247
+ 2. **Never delete or aggressively rewrite `apps/wordpress/.gitignore`.** It is the deploy filter (see above).
248
+ 3. **Never place workflows under `apps/wordpress/.github/`.** GitHub doesn't run nested workflows. If any exist there, delete them.
249
+ 4. **Never push to `main` directly.** Develop and stage get validated first, then `stage → main` via PR.
250
+ 5. **Don't bypass the path filter** (e.g. removing `paths: 'apps/wordpress/**'`). Doing so means every README edit redeploys.
251
+ 6. **Never run `composer install` (or any other build step) in the deploy workflow** without first changing the vendor policy as described above.
252
+
253
+ ## When to stop and ask the user
254
+
255
+ - About to add a path under `apps/wordpress/` and unsure if the nested allow-list covers it → run `git check-ignore -v` first; if it points at the wildcard rule, you need a new `!` line.
256
+ - A workflow run fails → diagnose, don't retry blindly. Common causes: missing secret, public key not in WP Engine's SSH allow-list for that install, wrong `install` name (`.git` path mismatch).
257
+ - Stage deploy works but production doesn't pick up → confirm `branch:` matches the actual production branch, and `<install>` matches the production install name in WP Engine.
258
+ - About to change a workflow to push something other than `apps/wordpress/wp-content/` → that's an architecture change; surface and confirm scope.
@@ -12,18 +12,19 @@ sub_agents: []
12
12
 
13
13
  Use this reference when the user invokes any of:
14
14
 
15
- - `/refact wp-env setup` — bring up a fresh local WordPress stack with a sample DB.
15
+ - `/refact wp-env setup` — bring up a fresh local WordPress stack and, in the same flow, optionally pull plugins/mu-plugins + DB from staging and set a local domain. Idempotent: each sub-step is verified independently and skipped silently if already met, so re-running on a fully-configured project is a no-op.
16
16
  - `/refact wp-env pull` — alias for **pull plugins + mu-plugins + db** (staging → local).
17
17
  - `/refact wp-env pull plugins`
18
18
  - `/refact wp-env pull mu-plugins`
19
19
  - `/refact wp-env pull db`
20
+ - `/refact wp-env pull wp-config` — read staging `wp-config.php` and extract application constants into local config files.
20
21
  - `/refact wp-env reset` — destroy containers + volumes and rebuild from scratch.
21
22
  - `/refact wp-env domain set <hostname>` — front the local env with a `.local` hostname over HTTPS via Caddy.
22
23
  - `/refact wp-env domain clear` — remove the hostname mapping and revert to `http://localhost:8888`.
23
24
 
24
25
  ## What this does
25
26
 
26
- Manages the local WordPress development stack for engagements scaffolded with `/refact add codebase wordpress`. Local code lives in `apps/wordpress/wp-content/`, which is the same tree the Kinsta deploy workflows push (see [`kinsta-auto-deploy.md`](./kinsta-auto-deploy.md)). The local env mirrors staging by pulling plugins, mu-plugins, and the DB over SSH using the credentials in `agent/AGENTS.md`.
27
+ Manages the local WordPress development stack for engagements scaffolded with `/refact add codebase wordpress`. Local code lives in `apps/wordpress/wp-content/`, which is the same tree the Kinsta and WP Engine deploy workflows push (see [`setup-kinsta-deploy.md`](../setup-kinsta-deploy/SKILL.md) / [`setup-wpengine-deploy.md`](../setup-wpengine-deploy/SKILL.md)). The local env mirrors staging by pulling plugins, mu-plugins, and the DB over SSH using the routing in `.refact-os.json` › `stack.wordpress.environments.staging`.
27
28
 
28
29
  ## Canonical layout
29
30
 
@@ -55,7 +56,7 @@ The hostname is stored at `.refact-os.json` › `wpEnv.localDomain` so every tea
55
56
  3. Docker is reachable: `docker info` exits 0. If not, stop and tell the user to start Docker Desktop / Colima.
56
57
  4. Node is **18+**: `node --version`. wp-env requires it.
57
58
 
58
- For `pull` flows only — also verify the SSH fields in `agent/AGENTS.md` are filled (not the literal `<ssh-staging-…>` placeholders). If any placeholder remains, stop and tell the user to run `/refact init` first.
59
+ For `pull` flows only — also verify `.refact-os.json` `stack.wordpress.environments.staging` has its `ssh` block (user/host/port/path) and `url` filled. If the `ssh` block is missing or any of its fields are absent, stop and tell the user to run `/refact init` first — the deploy/SSH source of truth is `.refact-os.json`, not `agent/AGENTS.md`.
59
60
 
60
61
  ---
61
62
 
@@ -149,23 +150,60 @@ Print to the user:
149
150
 
150
151
  If a domain is configured but Caddy isn't running yet, also remind the user to run `/refact wp-env domain set <hostname>` (or, if already set, `caddy start --config ~/.refact/Caddyfile` to bring the proxy up).
151
152
 
153
+ ### 1g. One-shot post-start checklist
154
+
155
+ The container is up. Walk this **idempotent checklist** so the user gets to a usable, populated local site in one command. Each item is independent: verify the condition, skip silently if already met, otherwise ask the user a single yes/no and delegate to the documented sub-flow. Re-running `/refact wp-env setup` on a fully-configured project does nothing.
156
+
157
+ The values prompted for here persist into `.refact-os.json`, so the next teammate who clones the repo and runs `/refact wp-env setup` is asked **none** of them.
158
+
159
+ #### Checklist
160
+
161
+ 1. **Pull plugins + mu-plugins from staging.**
162
+ - Skip silently if `apps/wordpress/wp-content/plugins/` contains any directory other than `.gitkeep` (i.e. there's already at least one plugin checked in or pulled).
163
+ - Otherwise ask: *"Pull plugins and mu-plugins from staging now? [Y/n]"*. On yes, run Step 2b (`pull plugins`) followed by Step 2c (`pull mu-plugins`). On no, mark as user-deferred and continue.
164
+ - Preflight is shared: if Step 2a's SSH check fails, surface and stop the checklist here.
165
+
166
+ 2. **Pull staging DB.**
167
+ - Skip silently if `npm run wp:cli -- option get siteurl` returns anything other than wp-env's default (`http://localhost:8888` or `https://localhost:8888`). Any non-default value means the DB has already been imported.
168
+ - Otherwise ask: *"Pull the staging database now? This rewrites local URLs from `<STAGING_URL>` → `<LOCAL_URL>` and resets the admin password. [Y/n]"*. On yes, run Step 2d (`pull db`). On no, mark as user-deferred.
169
+
170
+ 3. **Set a local domain.**
171
+ - Skip silently if `.refact-os.json` › `wpEnv.localDomain` is already set. (If it is set but Caddy isn't running, the message printed in 1f covers that.)
172
+ - Otherwise ask: *"Front the local stack with a `.local` hostname over HTTPS (recommended)? Leave blank to keep `http://localhost:8888`."*. If the user provides a hostname, run Step 3b (`domain set <hostname>`). If they leave it blank or decline, persist nothing (so the next teammate gets the same prompt — they may want a domain even if this user doesn't).
173
+
174
+ #### Output
175
+
176
+ After the checklist, print a single summary, in the same shape `setup-project` uses:
177
+
178
+ ```
179
+ wp-env setup checklist:
180
+ [x] containers up (wp-env start)
181
+ [x] plugins + mu-plugins pulled (or [-] deferred / [-] already populated)
182
+ [x] staging DB imported (or [-] deferred / [-] already populated)
183
+ [x] local domain set (or [-] deferred / [-] not requested)
184
+ ```
185
+
186
+ Use `[x]` for met, `[-]` for user-deferred or already-populated (don't re-prompt next run unless the user re-asks), and `[ ]` only for items that failed mid-step (with the suggested next action).
187
+
152
188
  ---
153
189
 
154
190
  ## Step 2 — `pull`
155
191
 
156
192
  `/refact wp-env pull` runs **2b → 2c → 2d** in order. The single-target variants (`pull plugins` / `pull mu-plugins` / `pull db`) run just that step. All variants share the preflight in 2a.
157
193
 
158
- ### 2a. Resolve SSH target from `agent/AGENTS.md`
194
+ ### 2a. Resolve SSH target from `.refact-os.json`
159
195
 
160
- Parse the **SSH access** section of `agent/AGENTS.md`. The init flow fills these placeholders:
196
+ Read `.refact-os.json` `stack.wordpress.environments.staging`. (For projects whose staging env is keyed differently — e.g. `stage` fall back to that key; never default to `production`.) Map the fields:
161
197
 
162
- | agent/AGENTS.md field | Variable |
198
+ | `.refact-os.json` path | Variable |
163
199
  |---|---|
164
- | `<ssh-staging-user>` | `SSH_USER` |
165
- | `<ssh-staging-host>` | `SSH_HOST` |
166
- | `<ssh-staging-port>` | `SSH_PORT` |
167
- | `<staging-document-root>` | `DOC_ROOT` |
168
- | `<staging-url>` (from the branch→env table) | `STAGING_URL` |
200
+ | `…environments.staging.ssh.user` | `SSH_USER` |
201
+ | `…environments.staging.ssh.host` | `SSH_HOST` |
202
+ | `…environments.staging.ssh.port` | `SSH_PORT` |
203
+ | `…environments.staging.ssh.path` | `DOC_ROOT` (the WordPress install root, relative to the SSH user's home; e.g. `sites/<install>` on WP Engine, `/www/<dir>/public` on Kinsta) |
204
+ | `…environments.staging.url` | `STAGING_URL` |
205
+
206
+ If `stack.wordpress.environments.staging.ssh` is absent (or any of `user`/`host`/`port`/`path` is missing), stop and tell the user to run `/refact init` to fill it. Do **not** fall back to parsing `agent/AGENTS.md` — `.refact-os.json` is the single source of truth.
169
207
 
170
208
  Build the SSH connection string:
171
209
 
@@ -198,24 +236,49 @@ Notes:
198
236
 
199
237
  - `--delete` mirrors staging exactly. Warn the user once: "this removes any local-only plugins under `apps/wordpress/wp-content/plugins/`. Confirm?" If they decline, drop `--delete`.
200
238
  - Some hosts inject plugins that don't belong in the repo (e.g. `kinsta-mu-plugins` lives in `mu-plugins/`, not here, so it shouldn't appear; but check). Don't auto-exclude anything beyond `index.php` without asking.
201
- - After the rsync, remind the user to inspect `git status` for new tracked paths in `apps/wordpress/wp-content/plugins/`. If something new should reach Kinsta on deploy, the nested `apps/wordpress/.gitignore` needs an explicit `!` exception — see [`kinsta-auto-deploy.md`](./kinsta-auto-deploy.md) § "Adding new tracked files".
239
+ - After the rsync, remind the user to inspect `git status` for new tracked paths in `apps/wordpress/wp-content/plugins/`. If something new should reach the host on deploy, the nested `apps/wordpress/.gitignore` needs an explicit `!` exception — see the project's deploy skill (`setup-kinsta-deploy` or `setup-wpengine-deploy`) § "Adding new tracked files under `apps/wordpress/`".
202
240
 
203
241
  ### 2c. Pull `mu-plugins`
204
242
 
243
+ Build the rsync exclude list from two sources: local-only files that must never be deleted by `--delete`, and host-injected system mu-plugins that aren't useful locally. Read `hosting` from `.refact-os.json` › `stack.wordpress.hosting` to select the right set.
244
+
245
+ **Always exclude (local-only wp-env helpers):**
246
+
247
+ ```
248
+ --exclude='index.php'
249
+ --exclude='00-wp-env-local-url.php'
250
+ --exclude='01-wp-env-local-config.php'
251
+ ```
252
+
253
+ **Host-specific excludes:**
254
+
255
+ | Hosting | Exclude patterns |
256
+ |---|---|
257
+ | `kinsta` | `kinsta-mu-plugins/`, `kinsta-mu-plugins.php` |
258
+ | `wpengine` | `mu-plugin.php`, `force-strong-passwords/`, `slt-force-strong-passwords.php`, `wpe-cache-plugin*`, `wpe-update-source-selector*`, `wpe-wp-sign-on-plugin*`, `wpengine-common/`, `wpengine-security-auditor.php` |
259
+ | Other | Ask the user if unrecognized system mu-plugins are detected |
260
+
261
+ Example for a WP Engine project:
262
+
205
263
  ```bash
206
264
  rsync -avz --delete \
207
265
  -e "ssh ${SSH_OPTS}" \
208
266
  --exclude='index.php' \
209
267
  --exclude='00-wp-env-local-url.php' \
210
- --exclude='kinsta-mu-plugins/' \
211
- --exclude='kinsta-mu-plugins.php' \
268
+ --exclude='01-wp-env-local-config.php' \
269
+ --exclude='mu-plugin.php' \
270
+ --exclude='force-strong-passwords/' \
271
+ --exclude='slt-force-strong-passwords.php' \
272
+ --exclude='wpe-cache-plugin*' \
273
+ --exclude='wpe-update-source-selector*' \
274
+ --exclude='wpe-wp-sign-on-plugin*' \
275
+ --exclude='wpengine-common/' \
276
+ --exclude='wpengine-security-auditor.php' \
212
277
  "${SSH_TARGET}:${DOC_ROOT}/wp-content/mu-plugins/" \
213
278
  apps/wordpress/wp-content/mu-plugins/
214
279
  ```
215
280
 
216
- The Kinsta-injected mu-plugin is excluded because Kinsta manages it server-side; it isn't useful locally and shouldn't end up in git. For other hosts (WP Engine's `wpengine-common`, Pantheon's `pantheon.php`, etc.), apply the same logic ask the user if you spot one you don't recognize before adding it to the exclude list permanently.
217
-
218
- `00-wp-env-local-url.php` is a local-only helper created by `domain set`; keep excluding it so `pull mu-plugins --delete` doesn't remove the local Caddy URL fix.
281
+ These host-injected mu-plugins are managed server-side; they aren't useful locally and shouldn't end up in git. If you spot an unrecognized system mu-plugin (owned by `root` or `nobody`, or matching a known hosting vendor pattern), ask the user before adding it to the exclude list.
219
282
 
220
283
  ### 2d. Pull `db`
221
284
 
@@ -223,21 +286,32 @@ The local stack **must be running** for this step. If `npx wp-env run cli wp cor
223
286
 
224
287
  Resolve `LOCAL_URL` the same way Step 1b does: `https://<wpEnv.localDomain>` if it's set in `.refact-os.json`, otherwise `http://localhost:8888`.
225
288
 
289
+ Use a two-step dump-to-file approach. Piping SSH export directly into `wp-env run cli wp db import -` can stall on databases larger than ~500 MB because Docker's stdin buffering saturates before the import catches up.
290
+
226
291
  ```bash
227
- # 1. Dump staging DB and pipe straight into the wp-env container.
292
+ # 1. Dump staging DB to a local file.
293
+ mkdir -p .wp-env-dumps
228
294
  ssh ${SSH_OPTS} "${SSH_TARGET}" \
229
295
  "cd '${DOC_ROOT}' && wp db export --single-transaction -" \
230
- | npx wp-env run cli wp db import -
296
+ > .wp-env-dumps/staging.sql
297
+
298
+ # 2. Copy the dump into the wp-env container and import.
299
+ CONTAINER=$(docker ps --filter "name=cli" --format '{{.Names}}' | grep wp-env | head -1)
300
+ docker cp .wp-env-dumps/staging.sql "${CONTAINER}:/tmp/staging.sql"
301
+ npx wp-env run cli wp db import /tmp/staging.sql
231
302
 
232
- # 2. Rewrite URLs from staging → local.
303
+ # 3. Rewrite URLs from staging → local.
233
304
  npx wp-env run cli wp search-replace "${STAGING_URL}" "${LOCAL_URL}" \
234
305
  --skip-columns=guid --all-tables
235
306
 
236
307
  # If the staging URL in wp_options has no trailing slash, make sure home/siteurl land exactly.
237
308
  npx wp-env run cli wp db query "UPDATE wp_options SET option_value='${LOCAL_URL}' WHERE option_name IN ('home','siteurl')"
238
309
 
239
- # 3. Flush caches.
310
+ # 4. Flush caches.
240
311
  npx wp-env run cli wp cache flush
312
+
313
+ # 5. Clean up the dump (gitignored path, but no reason to keep ~1 GB on disk).
314
+ rm -f .wp-env-dumps/staging.sql
241
315
  ```
242
316
 
243
317
  Print `STAGING_URL → LOCAL_URL` before running `search-replace` and ask the user to confirm. A bad replace can rewrite half the DB to the wrong host and is painful to undo.
@@ -257,6 +331,64 @@ After import:
257
331
 
258
332
  If the staging host has no `wp-cli` on `$PATH`, **stop** rather than silently falling back — surface the error and the user can decide whether to install wp-cli on the server or pull a manual `mysqldump`. Don't invent a fallback in this flow.
259
333
 
334
+ ### 2e. Pull `wp-config`
335
+
336
+ Reads the staging `wp-config.php` via SSH and extracts application-level constants into the local environment. Run this after `pull db` so the local stack has the same constants the staging code expects.
337
+
338
+ ```bash
339
+ ssh ${SSH_OPTS} "${SSH_TARGET}" "cat '${DOC_ROOT}/wp-config.php'"
340
+ ```
341
+
342
+ Classify each `define()` in the file into one of four buckets:
343
+
344
+ **Skip (infrastructure — handled by wp-env or the host):**
345
+
346
+ - Database: `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_HOST_SLAVE`, `DB_CHARSET`, `DB_COLLATE`, `$table_prefix`
347
+ - Salts/keys: `AUTH_KEY`, `SECURE_AUTH_KEY`, `LOGGED_IN_KEY`, `NONCE_KEY`, `AUTH_SALT`, `SECURE_AUTH_SALT`, `LOGGED_IN_SALT`, `NONCE_SALT`
348
+ - WordPress core paths: `ABSPATH`, `WP_CACHE`, `WPLANG`, `WP_AUTO_UPDATE_CORE`
349
+ - Host-injected (read `hosting` from `.refact-os.json` › `stack.wordpress.hosting`):
350
+ - **WP Engine**: `WPE_*`, `PWP_NAME`, `FS_METHOD`, `FS_CHMOD_*`, `WPE_SFTP_*`, `WPE_CDN_*`, `DISALLOW_FILE_*`, `DISABLE_WP_CRON`, `FORCE_SSL_LOGIN`, `WPE_FORCE_SSL_LOGIN`, `WP_POST_REVISIONS`, `WP_TURN_OFF_ADMIN_BAR`, `WPE_BETA_TESTER`, `WPE_WHITELABEL`, `WPE_EXTERNAL_URL`, `$wpe_*`, `$memcached_servers`
351
+ - **Kinsta**: `KINSTA_*`, `WP_CACHE_KEY_SALT`
352
+ - Debug flags: `WP_DEBUG`, `WP_DEBUG_LOG`, `WP_DEBUG_DISPLAY` (already set in `.wp-env.json`)
353
+
354
+ **Scalar, non-secret → `.wp-env.json` `config` (committed):**
355
+
356
+ Application constants with no secret value: version strings, import/feature IDs, memory limits, email addresses. Examples: `SLM_CORE_VERSION`, `CONTENT_IMPORT_ID`, `WP_MEMORY_LIMIT`, `WP_MAX_MEMORY_LIMIT`.
357
+
358
+ Add these to the existing `config` object in `.wp-env.json`. Don't overwrite values already present.
359
+
360
+ **Scalar, secret → `.wp-env.override.json` `config` (gitignored):**
361
+
362
+ API keys, encryption keys, tokens. Examples: `SLM_AKISMET_API_KEY`, `WP2FA_ENCRYPT_KEY`.
363
+
364
+ Create `.wp-env.override.json` if it doesn't exist (`{ "config": { … } }`). This file must be in the root `.gitignore`.
365
+
366
+ **Complex (PHP arrays) → local mu-plugin (gitignored):**
367
+
368
+ wp-env `config` only supports scalar values. PHP arrays (e.g. service-account credential blobs) go into `apps/wordpress/wp-content/mu-plugins/01-wp-env-local-config.php`, guarded by:
369
+
370
+ ```php
371
+ if ( defined( 'WP_ENVIRONMENT_TYPE' ) && 'local' === WP_ENVIRONMENT_TYPE ) {
372
+ if ( ! defined( 'SOME_CONSTANT' ) ) {
373
+ define( 'SOME_CONSTANT', array( … ) );
374
+ }
375
+ }
376
+ ```
377
+
378
+ This file must be in `apps/wordpress/.gitignore`.
379
+
380
+ **After writing all files:**
381
+
382
+ 1. Restart wp-env: `npx wp-env start` (picks up `.wp-env.json` and override changes).
383
+ 2. Verify: `npx wp-env run cli wp eval "echo defined('CONSTANT_NAME') ? 'yes' : 'no';"` for a sample of the extracted constants.
384
+ 3. If a previously-crashed plugin depended on a missing constant (common: GA4/analytics credentials), re-activate it: `npx wp-env run cli wp plugin activate <plugin-slug>`.
385
+
386
+ **Guardrails:**
387
+
388
+ - Never commit secrets. `.wp-env.override.json` and `01-wp-env-local-config.php` must always be gitignored.
389
+ - Never copy the staging DB credentials, salts, or host-specific constants into local files — they're meaningless locally and risk leaking if committed.
390
+ - Show the user the proposed classification (which constants go where) before writing, so they can override the bucket for edge cases.
391
+
260
392
  ---
261
393
 
262
394
  ## Step 3 — `domain set <hostname>` / `domain clear`
@@ -464,7 +596,7 @@ After reset, the user typically wants `/refact wp-env pull db` to restore stagin
464
596
  - **Never run `search-replace` without confirming the URLs.** A bad replace can rewrite half the DB to the wrong host and is painful to undo. Always print the source and target URL once before executing.
465
597
  - **Never `wp-env destroy` without confirmation.** It nukes the local DB. `reset` confirms; ad-hoc destroy elsewhere should too.
466
598
  - **Never invent a fallback** when staging's wp-cli or rsync isn't available. Surface the error, let the user decide.
467
- - **Never edit `agent/AGENTS.md` to fill missing SSH fields from this flow.** Send the user to `/refact init` so the placeholders are filled in one place.
599
+ - **Never edit `agent/AGENTS.md` (or any other file) to fill missing SSH fields from this flow.** Send the user to `/refact init` so `.refact-os.json` `stack.wordpress.environments.<env>.ssh` is filled that's the single source of truth.
468
600
  - **Never commit pulled DB dumps.** If you write a `.sql` file at any point during this flow, place it in a gitignored path (e.g. `./.wp-env-dumps/`) and delete it after import.
469
601
  - **Never write Caddyfiles into the project tree.** Per-project site blocks live under `~/.refact/caddy/`. Project-local files would either bind-collide with another project's Caddy instance or end up committed by accident.
470
602
  - **Never accept a public-TLD hostname** for `domain set` (`.com`, `.io`, etc.) without explicit user confirmation — Caddy would try Let's Encrypt and either fail or, worse, attempt ACME against a domain the user doesn't control.