diff --git a/docs/superpowers/plans/2026-05-08-envlite.md b/docs/superpowers/plans/2026-05-08-envlite.md new file mode 100644 index 0000000000000..67b17284914ae --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-envlite.md @@ -0,0 +1,2293 @@ +# envlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement `tools/local-env/envlite.php` per `plans/ENVLITE_SPECIFICATION.md` — a single-file PHP tool that takes a clean wordpress-develop checkout and brings it to a state where the PHP built-in server serves WordPress against SQLite and `phpunit --group html-api` runs green, without Docker or system MySQL. + +**Architecture:** One PHP file, namespaced via `envlite_*` function prefix. Pure helpers (port hash, manifest parse, placeholder replacement, ownership decision) are testable in isolation; I/O-heavy phases take a `$repoRoot` parameter so tests can drive them in temp dirs. The bottom of the file guards `envlite_main()` execution with `if (!defined('ENVLITE_NO_AUTORUN') && realpath(...) === __FILE__)`, so tests can `require` the file without triggering CLI dispatch. State (port cache, manifest) lives at `.envlite/`. Atomic writes go through a single helper that hashes in-memory bytes, writes to `.tmp`, fsyncs, and renames. + +**Tech Stack:** PHP ≥ 7.4, no library dependencies. PHP standard extensions (`pdo_sqlite`, `sqlite3`, `openssl`, `simplexml`, `zip`, `hash`). Subprocesses limited to `node`/`npm`/`composer`/`php` (via `proc_open` with array commands, no shell). Network via `file_get_contents` with stream contexts. Tests are plain PHP scripts using a tiny in-tree harness — no PHPUnit dependency, since envlite must build itself before composer-installed PHPUnit is available. + +--- + +## File Structure + +**Created by this plan:** + +| Path | Responsibility | +|---|---| +| `tools/local-env/envlite.php` | The whole tool: CLI dispatch, all 9 phases, state management. | +| `tools/local-env/tests/harness.php` | Tiny test harness (~40 lines): test discovery, `envlite_assert*` helpers, runner. | +| `tools/local-env/tests/run.php` | Test entry point — defines `ENVLITE_NO_AUTORUN`, requires `envlite.php`, runs all `test_*.php` files in this directory. | +| `tools/local-env/tests/test_dispatch.php` | Tests for CLI dispatch and help output (Task 1). | +| `tools/local-env/tests/test_logging.php` | Tests for diagnostic prefix formatting (Task 2). | +| `tools/local-env/tests/test_paths.php` | Tests for path canonicalization (Task 3). | +| `tools/local-env/tests/test_manifest.php` | Tests for manifest read/write (Task 4). | +| `tools/local-env/tests/test_atomic.php` | Tests for atomic file write (Task 5). | +| `tools/local-env/tests/test_ownership.php` | Tests for ownership decisions (Task 6). | +| `tools/local-env/tests/test_prompt.php` | Tests for prompt helper (Task 7). | +| `tools/local-env/tests/test_phase0.php` | Tests for preflight pure helpers (Task 8). | +| `tools/local-env/tests/test_phase1.php` | Tests for port discovery (Task 9). | +| `tools/local-env/tests/test_proc.php` | Tests for the subprocess helper (Task 10). | +| `tools/local-env/tests/test_phase5.php` | Tests for SQLite drop-in helpers (Task 12). | +| `tools/local-env/tests/test_phase6.php` | Tests for wp-tests-config.php generation (Task 13). | +| `tools/local-env/tests/test_phase7.php` | Tests for wp-config.php generation (Task 14). | +| `tools/local-env/tests/test_clean.php` | Tests for clean ordering (Task 18). | +| `tools/local-env/tests/test_smoke.php` | End-to-end smoke test in a fixture dir (Task 19). | + +`envlite.php` will grow to ~700 lines. The single-file constraint comes from the spec; do not split it. + +**Modified by this plan:** + +- `package.json` — adds an `envlite` npm script (convenience wrapper around `php tools/local-env/envlite.php`). +- `.gitignore` — ignores the `/.envlite/` state directory. + +No edits to `composer.json`, `phpunit.xml.dist`, or `wp-config-sample.php`/`wp-tests-config-sample.php` (envlite reads the samples and writes adjacent files in their own paths). + +--- + +## Spec → Task Map + +| Spec section | Task | +|---|---| +| CLI interface, exit codes, diagnostic output | 1, 2 | +| State directory, manifest, atomic writes, file-write conventions | 3, 4, 5 | +| Destructive operations and prompts, ownership decisions | 6, 7 | +| Phase 0 — Preflight | 8 | +| Phase 1 — Port discovery | 9 | +| Phases 2/3/4 — npm ci, build:dev, composer install | 10, 11 | +| Phase 5 — SQLite drop-in | 12 | +| Phase 6 — wp-tests-config.php | 13 | +| Phase 7 — src/wp-config.php | 14 | +| `init` orchestration, `.ht.sqlite` observation | 16 | +| `serve` subcommand | 17 | +| `clean` subcommand | 18 | +| End-to-end smoke | 19 | + +--- + +## Conventions used by every task + +- Run all tests after each task: `php tools/local-env/tests/run.php`. Expected line at end: `N tests, 0 failures`. +- Commit at the end of every task. Use Conventional Commits (`feat:`, `test:`, `refactor:`). One commit per task. +- All envlite-authored text writes use LF only (hard-code `"\n"`, never `PHP_EOL`), no BOM, single trailing newline. (Spec §"File-write conventions".) +- Function names are prefixed `envlite_`. No classes, no namespaces — a single PHP file with top-level functions. +- Constants are top-level: `ENVLITE_VERSION`, `ENVLITE_PORT_LOW`, `ENVLITE_PORT_POOL_SIZE`, `ENVLITE_SQLITE_PLUGIN_SHA256`, `ENVLITE_SQLITE_PLUGIN_URL`, `ENVLITE_SALT_URL`. + +--- + +### Task 1: Skeleton, test harness, CLI dispatch, help + +**Files:** +- Create: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/harness.php` +- Create: `tools/local-env/tests/run.php` +- Create: `tools/local-env/tests/test_dispatch.php` + +- [ ] **Step 1: Write the test harness** + +Write `tools/local-env/tests/harness.php` with this exact content: + +```php + str_starts_with($fn, 'test_') + )); + sort($tests); + $failures = 0; + foreach ($tests as $fn) { + try { + $fn(); + fwrite(STDERR, "PASS $fn\n"); + } catch (\Throwable $e) { + $failures++; + fwrite(STDERR, "FAIL $fn: " . $e->getMessage() . "\n"); + } + } + fwrite(STDERR, count($tests) . " tests, $failures failures\n"); + return $failures === 0 ? 0 : 1; +} +``` + +- [ ] **Step 2: Write the test runner** + +Write `tools/local-env/tests/run.php`: + +```php + [args]\n" + . "\n" + . "Subcommands:\n" + . " init [--port=N] [--no-build] Run all setup phases.\n" + . " serve Run the dev server on the cached port.\n" + . " clean Remove envlite-managed files.\n" + . " help Print this help.\n" + . "\n" + . "Global flags:\n" + . " --force Disable interactive prompts.\n"; +} + +function envlite_main(array $argv): int { + array_shift($argv); // drop script name + $force = false; + $rest = []; + foreach ($argv as $a) { + if ($a === '--force') { $force = true; continue; } + $rest[] = $a; + } + $sub = $rest[0] ?? 'help'; + $args = array_slice($rest, 1); + + if ($sub === 'help' || $sub === '--help' || $sub === '-h') { + fwrite(STDERR, envlite_help_text()); + return 0; + } + if ($sub === 'init') { return envlite_cmd_init($args, $force); } + if ($sub === 'serve') { return envlite_cmd_serve($args, $force); } + if ($sub === 'clean') { return envlite_cmd_clean($args, $force); } + + fwrite(STDERR, "envlite: unknown subcommand: $sub\n"); + return 2; +} + +function envlite_cmd_init(array $args, bool $force): int { + fwrite(STDERR, "envlite init: not implemented\n"); + return 1; +} + +function envlite_cmd_serve(array $args, bool $force): int { + fwrite(STDERR, "envlite serve: not implemented\n"); + return 1; +} + +function envlite_cmd_clean(array $args, bool $force): int { + fwrite(STDERR, "envlite clean: not implemented\n"); + return 1; +} + +if (!defined('ENVLITE_NO_AUTORUN') && isset($_SERVER['SCRIPT_FILENAME']) + && realpath($_SERVER['SCRIPT_FILENAME']) === realpath(__FILE__)) { + exit(envlite_main($_SERVER['argv'])); +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `3 tests, 0 failures`. + +- [ ] **Step 7: Smoke check — run the script directly** + +Run: `php tools/local-env/envlite.php --help 2>&1 | head -3` +Expected: First line `envlite — wordpress-develop dev environment setup`. + +Run: `php tools/local-env/envlite.php bogus; echo "exit=$?"` +Expected: stderr `envlite: unknown subcommand: bogus`, then `exit=2`. + +- [ ] **Step 8: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/ +git commit -m "feat(envlite): scaffold CLI dispatch and test harness" +``` + +--- + +### Task 2: Diagnostic logging helpers + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_logging.php` + +The spec mandates two stderr prefix forms: +- `envlite: ` — pre-subcommand errors. +- `envlite : ` — once a subcommand is running. + +Phase failures inside `init` use `envlite init: phase N: `. + +- [ ] **Step 1: Write the failing test** + +Write `tools/local-env/tests/test_logging.php`: + +```php +getMessage(), 'outside repo root')); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php tools/local-env/tests/run.php`. Expected: 3 new failures. + +- [ ] **Step 3: Implement** + +Add to `envlite.php`: + +```php +function envlite_path_to_posix(string $path): string { + return str_replace('\\', '/', $path); +} + +function envlite_path_relative_to(string $root, string $abs): string { + $root = rtrim(envlite_path_to_posix($root), '/'); + $abs = envlite_path_to_posix($abs); + if ($abs === $root) { return ''; } + if (str_starts_with($abs, $root . '/')) { + return substr($abs, strlen($root) + 1); + } + throw new \InvalidArgumentException("path outside repo root: $abs"); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `10 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_paths.php +git commit -m "feat(envlite): add POSIX path utilities" +``` + +--- + +### Task 4: Manifest read/write + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_manifest.php` + +Spec §"envlite state directory": manifest format is ` `, two-space delimiter. We model the manifest as an ordered associative array `path => hash` (preserving insertion order, which `clean` walks in reverse). + +- [ ] **Step 1: Write the failing tests** + +Write `tools/local-env/tests/test_manifest.php`: + +```php + 'a3f1c8b2' . str_repeat('0', 56), + 'src/wp-config.php' => str_repeat('b', 64), + 'src/wp-content/plugins/sqlite-database-integration' => 'dir', + ]; + envlite_manifest_save($dir, $entries); + envlite_assert_eq($entries, envlite_manifest_load($dir)); + // Order must round-trip. + envlite_assert_eq(array_keys($entries), array_keys(envlite_manifest_load($dir))); +} + +function test_manifest_save_emits_lf_only() { + $dir = envlite_test_tmpdir('manifest-lf'); + mkdir($dir . '/.envlite'); + envlite_manifest_save($dir, ['src/wp-config.php' => str_repeat('a', 64)]); + $bytes = file_get_contents($dir . '/.envlite/manifest'); + envlite_assert(strpos($bytes, "\r") === false, 'manifest must not contain CR'); + envlite_assert(str_ends_with($bytes, "\n"), 'manifest must end with LF'); +} + +function test_manifest_load_skips_blank_and_malformed_lines() { + $dir = envlite_test_tmpdir('manifest-malformed'); + mkdir($dir . '/.envlite'); + file_put_contents( + $dir . '/.envlite/manifest', + str_repeat('a', 64) . " src/wp-config.php\n" . + "\n" . + "garbage line\n" . + "dir some/dir\n" + ); + $loaded = envlite_manifest_load($dir); + envlite_assert_eq(['src/wp-config.php' => str_repeat('a', 64), 'some/dir' => 'dir'], $loaded); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php tools/local-env/tests/run.php`. Expected: 4 new failures. + +- [ ] **Step 3: Implement** + +Add to `envlite.php`. (`envlite_atomic_write` lands in Task 5; until then, use `file_put_contents` with rename.) + +```php +function envlite_manifest_path(string $repoRoot): string { + return rtrim(envlite_path_to_posix($repoRoot), '/') . '/.envlite/manifest'; +} + +function envlite_manifest_load(string $repoRoot): array { + $path = envlite_manifest_path($repoRoot); + if (!is_file($path)) { return []; } + $entries = []; + foreach (explode("\n", file_get_contents($path)) as $line) { + $line = rtrim($line, "\r"); + if ($line === '') { continue; } + // Two-space delimiter. Hash field is exactly 64 hex chars or the literal "dir". + if (!preg_match('/^([0-9a-f]{64}|dir) (.+)$/', $line, $m)) { + continue; // malformed, skip + } + $entries[$m[2]] = $m[1]; + } + return $entries; +} + +function envlite_manifest_save(string $repoRoot, array $entries): void { + $lines = ''; + foreach ($entries as $path => $hash) { + $lines .= "$hash $path\n"; + } + $manifestPath = envlite_manifest_path($repoRoot); + $dir = dirname($manifestPath); + if (!is_dir($dir)) { mkdir($dir, 0700, true); } + // TODO Task 5: use envlite_atomic_write here. + $tmp = $manifestPath . '.tmp'; + file_put_contents($tmp, $lines); + rename($tmp, $manifestPath); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `14 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_manifest.php +git commit -m "feat(envlite): add manifest read/write" +``` + +--- + +### Task 5: Atomic file write + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_atomic.php` + +Spec §"Atomic writes": hash bytes in memory, write to `.tmp`, fsync, rename. The hash returned is the manifest's source of truth; we never `hash_file()` the renamed target. + +- [ ] **Step 1: Write the failing tests** + +Write `tools/local-env/tests/test_atomic.php`: + +```php + $path"); + } + return $hash; +} +``` + +Now switch `envlite_manifest_save` to use it. Replace its tail with: + +```php + envlite_atomic_write($manifestPath, $lines); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `18 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_atomic.php +git commit -m "feat(envlite): add atomic file write helper" +``` + +--- + +### Task 6: Ownership decisions + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_ownership.php` + +Spec §"Ownership decisions": the manifest plus current bytes determine one of four states. This is the policy gate consulted by Phases 5–7 before any write. + +- [ ] **Step 1: Write the failing tests** + +Write `tools/local-env/tests/test_ownership.php`: + +```php + $hash], 'src/wp-config.php', $bytes) + ); +} + +function test_ownership_owned_drifted() { + envlite_assert_eq( + 'owned_drifted', + envlite_ownership( + ['src/wp-config.php' => str_repeat('a', 64)], + 'src/wp-config.php', + 'different bytes' + ) + ); +} + +function test_ownership_unowned() { + envlite_assert_eq( + 'unowned', + envlite_ownership([], 'src/wp-config.php', "user-authored\n") + ); +} + +function test_ownership_dir_entry_in_manifest() { + // For directory entries, the "current bytes" is null; presence on disk + // makes it owned_clean (we don't drift-check directory contents). + envlite_assert_eq( + 'owned_clean', + envlite_ownership( + ['src/wp-content/plugins/sqlite-database-integration' => 'dir'], + 'src/wp-content/plugins/sqlite-database-integration', + null + ) + ); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php tools/local-env/tests/run.php`. Expected: 5 new failures. + +- [ ] **Step 3: Implement** + +Add to `envlite.php`: + +```php +/** + * @param array $manifest path => sha256-hex|"dir" + * @param string|null $currentBytes Null if the file/dir does not exist on disk + * or is a directory entry whose contents we don't drift-check. + * @return 'absent'|'owned_clean'|'owned_drifted'|'unowned' + */ +function envlite_ownership(array $manifest, string $relPath, ?string $currentBytes): string { + $recorded = $manifest[$relPath] ?? null; + if ($currentBytes === null && $recorded === null) { return 'absent'; } + if ($recorded === null) { return 'unowned'; } + if ($recorded === 'dir') { return 'owned_clean'; } + if ($currentBytes === null) { + // Recorded as file but currentBytes wasn't provided — caller missed reading it. + // Treat as drifted; safer to prompt. + return 'owned_drifted'; + } + return hash('sha256', $currentBytes) === $recorded ? 'owned_clean' : 'owned_drifted'; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `23 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_ownership.php +git commit -m "feat(envlite): add manifest-anchored ownership decisions" +``` + +--- + +### Task 7: Prompt helper + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_prompt.php` + +Spec §"Destructive operations and prompts". The helper combines TTY detection, `--force`, the EOF-as-N rule, the non-interactive abort, and drift-prompt formatting (with hash preview). + +We split the helper in two for testability: `envlite_format_prompt()` produces the exact prompt string; `envlite_prompt()` does I/O. Tests cover the formatter directly and use a stream-injection variant of `envlite_prompt()` for I/O. + +- [ ] **Step 1: Write the failing tests** + +Write `tools/local-env/tests/test_prompt.php`: + +```php + immediate EOF + $err = fopen('php://memory', 'w'); + envlite_assert_eq(false, envlite_prompt_io( + false, true, 'init', 'overwrite', 'x', null, null, $in, $err + )); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php tools/local-env/tests/run.php`. Expected: 6 new failures. + +- [ ] **Step 3: Implement** + +Add to `envlite.php`: + +```php +function envlite_format_prompt( + string $subcommand, + string $operation, // unused for now; kept so future ops can specialize wording + string $relPath, + ?string $recordedHash, + ?string $currentHash +): string { + if ($recordedHash !== null && $currentHash !== null) { + $rec = substr($recordedHash, 0, 8); + $cur = substr($currentHash, 0, 8); + $body = "envlite owns $relPath but content has drifted (recorded {$rec}…, current {$cur}…). Overwrite?"; + } else { + $body = "not envlite-owned: $relPath. Overwrite?"; + } + return "envlite $subcommand: $body [y/N] "; +} + +/** + * Pure-IO variant for testability. Production code calls envlite_prompt() below. + */ +function envlite_prompt_io( + bool $force, + bool $isTty, + string $subcommand, + string $operation, + string $relPath, + ?string $recordedHash, + ?string $currentHash, + $stdin, + $stderr +): bool { + if ($force) { return true; } + if (!$isTty) { + fwrite($stderr, envlite_format_log( + null, + "non-interactive context and --force not given; aborting at $operation on $relPath" + )); + return false; + } + fwrite($stderr, envlite_format_prompt($subcommand, $operation, $relPath, $recordedHash, $currentHash)); + $line = fgets($stdin); + if ($line === false) { return false; } + $resp = strtolower(trim($line)); + return $resp === 'y' || $resp === 'yes'; +} + +/** + * Production wrapper. Returns true=overwrite, false=skip. On non-interactive + * abort the caller must exit 5 — see callers. + */ +function envlite_prompt( + bool $force, + string $subcommand, + string $operation, + string $relPath, + ?string $recordedHash, + ?string $currentHash +): bool { + return envlite_prompt_io( + $force, + stream_isatty(STDIN), + $subcommand, + $operation, + $relPath, + $recordedHash, + $currentHash, + STDIN, + STDERR + ); +} + +/** + * Convenience: returns true if the caller should proceed with the write. + * On non-interactive abort, exits 5 directly (matches spec). + */ +function envlite_prompt_or_abort( + bool $force, + string $subcommand, + string $operation, + string $relPath, + ?string $recordedHash, + ?string $currentHash +): bool { + if ($force) { return true; } + if (!stream_isatty(STDIN)) { + envlite_log(null, "non-interactive context and --force not given; aborting at $operation on $relPath"); + exit(5); + } + $ok = envlite_prompt($force, $subcommand, $operation, $relPath, $recordedHash, $currentHash); + if (!$ok) { exit(5); } + return true; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `29 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_prompt.php +git commit -m "feat(envlite): add interactive prompt helper" +``` + +--- + +### Task 8: Phase 0 — Preflight + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_phase0.php` + +Spec §"Phase 0 — Preflight". Six checks: +1. CWD is a wordpress-develop checkout (file presence). +2. PHP_VERSION_ID ≥ 70400. +3. Required PHP extensions loaded. +4. `node`, `npm`, `composer` present and meet minimum versions. + +We expose pure helpers for the parts that have a return value (file-check, version compare). The integration parts (extension check, tool check) call them. + +- [ ] **Step 1: Write the failing tests** + +Write `tools/local-env/tests/test_phase0.php`: + +```php + local-env/ -> tools/ -> repo + envlite_assert(envlite_phase0_is_wordpress_develop($root), "expected $root to be a WP-develop checkout"); +} + +function test_phase0_cwd_check_fails_for_random_dir() { + $dir = envlite_test_tmpdir('phase0-bogus'); + envlite_assert(!envlite_phase0_is_wordpress_develop($dir)); +} + +function test_phase0_parse_version_node() { + envlite_assert_eq([20, 10, 0], envlite_phase0_parse_version('v20.10.0')); + envlite_assert_eq([22, 5, 1], envlite_phase0_parse_version('v22.5.1\n')); +} + +function test_phase0_parse_version_npm() { + envlite_assert_eq([10, 2, 4], envlite_phase0_parse_version('10.2.4')); +} + +function test_phase0_parse_version_composer() { + envlite_assert_eq([2, 7, 1], envlite_phase0_parse_version('Composer version 2.7.1 2024-02-09 15:26:28')); +} + +function test_phase0_version_meets_minimum() { + envlite_assert(envlite_phase0_version_ge([20, 10, 0], [20, 10, 0])); + envlite_assert(envlite_phase0_version_ge([20, 10, 1], [20, 10, 0])); + envlite_assert(envlite_phase0_version_ge([21, 0, 0], [20, 10, 0])); + envlite_assert(!envlite_phase0_version_ge([20, 9, 0], [20, 10, 0])); + envlite_assert(!envlite_phase0_version_ge([19, 99, 99], [20, 10, 0])); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php tools/local-env/tests/run.php`. Expected: 6 new failures. + +- [ ] **Step 3: Implement preflight** + +Add to `envlite.php`: + +```php +const ENVLITE_REPO_MARKERS = [ + 'package.json', + 'composer.json', + 'wp-config-sample.php', + 'wp-tests-config-sample.php', + 'src/wp-includes', + 'tests/phpunit/includes/bootstrap.php', +]; + +function envlite_phase0_is_wordpress_develop(string $root): bool { + foreach (ENVLITE_REPO_MARKERS as $m) { + if (!file_exists($root . '/' . $m)) { return false; } + } + return true; +} + +/** Extracts [major, minor, patch] from any string containing a `\d+\.\d+\.\d+` substring. */ +function envlite_phase0_parse_version(string $output): array { + if (!preg_match('/(\d+)\.(\d+)\.(\d+)/', $output, $m)) { + throw new \RuntimeException("could not parse version from: " . trim($output)); + } + return [(int)$m[1], (int)$m[2], (int)$m[3]]; +} + +function envlite_phase0_version_ge(array $a, array $b): bool { + for ($i = 0; $i < 3; $i++) { + if ($a[$i] > $b[$i]) { return true; } + if ($a[$i] < $b[$i]) { return false; } + } + return true; +} + +/** + * Returns null on missing tool (proc_open failure / nonzero exit / unparseable + * output). Returns [major, minor, patch] otherwise. The version flag arg + * accommodates `--version` (npm/composer) and `-v` if a future tool prefers it. + */ +function envlite_phase0_tool_version(array $cmd): ?array { + $proc = @proc_open( + $cmd, + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + if (!is_resource($proc)) { return null; } + fclose($pipes[0]); + $out = stream_get_contents($pipes[1]); + $err = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + $exit = proc_close($proc); + if ($exit !== 0) { return null; } + try { + return envlite_phase0_parse_version($out !== '' ? $out : $err); + } catch (\Throwable $e) { + return null; + } +} + +/** Runs all preflight checks. Calls envlite_log and exits 3 on first failure. */ +function envlite_phase0_run(string $repoRoot): void { + if (!envlite_phase0_is_wordpress_develop($repoRoot)) { + envlite_log(null, "preflight: $repoRoot is not a wordpress-develop checkout"); + exit(3); + } + if (PHP_VERSION_ID < 70400) { + envlite_log(null, 'preflight: PHP ' . PHP_VERSION . ' is below the 7.4 floor'); + exit(3); + } + foreach (['pdo_sqlite', 'sqlite3', 'openssl', 'simplexml', 'zip'] as $ext) { + if (!extension_loaded($ext)) { + envlite_log(null, "preflight: required PHP extension missing: $ext"); + exit(3); + } + } + $tools = [ + ['node', ['node', '--version'], [20, 10, 0]], + ['npm', ['npm', '--version'], [10, 2, 0]], + ['composer', ['composer', '--version'], [2, 0, 0]], + ]; + foreach ($tools as [$name, $cmd, $min]) { + $ver = envlite_phase0_tool_version($cmd); + if ($ver === null) { + envlite_log(null, "preflight: $name not found or did not report a version"); + exit(3); + } + if (!envlite_phase0_version_ge($ver, $min)) { + $vstr = implode('.', $ver); + $mstr = implode('.', $min); + envlite_log(null, "preflight: $name $vstr is below the $mstr minimum"); + exit(3); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `35 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_phase0.php +git commit -m "feat(envlite): implement Phase 0 preflight" +``` + +--- + +### Task 9: Phase 1 — Port discovery + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_phase1.php` + +Spec §"Phase 1 — Port discovery". Pool 8100–8899; CRC32 of `realpath()` for the seed; cache at `.envlite/port`. `port_is_free` is a real bind/close probe. + +- [ ] **Step 1: Write the failing tests** + +Write `tools/local-env/tests/test_phase1.php`: + +```php += 8100 && $port <= 8899, "port $port out of pool"); +} + +function test_phase1_port_seed_deterministic() { + envlite_assert_eq( + envlite_phase1_seed_port('/Users/jonsurrell/foo'), + envlite_phase1_seed_port('/Users/jonsurrell/foo') + ); +} + +function test_phase1_port_seed_differs_for_different_paths() { + // Not a strong claim, but two paths should at least sometimes differ. + $a = envlite_phase1_seed_port('/a'); + $b = envlite_phase1_seed_port('/b'); + $c = envlite_phase1_seed_port('/abcdef'); + envlite_assert(count(array_unique([$a, $b, $c])) >= 2, 'expected some variation'); +} + +function test_phase1_port_is_free_on_random_high_port() { + // Pick a port we expect free; in a CI sandbox this is best-effort but + // 53219 is unlikely to be bound. If it is, the test reports it. + $p = 53219; + envlite_assert(envlite_phase1_port_is_free($p), "port $p unexpectedly in use"); +} + +function test_phase1_port_is_free_returns_false_when_bound() { + $sock = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + envlite_assert(is_resource($sock), "could not bind probe socket: $errstr"); + $name = stream_socket_get_name($sock, false); // "127.0.0.1:NNNN" + [, $port] = explode(':', $name); + envlite_assert(!envlite_phase1_port_is_free((int)$port), "expected $port reported in-use"); + fclose($sock); +} + +function test_phase1_uses_cached_port_when_in_range() { + $dir = envlite_test_tmpdir('phase1-cache'); + mkdir($dir . '/.envlite'); + file_put_contents($dir . '/.envlite/port', "8421\n"); + envlite_assert_eq(8421, envlite_phase1_discover_port($dir, null)); +} + +function test_phase1_ignores_cache_when_out_of_range() { + $dir = envlite_test_tmpdir('phase1-bad-cache'); + mkdir($dir . '/.envlite'); + file_put_contents($dir . '/.envlite/port', "9999\n"); + $port = envlite_phase1_discover_port($dir, null); + envlite_assert($port >= 8100 && $port <= 8899); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php tools/local-env/tests/run.php`. Expected: 7 new failures. + +- [ ] **Step 3: Implement** + +Add to `envlite.php`: + +```php +const ENVLITE_PORT_LOW = 8100; +const ENVLITE_PORT_POOL_SIZE = 800; + +function envlite_phase1_seed_port(string $absPath): int { + // hash('crc32b') is unsigned and 8 hex chars; substr(-7) is 28 bits, fits in PHP int even on 32-bit. + $digest = hash('crc32b', $absPath); + $seed = hexdec(substr($digest, -7)); + return ENVLITE_PORT_LOW + ($seed % ENVLITE_PORT_POOL_SIZE); +} + +function envlite_phase1_port_is_free(int $port): bool { + $sock = @stream_socket_server("tcp://127.0.0.1:$port", $errno, $errstr); + if (!is_resource($sock)) { return false; } + fclose($sock); + return true; +} + +function envlite_phase1_discover_port(string $repoRoot, ?int $explicitPort): int { + $cachePath = rtrim(envlite_path_to_posix($repoRoot), '/') . '/.envlite/port'; + + if ($explicitPort !== null) { + if (!envlite_phase1_port_is_free($explicitPort)) { + envlite_log('init', "phase 1: port $explicitPort is in use; try a different --port (e.g. lsof -nP -iTCP:$explicitPort -sTCP:LISTEN)"); + exit(1); + } + envlite_phase1_write_cache($repoRoot, $explicitPort); + return $explicitPort; + } + + if (is_file($cachePath)) { + $cached = (int) trim(file_get_contents($cachePath)); + if ($cached >= ENVLITE_PORT_LOW && $cached <= ENVLITE_PORT_LOW + ENVLITE_PORT_POOL_SIZE - 1) { + return $cached; + } + // out of range: fall through to re-pick + } + + $start = envlite_phase1_seed_port(realpath($repoRoot) ?: $repoRoot); + for ($i = 0; $i < ENVLITE_PORT_POOL_SIZE; $i++) { + $cand = ENVLITE_PORT_LOW + ((($start - ENVLITE_PORT_LOW) + $i) % ENVLITE_PORT_POOL_SIZE); + if (envlite_phase1_port_is_free($cand)) { + envlite_phase1_write_cache($repoRoot, $cand); + return $cand; + } + } + envlite_log('init', 'phase 1: no free port in 8100-8899'); + exit(1); +} + +function envlite_phase1_write_cache(string $repoRoot, int $port): void { + $cachePath = rtrim(envlite_path_to_posix($repoRoot), '/') . '/.envlite/port'; + $hash = envlite_atomic_write($cachePath, "$port\n"); + $manifest = envlite_manifest_load($repoRoot); + $manifest['.envlite/port'] = $hash; + envlite_manifest_save($repoRoot, $manifest); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `42 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_phase1.php +git commit -m "feat(envlite): implement Phase 1 port discovery" +``` + +--- + +### Task 10: Subprocess helper + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_proc.php` + +We need one helper that streams a child's stdout/stderr to the user's terminal (Phases 2/3/4) and one that captures output (Phase 0 already used `proc_open` ad-hoc; refactor to share). Keep them small; both pass the command as an array (no shell). + +- [ ] **Step 1: Write the failing tests** + +Write `tools/local-env/tests/test_proc.php`: + +```php + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $cwd + ); + if (!is_resource($proc)) { return [-1, '', '']; } + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + $exit = proc_close($proc); + return [$exit, $stdout ?: '', $stderr ?: '']; +} + +/** Streaming variant: child stdio inherits the parent's. Used by Phases 2/3/4 and `serve`. */ +function envlite_proc_stream(array $cmd, ?string $cwd = null): int { + $proc = @proc_open($cmd, [0 => STDIN, 1 => STDOUT, 2 => STDERR], $pipes, $cwd); + if (!is_resource($proc)) { return -1; } + return proc_close($proc); +} +``` + +Now refactor `envlite_phase0_tool_version` to use `envlite_proc_capture`: + +```php +function envlite_phase0_tool_version(array $cmd): ?array { + [$exit, $stdout, $stderr] = envlite_proc_capture($cmd); + if ($exit !== 0) { return null; } + try { + return envlite_phase0_parse_version($stdout !== '' ? $stdout : $stderr); + } catch (\Throwable $e) { + return null; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `45 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_proc.php +git commit -m "feat(envlite): add subprocess helper and refactor Phase 0" +``` + +--- + +### Task 11: Phases 2, 3, 4 — npm ci, build:dev, composer install + +**Files:** +- Modify: `tools/local-env/envlite.php` + +These three phases are nearly identical: spawn a known command, stream its stdio, exit nonzero on failure. No tests for them — they're trivial wrappers around `envlite_proc_stream` and end-to-end smoke (Task 19) covers them. + +- [ ] **Step 1: Implement the three phase wrappers** + +Add to `envlite.php`: + +```php +function envlite_phase2_npm_ci(string $repoRoot): void { + $exit = envlite_proc_stream(['npm', 'ci'], $repoRoot); + if ($exit !== 0) { + envlite_log('init', "phase 2: npm ci failed (exit $exit)"); + exit(1); + } +} + +function envlite_phase3_build_dev(string $repoRoot): void { + $exit = envlite_proc_stream(['npm', 'run', 'build:dev'], $repoRoot); + if ($exit !== 0) { + envlite_log('init', "phase 3: npm run build:dev failed (exit $exit)"); + exit(1); + } +} + +function envlite_phase4_composer_install(string $repoRoot): void { + $exit = envlite_proc_stream( + ['composer', 'install', '--no-interaction', '--ignore-platform-req=ext-simplexml'], + $repoRoot + ); + if ($exit !== 0) { + envlite_log('init', "phase 4: composer install failed (exit $exit)"); + exit(1); + } +} +``` + +- [ ] **Step 2: Run tests to verify nothing regressed** + +Run: `php tools/local-env/tests/run.php` +Expected: `45 tests, 0 failures` (no new tests). + +- [ ] **Step 3: Commit** + +```bash +git add tools/local-env/envlite.php +git commit -m "feat(envlite): implement Phases 2/3/4 (npm ci, build:dev, composer install)" +``` + +--- + +### Task 12: Phase 5 — SQLite drop-in + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_phase5.php` + +Spec §"Phase 5 — SQLite Database Integration drop-in". The phase has six steps (skip-if-present, download, SHA256 verify, ZipArchive extract, db.copy → db.php copy, placeholder tripwire). We extract the testable cores: HTTP fetch (with stream context), SHA256 verify against the pin, the placeholder tripwire, and the copy step. Network-touching and ZipArchive logic are smoke-tested via Task 19. + +The pinned SHA256 from spec §15: +`44be096a14ebcea424b5e4bf764436ec85fb067f74ab47822c4c5346df21591e`. + +- [ ] **Step 1: Write the failing tests** + +Write `tools/local-env/tests/test_phase5.php`: + +```php +getMessage(), 'SHA256 mismatch')); + } +} + +function test_phase5_tripwire_passes_when_placeholder_present() { + $dir = envlite_test_tmpdir('tripwire-ok'); + file_put_contents($dir . '/db.copy', 'getMessage(), 'placeholder')); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php tools/local-env/tests/run.php`. Expected: 4 new failures. + +- [ ] **Step 3: Implement Phase 5** + +Add to `envlite.php`: + +```php +const ENVLITE_SQLITE_PLUGIN_URL = 'https://downloads.wordpress.org/plugin/sqlite-database-integration.zip'; +const ENVLITE_SQLITE_PLUGIN_SHA256 = '44be096a14ebcea424b5e4bf764436ec85fb067f74ab47822c4c5346df21591e'; +const ENVLITE_SQLITE_PLACEHOLDER = '{SQLITE_IMPLEMENTATION_FOLDER_PATH}'; + +function envlite_http_get(string $url, int $timeoutSeconds = 30): string { + $ctx = stream_context_create([ + 'http' => [ + 'follow_location' => 1, + 'max_redirects' => 5, + 'timeout' => $timeoutSeconds, + 'header' => "User-Agent: envlite/" . ENVLITE_VERSION . "\r\n", + ], + 'https' => [ + 'follow_location' => 1, + 'max_redirects' => 5, + 'timeout' => $timeoutSeconds, + 'header' => "User-Agent: envlite/" . ENVLITE_VERSION . "\r\n", + ], + ]); + $bytes = @file_get_contents($url, false, $ctx); + if ($bytes === false) { + throw new \RuntimeException("HTTP fetch failed: $url"); + } + return $bytes; +} + +function envlite_phase5_verify_sha256(string $path, string $expected): void { + $actual = hash_file('sha256', $path); + if ($actual !== $expected) { + throw new \RuntimeException("SHA256 mismatch on $path: expected $expected, got $actual"); + } +} + +function envlite_phase5_assert_placeholder(string $dbCopyPath): void { + $bytes = file_get_contents($dbCopyPath); + if ($bytes === false || !str_contains($bytes, ENVLITE_SQLITE_PLACEHOLDER)) { + throw new \RuntimeException( + "tripwire: " . ENVLITE_SQLITE_PLACEHOLDER . " placeholder missing from $dbCopyPath; spec assumption broken" + ); + } +} + +function envlite_phase5_install(string $repoRoot, bool $force): void { + $pluginDir = "$repoRoot/src/wp-content/plugins/sqlite-database-integration"; + $dbCopy = "$pluginDir/db.copy"; + $dbPhpRel = 'src/wp-content/db.php'; + $pluginRel = 'src/wp-content/plugins/sqlite-database-integration'; + $manifest = envlite_manifest_load($repoRoot); + + // Step 1: skip if already installed (manifest entry + db.copy on disk). + $alreadyInstalled = isset($manifest[$pluginRel]) && $manifest[$pluginRel] === 'dir' && is_file($dbCopy); + if (!$alreadyInstalled) { + // Steps 2-4: prompt if dest exists and is not envlite-owned. + if (is_dir($pluginDir) && !isset($manifest[$pluginRel])) { + envlite_prompt_or_abort($force, 'init', 'overwrite plugin tree', $pluginRel, null, null); + } + $tmpZip = sys_get_temp_dir() . '/envlite-sqlite-' . bin2hex(random_bytes(4)) . '.zip'; + $bytes = envlite_http_get(ENVLITE_SQLITE_PLUGIN_URL); + file_put_contents($tmpZip, $bytes); + try { + envlite_phase5_verify_sha256($tmpZip, ENVLITE_SQLITE_PLUGIN_SHA256); + $zip = new \ZipArchive(); + if ($zip->open($tmpZip) !== true) { + throw new \RuntimeException("ZipArchive::open failed: $tmpZip"); + } + $zip->extractTo("$repoRoot/src/wp-content/plugins/"); + $zip->close(); + } finally { + @unlink($tmpZip); + } + $manifest[$pluginRel] = 'dir'; + envlite_manifest_save($repoRoot, $manifest); + } + + // Step 5: copy db.copy → db.php with manifest contract. + if (!is_file($dbCopy)) { + throw new \RuntimeException("db.copy missing at $dbCopy after extraction"); + } + $dbBytes = file_get_contents($dbCopy); + $dbPhpAbs = "$repoRoot/$dbPhpRel"; + $current = is_file($dbPhpAbs) ? file_get_contents($dbPhpAbs) : null; + $ownership = envlite_ownership($manifest, $dbPhpRel, $current); + if ($ownership === 'owned_drifted') { + $rec = $manifest[$dbPhpRel]; + $cur = hash('sha256', $current); + envlite_prompt_or_abort($force, 'init', 'overwrite drifted file', $dbPhpRel, $rec, $cur); + } elseif ($ownership === 'unowned') { + envlite_prompt_or_abort($force, 'init', 'overwrite unowned file', $dbPhpRel, null, null); + } + $hash = envlite_atomic_write($dbPhpAbs, $dbBytes); + $manifest[$dbPhpRel] = $hash; + envlite_manifest_save($repoRoot, $manifest); + + // Step 6: tripwire. + envlite_phase5_assert_placeholder($dbCopy); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `49 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_phase5.php +git commit -m "feat(envlite): implement Phase 5 SQLite drop-in" +``` + +--- + +### Task 13: Phase 6 — wp-tests-config.php + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_phase6.php` + +Spec §"Phase 6". Three placeholder substitutions in `wp-tests-config-sample.php`, plus a post-condition assert that none remain. + +- [ ] **Step 1: Write the failing tests** + +Write `tools/local-env/tests/test_phase6.php`: + +```php +getMessage(), 'placeholder')); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php tools/local-env/tests/run.php`. Expected: 2 new failures. + +- [ ] **Step 3: Implement** + +Add to `envlite.php`: + +```php +function envlite_phase6_render(string $sample): string { + $replacements = [ + 'youremptytestdbnamehere' => 'wordpress_test', + 'yourusernamehere' => 'wp', + 'yourpasswordhere' => 'wp', + ]; + foreach ($replacements as $placeholder => $value) { + if (substr_count($sample, $placeholder) !== 1) { + throw new \RuntimeException("phase 6: placeholder '$placeholder' must appear exactly once"); + } + } + $out = strtr($sample, $replacements); + foreach (array_keys($replacements) as $placeholder) { + if (str_contains($out, $placeholder)) { + throw new \RuntimeException("phase 6: placeholder '$placeholder' still present after substitution"); + } + } + return $out; +} + +function envlite_phase6_install(string $repoRoot, bool $force): void { + $samplePath = "$repoRoot/wp-tests-config-sample.php"; + $outRel = 'wp-tests-config.php'; + $outAbs = "$repoRoot/$outRel"; + + $sample = file_get_contents($samplePath); + $rendered = envlite_phase6_render($sample); + + $manifest = envlite_manifest_load($repoRoot); + $current = is_file($outAbs) ? file_get_contents($outAbs) : null; + $ownership = envlite_ownership($manifest, $outRel, $current); + if ($ownership === 'owned_drifted') { + envlite_prompt_or_abort($force, 'init', 'overwrite drifted file', $outRel, $manifest[$outRel], hash('sha256', $current)); + } elseif ($ownership === 'unowned') { + envlite_prompt_or_abort($force, 'init', 'overwrite unowned file', $outRel, null, null); + } + $hash = envlite_atomic_write($outAbs, $rendered); + $manifest[$outRel] = $hash; + envlite_manifest_save($repoRoot, $manifest); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `51 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_phase6.php +git commit -m "feat(envlite): implement Phase 6 wp-tests-config.php" +``` + +--- + +### Task 14: Phase 7 — src/wp-config.php + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_phase7.php` + +Spec §"Phase 7". DB substitutions; best-effort salts fetch; regex-replace 8-line salt block; inject `WP_HOME` / `WP_SITEURL` immediately before the marker. + +- [ ] **Step 1: Write the failing tests** + +Write `tools/local-env/tests/test_phase7.php`: + +```php + 'wordpress', + 'username_here' => 'wp', + 'password_here' => 'wp', + ]; + foreach ($dbReplacements as $placeholder => $value) { + if (substr_count($sample, $placeholder) !== 1) { + throw new \RuntimeException("phase 7: placeholder '$placeholder' must appear exactly once"); + } + } + $cfg = strtr($sample, $dbReplacements); + + // 2. Salt block: AUTH_KEY through NONCE_SALT, 8 contiguous define()s. + if ($saltsBlock !== null) { + $pattern = '/define\(\s*\'AUTH_KEY\'.*?define\(\s*\'NONCE_SALT\'\s*,\s*\'[^\']*\'\s*\);/s'; + $count = preg_match_all($pattern, $cfg, $m); + if ($count !== 1) { + throw new \RuntimeException("phase 7: expected exactly one salt block, found $count"); + } + $cfg = preg_replace($pattern, $saltsBlock, $cfg, 1); + } + + // 3. Inject WP_HOME / WP_SITEURL before the marker. + $marker = "/* That's all, stop editing! Happy publishing. */"; + if (substr_count($cfg, $marker) !== 1) { + throw new \RuntimeException("phase 7: expected exactly one marker line"); + } + $inject = "define( 'WP_HOME', 'http://127.0.0.1:$port' );\n" + . "define( 'WP_SITEURL', 'http://127.0.0.1:$port' );\n\n"; + $pos = strpos($cfg, $marker); + return substr($cfg, 0, $pos) . $inject . substr($cfg, $pos); +} + +function envlite_phase7_fetch_salts(): ?string { + try { + $bytes = envlite_http_get(ENVLITE_SALT_URL, 5); + // Sanity: must contain 8 define() lines and the keys we care about. + if (substr_count($bytes, "define(") < 8 || !str_contains($bytes, 'NONCE_SALT')) { + return null; + } + return rtrim($bytes, "\n"); + } catch (\Throwable $e) { + envlite_log('init', "phase 7: salt fetch failed: " . $e->getMessage() . " (continuing with sample placeholders)"); + return null; + } +} + +function envlite_phase7_install(string $repoRoot, int $port, bool $force): void { + $samplePath = "$repoRoot/wp-config-sample.php"; + $outRel = 'src/wp-config.php'; + $outAbs = "$repoRoot/$outRel"; + + $sample = file_get_contents($samplePath); + $salts = envlite_phase7_fetch_salts(); + $rendered = envlite_phase7_render($sample, $port, $salts); + + $manifest = envlite_manifest_load($repoRoot); + $current = is_file($outAbs) ? file_get_contents($outAbs) : null; + $ownership = envlite_ownership($manifest, $outRel, $current); + if ($ownership === 'owned_drifted') { + envlite_prompt_or_abort($force, 'init', 'overwrite drifted file', $outRel, $manifest[$outRel], hash('sha256', $current)); + } elseif ($ownership === 'unowned') { + envlite_prompt_or_abort($force, 'init', 'overwrite unowned file', $outRel, null, null); + } + $hash = envlite_atomic_write($outAbs, $rendered); + $manifest[$outRel] = $hash; + envlite_manifest_save($repoRoot, $manifest); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `55 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_phase7.php +git commit -m "feat(envlite): implement Phase 7 src/wp-config.php" +``` + +--- + +### Task 15: (removed) + +Phase 8 was eliminated — the router ships as a committed asset at +`tools/local-env/router.php` rather than being installed. See spec +§"`envlite serve` runtime". No task is required here; Task 17 +(`serve` subcommand) loads the router directly via +`__DIR__ . '/router.php'`. + +--- + +### Task 16: `init` orchestration + `.ht.sqlite` observation + +**Files:** +- Modify: `tools/local-env/envlite.php` + +Spec §"Phase ordering and parallelism" + §"What envlite explicitly does NOT do" §observation. Run phases serially in dependency order; observe `.ht.sqlite` at the start. + +`init` flag parsing: `--port=N`, `--no-build`. Unknown flags exit 2. + +- [ ] **Step 1: Implement `envlite_cmd_init`** + +Replace the stub `envlite_cmd_init` body: + +```php +function envlite_cmd_init(array $args, bool $force): int { + $port = null; + $noBuild = false; + foreach ($args as $a) { + if ($a === '--no-build') { $noBuild = true; continue; } + if (preg_match('/^--port=(\d+)$/', $a, $m)) { + $port = (int) $m[1]; + if ($port < 1 || $port > 65535) { + envlite_log('init', "invalid --port value: $a"); + return 2; + } + continue; + } + envlite_log('init', "unknown argument: $a"); + return 2; + } + + $repoRoot = getcwd(); + + // Phase 0 + envlite_phase0_run($repoRoot); + + // Observation point: record .ht.sqlite if present and not in manifest. + envlite_observe_ht_sqlite($repoRoot); + + // Phase 1 + $resolvedPort = envlite_phase1_discover_port($repoRoot, $port); + fwrite(STDERR, "envlite init: port $resolvedPort\n"); + + // Phase 2: npm ci + envlite_phase2_npm_ci($repoRoot); + + // Phase 3: build:dev (skippable) + if (!$noBuild) { + envlite_phase3_build_dev($repoRoot); + } + + // Phase 4: composer install + envlite_phase4_composer_install($repoRoot); + + // Phase 5: SQLite drop-in (must precede 6 and 7) + envlite_phase5_install($repoRoot, $force); + + // Phase 6: wp-tests-config.php + envlite_phase6_install($repoRoot, $force); + + // Phase 7: src/wp-config.php (consumes port) + envlite_phase7_install($repoRoot, $resolvedPort, $force); + + fwrite(STDERR, "envlite init: ok (port $resolvedPort)\n"); + return 0; +} + +function envlite_observe_ht_sqlite(string $repoRoot): void { + $rel = 'src/wp-content/database/.ht.sqlite'; + $abs = "$repoRoot/$rel"; + if (!is_file($abs)) { return; } + $manifest = envlite_manifest_load($repoRoot); + if (isset($manifest[$rel])) { return; } + $bytes = file_get_contents($abs); + $manifest[$rel] = hash('sha256', $bytes); + envlite_manifest_save($repoRoot, $manifest); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `php tools/local-env/tests/run.php` +Expected: `58 tests, 0 failures` (no new tests, but nothing regressed). + +- [ ] **Step 3: Commit** + +```bash +git add tools/local-env/envlite.php +git commit -m "feat(envlite): orchestrate init across all phases" +``` + +--- + +### Task 17: `serve` subcommand + +**Files:** +- Modify: `tools/local-env/envlite.php` + +Spec §"`envlite serve` runtime". Read cached port from `.envlite/port`; spawn `php -S 127.0.0.1: -t src tools/local-env/router.php` in the foreground (router path passed absolutely, computed via `__DIR__`). On bind failure, log and exit 1. + +- [ ] **Step 1: Implement `envlite_cmd_serve`** + +Replace the stub `envlite_cmd_serve` body: + +```php +function envlite_cmd_serve(array $args, bool $force): int { + if (!empty($args)) { + envlite_log('serve', 'unexpected arguments: ' . implode(' ', $args)); + return 2; + } + + $repoRoot = getcwd(); + if (!envlite_phase0_is_wordpress_develop($repoRoot)) { + envlite_log('serve', 'not a wordpress-develop checkout'); + return 3; + } + + $cachePath = "$repoRoot/.envlite/port"; + if (!is_file($cachePath)) { + envlite_log('serve', 'no cached port; run `envlite init` first'); + return 1; + } + $port = (int) trim(file_get_contents($cachePath)); + if ($port < 1 || $port > 65535) { + envlite_log('serve', "cached port out of range: $port"); + return 1; + } + + if (!envlite_phase1_port_is_free($port)) { + envlite_log('serve', "failed to bind 127.0.0.1:$port"); + return 1; + } + + // Stream the dev server. SIGINT propagates to the child via terminal. + $exit = envlite_proc_stream( + ['php', '-S', "127.0.0.1:$port", '-t', 'src', __DIR__ . '/router.php'], + $repoRoot + ); + return $exit === 0 ? 0 : 1; +} +``` + +- [ ] **Step 2: Smoke check** + +Run (in a separate terminal, do not commit until verified): + +```bash +php tools/local-env/envlite.php serve & sleep 2 ; curl -sI http://127.0.0.1:$(cat .envlite/port)/ ; kill %1 +``` + +Expected: an HTTP response status line. (If `init` hasn't run end-to-end, this will fail with "no cached port" — that's expected and not a Task 17 bug. Move to Task 18 in that case; Task 19 verifies the full chain.) + +- [ ] **Step 3: Run tests** + +Run: `php tools/local-env/tests/run.php` +Expected: `58 tests, 0 failures`. + +- [ ] **Step 4: Commit** + +```bash +git add tools/local-env/envlite.php +git commit -m "feat(envlite): implement serve subcommand" +``` + +--- + +### Task 18: `clean` subcommand + +**Files:** +- Modify: `tools/local-env/envlite.php` +- Create: `tools/local-env/tests/test_clean.php` + +Spec §"Outputs (final repo state)" §"`clean` semantics". Walk manifest in reverse insertion order, single batch prompt with full path list, delete each entry, then remove `.envlite/`. Observe `.ht.sqlite` first so it appears in the prompt. + +- [ ] **Step 1: Write the failing test** + +Write `tools/local-env/tests/test_clean.php`: + +```php + str_repeat('a', 64), + 'src/wp-config.php' => str_repeat('b', 64), + 'wp-tests-config.php' => str_repeat('c', 64), + ]; + $order = envlite_clean_collect($manifest); + envlite_assert_eq(['wp-tests-config.php', 'src/wp-config.php', '.envlite/port'], $order); +} + +function test_clean_removes_files_dirs_and_state() { + $dir = envlite_test_tmpdir('clean'); + mkdir("$dir/.envlite"); + mkdir("$dir/sub", 0755, true); + file_put_contents("$dir/wp-tests-config.php", 'x'); + file_put_contents("$dir/sub/db.php", 'y'); + $manifest = [ + '.envlite/port' => hash('sha256', 'p'), + 'wp-tests-config.php' => hash('sha256', 'x'), + 'sub' => 'dir', + ]; + envlite_manifest_save($dir, $manifest); + file_put_contents("$dir/.envlite/port", 'p'); + + envlite_clean_apply($dir, envlite_clean_collect($manifest)); + + envlite_assert(!file_exists("$dir/wp-tests-config.php")); + envlite_assert(!is_dir("$dir/sub")); + envlite_assert(!is_dir("$dir/.envlite"), '.envlite must be removed last'); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php tools/local-env/tests/run.php`. Expected: 2 new failures. + +- [ ] **Step 3: Implement** + +Add to `envlite.php`: + +```php +function envlite_cmd_clean(array $args, bool $force): int { + if (!empty($args)) { + envlite_log('clean', 'unexpected arguments: ' . implode(' ', $args)); + return 2; + } + $repoRoot = getcwd(); + if (!is_dir("$repoRoot/.envlite")) { + envlite_log('clean', 'nothing to clean (no .envlite/ directory)'); + return 0; + } + + envlite_observe_ht_sqlite($repoRoot); + $manifest = envlite_manifest_load($repoRoot); + $paths = envlite_clean_collect($manifest); + + if (empty($paths)) { + envlite_log('clean', 'manifest is empty; removing .envlite/ only'); + } else { + // Single batch prompt. + if (!$force) { + if (!stream_isatty(STDIN)) { + envlite_log(null, 'non-interactive context and --force not given; aborting at clean'); + return 5; + } + fwrite(STDERR, "envlite clean: will remove " . count($paths) . " path(s):\n"); + foreach ($paths as $p) { fwrite(STDERR, " $p\n"); } + fwrite(STDERR, "envlite clean: continue? [y/N] "); + $line = fgets(STDIN); + $resp = $line === false ? '' : strtolower(trim($line)); + if ($resp !== 'y' && $resp !== 'yes') { + envlite_log('clean', 'aborted by user'); + return 5; + } + } + envlite_clean_apply($repoRoot, $paths); + } + + // Remove .envlite/ itself. + @unlink("$repoRoot/.envlite/manifest"); + @unlink("$repoRoot/.envlite/port"); + @rmdir("$repoRoot/.envlite"); + return 0; +} + +/** Pure: returns paths in reverse insertion order. */ +function envlite_clean_collect(array $manifest): array { + return array_reverse(array_keys($manifest)); +} + +/** I/O: deletes each path. Must be called after the prompt has been resolved. */ +function envlite_clean_apply(string $repoRoot, array $paths): void { + foreach ($paths as $rel) { + $abs = "$repoRoot/$rel"; + if (!file_exists($abs) && !is_dir($abs)) { continue; } + if (is_dir($abs) && !is_link($abs)) { + envlite_rrmdir($abs); + } else { + @unlink($abs); + } + } +} + +function envlite_rrmdir(string $dir): void { + $items = scandir($dir); + if ($items === false) { return; } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { continue; } + $path = "$dir/$item"; + if (is_dir($path) && !is_link($path)) { + envlite_rrmdir($path); + } else { + @unlink($path); + } + } + @rmdir($dir); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php tools/local-env/tests/run.php` +Expected: `60 tests, 0 failures`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_clean.php +git commit -m "feat(envlite): implement clean subcommand" +``` + +--- + +### Task 19: End-to-end smoke test + +**Files:** +- Create: `tools/local-env/tests/test_smoke.php` + +The smoke test does NOT run all of `init` (that requires network and minutes). Instead it sets up a fixture directory that mimics a wordpress-develop checkout closely enough to drive the file-producing phases (5–7 minus the Phase 5 download), verifies the manifest, then drives `clean`. The point is to catch wiring bugs that unit tests miss: phase ordering, manifest accumulation across phases, `clean` walking the manifest correctly. + +We skip Phase 5's download by pre-staging the plugin directory and `db.copy` so envlite hits the "already installed" branch. We skip Phases 0/2/3/4 entirely for fixture-only testing. + +- [ ] **Step 1: Write the smoke test** + +Write `tools/local-env/tests/test_smoke.php`: + +```php + 'dir']; + envlite_manifest_save($dir, $manifest); + + // Drive Phases 5–7 with --force (no TTY in test). + envlite_phase5_install($dir, true); + envlite_phase6_install($dir, true); + envlite_phase7_install($dir, 8421, true); + + // Assert artifacts present. + envlite_assert(is_file("$dir/src/wp-content/db.php")); + envlite_assert(is_file("$dir/wp-tests-config.php")); + envlite_assert(is_file("$dir/src/wp-config.php")); + + // Manifest contains all three file entries plus the plugin dir. + $m = envlite_manifest_load($dir); + envlite_assert(isset($m['src/wp-content/db.php'])); + envlite_assert(isset($m['wp-tests-config.php'])); + envlite_assert(isset($m['src/wp-config.php'])); + envlite_assert(isset($m['src/wp-content/plugins/sqlite-database-integration'])); + + // wp-config.php picked up the port. + envlite_assert(str_contains(file_get_contents("$dir/src/wp-config.php"), 'http://127.0.0.1:8421')); + + // Now drive clean (force, no TTY). + $paths = envlite_clean_collect($m); + envlite_clean_apply($dir, $paths); + @unlink("$dir/.envlite/manifest"); + @rmdir("$dir/.envlite"); + + envlite_assert(!is_file("$dir/wp-tests-config.php")); + envlite_assert(!is_file("$dir/src/wp-config.php")); + envlite_assert(!is_file("$dir/src/wp-content/db.php")); + envlite_assert(!is_dir("$dir/src/wp-content/plugins/sqlite-database-integration")); + envlite_assert(!is_dir("$dir/.envlite")); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `php tools/local-env/tests/run.php` +Expected: `61 tests, 0 failures`. + +- [ ] **Step 3: Real-environment validation (manual)** + +Run on the actual repo: + +```bash +# Phase 0 only (preflight) +php tools/local-env/envlite.php init --port=8421 --no-build 2>&1 | tail -5 +``` + +Expected: phase 0 passes; phase 2 starts (`npm ci`). Stop with Ctrl-C if you don't want a full install. The point is to confirm the phase-0 gate clears on a real machine. Document the result in the commit message. + +- [ ] **Step 4: Commit** + +```bash +git add tools/local-env/tests/test_smoke.php +git commit -m "test(envlite): add end-to-end smoke covering phases 5-7 and clean" +``` + +--- + +## Self-Review Notes + +**Spec coverage:** +- §CLI interface (subcommands, flags, exit codes, diagnostic prefixes) — Tasks 1, 2, 16, 17, 18. +- §Phase 0 — Task 8. +- §Phase 1 — Task 9. +- §Phase 2/3/4 — Tasks 10, 11. +- §Phase 5 — Task 12. +- §Phase 6 — Task 13. +- §Phase 7 — Task 14. +- §`envlite serve` runtime (router asset) — Task 17. +- §State and ownership (manifest, atomic writes, file-write conventions, ownership) — Tasks 3, 4, 5, 6, 7. +- §Outputs / `clean` semantics + `.ht.sqlite` observation — Tasks 16, 18. +- §Phase ordering — Task 16. +- §Idempotency rules — exercised across Tasks 12–14 (manifest contract). + +**Notes for the implementer:** +- The single-file constraint is non-negotiable per spec — do not split `envlite.php`. +- Throughout, hard-code `"\n"`. Never use `PHP_EOL`. Spec §"File-write conventions". +- All envlite-authored writes go through `envlite_atomic_write()`. Don't call `file_put_contents()` on a final path. +- Manifest hashes always come from in-memory bytes (the return value of `envlite_atomic_write`), never from `hash_file()` on the renamed target. Spec §"Atomic writes". +- The `--force` flag is a global flag (`$force` parameter threaded through `envlite_cmd_*`), not subcommand-specific. +- Tests run via `php tools/local-env/tests/run.php`. Final expected line: `61 tests, 0 failures`. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-08-envlite.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach? diff --git a/docs/superpowers/plans/2026-05-08-pcntl-exec-dev-server.md b/docs/superpowers/plans/2026-05-08-pcntl-exec-dev-server.md new file mode 100644 index 0000000000000..225882e04055e --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-pcntl-exec-dev-server.md @@ -0,0 +1,634 @@ +# Switch envlite Dev-Server Launch to pcntl Process Replacement + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the `proc_open`-based launch of `php -S` in `envlite up` and `envlite serve` with `pcntl_exec( PHP_BINARY, … )`, so the envlite PHP process is replaced in place by the dev server (no parent-child indirection on Unix). Keep `proc_open` as a Windows fallback. + +**Architecture:** +1. Single helper `envlite_run_dev_server($repoRoot, $port)` builds the `php -S` argv once and chooses between `pcntl_exec` (Unix) and `envlite_proc_stream` (Windows fallback). Both `envlite_cmd_serve` and `envlite_cmd_up` call it. The helper also exposes an "is pcntl available right now" predicate so a Windows-only test path stays reachable. +2. Phase 0 gains a conditional `pcntl` extension check: required on Unix (`PHP_OS_FAMILY !== 'Windows'`), skipped on Windows. This makes the Unix path's promise (process replacement) auditable while preserving Windows operability. +3. Spec updates document the new behavior in three sections: tech stack / Phase 0 extension list, the `envlite serve` runtime section, and decision #8 ("PHP-only implementation surface"). + +**Tech Stack:** PHP 7.4+, `pcntl` extension (Unix only), existing envlite test harness (`tools/local-env/tests/run.php`). + +--- + +## Open decisions resolved + +These were the two explicit open questions in the request. Both are decided here so tasks can be executed without further input. + +1. **Windows fallback**: keep the current `envlite_proc_stream` path on Windows. `pcntl` is unavailable on Windows PHP; there is no pure-PHP equivalent of `execve`. Functionally `php -S` under `proc_open` already gives the user a foreground server with Ctrl-C handling — the Unix gain (process replacement, same PID, shallower process tree) is a polish, not a correctness requirement. So Windows pays no regression and no new external dependency. + +2. **Phase 0 update**: yes — require `pcntl` on Unix. Rationale: `pcntl` ships in stock CLI builds for Homebrew PHP, Debian/Ubuntu `php-cli`, Alpine `php-cli`, and the official Docker images. A user without it on Unix is rare and broken in other ways too; failing fast in Phase 0 is consistent with the spec's "Don't silently degrade" policy. The check is gated on `PHP_OS_FAMILY !== 'Windows'` so the Windows code path remains valid. + +The availability predicate at runtime is `PHP_OS_FAMILY !== 'Windows' && function_exists('pcntl_exec')`. Both clauses matter: `function_exists` covers the (rare) Unix install without `pcntl`, and the OS-family check ensures Windows never tries `pcntl_exec` even if a hypothetical port were to expose the symbol as a stub. + +--- + +## File Structure + +- **Modify:** `tools/local-env/envlite.php` + - Add `envlite_run_dev_server(string $repoRoot, int $port): int` — builds the `php -S` argv, chooses Unix vs Windows path, calls `pcntl_exec` or `envlite_proc_stream`. Single owner of the dev-server launch. + - Modify `envlite_phase0_run` (currently lines 287–321) — add conditional `pcntl` extension check. + - Modify `envlite_cmd_serve` (currently lines 836–870) — replace inline `envlite_proc_stream` call with `envlite_run_dev_server`. + - Modify `envlite_cmd_up` (currently lines 809–862) — same replacement. +- **Modify:** `plans/ENVLITE_SPECIFICATION.md` + - Tech stack section (lines 15–34) — add `pcntl` to required extensions and note the conditionality. + - Phase 0 extension list (lines 182–199) — add `pcntl` (Unix only). + - "envlite serve runtime" section (lines 134–157) — describe `pcntl_exec` semantics and Windows fallback. + - Decision #8 "PHP-only implementation surface" (lines 954–958) — note the `pcntl` carve-out. +- **Modify:** `tools/local-env/tests/test_phase0.php` — add a unit test for the new pcntl check. +- **Create:** `tools/local-env/tests/test_dev_server.php` — covers the new helper. Two cases: Unix branch (`pcntl_exec` actually replaces the process — verified via subprocess) and Windows fallback selection (verified by inspecting the chosen code path). + +--- + +## Task 1: Add the conditional `pcntl` Phase 0 check + +**Files:** +- Modify: `tools/local-env/envlite.php:287-321` (`envlite_phase0_run`) +- Modify: `tools/local-env/tests/test_phase0.php` + +- [ ] **Step 1: Write the failing test** + +Append to `tools/local-env/tests/test_phase0.php`: + +```php +function test_phase0_required_extensions_include_pcntl_on_unix() { + // The list is the source of truth used by envlite_phase0_run. + // We test the *list*, not by re-running phase0 (which exits the test runner). + if (PHP_OS_FAMILY === 'Windows') { + // On Windows, pcntl is not in the list. Sanity-check the inverse. + envlite_assert( + !in_array('pcntl', envlite_phase0_required_extensions(), true), + 'pcntl must NOT be required on Windows' + ); + return; + } + envlite_assert( + in_array('pcntl', envlite_phase0_required_extensions(), true), + 'pcntl must be required on Unix' + ); +} + +function test_phase0_required_extensions_includes_existing_set() { + foreach (['pdo_sqlite', 'sqlite3', 'openssl', 'simplexml', 'zip'] as $ext) { + envlite_assert( + in_array($ext, envlite_phase0_required_extensions(), true), + "$ext must remain required" + ); + } +} +``` + +- [ ] **Step 2: Run tests; confirm new tests fail with "undefined function envlite_phase0_required_extensions"** + +Run: `php tools/local-env/tests/run.php` +Expected: both new tests FAIL with "Call to undefined function envlite_phase0_required_extensions". All other tests still PASS. + +- [ ] **Step 3: Add the `envlite_phase0_required_extensions` helper and rewire `envlite_phase0_run`** + +In `tools/local-env/envlite.php`, locate the existing block in `envlite_phase0_run`: + +```php + foreach (['pdo_sqlite', 'sqlite3', 'openssl', 'simplexml', 'zip'] as $ext) { + if (!extension_loaded($ext)) { + envlite_log(null, "preflight: required PHP extension missing: $ext"); + exit(3); + } + } +``` + +Replace it with a call to a new helper, and add the helper just above `envlite_phase0_run`: + +```php +function envlite_phase0_required_extensions(): array { + $exts = ['pdo_sqlite', 'sqlite3', 'openssl', 'simplexml', 'zip']; + if (PHP_OS_FAMILY !== 'Windows') { + // pcntl is required on Unix so envlite_run_dev_server can call + // pcntl_exec into php -S. Windows lacks pcntl entirely; the + // dev-server launcher falls back to proc_open there. + $exts[] = 'pcntl'; + } + return $exts; +} +``` + +And in `envlite_phase0_run`, replace the literal array with the helper call: + +```php + foreach (envlite_phase0_required_extensions() as $ext) { + if (!extension_loaded($ext)) { + envlite_log(null, "preflight: required PHP extension missing: $ext"); + exit(3); + } + } +``` + +- [ ] **Step 4: Run tests; confirm all pass** + +Run: `php tools/local-env/tests/run.php` +Expected: all tests PASS, including the two new ones. + +- [ ] **Step 5: Commit** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_phase0.php +git commit -m "feat(envlite): require pcntl extension on Unix in Phase 0" +``` + +--- + +## Task 2: Add `envlite_run_dev_server` helper with Unix/Windows split + +**Files:** +- Modify: `tools/local-env/envlite.php` (add helper near `envlite_proc_stream`, ~line 270) +- Create: `tools/local-env/tests/test_dev_server.php` + +- [ ] **Step 1: Write a failing test for the helper's command construction** + +Create `tools/local-env/tests/test_dev_server.php` with the first tests: + +```php + -t src tools/local-env/router.php` in the +foreground. + +On Unix, the launch uses `pcntl_exec(PHP_BINARY, …)`: the envlite PHP +process is replaced in place by `php -S`, so there is no parent-child +relay, the PID stays the same, and signals (notably SIGINT from +Ctrl-C) reach `php -S` directly. The `envlite up` subcommand uses the +same launch path after its init phases finish. + +On Windows, `pcntl` is unavailable. `serve` falls back to `proc_open` +with stdio inherited from envlite's own STDIN/STDOUT/STDERR. Behavior +is functionally equivalent for the user — foreground server, Ctrl-C +shuts it down — but the process tree shows envlite as the parent of +`php -S`. + +The router is committed at `tools/local-env/router.php` alongside +`envlite.php`; it is not installed into the repo, the manifest does +not track it, and `clean` does not remove it. It has no inputs (the +port is a `php -S` argument, not baked into the file) and no +user-tunable knobs. + +The router resolves the repo's `src/` via +`dirname(__DIR__, 2) . '/src'`, returns `false` for files that exist +on disk so `php -S` serves them directly, and otherwise routes to +`src/index.php`. WordPress's index.php → wp-blog-header.php → +wp-load.php → wp-settings.php chain handles the rest, including +`wp-admin/install.php` on first hit and pretty-permalink fallback +once installed. The port is consumed only when `serve` runs, never +at `init` time. + +**Bind failure.** If `php -S` exits because the port is already +bound (another `envlite serve` running, or any other process on +``), envlite exits 1 with a single stderr line: +`envlite serve: failed to bind 127.0.0.1:`. No manifest +mutation occurs. Note that on Unix the envlite process has already +been replaced by the time `php -S` reports the bind failure, so the +exit code surfaced to the shell is `php -S`'s, not envlite's; +envlite's pre-flight `port_is_free` probe (in both `serve` and `up`) +is the path that emits the named log line above. +``` + +- [ ] **Step 5: Update decision #8 ("PHP-only implementation surface")** + +Find decision 8 (currently around lines 954–958): + +``` +8. **PHP-only implementation surface.** All file ops, hashing, HTTP, + and zip extraction go through PHP standard library. Subprocesses + are limited to `node`/`npm`/`composer`/`php` — tools envlite already + requires for setup. No `sed`/`awk`/`curl`/`unzip`/`shasum`/`python` + dependencies, even when those are commonly present. +``` + +Replace with: + +``` +8. **PHP-only implementation surface.** All file ops, hashing, HTTP, + and zip extraction go through PHP standard library. Subprocesses + are limited to `node`/`npm`/`composer`/`php` — tools envlite already + requires for setup. No `sed`/`awk`/`curl`/`unzip`/`shasum`/`python` + dependencies, even when those are commonly present. The dev-server + launch on Unix uses `pcntl_exec` rather than `proc_open` so the + envlite PHP process is replaced in place by `php -S` (same PID, + shallower process tree, direct signal delivery); Windows lacks + `pcntl` and falls back to `proc_open` with inherited stdio. +``` + +- [ ] **Step 6: Verify the spec still parses cleanly** + +Run a quick grep to confirm no stale "the dev server is launched via `proc_open`" wording remains in the runtime description: + +```bash +grep -n 'proc_open' plans/ENVLITE_SPECIFICATION.md +``` + +Expected: zero hits, OR only hits inside the new "Windows fallback" prose. + +- [ ] **Step 7: Commit** + +```bash +git add plans/ENVLITE_SPECIFICATION.md +git commit -m "docs(envlite): document pcntl dev-server launch and Windows fallback" +``` + +--- + +## Task 7: Final cross-cutting verification + +**Files:** none modified; this is verification only. + +- [ ] **Step 1: Run the full test suite once more** + +Run: `php tools/local-env/tests/run.php` +Expected: all tests PASS, including the two new test files (`test_dev_server.php`) and the new Phase 0 cases in `test_phase0.php`. + +- [ ] **Step 2: Confirm `envlite_proc_stream` still has callers** + +Run: `grep -n envlite_proc_stream tools/local-env/envlite.php` +Expected: at least Phases 2/3/4 (`npm ci`, `npm run build:dev`, `composer install`) plus the Windows fallback in `envlite_run_dev_server`. NOT in `envlite_cmd_serve` or `envlite_cmd_up` directly anymore. + +- [ ] **Step 3: Confirm only `envlite_run_dev_server` constructs the `php -S` argv** + +Run: `grep -n "'-S'" tools/local-env/envlite.php` +Expected: exactly one hit, in `envlite_dev_server_argv`. (If `envlite_cmd_serve` or `envlite_cmd_up` still has its own `-S` literal, the refactor missed a spot.) + +- [ ] **Step 4: Lint check (if a phpcs ruleset is configured)** + +If `vendor/bin/phpcs` exists, run: `./vendor/bin/phpcs tools/local-env/envlite.php tools/local-env/tests/` +Expected: no new violations. + +If it does not exist, skip this step (envlite ships without a phpcs gate by design). + +--- + +## Notes for the implementer + +- **Don't skip Task 6.** The spec is the source of truth in this repo; leaving it unchanged after this work would create drift. (The spec also doesn't currently document the `up` subcommand at all — that's a pre-existing gap and explicitly out of scope for this plan.) +- **Don't generalize `envlite_run_dev_server` to all subprocess calls.** `npm ci`, `composer install`, etc. need to *return* (init has more phases after them); they must keep using `envlite_proc_stream`. Process replacement is correct only for the terminal step of `serve` / `up`. +- **Don't try to test the Windows fallback path on macOS by mocking `function_exists`.** PHP doesn't make that ergonomic. The two tests in Task 3 are the right shape: one exercises the real `pcntl_exec` on Unix, the other exercises the same `proc_open` call shape that the Windows fallback uses. A real Windows runner is the only way to get end-to-end coverage of the fallback; document this gap rather than papering over it. +- **`pcntl_exec` is silent on the success path.** Anything envlite writes to STDERR after `pcntl_exec` is unreachable on Unix. Make sure any final "serving …" log line happens *before* the helper is called (Task 5 already does; Task 4's `serve` doesn't print one and that's fine). + diff --git a/docs/superpowers/plans/2026-05-13-fix-envlite-router-docroot.md b/docs/superpowers/plans/2026-05-13-fix-envlite-router-docroot.md new file mode 100644 index 0000000000000..294ffb9579e75 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-fix-envlite-router-docroot.md @@ -0,0 +1,251 @@ +# Fix envlite router path resolution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `tools/local-env/router.php` serve the repo where `php -S` was launched, not the repo that owns the router file. + +**Architecture:** The PHP built-in server populates `$_SERVER['DOCUMENT_ROOT']` from its `-t` flag (resolved to an absolute path). `envlite_run_dev_server` already chdirs to the target repo and passes `-t src`, so `DOCUMENT_ROOT` always equals `/src`. Replace the router's two `dirname(__DIR__, 2) . '/src'` expressions with `$_SERVER['DOCUMENT_ROOT']` so the router stops assuming it lives inside the target repo. + +**Tech Stack:** PHP 7.4+, PHP built-in server (`php -S`), envlite test harness (custom — `tools/local-env/tests/harness.php` + `run.php`). + +--- + +## Bug recap (why this exists) + +`router.php:11` and `router.php:25` use `dirname(__DIR__, 2) . '/src'` to locate the document root and front controller. `__DIR__` is the directory of the *router file itself*, so when envlite is invoked from a checkout other than the one that owns `router.php` (e.g. running `php /path/to/envlite-checkout/tools/local-env/envlite.php up` from a different worktree), the router loads the originating checkout's `src/index.php` — pulling in the *wrong* `wp-config.php`, which then triggers a WordPress canonical-URL 301 to whatever port that wp-config defines. + +Reproduction observed: server bound at `127.0.0.1:8722` (target repo's port), but `GET /` returned `301 Location: http://127.0.0.1:8762/` (originating envlite checkout's wp-config port). + +## File Structure + +- **Modify:** `tools/local-env/router.php` — replace both `dirname(__DIR__, 2) . '/src'` expressions with `$_SERVER['DOCUMENT_ROOT']`. No structural change; same 25 lines. +- **Create:** `tools/local-env/tests/test_router.php` — integration test that boots `php -S` against a fixture site whose path is unrelated to the router file's location, then asserts the request was served from the fixture (not from envlite's own tree). + +The router file is intentionally small and a single responsibility; no extraction is needed. + +--- + +### Task 1: Add the failing regression test + +**Files:** +- Create: `tools/local-env/tests/test_router.php` + +This test boots a real `php -S` with the shipped `router.php`, pointing `-t` at a tmp fixture directory that contains a tiny `index.php` marker. It then `GET`s `/` and asserts the marker came back — proving the router served the fixture, not the envlite checkout's own `src/`. + +Picking a free port: bind to `tcp://127.0.0.1:0`, read the assigned port from `stream_socket_get_name`, close the socket, then hand the port to `php -S`. There's a microscopic race window between close-and-rebind; if it bites in practice, retry once. Don't preemptively engineer for it. + +- [ ] **Step 1: Write the failing test** + +Create `tools/local-env/tests/test_router.php`: + +```php + /private/tmp on macOS so the assert + // below matches __DIR__ from the fixture's index.php (which resolves + // symlinks). On Linux this is a no-op. + $site = realpath(envlite_test_tmpdir('router-docroot')); + envlite_assert($site !== false, 'tmp fixture directory must resolve via realpath'); + file_put_contents("$site/index.php", " -t ` with cwd = site. + // Matches envlite_run_dev_server: chdir into the target repo, then pass + // -t . The router file lives outside $site on purpose — that is + // exactly the configuration that triggered the original bug. + $argv = [PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $site, $router]; + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open($argv, $descriptors, $pipes, $site); + envlite_assert(is_resource($proc), 'failed to start php -S'); + + try { + envlite_assert( + envlite_test_router_wait_for_bind($port), + "php -S did not bind on 127.0.0.1:$port within 3s" + ); + + $body = @file_get_contents("http://127.0.0.1:$port/"); + envlite_assert($body !== false, "request to 127.0.0.1:$port failed"); + + envlite_assert( + strpos($body, 'FIXTURE_OK ' . $site) !== false, + 'expected FIXTURE_OK marker from fixture index.php, got: ' . substr($body, 0, 400) + ); + } finally { + foreach ($pipes as $p) { if (is_resource($p)) { @fclose($p); } } + $status = @proc_get_status($proc); + if ($status && $status['running']) { + @proc_terminate($proc, 15); + } + @proc_close($proc); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php tools/local-env/tests/run.php` + +Expected: `FAIL test_router_serves_from_document_root_not_router_directory` with a message about the FIXTURE_OK marker being absent. The body will either be empty (require failed — index.php from envlite's own `src/` does not exist in a fresh checkout running tests) or contain unrelated content from envlite's `src/index.php`. Either way the assert fires. + +If instead the test passes here, STOP — the reproduction is wrong and the rest of the plan does nothing. + +- [ ] **Step 3: Commit the failing test** + +```bash +git add tools/local-env/tests/test_router.php +git commit -m "test(envlite): cover router DOCUMENT_ROOT resolution" +``` + +It is fine — and intentional — to land a failing regression test in its own commit. The next commit makes it pass. + +--- + +### Task 2: Fix the router to use DOCUMENT_ROOT + +**Files:** +- Modify: `tools/local-env/router.php:11` and `tools/local-env/router.php:25` + +- [ ] **Step 1: Edit router.php** + +Replace the current contents of `tools/local-env/router.php` with: + +```php + wp-admin/index.php). Without an index, fall + // through to the front controller to avoid directory listings. + if (file_exists(rtrim($file, '/') . '/index.php')) { + return false; + } +} + +require $docroot . '/index.php'; +``` + +- [ ] **Step 2: Run the new test to verify it passes** + +Run: `php tools/local-env/tests/run.php` + +Expected: `PASS test_router_serves_from_document_root_not_router_directory`, and the final tally line should show one more pass than before with zero failures. + +- [ ] **Step 3: Run the full test suite to verify no regressions** + +Run: `php tools/local-env/tests/run.php` + +Expected: `0 failures` in the final summary line. In particular `test_dev_server_argv_targets_correct_port_root_router` must still pass — it asserts the argv shape, which this change does not touch. + +- [ ] **Step 4: Manually verify the original reproduction is fixed** + +This is a one-time smoke check, not a permanent test. Skip if you do not have two envlite-prepared worktrees handy. + +Run: +```bash +# In one terminal, from a *different* envlite-prepared checkout B, start the server: +cd /path/to/checkout-B +php /path/to/checkout-A/tools/local-env/envlite.php up +``` + +```bash +# In another terminal: +curl -sI http://127.0.0.1:/ +``` + +Expected: a `200 OK` (or whatever the WordPress front page returns), NOT a `301` to checkout A's port. + +- [ ] **Step 5: Commit the fix** + +```bash +git add tools/local-env/router.php +git commit -m "fix(envlite): resolve router paths via DOCUMENT_ROOT + +The router previously used dirname(__DIR__, 2) . '/src' to locate both +the static-file root and the front controller. That resolves relative +to the router file's own checkout, so invoking envlite from a different +worktree loaded the wrong wp-config.php and triggered a canonical-URL +301 to that wp-config's WP_HOME port. + +Use \$_SERVER['DOCUMENT_ROOT'] instead — populated by php -S from its +-t flag, which envlite_run_dev_server already points at the target +repo's src/." +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Root cause (router uses `__DIR__`-derived path): covered by Task 2 Step 1. +- Fix uses `$_SERVER['DOCUMENT_ROOT']`: covered by Task 2 Step 1. +- Regression test exists: covered by Task 1. +- Manual verification of original reproduction: covered by Task 2 Step 4. +- No structural changes to envlite.php: confirmed — `envlite_dev_server_argv` already passes `-t src`, no change needed. + +**2. Placeholder scan:** No TBD/TODO/"fill in later". All code is concrete; all commands explicit. + +**3. Type/identifier consistency:** +- Helper names `envlite_test_router_pick_free_port` and `envlite_test_router_wait_for_bind` are referenced in the test exactly as defined. +- `envlite_test_tmpdir` is defined in `tests/test_manifest.php:2` and reused here (matching the pattern in `test_smoke.php:3` and `test_atomic.php`). +- `envlite_assert` is defined in `tests/harness.php:2`. +- `proc_open` with `$descriptors` array form, then `proc_terminate`/`proc_close` — standard PHP API. + +--- + +## Post-implementation notes + +These deviated from the plan as originally written and are recorded here so future readers don't think the plan and the committed code drifted by accident. + +- **macOS tmpdir symlink (commit `271d7f651d`).** Plan v1 wrote `$site = envlite_test_tmpdir('router-docroot')` directly. On macOS `sys_get_temp_dir()` returns `/var/folders/...` while PHP's `__DIR__` in the fixture's `index.php` resolves symlinks to `/private/var/folders/...`, so the `'FIXTURE_OK ' . $site` assertion fails even when the router is serving the fixture correctly. Fix: wrap the tmpdir in `realpath()` and assert it resolved, before the fixture write. The Task 1 code block above has been updated in-place to match what shipped; on Linux the change is a no-op. A more durable fix would be to push the `realpath()` into `envlite_test_tmpdir` itself so every test that compares tmp paths against `__DIR__`-resolved values is portable by default — deferred until a second caller needs it. diff --git a/package.json b/package.json index bcfc78d49508a..3f8e5dd737ada 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "lint:jsdoc": "wp-scripts lint-js", "lint:jsdoc:fix": "wp-scripts lint-js --fix", "typecheck:js": "tsc --build", + "envlite": "php ./tools/local-env/envlite.php", "env:start": "node ./tools/local-env/scripts/start.js && node ./tools/local-env/scripts/docker.js run -T --rm php composer update -W", "env:stop": "node ./tools/local-env/scripts/docker.js down", "env:restart": "npm run env:stop && npm run env:start", diff --git a/plans/2026-05-09-envlite-test-db-isolation-design.md b/plans/2026-05-09-envlite-test-db-isolation-design.md new file mode 100644 index 0000000000000..a75f35b346cc8 --- /dev/null +++ b/plans/2026-05-09-envlite-test-db-isolation-design.md @@ -0,0 +1,217 @@ +# envlite — phpunit test DB isolation + +**Status:** design. +**Relates to:** `plans/ENVLITE_SPECIFICATION.md` (Phases 5–8, "State and ownership", "Outputs", "Non-obvious decisions"). + +## Problem + +Today, an `envlite init` followed by `./vendor/bin/phpunit` silently wipes +the dev site that `init` just installed. The chain: + +- Phase 8 runs `wp_install()` against + `src/wp-content/database/.ht.sqlite`, the SQLite drop-in's default + `FQDB` (drop-in `constants.php:33-51`). +- `tests/phpunit/includes/bootstrap.php:261` shells out to + `tests/phpunit/includes/install.php` on every phpunit run. +- `install.php:66-79` issues `DROP TABLE IF EXISTS` for every table in + `$wpdb->tables()`. +- Phase 6's `wp-tests-config.php` does not define `DB_DIR` or `DB_FILE`, + so the drop-in resolves the same `FQDB` for both bootstraps. One + file, two writers, one of which truncates on every run. + +The spec is explicit that envlite "never drops tables" (Phase 8, +non-obvious decision 12). The single-file collision quietly violates +that contract via phpunit instead of via envlite. + +## Goal + +Run `phpunit` against a separate SQLite file from the one +`envlite serve` reads, with no change to the live runtime, no new +state surface, and no observable difference for `serve` / +`up` / `clean`. + +## Non-goals + +- Configurable test DB path. envlite is a dev-only tool; one + hardcoded path keeps cross-checkout drift at zero. +- Isolating phpunit invocations from each other. The test bootstrap + already drops and recreates tables on every run; per-run isolation + is its job, not envlite's. +- Changing the live runtime DB path or filename. +- Touching the SQLite drop-in. The drop-in already exposes the + control surface we need. + +## Mechanism + +Single new `define` in `wp-tests-config.php`: + +```php +define( 'DB_FILE', '.ht.test.sqlite' ); +``` + +`DB_DIR` stays unset. `FQDBDIR` keeps its drop-in default +(`WP_CONTENT_DIR . '/database/'`), so the test DB lives next to the +live DB, both in `src/wp-content/database/`: + +| File | Owner | Lifecycle | +|---|---|---| +| `.ht.sqlite` | live runtime (Phase 8 + `envlite serve`) | observation-recorded in manifest, prompts on `clean` | +| `.ht.test.sqlite` | phpunit `install.php` | not envlite-managed; `git clean -fdx` removes | + +The constants are scoped per bootstrap path: `wp-tests-config.php` is +loaded only by phpunit's bootstrap, `src/wp-config.php` only by +`wp-load.php`. Defining `DB_FILE` in the test config is invisible to +the live runtime. + +### Why same dir, different filename + +- Smallest delta to the spec — Phase 6 grows by one append, no other + phase touches. +- The drop-in's `FQDBDIR` machinery stays on its default; no risk of + path-resolution surprises in `WP_SQLite_DB::ensure_database_directory`. +- `clean`'s reverse-manifest walk is indifferent to a sibling file in + a directory it doesn't own. + +Considered and rejected: a separate `database-test/` dir (buys +nothing concrete, costs a `DB_DIR` define and an extra mkdir +codepath); placing the test DB under `.envlite/` (mixes envlite's own +state — port, manifest — with WP-managed file bytes, blurring the +ownership story documented in "envlite state directory"). + +### Why untracked + +The observation hook exists *because* the live `.ht.sqlite` may hold +user-authored content (admin posts, settings). That rationale does +not apply to a file phpunit drops every run. Treating the test DB as +a phpunit side effect — same category as `vendor/`, `node_modules/`, +and build outputs — is consistent with the existing pattern: envlite +invokes the tool, envlite does not own the tool's artifacts. + +`envlite clean` therefore does not prompt for `.ht.test.sqlite` and +does not remove it. The user removes it the same way they remove +`vendor/`: `git clean -fdx` or equivalent. + +## Spec changes (Phase 6) + +The phase's existing 3-substitution flow gains one append step: + +> 4. After the substitutions and the placeholder-elimination assertion, +> append the line `define( 'DB_FILE', '.ht.test.sqlite' );` to the +> substituted contents (with a leading newline if the sample does +> not end with one). Then write the result to `wp-tests-config.php`. + +Tripwire (mirrors Phase 5's `{SQLITE_IMPLEMENTATION_FOLDER_PATH}` +post-condition): + +> 5. Post-condition tripwire: assert that the substituted sample does +> not already contain `DB_FILE`. The append step assumes upstream +> has not added a `DB_FILE` define of its own; if a future sample +> reshape introduces one, the assumption silently breaks. On match, +> abort with `envlite init: phase 6: DB_FILE already defined in +> wp-tests-config-sample.php; envlite assumption broken`. + +Phase 6's idempotency contract is unchanged. The hash recorded in the +manifest covers the post-append bytes; user edits to the appended +`define` show up as drift on the next `init` and prompt. + +Rationale paragraph added to Phase 6 (positioned with the "Why two +distinct config files" notes): + +> The `DB_FILE` define isolates the test DB from the live one. The +> phpunit bootstrap's `install.php` drops every WP table on every +> run; sharing the drop-in's default `FQDB` with `src/wp-config.php` +> would silently wipe the dev site after every test invocation, +> contradicting Phase 8's "envlite never drops tables" contract via +> phpunit's bootstrap. + +## Spec changes (other sections) + +- **"Outputs (final repo state)" → "Side effects of `init`":** add bullet + ``` + src/wp-content/database/.ht.test.sqlite (created on first phpunit run) + ``` +- **"Non-obvious decisions, recorded once":** add item 14: + > **Test DB is isolated via `DB_FILE` in the test config only.** + > phpunit's `tests/phpunit/includes/install.php` drops every WP + > table on every run; without isolation it would wipe the dev + > site Phase 8 installs. The split is one `define( 'DB_FILE', + > '.ht.test.sqlite' )` in `wp-tests-config.php`; `src/wp-config.php` + > stays untouched and the live runtime keeps the drop-in's default + > `FQDB`. Same-directory + filename suffix beats a separate + > `database-test/` (no path-resolution surprises) and beats putting + > it under `.envlite/` (preserves envlite's own-state-only + > convention for that directory). The test DB is not + > observation-tracked because the rationale for tracking the live + > DB — possible user-authored content — does not apply to a file + > phpunit drops every run. + +## What does NOT change + +- Phase 0 preflight checks. +- Phase 1 port discovery, `.envlite/port`, the cache contract. +- Phase 2 `npm ci`, Phase 3 `build:dev`, Phase 4 `composer install`. +- Phase 5 SQLite drop-in install (zip download, SHA pin, `db.copy` + → `db.php` activation, `{SQLITE_IMPLEMENTATION_FOLDER_PATH}` + tripwire). +- Phase 7 `src/wp-config.php` (no `DB_FILE` define added; live + runtime keeps the drop-in's default `FQDB`). +- Phase 8 `wp_install()` flow, fixed credentials, idempotency, + observation hook for `.ht.sqlite`. +- Manifest contract, atomic-write rules, `clean` semantics. +- `envlite serve` / `up` behavior, the `pcntl_exec` Unix path, + the `proc_open` Windows fallback, the bind-failure pre-flight. +- All exit codes, all stderr prefixes. + +## Risk surface + +Implementation-time due-diligence items (not blockers; verified in +the implementation plan, not the design): + +1. **`DB_FILE` is read at the right time.** The drop-in's + `constants.php` reads `DB_FILE` to compute `FQDB`. That file + executes when `wp-content/db.php` is autoloaded by + `wp-settings.php`. The phpunit bootstrap chain is: + `bootstrap.php` → `wp-tests-config.php` (defines `DB_FILE`) → + spawns `install.php` subprocess → `install.php` re-loads + `wp-tests-config.php` *before* `require_once ABSPATH . 'wp-settings.php'`. + Both processes (`bootstrap.php`'s and `install.php`'s) define + `DB_FILE` before `wp-settings.php` runs, so the drop-in sees it. + Verify by reading `wp-settings.php`'s db.php load order and the + `install.php` config-load order. +2. **Drop-in creates the DB file on first use.** The drop-in's + `WP_SQLite_DB` opens (and creates) `FQDB` on first query; same + code path as the live DB. No filename-pattern check pins + `.ht.sqlite` specifically. Quick grep over the drop-in's + `wp-includes/` directory confirms. +3. **No other config pins the test DB path.** `phpunit.xml.dist`, + `phpunit/multisite.xml`, the bootstrap, and `install.php` use + `$wpdb` exclusively after `wp-tests-config.php` loads. Spec + already treats `wp-tests-config.php` as the single source of + truth for test DB config. Grep confirms no `define` of + `DB_DIR`/`DB_FILE`/`FQDB`/`FQDBDIR` lives elsewhere in the + wordpress-develop test tree. + +Each is a 30-second check; the design proceeds on the assumption that +all three pass. + +## Test plan + +Manually verifiable post-implementation: + +1. `php tools/local-env/envlite.php init` → Phase 8 succeeds; visit + `http://127.0.0.1:/` → 2xx homepage (not a 3xx redirect to + `/wp-admin/install.php`, which would mean the DB has no tables). +2. `./vendor/bin/phpunit --group html-api` → green. +3. Visit `http://127.0.0.1:/` again → still a 2xx homepage, + not a redirect to install.php. +4. `ls src/wp-content/database/` → both `.ht.sqlite` and + `.ht.test.sqlite` present. +5. `php tools/local-env/envlite.php clean` (with the implicit + `--force` for non-interactive runs, or `y` at the prompt) → + `.ht.sqlite` removed (observation-tracked), `.ht.test.sqlite` + preserved (untracked). +6. Re-run step 1 → succeeds without any prompt about the leftover + `.ht.test.sqlite`. + +Step 3 is the regression test for the bug this design fixes; without +the change, the dev site is wiped between steps 2 and 3. diff --git a/plans/2026-05-09-envlite-test-db-isolation-plan.md b/plans/2026-05-09-envlite-test-db-isolation-plan.md new file mode 100644 index 0000000000000..b1e3f350000d8 --- /dev/null +++ b/plans/2026-05-09-envlite-test-db-isolation-plan.md @@ -0,0 +1,434 @@ +# envlite test DB isolation — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `phpunit` use a separate SQLite file (`.ht.test.sqlite`) from the dev site (`.ht.sqlite`), so test runs no longer wipe the dev site Phase 8 just installed. + +**Architecture:** Append one `define( 'DB_FILE', '.ht.test.sqlite' );` to the bytes envlite writes for `wp-tests-config.php` in Phase 6. The SQLite drop-in's `constants.php` reads `DB_FILE` to compute `FQDB`; defining it only in the test config (and not in `src/wp-config.php`) gives per-bootstrap-path isolation. No other phase changes. + +**Tech Stack:** PHP 7.4+, the existing test harness at `tools/local-env/tests/`, `envlite_phase6_render` / `envlite_phase6_install` in `tools/local-env/envlite.php`. + +**Design doc:** `plans/2026-05-09-envlite-test-db-isolation-design.md` (commit `4344f0b3a6`). + +--- + +## Background for an engineer with zero context + +- envlite is a PHP CLI at `tools/local-env/envlite.php` that brings a clean wordpress-develop checkout to a runnable state — see `plans/ENVLITE_SPECIFICATION.md` for the full spec. +- It writes a phpunit-only config at `wp-tests-config.php` (Phase 6) and a separate runtime config at `src/wp-config.php` (Phase 7). Both bootstrap a SQLite drop-in plugin at `src/wp-content/db.php`. +- The drop-in (`src/wp-content/plugins/sqlite-database-integration/constants.php`, lines 33–51) computes its DB file path (`FQDB`) from optional `DB_DIR` / `DB_FILE` constants, falling back to `WP_CONTENT_DIR . '/database/.ht.sqlite'`. +- Today neither config defines `DB_FILE`, so the same SQLite file backs both the dev site and the phpunit test run. The test bootstrap (`tests/phpunit/includes/install.php:66-79`) drops every WP table on every run — so any `phpunit` invocation wipes the dev site. +- The fix is one line in the bytes envlite writes for `wp-tests-config.php`. That's it. + +The codebase ships its own tiny test harness — there's no PHPUnit for envlite itself. Each test is a global function whose name starts with `test_` in `tools/local-env/tests/test_*.php`; `php tools/local-env/tests/run.php` discovers and runs them. + +--- + +## File structure + +| File | Action | Responsibility | +|---|---|---| +| `tools/local-env/envlite.php` | Modify (functions `envlite_phase6_render` ~line 580 and surrounding) | Append the `DB_FILE` define and add a "DB_FILE not in upstream sample" tripwire. | +| `tools/local-env/tests/test_phase6.php` | Modify | New unit tests for the append and the tripwire. | +| `plans/ENVLITE_SPECIFICATION.md` | Modify | Update Phase 6 prose, "Outputs" side-effects bullet, "Non-obvious decisions" item. | + +No new files. No file splits. Keep the change confined to Phase 6's render function. + +--- + +## Task 0: Pre-implementation verification + +The design doc lists three risk-surface items that should be confirmed before writing any code. Each is a 30-second check. + +**Files:** read-only. + +- [ ] **Step 1: Verify the drop-in reads `DB_FILE` lazily (no early bind to default path).** + +Run: +``` +grep -nE "FQDB|DB_FILE|DB_DIR|FQDBDIR" src/wp-content/plugins/sqlite-database-integration/constants.php src/wp-content/plugins/sqlite-database-integration/db.copy +``` + +Expected: `constants.php` defines `FQDBDIR` and `FQDB` only inside `if ( ! defined( ... ) )` guards, reading `DB_FILE` / `DB_DIR` if defined. `db.copy` does not define `FQDB` itself before `constants.php` runs. Stop and revisit the design if either assumption fails. + +- [ ] **Step 2: Confirm `install.php` loads the test config before `wp-settings.php`.** + +Run: +``` +grep -nE "config_file_path|wp-settings" tests/phpunit/includes/install.php +``` + +Expected: a `require_once $config_file_path;` line that runs *before* `require_once ABSPATH . 'wp-settings.php';`. The `DB_FILE` constant defined in `wp-tests-config.php` therefore lands before the drop-in's `constants.php` runs (the drop-in is loaded by `wp-settings.php` via `wp-content/db.php`). + +- [ ] **Step 3: Confirm no other config pins the test DB path.** + +Run: +``` +grep -rnE "DB_FILE|DB_DIR|FQDB[^I]|FQDBDIR" tests/phpunit/ phpunit.xml.dist 2>/dev/null +``` + +Expected: no hits. (If anything turns up, the design's "single source of truth" assumption is wrong; pause and reassess.) + +- [ ] **Step 4: Confirm the upstream sample does not already contain a `DB_FILE` define.** + +Run: +``` +grep -nE "DB_FILE" wp-tests-config-sample.php +``` + +Expected: no output. If the sample already defines `DB_FILE`, the design's append+tripwire approach needs a rethink; stop here. + +No commit for this task — it's read-only verification. + +--- + +## Task 1: Add a tripwire for `DB_FILE` in the upstream sample + +A failing test first, then the implementation. This task adds the assertion only — the `define` append comes in Task 2 — so the failing-test step runs against the current `envlite_phase6_render` and confirms the tripwire isn't there yet. + +**Files:** +- Modify: `tools/local-env/tests/test_phase6.php` +- Modify: `tools/local-env/envlite.php` (function `envlite_phase6_render` ~line 580) + +- [ ] **Step 1: Write the failing test.** + +Append to `tools/local-env/tests/test_phase6.php`: + +```php +function test_phase6_render_throws_when_db_file_already_defined() { + $sample = "define( 'DB_NAME', 'youremptytestdbnamehere' );\n" + . "define( 'DB_USER', 'yourusernamehere' );\n" + . "define( 'DB_PASSWORD', 'yourpasswordhere' );\n" + . "define( 'DB_FILE', 'something.sqlite' );\n"; + try { + envlite_phase6_render($sample); + throw new \RuntimeException('expected exception'); + } catch (\RuntimeException $e) { + envlite_assert(strpos($e->getMessage(), 'DB_FILE') !== false); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails.** + +Run: `php tools/local-env/tests/run.php` + +Expected: `FAIL test_phase6_render_throws_when_db_file_already_defined: expected exception`. Other tests still pass. + +- [ ] **Step 3: Implement the tripwire in `envlite_phase6_render`.** + +In `tools/local-env/envlite.php`, modify `envlite_phase6_render` (the function that currently substitutes the three placeholders and returns the rendered string). Add the tripwire check just before the function's `return $out;`: + +```php + if (preg_match("/define\\s*\\(\\s*['\"]DB_FILE['\"]/", $out)) { + throw new \RuntimeException( + "phase 6: DB_FILE already defined in wp-tests-config-sample.php; envlite assumption broken" + ); + } + return $out; +``` + +- [ ] **Step 4: Run all envlite unit tests.** + +Run: `php tools/local-env/tests/run.php` + +Expected: all tests pass, including the new `test_phase6_render_throws_when_db_file_already_defined`. + +- [ ] **Step 5: Commit.** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_phase6.php +git commit -m "feat(envlite): assert DB_FILE absent from wp-tests-config sample" +``` + +--- + +## Task 2: Append the `DB_FILE` define + +Now the actual isolation. TDD again: assertion test, run-fail, implement, run-pass, commit. + +**Files:** +- Modify: `tools/local-env/tests/test_phase6.php` +- Modify: `tools/local-env/envlite.php` (function `envlite_phase6_render`) + +- [ ] **Step 1: Write the failing test.** + +Append to `tools/local-env/tests/test_phase6.php`: + +```php +function test_phase6_render_appends_db_file_define() { + $sample = "define( 'DB_NAME', 'youremptytestdbnamehere' );\n" + . "define( 'DB_USER', 'yourusernamehere' );\n" + . "define( 'DB_PASSWORD', 'yourpasswordhere' );\n"; + $out = envlite_phase6_render($sample); + envlite_assert(preg_match("/define\\(\\s*'DB_FILE'\\s*,\\s*'\\.ht\\.test\\.sqlite'\\s*\\)\\s*;/", $out) === 1); + // Output must end with a single trailing newline. + envlite_assert(substr($out, -1) === "\n"); + envlite_assert(substr($out, -2) !== "\n\n"); +} + +function test_phase6_render_appends_db_file_when_sample_has_no_trailing_newline() { + $sample = "define( 'DB_NAME', 'youremptytestdbnamehere' );\n" + . "define( 'DB_USER', 'yourusernamehere' );\n" + . "define( 'DB_PASSWORD', 'yourpasswordhere' );"; // no \n + $out = envlite_phase6_render($sample); + envlite_assert(preg_match("/define\\(\\s*'DB_FILE'\\s*,\\s*'\\.ht\\.test\\.sqlite'\\s*\\)\\s*;/", $out) === 1); + envlite_assert(substr($out, -1) === "\n"); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail.** + +Run: `php tools/local-env/tests/run.php` + +Expected: both new tests fail (the rendered output does not yet contain `define( 'DB_FILE', ... )`). + +- [ ] **Step 3: Implement the append in `envlite_phase6_render`.** + +In `tools/local-env/envlite.php`, modify `envlite_phase6_render` so the function (after the placeholder-elimination loop and the new tripwire from Task 1) appends the `DB_FILE` define before returning. The full function should read: + +```php +function envlite_phase6_render(string $sample): string { + $replacements = [ + 'youremptytestdbnamehere' => 'wordpress_test', + 'yourusernamehere' => 'wp', + 'yourpasswordhere' => 'wp', + ]; + foreach ($replacements as $placeholder => $value) { + if (substr_count($sample, $placeholder) !== 1) { + throw new \RuntimeException("phase 6: placeholder '$placeholder' must appear exactly once"); + } + } + $out = strtr($sample, $replacements); + foreach (array_keys($replacements) as $placeholder) { + if (strpos($out, $placeholder) !== false) { + throw new \RuntimeException("phase 6: placeholder '$placeholder' still present after substitution"); + } + } + if (preg_match("/define\\s*\\(\\s*['\"]DB_FILE['\"]/", $out)) { + throw new \RuntimeException( + "phase 6: DB_FILE already defined in wp-tests-config-sample.php; envlite assumption broken" + ); + } + if (substr($out, -1) !== "\n") { + $out .= "\n"; + } + $out .= "define( 'DB_FILE', '.ht.test.sqlite' );\n"; + return $out; +} +``` + +- [ ] **Step 4: Run all envlite unit tests.** + +Run: `php tools/local-env/tests/run.php` + +Expected: every test passes — the existing three Phase 6 tests, the Task 1 tripwire test, and the two new append tests. + +- [ ] **Step 5: Commit.** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_phase6.php +git commit -m "feat(envlite): isolate phpunit DB by appending DB_FILE to wp-tests-config" +``` + +--- + +## Task 3: End-to-end verification on a real checkout + +This task is the regression test for the bug the design fixes. It uses the actual envlite tool against this checkout (no automation needed — it's a manual sequence that takes ~3 minutes). If any step fails, fix forward; do not commit a broken state. + +**Files:** none modified. + +**Prerequisite:** working node ≥ 20.10 / npm ≥ 10.2.3 / composer ≥ 2 / PHP ≥ 7.4 with `pdo_sqlite`, `sqlite3`, `openssl`, `simplexml`, `zip`, `pcntl` (Unix). If any of these are missing, Phase 0 will fail-fast and tell you which. + +- [ ] **Step 1: Reset to a known-clean state.** + +Run: +``` +php tools/local-env/envlite.php clean --force +``` + +Expected: exit 0, `.envlite/` removed, no `wp-tests-config.php` / `src/wp-config.php` / `src/wp-content/db.php` / `src/wp-content/plugins/sqlite-database-integration/` left from a prior run. + +(If you've never run `init` before in this checkout, this is a no-op; that's fine.) + +- [ ] **Step 2: Run a fresh init.** + +Run: +``` +php tools/local-env/envlite.php init +``` + +Expected: exits 0. `wp-tests-config.php` exists at the repo root, ends with `define( 'DB_FILE', '.ht.test.sqlite' );` followed by a single newline: + +``` +tail -2 wp-tests-config.php +``` + +Should print: +``` +define( 'DB_FILE', '.ht.test.sqlite' ); +``` + +(Plus a trailing newline.) + +- [ ] **Step 3: Confirm Phase 8 created the live DB only.** + +Run: +``` +ls -la src/wp-content/database/ +``` + +Expected: `.ht.sqlite` present, `.ht.test.sqlite` absent (phpunit hasn't run yet). + +- [ ] **Step 4: Hit the dev site and capture a marker.** + +In one shell: +``` +php tools/local-env/envlite.php serve +``` + +In another shell, with `` replaced by the contents of `.envlite/port`: +``` +curl -sI "http://127.0.0.1:/" | head -1 +``` + +Expected: `HTTP/1.1 200 OK` (not a 3xx redirect to `wp-admin/install.php`). Now insert a marker post via `wp-admin/edit.php` in a browser (`admin` / `password`) — title it `MARKER PRE-PHPUNIT`, publish. + +Stop the dev server with Ctrl-C. + +- [ ] **Step 5: Run phpunit.** + +Run: +``` +./vendor/bin/phpunit --group html-api +``` + +Expected: green run, ~1300+ tests pass. + +- [ ] **Step 6: Confirm both DB files now exist.** + +Run: +``` +ls -la src/wp-content/database/ +``` + +Expected: both `.ht.sqlite` and `.ht.test.sqlite` present. The test DB's mtime should be newer than the live DB's (phpunit just touched it; nothing has touched the live DB since Step 4). + +- [ ] **Step 7: Confirm the marker post survived.** + +Restart `serve`: +``` +php tools/local-env/envlite.php serve +``` + +In a browser, hit `/wp-admin/edit.php` and confirm `MARKER PRE-PHPUNIT` is still listed. Without the fix, this post would have been wiped by the phpunit run. + +Stop the dev server. + +- [ ] **Step 8: Confirm `clean` removes only the live DB.** + +Run: +``` +php tools/local-env/envlite.php clean --force +ls -la src/wp-content/database/ 2>/dev/null +``` + +Expected: `.ht.sqlite` is gone (observation-tracked, removed by `clean`); `.ht.test.sqlite` is still present (untracked, preserved). The directory itself may or may not exist depending on whether other tracked entries triggered its removal — both are acceptable. + +- [ ] **Step 9: Confirm a follow-up `init` does not prompt about the leftover.** + +Run: +``` +php tools/local-env/envlite.php init +``` + +Expected: exit 0, no prompt, no warning about `.ht.test.sqlite`. The orphan is invisible to envlite — that's the whole point of leaving it untracked. + +No commit for this task — it's manual verification. + +--- + +## Task 4: Update the spec document + +With the implementation green and end-to-end-verified, fold the change into `plans/ENVLITE_SPECIFICATION.md`. + +**Files:** +- Modify: `plans/ENVLITE_SPECIFICATION.md` + +- [ ] **Step 1: Update Phase 6 — operation step.** + +Find the `## Phase 6 — phpunit configuration` section, then the `**Operation:**` block. The current text describes a 3-substitution flow that ends with "After the write, assert that each of the three placeholders is no longer present in the output". After that sentence, add: + +``` +Then assert that the substituted bytes do not already contain a +`DB_FILE` define (regex: `define\s*\(\s*['\"]DB_FILE['\"]`); a +match means upstream's `wp-tests-config-sample.php` has grown its +own `DB_FILE` and envlite's append assumption no longer holds — +abort with `envlite init: phase 6: DB_FILE already defined in +wp-tests-config-sample.php; envlite assumption broken`. Finally, +ensure the bytes end in `\n` (append one if not) and append the +literal line `define( 'DB_FILE', '.ht.test.sqlite' );\n`. Write +the result to `wp-tests-config.php`. +``` + +- [ ] **Step 2: Add a Phase 6 rationale paragraph.** + +In the same Phase 6 section, find the `**Notes:**` block. Add a new bullet at the end of the existing Notes list: + +``` +- The appended `DB_FILE` define isolates the phpunit test DB at + `src/wp-content/database/.ht.test.sqlite` from the live runtime + DB at `src/wp-content/database/.ht.sqlite`. The phpunit + bootstrap's `tests/phpunit/includes/install.php` drops every WP + table on every run; sharing the drop-in's default `FQDB` between + the two configs would silently wipe the dev site Phase 8 + installs, contradicting Phase 8's "envlite never drops tables" + invariant via phpunit's bootstrap. `src/wp-config.php` (Phase 7) + remains free of any `DB_FILE` define so the live runtime keeps + the drop-in's default `FQDB`. +``` + +- [ ] **Step 3: Update "Outputs (final repo state)".** + +Find the `**Side effects of `init` (not envlite-managed; remove with your usual tooling):**` block. Add a new line under the existing three: + +``` +src/wp-content/database/.ht.test.sqlite (created on first phpunit run; not envlite-managed) +``` + +- [ ] **Step 4: Add a new entry to "Non-obvious decisions, recorded once".** + +After the existing item 13 (`127.0.0.1` everywhere), add: + +``` +14. **Test DB is isolated via `DB_FILE` in the test config only.** + phpunit's `tests/phpunit/includes/install.php` drops every WP + table on every run; without isolation it would wipe the dev + site Phase 8 installs. The split is one `define( 'DB_FILE', + '.ht.test.sqlite' )` appended to `wp-tests-config.php`; + `src/wp-config.php` stays untouched and the live runtime keeps + the drop-in's default `FQDB`. Same-directory + filename suffix + beats a separate `database-test/` (no path-resolution surprises + in the drop-in's `FQDBDIR` machinery) and beats putting it + under `.envlite/` (preserves envlite's own-state-only convention + for that directory). The test DB is not observation-tracked + because the rationale for tracking the live DB — possible + user-authored content — does not apply to a file phpunit drops + every run. +``` + +- [ ] **Step 5: Commit.** + +```bash +git add plans/ENVLITE_SPECIFICATION.md +git commit -m "docs(envlite): record DB_FILE isolation in Phase 6 spec" +``` + +--- + +## Self-review (run by the plan author, not the implementer) + +- **Spec coverage:** every change called out in the design doc — Phase 6 step, Phase 6 tripwire, Phase 6 rationale paragraph, "Side effects" bullet, non-obvious decision item — has a task. The 3 risk-surface items are Task 0. The end-to-end test plan from the design is Task 3. ✓ +- **Placeholder scan:** no TBDs, no "implement appropriately", no "similar to Task N" — every code block is complete. ✓ +- **Type consistency:** all references to `envlite_phase6_render` match the existing function signature `(string $sample): string`. The constant name (`DB_FILE`), filename (`.ht.test.sqlite`), and error message string (`"phase 6: DB_FILE already defined in wp-tests-config-sample.php; envlite assumption broken"`) are identical across Tasks 1, 2, and 4. ✓ diff --git a/plans/2026-05-11-envlite-disable-wp-cron-design.md b/plans/2026-05-11-envlite-disable-wp-cron-design.md new file mode 100644 index 0000000000000..94773f6fcc336 --- /dev/null +++ b/plans/2026-05-11-envlite-disable-wp-cron-design.md @@ -0,0 +1,128 @@ +# envlite — disable WP-Cron by default in the runtime config + +**Status:** design. +**Relates to:** `plans/ENVLITE_SPECIFICATION.md` (Phase 7 — Runtime configuration (`src/wp-config.php`)). + +## Problem + +WordPress runs pseudo-cron via `spawn_cron()` on every front-end HTTP +request: a non-blocking loopback POST to `wp-cron.php` initiated from +the request that is currently being served. + +envlite's runtime is PHP's built-in dev server (`php -S`), which +serializes requests by default. Every front-end hit pays the cost of +opening the loopback connection plus `spawn_cron()`'s short +send/recv timeout, regardless of whether anything is due. When the +loopback request *is* finally serviced (after the foreground request +returns), it blocks the dev server from servicing the next browser +request until cron finishes — turning what would be background work +into a head-of-line stall. On a dev box where nothing depends on +cron firing promptly, the net effect is added per-request latency +and unpredictable stalls for no benefit. + +The current Phase 7 output leaves the WordPress default intact, so a +fresh `envlite init` ships a dev site that exhibits this behavior on +every page load. + +## Goal + +After `envlite init`, the rendered `src/wp-config.php` defines +`DISABLE_WP_CRON` as `true`, so `spawn_cron()` is suppressed on every +HTTP request served by `envlite serve` / `envlite up`. No change to +the test config, no new flags, no new state. + +## Non-goals + +- Configurability. envlite is a dev-only tool; an + `--enable-cron` / `--disable-cron` knob would just create + cross-checkout drift. If a user genuinely needs cron, they edit the + rendered file; envlite's existing owned-drifted prompt covers the + next `init`. +- Changing `wp-tests-config.php` (Phase 6). phpunit does not run + inside an HTTP request lifecycle, so `spawn_cron()` is never + invoked from a test run, and defining `DISABLE_WP_CRON` there + would only risk interfering with cron-related tests that expect + default behavior. +- Replacing cron with WP-CLI's `cron event run` or a system cron + shim. Out of scope; envlite leaves no daemons behind. +- Using `ALTERNATE_WP_CRON`. That mechanism runs cron in-band on the + same single-threaded server, which makes the latency problem + worse, not better. + +## Design + +### Where the change lives + +In `envlite_phase7_render()` (`tools/local-env/envlite.php:650`), the +existing inject block that lands immediately before the +`/* That's all, stop editing! Happy publishing. */` marker grows by +one line. + +Before: + +```php +define( 'WP_HOME', 'http://127.0.0.1:' ); +define( 'WP_SITEURL', 'http://127.0.0.1:' ); +``` + +After: + +```php +define( 'WP_HOME', 'http://127.0.0.1:' ); +define( 'WP_SITEURL', 'http://127.0.0.1:' ); +define( 'DISABLE_WP_CRON', true ); +``` + +The value is the literal `true` (hardcoded). The ordering keeps the +URL constants together, then the runtime-behavior override, then the +trailing blank line and the marker — same anchoring, same single +substring operation. + +### Spec edits + +Update `plans/ENVLITE_SPECIFICATION.md` Phase 7: + +- Step 5's injected block grows by the `DISABLE_WP_CRON` line. +- Add a "Why `DISABLE_WP_CRON` matters" sentence beneath the existing + "Why `WP_HOME` / `WP_SITEURL` matter" paragraph, explaining the + single-threaded `php -S` interaction with `spawn_cron()`. + +No change to Phase 6, Phase 8, ownership rules, manifest schema, or +CLI surface. + +### Idempotency and existing checkouts + +Phase 7's existing rule applies unchanged: + +- New checkout (path absent) → write, record. The new constant is + present from the first `init`. +- Existing checkout that previously ran `envlite init` + (path present, in manifest, hash matches) → silent re-stamp. The + re-rendered output now contains `DISABLE_WP_CRON`; the manifest + hash updates to the new render. No prompt; no user action. +- Path present, hash drifted (user edited the file) → existing + prompt fires before overwrite, unchanged. +- Path present, not in manifest → existing prompt fires, unchanged. + +The `envlite init` after the change is functionally equivalent to a +no-op silent re-stamp for any user who has not hand-edited +`src/wp-config.php`. Users who have hand-edited it will hit the +existing owned-drifted prompt — the correct path, since their custom +content needs to merge with the new line. + +## Testing + +- Unit-style check of `envlite_phase7_render()` output: the rendered + string contains exactly one occurrence of + `define( 'DISABLE_WP_CRON', true );`, positioned between the + `WP_SITEURL` line and the `/* That's all, stop editing!` marker. +- End-to-end: on a fresh checkout, `php tools/local-env/envlite.php init` + followed by `grep -c "DISABLE_WP_CRON" src/wp-config.php` returns + `1`. +- Re-run `init`: silent re-stamp on a checkout whose previous + `wp-config.php` was envlite-owned; existing drift prompt on a + checkout whose `wp-config.php` was hand-edited. + +## Open questions + +None. Scope, anchor, literal, and spec edits are all settled. diff --git a/plans/2026-05-11-envlite-disable-wp-cron-plan.md b/plans/2026-05-11-envlite-disable-wp-cron-plan.md new file mode 100644 index 0000000000000..5dea4d283e7a6 --- /dev/null +++ b/plans/2026-05-11-envlite-disable-wp-cron-plan.md @@ -0,0 +1,296 @@ +# envlite — disable WP-Cron by default — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** After `envlite init`, the rendered `src/wp-config.php` defines `DISABLE_WP_CRON` as `true`, so `spawn_cron()` no longer fires a loopback request on every front-end hit served by `envlite serve` / `envlite up`. + +**Architecture:** One additional line injected by `envlite_phase7_render()` in the existing inject block, anchored on the same `/* That's all, stop editing! Happy publishing. */` marker. No new files, no new flags, no manifest schema changes. Spec text in `plans/ENVLITE_SPECIFICATION.md` updated to match. + +**Tech Stack:** PHP 7.4+, the existing test harness at `tools/local-env/tests/`, `envlite_phase7_render` / `envlite_phase7_install` in `tools/local-env/envlite.php`. + +**Design doc:** `plans/2026-05-11-envlite-disable-wp-cron-design.md`. + +--- + +## Background for an engineer with zero context + +- envlite is a PHP CLI at `tools/local-env/envlite.php` that brings a clean wordpress-develop checkout to a runnable state — see `plans/ENVLITE_SPECIFICATION.md` for the full spec. +- Phase 7 (`envlite_phase7_render`, ~line 650) reads `wp-config-sample.php`, replaces DB constants, optionally swaps in fresh salts, injects two `define()` lines (`WP_HOME` / `WP_SITEURL`) immediately before the `/* That's all, stop editing! */` marker, then writes the result to `src/wp-config.php`. +- WordPress's pseudo-cron mechanism fires `spawn_cron()` on every front-end HTTP request: a non-blocking loopback POST to `wp-cron.php`. Because `php -S` (envlite's runtime) serializes requests by default, that loopback is paid for on every page load with no benefit on a dev box, and creates head-of-line stalls when it is finally serviced. The fix is to define `DISABLE_WP_CRON` as `true` in the runtime config. +- Phase 6 (`wp-tests-config.php`) is intentionally **not** changed — phpunit does not run inside an HTTP request lifecycle, so `spawn_cron()` is never invoked from a test run, and defining `DISABLE_WP_CRON` there would only risk interfering with cron-related tests. + +The codebase ships its own tiny test harness — there's no PHPUnit for envlite itself. Each test is a global function whose name starts with `test_` in `tools/local-env/tests/test_*.php`; `php tools/local-env/tests/run.php` discovers and runs them. Assertions are `envlite_assert` / `envlite_assert_eq` (defined in `tools/local-env/tests/harness.php`). + +--- + +## File structure + +| File | Action | Responsibility | +|---|---|---| +| `tools/local-env/envlite.php` | Modify (function `envlite_phase7_render` ~line 650) | Add `DISABLE_WP_CRON` to the inject block. | +| `tools/local-env/tests/test_phase7.php` | Modify | New unit test asserting the line is present and positioned correctly. | +| `plans/ENVLITE_SPECIFICATION.md` | Modify | Update Phase 7 step 5 inject block and add a "Why `DISABLE_WP_CRON` matters" paragraph. | + +No new files. No file splits. + +--- + +## Task 1: Pre-implementation sanity checks + +Two read-only checks the design depends on. Each is a 30-second confirmation. + +**Files:** read-only. + +- [ ] **Step 1: Confirm `wp-config-sample.php` does not already define `DISABLE_WP_CRON`.** + +Run: +``` +grep -nE "DISABLE_WP_CRON|ALTERNATE_WP_CRON|WP_CRON" wp-config-sample.php +``` + +Expected: no output. (If the upstream sample has gained a cron-related define, the plan needs a rethink — the new inject would create a duplicate. Stop and reassess.) + +- [ ] **Step 2: Confirm the marker still appears exactly once in `wp-config-sample.php`.** + +Run: +``` +grep -c "That's all, stop editing" wp-config-sample.php +``` + +Expected: `1`. (`envlite_phase7_render` already asserts this at runtime, but a hard divergence at the sample level would force a redesign of Phase 7's anchor before this plan can proceed.) + +No commit for this task — it's read-only verification. + +--- + +## Task 2: Add the failing test, then implement the inject + +Standard TDD: write the assertion, run the suite to confirm it fails, change `envlite_phase7_render`, run the suite to confirm it passes, commit. The whole task is a single commit — test plus implementation — because the test asserts a property of `envlite_phase7_render`'s output and there is nothing for the test to anchor to until the implementation lands. + +**Files:** +- Modify: `tools/local-env/tests/test_phase7.php` +- Modify: `tools/local-env/envlite.php` (function `envlite_phase7_render` ~line 696–697) + +- [ ] **Step 1: Write the failing test.** + +Append to `tools/local-env/tests/test_phase7.php`: + +```php +function test_phase7_render_injects_disable_wp_cron_before_marker() { + $sample = file_get_contents(dirname(__DIR__, 3) . '/wp-config-sample.php'); + $out = envlite_phase7_render($sample, 8421, null); + $cron = "define( 'DISABLE_WP_CRON', true );"; + $site = "define( 'WP_SITEURL', 'http://127.0.0.1:8421' );"; + $marker = "/* That's all, stop editing! Happy publishing. */"; + // Exactly one occurrence of the new define. + envlite_assert_eq(1, substr_count($out, $cron)); + // Positioned after WP_SITEURL and before the marker. + envlite_assert(strpos($out, $cron) > strpos($out, $site), 'DISABLE_WP_CRON must be after WP_SITEURL'); + envlite_assert(strpos($out, $cron) < strpos($out, $marker), 'DISABLE_WP_CRON must be before marker'); +} +``` + +- [ ] **Step 2: Run the suite to verify the new test fails and nothing else regresses.** + +Run: +``` +php tools/local-env/tests/run.php +``` + +Expected: every other test still passes; the new test fails with an assertion message from the `envlite_assert_eq(1, substr_count(...))` line because the rendered output does not yet contain the `DISABLE_WP_CRON` define. + +- [ ] **Step 3: Implement the inject in `envlite_phase7_render`.** + +In `tools/local-env/envlite.php`, locate the existing inject block in `envlite_phase7_render` (currently around lines 696–697): + +```php + $inject = "define( 'WP_HOME', 'http://127.0.0.1:$port' );\n" + . "define( 'WP_SITEURL', 'http://127.0.0.1:$port' );\n\n"; +``` + +Replace it with: + +```php + $inject = "define( 'WP_HOME', 'http://127.0.0.1:$port' );\n" + . "define( 'WP_SITEURL', 'http://127.0.0.1:$port' );\n" + . "define( 'DISABLE_WP_CRON', true );\n\n"; +``` + +The trailing `\n\n` (the blank line separating the inject block from the marker) is preserved exactly as before. + +- [ ] **Step 4: Run the suite to verify everything passes.** + +Run: +``` +php tools/local-env/tests/run.php +``` + +Expected: every test passes, including the new `test_phase7_render_injects_disable_wp_cron_before_marker`. The existing `test_phase7_render_injects_wp_home_siteurl_before_marker`, `test_phase7_render_substitutes_db_constants`, `test_phase7_render_replaces_salts_when_provided`, `test_phase7_render_keeps_sample_salts_when_null_provided`, `test_phase7_render_treats_salts_as_literal_not_backreferences`, and `test_phase7_render_normalizes_crlf_in_sample` must all still pass — the change is purely additive. + +- [ ] **Step 5: Commit.** + +```bash +git add tools/local-env/envlite.php tools/local-env/tests/test_phase7.php +git commit -m "feat(envlite): disable WP-Cron by default in Phase 7 runtime config" +``` + +--- + +## Task 3: Update the specification + +The spec is the source of truth for envlite's behavior. The change to Phase 7's inject block and the "Why ... matters" paragraph need to land alongside the code so the spec doesn't drift. + +**Files:** +- Modify: `plans/ENVLITE_SPECIFICATION.md` (Phase 7 section, ~lines 627–648) + +- [ ] **Step 1: Update Phase 7 step 5's inject block.** + +In `plans/ENVLITE_SPECIFICATION.md`, find this passage (Phase 7, step 5): + +``` +5. Locate the literal marker + `/* That's all, stop editing! Happy publishing. */` (appears exactly + once in the sample) and inject the following two lines immediately + *before* it, separated by a blank line: + + ``` + define( 'WP_HOME', 'http://127.0.0.1:' ); + define( 'WP_SITEURL', 'http://127.0.0.1:' ); + ``` + + `` is the value from Phase 1. +``` + +Replace it with: + +``` +5. Locate the literal marker + `/* That's all, stop editing! Happy publishing. */` (appears exactly + once in the sample) and inject the following three lines immediately + *before* it, separated by a blank line: + + ``` + define( 'WP_HOME', 'http://127.0.0.1:' ); + define( 'WP_SITEURL', 'http://127.0.0.1:' ); + define( 'DISABLE_WP_CRON', true ); + ``` + + `` is the value from Phase 1. +``` + +- [ ] **Step 2: Add a "Why `DISABLE_WP_CRON` matters" paragraph.** + +In `plans/ENVLITE_SPECIFICATION.md`, find this paragraph (immediately after the `**Outputs:** src/wp-config.php.` line of Phase 7): + +``` +**Why `WP_HOME` / `WP_SITEURL` matter:** WordPress generates absolute +URLs in markup (admin links, redirects, REST endpoints). If they don't +match the listening address (`http://127.0.0.1:`), `wp-admin` +redirects loop and asset URLs break. They go in the runtime config; the +phpunit config doesn't care. +``` + +Insert immediately after it, as a new paragraph: + +``` +**Why `DISABLE_WP_CRON` matters:** WordPress runs pseudo-cron via +`spawn_cron()` on every front-end HTTP request — a non-blocking +loopback POST to `wp-cron.php`. envlite's runtime is PHP's built-in +dev server (`php -S`), which serializes requests by default, so every +front-end hit pays the cost of opening the loopback connection plus +`spawn_cron()`'s send/recv timeout, and the loopback itself stalls +the next browser request while it runs. `DISABLE_WP_CRON = true` +suppresses `spawn_cron()` entirely; cron is not needed on a dev box. +The phpunit config does not set this — phpunit runs outside an HTTP +request lifecycle, so `spawn_cron()` never fires from tests. +``` + +- [ ] **Step 3: Commit.** + +```bash +git add plans/ENVLITE_SPECIFICATION.md +git commit -m "docs(envlite): document Phase 7 DISABLE_WP_CRON in spec" +``` + +--- + +## Task 4: End-to-end verification on this checkout + +Manual sequence (~2 minutes). Confirms the new define lands in the actual rendered file and that re-running `init` is a silent re-stamp on a previously-envlite-owned `src/wp-config.php`. + +**Files:** none modified. + +**Prerequisite:** working `node` ≥ 20.10 / `npm` ≥ 10.2.3 / `composer` ≥ 2 / PHP ≥ 7.4 with `pdo_sqlite`, `sqlite3`, `openssl`, `simplexml`, `zip`, `pcntl` (Unix). If any are missing, Phase 0 will fail-fast and tell you which. + +- [ ] **Step 1: Reset to a known-clean state.** + +Run: +``` +php tools/local-env/envlite.php clean --force +``` + +Expected: exit 0, `.envlite/` and envlite-managed files removed. If the checkout has never been `init`-ed, this is a no-op. + +- [ ] **Step 2: Run a fresh `init`.** + +Run: +``` +php tools/local-env/envlite.php init +``` + +Expected: exit 0, all 8 phases complete. + +- [ ] **Step 3: Confirm the define lands exactly once in the rendered file.** + +Run: +``` +grep -c "DISABLE_WP_CRON" src/wp-config.php +``` + +Expected: `1`. + +- [ ] **Step 4: Confirm the define sits between `WP_SITEURL` and the marker.** + +Run: +``` +awk '/WP_SITEURL/{s=NR} /DISABLE_WP_CRON/{c=NR} /That.s all, stop editing/{m=NR} END{print s, c, m}' src/wp-config.php +``` + +Expected: three ascending line numbers. (`s < c < m`.) + +- [ ] **Step 5: Confirm re-running `init` is a silent re-stamp.** + +Run: +``` +php tools/local-env/envlite.php init 2>&1 | grep -iE "overwrite|drift|prompt" || echo "no prompts" +``` + +Expected: `no prompts`. The previous `init` recorded the manifest hash for the rendered output; the second `init` re-renders the same bytes and finds the manifest entry matches the file, so it silently re-stamps without prompting. + +- [ ] **Step 6: Smoke-test the dev site.** + +Start the dev server in one terminal: +``` +php tools/local-env/envlite.php serve +``` + +In another terminal: +``` +PORT=$(cat .envlite/port) && curl -fsS "http://127.0.0.1:$PORT/" -o /dev/null && echo "front page OK" +``` + +Expected: `front page OK`. (The point of disabling cron is to remove a per-request penalty; serving the front page proves the runtime still boots with the new constant.) + +Stop the server with Ctrl-C when done. + +No commit for this task — it is verification. + +--- + +## Done criteria + +- `php tools/local-env/tests/run.php` exits 0 with all tests (existing + new) passing. +- A fresh `envlite init` produces a `src/wp-config.php` containing exactly one `define( 'DISABLE_WP_CRON', true );` line, positioned between `WP_SITEURL` and the marker. +- The spec's Phase 7 section reflects the new inject block and the new "Why `DISABLE_WP_CRON` matters" paragraph. +- Re-running `envlite init` on a previously-envlite-owned checkout silently re-stamps `src/wp-config.php` (no overwrite prompt). +- The dev server still starts and serves the front page on the cached port. diff --git a/plans/ENVLITE_SPECIFICATION.md b/plans/ENVLITE_SPECIFICATION.md new file mode 100644 index 0000000000000..c23d6222cacf8 --- /dev/null +++ b/plans/ENVLITE_SPECIFICATION.md @@ -0,0 +1,1373 @@ +# envlite — wordpress-develop repo setup specification + +**Goal:** Take a clean checkout of `WordPress/wordpress-develop` and bring it +to a state where (1) PHP's built-in server can serve a working WordPress +site against a SQLite database, and (2) `./vendor/bin/phpunit` runs against +that SQLite database on host PHP — without starting any global services (no +system MySQL, no Docker, no MAMP). + +**Non-goals:** worktree creation, background process management, HTTPS, +production-shaped stacks. envlite operates on whatever directory it is +invoked in, and leaves no daemons behind. Multisite support is not +prioritized for the initial version but is not excluded from envlite's +charter. + +**Tech stack:** + +- host PHP ≥ 7.4 (matching WordPress's own supported floor), with + `pdo_sqlite`, `sqlite3`, `openssl`, `simplexml`, `zip`, and `hash` + extensions loaded. On Unix, `pcntl` is also required so + `envlite up` can call `pcntl_exec` into `php -S`. + Phase 0 verifies the full set; the brief here just names the + unavoidable ones. +- host `node` ≥ 20.10, `npm` ≥ 10.2.3 (matching `package.json` `engines`). +- host `composer` ≥ 2. +- the SQLite Database Integration plugin from wordpress.org, pinned by + SHA256: `44be096a14ebcea424b5e4bf764436ec85fb067f74ab47822c4c5346df21591e`. + +**No assumed availability** of `python`, `sed`, `awk`, `jq`, `unzip`, +`shasum`, `curl`, or any other host CLI. envlite is implemented in PHP and +performs all file operations, hashing, HTTP fetches, and zip extraction +through PHP's standard library (`file_get_contents` with stream context, +`hash_file`, `ZipArchive`, `preg_replace`, `str_replace`, `proc_open`). +Subprocesses spawned by envlite are limited to `node`/`npm`/`composer`, +plus the host `php` itself in two places: launching the dev server at +the end of `envlite up` and running the Phase 8 site install (script +piped to the subprocess via stdin). On Unix, the dev-server launch +uses `pcntl_exec` (process replacement) rather than a proper +subprocess; on Windows it is a `proc_open` because `pcntl` is +unavailable. + +--- + +## CLI interface + +### Invocation + +envlite is implemented as a PHP script at +`tools/local-env/envlite.php` in the wordpress-develop checkout, with +a small router asset at `tools/local-env/router.php` that `envlite up` +loads into PHP's built-in dev server. The canonical (and only +supported) invocation form is: + +``` +$ php tools/local-env/envlite.php [args...] +``` + +PATH-based forms (`envlite ` via a user-installed symlink +or shebang execution) are out of scope; envlite does not install +itself onto `PATH`, and the spec assumes the explicit `php …` form +above. Throughout the rest of this document, `envlite ` is +shorthand for the full command line. + +### Subcommands + +| Subcommand | Purpose | +|---|---| +| (no args), `help`, `--help`, `-h` | Print usage and exit 0. | +| `up` | Run setup phases as needed (see "Skip semantics"), then start the dev server in the foreground. The primary command. | +| `clean` | Remove envlite-managed files (manifest entries). Does not touch `node_modules/`, `vendor/`, or build artifacts under `src/`. | + +There is no `init`, no `serve`, no `verify`. `up` is what users run. +The cached port lives at `.cache/envlite/port` and is one `cat` away. + +### Global flags + +- `--force` — disable all interactive y/N prompts (see "Destructive + operations and prompts" below). Honors the prompt-rule's *yes* answer + for every prompt envlite would otherwise raise during this invocation. + Orthogonal to `--rebuild`: `--force` only governs prompts. + +### Subcommand flags + +- `up [--port=N] [--no-build] [--no-serve] [--rebuild]` + - `--port=N` skips Phase 1 discovery and uses the given port. Updates + `.cache/envlite/port` to N. + - `--no-build` skips Phase 3 (`npm run build:dev`) even when the skip + rule would otherwise have run it. Useful when iterating on PHP-only + changes. + - `--no-serve` runs every setup phase that's needed and then exits 0 + without launching the dev server. The CI / automation form. The + setup phases are identical to a normal `up` — same skip rules, same + state writes — only the trailing `php -S` launch is suppressed. + - `--rebuild` discards the `.cache/envlite/state` file's recorded skip + state for this invocation only. Every phase runs as if envlite had + never observed its inputs before. Successful phases re-record state + normally. Use when state is suspect or when validating a fresh + install. + + The flags are independent. `--rebuild --no-build` re-runs phases + 2 and 4 from scratch but skips phase 3. `--no-serve --rebuild` is + the CI release-gate validation form. + + After all setup phases succeed (and `--no-serve` was not passed), + `up` re-probes the resolved port and runs `php -S` in the + foreground. On Unix, the launch replaces the envlite process via + `pcntl_exec(PHP_BINARY, …)`; on Windows, `proc_open` is used + because `pcntl` is unavailable. See "Dev-server launch" below. +- `clean` (no flags) + +### How to confirm setup works + +envlite has no `verify` subcommand. `phpunit` is a multi-second +operation users will run anyway during normal development; wrapping it +in envlite would just charge that cost on every invocation without +adding signal. After `up` (or `up --no-serve`), two quick checks +confirm the env is wired up: + +```sh +./vendor/bin/phpunit +curl -sI http://127.0.0.1:$(cat .cache/envlite/port)/ +``` + +Phpunit booting against the SQLite drop-in + a 2xx HTTP status (not a +3xx redirect to `/wp-admin/install.php`) proves the env is sound. +Phase 8 has already run `wp_install()`, so the site responds with the +homepage on first hit. Log in at `/wp-login.php` with `admin` / `password`. + +### Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success. | +| 1 | A phase failed. The phase number and a one-line cause are written to stderr. | +| 2 | Unknown subcommand or invalid argument. | +| 3 | Preflight (Phase 0) failed — environment does not satisfy envlite's preconditions. | +| 5 | User declined a destructive prompt. envlite aborted cleanly. | + +### Diagnostic output + +All diagnostic output goes to stderr. Stdout is reserved for content +that is meaningful as data (currently: nothing; envlite has no +data-producing subcommand). Every stderr line uses one of two prefixes: + +- `envlite: ` — for top-level errors before a subcommand has + taken control (unknown subcommand, missing CWD checks, preflight + failures). +- `envlite : ` — once a subcommand is running, all + errors and warnings carry the subcommand name (e.g. + `envlite up: phase 5: SHA256 mismatch on plugin zip`, + `envlite up: failed to bind 127.0.0.1:8421`). + +Phase failures inside `up` use `envlite up: phase N: `. +Prompts (interactive, on stderr) and the non-TTY abort line both follow +the `envlite : ...` form. envlite never writes timestamps, +log levels, or ANSI color codes to stderr — the convention is plain +single-line messages an aggregator can grep. + +Subprocess output (npm, composer) is **buffered** during the parallel +install pair: while running, envlite prints a single status line +`envlite up: installing dependencies…`. On success, no further output +from the subprocesses is shown. On failure of either or both, envlite +waits for both to complete, then dumps each captured buffer to stderr +under labeled separators: + +``` +--- npm ci --- + +--- composer install --- + +``` + +followed by `envlite up: phase N: ` and exit 1. Phase 3 +(`build:dev`) and any other lone subprocesses stream their output +directly to envlite's stderr — buffering is reserved for the parallel +case where interleaving would be unreadable. + +### Dev-server launch + +After all setup phases succeed (and `--no-serve` was not passed), `up` +launches `php -S 127.0.0.1: -t src tools/local-env/router.php` +in the foreground using the resolved port. + +On Unix, the launch uses `pcntl_exec(PHP_BINARY, …)`: the envlite PHP +process is replaced in place by `php -S`, so there is no parent-child +relay, the PID stays the same, and signals (notably SIGINT from +Ctrl-C) reach `php -S` directly. + +On Windows, `pcntl` is unavailable. envlite falls back to `proc_open` +with stdio inherited from envlite's own STDIN/STDOUT/STDERR. Behavior +is functionally equivalent for the user — foreground server, Ctrl-C +shuts it down — but the process tree shows envlite as the parent of +`php -S`. + +**Worker pool.** Before the launch (Unix or Windows), envlite calls +`putenv('PHP_CLI_SERVER_WORKERS=3')` so the built-in server forks +three worker processes and one slow request does not block every +other one behind it. `PHP_CLI_SERVER_WORKERS` is the only knob — +PHP exposes no CLI flag — and the variable was introduced in PHP +7.4.0, matching envlite's preflight floor (no version gating +needed). On Windows the variable is documented as unsupported and +silently ignored, so setting it there is harmless. If the user has +already exported `PHP_CLI_SERVER_WORKERS` in their environment, +envlite leaves it alone (`getenv()` check before `putenv()`). The +SQLite drop-in serializes writes through SQLite's file lock, so +concurrent workers cannot corrupt `.ht.sqlite`; the worst case is a +short serialization wait under contention, which is the same +behavior a single worker would have produced sequentially. + +**`--no-serve` short-circuit.** When `--no-serve` was passed, `up` +omits the bind probe and the launch entirely; it exits 0 once setup +phases complete. The port is still discovered/cached and +`src/wp-config.php` still encodes that port — the only difference is +that `php -S` is never started. + +The router is committed at `tools/local-env/router.php` alongside +`envlite.php`; it is not installed into the repo, the manifest does +not track it, and `clean` does not remove it. Its only request-time +inputs are the request URI and `$_SERVER['DOCUMENT_ROOT']` — the +absolute path PHP's built-in server resolved from its `-t` argument +— so the router file's own filesystem location is deliberately +irrelevant. The port is a `php -S` argument, never baked into the +file, and the router has no user-tunable knobs. + +The router uses `$_SERVER['DOCUMENT_ROOT']` to locate both static +files and the front controller: it returns `false` for paths that +exist on disk under the docroot so `php -S` serves them directly, +and otherwise `require`s `/index.php`. WordPress's +index.php → wp-blog-header.php → wp-load.php → wp-settings.php +chain handles the rest, including `wp-admin/install.php` on first +hit and pretty-permalink fallback once installed. The port is +consumed only by the dev-server launch at the end of `up`, never +during the setup phases or under `up --no-serve`. + +**Bind failure.** envlite's pre-flight `port_is_free` probe detects an +already-bound port and exits 1 with a single stderr line: +`envlite up: failed to bind 127.0.0.1:`. No manifest mutation +occurs. If the port becomes bound in the race window between the probe +and the launch, the Unix path's envlite process has already been +replaced by the time `php -S` reports the failure, so the exit code +surfaced to the shell is `php -S`'s, not envlite's. + +--- + +## Phase 0 — Preflight + +> envlite tracks every file it writes in `.cache/envlite/manifest` and never +> overwrites or deletes anything it doesn't demonstrably own without +> prompting first. See the **State and ownership** section below the +> phases for the full contract — it shapes Phases 5–7 and `clean`. + +**Purpose:** abort early if the environment cannot satisfy envlite's +assumptions. Cheap to run and informative on failure. + +**Inputs:** the current working directory; the `PATH`. + +**Checks (all required):** + +1. CWD is the root of a wordpress-develop checkout. Detect by the + simultaneous presence of: `package.json`, `composer.json`, + `wp-config-sample.php`, `wp-tests-config-sample.php`, + `src/wp-includes/`, `tests/phpunit/includes/bootstrap.php`. If any are + missing, abort with exit code 3. +2. `PHP_VERSION` ≥ 7.4. envlite is run by PHP itself, so `PHP_VERSION_ID` + is the authoritative check. +3. The following PHP extensions are loaded + (`extension_loaded(...)` returns true for each): + - `pdo_sqlite`, `sqlite3` — for the SQLite drop-in (Phase 5) and the + runtime/test database paths. + - `openssl` — required by PHP's HTTPS stream wrapper (used by + `file_get_contents` in Phases 5 and 7). Without it the spec's + network fetches fail with "Unable to find the wrapper 'https'". + - `simplexml` — required by the PHPStan/PHPCS toolchain that Phase 4 + installs. Phase 4 passes `--ignore-platform-req=ext-simplexml` to + Composer because Composer's resolver flags this requirement even + when the extension is loaded; that flag is what makes + `composer install` succeed. The Phase 0 check exists so the + `--ignore-platform-req` flag does not also paper over a genuinely + missing extension — when simplexml is absent, `composer install` + would still appear to succeed but `vendor/bin/phpstan` and PHPCS + ruleset loading would fail at runtime. + - `zip` — required by `ZipArchive` for Phase 5. + - `pcntl` (Unix only) — required so `envlite up` can call + `pcntl_exec(PHP_BINARY, …)` into the dev server, replacing + envlite's PHP process in place. The check is gated on + `PHP_OS_FAMILY !== 'Windows'`; Windows PHP has no `pcntl` and + uses a `proc_open` fallback. + + `hash` is non-disable-able since PHP 7.4 and is not checked. +4. `node`, `npm`, and `composer` are present and meet minimum versions: + `node` ≥ 20.10, `npm` ≥ 10.2.3, `composer` ≥ 2. The `npm` floor matches + `package.json`'s `engines.npm` so preflight catches the same constraint + `npm ci` would otherwise hit later. Each is verified by a + single `proc_open` call passing the binary as a command **array** + with its version flag — `['node', '--version']`, `['npm', '--version']`, + `['composer', '--version']` — and reading stdout. Passing an array + (rather than a string) avoids shell invocation entirely; the OS's + exec semantics handle binary lookup, including `PATHEXT` resolution + on Windows (`node.exe`, `npm.cmd`, `composer.bat`) and `PATH` + resolution on Unix. A non-zero exit or a "command not found" failure + from `proc_open` means the tool is missing — abort with exit 3 and + name the missing tool. A successful spawn whose parsed version + string falls below the minimum also aborts with exit 3. + +**Outputs:** none. On failure, exit 3 with the failed check identified. + +**Why this matters:** the recipe was validated under a specific stack. +Most of the gotchas (the SQLite drop-in's loading mechanism, the +composer simplexml workaround, the `convertDeprecationsToExceptions=true` +caveat) are tied to known versions. Don't silently degrade. + +--- + +## Phase 1 — Port discovery + +**Purpose:** select a single TCP port on `127.0.0.1` for the dev server, +deterministically derived from the checkout's filesystem path so that +two unrelated checkouts almost never collide, and stable across +invocations so that bookmarks/links don't rot. + +**Constraints on the port:** + +- Auto-discovered ports come from a fixed pool: **8100–8899**, in the + IANA user/registered range and away from the OS's ephemeral + allocation pool. The pool only governs auto-discovery; an explicit + `--port=N` accepts any 1–65535 (the user owns the choice). +- Must not be currently bound by another process **at first + discovery**. Once cached, envlite trusts the cache and does not + re-probe (the user may have envlite's own server running on it). +- Must be picked deterministically from the absolute checkout path so + that re-running `envlite up` after `envlite clean` returns the same + port whenever possible. + +**Cache location:** `.cache/envlite/port`. See "envlite state directory" +above for the broader contract. + +**Algorithm (pseudocode):** + +``` +function discover_port(repoRoot): + cacheFile = repoRoot + "/.cache/envlite/port" + if file_exists(cacheFile): + cached = (int) trim(read(cacheFile)) + if 1 <= cached <= 65535: + return cached # trust the cache; do not re-probe + # else: cache corrupt / out of any sane range, fall through to re-pick + + POOL_LOW = 8100 + POOL_SIZE = 800 + + # Deterministic seed: stable hash of the absolute, canonical path. + # Uses hash('crc32b', ...) — returns an 8-char hex string of the + # unsigned 32-bit CRC. Avoids PHP's signed-int crc32() which can + # return negatives on 32-bit builds (still common on Windows). + digest = hash('crc32b', realpath(repoRoot)) # e.g. "1a2b3c4d" + seed = hexdec(substr(digest, -7)) # low 28 bits, fits int + start = POOL_LOW + (seed mod POOL_SIZE) + + for i in 0 .. POOL_SIZE-1: + candidate = POOL_LOW + ((start - POOL_LOW + i) mod POOL_SIZE) + if port_is_free(candidate): + ensure_dir(repoRoot + "/.cache/envlite") + write(cacheFile, str(candidate)) + record_in_manifest(".cache/envlite/port") + return candidate + + error "no free port in 8100-8899" + +function port_is_free(port): + # Try to bind a server socket to 127.0.0.1:. If bind succeeds + # the port was free; close immediately and return true. + sock = stream_socket_server("tcp://127.0.0.1:" + port, suppress errors) + if sock == false: return false + close(sock) + return true +``` + +**Notes:** + +- The CRC32 of the canonical path is intentional, not cryptographic. It + needs to spread checkouts across the 800-port pool roughly uniformly. + With ~800 candidates the birthday-paradox 50% collision threshold is + ~33 concurrent checkouts on the same machine, well above realistic + use. Taking the low 28 bits (rather than the full 32) loses no + meaningful entropy at this pool size. +- No blacklist. Round-thousand ports are not meaningfully more contended + than their neighbors, and a blacklist that ages with the dev-tool + ecosystem is more bug surface than benefit. +- `realpath` on macOS canonicalizes `/var` → `/private/var`, + `/tmp` → `/private/tmp`. The chosen port is therefore tied to the + canonical absolute path of the checkout; moving the checkout + re-derives a new port. +- The probe binds and closes; it does not "reserve" the port. A racy + external process could grab the port between Phase 1 and the + dev-server launch at the end of `up`, but on a developer laptop + this race is negligible. The pre-launch bind probe surfaces the + failure if it happens. +- `up --port=N` bypasses hash-based discovery but **still probes**: + envlite calls `port_is_free(N)`; if N is currently bound, abort with + exit 1 and a one-line message naming the port and suggesting + `lsof -nP -iTCP:N -sTCP:LISTEN` to identify the occupant. Only on a + successful probe does N get written to the cache. N may be any + 1–65535 — the auto-discovery pool is not enforced on explicit ports, + so familiar choices like `8080` or `3000` are honored. +- To pick a different port without specifying one, delete + `.cache/envlite/port` and re-run `up`. + +**Outputs:** `.cache/envlite/port` (text file, single integer); manifest entry. + +--- + +## Phase 2 — JavaScript dependencies + +**Purpose:** install the build toolchain (grunt, webpack, sass, the +WordPress build scripts). + +**Operation:** spawn `npm ci` in the repo root. Output is buffered (see +"Phase ordering and parallelism" — phase 2 runs in parallel with +phase 4 and the bundled status line replaces direct streaming). Exit +non-zero if `npm` exits non-zero. + +**Inputs:** `package-lock.json` (committed to wordpress-develop). +**Outputs:** `node_modules/` populated. + +**Skip rule:** envlite skips Phase 2 if **all three** are true: + +1. `node_modules/` exists on disk. +2. `.cache/envlite/state` records a `phase2.input_hash` whose value equals + `hash_file('sha256', 'package-lock.json')`. +3. `--rebuild` was not passed. + +After a successful `npm ci`, envlite records the current hash of +`package-lock.json` to `phase2.input_hash`. Recording happens **only +on subprocess exit 0**; an interrupted (Ctrl-C'd) `npm ci` leaves +`node_modules/` partially populated but no recorded hash, so the next +`up` re-runs the install. Worst case is one redundant `npm ci`; never +a false-positive skip. + +The skip is deliberately blind to the *contents* of `node_modules/` +once the directory exists. Hashing that tree on every `up` would cost +multiple seconds and defeat the purpose. A user who has manually +mutated files inside `node_modules/` is out of supported territory +regardless of envlite. The `rm -rf node_modules/` escape hatch always +forces a re-install on the next `up`. + +**Failure modes:** + +| Symptom | Cause | Remediation | +|---|---|---| +| `npm ERR! engines` | node version below 20.10 | upgrade node | +| network errors | offline / proxy | retry | + +The verb is `npm ci`, not `npm install`. envlite must respect the +committed lockfile. + +--- + +## Phase 3 — Build artifacts + +**Purpose:** populate the generated files under `src/` that the runtime +and the phpunit bootstrap need. + +**Operation:** spawn `npm run build:dev`. This invokes the wordpress- +develop Gruntfile's `build:dev` target. Output streams directly to +envlite's stderr (Phase 3 runs serially after the parallel +composer/npm pair, so interleaving is not a concern). + +**Inputs:** populated `node_modules/`, populated `vendor/`, the sources +under `src/`. The dependency on `vendor/` is non-obvious: some +build-time certificate files used by `build:dev` come out of the +composer install. Phase 3 must therefore wait for **both** Phase 2 and +Phase 4 before running. + +**Outputs (as defined by upstream Gruntfile):** generated +`src/wp-includes/version.php`, compiled CSS under `src/wp-includes/css/`, +compiled blocks under `src/wp-includes/blocks/`, vendored JS, etc. envlite +does not enumerate these; it trusts the upstream target. + +**Why this is not optional:** phpunit's bootstrap loads +`src/wp-load.php` → `src/wp-settings.php`, which references generated +files (notably `src/wp-includes/version.php`). Without a build, phpunit +exits with the cryptic message "ABSPATH constant ... non-existent path". + +**Skip rule:** envlite skips Phase 3 if **all four** are true: + +1. Phase 2 was skipped this run (i.e. `node_modules/` is current). +2. Phase 4 was skipped this run (i.e. `vendor/` is current). +3. `src/wp-includes/version.php` exists on disk (sentinel for "build + has succeeded at least once"). +4. `.cache/envlite/state` records `phase3.recorded_npm_hash` matching the + current `package-lock.json` hash, AND `phase3.recorded_composer_hash` + matching the current `composer.json` hash. + +The verbose recorded hashes (rather than a single concat hash) are +deliberately readable: `cat .cache/envlite/state` shows an implementer +exactly which dependency state was current the last time `build:dev` +succeeded. + +After a successful `npm run build:dev`, envlite records both hashes. +Recording happens only on subprocess exit 0. + +`--no-build` forces the skip even when the rule would otherwise have +run the phase. Useful when iterating on PHP-only changes after a +dependency bump that hasn't actually invalidated build outputs. +`--rebuild` overrides the skip in the other direction: the recorded +hashes are ignored, and `build:dev` runs unconditionally. + +--- + +## Phase 4 — PHP dependencies + +**Purpose:** install `phpunit`, `yoast/phpunit-polyfills`, the WP +coding standards, PHPStan. + +**Operation:** spawn `composer install` with these flags: + +- `--no-interaction`. +- `--ignore-platform-req=ext-simplexml`. + +Output is buffered (Phase 4 runs in parallel with Phase 2; see "Phase +ordering and parallelism"). + +envlite does not set `COMPOSER_HOME`; Composer uses its default +(`~/.composer` or `~/.config/composer`, per Composer's own resolution). +Composer's cache layout is Composer's concern, not envlite's. + +**Inputs:** `composer.json`. wordpress-develop intentionally ships +**without** a `composer.lock` (`config.lock = false`). Each install +resolves fresh. +**Outputs:** `vendor/`, autoload files, `phpcs` `installed_paths` +configured. No lockfile is created. + +**Skip rule:** mirrors Phase 2, but keyed on `composer.json`: + +1. `vendor/` exists on disk. +2. `.cache/envlite/state` records `phase4.input_hash` matching + `hash_file('sha256', 'composer.json')`. +3. `--rebuild` was not passed. + +After a successful `composer install`, envlite records the current +hash of `composer.json` to `phase4.input_hash`. Recording happens +only on exit 0. + +The skip is blind to the contents of `vendor/` once the directory +exists, on the same reasoning as Phase 2. Note the absence of a +lockfile: envlite is not detecting whether the *resolved* set of +packages would change (composer might pick a newer compatible release +on a fresh install) — only whether the user has changed `composer.json` +itself. If a user wants to force composer to re-resolve against +upstream Packagist, `--rebuild` is the lever, or `rm -rf vendor/`. + +**Why `--ignore-platform-req=ext-simplexml`:** the PHPStan/PHPCS +toolchain in `composer.json` declares `ext-simplexml` in a way that +Composer's resolver flags even when the extension is loaded. The flag +is load-bearing on every PHP version, not "defensive on older ones". +Phase 0 already verified `simplexml` is present, so the flag here only +silences the resolver — it does not paper over a missing extension. If +someone bypasses Phase 0 on a PHP build genuinely lacking simplexml, +`composer install` succeeds but `vendor/bin/phpstan` (and ruleset +loading in PHPCS) fails at runtime. Fail-fast belongs in Phase 0. + +**Idempotency:** safe to re-run. + +--- + +## Phase 5 — SQLite Database Integration drop-in + +**Purpose:** make WordPress and phpunit use a file-backed SQLite database +instead of MySQL. + +**Operation:** + +All file writes in this phase follow the standard prompt rule (see +"Destructive operations and prompts"): an unowned destination prompts +before being overwritten; `--force` answers yes to every such prompt. + +1. If `src/wp-content/plugins/sqlite-database-integration/` is recorded + in the manifest (envlite-owned `dir` entry) **and** its `db.copy` is + present locally **and** `.cache/envlite/state` records a + `phase5.recorded_pin_sha` matching the literal + `ENVLITE_SQLITE_PLUGIN_SHA256` in `envlite.php`, skip steps 2–4 and + proceed to step 5. The pinned plugin tree from a prior `up` is + reusable as-is; there is no value in re-downloading it. + + Otherwise (no manifest entry, `db.copy` missing, or recorded pin + SHA differs from the current code literal) proceed to step 2. + `--rebuild` also forces re-entry into steps 2–4 unconditionally. +2. Download the plugin zip via PHP HTTP (`file_get_contents` with a + stream context that follows redirects, sets a User-Agent, and + times out at 30 s) from + `https://downloads.wordpress.org/plugin/sqlite-database-integration.zip` + to a temp file under `sys_get_temp_dir()`. +3. Verify the downloaded **zip's** SHA256 with `hash_file('sha256', ...)` + against the pinned value + `44be096a14ebcea424b5e4bf764436ec85fb067f74ab47822c4c5346df21591e`. + Mismatch is fatal; abort with exit 1. Re-pinning to a newer release + is an explicit envlite revision, not an automatic fall-through. +4. Extract using PHP's `ZipArchive` into `src/wp-content/plugins/`. + This produces `src/wp-content/plugins/sqlite-database-integration/`. + Delete the temp zip. + + If the destination directory exists and is **not** in the manifest + (a user-installed plugin), prompt before overwriting. `--force` + bypasses the prompt and the extract proceeds, overlaying envlite's + pinned tree on top of whatever was there. Record the directory in + the manifest as a `dir` entry once extraction succeeds. Record + the current pin literal to `phase5.recorded_pin_sha` in + `.cache/envlite/state` once extraction succeeds — subsequent `up` runs + compare against this to detect a code-level pin bump. +5. Copy `src/wp-content/plugins/sqlite-database-integration/db.copy` to + `src/wp-content/db.php` (byte-for-byte). This is the activation step — + `wp-settings.php` autoloads `wp-content/db.php` when present. + + The standard manifest contract applies: if `db.php` exists and is + not in the manifest (or is in the manifest with a drifted hash), + prompt before overwriting. `--force` bypasses. Record `db.php` in + the manifest with the hash of the bytes written. +6. Post-condition tripwire: assert that `db.copy` contains the literal + string `{SQLITE_IMPLEMENTATION_FOLDER_PATH}`. The plugin's fallback + `realpath()` (see below) depends on this placeholder being present + and unsubstituted. If a future plugin pin removes it, envlite's + "no substitution needed" assumption silently breaks — abort here + so the implementer is forced to revisit. + +**Inputs:** network access on first install only. +**Outputs:** +- `src/wp-content/plugins/sqlite-database-integration/` — recorded as a + single `dir` manifest entry. Internal files (including `db.copy`) are + not individually hash-tracked; the contents come from a SHA-pinned zip + and the step-6 tripwire is a one-shot install-time check, not ongoing + drift detection. +- `src/wp-content/db.php` — recorded as a file entry with content hash; + drift-detected on subsequent `up` runs. + +Both are removed by `clean`. The `phase5.recorded_pin_sha` entry in +`.cache/envlite/state` is also removed by `clean` (whole state file is wiped). + +**Why this is sufficient:** `tests/phpunit/includes/install.php` does +`require_once ABSPATH . 'wp-settings.php'` *before* issuing any DB +queries. `wp-settings.php` autoloads `wp-content/db.php` if present. +The drop-in is therefore active by the time `wp_install()` runs. The +`SET default_storage_engine = InnoDB` and `SET foreign_key_checks` calls +that follow are translated to no-ops by the drop-in. + +**Why the `{SQLITE_IMPLEMENTATION_FOLDER_PATH}` placeholder needs no +substitution:** the plugin's `db.copy` checks `file_exists()` on the +placeholder string and falls back to +`realpath(__DIR__ . '/plugins/sqlite-database-integration')` when the +check fails. The placeholder is a literal that never names a real path, +so the fallback always activates. Substitution would be dead code. + +**Idempotency:** anchored on the combination of (a) manifest entry for +the plugin directory, (b) local presence of `db.copy`, and (c) +recorded pin SHA matching the code literal. A corrupt or partial +plugin tree from a prior failed run will fail the step-6 tripwire on +re-install; the user can resolve by deleting the plugin tree and +re-running `up`. A code-level pin bump (someone edits +`ENVLITE_SQLITE_PLUGIN_SHA256` in `envlite.php`) re-triggers the +download/extract automatically on the next `up`. + +--- + +## Phase 6 — phpunit configuration + +**Purpose:** create `wp-tests-config.php` at the repo root from the +shipped sample. The phpunit bootstrap reads this file to learn `ABSPATH` +and DB constants. + +**Operation:** in PHP, read `wp-tests-config-sample.php`, replace the +following three literal substrings (each appears exactly once in the +sample), and write the result to `wp-tests-config.php`: + +| Sample placeholder | envlite value | +|---|---| +| `youremptytestdbnamehere` | `wordpress_test` | +| `yourusernamehere` | `wp` | +| `yourpasswordhere` | `wp` | + +(Use `str_replace` or `strtr` over the file contents; do not invoke any +external command.) After the write, assert that each of the three +placeholders is no longer present in the output (catches an upstream +sample reshape). Then assert that the substituted bytes do not already +contain a `DB_FILE` define (regex: `define\s*\(\s*['"]DB_FILE['"]`); a +match means upstream's `wp-tests-config-sample.php` has grown its own +`DB_FILE` and envlite's append assumption no longer holds — abort with +`envlite up: phase 6: DB_FILE already defined in wp-tests-config-sample.php; envlite assumption broken`. +Finally, ensure the bytes end in `\n` (append one if not) and append the +literal line `define( 'DB_FILE', '.ht.test.sqlite' );\n`. Write the +result to `wp-tests-config.php`. DB_HOST is left as the sample's +`localhost` — the SQLite drop-in ignores it, but `wpdb` still requires +it to be defined. + +**Inputs:** `wp-tests-config-sample.php`. +**Outputs:** `wp-tests-config.php` at the repo root. + +**Notes:** + +- The DB constants are placeholders from the SQLite drop-in's + perspective — it ignores them — but `wpdb` requires them to be + defined as something, so the patched values stay in. +- The sample's salt block ships with accept-anything strings ("put your + unique phrase here"). Test runs do not need real salts; envlite leaves + them as-is here. (Real salts are still injected into `src/wp-config.php` + in Phase 7 because that file *is* used by an HTTP runtime.) +- ABSPATH in the sample resolves to `dirname(__FILE__) . '/src/'`, which + is correct for envlite's layout. +- The appended `DB_FILE` define isolates the phpunit test DB at + `src/wp-content/database/.ht.test.sqlite` from the live runtime + DB at `src/wp-content/database/.ht.sqlite`. The phpunit + bootstrap's `tests/phpunit/includes/install.php` drops every WP + table on every run; sharing the drop-in's default `FQDB` between + the two configs would silently wipe the dev site Phase 8 + installs, contradicting Phase 8's "envlite never drops tables" + invariant via phpunit's bootstrap. `src/wp-config.php` (Phase 7) + remains free of any `DB_FILE` define so the live runtime keeps + the drop-in's default `FQDB`. + +**Idempotency:** anchored on the manifest. + +- Path absent → write, record in manifest. +- Path present, in manifest, hash matches → silent re-stamp (envlite + owns this file; pick up any upstream sample changes for free). +- Path present, in manifest, hash drifted → user has modified envlite's + output; prompt before overwriting (`--force` to skip the prompt). +- Path present, **not** in manifest → user authored this; prompt + before overwriting. + +--- + +## Phase 7 — Runtime configuration (`src/wp-config.php`) + +**Purpose:** create the runtime config that the dev server will load. +Distinct from Phase 6: `src/wp-config.php` is loaded by `wp-load.php`; +`wp-tests-config.php` is loaded only by phpunit's bootstrap. + +**Operation:** in PHP: + +1. Read `wp-config-sample.php` into a string `$cfg`. +2. Replace the three DB-related placeholders (each appears exactly once): + + | Sample placeholder | envlite value | + |---|---| + | `database_name_here` | `wordpress` | + | `username_here` | `wp` | + | `password_here` | `wp` | + +3. Best-effort fetch of fresh salts from + `https://api.wordpress.org/secret-key/1.1/salt/` via PHP HTTP, with a + short timeout (≤ 5 s). If the fetch fails, log a warning and skip + step 4 — the sample's "put your unique phrase here" placeholders + remain. Acceptable for a dev box; cookies will not survive across + `envlite up` re-runs. +4. If salts were fetched, locate and replace the eight contiguous + `define()` lines for `AUTH_KEY` through `NONCE_SALT` with the salts + payload. Use a multi-line regex anchored on the opening `define( 'AUTH_KEY'` + line and the closing `define( 'NONCE_SALT'` line; assert exactly one + match. Abort if zero or multiple. +5. Locate the literal marker + `/* That's all, stop editing! Happy publishing. */` (appears exactly + once in the sample) and inject the following two lines immediately + *before* it, separated by a blank line: + + ``` + define( 'WP_HOME', 'http://127.0.0.1:' ); + define( 'WP_SITEURL', 'http://127.0.0.1:' ); + ``` + + `` is the value from Phase 1. + +6. Write the result to `src/wp-config.php`. + +**Inputs:** `wp-config-sample.php`, the Phase 1 port, optional network. +**Outputs:** `src/wp-config.php`. + +**Why `WP_HOME` / `WP_SITEURL` matter:** WordPress generates absolute +URLs in markup (admin links, redirects, REST endpoints). If they don't +match the listening address (`http://127.0.0.1:`), `wp-admin` +redirects loop and asset URLs break. They go in the runtime config; the +phpunit config doesn't care. + +**Idempotency:** same manifest-anchored rule as Phase 6. + +- Path absent → write, record. +- Path present, in manifest, hash matches → silent re-stamp. Note that + the re-stamp picks up any change to the Phase 1 port automatically + (the port is interpolated at write time), so `WP_HOME`/`WP_SITEURL` + always match the cache. +- Path present, in manifest, hash drifted → prompt before overwriting. +- Path present, not in manifest → prompt before overwriting. + +--- + +## Phase 8 — Site install + +**Purpose:** run `wp_install()` so the site is immediately browsable +on first visit. Without this phase WordPress sees no DB tables and +redirects to `wp-admin/install.php`, forcing the user through a +manual install flow that envlite already has all the inputs to +script. + +**Operation:** envlite spawns a fresh `php` subprocess +(`proc_open([PHP_BINARY], …)`) and pipes the install script via +stdin — no second committed asset alongside `router.php`, and full +process isolation from `wp-settings.php`'s many side effects +(constants, autoloaders, shutdown handlers, `wp_die`). The script +template is a nowdoc inside `envlite.php`; `$repoRoot` and `$port` +are interpolated via `strtr()` with `var_export()`'d literals so +unusual paths cannot break the script. + +The script body: + +1. Sets `$_SERVER['HTTP_HOST']` to `127.0.0.1:` (and a few + companions). Required because `wp_install()` calls + `wp_guess_url()` which reads `$_SERVER`; without this, WP would + write a CLI-derived URL into the `siteurl` option. (Functionally + moot at runtime — `WP_SITEURL` from Phase 7 is a defined constant + and overrides the option — but belt-and-suspenders.) +2. `define('WP_INSTALLING', true)` before loading WP. +3. `require_once src/wp-load.php` — picks up `src/wp-config.php` + (and through it the SQLite drop-in via `wp-content/db.php`). +4. `require_once ABSPATH . 'wp-admin/includes/upgrade.php'`. +5. If `is_blog_installed()` is true → `exit(0)` (idempotent re-run). +6. Otherwise call `wp_install('WordPress Develop Envlite', 'admin', + 'admin@example.com', false, '', 'password')` and assert + `$result['user_id']` is non-empty (writes to STDERR and + `exit(1)` otherwise). + +A non-zero subprocess exit causes the parent (`envlite_phase8_install_site`) +to throw with the first non-empty stderr line as the cause; the +existing `envlite_phase_guard()` converts that into +`envlite up: phase 8: install subprocess: ` + exit 1. + +**Inputs:** `src/wp-config.php` (Phase 7), `.cache/envlite/port` (Phase 1), +populated `vendor/` (Phase 4), populated build outputs (Phase 3 — +`wp-load.php` requires `src/wp-includes/version.php`). + +**Outputs:** DB tables, default options/roles, single admin user +inside `src/wp-content/database/.ht.sqlite`. The DB file itself is +not added to the manifest by this phase; envlite's existing +observation hook records it on the next `up` or `clean`. + +**Fixed credentials:** the username, email, password, and site title +above are deliberately not configurable. envlite is a dev-only tool; +configurability would just mean per-checkout drift with no benefit. +Match the test bootstrap conventions +(`tests/phpunit/includes/install.php` uses the same `admin` / `password`). + +**Idempotency:** anchored on `is_blog_installed()`. + +- DB tables absent → install. +- DB tables present (e.g. user already ran `up` once, or wiped + `.ht.sqlite` and re-installed manually) → silent no-op. +- envlite **never** drops tables. User-authored posts/pages/uploads + survive any number of `envlite up` re-runs. The test bootstrap + pattern of "drop everything and re-install" is appropriate for + CI's clean-slate semantics but wrong for a dev tool. + +**Failure modes:** + +| Symptom | Cause | Remediation | +|---|---|---| +| phase 8 fails with "version.php" or "ABSPATH" error | `up --no-build` on a fresh checkout where build outputs were missing and the skip rule fired | re-run `up` without `--no-build`, or `up --rebuild` | +| phase 8 fails with a DB error | corrupt `.ht.sqlite` from a prior interrupted run | delete `src/wp-content/database/.ht.sqlite`, re-run `up` | +| phase 8 fails with a salt-related notice | rare; salt fetch in Phase 7 left placeholder strings | not a real failure mode; placeholders are accepted | + +**`--force` interaction:** none. The phase is non-destructive (it +only writes into an empty DB) and asks no prompts. + +--- + +## State and ownership + +These two sections describe envlite's contract with the filesystem. +They are policy for what the phases above do, not phases themselves; +the placement here is so the reader has the concrete file-by-file +picture from Phases 0–7 in mind before evaluating the abstract rules. + +### Destructive operations and prompts + +envlite must not silently overwrite or delete a file it does not +demonstrably own (see the manifest below for the ownership mechanism). +Any operation that would do so prompts the user interactively before +proceeding. + +**Prompt format:** a one-line `[y/N]` prompt naming the operation and the +file(s) involved, with `N` as the default. Reading a non-y/Y response or +EOF counts as `N` and aborts that operation with exit code 5. TTY +detection uses `stream_isatty(STDIN)` (built-in since PHP 7.2; no +extension dependency). + +**Drift prompts include a hash preview:** when the manifest records a +hash for a path but the current content hashes differently, the prompt +includes the first 8 hex chars of each side, e.g. +`envlite owns wp-tests-config.php but content has drifted (recorded a3f1c8b2…, current 9e07d44a…). Overwrite? [y/N]`. +Path-only ("not in manifest") prompts skip the hash preview. + +**Non-interactive contexts (no TTY) without `--force`:** envlite writes a +single line to stderr — +`envlite: non-interactive context and --force not given; aborting at on ` — +and exits 5. CI runners that omitted `--force` get an immediately +actionable signal, not silent failure. + +**Operations that prompt unless `--force` is passed:** + +- Overwriting a file that exists on disk and is **not** recorded in the + manifest as envlite-owned. (Phases 5–7.) +- Overwriting a file that **is** in the manifest but whose current + content hash has drifted from the recorded hash. +- Deleting any file or directory in `clean`. The default form prompts + once with the full list; declining aborts the cleanup. + +**Operations that never prompt:** + +- Re-creating files envlite owns (recorded in the manifest with a + matching content hash). These are silent overwrites — envlite is + updating its own output. +- Adding new files that don't exist yet. +- Reading anything. + +**`--force` semantics:** answer `y` to every prompt envlite would +otherwise raise during this invocation. Required for non-interactive +use (CI, scripts). It is the user's responsibility to know what they're +forcing. + +### envlite state directory (`.cache/envlite/`) + +`.cache/envlite/` at the repo root holds envlite's private state. +wordpress-develop's `.gitignore` already lists `.cache/*`, so envlite's +state files are ignored out of the box without a dedicated entry. +envlite itself does **not** modify `.gitignore`, `.git/info/exclude`, +or any other git configuration at runtime — the ignore rule is +committed to the repo, not written by the tool. envlite owns +`.cache/envlite/` only; the `.cache/` parent is a shared scratch dir +(used by phpcs and other tools), so `clean` removes `.cache/envlite/` +and leaves `.cache/` itself in place. + +Files inside: + +| File | Purpose | Schema | +|---|---|---| +| `port` | Cached site port (Phase 1). | A single integer line. | +| `manifest` | Records every file/directory envlite has written, with the content hash at the time of writing. | One entry per line: ` `. The hash is sha256 of the **bytes envlite is about to write**, computed before the temp file is renamed into place — never re-read from disk afterwards. `dir` in the hash field denotes a directory entry. | +| `state` | Per-phase skip metadata (input hashes, pin SHAs). Read by `up` to decide which phases can be skipped. | One entry per line: `\t\n`. Keys are bare ASCII (`phase2.input_hash`, `phase4.input_hash`, `phase3.recorded_npm_hash`, `phase3.recorded_composer_hash`, `phase5.recorded_pin_sha`). Values are 64-char lowercase hex. Unknown keys are ignored on read; missing expected keys are treated as "phase has never succeeded" → run the phase. | + +**State file vs. manifest.** The two files have different write +triggers and different contracts: + +- The manifest records **outputs envlite owns** with their content + hashes — drift-detected on every re-run, walked by `clean`. +- The state file records **inputs envlite observed** when each + skip-able phase last succeeded — used solely to decide whether the + next `up` can skip work. Not consulted by `clean` (the file is wiped + with the rest of `.cache/envlite/`). + +State entries are written **after** their phase's subprocess exits 0, +never before. An interrupted phase leaves the previous state value in +place (or no entry, on first run); the next `up` therefore re-runs +that phase. False-positive re-runs are acceptable; false-positive +skips are not. + +The `--rebuild` flag causes `up` to read `.cache/envlite/state` as if it +were empty for that invocation only. Successful phases re-record state +normally during the same run. + +**Path canonicalization.** Paths in the manifest are stored relative to +the repo root with `/` (POSIX-style) separators. On Windows, +`realpath()` returns `\`-separated paths; convert to `/` with +`str_replace` before writing to or comparing against the manifest. PHP +accepts `/` on Windows for all file APIs, so a single in-memory +convention keeps comparisons reliable. envlite does not promise that a +manifest written on one OS is interpretable on another — within-platform +consistency is the only contract. Other canonicalization details +(duplicate handling, which directories get `dir` entries) are +implementation-defined. + +**Manifest immutability.** The manifest is envlite-managed. Hand-editing +it (reordering lines, rewriting hashes) produces undefined behavior on +the next `up` or `clean`. The same applies to `.cache/envlite/state`: do +not hand-edit it; use `--rebuild` to ignore its contents for one +invocation, or `clean` to wipe it. Users who need to "forget" an +envlite-owned path should run `envlite clean` and re-run `up`. +(`clean` doesn't touch `node_modules/`, `vendor/`, or build artifacts, +so the slow-to-rebuild parts survive a clean+`up` cycle.) + +**Atomic writes.** Every file envlite writes — whether content +(`wp-config.php`, `wp-tests-config.php`, etc.) or the manifest itself — uses the +write-temp + fsync + rename pattern: hash the in-memory bytes +(`hash('sha256', $bytes)`), write them to a sibling `.tmp` path in +binary mode (`'wb'` or `file_put_contents()`; never PHP's text mode +`'t'`, which translates `\n` to `\r\n` on Windows and would make the +on-disk bytes diverge from the hash), fsync, `rename()` over the final +path. The manifest entry update uses the already-computed hash and +happens after the content rename, also atomic-replace. envlite +**never** calls `hash_file()` on the renamed target to populate the +manifest — that would race with any subsequent writer. A SIGINT +mid-operation leaves either fully-pre-write or fully-post-write state +on disk; no half-written file claims a hash for content that wasn't +durable. + +**Ownership decisions** (consulted by Phases 5–7): + +- Path in manifest **and** current content hash matches → envlite owns + it; safe to silently re-stamp. +- Path in manifest **but** current hash has drifted → envlite created + it, the user (or another tool) has modified it; prompt before + overwriting (drift prompt includes hash preview). +- Path **not** in manifest → not envlite-owned; prompt before + overwriting. + +`clean` walks the manifest in reverse insertion order and (after +prompting) removes each entry, then removes `.cache/envlite/` itself. Manifest +order is the order envlite wrote things; since users are not supposed +to edit the manifest, that order is well-defined. + +--- + +## Outputs (final repo state) + +After a successful `envlite up`, the repo has: + +**envlite-managed (in manifest, removed by `clean`):** + +``` +.cache/envlite/port (Phase 1) +.cache/envlite/manifest (all phases) +.cache/envlite/state (Phases 2/3/4/5 — skip metadata) +src/wp-content/plugins/sqlite-database-integration/ (Phase 5) +src/wp-content/db.php (Phase 5) +wp-tests-config.php (Phase 6) +src/wp-config.php (Phase 7) +src/wp-content/database/.ht.sqlite (populated by Phase 8; observation-recorded — see below) +``` + +`.cache/envlite/state` is removed by `clean` along with the rest of +`.cache/envlite/`, but is **not** tracked by the manifest itself — it is +operational metadata, not envlite-owned output (see "envlite state +directory" above). + +**Side effects of `up` (not envlite-managed; remove with your usual tooling):** + +``` +node_modules/ (Phase 2 — `npm ci`) +vendor/ (Phase 4 — `composer install`) +src/wp-includes/version.php and other build outputs (Phase 3 — `npm run build:dev`) +src/wp-content/database/.ht.test.sqlite (created on first phpunit run; not envlite-managed) +``` + +`.ht.sqlite` is created by the SQLite drop-in the first time +WordPress is loaded — Phase 8 is now that first load, so the file +exists by the time `up` returns (or, with `--no-serve`, by the time +the setup phases complete). The file may hold user-authored content +(posts, settings, uploads). + +**Observation point:** at the start of every `up` and every `clean`, +envlite checks whether `src/wp-content/database/.ht.sqlite` exists on +disk and is not yet in the manifest; if so, envlite adds an entry +recording the file's hash at that moment. The `up` recording persists +in the manifest as ongoing ownership. The `clean` recording is +transient — it exists only so the file appears in *this* invocation's +removal prompt; the manifest is wiped at the end of `clean` regardless. +Either way the guarantee is the same: a `clean` invoked after a prior +`up` treats the DB as envlite-tracked content and prompts before +removing it, rather than silently leaving an orphan or silently +deleting user data. + +**`clean` semantics:** walk the manifest in reverse insertion order, +present the full list of paths to be removed in a single prompt, then +delete each entry on confirmation (skipped with `--force`). After the +batch, remove `.cache/envlite/` itself. Anything **not** in the manifest is +preserved — `clean` never touches `node_modules/`, `vendor/`, build +artifacts under `src/`, a user-authored plugin checkout under +`src/wp-content/plugins/`, a hand-rolled `wp-config.php`, or any other +off-manifest content. To remove the side-effect dependency trees, use +`git clean -fdx` or your usual tooling. + +--- + +## Phase ordering and parallelism + +Strict dependency graph: + +- Phase 0 → all subsequent phases. +- Phase 1 → Phase 7 (port is consumed by `WP_HOME`, `WP_SITEURL`). +- Phase 2 → Phase 3 (`build:dev` needs `node_modules/`). +- Phase 4 → Phase 3 (`build:dev` consumes certificate files installed + by `composer install`; not obvious from the Gruntfile, but observed + empirically — running `build:dev` before `composer install` fails on + a fresh checkout). +- Phase 5 → Phase 6 and Phase 5 → Phase 7. Both config files assume + the SQLite drop-in is the active DB layer at any moment between + phases. Violating either edge (running 6 or 7 first) is harmless to + the final state but breaks the "internally consistent at every + step" invariant. +- Phase 3 → Phase 8 (Phase 8 loads `wp-load.php` which requires + `src/wp-includes/version.php`, generated by `build:dev`). +- Phase 4 → Phase 8 (Phase 8 loads `wp-settings.php` which requires + composer's autoload for some included libs). +- Phase 5 → Phase 8 (Phase 8 issues DB queries; the SQLite drop-in + must be active). +- Phase 7 → Phase 8 (Phase 8 loads `src/wp-config.php`). + +**Concrete schedule:** envlite runs Phases 2 and 4 **in parallel** +(they are mutually independent), waits for both, then runs Phase 3 +serially. The shape is `composer install & npm ci & wait; npm run +build:dev`. Phases 5, 6, 7 run serially after that (they're cheap and +their config-file dependencies are easier to reason about in +sequence). Phase 8 is always last. + +The parallel phase 2/4 launch is what motivates the buffered output +contract: with two long-running subprocesses streaming to the same +terminal, raw stdio interleaving would be unreadable. envlite captures +each subprocess's combined stdout+stderr to a per-process buffer, +prints a single status line `envlite up: installing dependencies…` +while they run, and on success discards the buffers without printing +them. On failure of either or both, envlite waits for both to +complete (no kill-the-partner machinery), then dumps both buffers to +stderr under labeled separators (`--- npm ci ---`, `--- composer +install ---`) before reporting the phase failure and exiting 1. Phase +3 streams its output directly to envlite's stderr in the normal +serial fashion. + +The wall-time savings of parallel 2/4 vs. serial run are +substantial on a fresh checkout (~10–30 s); on a re-run where both +phases skip, the parallel launch costs nothing. + +--- + +## Idempotency rules (summary) + +Two parallel mechanisms govern re-run behavior: + +1. **The manifest** governs file-producing phases (output ownership): + - **Path absent** → write, record in manifest with content hash. + - **Path in manifest, content hash matches** → silent re-stamp; + envlite owns this file and is updating its own output. Picks up + any upstream sample changes for free. + - **Path in manifest, content hash drifted** → user (or another + tool) has modified envlite's output; prompt before overwriting. + `--force` answers yes. + - **Path not in manifest** → user authored this; prompt before + overwriting. `--force` answers yes. + +2. **The `.cache/envlite/state` file** governs subprocess-running phases + (skip eligibility): + - Recorded input hash matches current input AND output sentinel + present → skip the phase. + - Recorded hash differs, sentinel missing, or no recorded value → + run the phase. On exit 0, record the new hash. + - `--rebuild` ignores recorded values for one invocation. + +Phase-specific notes: + +| Phase | Re-run behavior | +|---|---| +| 0 (preflight) | Always runs. | +| 1 (port) | Re-uses the cached port if the cache exists and is in `[1, 65535]`. Otherwise re-discovers from the 8100–8899 pool. | +| 2 (npm ci) | Skips if `node_modules/` exists AND `.cache/envlite/state` records `phase2.input_hash` matching `package-lock.json`. Otherwise spawns `npm ci`; on success, records the current hash. | +| 3 (build:dev) | Skips if Phases 2 and 4 both skipped this run AND `src/wp-includes/version.php` exists AND recorded `phase3.recorded_npm_hash` / `phase3.recorded_composer_hash` match current. `--no-build` forces skip; `--rebuild` forces run. | +| 4 (composer install) | Skips if `vendor/` exists AND `.cache/envlite/state` records `phase4.input_hash` matching `composer.json`. Otherwise spawns `composer install`; on success, records the current hash. | +| 5 (SQLite drop-in) | Skips download/extract if the plugin dir is in the manifest, `db.copy` is present, AND `phase5.recorded_pin_sha` matches the current code literal. Always copies `db.copy` → `db.php` (manifest contract governs the write). | +| 6 (`wp-tests-config.php`) | Manifest contract above. | +| 7 (`src/wp-config.php`) | Manifest contract above. Re-stamp interpolates the current Phase 1 port. | +| 8 (site install) | Always spawns the install subprocess; the subprocess short-circuits via `is_blog_installed()`. envlite never drops tables. | + +`envlite up` is safe to re-run on a half-configured repo: paths +envlite owns get refreshed silently, paths it doesn't own require +explicit user assent, subprocess-running phases skip themselves when +their inputs are unchanged. Users who want a fully clean slate run +`envlite clean` first; users who want to redo the work without +deleting outputs pass `--rebuild`. + +--- + +## Non-obvious decisions, recorded once + +1. **PHP 7.4 floor.** envlite is run by PHP itself; the floor matches + WordPress core's own supported floor at the time of writing. +2. **PHP 8.5 + `convertDeprecationsToExceptions=true`.** wordpress- + develop's `phpunit.xml.dist` opts every deprecation into a thrown + exception. On newer PHP some test groups will fail purely on + surfaced deprecations from core code; that's a per-group fix, not + envlite's problem. +3. **No `composer.lock`, by upstream design.** Every Phase 4 run + resolves fresh from `composer.json`. envlite does not generate or + check in a lock; doing so would diverge from upstream. For the same + reason Phase 4 does **not** pass `--platform-php` and does not set + `config.platform.php`: the resolver evaluates against runtime PHP, + not the 7.4 floor. Pinning to the floor would be a half-measure + without a lockfile (Composer still picks "latest compatible" each + run) and would penalize devs on newer PHP for no benefit — phpunit + runs against host PHP, which is exactly what runtime-resolved deps + target. WP CI also resolves against its matrix PHP, so envlite + mirrors CI rather than masking it. +4. **The SQLite plugin path placeholder is dead.** Documented in Phase 5. +5. **Two distinct config files.** `wp-tests-config.php` (Phase 6) and + `src/wp-config.php` (Phase 7) are loaded by different bootstrap paths + and serve different purposes. Both are needed; do not consolidate. +6. **Pin the plugin SHA, not the version number.** Plugin version + numbers can be reused. The SHA is the honest pin. Update intentionally. +7. **Port stability over freshness.** Once cached, the port is reused + unconditionally. The user may have envlite's own server running on + it; re-probing would falsely report "in use". `envlite clean` + forgets the port; `envlite up --port=N` is the in-place re-pick. +8. **PHP-only implementation surface.** All file ops, hashing, HTTP, + and zip extraction go through PHP standard library. Subprocesses + are limited to `node`/`npm`/`composer`/`php` — tools envlite already + requires for setup. No `sed`/`awk`/`curl`/`unzip`/`shasum`/`python` + dependencies, even when those are commonly present. The dev-server + launch on Unix uses `pcntl_exec` rather than `proc_open` so the + envlite PHP process is replaced in place by `php -S` (same PID, + shallower process tree, direct signal delivery); Windows lacks + `pcntl` and falls back to `proc_open` with inherited stdio. +9. **Manifest, not file presence, is the ownership signal.** Earlier + drafts gated idempotency on "does the file exist". That conflated + "envlite created it" with "anyone created it" and made `clean` a + blast-radius hazard. The manifest cleanly separates the two cases. +10. **Destructive-by-default is forbidden.** envlite never overwrites + or deletes a file it doesn't demonstrably own without asking. + `--force` exists for CI; humans get a prompt every time. +11. **Phase 8 pipes its install script via stdin to a fresh `php`.** + The two natural alternatives both lose: (a) loading WP + in-process couples envlite's exit semantics to `wp_die` and any + side effect of `wp-settings.php`; (b) shipping a second committed + asset alongside `router.php` adds repository surface area for a + one-off bootstrap. The stdin pipe gets full subprocess + isolation without an extra file — the install script is a + nowdoc heredoc inside `envlite.php`, with `$repoRoot` / `$port` + substituted via `strtr()` of `var_export()`'d literals so the + template body needs no escaping. `PHP_BINARY` is used so the + subprocess is the same PHP that's running envlite. +12. **Phase 8 never drops tables.** The test bootstrap drops and + re-creates on every run because CI wants clean-slate semantics; + envlite is a dev tool and the same behavior would silently + delete posts/pages/uploads on every `up`. envlite gates on + `is_blog_installed()` and skips if true. Users who want a clean + slate run `envlite clean` (which prompts for `.ht.sqlite`). +13. **`127.0.0.1` everywhere, never `localhost`.** `php -S` binds + IPv4-only, but `localhost` resolves to `::1` first on modern + macOS/Linux — a browser hitting `http://localhost:/` can get + `ECONNREFUSED` before any IPv4 fallback. Pinning the literal IPv4 + in every place a host appears (`php -S` bind, `WP_HOME`, + `WP_SITEURL`, `$_SERVER['HTTP_HOST']` in Phase 8, Phase 1 + bind-probe) also keeps the cookie origin invariant: WordPress + bakes `WP_HOME` into redirects and cookie domains, so a mismatch + between the constant and the address the user typed breaks admin + login. `localhost` would also depend on `/etc/hosts` and the + system resolver; `127.0.0.1` is a literal address with no + surprises. +14. **`PHP_CLI_SERVER_WORKERS=3` on `php -S` launch.** PHP's built-in + server is single-threaded by default — one slow request (a WP + admin page, a long REST call) blocks everything behind it, + including the parallel admin-ajax calls (heartbeat, autosave) a + single page can fire. The env var is the only knob; there is no + CLI flag. Available since PHP 7.4 (matches envlite's floor, so + Phase 0 already guards this) and silently ignored on Windows where + it is documented as unsupported — setting it there is harmless, + so envlite's launch path is platform-uniform (`putenv()` before + `pcntl_exec`/`proc_open`). Three workers covers typical WP-admin + concurrency (the page request plus one or two parallel admin-ajax + calls) without meaningful memory overhead, and SQLite's file lock + serializes writes so the multi-worker model can't corrupt the DB. + A user-exported `PHP_CLI_SERVER_WORKERS` is respected (envlite + only `putenv()`s when the variable is unset). +15. **Test DB is isolated via `DB_FILE` in the test config only.** + phpunit's `tests/phpunit/includes/install.php` drops every WP + table on every run; without isolation it would wipe the dev + site Phase 8 installs. The split is one `define( 'DB_FILE', + '.ht.test.sqlite' )` appended to `wp-tests-config.php`; + `src/wp-config.php` stays untouched and the live runtime keeps + the drop-in's default `FQDB`. Same-directory + filename suffix + beats a separate `database-test/` (no path-resolution surprises + in the drop-in's `FQDBDIR` machinery) and beats putting it + under `.cache/envlite/` (preserves envlite's own-state-only convention + for that directory). The test DB is not observation-tracked + because the rationale for tracking the live DB — possible + user-authored content — does not apply to a file phpunit drops + every run. +16. **Router resolves paths via `$_SERVER['DOCUMENT_ROOT']`, not + from `__DIR__`.** `php -S -t ` populates `DOCUMENT_ROOT` + with the absolute resolution of `-t`; `envlite_run_dev_server` + chdirs into the target repo and passes `-t src` before launch, + so `$_SERVER['DOCUMENT_ROOT']` always equals `/src` + at request time. Resolving from there decouples the router's + *resolution behavior* from the router file's *filesystem + location*: the router file can be loaded from a sibling envlite + checkout, a vendored copy, or a symlink without serving the + wrong repo. An earlier draft used `dirname(__DIR__, 2) . '/src'` + and silently broke this property — invoking envlite from one + worktree against a different worktree's repo loaded the + invoker's `wp-config.php` (with its `WP_HOME`/`WP_SITEURL`) and + triggered a canonical-URL 301 to the wrong port. + `tools/local-env/tests/test_router.php` is the regression test; + it boots `php -S` against a fixture docroot wholly outside the + router file's tree. +17. **`up` is the only setup command.** Earlier drafts had `init` + (setup, no serve) and `serve` (serve, no setup) alongside `up` + (both). Two pain points motivated the consolidation: (a) users + were running `init` followed by `up`, paying for the same setup + twice on every workflow start; (b) the help text had to explain + three commands that did overlapping things. With per-phase skip + rules in place, `up` is fast on a current repo (no subprocess + re-runs when inputs are unchanged) and `serve` adds nothing — the + bind probe + `php -S` launch is already what `up` does at the end. + `--no-serve` covers the CI / "set up but don't launch" niche that + `init` owned. The simplification removes a subcommand surface and + a bullet from the help text without losing capability. +18. **Skip via input hashes, not directory presence.** The natural + cheap check is "if `node_modules/` exists, skip `npm ci`." That + rule is wrong after `git pull` updates `package-lock.json` — + `node_modules/` is still on disk but its contents are stale, and + `up` would silently boot the dev server against the wrong deps. + Hashing the lockfile costs ~50 µs per phase; the staleness + detection it buys is worth it. Phase 4 mirrors the rule on + `composer.json`. Phase 3 keys on both phases plus a sentinel + output (`src/wp-includes/version.php`). Phase 5 keys on the SHA + pin literal in `envlite.php`. State is recorded only on subprocess + exit 0, so an interrupted phase always re-runs — false-positive + re-runs are acceptable, false-positive skips are not. +19. **`--rebuild` is distinct from `--force`.** `--force` answers yes + to file-overwrite prompts (its existing meaning); `--rebuild` + discards `.cache/envlite/state` for one invocation and re-runs every + skip-able phase. Conflating them would make CI's + prompt-bypass (`--force`) silently incur the full re-run cost on + every PR build — exactly the slowness the skip rules are designed + to eliminate. +20. **Parallel composer ‖ npm with serial build:dev.** Phases 2 and 4 + are mutually independent and the wall-time savings on a fresh + install are substantial. Phase 3 cannot join the parallel pair: + `build:dev` consumes certificate files installed by `composer + install`, observed empirically. Output buffering with a bundled + status line is the readable form of two long-running concurrent + subprocesses; on failure, both buffers are dumped under labeled + separators (no partner-kill machinery). + +--- + +## What envlite explicitly does NOT do + +- Allocate ports for *external* tooling (database GUIs, Xdebug, etc.) — + Phase 1 picks one port for the dev web server only. +- Start or stop the web server in the background. `envlite up` runs + the dev server in the foreground and respects Ctrl-C. +- Manage the SQLite database file itself. The drop-in creates + `src/wp-content/database/.ht.sqlite` when WordPress first loads; + Phase 8 triggers that load by running `wp_install()`, but envlite + does not own the file's bytes. envlite records the file in the + manifest the first time it observes the file's existence; `clean` + then prompts for it explicitly (the file may hold user-authored + content). +- Install global tools (PHP, node, composer) — Phase 0 just verifies. +- Configure HTTPS or a production-shaped reverse proxy. +- Perform any `composer update` or `npm update`. envlite is reproducible + from `package-lock.json` and `composer.json`; updates are an explicit + human action. +- Manage `node_modules/`, `vendor/`, or build artifacts under `src/`. + envlite invokes `npm ci`, `composer install`, and `npm run build:dev` + as a convenience during `up`, but treats their outputs as ordinary + dev-tool artifacts: not tracked in the manifest, not removed by + `clean`. Use `git clean -fdx` or your usual tooling. (Note: envlite + *does* track its skip metadata about these directories in + `.cache/envlite/state`, but the directories themselves remain + user-owned.) +- Override Composer's cache or home directory. envlite does not set + `COMPOSER_HOME`; Composer's default applies. +- Refresh the pinned SQLite drop-in. There is no `envlite update` + subcommand. To pick up a newer plugin release, edit the SHA256 pin + (and any associated logic) in `tools/local-env/envlite.php`. The + next `envlite up` detects the pin change via + `phase5.recorded_pin_sha` and re-downloads automatically; no manual + `clean` is required. The pin is intentional: bumping it is a + deliberate envlite revision, reviewed and committed alongside any + code adjustments the new release requires. +- Manage worktrees. envlite operates on whatever directory it is + invoked in. diff --git a/tools/local-env/README.md b/tools/local-env/README.md new file mode 100644 index 0000000000000..cebf0bb2e6d4f --- /dev/null +++ b/tools/local-env/README.md @@ -0,0 +1,87 @@ +# envlite + +A zero-daemon local environment for `wordpress-develop`. Runs WordPress +on SQLite via PHP's built-in server, with phpunit pointed at the same +SQLite database. No MySQL, no Docker, no MAMP. + +## Quickstart + +From the repo root: + +```sh +php tools/local-env/envlite.php up +``` + +That sets up the environment and starts the dev server in the +foreground at `http://127.0.0.1:`, where `` is auto-picked +from 8100–8899 on first run and cached at `.cache/envlite/port` for reuse. +Open the URL it prints; log in at `/wp-login.php` with `admin` / +`password`. Ctrl-C shuts it down. + +The first run needs network access (npm + Composer deps, plus a +pinned SQLite drop-in plugin). Subsequent runs are offline. + +Re-runs are safe. envlite skips work that's already done, prompts +before touching anything you've changed, and **never drops tables** — +your local content survives. + +## Requirements + +- PHP ≥ 7.4 with `pdo_sqlite`, `sqlite3`, `openssl`, `simplexml`, `zip`. + On Unix only, also `pcntl`. +- Node ≥ 20.10, npm ≥ 10.2.3. +- Composer ≥ 2. + +envlite checks these at startup and aborts with a clear error if +anything is missing. + +## Other commands + +```sh +php tools/local-env/envlite.php up --no-serve # setup only, no server (CI) +php tools/local-env/envlite.php clean # remove envlite-created files +``` + +`up` accepts: +- `--port=N` — pick a specific port (1–65535) and cache it. +- `--no-build` — skip `npm run build:dev`. Don't use this on a fresh + checkout; phpunit will fail with `ABSPATH constant ... non-existent path`. +- `--no-serve` — run setup phases only; don't launch the dev server. +- `--rebuild` — re-run every setup phase, ignoring cached skip-state. + Use when state is suspect or to validate a fresh install. +- `--force` — skip prompts (envlite prompts before overwriting files + you've modified). Required for non-interactive contexts. + +Re-running `up` is cheap. envlite hashes `package-lock.json` and +`composer.json` after each successful install; if those haven't +changed and the output directories are present, the install phases +skip. `npm run build:dev` skips when both deps phases skipped and the +build sentinel (`src/wp-includes/version.php`) exists. To force a +re-install of deps without nuking the directories, use `--rebuild`; +to force from scratch, `rm -rf node_modules/ vendor/` and re-run. + +`clean` removes envlite's config files (`src/wp-config.php`, +`wp-tests-config.php`, `src/wp-content/db.php`), the bundled SQLite +plugin directory, the cached port, the skip-state file, and — on a +single confirmation prompt — the live SQLite DB at +`src/wp-content/database/.ht.sqlite`. It does not touch +`node_modules/`, `vendor/`, or build artifacts under `src/`. For +those, use `git clean -fdx`. + +## Use `127.0.0.1`, not `localhost` + +envlite binds IPv4 only. `localhost` resolves to `::1` first on modern +macOS/Linux, so a browser hitting `http://localhost:/` can get +`ECONNREFUSED`. Use `127.0.0.1` and admin cookies will work too. + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `not in a wordpress-develop checkout` | `cd` to the repo root. | +| `extension X not loaded` | Install it. Ubuntu/Debian: `apt install php-sqlite3 php-xml php-zip`. Homebrew's `php` already bundles them. | +| ` below minimum` | Upgrade node/npm/composer. | +| `SHA256 mismatch on plugin zip` | Retry once. If persistent, the pinned SQLite drop-in needs a deliberate update — file an issue. | +| `failed to bind 127.0.0.1:` | Another process holds the port. `lsof -nP -iTCP: -sTCP:LISTEN`; kill the holder, or `up --port=N` to relocate. | +| phpunit fails with deprecation-as-exception | wordpress-develop sets `convertDeprecationsToExceptions=true`; newer PHP may surface deprecations from core code as exceptions. Per-group fix, not envlite's. | +| Corrupt-DB error after an interrupted run | Delete `src/wp-content/database/.ht.sqlite` and re-run. | diff --git a/tools/local-env/envlite.php b/tools/local-env/envlite.php new file mode 100644 index 0000000000000..d2dad3995dac5 --- /dev/null +++ b/tools/local-env/envlite.php @@ -0,0 +1,1275 @@ + [args] + + Commands: + up [--port=N] [--no-build] [--no-serve] [--rebuild] + Set up the checkout and start the dev server. Installs JS/PHP + deps in parallel, builds, writes config, installs the SQLite + drop-in, runs wp_install() if needed, then launches `php -S`. + Re-runs are cheap — unchanged phases are skipped. After install, + sign in at /wp-login.php with admin / password. + + clean + Remove every file envlite created. Prompts before deleting; + --force skips prompts. Does not touch node_modules/, vendor/, + or build outputs. + + help, --help, -h + Print this help. + + Flags: + --port=N Port for the dev server. Default: derived from the + checkout path, cached at .cache/envlite/port. + --no-build Skip `npm run build:dev` even if inputs changed. + --no-serve Run setup phases only; don't launch the server. + --rebuild Re-run every setup phase, ignoring cached state. + --force Answer 'y' to every prompt. Required in CI. + + TEXT; +} + +function envlite_format_log(?string $subcommand, string $message): string { + $prefix = $subcommand === null ? 'envlite' : "envlite $subcommand"; + $message = rtrim($message, "\n"); + return "$prefix: $message\n"; +} + +function envlite_log(?string $subcommand, string $message): void { + fwrite(STDERR, envlite_format_log($subcommand, $message)); +} + +function envlite_path_to_posix(string $path): string { + return str_replace('\\', '/', $path); +} + +function envlite_path_relative_to(string $root, string $abs): string { + $root = rtrim(envlite_path_to_posix($root), '/'); + $abs = envlite_path_to_posix($abs); + if ($abs === $root) { return ''; } + $prefix = $root . '/'; + if (substr($abs, 0, strlen($prefix)) === $prefix) { + return substr($abs, strlen($prefix)); + } + throw new \InvalidArgumentException("path outside repo root: $abs"); +} + +function envlite_manifest_path(string $repoRoot): string { + return rtrim(envlite_path_to_posix($repoRoot), '/') . '/.cache/envlite/manifest'; +} + +function envlite_manifest_load(string $repoRoot): array { + $path = envlite_manifest_path($repoRoot); + if (!is_file($path)) { return []; } + $entries = []; + foreach (explode("\n", file_get_contents($path)) as $line) { + $line = rtrim($line, "\r"); + if ($line === '') { continue; } + // Two-space delimiter. Hash field is exactly 64 hex chars or the literal "dir". + if (!preg_match('/^([0-9a-f]{64}|dir) (.+)$/', $line, $m)) { + continue; // malformed, skip + } + $entries[$m[2]] = $m[1]; + } + return $entries; +} + +function envlite_manifest_save(string $repoRoot, array $entries): void { + $lines = ''; + foreach ($entries as $path => $hash) { + $lines .= "$hash $path\n"; + } + $manifestPath = envlite_manifest_path($repoRoot); + $dir = dirname($manifestPath); + if (!is_dir($dir)) { mkdir($dir, 0755, true); } + envlite_atomic_write($manifestPath, $lines); +} + +function envlite_state_path(string $repoRoot): string { + return rtrim(envlite_path_to_posix($repoRoot), '/') . '/.cache/envlite/state'; +} + +function envlite_state_load(string $repoRoot): array { + $path = envlite_state_path($repoRoot); + if (!is_file($path)) { return []; } + $entries = []; + foreach (explode("\n", file_get_contents($path)) as $line) { + $line = rtrim($line, "\r"); + if ($line === '') { continue; } + $tab = strpos($line, "\t"); + if ($tab === false) { continue; } + $key = substr($line, 0, $tab); + $value = substr($line, $tab + 1); + $entries[$key] = $value; + } + return $entries; +} + +function envlite_state_save(string $repoRoot, array $entries): void { + ksort($entries); + $lines = ''; + foreach ($entries as $key => $value) { + $lines .= "$key\t$value\n"; + } + $path = envlite_state_path($repoRoot); + $dir = dirname($path); + if (!is_dir($dir)) { mkdir($dir, 0755, true); } + envlite_atomic_write($path, $lines); +} + +function envlite_atomic_write(string $path, string $bytes): string { + $dir = dirname($path); + if (!is_dir($dir)) { mkdir($dir, 0755, true); } + $hash = hash('sha256', $bytes); + $tmp = $path . '.tmp'; + $fh = fopen($tmp, 'wb'); + if ($fh === false) { throw new \RuntimeException("cannot open $tmp"); } + if (fwrite($fh, $bytes) !== strlen($bytes)) { + fclose($fh); @unlink($tmp); + throw new \RuntimeException("short write to $tmp"); + } + // fsync for crash-durability before rename. Available since PHP 8.1; on + // older PHPs we settle for fflush, which is the best we can do without + // pulling in extensions. + fflush($fh); + if (function_exists('fsync')) { @fsync($fh); } + fclose($fh); + if (!rename($tmp, $path)) { + @unlink($tmp); + throw new \RuntimeException("rename failed: $tmp -> $path"); + } + return $hash; +} + +/** + * @param array $manifest path => sha256-hex|"dir" + * @param string|null $currentBytes Null if the file/dir does not exist on disk + * or is a directory entry whose contents we don't drift-check. + * @return 'absent'|'owned_clean'|'owned_drifted'|'unowned' + */ +function envlite_ownership(array $manifest, string $relPath, ?string $currentBytes): string { + $recorded = $manifest[$relPath] ?? null; + if ($currentBytes === null && $recorded === null) { return 'absent'; } + if ($recorded === null) { return 'unowned'; } + if ($recorded === 'dir') { return 'owned_clean'; } + if ($currentBytes === null) { + // Recorded as file but currentBytes wasn't provided — caller missed reading it. + // Treat as drifted; safer to prompt. + return 'owned_drifted'; + } + return hash('sha256', $currentBytes) === $recorded ? 'owned_clean' : 'owned_drifted'; +} + +function envlite_format_prompt( + string $subcommand, + string $operation, // unused for now; kept so future ops can specialize wording + string $relPath, + ?string $recordedHash, + ?string $currentHash +): string { + if ($recordedHash !== null && $currentHash !== null) { + $rec = substr($recordedHash, 0, 8); + $cur = substr($currentHash, 0, 8); + $body = "envlite owns $relPath but content has drifted (recorded {$rec}\u{2026}, current {$cur}\u{2026}). Overwrite?"; + } else { + $body = "not envlite-owned: $relPath. Overwrite?"; + } + return "envlite $subcommand: $body [y/N] "; +} + +/** + * Pure-IO variant for testability. Production code calls envlite_prompt() below. + */ +function envlite_prompt_io( + bool $force, + bool $isTty, + string $subcommand, + string $operation, + string $relPath, + ?string $recordedHash, + ?string $currentHash, + $stdin, + $stderr +): bool { + if ($force) { return true; } + if (!$isTty) { + fwrite($stderr, envlite_format_log( + null, + "non-interactive context and --force not given; aborting at $operation on $relPath" + )); + return false; + } + fwrite($stderr, envlite_format_prompt($subcommand, $operation, $relPath, $recordedHash, $currentHash)); + $line = fgets($stdin); + if ($line === false) { return false; } + $resp = strtolower(trim($line)); + return $resp === 'y' || $resp === 'yes'; +} + +/** + * Production wrapper. Returns true=overwrite, false=skip. On non-interactive + * abort the caller must exit 5 — see callers. + */ +function envlite_prompt( + bool $force, + string $subcommand, + string $operation, + string $relPath, + ?string $recordedHash, + ?string $currentHash +): bool { + return envlite_prompt_io( + $force, + stream_isatty(STDIN), + $subcommand, + $operation, + $relPath, + $recordedHash, + $currentHash, + STDIN, + STDERR + ); +} + +/** + * Convenience: returns true if the caller should proceed with the write. + * On non-interactive abort, exits 5 directly (matches spec). + */ +function envlite_prompt_or_abort( + bool $force, + string $subcommand, + string $operation, + string $relPath, + ?string $recordedHash, + ?string $currentHash +): bool { + if ($force) { return true; } + if (!stream_isatty(STDIN)) { + envlite_log(null, "non-interactive context and --force not given; aborting at $operation on $relPath"); + exit(5); + } + $ok = envlite_prompt($force, $subcommand, $operation, $relPath, $recordedHash, $currentHash); + if (!$ok) { exit(5); } + return true; +} + +const ENVLITE_REPO_MARKERS = [ + 'package.json', + 'composer.json', + 'wp-config-sample.php', + 'wp-tests-config-sample.php', + 'src/wp-includes', + 'tests/phpunit/includes/bootstrap.php', +]; + +function envlite_phase0_is_wordpress_develop(string $root): bool { + foreach (ENVLITE_REPO_MARKERS as $m) { + if (!file_exists($root . '/' . $m)) { return false; } + } + return true; +} + +/** Extracts [major, minor, patch] from any string containing a `\d+\.\d+\.\d+` substring. */ +function envlite_phase0_parse_version(string $output): array { + if (!preg_match('/(\d+)\.(\d+)\.(\d+)/', $output, $m)) { + throw new \RuntimeException("could not parse version from: " . trim($output)); + } + return [(int)$m[1], (int)$m[2], (int)$m[3]]; +} + +function envlite_phase0_version_ge(array $a, array $b): bool { + for ($i = 0; $i < 3; $i++) { + if ($a[$i] > $b[$i]) { return true; } + if ($a[$i] < $b[$i]) { return false; } + } + return true; +} + +/** Capture variant: returns [$exit, $stdout, $stderr]. Used by Phase 0. */ +function envlite_proc_capture(array $cmd, ?string $cwd = null): array { + $proc = @proc_open( + $cmd, + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $cwd + ); + if (!is_resource($proc)) { return [-1, '', '']; } + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + $exit = proc_close($proc); + return [$exit, $stdout ?: '', $stderr ?: '']; +} + +/** Streaming variant: child stdio inherits the parent's. Used by Phase 3 and the dev-server fallback on Windows. */ +function envlite_proc_stream(array $cmd, ?string $cwd = null): int { + $proc = @proc_open($cmd, [0 => STDIN, 1 => STDOUT, 2 => STDERR], $pipes, $cwd); + if (!is_resource($proc)) { return -1; } + return proc_close($proc); +} + +/** + * Builds the argv passed to `php -S`. Excludes the binary itself — + * pcntl_exec receives the binary as its first argument and the rest as $args. + * On the Windows fallback path, envlite_run_dev_server prepends PHP_BINARY. + */ +function envlite_dev_server_argv(string $repoRoot, int $port): array { + return ['-S', "127.0.0.1:$port", '-t', 'src', __DIR__ . '/router.php']; +} + +/** + * Returns true if pcntl_exec is usable on this platform right now. + * Split into a function so tests can read the same predicate the launcher uses. + */ +function envlite_pcntl_exec_available(): bool { + return PHP_OS_FAMILY !== 'Windows' && function_exists('pcntl_exec'); +} + +/** + * Launches the dev server. On Unix, requires pcntl (enforced by Phase 0 at + * up time and re-checked here for safety) and replaces the current process + * via pcntl_exec — same PID, no parent-child relay. On Windows, falls back + * to envlite_proc_stream which inherits stdio so SIGINT still reaches the + * child. Returns only on error or when the Windows-fallback child exits. + */ +function envlite_run_dev_server(string $repoRoot, int $port): int { + $argv = envlite_dev_server_argv($repoRoot, $port); + + // Multi-worker `php -S`. Only knob PHP exposes; PHP 7.4+ on Unix, ignored + // on Windows. Don't clobber a user-exported value. + if (getenv('PHP_CLI_SERVER_WORKERS') === false) { + putenv('PHP_CLI_SERVER_WORKERS=3'); + } + + if (PHP_OS_FAMILY !== 'Windows') { + if (!function_exists('pcntl_exec')) { + // Phase 0 enforces pcntl on Unix; this defensive check exists so a + // PHP build that hides pcntl behind extension config still gets a + // clean error rather than degrading to proc_open silently. + envlite_log(null, 'pcntl extension is required on Unix; reinstall PHP with pcntl'); + return 1; + } + // pcntl_exec uses the *current* working directory; chdir first so + // `-t src` resolves relative to the repo root, matching the proc_open + // path's $cwd argument. + if (!@chdir($repoRoot)) { + envlite_log(null, "failed to chdir to $repoRoot before exec"); + return 1; + } + // Suppress the warning pcntl_exec emits on failure; we surface our own. + @pcntl_exec(PHP_BINARY, $argv); + // pcntl_exec returns only on failure (success replaces the process). + envlite_log(null, 'pcntl_exec(php -S) failed; the dev server did not start'); + return 1; + } + + // Windows fallback. Use PHP_BINARY explicitly so we don't depend on PATH + // resolution to the same PHP that is running envlite. + $exit = envlite_proc_stream(array_merge([PHP_BINARY], $argv), $repoRoot); + return $exit === 0 ? 0 : 1; +} + +/** + * Returns null on missing tool (proc_open failure / nonzero exit / unparseable + * output). Returns [major, minor, patch] otherwise. The version flag arg + * accommodates `--version` (npm/composer) and `-v` if a future tool prefers it. + */ +function envlite_phase0_tool_version(array $cmd): ?array { + [$exit, $stdout, $stderr] = envlite_proc_capture($cmd); + if ($exit !== 0) { return null; } + try { + return envlite_phase0_parse_version($stdout !== '' ? $stdout : $stderr); + } catch (\Throwable $e) { + return null; + } +} + +function envlite_phase0_required_extensions(): array { + $exts = ['pdo_sqlite', 'sqlite3', 'openssl', 'simplexml', 'zip']; + if (PHP_OS_FAMILY !== 'Windows') { + // pcntl is required on Unix so envlite_run_dev_server can call + // pcntl_exec into php -S. Windows lacks pcntl entirely; the + // dev-server launcher falls back to proc_open there. + $exts[] = 'pcntl'; + } + return $exts; +} + +/** Runs all preflight checks. Calls envlite_log and exits 3 on first failure. */ +function envlite_phase0_run(string $repoRoot): void { + if (!envlite_phase0_is_wordpress_develop($repoRoot)) { + envlite_log(null, "preflight: $repoRoot is not a wordpress-develop checkout"); + exit(3); + } + if (PHP_VERSION_ID < 70400) { + envlite_log(null, 'preflight: PHP ' . PHP_VERSION . ' is below the 7.4 floor'); + exit(3); + } + foreach (envlite_phase0_required_extensions() as $ext) { + if (!extension_loaded($ext)) { + envlite_log(null, "preflight: required PHP extension missing: $ext"); + exit(3); + } + } + $tools = [ + ['node', ['node', '--version'], [20, 10, 0]], + ['npm', ['npm', '--version'], [10, 2, 3]], + ['composer', ['composer', '--version'], [2, 0, 0]], + ]; + foreach ($tools as [$name, $cmd, $min]) { + $ver = envlite_phase0_tool_version($cmd); + if ($ver === null) { + envlite_log(null, "preflight: $name not found or did not report a version"); + exit(3); + } + if (!envlite_phase0_version_ge($ver, $min)) { + $vstr = implode('.', $ver); + $mstr = implode('.', $min); + envlite_log(null, "preflight: $name $vstr is below the $mstr minimum"); + exit(3); + } + } +} + +const ENVLITE_PORT_LOW = 8100; +const ENVLITE_PORT_POOL_SIZE = 800; + +function envlite_phase1_seed_port(string $absPath): int { + // hash('crc32b') is unsigned and 8 hex chars; substr(-7) is 28 bits, fits in PHP int even on 32-bit. + $digest = hash('crc32b', $absPath); + $seed = hexdec(substr($digest, -7)); + return ENVLITE_PORT_LOW + ($seed % ENVLITE_PORT_POOL_SIZE); +} + +function envlite_phase1_port_is_free(int $port): bool { + $sock = @stream_socket_server("tcp://127.0.0.1:$port", $errno, $errstr); + if (!is_resource($sock)) { return false; } + fclose($sock); + return true; +} + +function envlite_phase1_discover_port(string $repoRoot, ?int $explicitPort): int { + $cachePath = rtrim(envlite_path_to_posix($repoRoot), '/') . '/.cache/envlite/port'; + + if ($explicitPort !== null) { + if (!envlite_phase1_port_is_free($explicitPort)) { + envlite_log('up', "phase 1: port $explicitPort is in use; try a different --port (e.g. lsof -nP -iTCP:$explicitPort -sTCP:LISTEN)"); + exit(1); + } + envlite_phase1_write_cache($repoRoot, $explicitPort); + return $explicitPort; + } + + if (is_file($cachePath)) { + $cached = (int) trim(file_get_contents($cachePath)); + if ($cached >= 1 && $cached <= 65535 && envlite_phase1_port_is_free($cached)) { + return $cached; + } + // cache corrupt, out of range, or port now in use (e.g. after reboot): fall through to re-pick + } + + $start = envlite_phase1_seed_port(realpath($repoRoot) ?: $repoRoot); + for ($i = 0; $i < ENVLITE_PORT_POOL_SIZE; $i++) { + $cand = ENVLITE_PORT_LOW + ((($start - ENVLITE_PORT_LOW) + $i) % ENVLITE_PORT_POOL_SIZE); + if (envlite_phase1_port_is_free($cand)) { + envlite_phase1_write_cache($repoRoot, $cand); + return $cand; + } + } + envlite_log('up', 'phase 1: no free port in 8100-8899'); + exit(1); +} + +function envlite_phase1_write_cache(string $repoRoot, int $port): void { + $cachePath = rtrim(envlite_path_to_posix($repoRoot), '/') . '/.cache/envlite/port'; + $hash = envlite_atomic_write($cachePath, "$port\n"); + $manifest = envlite_manifest_load($repoRoot); + $manifest['.cache/envlite/port'] = $hash; + envlite_manifest_save($repoRoot, $manifest); +} + +function envlite_phase2_input_hash(string $repoRoot): ?string { + $path = "$repoRoot/package-lock.json"; + if (!is_file($path)) { return null; } + return hash_file('sha256', $path); +} + +function envlite_phase4_input_hash(string $repoRoot): ?string { + $path = "$repoRoot/composer.json"; + if (!is_file($path)) { return null; } + return hash_file('sha256', $path); +} + +/** + * Run multiple subprocesses concurrently with per-process buffered output. + * + * @param array $jobs label => spec + * @return array + */ +function envlite_run_parallel_buffered(array $jobs): array { + $procs = []; + $streamLabel = []; // (int) stream id => label + foreach ($jobs as $label => $spec) { + $proc = @proc_open( + $spec['cmd'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $spec['cwd'] ?? null + ); + if (!is_resource($proc)) { + foreach ($procs as $p) { + @proc_terminate($p['proc']); + @proc_close($p['proc']); + } + throw new \RuntimeException("failed to spawn $label"); + } + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $procs[$label] = [ + 'proc' => $proc, + 'stdout' => $pipes[1], + 'stderr' => $pipes[2], + 'buf' => '', + ]; + $streamLabel[(int) $pipes[1]] = $label; + $streamLabel[(int) $pipes[2]] = $label; + } + + while (true) { + $read = []; + foreach ($procs as $p) { + if (is_resource($p['stdout'])) { $read[] = $p['stdout']; } + if (is_resource($p['stderr'])) { $read[] = $p['stderr']; } + } + if (empty($read)) { break; } + + $write = $except = null; + $n = @stream_select($read, $write, $except, 1); + if ($n === false) { continue; } // EINTR — retry + if ($n === 0) { continue; } // timeout — retry + + foreach ($read as $stream) { + $label = $streamLabel[(int) $stream] ?? null; + if ($label === null) { continue; } + $chunk = fread($stream, 8192); + if ($chunk !== false && $chunk !== '') { + $procs[$label]['buf'] .= $chunk; + } + if (feof($stream)) { + fclose($stream); + if ($procs[$label]['stdout'] === $stream) { + $procs[$label]['stdout'] = null; + } + if ($procs[$label]['stderr'] === $stream) { + $procs[$label]['stderr'] = null; + } + } + } + } + + $result = []; + foreach ($procs as $label => $p) { + $result[$label] = [ + 'exit' => proc_close($p['proc']), + 'output' => $p['buf'], + ]; + } + return $result; +} + +/** + * Phases 2 and 4 — npm ci and composer install, in parallel. + * Returns a record of which phases were skipped this run; the caller + * (Phase 3) needs that to decide whether the build:dev sentinel + recorded + * hashes are sufficient to skip itself. + * + * @return array{phase2_skipped: bool, phase4_skipped: bool} + */ +function envlite_phase24_parallel(string $repoRoot, bool $rebuild): array { + $state = envlite_state_load($repoRoot); + + $npmHash = envlite_phase2_input_hash($repoRoot); + $composerHash = envlite_phase4_input_hash($repoRoot); + + $phase2Skip = !$rebuild + && $npmHash !== null + && is_dir("$repoRoot/node_modules") + && ($state['phase2.input_hash'] ?? null) === $npmHash; + $phase4Skip = !$rebuild + && $composerHash !== null + && is_dir("$repoRoot/vendor") + && ($state['phase4.input_hash'] ?? null) === $composerHash; + + if ($phase2Skip && $phase4Skip) { + return ['phase2_skipped' => true, 'phase4_skipped' => true]; + } + + $jobs = []; + if (!$phase2Skip) { + $jobs['npm ci'] = ['cmd' => ['npm', 'ci'], 'cwd' => $repoRoot]; + } + if (!$phase4Skip) { + $jobs['composer install'] = [ + 'cmd' => ['composer', 'install', '--no-interaction', '--ignore-platform-req=ext-simplexml'], + 'cwd' => $repoRoot, + ]; + } + + fwrite(STDERR, "envlite up: installing dependencies\u{2026}\n"); + $results = envlite_run_parallel_buffered($jobs); + + $failed = []; + foreach ($results as $label => $r) { + if ($r['exit'] !== 0) { $failed[$label] = $r; } + } + if (!empty($failed)) { + // Dump only the buffers of failed processes, with labeled separators. + foreach ($results as $label => $r) { + if (!isset($failed[$label])) { continue; } + fwrite(STDERR, "--- $label ---\n"); + fwrite(STDERR, $r['output']); + if ($r['output'] === '' || substr($r['output'], -1) !== "\n") { + fwrite(STDERR, "\n"); + } + } + if (count($failed) === 1) { + $label = array_keys($failed)[0]; + $phaseN = $label === 'npm ci' ? 2 : 4; + $exit = $failed[$label]['exit']; + throw new \RuntimeException("phase $phaseN: $label failed (exit $exit)"); + } + throw new \RuntimeException('phases 2 and 4: install subprocesses failed'); + } + + // Record state for each phase that ran successfully. + if (!$phase2Skip) { $state['phase2.input_hash'] = $npmHash; } + if (!$phase4Skip) { $state['phase4.input_hash'] = $composerHash; } + envlite_state_save($repoRoot, $state); + + return ['phase2_skipped' => $phase2Skip, 'phase4_skipped' => $phase4Skip]; +} + +/** + * Phase 3 — npm run build:dev, serial after phases 2 & 4. + * Skips when both deps phases skipped, sentinel exists, and recorded + * hashes still match. `--no-build` forces skip; `--rebuild` forces run. + */ +function envlite_phase3_build_dev( + string $repoRoot, + bool $rebuild, + bool $noBuild, + bool $phase2Skipped, + bool $phase4Skipped +): void { + if ($noBuild) { return; } + + $sentinel = "$repoRoot/src/wp-includes/version.php"; + $npmHash = envlite_phase2_input_hash($repoRoot); + $composerHash = envlite_phase4_input_hash($repoRoot); + $state = envlite_state_load($repoRoot); + + $skip = !$rebuild + && $phase2Skipped + && $phase4Skipped + && is_file($sentinel) + && $npmHash !== null && $composerHash !== null + && ($state['phase3.recorded_npm_hash'] ?? null) === $npmHash + && ($state['phase3.recorded_composer_hash'] ?? null) === $composerHash; + if ($skip) { return; } + + $exit = envlite_proc_stream(['npm', 'run', 'build:dev'], $repoRoot); + if ($exit !== 0) { + throw new \RuntimeException("phase 3: npm run build:dev failed (exit $exit)"); + } + + if ($npmHash !== null) { $state['phase3.recorded_npm_hash'] = $npmHash; } + if ($composerHash !== null) { $state['phase3.recorded_composer_hash'] = $composerHash; } + envlite_state_save($repoRoot, $state); +} + +const ENVLITE_SQLITE_PLUGIN_URL = 'https://downloads.wordpress.org/plugin/sqlite-database-integration.zip'; +const ENVLITE_SQLITE_PLUGIN_SHA256 = '44be096a14ebcea424b5e4bf764436ec85fb067f74ab47822c4c5346df21591e'; +const ENVLITE_SQLITE_PLACEHOLDER = '{SQLITE_IMPLEMENTATION_FOLDER_PATH}'; + +function envlite_http_get(string $url, int $timeoutSeconds = 30): string { + $ctx = stream_context_create([ + 'http' => [ + 'follow_location' => 1, + 'max_redirects' => 5, + 'timeout' => $timeoutSeconds, + 'header' => "User-Agent: envlite/" . ENVLITE_VERSION . "\r\n", + ], + 'https' => [ + 'follow_location' => 1, + 'max_redirects' => 5, + 'timeout' => $timeoutSeconds, + 'header' => "User-Agent: envlite/" . ENVLITE_VERSION . "\r\n", + ], + ]); + $bytes = @file_get_contents($url, false, $ctx); + if ($bytes === false) { + throw new \RuntimeException("HTTP fetch failed: $url"); + } + return $bytes; +} + +function envlite_phase5_verify_sha256(string $path, string $expected): void { + $actual = hash_file('sha256', $path); + if ($actual !== $expected) { + throw new \RuntimeException("SHA256 mismatch on $path: expected $expected, got $actual"); + } +} + +function envlite_phase5_assert_placeholder(string $dbCopyPath): void { + $bytes = @file_get_contents($dbCopyPath); + if ($bytes === false || strpos($bytes, ENVLITE_SQLITE_PLACEHOLDER) === false) { + throw new \RuntimeException( + "tripwire: " . ENVLITE_SQLITE_PLACEHOLDER . " placeholder missing from $dbCopyPath; spec assumption broken" + ); + } +} + +function envlite_phase5_install(string $repoRoot, bool $force, bool $rebuild = false): void { + $pluginDir = "$repoRoot/src/wp-content/plugins/sqlite-database-integration"; + $dbCopy = "$pluginDir/db.copy"; + $dbPhpRel = 'src/wp-content/db.php'; + $pluginRel = 'src/wp-content/plugins/sqlite-database-integration'; + $manifest = envlite_manifest_load($repoRoot); + $state = envlite_state_load($repoRoot); + + // Step 1: skip download/extract if (a) plugin dir is in the manifest, + // (b) db.copy is present, AND (c) the recorded pin SHA matches the + // current code literal. --rebuild bypasses the skip. + $pinMatches = ($state['phase5.recorded_pin_sha'] ?? null) === ENVLITE_SQLITE_PLUGIN_SHA256; + $alreadyInstalled = !$rebuild + && isset($manifest[$pluginRel]) + && $manifest[$pluginRel] === 'dir' + && is_file($dbCopy) + && $pinMatches; + if (!$alreadyInstalled) { + // Steps 2-4: prompt if dest exists and is not envlite-owned. + if (is_dir($pluginDir) && !isset($manifest[$pluginRel])) { + envlite_prompt_or_abort($force, 'up', 'overwrite plugin tree', $pluginRel, null, null); + } + $tmpZip = sys_get_temp_dir() . '/envlite-sqlite-' . bin2hex(random_bytes(4)) . '.zip'; + $bytes = envlite_http_get(ENVLITE_SQLITE_PLUGIN_URL); + file_put_contents($tmpZip, $bytes); + try { + envlite_phase5_verify_sha256($tmpZip, ENVLITE_SQLITE_PLUGIN_SHA256); + $zip = new \ZipArchive(); + if ($zip->open($tmpZip) !== true) { + throw new \RuntimeException("ZipArchive::open failed: $tmpZip"); + } + // extractTo returns false on partial/failed extraction (permissions, + // disk full, malformed entries). Recording the directory as + // envlite-owned in that case would let a later run satisfy the + // db.copy short-circuit and skip re-downloading, leaving a + // half-extracted plugin tree in place. + $extracted = $zip->extractTo("$repoRoot/src/wp-content/plugins/"); + $zip->close(); + if ($extracted !== true) { + throw new \RuntimeException("ZipArchive::extractTo failed for $tmpZip"); + } + } finally { + @unlink($tmpZip); + } + $manifest[$pluginRel] = 'dir'; + envlite_manifest_save($repoRoot, $manifest); + + // Record the pin SHA so a subsequent code-level pin bump + // re-triggers download/extract automatically. + $state['phase5.recorded_pin_sha'] = ENVLITE_SQLITE_PLUGIN_SHA256; + envlite_state_save($repoRoot, $state); + } + + // Step 5: copy db.copy → db.php with manifest contract. + if (!is_file($dbCopy)) { + throw new \RuntimeException("phase 5: db.copy missing at $dbCopy after extraction"); + } + $dbBytes = @file_get_contents($dbCopy); + if ($dbBytes === false) { + throw new \RuntimeException("phase 5: cannot read $dbCopy"); + } + $dbPhpAbs = "$repoRoot/$dbPhpRel"; + $current = null; + if (is_file($dbPhpAbs)) { + $current = @file_get_contents($dbPhpAbs); + if ($current === false) { + throw new \RuntimeException("phase 5: cannot read $dbPhpAbs"); + } + } + $ownership = envlite_ownership($manifest, $dbPhpRel, $current); + if ($ownership === 'owned_drifted') { + $rec = $manifest[$dbPhpRel]; + $cur = $current !== null ? hash('sha256', $current) : null; + envlite_prompt_or_abort($force, 'up', 'overwrite drifted file', $dbPhpRel, $rec, $cur); + } elseif ($ownership === 'unowned') { + envlite_prompt_or_abort($force, 'up', 'overwrite unowned file', $dbPhpRel, null, null); + } + $hash = envlite_atomic_write($dbPhpAbs, $dbBytes); + $manifest[$dbPhpRel] = $hash; + envlite_manifest_save($repoRoot, $manifest); + + // Step 6: tripwire. + envlite_phase5_assert_placeholder($dbCopy); +} + +function envlite_phase6_render(string $sample): string { + $replacements = [ + 'youremptytestdbnamehere' => 'wordpress_test', + 'yourusernamehere' => 'wp', + 'yourpasswordhere' => 'wp', + ]; + foreach ($replacements as $placeholder => $value) { + if (substr_count($sample, $placeholder) !== 1) { + throw new \RuntimeException("phase 6: placeholder '$placeholder' must appear exactly once"); + } + } + $out = strtr($sample, $replacements); + foreach (array_keys($replacements) as $placeholder) { + if (strpos($out, $placeholder) !== false) { + throw new \RuntimeException("phase 6: placeholder '$placeholder' still present after substitution"); + } + } + if (preg_match("/define\\s*\\(\\s*['\"]DB_FILE['\"]/", $out)) { + throw new \RuntimeException( + "phase 6: DB_FILE already defined in wp-tests-config-sample.php; envlite assumption broken" + ); + } + if (substr($out, -1) !== "\n") { + $out .= "\n"; + } + $out .= "define( 'DB_FILE', '.ht.test.sqlite' );\n"; + return $out; +} + +function envlite_phase6_install(string $repoRoot, bool $force): void { + $samplePath = "$repoRoot/wp-tests-config-sample.php"; + $outRel = 'wp-tests-config.php'; + $outAbs = "$repoRoot/$outRel"; + + $sample = @file_get_contents($samplePath); + if ($sample === false) { + throw new \RuntimeException("phase 6: cannot read $samplePath"); + } + $rendered = envlite_phase6_render($sample); + + $manifest = envlite_manifest_load($repoRoot); + $current = null; + if (is_file($outAbs)) { + $current = @file_get_contents($outAbs); + if ($current === false) { + throw new \RuntimeException("phase 6: cannot read $outAbs"); + } + } + $ownership = envlite_ownership($manifest, $outRel, $current); + if ($ownership === 'owned_drifted') { + $cur = $current !== null ? hash('sha256', $current) : null; + envlite_prompt_or_abort($force, 'up', 'overwrite drifted file', $outRel, $manifest[$outRel], $cur); + } elseif ($ownership === 'unowned') { + envlite_prompt_or_abort($force, 'up', 'overwrite unowned file', $outRel, null, null); + } + $hash = envlite_atomic_write($outAbs, $rendered); + $manifest[$outRel] = $hash; + envlite_manifest_save($repoRoot, $manifest); +} + +const ENVLITE_SALT_URL = 'https://api.wordpress.org/secret-key/1.1/salt/'; + +function envlite_phase7_render(string $sample, int $port, ?string $saltsBlock): string { + // wp-config-sample.php ships with CRLF line endings in tree. envlite + // injects LF-only lines (WP_HOME/WP_SITEURL, the salts block); without + // normalization the rendered output would be a mix of CRLF and LF, which + // makes envlite's recorded hash sensitive to how the user's git client + // chose to check out the sample. Normalize once up front so the output + // is LF-only and the hash is portable. + $sample = str_replace("\r\n", "\n", $sample); + + // 1. DB constants — exactly one of each in the sample. + $dbReplacements = [ + 'database_name_here' => 'wordpress', + 'username_here' => 'wp', + 'password_here' => 'wp', + ]; + foreach ($dbReplacements as $placeholder => $value) { + if (substr_count($sample, $placeholder) !== 1) { + throw new \RuntimeException("phase 7: placeholder '$placeholder' must appear exactly once"); + } + } + $cfg = strtr($sample, $dbReplacements); + + // 2. Salt block: AUTH_KEY through NONCE_SALT, 8 contiguous define()s. + // The salts API returns random bytes that can include `$` and `\`; using + // them as preg_replace's replacement argument would let sequences like + // `$1` or `\1` be interpreted as backreferences and silently corrupt the + // saved salts. Use a callback so the block is inserted as a literal. + if ($saltsBlock !== null) { + $pattern = '/define\(\s*\'AUTH_KEY\'.*?define\(\s*\'NONCE_SALT\'\s*,\s*\'[^\']*\'\s*\);/s'; + $count = preg_match_all($pattern, $cfg, $m); + if ($count !== 1) { + throw new \RuntimeException("phase 7: expected exactly one salt block, found $count"); + } + $cfg = preg_replace_callback( + $pattern, + static function () use ($saltsBlock) { return $saltsBlock; }, + $cfg, + 1 + ); + } + + // 3. Inject WP_HOME / WP_SITEURL before the marker. + $marker = "/* That's all, stop editing! Happy publishing. */"; + if (substr_count($cfg, $marker) !== 1) { + throw new \RuntimeException("phase 7: expected exactly one marker line"); + } + $inject = "define( 'WP_HOME', 'http://127.0.0.1:$port' );\n" + . "define( 'WP_SITEURL', 'http://127.0.0.1:$port' );\n\n"; + $pos = strpos($cfg, $marker); + return substr($cfg, 0, $pos) . $inject . substr($cfg, $pos); +} + +function envlite_phase7_fetch_salts(): ?string { + try { + $bytes = envlite_http_get(ENVLITE_SALT_URL, 5); + // Sanity: must contain 8 define() lines and the keys we care about. + if (substr_count($bytes, "define(") < 8 || strpos($bytes, 'NONCE_SALT') === false) { + return null; + } + return rtrim($bytes, "\n"); + } catch (\Throwable $e) { + envlite_log('up', "phase 7: salt fetch failed: " . $e->getMessage() . " (continuing with sample placeholders)"); + return null; + } +} + +function envlite_phase7_install(string $repoRoot, int $port, bool $force): void { + $samplePath = "$repoRoot/wp-config-sample.php"; + $outRel = 'src/wp-config.php'; + $outAbs = "$repoRoot/$outRel"; + + $sample = @file_get_contents($samplePath); + if ($sample === false) { + throw new \RuntimeException("phase 7: cannot read $samplePath"); + } + $salts = envlite_phase7_fetch_salts(); + $rendered = envlite_phase7_render($sample, $port, $salts); + + $manifest = envlite_manifest_load($repoRoot); + $current = null; + if (is_file($outAbs)) { + $current = @file_get_contents($outAbs); + if ($current === false) { + throw new \RuntimeException("phase 7: cannot read $outAbs"); + } + } + $ownership = envlite_ownership($manifest, $outRel, $current); + if ($ownership === 'owned_drifted') { + $cur = $current !== null ? hash('sha256', $current) : null; + envlite_prompt_or_abort($force, 'up', 'overwrite drifted file', $outRel, $manifest[$outRel], $cur); + } elseif ($ownership === 'unowned') { + envlite_prompt_or_abort($force, 'up', 'overwrite unowned file', $outRel, null, null); + } + $hash = envlite_atomic_write($outAbs, $rendered); + $manifest[$outRel] = $hash; + envlite_manifest_save($repoRoot, $manifest); +} + +/** + * Phase 8 — bootstrap WP and run wp_install if not already installed. + * + * Runs in a fresh `php` subprocess. The script is piped via stdin (no + * second committed asset to ship alongside router.php). Subprocess + * isolation keeps wp-settings.php's many side effects (constants, + * autoloaders, shutdown handlers, wp_die) from corrupting envlite's + * own process or its exit semantics. + */ +function envlite_phase8_install_site(string $repoRoot, int $port): void { + // Nowdoc — no $variable expansion in the template; values are + // substituted via strtr() with var_export()'d literals so a path + // with quotes/spaces can't break the script. + $tmpl = <<<'PHP' + var_export($repoRoot, true), + '__PORT__' => (string) $port, + ]); + + $proc = @proc_open( + [PHP_BINARY], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $repoRoot + ); + if (!is_resource($proc)) { + throw new \RuntimeException('failed to spawn php subprocess'); + } + fwrite($pipes[0], $script); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exit = proc_close($proc); + + if ($exit !== 0) { + $msg = trim($stderr !== '' ? $stderr : ($stdout ?: '')); + $first = $msg === '' ? "exit $exit" : strtok($msg, "\n"); + throw new \RuntimeException("install subprocess: $first"); + } +} + +function envlite_main(array $argv): int { + array_shift($argv); // drop script name + $force = false; + $rest = []; + foreach ($argv as $a) { + if ($a === '--force') { $force = true; continue; } + $rest[] = $a; + } + $sub = $rest[0] ?? 'help'; + $args = array_slice($rest, 1); + + if ($sub === 'help' || $sub === '--help' || $sub === '-h') { + fwrite(STDERR, envlite_help_text()); + return 0; + } + if ($sub === 'up') { return envlite_cmd_up($args, $force); } + if ($sub === 'clean') { return envlite_cmd_clean($args, $force); } + + envlite_log(null, "unknown subcommand: $sub"); + return 2; +} + +function envlite_cmd_up(array $args, bool $force): int { + $port = null; + $noBuild = false; + $noServe = false; + $rebuild = false; + foreach ($args as $a) { + if ($a === '--no-build') { $noBuild = true; continue; } + if ($a === '--no-serve') { $noServe = true; continue; } + if ($a === '--rebuild') { $rebuild = true; continue; } + if (preg_match('/^--port=(\d+)$/', $a, $m)) { + $port = (int) $m[1]; + if ($port < 1 || $port > 65535) { + envlite_log('up', "invalid --port value: $a"); + return 2; + } + continue; + } + envlite_log('up', "unknown argument: $a"); + return 2; + } + + $repoRoot = getcwd(); + + envlite_phase0_run($repoRoot); + envlite_observe_ht_sqlite($repoRoot); + + $resolvedPort = envlite_phase1_discover_port($repoRoot, $port); + fwrite(STDERR, "envlite up: port $resolvedPort\n"); + + // Phases 2 & 4 in parallel (composer install || npm ci), with skip+record. + $phase24 = ['phase2_skipped' => false, 'phase4_skipped' => false]; + $rc = envlite_phase_guard('up', 24, function () use ($repoRoot, $rebuild, &$phase24) { + $phase24 = envlite_phase24_parallel($repoRoot, $rebuild); + }); + if ($rc !== 0) { return $rc; } + + // Phase 3 (build:dev), serial after the parallel pair. + $rc = envlite_phase_guard('up', 3, function () use ($repoRoot, $rebuild, $noBuild, $phase24) { + envlite_phase3_build_dev( + $repoRoot, + $rebuild, + $noBuild, + $phase24['phase2_skipped'], + $phase24['phase4_skipped'] + ); + }); + if ($rc !== 0) { return $rc; } + + $phases = [ + [5, function () use ($repoRoot, $force, $rebuild) { envlite_phase5_install($repoRoot, $force, $rebuild); }], + [6, function () use ($repoRoot, $force) { envlite_phase6_install($repoRoot, $force); }], + [7, function () use ($repoRoot, $resolvedPort, $force) { envlite_phase7_install($repoRoot, $resolvedPort, $force); }], + [8, function () use ($repoRoot, $resolvedPort) { envlite_phase8_install_site($repoRoot, $resolvedPort); }], + ]; + foreach ($phases as [$n, $fn]) { + $rc = envlite_phase_guard('up', $n, $fn); + if ($rc !== 0) { return $rc; } + } + + if ($noServe) { + fwrite(STDERR, "envlite up: setup complete (--no-serve; not launching dev server)\n"); + return 0; + } + + if (!envlite_phase1_port_is_free($resolvedPort)) { + envlite_log('up', "failed to bind 127.0.0.1:$resolvedPort"); + return 1; + } + + fwrite(STDERR, "envlite up: serving http://127.0.0.1:$resolvedPort/ (admin / password)\n"); + // Hand off to the dev-server launcher. pcntl on Unix means this function + // never returns on success; the "serving …" line above is the last thing + // envlite itself prints. + return envlite_run_dev_server($repoRoot, $resolvedPort); +} + +function envlite_phase_guard(string $sub, int $n, callable $fn): int { + try { + $fn(); + return 0; + } catch (\Throwable $e) { + $msg = $e->getMessage(); + // If the exception message already starts with "phase N:" or + // "phases N and M:" — i.e. the inner code already named its phase — + // pass it through unchanged. Otherwise prepend "phase $n: ". + if (!preg_match('/^phases? \d/', $msg)) { + $msg = "phase $n: $msg"; + } + envlite_log($sub, $msg); + return 1; + } +} + +function envlite_observe_ht_sqlite(string $repoRoot): void { + $rel = 'src/wp-content/database/.ht.sqlite'; + $abs = "$repoRoot/$rel"; + if (!is_file($abs)) { return; } + $manifest = envlite_manifest_load($repoRoot); + if (isset($manifest[$rel])) { return; } + $bytes = @file_get_contents($abs); + // Read failure: leave the file unrecorded rather than capturing the + // empty-string hash. clean will treat it as user-owned, which is correct. + if ($bytes === false) { return; } + $manifest[$rel] = hash('sha256', $bytes); + envlite_manifest_save($repoRoot, $manifest); +} + +function envlite_cmd_clean(array $args, bool $force): int { + if (!empty($args)) { + envlite_log('clean', 'unexpected arguments: ' . implode(' ', $args)); + return 2; + } + $repoRoot = getcwd(); + if (!is_dir("$repoRoot/.cache/envlite")) { + envlite_log('clean', 'nothing to clean (no .cache/envlite/ directory)'); + return 0; + } + + envlite_observe_ht_sqlite($repoRoot); + $manifest = envlite_manifest_load($repoRoot); + $paths = envlite_clean_collect($manifest); + + if (empty($paths)) { + envlite_log('clean', 'manifest is empty; removing .cache/envlite/ only'); + } else { + // Single batch prompt. + if (!$force) { + if (!stream_isatty(STDIN)) { + envlite_log(null, 'non-interactive context and --force not given; aborting at clean'); + return 5; + } + fwrite(STDERR, "envlite clean: will remove " . count($paths) . " path(s):\n"); + foreach ($paths as $p) { fwrite(STDERR, " $p\n"); } + fwrite(STDERR, "envlite clean: continue? [y/N] "); + $line = fgets(STDIN); + $resp = $line === false ? '' : strtolower(trim($line)); + if ($resp !== 'y' && $resp !== 'yes') { + envlite_log('clean', 'aborted by user'); + return 5; + } + } + envlite_clean_apply($repoRoot, $paths); + } + + // Remove .cache/envlite/ itself. + @unlink("$repoRoot/.cache/envlite/manifest"); + @unlink("$repoRoot/.cache/envlite/port"); + @unlink("$repoRoot/.cache/envlite/state"); + @rmdir("$repoRoot/.cache/envlite"); + return 0; +} + +/** Pure: returns paths in reverse insertion order. */ +function envlite_clean_collect(array $manifest): array { + return array_reverse(array_keys($manifest)); +} + +/** I/O: deletes each path. Must be called after the prompt has been resolved. */ +function envlite_clean_apply(string $repoRoot, array $paths): void { + foreach ($paths as $rel) { + $abs = "$repoRoot/$rel"; + if (!file_exists($abs) && !is_dir($abs)) { continue; } + if (is_dir($abs) && !is_link($abs)) { + envlite_rrmdir($abs); + } else { + @unlink($abs); + } + } +} + +function envlite_rrmdir(string $dir): void { + $items = scandir($dir); + if ($items === false) { return; } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { continue; } + $path = "$dir/$item"; + if (is_dir($path) && !is_link($path)) { + envlite_rrmdir($path); + } else { + @unlink($path); + } + } + @rmdir($dir); +} + +if (!defined('ENVLITE_NO_AUTORUN') && isset($_SERVER['SCRIPT_FILENAME']) + && realpath($_SERVER['SCRIPT_FILENAME']) === realpath(__FILE__)) { + exit(envlite_main($_SERVER['argv'])); +} diff --git a/tools/local-env/router.php b/tools/local-env/router.php new file mode 100644 index 0000000000000..a324a37ab0974 --- /dev/null +++ b/tools/local-env/router.php @@ -0,0 +1,29 @@ + wp-admin/index.php). Without an index, fall + // through to the front controller to avoid directory listings. + if (file_exists(rtrim($file, '/') . '/index.php')) { + return false; + } +} + +require $docroot . '/index.php'; diff --git a/tools/local-env/tests/harness.php b/tools/local-env/tests/harness.php new file mode 100644 index 0000000000000..e9c8bab75eaf7 --- /dev/null +++ b/tools/local-env/tests/harness.php @@ -0,0 +1,41 @@ + substr($fn, 0, 5) === 'test_' + )); + sort($tests); + $failures = 0; + foreach ($tests as $fn) { + try { + $fn(); + fwrite(STDERR, "PASS $fn\n"); + } catch (\Throwable $e) { + $failures++; + fwrite(STDERR, "FAIL $fn: " . $e->getMessage() . "\n"); + } + } + fwrite(STDERR, count($tests) . " tests, $failures failures\n"); + return $failures === 0 ? 0 : 1; +} diff --git a/tools/local-env/tests/run.php b/tools/local-env/tests/run.php new file mode 100644 index 0000000000000..0ce17df7c53b2 --- /dev/null +++ b/tools/local-env/tests/run.php @@ -0,0 +1,5 @@ + str_repeat('a', 64), + 'src/wp-config.php' => str_repeat('b', 64), + 'wp-tests-config.php' => str_repeat('c', 64), + ]; + $order = envlite_clean_collect($manifest); + envlite_assert_eq(['wp-tests-config.php', 'src/wp-config.php', '.cache/envlite/port'], $order); +} + +function test_clean_removes_files_dirs_and_state() { + $dir = envlite_test_tmpdir('clean'); + mkdir("$dir/.cache/envlite", 0755, true); + mkdir("$dir/sub", 0755, true); + file_put_contents("$dir/wp-tests-config.php", 'x'); + file_put_contents("$dir/sub/db.php", 'y'); + $manifest = [ + '.cache/envlite/port' => hash('sha256', 'p'), + 'wp-tests-config.php' => hash('sha256', 'x'), + 'sub' => 'dir', + ]; + envlite_manifest_save($dir, $manifest); + file_put_contents("$dir/.cache/envlite/port", 'p'); + + envlite_clean_apply($dir, envlite_clean_collect($manifest)); + // Simulate the subcommand-level cleanup that follows envlite_clean_apply. + @unlink("$dir/.cache/envlite/manifest"); + @rmdir("$dir/.cache/envlite"); + + envlite_assert(!file_exists("$dir/wp-tests-config.php")); + envlite_assert(!is_dir("$dir/sub")); + envlite_assert(!is_dir("$dir/.cache/envlite")); +} diff --git a/tools/local-env/tests/test_dev_server.php b/tools/local-env/tests/test_dev_server.php new file mode 100644 index 0000000000000..e43ed2ea7eaab --- /dev/null +++ b/tools/local-env/tests/test_dev_server.php @@ -0,0 +1,56 @@ + 'a3f1c8b2' . str_repeat('0', 56), + 'src/wp-config.php' => str_repeat('b', 64), + 'src/wp-content/plugins/sqlite-database-integration' => 'dir', + ]; + envlite_manifest_save($dir, $entries); + envlite_assert_eq($entries, envlite_manifest_load($dir)); + // Order must round-trip. + envlite_assert_eq(array_keys($entries), array_keys(envlite_manifest_load($dir))); +} + +function test_manifest_save_emits_lf_only() { + $dir = envlite_test_tmpdir('manifest-lf'); + mkdir($dir . '/.cache/envlite', 0755, true); + envlite_manifest_save($dir, ['src/wp-config.php' => str_repeat('a', 64)]); + $bytes = file_get_contents($dir . '/.cache/envlite/manifest'); + envlite_assert(strpos($bytes, "\r") === false, 'manifest must not contain CR'); + envlite_assert(substr($bytes, -1) === "\n", 'manifest must end with LF'); +} + +function test_manifest_load_skips_blank_and_malformed_lines() { + $dir = envlite_test_tmpdir('manifest-malformed'); + mkdir($dir . '/.cache/envlite', 0755, true); + file_put_contents( + $dir . '/.cache/envlite/manifest', + str_repeat('a', 64) . " src/wp-config.php\n" . + "\n" . + "garbage line\n" . + "dir some/dir\n" + ); + $loaded = envlite_manifest_load($dir); + envlite_assert_eq(['src/wp-config.php' => str_repeat('a', 64), 'some/dir' => 'dir'], $loaded); +} diff --git a/tools/local-env/tests/test_ownership.php b/tools/local-env/tests/test_ownership.php new file mode 100644 index 0000000000000..6896815e391d7 --- /dev/null +++ b/tools/local-env/tests/test_ownership.php @@ -0,0 +1,47 @@ + $hash], 'src/wp-config.php', $bytes) + ); +} + +function test_ownership_owned_drifted() { + envlite_assert_eq( + 'owned_drifted', + envlite_ownership( + ['src/wp-config.php' => str_repeat('a', 64)], + 'src/wp-config.php', + 'different bytes' + ) + ); +} + +function test_ownership_unowned() { + envlite_assert_eq( + 'unowned', + envlite_ownership([], 'src/wp-config.php', "user-authored\n") + ); +} + +function test_ownership_dir_entry_in_manifest() { + // For directory entries, the "current bytes" is null; presence on disk + // makes it owned_clean (we don't drift-check directory contents). + envlite_assert_eq( + 'owned_clean', + envlite_ownership( + ['src/wp-content/plugins/sqlite-database-integration' => 'dir'], + 'src/wp-content/plugins/sqlite-database-integration', + null + ) + ); +} diff --git a/tools/local-env/tests/test_paths.php b/tools/local-env/tests/test_paths.php new file mode 100644 index 0000000000000..ffcbe85b04fff --- /dev/null +++ b/tools/local-env/tests/test_paths.php @@ -0,0 +1,23 @@ +getMessage(), 'outside repo root') !== false); + } +} diff --git a/tools/local-env/tests/test_phase0.php b/tools/local-env/tests/test_phase0.php new file mode 100644 index 0000000000000..fb7f913c05332 --- /dev/null +++ b/tools/local-env/tests/test_phase0.php @@ -0,0 +1,58 @@ + local-env/ -> tools/ -> repo + envlite_assert(envlite_phase0_is_wordpress_develop($root), "expected $root to be a WP-develop checkout"); +} + +function test_phase0_cwd_check_fails_for_random_dir() { + $dir = envlite_test_tmpdir('phase0-bogus'); + envlite_assert(!envlite_phase0_is_wordpress_develop($dir)); +} + +function test_phase0_parse_version_node() { + envlite_assert_eq([20, 10, 0], envlite_phase0_parse_version('v20.10.0')); + envlite_assert_eq([22, 5, 1], envlite_phase0_parse_version('v22.5.1\n')); +} + +function test_phase0_parse_version_npm() { + envlite_assert_eq([10, 2, 4], envlite_phase0_parse_version('10.2.4')); +} + +function test_phase0_parse_version_composer() { + envlite_assert_eq([2, 7, 1], envlite_phase0_parse_version('Composer version 2.7.1 2024-02-09 15:26:28')); +} + +function test_phase0_version_meets_minimum() { + envlite_assert(envlite_phase0_version_ge([20, 10, 0], [20, 10, 0])); + envlite_assert(envlite_phase0_version_ge([20, 10, 1], [20, 10, 0])); + envlite_assert(envlite_phase0_version_ge([21, 0, 0], [20, 10, 0])); + envlite_assert(!envlite_phase0_version_ge([20, 9, 0], [20, 10, 0])); + envlite_assert(!envlite_phase0_version_ge([19, 99, 99], [20, 10, 0])); +} + +function test_phase0_required_extensions_include_pcntl_on_unix() { + // The list is the source of truth used by envlite_phase0_run. + // We test the *list*, not by re-running phase0 (which exits the test runner). + if (PHP_OS_FAMILY === 'Windows') { + // On Windows, pcntl is not in the list. Sanity-check the inverse. + envlite_assert( + !in_array('pcntl', envlite_phase0_required_extensions(), true), + 'pcntl must NOT be required on Windows' + ); + return; + } + envlite_assert( + in_array('pcntl', envlite_phase0_required_extensions(), true), + 'pcntl must be required on Unix' + ); +} + +function test_phase0_required_extensions_includes_existing_set() { + foreach (['pdo_sqlite', 'sqlite3', 'openssl', 'simplexml', 'zip'] as $ext) { + envlite_assert( + in_array($ext, envlite_phase0_required_extensions(), true), + "$ext must remain required" + ); + } +} diff --git a/tools/local-env/tests/test_phase1.php b/tools/local-env/tests/test_phase1.php new file mode 100644 index 0000000000000..b8ac304727f92 --- /dev/null +++ b/tools/local-env/tests/test_phase1.php @@ -0,0 +1,53 @@ += 8100 && $port <= 8899, "port $port out of pool"); +} + +function test_phase1_port_seed_deterministic() { + envlite_assert_eq( + envlite_phase1_seed_port('/Users/jonsurrell/foo'), + envlite_phase1_seed_port('/Users/jonsurrell/foo') + ); +} + +function test_phase1_port_seed_differs_for_different_paths() { + // Not a strong claim, but two paths should at least sometimes differ. + $a = envlite_phase1_seed_port('/a'); + $b = envlite_phase1_seed_port('/b'); + $c = envlite_phase1_seed_port('/abcdef'); + envlite_assert(count(array_unique([$a, $b, $c])) >= 2, 'expected some variation'); +} + +function test_phase1_port_is_free_on_random_high_port() { + // Pick a port we expect free; in a CI sandbox this is best-effort but + // 53219 is unlikely to be bound. If it is, the test reports it. + $p = 53219; + envlite_assert(envlite_phase1_port_is_free($p), "port $p unexpectedly in use"); +} + +function test_phase1_port_is_free_returns_false_when_bound() { + $sock = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + envlite_assert(is_resource($sock), "could not bind probe socket: $errstr"); + $name = stream_socket_get_name($sock, false); // "127.0.0.1:NNNN" + [, $port] = explode(':', $name); + envlite_assert(!envlite_phase1_port_is_free((int)$port), "expected $port reported in-use"); + fclose($sock); +} + +function test_phase1_uses_cached_port_when_in_range() { + $dir = envlite_test_tmpdir('phase1-cache'); + mkdir($dir . '/.cache/envlite', 0755, true); + file_put_contents($dir . '/.cache/envlite/port', "8421\n"); + envlite_assert_eq(8421, envlite_phase1_discover_port($dir, null)); +} + +function test_phase1_ignores_cache_when_out_of_range() { + $dir = envlite_test_tmpdir('phase1-bad-cache'); + mkdir($dir . '/.cache/envlite', 0755, true); + // 70000 is outside the 1..65535 cached-port acceptance window, so the + // cache must be ignored and a fresh port picked from the auto pool. + file_put_contents($dir . '/.cache/envlite/port', "70000\n"); + $port = envlite_phase1_discover_port($dir, null); + envlite_assert($port >= 8100 && $port <= 8899); +} diff --git a/tools/local-env/tests/test_phase5.php b/tools/local-env/tests/test_phase5.php new file mode 100644 index 0000000000000..3f4f4239954bd --- /dev/null +++ b/tools/local-env/tests/test_phase5.php @@ -0,0 +1,39 @@ +getMessage(), 'SHA256 mismatch') !== false); + } +} + +function test_phase5_tripwire_passes_when_placeholder_present() { + $dir = envlite_test_tmpdir('tripwire-ok'); + file_put_contents($dir . '/db.copy', 'getMessage(), 'placeholder') !== false); + } +} diff --git a/tools/local-env/tests/test_phase6.php b/tools/local-env/tests/test_phase6.php new file mode 100644 index 0000000000000..3f88d4a4de9f7 --- /dev/null +++ b/tools/local-env/tests/test_phase6.php @@ -0,0 +1,54 @@ +getMessage(), 'placeholder') !== false); + } +} + +function test_phase6_render_throws_when_db_file_already_defined() { + $sample = "define( 'DB_NAME', 'youremptytestdbnamehere' );\n" + . "define( 'DB_USER', 'yourusernamehere' );\n" + . "define( 'DB_PASSWORD', 'yourpasswordhere' );\n" + . "define( 'DB_FILE', 'something.sqlite' );\n"; + try { + envlite_phase6_render($sample); + throw new \RuntimeException('expected exception'); + } catch (\RuntimeException $e) { + envlite_assert(strpos($e->getMessage(), 'DB_FILE') !== false); + } +} + +function test_phase6_render_appends_db_file_define() { + $sample = "define( 'DB_NAME', 'youremptytestdbnamehere' );\n" + . "define( 'DB_USER', 'yourusernamehere' );\n" + . "define( 'DB_PASSWORD', 'yourpasswordhere' );\n"; + $out = envlite_phase6_render($sample); + envlite_assert(preg_match("/define\\(\\s*'DB_FILE'\\s*,\\s*'\\.ht\\.test\\.sqlite'\\s*\\)\\s*;/", $out) === 1); + // Output must end with a single trailing newline. + envlite_assert(substr($out, -1) === "\n"); + envlite_assert(substr($out, -2) !== "\n\n"); +} + +function test_phase6_render_appends_db_file_when_sample_has_no_trailing_newline() { + $sample = "define( 'DB_NAME', 'youremptytestdbnamehere' );\n" + . "define( 'DB_USER', 'yourusernamehere' );\n" + . "define( 'DB_PASSWORD', 'yourpasswordhere' );"; // no \n + $out = envlite_phase6_render($sample); + envlite_assert(preg_match("/define\\(\\s*'DB_FILE'\\s*,\\s*'\\.ht\\.test\\.sqlite'\\s*\\)\\s*;/", $out) === 1); + envlite_assert(substr($out, -1) === "\n"); +} diff --git a/tools/local-env/tests/test_phase7.php b/tools/local-env/tests/test_phase7.php new file mode 100644 index 0000000000000..2ea5ff0af95ab --- /dev/null +++ b/tools/local-env/tests/test_phase7.php @@ -0,0 +1,74 @@ + immediate EOF + $err = fopen('php://memory', 'w'); + envlite_assert_eq(false, envlite_prompt_io( + false, true, 'init', 'overwrite', 'x', null, null, $in, $err + )); +} diff --git a/tools/local-env/tests/test_router.php b/tools/local-env/tests/test_router.php new file mode 100644 index 0000000000000..6ae155e8672ac --- /dev/null +++ b/tools/local-env/tests/test_router.php @@ -0,0 +1,75 @@ + /private/tmp on macOS so the assert + // below matches __DIR__ from the fixture's index.php (which resolves + // symlinks). On Linux this is a no-op. + $site = realpath(envlite_test_tmpdir('router-docroot')); + envlite_assert($site !== false, 'tmp fixture directory must resolve via realpath'); + file_put_contents("$site/index.php", " -t ` with cwd = site. + // Matches envlite_run_dev_server: chdir into the target repo, then pass + // -t . The router file lives outside $site on purpose — that is + // exactly the configuration that triggered the original bug. + $argv = [PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $site, $router]; + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open($argv, $descriptors, $pipes, $site); + envlite_assert(is_resource($proc), 'failed to start php -S'); + + try { + envlite_assert( + envlite_test_router_wait_for_bind($port), + "php -S did not bind on 127.0.0.1:$port within 3s" + ); + + $body = @file_get_contents("http://127.0.0.1:$port/"); + envlite_assert($body !== false, "request to 127.0.0.1:$port failed"); + + envlite_assert( + strpos($body, 'FIXTURE_OK ' . $site) !== false, + 'expected FIXTURE_OK marker from fixture index.php, got: ' . substr($body, 0, 400) + ); + } finally { + foreach ($pipes as $p) { if (is_resource($p)) { @fclose($p); } } + $status = @proc_get_status($proc); + if ($status && $status['running']) { + @proc_terminate($proc, 15); + } + @proc_close($proc); + } +} diff --git a/tools/local-env/tests/test_skip.php b/tools/local-env/tests/test_skip.php new file mode 100644 index 0000000000000..7c5a5d28d0daa --- /dev/null +++ b/tools/local-env/tests/test_skip.php @@ -0,0 +1,78 @@ + $current]); + + // Recompute the predicate that envlite_phase24_parallel uses inline. + $state = envlite_state_load($dir); + $shouldSkip = is_dir("$dir/node_modules") + && ($state['phase2.input_hash'] ?? null) === $current; + envlite_assert($shouldSkip, 'phase 2 should skip when hash matches and dir exists'); +} + +function test_skip_rule_phase2_drift_means_run() { + // Recorded hash does not match current → must NOT skip. + $dir = envlite_test_skip_make_repo('phase2-drift', 'newcontents'); + mkdir("$dir/node_modules", 0755, true); + envlite_state_save($dir, ['phase2.input_hash' => str_repeat('0', 64)]); + + $current = envlite_phase2_input_hash($dir); + $state = envlite_state_load($dir); + $shouldSkip = is_dir("$dir/node_modules") + && ($state['phase2.input_hash'] ?? null) === $current; + envlite_assert(!$shouldSkip, 'phase 2 must run when recorded hash drifts'); +} + +function test_skip_rule_phase2_missing_node_modules_means_run() { + // Recorded hash matches but node_modules/ missing → must run. + $dir = envlite_test_skip_make_repo('phase2-nomodules', 'lockcontents'); + $current = envlite_phase2_input_hash($dir); + envlite_state_save($dir, ['phase2.input_hash' => $current]); + + $state = envlite_state_load($dir); + $shouldSkip = is_dir("$dir/node_modules") + && ($state['phase2.input_hash'] ?? null) === $current; + envlite_assert(!$shouldSkip, 'phase 2 must run when node_modules/ missing'); +} diff --git a/tools/local-env/tests/test_smoke.php b/tools/local-env/tests/test_smoke.php new file mode 100644 index 0000000000000..5feac615d50f3 --- /dev/null +++ b/tools/local-env/tests/test_smoke.php @@ -0,0 +1,62 @@ + 'dir']; + envlite_manifest_save($dir, $manifest); + + // Drive Phases 5–7 with --force (no TTY in test). + envlite_phase5_install($dir, true); + envlite_phase6_install($dir, true); + envlite_phase7_install($dir, 8421, true); + + // Assert artifacts present. + envlite_assert(is_file("$dir/src/wp-content/db.php")); + envlite_assert(is_file("$dir/wp-tests-config.php")); + envlite_assert(is_file("$dir/src/wp-config.php")); + + // Manifest contains all three file entries plus the plugin dir. + $m = envlite_manifest_load($dir); + envlite_assert(isset($m['src/wp-content/db.php'])); + envlite_assert(isset($m['wp-tests-config.php'])); + envlite_assert(isset($m['src/wp-config.php'])); + envlite_assert(isset($m['src/wp-content/plugins/sqlite-database-integration'])); + + // wp-config.php picked up the port. + envlite_assert(strpos(file_get_contents("$dir/src/wp-config.php"), 'http://127.0.0.1:8421') !== false); + + // Now drive clean (force, no TTY). + $paths = envlite_clean_collect($m); + envlite_clean_apply($dir, $paths); + @unlink("$dir/.cache/envlite/manifest"); + @unlink("$dir/.cache/envlite/state"); + @rmdir("$dir/.cache/envlite"); + + envlite_assert(!is_file("$dir/wp-tests-config.php")); + envlite_assert(!is_file("$dir/src/wp-config.php")); + envlite_assert(!is_file("$dir/src/wp-content/db.php")); + envlite_assert(!is_dir("$dir/src/wp-content/plugins/sqlite-database-integration")); + envlite_assert(!is_dir("$dir/.cache/envlite")); +} diff --git a/tools/local-env/tests/test_state.php b/tools/local-env/tests/test_state.php new file mode 100644 index 0000000000000..80af11db006e2 --- /dev/null +++ b/tools/local-env/tests/test_state.php @@ -0,0 +1,50 @@ + str_repeat('a', 64), + 'phase4.input_hash' => str_repeat('b', 64), + 'phase5.recorded_pin_sha' => str_repeat('c', 64), + ]; + envlite_state_save($dir, $entries); + envlite_assert(is_file("$dir/.cache/envlite/state")); + + $loaded = envlite_state_load($dir); + envlite_assert_eq($entries, $loaded); +} + +function test_state_save_writes_tab_delimited_lines() { + $dir = envlite_test_tmpdir('state-format'); + envlite_state_save($dir, ['phase2.input_hash' => str_repeat('a', 64)]); + $bytes = file_get_contents("$dir/.cache/envlite/state"); + // One line, tab between key and value, trailing newline. + envlite_assert_eq("phase2.input_hash\t" . str_repeat('a', 64) . "\n", $bytes); +} + +function test_state_load_ignores_malformed_lines() { + $dir = envlite_test_tmpdir('state-malformed'); + mkdir("$dir/.cache/envlite", 0755, true); + file_put_contents( + "$dir/.cache/envlite/state", + "good\tvalue\nbadline-no-tab\n\nphase2.input_hash\thashvalue\n" + ); + envlite_assert_eq( + ['good' => 'value', 'phase2.input_hash' => 'hashvalue'], + envlite_state_load($dir) + ); +} + +function test_state_save_overwrites_atomically() { + // Overwriting an existing state file must leave a valid file (no .tmp + // residue, single coherent body). + $dir = envlite_test_tmpdir('state-atomic'); + envlite_state_save($dir, ['k' => 'v1']); + envlite_state_save($dir, ['k' => 'v2', 'k2' => 'v3']); + envlite_assert_eq(['k' => 'v2', 'k2' => 'v3'], envlite_state_load($dir)); + envlite_assert(!file_exists("$dir/.cache/envlite/state.tmp"), 'no .tmp residue'); +} diff --git a/tools/local-env/tests/test_up.php b/tools/local-env/tests/test_up.php new file mode 100644 index 0000000000000..59bd51d74b455 --- /dev/null +++ b/tools/local-env/tests/test_up.php @@ -0,0 +1,52 @@ + 0) { + pcntl_waitpid($pid, $status); + $observed = pcntl_wexitstatus($status); + } + } finally { + chdir($prevCwd); + @rmdir($tmp); + } + if ($pid > 0) { + envlite_assert_eq(3, $observed, '--no-serve and --rebuild must parse cleanly; only Phase 0 should fail'); + } +}