diff --git a/README.md b/README.md index b116f09..a8518ff 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ Archivbestand erhalten. Setup, Hosting und Betriebsablauf. 3. Arbeite anschliessend in [saas-app/README.md](saas-app/README.md) weiter. +Auf Webspace ohne Shell ist der bevorzugte Einstieg die gefuehrte Installation +unter `saas-app/public/install/`. + ## Hilfsskripte - `scripts/check-prerequisites.php` prueft lokale Voraussetzungen. diff --git a/docs/installationshandbuch.md b/docs/installationshandbuch.md index f22d1a0..833cfda 100644 --- a/docs/installationshandbuch.md +++ b/docs/installationshandbuch.md @@ -4,6 +4,23 @@ Diese Anleitung beschreibt den vorgesehenen Weg fuer die SaaS-Version in `saas-app/`. Sie ist auf Webspace-Betrieb ohne Docker und ohne dauerhafte Worker ausgelegt. +## Empfohlener Weg Auf Webspace + +Wenn auf dem Hosting keine Shell- oder CLI-Ausfuehrung moeglich ist, nutze den +gefuehrten Installer im Public-Pfad: + +- `https://deine-domain.tld/install/` + +Der Installer kann: + +- die `.env` speichern +- das SQL-Bundle erzeugen +- Migrationen direkt per PHP ausfuehren, wenn `pdo_sqlsrv` verfuegbar ist +- sich nach erfolgreicher Einrichtung sperren + +Die CLI-Skripte unter `scripts/*.php` bleiben als Alternative fuer lokale +Vorbereitung oder Server mit Shell-Zugriff erhalten. + ## Voraussetzungen - PHP 8.2 oder neuer @@ -14,7 +31,7 @@ Worker ausgelegt. - optional: SMTP-Zugang fuer Mails - optional: OIDC-Provider fuer SSO -## Installation lokal +## Installation Lokal 1. Repository klonen. 2. Mit `php scripts/check-prerequisites.php` die Voraussetzungen pruefen. @@ -26,7 +43,7 @@ Worker ausgelegt. 8. Einen ersten Benutzer und eine Mitgliedszuordnung anlegen. 9. Spaeter Composer und das eigentliche Laravel-Bootstrap nachziehen. -## Migrationen ausfuehren +## Migrationen Ausfuehren Die aktuellen Dateien unter `saas-app/database/migrations/` sind SQL-Skizzen in PHP-Dateien, keine Laravel-Migrationsklassen. Deshalb gibt es aktuell bewusst @@ -70,9 +87,10 @@ eigene Umgebung angepasst werden: 1. Den Inhalt von `saas-app/` auf den Zielserver hochladen. 2. Das Document-Root auf `public/` zeigen lassen. -3. Schreibrechte fuer spaetere Storage-, Cache- und Queue-Bereiche sicherstellen. -4. `.env` serverseitig hinterlegen. -5. Die Anwendung einmal per Browser aufrufen und die Grundseiten pruefen. +3. Schreibrechte fuer `saas-app/`, `.env`, `.installer.lock` und `database/migrations/generated/` sicherstellen. +4. Den Installer unter `/install/` aufrufen und die Einrichtung durchfuehren. +5. Nach erfolgreicher Einrichtung den Installer sperren. +6. Die Anwendung einmal per Browser aufrufen und die Grundseiten pruefen. ## Cron- und Batch-Betrieb @@ -89,7 +107,7 @@ Typische Aufgaben: Empfehlung: pro Aufgabe einen klar benannten Cron-Eintrag anlegen und die Ausgabe in Logdateien schreiben. -## Migration aus dem Legacy-System +## Migration Aus Dem Legacy-System Wenn Daten aus der alten Root-Anwendung uebernommen werden sollen, folgt die Reihenfolge: diff --git a/saas-app/README.md b/saas-app/README.md index 3886871..3c7cfd7 100644 --- a/saas-app/README.md +++ b/saas-app/README.md @@ -19,14 +19,19 @@ Die komplette Installationsanleitung steht im Repo unter Kurzfassung: +1. Webserver auf `public/` ausrichten. +2. Im Browser `https://deine-domain.tld/install/` aufrufen. +3. `.env`, DB-Zugang und Tenancy-Werte ueber den Installer speichern. +4. SQL-Bundle erzeugen und wenn moeglich Migrationen direkt ausfuehren. +5. Installer sperren. +6. Einen ersten Mandanten und erste Benutzer anlegen. +7. Cron-Jobs fuer Queue, Import, Export und Benachrichtigungen einrichten. + +Alternative mit PHP-CLI: + 1. `php ../scripts/check-prerequisites.php` 2. `php ../scripts/install-saas.php` -3. `.env` aus `.env.example` ableiten und anpassen. -4. Datenbank und Tenancy-Werte konfigurieren. -5. SQL-Migrationen ueber das erzeugte Bundle ausfuehren. -6. Einen ersten Mandanten und erste Benutzer anlegen. -7. Den Webserver auf `public/` ausrichten. -8. Cron-Jobs fuer Queue, Import, Export und Benachrichtigungen einrichten. +3. `php ../scripts/run-sql-migrations.php --server=... --database=...` ## Migrationen diff --git a/saas-app/database/migrations/generated/all-migrations.sql b/saas-app/database/migrations/generated/all-migrations.sql index a4c29d1..0212ece 100644 --- a/saas-app/database/migrations/generated/all-migrations.sql +++ b/saas-app/database/migrations/generated/all-migrations.sql @@ -1,5 +1,5 @@ -- Generated migration bundle for Kaffeeliste SaaS --- Generated at 2026-03-21T18:18:09+00:00 +-- Generated at 2026-03-21T19:14:14+00:00 SET XACT_ABORT ON; BEGIN TRANSACTION; diff --git a/saas-app/public/README.md b/saas-app/public/README.md index 63f6fd1..ed3a932 100644 --- a/saas-app/public/README.md +++ b/saas-app/public/README.md @@ -3,6 +3,7 @@ Dieses Verzeichnis ist fuer den Webserver-Document-Root vorgesehen. Aktuell enthaelt es bereits: - `index.php` als Preview-Einstieg fuer die neue SaaS-Struktur +- `install/index.php` als gefuehrten Einmal-Installer fuer Webspace - `.htaccess` fuer einen einfachen Front-Controller-Pfad auf Apache Sobald die Zielanwendung als vollwertiges Laravel-Projekt gebootstrapped ist, diff --git a/saas-app/public/index.php b/saas-app/public/index.php index ee4a36a..7565637 100644 --- a/saas-app/public/index.php +++ b/saas-app/public/index.php @@ -38,11 +38,11 @@ $modules = [ ]; $installSteps = [ - '1. `php scripts/check-prerequisites.php` ausfuehren.', - '2. `php scripts/prepare-saas-env.php` fuer die lokale `.env` verwenden.', - '3. `saas-app\.env` mit echten DB-, Mail-, Tenancy- und OIDC-Werten fuellen.', - '4. `php scripts/install-saas.php` fuer die SQL-Bundle-Erzeugung verwenden.', - '5. Optional `php scripts/run-sql-migrations.php --server= --database= --username= --password=` fuer die direkte SQL-Ausfuehrung nutzen.', + '1. Den Webserver auf `saas-app/public/` zeigen lassen.', + '2. Den Installer unter `/install/` oeffnen.', + '3. `.env`, DB-, Mail-, Tenancy- und OIDC-Werte im Formular speichern.', + '4. SQL-Bundle erzeugen und optional Migrationen direkt im Installer ausfuehren.', + '5. Den Installer danach sperren.', '6. Ersten Tenant, ersten Benutzer und erste Member-Zuordnung anlegen.', ]; @@ -289,6 +289,7 @@ function renderList(array $items): void Start Installation Migrationen + Installer @@ -297,13 +298,13 @@ function renderList(array $items): void
Installation

