feat: Add install-support.php and improve installer error handling

This commit is contained in:
2026-03-21 20:25:31 +01:00
parent cb6e4e7dcb
commit 2d2f80fe76
4 changed files with 332 additions and 286 deletions
@@ -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;
+317
View File
@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
function scripts_project_root(): string
{
return dirname(__DIR__);
}
function scripts_saas_app_path(): string
{
return __DIR__;
}
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_string_contains(string $haystack, string $needle): bool
{
if (function_exists('str_contains')) {
return str_contains($haystack, $needle);
}
return $needle === '' || strpos($haystack, $needle) !== false;
}
function scripts_string_starts_with(string $haystack, string $needle): bool
{
if (function_exists('str_starts_with')) {
return str_starts_with($haystack, $needle);
}
if ($needle === '') {
return true;
}
return strpos($haystack, $needle) === 0;
}
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 (!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);
}
+13 -4
View File
@@ -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.
</p>
<div class="actions" style="margin-top: 18px;">
<a class="link-button" href="/index.php">Zur Preview</a>
<a class="link-button" href="./../index.php">Zur Preview</a>
</div>
</section>
+1 -281
View File
@@ -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';