diff --git a/docs/superpowers/plans/2026-08-04-hostnamen-trennung.md b/docs/superpowers/plans/2026-08-04-hostnamen-trennung.md new file mode 100644 index 0000000..fed5a71 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-hostnamen-trennung.md @@ -0,0 +1,942 @@ +# Hostnamen-Trennung — Umsetzungsplan + +> **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:** Die vorhandene Hostnamen-Trennung wird eingeschaltet, per Test +festgehalten, und das eine unbegründete host-unabhängige Route-Paar geschlossen. + +**Architecture:** Am Routing wird nichts geändert — `routes/web.php` trennt +bereits korrekt, sobald `APP_HOST`/`SITE_HOST`/`STATUS_HOST`/`FILES_HOST` +gesetzt sind. Gebaut werden: ein Test, der die **vollständige** Routentabelle +misst (indem er eine zweite Anwendungsinstanz mit gesetzten Hostnamen bootet), +das Schließen von `storage/{path}`, der Konfigurationsweg für Neuinstallationen +(`install.sh`, `.env.example`) und ein Artisan-Befehl, der den Bestand nachrüstet. + +**Tech Stack:** Laravel 12, Pest 4, Bash (deploy/install.sh), Docker Compose. + +## Global Constraints + +- **Entwurf:** `docs/superpowers/specs/2026-08-04-hostnamen-trennung-design.md`. + Bei Abweichung gilt der Entwurf; bei Konflikt mit `CLAUDE.md` → STOP & fragen. +- **Testbefehl:** `docker exec clupilot-app-1 php artisan test`. Einzelne Datei: + `docker exec clupilot-app-1 php artisan test --filter=`. + Der Worktree hat **kein** `vendor/`; Tests laufen im Container, der + `/home/nexxo/clupilot` unter `/var/www/html` einhängt. +- **`routes/web.php`, `app/Http/Middleware/RestrictAdminHost.php`, + `config/fortify.php`, `app/Http/Middleware/PublicSiteGate.php` werden NICHT + geändert.** Gemessen korrekt. +- **Sprache:** Kommentare und Ausgaben auf Deutsch, wie in den letzten Commits. + Pfade und Routennamen bleiben englisch (R13). +- **Kommentare sagen WARUM, nicht WAS.** Hausstil: siehe `install-agent.sh` und + `RestrictAdminHost`. +- **R22:** Eine Prüfrunde, eine Fix-Runde. Keine flächendeckenden + Mutationstests. + +--- + +## File Structure + +| Datei | Zuständigkeit | +|---|---| +| `tests/Feature/HostSeparationTest.php` | **neu** — misst die vollständige Routentabelle mit gesetzten Hostnamen; hält Kriterium 4 und 5 fest | +| `config/filesystems.php` | **ändern**, Zeile 36 — `'serve' => false` | +| `deploy/install.sh` | **ändern** — Reihenfolge-Panne; schreibt die vier Hostnamen | +| `.env.example` | **ändern**, Zeilen 218–239 — `STATUS_HOST` ergänzen, Block sortieren | +| `tests/Feature/InstallerWritesHostsTest.php` | **neu** — hält beides am Installer fest | +| `app/Console/Commands/BindHosts.php` | **neu** — `clupilot:bind-hosts` für den Bestand | +| `tests/Feature/BindHostsCommandTest.php` | **neu** | +| `VERSION` | **ändern** — Release | + +--- + +## Task 1: Der Test, und das Leck, das er findet + +**Files:** +- Create: `tests/Feature/HostSeparationTest.php` +- Modify: `config/filesystems.php:36` + +**Interfaces:** +- Produces: `hostSeparationTable(): array` — globale Testfunktion, gibt je Route + `['uri' => string, 'domain' => ?string, 'name' => ?string]` zurück. Der Name + ist bewusst eigen, weil `PortalHostTest` bereits `routerWithHosts()`, + `dispatchOn()` und `answers()` global definiert. + +**Hintergrund für den Umsetzenden:** Die Routen werden einmal beim Booten +angemeldet. `config()->set()` in einem Test ändert daran nichts. `PortalHostTest` +löst das, indem es einen frischen `Router` baut und `routes/web.php` neu lädt — +das reicht hier **nicht**, weil Livewire, `storage/{path}`, `up` und +`broadcasting/auth` nicht aus `routes/web.php` kommen, sondern von +Service-Providern. Genau die fehlten dann in der Messung. Deshalb wird eine +**zweite Anwendungsinstanz** gebootet. + +Verifiziert: das reproduziert `artisan route:list --json` exakt (123 Routen, +12 ohne Domain). + +- [ ] **Step 1: Den Test schreiben** + +Datei `tests/Feature/HostSeparationTest.php`: + +```php +set()` daran nichts. + * PortalHostTest baut dafür einen frischen Router und lädt routes/web.php neu; + * das sieht die Provider-Routen nicht. Hier wird deshalb eine zweite + * Anwendungsinstanz gebootet — dasselbe, was `artisan route:list` tut. + * + * Was danach wieder zurückgestellt werden MUSS, und warum: Eloquent hält seinen + * Connection-Resolver STATISCH. Die zweite Anwendung zeigt ihn auf ihre eigene, + * leere :memory:-Datenbank, und der restliche Testlauf fände danach keine + * einzige Tabelle mehr. Das ist kein hypothetisches Risiko — genau so ist es + * beim Bau dieses Tests passiert. + */ +function hostSeparationTable(): array +{ + $env = [ + 'APP_HOST' => 'app.clupilot.test', + 'SITE_HOST' => 'www.clupilot.test,clupilot.test', + 'STATUS_HOST' => 'status.clupilot.test', + 'FILES_HOST' => 'files.clupilot.test', + 'ADMIN_HOSTS' => 'admin.clupilot.test', + 'ADMIN_HOST_EXCLUSIVE' => 'true', + ]; + + // Gemerkt und zurückgestellt, nicht bloß gelöscht: phpunit.xml setzt + // FILES_HOST und ADMIN_HOSTS für den ganzen Lauf, und ein Test, der sie + // hinterher entfernt, kippt die Tests nach ihm. + $previous = []; + foreach ($env as $key => $value) { + $previous[$key] = $_SERVER[$key] ?? null; + putenv("{$key}={$value}"); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + + $original = Container::getInstance(); + + try { + $app = require base_path('bootstrap/app.php'); + $app->make(Kernel::class)->bootstrap(); + + $table = []; + foreach ($app['router']->getRoutes()->getRoutes() as $route) { + $table[] = [ + 'uri' => $route->uri(), + 'domain' => $route->getDomain(), + 'name' => $route->getName(), + ]; + } + } finally { + Container::setInstance($original); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($original); + Model::setConnectionResolver($original['db']); + Model::setEventDispatcher($original['events']); + + foreach ($previous as $key => $value) { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } else { + putenv("{$key}={$value}"); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + } + } + + return $table; +} + +/** Die Tabelle nach Routennamen, für die gezielten Prüfungen unten. */ +function hostSeparationByName(): array +{ + $byName = []; + + foreach (hostSeparationTable() as $route) { + if ($route['name'] !== null && $route['name'] !== '') { + $byName[$route['name']] = $route; + } + } + + return $byName; +} + +it('bindet jede Route an einen Hostnamen, außer den hier benannten', function () { + // Jeder Eintrag mit seinem Grund. Wer hier etwas hinzufügt, schreibt den + // Grund dazu — sonst ist die Liste in einem Jahr eine Liste von Ausnahmen, + // die niemand mehr prüfen kann. + $shared = [ + // Konsole und Portal posten Komponenten-Aktionen an denselben + // Endpunkt. RestrictAdminHost ist die Schutzschicht dafür; eine + // Host-Bindung hier zerlegt die Konsole. + 'livewire/update', + 'livewire/upload-file', + 'livewire/preview-file/{filename}', + 'livewire/livewire.js', + 'livewire/livewire.min.js.map', + // Gesundheitsprüfung, ebenfalls RestrictAdminHost::SHARED. + 'up', + // Der Live-Feed der Konsole meldet sich hier an (routes/channels.php, + // admin.runs) — ebenfalls RestrictAdminHost::SHARED. + 'broadcasting/auth', + // Stripe postet an die URL, die es einmal bekommen hat. Ein + // Hostname-Fehler an dieser Stelle verliert Zahlungen. + 'webhooks/stripe', + // "/" auf einem Namen, der weder Website noch Portal noch Status ist: + // eine Weiterleitung ins Portal, kein 404. Wer "/" aus dem Gedächtnis + // tippt, soll nicht auf einer Fehlerseite über Hostnamen belehrt werden. + '/', + // Weiterleitung auf den Statushost. Eine Statusseite ist die Adresse, + // die in einem Lesezeichen steht, wenn ohnehin schon etwas kaputt ist. + 'status', + ]; + + $unbound = []; + foreach (hostSeparationTable() as $route) { + if ($route['domain'] === null) { + $unbound[] = $route['uri']; + } + } + + $unbound = array_values(array_unique($unbound)); + sort($unbound); + sort($shared); + + expect($unbound)->toBe($shared); +}); + +it('legt jeden Weg in ein Konto hinein und wieder heraus auf den Portal-Hostnamen', function () { + // Kriterium 4, und die teuerste Falle des ganzen Umbaus: Fortify meldet + // diese Routen SELBST an, nicht routes/web.php. Gebunden werden sie über + // config/fortify.php ('domain' => env('APP_HOST')). Verschwindet diese eine + // Zeile beim Aufräumen, kann sich auf app. niemand mehr anmelden — und + // routes/web.php sieht dabei völlig unverdächtig aus. + $byName = hostSeparationByName(); + + $mustBeOnThePortal = [ + 'login', 'login.store', 'logout', + 'password.request', 'password.email', + 'password.reset', 'password.update', + 'two-factor.login', 'two-factor.login.store', + 'password.confirm.store', + 'verification.notice', 'verification.verify', + ]; + + foreach ($mustBeOnThePortal as $name) { + expect($byName[$name]['domain'] ?? null) + ->toBe('app.clupilot.test', "Route [{$name}] liegt nicht auf dem Portal-Hostnamen."); + } +}); + +it('behält die Namen, an denen PublicSiteGate die Downloads erkennt', function () { + // PublicSiteGate::isDownload() prüft auf ROUTENNAMEN. Zieht eine Route um + // und ändert dabei ihren Namen, fällt die Ausnahme still weg — und ein + // Server im Rettungssystem schiebt die 503-Platzhalterseite in `tar`. + $byName = hostSeparationByName(); + + expect($byName['bootstrap.archive']['domain'] ?? null)->toBe('files.clupilot.test') + ->and($byName['files.public']['domain'] ?? null)->toBe('files.clupilot.test'); + + $gate = file_get_contents(base_path('app/Http/Middleware/PublicSiteGate.php')); + + expect($gate)->toContain("'bootstrap.archive'") + ->and($gate)->toContain("'files.public'"); +}); +``` + +- [ ] **Step 2: Den Test laufen lassen und den Fehlschlag ansehen** + +Run: `docker exec clupilot-app-1 php artisan test --filter=HostSeparation` + +Expected: Der erste Test **schlägt fehl**. Die Differenz zeigt `storage/{path}` +als überzähligen Eintrag in `$unbound`. Die beiden anderen Tests sind grün — +sie halten fest, was heute schon stimmt. + +Steht in der Differenz noch etwas anderes als `storage/{path}`: **STOP**. Dann +ist seit der Messung eine Route dazugekommen, und sie gehört erst begründet, +bevor sie auf die Liste kommt. + +- [ ] **Step 3: `storage/{path}` schließen** + +In `config/filesystems.php`, Zeile 36, `'serve' => true` ersetzen durch: + +```php + // Aus, und das ist der Unterschied zwischen zwei Routen und keiner: + // `true` meldet `storage/{path}` als GET und PUT an — ohne + // Hostnamen, also auf JEDEM Namen dieser Installation. Benutzt hat + // sie nie jemand. Jede Datei, die dieses Produkt ausliefert, geht + // durch einen eigenen Controller (PublicFileController, + // BootstrapArchiveController, invoices.pdf, dpa.file), und die + // beiden `temporaryUrl()`-Aufrufe im Repo sind Livewire-Uploads, + // die `livewire/preview-file/…` erzeugen und nicht diese Route. + 'serve' => false, +``` + +- [ ] **Step 4: Den Test noch einmal laufen lassen** + +Run: `docker exec clupilot-app-1 php artisan test --filter=HostSeparation` +Expected: PASS, drei Tests. + +- [ ] **Step 5: Die Nachbartests laufen lassen** + +Run: `docker exec clupilot-app-1 php artisan test --filter="PortalHost|FilesHost|Welcome|OfficialDomains"` +Expected: PASS. Das prüft, dass das Zurückstellen der Anwendungsinstanz sauber +ist und dass `serve => false` nichts anderes trifft. + +- [ ] **Step 6: Commit** + +```bash +git add tests/Feature/HostSeparationTest.php config/filesystems.php +git commit -m "Jede Route an einen Hostnamen, und storage/{path} zugemacht" +``` + +--- + +## Task 2: Der Konfigurationsweg für Neuinstallationen + +**Files:** +- Modify: `deploy/install.sh` (Frageblock ~Zeile 118; `optional_env`-Definition + Zeilen 300–312; Schreibblock ~Zeile 289–295) +- Modify: `.env.example:218-239` +- Create: `tests/Feature/InstallerWritesHostsTest.php` + +**Interfaces:** +- Consumes: nichts aus Task 1. +- Produces: nichts, was Task 3 braucht. + +**Hintergrund:** `install.sh` fragt nach `APP_DOMAIN`, `WWW_DOMAIN`, +`STATUS_DOMAIN` und wirft die Antworten weg. Und es ruft `optional_env` in +Zeile 295 auf, obwohl die Funktion erst in Zeile 308 definiert wird — bei +`set -euo pipefail` ist das Exit 127, **jede Neuinstallation bricht dort ab**. + +- [ ] **Step 1: Den Test schreiben** + +Datei `tests/Feature/InstallerWritesHostsTest.php`: + +```php +run(); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()); +}); + +it('definiert jede Hilfsfunktion, bevor sie gebraucht wird', function () { + $source = installerSource(); + + foreach (['set_env', 'optional_env'] as $function) { + $definedAt = strpos($source, " {$function}() {"); + expect($definedAt)->not->toBeFalse("{$function}() wird nicht mehr definiert."); + + // Der erste Aufruf ist der erste Treffer, der KEINE Definition ist. + $calledAt = preg_match( + '/^\s+'.preg_quote($function, '/').' [A-Z]/m', + $source, + $matches, + PREG_OFFSET_CAPTURE, + ) ? $matches[0][1] : false; + + expect($calledAt)->not->toBeFalse("{$function} wird nirgends aufgerufen."); + expect($definedAt)->toBeLessThan( + $calledAt, + "{$function}() wird vor seiner Definition aufgerufen — unter set -e ist das Exit 127.", + ); + } +}); + +it('schreibt jeden Hostnamen, nach dem es fragt', function () { + $source = installerSource(); + + // Gefragt wird nach allen fünf … + foreach (['APP_DOMAIN', 'WWW_DOMAIN', 'STATUS_DOMAIN', 'FILES_DOMAIN', 'ADMIN_DOMAIN'] as $asked) { + expect($source)->toMatch('/^ask\s+'.$asked.'\s/m', "Es wird nicht mehr nach {$asked} gefragt."); + } + + // … und jede Antwort landet auch in der .env. Genau das fehlte: die Fragen + // standen da, die Werte wurden nie geschrieben, und die Trennung war + // deshalb auf keiner Installation eingeschaltet. + foreach (['APP_HOST', 'SITE_HOST', 'STATUS_HOST', 'FILES_HOST'] as $written) { + expect($source)->toMatch( + '/^\s+(set_env|optional_env)\s+'.$written.'\s/m', + "{$written} wird nicht in die .env geschrieben.", + ); + } +}); +``` + +- [ ] **Step 2: Den Test laufen lassen und den Fehlschlag ansehen** + +Run: `docker exec clupilot-app-1 php artisan test --filter=InstallerWritesHosts` +Expected: „ist syntaktisch gültiges bash" grün; die beiden anderen **rot** — +`optional_env` wird vor der Definition aufgerufen, und `APP_HOST`/`SITE_HOST`/ +`FILES_HOST` werden nicht geschrieben. + +- [ ] **Step 3: `optional_env` nach oben ziehen** + +In `deploy/install.sh` **nach Inhalt** arbeiten, nicht nach Zeilennummer — die +verschiebt sich beim Bearbeiten. Zu entfernen ist der zusammenhängende Block aus +den **fünf** Kommentarzeilen ab „`# An \`if\`, not \`[[ … ]] && …\`:`" bis +einschließlich der schließenden Klammer von `optional_env()` (heute Zeilen +303–312). + +Achtung, was **stehen bleibt**: die beiden Zeilen unmittelbar davor +(„`# Supplied through --env-file; …`" / „`# install can happen before the Stripe +account exists.`") beschreiben die *Aufrufe* darunter, nicht die Definition. Die +beiden Zeilen davor („`# The container user is mapped …`") gehören zu +`HOST_UID`/`HOST_GID` weiter unten. Beide Paare bleiben, wo sie sind. + +Den entfernten Block direkt **hinter** die schließende Klammer von `set_env()` +setzen (dort endet die Funktion mit ` }` gefolgt von einer Leerzeile): + +```bash + # An `if`, not `[[ … ]] && …`: the && form makes the function return 1 for + # every value that is absent, and under `set -e` that ends the install. + # Absent is the NORMAL case here — these are the optional ones — so the + # script killed itself on the first installation that did not happen to + # supply a Hetzner token. + # + # Und die Definition steht VOR dem ersten Aufruf. Sie stand einmal dreizehn + # Zeilen dahinter: in bash ist eine Funktion erst ab ihrer Definition + # bekannt, der Aufruf davor ist ein Exit 127, und `set -euo pipefail` macht + # daraus das Ende der Installation. Jede Neuinstallation starb an + # `optional_env STATUS_HOST`. + optional_env() { + if [[ -n "${2:-}" ]]; then + set_env "$1" "$2" + fi + } +``` + +- [ ] **Step 4: Nach dem Dateinamen fragen** + +Direkt hinter die `ask STATUS_DOMAIN`-Zeile (Zeile 118): + +```bash +ask FILES_DOMAIN "Domain for downloads: terms, DPA, bootstrap archive (blank to keep them on the portal)" "files.clupilot.com" +``` + +- [ ] **Step 5: Die Hostnamen schreiben** + +Die Zeile `optional_env STATUS_HOST "$STATUS_DOMAIN"` ersetzen durch: + +```bash + # Die Antworten von oben landen HIER, und das ist der ganze Unterschied + # zwischen einer getrennten Installation und einer, auf der Website und + # Portal einander bedienen. Ohne diese vier Zeilen laufen beide + # Route::domain()-Gruppen host-unabhängig und antworten überall — der + # Betreiber erreicht dann die Website unter app. und die Anmeldung unter + # www., und nichts an der Konfiguration deutet darauf hin, warum. + set_env APP_HOST "$APP_DOMAIN" + set_env SITE_HOST "$WWW_DOMAIN" + optional_env STATUS_HOST "$STATUS_DOMAIN" + optional_env FILES_HOST "$FILES_DOMAIN" +``` + +- [ ] **Step 6: Test laufen lassen** + +Run: `docker exec clupilot-app-1 php artisan test --filter=InstallerWritesHosts` +Expected: PASS, drei Tests. + +- [ ] **Step 7: `.env.example` sortieren** + +Die Zeilen 218–239 vollständig ersetzen. Heute steht der `FILES_HOST`-Absatz +mitten im `APP_HOST`-Text, der `SITE_HOST`-Absatz bricht mitten im Satz ab +(„Leer heisst: Startseite ueberall,"), und `STATUS_HOST` fehlt ganz — obwohl +`install.sh` danach fragt und `config/admin_access.php` es liest. + +``` +# APP_HOST: Hostname des Kundenportals, z. B. app.clupilot.com. Gesetzt, ist +# JEDE Portal-Route an diesen Host gebunden und existiert nirgends sonst — +# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf. +# Das schliesst die Anmeldung ein: Fortify bindet seine eigenen Routen ueber +# denselben Wert (config/fortify.php). +# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber +# eine blanke IP braucht). +APP_HOST= + +# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die +# Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt +# unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall, +# auch auf dem Portal-Hostnamen. +# Mehrere Namen kommagetrennt, der ERSTE ist der kanonische — er liefert aus, +# alle weiteren leiten dauerhaft dorthin um (Pfad und Query bleiben erhalten): +# SITE_HOST=www.clupilot.com,clupilot.com +# Gebunden sind Startseite, robots.txt, Kontakt und die Legal-Seiten — dadurch +# erzeugt route('legal.impressum') auch aus einer Mail heraus eine www-Adresse, +# wo es keine Anfrage gibt, aus der man einen Hostnamen nehmen koennte. +SITE_HOST= + +# STATUS_HOST: Hostname der Statusseite, z. B. status.clupilot.com. Gesetzt, +# liegt sie an der WURZEL dieses Namens, und /status leitet von jedem anderen +# Host dorthin um statt zu 404en — eine Statusseite ist die Adresse, die in +# einem Lesezeichen steht, wenn ohnehin schon etwas kaputt ist. +# Leer = /status antwortet auf jedem Host. +STATUS_HOST= + +# FILES_HOST: Hostname fuer Dateien zum Herunterladen — AGB, AV, TOM +# (oeffentlich, dauerhaft zitierbar, weil diese Adressen in Vertraegen stehen) +# und das Bootstrap-Archiv (nur mit gueltigem Einmal-Code). +# Leer = das Archiv bleibt auf dem Portal-Hostnamen, damit keine bereits +# ausgegebene Befehlszeile bricht, solange der DNS-Eintrag noch nicht steht. +FILES_HOST= +``` + +Wichtig: **keine** `set -e`-Falle bauen — die Werte bleiben leer, weil eine +Entwicklungsmaschine über eine blanke IP erreicht wird und dort nichts gebunden +sein darf. + +- [ ] **Step 8: Den vollen Testlauf anstoßen und commiten** + +Run: `docker exec clupilot-app-1 php artisan test --filter="InstallerWritesHosts|HostSeparation"` +Expected: PASS. + +```bash +git add deploy/install.sh .env.example tests/Feature/InstallerWritesHostsTest.php +git commit -m "Der Installer schreibt die Hostnamen, nach denen er fragt" +``` + +--- + +## Task 3: `clupilot:bind-hosts` für den Bestand + +**Files:** +- Create: `app/Console/Commands/BindHosts.php` +- Create: `tests/Feature/BindHostsCommandTest.php` + +**Interfaces:** +- Consumes: `App\Services\Env\EnvFileEditor` — `path(): string`, + `read(): string`, `isValid(string): bool`, + `write(string $content): string` (gibt den Sicherungspfad zurück, validiert + und sichert vor dem Schreiben, wirft `InvalidEnvContentException`). +- Produces: Befehl `clupilot:bind-hosts`. + +**Warum es diesen Befehl gibt:** `install.sh` schreibt `.env` **nur** bei einer +neuen Installation („Keeping the existing .env"). Die laufende Maschine bekommt +die vier Zeilen also nie. + +**Die eine Regel, die festzulegen ist:** Ein Schlüssel mit **leerem** Wert gilt +als fehlend und wird gefüllt (`.env.example` liefert `APP_HOST=` leer aus). Ein +Schlüssel mit nicht-leerem Wert gilt als gesetzt und wird **nie** überschrieben. + +- [ ] **Step 1: Den Test schreiben** + +Datei `tests/Feature/BindHostsCommandTest.php`: + +```php +envPath = sys_get_temp_dir().'/clupilot-env-'.bin2hex(random_bytes(6)); + $this->app->instance(EnvFileEditor::class, new EnvFileEditor($this->envPath)); +}); + +afterEach(function () { + foreach (glob($this->envPath.'*') ?: [] as $file) { + @unlink($file); + } +}); + +it('trägt die fehlenden Hostnamen nach', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\nADMIN_HOSTS=admin.example.test\n"); + + $this->artisan('clupilot:bind-hosts', [ + '--site' => 'www.example.test,example.test', + '--status' => 'status.example.test', + '--files' => 'files.example.test', + '--force' => true, + ])->assertSuccessful(); + + $written = file_get_contents($this->envPath); + + // APP_HOST kommt aus dem Host von APP_URL — danach muss niemand gefragt + // werden, die Antwort steht schon in der Datei. + expect($written)->toContain('APP_HOST=app.example.test') + ->and($written)->toContain('SITE_HOST=www.example.test,example.test') + ->and($written)->toContain('STATUS_HOST=status.example.test') + ->and($written)->toContain('FILES_HOST=files.example.test') + // Und nichts Vorhandenes geht verloren. + ->and($written)->toContain('APP_URL=https://app.example.test') + ->and($written)->toContain('ADMIN_HOSTS=admin.example.test'); +}); + +it('überschreibt niemals einen Wert, den jemand von Hand gesetzt hat', function () { + file_put_contents( + $this->envPath, + "APP_URL=https://app.example.test\nAPP_HOST=eigener.example.test\nSITE_HOST=\n", + ); + + $this->artisan('clupilot:bind-hosts', [ + '--site' => 'www.example.test', + '--force' => true, + ])->assertSuccessful(); + + $written = file_get_contents($this->envPath); + + // Gesetzt bleibt gesetzt … + expect($written)->toContain('APP_HOST=eigener.example.test') + ->and($written)->not->toContain('APP_HOST=app.example.test') + // … und leer gilt als fehlend, denn genau so liefert .env.example aus. + ->and($written)->toContain('SITE_HOST=www.example.test'); +}); + +it('sichert die alte Datei, bevor es schreibt', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\n"); + + $this->artisan('clupilot:bind-hosts', ['--force' => true])->assertSuccessful(); + + // EnvFileEditor legt eine Kopie mit Zeitstempel daneben. Diese Datei hält + // jedes Geheimnis der Installation; ein Schreibfehler darf sie nicht + // ersatzlos ersetzen. + expect(glob($this->envPath.'.bak-*'))->not->toBeEmpty(); +}); + +it('tut ohne Bestätigung nichts', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\n"); + + $this->artisan('clupilot:bind-hosts', ['--site' => 'www.example.test']) + ->expectsConfirmation( + 'Diese Namen jetzt binden?', + 'no', + ) + ->assertFailed(); + + expect(file_get_contents($this->envPath))->not->toContain('SITE_HOST'); +}); + +it('schreibt bei --dry-run nichts, sagt aber was geschähe', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\n"); + + $this->artisan('clupilot:bind-hosts', ['--site' => 'www.example.test', '--dry-run' => true]) + ->expectsOutputToContain('SITE_HOST=www.example.test') + ->assertSuccessful(); + + expect(file_get_contents($this->envPath))->not->toContain('SITE_HOST') + ->and(glob($this->envPath.'.bak-*'))->toBeEmpty(); +}); + +it('sagt es, wenn schon alles steht, statt eine Sicherung anzulegen', function () { + file_put_contents($this->envPath, implode("\n", [ + 'APP_URL=https://app.example.test', + 'APP_HOST=app.example.test', + 'SITE_HOST=www.example.test', + 'STATUS_HOST=status.example.test', + 'FILES_HOST=files.example.test', + '', + ])); + + $this->artisan('clupilot:bind-hosts', ['--force' => true]) + ->expectsOutputToContain('Nichts nachzutragen') + ->assertSuccessful(); + + expect(glob($this->envPath.'.bak-*'))->toBeEmpty(); +}); +``` + +- [ ] **Step 2: Den Test laufen lassen und den Fehlschlag ansehen** + +Run: `docker exec clupilot-app-1 php artisan test --filter=BindHostsCommand` +Expected: FAIL — „The command 'clupilot:bind-hosts' does not exist." + +- [ ] **Step 3: Den Befehl schreiben** + +Datei `app/Console/Commands/BindHosts.php`: + +```php +read(); + + if (trim($content) === '') { + $this->error("Keine .env unter {$env->path()}."); + + return self::FAILURE; + } + + $wanted = [ + 'APP_HOST' => (string) ($this->option('app') ?: $this->hostOf($content)), + 'SITE_HOST' => (string) $this->option('site'), + 'STATUS_HOST' => (string) $this->option('status'), + 'FILES_HOST' => (string) $this->option('files'), + ]; + + $missing = []; + + foreach ($wanted as $key => $value) { + if ($value === '' || $this->valueOf($content, $key) !== '') { + continue; + } + + $missing[$key] = $value; + } + + if ($missing === []) { + $this->info('Nichts nachzutragen — jeder angegebene Hostname steht bereits in der Datei.'); + + return self::SUCCESS; + } + + $this->line('In '.$env->path().':'); + + foreach ($missing as $key => $value) { + $this->line(" {$key}={$value}"); + } + + // Der Satz, der einen Ausfall verhindert. Ein Hostname wird durch das + // Binden zur EINZIGEN Adresse, unter der diese Routen noch antworten — + // steht dafür kein DNS-Eintrag und kein Block im Reverse Proxy, ist der + // Bereich danach schlicht nicht mehr erreichbar. + $this->newLine(); + $this->warn('Jeder dieser Namen braucht einen DNS-Eintrag und einen Block im Reverse Proxy.'); + $this->warn('Ohne den ist der jeweilige Bereich nach dem Neuladen der Konfiguration nicht mehr erreichbar.'); + $this->newLine(); + + if ($this->option('dry-run')) { + $this->info('--dry-run: nichts geschrieben.'); + + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm('Diese Namen jetzt binden?')) { + $this->line('Nichts geschrieben.'); + + return self::FAILURE; + } + + $backup = $env->write($this->apply($content, $missing)); + + $this->info('Geschrieben. Die vorherige Fassung liegt unter '.$backup); + $this->line('Danach: php artisan config:cache && php artisan route:cache'); + + return self::SUCCESS; + } + + /** + * Der Wert eines Schlüssels, oder '' wenn er fehlt ODER leer ist. + * + * Beides zusammen, absichtlich: siehe Klassenkommentar. + */ + private function valueOf(string $content, string $key): string + { + return preg_match('/^'.preg_quote($key, '/').'=(.*)$/m', $content, $matches) === 1 + ? trim($matches[1]) + : ''; + } + + /** Der Hostname aus APP_URL — die Antwort für APP_HOST steht schon in der Datei. */ + private function hostOf(string $content): string + { + return (string) parse_url($this->valueOf($content, 'APP_URL'), PHP_URL_HOST); + } + + /** + * Vorhandene Zeile ersetzen, sonst anhängen. + * + * Ersetzen und nicht nur anhängen, weil ein leerer Schlüssel als fehlend + * gilt: `APP_HOST=` steht dann schon da, und ein zweites `APP_HOST=…` + * darunter wäre eine Datei mit zwei Antworten auf dieselbe Frage. + * + * @param array $values + */ + private function apply(string $content, array $values): string + { + foreach ($values as $key => $value) { + $line = $key.'='.$value; + $pattern = '/^'.preg_quote($key, '/').'=.*$/m'; + + $content = preg_match($pattern, $content) === 1 + ? preg_replace($pattern, $line, $content, 1) + : rtrim($content, "\n")."\n".$line."\n"; + } + + return $content; + } +} +``` + +- [ ] **Step 4: Test laufen lassen** + +Run: `docker exec clupilot-app-1 php artisan test --filter=BindHostsCommand` +Expected: PASS, sechs Tests. + +- [ ] **Step 5: Commit** + +```bash +git add app/Console/Commands/BindHosts.php tests/Feature/BindHostsCommandTest.php +git commit -m "clupilot:bind-hosts traegt die Hostnamen in eine bestehende .env nach" +``` + +--- + +## Task 4: Voller Testlauf und Release + +**Files:** +- Modify: `VERSION` + +- [ ] **Step 1: Voller Testlauf** + +Run: `docker exec clupilot-app-1 php artisan test` +Expected: PASS, alles. + +Bei Fehlschlägen außerhalb der neuen Dateien: **STOP** und melden, nicht +nebenbei reparieren. + +- [ ] **Step 2: Die Messung gegen das Abnahmekriterium** + +Run: + +```bash +docker exec -e APP_HOST=app.dev.clupilot.com -e SITE_HOST=www.dev.clupilot.com -e STATUS_HOST=status.dev.clupilot.com -e FILES_HOST=files.dev.clupilot.com clupilot-app-1 php artisan route:list --json | python3 -c "import json,sys; r=json.load(sys.stdin); u=[x for x in r if not x.get('domain')]; print(len(r),'Routen,',len(u),'ohne Domain'); [print(' ',x['uri']) for x in u]" +``` + +Expected: `10 ohne Domain`, und die zehn sind genau die Liste aus +`HostSeparationTest`. Das ist Abnahmekriterium 5, mit dem Werkzeug gemessen, +das das Kriterium nennt. + +- [ ] **Step 3: Den höchsten Tag prüfen — VOR dem Versionssprung** + +Run: + +```bash +git fetch --tags && git tag -l 'v*' --sort=-v:refname | head -3 && pwd && git branch --show-current +``` + +Es laufen mehrere Sitzungen an diesem Repo. Liegt der höchste Tag über `v1.8.0`, +wird die neue Version entsprechend höher gewählt — ein Release unter dem +höchsten Tag liefert der Update-Agent nie aus. + +- [ ] **Step 4: VERSION setzen und commiten** + +`VERSION` auf `1.9.0` (bzw. eine Minor über dem höchsten gefundenen Tag). + +```bash +git add VERSION +git commit -m "Version 1.9.0 — jede Route gehoert zu einem Hostnamen" +``` + +- [ ] **Step 5: Nach main bringen, taggen, pushen** + +`deploy/update.sh` folgt in Zweigbetrieb `BRANCH=main` (Zeile 55), und `v1.8.0` +liegt auf `main`. Der Tag gehört also auf `main`, nicht auf den Arbeitszweig. + +```bash +git switch main && git merge --no-ff claude/eager-elion-c581f4 -m "Hostnamen-Trennung eingeschaltet und festgehalten" +``` + +Dann prüfen, dass der Testlauf auf `main` grün ist, danach: + +```bash +git tag -a v1.9.0 -m "Jede Route gehoert zu genau einem Hostnamen" && git push origin main && git push origin v1.9.0 +``` + +- [ ] **Step 6: Dem Betreiber sagen, was außerhalb des Repos zu tun ist** + +Zusammenfassen, nicht ausführen: + +1. `php artisan clupilot:bind-hosts` auf der Maschine — trägt die vier + Schlüssel nach und legt vorher eine Sicherung an. +2. **Vorher** DNS und Caddy-Blöcke für die gewählten Namen. Ohne sie ist der + jeweilige Bereich danach nicht erreichbar. +3. `php artisan config:cache && php artisan route:cache`. + +--- + +## Self-Review + +**Spec-Abdeckung** + +| Entwurfsabschnitt | Task | +|---|---| +| 1. `HostSeparationTest` | Task 1 | +| 2. `storage/{path}` schließen | Task 1, Step 3 | +| 3. `deploy/install.sh` | Task 2, Steps 3–5 | +| 4. `clupilot:bind-hosts` | Task 3 | +| 5. `.env.example` | Task 2, Step 7 | +| Abnahme 1–3 (vorhandene Tests) | Task 4, Step 1 | +| Abnahme 4 (Anmeldung auf `app.`) | Task 1, Test 2 | +| Abnahme 5 (`route:list`) | Task 1, Test 1 + Task 4, Step 2 | +| Abnahme 6 (voller Lauf) | Task 4, Step 1 | +| Nicht anfassen | Global Constraints | + +**Platzhalter:** keine. Jeder Codeblock ist vollständig; der Testcode aus Task 1 +ist im Container gelaufen (123 Routen, 12 ungebunden, Original-App danach +lesend und schreibend intakt). + +**Typkonsistenz:** `hostSeparationTable()` liefert +`['uri' => string, 'domain' => ?string, 'name' => ?string]`; `hostSeparationByName()` +schlüsselt dieselbe Struktur nach Namen. `EnvFileEditor::write()` gibt den +Sicherungspfad als `string` zurück — so in Task 3 verwendet. Der Konstruktor +nimmt den Pfad als erstes Argument, wie im Test gebunden.