@pressgang-wp/shakedown 0.1.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/lib/derive.mjs ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Matrix derivation: enumerate the target site's route surface.
3
+ *
4
+ * Prefers `wp capstan matrix --resolve` (PressGang's own introspection —
5
+ * includes the expected-template/controller oracle for dispatched routes);
6
+ * falls back to the bundled matrix.php when Capstan isn't installed, which
7
+ * derives the same route families without oracle data.
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
15
+
16
+ /**
17
+ * Parse JSON out of WP-CLI output, tolerating pre-JSON noise
18
+ * (PHP notices on WP_DEBUG sites, plugin chatter).
19
+ *
20
+ * @param {string} out
21
+ * @returns {object|null}
22
+ */
23
+ function parseJson(out) {
24
+ const start = out.indexOf('{');
25
+ if (start === -1) return null;
26
+ try {
27
+ return JSON.parse(out.slice(start));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * @param {ReturnType<import('./target.mjs').resolveTarget>} target
35
+ * @returns {{matrix: object, source: string}|null}
36
+ */
37
+ function tryCapstan(target) {
38
+ try {
39
+ const out = execFileSync(
40
+ 'wp',
41
+ [
42
+ 'capstan',
43
+ 'matrix',
44
+ '--resolve',
45
+ '--format=json',
46
+ `--samples=${target.samplesPerType}`,
47
+ `--search=${target.searchTerm}`,
48
+ `--path=${target.sitePath}`,
49
+ ],
50
+ { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }
51
+ );
52
+ const matrix = parseJson(out);
53
+
54
+ return matrix ? { matrix, source: 'capstan' } : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {ReturnType<import('./target.mjs').resolveTarget>} target
62
+ * @returns {{matrix: object, source: string}}
63
+ */
64
+ function runBundledScript(target) {
65
+ const out = execFileSync(
66
+ 'wp',
67
+ [
68
+ 'eval-file',
69
+ join(pkgRoot, 'bin/matrix.php'),
70
+ String(target.samplesPerType),
71
+ target.searchTerm,
72
+ `--path=${target.sitePath}`,
73
+ ],
74
+ { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
75
+ );
76
+ const matrix = parseJson(out);
77
+
78
+ if (!matrix) {
79
+ throw new Error(`matrix.php produced no JSON:\n${out.trim().slice(0, 500)}`);
80
+ }
81
+
82
+ return { matrix, source: 'bundled matrix.php' };
83
+ }
84
+
85
+ /**
86
+ * Derive the matrix and persist it to <workspace>/.shakedown/matrix.json.
87
+ *
88
+ * @param {ReturnType<import('./target.mjs').resolveTarget>} target
89
+ * @param {string} workspace Directory run artifacts belong to.
90
+ * @returns {{matrix: object, source: string, path: string}}
91
+ */
92
+ /**
93
+ * Pre-flight configuration health via `wp capstan doctor --format=json`.
94
+ *
95
+ * @param {string} sitePath
96
+ * @returns {{checks: array, failures: number, warnings: number}|null}
97
+ * null when Capstan isn't installed — the pre-flight is optional.
98
+ */
99
+ export function capstanDoctor(sitePath) {
100
+ try {
101
+ const out = execFileSync('wp', ['capstan', 'doctor', '--format=json', `--path=${sitePath}`], {
102
+ encoding: 'utf8',
103
+ stdio: ['ignore', 'pipe', 'pipe'],
104
+ });
105
+
106
+ return parseJson(out);
107
+ } catch (err) {
108
+ // Doctor exits non-zero when checks FAIL — that's a report, not an error.
109
+ const report = parseJson(String(err.stdout ?? ''));
110
+
111
+ return report ?? null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Merge extra routes into the persisted matrix, first-occurrence-wins on URL
117
+ * collisions — so specific labels (state:*) prepended here beat the generic
118
+ * single:* labels the derivation gave the same URLs.
119
+ *
120
+ * @param {string} workspace
121
+ * @param {array<{url: string, kind: string, expect: number}>} extraRoutes
122
+ * @returns {object} The merged matrix.
123
+ */
124
+ export function mergeRoutes(workspace, extraRoutes) {
125
+ const path = join(workspace, '.shakedown', 'matrix.json');
126
+ const matrix = JSON.parse(readFileSync(path, 'utf8'));
127
+
128
+ const seen = new Set();
129
+ matrix.routes = [...extraRoutes, ...matrix.routes].filter((route) => {
130
+ if (seen.has(route.url)) return false;
131
+ seen.add(route.url);
132
+ return true;
133
+ });
134
+
135
+ writeFileSync(path, JSON.stringify(matrix, null, 2));
136
+
137
+ return matrix;
138
+ }
139
+
140
+ export function deriveMatrix(target, workspace) {
141
+ const { matrix, source } = tryCapstan(target) ?? runBundledScript(target);
142
+
143
+ matrix.target = target.name;
144
+ matrix.baseUrl = target.baseUrl;
145
+
146
+ const dir = join(workspace, '.shakedown');
147
+ mkdirSync(dir, { recursive: true });
148
+ const path = join(dir, 'matrix.json');
149
+ writeFileSync(path, JSON.stringify(matrix, null, 2));
150
+
151
+ return { matrix, source, path };
152
+ }
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Sandbox engine: a throwaway WordPress with an isolated SQLite database —
3
+ * WP's answer to Laravel's in-memory RefreshDatabase testing.
4
+ *
5
+ * Assembly (all in a temp dir, deleted after the run):
6
+ * - WordPress core, themes, plugins and mu-plugins are SYMLINKED read-only
7
+ * from the real project — the sandbox runs your actual code;
8
+ * - wp-config.php, the uploads dir, and the SQLite database are the
9
+ * sandbox's own — the real site's MySQL and uploads are never touched;
10
+ * - the SQLite Database Integration plugin (cached under ~/.cache) provides
11
+ * the db.php drop-in;
12
+ * - `wp core install` creates a fresh site, the project's child theme is
13
+ * activated, permalinks set, and `wp server` serves it on a local port.
14
+ *
15
+ * Nothing persists: fixtures, uploads, options — all evaporate with the
16
+ * temp dir. This is the only shakedown engine allowed to write to a
17
+ * database, because the database is disposable by construction.
18
+ */
19
+ import { execFileSync, spawn } from 'node:child_process';
20
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
21
+ import { createServer } from 'node:net';
22
+ import { tmpdir, homedir } from 'node:os';
23
+ import { basename, dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const SQLITE_PLUGIN_ZIP = 'https://downloads.wordpress.org/plugin/sqlite-database-integration.latest-stable.zip';
27
+
28
+ export const DEFAULT_FIXTURE_EPOCH = '2026-01-01T09:00:00+00:00';
29
+
30
+ const ABSOLUTE_EPOCH_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
31
+
32
+ /**
33
+ * Download (once) and cache the SQLite Database Integration plugin.
34
+ *
35
+ * @returns {string} Path to the cached plugin directory.
36
+ */
37
+ function ensureSqlitePlugin() {
38
+ const cache = join(homedir(), '.cache', 'pressgang-shakedown');
39
+ const pluginDir = join(cache, 'sqlite-database-integration');
40
+
41
+ if (!existsSync(pluginDir)) {
42
+ mkdirSync(cache, { recursive: true });
43
+ const zip = join(cache, 'sqlite.zip');
44
+ execFileSync('curl', ['-sL', '-o', zip, SQLITE_PLUGIN_ZIP]);
45
+ execFileSync('unzip', ['-oq', zip, '-d', cache]);
46
+ rmSync(zip);
47
+ }
48
+
49
+ return pluginDir;
50
+ }
51
+
52
+ /**
53
+ * Symlink an entry from the real project into the sandbox.
54
+ */
55
+ function link(from, to) {
56
+ if (existsSync(from) && !existsSync(to)) {
57
+ symlinkSync(from, to);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Assemble the sandbox filesystem: linked code, owned state.
63
+ *
64
+ * Plugins are strictly allowlisted. The sandbox's job is testing the theme's
65
+ * rendering, not the site's plugin stack — security/caching/SEO plugins add
66
+ * nondeterministic noise (we caught one force-HTTPS-redirecting while
67
+ * nominally inactive). What isn't allowlisted isn't even symlinked in.
68
+ *
69
+ * @param {string} sitePath Real project's WordPress root (contains wp-config.php).
70
+ * @param {array<string>} pluginAllowlist Plugin directory names to make available.
71
+ * @returns {string} Sandbox root path.
72
+ */
73
+ function assemble(sitePath, pluginAllowlist = [], pathMap = {}) {
74
+ const tmp = mkdtempSync(join(tmpdir(), 'shakedown-sandbox-'));
75
+
76
+ // Subdirectory installs (core in /wp under a parent docroot, as Herd/Valet
77
+ // "wp in subfolder" setups use): mirror the real layout so root-relative
78
+ // asset URLs (/assets/…) resolve. The parent's entries are linked in and
79
+ // the core assembly lands under the same subdirectory name.
80
+ const parent = dirname(sitePath);
81
+ const parentIndex = join(parent, 'index.php');
82
+ const subdir = existsSync(parentIndex) && readFileSync(parentIndex, 'utf8').includes('wp-blog-header')
83
+ ? basename(sitePath)
84
+ : null;
85
+
86
+ let docroot = tmp;
87
+ let root = tmp;
88
+
89
+ if (subdir) {
90
+ for (const entry of readdirSync(parent)) {
91
+ if (entry === basename(sitePath) || entry.startsWith('.')) {
92
+ continue;
93
+ }
94
+ const from = join(parent, entry);
95
+ if (entry.endsWith('.php')) {
96
+ cpSync(from, join(tmp, entry));
97
+ } else {
98
+ link(from, join(tmp, entry));
99
+ }
100
+ }
101
+
102
+ root = join(tmp, subdir);
103
+ mkdirSync(root);
104
+ }
105
+
106
+ // Site-specific docroot mappings the real web server provides via rewrites
107
+ // (e.g. BHP serves /assets from patterns/public/assets). Configured per
108
+ // target: sandbox.map = { "assets": "patterns/public/assets" }, paths
109
+ // relative to the real docroot.
110
+ const realDocroot = subdir ? parent : sitePath;
111
+ for (const [urlPath, realRelative] of Object.entries(pathMap)) {
112
+ link(join(realDocroot, realRelative), join(docroot, urlPath));
113
+ }
114
+
115
+ // Core: directories are linked read-only, but root-level PHP entry files
116
+ // (index.php, wp-load.php, wp-settings.php…) are COPIED. PHP resolves
117
+ // __DIR__ through symlinks to the real path, so a symlinked wp-load.php
118
+ // sets ABSPATH to the real install and loads the REAL wp-config.php —
119
+ // real database, real plugin stack. Copying keeps ABSPATH inside the
120
+ // sandbox; this is the isolation boundary, not an optimisation.
121
+ for (const entry of readdirSync(sitePath)) {
122
+ if (entry === 'wp-config.php' || entry === 'wp-content' || entry.startsWith('.')) {
123
+ continue;
124
+ }
125
+
126
+ const from = join(sitePath, entry);
127
+
128
+ if (entry.endsWith('.php')) {
129
+ cpSync(from, join(root, entry));
130
+ } else {
131
+ link(from, join(root, entry));
132
+ }
133
+ }
134
+
135
+ // wp-content: own directory; code linked, state owned.
136
+ const content = join(root, 'wp-content');
137
+ mkdirSync(join(content, 'uploads'), { recursive: true });
138
+ mkdirSync(join(root, 'database'));
139
+
140
+ for (const dir of ['themes', 'languages']) {
141
+ link(join(sitePath, 'wp-content', dir), join(content, dir));
142
+ }
143
+
144
+ // mu-plugins: a real directory with entries linked individually, so the
145
+ // sandbox can add its own helpers without touching the project's dir.
146
+ mkdirSync(join(content, 'mu-plugins'), { recursive: true });
147
+ const realMuPlugins = join(sitePath, 'wp-content', 'mu-plugins');
148
+ if (existsSync(realMuPlugins)) {
149
+ for (const entry of readdirSync(realMuPlugins)) {
150
+ if (!entry.startsWith('.')) {
151
+ link(join(realMuPlugins, entry), join(content, 'mu-plugins', entry));
152
+ }
153
+ }
154
+ }
155
+
156
+ // Plugins: allowlist only.
157
+ mkdirSync(join(content, 'plugins'), { recursive: true });
158
+ const realPlugins = join(sitePath, 'wp-content', 'plugins');
159
+ for (const plugin of pluginAllowlist) {
160
+ const from = join(realPlugins, plugin);
161
+ if (existsSync(from)) {
162
+ link(from, join(content, 'plugins', plugin));
163
+ } else {
164
+ console.warn(`⚠ sandbox: allowlisted plugin "${plugin}" not found in ${realPlugins}`);
165
+ }
166
+ }
167
+
168
+ // Observer: exposes template/controller/PHP-issue headers per request for
169
+ // the oracle assertions in pass 00. Sandbox-only, like all mu additions.
170
+ cpSync(
171
+ join(dirname(dirname(fileURLToPath(import.meta.url))), 'php', 'observer.php'),
172
+ join(content, 'mu-plugins', 'ab-shakedown-observer.php')
173
+ );
174
+
175
+ // Isolation witness: answers /?shakedown_whoami=1 with the paths this
176
+ // WordPress is actually running from, so boot can PROVE the sandbox is
177
+ // decoupled before any test traffic flows.
178
+ writeFileSync(join(content, 'mu-plugins', 'aa-shakedown-sandbox.php'), `<?php
179
+ /**
180
+ * Shakedown sandbox witness (sandbox-only; never installed on real sites).
181
+ */
182
+ if ( isset( $_GET['shakedown_whoami'] ) ) {
183
+ \theader( 'Content-Type: application/json' );
184
+ \techo json_encode( [
185
+ \t\t'abspath' => ABSPATH,
186
+ \t\t'content_dir' => WP_CONTENT_DIR,
187
+ \t\t'sqlite' => defined( 'DB_DIR' ) ? DB_DIR : null,
188
+ \t] );
189
+ \texit;
190
+ }
191
+ `);
192
+
193
+ // SQLite integration: plugin dir + the db.php drop-in built from its template.
194
+ const sqlite = ensureSqlitePlugin();
195
+ cpSync(sqlite, join(content, 'plugins', 'sqlite-database-integration'), { recursive: true });
196
+
197
+ const dropIn = readFileSync(join(sqlite, 'db.copy'), 'utf8')
198
+ .replaceAll('{SQLITE_IMPLEMENTATION_FOLDER_PATH}', join(content, 'plugins', 'sqlite-database-integration'))
199
+ .replaceAll('{SQLITE_MAIN_FILE}', join(content, 'plugins', 'sqlite-database-integration', 'load.php'));
200
+ writeFileSync(join(content, 'db.php'), dropIn);
201
+
202
+ return { docroot, root, subdir };
203
+ }
204
+
205
+ /**
206
+ * Write the sandbox's own wp-config.php.
207
+ */
208
+ function writeConfig(root, url, subdir = null) {
209
+ const siteUrl = subdir ? `${url}/${subdir}` : url;
210
+ writeFileSync(join(root, 'wp-config.php'), `<?php
211
+ // Shakedown sandbox — disposable. The real site's configuration is never read.
212
+ define( 'DB_NAME', 'shakedown' );
213
+ define( 'DB_USER', '' );
214
+ define( 'DB_PASSWORD', '' );
215
+ define( 'DB_HOST', '' );
216
+ define( 'DB_DIR', __DIR__ . '/database' );
217
+ define( 'DB_FILE', '.ht.sqlite' );
218
+ define( 'WP_CONTENT_DIR', __DIR__ . '/wp-content' );
219
+ define( 'WP_CONTENT_URL', '${siteUrl}/wp-content' );
220
+ define( 'WP_HOME', '${url}' );
221
+ define( 'WP_SITEURL', '${siteUrl}' );
222
+ define( 'WP_DEBUG', true );
223
+ define( 'WP_DEBUG_LOG', __DIR__ . '/debug.log' );
224
+ define( 'WP_DEBUG_DISPLAY', false );
225
+ define( 'WP_ENVIRONMENT_TYPE', 'local' );
226
+ define( 'DISABLE_WP_CRON', true );
227
+ define( 'AUTH_KEY', 'shakedown' );
228
+ define( 'SECURE_AUTH_KEY', 'shakedown' );
229
+ define( 'LOGGED_IN_KEY', 'shakedown' );
230
+ define( 'NONCE_KEY', 'shakedown' );
231
+ define( 'AUTH_SALT', 'shakedown' );
232
+ define( 'SECURE_AUTH_SALT', 'shakedown' );
233
+ define( 'LOGGED_IN_SALT', 'shakedown' );
234
+ define( 'NONCE_SALT', 'shakedown' );
235
+ $table_prefix = 'wp_';
236
+ if ( ! defined( 'ABSPATH' ) ) {
237
+ \tdefine( 'ABSPATH', __DIR__ . '/' );
238
+ }
239
+ require_once ABSPATH . 'wp-settings.php';
240
+ `);
241
+ }
242
+
243
+ /**
244
+ * Run WP-CLI against the sandbox.
245
+ */
246
+ function wp(root, args) {
247
+ return execFileSync('wp', [...args, `--path=${root}`], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
248
+ }
249
+
250
+ /**
251
+ * Boot a sandbox for a target: assemble, install, activate, serve.
252
+ *
253
+ * @param {{sitePath: string}} target Resolved shakedown target (real project).
254
+ * @param {{port?: number, theme?: string}} options
255
+ * @returns {{baseUrl: string, root: string, stop: () => void}}
256
+ */
257
+ export async function bootSandbox(target, options = {}) {
258
+ // An OS-assigned ephemeral port: fixed defaults collide with stale
259
+ // servers from crashed runs, which then answer for a deleted sandbox.
260
+ const port = options.port ?? (await freePort());
261
+ const url = `http://127.0.0.1:${port}`;
262
+
263
+ const allowlist = options.plugins ?? target.sandbox?.plugins ?? [];
264
+ const { docroot, root, subdir } = assemble(target.sitePath, allowlist, target.sandbox?.map ?? {});
265
+ writeConfig(root, url, subdir);
266
+
267
+ wp(root, [
268
+ 'core', 'install',
269
+ `--url=${url}`,
270
+ '--title=Shakedown Sandbox',
271
+ '--admin_user=shakedown',
272
+ '--admin_password=shakedown',
273
+ '--admin_email=sandbox@shakedown.test',
274
+ '--skip-email',
275
+ ]);
276
+
277
+ const theme = options.theme ?? detectChildTheme(target.sitePath);
278
+ if (theme) {
279
+ wp(root, ['theme', 'activate', theme]);
280
+ }
281
+
282
+ for (const plugin of allowlist) {
283
+ if (existsSync(join(root, 'wp-content', 'plugins', plugin))) {
284
+ wp(root, ['plugin', 'activate', plugin]);
285
+ }
286
+ }
287
+
288
+ wp(root, ['rewrite', 'structure', '/%postname%/', '--hard']);
289
+
290
+ const server = spawn('wp', ['server', `--host=127.0.0.1`, `--port=${port}`, `--docroot=${docroot}`, `--path=${root}`], {
291
+ stdio: 'ignore',
292
+ detached: false,
293
+ });
294
+
295
+ waitForServer(url);
296
+ assertIsolated(url, docroot, server);
297
+
298
+ return {
299
+ baseUrl: url,
300
+ root,
301
+ theme,
302
+ stop() {
303
+ server.kill();
304
+ rmSync(docroot, { recursive: true, force: true });
305
+ },
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Seed ACF state fixtures (populated + minimal per field group) inside a
311
+ * booted sandbox, via Muster. Only ever called for sandboxes — the isolation
312
+ * witness has already proven the database is throwaway before seeding runs.
313
+ *
314
+ * @param {{root: string, theme: string|null}} sandbox
315
+ * @param {string} musterAutoload Path to Muster's vendor/autoload.php.
316
+ * @param {{seed?: number, epoch?: string}} options Deterministic random and time inputs.
317
+ * @returns {array<{url: string, kind: string, expect: number}>} State routes.
318
+ */
319
+ export function seedAcfStates(
320
+ sandbox,
321
+ musterAutoload,
322
+ { seed = 42, epoch = DEFAULT_FIXTURE_EPOCH } = {}
323
+ ) {
324
+ if (!Number.isInteger(seed)) {
325
+ throw new TypeError('Muster fixture seed must be an integer.');
326
+ }
327
+ if (
328
+ typeof epoch !== 'string' ||
329
+ !ABSOLUTE_EPOCH_PATTERN.test(epoch) ||
330
+ Number.isNaN(Date.parse(epoch))
331
+ ) {
332
+ throw new TypeError('Muster fixture epoch must be a timezone-qualified ISO 8601 datetime.');
333
+ }
334
+
335
+ const acfJsonDir = join(sandbox.root, 'wp-content', 'themes', sandbox.theme ?? '', 'acf-json');
336
+
337
+ if (!sandbox.theme || !existsSync(acfJsonDir)) {
338
+ return [];
339
+ }
340
+
341
+ const script = join(dirname(dirname(new URL(import.meta.url).pathname)), 'bin', 'seed-states.php');
342
+ const out = execFileSync(
343
+ 'wp',
344
+ ['eval-file', script, musterAutoload, acfJsonDir, String(seed), epoch, `--path=${sandbox.root}`],
345
+ { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
346
+ );
347
+
348
+ const start = out.indexOf('{');
349
+ if (start === -1) {
350
+ throw new Error(`seed-states produced no JSON:\n${out.trim().slice(0, 500)}`);
351
+ }
352
+
353
+ return JSON.parse(out.slice(start)).routes;
354
+ }
355
+
356
+ /**
357
+ * Prove the served WordPress runs from the sandbox before any test traffic:
358
+ * ABSPATH and WP_CONTENT_DIR must point into the temp dir and the database
359
+ * must be the sandbox SQLite. If not, kill everything — a sandbox that can
360
+ * reach the real site's config/database must never be tested against.
361
+ *
362
+ * @param {string} url
363
+ * @param {string} root
364
+ * @param {import('node:child_process').ChildProcess} server
365
+ */
366
+ function assertIsolated(url, root, server) {
367
+ let who = {};
368
+
369
+ try {
370
+ who = JSON.parse(execFileSync('curl', ['-s', `${url}/?shakedown_whoami=1`], { encoding: 'utf8' }));
371
+ } catch {
372
+ // fall through to the failure below
373
+ }
374
+
375
+ // PHP reports realpaths; on macOS the temp dir arrives via the /var →
376
+ // /private/var symlink, so compare against the resolved root.
377
+ const real = realpathSync(root);
378
+ const inside = (p) => typeof p === 'string' && (p.startsWith(root) || p.startsWith(real));
379
+
380
+ if (!inside(who.content_dir) || !inside(who.sqlite)) {
381
+ server.kill();
382
+ rmSync(root, { recursive: true, force: true });
383
+ throw new Error(
384
+ `sandbox isolation check FAILED (got ${JSON.stringify(who)}) — refusing to run tests against a non-isolated WordPress.`
385
+ );
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Ask the OS for a free ephemeral port.
391
+ *
392
+ * @returns {Promise<number>}
393
+ */
394
+ function freePort() {
395
+ return new Promise((resolve, reject) => {
396
+ const server = createServer();
397
+ server.listen(0, '127.0.0.1', () => {
398
+ const { port } = server.address();
399
+ server.close(() => resolve(port));
400
+ });
401
+ server.on('error', reject);
402
+ });
403
+ }
404
+
405
+ /**
406
+ * The project's child theme: the theme directory whose style.css declares a
407
+ * Template (parent), preferring one matching a known PressGang parent.
408
+ *
409
+ * @param {string} sitePath
410
+ * @returns {string|null}
411
+ */
412
+ function detectChildTheme(sitePath) {
413
+ const themes = join(sitePath, 'wp-content', 'themes');
414
+ if (!existsSync(themes)) return null;
415
+
416
+ for (const dir of readdirSync(themes)) {
417
+ const css = join(themes, dir, 'style.css');
418
+ if (existsSync(css) && /^[\s*]*Template:\s*pressgang/m.test(readFileSync(css, 'utf8'))) {
419
+ return dir;
420
+ }
421
+ }
422
+
423
+ return null;
424
+ }
425
+
426
+ /**
427
+ * Poll until the sandbox answers (or fail after ~15s).
428
+ */
429
+ function waitForServer(url, attempts = 30) {
430
+ for (let i = 0; i < attempts; i++) {
431
+ try {
432
+ execFileSync('curl', ['-s', '-o', '/dev/null', '--max-time', '2', url]);
433
+ return;
434
+ } catch {
435
+ execFileSync('sleep', ['0.5']);
436
+ }
437
+ }
438
+
439
+ throw new Error(`Sandbox did not answer at ${url}`);
440
+ }
package/lib/target.mjs ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Target resolution: which site is under trial, and where is it?
3
+ *
4
+ * Resolution order:
5
+ * 1. `shakedown.config.json` found in the cwd or an ancestor directory.
6
+ * - With a `targets` map (central/multi-site shape): pick `--target`,
7
+ * $SHAKEDOWN_TARGET, or `defaultTarget`.
8
+ * - Otherwise the file is a single-target config for this theme.
9
+ * 2. Auto-detection fills any gaps: walk up from cwd to find wp-config.php
10
+ * (the WP-CLI `sitePath`), then ask WP-CLI for the home URL.
11
+ *
12
+ * A theme therefore needs NO config at all when its site is resolvable by
13
+ * WP-CLI and served at its home URL — config exists for overrides.
14
+ */
15
+ import { execFileSync } from 'node:child_process';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+
19
+ /**
20
+ * Find a file in `start` or the nearest ancestor directory.
21
+ *
22
+ * @param {string} start
23
+ * @param {string} name
24
+ * @returns {string|null} Absolute path of the containing directory, or null.
25
+ */
26
+ function findUp(start, name) {
27
+ let dir = start;
28
+ for (;;) {
29
+ if (existsSync(join(dir, name))) return dir;
30
+ const parent = dirname(dir);
31
+ if (parent === dir) return null;
32
+ dir = parent;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Ask WP-CLI for the site's home URL.
38
+ *
39
+ * @param {string} sitePath
40
+ * @returns {string}
41
+ */
42
+ function detectBaseUrl(sitePath) {
43
+ const out = execFileSync('wp', ['option', 'get', 'home', `--path=${sitePath}`, '--skip-plugins', '--skip-themes'], {
44
+ encoding: 'utf8',
45
+ });
46
+ const lines = out.trim().split('\n');
47
+
48
+ return lines[lines.length - 1].trim().replace(/\/$/, '');
49
+ }
50
+
51
+ /**
52
+ * Resolve the target for a shakedown run.
53
+ *
54
+ * @param {string} cwd Directory the CLI was invoked from (the workspace).
55
+ * @param {{target?: string}} flags
56
+ * @param {{requireBaseUrl?: boolean}} options Sandbox runs serve their own
57
+ * URL, so they resolve targets without needing the real site to answer
58
+ * WP-CLI (a bare core checkout in CI has no installed database).
59
+ * @returns {{name: string, sitePath: string, baseUrl: string, samplesPerType: number, searchTerm: string}}
60
+ */
61
+ export function resolveTarget(cwd, flags = {}, { requireBaseUrl = true } = {}) {
62
+ let config = {};
63
+ let name = 'auto';
64
+
65
+ const configDir = findUp(cwd, 'shakedown.config.json');
66
+ if (configDir) {
67
+ const file = JSON.parse(readFileSync(join(configDir, 'shakedown.config.json'), 'utf8'));
68
+
69
+ if (file.targets) {
70
+ name = flags.target || process.env.SHAKEDOWN_TARGET || file.defaultTarget;
71
+ config = file.targets[name];
72
+ if (!config) {
73
+ throw new Error(`Unknown target "${name}". Available: ${Object.keys(file.targets).join(', ')}`);
74
+ }
75
+ } else {
76
+ config = file;
77
+ }
78
+ }
79
+
80
+ const sitePath = config.sitePath ?? findUp(cwd, 'wp-config.php');
81
+ if (!sitePath) {
82
+ throw new Error(
83
+ 'No WordPress found: no sitePath in shakedown.config.json and no wp-config.php in any ancestor directory.'
84
+ );
85
+ }
86
+
87
+ const baseUrl = (config.baseUrl ?? (requireBaseUrl ? detectBaseUrl(sitePath) : 'http://sandbox.invalid')).replace(/\/$/, '');
88
+
89
+ return {
90
+ name,
91
+ sitePath,
92
+ baseUrl,
93
+ samplesPerType: config.samplesPerType ?? 2,
94
+ searchTerm: config.searchTerm ?? 'test',
95
+ // Sandbox policy, e.g. { "plugins": ["contact-form-7"] } — the plugin
96
+ // allowlist for throwaway environments (default: none).
97
+ sandbox: config.sandbox ?? {},
98
+ };
99
+ }