Die Installationsschritte laufen jetzt ueber interne Preview-Seiten.

- Der Link zeigt nicht mehr auf docs/installationshandbuch.md, - sondern auf eine im Public-Bereich erreichbare Anleitung. Das ist noetig, - weil Dateien ausserhalb von saas-app/public/ im Browser nicht - direkt verlinkt werden sollten. + Der bevorzugte Webspace-Weg laeuft jetzt ueber den gefuehrten Installer + unter /install/. Das ist noetig, weil Dateien ausserhalb von + saas-app/public/ im Browser nicht direkt verlinkt werden + sollten und Shell-Zugriff auf vielen Hostings fehlt.

@@ -314,11 +315,11 @@ function renderList(array $items): void

Die Migrationen werden ueber ein SQL-Bundle erzeugt und ausgefuehrt.

Aktuell gibt es hier keine lauffaehige Laravel-artisan migrate-Strecke. - Die Migrationsdateien liefern SQL und werden deshalb ueber Skripte zu - einer ausfuehrbaren Datei zusammengefuehrt. + Die Migrationsdateien liefern SQL und werden deshalb im Installer oder + ueber PHP-Skripte zu einer ausfuehrbaren Datei zusammengefuehrt.

@@ -337,7 +338,7 @@ function renderList(array $items): void gestalteten Produktseiten liegen unter saas-app/.

