|
| 1 | +# Fix envlite router path resolution Implementation Plan |
| 2 | + |
| 3 | +> **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. |
| 4 | +
|
| 5 | +**Goal:** Make `tools/local-env/router.php` serve the repo where `php -S` was launched, not the repo that owns the router file. |
| 6 | + |
| 7 | +**Architecture:** The PHP built-in server populates `$_SERVER['DOCUMENT_ROOT']` from its `-t` flag (resolved to an absolute path). `envlite_run_dev_server` already chdirs to the target repo and passes `-t src`, so `DOCUMENT_ROOT` always equals `<target-repo>/src`. Replace the router's two `dirname(__DIR__, 2) . '/src'` expressions with `$_SERVER['DOCUMENT_ROOT']` so the router stops assuming it lives inside the target repo. |
| 8 | + |
| 9 | +**Tech Stack:** PHP 7.4+, PHP built-in server (`php -S`), envlite test harness (custom — `tools/local-env/tests/harness.php` + `run.php`). |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## Bug recap (why this exists) |
| 14 | + |
| 15 | +`router.php:11` and `router.php:25` use `dirname(__DIR__, 2) . '/src'` to locate the document root and front controller. `__DIR__` is the directory of the *router file itself*, so when envlite is invoked from a checkout other than the one that owns `router.php` (e.g. running `php /path/to/envlite-checkout/tools/local-env/envlite.php up` from a different worktree), the router loads the originating checkout's `src/index.php` — pulling in the *wrong* `wp-config.php`, which then triggers a WordPress canonical-URL 301 to whatever port that wp-config defines. |
| 16 | + |
| 17 | +Reproduction observed: server bound at `127.0.0.1:8722` (target repo's port), but `GET /` returned `301 Location: http://127.0.0.1:8762/` (originating envlite checkout's wp-config port). |
| 18 | + |
| 19 | +## File Structure |
| 20 | + |
| 21 | +- **Modify:** `tools/local-env/router.php` — replace both `dirname(__DIR__, 2) . '/src'` expressions with `$_SERVER['DOCUMENT_ROOT']`. No structural change; same 25 lines. |
| 22 | +- **Create:** `tools/local-env/tests/test_router.php` — integration test that boots `php -S` against a fixture site whose path is unrelated to the router file's location, then asserts the request was served from the fixture (not from envlite's own tree). |
| 23 | + |
| 24 | +The router file is intentionally small and a single responsibility; no extraction is needed. |
| 25 | + |
| 26 | +--- |
| 27 | + |
| 28 | +### Task 1: Add the failing regression test |
| 29 | + |
| 30 | +**Files:** |
| 31 | +- Create: `tools/local-env/tests/test_router.php` |
| 32 | + |
| 33 | +This test boots a real `php -S` with the shipped `router.php`, pointing `-t` at a tmp fixture directory that contains a tiny `index.php` marker. It then `GET`s `/` and asserts the marker came back — proving the router served the fixture, not the envlite checkout's own `src/`. |
| 34 | + |
| 35 | +Picking a free port: bind to `tcp://127.0.0.1:0`, read the assigned port from `stream_socket_get_name`, close the socket, then hand the port to `php -S`. There's a microscopic race window between close-and-rebind; if it bites in practice, retry once. Don't preemptively engineer for it. |
| 36 | + |
| 37 | +- [ ] **Step 1: Write the failing test** |
| 38 | + |
| 39 | +Create `tools/local-env/tests/test_router.php`: |
| 40 | + |
| 41 | +```php |
| 42 | +<?php |
| 43 | +function envlite_test_router_pick_free_port(): int { |
| 44 | + $sock = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); |
| 45 | + if ($sock === false) { |
| 46 | + throw new \RuntimeException("could not bind to find free port: $errstr"); |
| 47 | + } |
| 48 | + $name = stream_socket_get_name($sock, false); |
| 49 | + $port = (int) substr($name, strrpos($name, ':') + 1); |
| 50 | + fclose($sock); |
| 51 | + return $port; |
| 52 | +} |
| 53 | + |
| 54 | +function envlite_test_router_wait_for_bind(int $port, float $timeout_seconds = 3.0): bool { |
| 55 | + $deadline = microtime(true) + $timeout_seconds; |
| 56 | + while (microtime(true) < $deadline) { |
| 57 | + $check = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1); |
| 58 | + if ($check) { |
| 59 | + fclose($check); |
| 60 | + return true; |
| 61 | + } |
| 62 | + usleep(100_000); |
| 63 | + } |
| 64 | + return false; |
| 65 | +} |
| 66 | + |
| 67 | +function test_router_serves_from_document_root_not_router_directory() { |
| 68 | + // Build a fixture "site" that does NOT share a parent with router.php. |
| 69 | + $site = envlite_test_tmpdir('router-docroot'); |
| 70 | + file_put_contents("$site/index.php", "<?php echo 'FIXTURE_OK ' . __DIR__;"); |
| 71 | + |
| 72 | + // Use the real shipped router so we exercise its path resolution. |
| 73 | + $router = realpath(__DIR__ . '/../router.php'); |
| 74 | + envlite_assert(is_file($router), 'router.php must exist at ' . __DIR__ . '/../router.php'); |
| 75 | + |
| 76 | + $port = envlite_test_router_pick_free_port(); |
| 77 | + |
| 78 | + // Spawn `php -S 127.0.0.1:<port> -t <site> <router>` with cwd = site. |
| 79 | + // Matches envlite_run_dev_server: chdir into the target repo, then pass |
| 80 | + // -t <docroot>. The router file lives outside $site on purpose — that is |
| 81 | + // exactly the configuration that triggered the original bug. |
| 82 | + $argv = [PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $site, $router]; |
| 83 | + $descriptors = [ |
| 84 | + 0 => ['pipe', 'r'], |
| 85 | + 1 => ['pipe', 'w'], |
| 86 | + 2 => ['pipe', 'w'], |
| 87 | + ]; |
| 88 | + $proc = proc_open($argv, $descriptors, $pipes, $site); |
| 89 | + envlite_assert(is_resource($proc), 'failed to start php -S'); |
| 90 | + |
| 91 | + try { |
| 92 | + envlite_assert( |
| 93 | + envlite_test_router_wait_for_bind($port), |
| 94 | + "php -S did not bind on 127.0.0.1:$port within 3s" |
| 95 | + ); |
| 96 | + |
| 97 | + $body = @file_get_contents("http://127.0.0.1:$port/"); |
| 98 | + envlite_assert($body !== false, "request to 127.0.0.1:$port failed"); |
| 99 | + |
| 100 | + envlite_assert( |
| 101 | + strpos($body, 'FIXTURE_OK ' . $site) !== false, |
| 102 | + 'expected FIXTURE_OK marker from fixture index.php, got: ' . substr($body, 0, 400) |
| 103 | + ); |
| 104 | + } finally { |
| 105 | + foreach ($pipes as $p) { if (is_resource($p)) { @fclose($p); } } |
| 106 | + $status = @proc_get_status($proc); |
| 107 | + if ($status && $status['running']) { |
| 108 | + @proc_terminate($proc, 15); |
| 109 | + } |
| 110 | + @proc_close($proc); |
| 111 | + } |
| 112 | +} |
| 113 | +``` |
| 114 | + |
| 115 | +- [ ] **Step 2: Run test to verify it fails** |
| 116 | + |
| 117 | +Run: `php tools/local-env/tests/run.php` |
| 118 | + |
| 119 | +Expected: `FAIL test_router_serves_from_document_root_not_router_directory` with a message about the FIXTURE_OK marker being absent. The body will either be empty (require failed — index.php from envlite's own `src/` does not exist in a fresh checkout running tests) or contain unrelated content from envlite's `src/index.php`. Either way the assert fires. |
| 120 | + |
| 121 | +If instead the test passes here, STOP — the reproduction is wrong and the rest of the plan does nothing. |
| 122 | + |
| 123 | +- [ ] **Step 3: Commit the failing test** |
| 124 | + |
| 125 | +```bash |
| 126 | +git add tools/local-env/tests/test_router.php |
| 127 | +git commit -m "test(envlite): cover router DOCUMENT_ROOT resolution" |
| 128 | +``` |
| 129 | + |
| 130 | +It is fine — and intentional — to land a failing regression test in its own commit. The next commit makes it pass. |
| 131 | + |
| 132 | +--- |
| 133 | + |
| 134 | +### Task 2: Fix the router to use DOCUMENT_ROOT |
| 135 | + |
| 136 | +**Files:** |
| 137 | +- Modify: `tools/local-env/router.php:11` and `tools/local-env/router.php:25` |
| 138 | + |
| 139 | +- [ ] **Step 1: Edit router.php** |
| 140 | + |
| 141 | +Replace the current contents of `tools/local-env/router.php` with: |
| 142 | + |
| 143 | +```php |
| 144 | +<?php |
| 145 | +$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); |
| 146 | + |
| 147 | +// php -S does not honor Apache .ht* deny rules. Block any segment so the |
| 148 | +// SQLite DB at wp-content/database/.ht.sqlite is not downloadable. |
| 149 | +if (preg_match('#(^|/)\.ht#', $path)) { |
| 150 | + http_response_code(403); |
| 151 | + return true; |
| 152 | +} |
| 153 | + |
| 154 | +// DOCUMENT_ROOT is the absolute resolution of php -S's -t flag. Using it |
| 155 | +// instead of a path computed from __DIR__ lets the router live outside the |
| 156 | +// target repo (e.g. envlite invoked from a different checkout). |
| 157 | +$docroot = $_SERVER['DOCUMENT_ROOT']; |
| 158 | +$file = $docroot . $path; |
| 159 | + |
| 160 | +if ($path !== '/' && file_exists($file)) { |
| 161 | + if (!is_dir($file)) { |
| 162 | + return false; |
| 163 | + } |
| 164 | + // Existing directory: let the built-in server serve its index.php |
| 165 | + // (e.g. /wp-admin/ -> wp-admin/index.php). Without an index, fall |
| 166 | + // through to the front controller to avoid directory listings. |
| 167 | + if (file_exists(rtrim($file, '/') . '/index.php')) { |
| 168 | + return false; |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +require $docroot . '/index.php'; |
| 173 | +``` |
| 174 | + |
| 175 | +- [ ] **Step 2: Run the new test to verify it passes** |
| 176 | + |
| 177 | +Run: `php tools/local-env/tests/run.php` |
| 178 | + |
| 179 | +Expected: `PASS test_router_serves_from_document_root_not_router_directory`, and the final tally line should show one more pass than before with zero failures. |
| 180 | + |
| 181 | +- [ ] **Step 3: Run the full test suite to verify no regressions** |
| 182 | + |
| 183 | +Run: `php tools/local-env/tests/run.php` |
| 184 | + |
| 185 | +Expected: `0 failures` in the final summary line. In particular `test_dev_server_argv_targets_correct_port_root_router` must still pass — it asserts the argv shape, which this change does not touch. |
| 186 | + |
| 187 | +- [ ] **Step 4: Manually verify the original reproduction is fixed** |
| 188 | + |
| 189 | +This is a one-time smoke check, not a permanent test. Skip if you do not have two envlite-prepared worktrees handy. |
| 190 | + |
| 191 | +Run: |
| 192 | +```bash |
| 193 | +# In one terminal, from a *different* envlite-prepared checkout B, start the server: |
| 194 | +cd /path/to/checkout-B |
| 195 | +php /path/to/checkout-A/tools/local-env/envlite.php up |
| 196 | +``` |
| 197 | + |
| 198 | +```bash |
| 199 | +# In another terminal: |
| 200 | +curl -sI http://127.0.0.1:<B's port>/ |
| 201 | +``` |
| 202 | +
|
| 203 | +Expected: a `200 OK` (or whatever the WordPress front page returns), NOT a `301` to checkout A's port. |
| 204 | + |
| 205 | +- [ ] **Step 5: Commit the fix** |
| 206 | + |
| 207 | +```bash |
| 208 | +git add tools/local-env/router.php |
| 209 | +git commit -m "fix(envlite): resolve router paths via DOCUMENT_ROOT |
| 210 | +
|
| 211 | +The router previously used dirname(__DIR__, 2) . '/src' to locate both |
| 212 | +the static-file root and the front controller. That resolves relative |
| 213 | +to the router file's own checkout, so invoking envlite from a different |
| 214 | +worktree loaded the wrong wp-config.php and triggered a canonical-URL |
| 215 | +301 to that wp-config's WP_HOME port. |
| 216 | +
|
| 217 | +Use \$_SERVER['DOCUMENT_ROOT'] instead — populated by php -S from its |
| 218 | +-t flag, which envlite_run_dev_server already points at the target |
| 219 | +repo's src/." |
| 220 | +``` |
| 221 | + |
| 222 | +--- |
| 223 | + |
| 224 | +## Self-Review |
| 225 | + |
| 226 | +**1. Spec coverage:** |
| 227 | +- Root cause (router uses `__DIR__`-derived path): covered by Task 2 Step 1. |
| 228 | +- Fix uses `$_SERVER['DOCUMENT_ROOT']`: covered by Task 2 Step 1. |
| 229 | +- Regression test exists: covered by Task 1. |
| 230 | +- Manual verification of original reproduction: covered by Task 2 Step 4. |
| 231 | +- No structural changes to envlite.php: confirmed — `envlite_dev_server_argv` already passes `-t src`, no change needed. |
| 232 | + |
| 233 | +**2. Placeholder scan:** No TBD/TODO/"fill in later". All code is concrete; all commands explicit. |
| 234 | + |
| 235 | +**3. Type/identifier consistency:** |
| 236 | +- Helper names `envlite_test_router_pick_free_port` and `envlite_test_router_wait_for_bind` are referenced in the test exactly as defined. |
| 237 | +- `envlite_test_tmpdir` is defined in `tests/test_manifest.php:2` and reused here (matching the pattern in `test_smoke.php:3` and `test_atomic.php`). |
| 238 | +- `envlite_assert` is defined in `tests/harness.php:2`. |
| 239 | +- `proc_open` with `$descriptors` array form, then `proc_terminate`/`proc_close` — standard PHP API. |
0 commit comments