From 2d2f80fe7624ddc67c26be1f20280fb048ac72ab Mon Sep 17 00:00:00 2001
From: Clemens Creutzburg
Date: Sat, 21 Mar 2026 20:25:31 +0100
Subject: [PATCH] feat: Add install-support.php and improve installer error
handling
---
.../migrations/generated/all-migrations.sql | 2 +-
saas-app/install-support.php | 317 ++++++++++++++++++
saas-app/public/install.php | 17 +-
scripts/support.php | 282 +---------------
4 files changed, 332 insertions(+), 286 deletions(-)
create mode 100644 saas-app/install-support.php
diff --git a/saas-app/database/migrations/generated/all-migrations.sql b/saas-app/database/migrations/generated/all-migrations.sql
index 0212ece..d7e6140 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-21T19:14:14+00:00
+-- Generated at 2026-03-21T19:24:13+00:00
SET XACT_ABORT ON;
BEGIN TRANSACTION;
diff --git a/saas-app/install-support.php b/saas-app/install-support.php
new file mode 100644
index 0000000..d3f6e88
--- /dev/null
+++ b/saas-app/install-support.php
@@ -0,0 +1,317 @@
+NUL' : ' 2>/dev/null';
+ @exec($where . ' ' . escapeshellarg($command) . $redirect, $output, $exitCode);
+
+ return $exitCode === 0;
+}
+
+function scripts_parse_options(array $argv): array
+{
+ $options = [];
+
+ foreach (array_slice($argv, 1) as $arg) {
+ if (!scripts_string_starts_with($arg, '--')) {
+ continue;
+ }
+
+ $arg = substr($arg, 2);
+
+ if (scripts_string_contains($arg, '=')) {
+ list($key, $value) = explode('=', $arg, 2);
+ $options[$key] = $value;
+ continue;
+ }
+
+ $options[$arg] = true;
+ }
+
+ return $options;
+}
+
+function scripts_read_env_file(string $path): array
+{
+ if (!is_file($path)) {
+ return [];
+ }
+
+ $values = [];
+
+ foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
+ $trimmed = trim($line);
+
+ if ($trimmed === '' || scripts_string_starts_with($trimmed, '#') || !scripts_string_contains($trimmed, '=')) {
+ continue;
+ }
+
+ list($key, $value) = explode('=', $trimmed, 2);
+ $values[trim($key)] = trim($value, " \t\n\r\0\x0B\"");
+ }
+
+ return $values;
+}
+
+function scripts_write_env_file(array $values, ?string $targetPath = null): string
+{
+ if ($targetPath === null) {
+ $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 (!scripts_string_contains($line, '=')) {
+ $output[] = $line;
+ continue;
+ }
+
+ list($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
+{
+ $migrationDir = scripts_saas_app_path()
+ . DIRECTORY_SEPARATOR . 'database'
+ . DIRECTORY_SEPARATOR . 'migrations';
+
+ if ($outputPath === null) {
+ $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 = isset($config['server']) ? $config['server'] : null;
+ $database = isset($config['database']) ? $config['database'] : null;
+ $port = isset($config['port']) ? (string) $config['port'] : '1433';
+ $username = isset($config['username']) ? $config['username'] : null;
+ $password = isset($config['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_saas_app_path()
+ . 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);
+}
+
+function scripts_stderr(string $message, int $exitCode = 1): void
+{
+ fwrite(STDERR, $message . PHP_EOL);
+ exit($exitCode);
+}
diff --git a/saas-app/public/install.php b/saas-app/public/install.php
index 341e55e..852ac91 100644
--- a/saas-app/public/install.php
+++ b/saas-app/public/install.php
@@ -4,7 +4,16 @@ declare(strict_types=1);
session_start();
-require_once dirname(__DIR__, 2) . '/scripts/support.php';
+$bootstrapPath = dirname(__DIR__) . '/install-support.php';
+
+if (!is_file($bootstrapPath)) {
+ http_response_code(500);
+ header('Content-Type: text/plain; charset=utf-8');
+ echo "Installer-Basis nicht gefunden: " . $bootstrapPath;
+ exit;
+}
+
+require_once $bootstrapPath;
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
@@ -105,9 +114,9 @@ if ($requestMethod === 'POST' && !$locked) {
} catch (Throwable $exception) {
$message = $exception->getMessage();
- if (str_contains($message, 'pdo_sqlsrv')) {
+ if (scripts_string_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')) {
+ } elseif (scripts_string_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.';
@@ -292,7 +301,7 @@ function h(string $value): string
Nach erfolgreicher Einrichtung sollte der Installer gesperrt bleiben.
diff --git a/scripts/support.php b/scripts/support.php
index ffecd24..c254d22 100644
--- a/scripts/support.php
+++ b/scripts/support.php
@@ -2,284 +2,4 @@
declare(strict_types=1);
-function scripts_project_root(): string
-{
- return dirname(__DIR__);
-}
-
-function scripts_saas_app_path(): string
-{
- return scripts_project_root() . DIRECTORY_SEPARATOR . 'saas-app';
-}
-
-function scripts_env_example_path(): string
-{
- return scripts_saas_app_path() . DIRECTORY_SEPARATOR . '.env.example';
-}
-
-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';
- $output = [];
- $exitCode = 0;
- $redirect = stripos(PHP_OS_FAMILY, 'Windows') === 0 ? ' 2>NUL' : ' 2>/dev/null';
- @exec($where . ' ' . escapeshellarg($command) . $redirect, $output, $exitCode);
-
- return $exitCode === 0;
-}
-
-function scripts_parse_options(array $argv): array
-{
- $options = [];
-
- foreach (array_slice($argv, 1) as $arg) {
- if (!str_starts_with($arg, '--')) {
- continue;
- }
-
- $arg = substr($arg, 2);
-
- if (str_contains($arg, '=')) {
- [$key, $value] = explode('=', $arg, 2);
- $options[$key] = $value;
- continue;
- }
-
- $options[$arg] = true;
- }
-
- return $options;
-}
-
-function scripts_read_env_file(string $path): array
-{
- if (!is_file($path)) {
- return [];
- }
-
- $values = [];
-
- foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
- $trimmed = trim($line);
-
- if ($trimmed === '' || str_starts_with($trimmed, '#') || !str_contains($trimmed, '=')) {
- continue;
- }
-
- [$key, $value] = explode('=', $trimmed, 2);
- $values[trim($key)] = trim($value, " \t\n\r\0\x0B\"");
- }
-
- 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);
-}
-
-function scripts_stderr(string $message, int $exitCode = 1): never
-{
- fwrite(STDERR, $message . PHP_EOL);
- exit($exitCode);
-}
+require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'install-support.php';