@@ -371,6 +372,7 @@ function renderList(array $items): void
  • scripts/install-saas.php
  • scripts/build-migration-bundle.php
  • scripts/run-sql-migrations.php
  • +
  • public/install/index.php
  • @@ -379,6 +381,7 @@ function renderList(array $items): void
  • Composer ist fuer den finalen Bootstrap weiterhin erforderlich.
  • Die Migrationen sind aktuell SQL-basiert, nicht artisan-basiert.
  • Fuer automatische Ausfuehrung wird die PHP-Erweiterung pdo_sqlsrv benoetigt.
  • +
  • Der bevorzugte Hosting-Pfad ist jetzt /install/ als gefuehrter Einmal-Installer.
  • diff --git a/saas-app/public/install.php b/saas-app/public/install.php new file mode 100644 index 0000000..341e55e --- /dev/null +++ b/saas-app/public/install.php @@ -0,0 +1,453 @@ + $currentEnv['APP_URL'] ?? ($defaults['APP_URL'] ?? ''), + 'DB_HOST' => $currentEnv['DB_HOST'] ?? ($defaults['DB_HOST'] ?? '127.0.0.1'), + 'DB_PORT' => $currentEnv['DB_PORT'] ?? ($defaults['DB_PORT'] ?? '1433'), + 'DB_DATABASE' => $currentEnv['DB_DATABASE'] ?? ($defaults['DB_DATABASE'] ?? ''), + 'DB_USERNAME' => $currentEnv['DB_USERNAME'] ?? ($defaults['DB_USERNAME'] ?? ''), + 'DB_PASSWORD' => '', + 'TENANCY_MODE' => $currentEnv['TENANCY_MODE'] ?? ($defaults['TENANCY_MODE'] ?? 'subdomain'), + 'TENANCY_CENTRAL_DOMAINS' => $currentEnv['TENANCY_CENTRAL_DOMAINS'] ?? ($defaults['TENANCY_CENTRAL_DOMAINS'] ?? 'localhost'), + 'TENANCY_FALLBACK_TENANT' => $currentEnv['TENANCY_FALLBACK_TENANT'] ?? ($defaults['TENANCY_FALLBACK_TENANT'] ?? ''), + 'MAIL_HOST' => $currentEnv['MAIL_HOST'] ?? ($defaults['MAIL_HOST'] ?? '127.0.0.1'), + 'MAIL_PORT' => $currentEnv['MAIL_PORT'] ?? ($defaults['MAIL_PORT'] ?? '1025'), + 'MAIL_USERNAME' => $currentEnv['MAIL_USERNAME'] ?? ($defaults['MAIL_USERNAME'] ?? ''), + 'MAIL_PASSWORD' => '', + 'MAIL_FROM_ADDRESS' => $currentEnv['MAIL_FROM_ADDRESS'] ?? ($defaults['MAIL_FROM_ADDRESS'] ?? 'noreply@example.test'), + 'MAIL_FROM_NAME' => $currentEnv['MAIL_FROM_NAME'] ?? ($defaults['MAIL_FROM_NAME'] ?? 'KaffeelisteSaaS'), + 'OIDC_ENABLED' => $currentEnv['OIDC_ENABLED'] ?? ($defaults['OIDC_ENABLED'] ?? 'false'), + 'OIDC_DEFAULT_PROVIDER' => $currentEnv['OIDC_DEFAULT_PROVIDER'] ?? ($defaults['OIDC_DEFAULT_PROVIDER'] ?? ''), +]; + +$messages = []; +$errors = []; +$executedMigrations = []; +$bundlePath = is_file(scripts_bundle_output_path()) ? scripts_bundle_output_path() : null; +$locked = scripts_installer_is_locked(); + +if ($requestMethod === 'POST' && !$locked) { + $csrf = (string) ($_POST['csrf'] ?? ''); + + if (!hash_equals($_SESSION['installer_csrf'], $csrf)) { + $errors[] = 'Ungueltiger CSRF-Status. Seite neu laden.'; + } else { + foreach (array_keys($values) as $key) { + if (array_key_exists($key, $_POST)) { + $values[$key] = trim((string) $_POST[$key]); + } + } + + $values['OIDC_ENABLED'] = isset($_POST['OIDC_ENABLED']) ? 'true' : 'false'; + + if ($values['DB_PASSWORD'] === '' && isset($currentEnv['DB_PASSWORD'])) { + $values['DB_PASSWORD'] = $currentEnv['DB_PASSWORD']; + } + + if ($values['MAIL_PASSWORD'] === '' && isset($currentEnv['MAIL_PASSWORD'])) { + $values['MAIL_PASSWORD'] = $currentEnv['MAIL_PASSWORD']; + } + + foreach (['APP_URL', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME'] as $required) { + if ($values[$required] === '') { + $errors[] = $required . ' darf nicht leer sein.'; + } + } + + if ($errors === []) { + try { + scripts_write_env_file($values); + $messages[] = '.env wurde geschrieben.'; + + $bundlePath = scripts_build_migration_bundle(); + $messages[] = 'SQL-Bundle wurde erzeugt.'; + + if (isset($_POST['run_migrations'])) { + $executedMigrations = scripts_run_sql_migrations([ + 'server' => $values['DB_HOST'], + 'database' => $values['DB_DATABASE'], + 'port' => $values['DB_PORT'], + 'username' => $values['DB_USERNAME'], + 'password' => $values['DB_PASSWORD'], + ]); + $messages[] = 'Migrationen wurden direkt ueber PHP ausgefuehrt.'; + } + + if (isset($_POST['lock_installer'])) { + scripts_installer_lock([ + 'app_url' => $values['APP_URL'], + 'db_database' => $values['DB_DATABASE'], + ]); + $messages[] = 'Installer wurde gesperrt. Fuer eine erneute Ausfuehrung muss `saas-app/.installer.lock` entfernt werden.'; + $locked = true; + } + } catch (Throwable $exception) { + $message = $exception->getMessage(); + + if (str_contains($message, 'pdo_sqlsrv')) { + $errors[] = 'Migrationen konnten nicht direkt ueber PHP ausgefuehrt werden. Bitte `pdo_sqlsrv` pruefen oder das SQL-Bundle manuell importieren.'; + } elseif (str_contains($message, '.env')) { + $errors[] = 'Die Konfiguration konnte nicht gespeichert werden. Bitte Schreibrechte pruefen.'; + } else { + $errors[] = 'Die Installation konnte nicht abgeschlossen werden. Bitte Eingaben, DB-Zugang und Dateirechte pruefen.'; + } + } + } + } +} + +function h(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES); +} +?> + + + + + Kaffeeliste SaaS Installer + + + +
    +
    +
    One-time Installer
    +

    Kaffeeliste SaaS im Browser installieren.

    +

    + Diese Seite fuehrt die Erstinstallation ueber den Webspace: `.env` schreiben, + SQL-Bundle erzeugen und optional die Migrationen direkt ueber PHP ausfuehren. + Nach erfolgreicher Einrichtung sollte der Installer gesperrt bleiben. +

    + +
    + +
    +
    +

    Installation

    + + +
      + +
    • + +
    + + + +
      + +
    • + +
    + + + +
      +
    • Der Installer ist gesperrt.
    • +
    • Fuer eine erneute Ausfuehrung muss saas-app/.installer.lock entfernt werden.
    • +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + + + SQL-Bundle herunterladen + +
    +
    + +
    + + +
    +
    + + diff --git a/saas-app/public/install/index.php b/saas-app/public/install/index.php new file mode 100644 index 0000000..5c454e7 --- /dev/null +++ b/saas-app/public/install/index.php @@ -0,0 +1,5 @@ +getMessage()); } - -$files = glob($migrationDir . DIRECTORY_SEPARATOR . '*.php'); -sort($files, SORT_STRING); - -if ($files === []) { - fwrite(STDERR, "Keine Migrationsdateien gefunden." . PHP_EOL); - exit(1); -} - -$outputDir = dirname($outputPath); -if (!is_dir($outputDir) && !mkdir($outputDir, 0777, true) && !is_dir($outputDir)) { - fwrite(STDERR, "Ausgabeverzeichnis konnte nicht erstellt werden: {$outputDir}" . PHP_EOL); - exit(1); -} - -$bundle = []; -$bundle[] = '-- Generated migration bundle for Kaffeeliste SaaS'; -$bundle[] = '-- Generated at ' . date('c'); -$bundle[] = 'SET XACT_ABORT ON;'; -$bundle[] = 'BEGIN TRANSACTION;'; - -foreach ($files as $file) { - $sql = require $file; - - if (!is_string($sql) || trim($sql) === '') { - fwrite(STDERR, "Ungueltige Migration: {$file}" . PHP_EOL); - exit(1); - } - - $bundle[] = ''; - $bundle[] = '-- Migration: ' . basename($file); - $bundle[] = trim($sql); -} - -$bundle[] = ''; -$bundle[] = 'COMMIT TRANSACTION;'; -$bundle[] = ''; - -file_put_contents($outputPath, implode(PHP_EOL, $bundle)); - -fwrite(STDOUT, $outputPath . PHP_EOL); diff --git a/scripts/check-prerequisites.php b/scripts/check-prerequisites.php index bf57c57..ee4b7a8 100644 --- a/scripts/check-prerequisites.php +++ b/scripts/check-prerequisites.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/support.php'; $phpVersion = PHP_VERSION; $composerExists = scripts_check_command('composer'); $gitExists = scripts_check_command('git'); +$pdoSqlsrvLoaded = extension_loaded('pdo_sqlsrv'); scripts_stdout('Pruefe lokale Voraussetzungen fuer Kaffeeliste SaaS...'); scripts_stdout('Projektwurzel: ' . scripts_project_root()); @@ -16,6 +17,7 @@ $checks = [ ['PHP', true, $phpVersion], ['Composer', $composerExists, $composerExists ? 'verfuegbar' : 'nicht gefunden'], ['Git', $gitExists, $gitExists ? 'verfuegbar' : 'nicht gefunden'], + ['pdo_sqlsrv', $pdoSqlsrvLoaded, $pdoSqlsrvLoaded ? 'geladen' : 'nicht geladen'], ['SaaS-App', is_dir(scripts_saas_app_path()), scripts_saas_app_path()], ['.env.example', is_file(scripts_env_example_path()), scripts_env_example_path()], ['.env', is_file(scripts_env_path()), is_file(scripts_env_path()) ? 'vorhanden' : 'noch nicht angelegt'], diff --git a/scripts/install-saas.php b/scripts/install-saas.php index 3fa6099..3ef2427 100644 --- a/scripts/install-saas.php +++ b/scripts/install-saas.php @@ -6,7 +6,7 @@ require_once __DIR__ . '/support.php'; $options = scripts_parse_options($argv); $forceEnv = isset($options['force-env']); -$prepareEnv = isset($options['prepare-env']) || !is_file(scripts_env_path()); +$prepareEnv = $forceEnv || isset($options['prepare-env']) || !is_file(scripts_env_path()); scripts_stdout('Starte SaaS-Installationsvorbereitung...'); scripts_stdout(''); @@ -14,28 +14,27 @@ scripts_stdout(''); require __DIR__ . '/check-prerequisites.php'; if ($prepareEnv) { - $command = 'php ' . escapeshellarg(__DIR__ . '/prepare-saas-env.php'); - - if ($forceEnv) { - $command .= ' --force'; + $envValues = scripts_read_env_file(scripts_env_example_path()); + try { + scripts_write_env_file($envValues, scripts_env_path()); + scripts_stdout('Lokale .env wurde angelegt: ' . scripts_env_path()); + } catch (Throwable $exception) { + scripts_stderr($exception->getMessage()); } - passthru($command, $exitCode); - - if ($exitCode !== 0) { - exit($exitCode); + if ($forceEnv) { + scripts_stdout('Hinweis: vorhandene .env wurde bewusst ueberschrieben.'); } } -$bundlePath = scripts_project_root() . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR . 'generated' . DIRECTORY_SEPARATOR . 'all-migrations.sql'; -$command = 'php ' . escapeshellarg(__DIR__ . '/build-migration-bundle.php') . ' ' . escapeshellarg($bundlePath); -passthru($command, $exitCode); - -if ($exitCode !== 0) { - exit($exitCode); +try { + $bundlePath = scripts_build_migration_bundle(); +} catch (Throwable $exception) { + scripts_stderr($exception->getMessage()); } scripts_stdout(''); +scripts_stdout('SQL-Bundle erzeugt: ' . $bundlePath); scripts_stdout('Naechste Schritte:'); scripts_stdout('1. saas-app/.env mit echten DB-, Mail- und Tenancy-Werten fuellen.'); scripts_stdout('2. Das SQL-Bundle manuell importieren oder `php scripts/run-sql-migrations.php --server=... --database=...` verwenden.'); diff --git a/scripts/run-sql-migrations.php b/scripts/run-sql-migrations.php index 52b0a24..1a927ee 100644 --- a/scripts/run-sql-migrations.php +++ b/scripts/run-sql-migrations.php @@ -7,54 +7,17 @@ require_once __DIR__ . '/support.php'; $options = scripts_parse_options($argv); $env = scripts_read_env_file(scripts_env_path()); -$server = $options['server'] ?? $env['DB_HOST'] ?? null; -$database = $options['database'] ?? $env['DB_DATABASE'] ?? null; -$port = $options['port'] ?? $env['DB_PORT'] ?? '1433'; -$username = $options['username'] ?? $env['DB_USERNAME'] ?? null; -$password = $options['password'] ?? $env['DB_PASSWORD'] ?? null; - -if ($server === null || $database === null) { - scripts_stderr('Bitte --server und --database angeben oder eine passende saas-app/.env pflegen.'); -} - -if (!extension_loaded('pdo_sqlsrv')) { - scripts_stderr('Die PHP-Erweiterung pdo_sqlsrv ist nicht geladen. Fuehre das Bundle manuell aus oder aktiviere den Treiber.'); -} - -$migrationDir = scripts_project_root() . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations'; -$files = glob($migrationDir . DIRECTORY_SEPARATOR . '*.php'); -sort($files, SORT_STRING); - -if ($files === []) { - scripts_stderr('Keine Migrationsdateien gefunden.'); -} - -$dsn = sprintf('sqlsrv:Server=%s,%s;Database=%s;TrustServerCertificate=1', $server, $port, $database); +$config = [ + 'server' => $options['server'] ?? $env['DB_HOST'] ?? null, + 'database' => $options['database'] ?? $env['DB_DATABASE'] ?? null, + 'port' => $options['port'] ?? $env['DB_PORT'] ?? '1433', + 'username' => $options['username'] ?? $env['DB_USERNAME'] ?? null, + 'password' => $options['password'] ?? $env['DB_PASSWORD'] ?? null, +]; try { - $pdo = new PDO($dsn, $username, $password, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - ]); - - $pdo->beginTransaction(); - - foreach ($files as $file) { - $sql = require $file; - - if (!is_string($sql) || trim($sql) === '') { - throw new RuntimeException('Ungueltige Migration: ' . basename($file)); - } - - scripts_stdout('Fuehre aus: ' . basename($file)); - $pdo->exec($sql); - } - - $pdo->commit(); + scripts_run_sql_migrations($config); scripts_stdout('Migrationen wurden erfolgreich ausgefuehrt.'); } catch (Throwable $exception) { - if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) { - $pdo->rollBack(); - } - scripts_stderr('Migration fehlgeschlagen: ' . $exception->getMessage()); } diff --git a/scripts/support.php b/scripts/support.php index d74552a..ffecd24 100644 --- a/scripts/support.php +++ b/scripts/support.php @@ -22,6 +22,20 @@ function scripts_env_path(): string return scripts_saas_app_path() . DIRECTORY_SEPARATOR . '.env'; } +function scripts_bundle_output_path(): string +{ + return scripts_saas_app_path() + . DIRECTORY_SEPARATOR . 'database' + . DIRECTORY_SEPARATOR . 'migrations' + . DIRECTORY_SEPARATOR . 'generated' + . DIRECTORY_SEPARATOR . 'all-migrations.sql'; +} + +function scripts_installer_lock_path(): string +{ + return scripts_saas_app_path() . DIRECTORY_SEPARATOR . '.installer.lock'; +} + function scripts_check_command(string $command): bool { $where = stripos(PHP_OS_FAMILY, 'Windows') === 0 ? 'where' : 'command -v'; @@ -78,6 +92,187 @@ function scripts_read_env_file(string $path): array return $values; } +function scripts_write_env_file(array $values, ?string $targetPath = null): string +{ + $targetPath ??= scripts_env_path(); + $templateLines = file(scripts_env_example_path(), FILE_IGNORE_NEW_LINES); + + if ($templateLines === false) { + throw new RuntimeException('Die .env.example konnte nicht gelesen werden.'); + } + + $output = []; + + foreach ($templateLines as $line) { + if (!str_contains($line, '=')) { + $output[] = $line; + continue; + } + + [$key] = explode('=', $line, 2); + $trimmedKey = trim($key); + + if (!array_key_exists($trimmedKey, $values)) { + $output[] = $line; + continue; + } + + $output[] = $trimmedKey . '=' . scripts_format_env_value((string) $values[$trimmedKey]); + unset($values[$trimmedKey]); + } + + foreach ($values as $key => $value) { + $output[] = $key . '=' . scripts_format_env_value((string) $value); + } + + if (file_put_contents($targetPath, implode(PHP_EOL, $output) . PHP_EOL) === false) { + throw new RuntimeException('Die .env konnte nicht geschrieben werden: ' . $targetPath); + } + + return $targetPath; +} + +function scripts_format_env_value(string $value): string +{ + if ($value === '') { + return ''; + } + + if (preg_match('/\s|#|"|=/', $value) === 1) { + return '"' . addcslashes($value, "\\\"") . '"'; + } + + return $value; +} + +function scripts_build_migration_bundle(?string $outputPath = null): string +{ + $projectRoot = scripts_project_root(); + $migrationDir = $projectRoot . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations'; + $outputPath ??= scripts_bundle_output_path(); + + if (!is_dir($migrationDir)) { + throw new RuntimeException("Migrationsverzeichnis nicht gefunden: {$migrationDir}"); + } + + $files = glob($migrationDir . DIRECTORY_SEPARATOR . '*.php'); + sort($files, SORT_STRING); + + if ($files === []) { + throw new RuntimeException('Keine Migrationsdateien gefunden.'); + } + + $outputDir = dirname($outputPath); + if (!is_dir($outputDir) && !mkdir($outputDir, 0777, true) && !is_dir($outputDir)) { + throw new RuntimeException("Ausgabeverzeichnis konnte nicht erstellt werden: {$outputDir}"); + } + + $bundle = []; + $bundle[] = '-- Generated migration bundle for Kaffeeliste SaaS'; + $bundle[] = '-- Generated at ' . date('c'); + $bundle[] = 'SET XACT_ABORT ON;'; + $bundle[] = 'BEGIN TRANSACTION;'; + + foreach ($files as $file) { + $sql = require $file; + + if (!is_string($sql) || trim($sql) === '') { + throw new RuntimeException("Ungueltige Migration: {$file}"); + } + + $bundle[] = ''; + $bundle[] = '-- Migration: ' . basename($file); + $bundle[] = trim($sql); + } + + $bundle[] = ''; + $bundle[] = 'COMMIT TRANSACTION;'; + $bundle[] = ''; + + if (file_put_contents($outputPath, implode(PHP_EOL, $bundle)) === false) { + throw new RuntimeException("Das SQL-Bundle konnte nicht geschrieben werden: {$outputPath}"); + } + + return $outputPath; +} + +function scripts_run_sql_migrations(array $config): array +{ + $server = $config['server'] ?? null; + $database = $config['database'] ?? null; + $port = (string) ($config['port'] ?? '1433'); + $username = $config['username'] ?? null; + $password = $config['password'] ?? null; + + if ($server === null || $database === null) { + throw new RuntimeException('Bitte Server und Datenbank angeben.'); + } + + if (!extension_loaded('pdo_sqlsrv')) { + throw new RuntimeException('Die PHP-Erweiterung pdo_sqlsrv ist nicht geladen.'); + } + + $migrationDir = scripts_project_root() . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations'; + $files = glob($migrationDir . DIRECTORY_SEPARATOR . '*.php'); + sort($files, SORT_STRING); + + if ($files === []) { + throw new RuntimeException('Keine Migrationsdateien gefunden.'); + } + + $dsn = sprintf('sqlsrv:Server=%s,%s;Database=%s;TrustServerCertificate=1', $server, $port, $database); + $pdo = new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + + $pdo->beginTransaction(); + + $executed = []; + + try { + foreach ($files as $file) { + $sql = require $file; + + if (!is_string($sql) || trim($sql) === '') { + throw new RuntimeException('Ungueltige Migration: ' . basename($file)); + } + + $pdo->exec($sql); + $executed[] = basename($file); + } + + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } + + return $executed; +} + +function scripts_installer_is_locked(): bool +{ + return is_file(scripts_installer_lock_path()); +} + +function scripts_installer_lock(array $meta = []): string +{ + $payload = json_encode([ + 'locked_at' => date('c'), + 'meta' => $meta, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + $path = scripts_installer_lock_path(); + if (file_put_contents($path, $payload . PHP_EOL) === false) { + throw new RuntimeException('Die Installer-Sperrdatei konnte nicht geschrieben werden.'); + } + + return $path; +} + function scripts_stdout(string $message): void { fwrite(STDOUT, $message . PHP_EOL);