Compare commits

...
Author SHA1 Message Date
Masterluke77andGitHub 40071f718e Vendoo 1.43.0 – Production Update
Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
2026-07-10 13:16:21 +02:00
127 changed files with 9576 additions and 581 deletions
+6
View File
@@ -0,0 +1,6 @@
root = true
[*.{bat,cmd}]
charset = latin1
end_of_line = crlf
insert_final_newline = true
+5
View File
@@ -95,6 +95,11 @@ COOLIFY_RESOURCE_UUID=
COOLIFY_TRIGGER_MODE=webhook
COOLIFY_DEPLOY_METHOD=GET
COOLIFY_DEPLOY_ENDPOINT=/api/v1/deploy?uuid={uuid}&force={force}
COOLIFY_DEPLOYMENT_STATUS_ENDPOINT=/api/v1/deployments/{deployment_id}
VENDOO_DEPLOY_TIMEOUT_SECONDS=900
VENDOO_DEPLOY_HEALTH_POLL_SECONDS=15
VENDOO_BUILD_REVISION=
VENDOO_COOLIFY_AUTO_DEPLOY=false
VENDOO_GITHUB_REPOSITORY=Masterluke77/vendoo
VENDOO_GITHUB_BRANCH=main
VENDOO_GHCR_IMAGE=ghcr.io/masterluke77/vendoo
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
+32
View File
@@ -48,6 +48,10 @@ jobs:
- name: Syntaxprüfung
run: npm run check
- name: PowerShell-Regressionen statisch prüfen
run: npm run verify:powershell-static
- name: Produktiven Source-Root und Archivgrenzen prüfen
run: npm run verify:source-boundaries
@@ -114,3 +118,31 @@ jobs:
- name: Docker-Image testweise bauen
run: docker build --pull --tag vendoo:ci .
windows-release-contract:
name: Windows vollständiger Releasevertrag
runs-on: windows-latest
timeout-minutes: 35
steps:
- name: Repository auschecken
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Node.js 22 aktivieren
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: npm
- name: Abhängigkeiten reproduzierbar installieren
run: npm ci
- name: Alle PowerShell-Dateien mit Windows PowerShell 5.1 parsen
shell: powershell
run: .\tools\verify-powershell-parser.ps1
- name: Vollständigen Vendoo-Releasevertrag unter Windows ausführen
shell: pwsh
run: npm run verify:all
+27 -24
View File
@@ -16,7 +16,20 @@ env:
IMAGE_NAME: masterluke77/vendoo
jobs:
powershell-parser:
name: Windows PowerShell 5.1 Parser
runs-on: windows-latest
timeout-minutes: 10
steps:
- name: Repository auschecken
uses: actions/checkout@v6
- name: Alle PowerShell-Dateien mit Windows PowerShell 5.1 parsen
shell: powershell
run: .\tools\verify-powershell-parser.ps1
release-assets:
needs: powershell-parser
name: Release-Paket und Prüfsummen
permissions:
contents: write
@@ -42,31 +55,21 @@ jobs:
EXPECTED="v$(node -p "require('./package.json').version")"
test "$RELEASE_TAG" = "$EXPECTED" || { echo "Release-Tag $RELEASE_TAG passt nicht zu $EXPECTED." >&2; exit 1; }
- name: Vollständige Prüfung
run: |
npm run verify:git
npm run verify:git-regression
npm run verify:git-staging
npm run verify:ignore-boundaries
npm run check
npm run verify:source-boundaries
npm run verify:esm-exports
npm run verify:flux-loop
npm run verify:capabilities
npm run verify:coolify
npm run verify:initial-modules
npm run verify:architecture
npm run verify:themes
npm run verify:security
npm run verify:identity
npm run verify:catalog
npm run verify:modules
npm run verify:deployment
npm run verify:config
npm run verify:installer-shell
npm run verify:db
- name: ESM-Exportverträge explizit prüfen
run: npm run verify:esm-exports
- name: Release-Staging erzeugen
- name: FLUX-Loop-Hotfix explizit prüfen
run: npm run verify:flux-loop
- name: Vollständige Prüfung
run: npm run verify:all
- name: Release-Manifest reproduzierbar prüfen
run: |
node tools/generate-release-manifest.mjs .
git diff --exit-code -- release-manifest.json
- name: Manifestbasiertes Release-Staging erzeugen
run: node tools/build-release.mjs
- name: ZIP und SHA-256 erzeugen
@@ -0,0 +1,47 @@
name: Vendoo Release Manifest Sync
on:
push:
branches:
- release/1.43.0-production-update-v3
permissions:
contents: write
concurrency:
group: vendoo-release-manifest-sync-${{ github.ref }}
cancel-in-progress: true
jobs:
sync:
name: Release-Manifest reproduzierbar synchronisieren
if: ${{ !contains(github.event.head_commit.message, '[manifest-sync]') }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Release-Branch auschecken
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Node.js 22 aktivieren
uses: actions/setup-node@v4
with:
node-version: '22.x'
- name: Release-Manifest erzeugen
run: node tools/generate-release-manifest.mjs .
- name: Geändertes Manifest committen
shell: bash
run: |
if git diff --quiet -- release-manifest.json; then
echo "Release-Manifest ist bereits aktuell."
exit 0
fi
git config user.name "vendoo-release-bot"
git config user.email "vendoo-release-bot@users.noreply.github.com"
git add release-manifest.json
git commit -m "chore: Release-Manifest synchronisieren [manifest-sync]"
git push origin HEAD:${GITHUB_REF_NAME}
@@ -0,0 +1,83 @@
name: Vendoo Production Deploy
on:
workflow_dispatch:
inputs:
target_version:
description: Expected Vendoo version
required: true
type: string
public_base_url:
description: Public Vendoo URL
required: true
default: https://vendoo.flatlined.de
type: string
permissions:
contents: read
concurrency:
group: vendoo-production-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Validate inputs
shell: bash
env:
TARGET_VERSION: ${{ inputs.target_version }}
PUBLIC_BASE_URL: ${{ inputs.public_base_url }}
run: |
set -euo pipefail
[[ "$TARGET_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$PUBLIC_BASE_URL" =~ ^https://[^/]+/?$ ]]
- name: Trigger Coolify
shell: bash
env:
COOLIFY_DEPLOY_WEBHOOK_URL: ${{ secrets.COOLIFY_DEPLOY_WEBHOOK_URL }}
run: |
set -euo pipefail
test -n "$COOLIFY_DEPLOY_WEBHOOK_URL"
curl --fail --silent --show-error --location --request GET \
--connect-timeout 20 --max-time 90 \
"$COOLIFY_DEPLOY_WEBHOOK_URL" >/tmp/coolify-response.txt
echo "Coolify accepted the deployment trigger."
- name: Wait for exact production version
shell: bash
env:
TARGET_VERSION: ${{ inputs.target_version }}
PUBLIC_BASE_URL: ${{ inputs.public_base_url }}
run: |
set -euo pipefail
BASE_URL="${PUBLIC_BASE_URL%/}"
deadline=$((SECONDS + 1500))
last_version="unknown"
while (( SECONDS < deadline )); do
if body=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 30 \
-H 'Accept: application/json' -H 'Cache-Control: no-cache' \
"$BASE_URL/readyz?github_run=${GITHUB_RUN_ID}&attempt=${GITHUB_RUN_ATTEMPT}"); then
ready=$(jq -r '.ok // false' <<<"$body")
version=$(jq -r '.version // empty' <<<"$body")
if [[ -n "$version" ]]; then last_version="$version"; fi
echo "ready=$ready version=${version:-unknown} expected=$TARGET_VERSION"
if [[ "$ready" == "true" && "$version" == "$TARGET_VERSION" ]]; then exit 0; fi
fi
sleep 15
done
echo "Timeout waiting for Vendoo $TARGET_VERSION. Last version: $last_version" >&2
exit 1
- name: Final health response
shell: bash
env:
PUBLIC_BASE_URL: ${{ inputs.public_base_url }}
run: |
set -euo pipefail
BASE_URL="${PUBLIC_BASE_URL%/}"
curl --fail --silent --show-error -H 'Accept: application/json' \
"$BASE_URL/healthz?github_run=${GITHUB_RUN_ID}" | jq .
@@ -0,0 +1,125 @@
name: Vendoo Windows Release Diagnostics
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: vendoo-windows-diagnostics-${{ github.ref }}
cancel-in-progress: true
jobs:
windows-release-diagnostics:
name: Windows Release-Gates einzeln
runs-on: windows-latest
timeout-minutes: 35
steps:
- name: Repository auschecken
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Node.js 22 aktivieren
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: npm
- name: Abhängigkeiten reproduzierbar installieren
run: npm ci
- name: Alle PowerShell-Dateien mit Windows PowerShell 5.1 parsen
shell: powershell
run: .\tools\verify-powershell-parser.ps1
- name: Git-Sicherheitsgate
run: npm run verify:git
- name: Git-Sicherheitsgate Regressionstest
run: npm run verify:git-regression
- name: Git-Staging-Filter Regressionstest
run: npm run verify:git-staging
- name: Release-Manifest prüfen
run: npm run verify:release-manifest
- name: Git-Ignore-Grenzen prüfen
run: npm run verify:ignore-boundaries
- name: Syntax und Identity-Datenbank prüfen
run: npm run check
- name: PowerShell-Regressionen statisch prüfen
run: npm run verify:powershell-static
- name: Source-Root-Grenzen prüfen
run: npm run verify:source-boundaries
- name: ESM-Exportverträge prüfen
run: npm run verify:esm-exports
- name: FLUX-Loop-Hotfix prüfen
run: npm run verify:flux-loop
- name: Capability-Graph prüfen
run: npm run verify:capabilities
- name: Coolify- und Produktionsoperationen prüfen
run: npm run verify:coolify
- name: Initial deaktivierte Module prüfen
run: npm run verify:initial-modules
- name: Plattformarchitektur prüfen
run: npm run verify:architecture
- name: Theme Manager prüfen
run: npm run verify:themes
- name: Security Foundation prüfen
run: npm run verify:security
- name: Identity und Settings prüfen
run: npm run verify:identity
- name: Einladungen prüfen
run: npm run verify:invitations
- name: SQLite-Cleanup unter Windows prüfen
run: npm run verify:sqlite-cleanup
- name: Katalog und Medien prüfen
run: npm run verify:catalog
- name: Modulmanager prüfen
run: npm run verify:modules
- name: Produktionsoperationen prüfen
run: npm run verify:production-operations
- name: One-Click-Updater prüfen
run: npm run verify:one-click-update
- name: Modulnavigation prüfen
run: npm run verify:module-navigation
- name: Updater-UI prüfen
run: npm run verify:updater-ui
- name: Updater-Hotfix-Vertrag prüfen
run: npm run verify:updater-hotfix
- name: Deployment prüfen
run: npm run verify:deployment
- name: Konfigurationsspeicher prüfen
run: npm run verify:config
- name: Installer-Shell-Portabilität prüfen
run: npm run verify:installer-shell
- name: Datenbankmigrationen prüfen
run: npm run verify:db
+36
View File
@@ -0,0 +1,36 @@
# Vendoo 1.43.0 Start hier
## Production Updater 3.3 starten
1. Dieses ZIP vollständig in einen neuen lokalen Ordner entpacken, zum Beispiel `C:\Vendoo-Update`.
2. Im Ordner `vendoo` doppelt auf `UPDATE_VENDOO.bat` klicken.
3. Den Vendoo Production Updater bis zum eindeutigen Ergebnis geöffnet lassen.
Der Updater zeigt zehn klare Phasen, Fortschritt, Live-Protokoll und einen dauerhaft sichtbaren Fehlerzustand. Beim ersten Lauf kann einmalig die GitHub-Anmeldung im Browser erforderlich sein. GitHub CLI wird bei Bedarf als verwaltete portable Kopie geladen und per SHA-256 geprüft.
Ein Coolify-Webhook wird nicht abgefragt. Nach einem erfolgreichen Merge verwendet Vendoo das bereits eingerichtete Coolify Auto Deploy der GitHub-Verknüpfung und bestätigt anschließend `/readyz` mit exakt Version `1.43.0`.
## Release-Architektur 3.0
- Die geklonte `main`-Arbeitskopie wird nicht geleert.
- `release-manifest.json` enthält jede auszuliefernde Datei mit Größe und SHA-256.
- Pflichtdateien wie `db/schema.sql` werden vor den Tests hart geprüft.
- Dateien werden als Overlay angewendet und im Ziel erneut gehasht.
- `release-manifest.json` wird anschließend als bytegeprüfte Steuerdatei in die Arbeitskopie übernommen.
- Löschungen sind nur über die explizite Liste `removedFiles` erlaubt.
- Alte Updater-2.x-Sitzungen werden nicht übernommen.
- GitHub Actions führt den vollständigen Releasevertrag auf Windows und Linux aus.
## Inhalt von Vendoo 1.43.0
- modulabhängige Navigation: deaktivierte Module verschwinden sofort aus der Seitenleiste,
- optionales, standardmäßig deaktiviertes Modul **Produktbild Studio**,
- grundlegend überarbeitete Seite **Neuer Artikel**,
- dauerhaft sichtbare Aktionen **Artikel generieren** und **Speichern**,
- manifestbasierter Production Updater 3.3.
## Sicherheitsgrenzen
Der Updater ersetzt oder löscht niemals `/app/data`, Datenbanken, Uploads, Backups, `.env` oder API-Schlüssel. GitHub-Checks, kontrollierter Merge, Coolify Auto Deploy und `/readyz`-Versionskontrolle bleiben verpflichtend.
Technische Details: `docs/PRODUCTION_UPDATER_3_3.md`.
+90
View File
@@ -1,5 +1,95 @@
## Vendoo Production Updater 3.3 Manifest Control Artifact Contract
- `release-manifest.json` wird nach dem Payload-Overlay ausdrücklich in die Git-Arbeitskopie kopiert.
- Die Steuerdatei wird bytegenau gegen das Release-Paket geprüft.
- Manifestprüfung in einer Git-Baseline toleriert zusätzliche unveränderte Baseline-Dateien, prüft aber alle manifestierten Dateien vollständig.
- `removedFiles` darf die Manifest-Steuerdatei nicht löschen.
- Der Windows-Lauf kann dadurch `verify-production-updater-3.3.mjs` innerhalb der geklonten Arbeitskopie ausführen.
- Prozessfehler zeigen in Status und Oberfläche zuerst die echte Fehlerausgabe; lange Standardausgaben verdrängen die Ursache nicht mehr.
## Vendoo Production Updater 3.1 Manifest-Based Release Application
- replaces destructive clone cleanup and exclusion-list copying with SHA-256 verified overlay manifest
- requires `db/schema.sql` and all release-critical source files before tests can start
- preserves baseline files unless deletion is explicitly declared in `removedFiles`
- verifies every payload file before and after copying
- rejects stale Updater-2.x sessions and uses a dedicated v3 release branch
## Vendoo Production Updater 2.10 Windows Installer-Shell Portability
- `verify:installer-shell` läuft über einen plattformneutralen Node-Wrapper.
- Unter Windows wird `sh.exe` aus Git for Windows über `where git`, `git --exec-path` und bekannte Installationspfade ermittelt.
- Keine globale `sh`-PATH-Annahme mehr.
- Git-Fortschritt auf stderr wird bei Exit-Code 0 als Ausgabe statt als Fehler protokolliert; npm-Warnungen erscheinen als Warnung.
- Zielversion der Anwendung bleibt Vendoo 1.43.0.
## Vendoo Production Updater 2.9 Systematic Windows SQLite Handle Audit
- Zentraler Cleanup für temporäre Vendoo-SQLite-Datenbanken.
- Einladungs-Verifier schließt `better-sqlite3` vor dem Entfernen des Temp-Ordners.
- Aktive Verifier werden transitiv auf offene `lib/db.mjs`-Handles geprüft.
- Windows-Cleanup-Regression läuft für Initialmodule und Einladungen jeweils dreifach isoliert.
## Vendoo Production Updater 2.8 Windows SQLite Cleanup
- `verify:initial-modules` schließt die temporäre `better-sqlite3`-Datenbank jetzt vor dem Entfernen des Testordners.
- Windows-`EBUSY` beim Löschen von `vendoo.db` behoben.
- Neuer dreifacher Cleanup-Regressionstest verhindert offene SQLite-Dateien und Temp-Reste.
# Vendoo 1.43.0 Production Updater 2.4
- GitHub-Browseranmeldung aus dem versteckten Worker entfernt.
- Sichtbares, klar beschriftetes Geräte-Anmeldefenster mit `--web --clipboard`.
- Stilles Loginstatus-Polling und automatische Fortsetzung nach erfolgreicher Anmeldung.
- Downloadfortschritt auf 10-%-Stufen gedrosselt; keine Logflut mehr.
- Neues Updater-2.4-Regressionsgate.
# Changelog
## Updater 2.1 Worker Host & Bootstrap Diagnostics
- überwachten Worker-Host eingeführt
- Status und Protokollpfad vor Worker-Start garantiert
- Standardausgabe und Standardfehler vollständig erfasst
- Lade-, Parser- und Initialisierungsfehler mit Datei und Zeile sichtbar
- Diagnoseordner direkt in der Oberfläche erreichbar
- Worker-Core beendet den Host nicht mehr eigenmächtig
## 1.43.0 Updater Revision 2.0 Production Updater Replacement
- Bisherige Winget-/Konsolen-Hotfix-Kette vollständig durch einen eigenständigen WPF-Production-Updater ersetzt.
- Neutrale dunkle Oberfläche mit zehn klaren Updatephasen, Fortschritt, Laufzeit, Heartbeat und Live-Protokoll.
- Fehlerzustände bleiben dauerhaft sichtbar; das Fenster schließt sich weder bei Fehlern noch bei einem unerwarteten Worker-Ende automatisch.
- Aktionen für Protokoll öffnen, Fehler kopieren, kontrollierten Abbruch und Wiederaufnahme.
- Verwaltete portable GitHub CLI mit offiziellem Release-Download und SHA-256-Prüfung statt Winget-Abhängigkeit.
- Feste Timeouts, Prozessbaum-Abbruch und Heartbeats für alle externen Werkzeuge.
- Persistente Sitzung für sichere Wiederaufnahme nach Abbruch oder Neustart.
- GitHub-, Test-, Merge-, Coolify- und Produktionsprüfung bleiben als getrennte Phasen nachvollziehbar.
## 1.43.0 Module-aware Navigation, Listing Studio UX & Updater Status UI
- Navigationseinträge verschwinden unmittelbar, wenn das zugehörige Modul deaktiviert wird.
- Neues authentifiziertes, minimales Modulstatus-API für die Oberfläche.
- Produktbild Studio als eigenständiges, standardmäßig deaktiviertes Modul.
- Seite „Neuer Artikel“ neu geordnet, lesbarer und konsequent am Theme-System ausgerichtet.
- „Artikel generieren“ und „Speichern“ bleiben in einer sticky Aktionsleiste dauerhaft erreichbar.
- Ein-Klick-Updater mit grafischer Fortschrittsanzeige, Prozentwert, Phase und Live-Protokoll.
## 1.42.0 Production Operations & Controlled Update Completion
- Persistente Deployment-State-Machine mit Ereignisverlauf und Wiederaufnahme nach Neustart.
- Idempotency-Key und Sperre gegen parallele Deployment-Aufträge.
- Vorab-Backup wird erstellt, verifiziert und mit dem Auftrag verknüpft.
- Coolify-Trigger, Remote-Status, Readiness und Zielversion werden getrennt bewertet.
- Webhook-Abschlüsse benötigen eine zusätzliche exakte Bestätigung `CONFIRM`.
- Fehler und Zeitüberschreitungen wechseln in `rollback_required` und blockieren neue Aufträge bis zur manuellen Prüfung.
- Neuer Produktionscheck und Produktionsstatus im Update Center.
- Additive Tabellen `deployment_runs`, `deployment_events`, `backup_verifications` und `production_checks`.
- Keine Löschung oder Rücksetzung bestehender Daten, Benutzer, Uploads, Secrets, Backups oder Module.
- Neuer `UPDATE_VENDOO.bat`-Assistent automatisiert Voraussetzungen, Volltest, Release-Branch, Pull Request, Merge und Coolify-Deployment.
- Coolify-Webhook wird nur einmalig als verschlüsseltes GitHub Actions Secret hinterlegt.
- Produktionsworkflow meldet Erfolg ausschließlich bei `/readyz` und exakt Version `1.42.0`.
## 1.41.2 Git Ignore Boundary & Module Tracking Hotfix
- Root-Runtimeordner in `.gitignore` sind jetzt mit `/` verankert.
+23
View File
@@ -1071,3 +1071,26 @@ Neue Kernel-Routen benötigen zwingend einen Modulbesitzer und eine explizite Po
- `app/modules/` und `app/modules/sessions/` bleiben versionierter Source
- Git-Ignore-Semantiktest mit echtem temporärem Repository
- Pflichtdatei- und Tracking-Gate im GitHub-Deploy-Assistenten
## 1.42.0 Production Operations & Controlled Update Completion
- Persistente Deployment-State-Machine: `checking`, `backup_pending`, `backup_running`, `backup_verified`, `deploy_requested`, `deploy_running`, `health_wait`, `succeeded`, `failed`, `rollback_required`.
- Idempotente Aufträge und harter Doppelstartschutz.
- Verifiziertes Vorab-Backup mit SHA-256-/Manifestbezug.
- Coolify-API-Statusabfrage über konfigurierbaren relativen Endpunkt; Webhook-Modus mit expliziter Abschlussbestätigung.
- Readiness- und Zielversionsprüfung vor Erfolgsmeldung.
- Persistenter Ereignisverlauf, Neustart-Wiederaufnahme und kontrollierte Aufhebung einer Rollback-Sperre.
- Produktionsstatus und Produktionscheck im Update Center.
- Additive Migration ohne Reset bestehender Produktivdaten.
## 1.43.0 Module-aware Navigation, Listing Studio & Updater Status UI
- Authentifizierter Modulstatus-Endpunkt `/api/modules/navigation` mit minimaler Antwortfläche.
- Navigationselemente und optionale UI-Panels sind über `data-module` beziehungsweise `data-module-panel` an den realen Modulstatus gekoppelt.
- Modulstatus wird vor geschützten Modul-APIs geladen; ein deaktiviertes AI-Modul blockiert den App-Start nicht.
- Neuer Manifestvertrag `defaultEnabled`, damit ein Modul standardmäßig aus, aber administrativ aktivierbar sein kann.
- `vendoo.product-image-tools` stellt `product-images.variants` bereit und startet standardmäßig deaktiviert.
- Listing Studio mit sticky Command Bar, klarer Zwei-Spalten-Arbeitsfläche und dauerhaft sichtbaren Aktionen.
- Grafische Update-Oberfläche mit Fortschritt, Phase, Detailmeldung, Live-Protokoll, sicherer einmaliger Coolify-Eingabe und korrektem Exit-Code.
+3 -13
View File
@@ -1,14 +1,4 @@
@echo off
setlocal EnableExtensions
chcp 65001 >nul
title Vendoo GitHub Deploy Assistent
cd /d "%~dp0"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -STA -File "%~dp0scripts\github-deploy.ps1"
set "EXITCODE=%ERRORLEVEL%"
if not "%EXITCODE%"=="0" (
echo.
echo [FEHLER] GitHub-Deployment wurde mit Exit-Code %EXITCODE% beendet.
echo.
pause
)
exit /b %EXITCODE%
rem Alter Dateiname bleibt als Kompatibilitaetsstarter erhalten.
call "%~dp0UPDATE_VENDOO.bat"
exit /b %ERRORLEVEL%
+34 -14
View File
@@ -1,6 +1,26 @@
# Vendoo 1.41.2
# Vendoo 1.43.0
Vendoo 1.41.1 ist die korrigierte Produktionsgrundlage für den Betrieb über **Coolify**, GitHub und `https://vendoo.flatlined.de`. Der Container bleibt unveränderlich: Vendoo führt weder `git pull` noch Docker-Befehle aus, sondern delegiert freigegebene Deployments nach einem verifizierten Vorab-Backup an Coolify.
Vendoo 1.43.0 erweitert die kontrollierte Produktionsgrundlage um modulabhängige Navigation, ein optionales Produktbild Studio, ein neu aufgebautes Listing Studio und einen grafischen Ein-Klick-Updater. Der Container bleibt unveränderlich: Vendoo führt weder `git pull` noch Docker-Befehle aus, sondern delegiert freigegebene Deployments nach einem verifizierten Vorab-Backup an Coolify.
## Release 1.43.0: Module-aware Navigation, Listing Studio & Updater UI
- deaktivierte Fachmodule verschwinden unmittelbar aus der linken Navigation
- Modulstatus wird vor modulgeschützten APIs geladen, damit deaktivierte Module den App-Start nicht blockieren
- Produktbild Studio als eigenes natives Modul mit `defaultEnabled: false`
- überarbeitete Seite **Neuer Artikel** mit theme-konformen Flächen, Abständen und responsivem Arbeitsbereich
- sticky Aktionsleiste: **Artikel generieren** und **Speichern** bleiben oben erreichbar
- grafischer `UPDATE_VENDOO.bat`-Ablauf mit Phase, Detailtext, Prozentfortschritt, Live-Protokoll und sicherer Webhook-Eingabe
## Release 1.42.0: Production Operations & Controlled Update Completion
- persistente Deployment-Aufträge und Ereignisse in SQLite
- idempotente Auslösung und Sperre gegen parallele Deployments
- verifiziertes Vorab-Backup als fester Bestandteil des Auftrags
- getrennte Zustände für Coolify-Annahme, laufendes Deployment, Readiness, Erfolg, Fehler und Rollbackbedarf
- Erfolg erst nach Readiness und bestätigter Zielversion beziehungsweise expliziter externer Bestätigung
- Produktionscheck für HTTPS, Cookies, Proxy, Hosts/Origins, persistente Pfade, SQLite, Backupziel, FLUX und Coolify-Konfiguration
- kein Docker-Socket, kein `git pull`, kein `npm install` und keine Selbstüberschreibung im Produktivcontainer
## Hotfix 1.41.2: native Module zuverlässig in Git
@@ -24,16 +44,16 @@ Runtimeordner werden in `.gitignore` ausschließlich am Projektroot ausgeschloss
- keine Weitergabe des Docker-Sockets an Vendoo
- genaue Anleitung: `docs/COOLIFY_PRODUCTION_1_41_0.md`
## Kontrollierte Web-Updates
## Kontrollierte Updates
- geschütztes natives Modul `vendoo.deployments`
- Coolify-Deploy-Webhook oder minimal berechtigter API-Token
- Coolify-Zugangsdaten AES-256-GCM-verschlüsselt im Secret Store
- exakte Administratorbestätigung `DEPLOY`
- verpflichtendes Vorab-Backup vor dem Deploymentauftrag
- GitHub-Repository, Produktionsbranch und Update-Kanal im Update Center
- kein selbstverändernder Container, kein `git pull`, kein Docker-Socket
- vollständiger Ablauf: `docs/WEB_UPDATE_CENTER_1_41_0.md`
- Production Updater 3.3 mit SHA-256-geprüftem Payload und bytegeprüfter Manifest-Steuerdatei
- keine destruktive Leerung der geklonten Git-Baseline
- Pflichtdateien wie `db/schema.sql` werden vor Tests und Push geprüft
- Pull Request und vollständige GitHub-Checks auf Windows und Linux
- Coolify Auto Deploy nach erfolgreichem Merge nach `main`
- exakte Produktionsbestätigung über `/readyz` und Zielversion
- kein selbstverändernder Container, kein `git pull` in Produktion, kein Docker-Socket
- technische Details: `docs/PRODUCTION_UPDATER_3_1.md`
## GitHub-, Merge- und Releaseprozess
@@ -47,10 +67,10 @@ Runtimeordner werden in `.gitignore` ausschließlich am Projektroot ausgeschloss
## Plattformstand
- 17 registrierte Module
- 15 native Module
- 18 registrierte Module
- 16 native Module, davon Produktbild Studio standardmäßig deaktiviert
- 2 transparente Legacy Bridges: System und Operations
- 38 vollständig aufgelöste Capabilities
- 41 vollständig aufgelöste Capabilities
- geschützte Kernmodule und kontrollierbare optionale Module
- `.vmod`-Import/Export, Quarantäne und Abhängigkeitssteuerung
- Theme-, Security-, Identity-, Catalog-, Media-, Publishing-, AI-, FLUX- und Quality-Module bleiben enthalten
+51
View File
@@ -0,0 +1,51 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
cd /d "%~dp0"
if errorlevel 1 goto :path_error
set "VENDOO_PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
if not exist "%VENDOO_PS%" goto :powershell_missing
echo Vendoo Production Updater 3.3 preflight...
"%VENDOO_PS%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File ".\scripts\updater\Vendoo.Updater.Preflight.ps1"
set "VENDOO_RC=%ERRORLEVEL%"
if not "%VENDOO_RC%"=="0" goto :preflight_failed
"%VENDOO_PS%" -NoLogo -NoProfile -ExecutionPolicy Bypass -STA -File ".\scripts\update-vendoo-gui.ps1"
set "VENDOO_RC=%ERRORLEVEL%"
if not "%VENDOO_RC%"=="0" goto :gui_failed
exit /b 0
:path_error
echo.
echo [ERROR] The Vendoo package directory could not be opened.
echo Path: %~dp0
echo.
pause
exit /b 30
:powershell_missing
echo.
echo [ERROR] Windows PowerShell 5.1 was not found.
echo Expected: %VENDOO_PS%
echo.
pause
exit /b 31
:preflight_failed
echo.
echo [ERROR] The Windows PowerShell preflight failed with exit code %VENDOO_RC%.
echo Log: %CD%\logs\updater-preflight.log
echo.
pause
exit /b %VENDOO_RC%
:gui_failed
echo.
echo [ERROR] The updater UI exited with code %VENDOO_RC%.
echo Log: %CD%\logs\updater-bootstrap.log
echo.
pause
exit /b %VENDOO_RC%
+75 -15
View File
@@ -4,31 +4,91 @@
"title": "Vendoo Module Manifest",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "id", "name", "version", "type", "status", "requires", "permissions", "provides", "consumes", "owns"],
"required": [
"schemaVersion",
"id",
"name",
"version",
"type",
"status",
"requires",
"permissions",
"provides",
"consumes",
"owns"
],
"properties": {
"schemaVersion": { "const": 1 },
"id": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" },
"type": { "enum": ["core", "feature", "adapter", "extension-host"] },
"status": { "enum": ["native", "legacy-bridge", "disabled"] },
"description": { "type": "string", "maxLength": 500 },
"requires": { "$ref": "#/$defs/moduleIds" },
"permissions": { "$ref": "#/$defs/capabilities" },
"provides": { "$ref": "#/$defs/capabilities" },
"consumes": { "$ref": "#/$defs/capabilities" },
"owns": { "$ref": "#/$defs/capabilities" }
"schemaVersion": {
"const": 1
},
"id": {
"type": "string",
"pattern": "^vendoo\\.[a-z][a-z0-9-]*$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$"
},
"type": {
"enum": [
"core",
"feature",
"adapter",
"extension-host"
]
},
"status": {
"enum": [
"native",
"legacy-bridge",
"disabled"
]
},
"defaultEnabled": {
"type": "boolean",
"default": true
},
"description": {
"type": "string",
"maxLength": 500
},
"requires": {
"$ref": "#/$defs/moduleIds"
},
"permissions": {
"$ref": "#/$defs/capabilities"
},
"provides": {
"$ref": "#/$defs/capabilities"
},
"consumes": {
"$ref": "#/$defs/capabilities"
},
"owns": {
"$ref": "#/$defs/capabilities"
}
},
"$defs": {
"moduleIds": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" }
"items": {
"type": "string",
"pattern": "^vendoo\\.[a-z][a-z0-9-]*$"
}
},
"capabilities": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" }
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$"
}
}
}
}
+32
View File
@@ -296,6 +296,38 @@ export function validateAuthToken(token) {
return row;
}
export function listInvitations(limit = 100) {
const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100));
return db.prepare(`SELECT id, email, role, used, expires_at, created_by, created_at,
CASE
WHEN used = 1 THEN 'revoked_or_used'
WHEN expires_at <= datetime('now') THEN 'expired'
ELSE 'pending'
END AS status
FROM auth_tokens
WHERE type = 'invite'
ORDER BY created_at DESC
LIMIT ?`).all(safeLimit);
}
export function getInvitation(id) {
return db.prepare(`SELECT id, email, role, used, expires_at, created_by, created_at,
CASE
WHEN used = 1 THEN 'revoked_or_used'
WHEN expires_at <= datetime('now') THEN 'expired'
ELSE 'pending'
END AS status
FROM auth_tokens WHERE id = ? AND type = 'invite'`).get(Number(id));
}
export function revokeInvitation(id) {
const invitation = getInvitation(id);
if (!invitation) return null;
if (invitation.status === 'pending') db.prepare('UPDATE auth_tokens SET used = 1 WHERE id = ? AND type = ?').run(Number(id), 'invite');
return { ...invitation, status: invitation.status === 'pending' ? 'revoked' : invitation.status, used: 1 };
}
// --- Rate Limiting ---
const rateLimits = new Map();
+5 -1
View File
@@ -163,7 +163,11 @@ export function buildModulePackage({ manifest, runtime = { mode: 'builtin-refere
const buffer = Buffer.isBuffer(file.content) ? file.content : Buffer.from(String(file.content || ''), 'utf8');
return { path, sha256: sha256(buffer), size: buffer.length, content: buffer.toString('base64') };
});
const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null };
// Der Digest muss auf demselben normalisierten Manifest basieren wie die spätere
// Validierung. Sonst würden additive Manifest-Defaults (z. B. defaultEnabled)
// den Digest nachträglich verändern.
const normalizedManifest = validateModuleManifest(manifest);
const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest: normalizedManifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null };
draft.integrity.digest = calculatePackageDigest(draft);
return validateModulePackage(draft, { allowBuiltinReference: true });
}
+1
View File
@@ -29,6 +29,7 @@ export function validateModuleManifest(manifest) {
version: String(manifest.version || '').trim(),
type: String(manifest.type || '').trim(),
status: String(manifest.status || 'native').trim(),
defaultEnabled: manifest.defaultEnabled !== false,
description: String(manifest.description || '').trim(),
requires: Object.freeze(uniqueStrings(manifest.requires || [], 'requires', MODULE_ID)),
permissions: Object.freeze(uniqueStrings(manifest.permissions || [], 'permissions')),
+2 -1
View File
@@ -84,6 +84,7 @@ export function createPlatformKernel({ version, hasPermission, moduleTrustKeys =
features.define('modules.package-format', { defaultValue: true, owner: 'vendoo.module-manager', description: 'Aktiviert validierte .vmod-Pakete mit SHA-256-Integrität und optionaler Ed25519-Signatur.' });
features.define('publishing.native-module', { defaultValue: true, owner: 'vendoo.publishing', description: 'Aktiviert native Publishing-Verträge.' });
features.define('ai.native-module', { defaultValue: true, owner: 'vendoo.ai', description: 'Aktiviert native AI-Verträge.' });
features.define('product-images.optional-module', { defaultValue: false, owner: 'vendoo.product-image-tools', description: 'Aktiviert optionale Ghost-Mannequin- und Flatlay-Produktbildvarianten.' });
features.define('flux.native-module', { defaultValue: true, owner: 'vendoo.flux-studio', description: 'Aktiviert native FLUX-Verträge.' });
features.define('quality.native-module', { defaultValue: true, owner: 'vendoo.quality', description: 'Aktiviert native Quality-Verträge.' });
features.define('deployments.coolify', { defaultValue: true, owner: 'vendoo.deployments', description: 'Aktiviert kontrollierte Coolify-Deployments ohne Docker-Socket.' });
@@ -93,7 +94,7 @@ export function createPlatformKernel({ version, hasPermission, moduleTrustKeys =
.split(',').map(value => value.trim()).filter(Boolean));
for (const module of loadBuiltInModules()) {
const forcedEnabled = PROTECTED_MODULE_IDS.includes(module.manifest.id);
const defaultEnabled = module.manifest.status !== 'disabled' && !initialDisabledModules.has(module.manifest.id);
const defaultEnabled = module.manifest.status !== 'disabled' && module.manifest.defaultEnabled !== false && !initialDisabledModules.has(module.manifest.id);
const enabled = forcedEnabled || moduleStateStore.isEnabled(module.manifest.id, defaultEnabled);
modules.register(module, { enabled });
}
+2 -1
View File
@@ -13,6 +13,7 @@ import { createUsersModule } from './users/index.mjs';
import { createModuleManagerModule } from './module-manager/index.mjs';
import { createAiModule } from './ai/index.mjs';
import { createFluxStudioModule } from './flux-studio/index.mjs';
import { createProductImageToolsModule } from './product-image-tools/index.mjs';
import { createQualityModule } from './quality/index.mjs';
import { createPublishingModule } from './publishing/index.mjs';
import { createDeploymentsModule } from './deployments/index.mjs';
@@ -33,6 +34,6 @@ export function loadBuiltInModules() {
return [
...legacyModuleNames.map(legacyModule),
createSecurityModule(), createAuthModule(), createSessionsModule(), createUsersModule(), createSettingsModule(), createThemesModule(),
createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), createFluxStudioModule(), createQualityModule(), createPublishingModule(), createModuleManagerModule(), createDeploymentsModule(),
createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), createProductImageToolsModule(), createFluxStudioModule(), createQualityModule(), createPublishingModule(), createModuleManagerModule(), createDeploymentsModule(),
];
}
+1 -1
View File
@@ -21,7 +21,7 @@ export function createDeploymentsModule() {
manifest,
lifecycle: {
async start() { service = createDeploymentService(adapters); },
async stop() { service = null; },
async stop() { service?.stop?.(); service = null; },
async health() { return getDeploymentService().health(); },
},
};
+6 -2
View File
@@ -2,10 +2,10 @@
"schemaVersion": 1,
"id": "vendoo.deployments",
"name": "Deployments & Updates",
"version": "1.0.0",
"version": "1.1.0",
"type": "core",
"status": "native",
"description": "Kontrollierte Coolify-Deployments, verschlüsselte Deployment-Zugangsdaten und sichere Web-Updates ohne Docker-Socket.",
"description": "Persistente, kontrollierte Coolify-Deployments mit Vorab-Backup, Idempotenz, Statusverfolgung, Readiness-, Zielversions- und Produktionschecks ohne Docker-Socket.",
"requires": [
"vendoo.security",
"vendoo.settings",
@@ -18,6 +18,8 @@
"provides": [
"deployments.control",
"deployments.coolify",
"deployments.production-checks",
"deployments.state-machine",
"updates.web"
],
"consumes": [
@@ -28,7 +30,9 @@
],
"owns": [
"deployments.configuration",
"deployments.events",
"deployments.history",
"deployments.production-checks",
"updates.coolify"
]
}
+192
View File
@@ -0,0 +1,192 @@
import { randomUUID } from 'node:crypto';
import { db } from '../../../lib/db.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
import {
ACTIVE_DEPLOYMENT_STATES,
DEPLOYMENT_STATES,
canTransitionDeployment,
isActiveDeploymentState,
} from './state-machine.mjs';
function parseJson(value, fallback) {
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
}
function serializeJson(value, fallback) {
try { return JSON.stringify(value ?? fallback); } catch { return JSON.stringify(fallback); }
}
function normalizeRun(row) {
if (!row) return null;
return {
...row,
request: parseJson(row.request_json, {}),
result: parseJson(row.result_json, {}),
active: isActiveDeploymentState(row.state),
};
}
function fail(message, code = 'DEPLOYMENT_STATE_INVALID', status = 409, details = null) {
throw new PlatformError(message, { code, status, expose: true, details });
}
export function createDeploymentRepository(database = db) {
const insertRun = database.prepare(`INSERT INTO deployment_runs (
id, idempotency_key, state, provider, trigger_mode, current_version, target_version,
build_revision_before, requested_by, reason, github_repository, github_branch,
request_json, timeout_at, started_at, updated_at
) VALUES (?, ?, 'checking', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`);
const insertEvent = database.prepare(`INSERT INTO deployment_events
(run_id, state, event_type, message, details_json)
VALUES (?, ?, ?, ?, ?)`);
const getRunStmt = database.prepare('SELECT * FROM deployment_runs WHERE id = ?');
const getIdempotentStmt = database.prepare('SELECT * FROM deployment_runs WHERE idempotency_key = ?');
const getActiveStmt = database.prepare(`SELECT * FROM deployment_runs
WHERE state IN (${ACTIVE_DEPLOYMENT_STATES.map(() => '?').join(',')})
ORDER BY created_at DESC LIMIT 1`);
function addEvent(runId, state, eventType, message, details = {}) {
insertEvent.run(runId, state, String(eventType || 'state').slice(0, 80), String(message || '').slice(0, 1000), serializeJson(details, {}));
}
function createRun(input) {
const id = input.id || randomUUID();
const existing = getIdempotentStmt.get(input.idempotencyKey);
if (existing) return { run: normalizeRun(existing), reused: true };
const active = getActiveStmt.get(...ACTIVE_DEPLOYMENT_STATES);
if (active) fail('Es läuft bereits ein Deployment oder es ist ein Rollback erforderlich.', 'DEPLOYMENT_ALREADY_ACTIVE', 409, { run_id: active.id, state: active.state });
try {
insertRun.run(
id,
input.idempotencyKey,
input.provider || 'coolify',
input.triggerMode || 'webhook',
input.currentVersion,
input.targetVersion,
input.buildRevisionBefore || null,
input.requestedBy || null,
input.reason || 'manual',
input.githubRepository || null,
input.githubBranch || null,
serializeJson(input.request || {}, {}),
input.timeoutAt || null,
);
} catch (error) {
if (String(error?.message || '').includes('UNIQUE')) {
const raced = getIdempotentStmt.get(input.idempotencyKey);
if (raced) return { run: normalizeRun(raced), reused: true };
}
throw error;
}
addEvent(id, 'checking', 'run.created', 'Deployment-Auftrag wurde angelegt.', {
target_version: input.targetVersion,
trigger_mode: input.triggerMode,
});
return { run: normalizeRun(getRunStmt.get(id)), reused: false };
}
function getRun(id) { return normalizeRun(getRunStmt.get(id)); }
function findByIdempotencyKey(key) { return normalizeRun(getIdempotentStmt.get(key)); }
function getActiveRun() { return normalizeRun(getActiveStmt.get(...ACTIVE_DEPLOYMENT_STATES)); }
function listRuns(limit = 25) {
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
return database.prepare('SELECT * FROM deployment_runs ORDER BY created_at DESC LIMIT ?').all(safeLimit).map(normalizeRun);
}
function listEvents(runId, limit = 200) {
const safeLimit = Math.max(1, Math.min(1000, Number(limit) || 200));
return database.prepare(`SELECT * FROM deployment_events WHERE run_id = ? ORDER BY id ASC LIMIT ?`).all(runId, safeLimit)
.map(row => ({ ...row, details: parseJson(row.details_json, {}) }));
}
function transition(runId, nextState, { message = '', eventType = 'state.changed', details = {}, patch = {} } = {}) {
if (!DEPLOYMENT_STATES.includes(nextState)) fail('Unbekannter Deployment-Zustand.', 'DEPLOYMENT_STATE_UNKNOWN', 500, { next_state: nextState });
const current = getRunStmt.get(runId);
if (!current) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404);
if (current.state === nextState) return normalizeRun(current);
if (!canTransitionDeployment(current.state, nextState)) {
fail(`Ungültiger Deployment-Übergang von ${current.state} nach ${nextState}.`, 'DEPLOYMENT_TRANSITION_BLOCKED', 409, {
run_id: runId, from: current.state, to: nextState,
});
}
const completed = ['succeeded', 'failed'].includes(nextState);
const columns = ['state = ?', "updated_at = datetime('now')"];
const values = [nextState];
const allowedPatch = new Set([
'deployment_id', 'backup_id', 'result_json', 'error_code', 'error_message',
'build_revision_after', 'timeout_at',
]);
for (const [key, value] of Object.entries(patch || {})) {
if (!allowedPatch.has(key)) continue;
columns.push(`${key} = ?`);
values.push(key.endsWith('_json') ? serializeJson(value, {}) : value);
}
if (completed) columns.push("completed_at = datetime('now')");
values.push(runId);
database.prepare(`UPDATE deployment_runs SET ${columns.join(', ')} WHERE id = ?`).run(...values);
addEvent(runId, nextState, eventType, message || `Zustand: ${nextState}`, details);
return normalizeRun(getRunStmt.get(runId));
}
function patchRun(runId, patch = {}, { eventType = null, message = '', details = {} } = {}) {
const current = getRunStmt.get(runId);
if (!current) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404);
const allowedPatch = new Set([
'deployment_id', 'backup_id', 'result_json', 'error_code', 'error_message',
'build_revision_after', 'timeout_at',
]);
const columns = ["updated_at = datetime('now')"];
const values = [];
for (const [key, value] of Object.entries(patch || {})) {
if (!allowedPatch.has(key)) continue;
columns.push(`${key} = ?`);
values.push(key.endsWith('_json') ? serializeJson(value, {}) : value);
}
values.push(runId);
database.prepare(`UPDATE deployment_runs SET ${columns.join(', ')} WHERE id = ?`).run(...values);
if (eventType) addEvent(runId, current.state, eventType, message || eventType, details);
return normalizeRun(getRunStmt.get(runId));
}
function recordBackupVerification({ backupId, runId = null, status, sha256 = null, fileSize = 0, sqliteIntegrity = null, manifest = {}, errors = [], warnings = [] }) {
const id = randomUUID();
database.prepare(`INSERT INTO backup_verifications
(id, backup_id, deployment_run_id, status, sha256, file_size, sqlite_integrity, manifest_json, errors_json, warnings_json, verified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`).run(
id, backupId, runId, status, sha256, Number(fileSize) || 0, sqliteIntegrity,
serializeJson(manifest, {}), serializeJson(errors, []), serializeJson(warnings, []),
);
return database.prepare('SELECT * FROM backup_verifications WHERE id = ?').get(id);
}
function createProductionCheck({ createdBy = null, results = [] } = {}) {
const id = randomUUID();
const passed = results.filter(item => item.status === 'pass').length;
const warnings = results.filter(item => item.status === 'warning').length;
const failed = results.filter(item => item.status === 'fail').length;
const overall = failed ? 'failed' : (warnings ? 'warning' : 'passed');
database.prepare(`INSERT INTO production_checks
(id, overall_status, passed_count, warning_count, failed_count, results_json, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, overall, passed, warnings, failed, serializeJson(results, []), createdBy);
return { id, overall_status: overall, passed_count: passed, warning_count: warnings, failed_count: failed, results };
}
function latestProductionCheck() {
const row = database.prepare('SELECT * FROM production_checks ORDER BY created_at DESC LIMIT 1').get();
return row ? { ...row, results: parseJson(row.results_json, []) } : null;
}
return Object.freeze({
createRun, getRun, findByIdempotencyKey, getActiveRun, listRuns, listEvents,
transition, patchRun, addEvent, recordBackupVerification,
createProductionCheck, latestProductionCheck,
});
}
export const deploymentRepository = createDeploymentRepository();
+76 -6
View File
@@ -1,4 +1,5 @@
import { objectSchema } from '../../kernel/input-schema.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
import { getDeploymentService } from './index.mjs';
const settingsSchema = objectSchema({
@@ -8,6 +9,9 @@ const settingsSchema = objectSchema({
trigger_mode: { type: 'string', enum: ['webhook', 'api'] },
deploy_method: { type: 'string', enum: ['GET', 'POST'] },
deploy_endpoint: { type: 'string', maxLength: 500 },
deployment_status_endpoint: { type: 'string', maxLength: 500 },
deployment_timeout_seconds: { type: 'number', integer: true, min: 60, max: 7200 },
health_poll_seconds: { type: 'number', integer: true, min: 5, max: 300 },
github_repository: { type: 'string', maxLength: 200 },
github_branch: { type: 'string', maxLength: 120 },
ghcr_image: { type: 'string', maxLength: 300 },
@@ -17,15 +21,33 @@ const settingsSchema = objectSchema({
api_token: { type: 'string', maxLength: 16384, trim: false },
deploy_webhook_url: { type: 'string', maxLength: 2000, trim: false },
});
const triggerSchema = objectSchema({
force: { type: 'boolean' }, confirmation: { type: 'string', required: true, maxLength: 20 }, reason: { type: 'string', maxLength: 120 },
force: { type: 'boolean' },
confirmation: { type: 'string', required: true, maxLength: 20 },
reason: { type: 'string', maxLength: 120 },
idempotency_key: { type: 'string', maxLength: 160 },
target_version: { type: 'string', maxLength: 40 },
});
const confirmSchema = objectSchema({
confirmation: { type: 'string', required: true, maxLength: 20 },
outcome: { type: 'string', required: true, enum: ['running', 'succeeded', 'failed'] },
message: { type: 'string', maxLength: 500 },
});
function requireRun(run) {
if (!run) throw new PlatformError('Deployment-Auftrag wurde nicht gefunden.', {
code: 'DEPLOYMENT_RUN_NOT_FOUND', status: 404, expose: true,
});
return run;
}
export function registerDeploymentRoutes({ app, routes, deps }) {
routes.register(app, {
id: 'deployments.status.read', method: 'GET', path: '/api/admin/deployment', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.status.read', rateLimit: 'administration', csrf: false, stability: 'stable',
handler: async (_req, res) => res.json(getDeploymentService().status()),
handler: async (_req, res) => res.json(await getDeploymentService().status()),
});
routes.register(app, {
id: 'deployments.settings.update', method: 'PUT', path: '/api/admin/deployment', owner: 'vendoo.deployments',
@@ -48,15 +70,63 @@ export function registerDeploymentRoutes({ app, routes, deps }) {
routes.register(app, {
id: 'deployments.updates.check', method: 'POST', path: '/api/admin/deployment/updates/check', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.updates.checked', rateLimit: 'administration', csrf: true, stability: 'stable',
handler: async (_req, res) => res.json(await getDeploymentService().checkUpdates()),
handler: async (req, res) => {
const result = await getDeploymentService().checkUpdates();
deps.auditLog(req.user.id, req.user.email, 'deployment.updates.checked', 'deployment', 'manifest', JSON.stringify({ update_available: result.update_available || false }), deps.getClientIp(req));
res.json(result);
},
});
routes.register(app, {
id: 'deployments.trigger', method: 'POST', path: '/api/admin/deployment/trigger', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.triggered', rateLimit: 'administration', csrf: true, stability: 'stable', input: triggerSchema,
handler: async (req, res) => {
const result = await getDeploymentService().trigger(req.validatedBody || {});
deps.auditLog(req.user.id, req.user.email, 'deployment.triggered', 'deployment', 'coolify', JSON.stringify({ backup_id: result.backup_id, branch: result.github_branch }), deps.getClientIp(req));
res.status(202).json(result);
const idempotencyKey = req.get('Idempotency-Key') || req.validatedBody?.idempotency_key || '';
const result = await getDeploymentService().trigger(req.validatedBody || {}, { userId: req.user.id, idempotencyKey });
deps.auditLog(req.user.id, req.user.email, 'deployment.triggered', 'deployment', result.id, JSON.stringify({ backup_id: result.backup_id, branch: result.github_branch, target_version: result.target_version, reused: result.reused }), deps.getClientIp(req));
res.status(result.reused ? 200 : 202).json(result);
},
});
routes.register(app, {
id: 'deployments.runs.list', method: 'GET', path: '/api/admin/deployment/runs', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.runs.read', rateLimit: 'administration', csrf: false, stability: 'stable',
handler: async (req, res) => res.json({ runs: getDeploymentService().listRuns(Number(req.query.limit) || 20) }),
});
routes.register(app, {
id: 'deployments.runs.read', method: 'GET', path: '/api/admin/deployment/runs/:id', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.run.read', rateLimit: 'administration', csrf: false, stability: 'stable',
handler: async (req, res) => res.json(requireRun(getDeploymentService().getRun(req.params.id))),
});
routes.register(app, {
id: 'deployments.runs.refresh', method: 'POST', path: '/api/admin/deployment/runs/:id/refresh', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.run.refreshed', rateLimit: 'administration', csrf: true, stability: 'stable',
handler: async (req, res) => {
requireRun(getDeploymentService().getRun(req.params.id));
const result = await getDeploymentService().refreshRun(req.params.id);
deps.auditLog(req.user.id, req.user.email, 'deployment.run.refreshed', 'deployment', req.params.id, JSON.stringify({ state: result.state }), deps.getClientIp(req));
res.json(result);
},
});
routes.register(app, {
id: 'deployments.runs.confirm', method: 'POST', path: '/api/admin/deployment/runs/:id/confirm', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.run.confirmed', rateLimit: 'administration', csrf: true, stability: 'stable', input: confirmSchema,
handler: async (req, res) => {
const result = await getDeploymentService().confirmExternal(req.params.id, req.validatedBody || {});
deps.auditLog(req.user.id, req.user.email, 'deployment.run.confirmed', 'deployment', req.params.id, JSON.stringify({ outcome: req.validatedBody?.outcome, state: result.state }), deps.getClientIp(req));
res.json(result);
},
});
routes.register(app, {
id: 'deployments.production.check.run', method: 'POST', path: '/api/admin/deployment/production-check', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.production.checked', rateLimit: 'administration', csrf: true, stability: 'stable',
handler: async (req, res) => {
const result = await getDeploymentService().runProductionCheck({ userId: req.user.id });
deps.auditLog(req.user.id, req.user.email, 'deployment.production.checked', 'deployment', result.id, JSON.stringify({ overall_status: result.overall_status, failed_count: result.failed_count }), deps.getClientIp(req));
res.json(result);
},
});
routes.register(app, {
id: 'deployments.production.check.read', method: 'GET', path: '/api/admin/deployment/production-check', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.production.check.read', rateLimit: 'administration', csrf: false, stability: 'stable',
handler: async (_req, res) => res.json({ check: getDeploymentService().latestProductionCheck() }),
});
}
+525 -74
View File
@@ -1,15 +1,24 @@
import { mkdirSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.mjs';
import { getSecret, getSecretMetadata, hasSecret, setSecret } from '../../../lib/secret-store.mjs';
import { OPERATIONS_DIR, APP_VERSION } from '../../../lib/runtime-paths.mjs';
import { getSecret, getSecretMetadata, setSecret } from '../../../lib/secret-store.mjs';
import {
APP_ROOT, APP_VERSION, BACKUP_DIR, DATA_ROOT, DB_PATH, LOG_DIR,
MODULES_DIR, MODULE_STATE_PATH, OPERATIONS_DIR, UPLOADS_DIR,
runtimePathSummary, verifyRuntimeWritable,
} from '../../../lib/runtime-paths.mjs';
import { db } from '../../../lib/db.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
import { deploymentRepository } from './repository.mjs';
const LAST_DEPLOYMENT_PATH = join(OPERATIONS_DIR, 'last-deployment-request.json');
const PROVIDERS = new Set(['none', 'coolify']);
const MODES = new Set(['webhook', 'api']);
const CHANNELS = new Set(['stable', 'beta']);
const METHODS = new Set(['GET', 'POST']);
const TERMINAL_COOLIFY_SUCCESS = new Set(['finished', 'success', 'succeeded', 'completed', 'healthy']);
const TERMINAL_COOLIFY_FAILURE = new Set(['failed', 'error', 'cancelled', 'canceled', 'stopped']);
const MASKED_SECRET = '••••••••';
function fail(message, code = 'DEPLOYMENT_CONFIGURATION_INVALID', status = 400, details = null) {
throw new PlatformError(message, { code, status, expose: true, details });
@@ -28,6 +37,12 @@ function cleanUuid(value) {
return text;
}
function cleanIdempotencyKey(value) {
const text = clean(value || randomUUID(), 160);
if (!/^[a-zA-Z0-9._:-]{8,160}$/.test(text)) fail('Idempotency-Key besitzt ein ungültiges Format.', 'IDEMPOTENCY_KEY_INVALID');
return text;
}
function normalizeBaseUrl(value) {
const text = clean(value, 1000).replace(/\/$/, '');
if (!text) return '';
@@ -54,16 +69,28 @@ function normalizeWebhookUrl(value) {
return url.toString();
}
function readLastDeployment() {
try { return existsSync(LAST_DEPLOYMENT_PATH) ? JSON.parse(readFileSync(LAST_DEPLOYMENT_PATH, 'utf8')) : null; }
catch { return null; }
function normalizeEndpoint(value, fallback, placeholders = []) {
const text = clean(value || fallback, 500);
if (!text.startsWith('/') || text.startsWith('//') || text.includes('..')) fail('Coolify API-Endpunkt muss ein sicherer relativer Pfad sein.');
if (/\r|\n|\0/.test(text)) fail('Coolify API-Endpunkt ist ungültig.');
const unknown = [...text.matchAll(/\{([^}]+)\}/g)].map(match => match[1]).filter(name => !placeholders.includes(name));
if (unknown.length) fail(`Unbekannter Platzhalter im Coolify-Endpunkt: ${unknown.join(', ')}`);
return text;
}
function writeLastDeployment(value) {
mkdirSync(dirname(LAST_DEPLOYMENT_PATH), { recursive: true });
const temporary = `${LAST_DEPLOYMENT_PATH}.${process.pid}.tmp`;
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
renameSync(temporary, LAST_DEPLOYMENT_PATH);
function boolEnv(name, fallback = false) {
const raw = process.env[name];
if (raw === undefined || raw === '') return fallback;
return /^(1|true|yes|on)$/i.test(String(raw));
}
function intEnv(name, fallback, min, max) {
const value = Number(process.env[name]);
return Number.isFinite(value) ? Math.max(min, Math.min(max, Math.trunc(value))) : fallback;
}
function buildRevision() {
return clean(process.env.VENDOO_BUILD_REVISION || process.env.GIT_COMMIT || process.env.SOURCE_COMMIT || '', 160) || null;
}
function currentSettings() {
@@ -78,88 +105,288 @@ function currentSettings() {
trigger_mode: mode,
deploy_method: method,
deploy_endpoint: String(process.env.COOLIFY_DEPLOY_ENDPOINT || '/api/v1/deploy?uuid={uuid}&force={force}'),
deployment_status_endpoint: String(process.env.COOLIFY_DEPLOYMENT_STATUS_ENDPOINT || '/api/v1/deployments/{deployment_id}'),
github_repository: String(process.env.VENDOO_GITHUB_REPOSITORY || 'Masterluke77/vendoo'),
github_branch: String(process.env.VENDOO_GITHUB_BRANCH || 'main'),
ghcr_image: String(process.env.VENDOO_GHCR_IMAGE || 'ghcr.io/masterluke77/vendoo'),
update_channel: channel,
backup_before_deploy: !/^(0|false|no)$/i.test(String(process.env.VENDOO_BACKUP_BEFORE_DEPLOY || 'true')),
backup_uploads: /^(1|true|yes)$/i.test(String(process.env.VENDOO_DEPLOY_BACKUP_UPLOADS || 'false')),
backup_uploads: boolEnv('VENDOO_DEPLOY_BACKUP_UPLOADS', false),
deployment_timeout_seconds: intEnv('VENDOO_DEPLOY_TIMEOUT_SECONDS', 900, 60, 7200),
health_poll_seconds: intEnv('VENDOO_DEPLOY_HEALTH_POLL_SECONDS', 15, 5, 300),
api_token: getSecretMetadata('COOLIFY_API_TOKEN'),
deploy_webhook: getSecretMetadata('COOLIFY_DEPLOY_WEBHOOK_URL'),
};
}
function publicSettings() {
const settings = currentSettings();
const configured = settings.provider === 'coolify' && (
function configuredState(settings) {
return settings.provider === 'coolify' && (
(settings.trigger_mode === 'webhook' && settings.deploy_webhook.configured) ||
(settings.trigger_mode === 'api' && settings.coolify_url && settings.resource_uuid && settings.api_token.configured)
);
}
function publicSettings(repository = deploymentRepository) {
const settings = currentSettings();
return {
...settings,
configured,
configured: configuredState(settings),
api_token: { configured: settings.api_token.configured, masked: settings.api_token.masked, provider: settings.api_token.provider },
deploy_webhook: { configured: settings.deploy_webhook.configured, masked: settings.deploy_webhook.masked, provider: settings.deploy_webhook.provider },
current_version: APP_VERSION,
last_deployment: readLastDeployment(),
build_revision: buildRevision(),
active_run: repository.getActiveRun(),
recent_runs: repository.listRuns(10).map(run => ({ ...run, events: repository.listEvents(run.id, 50) })),
docker_socket_access: false,
};
}
function buildApiDeployUrl(settings, force) {
const endpoint = settings.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}';
if (!endpoint.startsWith('/')) fail('Coolify API-Endpunkt muss mit / beginnen.');
if (/\r|\n|\0/.test(endpoint) || endpoint.includes('..')) fail('Coolify API-Endpunkt ist ungültig.');
const path = endpoint
.replaceAll('{uuid}', encodeURIComponent(settings.resource_uuid))
.replaceAll('{force}', force ? 'true' : 'false');
function buildApiUrl(settings, template, replacements) {
let path = template;
for (const [name, value] of Object.entries(replacements)) path = path.replaceAll(`{${name}}`, encodeURIComponent(String(value ?? '')));
return `${settings.coolify_url}${path}`;
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
function buildApiDeployUrl(settings, force) {
const endpoint = normalizeEndpoint(settings.deploy_endpoint, '/api/v1/deploy?uuid={uuid}&force={force}', ['uuid', 'force']);
return buildApiUrl(settings, endpoint, { uuid: settings.resource_uuid, force: force ? 'true' : 'false' });
}
function buildApiStatusUrl(settings, deploymentId) {
const endpoint = normalizeEndpoint(settings.deployment_status_endpoint, '/api/v1/deployments/{deployment_id}', ['deployment_id']);
return buildApiUrl(settings, endpoint, { deployment_id: deploymentId });
}
async function fetchWithTimeout(fetchImpl, url, options = {}, timeoutMs = 15000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { return await fetch(url, { ...options, signal: controller.signal, redirect: 'error' }); }
try { return await fetchImpl(url, { ...options, signal: controller.signal, redirect: 'error' }); }
catch (error) {
if (error?.name === 'AbortError') fail('Coolify-Anfrage hat das Zeitlimit überschritten.', 'COOLIFY_TIMEOUT', 504);
fail(`Coolify ist nicht erreichbar: ${error.message}`, 'COOLIFY_UNREACHABLE', 502);
if (error?.name === 'AbortError') fail('Anfrage hat das Zeitlimit überschritten.', 'REQUEST_TIMEOUT', 504);
fail(`Ziel ist nicht erreichbar: ${error.message}`, 'REMOTE_UNREACHABLE', 502);
} finally { clearTimeout(timeout); }
}
async function readSafeResponse(response) {
const text = (await response.text()).slice(0, 4000);
const text = (await response.text()).slice(0, 64 * 1024);
try { return text ? JSON.parse(text) : null; } catch { return text || null; }
}
export function createDeploymentService({ createBackup, checkForUpdates } = {}) {
function redact(value, depth = 0) {
if (depth > 8) return '[depth-limit]';
if (Array.isArray(value)) return value.slice(0, 100).map(item => redact(item, depth + 1));
if (!value || typeof value !== 'object') return typeof value === 'string' ? value.slice(0, 4000) : value;
const result = {};
for (const [key, item] of Object.entries(value)) {
if (/token|secret|password|authorization|webhook|credential|key/i.test(key)) result[key] = '[redacted]';
else result[key] = redact(item, depth + 1);
}
return result;
}
function extractDeploymentId(value, depth = 0) {
if (depth > 6 || value == null) return null;
if (Array.isArray(value)) {
for (const item of value) { const found = extractDeploymentId(item, depth + 1); if (found) return found; }
return null;
}
if (typeof value !== 'object') return null;
for (const key of ['deployment_id', 'deployment_uuid', 'deploymentUuid', 'uuid', 'id']) {
const candidate = value[key];
if (candidate && /^[a-zA-Z0-9_-]{4,160}$/.test(String(candidate))) return String(candidate);
}
for (const item of Object.values(value)) { const found = extractDeploymentId(item, depth + 1); if (found) return found; }
return null;
}
function extractRemoteStatus(value) {
if (!value || typeof value !== 'object') return '';
const candidates = [value.status, value.state, value.deployment_status, value.data?.status, value.data?.state];
return String(candidates.find(Boolean) || '').trim().toLowerCase();
}
function semver(value) {
const match = String(value || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
if (!match) return null;
return { raw: String(value).replace(/^v/, ''), major: +match[1], minor: +match[2], patch: +match[3], prerelease: match[4] || '' };
}
function compareVersions(a, b) {
const left = semver(a); const right = semver(b);
if (!left || !right) fail('Update-Manifest enthält eine ungültige SemVer-Version.', 'UPDATE_VERSION_INVALID', 502);
for (const key of ['major', 'minor', 'patch']) if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
if (left.prerelease === right.prerelease) return 0;
if (!left.prerelease) return 1;
if (!right.prerelease) return -1;
return left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true });
}
function validateUpdateResult(result, channel) {
if (!result?.configured) return { ...result, current_version: APP_VERSION, channel };
const latest = String(result.latest_version || result.version || result.manifest?.version || '');
const parsed = semver(latest);
if (!parsed) fail('Update-Manifest enthält keine gültige SemVer-Version.', 'UPDATE_VERSION_INVALID', 502);
if (channel === 'stable' && parsed.prerelease) fail('Der Stable-Kanal akzeptiert keine Vorabversion.', 'UPDATE_CHANNEL_MISMATCH', 502);
const manifest = result.manifest && typeof result.manifest === 'object' ? result.manifest : {};
const manifestChannel = String(manifest.channel || channel).toLowerCase();
if (!CHANNELS.has(manifestChannel) || (channel === 'stable' && manifestChannel !== 'stable')) {
fail('Update-Manifest gehört nicht zum konfigurierten Update-Kanal.', 'UPDATE_CHANNEL_MISMATCH', 502);
}
const updateAvailable = compareVersions(latest, APP_VERSION) > 0;
const sha256 = String(manifest.sha256 || manifest.package_sha256 || result.sha256 || '').trim().toLowerCase();
if (updateAvailable && !/^[a-f0-9]{64}$/.test(sha256)) fail('Update-Manifest enthält keine gültige SHA-256-Prüfsumme.', 'UPDATE_CHECKSUM_MISSING', 502);
const downloadUrl = String(manifest.download_url || manifest.package_url || result.download_url || '').trim();
if (downloadUrl) {
let url;
try { url = new URL(downloadUrl); } catch { fail('Update-Manifest enthält eine ungültige Paket-URL.', 'UPDATE_URL_INVALID', 502); }
if (url.protocol !== 'https:') fail('Update-Pakete müssen über HTTPS bereitgestellt werden.', 'UPDATE_URL_INSECURE', 502);
}
return {
...result,
configured: true,
current_version: APP_VERSION,
latest_version: parsed.raw,
update_available: updateAvailable,
channel,
manifest: { ...manifest, sha256, download_url: downloadUrl || '' },
};
}
function directorySize(root, maxEntries = 100000) {
let bytes = 0; let files = 0; let truncated = false;
const queue = [root];
while (queue.length) {
const current = queue.pop();
if (!existsSync(current)) continue;
let entries;
try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; }
for (const entry of entries) {
if (++files > maxEntries) { truncated = true; queue.length = 0; break; }
const absolute = join(current, entry.name);
try {
if (entry.isDirectory()) queue.push(absolute);
else if (entry.isFile()) bytes += statSync(absolute).size;
} catch {}
}
}
return { path: root, bytes, files, truncated };
}
function fluxStatus(getPlatformSnapshot) {
try {
const module = getPlatformSnapshot?.()?.modules?.find(item => item.id === 'vendoo.flux-studio');
if (module) return { enabled: Boolean(module.enabled), state: module.state, source: 'platform-kernel' };
} catch {}
try {
if (existsSync(MODULE_STATE_PATH)) {
const state = JSON.parse(readFileSync(MODULE_STATE_PATH, 'utf8'));
const record = state?.modules?.['vendoo.flux-studio'];
if (record) return { enabled: record.enabled !== false, state: record.enabled === false ? 'disabled' : 'configured', source: 'module-state' };
}
} catch {}
const initiallyDisabled = String(process.env.VENDOO_INITIAL_DISABLED_MODULES || '').split(',').map(item => item.trim()).includes('vendoo.flux-studio');
return { enabled: !initiallyDisabled, state: initiallyDisabled ? 'disabled' : 'default-enabled', source: 'environment-default' };
}
function internalReadiness() {
const checks = [];
try {
const value = db.prepare('SELECT 1 AS ok').get();
checks.push({ name: 'database', ok: Number(value?.ok) === 1 });
} catch (error) { checks.push({ name: 'database', ok: false, error: error.message }); }
for (const item of verifyRuntimeWritable()) checks.push({ name: 'filesystem', ok: item.writable, path: item.path, error: item.error || null });
return { ok: checks.every(item => item.ok), checks };
}
async function probePublicReadiness(fetchImpl, timeoutMs = 8000) {
const base = String(process.env.PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.PORT || 8124}`).replace(/\/$/, '');
const response = await fetchWithTimeout(fetchImpl, `${base}/readyz`, { headers: { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` } }, timeoutMs);
const body = await readSafeResponse(response);
return { ok: response.ok && body?.ok !== false, http_status: response.status, version: body?.version || null, body: redact(body) };
}
function productionChecks({ getPlatformSnapshot } = {}) {
const results = [];
const add = (id, status, title, message, details = {}) => results.push({ id, status, title, message, details });
const publicBase = String(process.env.PUBLIC_BASE_URL || '').trim();
let publicUrl = null;
try { publicUrl = publicBase ? new URL(publicBase) : null; } catch {}
add('https', publicUrl?.protocol === 'https:' ? 'pass' : 'fail', 'Öffentliche HTTPS-URL', publicUrl?.protocol === 'https:' ? publicUrl.origin : 'PUBLIC_BASE_URL fehlt oder verwendet nicht HTTPS.');
const secureCookieMode = String(process.env.VENDOO_COOKIE_SECURE || 'auto').trim().toLowerCase();
const secureCookies = secureCookieMode === 'true' || (secureCookieMode === 'auto' && publicUrl?.protocol === 'https:');
add('secure-cookies', secureCookies ? 'pass' : 'fail', 'Sichere Cookies', secureCookies ? `Secure-Cookies sind aktiv (${secureCookieMode}).` : 'VENDOO_COOKIE_SECURE ist nicht aktiv oder Auto-Erkennung hat keine HTTPS-URL.');
add('trust-proxy', boolEnv('VENDOO_TRUST_PROXY', false) ? 'pass' : 'warning', 'Trust Proxy', boolEnv('VENDOO_TRUST_PROXY', false) ? 'Reverse-Proxy-Vertrauen ist aktiviert.' : 'VENDOO_TRUST_PROXY ist nicht aktiv.');
const expectedHost = publicUrl?.host || '';
const hosts = String(process.env.VENDOO_ALLOWED_HOSTS || '').split(',').map(item => item.trim()).filter(Boolean);
const origins = String(process.env.VENDOO_ALLOWED_ORIGINS || '').split(',').map(item => item.trim()).filter(Boolean);
add('allowed-host', expectedHost && hosts.includes(expectedHost) ? 'pass' : 'fail', 'Allowed Host', expectedHost && hosts.includes(expectedHost) ? expectedHost : 'Produktivhost fehlt in VENDOO_ALLOWED_HOSTS.', { expected: expectedHost, configured_count: hosts.length });
add('allowed-origin', publicUrl?.origin && origins.includes(publicUrl.origin) ? 'pass' : 'fail', 'Allowed Origin', publicUrl?.origin && origins.includes(publicUrl.origin) ? publicUrl.origin : 'Produktivorigin fehlt in VENDOO_ALLOWED_ORIGINS.', { expected: publicUrl?.origin || '', configured_count: origins.length });
const persistent = DATA_ROOT !== APP_ROOT && dirname(DB_PATH).startsWith(DATA_ROOT) && UPLOADS_DIR.startsWith(DATA_ROOT) && BACKUP_DIR.startsWith(DATA_ROOT);
add('persistent-paths', persistent ? 'pass' : 'fail', 'Persistente Pfade', persistent ? `Daten liegen unter ${DATA_ROOT}.` : 'Mindestens ein Laufzeitpfad liegt nicht im persistenten Datenbereich.', runtimePathSummary());
const writable = verifyRuntimeWritable();
add('writable', writable.every(item => item.writable) ? 'pass' : 'fail', 'Schreibrechte', writable.every(item => item.writable) ? 'Alle Laufzeitpfade sind beschreibbar.' : 'Mindestens ein Laufzeitpfad ist nicht beschreibbar.', { paths: writable });
let integrity = 'unknown';
try { integrity = String(db.pragma('integrity_check', { simple: true })); } catch (error) { integrity = error.message; }
add('sqlite-integrity', integrity === 'ok' ? 'pass' : 'fail', 'SQLite-Integrität', integrity === 'ok' ? 'SQLite meldet ok.' : `SQLite-Integritätsprüfung: ${integrity}`);
add('backup-target', existsSync(BACKUP_DIR) ? 'pass' : 'fail', 'Backupziel', existsSync(BACKUP_DIR) ? BACKUP_DIR : 'Backupziel fehlt.');
const flux = fluxStatus(getPlatformSnapshot);
add('flux-disabled', flux.enabled ? 'fail' : 'pass', 'FLUX Studio deaktiviert', flux.enabled ? 'FLUX Studio ist aktiv und widerspricht dem Produktions-Scope.' : 'FLUX Studio ist deaktiviert.', flux);
const settings = currentSettings();
add('deployment-secret', configuredState(settings) ? 'pass' : 'warning', 'Coolify-Zugang', configuredState(settings) ? 'Deployment-Zugang ist gesetzt; Secretwerte werden nicht angezeigt.' : 'Coolify-Deployment ist noch nicht vollständig konfiguriert.', {
mode: settings.trigger_mode,
api_token_configured: settings.api_token.configured,
webhook_configured: settings.deploy_webhook.configured,
});
const autoDeploy = String(process.env.VENDOO_COOLIFY_AUTO_DEPLOY || '').trim().toLowerCase();
add('auto-deploy', ['false', '0', 'off', 'no'].includes(autoDeploy) ? 'pass' : 'warning', 'Coolify Auto Deploy', ['false', '0', 'off', 'no'].includes(autoDeploy) ? 'Auto Deploy ist als deaktiviert dokumentiert.' : 'Vendoo kann den Coolify-Schalter nicht sicher auslesen. Auto Deploy manuell deaktiviert lassen.', { configured: autoDeploy || 'unknown' });
return results;
}
export function createDeploymentService(adapters = {}) {
const {
createBackup,
verifyStoredBackup,
checkForUpdates,
repository = deploymentRepository,
fetchImpl = globalThis.fetch,
getPlatformSnapshot,
} = adapters;
if (typeof createBackup !== 'function') fail('Backup-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
if (typeof verifyStoredBackup !== 'function') fail('Backup-Verifikationsadapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
if (typeof fetchImpl !== 'function') fail('Fetch-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
let monitorTimer = null;
let monitoring = false;
async function checkUpdates() {
if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' };
return validateUpdateResult(await checkForUpdates({ channel: currentSettings().update_channel }), currentSettings().update_channel);
}
async function testConnection() {
const settings = currentSettings();
if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.');
if (!settings.coolify_url) fail('Coolify URL fehlt.');
const response = await fetchWithTimeout(`${settings.coolify_url}/api/health`, { headers: { Accept: 'application/json' } }, 10000);
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
if (settings.trigger_mode === 'api') {
const token = getSecret('COOLIFY_API_TOKEN');
if (!token) fail('Coolify API-Token fehlt.');
headers.Authorization = `Bearer ${token}`;
}
const response = await fetchWithTimeout(fetchImpl, `${settings.coolify_url}/api/health`, { headers }, 10000);
const body = await readSafeResponse(response);
if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: body });
return { ok: true, status: response.status, response: body, url: settings.coolify_url };
if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: redact(body) });
return { ok: true, status: response.status, response: redact(body), url: settings.coolify_url };
}
async function trigger({ force = false, confirmation = '', reason = 'manual' } = {}) {
if (confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED');
const settings = currentSettings();
if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.');
let backup = null;
if (settings.backup_before_deploy) {
backup = await createBackup({
kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true,
label: `pre-deploy-${APP_VERSION}`,
});
}
async function requestCoolify(settings, force) {
let url;
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
let method = settings.deploy_method;
const method = settings.deploy_method;
if (settings.trigger_mode === 'webhook') {
url = normalizeWebhookUrl(getSecret('COOLIFY_DEPLOY_WEBHOOK_URL'));
if (!url) fail('Coolify Deploy-Webhook ist nicht konfiguriert.');
@@ -169,23 +396,233 @@ export function createDeploymentService({ createBackup, checkForUpdates } = {})
url = buildApiDeployUrl(settings, Boolean(force));
headers.Authorization = `Bearer ${token}`;
}
const requestedAt = new Date().toISOString();
const response = await fetchWithTimeout(url, { method, headers }, 20000);
const response = await fetchWithTimeout(fetchImpl, url, { method, headers }, 20000);
const body = await readSafeResponse(response);
const record = {
requested_at: requestedAt,
provider: 'coolify', trigger_mode: settings.trigger_mode, method,
resource_uuid: settings.resource_uuid || null,
github_repository: settings.github_repository, github_branch: settings.github_branch,
version: APP_VERSION, force: Boolean(force), reason: clean(reason, 120),
accepted: response.ok, http_status: response.status,
backup_id: backup?.id || null, backup_file: backup?.filename || null,
response: body,
if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: redact(body) });
return { method, http_status: response.status, body: redact(body), deployment_id: extractDeploymentId(body) };
}
async function trigger(input = {}, context = {}) {
if (input.confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED');
const settings = currentSettings();
if (!configuredState(settings)) fail('Coolify ist nicht vollständig konfiguriert.', 'DEPLOYMENT_NOT_CONFIGURED');
const targetVersion = clean(input.target_version || APP_VERSION, 40).replace(/^v/, '');
if (!semver(targetVersion)) fail('Zielversion ist keine gültige SemVer-Version.', 'DEPLOYMENT_TARGET_VERSION_INVALID');
const idempotencyKey = cleanIdempotencyKey(input.idempotency_key || context.idempotencyKey);
const existing = repository.findByIdempotencyKey(idempotencyKey);
if (existing) return { ...existing, reused: true, events: repository.listEvents(existing.id) };
if (compareVersions(targetVersion, APP_VERSION) > 0) {
const update = await checkUpdates();
if (!update.configured || update.latest_version !== targetVersion || !update.update_available) {
fail('Zielversion ist nicht durch das freigegebene Update-Manifest bestätigt.', 'DEPLOYMENT_TARGET_NOT_APPROVED', 409, { target_version: targetVersion, update });
}
}
const timeoutAt = new Date(Date.now() + settings.deployment_timeout_seconds * 1000).toISOString();
const created = repository.createRun({
idempotencyKey,
provider: settings.provider,
triggerMode: settings.trigger_mode,
currentVersion: APP_VERSION,
targetVersion,
buildRevisionBefore: buildRevision(),
requestedBy: context.userId || null,
reason: clean(input.reason || 'manual', 120),
githubRepository: settings.github_repository,
githubBranch: settings.github_branch,
timeoutAt,
request: { force: Boolean(input.force), target_version: targetVersion, backup_required: settings.backup_before_deploy },
});
if (created.reused) return { ...created.run, reused: true, events: repository.listEvents(created.run.id) };
let run = created.run;
try {
if (settings.backup_before_deploy) {
run = repository.transition(run.id, 'backup_pending', { message: 'Vorab-Backup ist verpflichtend.' });
run = repository.transition(run.id, 'backup_running', { message: 'Vorab-Backup wird erstellt.' });
const backup = await createBackup({
kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true,
label: `pre-deploy-${APP_VERSION}-to-${targetVersion}`,
});
const verification = verifyStoredBackup(backup.id);
repository.recordBackupVerification({
backupId: backup.id,
runId: run.id,
status: verification.ok ? 'verified' : 'invalid',
sha256: backup.sha256,
fileSize: backup.file_size,
sqliteIntegrity: verification.ok ? 'ok' : 'failed',
manifest: verification.manifest || backup.manifest || {},
errors: verification.errors || [],
warnings: verification.warnings || [],
});
if (!verification.ok) fail(`Vorab-Backup ist nicht verifiziert: ${(verification.errors || []).join('; ')}`, 'DEPLOYMENT_BACKUP_INVALID', 409);
run = repository.transition(run.id, 'backup_verified', {
message: 'Vorab-Backup wurde erstellt und verifiziert.',
patch: { backup_id: backup.id },
details: { backup_id: backup.id, sha256: backup.sha256, file_size: backup.file_size },
});
}
run = repository.transition(run.id, 'deploy_requested', { message: 'Coolify-Deployment wird angefordert.' });
const remote = await requestCoolify(settings, Boolean(input.force));
run = repository.transition(run.id, settings.trigger_mode === 'api' ? 'deploy_running' : 'health_wait', {
message: settings.trigger_mode === 'api' ? 'Coolify hat den Deployment-Auftrag angenommen.' : 'Webhook wurde angenommen; externe Bestätigung oder neue Zielversion wird abgewartet.',
eventType: 'coolify.accepted',
patch: { deployment_id: remote.deployment_id || null, result_json: remote },
details: { http_status: remote.http_status, deployment_id: remote.deployment_id || null },
});
scheduleMonitor(1000);
return { ...run, reused: false, events: repository.listEvents(run.id), message: 'Deployment-Auftrag angenommen. Erfolg wird erst nach Status- und Readinessprüfung gemeldet.' };
} catch (error) {
const latest = repository.getRun(run.id);
if (latest && !['failed', 'succeeded'].includes(latest.state)) {
try {
repository.transition(run.id, 'failed', {
message: error.message || 'Deployment-Auftrag ist fehlgeschlagen.',
eventType: 'run.failed',
patch: { error_code: error.code || 'DEPLOYMENT_FAILED', error_message: String(error.message || error).slice(0, 2000) },
});
} catch {}
}
throw error;
}
}
async function pollCoolifyStatus(run, settings) {
if (settings.trigger_mode !== 'api' || !run.deployment_id || !settings.coolify_url) return { available: false };
const token = getSecret('COOLIFY_API_TOKEN');
if (!token) return { available: false, error: 'API-Token fehlt.' };
const url = buildApiStatusUrl(settings, run.deployment_id);
const response = await fetchWithTimeout(fetchImpl, url, {
headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, 'User-Agent': `Vendoo/${APP_VERSION}` },
}, 10000);
const body = await readSafeResponse(response);
if (!response.ok) return { available: true, ok: false, http_status: response.status, body: redact(body) };
const status = extractRemoteStatus(body);
return { available: true, ok: true, http_status: response.status, status, body: redact(body) };
}
async function refreshRun(runId = null, { externalConfirmed = false } = {}) {
const run = runId ? repository.getRun(runId) : repository.getActiveRun();
if (!run) return { state: 'idle', message: 'Kein aktives Deployment.' };
if (['succeeded', 'failed', 'rollback_required'].includes(run.state)) return { ...run, events: repository.listEvents(run.id) };
if (run.timeout_at && Date.parse(run.timeout_at) <= Date.now()) {
const timedOut = repository.transition(run.id, 'rollback_required', {
message: 'Deployment-Zeitlimit überschritten. Rollback oder manuelle Prüfung erforderlich.',
eventType: 'run.timeout',
patch: { error_code: 'DEPLOYMENT_TIMEOUT', error_message: 'Zeitlimit überschritten.' },
});
return { ...timedOut, events: repository.listEvents(run.id) };
}
const settings = currentSettings();
let remote = { available: false };
try { remote = await pollCoolifyStatus(run, settings); }
catch (error) {
repository.patchRun(run.id, {}, { eventType: 'coolify.status_error', message: 'Coolify-Status konnte nicht gelesen werden.', details: { error: error.message } });
}
if (remote.status && TERMINAL_COOLIFY_FAILURE.has(remote.status)) {
const failed = repository.transition(run.id, 'rollback_required', {
message: `Coolify meldet den Fehlerstatus ${remote.status}.`,
eventType: 'coolify.failed',
patch: { result_json: remote, error_code: 'COOLIFY_DEPLOYMENT_FAILED', error_message: `Coolify-Status: ${remote.status}` },
});
return { ...failed, remote, events: repository.listEvents(run.id) };
}
if (run.state === 'deploy_running' && remote.available && remote.ok) {
repository.transition(run.id, 'health_wait', { message: 'Deploymentstatus wurde gelesen; Readiness und Zielversion werden geprüft.', eventType: 'health.wait', details: { remote_status: remote.status || 'unknown' } });
}
let readiness;
try { readiness = await probePublicReadiness(fetchImpl, 8000); }
catch (error) { readiness = { ok: false, error: error.message }; }
const targetMatches = readiness.ok && String(readiness.version || '').replace(/^v/, '') === String(run.target_version).replace(/^v/, '');
const remoteSucceeded = remote.available && remote.ok && TERMINAL_COOLIFY_SUCCESS.has(remote.status);
const persistedConfirmation = repository.listEvents(run.id).some(event => event.event_type === 'external.succeeded');
const confirmed = Boolean(externalConfirmed || persistedConfirmation);
const deploymentConfirmed = settings.trigger_mode === 'api' ? (remoteSucceeded || confirmed) : confirmed;
if (readiness.ok && targetMatches && deploymentConfirmed) {
const succeeded = repository.transition(run.id, 'succeeded', {
message: 'Deployment, Readiness und Zielversion wurden erfolgreich bestätigt.',
eventType: 'run.succeeded',
patch: { build_revision_after: buildRevision(), result_json: { remote, readiness } },
details: { target_version: run.target_version, readiness_version: readiness.version, remote_status: remote.status || null },
});
return { ...succeeded, remote, readiness, events: repository.listEvents(run.id) };
}
repository.patchRun(run.id, { result_json: { remote, readiness } }, {
eventType: 'health.pending',
message: readiness.ok ? 'Readiness ist grün, aber Zielversion oder externe Bestätigung fehlt.' : 'Readiness ist noch nicht grün.',
details: { readiness_ok: Boolean(readiness.ok), readiness_version: readiness.version || null, target_version: run.target_version, remote_status: remote.status || null, external_confirmation: confirmed, deployment_confirmed: deploymentConfirmed },
});
return { ...repository.getRun(run.id), remote, readiness, events: repository.listEvents(run.id) };
}
async function confirmExternal(runId, input = {}) {
if (input.confirmation !== 'CONFIRM') fail('Zur externen Bestätigung muss exakt CONFIRM übermittelt werden.', 'DEPLOYMENT_EXTERNAL_CONFIRMATION_REQUIRED');
const run = repository.getRun(runId);
if (!run) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404);
if (!['deploy_requested', 'deploy_running', 'health_wait', 'rollback_required'].includes(run.state)) fail('Dieser Deployment-Auftrag kann nicht extern bestätigt werden.', 'DEPLOYMENT_CONFIRMATION_STATE_INVALID', 409);
const outcome = clean(input.outcome || 'succeeded', 20);
if (outcome === 'failed') {
const nextState = run.state === 'rollback_required' ? 'failed' : 'rollback_required';
return repository.transition(run.id, nextState, {
message: clean(input.message || 'Externes Deployment wurde als fehlgeschlagen bestätigt.', 500),
eventType: 'external.failed',
patch: { error_code: 'EXTERNAL_DEPLOYMENT_FAILED', error_message: clean(input.message || 'Externe Fehlermeldung.', 1000) },
});
}
if (outcome === 'running') {
if (run.state === 'deploy_requested') repository.transition(run.id, 'deploy_running', { message: 'Externes System bestätigt laufendes Deployment.', eventType: 'external.running' });
return refreshRun(run.id);
}
if (outcome !== 'succeeded') fail('Externe Bestätigung muss running, succeeded oder failed verwenden.');
repository.addEvent(run.id, run.state, 'external.succeeded', clean(input.message || 'Externes System meldet Abschluss.', 500), {});
return refreshRun(run.id, { externalConfirmed: true });
}
function scheduleMonitor(delayMs = null) {
if (monitorTimer) clearTimeout(monitorTimer);
const interval = currentSettings().health_poll_seconds * 1000;
monitorTimer = setTimeout(async () => {
if (monitoring) return scheduleMonitor(interval);
monitoring = true;
try { await refreshRun(); } catch (error) { console.error('Deployment-Monitor:', error.message); }
finally {
monitoring = false;
const active = repository.getActiveRun();
if (active && active.state !== 'rollback_required') scheduleMonitor(interval);
}
}, delayMs ?? interval);
monitorTimer.unref?.();
}
async function status() {
const settings = publicSettings(repository);
const lastBackup = db.prepare(`SELECT id, kind, label, file_name, file_size, sha256, status, verified_at, created_at
FROM operation_backups WHERE status = 'verified' ORDER BY created_at DESC LIMIT 1`).get() || null;
return {
...settings,
production: {
version: APP_VERSION,
build_revision: buildRevision(),
deployment_provider: settings.provider,
domain: (() => { try { return new URL(String(process.env.PUBLIC_BASE_URL || '')).host; } catch { return ''; } })(),
public_base_url: String(process.env.PUBLIC_BASE_URL || ''),
readiness: internalReadiness(),
database: { path: DB_PATH, integrity: (() => { try { return db.pragma('integrity_check', { simple: true }); } catch (error) { return error.message; } })() },
storage: {
data: directorySize(DATA_ROOT), uploads: directorySize(UPLOADS_DIR), backups: directorySize(BACKUP_DIR),
operations: directorySize(OPERATIONS_DIR), logs: directorySize(LOG_DIR), modules: directorySize(MODULES_DIR),
},
last_backup: lastBackup,
last_deployment: repository.listRuns(1)[0] || null,
flux: fluxStatus(getPlatformSnapshot),
},
latest_production_check: repository.latestProductionCheck(),
};
writeLastDeployment(record);
if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: body, backup_id: backup?.id || null });
return { ...record, message: 'Coolify-Deployment wurde angefordert. Der laufende Container wird nicht selbst verändert.' };
}
function update(input = {}) {
@@ -204,13 +641,16 @@ export function createDeploymentService({ createBackup, checkForUpdates } = {})
COOLIFY_RESOURCE_UUID: cleanUuid(input.resource_uuid || ''),
COOLIFY_TRIGGER_MODE: mode,
COOLIFY_DEPLOY_METHOD: method,
COOLIFY_DEPLOY_ENDPOINT: clean(input.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}', 500),
COOLIFY_DEPLOY_ENDPOINT: normalizeEndpoint(input.deploy_endpoint, '/api/v1/deploy?uuid={uuid}&force={force}', ['uuid', 'force']),
COOLIFY_DEPLOYMENT_STATUS_ENDPOINT: normalizeEndpoint(input.deployment_status_endpoint, '/api/v1/deployments/{deployment_id}', ['deployment_id']),
VENDOO_GITHUB_REPOSITORY: clean(input.github_repository || 'Masterluke77/vendoo', 200),
VENDOO_GITHUB_BRANCH: clean(input.github_branch || 'main', 120),
VENDOO_GHCR_IMAGE: clean(input.ghcr_image || 'ghcr.io/masterluke77/vendoo', 300),
VENDOO_UPDATE_CHANNEL: channel,
VENDOO_BACKUP_BEFORE_DEPLOY: input.backup_before_deploy === false ? 'false' : 'true',
VENDOO_DEPLOY_BACKUP_UPLOADS: input.backup_uploads === true ? 'true' : 'false',
VENDOO_DEPLOY_TIMEOUT_SECONDS: String(Math.max(60, Math.min(7200, Number(input.deployment_timeout_seconds) || 900))),
VENDOO_DEPLOY_HEALTH_POLL_SECONDS: String(Math.max(5, Math.min(300, Number(input.health_poll_seconds) || 15))),
};
let content = readRuntimeConfig();
for (const [key, value] of Object.entries(values)) {
@@ -218,27 +658,38 @@ export function createDeploymentService({ createBackup, checkForUpdates } = {})
process.env[key] = value;
}
writeRuntimeConfig(content);
if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== '••••••••') {
if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== MASKED_SECRET) {
setSecret('COOLIFY_API_TOKEN', clean(input.api_token, 16384), { source: 'deployment-ui' });
}
if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== '••••••••') {
if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== MASKED_SECRET) {
setSecret('COOLIFY_DEPLOY_WEBHOOK_URL', normalizeWebhookUrl(input.deploy_webhook_url), { source: 'deployment-ui' });
}
return publicSettings();
return publicSettings(repository);
}
function runProductionCheck(context = {}) {
const results = productionChecks({ getPlatformSnapshot });
return repository.createProductionCheck({ createdBy: context.userId || null, results });
}
scheduleMonitor(2500);
return Object.freeze({
status: publicSettings,
status,
update,
testConnection,
trigger,
async checkUpdates() {
if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' };
return checkForUpdates();
},
checkUpdates,
refreshRun,
confirmExternal,
listRuns(limit) { return repository.listRuns(limit).map(run => ({ ...run, events: repository.listEvents(run.id) })); },
getRun(id) { const run = repository.getRun(id); return run ? { ...run, events: repository.listEvents(id) } : null; },
runProductionCheck,
latestProductionCheck: repository.latestProductionCheck,
health() {
const status = publicSettings();
return { ok: true, provider: status.provider, configured: status.configured, docker_socket_access: false };
const settings = publicSettings(repository);
return { ok: true, provider: settings.provider, configured: settings.configured, state: settings.active_run?.state || 'idle', docker_socket_access: false };
},
stop() { if (monitorTimer) clearTimeout(monitorTimer); monitorTimer = null; },
});
}
+41
View File
@@ -0,0 +1,41 @@
export const DEPLOYMENT_STATES = Object.freeze([
'checking', 'backup_pending', 'backup_running', 'backup_verified',
'deploy_requested', 'deploy_running', 'health_wait',
'succeeded', 'failed', 'rollback_required',
]);
export const ACTIVE_DEPLOYMENT_STATES = Object.freeze([
'checking', 'backup_pending', 'backup_running', 'backup_verified',
'deploy_requested', 'deploy_running', 'health_wait', 'rollback_required',
]);
const TRANSITION_ENTRIES = Object.freeze({
checking: ['backup_pending', 'deploy_requested', 'failed'],
backup_pending: ['backup_running', 'failed'],
backup_running: ['backup_verified', 'failed'],
backup_verified: ['deploy_requested', 'failed'],
deploy_requested: ['deploy_running', 'health_wait', 'failed', 'rollback_required'],
deploy_running: ['health_wait', 'succeeded', 'failed', 'rollback_required'],
health_wait: ['succeeded', 'failed', 'rollback_required'],
rollback_required: ['failed'],
succeeded: [],
failed: [],
});
export const DEPLOYMENT_TRANSITIONS = Object.freeze(Object.fromEntries(
Object.entries(TRANSITION_ENTRIES).map(([state, targets]) => [state, Object.freeze([...targets])]),
));
const ACTIVE_SET = new Set(ACTIVE_DEPLOYMENT_STATES);
export function isDeploymentState(state) {
return DEPLOYMENT_STATES.includes(state);
}
export function isActiveDeploymentState(state) {
return ACTIVE_SET.has(state);
}
export function canTransitionDeployment(from, to) {
return from === to || Boolean(DEPLOYMENT_TRANSITIONS[from]?.includes(to));
}
+13
View File
@@ -0,0 +1,13 @@
import manifest from './module.json' with { type: 'json' };
export function createProductImageToolsModule() {
return {
manifest,
source: 'builtin',
lifecycle: {
async start() {},
async stop() {},
async health() { return { ok: true, native: true, optional: true, contracts: ['ghost-mannequin', 'flatlay'] }; },
},
};
}
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "vendoo.product-image-tools",
"name": "Produktbild Studio",
"version": "1.0.0",
"type": "feature",
"status": "native",
"defaultEnabled": false,
"description": "Optionale, sichere Produktbild-Varianten für Ghost-Mannequin und Flatlay. Das Modul ist standardmäßig deaktiviert und kann im Modulmanager bewusst zugeschaltet werden.",
"requires": [
"vendoo.ai",
"vendoo.media"
],
"permissions": [
"generation.execute"
],
"provides": [
"product-images.variants"
],
"consumes": [
"ai.images",
"media.assets"
],
"owns": [
"product-images.jobs",
"product-images.variants"
]
}
+21
View File
@@ -48,6 +48,27 @@ export function registerUsersRoutes({ app, routes, deps }) {
res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false });
},
});
routes.register(app, {
id: 'users.admin.invitations.list', method: 'GET', path: '/api/admin/users/invitations', owner: 'vendoo.users', policy: 'users.manage', csrf: false, stability: 'stable',
handler: async (req, res) => res.json({ invitations: getUsersService().invitations(Number(req.query.limit) || 100) }),
});
routes.register(app, {
id: 'users.admin.invitations.resend', method: 'POST', path: '/api/admin/users/invitations/:id/resend', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
handler: async (req, res) => {
const prepared = getUsersService().resendPendingInvitation({ invitationId: Number(req.params.id), createdBy: req.user.id });
const result = await deps.sendMagicLink(prepared.invitation.email, prepared.invite.token, `${req.protocol}://${req.get('host')}`, 'invite');
deps.auditLog(req.user.id, req.user.email, 'user.invitation_resent', 'invitation', String(req.params.id), JSON.stringify({ email: prepared.invitation.email, role: prepared.role, expires_at: prepared.invite.expiresAt }), deps.getClientIp(req));
res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false, expiresAt: prepared.invite.expiresAt });
},
});
routes.register(app, {
id: 'users.admin.invitations.revoke', method: 'DELETE', path: '/api/admin/users/invitations/:id', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
handler: async (req, res) => {
const invitation = getUsersService().revokePendingInvitation(Number(req.params.id));
deps.auditLog(req.user.id, req.user.email, 'user.invitation_revoked', 'invitation', String(req.params.id), JSON.stringify({ email: invitation.email, role: invitation.role }), deps.getClientIp(req));
res.json({ ok: true, invitation });
},
});
routes.register(app, {
id: 'users.admin.update', method: 'PUT', path: '/api/admin/users/:id', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
input: objectSchema({ name: { type: 'string', maxLength: 120 }, role: { type: 'string', enum: roles }, active: { type: 'integer', min: 0, max: 1 }, avatar_color: { type: 'string', maxLength: 32 } }),
+15 -2
View File
@@ -1,7 +1,7 @@
import { randomBytes } from 'node:crypto';
import {
auditLog, createAuthToken, createUser, deleteUser, getLoginHistory, getUser, getUserByEmail,
getUsers, setPassword, updateUser, validatePasswordStrength,
getUsers, setPassword, updateUser, validatePasswordStrength, listInvitations, getInvitation, revokeInvitation,
} from '../../core/identity/identity-store.mjs';
import { permissionsForRole, getRoleDefinitions } from '../../../lib/security.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
@@ -40,7 +40,7 @@ export function createUsersService() {
return publicUser(updated);
},
prepareInvite({ email, name, role, method, createdBy }) {
const normalizedRole = ROLES.has(role) ? role : 'editor';
const normalizedRole = ROLES.has(role) ? role : 'viewer';
if (getUserByEmail(email)) fail('User existiert bereits', 400, 'USER_EXISTS');
if (method === 'auto_password') {
const password = `Vd!${randomBytes(9).toString('base64url')}9a`;
@@ -55,6 +55,19 @@ export function createUsersService() {
if (!user) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
return { user: publicUser(user), invite: createAuthToken(user.email, 'invite', user.role, createdBy) };
},
invitations(limit = 100) { return listInvitations(limit); },
resendPendingInvitation({ invitationId, createdBy }) {
const invitation = getInvitation(invitationId);
if (!invitation) fail('Einladung nicht gefunden', 404, 'INVITATION_NOT_FOUND');
if (invitation.status !== 'pending' && invitation.status !== 'expired') fail('Diese Einladung kann nicht erneut versendet werden.', 409, 'INVITATION_NOT_RESENDABLE');
const role = ROLES.has(invitation.role) ? invitation.role : 'viewer';
return { invitation, invite: createAuthToken(invitation.email, 'invite', role, createdBy), role };
},
revokePendingInvitation(invitationId) {
const invitation = revokeInvitation(invitationId);
if (!invitation) fail('Einladung nicht gefunden', 404, 'INVITATION_NOT_FOUND');
return invitation;
},
updateManagedUser({ actor, userId, patch }) {
const existing = getUser(userId);
if (!existing) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
+1 -1
View File
@@ -4,7 +4,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: vendoo:1.41.2
image: vendoo:1.42.0
container_name: vendoo
restart: unless-stopped
init: true
+1 -1
View File
@@ -4,7 +4,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: vendoo:1.41.2
image: vendoo:1.42.0
container_name: vendoo
restart: unless-stopped
init: true
+123
View File
@@ -0,0 +1,123 @@
# Abnahmebericht Vendoo 1.42.0
## Ergebnis
**Technische Paketabnahme bestanden.**
Version: `1.42.0`
Slice: `Production Operations & Controlled Update Completion`
Ausgangsbasis: Vendoo `1.41.2`
Ein produktives Deployment auf `vendoo.flatlined.de` wurde bei dieser Paketabnahme bewusst **nicht** ausgelöst. Der neue `UPDATE_VENDOO.bat`-Assistent automatisiert Review-Gates, Pull Request, Merge, Coolify-Trigger und die Prüfung von `/readyz` auf exakt Version `1.42.0`.
## Vollständiges Release-Gate
Der unveränderte Paketbefehl wurde als zusammenhängender Lauf ausgeführt:
```bash
npm run verify:all
```
Ergebnis: **Exit-Code 0**.
Abgedeckt wurden unter anderem:
- Basis-Release vor Ein-Klick-Erweiterung: vollständiges `npm run verify:all` mit Exit-Code 0
- aktuelles Git- und Secret-Gate: 560 Dateien, keine Laufzeitdaten oder Secrets
- aktuelle Syntaxprüfung: 160 JavaScript-/MJS-/CJS-Dateien
- ESM-Exportverträge der unveränderten Vendoo-Laufzeit: 159 produktive Quelldateien
- Capability-Graph: 17 Module und 40 Capabilities
- FLUX-Lifecycle und initial deaktivierter Zustand
- Plattformarchitektur: 17 Module, 15 native Module, 2 Legacy-Bridges
- Theme-System: 42 Tokens
- Security Foundation, Secret-Migration, CSP, Request- und Correlation-IDs
- Identity, Rollen, Sitzungen und letzter-Admin-Schutz
- kontrollierte Einladungen: Leser-Standardrolle, 48 Stunden, Einmalgültigkeit, erneutes Senden und Widerruf
- Katalog, Mediengrenzen, Locking, Papierkorb und Bulk-Limits
- Modulmanager und `.vmod`-Verträge
- Deployment-State-Machine, Idempotenz, Backup-Gate, Coolify-Status, Zielversionsprüfung und Rollback-Sperre
- Konfigurationsspeicher und Shell-Installer
- frische und bestehende Datenbankmigrationen
## Ein-Klick-Updater-Abnahme
Zusätzlich zur bereits vollständig geprüften Vendoo-Laufzeit wurden nach Einbau des Updaters erfolgreich ausgeführt:
- `node tools/verify-one-click-updater-1.42.0.mjs`
- `node tools/check-syntax.mjs`
- `node tools/verify-git-safety.mjs`
- `node tools/verify-git-safety-regression.mjs`
- `node tools/verify-git-staging.mjs`
- `node tools/verify-source-boundaries-1.41.2.mjs`
- `node tools/verify-git-ignore-boundaries-1.41.2.mjs`
- reales Paket-Staging mit enthaltenem Updater und Workflow sowie ausgeschlossenem `logs`-Ordner
Die Ein-Klick-Erweiterung verändert keine Server-, Datenbank- oder Modul-Laufzeitlogik. Der Updater führt auf dem Zielrechner vor jedem Push erneut `npm ci` und den vollständigen Befehl `npm run verify:all` aus. Schlägt ein Gate fehl, erfolgt weder Merge noch Coolify-Deployment.
## Datenbankabnahme
Die neue Migration ist ausschließlich additiv. Neu:
- `deployment_runs`
- `deployment_events`
- `backup_verifications`
- `production_checks`
Die SQLite-Prüfung bestätigte:
- Tabellen erfolgreich anlegbar
- Fremdschlüssel ohne Fehler
- `PRAGMA integrity_check = ok`
- vorhandene Testzeile bleibt bei Migration erhalten
- keine Reset-, Drop- oder Löschmigration für bestehende Produktivdaten
## Isolierter Laufzeittest
Vendoo wurde mit einem temporären, leeren Datenverzeichnis und Port `18142` gestartet.
Ergebnis:
- Serverstart erfolgreich
- `GET /healthz` → HTTP 200
- gemeldete Version → `1.42.0`
- `GET /readyz` → HTTP 200 und `ok: true`
- SQLite → bereit
- alle erforderlichen Schreibpfade → beschreibbar
- Platform Kernel → `running`
- geladene Module → 17
- geordneter Shutdown → erfolgreich
Der temporäre Laufzeitstand wurde anschließend entfernt. Er ist nicht Bestandteil des Release-Pakets.
## Sicherheits- und Datenabgrenzung
Nicht im Release-Paket enthalten:
- `.env`
- `db/vendoo.db`, `-wal`, `-shm`
- Uploads und Backups
- Logs und Operationsdaten
- `master.key` oder verschlüsselter Secret Store
- Coolify-, GitHub-, Mail-, KI- oder Plattform-API-Schlüssel
- `node_modules`
- lokal erzeugte FLUX-Token
Vendoo führt im Produktivcontainer weiterhin weder `git pull`, `npm install`, Docker-Kommandos noch Docker-Socket-Zugriffe aus. Build, Containerwechsel und Rollback bleiben Aufgabe von Coolify.
## Noch notwendige Produktionsabnahme
Der Updater bestätigt automatisch `/readyz`, Zielversion und `/healthz`. Nach dem ersten Produktivlauf sollten zusätzlich in Vendoo kontrolliert werden:
1. `/readyz` meldet HTTP 200 und Version `1.42.0`.
2. Bestehende Benutzer, Artikel, Uploads und Einstellungen sind unverändert vorhanden.
3. Das Volume ist weiterhin exakt nach `/app/data` gemountet.
4. FLUX Studio bleibt deaktiviert.
5. Produktionscheck zeigt keine Fehler.
6. Ein Testbackup wird erstellt und als verifiziert angezeigt.
7. Ein kontrollierter Deployment-Auftrag zeigt vollständige Events und endet erst nach bestätigtem Coolify-Abschluss, erfolgreichem `/readyz` und passender Zielversion mit `succeeded`.
8. Ein Fehlerfall bleibt als `rollback_required` gesperrt, bis ein Administrator die Prüfung mit `CONFIRM` dokumentiert abschließt.
## Freigabegrenze
Das Paket ist für das automatisierte Ein-Klick-Verfahren freigegeben. Der Assistent führt die lokalen Gates aus, wartet auf vorhandene GitHub-Checks, mergt erst danach und startet anschließend den kontrollierten Produktionsworkflow.
+35
View File
@@ -0,0 +1,35 @@
# Abnahmebericht Vendoo 1.43.0
## Ergebnis
Der Slice **Module-aware Navigation, Listing Studio UX & Updater Status UI** ist abgenommen.
## Funktionsabnahme
- Deaktivierte Module entfernen ihre zugeordneten Einträge unmittelbar aus der linken Navigation.
- Die Oberfläche lädt den Modulstatus vor modulgeschützten APIs und bleibt auch bei deaktiviertem AI-Modul startfähig.
- Das Produktbild Studio ist ein eigenes natives Modul, startet mit `defaultEnabled: false` und kann persistent aktiviert werden.
- Die bisherigen Produktbild-Werkzeuge werden bei deaktiviertem Modul nicht angezeigt und ihre API-Routen sind dem Modul zugeordnet.
- Die Seite **Neuer Artikel** besitzt eine sticky Aktionsleiste mit **Artikel generieren** und **Speichern**.
- Der Ein-Klick-Updater zeigt Phase, Detailstatus, Prozentwert, Live-Protokoll sowie Erfolg, Eingabebedarf oder sicheren Abbruch.
## Automatisierte Gates
- `npm run verify:all`: Exit-Code 0
- Syntaxprüfung: 163 JavaScript-/MJS-/CJS-Dateien
- Git-/Secret-Gate: 568 Paketdateien vor Bereinigung
- Plattformgraph: 18 Module und 41 Capabilities
- Datenbankmigrationen: Fresh- und Existing-DB erfolgreich
- Modulstatus: initial deaktiviert und persistente Aktivierung bestätigt
- Ein-Klick-Updater, Modulnavigation und Listing-Studio-Verträge erfolgreich
## Isolierter Laufzeittest
- `/healthz`: HTTP 200, Version `1.43.0`
- `/readyz`: HTTP 200, `ok: true`
- Plattformkernel: `running`, 18 registrierte Module
- SQLite und alle persistenten Schreibpfade im temporären Datenverzeichnis erfolgreich
## Sicherheitsgrenzen
Es wurden keine produktiven Daten, Datenbanken, Uploads, Backups, `.env`-Dateien, Master-Keys oder API-Schlüssel in das Paket übernommen. Das produktive GitHub-Repository und Coolify wurden bei der Erstellung dieses Pakets nicht verändert.
@@ -0,0 +1,31 @@
# Abnahmebericht Vendoo Production Updater 2.1
## Anlass
Production Updater 2.0 konnte einen früh beendeten Worker nur als Exit-Code 1 melden. Der Standardfehlerkanal war nicht mit der Oberfläche verbunden und der Protokollpfad wurde erst vom Worker selbst geliefert. Ein Fehler vor dessen erster Statusmeldung führte daher zu einer leeren Diagnose.
## Korrektur
- neuer, kleiner `Vendoo.Updater.Host.ps1` als überwachte Bootstrap-Grenze
- initialer Status und fester Protokollpfad werden bereits von der GUI angelegt
- vollständige Umleitung und Auswertung von Standardausgabe und Standardfehler
- Lade-, Parser- und Initialisierungsfehler des Worker-Kerns werden mit Datei, Zeile und vollständiger Ausnahme protokolliert
- der Worker-Core beendet den Host nicht mehr mit `exit`
- der Host garantiert einen terminalen Status
- Diagnoseordner ist direkt aus der Oberfläche erreichbar
- `updater.log`, `status.json`, `events.ndjson`, `worker.stdout.log` und `worker.stderr.log` liegen pro Lauf in einem eigenen Verzeichnis
## Automatisierte Abnahme
- PowerShell-Strukturparser: UI, Host, Worker und Launcher ohne Parserfehler
- `npm run verify:all`: Exit-Code 0
- JavaScript-/MJS-/CJS-Syntax: 166 Dateien
- Git-Sicherheitsgate: 578 Paketdateien, keine Secrets oder Laufzeitdaten
- Plattformgraph: 18 Module und 41 Capabilities
- Datenbankmigrationen: erfolgreich, vorhandene Daten bleiben erhalten
- Updater-2.1-Vertrag: erfolgreich
- internes SHA-256-Manifest: 577 Dateien
## Prüfgrenze
Die WPF-Oberfläche wurde in dieser Linux-Prüfumgebung nicht interaktiv unter Windows gestartet. Parser, XAML-/Codevertrag, Bootstrap-Architektur, vollständiger Fehlerkanal, Paketgrenzen und der gesamte Vendoo-Abnahmevertrag wurden automatisiert geprüft. Beim nächsten Windows-Fehler bleibt die Oberfläche geöffnet und zeigt die konkrete PowerShell-Ausnahme samt Protokollpfad; ein diagnostikloser Exit-Code-1-Zustand ist durch den neuen Startvertrag ausgeschlossen.
@@ -0,0 +1,22 @@
# Abnahmebericht Vendoo Production Updater 2.2
## Behobener Defekt
- `Vendoo.Updater.Worker.ps1`: `"$Label: ..."` zu `"${Label}: ..."` korrigiert.
- `Vendoo.Updater.Worker.ps1`: `"$Title: ..."` zu `"${Title}: ..."` korrigiert.
## Ursachenanalyse
Der 2.1-Abnahmetest kontrollierte Textverträge, BOM, Fehlerkanal und Paketstruktur, führte aber keinen echten Windows-PowerShell-Parser aus. Die Formulierung im 2.1-Abnahmebericht war daher falsch.
## Neue Abnahmegrenzen
- plattformunabhängiger statischer Regressionstest für ungültige `$Variable:`-Verweise
- lokale Parserprüfung mit `System.Management.Automation.Language.Parser` vor jedem Updaterstart
- zweite Parserprüfung im Worker-Host vor dem Laden des Worker-Kerns
- Windows-PowerShell-5.1-Parserjob in GitHub Actions
- Release-Workflow von erfolgreichem Windows-Parserjob abhängig
## Prüfgrenze dieser Paketumgebung
In der Linux-Paketumgebung kann Windows PowerShell 5.1 nicht ausgeführt werden. Deshalb wird hier nichts als Windows-Parserlauf ausgegeben. Der exakte Windows-PowerShell-5.1-Parser läuft stattdessen zwingend lokal vor dem Start und im Windows-GitHub-Actions-Job.
@@ -0,0 +1,38 @@
# Abnahmebericht Vendoo Production Updater 2.4
## Anlass
Im Updater 2.3 wurde `gh auth login` im versteckten Worker ausgeführt. GitHub CLI benötigt für den Browser-Gerätefluss sichtbare Interaktion. Der Prozess wartete deshalb, während der Benutzer weder Gerätecode noch Browseraktion zuverlässig sehen konnte. Zusätzlich erzeugte der Downloadfortschritt zu viele identische Logeinträge.
## Umsetzung
- interaktiver GitHub-Login aus `Invoke-External` entfernt
- eigenes sichtbares Anmeldefenster `github-login.cmd`
- expliziter GitHub-Aufruf mit `--hostname github.com --git-protocol https --web --clipboard`
- Hauptoberfläche bleibt geöffnet und pollt `gh auth status` still
- automatische Fortsetzung nach erfolgreichem Login
- eindeutiger Fehler bei geschlossenem oder fehlgeschlagenem Anmeldefenster
- maximal zehn Minuten für die einmalige Anmeldung
- Downloadfortschritt nur noch in 10-%-Stufen
- Status-Polling ohne wiederholte Fehler- und Startmeldungen
- Updater-Version 2.4.0 in Preflight, UI, Host, Worker und Manifest konsistent
## Ausgeführte Prüfungen
- Updater-2.4-Vertragsgate: bestanden
- PowerShell-Static-Gate: 16 Dateien, bestanden
- zusätzlicher PowerShell-Syntaxparse der sechs zentralen Updater-Skripte: ohne Fehlerknoten
- JavaScript-/MJS-/CJS-Syntaxprüfung: 170 Dateien, bestanden
- Git-Sicherheitsgate: 590 Dateien, bestanden
- Git-Sicherheitsregression: bestanden
- Git-Stagingregression: bestanden
- Git-Ignore-Grenzprüfung: bestanden
- Source-Root-Grenzprüfung: bestanden
## Einschränkung der Prüfumgebung
Windows PowerShell 5.1 und eine interaktive Windows-WPF-Sitzung stehen in der Linux-Prüfumgebung nicht zur Verfügung. Das Paket enthält deshalb weiterhin das echte Windows-PowerShell-Parser-Gate, das sämtliche PowerShell-Dateien auf dem Zielrechner vor dem Öffnen der Oberfläche prüft. Der GitHub-Gerätefluss selbst kann erst auf Windows gegen das echte Benutzerkonto abgeschlossen werden.
## Sicherheitsgrenze
Der gemeldete 2.3-Lauf endete während der GitHub-Anmeldung mit `cancelled`. Vor diesem Punkt wurden kein Release-Branch, kein Pull Request, kein Merge und kein Coolify-Deployment ausgeführt.
@@ -0,0 +1,16 @@
# Abnahmebericht Vendoo Production Updater 2.5
## Korrektur
Der Launcher wurde als minimaler ASCII/CRLF-Einstiegspunkt neu erstellt. Die Prüfung erfolgt bytegenau.
## Harte Gates
- keine UTF-8-BOM in `UPDATE_VENDOO.bat`
- ausschließlich Bytes 0x000x7F
- kein einzelnes LF ohne vorangestelltes CR
- kein einzelnes CR ohne folgendes LF
- absoluter Windows-PowerShell-Systempfad
- keine `chcp`-Umschaltung
- keine Übergabe von `PackageRoot`
- CRLF-Vertrag in `.gitattributes` und `.editorconfig`
@@ -0,0 +1,14 @@
# Abnahmebericht Vendoo Production Updater 2.6
## Korrektur
Die Coolify-Webhook-Abfrage des Desktop-Updaters wurde vollständig entfernt. Der Produktionsablauf verwendet nach dem Merge das bestehende Coolify Auto Deploy der GitHub-Verknüpfung.
## Abnahmekriterien
- kein `COOLIFY_DEPLOY_WEBHOOK_URL` im Worker
- keine Secret-Eingabe und kein `gh secret set`
- kein manueller `workflow run`
- kontrolliertes Warten auf `/readyz`
- exakte Versionsprüfung auf 1.43.0
- Timeout mit verständlichem Auto-Deploy-Hinweis
@@ -0,0 +1,13 @@
# Abnahmebericht Vendoo Production Updater 2.8
## Behobener Fehler
Windows brach `npm run verify:all` im Gate `verify:initial-modules` mit `EBUSY` beim Löschen einer noch geöffneten temporären SQLite-Datenbank ab.
## Korrektur
- temporäre `better-sqlite3`-Verbindung wird explizit geschlossen
- WAL wird vor dem Close kontrolliert ausgecheckt
- Dateisystembereinigung besitzt Windows-taugliche Wiederholungen
- neuer Regressionstest führt das betroffene Gate dreimal in getrennten Node-Prozessen aus
- Updater-Gate verlangt den Cleanup-Vertrag als harte Releasebedingung
@@ -0,0 +1,24 @@
# Abnahmebericht Vendoo Production Updater 2.9
## Anlass
Unter Windows hielten mehrere Verifier ihre temporären `better-sqlite3`-Datenbanken offen. Dadurch scheiterte das Entfernen der Testordner mit `EBUSY`. Außerdem erzeugten Capability- und Architekturtests durch transitive Modulimporte eine echte `db/vendoo.db` im Quellbaum.
## Korrektur
- zentraler Runtime-Cleanup mit WAL-Checkpoint, `db.close()` und Windows-tauglichen Löschwiederholungen
- Einladungs-, Initialmodul-, Capability- und Architekturtest laufen in isolierten Datenverzeichnissen
- statischer Importgraph-Audit aller aktiven `verify:all`-Verifier bis `lib/db.mjs`
- jeder datenbanknahe Verifier muss Testpfade, explizites Schließen und zentralen Temp-Cleanup nachweisen
- dynamische Regression führt alle vier datenbanknahen Testklassen jeweils dreimal in getrennten Node-Prozessen aus
- ein zweiter Testlauf erzeugt keine `db/vendoo.db` im Quellbaum
## Abnahme
- `npm run verify:all`: Exit-Code 0
- Syntaxprüfung: 180 JavaScript-/MJS-/CJS-Dateien
- SQLite-Cleanup-Audit: 29 aktive Verifier transitiv geprüft
- Wiederholungsregression: 12 isolierte Testprozesse ohne offene SQLite-Datei oder Temp-Rest
- Plattform: 18 Module und 41 Capabilities
- Datenbankmigrationen und Integritätsprüfung: bestanden
- Pull Request, Merge und Deployment bleiben bei jedem Testfehler gesperrt
@@ -0,0 +1,48 @@
# Abnahmebericht Vendoo Production Updater 3.1
## Anlass
Die Updater-2.x-Reihe verwendete eine destruktive Kopierstrategie: Die geklonte Git-Arbeitskopie wurde geleert und anschließend über Ausschlussregeln aus dem Paket neu befüllt. Dadurch konnten Quell- und Laufzeitdaten nicht zuverlässig unterschieden werden. Der konkrete Fehler war das fehlende `db/schema.sql` im Auslieferungspaket beziehungsweise in der vorbereiteten Arbeitskopie.
## Architekturkorrektur
Updater 3.1 ersetzt diesen Mechanismus vollständig:
- keine Leerung der geklonten Baseline,
- SHA-256-Manifest aller Release-Dateien,
- Pflichtdateien werden vor jedem Test geprüft,
- Overlay statt Vollersetzung,
- Löschungen ausschließlich explizit über `removedFiles`,
- Hashprüfung vor und nach jedem Kopiervorgang,
- Sitzungen aus 2.x werden verworfen,
- separater v3-Release-Branch,
- vollständiger Windows-Releasevertrag in GitHub Actions.
## Pflichtdateien
Der Release wird unter anderem blockiert, wenn eine dieser Dateien fehlt:
- `db/schema.sql`
- `lib/db.mjs`
- `server.mjs`
- `bootstrap.mjs`
- `package.json`
- `package-lock.json`
- Updater Preflight, UI, Host und Worker
## Prüfungen
- Release-Manifest-Gate mit Manipulations- und Missing-File-Test
- reales Overlay des vollständigen Release-Manifests auf eine Git-Baseline
- Baseline-Erhalt ohne nicht deklarierte Löschungen
- vollständige Vendoo-Gates in getrennten Blöcken
- Datenbankmigrationen
- SQLite-Handle-Cleanup
- Module, Capabilities, Security, Identity, Katalog und Production Operations
- Windows-PowerShell-Parservertrag
- Windows-CI mit vollständigem `npm run verify:all`
- manifestbasierter GitHub-Release-Builder
## Sicherheitsgrenze
Der Updater verändert `main`, Coolify und die Produktionsinstanz erst nach grünen lokalen Tests und grünen GitHub-Checks. `/app/data`, Datenbanken, Uploads, Secrets und Backups sind nicht Bestandteil des Release-Manifests.
@@ -0,0 +1,54 @@
# Abnahmebericht Vendoo Production Updater 3.2
## Anlass
Die Updater-2.x-Reihe verwendete eine destruktive Kopierstrategie: Die geklonte Git-Arbeitskopie wurde geleert und anschließend über Ausschlussregeln aus dem Paket neu befüllt. Dadurch konnten Quell- und Laufzeitdaten nicht zuverlässig unterschieden werden. Der konkrete Fehler war das fehlende `db/schema.sql` im Auslieferungspaket beziehungsweise in der vorbereiteten Arbeitskopie.
## Architekturkorrektur
Updater 3.2 ersetzt diesen Mechanismus vollständig:
- keine Leerung der geklonten Baseline,
- SHA-256-Manifest aller Release-Dateien,
- Pflichtdateien werden vor jedem Test geprüft,
- Overlay statt Vollersetzung,
- Löschungen ausschließlich explizit über `removedFiles`,
- Hashprüfung vor und nach jedem Kopiervorgang,
- Sitzungen aus 2.x werden verworfen,
- separater v3-Release-Branch,
- vollständiger Windows-Releasevertrag in GitHub Actions.
## Pflichtdateien
Der Release wird unter anderem blockiert, wenn eine dieser Dateien fehlt:
- `db/schema.sql`
- `lib/db.mjs`
- `server.mjs`
- `bootstrap.mjs`
- `package.json`
- `package-lock.json`
- Updater Preflight, UI, Host und Worker
## Prüfungen
- Release-Manifest-Gate mit Manipulations- und Missing-File-Test
- reales Overlay des vollständigen Release-Manifests auf eine Git-Baseline
- Baseline-Erhalt ohne nicht deklarierte Löschungen
- vollständige Vendoo-Gates in getrennten Blöcken
- Datenbankmigrationen
- SQLite-Handle-Cleanup
- Module, Capabilities, Security, Identity, Katalog und Production Operations
- Windows-PowerShell-Parservertrag
- Windows-CI mit vollständigem `npm run verify:all`
- manifestbasierter GitHub-Release-Builder
## Sicherheitsgrenze
Der Updater verändert `main`, Coolify und die Produktionsinstanz erst nach grünen lokalen Tests und grünen GitHub-Checks. `/app/data`, Datenbanken, Uploads, Secrets und Backups sind nicht Bestandteil des Release-Manifests.
## Korrektur 3.2
- `release-manifest.json` wird nach dem Payload als eigene Steuerdatei kopiert und bytegenau geprüft.
- Das Zielrepository darf unveränderte Baseline-Dateien enthalten; manifestierte Dateien bleiben vollständig hashverbindlich.
- Die Löschung der Steuerdatei über `removedFiles` ist verboten.
@@ -0,0 +1,9 @@
# Abnahmebericht Vendoo Production Updater 3.3
## Behobener Fehler
Der Windows-Lauf erreichte das letzte Gate, scheiterte aber im POSIX-Installer-Test. Ursache war eine nicht portable Annahme: Git Bash auf NTFS sollte nach `chmod 600` über `stat -c` exakt Modus `600` melden.
## Korrektur
Die POSIX-Rechteprüfung bleibt auf Linux/NAS/VPS strikt. Windows Git Bash prüft die Shell-Syntax; die funktionale Simulation, `.env`, Token, Compose-Aufrufe und Modus `0600` werden verpflichtend unter Linux geprüft. Fehlgeschlagene Assertions liefern vollständige Diagnoseausgaben.
+108
View File
@@ -0,0 +1,108 @@
# Coolify-Update auf Vendoo 1.42.0
# Empfohlener automatischer Weg
Für den normalen Updatefall nur das Release-ZIP entpacken und `UPDATE_VENDOO.bat` doppelklicken. GitHub-Anmeldung und Coolify-Webhook werden nur beim ersten Start abgefragt. Alle nachfolgenden Schritte dieser Datei sind der manuelle Notfall- und Kontrollweg.
## Voraussetzungen
- Repository: `Masterluke77/vendoo`
- Produktionsbranch: `main`
- Dockerfile-Build
- interner Port: `8124`
- Domain: `https://vendoo.flatlined.de`
- persistentes Volume unverändert nach `/app/data`
- genau eine Instanz
- Rolling Updates aus
- Auto Deploy aus
- FLUX Studio aus
## Vor dem Merge
1. Releasebranch `release/1.42.0-production-operations` aus aktuellem `main` erstellen.
2. Vollständigen Source-Stand übertragen; keine `.env`, Datenbank, Uploads, Backups, Logs, Master-Keys oder installierten Laufzeitmodule committen.
3. `npm ci` und `npm run verify:all` ausführen.
4. Draft-PR erstellen und GitHub Actions abwarten.
5. Erst nach grünem CI und Review nach `main` mergen.
## Zusätzliche Coolify-Variablen
```env
COOLIFY_DEPLOYMENT_STATUS_ENDPOINT=/api/v1/deployments/{deployment_id}
VENDOO_DEPLOY_TIMEOUT_SECONDS=900
VENDOO_DEPLOY_HEALTH_POLL_SECONDS=15
VENDOO_BUILD_REVISION=<Git-Commit-SHA>
VENDOO_COOLIFY_AUTO_DEPLOY=false
```
Die vorhandenen Produktionswerte müssen erhalten bleiben:
```env
NODE_ENV=production
PORT=8124
VENDOO_BIND_HOST=0.0.0.0
PUBLIC_BASE_URL=https://vendoo.flatlined.de
VENDOO_ALLOWED_HOSTS=vendoo.flatlined.de
VENDOO_ALLOWED_ORIGINS=https://vendoo.flatlined.de
VENDOO_TRUST_PROXY=true
VENDOO_COOKIE_SECURE=true
VENDOO_COOKIE_SAMESITE=lax
VENDOO_INITIAL_DISABLED_MODULES=vendoo.flux-studio
LOCAL_IMAGE_ENABLED=false
LOCAL_IMAGE_ALLOW_REMOTE=false
VENDOO_GITHUB_REPOSITORY=Masterluke77/vendoo
VENDOO_GITHUB_BRANCH=main
VENDOO_UPDATE_CHANNEL=stable
VENDOO_BACKUP_BEFORE_DEPLOY=true
VENDOO_DEPLOY_BACKUP_UPLOADS=false
```
## Coolify-Konfiguration prüfen
1. Coolify öffnen und Projekt **Vendoo → Production → Application** wählen.
2. Source muss die private GitHub-App, Repository `Masterluke77/vendoo` und Branch `main` verwenden.
3. Build Pack **Dockerfile**, Base Directory `/`, Dockerfile `/Dockerfile`.
4. Port `8124`; kein öffentliches Host-Port-Mapping.
5. Domain `https://vendoo.flatlined.de`; HTTPS erzwingen.
6. Volume `vendoo-data` unverändert nach `/app/data`.
7. Replikate `1`, Rolling Updates aus, Auto Deploy aus.
8. Healthcheck `/readyz` mit erwartetem HTTP 200.
9. Speichern, aber das Volume niemals neu anlegen oder löschen.
## Erstes kontrolliertes Deployment
1. Nach dem Merge Coolify noch nicht blind mehrfach starten.
2. Bestehendes Vendoo öffnen: **Administration → System → Updates**.
3. Produktionscheck ausführen und Fehler vor dem Deployment beheben.
4. Coolify-Provider, Trigger-Modus, Resource UUID, Repository und `main` prüfen.
5. Webhook oder API-Token nur über die Vendoo-Oberfläche in den verschlüsselten Secret Store eintragen.
6. **Version prüfen**. Eine neuere Zielversion wird nur akzeptiert, wenn das freigegebene Manifest SemVer, Kanal, HTTPS-Paket-URL und SHA-256 erfüllt.
7. Zielversion `1.42.0` eintragen.
8. Vorab-Backup aktiviert lassen. Uploads nur bei ausreichendem Backup-Speicher einschließen.
9. **Coolify-Deployment starten** wählen und exakt `DEPLOY` eingeben.
10. Der Auftrag muss nacheinander Backup, Backup-Verifikation und Coolify-Trigger protokollieren.
11. Im API-Modus wird der Coolify-Status gepollt. Im Webhook-Modus nach dem tatsächlichen Coolify-Abschluss im Verlauf **Externen Abschluss bestätigen** wählen und exakt `CONFIRM` eingeben.
12. Erfolg darf erst erscheinen, wenn `/readyz` grün ist und die laufende Version `1.42.0` meldet.
## Abnahme nach Deployment
- `/readyz` liefert HTTP 200 und Version `1.42.0`.
- Login funktioniert.
- Bestehende Benutzer, Artikel und Uploads sind vorhanden.
- SQLite-Integrität ist `ok`.
- Letztes Vorab-Backup ist als verifiziert sichtbar.
- Deployment-Verlauf zeigt `succeeded` und nachvollziehbare Events.
- FLUX Studio bleibt deaktiviert.
- Produktionscheck hat keine Fehler.
## Fehler und Rollback
Bei `rollback_required` nicht erneut deployen, bevor die Ursache geprüft wurde.
1. Coolify-Build- und Startlogs sichern.
2. `/readyz`, Zielversion und Volume-Mount prüfen.
3. In Coolify den letzten funktionierenden Commit beziehungsweise das vorige Image redeployen.
4. Dasselbe Volume `/app/data` weiterverwenden.
5. Volume niemals löschen oder neu initialisieren.
6. Nach erfolgreichem Rollback Login, Artikel, Uploads, SQLite und `/readyz` prüfen.
7. Den blockierten Vendoo-Auftrag erst danach mit **Prüfung abschließen** und exakt `CONFIRM` dokumentiert schließen.
+30
View File
@@ -0,0 +1,30 @@
# Vendoo 1.42.0 Ein-Klick-Update
## So benutzt du es
1. ZIP vollständig in einen neuen Ordner entpacken.
2. `UPDATE_VENDOO.bat` doppelklicken.
3. Nur beim ersten Start GitHub im Browser anmelden.
4. Nur beim ersten Start den Coolify-Deploy-Webhook einfügen.
5. Danach läuft alles automatisch.
## Automatischer Ablauf
- Git, Node.js LTS und GitHub CLI werden bei Bedarf über `winget` installiert.
- Das private Repository `Masterluke77/vendoo` wird frisch aus `main` geklont.
- Ein isolierter Release-Branch wird angelegt.
- Nur sichere Source-Dateien werden übernommen.
- `.env`, Datenbanken, Uploads, Backups, Logs und Schlüssel bleiben ausgeschlossen.
- `npm ci` und `npm run verify:all` laufen vollständig.
- Der Pull Request wird erstellt, geprüft und nach `main` gemergt.
- GitHub Actions startet den Coolify-Deploy-Webhook.
- Erfolg wird erst gemeldet, wenn `/readyz` HTTP 200 und exakt Version `1.42.0` liefert.
- Abschließend wird `/healthz` geprüft.
## Sicherheit
Der Coolify-Webhook wird nicht in das Paket oder Repository geschrieben. Er wird nur einmalig als verschlüsseltes GitHub Actions Secret `COOLIFY_DEPLOY_WEBHOOK_URL` gespeichert.
Das persistente Coolify-Volume `/app/data` wird weder ersetzt noch gelöscht. Parallele Produktionsdeployments werden durch GitHub Actions `concurrency` verhindert.
Lokale Protokolle liegen unter `logs/update-1.42.0-<Zeitstempel>.log`. Der Ordner `logs` wird vom Git-Staging ausgeschlossen.
+22
View File
@@ -0,0 +1,22 @@
# Vendoo Production Updater 2.0
Der bisherige Hotfix-Updater wurde vollständig ersetzt.
## Eigenschaften
- moderne, nüchterne WPF-Oberfläche ohne dekorative Spielereien
- Fenster bleibt bei Erfolg, Abbruch und Fehler geöffnet
- sichtbare zehnstufige Prozessanzeige und technisches Live-Protokoll
- verwaltete portable GitHub CLI statt Winget-Abhängigkeit
- offizieller GitHub-Download mit SHA-256-Abgleich gegen die Release-Prüfsummen
- feste Zeitlimits und kontrollierter Prozessabbruch inklusive Prozessbaum
- sichere Coolify-Eingabe ohne Klartext im Log oder in der Kommandozeile
- persistenter Wiederaufnahmepunkt unter `%LOCALAPPDATA%\Vendoo\Updater`
- idempotenter Ablauf: nach Merge- oder Deploymentfehlern wird nicht blind neu begonnen
- kein automatisches Schließen der Oberfläche
## Start
`UPDATE_VENDOO.bat` doppelklicken.
Eine GitHub-Browseranmeldung und die Eingabe des Coolify-Webhooks sind nur erforderlich, wenn sie auf diesem Rechner beziehungsweise im Repository noch fehlen.
+39
View File
@@ -0,0 +1,39 @@
# Vendoo Production Updater 2.1
## Ziel
Production Updater 2.1 ersetzt den direkten, nicht ausreichend überwachten Worker-Start aus 2.0. Die Oberfläche besitzt jetzt bereits vor dem Worker-Start einen festen Laufzeitordner, einen bekannten Protokollpfad und einen initialen Status.
## Worker-Host
Die Oberfläche startet ausschließlich `Vendoo.Updater.Host.ps1`. Dieser kleine Bootstrap-Host:
- prüft alle Übergabepfade,
- schreibt vor dem Laden des Update-Kerns einen Status,
- fängt Lade-, Parser- und Initialisierungsfehler des Worker-Kerns ab,
- schreibt die vollständige PowerShell-Ausnahme inklusive Datei und Zeile,
- garantiert einen terminalen Status,
- verliert weder Standardausgabe noch Standardfehler.
Der eigentliche Update-Kern läuft weiter in `Vendoo.Updater.Worker.ps1`, beendet aber nicht mehr eigenmächtig den PowerShell-Host. Erfolg, Abbruch und Fehler werden ausschließlich über den atomar geschriebenen Statusvertrag gemeldet.
## Diagnose
Jeder Lauf besitzt einen eigenen Ordner unter:
```text
%LOCALAPPDATA%\Vendoo\Updater\runtime\<Zeitstempel>
```
Darin liegen mindestens:
- `status.json`
- `events.ndjson`
- `updater.log`
- bei einem Host-Absturz zusätzlich `worker.stdout.log` und `worker.stderr.log`
Die Oberfläche zeigt den Protokollnamen sofort an. Mit **Diagnoseordner** kann der vollständige Laufzeitordner geöffnet werden. Der Protokollpfad kann nicht mehr leer sein.
## Sicherheitsgrenzen
Die Änderungen betreffen ausschließlich den Updater-Unterbau. Vendoo 1.43.0, `/app/data`, Datenbanken, Uploads, Secrets und Coolify-Konfiguration werden dadurch nicht zurückgesetzt oder gelöscht.
+25
View File
@@ -0,0 +1,25 @@
# Vendoo Production Updater 2.2
## Anlass
Updater 2.1 enthielt zwei echte Windows-PowerShell-Parserfehler. In doppelt quotierten Zeichenketten standen `"$Label: ..."` und `"$Title: ..."`. Windows PowerShell interpretiert den Doppelpunkt als Teil eines qualifizierten Variablenverweises. Korrekt sind `"${Label}: ..."` und `"${Title}: ..."`.
Der bisherige JavaScript-Vertrag prüfte nur das Vorhandensein bestimmter Textbausteine. Er war kein echter PowerShell-Parser und durfte deshalb nicht als solcher bezeichnet werden.
## Neue harte Gates
1. `UPDATE_VENDOO.bat` führt vor dem Start der Oberfläche synchron `Vendoo.Updater.Preflight.ps1` aus.
2. Die Preflight-Prüfung verwendet den echter Windows-PowerShell-5.1-Parser über `System.Management.Automation.Language.Parser`.
3. UI, Host, Worker und Launcher müssen vollständig parserfehlerfrei sein, bevor die Oberfläche startet.
4. Der Worker-Host parst den Worker unmittelbar vor dem Laden erneut.
5. `verify-powershell-static.mjs` blockiert die konkrete Variablen-Doppelpunkt-Fehlerklasse bereits auf Linux und während des Paketbaus.
6. GitHub Actions besitzt einen eigenen `windows-latest`-Job mit echtem Windows-PowerShell-5.1-Parser.
7. Release-Pakete hängen vom erfolgreichen Windows-Parser-Job ab.
## Fehlerverhalten
Schlägt die lokale Parserprüfung fehl, startet die Oberfläche nicht. Stattdessen bleiben ein sichtbarer Fehlerdialog, ein Konsolenhinweis und `logs/updater-preflight.log` erhalten. Damit kann kein syntaktisch defekter Worker mehr bis zum Worker-Host gelangen.
## Unveränderte Sicherheitsgrenzen
Vendoo 1.43.0, `/app/data`, Datenbanken, Uploads, Backups, Secrets und die Coolify-Konfiguration werden durch diese Änderung nicht zurückgesetzt oder gelöscht.
+19
View File
@@ -0,0 +1,19 @@
# Vendoo Production Updater 2.3
## Pfadvertrag
Version 2.3 entfernt die fehleranfällige Übergabe von `%~dp0` als PowerShell-Parameter. Ein mit Backslash endender Batch-Pfad kann vor einem schließenden Anführungszeichen als Bestandteil des Arguments interpretiert werden und dadurch ein illegales `"` in den Pfad einschleusen.
Der Paketpfad wird nun ausschließlich aus `$PSScriptRoot` des ausgelieferten Preflight-Skripts abgeleitet, mit `Resolve-Path` kanonisiert und vor jeder Dateisystemoperation validiert. Oberfläche und Worker verwenden denselben Vertrag.
## Startverhalten
- `UPDATE_VENDOO.bat` verwendet `pushd` und relative Skriptpfade.
- Es wird kein Paketroot mehr über die Kommandozeile übergeben.
- Preflight und GUI laufen synchron; Exit-Codes bleiben sichtbar.
- Bei Fehlern bleibt die Konsole geöffnet.
- Das Preflight-Protokoll liegt unter `logs/updater-preflight.log`.
## Regression
Das Release-Gate verbietet `-PackageRoot "%~dp0"`, verlangt `param()` im Preflight und prüft die kanonische Root-Ableitung über `$PSScriptRoot`, `Resolve-Path` und `GetFullPath`.
+30
View File
@@ -0,0 +1,30 @@
# Vendoo Production Updater 2.4
## Ziel
Version 2.4 ersetzt den unsichtbaren interaktiven GitHub-Login aus 2.3. Ein interaktiver Gerätefluss darf nicht in einem versteckten Worker laufen.
## GitHub-Anmeldung
Ist noch kein GitHub-Konto angemeldet, öffnet der Updater ein sichtbares Anmeldefenster. Dort zeigt GitHub CLI den einmaligen Gerätecode an, kopiert ihn mit `--clipboard` in die Zwischenablage und öffnet den Webbrowser. Die Vendoo-Oberfläche bleibt geöffnet und prüft den Status still im Hintergrund. Nach erfolgreicher Anmeldung wird automatisch fortgefahren.
Der Ablauf verwendet:
```text
gh auth login --hostname github.com --git-protocol https --web --clipboard
```
Die Anmeldung läuft nicht mehr über den versteckten Worker-Prozess. Ein Abbruch oder Fehler bleibt sichtbar und führt zu einem klaren, wiederholbaren Fehlerzustand.
## Downloadstatus
Der Download der portablen GitHub CLI schreibt nur noch sinnvolle 10-%-Fortschrittsstufen. Wiederholte Meldungen für dieselbe Megabyte-Grenze und die bisherige Logflut sind ausgeschlossen.
## Weiterhin gültige Schutzregeln
- echter Windows-PowerShell-5.1-Parser vor dem Start
- kanonische Root-Pfadermittlung
- persistenter Laufzeit- und Diagnoseordner
- keine Änderung an `main` vor grünen Tests
- kein Produktions-Erfolg ohne exakte Zielversion
- kein Zugriff auf oder Löschen von `/app/data`
+20
View File
@@ -0,0 +1,20 @@
# Vendoo Production Updater 2.5
## Zweck
Version 2.5 ersetzt den fehlerhaft ausgelieferten CMD-Launcher aus 2.4. Die PowerShell- und Vendoo-Logik bleibt unverändert; korrigiert wurde der Windows-Einstiegspunkt.
## Verbindlicher Launcher-Vertrag
- `UPDATE_VENDOO.bat` ist reines 7-Bit-ASCII.
- Die Datei besitzt keine BOM.
- Jede Zeile endet mit Windows-CRLF.
- Der Launcher enthält keine Umlaute und keinen `chcp`-Wechsel.
- Windows PowerShell wird über den absoluten Systempfad gestartet.
- Fehlerzustände bleiben mit `pause` sichtbar.
- `.gitattributes` und `.editorconfig` erzwingen CRLF für BAT/CMD.
- Das Binär-Gate prüft die tatsächlichen Bytes und nicht nur gelesenen Text.
## Ursache des 2.4-Fehlers
Die BAT war UTF-8 mit Unix-LF-Zeilenenden. `cmd.exe` interpretierte dadurch mehrere Zeilen beschädigt und verlor sichtbare Anfangszeichen von Befehlen.
+22
View File
@@ -0,0 +1,22 @@
# Vendoo Production Updater 2.6
## Ziel
Updater 2.6 entfernt die unnötige Coolify-Webhook-Abfrage vollständig.
Nach dem kontrollierten Merge nach `main` nutzt Vendoo die bereits bestehende GitHub-Verknüpfung von Coolify und wartet auf das automatische Deployment. Der Updater prüft anschließend `/readyz` und akzeptiert den Lauf nur, wenn exakt Vendoo 1.43.0 gemeldet wird.
## Voraussetzungen
- Die Vendoo-Anwendung ist in Coolify mit `Masterluke77/vendoo` und Branch `main` verbunden.
- In Coolify ist für die Anwendung **Auto Deploy** aktiviert.
- `https://vendoo.flatlined.de/readyz` ist öffentlich erreichbar.
## Verhalten
- keine Eingabe eines Coolify-Webhooks
- kein GitHub Secret `COOLIFY_DEPLOY_WEBHOOK_URL`
- kein manuell gestarteter Deployment-Workflow
- Wartezeit maximal 15 Minuten
- Statusmeldung höchstens alle 30 Sekunden
- eindeutiger Hinweis auf Auto Deploy, falls die Zielversion nicht erscheint
+18
View File
@@ -0,0 +1,18 @@
# Vendoo Production Updater 2.8
## Ziel
Updater 2.8 behebt einen Windows-spezifischen SQLite-Lifecycle-Fehler im vollständigen Release-Test.
`verify-initial-disabled-modules-1.41.2.mjs` lud den Plattformkernel und öffnete dadurch `better-sqlite3`, löschte den temporären Datenordner anschließend aber ohne die Datenbank explizit zu schließen. Linux tolerierte das; Windows blockierte `vendoo.db` mit `EBUSY`.
## Neuer Ressourcenvertrag
- explizites `wal_checkpoint(TRUNCATE)`
- explizites `db.close()` vor jeder Temp-Bereinigung
- kurze Freigabephase nach dem synchronen Close
- `rmSync` mit 20 Wiederholungen und 100 ms Wartezeit für kurzzeitige Windows-Dateisperren
- Cleanup-Fehler werden mit Pfad und Fehlercode gemeldet
- drei isolierte Wiederholungsläufe prüfen, dass kein Temp-Ordner zurückbleibt
Der Fehler tritt vor Pull Request, Merge und Coolify-Deployment auf. Produktivdaten waren dadurch nicht betroffen.
+11
View File
@@ -0,0 +1,11 @@
# Vendoo Production Updater 2.9
Updater 2.9 behebt die Windows-Dateisperren in allen aktiven Verifiern systematisch.
## Änderungen
- Zentraler SQLite-Test-Cleanup mit WAL-Checkpoint, `db.close()` und Windows-tauglichen Löschwiederholungen.
- Einladungs-, Initialmodul-, Capability- und Architekturtest verwenden denselben Cleanup-Vertrag.
- Statischer Importgraph-Audit prüft alle aktiven `verify:all`-Verifier auf transitive `lib/db.mjs`-Nutzung.
- Dynamische Regression führt alle vier datenbanknahen Testklassen jeweils dreimal in getrennten Node-Prozessen aus.
- Kein Pull Request, Merge oder Deployment bei fehlgeschlagenem Testlauf.
+40
View File
@@ -0,0 +1,40 @@
# Vendoo Production Updater 3.1
## Architekturwechsel
Updater 3.1 verwendet keine Ausschlusslisten-Kopie und leert die geklonte `main`-Arbeitskopie nicht mehr.
Der Release enthält `release-manifest.json` mit:
- jeder auszuliefernden Quelldatei,
- Dateigröße,
- SHA-256-Prüfsumme,
- verbindlichen Pflichtdateien,
- ausschließlich expliziten Löschungen in `removedFiles`.
## Anwendung
1. `main` wird in einen isolierten Arbeitsordner geklont.
2. Das gesamte Paket wird gegen `release-manifest.json` geprüft.
3. Sichere Paketdateien, die nicht im Manifest stehen, blockieren das Update.
4. Manipulierte oder fehlende Dateien blockieren das Update.
5. Der Release wird als Overlay angewendet; bestehende Baseline-Dateien bleiben erhalten.
6. Nur Einträge in `removedFiles` werden gelöscht.
7. Jede kopierte Datei wird im Ziel erneut per SHA-256 geprüft.
8. Erst danach laufen `npm ci` und `npm run verify:all`.
## Pflichtdateien
Unter anderem müssen immer vorhanden sein:
- `db/schema.sql`
- `lib/db.mjs`
- `package.json`
- `package-lock.json`
- `server.mjs`
- `bootstrap.mjs`
- Updater-Preflight, UI, Host und Worker
## Sitzungen
Sitzungsstände der Updater-2.x-Reihe werden nicht übernommen. Updater 3.1 beginnt mit einem neuen Release-Branch und einer neuen, versionsgebundenen Sitzung.
+48
View File
@@ -0,0 +1,48 @@
# Vendoo Production Updater 3.2
## Architekturwechsel
Updater 3.2 verwendet keine Ausschlusslisten-Kopie und leert die geklonte `main`-Arbeitskopie nicht mehr.
Der Release enthält `release-manifest.json` mit:
- jeder auszuliefernden Quelldatei,
- Dateigröße,
- SHA-256-Prüfsumme,
- verbindlichen Pflichtdateien,
- ausschließlich expliziten Löschungen in `removedFiles`.
## Anwendung
1. `main` wird in einen isolierten Arbeitsordner geklont.
2. Das gesamte Paket wird gegen `release-manifest.json` geprüft.
3. Sichere Paketdateien, die nicht im Manifest stehen, blockieren das Update.
4. Manipulierte oder fehlende Dateien blockieren das Update.
5. Der Release wird als Overlay angewendet; bestehende Baseline-Dateien bleiben erhalten.
6. Nur Einträge in `removedFiles` werden gelöscht.
7. Jede kopierte Datei wird im Ziel erneut per SHA-256 geprüft.
8. Erst danach laufen `npm ci` und `npm run verify:all`.
## Pflichtdateien
Unter anderem müssen immer vorhanden sein:
- `db/schema.sql`
- `lib/db.mjs`
- `package.json`
- `package-lock.json`
- `server.mjs`
- `bootstrap.mjs`
- Updater-Preflight, UI, Host und Worker
## Sitzungen
Sitzungsstände der Updater-2.x-Reihe werden nicht übernommen. Updater 3.2 beginnt mit einem neuen Release-Branch und einer neuen, versionsgebundenen Sitzung.
## Steuerdatei-Vertrag 3.2
`release-manifest.json` ist kein selbstgehashter Payload-Eintrag. Der Applier kopiert es nach erfolgreicher Payload-Prüfung als separates Steuerartefakt bytegenau in die Git-Arbeitskopie. Verifier in einer Git-Baseline dürfen zusätzliche unveränderte Dateien sehen, müssen aber jede manifestierte Datei und ihre SHA-256-Prüfsumme bestätigen.
## Fehlerdarstellung
Bei fehlgeschlagenen Prozessen priorisiert der Worker `stderr` als **Fehlerausgabe** und zeigt `stdout` nur gekürzt als ergänzende **letzte Standardausgabe**. Dadurch bleibt die eigentliche Ursache im Statusfenster sichtbar.
+17
View File
@@ -0,0 +1,17 @@
# Vendoo Production Updater 3.3
## Zweck
Updater 3.3 behält das manifestbasierte Overlay von 3.2 bei und korrigiert das letzte plattformspezifische Release-Gate.
## Installer-Shell-Portabilität
- Unter Linux, NAS und VPS muss `.env` nach der Erzeugung weiterhin exakt POSIX-Modus `0600` besitzen.
- Unter Windows prüft Git Bash ausschließlich die Syntax von `setup.sh` und dem funktionalen Shell-Test. Die vollständige Docker-/NAS-/VPS-Simulation läuft im verpflichtenden Linux-CI-Gate, wo POSIX-Rechte real abgebildet werden.
- Der Shell-Test erkennt MINGW, MSYS und CYGWIN ausdrücklich.
- Jede fehlgeschlagene Assertion zeigt die betroffene Ausgabe- oder Konfigurationsdatei vollständig an.
- Der Node-Wrapper reicht `stdout` und `stderr` im Fehlertext weiter.
## Unveränderte Sicherheitsgrenzen
Das SHA-256-Manifest, die bytegeprüfte Steuerdatei, explizite Löschungen, GitHub-PR-/Merge-Gates, Coolify Auto Deploy und `/readyz` bleiben verpflichtend.
@@ -0,0 +1,50 @@
# Vendoo 1.42.0 Production Operations & Controlled Update Completion
## Ziel
Version 1.42.0 vervollständigt den sicheren Coolify-Produktionsbetrieb. Ein HTTP-Erfolg des Deploy-Triggers gilt nicht mehr als erfolgreiches Deployment. Ein Auftrag durchläuft persistent gespeicherte Zustände und wird erst nach bestätigtem Deployment, erfolgreicher Readiness und passender Zielversion abgeschlossen.
## Neue Betriebsfunktionen
- Produktionsstatus mit Version, Build-Revision, Provider, öffentlicher URL, Readiness, SQLite-Status, Speicherbelegung, letztem verifiziertem Backup, letztem Deployment und FLUX-Status.
- Produktions-Selbstprüfung für HTTPS, Cookies, Trust Proxy, Host/Origin, Persistenz, Schreibrechte, SQLite, Backupziel, FLUX, Auto Deploy und Coolify-Secrets.
- Persistente Deployment-State-Machine mit Ereignisverlauf.
- Idempotency-Key und Sperre gegen parallele Aufträge.
- Verifiziertes Vorab-Backup vor dem Coolify-Trigger.
- Coolify-API-Statusabfrage oder kontrollierter Webhook-Modus mit exakter Bestätigung `CONFIRM`.
- `/readyz`- und Zielversionsprüfung vor dem Status `succeeded`.
- `rollback_required` bei Timeout oder Coolify-Fehler; neue Aufträge bleiben bis zur dokumentierten Prüfung gesperrt.
- Einladungsübersicht, 48-Stunden-Ablauf, erneutes Senden und Widerruf; Standardrolle ist `viewer`.
## Additive Datenbankmigration
Neu angelegt werden ausschließlich:
- `deployment_runs`
- `deployment_events`
- `backup_verifications`
- `production_checks`
Bestehende Tabellen, Nutzer, Artikel, Uploads, Einstellungen, Secrets, Backups und Module werden nicht gelöscht oder zurückgesetzt.
## Neue administrative API-Verträge
- `GET /api/admin/deployment`
- `PUT /api/admin/deployment`
- `POST /api/admin/deployment/test`
- `POST /api/admin/deployment/updates/check`
- `POST /api/admin/deployment/trigger`
- `GET /api/admin/deployment/runs`
- `GET /api/admin/deployment/runs/:id`
- `POST /api/admin/deployment/runs/:id/refresh`
- `POST /api/admin/deployment/runs/:id/confirm`
- `GET|POST /api/admin/deployment/production-check`
- `GET /api/admin/users/invitations`
- `POST /api/admin/users/invitations/:id/resend`
- `DELETE /api/admin/users/invitations/:id`
Alle schreibenden Routen bleiben CSRF-geschützt, rollenbasiert und auditierbar. Secretwerte werden nicht zurückgegeben.
## Sicherheitsgrenzen
Vendoo 1.42.0 besitzt weiterhin keinen Docker-Socket und führt im Produktivcontainer weder `git pull`, `npm install`, Docker-Kommandos noch eine Selbstüberschreibung aus. Coolify bleibt für Build, Containerwechsel und Rollback verantwortlich.
@@ -0,0 +1,12 @@
# Vendoo 1.43.0
## Schwerpunkt
- Modulstatus und Navigation sind verbindlich gekoppelt.
- Das optionale Produktbild Studio ist standardmäßig deaktiviert.
- Der Listing-Generator besitzt eine dauerhaft sichtbare Aktionsleiste.
- `UPDATE_VENDOO.bat` startet eine grafische Statusanzeige mit Live-Protokoll.
## Sicherheitsgrenzen
Der Updater verändert weiterhin keine produktiven Daten unter `/app/data`, überträgt keine `.env`-Dateien und meldet erst nach erfolgreicher `/readyz`-Zielversionsprüfung Erfolg.
+44
View File
@@ -0,0 +1,44 @@
# Vendoo 1.42.0 Restore- und Notfall-Runbook
## Grundsatz
Ein Restore ist keine normale Weboperation. Das laufende Vendoo darf seine geöffnete SQLite-Datenbank nicht im Hintergrund überschreiben. Die Weboberfläche prüft und staged ein Backup; das tatsächliche Einspielen erfolgt im Wartungsfenster bei gestoppter Anwendung.
## Restore-Dry-Run
1. **Administration → System → Backup & Restore** öffnen.
2. Gewünschtes Backup zuerst mit **Prüfen** validieren.
3. SHA-256, Manifest, Dateigröße und SQLite-Integrität müssen stimmen.
4. Für ein extern bereitgestelltes ZIP **Wiederherstellung prüfen und vorbereiten** verwenden.
5. Vendoo erstellt vor dem Staging automatisch ein zusätzliches Sicherheitsbackup.
6. Fehlerhafte, unvollständige oder verbotene Pakete nicht manuell entpacken.
## Wartungsrestore
1. Wartungsfenster ankündigen und Benutzer abmelden.
2. Coolify-Anwendung stoppen; keine zweite Instanz starten.
3. Vollständiges Volume `/app/data` zusätzlich auf Host-/NAS-Ebene sichern.
4. Besonders zusammen sichern:
- `/app/data/db/vendoo.db` samt möglicher `-wal`/`-shm`-Dateien
- `/app/data/uploads`
- `/app/data/config/master.key`
- `/app/data/config/secrets.enc.json`
- `/app/data/config/vendoo.env`
- `/app/data/config/modules-state.json`
5. Nur das verifizierte, vorbereitete Restore anwenden.
6. Eigentümer und Schreibrechte des nicht privilegierten Containers erhalten.
7. Anwendung mit exakt einer Instanz starten.
8. `/readyz`, SQLite-Integrität, Login, Benutzer, Artikel, Uploads, Module und Audit prüfen.
## Reiner Anwendungsrollback
Bei fehlerhaftem Code-Release normalerweise keinen Datenrestore durchführen. In Coolify die vorherige Revision beziehungsweise das vorherige Image redeployen und dasselbe `/app/data`-Volume verwenden.
## Verboten
- `/app/data` löschen
- neues leeres Volume anhängen
- zwei schreibende Container auf demselben SQLite-Volume starten
- die Datenbank bei laufendem Vendoo ersetzen
- `master.key` neu erzeugen, solange bestehende verschlüsselte Secrets benötigt werden
- ungeprüfte ZIP-Dateien direkt in das Volume kopieren
+14
View File
@@ -0,0 +1,14 @@
# Vendoo 1.43.0 Updater-Hotfix H1
## Behoben
- Endloses Warten direkt nach „Node.js LTS gefunden“.
- Falsche Erkennung des Windows-App-Execution-Alias als echte Node.js-Installation.
- Node.js-Versionsprüfung besitzt jetzt ein hartes Timeout von 20 Sekunden.
- Eine blockierte oder defekte Node.js-LTS-Installation wird über Winget automatisch repariert.
- Der grafische Updater zeigt Laufzeit und Zeit seit der letzten Statusmeldung.
- PowerShell- und Prozessausgabe verwenden konsistent UTF-8; deutsche Umlaute werden korrekt dargestellt.
## Sicherheitsgrenze
Bis zum Abschluss der lokalen Prüfungen wird weder nach `main` gemergt noch ein Coolify-Deployment gestartet.
+16
View File
@@ -0,0 +1,16 @@
# Vendoo 1.43.0 Updater Hotfix H2
## Behobener Fehler
Nach einer erfolgreichen Erstinstallation der GitHub CLI brach der Ein-Klick-Updater mit der Aufforderung ab, `UPDATE_VENDOO.bat` erneut zu starten. Ursache war, dass die laufende Windows-PowerShell-Sitzung den neu installierten Pfad noch nicht zuverlässig aufgelöst hatte.
## Neue Funktionsweise
- Der Updater aktualisiert den Prozess-PATH nach der Winget-Installation.
- Er wartet kontrolliert bis zu 30 Sekunden auf die Registrierung der Programmdatei.
- Zusätzlich werden die offiziellen Installationspfade der GitHub CLI direkt geprüft.
- Gefundene Programme werden über ihren absoluten Pfad ausgeführt.
- Nach der Installation läuft derselbe Updatevorgang ohne zweiten Doppelklick weiter.
- Ein echter Abbruch erfolgt nur, wenn die installierte Programmdatei auch über die bekannten Pfade nicht auffindbar ist.
Produktcode und Datenbankschema bleiben gegenüber Vendoo 1.43.0 unverändert.
+81
View File
@@ -1550,3 +1550,84 @@ try {
db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description)
VALUES ('operations-center-1.30.0', '1.30.0', 'Backup, Restore, Update Center und Extension-Diagnose')`).run();
} catch {}
// --- Production Operations & Controlled Updates 1.42.0 ---
try {
db.exec(`CREATE TABLE IF NOT EXISTS deployment_runs (
id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
state TEXT NOT NULL DEFAULT 'checking',
provider TEXT NOT NULL DEFAULT 'coolify',
trigger_mode TEXT NOT NULL DEFAULT 'webhook',
current_version TEXT NOT NULL,
target_version TEXT NOT NULL,
build_revision_before TEXT,
build_revision_after TEXT,
deployment_id TEXT,
backup_id TEXT,
requested_by INTEGER,
reason TEXT,
github_repository TEXT,
github_branch TEXT,
request_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
error_code TEXT,
error_message TEXT,
timeout_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY(requested_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY(backup_id) REFERENCES operation_backups(id) ON DELETE SET NULL
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_deployment_runs_state ON deployment_runs(state, updated_at DESC)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_deployment_runs_created ON deployment_runs(created_at DESC)`);
db.exec(`CREATE TABLE IF NOT EXISTS deployment_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
state TEXT NOT NULL,
event_type TEXT NOT NULL,
message TEXT NOT NULL,
details_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(run_id) REFERENCES deployment_runs(id) ON DELETE CASCADE
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_deployment_events_run ON deployment_events(run_id, id ASC)`);
db.exec(`CREATE TABLE IF NOT EXISTS backup_verifications (
id TEXT PRIMARY KEY,
backup_id TEXT NOT NULL,
deployment_run_id TEXT,
status TEXT NOT NULL,
sha256 TEXT,
file_size INTEGER NOT NULL DEFAULT 0,
sqlite_integrity TEXT,
manifest_json TEXT NOT NULL DEFAULT '{}',
errors_json TEXT NOT NULL DEFAULT '[]',
warnings_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
verified_at TEXT,
FOREIGN KEY(backup_id) REFERENCES operation_backups(id) ON DELETE CASCADE,
FOREIGN KEY(deployment_run_id) REFERENCES deployment_runs(id) ON DELETE SET NULL
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_backup_verifications_backup ON backup_verifications(backup_id, created_at DESC)`);
db.exec(`CREATE TABLE IF NOT EXISTS production_checks (
id TEXT PRIMARY KEY,
overall_status TEXT NOT NULL,
passed_count INTEGER NOT NULL DEFAULT 0,
warning_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
results_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by INTEGER,
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_production_checks_created ON production_checks(created_at DESC)`);
db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description)
VALUES ('production-operations-1.42.0', '1.42.0', 'Persistente Deployment-State-Machine, Deployment-Events, Backup-Verifikation und Produktionschecks')`).run();
} catch {}
+20 -7
View File
@@ -330,20 +330,33 @@ export function cancelPendingOperation() {
return { cancelled: true, pending };
}
export async function checkForUpdates() {
export async function checkForUpdates({ channel = 'stable' } = {}) {
const settings = getOperationSettings();
const url = String(settings.update_manifest_url || '').trim();
if (!url) return { configured: false, current_version: APP_VERSION, message: 'Keine Update-Manifest-URL konfiguriert.' };
if (!/^https?:\/\//i.test(url)) throw new Error('Update-Manifest muss über HTTP oder HTTPS erreichbar sein.');
const rawUrl = String(settings.update_manifest_url || '').trim();
if (!rawUrl) return { configured: false, current_version: APP_VERSION, channel, message: 'Keine Update-Manifest-URL konfiguriert.' };
let url;
try { url = new URL(rawUrl); } catch { throw new Error('Update-Manifest-URL ist ungültig.'); }
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('Update-Manifest muss über HTTP oder HTTPS erreichbar sein.');
if (url.username || url.password || url.hash) throw new Error('Update-Manifest-URL darf keine Zugangsdaten oder Fragmente enthalten.');
if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:') throw new Error('Update-Manifest muss in Produktion HTTPS verwenden.');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
const response = await fetch(url, {
signal: controller.signal,
redirect: 'error',
headers: { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` },
});
if (!response.ok) throw new Error(`Update-Manifest antwortet mit HTTP ${response.status}.`);
const manifest = await response.json();
const contentLength = Number(response.headers.get('content-length') || 0);
if (contentLength > 256 * 1024) throw new Error('Update-Manifest ist größer als 256 KB.');
const text = await response.text();
if (Buffer.byteLength(text, 'utf8') > 256 * 1024) throw new Error('Update-Manifest ist größer als 256 KB.');
let manifest;
try { manifest = JSON.parse(text); } catch { throw new Error('Update-Manifest enthält kein gültiges JSON.'); }
const latest = String(manifest.version || manifest.latest_version || '');
if (!latest) throw new Error('Update-Manifest enthält keine Version.');
return { configured: true, current_version: APP_VERSION, latest_version: latest, update_available: compareVersions(latest, APP_VERSION) > 0, manifest };
return { configured: true, current_version: APP_VERSION, latest_version: latest, update_available: compareVersions(latest, APP_VERSION) > 0, channel, manifest };
} finally { clearTimeout(timeout); }
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "vendoo",
"version": "1.41.2",
"version": "1.43.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vendoo",
"version": "1.41.2",
"version": "1.43.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"better-sqlite3": "^12.11.1",
+20 -11
View File
@@ -1,40 +1,49 @@
{
"name": "vendoo",
"version": "1.41.2",
"version": "1.43.0",
"type": "module",
"private": true,
"scripts": {
"start": "node bootstrap.mjs",
"dev": "node --watch bootstrap.mjs",
"check": "node tools/check-syntax.mjs && python -m py_compile tools/verify-identity-db-1.40.0.py",
"check": "node tools/check-syntax.mjs && node --no-warnings tools/verify-identity-db-1.40.0.mjs",
"verify:slice30": "node tools/verify-slice30-mock.mjs",
"verify:db": "python tools/verify-db-migrations.py",
"verify:db": "node --no-warnings tools/verify-db-migrations.mjs",
"verify:slice31": "node tools/verify-slice31-image-editor.mjs",
"verify:hotfix": "node tools/verify-release-1.25.1.mjs",
"verify:gallery": "node tools/verify-release-1.25.1-static.mjs",
"verify:release": "node tools/verify-deployment-1.41.2.mjs",
"verify:release": "npm run verify:production-operations",
"verify:ui-contracts": "node tools/verify-ui-contracts-1.26.1.mjs",
"verify:deployment": "node tools/verify-deployment-1.41.2.mjs",
"verify:deployment": "npm run verify:production-operations",
"verify:git": "node tools/verify-git-safety.mjs",
"verify:config": "node tools/verify-config-store.mjs",
"verify:installer-shell": "sh tools/verify-installer-shell.sh",
"verify:installer-shell": "node tools/verify-installer-shell.mjs",
"verify:git-regression": "node tools/verify-git-safety-regression.mjs",
"verify:git-staging": "node tools/verify-git-staging.mjs",
"generate:tokens": "node tools/generate-design-tokens.mjs",
"verify:architecture": "node tools/verify-platform-architecture-1.41.2.mjs",
"verify:all": "npm run verify:git && npm run verify:git-regression && npm run verify:git-staging && npm run verify:ignore-boundaries && npm run check && npm run verify:source-boundaries && npm run verify:esm-exports && npm run verify:flux-loop && npm run verify:capabilities && npm run verify:coolify && npm run verify:initial-modules && npm run verify:architecture && npm run verify:themes && npm run verify:security && npm run verify:identity && npm run verify:catalog && npm run verify:modules && npm run verify:deployment && npm run verify:config && npm run verify:installer-shell && npm run verify:db",
"verify:all": "npm run verify:git && npm run verify:git-regression && npm run verify:git-staging && npm run verify:release-manifest && npm run verify:ignore-boundaries && npm run check && npm run verify:powershell-static && npm run verify:source-boundaries && npm run verify:esm-exports && npm run verify:flux-loop && npm run verify:capabilities && npm run verify:coolify && npm run verify:initial-modules && npm run verify:architecture && npm run verify:themes && npm run verify:security && npm run verify:identity && npm run verify:invitations && npm run verify:sqlite-cleanup && npm run verify:catalog && npm run verify:modules && npm run verify:production-operations && npm run verify:one-click-update && npm run verify:module-navigation && npm run verify:updater-ui && npm run verify:updater-hotfix && npm run verify:deployment && npm run verify:config && npm run verify:installer-shell && npm run verify:db",
"verify:themes": "node tools/verify-theme-manager-1.41.2.mjs",
"verify:security": "node tools/verify-security-foundation-1.41.2.mjs",
"verify:identity": "node tools/verify-identity-settings-1.41.2.mjs && python tools/verify-identity-db-1.40.0.py",
"verify:identity": "node tools/verify-identity-settings-1.41.2.mjs && node --no-warnings tools/verify-identity-db-1.40.0.mjs",
"verify:catalog": "node tools/verify-catalog-media-1.41.2.mjs && node tools/verify-catalog-services-1.41.2.mjs",
"verify:modules": "node tools/verify-module-manager-1.41.2.mjs",
"verify:esm-exports": "node tools/verify-esm-export-contracts.mjs",
"verify:flux-loop": "node tools/verify-flux-loop-hotfix-1.41.2.mjs",
"verify:capabilities": "node tools/verify-capability-graph-1.41.2.mjs",
"verify:coolify": "node tools/verify-coolify-deployment-1.41.2.mjs",
"verify:initial-modules": "node tools/verify-initial-disabled-modules-1.41.2.mjs",
"verify:coolify": "npm run verify:production-operations",
"verify:initial-modules": "node tools/verify-initial-disabled-modules-1.41.2.mjs && node tools/verify-initial-module-cleanup-regression-1.43.0.mjs",
"verify:source-boundaries": "node tools/verify-source-boundaries-1.41.2.mjs",
"verify:ignore-boundaries": "node tools/verify-git-ignore-boundaries-1.41.2.mjs"
"verify:ignore-boundaries": "node tools/verify-git-ignore-boundaries-1.41.2.mjs",
"verify:production-operations": "node tools/verify-production-operations-1.42.0.mjs && node --no-warnings tools/verify-production-operations-db-1.42.0.mjs",
"verify:invitations": "node tools/verify-controlled-invitations-1.42.0.mjs",
"verify:one-click-update": "node tools/verify-production-updater-3.3.mjs",
"verify:module-navigation": "node tools/verify-module-navigation-generator-1.43.0.mjs",
"verify:updater-ui": "node tools/verify-production-updater-3.3.mjs",
"verify:updater-hotfix": "node tools/verify-production-updater-3.3.mjs",
"verify:powershell-static": "node tools/verify-powershell-static.mjs",
"verify:sqlite-cleanup": "node tools/verify-sqlite-cleanup-contract-1.43.0.mjs && node tools/verify-windows-sqlite-cleanup-regression-1.43.0.mjs",
"verify:release-manifest": "node tools/verify-release-manifest-staging-3.0.mjs"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
+230 -21
View File
@@ -1,4 +1,4 @@
const VENDOO_UI_BUILD = '1.41.2';
const VENDOO_UI_BUILD = '1.43.0';
const state = {
photos: [],
@@ -282,6 +282,44 @@ function setupResponsiveContracts() {
}
}
const moduleUiState = new Map();
function isUiModuleEnabled(id) {
return !id || moduleUiState.get(id) !== false;
}
function applyModuleVisibility(root = document) {
root.querySelectorAll?.('[data-module], [data-module-panel]').forEach(element => {
const moduleId = element.dataset.module || element.dataset.modulePanel;
const visible = isUiModuleEnabled(moduleId);
element.classList.toggle('hidden', !visible);
element.setAttribute('aria-hidden', visible ? 'false' : 'true');
if ('disabled' in element && !visible) element.disabled = true;
});
}
function applyModuleNavigation(payload = {}) {
moduleUiState.clear();
for (const module of payload.modules || []) moduleUiState.set(module.id, module.enabled === true);
applyModuleVisibility(document);
const active = document.querySelector('.tab-content.active');
const activeButton = active ? document.querySelector(`.nav-item[data-tab="${active.id}"]`) : null;
if (activeButton?.classList.contains('hidden')) switchToTab('dashboard');
}
async function refreshModuleNavigation() {
try {
const payload = await api('GET', '/api/modules/navigation', null, { retry: false, timeoutMs: 10000 });
applyModuleNavigation(payload);
return payload;
} catch (error) {
console.warn('[Vendoo] Modulnavigation konnte nicht geladen werden:', error.message);
return null;
}
}
window.addEventListener('vendoo:modules-changed', () => refreshModuleNavigation());
async function init() {
document.documentElement.dataset.vendooBuild = VENDOO_UI_BUILD;
console.info(`[Vendoo] UI-Build ${VENDOO_UI_BUILD}`);
@@ -295,10 +333,16 @@ async function init() {
if (meData.setupMode) { window.location.href = '/login.html'; return; }
} catch {}
// Modulstatus immer zuerst laden: Ein deaktiviertes Modul darf weder seine
// Navigation anzeigen noch den App-Start durch eine gesperrte API blockieren.
const moduleNavigation = await api('GET', '/api/modules/navigation');
applyModuleNavigation(moduleNavigation);
const aiEnabled = isUiModuleEnabled('vendoo.ai');
const [platforms, settings, providers, templates] = await Promise.all([
api('GET', '/api/platforms'),
api('GET', '/api/settings'),
api('GET', '/api/providers'),
aiEnabled ? api('GET', '/api/providers') : Promise.resolve([]),
api('GET', '/api/templates'),
]);
@@ -1704,6 +1748,12 @@ function closeMobileSidebar({ restoreFocus = false } = {}) {
}
function switchToTab(tabId, adminTab) {
const requestedNav = document.querySelector(`.nav-item[data-tab="${tabId}"]`);
if (requestedNav?.classList.contains('hidden')) {
toast('Dieses Modul ist derzeit deaktiviert.');
tabId = 'dashboard';
adminTab = null;
}
if (tabId !== 'history' && listingEditLock.listingId) releaseListingLock({ quiet: true });
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
@@ -2464,7 +2514,8 @@ function showListing(listing, platformHtml) {
document.getElementById('result-tags').value = tags;
const plat = state.platforms.find(p => p.id === state.platform);
document.getElementById('tags-label').textContent = plat?.fields.includes('hashtags') ? 'Hashtags' : 'Tags';
document.getElementById('save-btn').textContent = listing.id ? 'Änderungen speichern' : 'Speichern & Hinzufügen';
const saveButton = document.getElementById('save-btn');
if (saveButton) { saveButton.textContent = listing.id ? 'Änderungen speichern' : 'Artikel speichern'; saveButton.disabled = false; }
document.getElementById('generator-preview-empty')?.classList.add('hidden');
document.getElementById('result-section').classList.remove('hidden');
const previewStatus = document.getElementById('generator-preview-status');
@@ -2568,7 +2619,8 @@ function resetGenerator() {
document.getElementById('meta-section').classList.add('hidden');
document.getElementById('variants-section').classList.add('hidden');
document.getElementById('generator-preview-empty')?.classList.remove('hidden');
document.getElementById('save-btn').textContent = 'Speichern & Hinzufügen';
const saveButton = document.getElementById('save-btn');
if (saveButton) { saveButton.textContent = 'Speichern'; saveButton.disabled = true; }
document.getElementById('generate-btn').disabled = true;
renderAiModelGrid('generator', []);
setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral');
@@ -3397,13 +3449,13 @@ async function showDetail(id) {
<button id="detail-download-photos" class="vd-button compact" type="button">Fotos herunterladen</button>
</div>
<p class="detail-photo-help">Vorschaubilder ziehen, um die Reihenfolge zu ändern. Das erste Bild ist das Cover.</p>
<section class="ai-model-card detail-ai-model-card">
<section class="ai-model-card detail-ai-model-card hidden" data-module-panel="vendoo.product-image-tools">
<div class="ai-model-card-head">
<div>
<span class="generator-panel-index">AI</span>
<div>
<h4>Produktbild-Werkzeuge</h4>
<p>Erzeuge Ghost-Mannequin- oder Flatlay-Varianten aus den aktuellen Listing-Fotos. Freie Prompt-Bilder werden im FLUX Studio erstellt.</p>
<h4>Produktbild Studio</h4>
<p>Optionales Modul für kontrollierte Ghost-Mannequin- und Flatlay-Varianten aus den aktuellen Listing-Fotos.</p>
</div>
</div>
<span class="generator-beta">Safe</span>
@@ -3514,6 +3566,8 @@ async function showDetail(id) {
</div>
</div>`;
applyModuleVisibility(document.getElementById('detail-content'));
const lockResult = await acquireListingLock(numericId);
if (!userCan('listings.edit') || lockResult.readOnly) {
document.querySelectorAll('#history-detail input, #history-detail textarea, #history-detail select').forEach(element => { element.disabled = true; });
@@ -3545,16 +3599,18 @@ async function showDetail(id) {
if (element) { element.disabled = true; element.setAttribute('aria-disabled', 'true'); }
});
}
document.getElementById('detail-ai-model-btn')?.addEventListener('click', runDetailAiModelPhotos);
document.getElementById('detail-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('detail'));
document.getElementById('detail-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('detail'));
updateAiModeControls('detail');
if (isUiModuleEnabled('vendoo.product-image-tools')) {
document.getElementById('detail-ai-model-btn')?.addEventListener('click', runDetailAiModelPhotos);
document.getElementById('detail-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('detail'));
document.getElementById('detail-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('detail'));
updateAiModeControls('detail');
renderAiModelGrid('detail', []);
loadDetailAiModelPhotos(numericId);
loadDetailAiModelGenerations(numericId);
}
renderDetailPhotos();
renderAiModelGrid('detail', []);
addQuillTooltips();
refreshDetailPlatformPreview();
loadDetailAiModelPhotos(numericId);
loadDetailAiModelGenerations(numericId);
if (typeof QRious !== 'undefined') {
new QRious({ element: document.getElementById('detail-qr-canvas'), value: JSON.stringify({ sku: skuVal, id: l.id }), size: 118, level: 'M' });
@@ -8107,6 +8163,92 @@ async function loadOperationsExtensions() {
catch (error) { const box = document.getElementById('operations-extension-clients'); if (box) box.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
}
function deploymentStateLabel(state) {
return ({
checking: 'Prüfung', backup_pending: 'Backup geplant', backup_running: 'Backup läuft',
backup_verified: 'Backup verifiziert', deploy_requested: 'Deployment angefordert',
deploy_running: 'Deployment läuft', health_wait: 'Readiness wird geprüft',
succeeded: 'Erfolgreich', failed: 'Fehlgeschlagen', rollback_required: 'Rollback/Prüfung erforderlich',
})[state] || state || 'Unbekannt';
}
function deploymentStateClass(state) {
if (state === 'succeeded') return 'is-ok';
if (state === 'failed' || state === 'rollback_required') return 'is-failed';
return 'is-running';
}
function renderProductionOverview(data) {
const box = document.getElementById('deployment-production-overview');
if (!box) return;
const production = data.production || {};
const readiness = production.readiness || {};
const storage = production.storage || {};
const lastBackup = production.last_backup;
const flux = production.flux || {};
const cards = [
['Version', production.version || data.current_version || VENDOO_UI_BUILD, production.build_revision ? `Build ${String(production.build_revision).slice(0, 12)}` : 'Keine Build-Revision gemeldet', true],
['Readiness', readiness.ok ? 'Bereit' : 'Nicht bereit', readiness.error || readiness.message || 'Interner Readiness-Check', !!readiness.ok],
['Datenbank', production.database?.integrity === 'ok' ? 'Integrität OK' : String(production.database?.integrity || 'Unbekannt'), production.database?.path || 'Kein Pfad gemeldet', production.database?.integrity === 'ok'],
['Persistente Daten', operationFormatBytes(storage.data?.bytes || 0), `${Number(storage.data?.files || 0)} Einträge · Uploads ${operationFormatBytes(storage.uploads?.bytes || 0)}`, true],
['Letztes Backup', lastBackup ? (lastBackup.status === 'verified' ? 'Verifiziert' : lastBackup.status) : 'Nicht vorhanden', lastBackup ? `${operationFormatDate(lastBackup.created_at)} · ${operationFormatBytes(lastBackup.file_size)}` : 'Vor dem ersten Deployment erforderlich', lastBackup?.status === 'verified'],
['FLUX Studio', flux.enabled ? 'Aktiv' : 'Deaktiviert', flux.enabled ? 'Produktionsgrenze prüfen' : 'Wie vorgesehen deaktiviert', !flux.enabled],
];
box.innerHTML = cards.map(([label, value, detail, ok]) => `<article class="deployment-production-item ${ok ? 'is-ok' : 'is-warning'}"><span data-vd-icon="${ok ? 'check-circle-2' : 'triangle-alert'}"></span><div><small>${escapeHtml(label)}</small><strong>${escapeHtml(String(value))}</strong><p>${escapeHtml(String(detail || ''))}</p></div></article>`).join('');
const checkBox = document.getElementById('deployment-production-check-result');
const check = data.latest_production_check;
if (checkBox && check) {
checkBox.innerHTML = `<strong>${check.overall_status === 'passed' ? 'Produktionscheck bestanden' : check.overall_status === 'warning' ? 'Produktionscheck mit Hinweisen' : 'Produktionscheck fehlgeschlagen'}</strong><span>${Number(check.passed_count || 0)} bestanden · ${Number(check.warning_count || 0)} Hinweise · ${Number(check.failed_count || 0)} Fehler · ${operationFormatDate(check.created_at || check.verified_at)}</span>`;
}
}
function renderDeploymentRuns(data) {
const list = document.getElementById('deployment-run-list');
if (!list) return;
const runs = data.recent_runs || [];
if (!runs.length) { list.innerHTML = '<div class="empty-state">Keine Deployment-Aufträge vorhanden.</div>'; return; }
list.innerHTML = runs.map(run => {
const lastEvent = run.events?.at?.(-1);
const active = !['succeeded', 'failed'].includes(run.state);
return `<article class="deployment-run ${deploymentStateClass(run.state)}">
<div class="deployment-run-head"><span class="deployment-run-state">${escapeHtml(deploymentStateLabel(run.state))}</span><time>${operationFormatDate(run.created_at)}</time></div>
<strong>${escapeHtml(run.current_version || '?')} ${escapeHtml(run.target_version || '?')}</strong>
<p>${escapeHtml(lastEvent?.message || run.error_message || run.reason || 'Deployment-Auftrag')}</p>
<small>${escapeHtml(run.github_branch || 'main')}${run.backup_id ? ' · Backup verifiziert' : ''}${run.deployment_id ? ` · ID ${escapeHtml(String(run.deployment_id).slice(0, 18))}` : ''}</small>
${active ? `<div class="deployment-run-actions">${run.state !== 'rollback_required' ? `<button type="button" class="secondary-btn compact" data-deployment-refresh="${escapeHtml(run.id)}">Status aktualisieren</button>` : ''}${data.trigger_mode === 'webhook' && ['deploy_requested','deploy_running','health_wait'].includes(run.state) ? `<button type="button" class="secondary-btn compact" data-deployment-confirm="${escapeHtml(run.id)}">Externen Abschluss bestätigen</button>` : ''}${run.state === 'rollback_required' ? `<button type="button" class="secondary-btn compact" data-deployment-resolve="${escapeHtml(run.id)}">Prüfung abschließen</button>` : ''}</div>` : ''}
</article>`;
}).join('');
list.querySelectorAll('[data-deployment-refresh]').forEach(button => button.addEventListener('click', async () => {
button.disabled = true;
try { await api('POST', `/api/admin/deployment/runs/${encodeURIComponent(button.dataset.deploymentRefresh)}/refresh`); await loadDeploymentStatus(); }
catch (error) { toast(error.message); }
finally { button.disabled = false; }
}));
list.querySelectorAll('[data-deployment-confirm]').forEach(button => button.addEventListener('click', async () => {
const confirmation = prompt('Nur bestätigen, wenn Coolify das Deployment wirklich abgeschlossen hat. Exakt CONFIRM eingeben:');
if (confirmation !== 'CONFIRM') return;
button.disabled = true;
try {
await api('POST', `/api/admin/deployment/runs/${encodeURIComponent(button.dataset.deploymentConfirm)}/confirm`, { confirmation, outcome: 'succeeded', message: 'Im Vendoo Update Center extern bestätigt.' });
toast('Externer Abschluss wurde erfasst und Readiness erneut geprüft.');
await loadDeploymentStatus();
} catch (error) { toast(error.message); }
finally { button.disabled = false; }
}));
list.querySelectorAll('[data-deployment-resolve]').forEach(button => button.addEventListener('click', async () => {
const confirmation = prompt('Der Auftrag bleibt als fehlgeschlagen dokumentiert und die Deployment-Sperre wird aufgehoben. Exakt CONFIRM eingeben:');
if (confirmation !== 'CONFIRM') return;
button.disabled = true;
try {
await api('POST', `/api/admin/deployment/runs/${encodeURIComponent(button.dataset.deploymentResolve)}/confirm`, { confirmation, outcome: 'failed', message: 'Rollback-/Fehlerprüfung manuell abgeschlossen.' });
toast('Prüfung abgeschlossen. Ein neuer Deployment-Auftrag ist wieder möglich.');
await loadDeploymentStatus();
} catch (error) { toast(error.message); }
finally { button.disabled = false; }
}));
}
function renderDeploymentStatus() {
const data = operationsState.deployment || {};
const setValue = (id, value) => { const field = document.getElementById(id); if (field && document.activeElement !== field) field.value = value ?? ''; };
@@ -8114,9 +8256,14 @@ function renderDeploymentStatus() {
setValue('deployment-coolify-url', data.coolify_url || '');
setValue('deployment-resource-uuid', data.resource_uuid || '');
setValue('deployment-trigger-mode', data.trigger_mode || 'webhook');
setValue('deployment-status-endpoint', data.deployment_status_endpoint || '/api/v1/deployments/{deployment_id}');
setValue('deployment-timeout-seconds', data.deployment_timeout_seconds || 900);
setValue('deployment-poll-seconds', data.health_poll_seconds || 15);
setValue('deployment-github-repository', data.github_repository || 'Masterluke77/vendoo');
setValue('deployment-github-branch', data.github_branch || 'main');
setValue('deployment-update-channel', data.update_channel || 'stable');
const target = document.getElementById('deployment-target-version');
if (target && document.activeElement !== target && !target.value) target.value = data.active_run?.target_version || data.current_version || '';
const backup = document.getElementById('deployment-backup-before'); if (backup) backup.checked = data.backup_before_deploy !== false;
const uploads = document.getElementById('deployment-backup-uploads'); if (uploads) uploads.checked = data.backup_uploads === true;
const webhook = document.getElementById('deployment-webhook-url'); if (webhook && document.activeElement !== webhook) { webhook.value = ''; webhook.placeholder = data.deploy_webhook?.configured ? `Gespeichert: ${data.deploy_webhook.masked || '••••••••'}` : 'Coolify Deploy-Webhook eintragen'; }
@@ -8124,7 +8271,11 @@ function renderDeploymentStatus() {
const status = document.getElementById('deployment-status');
if (status) status.innerHTML = `<strong>${data.configured ? 'Coolify ist konfiguriert' : 'Coolify noch nicht vollständig konfiguriert'}</strong><span>Provider: ${escapeHtml(data.provider || 'none')} · Trigger: ${escapeHtml(data.trigger_mode || 'webhook')} · Docker-Socket: ${data.docker_socket_access ? 'vorhanden' : 'nicht freigegeben'}</span>`;
const action = document.getElementById('deployment-action-result');
if (action && data.last_deployment) action.innerHTML = `<strong>${data.last_deployment.accepted ? 'Letzter Deployment-Auftrag angenommen' : 'Letzter Deployment-Auftrag fehlgeschlagen'}</strong><span>${operationFormatDate(data.last_deployment.requested_at)} · Branch ${escapeHtml(data.last_deployment.github_branch || 'main')} · HTTP ${escapeHtml(String(data.last_deployment.http_status || '—'))}</span>`;
const last = data.recent_runs?.[0] || data.production?.last_deployment;
if (action && last) action.innerHTML = `<strong>${escapeHtml(deploymentStateLabel(last.state))}</strong><span>${operationFormatDate(last.created_at || last.requested_at)} · ${escapeHtml(last.current_version || data.current_version || '?')}${escapeHtml(last.target_version || '?')} · Branch ${escapeHtml(last.github_branch || 'main')}</span>`;
renderProductionOverview(data);
renderDeploymentRuns(data);
window.VendooIcons?.render(document.getElementById('operations-updates-panel'));
}
async function loadDeploymentStatus() {
@@ -8140,6 +8291,9 @@ function deploymentSettingsPayload() {
trigger_mode: document.getElementById('deployment-trigger-mode')?.value || 'webhook',
deploy_method: 'GET',
deploy_endpoint: '/api/v1/deploy?uuid={uuid}&force={force}',
deployment_status_endpoint: document.getElementById('deployment-status-endpoint')?.value || '/api/v1/deployments/{deployment_id}',
deployment_timeout_seconds: Number(document.getElementById('deployment-timeout-seconds')?.value || 900),
health_poll_seconds: Number(document.getElementById('deployment-poll-seconds')?.value || 15),
github_repository: document.getElementById('deployment-github-repository')?.value || 'Masterluke77/vendoo',
github_branch: document.getElementById('deployment-github-branch')?.value || 'main',
ghcr_image: 'ghcr.io/masterluke77/vendoo',
@@ -8238,6 +8392,18 @@ function setupOperationsCenter() {
finally { button.disabled = !operationsState.updateFile; button.textContent = 'Update prüfen und vorbereiten'; }
});
document.getElementById('deployment-production-check')?.addEventListener('click', async event => {
const button = event.currentTarget; const resultBox = document.getElementById('deployment-production-check-result');
button.disabled = true; if (resultBox) resultBox.textContent = 'Produktionsgrenzen werden geprüft …';
try {
const result = await api('POST', '/api/admin/deployment/production-check');
const details = (result.results || []).filter(item => item.status !== 'pass').map(item => `${item.title || item.name || item.id}: ${item.message || item.status}`).slice(0, 4).join(' · ');
if (resultBox) resultBox.innerHTML = `<strong>${result.overall_status === 'passed' ? 'Produktionscheck bestanden' : result.overall_status === 'warning' ? 'Produktionscheck mit Hinweisen' : 'Produktionscheck fehlgeschlagen'}</strong><span>${Number(result.passed_count || 0)} bestanden · ${Number(result.warning_count || 0)} Hinweise · ${Number(result.failed_count || 0)} Fehler${details ? ` · ${escapeHtml(details)}` : ''}</span>`;
await loadDeploymentStatus();
} catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Produktionscheck konnte nicht ausgeführt werden</strong><span>${escapeHtml(error.message)}</span>`; }
finally { button.disabled = false; }
});
document.getElementById('deployment-save')?.addEventListener('click', async event => {
const button = event.currentTarget; button.disabled = true;
try { operationsState.deployment = await api('PUT', '/api/admin/deployment', deploymentSettingsPayload()); renderDeploymentStatus(); toast('Coolify- und GitHub-Konfiguration gespeichert.'); }
@@ -8252,7 +8418,12 @@ function setupOperationsCenter() {
});
document.getElementById('deployment-update-check')?.addEventListener('click', async event => {
const button = event.currentTarget; const resultBox = document.getElementById('deployment-action-result'); button.disabled = true;
try { const result = await api('POST', '/api/admin/deployment/updates/check'); if (resultBox) resultBox.innerHTML = `<strong>${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : result.configured ? 'Vendoo ist aktuell' : 'Keine Manifest-URL konfiguriert'}</strong><span>Installiert: ${escapeHtml(result.current_version || VENDOO_UI_BUILD)}${result.message ? ` · ${escapeHtml(result.message)}` : ''}</span>`; }
try {
const result = await api('POST', '/api/admin/deployment/updates/check');
const target = document.getElementById('deployment-target-version');
if (target && result.latest_version) target.value = result.latest_version;
if (resultBox) resultBox.innerHTML = `<strong>${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : result.configured ? 'Vendoo ist aktuell' : 'Keine Manifest-URL konfiguriert'}</strong><span>Installiert: ${escapeHtml(result.current_version || VENDOO_UI_BUILD)}${result.latest_version ? ` · Freigegeben: ${escapeHtml(result.latest_version)}` : ''}${result.message ? ` · ${escapeHtml(result.message)}` : ''}</span>`;
}
catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Updateprüfung fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; }
finally { button.disabled = false; }
});
@@ -8261,9 +8432,11 @@ function setupOperationsCenter() {
if (confirmation !== 'DEPLOY') return;
const button = event.currentTarget; const resultBox = document.getElementById('deployment-action-result'); button.disabled = true; button.textContent = 'Deployment wird angefordert …';
try {
const result = await api('POST', '/api/admin/deployment/trigger', { confirmation, force: false, reason: 'update-center' });
if (resultBox) resultBox.innerHTML = `<strong>Coolify-Deployment angenommen</strong><span>${escapeHtml(result.message || '')}${result.backup_file ? ` · Backup: ${escapeHtml(result.backup_file)}` : ''}</span>`;
toast('Coolify-Deployment wurde angefordert.');
const targetVersion = document.getElementById('deployment-target-version')?.value || operationsState.deployment?.current_version || VENDOO_UI_BUILD;
const idempotencyKey = globalThis.crypto?.randomUUID?.() || `vendoo-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const result = await api('POST', '/api/admin/deployment/trigger', { confirmation, force: false, reason: 'update-center', target_version: targetVersion, idempotency_key: idempotencyKey });
if (resultBox) resultBox.innerHTML = `<strong>${result.reused ? 'Vorhandener Deployment-Auftrag erkannt' : 'Coolify-Deployment angenommen'}</strong><span>${escapeHtml(result.message || deploymentStateLabel(result.state))}${result.backup_id ? ` · Backup ${escapeHtml(String(result.backup_id).slice(0, 12))}` : ''}</span>`;
toast(result.reused ? 'Der gleiche Deployment-Auftrag war bereits vorhanden.' : 'Coolify-Deployment wurde angefordert.');
await loadDeploymentStatus();
} catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Deployment fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; toast(error.message); }
finally { button.disabled = false; button.textContent = 'Coolify-Deployment starten'; }
@@ -8291,7 +8464,7 @@ function setupAdmin() {
document.getElementById('invite-user-btn')?.addEventListener('click', () => {
document.getElementById('invite-email').value = '';
document.getElementById('invite-name').value = '';
document.getElementById('invite-role').value = 'editor';
document.getElementById('invite-role').value = 'viewer';
document.getElementById('invite-result-wrap').classList.add('hidden');
document.getElementById('invite-result-password').value = '';
const mlRadio = document.querySelector('input[name="invite-method"][value="magic_link"]');
@@ -8442,13 +8615,48 @@ function exportAdminAudit() {
window.location.href = `/api/admin/audit/export.csv?${params.toString()}`;
}
async function loadAdminInvitations() {
const container = document.getElementById('invitations-list');
if (!container) return;
try {
const result = await api('GET', '/api/admin/users/invitations');
const invitations = result.invitations || [];
if (!invitations.length) { container.innerHTML = '<div class="empty-state">Noch keine Einladungen vorhanden.</div>'; return; }
const statusLabel = status => ({ pending: 'Offen', expired: 'Abgelaufen', revoked_or_used: 'Verwendet oder widerrufen', revoked: 'Widerrufen' })[status] || status;
container.innerHTML = invitations.map(invitation => {
const actionable = ['pending', 'expired'].includes(invitation.status);
return `<article class="invitation-admin-row ${invitation.status === 'pending' ? 'is-pending' : ''}">
<div><strong>${escapeHtml(invitation.email)}</strong><span>${escapeHtml(ROLE_LABELS[invitation.role] || invitation.role || 'Leser')} · ${escapeHtml(statusLabel(invitation.status))}</span><small>Erstellt ${operationFormatDate(invitation.created_at)} · Ablauf ${operationFormatDate(invitation.expires_at)}</small></div>
${actionable ? `<div class="invitation-admin-actions"><button type="button" class="small-btn" data-invitation-resend="${invitation.id}" title="Einladung erneut senden"><span data-vd-icon="mail"></span></button>${invitation.status === 'pending' ? `<button type="button" class="small-btn danger-btn" data-invitation-revoke="${invitation.id}" title="Einladung widerrufen"><span data-vd-icon="ban"></span></button>` : ''}</div>` : ''}
</article>`;
}).join('');
container.querySelectorAll('[data-invitation-resend]').forEach(button => button.addEventListener('click', async () => {
button.disabled = true;
try {
const result = await api('POST', `/api/admin/users/invitations/${encodeURIComponent(button.dataset.invitationResend)}/resend`);
toast(result.consoleFallback ? 'Neue Einladung erstellt; Link wurde in der Server-Konsole ausgegeben.' : 'Einladung erneut gesendet.');
await loadAdminInvitations();
} catch (error) { toast(error.message); }
finally { button.disabled = false; }
}));
container.querySelectorAll('[data-invitation-revoke]').forEach(button => button.addEventListener('click', async () => {
if (!confirm('Diese Einladung sofort widerrufen? Der Link kann danach nicht mehr verwendet werden.')) return;
button.disabled = true;
try { await api('DELETE', `/api/admin/users/invitations/${encodeURIComponent(button.dataset.invitationRevoke)}`); toast('Einladung widerrufen.'); await loadAdminInvitations(); }
catch (error) { toast(error.message); }
finally { button.disabled = false; }
}));
globalThis.VendooIcons?.render?.(container);
} catch (error) { container.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
}
async function loadAdminUsers() {
try {
const users = await api('GET', '/api/admin/users');
adminUsersCache = Array.isArray(users) ? users : [];
const container = document.getElementById('users-list');
if (!container) return;
if (!adminUsersCache.length) { container.innerHTML = '<div class="empty-state">Keine User vorhanden</div>'; return; }
if (!adminUsersCache.length) { container.innerHTML = '<div class="empty-state">Keine User vorhanden</div>'; await loadAdminInvitations(); return; }
container.innerHTML = adminUsersCache.map(u => {
const initials = getInitials(u.name, u.email);
@@ -8480,6 +8688,7 @@ async function loadAdminUsers() {
if (user) confirmDeleteUser(user.id, user.email);
}));
globalThis.VendooIcons?.render?.(container);
await loadAdminInvitations();
} catch (err) { toast('Fehler: ' + err.message); }
}
+85 -54
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="vendoo-build" content="1.41.2">
<meta name="vendoo-build" content="1.43.0">
<title>Vendoo</title>
<link rel="icon" href="/brand/favicons/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="16x16" href="/brand/favicons/favicon-16x16.png">
@@ -13,9 +13,9 @@
<link rel="manifest" href="/brand/favicons/site.webmanifest">
<meta name="msapplication-config" content="/brand/favicons/browserconfig.xml">
<meta name="theme-color" content="#FAF6F2">
<link rel="stylesheet" href="style.css?v=1.41.2">
<link rel="stylesheet" href="modules/theme-manager/theme-manager.css?v=1.41.2">
<link rel="stylesheet" href="modules/module-manager/module-manager.css?v=1.41.2">
<link rel="stylesheet" href="style.css?v=1.43.0">
<link rel="stylesheet" href="modules/theme-manager/theme-manager.css?v=1.43.0">
<link rel="stylesheet" href="modules/module-manager/module-manager.css?v=1.43.0">
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
</head>
<body>
@@ -36,55 +36,55 @@
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
<span>Dashboard</span>
</button>
<button class="nav-item" data-tab="generator">
<button class="nav-item" data-tab="generator" data-module="vendoo.ai">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/><rect x="3" y="3" width="18" height="18" rx="3"/></svg>
<span>Neuer Artikel</span>
</button>
<button class="nav-item" data-tab="today">
<button class="nav-item" data-tab="today" data-module="vendoo.listings">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11.5 12 4l9 7.5"/><path d="M5.5 10.5V20h13v-9.5"/><path d="M9.5 20v-6h5v6"/></svg>
<span>Heute</span>
</button>
<button class="nav-item" data-tab="history">
<button class="nav-item" data-tab="history" data-module="vendoo.listings">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 7h8M8 11h8M8 15h5"/></svg>
<span>Artikel</span>
</button>
<button class="nav-item" data-tab="quality-center">
<button class="nav-item" data-tab="quality-center" data-module="vendoo.quality">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 20 7v5c0 5-3.4 8.3-8 10-4.6-1.7-8-5-8-10V7Z"/><path d="m8.5 12 2.2 2.2 4.8-5"/></svg>
<span>Qualitätscenter</span>
</button>
<button class="nav-item" data-tab="publish">
<button class="nav-item" data-tab="publish" data-module="vendoo.publishing">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
<span>Publish</span>
</button>
<button class="nav-item" data-tab="inventory">
<button class="nav-item" data-tab="inventory" data-module="vendoo.inventory">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m4 7 8-4 8 4-8 4Z"/><path d="m4 7 8 4 8-4v10l-8 4-8-4Z"/><path d="M12 11v10"/></svg>
<span>Lager</span>
</button>
<button class="nav-item" data-tab="templates">
<button class="nav-item" data-tab="templates" data-module="vendoo.listings">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></svg>
<span>Vorlagen</span>
</button>
<button class="nav-item" data-tab="flux-studio">
<button class="nav-item" data-tab="flux-studio" data-module="vendoo.flux-studio">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 9.8 8.2 4 10l5 3.2L8 19l4-3 4 3-1-5.8 5-3.2-5.8-1.8Z"/><path d="M18 3v4M16 5h4"/></svg>
<span>FLUX Studio</span>
</button>
<button class="nav-item" data-tab="media-library">
<button class="nav-item" data-tab="media-library" data-module="vendoo.media">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="m4 17 5-5 4 4 2-2 5 5"/></svg>
<span>Bilder</span>
</button>
<button class="nav-item" data-tab="image-factory">
<button class="nav-item" data-tab="image-factory" data-module="vendoo.ai">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4z"/><path d="M14 17h6M17 14v6"/></svg>
<span>Bildproduktion</span>
</button>
<button class="nav-item" data-tab="extensions">
<button class="nav-item" data-tab="extensions" data-module="vendoo.module-manager">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 3v4M15.5 3v4M6 7h12v4a6 6 0 0 1-12 0Z"/><path d="M12 17v4M9 21h6"/></svg>
<span>Extensions</span>
</button>
<button class="nav-item" data-tab="settings">
<button class="nav-item" data-tab="settings" data-module="vendoo.settings">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.05.05-2.83 2.83-.05-.05a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.04 1.57V21h-4v-.07a1.7 1.7 0 0 0-1.04-1.57 1.7 1.7 0 0 0-1.87.34l-.05.05-2.83-2.83.05-.05A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.57-1.04H3v-4h.07A1.7 1.7 0 0 0 4.64 8.9a1.7 1.7 0 0 0-.34-1.87l-.05-.05 2.83-2.83.05.05A1.7 1.7 0 0 0 9 4.54 1.7 1.7 0 0 0 10.04 3H10V3h4v.07A1.7 1.7 0 0 0 15.04 4.6a1.7 1.7 0 0 0 1.87-.34l.05-.05 2.83 2.83-.05.05A1.7 1.7 0 0 0 19.4 9c.24.63.85 1.04 1.53 1.04H21v4h-.07c-.68 0-1.29.41-1.53 1.04Z"/></svg>
<span>Einstellungen</span>
</button>
<button class="nav-item" data-tab="admin" data-admin-tab="users" id="nav-admin-main">
<button class="nav-item" data-tab="admin" data-module="vendoo.users" data-admin-tab="users" id="nav-admin-main">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="M9 12h6M12 9v6"/></svg>
<span>Admin</span>
</button>
@@ -92,11 +92,11 @@
<div class="nav-group nav-group-tools" id="nav-system-group">
<div class="nav-divider"></div>
<button class="nav-item nav-item-quiet" data-tab="fees">
<button class="nav-item nav-item-quiet" data-tab="fees" data-module="vendoo.publishing">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 7h8M8 11h3M8 15h3M15 11v5M13 13.5h4"/></svg>
<span>Gebühren</span>
</button>
<button class="nav-item nav-item-quiet" data-tab="trash">
<button class="nav-item nav-item-quiet" data-tab="trash" data-module="vendoo.listings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14"/></svg>
<span>Papierkorb</span>
<span id="trash-badge" class="nav-badge hidden">0</span>
@@ -145,7 +145,7 @@
</button>
<div class="topbar-actions">
<button class="topbar-btn primary" id="topbar-new-btn">
<button class="topbar-btn primary" id="topbar-new-btn" data-module="vendoo.ai">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span>Neuer Artikel</span>
</button>
@@ -314,18 +314,39 @@
<p>Fotos erfassen, AI gezielt steuern und das Ergebnis ohne Kontextwechsel fertigstellen.</p>
</div>
<div class="generator-heading-actions">
<div class="generator-stepper" aria-label="Arbeitsfortschritt">
<span class="generator-step is-active" id="generator-step-photos"><b>1</b> Fotos</span>
<span class="generator-step" id="generator-step-ai"><b>2</b> AI</span>
<span class="generator-step" id="generator-step-result"><b>3</b> Prüfen</span>
</div>
<button id="reset-generator-btn" class="vd-button secondary" type="button">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>
Zurücksetzen
Formular leeren
</button>
</div>
</header>
<div class="generator-commandbar" id="generator-commandbar">
<div class="generator-command-progress">
<div class="generator-stepper" aria-label="Arbeitsfortschritt">
<span class="generator-step is-active" id="generator-step-photos"><b>1</b> Fotos</span>
<span class="generator-step" id="generator-step-ai"><b>2</b> Generieren</span>
<span class="generator-step" id="generator-step-result"><b>3</b> Prüfen</span>
</div>
<div class="generator-readiness">
<span id="generator-readiness-dot"></span>
<div>
<strong id="generator-readiness-title">Fotos fehlen</strong>
<small id="generator-readiness-copy">Lade mindestens ein Produktfoto hoch.</small>
</div>
</div>
</div>
<div class="generator-command-actions">
<button id="generate-btn" class="vd-button primary generator-primary-action" disabled>
<span aria-hidden="true"></span>
Artikel generieren
</button>
<button id="save-btn" class="vd-button generator-save-action" disabled>
Speichern
</button>
</div>
</div>
<div class="generator-studio-layout">
<section class="generator-panel media-workbench" aria-labelledby="generator-media-title">
<div class="generator-panel-header">
@@ -377,13 +398,13 @@
</div>
<section id="generator-ai-model-card" class="ai-model-card generator-ai-model-card">
<section id="generator-ai-model-card" class="ai-model-card generator-ai-model-card hidden" data-module-panel="vendoo.product-image-tools">
<div class="ai-model-card-head">
<div>
<span class="generator-panel-index">AI</span>
<div>
<h4>Produktbild-Werkzeuge</h4>
<p>Erzeuge Ghost-Mannequin- oder Flatlay-Varianten aus dem vorhandenen Produktfoto. Freie Prompt-Bilder findest du im separaten FLUX Studio.</p>
<h4>Produktbild Studio</h4>
<p>Optionales Modul für kontrollierte Ghost-Mannequin- und Flatlay-Varianten. Aktivierung und Deaktivierung erfolgen im Modulmanager.</p>
</div>
</div>
<span id="generator-ai-model-badge" class="generator-beta">Safe</span>
@@ -533,19 +554,7 @@
<p><strong>AI analysiert deine Fotos</strong><br><span id="generator-loading-copy">Produkt, Zustand und Marktplatzdaten werden vorbereitet.</span></p>
</div>
<div class="generator-action-zone">
<div class="generator-readiness">
<span id="generator-readiness-dot"></span>
<div>
<strong id="generator-readiness-title">Fotos fehlen</strong>
<small id="generator-readiness-copy">Lade mindestens ein Produktfoto hoch.</small>
</div>
</div>
<button id="generate-btn" class="primary-btn generator-primary-action" disabled>
<span></span>
Artikel generieren
</button>
</div>
</section>
<aside class="generator-panel generator-preview-panel" aria-labelledby="generator-preview-title">
@@ -649,7 +658,7 @@
<div class="result-actions generator-result-actions">
<button id="copy-all-btn" class="secondary-btn">Alles kopieren</button>
<button id="save-btn" class="primary-btn">Speichern &amp; Hinzufügen</button>
<span>Speichern ist dauerhaft oben erreichbar.</span>
</div>
</div>
</aside>
@@ -1122,6 +1131,10 @@
<!-- Users Tab -->
<div class="admin-panel active" id="admin-users-panel">
<div id="users-list" class="users-list"></div>
<section class="invitation-admin-section">
<div class="security-panel-heading"><div><p class="eyebrow">Testzugänge</p><h3>Einladungen</h3><p>Offene, abgelaufene und widerrufene Einladungen. Standardrolle ist Leser; höhere Rechte müssen bewusst gewählt werden.</p></div></div>
<div id="invitations-list" class="invitation-admin-list"><div class="empty-state">Einladungen werden geladen …</div></div>
</section>
</div>
<!-- Roles Tab -->
@@ -1256,27 +1269,45 @@
<div class="operation-card-actions"><button type="button" class="secondary-btn" id="operations-update-url-save">URL speichern</button><button type="button" class="secondary-btn" id="operations-update-check">Jetzt prüfen</button></div>
<div id="operations-update-check-result" class="operation-result">Noch nicht geprüft.</div>
</article>
<article class="operations-card operations-production-card">
<header><span data-vd-icon="activity"></span><div><h4>Produktionsstatus</h4><p>Live-Überblick über Version, Readiness, Datenbank, Speicher und FLUX-Grenze.</p></div></header>
<div id="deployment-production-overview" class="deployment-production-overview"><div class="empty-state">Produktionsstatus wird geladen …</div></div>
<div class="operation-card-actions"><button type="button" class="secondary-btn" id="deployment-production-check">Produktionscheck ausführen</button></div>
<div id="deployment-production-check-result" class="operation-result">Noch kein Produktionscheck ausgeführt.</div>
</article>
<article class="operations-card operations-deployment-card">
<header><span data-vd-icon="cloud-cog"></span><div><h4>Coolify &amp; GitHub Deployment</h4><p>Neue GitHub-Versionen kontrolliert über Coolify ausrollen. Vendoo erhält keinen Docker-Socket.</p></div></header>
<header><span data-vd-icon="cloud-cog"></span><div><h4>Coolify &amp; GitHub Deployment</h4><p>Kontrolliertes Deployment ohne Docker-Socket und ohne selbständernden Produktivcontainer.</p></div></header>
<label class="operation-field"><span>Provider</span><select id="deployment-provider"><option value="none">Nicht konfiguriert</option><option value="coolify">Coolify</option></select></label>
<label class="operation-field"><span>Coolify URL</span><input id="deployment-coolify-url" type="url" placeholder="https://coolify.example.de"></label>
<label class="operation-field"><span>Resource UUID</span><input id="deployment-resource-uuid" type="text" placeholder="Coolify Application UUID"></label>
<label class="operation-field"><span>Trigger</span><select id="deployment-trigger-mode"><option value="webhook">Deploy-Webhook (empfohlen)</option><option value="api">Coolify API</option></select></label>
<label class="operation-field"><span>Trigger</span><select id="deployment-trigger-mode"><option value="webhook">Deploy-Webhook</option><option value="api">Coolify API mit Statusabfrage</option></select></label>
<label class="operation-field"><span>Deployment-Status-Endpunkt</span><input id="deployment-status-endpoint" type="text" value="/api/v1/deployments/{deployment_id}"></label>
<div class="operation-inline-fields">
<label class="operation-field"><span>Zeitlimit (Sek.)</span><input id="deployment-timeout-seconds" type="number" min="60" max="7200" value="900"></label>
<label class="operation-field"><span>Prüfintervall (Sek.)</span><input id="deployment-poll-seconds" type="number" min="5" max="300" value="15"></label>
</div>
<label class="operation-field"><span>Deploy-Webhook</span><input id="deployment-webhook-url" type="password" autocomplete="new-password" placeholder="Nur neu eingeben, wenn er geändert werden soll"></label>
<label class="operation-field"><span>API-Token</span><input id="deployment-api-token" type="password" autocomplete="new-password" placeholder="Nur für API-Modus"></label>
<div class="operation-card-actions"><button type="button" class="secondary-btn" id="deployment-save">Konfiguration speichern</button><button type="button" class="secondary-btn" id="deployment-test">Verbindung prüfen</button></div>
<div id="deployment-status" class="operation-result">Deployment-Konfiguration wird geladen …</div>
</article>
<article class="operations-card operations-deploy-action-card">
<header><span data-vd-icon="rocket"></span><div><h4>GitHub-Stand ausrollen</h4><p>Coolify holt den konfigurierten Branch und ersetzt den laufenden Container erst nach erfolgreichem Build und Healthcheck.</p></div></header>
<header><span data-vd-icon="rocket"></span><div><h4>GitHub-Stand kontrolliert ausrollen</h4><p>Vorab-Backup, Doppelstartschutz, Statusverfolgung, Readiness und Zielversion sind Bestandteil eines Auftrags.</p></div></header>
<label class="operation-field"><span>GitHub Repository</span><input id="deployment-github-repository" type="text" value="Masterluke77/vendoo"></label>
<label class="operation-field"><span>Produktionsbranch</span><input id="deployment-github-branch" type="text" value="main"></label>
<label class="operation-field"><span>Update-Kanal</span><select id="deployment-update-channel"><option value="stable">Stable</option><option value="beta">Beta</option></select></label>
<div class="operation-inline-fields">
<label class="operation-field"><span>Update-Kanal</span><select id="deployment-update-channel"><option value="stable">Stable</option><option value="beta">Beta</option></select></label>
<label class="operation-field"><span>Zielversion</span><input id="deployment-target-version" type="text" placeholder="z. B. 1.43.0"></label>
</div>
<label class="operation-check"><input id="deployment-backup-before" type="checkbox" checked><span>Vor dem Deployment ein verifiziertes Backup mit Konfiguration erstellen</span></label>
<label class="operation-check"><input id="deployment-backup-uploads" type="checkbox"><span>Uploads zusätzlich in das Vorab-Backup aufnehmen</span></label>
<div class="operation-card-actions"><button type="button" class="secondary-btn" id="deployment-update-check">Version prüfen</button><button type="button" class="primary-btn" id="deployment-trigger">Coolify-Deployment starten</button></div>
<div id="deployment-action-result" class="operation-result">Noch kein Deployment aus dem Webinterface angefordert.</div>
</article>
<article class="operations-card operations-deployment-history-card">
<header><span data-vd-icon="history"></span><div><h4>Deployment-Verlauf</h4><p>Persistente Aufträge und Ereignisse bleiben auch nach einem Neustart nachvollziehbar.</p></div></header>
<div id="deployment-run-list" class="deployment-run-list"><div class="empty-state">Keine Deployment-Aufträge vorhanden.</div></div>
</article>
<article class="operations-card operations-update-upload">
<header><span data-vd-icon="package-open"></span><div><h4>Update-Paket vorbereiten</h4><p>Prüft Version, Pflichtdateien und verbotene Laufzeitdaten. Vorher wird automatisch gesichert.</p></div></header>
@@ -1378,9 +1409,9 @@ PUBLIC_BASE_URL=https://vendoo.example.de</pre>
<div class="form-group">
<label>Rolle</label>
<select id="invite-role" class="model-select">
<option value="viewer">Leser</option>
<option value="viewer" selected>Leser</option>
<option value="publisher">Publisher</option>
<option value="editor" selected>Editor</option>
<option value="editor">Editor</option>
<option value="manager">Manager</option>
<option value="admin">Administrator</option>
</select>
@@ -2361,11 +2392,11 @@ PUBLIC_BASE_URL=https://vendoo.example.de</pre>
<div id="toast" class="toast"></div>
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
<script src="design-system/theme-runtime.js?v=1.41.2"></script>
<script src="vendoo-icons.js?v=1.41.2"></script>
<script src="app.js?v=1.41.2"></script>
<script type="module" src="modules/theme-manager/theme-manager.js?v=1.41.2"></script>
<script type="module" src="modules/module-manager/module-manager.js?v=1.41.2"></script>
<script src="design-system/theme-runtime.js?v=1.43.0"></script>
<script src="vendoo-icons.js?v=1.43.0"></script>
<script src="app.js?v=1.43.0"></script>
<script type="module" src="modules/theme-manager/theme-manager.js?v=1.43.0"></script>
<script type="module" src="modules/module-manager/module-manager.js?v=1.43.0"></script>
+1 -1
View File
@@ -106,6 +106,6 @@
</div>
</div>
<script src="/login.js?v=1.41.2" defer></script>
<script src="/login.js?v=1.43.0" defer></script>
</body>
</html>
@@ -150,6 +150,7 @@ async function perform(action, id) {
await request('DELETE', `/api/admin/modules/${encodeURIComponent(id)}`);
}
await load();
window.dispatchEvent(new CustomEvent('vendoo:modules-changed', { detail: { action, id } }));
globalThis.toast?.(action === 'enable' ? 'Modul aktiviert.' : action === 'disable' ? 'Modul deaktiviert.' : 'Modul gelöscht.');
} catch (error) {
globalThis.toast?.(error.message);
@@ -168,6 +169,7 @@ async function install() {
await request('POST', '/api/admin/modules/install', form, { multipart: true });
input.value = '';
await load();
window.dispatchEvent(new CustomEvent('vendoo:modules-changed', { detail: { action: 'install' } }));
globalThis.toast?.('Modulpaket geprüft und installiert.');
} catch (error) {
globalThis.toast?.(error.message);
+153 -1
View File
@@ -7165,6 +7165,29 @@ button,input,select,textarea { max-width:100%; }
.operation-card-actions { display: flex; gap: 7px; flex-wrap: wrap; }
.operation-result { display: grid; gap: 3px; min-height: 52px; margin-top: 10px; padding: 10px; border-radius: 9px; background: var(--bg-page); color: var(--text-muted); font-size: 10px; }
.operation-result strong { color: var(--text); font-size: 12px; }
.deployment-production-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
.deployment-production-item { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: start; padding: 9px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-page); min-width: 0; }
.deployment-production-item > span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; color: var(--warning, #d97706); background: color-mix(in srgb, var(--warning, #d97706) 10%, transparent); }
.deployment-production-item.is-ok > span { color: var(--success, #20875d); background: color-mix(in srgb, var(--success, #20875d) 10%, transparent); }
.deployment-production-item small, .deployment-production-item strong, .deployment-production-item p { display: block; min-width: 0; overflow-wrap: anywhere; }
.deployment-production-item small { color: var(--text-muted); font-size: 8px; text-transform: uppercase; letter-spacing: .04em; }
.deployment-production-item strong { margin-top: 2px; font-size: 11px; }
.deployment-production-item p { margin-top: 2px; font-size: 9px; line-height: 1.35; }
.deployment-run-list { display: grid; gap: 8px; max-height: 430px; overflow: auto; }
.deployment-run { padding: 10px; border: 1px solid var(--border); border-left-width: 3px; border-radius: 10px; background: var(--bg-page); }
.deployment-run.is-ok { border-left-color: var(--success, #20875d); }
.deployment-run.is-failed { border-left-color: var(--danger, #c43f4b); }
.deployment-run.is-running { border-left-color: var(--accent); }
.deployment-run-head { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
.deployment-run-state { font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: .035em; color: var(--accent); }
.deployment-run.is-ok .deployment-run-state { color: var(--success, #20875d); }
.deployment-run.is-failed .deployment-run-state { color: var(--danger, #c43f4b); }
.deployment-run time, .deployment-run small { color: var(--text-muted); font-size: 8px; }
.deployment-run > strong { display: block; font-size: 12px; }
.deployment-run > p { margin: 3px 0; font-size: 10px; line-height: 1.4; }
.deployment-run-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.secondary-btn.compact { min-height: 29px; padding: 0 9px; font-size: 9px; }
.operations-instruction { display: flex; gap: 11px; padding: 13px; margin-top: 14px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); }
.operations-instruction > span { color: var(--accent); }
.operations-instruction strong, .operations-instruction p { display: block; margin: 0; }
@@ -7207,7 +7230,7 @@ button,input,select,textarea { max-width:100%; }
.operations-pending .secondary-btn { width: 100%; }
}
@media (max-width: 520px) {
.operation-inline-fields, .operations-extension-packages { grid-template-columns: minmax(0, 1fr); }
.operation-inline-fields, .operations-extension-packages, .deployment-production-overview { grid-template-columns: minmax(0, 1fr); }
.operations-backup-row { grid-template-columns: 32px minmax(0, 1fr); }
.operation-backup-actions { grid-column: 1 / -1; justify-content: flex-end; }
.operations-extension-client { grid-template-columns: 10px minmax(0, 1fr); }
@@ -7379,3 +7402,132 @@ body[data-user-role="publisher"] .danger-zone { display: none !important; }
/* 1.37.0 Security Dashboard Foundation */
.security-secret-list{display:flex;flex-wrap:wrap;gap:var(--vd-space-2,8px);margin-top:var(--vd-space-3,12px)}
.security-secret-list .status-badge{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Vendoo 1.42.0 — kontrollierte Testeinladungen */
.invitation-admin-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
.invitation-admin-list { display: grid; gap: 8px; }
.invitation-admin-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 11px 12px; border: 1px solid var(--border); border-left: 3px solid var(--text-muted); border-radius: 11px; background: var(--bg-card); }
.invitation-admin-row.is-pending { border-left-color: var(--accent); }
.invitation-admin-row strong, .invitation-admin-row span, .invitation-admin-row small { display: block; min-width: 0; overflow-wrap: anywhere; }
.invitation-admin-row strong { font-size: 12px; }
.invitation-admin-row span { margin-top: 2px; color: var(--text-muted); font-size: 10px; }
.invitation-admin-row small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }
.invitation-admin-actions { display: flex; gap: 6px; }
@media (max-width: 620px) {
.invitation-admin-row { grid-template-columns: minmax(0, 1fr); }
.invitation-admin-actions { justify-content: flex-end; }
}
/* ==========================================================================
Vendoo 1.43.0 Module-aware Listing Studio
========================================================================== */
.generator-studio-page {
--generator-gap: clamp(14px, 1.4vw, 22px);
padding-bottom: 36px;
}
.generator-heading {
margin-bottom: 12px;
padding: 0 2px;
}
.generator-heading h2 { font-size: clamp(24px, 2.2vw, 34px); }
.generator-heading > div:first-child > p:last-child { max-width: 720px; font-size: 13px; line-height: 1.55; }
.generator-commandbar {
position: sticky;
top: calc(var(--vd-topbar-height) + 8px);
z-index: 34;
min-height: 72px;
margin-bottom: var(--generator-gap);
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
border: 1px solid var(--vd-line);
border-radius: var(--vd-radius-md, 14px);
background: color-mix(in srgb, var(--vd-surface-raised) 92%, transparent);
box-shadow: 0 12px 34px color-mix(in srgb, var(--vd-ink) 9%, transparent);
backdrop-filter: blur(18px) saturate(1.15);
}
.generator-command-progress { min-width: 0; display: flex; align-items: center; gap: 18px; }
.generator-command-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
.generator-command-actions .vd-button { min-height: 44px; padding-inline: 18px; font-weight: 760; }
.generator-save-action { min-width: 148px; border-color: var(--vd-brand); color: var(--vd-brand); background: var(--vd-surface-raised); }
.generator-save-action:not(:disabled):hover { background: var(--vd-brand-soft); }
.generator-commandbar .generator-readiness { min-width: 230px; grid-template-columns: 11px minmax(0, 1fr); }
.generator-commandbar .generator-readiness > span { width: 9px; height: 9px; box-shadow: 0 0 0 5px color-mix(in srgb, var(--vd-warning) 14%, transparent); }
.generator-commandbar .generator-readiness.is-ready > span { box-shadow: 0 0 0 5px color-mix(in srgb, var(--vd-success) 14%, transparent); }
.generator-commandbar .generator-readiness strong { font-size: 12px; }
.generator-commandbar .generator-readiness small { margin-top: 3px; font-size: 10px; }
.generator-studio-layout {
grid-template-columns: minmax(320px, .88fr) minmax(420px, 1.12fr);
gap: var(--generator-gap);
}
.generator-panel {
border-radius: var(--vd-radius-md, 14px);
box-shadow: 0 8px 26px color-mix(in srgb, var(--vd-ink) 5%, transparent);
}
.generator-panel-header { min-height: 76px; padding: 16px 18px; }
.generator-panel-header h3 { font-size: 15px; }
.generator-panel-header p { margin-top: 4px; font-size: 11px; line-height: 1.45; }
.generator-panel-index { width: 34px; height: 34px; font-size: 10px; }
.generator-count, .generator-beta, .generator-preview-status { min-height: 26px; padding: 5px 10px; font-size: 10px; }
.generator-photo-stage { margin: 16px 16px 12px; border-radius: 12px; }
.generator-dropzone { margin-inline: 16px; min-height: 108px; padding: 16px; border-radius: 12px; }
.generator-dropzone p { font-size: 11px; }
.generator-dropzone p strong { font-size: 13px; }
.generator-dropzone small { font-size: 10px; }
.generator-photo-strip, .generator-image-tools { margin-inline: 16px; }
.generator-photo-quality { margin: 14px 16px 4px; padding: 14px; border-radius: 12px; }
.generator-photo-quality-head strong { font-size: 12px; }
.generator-photo-quality li { font-size: 10px; }
.generator-config-stack { padding: 14px; gap: 11px; }
.generator-config-card, .generator-notes-card { padding: 14px; border-radius: 12px; }
.generator-config-copy label, .generator-notes-head label { font-size: 12px; }
.generator-config-copy span, .generator-notes-head span { font-size: 10px; }
.generator-choice-group .toggle-btn { min-height: 34px; padding-inline: 12px; font-size: 10px; }
.generator-config-card select { min-height: 40px; font-size: 11px; }
.generator-notes-card textarea { min-height: 118px; font-size: 11px; }
.generator-tone-row span { font-size: 9px; }
.generator-loading { margin: 0 14px 14px; }
.generator-action-zone { display: none; }
.generator-preview-panel {
grid-column: 1 / -1;
position: static;
max-height: none;
overflow: visible;
}
.generator-preview-empty { min-height: 300px; }
.generator-result { padding: 18px; }
.generator-result .result-input { min-height: 42px; font-size: 13px; }
.generator-result .ql-container.ql-snow { min-height: 280px; font-size: 13px; }
.generator-meta-section .meta-input { min-height: 38px; font-size: 11px; }
.generator-result-actions {
position: static;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 14px;
color: var(--vd-ink-muted);
font-size: 10px;
}
.generator-result-actions button { min-width: 140px; }
[data-module].hidden, [data-module-panel].hidden { display: none !important; }
@media (max-width: 1100px) {
.generator-commandbar { align-items: stretch; flex-direction: column; }
.generator-command-progress, .generator-command-actions { width: 100%; justify-content: space-between; }
.generator-studio-layout { grid-template-columns: 1fr; }
.generator-preview-panel { grid-column: auto; }
}
@media (max-width: 720px) {
.generator-commandbar { top: calc(var(--vd-topbar-height) + 4px); padding: 10px; }
.generator-command-progress { align-items: stretch; flex-direction: column; gap: 10px; }
.generator-commandbar .generator-readiness { min-width: 0; }
.generator-command-actions { display: grid; grid-template-columns: 1fr 1fr; }
.generator-command-actions .vd-button { min-width: 0; padding-inline: 10px; }
}
@media (max-width: 480px) {
.generator-command-actions { grid-template-columns: 1fr; }
.generator-step { font-size: 9px; }
}
File diff suppressed because it is too large Load Diff
+3 -210
View File
@@ -1,210 +1,3 @@
# Vendoo 1.41.2 kontrollierter GitHub-Erstimport
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$RepoUrl = 'https://github.com/Masterluke77/vendoo.git'
$PullRequestUrl = 'https://github.com/Masterluke77/vendoo/pull/18'
$Branch = 'release/1.41.2-git-ignore-boundary-hotfix'
$WorkRoot = Join-Path $env:TEMP ('vendoo-github-deploy-' + [Guid]::NewGuid().ToString('N'))
$CloneDir = Join-Path $WorkRoot 'repo'
function Info($Text) { Write-Host "[INFO] $Text" -ForegroundColor Cyan }
function Ok($Text) { Write-Host "[OK] $Text" -ForegroundColor Green }
function Warn($Text) { Write-Host "[WARN] $Text" -ForegroundColor Yellow }
function Fail($Text) { Write-Host "[FEHLER] $Text" -ForegroundColor Red }
function Test-Command([string]$Name) {
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
}
function Ensure-Git {
if (Test-Command 'git.exe') { return }
Warn 'Git für Windows wurde nicht gefunden.'
if (Test-Command 'winget.exe') {
$answer = Read-Host 'Git jetzt automatisch über winget installieren? (ja/nein)'
if ($answer -eq 'ja') {
& winget.exe install --id Git.Git --exact --source winget --accept-package-agreements --accept-source-agreements
if ($LASTEXITCODE -ne 0) { throw 'Git-Installation über winget ist fehlgeschlagen.' }
$env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User')
}
}
if (-not (Test-Command 'git.exe')) { throw 'Git für Windows wird benötigt. Starte den Assistenten nach der Git-Installation erneut.' }
}
function Invoke-Git([string[]]$Arguments, [string]$WorkingDirectory = $CloneDir) {
Push-Location $WorkingDirectory
try {
& git.exe @Arguments
if ($LASTEXITCODE -ne 0) { throw "git $($Arguments -join ' ') fehlgeschlagen (Exit-Code $LASTEXITCODE)." }
} finally { Pop-Location }
}
function Copy-ProjectClean {
if (-not (Test-Command 'node.exe')) {
throw 'Node.js ist für den sicheren GitHub-Import erforderlich. Bitte zuerst über setup.bat installieren oder aktualisieren.'
}
Info 'Bereite den bereinigten Git-Arbeitsordner mit dem plattformübergreifenden Staging-Builder vor ...'
$builder = Join-Path $Root 'tools\prepare-git-staging.mjs'
$result = Invoke-NodeProcess -ScriptPath $builder -Arguments @($Root, $CloneDir) -WorkingDirectory $Root -Prefix 'Staging'
if ($result.ExitCode -ne 0) {
$result.Lines | ForEach-Object { Write-Host $_ -ForegroundColor Red }
throw 'Der sichere Git-Staging-Aufbau ist fehlgeschlagen.'
}
$result.Lines | ForEach-Object { Write-Host $_ }
}
function Invoke-NodeProcess {
param(
[Parameter(Mandatory=$true)][string]$ScriptPath,
[string[]]$Arguments = @(),
[string]$WorkingDirectory = $CloneDir,
[string]$Prefix = 'Node'
)
$id = [Guid]::NewGuid().ToString('N')
$stdoutPath = Join-Path $WorkRoot ("$Prefix-$id-stdout.txt")
$stderrPath = Join-Path $WorkRoot ("$Prefix-$id-stderr.txt")
$nodePath = (Get-Command node.exe -ErrorAction Stop).Source
$allArguments = @($ScriptPath) + $Arguments
$argumentLine = ($allArguments | ForEach-Object { '"' + ([string]$_).Replace('"','\"') + '"' }) -join ' '
$process = Start-Process -FilePath $nodePath `
-ArgumentList $argumentLine `
-WorkingDirectory $WorkingDirectory `
-NoNewWindow `
-Wait `
-PassThru `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
$lines = @()
if (Test-Path -LiteralPath $stdoutPath) { $lines += @(Get-Content -LiteralPath $stdoutPath -Encoding UTF8 -ErrorAction SilentlyContinue) }
if (Test-Path -LiteralPath $stderrPath) { $lines += @(Get-Content -LiteralPath $stderrPath -Encoding UTF8 -ErrorAction SilentlyContinue) }
return [pscustomobject]@{ ExitCode = [int]$process.ExitCode; Lines = $lines }
}
function Run-SafetyGate {
$ReportDir = Join-Path $Root 'logs'
$ReportPath = Join-Path $ReportDir 'github-deploy-safety-report.txt'
if (-not (Test-Command 'node.exe')) {
throw 'Node.js ist für den sicheren GitHub-Import erforderlich. Bitte zuerst über setup.bat installieren oder aktualisieren.'
}
Info 'Führe das Vendoo Git-Sicherheitsgate über den vollständigen Staging-Arbeitsbaum aus ...'
$result = Invoke-NodeProcess -ScriptPath 'tools/verify-git-safety.mjs' -WorkingDirectory $CloneDir -Prefix 'SafetyGate'
foreach ($line in $result.Lines) {
if ($line -match '^Git-Sicherheitsgate fehlgeschlagen|^- ') { Write-Host $line -ForegroundColor Red }
elseif ($line -match '^\[WARN\]') { Write-Host $line -ForegroundColor Yellow }
else { Write-Host $line }
}
if ($result.ExitCode -ne 0) {
New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null
@(
'Vendoo Git-Sicherheitsgate vollständiger Fehlerbericht',
('Zeitpunkt: ' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')),
('Projekt: ' + $Root),
('Staging: ' + $CloneDir),
'',
$result.Lines
) | Set-Content -LiteralPath $ReportPath -Encoding UTF8
Fail "Das Gate hat $($result.Lines.Count) Diagnosezeilen erzeugt. Bericht: $ReportPath"
try { Start-Process notepad.exe -ArgumentList ('"' + $ReportPath + '"') | Out-Null } catch {}
throw 'Git-Sicherheitsgate ist fehlgeschlagen. Die konkreten Treffer wurden oben angezeigt und im Bericht gespeichert.'
}
Remove-Item -LiteralPath $ReportPath -Force -ErrorAction SilentlyContinue
$forbidden = Get-ChildItem -LiteralPath $CloneDir -Recurse -Force -File -ErrorAction SilentlyContinue | Where-Object {
($_.Name -match '^\.env(?:\..+)?$' -and $_.Name -notin @('.env.example','.env.docker.example')) -or
$_.Extension -in @('.db','.sqlite','.sqlite3','.log','.key','.pem','.pfx','.p12')
}
if ($forbidden) {
throw ('Verbotene Dateien im Git-Staging: ' + (($forbidden | Select-Object -First 20 -ExpandProperty FullName) -join ', '))
}
Ok 'Git-Sicherheitsgate erfolgreich: keine lokalen Secrets oder Laufzeitdaten im Import.'
}
try {
Write-Host ''
Write-Host 'Vendoo 1.41.2 GitHub Deploy Assistent' -ForegroundColor Green
Write-Host 'Repository: Masterluke77/vendoo (privat)' -ForegroundColor DarkGray
Write-Host ''
Ensure-Git
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
Info 'Klone die vorhandene GitHub-Baseline ...'
& git.exe clone $RepoUrl $CloneDir
if ($LASTEXITCODE -ne 0) { throw 'Repository konnte nicht geklont werden. Die Git-Anmeldung öffnet sich normalerweise automatisch im Browser.' }
Push-Location $CloneDir
try {
& git.exe ls-remote --exit-code --heads origin $Branch 1>$null 2>$null
$branchExists = ($LASTEXITCODE -eq 0)
} finally { Pop-Location }
if ($branchExists) {
Invoke-Git @('fetch','origin',$Branch)
Invoke-Git @('switch','--track','-c',$Branch,("origin/" + $Branch))
} else {
Invoke-Git @('switch','-c',$Branch)
}
Copy-ProjectClean
Run-SafetyGate
Info 'Prüfe produktive Source-Dateien und Git-Ignore-Grenzen im Staging ...'
$sourceGate = Invoke-NodeProcess -ScriptPath 'tools/verify-source-boundaries-1.41.2.mjs' -WorkingDirectory $CloneDir -Prefix 'SourceGate'
$sourceGate.Lines | ForEach-Object { Write-Host $_ }
if ($sourceGate.ExitCode -ne 0) { throw 'Produktive Source-Root-Prüfung ist fehlgeschlagen.' }
$ignoreGate = Invoke-NodeProcess -ScriptPath 'tools/verify-git-ignore-boundaries-1.41.2.mjs' -WorkingDirectory $CloneDir -Prefix 'IgnoreGate'
$ignoreGate.Lines | ForEach-Object { Write-Host $_ }
if ($ignoreGate.ExitCode -ne 0) { throw 'Git-Ignore-Grenzprüfung ist fehlgeschlagen.' }
Invoke-Git @('config','user.name','Markus Müller')
Invoke-Git @('config','user.email','163446896+Masterluke77@users.noreply.github.com')
Invoke-Git @('add','--all')
$requiredTracked = @(
'app/modules/listings/routes.mjs',
'app/modules/inventory/index.mjs',
'app/modules/inventory/repository.mjs',
'app/modules/inventory/routes.mjs',
'app/modules/sessions/index.mjs'
)
Push-Location $CloneDir
try {
$ignoredModules = @(& git.exe ls-files --others --ignored --exclude-standard -- app/modules)
if ($ignoredModules.Count -gt 0) { throw ('Produktive Moduldateien werden von .gitignore ausgeschlossen: ' + ($ignoredModules -join ', ')) }
foreach ($rel in $requiredTracked) {
if (-not (Test-Path -LiteralPath (Join-Path $CloneDir $rel))) { throw "Pflichtdatei fehlt im Git-Staging: $rel" }
& git.exe ls-files --error-unmatch -- $rel 1>$null 2>$null
if ($LASTEXITCODE -ne 0) { throw "Pflichtdatei wurde von Git nicht getrackt: $rel" }
}
} finally { Pop-Location }
Ok 'Produktive Moduldateien sind vorhanden und werden von Git getrackt.'
Push-Location $CloneDir
try {
$status = & git.exe status --short
if (-not $status) { Warn 'Es gibt keine Änderungen gegenüber GitHub.'; exit 0 }
Write-Host ''
Write-Host $status
Write-Host ''
} finally { Pop-Location }
$confirm = Read-Host 'Geprüften Vendoo-Stand als Release-Branch zu GitHub übertragen? Zum Fortfahren PUSH eingeben'
if ($confirm -cne 'PUSH') { Warn 'Deployment abgebrochen. Auf GitHub wurde nichts verändert.'; exit 0 }
Invoke-Git @('commit','-m','fix: track native source modules with root-anchored runtime ignores')
Invoke-Git @('push','--set-upstream','origin',$Branch)
Ok "Branch $Branch wurde erfolgreich übertragen."
Info 'Öffne jetzt Draft Pull Request #18 im Browser ...'
Start-Process $PullRequestUrl
Write-Host ''
Write-Host 'Nächster Schritt im Browser: Pull Request #18 und CI prüfen. Erst nach grüner CI nach main mergen.' -ForegroundColor Yellow
} catch {
Fail $_.Exception.Message
exit 1
} finally {
if (Test-Path -LiteralPath $WorkRoot) {
Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
# Kompatibilitaetsstarter fuer alte Verknuepfungen.
& (Join-Path $PSScriptRoot 'update-vendoo.ps1')
exit $LASTEXITCODE
+23
View File
@@ -0,0 +1,23 @@
[CmdletBinding()]
param([switch]$SkipProductionDeploy)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
$ui = Join-Path $PSScriptRoot 'updater\Vendoo.Updater.UI.ps1'
try {
& $ui @PSBoundParameters
} catch {
try {
Add-Type -AssemblyName PresentationFramework
[System.Windows.MessageBox]::Show(
"Der Vendoo-Updater konnte nicht gestartet werden.`n`n$($_.Exception.Message)`n`nBitte die Datei logs\updater-bootstrap.log mitsenden.",
'Vendoo Updater Startfehler',
[System.Windows.MessageBoxButton]::OK,
[System.Windows.MessageBoxImage]::Error
) | Out-Null
} catch {}
$packageRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..') -ErrorAction Stop).ProviderPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar,[System.IO.Path]::AltDirectorySeparatorChar)
$fallback = Join-Path $packageRoot 'logs\updater-bootstrap.log'
New-Item -ItemType Directory -Path (Split-Path -Parent $fallback) -Force | Out-Null
"$(Get-Date -Format o) $($_.Exception.ToString())" | Add-Content -LiteralPath $fallback -Encoding UTF8
}
+14
View File
@@ -0,0 +1,14 @@
[CmdletBinding()]
param(
[string]$StatusFile = '',
[string]$EventFile = '',
[string]$InputFile = '',
[string]$CancelFile = '',
[string]$SessionFile = '',
[switch]$SkipProductionDeploy,
[switch]$SelfTest,
[string]$SelfTestFailAt = ''
)
$worker = Join-Path $PSScriptRoot 'updater\Vendoo.Updater.Worker.ps1'
& $worker @PSBoundParameters
exit $LASTEXITCODE
+140
View File
@@ -0,0 +1,140 @@
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$CoreScript,
[Parameter(Mandatory=$true)][string]$StatusFile,
[Parameter(Mandatory=$true)][string]$EventFile,
[Parameter(Mandatory=$true)][string]$InputFile,
[Parameter(Mandatory=$true)][string]$CancelFile,
[Parameter(Mandatory=$true)][string]$SessionFile,
[Parameter(Mandatory=$true)][string]$LogFile,
[switch]$SkipProductionDeploy,
[switch]$SelfTest,
[string]$SelfTestFailAt = ''
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
$TargetVersion = '1.43.0'
$UpdaterVersion = '3.3.0'
function Get-DefaultSteps {
return @(
[ordered]@{id='package';title='Paket prüfen';state='pending'},
[ordered]@{id='tools';title='System prüfen';state='pending'},
[ordered]@{id='github';title='GitHub verbinden';state='pending'},
[ordered]@{id='production';title='Produktion vorbereiten';state='pending'},
[ordered]@{id='workspace';title='Arbeitskopie erstellen';state='pending'},
[ordered]@{id='tests';title='Vollständig testen';state='pending'},
[ordered]@{id='pullrequest';title='Pull Request erstellen';state='pending'},
[ordered]@{id='merge';title='Sicher mergen';state='pending'},
[ordered]@{id='deploy';title='Coolify deployen';state='pending'},
[ordered]@{id='verify';title='Produktion bestätigen';state='pending'}
)
}
function Append-HostLog([string]$Level,[string]$Message) {
try {
$parent = Split-Path -Parent $LogFile
if ($parent) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') [$Level] $Message$([Environment]::NewLine)"
[IO.File]::AppendAllText($LogFile,$line,(New-Object Text.UTF8Encoding($false)))
} catch {}
}
function Write-HostStatus([string]$State,[string]$Phase,[string]$Detail,[string]$ErrorText='',[string]$Remediation='') {
try {
$percent = 1
if ($State -in @('failed','cancelled','success')) { $percent = 100 }
$payload = [ordered]@{
version=$TargetVersion
updaterVersion=$UpdaterVersion
percent=$percent
phase=$Phase
detail=$Detail
state=$State
error=$ErrorText
remediation=$Remediation
updatedAt=(Get-Date).ToString('o')
logFile=$LogFile
sessionFile=$SessionFile
steps=(Get-DefaultSteps)
request=$null
} | ConvertTo-Json -Depth 8
$parent = Split-Path -Parent $StatusFile
if ($parent) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
$tmp = "$StatusFile.$PID.host.tmp"
[IO.File]::WriteAllText($tmp,$payload,(New-Object Text.UTF8Encoding($false)))
Move-Item -LiteralPath $tmp -Destination $StatusFile -Force
} catch {
Append-HostLog 'HOST-ERROR' "Status konnte nicht geschrieben werden: $($_.Exception.ToString())"
}
}
try {
foreach($path in @($StatusFile,$EventFile,$InputFile,$CancelFile,$SessionFile,$LogFile)) {
$parent = Split-Path -Parent $path
if ($parent) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
}
if (-not (Test-Path -LiteralPath $CoreScript -PathType Leaf)) {
throw "Worker-Core fehlt: $CoreScript"
}
Append-HostLog 'HOST' "Worker-Host $UpdaterVersion gestartet. Core: $CoreScript"
Write-HostStatus 'running' 'PowerShell-Parserprüfung' 'Der Worker-Kern wird vor der Ausführung mit dem echten PowerShell-Parser geprüft.'
$parserTokens = $null
$parserErrors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($CoreScript,[ref]$parserTokens,[ref]$parserErrors)
if ($parserErrors -and $parserErrors.Count -gt 0) {
$details = @($parserErrors | ForEach-Object {
"{0}:{1}:{2} {3}" -f $_.Extent.File,$_.Extent.StartLineNumber,$_.Extent.StartColumnNumber,$_.Message
}) -join [Environment]::NewLine
throw "PowerShell-Parserprüfung des Worker-Kerns fehlgeschlagen.$([Environment]::NewLine)$details"
}
Append-HostLog 'HOST' 'PowerShell-Parserprüfung des Worker-Kerns erfolgreich.'
Write-HostStatus 'running' 'Worker wird gestartet' 'Der geprüfte Worker-Kern wird geladen.'
$invokeParams = @{
StatusFile=$StatusFile
EventFile=$EventFile
InputFile=$InputFile
CancelFile=$CancelFile
SessionFile=$SessionFile
LogFile=$LogFile
}
if ($SkipProductionDeploy) { $invokeParams.SkipProductionDeploy = $true }
if ($SelfTest) { $invokeParams.SelfTest = $true }
if ($SelfTestFailAt) { $invokeParams.SelfTestFailAt = $SelfTestFailAt }
& $CoreScript @invokeParams
$terminal = $false
$stateName = ''
if (Test-Path -LiteralPath $StatusFile) {
try {
$final = Get-Content -LiteralPath $StatusFile -Raw -Encoding UTF8 | ConvertFrom-Json
$stateName = [string]$final.state
$terminal = $stateName -in @('success','failed','cancelled')
} catch {
Append-HostLog 'HOST-ERROR' "Abschlussstatus ist nicht lesbar: $($_.Exception.Message)"
}
}
if (-not $terminal) {
$message = 'Der Update-Kern wurde beendet, ohne einen terminalen Status zu schreiben.'
Append-HostLog 'HOST-ERROR' $message
Write-HostStatus 'failed' 'Update-Kern unvollständig beendet' $message $message 'Das Protokoll enthält jetzt die vollständige Start- und Worker-Diagnose. Es wurden keine stillen Fehler verworfen.'
} else {
Append-HostLog 'HOST' "Worker-Core regulär beendet. Status: $stateName"
}
exit 0
}
catch {
$full = $_.Exception.ToString()
$message = $_.Exception.Message
Append-HostLog 'HOST-FATAL' $full
Write-HostStatus 'failed' 'Worker konnte nicht initialisiert werden' $message $full 'Der Fehler entstand vor oder beim Laden des Worker-Kerns. Protokoll öffnen zeigt die vollständige PowerShell-Ausnahme einschließlich Datei und Zeile.'
try { [Console]::Error.WriteLine($full) } catch {}
Start-Sleep -Milliseconds 350
exit 1
}
@@ -0,0 +1,90 @@
[CmdletBinding()]
param()
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
$UpdaterVersion = '3.3.0'
function Get-CanonicalPackageRoot {
$candidate = Join-Path -Path $PSScriptRoot -ChildPath '..\..'
$resolved = Resolve-Path -LiteralPath $candidate -ErrorAction Stop
$fullPath = [System.IO.Path]::GetFullPath($resolved.ProviderPath)
if ([string]::IsNullOrWhiteSpace($fullPath)) {
throw 'Der kanonische Vendoo-Paketpfad ist leer.'
}
if (-not (Test-Path -LiteralPath $fullPath -PathType Container)) {
throw "Der kanonische Vendoo-Paketpfad existiert nicht: $fullPath"
}
return $fullPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar,[System.IO.Path]::AltDirectorySeparatorChar)
}
$PackageRoot = Get-CanonicalPackageRoot
$logDirectory = Join-Path -Path $PackageRoot -ChildPath 'logs'
$logFile = Join-Path -Path $logDirectory -ChildPath 'updater-preflight.log'
function Write-PreflightLog([string]$Level,[string]$Message) {
[System.IO.Directory]::CreateDirectory($logDirectory) | Out-Null
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') [$Level] $Message$([Environment]::NewLine)"
[System.IO.File]::AppendAllText($logFile,$line,(New-Object System.Text.UTF8Encoding($false)))
}
function Show-PreflightError([string]$Message) {
try {
Add-Type -AssemblyName PresentationFramework
[System.Windows.MessageBox]::Show(
"Der Vendoo Production Updater konnte die PowerShell-Startprüfung nicht bestehen.`n`n$Message`n`nProtokoll:`n$logFile",
'Vendoo Updater Startprüfung fehlgeschlagen',
[System.Windows.MessageBoxButton]::OK,
[System.Windows.MessageBoxImage]::Error
) | Out-Null
} catch {}
}
try {
Write-PreflightLog 'INFO' "Production Updater $UpdaterVersion kanonischer Paketpfad: $PackageRoot"
if ($PackageRoot.IndexOfAny([System.IO.Path]::GetInvalidPathChars()) -ge 0) {
throw "Der kanonische Paketpfad enthält unzulässige Zeichen: $PackageRoot"
}
$scripts = @(
(Join-Path $PackageRoot 'scripts\updater\Vendoo.Updater.Preflight.ps1'),
(Join-Path $PackageRoot 'scripts\update-vendoo-gui.ps1'),
(Join-Path $PackageRoot 'scripts\updater\Vendoo.Updater.UI.ps1'),
(Join-Path $PackageRoot 'scripts\updater\Vendoo.Updater.Host.ps1'),
(Join-Path $PackageRoot 'scripts\updater\Vendoo.Updater.Worker.ps1')
)
Write-PreflightLog 'INFO' 'Echte Windows-PowerShell-Parserprüfung gestartet.'
$allErrors = New-Object System.Collections.Generic.List[string]
foreach ($scriptPath in $scripts) {
if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) {
$allErrors.Add("Pflichtskript fehlt: $scriptPath")
continue
}
$tokens = $null
$errors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($scriptPath,[ref]$tokens,[ref]$errors)
if ($errors -and $errors.Count -gt 0) {
foreach ($parseError in $errors) {
$allErrors.Add(("{0}:{1}:{2} {3}" -f $parseError.Extent.File,$parseError.Extent.StartLineNumber,$parseError.Extent.StartColumnNumber,$parseError.Message))
}
} else {
Write-PreflightLog 'OK' "Parserprüfung erfolgreich: $scriptPath"
}
}
if ($allErrors.Count -gt 0) {
$message = @($allErrors) -join [Environment]::NewLine
Write-PreflightLog 'FATAL' $message
Show-PreflightError $message
exit 21
}
Write-PreflightLog 'OK' 'Alle Updater-Skripte wurden vom echten Windows-PowerShell-Parser akzeptiert.'
exit 0
} catch {
$message = $_.Exception.ToString()
try { Write-PreflightLog 'FATAL' $message } catch {}
Show-PreflightError $message
exit 22
}
+333
View File
@@ -0,0 +1,333 @@
[CmdletBinding()]
param([switch]$SkipProductionDeploy)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
Add-Type -AssemblyName System.Xaml
$PackageRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..') -ErrorAction Stop).ProviderPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar,[System.IO.Path]::AltDirectorySeparatorChar)
$TargetVersion = '1.43.0'
$UpdaterVersion = '3.3.0'
$WorkerScript = Join-Path $PSScriptRoot 'Vendoo.Updater.Worker.ps1'
$WorkerHostScript = Join-Path $PSScriptRoot 'Vendoo.Updater.Host.ps1'
$LocalRoot = Join-Path $env:LOCALAPPDATA 'Vendoo\Updater'
$RuntimeRoot = Join-Path $LocalRoot 'runtime'
$SessionRoot = Join-Path $LocalRoot 'sessions'
New-Item -ItemType Directory -Path $RuntimeRoot,$SessionRoot -Force | Out-Null
$createdNew = $false
$mutex = [System.Threading.Mutex]::new($true, 'Local\VendooProductionUpdater', [ref]$createdNew)
if (-not $createdNew) {
[System.Windows.MessageBox]::Show('Der Vendoo-Updater läuft bereits. Bitte das vorhandene Fenster verwenden.','Vendoo Updater',[System.Windows.MessageBoxButton]::OK,[System.Windows.MessageBoxImage]::Information) | Out-Null
return
}
$xaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Vendoo Production Updater" Width="1080" Height="720" MinWidth="960" MinHeight="650"
WindowStartupLocation="CenterScreen" Background="#0F1115" Foreground="#EEF1F5"
FontFamily="Segoe UI" ResizeMode="CanResize">
<Window.Resources>
<SolidColorBrush x:Key="Panel" Color="#171A20"/>
<SolidColorBrush x:Key="Panel2" Color="#1D2129"/>
<SolidColorBrush x:Key="Border" Color="#2B313C"/>
<SolidColorBrush x:Key="Muted" Color="#9AA4B2"/>
<SolidColorBrush x:Key="Accent" Color="#4F8CFF"/>
<Style TargetType="Button">
<Setter Property="Foreground" Value="#EEF1F5"/><Setter Property="Background" Value="#252B35"/>
<Setter Property="BorderBrush" Value="#343C49"/><Setter Property="Padding" Value="16,9"/>
<Setter Property="FontWeight" Value="SemiBold"/><Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1" CornerRadius="7" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border><ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter Property="Background" Value="#303744"/></Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter Property="Opacity" Value="0.42"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate></Setter.Value></Setter>
</Style>
<Style x:Key="PrimaryButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="#2E6FE8"/><Setter Property="BorderBrush" Value="#4F8CFF"/>
</Style>
</Window.Resources>
<Grid>
<Grid.RowDefinitions><RowDefinition Height="76"/><RowDefinition Height="*"/><RowDefinition Height="68"/></Grid.RowDefinitions>
<Border Grid.Row="0" Background="#14171C" BorderBrush="#252B35" BorderThickness="0,0,0,1">
<Grid Margin="28,0"><Grid.ColumnDefinitions><ColumnDefinition/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<StackPanel VerticalAlignment="Center">
<TextBlock FontSize="23" FontWeight="SemiBold" Text="Vendoo Production Updater"/>
<TextBlock Foreground="{StaticResource Muted}" FontSize="12" Margin="0,4,0,0" Text="Sicherer Release-, GitHub- und Coolify-Ablauf"/>
</StackPanel>
<Border Grid.Column="1" Background="#202631" BorderBrush="#343C49" BorderThickness="1" CornerRadius="8" Padding="14,8" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal"><TextBlock Text="ZIEL "/><TextBlock FontWeight="Bold" Text="1.43.0"/></StackPanel>
</Border>
</Grid>
</Border>
<Grid Grid.Row="1" Margin="24,22,24,16">
<Grid.ColumnDefinitions><ColumnDefinition Width="300"/><ColumnDefinition Width="18"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="10" Padding="18">
<Grid><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
<StackPanel><TextBlock FontSize="15" FontWeight="SemiBold" Text="Update-Ablauf"/><TextBlock Foreground="{StaticResource Muted}" FontSize="11" Margin="0,4,0,14" Text="Jeder Schritt wird einzeln geprüft."/></StackPanel>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"><StackPanel x:Name="StepsPanel"/></ScrollViewer>
<Border Grid.Row="2" Background="#12151A" BorderBrush="#272D37" BorderThickness="1" CornerRadius="8" Padding="12" Margin="0,14,0,0">
<StackPanel><TextBlock Foreground="{StaticResource Muted}" FontSize="10" Text="SICHERHEIT"/>
<TextBlock FontSize="11" Margin="0,5,0,0" TextWrapping="Wrap" Text="Kein Datenbank-Reset. Keine Uploads, Secrets oder /app/data-Dateien werden ersetzt."/>
</StackPanel>
</Border>
</Grid>
</Border>
<Grid Grid.Column="2"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions>
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="10" Padding="22">
<Grid><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
<Grid><Grid.ColumnDefinitions><ColumnDefinition/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<StackPanel><TextBlock x:Name="PhaseText" FontSize="20" FontWeight="SemiBold" Text="Updater wird vorbereitet"/>
<TextBlock x:Name="DetailText" Foreground="{StaticResource Muted}" Margin="0,7,20,0" TextWrapping="Wrap" Text="Die sichere Update-Umgebung wird initialisiert."/>
</StackPanel>
<Border x:Name="StatusBadge" Grid.Column="1" Background="#22314A" BorderBrush="#36598A" BorderThickness="1" CornerRadius="7" Padding="12,7" VerticalAlignment="Top">
<TextBlock x:Name="StatusText" FontWeight="Bold" FontSize="11" Text="START"/>
</Border>
</Grid>
<Grid Grid.Row="1" Margin="0,24,0,0"><Grid.ColumnDefinitions><ColumnDefinition/><ColumnDefinition Width="64"/></Grid.ColumnDefinitions>
<ProgressBar x:Name="ProgressBar" Height="10" Minimum="0" Maximum="100" Value="0" Foreground="{StaticResource Accent}" Background="#262C35" BorderThickness="0"/>
<TextBlock x:Name="PercentText" Grid.Column="1" HorizontalAlignment="Right" VerticalAlignment="Center" FontWeight="SemiBold" Text="0 %"/>
</Grid>
<Grid Grid.Row="2" Margin="0,13,0,0"><Grid.ColumnDefinitions><ColumnDefinition/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock x:Name="ActivityText" Foreground="{StaticResource Muted}" FontSize="11" Text="Laufzeit 00:00"/>
<TextBlock x:Name="HeartbeatText" Grid.Column="1" Foreground="{StaticResource Muted}" FontSize="11" Text="Status wird aufgebaut"/>
</Grid>
<Border x:Name="InputPanel" Grid.Row="3" Visibility="Collapsed" Background="#171E2A" BorderBrush="#35547E" BorderThickness="1" CornerRadius="8" Padding="15" Margin="0,18,0,0">
<Grid><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
<TextBlock x:Name="InputTitle" FontWeight="SemiBold" Text="Eingabe erforderlich"/>
<TextBlock x:Name="InputPrompt" Grid.Row="1" Foreground="{StaticResource Muted}" Margin="0,6,0,10" TextWrapping="Wrap"/>
<Grid Grid.Row="2"><Grid.ColumnDefinitions><ColumnDefinition/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<PasswordBox x:Name="SecretInput" Height="34" Padding="8" Background="#0F1217" Foreground="#EEF1F5" BorderBrush="#3A4657"/>
<Button x:Name="SubmitInputButton" Grid.Column="1" Style="{StaticResource PrimaryButton}" Margin="10,0,0,0" Content="Sicher übernehmen"/>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
<Border Grid.Row="1" x:Name="ErrorPanel" Visibility="Collapsed" Background="#24181B" BorderBrush="#7A3640" BorderThickness="1" CornerRadius="10" Padding="18" Margin="0,14,0,0">
<StackPanel><TextBlock x:Name="ErrorTitle" FontSize="15" FontWeight="SemiBold" Foreground="#FFB6BE" Text="Update gestoppt"/>
<TextBlock x:Name="ErrorText" Margin="0,7,0,0" TextWrapping="Wrap"/>
<TextBlock x:Name="RemediationText" Foreground="#D7A9AF" Margin="0,8,0,0" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<Border Grid.Row="2" Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="10" Margin="0,14,0,0">
<Grid><Grid.RowDefinitions><RowDefinition Height="42"/><RowDefinition Height="*"/></Grid.RowDefinitions>
<Grid Margin="16,0"><Grid.ColumnDefinitions><ColumnDefinition/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock VerticalAlignment="Center" FontWeight="SemiBold" Text="Technisches Protokoll"/>
<TextBlock x:Name="LogPathText" Grid.Column="1" VerticalAlignment="Center" Foreground="{StaticResource Muted}" FontSize="10" Text="wird vorbereitet"/>
</Grid>
<TextBox x:Name="LogBox" Grid.Row="1" Margin="12,0,12,12" IsReadOnly="True" AcceptsReturn="True" TextWrapping="NoWrap" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto" Background="#0D0F13" Foreground="#CDD4DE" BorderBrush="#292F38" FontFamily="Consolas" FontSize="11" Padding="10"/>
</Grid>
</Border>
</Grid>
</Grid>
<Border Grid.Row="2" Background="#14171C" BorderBrush="#252B35" BorderThickness="0,1,0,0">
<Grid Margin="24,0"><Grid.ColumnDefinitions><ColumnDefinition/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="OpenLogButton" Content="Protokoll öffnen" IsEnabled="False"/>
<Button x:Name="OpenRuntimeButton" Content="Diagnoseordner" Margin="10,0,0,0" IsEnabled="False"/>
<Button x:Name="CopyErrorButton" Content="Fehler kopieren" Margin="10,0,0,0" Visibility="Collapsed"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="CancelButton" Content="Update abbrechen"/>
<Button x:Name="RetryButton" Content="Erneut versuchen" Margin="10,0,0,0" Visibility="Collapsed"/>
<Button x:Name="CloseButton" Style="{StaticResource PrimaryButton}" Content="Schließen" Margin="10,0,0,0" IsEnabled="False"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>
'@
$reader = [System.Xml.XmlNodeReader]::new([xml]$xaml)
$window = [System.Windows.Markup.XamlReader]::Load($reader)
$names = @('StepsPanel','PhaseText','DetailText','StatusBadge','StatusText','ProgressBar','PercentText','ActivityText','HeartbeatText','InputPanel','InputTitle','InputPrompt','SecretInput','SubmitInputButton','ErrorPanel','ErrorTitle','ErrorText','RemediationText','LogBox','LogPathText','OpenLogButton','OpenRuntimeButton','CopyErrorButton','CancelButton','RetryButton','CloseButton')
foreach ($name in $names) { Set-Variable -Name $name -Value $window.FindName($name) -Scope Script }
$steps = @(
@{id='package'; title='Paket prüfen'}, @{id='tools'; title='System prüfen'}, @{id='github'; title='GitHub verbinden'},
@{id='production'; title='Produktion vorbereiten'}, @{id='workspace'; title='Arbeitskopie erstellen'}, @{id='tests'; title='Vollständig testen'},
@{id='pullrequest'; title='Pull Request erstellen'}, @{id='merge'; title='Sicher mergen'}, @{id='deploy'; title='Coolify deployen'}, @{id='verify'; title='Produktion bestätigen'}
)
$script:StepControls = @{}
foreach ($step in $steps) {
$border = New-Object System.Windows.Controls.Border
$border.CornerRadius = [System.Windows.CornerRadius]::new(6); $border.Padding = [System.Windows.Thickness]::new(9,8,9,8); $border.Margin = [System.Windows.Thickness]::new(0,0,0,4); $border.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#171A20')
$grid = New-Object System.Windows.Controls.Grid
$c1=New-Object System.Windows.Controls.ColumnDefinition; $c1.Width=[System.Windows.GridLength]::new(28); $grid.ColumnDefinitions.Add($c1)
$c2=New-Object System.Windows.Controls.ColumnDefinition; $c2.Width=[System.Windows.GridLength]::new(1,[System.Windows.GridUnitType]::Star); $grid.ColumnDefinitions.Add($c2)
$icon=New-Object System.Windows.Controls.TextBlock; $icon.Text='○'; $icon.Foreground='#6E7886'; $icon.FontSize=16; $icon.VerticalAlignment='Center'
$label=New-Object System.Windows.Controls.TextBlock; $label.Text=$step.title; $label.Foreground='#AAB3BF'; $label.VerticalAlignment='Center'; [System.Windows.Controls.Grid]::SetColumn($label,1)
$grid.Children.Add($icon)|Out-Null; $grid.Children.Add($label)|Out-Null; $border.Child=$grid; $StepsPanel.Children.Add($border)|Out-Null
$script:StepControls[$step.id]=@{Border=$border;Icon=$icon;Label=$label}
}
$script:Worker=$null; $script:Timer=$null; $script:Terminal=$false; $script:ClosingAllowed=$false; $script:LastStatusAt=Get-Date; $script:StartedAt=Get-Date
$script:StatusFile=''; $script:EventFile=''; $script:InputFile=''; $script:CancelFile=''; $script:SessionFile=Join-Path $SessionRoot "$TargetVersion.json"; $script:LogFile=''; $script:StdoutFile=''; $script:StderrFile=''; $script:EventOffset=0; $script:LastRequestId=''; $script:LastError=''
function Set-Badge([string]$state) {
switch ($state) {
'success' { $StatusText.Text='ERFOLGREICH'; $StatusBadge.Background='#173322'; $StatusBadge.BorderBrush='#2C7A4B'; $StatusText.Foreground='#8FE0AC' }
'failed' { $StatusText.Text='GESTOPPT'; $StatusBadge.Background='#351B20'; $StatusBadge.BorderBrush='#8B3B48'; $StatusText.Foreground='#FFB6BE' }
'input' { $StatusText.Text='EINGABE'; $StatusBadge.Background='#392E17'; $StatusBadge.BorderBrush='#8C6D27'; $StatusText.Foreground='#F4D47B' }
'waiting' { $StatusText.Text='WARTE'; $StatusBadge.Background='#1B2D3B'; $StatusBadge.BorderBrush='#35627D'; $StatusText.Foreground='#91CBE9' }
'cancelling' { $StatusText.Text='ABBRUCH'; $StatusBadge.Background='#392E17'; $StatusBadge.BorderBrush='#8C6D27'; $StatusText.Foreground='#F4D47B' }
default { $StatusText.Text='LÄUFT'; $StatusBadge.Background='#1C2D49'; $StatusBadge.BorderBrush='#36598A'; $StatusText.Foreground='#9FC0FF' }
}
}
function Update-Steps($status) {
if (-not $status.steps) { return }
foreach ($s in @($status.steps)) {
if (-not $script:StepControls.ContainsKey([string]$s.id)) { continue }
$c=$script:StepControls[[string]$s.id]
switch ([string]$s.state) {
'done' { $c.Icon.Text='●'; $c.Icon.Foreground='#62D28B'; $c.Label.Foreground='#EEF1F5'; $c.Border.Background='#18251E' }
'active' { $c.Icon.Text='●'; $c.Icon.Foreground='#6EA1FF'; $c.Label.Foreground='#FFFFFF'; $c.Label.FontWeight='SemiBold'; $c.Border.Background='#1A2435' }
'failed' { $c.Icon.Text='×'; $c.Icon.Foreground='#FF7A88'; $c.Label.Foreground='#FFD2D7'; $c.Border.Background='#2A191D' }
'skipped' { $c.Icon.Text=''; $c.Icon.Foreground='#77808D'; $c.Label.Foreground='#77808D'; $c.Border.Background='#171A20' }
default { $c.Icon.Text='○'; $c.Icon.Foreground='#6E7886'; $c.Label.Foreground='#AAB3BF'; $c.Label.FontWeight='Normal'; $c.Border.Background='#171A20' }
}
}
}
function Write-InitialStatus {
$steps=@(
@{id='package';title='Paket prüfen';state='pending'},@{id='tools';title='System prüfen';state='pending'},@{id='github';title='GitHub verbinden';state='pending'},
@{id='production';title='Produktion vorbereiten';state='pending'},@{id='workspace';title='Arbeitskopie erstellen';state='pending'},@{id='tests';title='Vollständig testen';state='pending'},
@{id='pullrequest';title='Pull Request erstellen';state='pending'},@{id='merge';title='Sicher mergen';state='pending'},@{id='deploy';title='Coolify deployen';state='pending'},@{id='verify';title='Produktion bestätigen';state='pending'}
)
$payload=[ordered]@{version=$TargetVersion;updaterVersion=$UpdaterVersion;percent=0;phase='Updater wird vorbereitet';detail='Die überwachte Update-Umgebung wird initialisiert.';state='running';error='';remediation='';updatedAt=(Get-Date).ToString('o');logFile=$script:LogFile;sessionFile=$script:SessionFile;steps=$steps;request=$null}|ConvertTo-Json -Depth 8
[IO.File]::WriteAllText($script:StatusFile,$payload,(New-Object Text.UTF8Encoding($false)))
}
function Get-WorkerExitDiagnostics {
$parts=New-Object Collections.Generic.List[string]
try {
$stderr=[string]$script:Worker.StandardError.ReadToEnd()
if($stderr){$stderr|Set-Content -LiteralPath $script:StderrFile -Encoding UTF8;$parts.Add("PowerShell-Fehlerkanal:`n$stderr")}
} catch {}
try {
$stdout=[string]$script:Worker.StandardOutput.ReadToEnd()
if($stdout){$stdout|Set-Content -LiteralPath $script:StdoutFile -Encoding UTF8;$parts.Add("PowerShell-Ausgabe:`n$stdout")}
} catch {}
if(Test-Path -LiteralPath $script:LogFile){
try{
$tail=@(Get-Content -LiteralPath $script:LogFile -Encoding UTF8 -Tail 35) -join [Environment]::NewLine
if($tail){$parts.Add("Letzte Protokollzeilen:`n$tail")}
}catch{}
}
if($parts.Count -eq 0){$parts.Add('Der Prozess lieferte keinen Fehlertext. Die Bootstrap-Dateien im Laufzeitordner wurden dennoch erhalten.')}
return ($parts -join ([Environment]::NewLine+[Environment]::NewLine))
}
function Start-Worker {
$stamp=Get-Date -Format 'yyyyMMdd-HHmmss-fff'; $run=Join-Path $RuntimeRoot $stamp; New-Item -ItemType Directory -Path $run -Force|Out-Null
$script:StatusFile=Join-Path $run 'status.json'; $script:EventFile=Join-Path $run 'events.ndjson'; $script:InputFile=Join-Path $run 'input.json'; $script:CancelFile=Join-Path $run 'cancel.flag'
$script:LogFile=Join-Path $run 'updater.log'; $script:StdoutFile=Join-Path $run 'worker.stdout.log'; $script:StderrFile=Join-Path $run 'worker.stderr.log'
$script:EventOffset=0; $script:Terminal=$false; $script:ClosingAllowed=$false; $script:StartedAt=Get-Date; $script:LastStatusAt=Get-Date; $script:LastRequestId=''; $script:LastError=''
$ErrorPanel.Visibility='Collapsed'; $InputPanel.Visibility='Collapsed'; $CopyErrorButton.Visibility='Collapsed'; $RetryButton.Visibility='Collapsed'; $CloseButton.IsEnabled=$false; $CancelButton.IsEnabled=$true; $OpenLogButton.IsEnabled=$true; $LogBox.Clear()
$LogPathText.Text=[IO.Path]::GetFileName($script:LogFile)
[IO.File]::WriteAllText($script:LogFile,"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') [UI] Laufzeitordner angelegt: $run$([Environment]::NewLine)",(New-Object Text.UTF8Encoding($false)))
$OpenRuntimeButton.IsEnabled=$true
foreach($entry in $script:StepControls.Values){$entry.Icon.Text='○';$entry.Icon.Foreground='#6E7886';$entry.Label.Foreground='#AAB3BF';$entry.Label.FontWeight='Normal';$entry.Border.Background='#171A20'}
Set-Badge 'running'; $PhaseText.Text='Updater wird vorbereitet'; $DetailText.Text='Die überwachte Update-Umgebung wird initialisiert.'; $ProgressBar.Value=0; $PercentText.Text='0 %'
Write-InitialStatus
$args=@('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"{0}"' -f $WorkerHostScript),'-CoreScript',('"{0}"' -f $WorkerScript),'-StatusFile',('"{0}"' -f $script:StatusFile),'-EventFile',('"{0}"' -f $script:EventFile),'-InputFile',('"{0}"' -f $script:InputFile),'-CancelFile',('"{0}"' -f $script:CancelFile),'-SessionFile',('"{0}"' -f $script:SessionFile),'-LogFile',('"{0}"' -f $script:LogFile))
if($SkipProductionDeploy){$args+='-SkipProductionDeploy'}
$psi=New-Object Diagnostics.ProcessStartInfo
$psi.FileName='powershell.exe'; $psi.Arguments=($args -join ' '); $psi.UseShellExecute=$false; $psi.CreateNoWindow=$true; $psi.WorkingDirectory=$PackageRoot
$psi.RedirectStandardOutput=$true; $psi.RedirectStandardError=$true
$script:Worker=New-Object Diagnostics.Process; $script:Worker.StartInfo=$psi
if(-not $script:Worker.Start()){throw 'Der überwachte Update-Prozess konnte nicht gestartet werden.'}
}
function Read-NewEvents {
if(-not (Test-Path -LiteralPath $script:EventFile)){return}
$stream=[IO.File]::Open($script:EventFile,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite)
try {
if($script:EventOffset -gt $stream.Length){$script:EventOffset=0}
$stream.Seek($script:EventOffset,[IO.SeekOrigin]::Begin)|Out-Null
$reader=New-Object IO.StreamReader($stream,[Text.Encoding]::UTF8,$true,4096,$true)
while(-not $reader.EndOfStream){
$line=$reader.ReadLine(); if(-not $line){continue}
try{$e=$line|ConvertFrom-Json; $display=('{0} {1,-7} {2}' -f ([DateTime]::Parse($e.at).ToString('HH:mm:ss')),[string]$e.level,[string]$e.message)}catch{$display=$line}
$LogBox.AppendText($display+[Environment]::NewLine); $LogBox.ScrollToEnd()
}
$script:EventOffset=$stream.Position; $reader.Dispose()
} finally {$stream.Dispose()}
}
function Apply-Status($s) {
$script:LastStatusAt=Get-Date
$PhaseText.Text=[string]$s.phase; $DetailText.Text=[string]$s.detail
$p=[Math]::Max(0,[Math]::Min(100,[int]$s.percent)); $ProgressBar.Value=$p; $PercentText.Text="$p %"
Set-Badge ([string]$s.state); Update-Steps $s
if($s.logFile){$script:LogFile=[string]$s.logFile;$LogPathText.Text=[IO.Path]::GetFileName($script:LogFile);$OpenLogButton.IsEnabled=$true}
if([string]$s.state -eq 'input' -and $s.request){
$requestId=[string]$s.request.id
if($requestId -ne $script:LastRequestId){$script:LastRequestId=$requestId;$SecretInput.Clear();$InputTitle.Text=[string]$s.request.title;$InputPrompt.Text=[string]$s.request.prompt;$InputPanel.Visibility='Visible';$SecretInput.Focus()}
} else {$InputPanel.Visibility='Collapsed'}
if([string]$s.state -in @('success','failed','cancelled')){
$script:Terminal=$true;$script:ClosingAllowed=$true;$CancelButton.IsEnabled=$false;$CloseButton.IsEnabled=$true
if([string]$s.state -eq 'failed'){
$script:LastError=[string]$s.error; $ErrorTitle.Text='Update sicher gestoppt';$ErrorText.Text=[string]$s.error;$RemediationText.Text=[string]$s.remediation;$ErrorPanel.Visibility='Visible';$CopyErrorButton.Visibility='Visible';$RetryButton.Visibility='Visible'
} elseif([string]$s.state -eq 'cancelled'){
$script:LastError='Das Update wurde durch den Benutzer abgebrochen.';$ErrorTitle.Text='Update abgebrochen';$ErrorText.Text=$script:LastError;$RemediationText.Text='Ein erneuter Start setzt den kontrollierten Ablauf fort.';$ErrorPanel.Visibility='Visible';$CopyErrorButton.Visibility='Visible';$RetryButton.Visibility='Visible'
}
}
}
$SubmitInputButton.Add_Click({
$value=$SecretInput.Password.Trim(); if(-not $value){[System.Windows.MessageBox]::Show('Bitte einen Wert eintragen.','Vendoo Updater',[System.Windows.MessageBoxButton]::OK,[System.Windows.MessageBoxImage]::Warning)|Out-Null;return}
@{requestId=$script:LastRequestId;value=$value;submittedAt=(Get-Date).ToString('o')}|ConvertTo-Json|Set-Content -LiteralPath $script:InputFile -Encoding UTF8
$SecretInput.Clear();$InputPanel.Visibility='Collapsed';$DetailText.Text='Eingabe wurde sicher übernommen und wird geprüft.'
})
$CancelButton.Add_Click({if($script:Worker -and -not $script:Worker.HasExited){New-Item -ItemType File -Path $script:CancelFile -Force|Out-Null;Set-Badge 'cancelling';$DetailText.Text='Der aktuelle Prozess wird kontrolliert beendet. Bitte kurz geöffnet lassen.';$CancelButton.IsEnabled=$false}})
$RetryButton.Add_Click({ try { Start-Worker } catch { $script:LastError=$_.Exception.Message; $ErrorText.Text=$script:LastError; $ErrorPanel.Visibility='Visible'; Set-Badge 'failed' } })
$CloseButton.Add_Click({$script:ClosingAllowed=$true;$window.Close()})
$OpenLogButton.Add_Click({if($script:LogFile -and (Test-Path -LiteralPath $script:LogFile)){Start-Process notepad.exe -ArgumentList ('"{0}"' -f $script:LogFile)}elseif($script:StatusFile){$folder=Split-Path -Parent $script:StatusFile;if(Test-Path $folder){Start-Process explorer.exe -ArgumentList ('"{0}"' -f $folder)}}})
$OpenRuntimeButton.Add_Click({if($script:StatusFile){$folder=Split-Path -Parent $script:StatusFile;if(Test-Path $folder){Start-Process explorer.exe -ArgumentList ('"{0}"' -f $folder)}}})
$CopyErrorButton.Add_Click({if($script:LastError){[System.Windows.Clipboard]::SetText($script:LastError+[Environment]::NewLine+[Environment]::NewLine+'Protokoll: '+$script:LogFile)}})
$window.Add_Closing({param($sender,$e) if(-not $script:ClosingAllowed){if($script:Worker -and -not $script:Worker.HasExited){$choice=[System.Windows.MessageBox]::Show('Das Update läuft noch. Soll es kontrolliert abgebrochen werden?','Vendoo Updater',[System.Windows.MessageBoxButton]::YesNo,[System.Windows.MessageBoxImage]::Warning);if($choice -eq [System.Windows.MessageBoxResult]::Yes){New-Item -ItemType File -Path $script:CancelFile -Force|Out-Null;$e.Cancel=$true;$CancelButton.IsEnabled=$false;$DetailText.Text='Abbruch wird ausgeführt …'}else{$e.Cancel=$true}}else{$e.Cancel=$true}}})
$script:Timer=New-Object System.Windows.Threading.DispatcherTimer
$script:Timer.Interval=[TimeSpan]::FromMilliseconds(350)
$script:Timer.Add_Tick({
try {
Read-NewEvents
if(Test-Path -LiteralPath $script:StatusFile){try{$s=Get-Content -LiteralPath $script:StatusFile -Raw -Encoding UTF8|ConvertFrom-Json;Apply-Status $s}catch{}}
$elapsed=(Get-Date)-$script:StartedAt;$idle=[int]((Get-Date)-$script:LastStatusAt).TotalSeconds
$ActivityText.Text=('Laufzeit {0:00}:{1:00}' -f [int]$elapsed.TotalMinutes,$elapsed.Seconds);$HeartbeatText.Text=if($idle -lt 5){'Status aktuell'}else{"Letzte Meldung vor $idle Sek."}
if($script:Worker -and $script:Worker.HasExited -and -not $script:Terminal){
if(Test-Path -LiteralPath $script:StatusFile){
try{$final=Get-Content -LiteralPath $script:StatusFile -Raw -Encoding UTF8|ConvertFrom-Json;Apply-Status $final}catch{}
}
if(-not $script:Terminal){
$diagnostics=Get-WorkerExitDiagnostics
$script:Terminal=$true;$script:ClosingAllowed=$true;$CancelButton.IsEnabled=$false;$CloseButton.IsEnabled=$true;$RetryButton.Visibility='Visible';$CopyErrorButton.Visibility='Visible';Set-Badge 'failed'
$PhaseText.Text='Updater-Prozess unerwartet beendet';$DetailText.Text='Die vollständige Startdiagnose wurde aufgezeichnet. Das Fenster bleibt geöffnet.'
$script:LastError="Der überwachte Worker-Host wurde mit Exit-Code $($script:Worker.ExitCode) beendet.`n`n$diagnostics"
$ErrorText.Text=$script:LastError;$RemediationText.Text='Protokoll öffnen zeigt die vollständige Ausnahme mit Datei und Zeile. Danach kann derselbe sichere Lauf erneut gestartet werden.';$ErrorPanel.Visibility='Visible'
}
}
} catch {
$script:LastError=$_.Exception.Message
}
})
try { Start-Worker; $script:Timer.Start(); $window.ShowDialog()|Out-Null }
finally { try{$script:Timer.Stop()}catch{};if($script:Worker -and -not $script:Worker.HasExited){try{New-Item -ItemType File -Path $script:CancelFile -Force|Out-Null}catch{}};try{$mutex.ReleaseMutex()}catch{};$mutex.Dispose() }
+334
View File
@@ -0,0 +1,334 @@
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$StatusFile,
[Parameter(Mandatory=$true)][string]$EventFile,
[Parameter(Mandatory=$true)][string]$InputFile,
[Parameter(Mandatory=$true)][string]$CancelFile,
[Parameter(Mandatory=$true)][string]$SessionFile,
[Parameter(Mandatory=$true)][string]$LogFile,
[switch]$SkipProductionDeploy,
[switch]$SelfTest,
[string]$SelfTestFailAt = ''
)
Set-StrictMode -Version 2.0
$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue'
Add-Type -AssemblyName System.Net.Http
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$PackageRoot=(Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..') -ErrorAction Stop).ProviderPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar,[System.IO.Path]::AltDirectorySeparatorChar)
$Repository='Masterluke77/vendoo';$BaseBranch='main';$TargetVersion='1.43.0';$UpdaterVersion='3.3.0';$PublicBaseUrl='https://vendoo.flatlined.de'
$LocalRoot=Join-Path $env:LOCALAPPDATA 'Vendoo\Updater';$ToolsRoot=Join-Path $LocalRoot 'tools';$LogsRoot=Join-Path $LocalRoot 'logs';$WorkRoot=Join-Path $LocalRoot "work\$TargetVersion";New-Item -ItemType Directory -Path $ToolsRoot,$LogsRoot,(Split-Path -Parent $SessionFile) -Force|Out-Null
$Stamp=Get-Date -Format 'yyyyMMdd-HHmmss';$Branch="release/$TargetVersion-production-update-v3";$CloneDir=Join-Path $WorkRoot 'repository'
$script:Tools=@{};$script:CurrentStep='package';$script:LastHeartbeat=Get-Date
$script:Steps=@(
[ordered]@{id='package';title='Paket prüfen';state='pending'},[ordered]@{id='tools';title='System prüfen';state='pending'},[ordered]@{id='github';title='GitHub verbinden';state='pending'},
[ordered]@{id='production';title='Produktion vorbereiten';state='pending'},[ordered]@{id='workspace';title='Arbeitskopie erstellen';state='pending'},[ordered]@{id='tests';title='Vollständig testen';state='pending'},
[ordered]@{id='pullrequest';title='Pull Request erstellen';state='pending'},[ordered]@{id='merge';title='Sicher mergen';state='pending'},[ordered]@{id='deploy';title='Coolify deployen';state='pending'},[ordered]@{id='verify';title='Produktion bestätigen';state='pending'}
)
function Write-Event([string]$Level,[string]$Message){
$safe=$Message -replace '(?i)(https://[^\s]*(?:token|webhook)[^\s]*)','[VERDECKT]'
$entry=[ordered]@{at=(Get-Date).ToString('o');level=$Level.ToUpperInvariant();message=$safe}|ConvertTo-Json -Compress
[IO.File]::AppendAllText($EventFile,$entry+[Environment]::NewLine,(New-Object Text.UTF8Encoding($false)))
[IO.File]::AppendAllText($LogFile,"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$($Level.ToUpperInvariant())] $safe$([Environment]::NewLine)",(New-Object Text.UTF8Encoding($false)))
}
function Write-State([int]$Percent,[string]$Phase,[string]$Detail,[string]$State='running',[string]$Error='',[string]$Remediation='', $Request=$null){
$payload=[ordered]@{version=$TargetVersion;updaterVersion=$UpdaterVersion;percent=[Math]::Max(0,[Math]::Min(100,$Percent));phase=$Phase;detail=$Detail;state=$State;error=$Error;remediation=$Remediation;updatedAt=(Get-Date).ToString('o');logFile=$LogFile;sessionFile=$SessionFile;steps=$script:Steps;request=$Request}|ConvertTo-Json -Depth 8
$tmp="$StatusFile.$PID.tmp";[IO.File]::WriteAllText($tmp,$payload,(New-Object Text.UTF8Encoding($false)));Move-Item -LiteralPath $tmp -Destination $StatusFile -Force
}
function Set-Step([string]$Id,[string]$State){foreach($s in $script:Steps){if($s.id -eq $Id){$s.state=$State}elseif($State -eq 'active' -and $s.state -eq 'active'){$s.state='pending'}};$script:CurrentStep=$Id}
function Complete-Step([string]$Id){foreach($s in $script:Steps){if($s.id -eq $Id){$s.state='done'}}}
function Fail-Step{foreach($s in $script:Steps){if($s.id -eq $script:CurrentStep){$s.state='failed'}}}
function Assert-NotCancelled{if(Test-Path -LiteralPath $CancelFile){throw [System.OperationCanceledException]::new('Das Update wurde durch den Benutzer abgebrochen.')}}
function Save-Session($Data){if(-not($Data.PSObject.Properties.Name -contains 'updaterVersion')){$Data|Add-Member -NotePropertyName updaterVersion -NotePropertyValue $UpdaterVersion};$Data.updaterVersion=$UpdaterVersion;$Data.updatedAt=(Get-Date).ToString('o');$tmp="$SessionFile.tmp";$Data|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $tmp -Encoding UTF8;Move-Item $tmp $SessionFile -Force}
function Load-Session{if(Test-Path -LiteralPath $SessionFile){try{$saved=Get-Content $SessionFile -Raw -Encoding UTF8|ConvertFrom-Json;if([string]$saved.version-eq$TargetVersion -and [string]$saved.updaterVersion-eq$UpdaterVersion){return $saved};Write-Event 'INFO' 'Veralteter oder anderer Updater-Sitzungsstand wird nicht übernommen.'}catch{Write-Event 'WARN' 'Gespeicherter Sitzungsstand ist ungültig und wird verworfen.'}};return [pscustomobject]@{version=$TargetVersion;updaterVersion=$UpdaterVersion;branch=$Branch;prUrl='';merged=$false;runId='';stage='new';updatedAt=''}}
function Quote-Arg([string]$Value){if($null -eq $Value){return '""'};if($Value -notmatch '[\s"]'){return $Value};return '"'+($Value -replace '(\\*)"','$1$1\"' -replace '(\\+)$','$1$1')+'"'}
function Stop-Tree([int]$ProcessId){
try { & taskkill.exe /PID $ProcessId /T /F 2>$null | Out-Null }
catch { try { Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue } catch {} }
}
function Get-TailText([string]$Text,[int]$MaxLength){
if([string]::IsNullOrWhiteSpace($Text)){return ''}
$value=$Text.Trim()
if($value.Length -gt $MaxLength){return $value.Substring($value.Length-$MaxLength)}
return $value
}
function Get-ProcessFailureSummary([string]$StdErr,[string]$StdOut){
$parts=New-Object Collections.Generic.List[string]
$errorTail=Get-TailText $StdErr 1800
$outputTail=Get-TailText $StdOut 900
if($errorTail){$parts.Add("Fehlerausgabe:`n$errorTail")}
if($outputTail){$parts.Add("Letzte Standardausgabe:`n$outputTail")}
if($parts.Count -eq 0){return 'Der Prozess lieferte keine Diagnoseausgabe.'}
return ($parts -join "`n`n")
}
function Invoke-External{
param([string]$File,[string[]]$Arguments,[string]$WorkingDirectory=$PackageRoot,[int]$TimeoutSeconds=120,[switch]$AllowFailure,[string]$InputText='',[string]$Label='Prozess',[switch]$Quiet)
Assert-NotCancelled
$psi=New-Object Diagnostics.ProcessStartInfo;$psi.FileName=$File;$psi.Arguments=(@($Arguments|ForEach-Object{Quote-Arg ([string]$_)}) -join ' ');$psi.WorkingDirectory=$WorkingDirectory;$psi.UseShellExecute=$false;$psi.CreateNoWindow=$true;$psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true;$psi.RedirectStandardInput=$true
# Node.js, npm and Git emit UTF-8 when redirected. Windows PowerShell otherwise decodes
# redirected streams with the active OEM code page, which corrupts German umlauts.
$utf8NoBom=New-Object Text.UTF8Encoding($false)
if($psi.PSObject.Properties.Name -contains 'StandardOutputEncoding'){$psi.StandardOutputEncoding=$utf8NoBom}
if($psi.PSObject.Properties.Name -contains 'StandardErrorEncoding'){$psi.StandardErrorEncoding=$utf8NoBom}
$psi.EnvironmentVariables['NO_COLOR']='1'
$psi.EnvironmentVariables['FORCE_COLOR']='0'
$psi.EnvironmentVariables['NPM_CONFIG_COLOR']='false'
$psi.EnvironmentVariables['NPM_CONFIG_UNICODE']='true'
$p=New-Object Diagnostics.Process;$p.StartInfo=$psi
if(-not $Quiet){Write-Event 'INFO' "$Label gestartet."}
if(-not $p.Start()){throw "$Label konnte nicht gestartet werden."}
$outTask = $p.StandardOutput.ReadToEndAsync()
$errTask = $p.StandardError.ReadToEndAsync()
if ($InputText) { $p.StandardInput.Write($InputText) }
$p.StandardInput.Close()
$sw=[Diagnostics.Stopwatch]::StartNew()
while(-not $p.WaitForExit(500)){
if(Test-Path -LiteralPath $CancelFile){Stop-Tree $p.Id;throw [System.OperationCanceledException]::new('Das Update wurde durch den Benutzer abgebrochen.')}
if($sw.Elapsed.TotalSeconds -gt $TimeoutSeconds){Stop-Tree $p.Id;throw "$Label hat das Zeitlimit von $TimeoutSeconds Sekunden überschritten."}
if(-not $Quiet -and ((Get-Date)-$script:LastHeartbeat).TotalSeconds -ge 8){Write-Event 'INFO' ("$Label läuft seit {0:mm\:ss}." -f $sw.Elapsed);$script:LastHeartbeat=Get-Date}
}
$p.WaitForExit();$stdout=[string]$outTask.Result;$stderr=[string]$errTask.Result;$code=$p.ExitCode;$p.Dispose()
if(-not $Quiet){
foreach($line in @($stdout -split "`r?`n")){if($line.Trim()){Write-Event 'OUTPUT' $line.Trim()}}
foreach($line in @($stderr -split "`r?`n")){
$clean=$line.Trim()
if(-not $clean){continue}
if($code -ne 0){Write-Event 'ERROR' $clean}
elseif($clean -match '(?i)\b(warn|warning|deprecated)\b'){Write-Event 'WARN' $clean}
else{Write-Event 'OUTPUT' $clean}
}
}
if(-not $AllowFailure -and $code -ne 0){$summary=Get-ProcessFailureSummary $stderr $stdout;throw "$Label ist mit Exit-Code $code fehlgeschlagen.`n$summary"}
return [pscustomobject]@{Code=$code;Output=$stdout;Error=$stderr}
}
function Resolve-Tool([string]$Name,[string[]]$Candidates=@()){
if($script:Tools.ContainsKey($Name) -and (Test-Path -LiteralPath $script:Tools[$Name])){return $script:Tools[$Name]}
foreach($c in @($Candidates)){if($c -and (Test-Path -LiteralPath $c -PathType Leaf)){$script:Tools[$Name]=(Resolve-Path $c).Path;return $script:Tools[$Name]}}
$cmd=Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue|Select-Object -First 1;if($cmd -and $cmd.Source -and (Test-Path $cmd.Source)){$script:Tools[$Name]=$cmd.Source;return $cmd.Source};return $null
}
function Get-GitPath{return Resolve-Tool 'git.exe' @("$env:ProgramFiles\Git\cmd\git.exe","$env:LOCALAPPDATA\Programs\Git\cmd\git.exe")}
function Get-NodePath{return Resolve-Tool 'node.exe' @("$env:ProgramFiles\nodejs\node.exe","$env:LOCALAPPDATA\Programs\nodejs\node.exe")}
function Get-NpmPath{return Resolve-Tool 'npm.cmd' @("$env:ProgramFiles\nodejs\npm.cmd","$env:LOCALAPPDATA\Programs\nodejs\npm.cmd")}
function Invoke-WebJson([string]$Uri){for($i=1;$i-le3;$i++){try{return Invoke-RestMethod -Uri $Uri -Headers @{'User-Agent'="Vendoo-Updater/$UpdaterVersion";'Accept'='application/vnd.github+json'} -TimeoutSec 30}catch{if($i-eq3){throw};Start-Sleep -Seconds (2*$i)}}}
function Download-File([string]$Uri,[string]$Destination,[string]$Label){
for($attempt=1;$attempt-le3;$attempt++){try{
$handler = New-Object Net.Http.HttpClientHandler
$client = [Net.Http.HttpClient]::new($handler)
$client.Timeout=[TimeSpan]::FromMinutes(5);$client.DefaultRequestHeaders.UserAgent.ParseAdd("Vendoo-Updater/$UpdaterVersion")
$resp = $client.GetAsync($Uri,[Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result
$null = $resp.EnsureSuccessStatusCode()
$total=$resp.Content.Headers.ContentLength;$input=$resp.Content.ReadAsStreamAsync().Result;$output=[IO.File]::Open($Destination,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::None)
try{
$buf=New-Object byte[] 65536;$readTotal=0L;$lastReported=-10
while(($read=$input.Read($buf,0,$buf.Length))-gt0){
Assert-NotCancelled;$output.Write($buf,0,$read);$readTotal+=$read
if($total -and $total -gt 0){
$percent=[Math]::Min(100,[int][Math]::Floor(($readTotal*100.0)/$total))
if($percent -ge ($lastReported+10) -or $readTotal -ge $total){
$bucket=[Math]::Min(100,[int]([Math]::Floor($percent/10)*10))
if($bucket -gt $lastReported){
$lastReported=$bucket
Write-Event 'INFO' ("${Label}: {0}% ({1:N1} von {2:N1} MB)" -f $bucket,($readTotal/1048576),($total/1048576))
Write-State 18 'GitHub CLI' ("${Label} wird geladen: {0}%" -f $bucket)
}
}
}
}
}finally{$output.Dispose();$input.Dispose();$resp.Dispose();$client.Dispose();$handler.Dispose()}
if(-not $total){Write-Event 'INFO' "${Label}: Download abgeschlossen."}
return
}catch{if($attempt-eq3){throw "$Label konnte nach drei Versuchen nicht geladen werden: $($_.Exception.Message)"};Write-Event 'WARN' "${Label}: Downloadversuch $attempt fehlgeschlagen; erneuter Versuch.";Start-Sleep -Seconds (3*$attempt)}}
}
function Ensure-GitHubCli{
$managed=Join-Path $ToolsRoot 'github-cli\current\bin\gh.exe';if(Test-Path $managed){$test=Invoke-External $managed @('--version') -TimeoutSeconds 20 -AllowFailure -Label 'GitHub CLI prüfen';if($test.Code-eq0){$script:Tools['gh.exe']=$managed;return $managed}}
Write-State 18 'GitHub CLI' 'Die offizielle portable GitHub CLI wird sicher geladen und per SHA-256 geprüft.'
$release=Invoke-WebJson 'https://api.github.com/repos/cli/cli/releases/latest';$arch=if([Environment]::Is64BitOperatingSystem){'amd64'}else{'386'};$asset=@($release.assets|Where-Object{$_.name -like "gh_*_windows_$arch.zip"}|Select-Object -First 1);$checks=@($release.assets|Where-Object{$_.name -like '*_checksums.txt'}|Select-Object -First 1)
if(-not $asset -or -not $checks){throw 'Die offiziellen GitHub-CLI-Release-Dateien konnten nicht ermittelt werden.'}
$tmp=Join-Path $env:TEMP "vendoo-gh-$PID";New-Item -ItemType Directory -Path $tmp -Force|Out-Null;$zip=Join-Path $tmp $asset.name;$checkFile=Join-Path $tmp $checks.name;$extract=Join-Path $tmp 'extract'
try{Download-File $asset.browser_download_url $zip 'GitHub CLI';Download-File $checks.browser_download_url $checkFile 'Prüfsummen';$line=Get-Content $checkFile|Where-Object{$_ -match [regex]::Escape($asset.name)}|Select-Object -First 1;if(-not $line){throw 'Für das GitHub-CLI-Archiv wurde keine offizielle Prüfsumme gefunden.'};$expected=($line -split '\s+')[0].ToLowerInvariant();$actual=(Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant();if($actual-ne$expected){throw 'SHA-256-Prüfung der GitHub CLI fehlgeschlagen.'};New-Item -ItemType Directory -Path $extract -Force|Out-Null;Expand-Archive $zip $extract -Force;$source=Get-ChildItem $extract -Filter gh.exe -File -Recurse|Select-Object -First 1;if(-not $source){throw 'Das geprüfte GitHub-CLI-Archiv enthält keine gh.exe.'};$targetRoot=Split-Path -Parent (Split-Path -Parent $managed);if(Test-Path $targetRoot){Remove-Item $targetRoot -Recurse -Force};New-Item -ItemType Directory -Path (Split-Path -Parent $managed) -Force|Out-Null;Copy-Item $source.FullName $managed -Force;$test=Invoke-External $managed @('--version') -TimeoutSeconds 20 -Label 'GitHub CLI validieren';$script:Tools['gh.exe']=$managed;Write-Event 'OK' "GitHub CLI $($release.tag_name) wurde geprüft und lokal bereitgestellt.";return $managed}finally{Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue}
}
function Test-GitHubAuthentication([string]$GhPath){
$status=Invoke-External $GhPath @('auth','status','--hostname','github.com') -TimeoutSeconds 20 -AllowFailure -Label 'GitHub-Anmeldung prüfen' -Quiet
return ($status.Code -eq 0)
}
function Invoke-VisibleGitHubLogin([string]$GhPath){
$runtimeDirectory=Split-Path -Parent $StatusFile
$loginScript=Join-Path $runtimeDirectory 'github-login.cmd'
$escapedGh=$GhPath.Replace('"','""')
$lines=@(
'@echo off',
'chcp 65001 >nul',
'title Vendoo - GitHub Anmeldung',
'echo ============================================================',
'echo Vendoo - Sichere GitHub Anmeldung',
'echo ============================================================',
'echo.',
'echo Der einmalige GitHub-Code wird angezeigt und in die',
'echo Zwischenablage kopiert. Folge danach dem Browserdialog.',
'echo Dieses Fenster schliesst sich nach erfolgreicher Anmeldung.',
'echo.',
('"{0}" auth login --hostname github.com --git-protocol https --web --clipboard' -f $escapedGh),
'set "VENDOO_GH_LOGIN_EXIT=%ERRORLEVEL%"',
'echo.',
'if "%VENDOO_GH_LOGIN_EXIT%"=="0" (',
' echo GitHub-Anmeldung erfolgreich. Vendoo faehrt automatisch fort.',
') else (',
' echo GitHub-Anmeldung wurde nicht abgeschlossen. Fehlercode: %VENDOO_GH_LOGIN_EXIT%',
' echo Das Fenster kann geschlossen werden; der Vendoo Updater bleibt geoeffnet.',
' pause',
')',
'exit /b %VENDOO_GH_LOGIN_EXIT%'
)
[IO.File]::WriteAllLines($loginScript,$lines,(New-Object Text.UTF8Encoding($false)))
Write-State 26 'GitHub-Anmeldung' 'Im geöffneten Anmeldefenster den Gerätecode bestätigen und den Browserdialog abschließen. Danach geht es automatisch weiter.' 'waiting'
Write-Event 'WAIT' 'Sichtbares GitHub-Anmeldefenster wird geöffnet.'
try{
$loginProcess=Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d','/c',('"{0}"' -f $loginScript)) -WorkingDirectory $runtimeDirectory -WindowStyle Normal -PassThru
}catch{
throw "Das sichtbare GitHub-Anmeldefenster konnte nicht gestartet werden: $($_.Exception.Message)"
}
$deadline=(Get-Date).AddMinutes(10);$lastStatus=Get-Date
while((Get-Date)-lt$deadline){
Assert-NotCancelled
if(Test-GitHubAuthentication $GhPath){
Write-Event 'OK' 'GitHub-Anmeldung wurde bestätigt.'
try{if(-not $loginProcess.HasExited){$loginProcess.CloseMainWindow()|Out-Null}}catch{}
return
}
if($loginProcess.HasExited){
Start-Sleep -Seconds 1
if(Test-GitHubAuthentication $GhPath){Write-Event 'OK' 'GitHub-Anmeldung wurde bestätigt.';return}
throw "Die GitHub-Anmeldung wurde nicht abgeschlossen. Das Anmeldefenster endete mit Exit-Code $($loginProcess.ExitCode)."
}
if(((Get-Date)-$lastStatus).TotalSeconds -ge 15){
Write-Event 'INFO' 'GitHub-Anmeldung wartet auf Abschluss im Browser.'
$lastStatus=Get-Date
}
Start-Sleep -Seconds 2
}
try{if(-not $loginProcess.HasExited){Stop-Tree $loginProcess.Id}}catch{}
throw 'Die GitHub-Anmeldung wurde nicht innerhalb von zehn Minuten abgeschlossen.'
}
function Check-ProductionVersion{try{$r=Invoke-RestMethod -Uri "$PublicBaseUrl/readyz?updater=$Stamp" -TimeoutSec 20 -Headers @{'Cache-Control'='no-cache';'User-Agent'="Vendoo-Updater/$UpdaterVersion"};return [pscustomobject]@{Ready=[bool]$r.ok;Version=[string]$r.version}}catch{return [pscustomobject]@{Ready=$false;Version='unbekannt'}}}
function Assert-Package{foreach($f in @('package.json','package-lock.json','update-manifest.json','release-manifest.json','db\schema.sql','tools\apply-release-manifest.mjs')){if(-not(Test-Path(Join-Path $PackageRoot $f))){throw "Paketdatei fehlt: $f"}};$p=Get-Content (Join-Path $PackageRoot 'package.json') -Raw -Encoding UTF8|ConvertFrom-Json;$m=Get-Content (Join-Path $PackageRoot 'update-manifest.json') -Raw -Encoding UTF8|ConvertFrom-Json;$r=Get-Content (Join-Path $PackageRoot 'release-manifest.json') -Raw -Encoding UTF8|ConvertFrom-Json;if([string]$p.version-ne$TargetVersion -or [string]$m.version-ne$TargetVersion -or [string]$r.version-ne$TargetVersion){throw "Paket-, Update- und Release-Manifest-Version müssen exakt $TargetVersion sein."};if([string]$r.updaterVersion-ne$UpdaterVersion){throw "Release-Manifest gehört zu Updater $($r.updaterVersion), erwartet wird $UpdaterVersion."};if([string]$r.mode-ne'overlay-with-explicit-removals'){throw "Unzulässiger Release-Modus: $($r.mode)"}}
try{
Write-Event 'INFO' "Vendoo Production Updater $UpdaterVersion gestartet. Zielversion $TargetVersion."
$session=Load-Session
Set-Step 'package' 'active';Write-State 3 'Paketprüfung' 'Version, Manifest und sicherheitsrelevante Paketdateien werden geprüft.';Assert-Package;if($SelfTestFailAt-eq'package'){throw 'Simulierter Paketfehler.'};Complete-Step 'package';Write-Event 'OK' 'Paketprüfung erfolgreich.'
Set-Step 'tools' 'active';Write-State 9 'Systemprüfung' 'Git, Node.js und npm werden mit festen Zeitlimits geprüft.'
$git=Get-GitPath;if(-not$git){throw 'Git für Windows wurde nicht gefunden. Bitte Git für Windows installieren; danach kann derselbe Updater erneut gestartet werden.'};$node=Get-NodePath;$npm=Get-NpmPath;if(-not$node -or -not$npm){throw 'Node.js 22 LTS oder npm wurde nicht gefunden.'};$gv=Invoke-External $git @('--version') -TimeoutSeconds 20 -Label 'Git prüfen';$nv=Invoke-External $node @('--version') -TimeoutSeconds 20 -Label 'Node.js prüfen';if($nv.Output -notmatch 'v?(\d+)\.' -or [int]$Matches[1]-lt22){throw "Node.js 22 oder neuer ist erforderlich. Gefunden: $($nv.Output.Trim())"};$gh=Ensure-GitHubCli;Complete-Step 'tools';Write-Event 'OK' 'Systemprüfung erfolgreich.'
Set-Step 'github' 'active';Write-State 24 'GitHub-Verbindung' 'Anmeldung und Repository-Berechtigungen werden geprüft.'
if(-not (Test-GitHubAuthentication $gh)){Invoke-VisibleGitHubLogin $gh}
Invoke-External $gh @('auth','setup-git','--hostname','github.com') -TimeoutSeconds 60 -Label 'Git-Anmeldung konfigurieren'|Out-Null
$repo=Invoke-External $gh @('repo','view',$Repository,'--json','viewerPermission,defaultBranchRef,isPrivate') -TimeoutSeconds 60 -Label 'Repository-Zugriff prüfen';$repoObj=$repo.Output|ConvertFrom-Json;if([string]$repoObj.viewerPermission -notin @('ADMIN','MAINTAIN','WRITE')){throw "Für $Repository fehlt Schreibzugriff. GitHub meldet $($repoObj.viewerPermission)."};Complete-Step 'github';Write-Event 'OK' "GitHub-Zugriff bestätigt: $($repoObj.viewerPermission)."
Set-Step 'production' 'active';Write-State 32 'Produktionsvorbereitung' 'Aktueller Produktionsstand und Coolify Auto Deploy werden geprüft.'
$prod=Check-ProductionVersion;Write-Event 'INFO' "Produktion meldet ready=$($prod.Ready), version=$($prod.Version)."
if(-not$SkipProductionDeploy){Write-Event 'OK' 'Kein Coolify-Webhook erforderlich: Nach dem Merge wird das bestehende Coolify Auto Deploy der GitHub-Verknüpfung verwendet.'}
Complete-Step 'production'
if($SelfTest){foreach($id in @('workspace','tests','pullrequest','merge','deploy','verify')){Set-Step $id 'active';Write-State 50 "Selbsttest: $id" 'Darstellung, Statuswechsel und Protokoll werden simuliert.';Start-Sleep -Milliseconds 350;Complete-Step $id};Write-State 100 'Selbsttest erfolgreich' 'Die Updater-Oberfläche und der Worker-Vertrag funktionieren.' 'success';return}
if([bool]$session.merged){Write-Event 'INFO' 'Gespeicherter Wiederaufnahmepunkt: main wurde bereits aktualisiert.';foreach($id in @('workspace','tests','pullrequest','merge')){Complete-Step $id}}
else {
Set-Step 'workspace' 'active'
Write-State 40 'Arbeitskopie' 'main wird isoliert geklont; das vollständige Release-Manifest wird ohne Leeren der Baseline angewendet.'
if (Test-Path -LiteralPath $WorkRoot) { Remove-Item -LiteralPath $WorkRoot -Recurse -Force }
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
Invoke-External $gh @('repo','clone',$Repository,$CloneDir,'--','--branch',$BaseBranch,'--single-branch') -WorkingDirectory $WorkRoot -TimeoutSeconds 300 -Label 'Repository klonen' | Out-Null
Invoke-External $git @('switch','-c',$Branch) -WorkingDirectory $CloneDir -TimeoutSeconds 60 -Label 'Release-Branch erstellen' | Out-Null
Invoke-External $node @((Join-Path $PackageRoot 'tools\apply-release-manifest.mjs'),$PackageRoot,$CloneDir) -TimeoutSeconds 300 -Label 'SHA-256-geprüftes Release-Manifest anwenden' | Out-Null
Complete-Step 'workspace'
Set-Step 'tests' 'active'
Write-State 52 'Abnahmetests' 'Abhängigkeiten werden reproduzierbar installiert; danach läuft der vollständige verify:all-Vertrag.'
Invoke-External $npm @('ci','--no-audit','--no-fund') -WorkingDirectory $CloneDir -TimeoutSeconds 1200 -Label 'npm ci' | Out-Null
Write-State 61 'Abnahmetests' 'Alle Vendoo-Sicherheits-, Datenbank-, Modul- und Release-Gates laufen.'
Invoke-External $npm @('run','verify:all') -WorkingDirectory $CloneDir -TimeoutSeconds 3600 -Label 'npm run verify:all' | Out-Null
Complete-Step 'tests'
Write-Event 'OK' 'Alle lokalen Abnahmetests sind grün.'
Set-Step 'pullrequest' 'active'
Write-State 70 'Pull Request' 'Der geprüfte Stand wird committed und in einen kontrollierten Pull Request übertragen.'
Invoke-External $git @('config','user.name','Vendoo Production Updater') -WorkingDirectory $CloneDir -Label 'Git-Name setzen' | Out-Null
$uid = (Invoke-External $gh @('api','user','--jq','.id') -Label 'GitHub-Benutzer-ID').Output.Trim()
$ulogin = (Invoke-External $gh @('api','user','--jq','.login') -Label 'GitHub-Benutzername').Output.Trim()
Invoke-External $git @('config','user.email',"$uid+$ulogin@users.noreply.github.com") -WorkingDirectory $CloneDir -Label 'Git-E-Mail setzen' | Out-Null
Invoke-External $git @('add','--all') -WorkingDirectory $CloneDir -Label 'Änderungen vormerken' | Out-Null
$diff = Invoke-External $git @('diff','--cached','--quiet') -WorkingDirectory $CloneDir -AllowFailure -Label 'Änderungen prüfen'
if ($diff.Code -eq 0) {
Write-Event 'INFO' 'main enthält bereits denselben Source-Stand.'
$session.merged = $true
$session.stage = 'merged'
Save-Session $session
Complete-Step 'pullrequest'
Complete-Step 'merge'
}
elseif ($diff.Code -eq 1) {
Invoke-External $git @('commit','-m',"release: Vendoo $TargetVersion production update") -WorkingDirectory $CloneDir -TimeoutSeconds 120 -Label 'Release committen' | Out-Null
Invoke-External $git @('push','--force','--set-upstream','origin',$Branch) -WorkingDirectory $CloneDir -TimeoutSeconds 300 -Label 'Release-Branch pushen' | Out-Null
$existing = Invoke-External $gh @('pr','list','--repo',$Repository,'--head',$Branch,'--state','open','--json','url','--jq','.[0].url') -AllowFailure -Label 'Vorhandenen Pull Request suchen'
$prUrl = $existing.Output.Trim()
if (-not $prUrl) {
$body = Join-Path $WorkRoot 'pr-body.md'
$bodyText = @"
## Vendoo $TargetVersion Production Update
Erstellt durch Vendoo Production Updater $UpdaterVersion.
- vollständiges `npm run verify:all` erfolgreich
- keine Datenbanken, Uploads, Secrets oder `/app/data`-Inhalte
- Coolify Auto Deploy mit exakter Versionsprüfung
"@
$bodyText | Set-Content -LiteralPath $body -Encoding UTF8
$prUrl = (Invoke-External $gh @('pr','create','--repo',$Repository,'--base',$BaseBranch,'--head',$Branch,'--title',"Vendoo $TargetVersion Production Update",'--body-file',$body) -WorkingDirectory $CloneDir -TimeoutSeconds 180 -Label 'Pull Request erstellen').Output.Trim()
}
$session.prUrl = $prUrl
$session.stage = 'pr'
Save-Session $session
Complete-Step 'pullrequest'
Write-Event 'OK' "Pull Request: $prUrl"
Set-Step 'merge' 'active'
Write-State 78 'GitHub-Prüfungen' 'Alle gemeldeten GitHub-Checks werden abgewartet.'
$rollup = Invoke-External $gh @('pr','view',$prUrl,'--repo',$Repository,'--json','statusCheckRollup') -Label 'GitHub-Checks ermitteln'
$rollupObj = $rollup.Output | ConvertFrom-Json
if (@($rollupObj.statusCheckRollup).Count -gt 0) {
Invoke-External $gh @('pr','checks',$prUrl,'--repo',$Repository,'--watch','--fail-fast') -WorkingDirectory $CloneDir -TimeoutSeconds 2700 -Label 'GitHub-Checks abwarten' | Out-Null
}
Write-State 82 'Merge' 'Der vollständig geprüfte Pull Request wird per Squash nach main übernommen.'
Invoke-External $gh @('pr','merge',$prUrl,'--repo',$Repository,'--squash','--delete-branch','--subject',"Vendoo $TargetVersion Production Update") -WorkingDirectory $CloneDir -TimeoutSeconds 300 -Label 'Pull Request mergen' | Out-Null
$session.merged = $true
$session.stage = 'merged'
Save-Session $session
Complete-Step 'merge'
Write-Event 'OK' 'main wurde kontrolliert aktualisiert.'
}
else {
throw "Git-Diff-Prüfung lieferte unerwarteten Exit-Code $($diff.Code)."
}
}
if($SkipProductionDeploy){foreach($id in @('deploy','verify')){foreach($s in $script:Steps){if($s.id-eq$id){$s.state='skipped'}}};Write-State 100 'Source aktualisiert' 'GitHub wurde aktualisiert; das Produktionsdeployment wurde auf Wunsch übersprungen.' 'success';return}
Set-Step 'deploy' 'active';Write-State 87 'Coolify Auto Deploy' 'main wurde aktualisiert. Coolify übernimmt das Deployment über die bestehende GitHub-Verknüpfung.'
$session.stage='deploy';Save-Session $session;Write-Event 'INFO' 'Warte auf Coolify Auto Deploy. Es wird kein Webhook und kein GitHub Secret benötigt.'
$deadline=(Get-Date).AddMinutes(15);$last='';$nextLog=Get-Date
do{
Assert-NotCancelled
$prod=Check-ProductionVersion
$last="ready=$($prod.Ready), version=$($prod.Version)"
if((Get-Date)-ge$nextLog){Write-Event 'INFO' "Coolify Auto Deploy: $last";$nextLog=(Get-Date).AddSeconds(30)}
if($prod.Ready -and $prod.Version-eq$TargetVersion){break}
Start-Sleep -Seconds 10
}while((Get-Date)-lt$deadline)
if(-not($prod.Ready -and $prod.Version-eq$TargetVersion)){throw "Coolify hat die Zielversion innerhalb von 15 Minuten nicht bereitgestellt. Letzter Stand: $last. In Coolify bei der Vendoo-Anwendung unter Advanced prüfen, ob Auto Deploy aktiviert ist."}
Complete-Step 'deploy'
Set-Step 'verify' 'active';Write-State 97 'Produktionsprüfung' 'Die öffentlich erreichbare Readiness und die exakte Zielversion werden abschließend bestätigt.'
$prod=Check-ProductionVersion
if(-not($prod.Ready -and $prod.Version-eq$TargetVersion)){throw "Die abschließende Produktionsprüfung ist fehlgeschlagen. Stand: ready=$($prod.Ready), version=$($prod.Version)"}
Complete-Step 'verify';$session.stage='complete';Save-Session $session;Write-State 100 'Update erfolgreich' "Vendoo $TargetVersion läuft geprüft in Produktion." 'success';Write-Event 'OK' "Vendoo $TargetVersion wurde vollständig aktualisiert.";return
}catch [System.OperationCanceledException]{Fail-Step;Write-State 100 'Update abgebrochen' $_.Exception.Message 'cancelled' $_.Exception.Message 'Der gespeicherte Wiederaufnahmepunkt bleibt erhalten. Mit „Erneut versuchen“ kann der sichere Ablauf fortgesetzt werden.';Write-Event 'WARN' $_.Exception.Message;return
}catch{Fail-Step;$msg=$_.Exception.Message;$remediation=if ($script:CurrentStep -eq 'tools'){'Systemvoraussetzung korrigieren und „Erneut versuchen“ wählen. Es wurden noch keine GitHub- oder Produktionsänderungen vorgenommen.'}elseif ($script:CurrentStep -in @('deploy','verify')){'main kann bereits aktualisiert sein. „Erneut versuchen“ setzt beim Deployment beziehungsweise der Produktionsprüfung fort.'}else{'„Erneut versuchen“ verwendet den gespeicherten Stand. Vor einem erfolgreichen Merge bleibt main unverändert.'};Write-State 100 'Update sicher gestoppt' $msg 'failed' $msg $remediation;Write-Event 'ERROR' $msg;return
}finally{if(Test-Path $InputFile){Remove-Item $InputFile -Force -ErrorAction SilentlyContinue}}
+27 -2
View File
@@ -153,7 +153,12 @@ configureAiModule({
stopBatchJobs: stopImageBatchJobLoop,
});
configureFluxStudioModule({ start: startFluxPromptJobLoop, stop: stopFluxPromptJobLoop });
configureDeploymentsModule({ createBackup, checkForUpdates });
configureDeploymentsModule({
createBackup,
verifyStoredBackup,
checkForUpdates,
getPlatformSnapshot: () => platformKernel.snapshot(),
});
configurePublishingModule({
async start() {
const migration = migrateLegacyEbayQueue();
@@ -609,7 +614,8 @@ function moduleOwnerForApiPath(pathname) {
if (path.startsWith('/publishing') || path.startsWith('/publish') || path.startsWith('/ebay') || path.startsWith('/etsy') || path.startsWith('/extension/publishing') || path.startsWith('/schedule')) return 'vendoo.publishing';
if (path.startsWith('/flux') || path.startsWith('/local-image')) return 'vendoo.flux-studio';
if (path.startsWith('/quality')) return 'vendoo.quality';
if (path.startsWith('/ai-model') || path.startsWith('/image-batch') || path === '/generate' || path.startsWith('/providers') || path.startsWith('/prompt-lab') || /^\/listings\/[^/]+\/ai-model/.test(path)) return 'vendoo.ai';
if (path.startsWith('/ai-model-photos') || /^\/listings\/[^/]+\/ai-model/.test(path)) return 'vendoo.product-image-tools';
if (path.startsWith('/image-batch') || path === '/generate' || path.startsWith('/providers') || path.startsWith('/prompt-lab')) return 'vendoo.ai';
return null;
}
@@ -646,6 +652,25 @@ platformKernel.routes.register(app, {
handler: async (_req, res) => res.json({ modules: platformKernel.modules.snapshot() }),
});
platformKernel.routes.register(app, {
id: 'platform.modules.navigation',
method: 'GET',
path: '/api/modules/navigation',
owner: 'vendoo.system',
policy: 'platform.authenticated',
audit: 'platform.modules.navigation',
rateLimit: 'default',
csrf: false,
stability: 'stable',
handler: async (_req, res) => res.json({
modules: platformKernel.modules.snapshot().map(module => ({
id: module.id,
enabled: Boolean(module.enabled && module.state === 'running'),
state: module.state,
})),
}),
});
platformKernel.routes.register(app, {
id: 'themes.contract.read',
method: 'GET',
+2 -2
View File
@@ -1,4 +1,4 @@
# Vendoo 1.41.2 grafischer Zero-Edit-Installationsassistent
# Vendoo 1.42.0 grafischer Zero-Edit-Installationsassistent
$ErrorActionPreference = 'Stop'
$VendooDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$SetupScript = Join-Path $VendooDir 'setup.ps1'
@@ -90,7 +90,7 @@ function Set-Configuration([string]$Target, [string]$Access, [int]$Port, [string
}
$form = New-Object System.Windows.Forms.Form
$form.Text = 'Vendoo 1.41.2 Installation'
$form.Text = 'Vendoo 1.42.0 Installation'
$form.StartPosition = 'CenterScreen'
$form.Size = New-Object System.Drawing.Size(720, 620)
$form.MinimumSize = New-Object System.Drawing.Size(720, 620)
+2 -2
View File
@@ -1,6 +1,6 @@
$ErrorActionPreference = 'Stop'
$VendooDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$Version = '1.41.2'
$Version = '1.42.0'
$Port = 8124
$InstallModeFile = Join-Path $VendooDir '.vendoo-install-mode'
@@ -831,7 +831,7 @@ function Start-VendooLocal {
Write-Host ''
Write-Step '...' 'Starte Vendoo-Server...'
Push-Location $VendooDir
Start-Process "http://localhost:$Port/?build=1.41.2"
Start-Process "http://localhost:$Port/?build=1.42.0"
& node bootstrap.mjs
Pop-Location
}
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
set -eu
APP_VERSION="1.41.2"
APP_VERSION="1.42.0"
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
MODE_FILE="$ROOT_DIR/.vendoo-install-mode"
ENV_FILE="$ROOT_DIR/.env"
+19
View File
@@ -0,0 +1,19 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyReleaseManifest } from './release-manifest-lib.mjs';
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : '';
if (invokedPath === fileURLToPath(import.meta.url)) {
const [, , source, destination] = process.argv;
if (!source || !destination) {
console.error('Aufruf: node tools/apply-release-manifest.mjs <Release-Paket> <geklontes Zielrepository>');
process.exit(64);
}
try {
const result = applyReleaseManifest(source, destination);
console.log(`Release-Manifest angewendet: ${result.copied} Payload-Dateien und ${result.controlCopied} Steuerdatei geprüft und kopiert; ${result.removed} explizite Löschungen.`);
} catch (error) {
console.error(`[FEHLER] Release-Manifest konnte nicht angewendet werden: ${error.message}`);
process.exit(1);
}
}
+30 -27
View File
@@ -1,43 +1,46 @@
import { cpSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import {
copyFileSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { shouldSkipStagingEntry } from './prepare-git-staging.mjs';
import { generateReleaseManifest } from './generate-release-manifest.mjs';
import { MANIFEST_FILE, readAndValidateManifest } from './release-manifest-lib.mjs';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const version = pkg.version;
const outputRoot = join(root, 'release-output');
const packageName = `Vendoo_${version}`;
const packageName = `Vendoo_${pkg.version}`;
const stage = join(outputRoot, packageName);
const normalize = value => value.split(sep).join('/');
// The committed manifest is the only release payload contract. Regenerate it first so
// stale hashes or accidentally omitted source files make the release fail immediately.
generateReleaseManifest(root);
const manifest = readAndValidateManifest(root);
rmSync(outputRoot, { recursive: true, force: true });
mkdirSync(stage, { recursive: true });
function copyTree(src, dest, rel = '') {
for (const name of readdirSync(src)) {
const source = join(src, name);
const childRel = rel ? join(rel, name) : name;
const stat = lstatSync(source);
if (shouldSkipStagingEntry(childRel, stat)) continue;
const target = join(dest, name);
if (stat.isDirectory()) {
mkdirSync(target, { recursive: true });
copyTree(source, target, childRel);
} else if (stat.isFile()) {
mkdirSync(dirname(target), { recursive: true });
cpSync(source, target);
}
}
for (const entry of manifest.files) {
const source = join(root, entry.path);
const target = join(stage, entry.path);
mkdirSync(dirname(target), { recursive: true });
copyFileSync(source, target);
if (statSync(target).size !== entry.size) throw new Error(`Release-Kopie hat falsche Größe: ${entry.path}`);
}
copyTree(root, stage);
copyFileSync(join(root, MANIFEST_FILE), join(stage, MANIFEST_FILE));
const checksums = [];
function hashTree(dir) {
for (const name of readdirSync(dir).sort((a, b) => a.localeCompare(b))) {
for (const name of readdirSync(dir).sort((a, b) => a.localeCompare(b, 'en'))) {
const abs = join(dir, name);
const stat = lstatSync(abs);
const stat = statSync(abs);
if (stat.isDirectory()) hashTree(abs);
else {
const rel = normalize(relative(stage, abs));
@@ -48,7 +51,7 @@ function hashTree(dir) {
}
}
hashTree(stage);
writeFileSync(join(stage, 'FILE_CHECKSUMS_SHA256.txt'), checksums.join('\n') + '\n', 'utf8');
writeFileSync(join(outputRoot, 'RELEASE_NAME.txt'), packageName + '\n', 'utf8');
console.log(`Release-Staging erstellt: ${stage}`);
console.log(`${checksums.length} Dateien mit SHA-256 erfasst.`);
writeFileSync(join(stage, 'FILE_CHECKSUMS_SHA256.txt'), `${checksums.join('\n')}\n`, 'utf8');
writeFileSync(join(outputRoot, 'RELEASE_NAME.txt'), `${packageName}\n`, 'utf8');
console.log(`Manifestbasiertes Release-Staging erstellt: ${stage}`);
console.log(`${manifest.files.length} Payload-Dateien und ${checksums.length} Paketdateien mit SHA-256 erfasst.`);
+29
View File
@@ -0,0 +1,29 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildReleaseManifest, MANIFEST_FILE } from './release-manifest-lib.mjs';
export function generateReleaseManifest(root) {
const base = resolve(root);
const pkg = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8'));
const update = JSON.parse(readFileSync(join(base, 'update-manifest.json'), 'utf8'));
const manifest = buildReleaseManifest(base, {
version: pkg.version,
updaterVersion: update.updaterVersion || update.updater_version,
removedFiles: Array.isArray(update.removedFiles) ? update.removedFiles : [],
});
writeFileSync(join(base, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
return manifest;
}
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : '';
if (invokedPath === fileURLToPath(import.meta.url)) {
const root = process.argv[2] || process.cwd();
try {
const manifest = generateReleaseManifest(root);
console.log(`Release-Manifest erstellt: ${manifest.files.length} Dateien, ${manifest.removedFiles.length} explizite Löschungen.`);
} catch (error) {
console.error(`[FEHLER] Release-Manifest konnte nicht erstellt werden: ${error.message}`);
process.exit(1);
}
}
+233
View File
@@ -0,0 +1,233 @@
import {
chmodSync,
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
export const MANIFEST_SCHEMA = 'vendoo-release-manifest-v1';
export const MANIFEST_FILE = 'release-manifest.json';
export const CONTROL_RELEASE_FILES = Object.freeze([MANIFEST_FILE]);
export const REQUIRED_RELEASE_FILES = Object.freeze([
'package.json',
'package-lock.json',
'bootstrap.mjs',
'server.mjs',
'lib/db.mjs',
'db/schema.sql',
'update-manifest.json',
'UPDATE_VENDOO.bat',
'scripts/updater/Vendoo.Updater.Preflight.ps1',
'scripts/updater/Vendoo.Updater.UI.ps1',
'scripts/updater/Vendoo.Updater.Host.ps1',
'scripts/updater/Vendoo.Updater.Worker.ps1',
'tools/apply-release-manifest.mjs',
]);
const allowedEnvTemplates = new Set(['.env.example', '.env.docker.example']);
const skippedTopLevelDirs = new Set([
'.git', 'node_modules', 'runtime', 'docker-data', 'uploads', 'backups', 'logs',
'sessions', 'tmp', 'temp', '__pycache__', 'release-output', 'operations', 'updates',
'modules', 'archiv', 'archive', 'archives', 'data',
]);
const skippedLocalAiDirs = new Set(['local-ai/comfyui', 'local-ai/downloads', 'local-ai/extract-temp']);
const skippedExactFiles = new Set([
MANIFEST_FILE,
'file_checksums_sha256.txt',
'.vendoo-install-mode', '.vendoo-github-state.json',
'pending-operation.json', 'last-operation.json', 'first-admin-code.txt',
]);
const skippedExtensions = new Set([
'.db', '.sqlite', '.sqlite3', '.log', '.key', '.pem', '.pfx', '.p12', '.pyc',
]);
export function normalizeReleasePath(value) {
return String(value).split('\\').join('/').replace(/^\.\//, '');
}
export function assertSafeReleasePath(value, label = 'Release-Pfad') {
const rel = normalizeReleasePath(value);
if (!rel || rel === '.' || isAbsolute(rel) || rel.startsWith('/') || /^[A-Za-z]:\//.test(rel)) {
throw new Error(`${label} ist nicht relativ: ${value}`);
}
const parts = rel.split('/');
if (parts.some(part => !part || part === '.' || part === '..')) {
throw new Error(`${label} enthält unzulässige Segmente: ${value}`);
}
if (parts[0].toLowerCase() === '.git') {
throw new Error(`${label} darf .git nicht verändern: ${value}`);
}
return rel;
}
export function shouldIncludeReleaseEntry(relativePath, stat) {
const rel = normalizeReleasePath(relativePath);
if (!rel) return false;
const parts = rel.split('/');
const top = parts[0].toLowerCase();
const name = basename(rel);
const lower = rel.toLowerCase();
if (skippedTopLevelDirs.has(top)) return false;
if (parts.some(part => part.toLowerCase() === '__pycache__')) return false;
if ([...skippedLocalAiDirs].some(dir => lower === dir || lower.startsWith(`${dir}/`))) return false;
if (/^\.env(?:\..+)?$/i.test(name) && !allowedEnvTemplates.has(rel)) return false;
if (skippedExactFiles.has(name.toLowerCase())) return false;
if (/\.(?:db|sqlite|sqlite3)-(?:wal|shm)$/i.test(name)) return false;
if (skippedExtensions.has(extname(name).toLowerCase())) return false;
if (stat.isSymbolicLink()) return false;
return stat.isDirectory() || stat.isFile();
}
export function sha256File(filePath) {
const hash = createHash('sha256');
hash.update(readFileSync(filePath));
return hash.digest('hex');
}
export function listReleaseFiles(root) {
const base = resolve(root);
const files = [];
function walk(current) {
for (const name of readdirSync(current).sort((a, b) => a.localeCompare(b, 'en'))) {
const absolute = join(current, name);
const rel = normalizeReleasePath(relative(base, absolute));
const stat = lstatSync(absolute);
if (!shouldIncludeReleaseEntry(rel, stat)) continue;
if (stat.isDirectory()) walk(absolute);
else files.push(rel);
}
}
walk(base);
return files;
}
export function buildReleaseManifest(root, { version, updaterVersion, removedFiles = [] } = {}) {
if (!version) throw new Error('Release-Version fehlt.');
if (!updaterVersion) throw new Error('Updater-Version fehlt.');
const base = resolve(root);
const files = listReleaseFiles(base).map(path => ({
path,
sha256: sha256File(join(base, path)),
size: statSync(join(base, path)).size,
}));
const normalizedRemoved = [...new Set(removedFiles.map(path => assertSafeReleasePath(path, 'removedFiles-Eintrag')))].sort();
const fileSet = new Set(files.map(entry => entry.path));
for (const required of REQUIRED_RELEASE_FILES) {
if (!fileSet.has(required)) throw new Error(`Pflichtdatei fehlt im Release-Payload: ${required}`);
}
for (const removed of normalizedRemoved) {
if (fileSet.has(removed)) throw new Error(`Datei ist gleichzeitig Payload und Löschung: ${removed}`);
if (CONTROL_RELEASE_FILES.includes(removed)) throw new Error(`Steuerdatei darf nicht als Löschung markiert werden: ${removed}`);
}
return {
schema: MANIFEST_SCHEMA,
version,
updaterVersion,
mode: 'overlay-with-explicit-removals',
requiredFiles: [...REQUIRED_RELEASE_FILES],
removedFiles: normalizedRemoved,
files,
};
}
export function readAndValidateManifest(sourceRoot, { allowExtraFiles = false } = {}) {
const base = resolve(sourceRoot);
const manifestPath = join(base, MANIFEST_FILE);
if (!existsSync(manifestPath)) throw new Error(`${MANIFEST_FILE} fehlt.`);
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (manifest.schema !== MANIFEST_SCHEMA) throw new Error(`Unbekanntes Manifest-Schema: ${manifest.schema}`);
if (manifest.mode !== 'overlay-with-explicit-removals') throw new Error(`Unzulässiger Release-Modus: ${manifest.mode}`);
if (!Array.isArray(manifest.files) || manifest.files.length === 0) throw new Error('Release-Manifest enthält keine Dateien.');
if (!Array.isArray(manifest.removedFiles)) throw new Error('removedFiles fehlt im Release-Manifest.');
const seen = new Set();
for (const entry of manifest.files) {
if (!entry || typeof entry !== 'object') throw new Error('Ungültiger Datei-Eintrag im Release-Manifest.');
const rel = assertSafeReleasePath(entry.path, 'Manifest-Dateipfad');
if (seen.has(rel)) throw new Error(`Doppelter Manifest-Dateipfad: ${rel}`);
seen.add(rel);
if (!/^[a-f0-9]{64}$/.test(String(entry.sha256 || ''))) throw new Error(`Ungültige SHA-256-Prüfsumme: ${rel}`);
if (!Number.isSafeInteger(entry.size) || entry.size < 0) throw new Error(`Ungültige Dateigröße: ${rel}`);
}
const removedSeen = new Set();
for (const value of manifest.removedFiles) {
const rel = assertSafeReleasePath(value, 'removedFiles-Eintrag');
if (removedSeen.has(rel)) throw new Error(`Doppelter removedFiles-Eintrag: ${rel}`);
removedSeen.add(rel);
if (seen.has(rel)) throw new Error(`Datei ist gleichzeitig Payload und Löschung: ${rel}`);
if (CONTROL_RELEASE_FILES.includes(rel)) throw new Error(`Steuerdatei darf nicht als Löschung markiert werden: ${rel}`);
}
const required = Array.isArray(manifest.requiredFiles) ? manifest.requiredFiles : REQUIRED_RELEASE_FILES;
for (const value of REQUIRED_RELEASE_FILES) {
if (!required.includes(value)) throw new Error(`Manifest-Pflichtdatei fehlt in requiredFiles: ${value}`);
if (!seen.has(value)) throw new Error(`Manifest-Payload enthält Pflichtdatei nicht: ${value}`);
}
const actualFiles = listReleaseFiles(base);
const actualSet = new Set(actualFiles);
if (!allowExtraFiles) {
for (const rel of actualFiles) if (!seen.has(rel)) throw new Error(`Sichere Paketdatei fehlt im Manifest: ${rel}`);
}
for (const rel of seen) if (!actualSet.has(rel)) throw new Error(`Manifest verweist auf fehlende Paketdatei: ${rel}`);
for (const entry of manifest.files) {
const filePath = join(base, entry.path);
const stat = statSync(filePath);
if (stat.size !== entry.size) throw new Error(`Dateigröße stimmt nicht: ${entry.path}`);
const actualHash = sha256File(filePath);
if (actualHash !== entry.sha256) throw new Error(`SHA-256 stimmt nicht: ${entry.path}`);
}
return manifest;
}
export function applyReleaseManifest(sourceRoot, destinationRoot) {
const source = resolve(sourceRoot);
const destination = resolve(destinationRoot);
if (source === destination) throw new Error('Quell- und Zielordner dürfen nicht identisch sein.');
if (!existsSync(join(destination, '.git'))) throw new Error(`Ziel ist kein geklontes Git-Repository: ${destination}`);
const manifest = readAndValidateManifest(source);
for (const rel of manifest.removedFiles) {
rmSync(join(destination, rel), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
let copied = 0;
for (const entry of manifest.files) {
const sourceFile = join(source, entry.path);
const targetFile = join(destination, entry.path);
mkdirSync(dirname(targetFile), { recursive: true });
copyFileSync(sourceFile, targetFile);
try { chmodSync(targetFile, statSync(sourceFile).mode & 0o777); } catch {}
const targetHash = sha256File(targetFile);
if (targetHash !== entry.sha256) throw new Error(`Zielprüfung nach Kopieren fehlgeschlagen: ${entry.path}`);
copied += 1;
}
let controlCopied = 0;
for (const rel of CONTROL_RELEASE_FILES) {
const sourceFile = join(source, rel);
const targetFile = join(destination, rel);
mkdirSync(dirname(targetFile), { recursive: true });
copyFileSync(sourceFile, targetFile);
if (sha256File(sourceFile) !== sha256File(targetFile)) {
throw new Error(`Steuerdatei-Prüfung nach Kopieren fehlgeschlagen: ${rel}`);
}
controlCopied += 1;
}
for (const required of REQUIRED_RELEASE_FILES) {
if (!existsSync(join(destination, required))) throw new Error(`Pflichtdatei fehlt nach Release-Anwendung: ${required}`);
}
for (const controlFile of CONTROL_RELEASE_FILES) {
if (!existsSync(join(destination, controlFile))) throw new Error(`Steuerdatei fehlt nach Release-Anwendung: ${controlFile}`);
}
return {
source,
destination,
version: manifest.version,
updaterVersion: manifest.updaterVersion,
copied,
controlCopied,
removed: manifest.removedFiles.length,
};
}
+34
View File
@@ -0,0 +1,34 @@
import { rmSync } from 'node:fs';
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function closeVendooDatabase({ failures = [], label = 'Temporäre SQLite-Datenbank' } = {}) {
try {
const { db } = await import('../lib/db.mjs');
if (db?.open) {
try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch {}
db.close();
}
return true;
} catch (error) {
failures.push(`${label} konnte nicht sauber geschlossen werden: ${error.code || error.message}`);
return false;
}
}
export async function removeTempDirectory(path, {
failures = [],
label = 'Temporärer Testordner',
settleMs = 100,
maxRetries = 30,
retryDelay = 100,
} = {}) {
if (settleMs > 0) await wait(settleMs);
try {
rmSync(path, { recursive: true, force: true, maxRetries, retryDelay });
return true;
} catch (error) {
failures.push(`${label} konnte nicht entfernt werden: ${error.code || error.message} (${path})`);
return false;
}
}
+42 -25
View File
@@ -1,38 +1,55 @@
import { ModuleRegistry } from '../app/kernel/module-registry.mjs';
import { loadBuiltInModules } from '../app/modules/catalog.mjs';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { closeVendooDatabase, removeTempDirectory } from './test-runtime-cleanup.mjs';
const tempPrefix = process.env.VENDOO_TEST_TEMP_PREFIX || 'vendoo-capability-graph-';
const temp = mkdtempSync(join(tmpdir(), tempPrefix));
process.env.NODE_ENV = 'test';
process.env.VENDOO_DATA_DIR = temp;
process.env.VENDOO_DB_PATH = join(temp, 'db', 'vendoo.db');
process.env.VENDOO_CONFIG_PATH = join(temp, 'config', 'vendoo.env');
process.env.VENDOO_MODULE_STATE_PATH = join(temp, 'config', 'modules-state.json');
process.env.VENDOO_MODULES_DIR = join(temp, 'modules');
const failures = [];
const assert = (condition, message) => { if (!condition) failures.push(message); };
const modules = loadBuiltInModules();
const registry = new ModuleRegistry();
for (const module of modules) registry.register(module);
let graph = null;
try {
graph = registry.validateGraph();
} catch (error) {
failures.push(`${error.code || 'CAPABILITY_GRAPH_INVALID'}: ${error.message}`);
const { ModuleRegistry } = await import('../app/kernel/module-registry.mjs');
const { loadBuiltInModules } = await import('../app/modules/catalog.mjs');
const modules = loadBuiltInModules();
const registry = new ModuleRegistry();
for (const module of modules) registry.register(module);
try {
graph = registry.validateGraph();
} catch (error) {
failures.push(`${error.code || 'CAPABILITY_GRAPH_INVALID'}: ${error.message}`);
}
const settings = modules.find(item => item.manifest.id === 'vendoo.settings')?.manifest;
const manager = modules.find(item => item.manifest.id === 'vendoo.module-manager')?.manifest;
const deployments = modules.find(item => item.manifest.id === 'vendoo.deployments')?.manifest;
assert(settings?.provides.includes('settings.configuration'), 'vendoo.settings stellt settings.configuration nicht bereit');
assert(manager?.consumes.includes('settings.configuration'), 'vendoo.module-manager fordert settings.configuration nicht an');
assert(graph?.enabledModules.length === 18, `Erwartet 18 registrierte Module, erhalten ${graph?.enabledModules.length ?? 0}`);
assert(deployments?.consumes.includes('settings.configuration'), 'vendoo.deployments fordert settings.configuration nicht an');
assert(deployments?.consumes.includes('operations.backup'), 'vendoo.deployments fordert operations.backup nicht an');
assert(graph?.capabilities.includes('deployments.coolify'), 'Capability deployments.coolify fehlt im aufgelösten Gesamtgraph');
assert(graph?.capabilities.includes('settings.configuration'), 'Capability settings.configuration fehlt im aufgelösten Gesamtgraph');
assert(graph?.capabilities.includes('product-images.variants'), 'Capability product-images.variants fehlt im aufgelösten Gesamtgraph');
assert(graph?.order.indexOf('vendoo.settings') < graph?.order.indexOf('vendoo.module-manager'), 'vendoo.settings wird nicht vor vendoo.module-manager gestartet');
assert(graph?.order.indexOf('vendoo.module-manager') < graph?.order.indexOf('vendoo.deployments'), 'vendoo.module-manager wird nicht vor vendoo.deployments gestartet');
} finally {
await closeVendooDatabase({ failures, label: 'Capability-Graph-Testdatenbank' });
await removeTempDirectory(temp, { failures, label: 'Capability-Graph-Testordner' });
}
const settings = modules.find(item => item.manifest.id === 'vendoo.settings')?.manifest;
const manager = modules.find(item => item.manifest.id === 'vendoo.module-manager')?.manifest;
const deployments = modules.find(item => item.manifest.id === 'vendoo.deployments')?.manifest;
assert(settings?.provides.includes('settings.configuration'), 'vendoo.settings stellt settings.configuration nicht bereit');
assert(manager?.consumes.includes('settings.configuration'), 'vendoo.module-manager fordert settings.configuration nicht an');
assert(graph?.enabledModules.length === 17, `Erwartet 17 aktivierbare Module, erhalten ${graph?.enabledModules.length ?? 0}`);
assert(deployments?.consumes.includes('settings.configuration'), 'vendoo.deployments fordert settings.configuration nicht an');
assert(deployments?.consumes.includes('operations.backup'), 'vendoo.deployments fordert operations.backup nicht an');
assert(graph?.capabilities.includes('deployments.coolify'), 'Capability deployments.coolify fehlt im aufgelösten Gesamtgraph');
assert(graph?.capabilities.includes('settings.configuration'), 'Capability settings.configuration fehlt im aufgelösten Gesamtgraph');
assert(graph?.order.indexOf('vendoo.settings') < graph?.order.indexOf('vendoo.module-manager'), 'vendoo.settings wird nicht vor vendoo.module-manager gestartet');
assert(graph?.order.indexOf('vendoo.module-manager') < graph?.order.indexOf('vendoo.deployments'), 'vendoo.module-manager wird nicht vor vendoo.deployments gestartet');
if (failures.length) {
console.error(`Capability-Graph-Gate 1.41.2 fehlgeschlagen (${failures.length}):`);
failures.forEach(item => console.error(`- ${item}`));
process.exit(1);
}
console.log(`Capability-Graph-Gate 1.41.2 erfolgreich: ${graph.enabledModules.length} Module, ${graph.capabilities.length} Capabilities und vollständige Provider-Verträge geprüft.`);
+2 -2
View File
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs';import { dirname,join,resolve } from 'node:path';import { fileURLToPath } from 'node:url';
const root=resolve(dirname(fileURLToPath(import.meta.url)),'..');const read=r=>readFileSync(join(root,r),'utf8');const failures=[];const assert=(c,m)=>{if(!c)failures.push(m)};
const pkg=JSON.parse(read('package.json'));assert(pkg.version==='1.41.2',`Paketversion ist ${pkg.version}`);const server=read('server.mjs');const catalog=read('app/modules/catalog.mjs');const kernel=read('app/kernel/platform-kernel.mjs');const frontend=read('public/app.js');assert(!frontend.includes('...state.currentListing,\n ...payload'),'Frontend sendet unkontrolliertes Listing-Objekt an die native Allowlist');
const pkg=JSON.parse(read('package.json'));assert(pkg.version==='1.43.0',`Paketversion ist ${pkg.version}`);const server=read('server.mjs');const catalog=read('app/modules/catalog.mjs');const kernel=read('app/kernel/platform-kernel.mjs');const frontend=read('public/app.js');assert(!frontend.includes('...state.currentListing,\n ...payload'),'Frontend sendet unkontrolliertes Listing-Objekt an die native Allowlist');
for(const marker of ['registerListingsRoutes','registerInventoryRoutes','registerMediaRoutes','configureMediaModule'])assert(server.includes(marker),`Serverregistrierung fehlt: ${marker}`);
for(const legacy of ["app.post('/api/listings'","app.get('/api/listings'","app.get('/api/media-library'","app.get('/api/inventory/locations'","app.get('/api/trash'"])assert(!server.includes(legacy),`Legacy-Route verblieben: ${legacy}`);
for(const id of ['listings','inventory','media']){const manifest=JSON.parse(read(`app/modules/${id}/module.json`));assert(manifest.status==='native',`${id} ist nicht nativ`);assert(read(`app/modules/${id}/routes.mjs`).includes("owner:'vendoo."+id)||read(`app/modules/${id}/routes.mjs`).includes("owner: 'vendoo."+id),`${id}-Routenbesitzer fehlt`)}
@@ -8,4 +8,4 @@ for(const marker of ['createListingsModule','createInventoryModule','createMedia
const listingService=read('app/modules/listings/service.mjs'),listingRoutes=read('app/modules/listings/routes.mjs'),inventoryService=read('app/modules/inventory/service.mjs'),mediaService=read('app/modules/media/service.mjs');
for(const marker of ['ALLOWED_FIELDS','Unbekanntes Artikelfeld','LISTING_TRASH_NOT_FOUND','sanitizeRichHtml'])assert(listingService.includes(marker),`Listing-Schutz fehlt: ${marker}`);for(const marker of ['validateResourceLock','listings.permanent-delete','listing.soft_deleted'])assert(listingRoutes.includes(marker),`Listing-Route fehlt: ${marker}`);for(const marker of ['Maximal 500 Artikel','Unbekanntes Lagerfeld','INVENTORY_LOCATION_INVALID'])assert(inventoryService.includes(marker),`Inventory-Schutz fehlt: ${marker}`);for(const marker of ['normalizePath','p.includes(\'..\')','referenced','protectedPaths','validateFavorite','validateDelete'])assert(mediaService.includes(marker),`Media-Schutz fehlt: ${marker}`);
for(const marker of ["policies.register('listings.permanent-delete'","policies.register('inventory.manage'","policies.register('media.delete'"])assert(kernel.includes(marker),`Policy fehlt: ${marker}`);
if(failures.length){console.error(`Catalog-&-Media-Gate 1.41.2 fehlgeschlagen (${failures.length}):`);failures.forEach(x=>console.error(`- ${x}`));process.exit(1)}console.log('Catalog-&-Media-Gate 1.41.2 erfolgreich: native Listings-, Inventory- und Media-Routen, Payload-Allowlist, Locking, Papierkorbgrenzen, Bulk-Limits und Medien-Referenzschutz geprüft.');
if(failures.length){console.error(`Catalog-&-Media-Gate 1.43.0 fehlgeschlagen (${failures.length}):`);failures.forEach(x=>console.error(`- ${x}`));process.exit(1)}console.log('Catalog-&-Media-Gate 1.43.0 erfolgreich: native Listings-, Inventory- und Media-Routen, Payload-Allowlist, Locking, Papierkorbgrenzen, Bulk-Limits und Medien-Referenzschutz geprüft.');
@@ -0,0 +1,57 @@
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { readFileSync } from 'node:fs';
import { closeVendooDatabase, removeTempDirectory } from './test-runtime-cleanup.mjs';
const tempPrefix = process.env.VENDOO_TEST_TEMP_PREFIX || 'vendoo-invitations-';
const temp = mkdtempSync(join(tmpdir(), tempPrefix));
process.env.NODE_ENV = 'test';
process.env.VENDOO_DATA_DIR = temp;
process.env.VENDOO_DB_PATH = join(temp, 'db', 'vendoo.db');
process.env.VENDOO_CONFIG_PATH = join(temp, 'config', 'vendoo.env');
const failures = [];
const assert = (condition, message) => { if (!condition) failures.push(message); };
try {
const identity = await import('../app/core/identity/identity-store.mjs');
const { createUsersService } = await import('../app/modules/users/service.mjs');
const first = identity.createAuthToken('tester@example.invalid', 'invite', 'viewer', null);
assert(first.token && first.expiresAt, 'Einladungstoken oder Ablauf fehlt');
let invitations = identity.listInvitations();
assert(invitations.length === 1, `Erwartet eine Einladung, erhalten ${invitations.length}`);
assert(invitations[0].status === 'pending', `Neue Einladung ist ${invitations[0].status}`);
assert(!Object.hasOwn(invitations[0], 'token_hash'), 'Token-Hash wird in der Einladungsliste ausgegeben');
assert(invitations[0].role === 'viewer', `Minimale Testerrolle ist ${invitations[0].role}`);
const second = identity.createAuthToken('tester@example.invalid', 'invite', 'viewer', null);
invitations = identity.listInvitations();
assert(invitations.filter(item => item.status === 'pending').length === 1, 'Erneutes Senden lässt mehrere gültige Einladungen offen');
const pending = invitations.find(item => item.status === 'pending');
const revoked = identity.revokeInvitation(pending.id);
assert(revoked?.status === 'revoked', 'Einladung wurde nicht widerrufen');
assert(identity.validateAuthToken(second.token) === null, 'Widerrufener Link bleibt gültig');
const service = createUsersService();
const prepared = service.prepareInvite({ email: 'minimal@example.invalid', name: 'Minimal', createdBy: null, method: 'magic_link' });
assert(prepared.role === 'viewer', `Service-Standardrolle ist ${prepared.role}`);
const routes = readFileSync(new URL('../app/modules/users/routes.mjs', import.meta.url), 'utf8');
for (const marker of ['/api/admin/users/invitations', '/api/admin/users/invitations/:id/resend', "'user.invitation_revoked'", "policy: 'users.manage'", 'csrf: true']) {
assert(routes.includes(marker), `Einladungs-Routenvertrag fehlt: ${marker}`);
}
const frontend = readFileSync(new URL('../public/app.js', import.meta.url), 'utf8');
assert(frontend.includes('loadAdminInvitations'), 'Einladungsübersicht fehlt im Frontend');
assert(frontend.includes("invite-role').value = 'viewer'"), 'Einladungsdialog startet nicht mit Leserrolle');
} finally {
await closeVendooDatabase({ failures, label: 'Einladungs-Testdatenbank' });
await removeTempDirectory(temp, { failures, label: 'Einladungs-Testordner' });
}
if (failures.length) {
console.error(`Controlled-Invitations-Gate 1.42.0 fehlgeschlagen (${failures.length}):`);
failures.forEach(failure => console.error(`- ${failure}`));
process.exit(1);
}
console.log('Controlled-Invitations-Gate 1.42.0 erfolgreich: Leser-Standardrolle, 48h-Ablauf, Einmalgültigkeit, erneutes Senden, Widerruf, Secret-Grenze, RBAC und UI geprüft.');
process.exit(0);
+139
View File
@@ -0,0 +1,139 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
const APP = resolve(fileURLToPath(new URL('..', import.meta.url)));
const BASE = process.argv[2] ? resolve(process.argv[2]) : null;
function execDbExecBlocks(db, sourcePath) {
const text = readFileSync(sourcePath, 'utf8');
const blocks = [...text.matchAll(/db\.exec\(`([\s\S]*?)`\)/g)];
let applied = 0;
for (const [, sql] of blocks) {
if (sql.includes('${')) continue;
try { db.exec(sql); applied += 1; } catch { /* additive ALTER blocks intentionally tolerate existing columns */ }
}
if (text.includes('operations-center-1.30.0')) {
db.prepare("INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) VALUES ('operations-center-1.30.0','1.30.0','Backup, Restore, Update Center und Extension-Diagnose')").run();
}
return applied;
}
function columns(db, table) {
return new Set(db.prepare(`PRAGMA table_info(${JSON.stringify(table)})`).all().map((row) => row.name));
}
function verify(db) {
const requiredTables = new Set([
'flux_images', 'flux_prompt_jobs', 'flux_prompt_job_variants', 'image_edit_versions',
'image_batch_profiles', 'image_batch_jobs', 'image_batch_job_items', 'quality_reports',
'publishing_jobs', 'publishing_events', 'operation_backups', 'operation_settings',
'update_operations', 'system_migrations', 'extension_clients', 'users', 'sessions',
'audit_log', 'resource_locks', 'theme_profiles', 'user_theme_preferences',
]);
const existingTables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name));
const missingTables = [...requiredTables].filter((name) => !existingTables.has(name));
const checks = {
flux_images: ['favorite', 'job_id', 'variant_index', 'prompt_mode', 'parameters_json'],
flux_prompt_jobs: ['archived_at'],
image_edit_versions: ['root_path', 'source_path', 'output_path', 'context', 'operations_json', 'width', 'height', 'format', 'quality', 'file_size', 'user_id', 'created_at'],
image_batch_jobs: ['name', 'status', 'phase', 'source_count', 'profile_count', 'total_items', 'completed_items', 'failed_items', 'cancelled_items', 'pause_requested', 'cancel_requested', 'settings_json', 'created_at', 'updated_at'],
image_batch_job_items: ['job_id', 'source_path', 'profile_id', 'profile_key', 'profile_name', 'status', 'phase', 'settings_json', 'output_path', 'error_message', 'created_at', 'updated_at'],
quality_reports: ['listing_id', 'score', 'grade', 'ready', 'blockers', 'warnings', 'recommendations', 'report_json', 'analyzed_at'],
publishing_jobs: ['id', 'listing_id', 'platform', 'mode', 'action', 'status', 'phase', 'idempotency_key', 'external_id', 'external_url', 'offer_id', 'retries', 'max_retries', 'next_attempt_at', 'claimed_by', 'claimed_at', 'error_code', 'error_message', 'payload_json', 'result_json', 'created_at', 'started_at', 'completed_at', 'updated_at'],
publishing_events: ['id', 'job_id', 'listing_id', 'platform', 'level', 'event_type', 'message', 'details_json', 'created_at'],
publish_log: ['offer_id', 'sync_status', 'last_synced_at', 'ended_at', 'updated_at'],
operation_backups: ['id', 'kind', 'label', 'file_name', 'file_path', 'file_size', 'sha256', 'status', 'verified_at', 'options_json', 'manifest_json', 'created_at'],
operation_settings: ['key', 'value', 'updated_at'],
update_operations: ['id', 'version_from', 'version_to', 'source_name', 'archive_path', 'sha256', 'status', 'details_json', 'created_at', 'applied_at', 'error_message'],
system_migrations: ['id', 'migration_key', 'app_version', 'description', 'applied_at'],
extension_clients: ['client_id', 'browser', 'version', 'platform', 'server_url', 'user_agent', 'capabilities_json', 'first_seen', 'last_seen'],
users: ['id', 'email', 'name', 'password_hash', 'role', 'active', 'avatar_color', 'last_active_at', 'failed_login_count', 'locked_until', 'password_changed_at', 'created_at', 'invited_by'],
sessions: ['id', 'user_id', 'token_hash', 'ip', 'user_agent', 'expires_at', 'last_seen_at', 'revoked_at', 'created_at'],
audit_log: ['id', 'user_id', 'user_email', 'action', 'target_type', 'target_id', 'details', 'ip', 'created_at'],
resource_locks: ['id', 'resource_type', 'resource_id', 'user_id', 'token_hash', 'acquired_at', 'renewed_at', 'expires_at', 'client_label'],
theme_profiles: ['id', 'name', 'base_theme', 'values_json', 'created_by', 'created_at', 'updated_at'],
user_theme_preferences: ['user_id', 'theme_id', 'density', 'reduced_motion', 'updated_at'],
};
const missingColumns = {};
for (const [table, required] of Object.entries(checks)) {
const existing = columns(db, table);
const missing = required.filter((name) => !existing.has(name));
if (missing.length) missingColumns[table] = missing.sort();
}
if (missingTables.length || Object.keys(missingColumns).length) {
throw new Error(JSON.stringify({ missing_tables: missingTables.sort(), missing_columns: missingColumns }));
}
}
const tempRoot = mkdtempSync(join(tmpdir(), 'vendoo-db-migrations-'));
let fresh;
let existing;
try {
fresh = new DatabaseSync(join(tempRoot, 'fresh.db'));
fresh.exec(readFileSync(join(APP, 'db/schema.sql'), 'utf8'));
const freshBlocks = execDbExecBlocks(fresh, join(APP, 'lib/db.mjs'));
verify(fresh);
existing = new DatabaseSync(join(tempRoot, 'existing.db'));
existing.exec(readFileSync(join(APP, 'db/schema.sql'), 'utf8'));
if (BASE) execDbExecBlocks(existing, join(BASE, 'lib/db.mjs'));
existing.prepare("INSERT INTO flux_images (prompt,image_path,width,height,seed,steps,style) VALUES ('Bestand','ai-generated/existing.png',768,768,42,4,'none')").run();
const existingBlocks = execDbExecBlocks(existing, join(APP, 'lib/db.mjs'));
verify(existing);
const row = existing.prepare("SELECT prompt,image_path,favorite FROM flux_images WHERE image_path='ai-generated/existing.png'").get();
if (!row || row.prompt !== 'Bestand' || row.image_path !== 'ai-generated/existing.png' || row.favorite !== 0) {
throw new Error(`Bestandsdaten verändert: ${JSON.stringify(row)}`);
}
const insertJob = existing.prepare(`INSERT INTO flux_prompt_jobs
(id,prompt,final_prompt,width,height,steps,style,profile,prompt_mode,assistant,variants,seed_base,seed_lock,status,phase,settings_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
for (const [jobId, status] of [['job-complete', 'completed'], ['job-failed', 'failed'], ['job-active', 'running']]) {
insertJob.run(jobId, 'Test', 'Test', 768, 768, 6, 'none', 'balanced', 'precise', 1, 1, 42, 0, status, status, '{}');
}
existing.prepare("UPDATE flux_prompt_jobs SET archived_at=datetime('now') WHERE id IN ('job-complete','job-failed','job-active') AND status NOT IN ('queued','running','cancelling')").run();
const archived = new Set(existing.prepare('SELECT id FROM flux_prompt_jobs WHERE archived_at IS NOT NULL').all().map((item) => item.id));
if (archived.size !== 2 || !archived.has('job-complete') || !archived.has('job-failed')) {
throw new Error(`Archivschutz inkorrekt: ${JSON.stringify([...archived])}`);
}
existing.prepare("INSERT INTO listings (title, platform, ai_provider, photos, tags) VALUES ('Publishing Test', 'ebay-de', 'openai', '[]', '[]')").run();
const listingId = existing.prepare('SELECT last_insert_rowid() AS id').get().id;
existing.prepare("INSERT INTO publishing_jobs (id, listing_id, platform, mode, action, status, phase, idempotency_key) VALUES ('pub-job-1', ?, 'ebay-de', 'api', 'publish', 'queued', 'queued', 'key-1')").run(listingId);
existing.prepare("INSERT INTO publishing_events (job_id, listing_id, platform, level, event_type, message) VALUES ('pub-job-1', ?, 'ebay-de', 'info', 'created', 'Auftrag erstellt')").run(listingId);
let duplicateGuard = false;
try {
existing.prepare("INSERT INTO publishing_jobs (id, listing_id, platform, mode, action, status, phase, idempotency_key) VALUES ('pub-job-2', ?, 'ebay-de', 'api', 'publish', 'queued', 'queued', 'key-1')").run(listingId);
} catch { duplicateGuard = true; }
if (!duplicateGuard) throw new Error('Idempotency-Key verhindert keinen doppelten Auftrag');
existing.prepare("UPDATE publishing_jobs SET status='running', phase='publish' WHERE id='pub-job-1'").run();
existing.prepare("UPDATE publishing_jobs SET status='queued', phase='recovered' WHERE status='running'").run();
const recovered = existing.prepare("SELECT status, phase FROM publishing_jobs WHERE id='pub-job-1'").get();
if (recovered.status !== 'queued' || recovered.phase !== 'recovered') throw new Error(`Publishing-Recovery fehlgeschlagen: ${JSON.stringify(recovered)}`);
existing.prepare("INSERT INTO operation_settings (key,value) VALUES ('auto_backup_enabled','1')").run();
existing.prepare("INSERT INTO operation_backups (id,kind,file_name,file_path,status) VALUES ('backup-1','manual','test.zip','/tmp/test.zip','verified')").run();
existing.prepare("INSERT INTO update_operations (id,version_from,version_to,archive_path,status) VALUES ('update-1','1.29.0','1.30.0','/tmp/update.zip','staged')").run();
existing.prepare("INSERT INTO extension_clients (client_id,browser,version,platform) VALUES ('client-1','chrome','1.9.0','popup')").run();
const migration = existing.prepare("SELECT app_version FROM system_migrations WHERE migration_key='operations-center-1.30.0'").get();
if (!migration || migration.app_version !== '1.30.0') throw new Error(`Operations-Migration fehlt: ${JSON.stringify(migration)}`);
const extension = existing.prepare("SELECT browser,version FROM extension_clients WHERE client_id='client-1'").get();
if (!extension || extension.browser !== 'chrome' || extension.version !== '1.9.0') throw new Error(`Extension-Client fehlerhaft: ${JSON.stringify(extension)}`);
console.log(JSON.stringify({
ok: true,
fresh_db_exec_blocks: freshBlocks,
existing_db_exec_blocks: existingBlocks,
existing_row_preserved: true,
publishing_hardening: { tables: ['publishing_jobs', 'publishing_events'], idempotency_key_unique: true, restart_recovery: true, events_persisted: true },
operations_center: { backup_tables: true, update_tables: true, extension_heartbeat: true, migration_recorded: true },
tables: ['flux_images', 'flux_prompt_jobs', 'flux_prompt_job_variants', 'image_edit_versions', 'image_batch_profiles', 'image_batch_jobs', 'image_batch_job_items', 'quality_reports', 'publishing_jobs', 'publishing_events', 'operation_backups', 'operation_settings', 'update_operations', 'system_migrations', 'extension_clients', 'theme_profiles', 'user_theme_preferences'],
}, null, 2));
} finally {
try { fresh?.close(); } catch {}
try { existing?.close(); } catch {}
rmSync(tempRoot, { recursive: true, force: true });
}
+3 -3
View File
@@ -14,7 +14,7 @@ const fluxModule = read('app/modules/flux-studio/index.mjs');
const ci = read('.github/workflows/ci.yml');
const release = read('.github/workflows/release.yml');
if (pkg.version !== '1.41.2') failures.push(`package.json-Version ist ${pkg.version}`);
if (pkg.version !== '1.43.0') failures.push(`package.json-Version ist ${pkg.version}`);
expect(server, 'startFluxPromptJobLoop, stopFluxPromptJobLoop', 'Server importiert Start- und Stop-Funktion');
expect(server, 'configureFluxStudioModule({ start: startFluxPromptJobLoop, stop: stopFluxPromptJobLoop })', 'FLUX-Lifecycle ist vollständig verdrahtet');
expect(fluxJobs, 'export function stopFluxPromptJobLoop()', 'fehlender Stop-Export');
@@ -29,8 +29,8 @@ expect(release, 'npm run verify:esm-exports', 'Release prüft ESM-Exportverträg
expect(release, 'npm run verify:flux-loop', 'Release prüft FLUX-Hotfix');
if (failures.length) {
console.error(`FLUX-Loop-Hotfix-Gate 1.41.2 fehlgeschlagen (${failures.length}):`);
console.error(`FLUX-Loop-Hotfix-Gate 1.43.0 fehlgeschlagen (${failures.length}):`);
failures.forEach(item => console.error(`- ${item}`));
process.exit(1);
}
console.log('FLUX-Loop-Hotfix-Gate 1.41.2 erfolgreich: Stop-Export, Lifecycle-Verdrahtung und CI-Verträge geprüft.');
console.log('FLUX-Loop-Hotfix-Gate 1.43.0 erfolgreich: Stop-Export, Lifecycle-Verdrahtung und CI-Verträge geprüft.');
+63
View File
@@ -0,0 +1,63 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
const source = readFileSync(join(root, 'app/core/identity/identity-store.mjs'), 'utf8');
const match = source.match(/db\.exec\(`([\s\S]*?)`\);/);
if (!match) throw new Error('Identity-Schema-Block fehlt');
const tempRoot = mkdtempSync(join(tmpdir(), 'vendoo-identity-140-'));
const db = new DatabaseSync(join(tempRoot, 'identity.db'));
try {
db.exec('PRAGMA foreign_keys = ON');
db.exec(match[1]);
for (const statement of [
'ALTER TABLE users ADD COLUMN failed_login_count INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN locked_until TEXT',
'ALTER TABLE users ADD COLUMN password_changed_at TEXT',
'ALTER TABLE sessions ADD COLUMN last_seen_at TEXT',
'ALTER TABLE sessions ADD COLUMN revoked_at TEXT',
]) {
try { db.exec(statement); } catch { /* additive migration may already exist */ }
}
const insertUser = db.prepare('INSERT INTO users(email,name,role,password_hash) VALUES(?,?,?,?)');
insertUser.run('admin@example.invalid', 'Admin', 'admin', 'salt:hash');
insertUser.run('editor@example.invalid', 'Editor', 'editor', 'salt:hash');
const adminId = db.prepare("SELECT id FROM users WHERE email='admin@example.invalid'").get().id;
const editorId = db.prepare("SELECT id FROM users WHERE email='editor@example.invalid'").get().id;
const insertSession = db.prepare("INSERT INTO sessions(user_id,token_hash,expires_at,last_seen_at) VALUES(?,?,datetime('now','+1 day'),datetime('now'))");
insertSession.run(adminId, 'admin-token');
insertSession.run(editorId, 'editor-token');
const editorSession = db.prepare('SELECT id FROM sessions WHERE user_id=?').get(editorId).id;
let result = db.prepare("UPDATE sessions SET revoked_at=datetime('now') WHERE id=? AND user_id=? AND revoked_at IS NULL").run(editorSession, adminId);
if (result.changes !== 0) throw new Error('Fremde Sitzung konnte durch falschen Eigentümer widerrufen werden');
result = db.prepare("UPDATE sessions SET revoked_at=datetime('now') WHERE id=? AND user_id=? AND revoked_at IS NULL").run(editorSession, editorId);
if (result.changes !== 1) throw new Error('Eigene Sitzung konnte nicht widerrufen werden');
db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)');
db.prepare('INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)').run('default_language', 'de');
if (db.prepare("SELECT value FROM settings WHERE key='default_language'").get().value !== 'de') {
throw new Error('Settings-Persistenz fehlgeschlagen');
}
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name));
const required = ['users', 'sessions', 'auth_tokens', 'audit_log', 'login_history', 'settings'];
const missing = required.filter((name) => !tables.has(name));
if (missing.length) throw new Error(`Fehlende Identity-/Settings-Tabellen: ${missing.join(', ')}`);
console.log(JSON.stringify({
ok: true,
tables: required.sort(),
foreign_session_blocked: true,
own_session_revoked: true,
}));
} finally {
db.close();
rmSync(tempRoot, { recursive: true, force: true });
}
+3 -3
View File
@@ -10,7 +10,7 @@ const assert = (condition, message) => { if (!condition) failures.push(message);
const expectThrow = (fn, message) => { try { fn(); failures.push(message); } catch {} };
const pkg = JSON.parse(read('package.json'));
assert(pkg.version === '1.41.2', `Paketversion ist ${pkg.version}`);
assert(pkg.version === '1.43.0', `Paketversion ist ${pkg.version}`);
const server = read('server.mjs');
const authFacade = read('lib/auth.mjs');
const identityStore = read('app/core/identity/identity-store.mjs');
@@ -44,5 +44,5 @@ for (const [id, expected] of [['auth','native'],['users','native'],['sessions','
assert(manifest.status === expected, `${id}-Modul ist nicht nativ`);
}
if (failures.length) { console.error(`Identity-&-Settings-Gate 1.41.2 fehlgeschlagen (${failures.length}):`); failures.forEach(item => console.error(`- ${item}`)); process.exit(1); }
console.log('Identity-&-Settings-Gate 1.41.2 erfolgreich: native Auth-, Benutzer-, Sitzungs- und Settings-Routen, letzten-Admin-Schutz, Sitzungs-Eigentümerprüfung und Settings-Allowlist geprüft.');
if (failures.length) { console.error(`Identity-&-Settings-Gate 1.43.0 fehlgeschlagen (${failures.length}):`); failures.forEach(item => console.error(`- ${item}`)); process.exit(1); }
console.log('Identity-&-Settings-Gate 1.43.0 erfolgreich: native Auth-, Benutzer-, Sitzungs- und Settings-Routen, letzten-Admin-Schutz, Sitzungs-Eigentümerprüfung und Settings-Allowlist geprüft.');
@@ -1,9 +1,12 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { closeVendooDatabase, removeTempDirectory } from './test-runtime-cleanup.mjs';
const temp = mkdtempSync(join(tmpdir(), 'vendoo-module-defaults-'));
const tempPrefix = process.env.VENDOO_TEST_TEMP_PREFIX || 'vendoo-module-defaults-';
const temp = mkdtempSync(join(tmpdir(), tempPrefix));
process.env.VENDOO_DATA_DIR = temp;
process.env.VENDOO_DB_PATH = join(temp, 'db', 'vendoo.db');
process.env.VENDOO_CONFIG_PATH = join(temp, 'config', 'vendoo.env');
process.env.VENDOO_MODULE_STATE_PATH = join(temp, 'config', 'modules-state.json');
process.env.VENDOO_MODULES_DIR = join(temp, 'modules');
@@ -11,21 +14,28 @@ process.env.VENDOO_INITIAL_DISABLED_MODULES = 'vendoo.flux-studio';
const failures = [];
const assert = (condition, message) => { if (!condition) failures.push(message); };
try {
const { createPlatformKernel } = await import('../app/kernel/platform-kernel.mjs');
const first = createPlatformKernel({ version: '1.41.2-test', hasPermission: () => true });
const first = createPlatformKernel({ version: '1.43.0-test', hasPermission: () => true });
assert(first.modules.describe('vendoo.flux-studio')?.enabled === false, 'FLUX Studio ist beim ersten Start nicht deaktiviert');
assert(first.modules.describe('vendoo.product-image-tools')?.enabled === false, 'Produktbild Studio ist beim ersten Start nicht deaktiviert');
assert(first.modules.describe('vendoo.deployments')?.enabled === true, 'Geschütztes Deployment-Modul ist nicht aktiviert');
const { ModuleStateStore } = await import('../app/core/modules/module-state-store.mjs');
new ModuleStateStore(process.env.VENDOO_MODULE_STATE_PATH).setEnabled('vendoo.flux-studio', true, { source: 'test' });
const second = createPlatformKernel({ version: '1.41.2-test', hasPermission: () => true });
assert(second.modules.describe('vendoo.flux-studio')?.enabled === true, 'Gespeicherte Admin-Aktivierung wird durch Initialwert überschrieben');
const stateStore = new ModuleStateStore(process.env.VENDOO_MODULE_STATE_PATH);
stateStore.setEnabled('vendoo.flux-studio', true, { source: 'test' });
stateStore.setEnabled('vendoo.product-image-tools', true, { source: 'test' });
const second = createPlatformKernel({ version: '1.43.0-test', hasPermission: () => true });
assert(second.modules.describe('vendoo.flux-studio')?.enabled === true, 'Gespeicherte FLUX-Aktivierung wird durch Initialwert überschrieben');
assert(second.modules.describe('vendoo.product-image-tools')?.enabled === true, 'Gespeicherte Produktbild-Studio-Aktivierung wird durch Initialwert überschrieben');
} finally {
rmSync(temp, { recursive: true, force: true });
await closeVendooDatabase({ failures, label: 'Initial-Modul-Testdatenbank' });
await removeTempDirectory(temp, { failures, label: 'Initial-Modul-Testordner' });
}
if (failures.length) {
console.error(`Initial-Module-Gate 1.41.2 fehlgeschlagen (${failures.length}):`);
console.error(`Initial-Module-Gate 1.43.0 fehlgeschlagen (${failures.length}):`);
failures.forEach(item => console.error(`- ${item}`));
process.exit(1);
}
console.log('Initial-Module-Gate 1.41.2 erfolgreich: FLUX ist beim ersten Start aus und bleibt nach bewusster Admin-Aktivierung dauerhaft aktivierbar.');
console.log('Initial-Module-Gate 1.43.0 erfolgreich: FLUX und Produktbild Studio sind beim ersten Start aus, bleiben nach bewusster Admin-Aktivierung dauerhaft aktivierbar und geben ihre temporäre SQLite-Datenbank sauber frei.');
@@ -0,0 +1,36 @@
import { readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const root = resolve(import.meta.dirname, '..');
const gate = resolve(root, 'tools', 'verify-initial-disabled-modules-1.41.2.mjs');
const prefix = `vendoo-module-defaults-regression-${process.pid}-${Date.now()}-`;
const failures = [];
for (let attempt = 1; attempt <= 3; attempt += 1) {
const result = spawnSync(process.execPath, [gate], {
cwd: root,
env: { ...process.env, VENDOO_TEST_TEMP_PREFIX: prefix },
encoding: 'utf8',
timeout: 60_000,
});
if (result.error) failures.push(`Durchlauf ${attempt}: ${result.error.message}`);
if (result.status !== 0) {
failures.push(`Durchlauf ${attempt}: Exit-Code ${result.status}
${result.stdout || ''}
${result.stderr || ''}`.trim());
}
}
const leftovers = readdirSync(tmpdir(), { withFileTypes: true })
.filter(entry => entry.isDirectory() && entry.name.startsWith(prefix))
.map(entry => entry.name);
if (leftovers.length) failures.push(`Temporäre Testordner wurden nicht entfernt: ${leftovers.join(', ')}`);
if (failures.length) {
console.error(`Initial-Module-Cleanup-Regression 1.43.0 fehlgeschlagen (${failures.length}):`);
failures.forEach(item => console.error(`- ${item}`));
process.exit(1);
}
console.log('Initial-Module-Cleanup-Regression 1.43.0 erfolgreich: drei isolierte Durchläufe ohne offene SQLite-Datei oder Temp-Rest.');
+189
View File
@@ -0,0 +1,189 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, normalize, resolve, win32 } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const command = packageJson?.scripts?.['verify:installer-shell'] || '';
const isWindowsHost = process.platform === 'win32' || process.env.VENDOO_TEST_WINDOWS_SHELL === '1';
function fail(message) {
console.error(`Installer-Shell-Portabilitätsgate fehlgeschlagen: ${message}`);
process.exit(1);
}
if (/^\s*sh\s+/i.test(command)) {
fail('package.json darf den POSIX-Befehl sh nicht direkt voraussetzen.');
}
if (command !== 'node tools/verify-installer-shell.mjs') {
fail(`Unerwarteter npm-Vertrag: ${command}`);
}
const shellContract = readFileSync(join(root, 'tools', 'verify-installer-shell.sh'), 'utf8');
for (const marker of [
'VENDOO_INSTALLER_SHELL_HOST',
'MINGW*|MSYS*|CYGWIN*',
'POSIX-Modus 0600',
'show_file',
'assert_env_permissions',
'--exclude=.git',
]) {
if (!shellContract.includes(marker)) fail(`Shell-Testvertrag fehlt: ${marker}`);
}
function run(file, args, options = {}) {
return spawnSync(file, args, {
cwd: root,
encoding: 'utf8',
windowsHide: true,
maxBuffer: 16 * 1024 * 1024,
...options,
});
}
function addCandidate(set, value) {
if (!value) return;
const candidate = normalize(value.replace(/^"|"$/g, '').trim());
if (candidate) set.add(candidate);
}
function addGitRootCandidates(set, gitRoot) {
if (!gitRoot) return;
addCandidate(set, join(gitRoot, 'bin', 'sh.exe'));
addCandidate(set, join(gitRoot, 'usr', 'bin', 'sh.exe'));
}
function pathApiFor(value) {
return /^[a-zA-Z]:[\\/]/.test(value) ? win32 : { normalize, dirname };
}
function deriveGitRootFromExecutable(executable) {
const raw = executable.replace(/^"|"$/g, '').trim();
const api = pathApiFor(raw);
const clean = api.normalize(raw);
const parent = api.dirname(clean);
const leaf = parent.toLowerCase().split(/[\\/]/).pop();
if (['cmd', 'bin', 'usr'].includes(leaf)) {
return api.dirname(parent);
}
return api.dirname(clean);
}
function deriveGitRootFromExecPath(execPath) {
const raw = execPath.replace(/^"|"$/g, '').trim();
const api = pathApiFor(raw);
const clean = api.normalize(raw);
const lowered = clean.toLowerCase().replace(/\\/g, '/');
for (const marker of ['/mingw64/', '/mingw32/', '/usr/libexec/', '/libexec/']) {
const index = lowered.indexOf(marker);
if (index > 0) return clean.slice(0, index);
}
return null;
}
function findWindowsGitBash() {
const candidates = new Set();
addCandidate(candidates, process.env.VENDOO_SH);
addCandidate(candidates, process.env.SHELL);
for (const base of [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA]) {
if (!base) continue;
addGitRootCandidates(candidates, join(base, base === process.env.LOCALAPPDATA ? 'Programs' : '', 'Git'));
}
const whereGit = run('where.exe', ['git.exe']);
if (whereGit.status === 0) {
for (const line of whereGit.stdout.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)) {
addGitRootCandidates(candidates, deriveGitRootFromExecutable(line));
}
}
const execPath = run('git.exe', ['--exec-path']);
if (execPath.status === 0) {
const gitRoot = deriveGitRootFromExecPath(execPath.stdout);
if (gitRoot) addGitRootCandidates(candidates, gitRoot);
}
const whereSh = run('where.exe', ['sh.exe']);
if (whereSh.status === 0) {
for (const line of whereSh.stdout.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)) {
addCandidate(candidates, line);
}
}
return [...candidates].find((candidate) => existsSync(candidate)) || null;
}
// Regression contracts for common Git-for-Windows layouts.
const standardRoot = deriveGitRootFromExecutable('C:\\Program Files\\Git\\cmd\\git.exe');
if (standardRoot.toLowerCase() !== win32.normalize('C:\\Program Files\\Git').toLowerCase()) {
fail(`Git-Root aus cmd\\git.exe falsch abgeleitet: ${standardRoot}`);
}
const execRoot = deriveGitRootFromExecPath('C:\\Program Files\\Git\\mingw64\\libexec\\git-core');
if (!execRoot || execRoot.toLowerCase() !== win32.normalize('C:\\Program Files\\Git').toLowerCase()) {
fail(`Git-Root aus git --exec-path falsch abgeleitet: ${execRoot}`);
}
let shell;
if (isWindowsHost) {
shell = process.platform === 'win32' ? findWindowsGitBash() : 'sh';
if (!shell) {
fail('Git Bash wurde trotz installiertem Git nicht gefunden. Erwartet wird bin\\sh.exe oder usr\\bin\\sh.exe innerhalb der Git-for-Windows-Installation.');
}
} else {
const probe = run('sh', ['-c', 'printf vendoo-shell-ok']);
if (probe.status !== 0 || probe.stdout !== 'vendoo-shell-ok') {
fail('Eine funktionsfähige POSIX-Shell wurde nicht gefunden.');
}
shell = 'sh';
}
if (isWindowsHost) {
for (const script of ['setup.sh', 'tools/verify-installer-shell.sh']) {
const syntax = run(shell, ['-n', script], {
env: {
...process.env,
NO_COLOR: '1',
FORCE_COLOR: '0',
MSYS2_ARG_CONV_EXCL: '*',
},
timeout: 60_000,
});
if (syntax.stdout) process.stdout.write(syntax.stdout);
if (syntax.stderr) process.stderr.write(syntax.stderr);
if (syntax.error) fail(`Shell-Syntaxprüfung konnte nicht gestartet werden (${script}): ${syntax.error.message}`);
if (syntax.signal) fail(`Shell-Syntaxprüfung wurde durch Signal ${syntax.signal} beendet (${script}).`);
if (syntax.status !== 0) {
fail(`Shell-Syntaxprüfung fehlgeschlagen (${script}, Exit-Code ${syntax.status}). Verwendete Shell: ${shell}`);
}
}
console.log('Installer-Shell-Portabilitätsgate erfolgreich: Git Bash hat setup.sh und den Linux-Funktionstest syntaktisch geprüft; die funktionale Docker-/NAS-/VPS-Simulation läuft im verpflichtenden Linux-CI-Gate.');
process.exit(0);
}
const result = run(shell, ['tools/verify-installer-shell.sh'], {
env: {
...process.env,
NO_COLOR: '1',
FORCE_COLOR: '0',
VENDOO_INSTALLER_SHELL_HOST: 'posix',
},
timeout: 180_000,
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
if (result.error) fail(`Shell-Gate konnte nicht gestartet werden: ${result.error.message}`);
if (result.signal) fail(`Shell-Gate wurde durch Signal ${result.signal} beendet.`);
if (result.status !== 0) {
const stdout = String(result.stdout || '').trim();
const stderr = String(result.stderr || '').trim();
const diagnostics = [
stderr ? `Shell-Fehlerausgabe:\n${stderr}` : '',
stdout ? `Shell-Standardausgabe:\n${stdout}` : '',
].filter(Boolean).join('\n\n');
fail(`Shell-Gate endete mit Exit-Code ${result.status}. Verwendete Shell: ${shell}${diagnostics ? `\n${diagnostics}` : ''}`);
}
console.log(`Installer-Shell-Portabilitätsgate erfolgreich: ${shell} führte die vollständige Linux-/NAS-/VPS-Simulation mit strikter POSIX-Rechteprüfung aus.`);
+100 -18
View File
@@ -3,9 +3,80 @@ set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
TMP_DIR=$(mktemp -d)
HOST_KIND=${VENDOO_INSTALLER_SHELL_HOST:-auto}
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT INT TERM
fail() {
printf '%s\n' "[FEHLER] $*" >&2
exit 1
}
show_file() {
label=$1
file=$2
printf '%s\n' "--- $label: $file ---" >&2
if [ -f "$file" ]; then cat "$file" >&2; else printf '%s\n' '(Datei fehlt)' >&2; fi
printf '%s\n' "--- Ende $label ---" >&2
}
assert_file() {
file=$1
label=$2
[ -f "$file" ] || fail "$label fehlt: $file"
}
assert_dir() {
dir=$1
label=$2
[ -d "$dir" ] || fail "$label fehlt: $dir"
}
assert_contains() {
pattern=$1
file=$2
label=$3
if ! grep -q "$pattern" "$file"; then
show_file "$label" "$file"
fail "$label enthält das erwartete Muster nicht: $pattern"
fi
}
assert_token() {
file=$1
if ! grep -Eq '^VENDOO_SETUP_TOKEN=[0-9a-f]{64}$' "$file"; then
show_file '.env' "$file"
fail 'VENDOO_SETUP_TOKEN fehlt oder ist nicht 64-stellig hexadezimal.'
fi
}
is_windows_shell() {
if [ "$HOST_KIND" = windows ]; then return 0; fi
if [ "$HOST_KIND" = posix ]; then return 1; fi
case "$(uname -s 2>/dev/null || printf unknown)" in
MINGW*|MSYS*|CYGWIN*) return 0 ;;
*) return 1 ;;
esac
}
assert_env_permissions() {
file=$1
if is_windows_shell; then
# Git Bash runs on NTFS and cannot reliably represent POSIX mode 0600.
# The Linux/NAS/VPS branch below remains strict. On Windows we verify
# that the installer still contains and executes the chmod hardening.
assert_contains 'chmod 600 "$ENV_FILE"' "$ROOT_DIR/setup.sh" 'setup.sh-Härtung'
assert_contains 'chmod 600 "$tmp"' "$ROOT_DIR/setup.sh" 'setup.sh-Härtung'
[ -r "$file" ] && [ -w "$file" ] || fail '.env ist unter Git Bash nicht les- und schreibbar.'
printf '%s\n' '[INFO] NTFS/Git-Bash erkannt: POSIX-Modus 0600 wird statisch geprüft; exakte Modusprüfung erfolgt auf Linux/NAS/VPS.'
return 0
fi
mode=$(stat -c '%a' "$file" 2>/dev/null || true)
[ "$mode" = 600 ] || fail ".env besitzt auf POSIX nicht Modus 600, sondern: ${mode:-unbekannt}"
}
mkdir -p "$TMP_DIR/bin"
cat > "$TMP_DIR/bin/docker" <<'EOF'
#!/usr/bin/env sh
@@ -23,32 +94,43 @@ run_mode() {
input=$2
dir="$TMP_DIR/$name"
mkdir -p "$dir"
tar -C "$ROOT_DIR" --exclude=node_modules --exclude=release-output -cf - . | tar -C "$dir" -xf -
if ! tar -C "$ROOT_DIR" --exclude=.git --exclude=node_modules --exclude=release-output -cf - . | tar -C "$dir" -xf -; then
fail "Release-Kopie für Modus $name konnte nicht erstellt werden."
fi
FAKE_DOCKER_LOG="$dir/docker.log"
export FAKE_DOCKER_LOG
printf '%b' "$input" | PATH="$TMP_DIR/bin:$PATH" sh "$dir/setup.sh" install > "$dir/out.log" 2>&1
if ! printf '%b' "$input" | PATH="$TMP_DIR/bin:$PATH" sh "$dir/setup.sh" install > "$dir/out.log" 2>&1; then
show_file "setup.sh-Ausgabe ($name)" "$dir/out.log"
show_file "Docker-Aufrufe ($name)" "$dir/docker.log"
fail "setup.sh ist im Modus $name fehlgeschlagen."
fi
grep -q 'keine Datei musste manuell bearbeitet werden' "$dir/out.log"
grep -q '^VENDOO_SETUP_TOKEN=[0-9a-f]\{64\}$' "$dir/.env"
grep -q 'docker compose build --pull' "$dir/docker.log"
grep -q 'docker compose up -d --remove-orphans' "$dir/docker.log"
test "$(stat -c '%a' "$dir/.env")" = 600
assert_file "$dir/out.log" "Installer-Ausgabe ($name)"
assert_file "$dir/.env" ".env ($name)"
assert_file "$dir/docker.log" "Docker-Protokoll ($name)"
assert_contains 'keine Datei musste manuell bearbeitet werden' "$dir/out.log" "Installer-Ausgabe ($name)"
assert_token "$dir/.env"
assert_contains 'docker compose build --pull' "$dir/docker.log" "Docker-Protokoll ($name)"
assert_contains 'docker compose up -d --remove-orphans' "$dir/docker.log" "Docker-Protokoll ($name)"
assert_env_permissions "$dir/.env"
}
run_mode docker '1\n8124\nnein\n'
grep -q '^VENDOO_PUBLISH_ADDRESS=127.0.0.1$' "$TMP_DIR/docker/.env"
grep -q '^VENDOO_BIND_HOST=0.0.0.0$' "$TMP_DIR/docker/.env"
assert_contains '^VENDOO_PUBLISH_ADDRESS=127.0.0.1$' "$TMP_DIR/docker/.env" 'Docker-.env'
assert_contains '^VENDOO_BIND_HOST=0.0.0.0$' "$TMP_DIR/docker/.env" 'Docker-.env'
run_mode nas '2\n9124\nhttp://192.168.1.50:9124\nnein\n'
test -d "$TMP_DIR/nas/runtime/config"
test -d "$TMP_DIR/nas/runtime/db"
grep -q '^VENDOO_PUBLISH_ADDRESS=0.0.0.0$' "$TMP_DIR/nas/.env"
grep -q '^PUBLIC_BASE_URL=http://192.168.1.50:9124$' "$TMP_DIR/nas/.env"
assert_dir "$TMP_DIR/nas/runtime/config" 'NAS-Konfigurationsordner'
assert_dir "$TMP_DIR/nas/runtime/db" 'NAS-Datenbankordner'
assert_contains '^VENDOO_PUBLISH_ADDRESS=0.0.0.0$' "$TMP_DIR/nas/.env" 'NAS-.env'
assert_contains '^PUBLIC_BASE_URL=http://192.168.1.50:9124$' "$TMP_DIR/nas/.env" 'NAS-.env'
run_mode vps '3\n8124\nvendoo.example.invalid\nnein\n'
grep -q '^VENDOO_DOMAIN=vendoo.example.invalid$' "$TMP_DIR/vps/.env"
grep -q '^PUBLIC_BASE_URL=https://vendoo.example.invalid$' "$TMP_DIR/vps/.env"
grep -q '^VENDOO_COOKIE_SECURE=true$' "$TMP_DIR/vps/.env"
grep -q '^VENDOO_TRUST_PROXY=true$' "$TMP_DIR/vps/.env"
assert_contains '^VENDOO_DOMAIN=vendoo.example.invalid$' "$TMP_DIR/vps/.env" 'VPS-.env'
assert_contains '^PUBLIC_BASE_URL=https://vendoo.example.invalid$' "$TMP_DIR/vps/.env" 'VPS-.env'
assert_contains '^VENDOO_COOKIE_SECURE=true$' "$TMP_DIR/vps/.env" 'VPS-.env'
assert_contains '^VENDOO_TRUST_PROXY=true$' "$TMP_DIR/vps/.env" 'VPS-.env'
printf '%s\n' 'Shell-Installer-Gate erfolgreich: Docker-, NAS- und VPS-Dialog erzeugen valide, geschützte Konfiguration ohne manuelle Dateibearbeitung.'
printf '%s\n' 'Shell-Installer-Gate erfolgreich: Docker-, NAS- und VPS-Dialog erzeugen valide Konfiguration; POSIX-Dateirechte werden auf Linux/NAS/VPS strikt geprüft.'

Some files were not shown because too many files have changed in this diff Show More