Add guided installer for SaaS application setup
- Updated README.md to include instructions for guided installation on webspace without shell access. - Enhanced installationshandbuch.md with detailed steps for using the guided installer. - Modified saas-app/README.md to summarize the installation process using the new installer. - Generated SQL migration bundle now includes timestamp for better tracking. - Introduced public/install/index.php as the entry point for the guided installer. - Updated public/index.php to reflect changes in installation steps and added links to the installer. - Refactored migration bundle generation and SQL execution scripts for improved error handling and modularity. - Implemented session-based CSRF protection in the installer. - Added form handling for environment variable configuration and migration execution in the installer. - Created a user-friendly HTML interface for the installer with status messages and error handling.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+11
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+17
-14
@@ -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=<server> --database=<db> --username=<user> --password=<pass>` 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
|
||||
<a href="?page=home">Start</a>
|
||||
<a href="?page=install">Installation</a>
|
||||
<a href="?page=migrations">Migrationen</a>
|
||||
<a href="install/">Installer</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -297,13 +298,13 @@ function renderList(array $items): void
|
||||
<div class="eyebrow">Installation</div>
|
||||
<h1>Die Installationsschritte laufen jetzt ueber interne Preview-Seiten.</h1>
|
||||
<p>
|
||||
Der Link zeigt nicht mehr auf <code>docs/installationshandbuch.md</code>,
|
||||
sondern auf eine im Public-Bereich erreichbare Anleitung. Das ist noetig,
|
||||
weil Dateien ausserhalb von <code>saas-app/public/</code> im Browser nicht
|
||||
direkt verlinkt werden sollten.
|
||||
Der bevorzugte Webspace-Weg laeuft jetzt ueber den gefuehrten Installer
|
||||
unter <code>/install/</code>. Das ist noetig, weil Dateien ausserhalb von
|
||||
<code>saas-app/public/</code> im Browser nicht direkt verlinkt werden
|
||||
sollten und Shell-Zugriff auf vielen Hostings fehlt.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a class="button" href="?page=migrations">Migrationen ansehen</a>
|
||||
<a class="button" href="install/">Installer starten</a>
|
||||
<a class="button secondary" href="?page=home">Zur Startseite</a>
|
||||
</div>
|
||||
<?php renderList($installSteps); ?>
|
||||
@@ -314,11 +315,11 @@ function renderList(array $items): void
|
||||
<h1>Die Migrationen werden ueber ein SQL-Bundle erzeugt und ausgefuehrt.</h1>
|
||||
<p>
|
||||
Aktuell gibt es hier keine lauffaehige Laravel-<code>artisan migrate</code>-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.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a class="button" href="?page=install">Installationsschritte</a>
|
||||
<a class="button" href="install/">Installer starten</a>
|
||||
<a class="button secondary" href="?page=home">Zur Startseite</a>
|
||||
</div>
|
||||
<?php renderList($migrationSteps); ?>
|
||||
@@ -337,7 +338,7 @@ function renderList(array $items): void
|
||||
gestalteten Produktseiten liegen unter <code>saas-app/</code>.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a class="button" href="?page=install">Installation ansehen</a>
|
||||
<a class="button" href="install/">Installer starten</a>
|
||||
<a class="button secondary" href="?page=migrations">Migrationen ansehen</a>
|
||||
</div>
|
||||
<div class="meta">
|
||||
@@ -371,6 +372,7 @@ function renderList(array $items): void
|
||||
<li><code>scripts/install-saas.php</code></li>
|
||||
<li><code>scripts/build-migration-bundle.php</code></li>
|
||||
<li><code>scripts/run-sql-migrations.php</code></li>
|
||||
<li><code>public/install/index.php</code></li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="card">
|
||||
@@ -379,6 +381,7 @@ function renderList(array $items): void
|
||||
<li>Composer ist fuer den finalen Bootstrap weiterhin erforderlich.</li>
|
||||
<li>Die Migrationen sind aktuell SQL-basiert, nicht <code>artisan</code>-basiert.</li>
|
||||
<li>Fuer automatische Ausfuehrung wird die PHP-Erweiterung <code>pdo_sqlsrv</code> benoetigt.</li>
|
||||
<li>Der bevorzugte Hosting-Pfad ist jetzt <code>/install/</code> als gefuehrter Einmal-Installer.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/scripts/support.php';
|
||||
|
||||
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
|
||||
if (isset($_GET['download']) && $_GET['download'] === 'bundle' && is_file(scripts_bundle_output_path())) {
|
||||
header('Content-Type: application/sql; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="all-migrations.sql"');
|
||||
readfile(scripts_bundle_output_path());
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['installer_csrf'])) {
|
||||
$_SESSION['installer_csrf'] = bin2hex(random_bytes(24));
|
||||
}
|
||||
|
||||
$defaults = scripts_read_env_file(scripts_env_example_path());
|
||||
$currentEnv = scripts_read_env_file(scripts_env_path());
|
||||
$values = [
|
||||
'APP_URL' => $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);
|
||||
}
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kaffeeliste SaaS Installer</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f1e8;
|
||||
--ink: #24170f;
|
||||
--muted: #6a5649;
|
||||
--brand: #0f766e;
|
||||
--accent: #b45309;
|
||||
--danger: #b42318;
|
||||
--ok: #027a48;
|
||||
--card: rgba(255, 252, 247, 0.92);
|
||||
--line: rgba(36, 23, 15, 0.12);
|
||||
--shadow: 0 24px 60px rgba(61, 38, 24, 0.12);
|
||||
--radius: 24px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Aptos", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(180, 83, 9, 0.16), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(15, 118, 110, 0.12), transparent 22%),
|
||||
linear-gradient(180deg, #fbf7f0 0%, var(--bg) 100%);
|
||||
}
|
||||
.shell {
|
||||
width: min(1100px, calc(100vw - 32px));
|
||||
margin: 24px auto 40px;
|
||||
}
|
||||
.hero, .panel {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero {
|
||||
padding: 28px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: 1.35fr 0.85fr;
|
||||
}
|
||||
.panel {
|
||||
padding: 24px;
|
||||
}
|
||||
h1, h2 {
|
||||
margin: 0 0 12px;
|
||||
font-family: Georgia, serif;
|
||||
line-height: 1.05;
|
||||
}
|
||||
h1 { font-size: clamp(2rem, 4vw, 3.4rem); }
|
||||
h2 { font-size: 1.35rem; }
|
||||
p, li {
|
||||
color: var(--muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
margin-bottom: 12px;
|
||||
color: var(--accent);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.message-list, .status-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.message-list li + li,
|
||||
.status-list li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ok { color: var(--ok); }
|
||||
.error { color: var(--danger); }
|
||||
form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.field.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
label {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(36, 23, 15, 0.16);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.checkbox {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.checkbox input {
|
||||
width: auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
button, .link-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
button {
|
||||
background: linear-gradient(135deg, var(--brand), #115e59);
|
||||
color: #fff;
|
||||
}
|
||||
.link-button {
|
||||
color: var(--brand);
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
}
|
||||
code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: var(--brand);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid,
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div class="eyebrow">One-time Installer</div>
|
||||
<h1>Kaffeeliste SaaS im Browser installieren.</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div class="actions" style="margin-top: 18px;">
|
||||
<a class="link-button" href="/index.php">Zur Preview</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<section class="panel">
|
||||
<h2>Installation</h2>
|
||||
|
||||
<?php if ($messages !== []): ?>
|
||||
<ul class="message-list">
|
||||
<?php foreach ($messages as $message): ?>
|
||||
<li class="ok"><?= h($message) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($errors !== []): ?>
|
||||
<ul class="message-list">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<li class="error"><?= h($error) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($locked): ?>
|
||||
<ul class="status-list">
|
||||
<li>Der Installer ist gesperrt.</li>
|
||||
<li>Fuer eine erneute Ausfuehrung muss <code>saas-app/.installer.lock</code> entfernt werden.</li>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf" value="<?= h($_SESSION['installer_csrf']) ?>">
|
||||
|
||||
<div class="field-grid">
|
||||
<div class="field full">
|
||||
<label for="APP_URL">APP_URL</label>
|
||||
<input id="APP_URL" name="APP_URL" value="<?= h($values['APP_URL']) ?>" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="DB_HOST">DB_HOST</label>
|
||||
<input id="DB_HOST" name="DB_HOST" value="<?= h($values['DB_HOST']) ?>" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="DB_PORT">DB_PORT</label>
|
||||
<input id="DB_PORT" name="DB_PORT" value="<?= h($values['DB_PORT']) ?>" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="DB_DATABASE">DB_DATABASE</label>
|
||||
<input id="DB_DATABASE" name="DB_DATABASE" value="<?= h($values['DB_DATABASE']) ?>" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="DB_USERNAME">DB_USERNAME</label>
|
||||
<input id="DB_USERNAME" name="DB_USERNAME" value="<?= h($values['DB_USERNAME']) ?>" required>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="DB_PASSWORD">DB_PASSWORD</label>
|
||||
<input id="DB_PASSWORD" name="DB_PASSWORD" type="password" value="<?= h($values['DB_PASSWORD']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="TENANCY_MODE">TENANCY_MODE</label>
|
||||
<select id="TENANCY_MODE" name="TENANCY_MODE">
|
||||
<option value="subdomain"<?= $values['TENANCY_MODE'] === 'subdomain' ? ' selected' : '' ?>>subdomain</option>
|
||||
<option value="path"<?= $values['TENANCY_MODE'] === 'path' ? ' selected' : '' ?>>path</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="TENANCY_CENTRAL_DOMAINS">TENANCY_CENTRAL_DOMAINS</label>
|
||||
<input id="TENANCY_CENTRAL_DOMAINS" name="TENANCY_CENTRAL_DOMAINS" value="<?= h($values['TENANCY_CENTRAL_DOMAINS']) ?>">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="TENANCY_FALLBACK_TENANT">TENANCY_FALLBACK_TENANT</label>
|
||||
<input id="TENANCY_FALLBACK_TENANT" name="TENANCY_FALLBACK_TENANT" value="<?= h($values['TENANCY_FALLBACK_TENANT']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="MAIL_HOST">MAIL_HOST</label>
|
||||
<input id="MAIL_HOST" name="MAIL_HOST" value="<?= h($values['MAIL_HOST']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="MAIL_PORT">MAIL_PORT</label>
|
||||
<input id="MAIL_PORT" name="MAIL_PORT" value="<?= h($values['MAIL_PORT']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="MAIL_USERNAME">MAIL_USERNAME</label>
|
||||
<input id="MAIL_USERNAME" name="MAIL_USERNAME" value="<?= h($values['MAIL_USERNAME']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="MAIL_PASSWORD">MAIL_PASSWORD</label>
|
||||
<input id="MAIL_PASSWORD" name="MAIL_PASSWORD" type="password" value="<?= h($values['MAIL_PASSWORD']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="MAIL_FROM_ADDRESS">MAIL_FROM_ADDRESS</label>
|
||||
<input id="MAIL_FROM_ADDRESS" name="MAIL_FROM_ADDRESS" value="<?= h($values['MAIL_FROM_ADDRESS']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="MAIL_FROM_NAME">MAIL_FROM_NAME</label>
|
||||
<input id="MAIL_FROM_NAME" name="MAIL_FROM_NAME" value="<?= h($values['MAIL_FROM_NAME']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="OIDC_DEFAULT_PROVIDER">OIDC_DEFAULT_PROVIDER</label>
|
||||
<input id="OIDC_DEFAULT_PROVIDER" name="OIDC_DEFAULT_PROVIDER" value="<?= h($values['OIDC_DEFAULT_PROVIDER']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>OIDC_ENABLED</label>
|
||||
<label class="checkbox">
|
||||
<input name="OIDC_ENABLED" type="checkbox"<?= $values['OIDC_ENABLED'] === 'true' ? ' checked' : '' ?>>
|
||||
<span>OIDC aktivieren</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<label class="checkbox">
|
||||
<input name="run_migrations" type="checkbox"<?= extension_loaded('pdo_sqlsrv') ? '' : ' disabled' ?>>
|
||||
<span>Migrationen direkt ueber PHP ausfuehren<?= extension_loaded('pdo_sqlsrv') ? '' : ' (pdo_sqlsrv fehlt aktuell)' ?></span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input name="lock_installer" type="checkbox" checked>
|
||||
<span>Installer nach erfolgreicher Einrichtung sperren</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Installation starten</button>
|
||||
<?php if ($bundlePath !== null): ?>
|
||||
<a class="link-button" href="?download=bundle">SQL-Bundle herunterladen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<h2>Status</h2>
|
||||
<ul class="status-list">
|
||||
<li>PHP Version: <code><?= h(PHP_VERSION) ?></code></li>
|
||||
<li>.env.example: <code><?= is_file(scripts_env_example_path()) ? 'vorhanden' : 'fehlt' ?></code></li>
|
||||
<li>.env: <code><?= is_file(scripts_env_path()) ? 'vorhanden' : 'fehlt' ?></code></li>
|
||||
<li>pdo_sqlsrv: <code><?= extension_loaded('pdo_sqlsrv') ? 'aktiv' : 'nicht geladen' ?></code></li>
|
||||
<li>Installer-Lock: <code><?= $locked ? 'aktiv' : 'offen' ?></code></li>
|
||||
<li>Bundle-Pfad: <code><?= h(scripts_bundle_output_path()) ?></code></li>
|
||||
</ul>
|
||||
|
||||
<?php if ($bundlePath !== null): ?>
|
||||
<p style="margin-top: 18px;">Erzeugtes Bundle: <code><?= h($bundlePath) ?></code></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($executedMigrations !== []): ?>
|
||||
<p style="margin-top: 18px;">Ausgefuehrte Migrationen:</p>
|
||||
<ul class="status-list">
|
||||
<?php foreach ($executedMigrations as $file): ?>
|
||||
<li><code><?= h($file) ?></code></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/install.php';
|
||||
@@ -2,54 +2,12 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$projectRoot = dirname(__DIR__);
|
||||
$migrationDir = $projectRoot . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations';
|
||||
$outputPath = $argv[1] ?? (
|
||||
$projectRoot . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR . 'generated' . DIRECTORY_SEPARATOR . 'all-migrations.sql'
|
||||
);
|
||||
require_once __DIR__ . '/support.php';
|
||||
|
||||
if (!is_dir($migrationDir)) {
|
||||
fwrite(STDERR, "Migrationsverzeichnis nicht gefunden: {$migrationDir}" . PHP_EOL);
|
||||
exit(1);
|
||||
$outputPath = $argv[1] ?? scripts_bundle_output_path();
|
||||
|
||||
try {
|
||||
scripts_stdout(scripts_build_migration_bundle($outputPath));
|
||||
} catch (Throwable $exception) {
|
||||
scripts_stderr($exception->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);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
+14
-15
@@ -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.');
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user