diff --git a/.gitignore b/.gitignore index 15876fa47fee8..622fc3dbbed88 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ wp-tests-config.php /jsdoc /composer.lock /vendor +/.envlite/ /src/wp-admin/css/*.min.css /src/wp-admin/css/*-rtl.css /src/wp-admin/css/colors/*/*.css 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/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..a7868a1769cc7 --- /dev/null +++ b/plans/ENVLITE_SPECIFICATION.md @@ -0,0 +1,1135 @@ +# 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 serve` / `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 +(`envlite serve` / `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 serve` 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. | +| `init` | Run all setup phases. Leaves the repo ready to `serve` and to run tests. | +| `up` | Run all setup phases, then start the dev server in the foreground. Equivalent to `init` followed by `serve`. | +| `serve` | Exec the dev server on the discovered/cached port. Foreground; respond to Ctrl-C. | +| `clean` | Remove envlite-managed files (manifest entries). Does not touch `node_modules/`, `vendor/`, or build artifacts under `src/`. | + +`port` is intentionally not a subcommand; the cached port lives at +`.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. + +### Subcommand flags + +- `init [--port=N] [--no-build]` + - `--port=N` skips Phase 1 discovery and uses the given port. Updates + `.envlite/port` to N. + - `--no-build` skips Phase 3. Useful when iterating on PHP-only changes. +- `up [--port=N] [--no-build]` + - Same flag semantics as `init`. After all phases succeed, `up` + re-probes the resolved port and runs `php -S` in the foreground — + the same invocation `serve` uses. On Unix, the launch uses + `pcntl_exec(PHP_BINARY, …)` so the envlite process is replaced in + place by `php -S`; on Windows, `proc_open` is used because `pcntl` + is unavailable. See "`envlite serve` runtime" below for details. +- `serve` (no flags; the cached port is the source of truth) +- `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 `init`, two quick checks confirm the env is wired +up: + +```sh +./vendor/bin/phpunit +envlite serve & curl -sI http://127.0.0.1:$(cat .envlite/port)/ +``` + +Phpunit booting against the SQLite drop-in + a 2xx HTTP status (not a +3xx redirect to `/wp-admin/install.php`) proves the same thing the old +`verify` did, with less ceremony. 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 init: phase 5: SHA256 mismatch on plugin zip`, + `envlite serve: failed to bind 127.0.0.1:8421`). + +Phase failures inside `init` use `envlite init: 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. + +### `envlite serve` runtime + +`serve` reads the port from `.envlite/port` and launches +`php -S 127.0.0.1: -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`. + +**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. + +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.** envlite's pre-flight `port_is_free` probe (in both +`serve` and `up`) detects an already-bound port and exits 1 with a +single stderr line: `envlite serve: 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 `.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 serve` and + `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 init` after `envlite clean` returns the same + port whenever possible. + +**Cache location:** `.envlite/port`. See "envlite state directory" +above for the broader contract. + +**Algorithm (pseudocode):** + +``` +function discover_port(repoRoot): + cacheFile = repoRoot + "/.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 + "/.envlite") + write(cacheFile, str(candidate)) + record_in_manifest(".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 user + starting `envlite serve`, but on a developer laptop this race is + negligible. `serve` will surface the bind failure if it happens. +- `init --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. The user is + then expected to pass a different `--port` if they really want one. +- There is no `serve --port=N`; the cache is the source of truth. To + pick a different port, either run `init --port=N` or delete + `.envlite/port` and re-run. + +**Outputs:** `.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 and stream its output to +the user's terminal. Exit non-zero if `npm` exits non-zero. + +**Inputs:** `package-lock.json` (committed to wordpress-develop). +**Outputs:** `node_modules/` populated. + +**Idempotency:** safe to re-run; `npm ci` itself is idempotent. envlite +does not gate this phase on `node_modules/` existing — let `npm ci` +decide whether work is needed. + +**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. + +**Inputs:** populated `node_modules/`, the sources under `src/`. +**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". + +**Idempotency:** `build:dev` is incremental; safe to re-run. The +`init --no-build` flag exists for users who know their changes do not +affect build outputs. + +--- + +## 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`. + +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. + +**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, skip steps 2–4 and proceed to step 5. The pinned + plugin tree from a prior `init` is reusable as-is; there is no value + in re-downloading it. + + Otherwise (no manifest entry, or `db.copy` missing) proceed to + step 2. +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. +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 `init` runs. + +Both are removed by `clean`. + +**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 local presence of +`src/wp-content/plugins/sqlite-database-integration/db.copy` (step 1). +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 `init`. + +--- + +## 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 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`. 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 init` 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_init_phase_guard()` converts that into +`envlite init: phase 8: install subprocess: ` + exit 1. + +**Inputs:** `src/wp-config.php` (Phase 7), `.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 `init` 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 `init` 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 init` 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 | `init --no-build` on a fresh checkout | re-run `init` without `--no-build` | +| phase 8 fails with a DB error | corrupt `.ht.sqlite` from a prior interrupted run | delete `src/wp-content/database/.ht.sqlite`, re-run `init` | +| 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 (`.envlite/`) + +`.envlite/` at the repo root holds envlite's private state. +wordpress-develop's `.gitignore` lists `/.envlite/`, so envlite's state +files are ignored out of the box. envlite itself does **not** modify +`.gitignore`, `.git/info/exclude`, or any other git configuration at +runtime — the entry is committed to the repo, not written by the tool. + +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. | + +**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 `init` or `clean`. Users who need to "forget" an envlite-owned +path should run `envlite clean` and re-`init`. (`clean` doesn't touch +`node_modules/`, `vendor/`, or build artifacts, so the slow-to-rebuild +parts survive a clean+init 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 `.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 init`, the repo has: + +**envlite-managed (in manifest, removed by `clean`):** + +``` +.envlite/port (Phase 1) +.envlite/manifest (all phases) +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) +``` + +**Side effects of `init` (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 `init` returns. The file may hold user-authored +content (posts, settings, uploads). + +**Observation point:** at the start of every `init` 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 `init` 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 `serve` +(without an intervening `init`) 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 `.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 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`). + +Phases 1, 2, 4, 5, 6 are mutually independent and could be run in +parallel. envlite v1 runs them serially: the wall-time savings (~5 s) +are not worth the output-interleaving and error-handling complexity in +the initial implementation. A future revision may parallelize. Phase +8 must always run last in `init` — it has the most predecessors. + +`up` runs the same Phase 0–8 sequence as `init`, then performs the same +bind-probe + foreground `php -S` invocation as `serve`. It introduces no +new phases. + +--- + +## Idempotency rules (summary) + +All file-producing phases consult the manifest. The contract is uniform: + +- **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. + +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) | Always spawns `npm ci`; npm decides whether work is needed. | +| 3 (build:dev) | Always spawns `build:dev` unless `--no-build`. | +| 4 (composer install) | Always spawns `composer install`; the operation is idempotent. | +| 5 (SQLite drop-in) | Skips download if the local plugin's `db.copy` is present; copies `db.copy` → `db.php` either way. | +| 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 init` 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. Users who want a fully clean slate run +`envlite clean` first. + +--- + +## 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 init --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 `init`. 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 `.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 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 serve` runs + 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 `init`, 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. +- 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`, then + run `envlite clean && envlite init`. 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..495a4f467fe94 --- /dev/null +++ b/tools/local-env/README.md @@ -0,0 +1,76 @@ +# 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 `.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 init # setup only, no server +php tools/local-env/envlite.php serve # server only (after init) +php tools/local-env/envlite.php clean # remove envlite-created files +``` + +`init` and `up` accept: +- `--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`. +- `--force` — skip prompts (envlite prompts before overwriting files + you've modified). Required for non-interactive contexts. + +`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, 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 `init --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..deb2fbb78f5a1 --- /dev/null +++ b/tools/local-env/envlite.php @@ -0,0 +1,1112 @@ + [args] + + Subcommands: + init [--port=N] [--no-build] Run all setup phases. + up [--port=N] [--no-build] Run init phases, then start the dev server. + serve Run the dev server on the cached port. + clean Remove envlite-managed files. + help Print this help. + + Global flags: + --force Disable interactive prompts. + + 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), '/') . '/.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); } + envlite_atomic_write($manifestPath, $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 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); +} + +/** + * 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 + * init 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, but `serve` skips Phase 0 — so a + // checkout cached from a different system could land here. The spec + // says Unix uses pcntl_exec; do not silently degrade to proc_open. + 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), '/') . '/.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 >= 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('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); +} + +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); + } +} + +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): 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"); + } + // 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); + } + + // 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, '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); +} + +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, 'init', 'overwrite drifted file', $outRel, $manifest[$outRel], $cur); + } 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); +} + +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('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); + 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, 'init', 'overwrite drifted file', $outRel, $manifest[$outRel], $cur); + } 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); +} + +/** + * 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 === 'init') { return envlite_cmd_init($args, $force); } + if ($sub === 'up') { return envlite_cmd_up($args, $force); } + if ($sub === 'serve') { return envlite_cmd_serve($args, $force); } + if ($sub === 'clean') { return envlite_cmd_clean($args, $force); } + + envlite_log(null, "unknown subcommand: $sub"); + return 2; +} + +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); + + // Phases 5-7 throw RuntimeException for diagnostic failures (e.g., + // SHA256 mismatch, missing placeholders, I/O errors). Convert each into + // the spec's `envlite init: phase N: ` line + exit 1. + $phases = [ + [5, function () use ($repoRoot, $force) { envlite_phase5_install($repoRoot, $force); }], + [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('init', $n, $fn); + if ($rc !== 0) { return $rc; } + } + + fwrite(STDERR, "envlite init: ok — http://127.0.0.1:$resolvedPort/ (admin / password)\n"); + return 0; +} + +function envlite_cmd_up(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('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"); + + envlite_phase2_npm_ci($repoRoot); + if (!$noBuild) { + envlite_phase3_build_dev($repoRoot); + } + envlite_phase4_composer_install($repoRoot); + + $phases = [ + [5, function () use ($repoRoot, $force) { envlite_phase5_install($repoRoot, $force); }], + [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 (!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(); + $prefix = "phase $n: "; + if (strpos($msg, $prefix) !== 0) { + $msg = $prefix . $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_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; + } + + // Hand off to the dev-server launcher. On Unix this calls pcntl_exec and + // never returns; on Windows it streams through proc_open and returns the + // exit code. + return envlite_run_dev_server($repoRoot, $port); +} + +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); +} + +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..0651e88e1be8c --- /dev/null +++ b/tools/local-env/router.php @@ -0,0 +1,25 @@ + 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 dirname(__DIR__, 2) . '/src/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', '.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)); + // Simulate the subcommand-level cleanup that follows envlite_clean_apply. + @unlink("$dir/.envlite/manifest"); + @rmdir("$dir/.envlite"); + + envlite_assert(!file_exists("$dir/wp-tests-config.php")); + envlite_assert(!is_dir("$dir/sub")); + envlite_assert(!is_dir("$dir/.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 . '/.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(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 . '/.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); +} 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..b88ff5aa1400e --- /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 . '/.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'); + // 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 . '/.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_smoke.php b/tools/local-env/tests/test_smoke.php new file mode 100644 index 0000000000000..9997ba4675753 --- /dev/null +++ b/tools/local-env/tests/test_smoke.php @@ -0,0 +1,61 @@ + '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/.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")); +} diff --git a/tools/local-env/tests/test_up.php b/tools/local-env/tests/test_up.php new file mode 100644 index 0000000000000..ea4a1a1ced53b --- /dev/null +++ b/tools/local-env/tests/test_up.php @@ -0,0 +1,14 @@ +