Compare commits

..
1 Commits
Author SHA1 Message Date
dependabot[bot]andGitHub 7f2e3cc7c7 chore(deps): bump node from 22-bookworm-slim to 26-bookworm-slim
Bumps node from 22-bookworm-slim to 26-bookworm-slim.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-bookworm-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 15:09:45 +00:00
136 changed files with 1190 additions and 11280 deletions
-6
View File
@@ -1,6 +0,0 @@
root = true
[*.{bat,cmd}]
charset = latin1
end_of_line = crlf
insert_final_newline = true
-5
View File
@@ -95,11 +95,6 @@ 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
@@ -1,3 +0,0 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
-122
View File
@@ -5,7 +5,6 @@ on:
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
pull_request:
branches: [main, develop]
workflow_dispatch:
permissions:
contents: read
@@ -43,21 +42,12 @@ jobs:
- name: Git-Staging-Filter Regressionstest
run: npm run verify:git-staging
- name: Release-Manifest und Paketvertrag prüfen
run: npm run verify:release-manifest
- name: Git-Ignore-Grenzen für Quell- und Runtimeordner prüfen
run: npm run verify:ignore-boundaries
- name: Syntaxprüfung
run: npm run check
- name: PowerShell-Regressionen statisch prüfen
run: npm run verify:powershell-static
- name: Updater-3.4-Lifecycle und Startkette prüfen
run: npm run verify:updater-start-chain && npm run verify:updater-lifecycle
- name: Produktiven Source-Root und Archivgrenzen prüfen
run: npm run verify:source-boundaries
@@ -124,115 +114,3 @@ 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 -ReportPath "$env:RUNNER_TEMP\powershell-parser-diagnostics.txt"
- name: Parserdiagnose bei Fehler sichern
if: failure()
uses: actions/upload-artifact@v4
with:
name: powershell-parser-diagnostics-${{ github.event.pull_request.head.sha || github.sha }}
path: ${{ runner.temp }}/powershell-parser-diagnostics.txt
if-no-files-found: error
retention-days: 14
- name: Vollständigen Vendoo-Releasevertrag unter Windows ausführen
shell: pwsh
run: npm run verify:all
package-after-acceptance:
name: Updater-3.4-Paket nach Abnahme
needs: [quality, windows-release-contract]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Exakten geprüften Head auschecken
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
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: main für den Provenienzvergleich aktualisieren
run: git fetch origin main:refs/remotes/origin/main
- name: Reproduzierbares Root-Manifest bestätigen
shell: bash
run: |
node tools/generate-release-manifest.mjs .
git diff --exit-code -- release-manifest.json
- name: Paket mit unveränderlicher Provenienz erzeugen
env:
VENDOO_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
run: node tools/build-release.mjs
- name: Paketmanifest und SHA-256 prüfen
shell: bash
run: |
set -euo pipefail
release_name="$(tr -d '\r\n' < release-output/RELEASE_NAME.txt)"
package_dir="release-output/${release_name}"
node --input-type=module - "$package_dir" <<'NODE'
import { readAndValidateManifest } from './tools/release-manifest-lib.mjs';
const packageDir = process.argv[2];
const manifest = readAndValidateManifest(packageDir);
if (manifest.schema !== 'vendoo-release-package-v2') throw new Error(`Unerwartetes Paketschema: ${manifest.schema}`);
if (manifest.updaterVersion !== '3.4.0') throw new Error(`Unerwartete Updater-Version: ${manifest.updaterVersion}`);
if (manifest.targetVersion !== '1.43.0') throw new Error(`Unerwartete Zielversion: ${manifest.targetVersion}`);
console.log(`Paket validiert: ${manifest.targetVersion}, Updater ${manifest.updaterVersion}, Payload ${manifest.payloadHash}`);
NODE
archive="release-output/${release_name}.zip"
python tools/build-deterministic-zip.py "$package_dir" "$archive"
first_hash="$(sha256sum "$archive" | awk '{print $1}')"
python tools/build-deterministic-zip.py "$package_dir" "$archive"
second_hash="$(sha256sum "$archive" | awk '{print $1}')"
if [[ "$first_hash" != "$second_hash" ]]; then
echo "Der ZIP-Build ist nicht byte-reproduzierbar." >&2
exit 1
fi
python -m zipfile -t "$archive"
(
cd release-output
sha256sum "${release_name}.zip" > "${release_name}.zip.sha256"
sha256sum --check "${release_name}.zip.sha256"
)
- name: Abgenommenes Paket als CI-Artefakt bereitstellen
uses: actions/upload-artifact@v4
with:
name: vendoo-1.43.0-updater-3.4-${{ github.event.pull_request.head.sha || github.sha }}
path: |
release-output/*.zip
release-output/*.zip.sha256
if-no-files-found: error
retention-days: 14
+22 -25
View File
@@ -16,20 +16,7 @@ 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
@@ -55,21 +42,31 @@ 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: ESM-Exportverträge explizit prüfen
run: npm run verify:esm-exports
- 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
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: Manifestbasiertes Release-Staging erzeugen
- name: Release-Staging erzeugen
run: node tools/build-release.mjs
- name: ZIP und SHA-256 erzeugen
@@ -1,71 +0,0 @@
name: Vendoo Release Manifest Sync
on:
push:
branches:
- 'release/*-production-update-v*'
workflow_dispatch:
permissions:
contents: write
actions: write
concurrency:
group: vendoo-release-manifest-sync-${{ github.ref }}
cancel-in-progress: false
jobs:
sync:
name: Release-Manifest reproduzierbar synchronisieren
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
id: manifest
shell: bash
run: |
set -euo pipefail
if git diff --quiet -- release-manifest.json; then
echo "Release-Manifest ist bereits aktuell."
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
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}
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Finalen Manifest-Head explizit validieren
if: ${{ steps.manifest.outputs.changed == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
final_head="${{ steps.manifest.outputs.head_sha }}"
remote_head="$(git ls-remote origin "refs/heads/${GITHUB_REF_NAME}" | awk '{print $1}')"
if [[ -z "$remote_head" || "$remote_head" != "$final_head" ]]; then
echo "Der gepushte Manifest-Head ist nicht stabil: lokal=$final_head remote=$remote_head" >&2
exit 1
fi
gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME"
gh workflow run sync-release-manifest.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME"
echo "Vendoo CI und Manifest-Sync wurden für finalen Head $final_head explizit registriert."
@@ -1,83 +0,0 @@
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 .
@@ -1,125 +0,0 @@
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
@@ -1,36 +0,0 @@
# 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,95 +1,5 @@
## 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.
+2 -2
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS dependencies
FROM node:26-bookworm-slim AS dependencies
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
@@ -8,7 +8,7 @@ COPY package.json package-lock.json .npmrc ./
RUN npm ci --omit=dev --no-audit --no-fund \
&& npm cache clean --force
FROM node:22-bookworm-slim AS runtime
FROM node:26-bookworm-slim AS runtime
ENV NODE_ENV=production \
PORT=8124 \
VENDOO_BIND_HOST=0.0.0.0 \
-23
View File
@@ -1071,26 +1071,3 @@ 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.
+13 -3
View File
@@ -1,4 +1,14 @@
@echo off
rem Alter Dateiname bleibt als Kompatibilitaetsstarter erhalten.
call "%~dp0UPDATE_VENDOO.bat"
exit /b %ERRORLEVEL%
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%
+14 -34
View File
@@ -1,26 +1,6 @@
# Vendoo 1.43.0
# Vendoo 1.41.2
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
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.
## Hotfix 1.41.2: native Module zuverlässig in Git
@@ -44,16 +24,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 Updates
## Kontrollierte Web-Updates
- 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`
- 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`
## GitHub-, Merge- und Releaseprozess
@@ -67,10 +47,10 @@ Runtimeordner werden in `.gitignore` ausschließlich am Projektroot ausgeschloss
## Plattformstand
- 18 registrierte Module
- 16 native Module, davon Produktbild Studio standardmäßig deaktiviert
- 17 registrierte Module
- 15 native Module
- 2 transparente Legacy Bridges: System und Operations
- 41 vollständig aufgelöste Capabilities
- 38 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
@@ -1,51 +0,0 @@
@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.4 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%
+15 -75
View File
@@ -4,91 +4,31 @@
"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"
]
},
"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"
}
"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" }
},
"$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,38 +296,6 @@ 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();
+1 -5
View File
@@ -163,11 +163,7 @@ 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') };
});
// 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 };
const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null };
draft.integrity.digest = calculatePackageDigest(draft);
return validateModulePackage(draft, { allowBuiltinReference: true });
}
-1
View File
@@ -29,7 +29,6 @@ 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')),
+1 -2
View File
@@ -84,7 +84,6 @@ 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.' });
@@ -94,7 +93,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' && module.manifest.defaultEnabled !== false && !initialDisabledModules.has(module.manifest.id);
const defaultEnabled = module.manifest.status !== 'disabled' && !initialDisabledModules.has(module.manifest.id);
const enabled = forcedEnabled || moduleStateStore.isEnabled(module.manifest.id, defaultEnabled);
modules.register(module, { enabled });
}
+1 -2
View File
@@ -13,7 +13,6 @@ 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';
@@ -34,6 +33,6 @@ export function loadBuiltInModules() {
return [
...legacyModuleNames.map(legacyModule),
createSecurityModule(), createAuthModule(), createSessionsModule(), createUsersModule(), createSettingsModule(), createThemesModule(),
createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), createProductImageToolsModule(), createFluxStudioModule(), createQualityModule(), createPublishingModule(), createModuleManagerModule(), createDeploymentsModule(),
createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), 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?.stop?.(); service = null; },
async stop() { service = null; },
async health() { return getDeploymentService().health(); },
},
};
+2 -6
View File
@@ -2,10 +2,10 @@
"schemaVersion": 1,
"id": "vendoo.deployments",
"name": "Deployments & Updates",
"version": "1.1.0",
"version": "1.0.0",
"type": "core",
"status": "native",
"description": "Persistente, kontrollierte Coolify-Deployments mit Vorab-Backup, Idempotenz, Statusverfolgung, Readiness-, Zielversions- und Produktionschecks ohne Docker-Socket.",
"description": "Kontrollierte Coolify-Deployments, verschlüsselte Deployment-Zugangsdaten und sichere Web-Updates ohne Docker-Socket.",
"requires": [
"vendoo.security",
"vendoo.settings",
@@ -18,8 +18,6 @@
"provides": [
"deployments.control",
"deployments.coolify",
"deployments.production-checks",
"deployments.state-machine",
"updates.web"
],
"consumes": [
@@ -30,9 +28,7 @@
],
"owns": [
"deployments.configuration",
"deployments.events",
"deployments.history",
"deployments.production-checks",
"updates.coolify"
]
}
-192
View File
@@ -1,192 +0,0 @@
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();
+6 -76
View File
@@ -1,5 +1,4 @@
import { objectSchema } from '../../kernel/input-schema.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
import { getDeploymentService } from './index.mjs';
const settingsSchema = objectSchema({
@@ -9,9 +8,6 @@ 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 },
@@ -21,33 +17,15 @@ 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 },
idempotency_key: { type: 'string', maxLength: 160 },
target_version: { type: 'string', maxLength: 40 },
force: { type: 'boolean' }, confirmation: { type: 'string', required: true, maxLength: 20 }, reason: { type: 'string', maxLength: 120 },
});
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(await getDeploymentService().status()),
handler: async (_req, res) => res.json(getDeploymentService().status()),
});
routes.register(app, {
id: 'deployments.settings.update', method: 'PUT', path: '/api/admin/deployment', owner: 'vendoo.deployments',
@@ -70,63 +48,15 @@ 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) => {
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);
},
handler: async (_req, res) => res.json(await getDeploymentService().checkUpdates()),
});
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 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);
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);
},
});
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() }),
});
}
+74 -525
View File
@@ -1,24 +1,15 @@
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { mkdirSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.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 { getSecret, getSecretMetadata, hasSecret, setSecret } from '../../../lib/secret-store.mjs';
import { OPERATIONS_DIR, APP_VERSION } from '../../../lib/runtime-paths.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 });
@@ -37,12 +28,6 @@ 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 '';
@@ -69,28 +54,16 @@ function normalizeWebhookUrl(value) {
return url.toString();
}
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 readLastDeployment() {
try { return existsSync(LAST_DEPLOYMENT_PATH) ? JSON.parse(readFileSync(LAST_DEPLOYMENT_PATH, 'utf8')) : null; }
catch { return null; }
}
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 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 currentSettings() {
@@ -105,288 +78,88 @@ 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: 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),
backup_uploads: /^(1|true|yes)$/i.test(String(process.env.VENDOO_DEPLOY_BACKUP_UPLOADS || 'false')),
api_token: getSecretMetadata('COOLIFY_API_TOKEN'),
deploy_webhook: getSecretMetadata('COOLIFY_DEPLOY_WEBHOOK_URL'),
};
}
function configuredState(settings) {
return settings.provider === 'coolify' && (
function publicSettings() {
const settings = currentSettings();
const configured = 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: configuredState(settings),
configured,
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,
build_revision: buildRevision(),
active_run: repository.getActiveRun(),
recent_runs: repository.listRuns(10).map(run => ({ ...run, events: repository.listEvents(run.id, 50) })),
last_deployment: readLastDeployment(),
docker_socket_access: false,
};
}
function buildApiUrl(settings, template, replacements) {
let path = template;
for (const [name, value] of Object.entries(replacements)) path = path.replaceAll(`{${name}}`, encodeURIComponent(String(value ?? '')));
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');
return `${settings.coolify_url}${path}`;
}
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) {
async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { return await fetchImpl(url, { ...options, signal: controller.signal, redirect: 'error' }); }
try { return await fetch(url, { ...options, signal: controller.signal, redirect: 'error' }); }
catch (error) {
if (error?.name === 'AbortError') fail('Anfrage hat das Zeitlimit überschritten.', 'REQUEST_TIMEOUT', 504);
fail(`Ziel ist nicht erreichbar: ${error.message}`, 'REMOTE_UNREACHABLE', 502);
if (error?.name === 'AbortError') fail('Coolify-Anfrage hat das Zeitlimit überschritten.', 'COOLIFY_TIMEOUT', 504);
fail(`Coolify ist nicht erreichbar: ${error.message}`, 'COOLIFY_UNREACHABLE', 502);
} finally { clearTimeout(timeout); }
}
async function readSafeResponse(response) {
const text = (await response.text()).slice(0, 64 * 1024);
const text = (await response.text()).slice(0, 4000);
try { return text ? JSON.parse(text) : null; } catch { return text || null; }
}
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;
export function createDeploymentService({ createBackup, checkForUpdates } = {}) {
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 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 response = await fetchWithTimeout(`${settings.coolify_url}/api/health`, { headers: { Accept: 'application/json' } }, 10000);
const body = await readSafeResponse(response);
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 };
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 };
}
async function requestCoolify(settings, force) {
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}`,
});
}
let url;
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
const method = settings.deploy_method;
let 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.');
@@ -396,233 +169,23 @@ export function createDeploymentService(adapters = {}) {
url = buildApiDeployUrl(settings, Boolean(force));
headers.Authorization = `Bearer ${token}`;
}
const response = await fetchWithTimeout(fetchImpl, url, { method, headers }, 20000);
const requestedAt = new Date().toISOString();
const response = await fetchWithTimeout(url, { method, headers }, 20000);
const body = await readSafeResponse(response);
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(),
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,
};
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 = {}) {
@@ -641,16 +204,13 @@ export function createDeploymentService(adapters = {}) {
COOLIFY_RESOURCE_UUID: cleanUuid(input.resource_uuid || ''),
COOLIFY_TRIGGER_MODE: mode,
COOLIFY_DEPLOY_METHOD: method,
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']),
COOLIFY_DEPLOY_ENDPOINT: clean(input.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}', 500),
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)) {
@@ -658,38 +218,27 @@ export function createDeploymentService(adapters = {}) {
process.env[key] = value;
}
writeRuntimeConfig(content);
if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== MASKED_SECRET) {
if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== '••••••••') {
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 !== MASKED_SECRET) {
if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== '••••••••') {
setSecret('COOLIFY_DEPLOY_WEBHOOK_URL', normalizeWebhookUrl(input.deploy_webhook_url), { source: 'deployment-ui' });
}
return publicSettings(repository);
return publicSettings();
}
function runProductionCheck(context = {}) {
const results = productionChecks({ getPlatformSnapshot });
return repository.createProductionCheck({ createdBy: context.userId || null, results });
}
scheduleMonitor(2500);
return Object.freeze({
status,
status: publicSettings,
update,
testConnection,
trigger,
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,
async checkUpdates() {
if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' };
return checkForUpdates();
},
health() {
const settings = publicSettings(repository);
return { ok: true, provider: settings.provider, configured: settings.configured, state: settings.active_run?.state || 'idle', docker_socket_access: false };
const status = publicSettings();
return { ok: true, provider: status.provider, configured: status.configured, docker_socket_access: false };
},
stop() { if (monitorTimer) clearTimeout(monitorTimer); monitorTimer = null; },
});
}
-41
View File
@@ -1,41 +0,0 @@
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
@@ -1,13 +0,0 @@
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'] }; },
},
};
}
@@ -1,28 +0,0 @@
{
"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,27 +48,6 @@ 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 } }),
+2 -15
View File
@@ -1,7 +1,7 @@
import { randomBytes } from 'node:crypto';
import {
auditLog, createAuthToken, createUser, deleteUser, getLoginHistory, getUser, getUserByEmail,
getUsers, setPassword, updateUser, validatePasswordStrength, listInvitations, getInvitation, revokeInvitation,
getUsers, setPassword, updateUser, validatePasswordStrength,
} 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 : 'viewer';
const normalizedRole = ROLES.has(role) ? role : 'editor';
if (getUserByEmail(email)) fail('User existiert bereits', 400, 'USER_EXISTS');
if (method === 'auto_password') {
const password = `Vd!${randomBytes(9).toString('base64url')}9a`;
@@ -55,19 +55,6 @@ 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.42.0
image: vendoo:1.41.2
container_name: vendoo
restart: unless-stopped
init: true
+1 -1
View File
@@ -4,7 +4,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: vendoo:1.42.0
image: vendoo:1.41.2
container_name: vendoo
restart: unless-stopped
init: true
-123
View File
@@ -1,123 +0,0 @@
# 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
@@ -1,35 +0,0 @@
# 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.
@@ -1,31 +0,0 @@
# 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.
@@ -1,22 +0,0 @@
# 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.
@@ -1,38 +0,0 @@
# 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.
@@ -1,16 +0,0 @@
# 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`
@@ -1,14 +0,0 @@
# 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
@@ -1,13 +0,0 @@
# 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
@@ -1,24 +0,0 @@
# 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
@@ -1,48 +0,0 @@
# 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.
@@ -1,54 +0,0 @@
# 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.
@@ -1,9 +0,0 @@
# 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.
@@ -1,82 +0,0 @@
# Abnahmebericht Vendoo Production Updater 3.4
**Status:** in technischer Abnahme
**Branch:** `feature/updater-3.4-lifecycle`
**Pull Request:** `#28`
**Zielversion:** Vendoo `1.43.0`
**Updater-Version:** `3.4.0`
## Verbindliche Ausgangslage
- `main` basiert auf dem gültigen Release aus PR #26.
- PR #27 ist geschlossen, nicht gemergt und kein zulässiger Quellstand.
- Der alte Worker 3.3 ist aus dem aktiven Release-Payload entfernt.
- Persistente Produktionsdaten unter `/app/data` sind nicht Bestandteil des Payloads.
- PR #28 bleibt bis zum vollständigen Linux-/Windows-Nachweis ein Draft.
## Ist-/Lückenanalyse Updater 3.3
| Bereich | Reale 3.3-Lücke | Umsetzung 3.4 | Nachweis |
|---|---|---|---|
| Check-Registrierung | `gh pr checks --watch` konnte bei noch nicht registrierten Checks sofort mit `no checks reported` abbrechen | eigener Check-Classifier mit 90 Sekunden Grace Period | Regressionstest und GitHub-CI ausstehend |
| Manifest-Sync | Head-SHA konnte sich nach Start der Checkphase ändern | aktuelle `headRefOid` wird in jeder Runde gelesen; Wechsel startet Grace Period und Auswertung neu | Regressionstest und Integrationstest ausstehend |
| Wiederholung nach Merge | 3.3 suchte nur offene PRs und konnte nach einem Merge erneut Sourcearbeit beginnen | gemergter passender PR führt direkt in Deployment/Verifikation | Regressionstest vorhanden, CI ausstehend |
| `main` bereits Zielversion | 3.3 erzeugte weiterhin Arbeitskopie und Releasebranch | Sourcephase wird vollständig übersprungen | Regressionstest vorhanden, CI ausstehend |
| Produktion bereits Zielversion | kein sofortiger erfolgreicher Zielzustand | sofortiger Erfolg ohne Branch, PR oder Deployment | Regressionstest vorhanden, reale Produktionsprobe ausstehend |
| veralteter Payload | Overlay plus Force-Push konnte neuere `main`-Änderungen zurücksetzen | Provenienz v2, Base-Ancestor-Prüfung und Pfadkonflikt-Guard vor dem Overlay | realer temporärer Git-Regressionstest vorhanden, CI ausstehend |
| Branch/PR-Idempotenz | Branch wurde stets neu erzeugt und unbedingt mit `--force` gepusht | deterministischer Branch, PR-Reconciliation und `--force-with-lease` | statischer Vertrag vorhanden, Windows-Lauf ausstehend |
| Check-Lifecycle | keine konkrete Workflow-Run-Auswertung am finalen Head | Rollup plus Actions-Runs nach `head_sha`, neuester Run entscheidet | Regressionstests vorhanden, GitHub-Lauf ausstehend |
| Merge-Sicherheit | kein atomarer Nachweis, dass der gemergte Head geprüft wurde | `--match-head-commit` mit unmittelbar zuvor bestätigtem finalen SHA | statischer Vertrag vorhanden, Integration ausstehend |
## Zustandsautomat
Der verbindliche Automat ist in `docs/PRODUCTION_UPDATER_3_4.md` dokumentiert. Die terminalen Zustände sind:
- `COMPLETED`: GitHub- und Produktionsziel nachgewiesen,
- `FAILED`: terminaler Pflichtcheck, Payloadkonflikt, Timeout oder technischer Fehler,
- `CANCELLED`: expliziter Benutzerabbruch mit erhaltenem Sitzungsstand.
Ein fehlender oder noch laufender Check ist kein terminaler Fehler.
## Regressionstest-Matrix
| Szenario | Erwartung | Automatisierter Test | Linux | Windows |
|---|---|---:|---:|---:|
| verspätet registrierte Checks | innerhalb Grace Period weiter warten | vorhanden | ausstehend | ausstehend |
| `queued` / `pending` / `in_progress` | nicht fehlschlagen | vorhanden | ausstehend | ausstehend |
| Manifest-Sync erzeugt neuen Head | alten Head verwerfen, neuen Head vollständig prüfen | Worker-/Lifecycle-Vertrag vorhanden | ausstehend | ausstehend |
| bereits gemergter PR | keinen zweiten PR erzeugen | vorhanden | ausstehend | ausstehend |
| bereits deployte Zielversion | sofort erfolgreich ohne Änderungen | vorhanden | ausstehend | ausstehend |
| doppelter Start | vorhandenen Zustand übernehmen | Entscheidungs- und Sessionvertrag vorhanden | ausstehend | ausstehend |
| parallele Workflow-Runs | zeitlich neuesten Run bewerten | vorhanden | ausstehend | ausstehend |
| veralteter Payload gegen neueren `main` | vor Overlay blockieren | realer temporärer Git-Test vorhanden | ausstehend | ausstehend |
| blockierter Rückschritt | Konfliktpfade ausgeben, keine Sourceänderung | vorhanden | ausstehend | ausstehend |
| älterer Erfolg, neuerer Fehler | neuer Fehler ist terminal | vorhanden | ausstehend | ausstehend |
| älterer Fehler, neuer Lauf | neuer Lauf bleibt pending | vorhanden | ausstehend | ausstehend |
| älterer Abbruch, neuer Erfolg | neuer Erfolg gilt | vorhanden | ausstehend | ausstehend |
## Releasevertrag
Vor einer Freigabe müssen nachweislich erfolgreich sein:
1. Syntax- und Sicherheitsgates unter Linux,
2. vollständiges `npm run verify:all` unter Windows,
3. PowerShell-Parserprüfung von UI, Host und Worker34,
4. Manifest-Reproduzierbarkeit,
5. Release-Staging mit `vendoo-release-package-v2`,
6. SHA-256-Prüfung aller Paketdateien,
7. Nachweis, dass der alte Worker 3.3 nicht ausgeliefert wird,
8. keine Änderung an Datenbank- oder `/app/data`-Inhalten,
9. Review des finalen PR-Head-SHA.
## Noch offene Abnahmepunkte
- GitHub-CI für den finalen PR-Head muss vollständig grün sein.
- Der Windows-Vertrag muss den echten PowerShell-Parser und alle bestehenden Tests erfolgreich durchlaufen.
- Das auslieferbare Paket darf erst danach erzeugt und als Workflow-Artefakt übernommen werden.
- Die öffentliche Produktionsantwort konnte in dieser Arbeitsumgebung noch nicht unabhängig verifiziert werden; sie ist daher kein bereits erbrachter Nachweis.
## Freigabeentscheidung
**Noch nicht freigegeben.**
Es existiert noch kein als produktionsreif deklarierter Download. Der Bericht wird erst nach finaler CI-, Paket- und Produktionsabnahme auf „bestanden“ gesetzt.
-108
View File
@@ -1,108 +0,0 @@
# 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
@@ -1,30 +0,0 @@
# 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
@@ -1,22 +0,0 @@
# 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
@@ -1,39 +0,0 @@
# 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
@@ -1,25 +0,0 @@
# 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
@@ -1,19 +0,0 @@
# 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
@@ -1,30 +0,0 @@
# 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
@@ -1,20 +0,0 @@
# 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
@@ -1,22 +0,0 @@
# 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
@@ -1,18 +0,0 @@
# 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
@@ -1,11 +0,0 @@
# 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
@@ -1,40 +0,0 @@
# 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
@@ -1,48 +0,0 @@
# 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
@@ -1,17 +0,0 @@
# 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.
-189
View File
@@ -1,189 +0,0 @@
# Vendoo Production Updater 3.4
## Zweck
Updater 3.4 ersetzt den linearen, branchorientierten Ablauf von Updater 3.3 durch einen idempotenten Release-Lifecycle. Der Updater arbeitet auf einen nachprüfbaren Zielzustand hin und erzeugt keine weiteren Quelländerungen, sobald `main` oder die Produktion die Zielversion bereits erreicht haben.
Der Vertrag schützt insbesondere:
- den aktuellen Stand von `main` vor veralteten Payloads,
- den Pull Request vor Merge eines nicht mehr geprüften Head-SHA,
- vorhandene Releases vor doppelten Branches und Pull Requests,
- persistente Produktionsdaten unter `/app/data`,
- Datenbanken, Uploads, Benutzer, Artikel, Einstellungen, API-Schlüssel, FLUX-Modelle und Backups.
## Quellwahrheit
Der Updater wertet den Zustand in dieser Reihenfolge aus:
1. aktueller GitHub-Branch `main`,
2. aktueller Pull-Request-Head und konkrete GitHub-Workflow-Runs,
3. öffentliche Produktionsantwort `/readyz`,
4. lokaler Sitzungsstand nur als Wiederaufnahmekontext.
Ein lokaler Sitzungsstand darf niemals einen neueren GitHub- oder Produktionszustand überschreiben.
## Zustandsautomat
```text
PACKAGE_VALIDATED
-> TOOLS_READY
-> GITHUB_READY
-> TARGET_OBSERVED
TARGET_OBSERVED
-> COMPLETED
wenn main == target und production == target
-> DEPLOY_REQUIRED
wenn main == target und production != target
-> PR_RESUME
wenn ein passender offener PR existiert
-> DEPLOY_REQUIRED
wenn ein passender PR bereits gemergt ist
-> SOURCE_REQUIRED
nur wenn main noch nicht target ist und kein passender PR existiert
SOURCE_REQUIRED
-> PAYLOAD_GUARD
-> LOCAL_CONTRACT
-> TARGET_REOBSERVED
-> PR_RECONCILED
PR_RESUME | PR_RECONCILED
-> CHECKS_TRACKING
CHECKS_TRACKING
-> CHECKS_TRACKING
wenn sich der PR-Head-SHA ändert; Grace Period beginnt erneut
-> CHECKS_TRACKING
wenn Checks noch nicht registriert, queued, pending oder in_progress sind
-> MERGE_READY
wenn alle Pflichtchecks und Pflichtworkflows am stabilen finalen Head erfolgreich sind
-> FAILED
bei terminalem Pflichtfehler, Abbruch oder Gesamtzeitüberschreitung
MERGE_READY
-> MERGED
nur per --match-head-commit für den geprüften Head-SHA
-> DEPLOY_REQUIRED
-> PRODUCTION_VERIFIED
-> COMPLETED
```
## Idempotenzregeln
### Bereits aktuelle Produktion
Wenn `main` und `/readyz` bereits die Zielversion melden, endet der Updater sofort erfolgreich. Es werden weder Arbeitskopie noch Branch, Pull Request, Commit oder Deployment erzeugt.
### `main` bereits auf Zielversion
Wenn `main` bereits die Zielversion enthält, wird die Sourcephase vollständig übersprungen. Der Updater prüft nur noch, ob die Produktion nachziehen muss.
### Bereits gemergter Pull Request
Ein bereits gemergter passender Pull Request wird als abgeschlossene Sourcephase behandelt. Der Updater erzeugt keinen zweiten Pull Request und setzt beim Deployment beziehungsweise bei der Produktionsprüfung fort.
### Bereits offener Pull Request
Ein vorhandener offener Pull Request des deterministischen Releasebranches wird übernommen. Sein aktueller `headRefOid` ist die alleinige Prüfreferenz.
### Doppelter oder paralleler Start
Die Desktopoberfläche verhindert parallele lokale Prozesse per Mutex. Zusätzlich gleicht jeder Lauf GitHub und Produktion erneut ab. Ein konkurrierender Lauf, der zwischen lokaler Prüfung und Push einen Pull Request erzeugt oder `main` aktualisiert, wird bei der zweiten Zielbeobachtung erkannt und übernommen.
## Paket-Provenienz
Auslieferbare Pakete verwenden das Schema `vendoo-release-package-v2` und enthalten:
- `targetVersion`,
- `updaterVersion`,
- `sourceCommit`,
- `baseCommit`,
- `createdAt`,
- `payloadHash`,
- die vollständige SHA-256-Dateiliste,
- explizite Löschungen.
`payloadHash` wird aus den stabilen Manifestfeldern berechnet. Jede Änderung an Herkunft, Zieldateien oder Löschungen verändert die Paketidentität.
Vor dem Overlay wird geprüft:
1. `baseCommit` und aktueller `main` müssen lokal auflösbar sein.
2. `baseCommit` muss Vorfahr des aktuellen `main` sein.
3. Alle Pfade, die Payload und `main` seit `baseCommit` beide verändert haben, werden verglichen.
4. Weicht der gewünschte Payloadstand vom aktuellen `main` ab, wird der Lauf vor jeder Quelländerung blockiert.
5. Nicht kollidierende Fortschritte in `main` dürfen bestehen bleiben.
Damit kann ein altes Paket neuere Änderungen aus `main` weder still noch per Force-Push zurücksetzen.
## GitHub-Check-Lifecycle
Updater 3.4 verwendet nicht mehr `gh pr checks --watch` als Blackbox.
Für jeden aktuellen PR-Head werden ausgewertet:
- `statusCheckRollup` des Pull Requests,
- Workflow-Runs aus `actions/runs?head_sha=<finaler-head>&per_page=100`,
- die Pflichtchecks `Syntax, Sicherheit und Release-Gates`, `Windows vollständiger Releasevertrag` und `Updater-3.4-Paket nach Abnahme`,
- die Pflichtworkflows `Vendoo CI` und `Vendoo Release Manifest Sync`.
Regeln:
- `no checks reported` ist während der 90-sekündigen Registrierungs-Grace-Period kein Fehler.
- `queued`, `pending`, `in_progress`, `waiting` und `requested` sind laufende Zustände.
- Bei mehreren Runs desselben Workflows entscheidet der zeitlich neueste Run; ein alter Erfolg darf einen neueren Fehler nicht verdecken.
- Ändert der Manifest-Sync den Head-SHA, werden Grace Period und Checkauswertung für den neuen Head vollständig neu gestartet.
- Vor dem Merge wird der Head-SHA erneut gelesen.
- Der Merge verwendet `--match-head-commit`; ein weiterer Headwechsel verhindert den Merge atomar.
### Final-Head-Registrierung nach Manifest-Sync
Ein Push mit dem GitHub-Workflow-Token startet für den dadurch erzeugten Commit nicht automatisch einen weiteren Push-Workflow. Deshalb bestätigt der Manifest-Sync nach seinem Commit zuerst den tatsächlichen Remote-Head und dispatcht anschließend explizit:
1. `Vendoo CI` für den neuen Head,
2. `Vendoo Release Manifest Sync` für denselben Head.
Der zweite Manifest-Sync-Lauf findet ein unverändertes Manifest, erzeugt keinen weiteren Commit und dient als konkreter erfolgreicher Workflow-Nachweis am finalen SHA. Die Concurrency des Sync-Workflows darf den auslösenden Lauf nicht abbrechen.
### Paket erst nach Plattformabnahme
Das auslieferbare ZIP entsteht im Job `Updater-3.4-Paket nach Abnahme`. Dieser Job besitzt eine harte `needs`-Abhängigkeit auf Linux- und Windows-Abnahme. Er läuft daher erst, wenn beide Verträge erfolgreich abgeschlossen wurden. Danach werden Root-Manifest, Paketmanifest v2, Zielversion, Updater-Version und ZIP-SHA-256 erneut geprüft. Nur dieses Artefakt ist ein auslieferbarer Kandidat.
## Sitzungsvertrag
Sitzungen verwenden `vendoo-updater-session-v2`. Die Identität besteht aus:
- Repository,
- Zielversion,
- Payload-Hash,
- deterministischem Releasebranch.
Gespeichert werden unter anderem Pull-Request-Nummer und URL, finaler geprüfter Head-SHA, Merge-Commit, Produktionsversion, Releasezustand und Abschlusszeitpunkt. Der Sitzungsstand dient ausschließlich der Wiederaufnahme; jeder Lauf validiert die externen Zustände erneut.
## Branch- und Push-Sicherheit
- Releasebranch: `release/<zielversion>-production-update-v4`
- kein unbedingtes `git push --force`
- vorhandene Branches werden nur mit `--force-with-lease=<erwarteter-remote-sha>` aktualisiert
- Merge ausschließlich für den nachgewiesenen finalen Head-SHA
- kein Quell-PR, wenn `main` bereits auf Zielversion ist
## Produktionsvertrag
Das Deployment verändert keine persistenten Nutzdaten. `/app/data` bleibt außerhalb des Release-Payloads. Der Updater akzeptiert Erfolg erst, wenn `/readyz` sowohl `ok: true` als auch exakt die Zielversion meldet.
Die Option `-SkipProductionDeploy` ist ausschließlich für Source-/CI-Abnahmen vorgesehen. Sie darf nicht als Nachweis eines vollständigen Produktionsupdates ausgegeben werden.
## Fehlerdiagnose
Bei Fehler oder Timeout werden protokolliert:
- aktuelle Phase,
- aktueller PR und Head-SHA,
- Pflichtcheck- und Workflow-Matrix,
- Prozessausgabe und Exit-Code,
- Payload-Konfliktpfade,
- konkrete Wiederanlaufempfehlung.
Ein erneuter Start beginnt nicht blind von vorn, sondern beobachtet Zielzustand, Pull Requests, Merge und Produktion erneut und setzt beim tatsächlich offenen Zustand fort.
@@ -1,50 +0,0 @@
# 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.
@@ -1,12 +0,0 @@
# 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
@@ -1,44 +0,0 @@
# 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
@@ -1,14 +0,0 @@
# 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
@@ -1,16 +0,0 @@
# 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,84 +1550,3 @@ 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 {}
+7 -20
View File
@@ -330,33 +330,20 @@ export function cancelPendingOperation() {
return { cancelled: true, pending };
}
export async function checkForUpdates({ channel = 'stable' } = {}) {
export async function checkForUpdates() {
const settings = getOperationSettings();
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 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 controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: 'error',
headers: { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` },
});
const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`Update-Manifest antwortet mit HTTP ${response.status}.`);
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 manifest = await response.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, channel, manifest };
return { configured: true, current_version: APP_VERSION, latest_version: latest, update_available: compareVersions(latest, APP_VERSION) > 0, manifest };
} finally { clearTimeout(timeout); }
}
+604 -754
View File
File diff suppressed because it is too large Load Diff
+17 -29
View File
@@ -1,63 +1,51 @@
{
"name": "vendoo",
"version": "1.43.0",
"version": "1.41.2",
"type": "module",
"private": true,
"scripts": {
"start": "node bootstrap.mjs",
"dev": "node --watch bootstrap.mjs",
"check": "node tools/check-syntax.mjs && node --no-warnings tools/verify-identity-db-1.40.0.mjs",
"check": "node tools/check-syntax.mjs && python -m py_compile tools/verify-identity-db-1.40.0.py",
"verify:slice30": "node tools/verify-slice30-mock.mjs",
"verify:db": "node --no-warnings tools/verify-db-migrations.mjs",
"verify:db": "python tools/verify-db-migrations.py",
"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": "npm run verify:production-operations",
"verify:release": "node tools/verify-deployment-1.41.2.mjs",
"verify:ui-contracts": "node tools/verify-ui-contracts-1.26.1.mjs",
"verify:deployment": "npm run verify:production-operations",
"verify:deployment": "node tools/verify-deployment-1.41.2.mjs",
"verify:git": "node tools/verify-git-safety.mjs",
"verify:config": "node tools/verify-config-store.mjs",
"verify:installer-shell": "node tools/verify-installer-shell.mjs",
"verify:installer-shell": "sh tools/verify-installer-shell.sh",
"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: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:updater-start-chain && npm run verify:updater-ci-lifecycle && 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: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: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 && node --no-warnings tools/verify-identity-db-1.40.0.mjs",
"verify:identity": "node tools/verify-identity-settings-1.41.2.mjs && python tools/verify-identity-db-1.40.0.py",
"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": "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:coolify": "node tools/verify-coolify-deployment-1.41.2.mjs",
"verify:initial-modules": "node tools/verify-initial-disabled-modules-1.41.2.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: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.4.mjs",
"verify:module-navigation": "node tools/verify-module-navigation-generator-1.43.0.mjs",
"verify:updater-ui": "node tools/verify-production-updater-3.4.mjs",
"verify:updater-hotfix": "node tools/verify-production-updater-3.4.mjs",
"verify:updater-lifecycle": "node tools/verify-production-updater-3.4.mjs",
"verify:updater-start-chain": "node tools/verify-updater-start-chain-3.4.mjs",
"verify:updater-ci-lifecycle": "node tools/verify-updater-ci-lifecycle-3.4.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"
"verify:ignore-boundaries": "node tools/verify-git-ignore-boundaries-1.41.2.mjs"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.111.0",
"@anthropic-ai/sdk": "^0.39.0",
"better-sqlite3": "^12.11.1",
"cookie-parser": "^1.4.7",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"helmet": "^8.3.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"helmet": "^8.2.0",
"multer": "^2.2.0",
"nodemailer": "^9.0.3",
"openai": "^6.46.0",
"sharp": "^0.35.3"
"openai": "^4.77.0",
"sharp": "^0.33.5"
}
}
+21 -230
View File
@@ -1,4 +1,4 @@
const VENDOO_UI_BUILD = '1.43.0';
const VENDOO_UI_BUILD = '1.41.2';
const state = {
photos: [],
@@ -282,44 +282,6 @@ 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}`);
@@ -333,16 +295,10 @@ 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'),
aiEnabled ? api('GET', '/api/providers') : Promise.resolve([]),
api('GET', '/api/providers'),
api('GET', '/api/templates'),
]);
@@ -1748,12 +1704,6 @@ 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'));
@@ -2514,8 +2464,7 @@ 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';
const saveButton = document.getElementById('save-btn');
if (saveButton) { saveButton.textContent = listing.id ? 'Änderungen speichern' : 'Artikel speichern'; saveButton.disabled = false; }
document.getElementById('save-btn').textContent = listing.id ? 'Änderungen speichern' : 'Speichern & Hinzufügen';
document.getElementById('generator-preview-empty')?.classList.add('hidden');
document.getElementById('result-section').classList.remove('hidden');
const previewStatus = document.getElementById('generator-preview-status');
@@ -2619,8 +2568,7 @@ function resetGenerator() {
document.getElementById('meta-section').classList.add('hidden');
document.getElementById('variants-section').classList.add('hidden');
document.getElementById('generator-preview-empty')?.classList.remove('hidden');
const saveButton = document.getElementById('save-btn');
if (saveButton) { saveButton.textContent = 'Speichern'; saveButton.disabled = true; }
document.getElementById('save-btn').textContent = 'Speichern & Hinzufügen';
document.getElementById('generate-btn').disabled = true;
renderAiModelGrid('generator', []);
setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral');
@@ -3449,13 +3397,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 hidden" data-module-panel="vendoo.product-image-tools">
<section class="ai-model-card detail-ai-model-card">
<div class="ai-model-card-head">
<div>
<span class="generator-panel-index">AI</span>
<div>
<h4>Produktbild Studio</h4>
<p>Optionales Modul für kontrollierte Ghost-Mannequin- und Flatlay-Varianten aus den aktuellen Listing-Fotos.</p>
<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>
</div>
</div>
<span class="generator-beta">Safe</span>
@@ -3566,8 +3514,6 @@ 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; });
@@ -3599,18 +3545,16 @@ async function showDetail(id) {
if (element) { element.disabled = true; element.setAttribute('aria-disabled', 'true'); }
});
}
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);
}
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');
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' });
@@ -8163,92 +8107,6 @@ 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 ?? ''; };
@@ -8256,14 +8114,9 @@ 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'; }
@@ -8271,11 +8124,7 @@ 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');
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'));
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>`;
}
async function loadDeploymentStatus() {
@@ -8291,9 +8140,6 @@ 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',
@@ -8392,18 +8238,6 @@ 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.'); }
@@ -8418,12 +8252,7 @@ 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');
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>`;
}
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>`; }
catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Updateprüfung fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; }
finally { button.disabled = false; }
});
@@ -8432,11 +8261,9 @@ 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 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.');
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.');
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'; }
@@ -8464,7 +8291,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 = 'viewer';
document.getElementById('invite-role').value = 'editor';
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"]');
@@ -8615,48 +8442,13 @@ 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>'; await loadAdminInvitations(); return; }
if (!adminUsersCache.length) { container.innerHTML = '<div class="empty-state">Keine User vorhanden</div>'; return; }
container.innerHTML = adminUsersCache.map(u => {
const initials = getInitials(u.name, u.email);
@@ -8688,7 +8480,6 @@ async function loadAdminUsers() {
if (user) confirmDeleteUser(user.id, user.email);
}));
globalThis.VendooIcons?.render?.(container);
await loadAdminInvitations();
} catch (err) { toast('Fehler: ' + err.message); }
}
+54 -85
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.43.0">
<meta name="vendoo-build" content="1.41.2">
<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.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 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 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" data-module="vendoo.ai">
<button class="nav-item" data-tab="generator">
<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" data-module="vendoo.listings">
<button class="nav-item" data-tab="today">
<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" data-module="vendoo.listings">
<button class="nav-item" data-tab="history">
<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" data-module="vendoo.quality">
<button class="nav-item" data-tab="quality-center">
<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" data-module="vendoo.publishing">
<button class="nav-item" data-tab="publish">
<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" data-module="vendoo.inventory">
<button class="nav-item" data-tab="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" data-module="vendoo.listings">
<button class="nav-item" data-tab="templates">
<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" data-module="vendoo.flux-studio">
<button class="nav-item" data-tab="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" data-module="vendoo.media">
<button class="nav-item" data-tab="media-library">
<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" data-module="vendoo.ai">
<button class="nav-item" data-tab="image-factory">
<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" data-module="vendoo.module-manager">
<button class="nav-item" data-tab="extensions">
<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" data-module="vendoo.settings">
<button class="nav-item" data-tab="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-module="vendoo.users" data-admin-tab="users" id="nav-admin-main">
<button class="nav-item" data-tab="admin" 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" data-module="vendoo.publishing">
<button class="nav-item nav-item-quiet" data-tab="fees">
<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" data-module="vendoo.listings">
<button class="nav-item nav-item-quiet" data-tab="trash">
<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" data-module="vendoo.ai">
<button class="topbar-btn primary" id="topbar-new-btn">
<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,39 +314,18 @@
<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>
Formular leeren
Zurücksetzen
</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">
@@ -398,13 +377,13 @@
</div>
<section id="generator-ai-model-card" class="ai-model-card generator-ai-model-card hidden" data-module-panel="vendoo.product-image-tools">
<section id="generator-ai-model-card" class="ai-model-card generator-ai-model-card">
<div class="ai-model-card-head">
<div>
<span class="generator-panel-index">AI</span>
<div>
<h4>Produktbild Studio</h4>
<p>Optionales Modul für kontrollierte Ghost-Mannequin- und Flatlay-Varianten. Aktivierung und Deaktivierung erfolgen im Modulmanager.</p>
<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>
</div>
</div>
<span id="generator-ai-model-badge" class="generator-beta">Safe</span>
@@ -554,7 +533,19 @@
<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">
@@ -658,7 +649,7 @@
<div class="result-actions generator-result-actions">
<button id="copy-all-btn" class="secondary-btn">Alles kopieren</button>
<span>Speichern ist dauerhaft oben erreichbar.</span>
<button id="save-btn" class="primary-btn">Speichern &amp; Hinzufügen</button>
</div>
</div>
</aside>
@@ -1131,10 +1122,6 @@
<!-- 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 -->
@@ -1269,45 +1256,27 @@
<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>Kontrolliertes Deployment ohne Docker-Socket und ohne selbständernden Produktivcontainer.</p></div></header>
<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>
<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</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>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>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 kontrolliert ausrollen</h4><p>Vorab-Backup, Doppelstartschutz, Statusverfolgung, Readiness und Zielversion sind Bestandteil eines Auftrags.</p></div></header>
<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>
<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>
<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-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-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>
@@ -1409,9 +1378,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" selected>Leser</option>
<option value="viewer">Leser</option>
<option value="publisher">Publisher</option>
<option value="editor">Editor</option>
<option value="editor" selected>Editor</option>
<option value="manager">Manager</option>
<option value="admin">Administrator</option>
</select>
@@ -2392,11 +2361,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.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>
<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>
+1 -1
View File
@@ -106,6 +106,6 @@
</div>
</div>
<script src="/login.js?v=1.43.0" defer></script>
<script src="/login.js?v=1.41.2" defer></script>
</body>
</html>
@@ -150,7 +150,6 @@ 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);
@@ -169,7 +168,6 @@ 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);
+1 -153
View File
@@ -7165,29 +7165,6 @@ 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; }
@@ -7230,7 +7207,7 @@ button,input,select,textarea { max-width:100%; }
.operations-pending .secondary-btn { width: 100%; }
}
@media (max-width: 520px) {
.operation-inline-fields, .operations-extension-packages, .deployment-production-overview { grid-template-columns: minmax(0, 1fr); }
.operation-inline-fields, .operations-extension-packages { 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); }
@@ -7402,132 +7379,3 @@ 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
+210 -3
View File
@@ -1,3 +1,210 @@
# Kompatibilitaetsstarter fuer alte Verknuepfungen.
& (Join-Path $PSScriptRoot 'update-vendoo.ps1')
exit $LASTEXITCODE
# 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
}
}
-23
View File
@@ -1,23 +0,0 @@
[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
@@ -1,14 +0,0 @@
[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
@@ -1,140 +0,0 @@
[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.4.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='Zielzustand erkennen';state='pending'},
[ordered]@{id='workspace';title='Payload sicher vorbereiten';state='pending'},
[ordered]@{id='tests';title='Vollständig testen';state='pending'},
[ordered]@{id='pullrequest';title='Pull Request abgleichen';state='pending'},
[ordered]@{id='merge';title='Finalen Head prüfen und 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
}
@@ -1,90 +0,0 @@
[CmdletBinding()]
param()
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
$UpdaterVersion = '3.4.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.Worker34.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
@@ -1,333 +0,0 @@
[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.4.0'
$WorkerScript = Join-Path $PSScriptRoot 'Vendoo.Updater.Worker34.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='Zielzustand erkennen'}, @{id='workspace'; title='Payload sicher vorbereiten'}, @{id='tests'; title='Vollständig testen'},
@{id='pullrequest'; title='Pull Request abgleichen'}, @{id='merge'; title='Finalen Head prüfen und 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='Zielzustand erkennen';state='pending'},@{id='workspace';title='Payload sicher vorbereiten';state='pending'},@{id='tests';title='Vollständig testen';state='pending'},
@{id='pullrequest';title='Pull Request abgleichen';state='pending'},@{id='merge';title='Finalen Head prüfen und 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() }
-155
View File
@@ -1,155 +0,0 @@
[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.4.0';$PublicBaseUrl='https://vendoo.flatlined.de'
$Branch="release/$TargetVersion-production-update-v4"
$LocalRoot=Join-Path $env:LOCALAPPDATA 'Vendoo\Updater';$ToolsRoot=Join-Path $LocalRoot 'tools';$LogsRoot=Join-Path $LocalRoot 'logs';$WorkRoot=Join-Path $LocalRoot "work\$TargetVersion-v4";$CloneDir=Join-Path $WorkRoot 'repository'
New-Item -ItemType Directory -Path $ToolsRoot,$LogsRoot,(Split-Path -Parent $SessionFile) -Force|Out-Null
$Stamp=Get-Date -Format 'yyyyMMdd-HHmmss';$script:Tools=@{};$script:CurrentStep='package';$script:LastHeartbeat=Get-Date;$script:LastCheckLog=[DateTime]::MinValue
$LifecycleScript=Join-Path $PackageRoot 'tools\updater-lifecycle-3.4.mjs'
$PayloadGuardScript=Join-Path $PackageRoot 'tools\check-release-payload-3.4.mjs'
$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='Zielzustand erkennen';state='pending'},[ordered]@{id='workspace';title='Payload sicher vorbereiten';state='pending'},[ordered]@{id='tests';title='Vollständig testen';state='pending'},
[ordered]@{id='pullrequest';title='Pull Request abgleichen';state='pending'},[ordered]@{id='merge';title='Finalen Head prüfen und 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 10
$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 Skip-Step([string]$Id){foreach($s in $script:Steps){if($s.id -eq $Id){$s.state='skipped'}}}
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 Add-OrSetProperty($Object,[string]$Name,$Value){if($Object.PSObject.Properties.Name -contains $Name){$Object.$Name=$Value}else{$Object|Add-Member -NotePropertyName $Name -NotePropertyValue $Value}}
function Save-Session($Data){
Add-OrSetProperty $Data 'schema' 'vendoo-updater-session-v2';Add-OrSetProperty $Data 'repository' $Repository;Add-OrSetProperty $Data 'targetVersion' $TargetVersion;Add-OrSetProperty $Data 'updaterVersion' $UpdaterVersion;Add-OrSetProperty $Data 'payloadHash' $script:Manifest.payloadHash;Add-OrSetProperty $Data 'branch' $Branch;Add-OrSetProperty $Data 'updatedAt' (Get-Date).ToString('o')
$tmp="$SessionFile.tmp";$json=$Data|ConvertTo-Json -Depth 10;[IO.File]::WriteAllText($tmp,$json,(New-Object Text.UTF8Encoding($false)));Move-Item $tmp $SessionFile -Force
}
function New-Session{return [pscustomobject]@{schema='vendoo-updater-session-v2';repository=$Repository;targetVersion=$TargetVersion;updaterVersion=$UpdaterVersion;payloadHash=[string]$script:Manifest.payloadHash;branch=$Branch;releaseState='new';prNumber=$null;prUrl='';finalPrHeadSha='';mergeCommit='';productionVersion='';completedAt='';updatedAt=''}}
function Load-Session{
if(Test-Path -LiteralPath $SessionFile){try{$saved=Get-Content $SessionFile -Raw -Encoding UTF8|ConvertFrom-Json;if([string]$saved.schema-eq'vendoo-updater-session-v2' -and [string]$saved.repository-eq$Repository -and [string]$saved.targetVersion-eq$TargetVersion -and [string]$saved.payloadHash-eq[string]$script:Manifest.payloadHash){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 New-Session
}
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];$e=Get-TailText $StdErr 2200;$o=Get-TailText $StdOut 1200;if($e){$parts.Add("Fehlerausgabe:`n$e")};if($o){$parts.Add("Letzte Standardausgabe:`n$o")};if($parts.Count-eq0){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
$utf8=New-Object Text.UTF8Encoding($false);if($psi.PSObject.Properties.Name-contains'StandardOutputEncoding'){$psi.StandardOutputEncoding=$utf8};if($psi.PSObject.Properties.Name-contains'StandardErrorEncoding'){$psi.StandardErrorEncoding=$utf8}
$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-ge8){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-ne0){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-ne0){throw "$Label ist mit Exit-Code $code fehlgeschlagen.`n$(Get-ProcessFailureSummary $stderr $stdout)"};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();$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;while(($read=$input.Read($buf,0,$buf.Length))-gt0){Assert-NotCancelled;$output.Write($buf,0,$read)}}finally{$output.Dispose();$input.Dispose();$resp.Dispose();$client.Dispose();$handler.Dispose()};return}catch{if($attempt-eq3){throw "$Label konnte nach drei Versuchen nicht geladen werden: $($_.Exception.Message)"};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' -Quiet;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;$null=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-eq0)}
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 Vendoo - Sichere GitHub Anmeldung','echo.','echo Folge dem Browserdialog zur GitHub-Anmeldung.','echo.',('"{0}" auth login --hostname github.com --git-protocol https --web --clipboard'-f$escapedGh),'exit /b %ERRORLEVEL%');[IO.File]::WriteAllLines($loginScript,$lines,(New-Object Text.UTF8Encoding($false)));Write-State 26 'GitHub-Anmeldung' 'Im geöffneten Fenster den Gerätecode bestätigen.' 'waiting';$loginProcess=Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d','/c',('"{0}"'-f$loginScript))-WorkingDirectory $runtimeDirectory -WindowStyle Normal -PassThru;$deadline=(Get-Date).AddMinutes(10);while((Get-Date)-lt$deadline){Assert-NotCancelled;if(Test-GitHubAuthentication $GhPath){return};if($loginProcess.HasExited){throw'Die GitHub-Anmeldung wurde nicht abgeschlossen.'};Start-Sleep -Seconds 2};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 Invoke-Lifecycle([string]$Command,$InputObject){$json=$InputObject|ConvertTo-Json -Depth 20 -Compress;$r=Invoke-External $script:Node @($LifecycleScript,$Command)-InputText $json -TimeoutSeconds 60 -Label "Lifecycle $Command" -Quiet;return($r.Output|ConvertFrom-Json)}
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','tools\updater-lifecycle-3.4.mjs','tools\check-release-payload-3.4.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.targetVersion-ne$TargetVersion){throw"Paket-, Update- und Release-Manifest-Version müssen exakt $TargetVersion sein."};if([string]$r.schema-ne'vendoo-release-package-v2'){if(-not$SelfTest){throw"Auslieferbares Paket benötigt Manifest-Schema vendoo-release-package-v2, gefunden: $($r.schema)"}};if([string]$r.updaterVersion-ne$UpdaterVersion){throw"Release-Manifest gehört zu Updater $($r.updaterVersion), erwartet wird $UpdaterVersion."};foreach($name in @('sourceCommit','baseCommit','createdAt','payloadHash')){if([string]::IsNullOrWhiteSpace([string]$r.$name)){if(-not$SelfTest){throw"Release-Manifest enthält keine gültige Herkunft: $name fehlt."}}};$script:Manifest=$r
}
function Get-MainVersion([string]$GhPath){$encoded=(Invoke-External $GhPath @('api',"repos/$Repository/contents/package.json?ref=$BaseBranch",'--jq','.content')-TimeoutSeconds 60 -Label 'main-Version lesen' -Quiet).Output -replace'\s','';$text=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded));return[string](($text|ConvertFrom-Json).version)}
function Get-ReleaseObservation([string]$GhPath){
$sha=(Invoke-External $GhPath @('api',"repos/$Repository/commits/$BaseBranch",'--jq','.sha')-TimeoutSeconds 60 -Label 'main-SHA lesen' -Quiet).Output.Trim();$version=Get-MainVersion $GhPath;$prod=Check-ProductionVersion
$prResult=Invoke-External $GhPath @('pr','list','--repo',$Repository,'--head',$Branch,'--state','all','--limit','100','--json','number,url,state,mergedAt,closedAt,updatedAt,headRefOid,title')-TimeoutSeconds 60 -AllowFailure -Label 'Release-PRs ermitteln' -Quiet;$prs=@();if($prResult.Code-eq0-and$prResult.Output.Trim()){$raw=@($prResult.Output|ConvertFrom-Json);foreach($pr in $raw){$prs+=[pscustomobject]@{targetVersion=$TargetVersion;number=$pr.number;url=$pr.url;state=$pr.state;mergedAt=$pr.mergedAt;closedAt=$pr.closedAt;updatedAt=$pr.updatedAt;headRefOid=$pr.headRefOid;title=$pr.title}}}
return[pscustomobject]@{MainSha=$sha;MainVersion=$version;Production=$prod;PullRequests=$prs}
}
function Get-Decision($Observation,$Session){return Invoke-Lifecycle 'decide' ([ordered]@{targetVersion=$TargetVersion;mainVersion=$Observation.MainVersion;productionReady=$Observation.Production.Ready;productionVersion=$Observation.Production.Version;pullRequests=$Observation.PullRequests;session=$Session})}
function Complete-WithoutSource([string]$Reason,$Observation,$Session){foreach($id in @('workspace','tests','pullrequest','merge','deploy','verify')){Skip-Step $id};$Session.releaseState='completed';$Session.productionVersion=$Observation.Production.Version;$Session.completedAt=(Get-Date).ToString('o');Save-Session $Session;Write-State 100 'Bereits vollständig aktuell' "Vendoo $TargetVersion ist in main und Produktion bereits bestätigt. Grund: $Reason" 'success';Write-Event 'OK' 'Zielzustand bereits erreicht; kein Branch, kein PR und kein Redeploy erforderlich.'}
function Get-OpenPrFromDecision($Decision){if($Decision.PSObject.Properties.Name-contains'pullRequest'){return$Decision.pullRequest};return$null}
function Wait-RequiredGitHubChecks([string]$GhPath,[string]$PrUrl,$Session){
$deadline=(Get-Date).AddMinutes(45);$head='';$headSince=Get-Date;$lastMatrix=''
while($true){Assert-NotCancelled;$pr=(Invoke-External $GhPath @('pr','view',$PrUrl,'--repo',$Repository,'--json','number,url,state,mergedAt,headRefOid,statusCheckRollup')-TimeoutSeconds 90 -Label 'PR-Head und Checks lesen' -Quiet).Output|ConvertFrom-Json
if($pr.mergedAt){return[pscustomobject]@{Merged=$true;HeadSha=[string]$pr.headRefOid;Pr=$pr}}
$current=[string]$pr.headRefOid;if(-not$current){throw'Der Pull Request meldet keinen Head-SHA.'};if($current-ne$head){$head=$current;$headSince=Get-Date;$Session.finalPrHeadSha=$head;Save-Session $Session;Write-Event 'INFO' "Aktueller PR-Head-SHA: $head. Check-Grace-Period wurde neu gestartet."}
$runsResult=Invoke-External $GhPath @('api',"repos/$Repository/actions/runs?head_sha=$head&per_page=100")-TimeoutSeconds 90 -AllowFailure -Label 'Workflow-Runs des finalen Heads lesen' -Quiet;$runs=@();if($runsResult.Code-eq0-and$runsResult.Output.Trim()){try{$runs=@(($runsResult.Output|ConvertFrom-Json).workflow_runs)}catch{$runs=@()}}
$elapsed=[int]((Get-Date)-$headSince).TotalSeconds;$timedOut=(Get-Date)-ge$deadline;$classification=Invoke-Lifecycle 'classify-checks' ([ordered]@{checks=@($pr.statusCheckRollup);workflowRuns=$runs;elapsedSeconds=$elapsed;gracePeriodSeconds=90;timedOut=$timedOut})
$matrix=@($classification.rows|ForEach-Object{"$($_.name): $($_.state) ($($_.conclusion)$($_.status))"})-join'; '
if($matrix-ne$lastMatrix-or((Get-Date)-$script:LastCheckLog).TotalSeconds-ge30){Write-Event 'INFO' "GitHub-Status für ${head}: $matrix";$lastMatrix=$matrix;$script:LastCheckLog=Get-Date}
if([string]$classification.state-eq'failed'){throw"Ein erforderlicher GitHub-Check ist fehlgeschlagen.`n$matrix"}
if([string]$classification.state-eq'timeout'){throw"Zeitlimit beim Warten auf GitHub-Checks für finalen Head $head.`n$matrix"}
if([string]$classification.state-eq'success'){$verify=(Invoke-External $GhPath @('pr','view',$PrUrl,'--repo',$Repository,'--json','headRefOid')-TimeoutSeconds 60 -Label 'Finalen PR-Head bestätigen' -Quiet).Output|ConvertFrom-Json;if([string]$verify.headRefOid-eq$head){return[pscustomobject]@{Merged=$false;HeadSha=$head;Pr=$pr}};Write-Event 'INFO' 'PR-Head änderte sich nach erfolgreicher Check-Auswertung; Auswertung wird für den neuen Head wiederholt.'}
Write-State 80 'GitHub-Checks' "Finaler Head ${head}: $($classification.reason).";Start-Sleep -Seconds 10
}
}
function Ensure-Deployment($Session){
$prod=Check-ProductionVersion;if($prod.Ready-and$prod.Version-eq$TargetVersion){Write-Event 'OK' 'Produktion meldet die Zielversion bereits; Redeploy-Wartephase entfällt.';Complete-Step 'deploy';return$prod}
if($SkipProductionDeploy){Skip-Step 'deploy';Skip-Step 'verify';Write-State 100 'Source aktualisiert' 'GitHub wurde aktualisiert; das Produktionsdeployment wurde auf Wunsch übersprungen.' 'success';return$null}
Set-Step 'deploy' 'active';Write-State 88 'Coolify Auto Deploy' 'main ist auf Zielstand. Coolify übernimmt das Deployment über die bestehende GitHub-Verknüpfung.';$Session.releaseState='deploying';Save-Session $Session;$deadline=(Get-Date).AddMinutes(15);$nextLog=Get-Date;$last=''
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."};Complete-Step 'deploy';return$prod
}
try{
Write-Event 'INFO' "Vendoo Production Updater $UpdaterVersion gestartet. Zielversion $TargetVersion."
Set-Step 'package' 'active';Write-State 3 'Paketprüfung' 'Version, Herkunft, Payload-Hash und Manifest werden geprüft.';Assert-Package;$session=Load-Session;if($SelfTestFailAt-eq'package'){throw'Simulierter Paketfehler.'};Complete-Step 'package';Write-Event 'OK' "Paketprüfung erfolgreich. payloadHash=$($script:Manifest.payloadHash), sourceCommit=$($script:Manifest.sourceCommit), baseCommit=$($script:Manifest.baseCommit)."
Set-Step 'tools' 'active';Write-State 9 'Systemprüfung' 'Git, Node.js, npm und GitHub CLI werden geprüft.';$script:Git=Get-GitPath;if(-not$script:Git){throw'Git für Windows wurde nicht gefunden.'};$script:Node=Get-NodePath;$script:Npm=Get-NpmPath;if(-not$script:Node-or-not$script:Npm){throw'Node.js 22 LTS oder npm wurde nicht gefunden.'};$nv=Invoke-External $script: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.'};$gh=Ensure-GitHubCli;Complete-Step 'tools'
Set-Step 'github' 'active';Write-State 22 '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').Output|ConvertFrom-Json;if([string]$repo.viewerPermission-notin@('ADMIN','MAINTAIN','WRITE')){throw"Für $Repository fehlt Schreibzugriff."};Complete-Step 'github'
Set-Step 'production' 'active';Write-State 31 'Zielzustand' 'main, Produktion, bestehende PRs und Sitzung werden vor jeder Änderung abgeglichen.';$observation=Get-ReleaseObservation $gh;$decision=Get-Decision $observation $session;Write-Event 'INFO' "Lifecycle-Entscheidung: $($decision.action) / $($decision.reason); main=$($observation.MainVersion)@$($observation.MainSha); production=$($observation.Production.Version).";Complete-Step 'production'
if($SelfTest){foreach($id in @('workspace','tests','pullrequest','merge','deploy','verify')){Set-Step $id 'active';Start-Sleep -Milliseconds 120;Complete-Step $id};Write-State 100 'Selbsttest erfolgreich' 'Updater-3.4-Oberfläche und Host-Vertrag funktionieren.' 'success';return}
if([string]$decision.action-eq'complete'){Complete-WithoutSource $decision.reason $observation $session;return}
$prUrl='';$prNumber=$null;$sourceRequired=([string]$decision.action-eq'source')
if([string]$decision.action-eq'resume-pr'){$pr=Get-OpenPrFromDecision $decision;$prUrl=[string]$pr.url;$prNumber=$pr.number;$session.prUrl=$prUrl;$session.prNumber=$prNumber;$session.releaseState='checks';Save-Session $session;foreach($id in @('workspace','tests')){Skip-Step $id};Complete-Step 'pullrequest';Write-Event 'INFO' "Vorhandener offener Pull Request wird übernommen: $prUrl"}
elseif([string]$decision.action-eq'deploy'){foreach($id in @('workspace','tests','pullrequest','merge')){Skip-Step $id};$sourceRequired=$false}
if($sourceRequired){
Set-Step 'workspace' 'active';Write-State 40 'Payload-Sicherheitsprüfung' 'main wird isoliert geklont; Herkunft und mögliche Rückschritte werden vor dem Overlay geprüft.';if(Test-Path -LiteralPath $WorkRoot){Remove-Item $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
$guard=Invoke-External $script:Node @($PayloadGuardScript,$PackageRoot,$CloneDir,[string]$script:Manifest.baseCommit,[string]$observation.MainSha)-TimeoutSeconds 300 -AllowFailure -Label 'Veralteten Payload ausschließen';if($guard.Code-ne0){$detail='';try{$g=$guard.Output|ConvertFrom-Json;$detail=@($g.conflicts)-join', '}catch{$detail=$guard.Output};throw"Payload-Rückschritt blockiert. baseCommit=$($script:Manifest.baseCommit), main=$($observation.MainSha). Konflikte: $detail"}
Invoke-External $script:Git @('switch','-c',$Branch,$observation.MainSha)-WorkingDirectory $CloneDir -TimeoutSeconds 60 -Label 'Deterministischen Release-Branch erstellen'|Out-Null;Invoke-External $script: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 55 'Abnahmetests' 'npm ci und der vollständige Releasevertrag werden auf dem sicheren Overlay ausgeführt.';Invoke-External $script:Npm @('ci','--no-audit','--no-fund')-WorkingDirectory $CloneDir -TimeoutSeconds 1200 -Label 'npm ci'|Out-Null;Invoke-External $script:Npm @('run','verify:all')-WorkingDirectory $CloneDir -TimeoutSeconds 3600 -Label 'npm run verify:all'|Out-Null;Complete-Step 'tests'
$latest=Get-ReleaseObservation $gh;$lateDecision=Get-Decision $latest $session;if([string]$lateDecision.action-eq'complete'){Complete-WithoutSource 'target-reached-during-local-tests' $latest $session;return}elseif([string]$lateDecision.action-eq'resume-pr'){$pr=Get-OpenPrFromDecision $lateDecision;$prUrl=[string]$pr.url;$prNumber=$pr.number;Write-Event 'INFO' "Paralleler Lauf hat bereits einen PR erzeugt; dieser wird übernommen: $prUrl"}elseif([string]$lateDecision.action-eq'deploy'){foreach($id in @('pullrequest','merge')){Skip-Step $id};$sourceRequired=$false}else{
Set-Step 'pullrequest' 'active';Write-State 70 'Pull Request' 'Branch und Pull Request werden idempotent abgeglichen.';Invoke-External $script: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 $script:Git @('config','user.email',"$uid+$ulogin@users.noreply.github.com")-WorkingDirectory $CloneDir -Label 'Git-E-Mail setzen'|Out-Null;Invoke-External $script:Git @('add','--all')-WorkingDirectory $CloneDir -Label 'Änderungen vormerken'|Out-Null;$diff=Invoke-External $script:Git @('diff','--cached','--quiet')-WorkingDirectory $CloneDir -AllowFailure -Label 'Änderungen prüfen';if($diff.Code-ne1){throw'Payload erzeugt gegenüber main keinen gültigen neuen Source-Stand.'};Invoke-External $script:Git @('commit','-m',"release: Vendoo $TargetVersion production update via updater 3.4")-WorkingDirectory $CloneDir -TimeoutSeconds 120 -Label 'Release committen'|Out-Null
$remoteLine=(Invoke-External $script:Git @('ls-remote','--heads','origin',"refs/heads/$Branch")-WorkingDirectory $CloneDir -TimeoutSeconds 60 -AllowFailure -Label 'Remote-Branch prüfen' -Quiet).Output.Trim();if($remoteLine){$remoteSha=($remoteLine-split'\s+')[0];Invoke-External $script:Git @('push',"--force-with-lease=refs/heads/$Branch`:$remoteSha",'--set-upstream','origin',$Branch)-WorkingDirectory $CloneDir -TimeoutSeconds 300 -Label 'Bestehenden Release-Branch sicher aktualisieren'|Out-Null}else{Invoke-External $script:Git @('push','--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','number,url','--jq','.[0]')-AllowFailure -Label 'Vorhandenen Pull Request erneut suchen' -Quiet;if($existing.Output.Trim()){$existingObj=$existing.Output|ConvertFrom-Json;$prUrl=[string]$existingObj.url;$prNumber=$existingObj.number}else{$body=Join-Path $WorkRoot 'pr-body.md';$bodyText="## Vendoo $TargetVersion Production Update`r`n`r`nErstellt durch Vendoo Production Updater $UpdaterVersion.`r`n`r`n- vollständiges ``npm run verify:all`` erfolgreich`r`n- Payload-Herkunft und Rückschrittschutz geprüft`r`n- keine Datenbanken, Uploads, Secrets oder ``/app/data``-Inhalte`r`n- GitHub-Checks werden am finalen Head-SHA ausgewertet`r`n";[IO.File]::WriteAllText($body,$bodyText,(New-Object Text.UTF8Encoding($false)));$created=(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();$prUrl=$created;$prInfo=(Invoke-External $gh @('pr','view',$prUrl,'--repo',$Repository,'--json','number')-TimeoutSeconds 60 -Label 'PR-Nummer lesen' -Quiet).Output|ConvertFrom-Json;$prNumber=$prInfo.number}
$session.prUrl=$prUrl;$session.prNumber=$prNumber;$session.releaseState='checks';Save-Session $session;Complete-Step 'pullrequest';Write-Event 'OK' "Pull Request: $prUrl"
}
}
if($prUrl){Set-Step 'merge' 'active';Write-State 78 'GitHub-Checks' 'Workflow-Runs und Pflichtchecks werden für den jeweils aktuellen PR-Head verfolgt.';$checkResult=Wait-RequiredGitHubChecks $gh $prUrl $session;if(-not$checkResult.Merged){Write-State 84 'Merge' 'Der geprüfte finale Head wird atomar per Squash nach main übernommen.';Invoke-External $gh @('pr','merge',$prUrl,'--repo',$Repository,'--squash','--delete-branch','--subject',"Vendoo $TargetVersion Production Update",'--match-head-commit',[string]$checkResult.HeadSha)-WorkingDirectory $CloneDir -TimeoutSeconds 300 -Label 'Pull Request mergen'|Out-Null};$mergedInfo=(Invoke-External $gh @('pr','view',$prUrl,'--repo',$Repository,'--json','mergedAt,mergeCommit,headRefOid,state')-TimeoutSeconds 60 -Label 'Merge-Ergebnis bestätigen' -Quiet).Output|ConvertFrom-Json;if(-not$mergedInfo.mergedAt){throw'Der Pull Request wurde nicht als gemergt bestätigt.'};$session.finalPrHeadSha=[string]$mergedInfo.headRefOid;$session.mergeCommit=[string]$mergedInfo.mergeCommit.oid;$session.releaseState='merged';Save-Session $session;Complete-Step 'merge';Write-Event 'OK' "Merge bestätigt: $($session.mergeCommit)"}
$prod=Ensure-Deployment $session;if($SkipProductionDeploy){return};Set-Step 'verify' 'active';Write-State 97 'Produktionsprüfung' 'Die öffentliche Readiness und die exakte Zielversion werden abschließend bestätigt.';if($null-eq$prod){$prod=Check-ProductionVersion};if(-not($prod.Ready-and$prod.Version-eq$TargetVersion)){throw"Die abschließende Produktionsprüfung ist fehlgeschlagen: ready=$($prod.Ready), version=$($prod.Version)"};Complete-Step 'verify';$session.productionVersion=$prod.Version;$session.releaseState='completed';$session.completedAt=(Get-Date).ToString('o');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 und idempotent aktualisiert."
} catch [System.OperationCanceledException] {Fail-Step;Write-State 100 'Update abgebrochen' $_.Exception.Message 'cancelled' $_.Exception.Message 'Der gespeicherte Wiederaufnahmepunkt bleibt erhalten.';Write-Event 'WARN' $_.Exception.Message
}catch{Fail-Step;$msg=$_.Exception.Message;$remediation=if($script:CurrentStep -in @('deploy','verify')){'main kann bereits aktualisiert sein. Ein erneuter Start setzt bei Deployment und Produktionsprüfung fort.'}elseif($msg-match'Payload-Rückschritt'){'Das Paket ist gegenüber main veraltet. Kein Branch und kein PR wurden aus diesem Payload erstellt. Ein neues Paket aus aktuellem main erzeugen.'}else{'Ein erneuter Start gleicht main, PR, finalen Head-SHA und Sitzung erneut ab; bereits abgeschlossene Phasen werden nicht dupliziert.'};Write-State 100 'Update sicher gestoppt' $msg 'failed' $msg $remediation;Write-Event 'ERROR' $msg
}finally{if(Test-Path $InputFile){Remove-Item $InputFile -Force -ErrorAction SilentlyContinue}}
+2 -27
View File
@@ -153,12 +153,7 @@ configureAiModule({
stopBatchJobs: stopImageBatchJobLoop,
});
configureFluxStudioModule({ start: startFluxPromptJobLoop, stop: stopFluxPromptJobLoop });
configureDeploymentsModule({
createBackup,
verifyStoredBackup,
checkForUpdates,
getPlatformSnapshot: () => platformKernel.snapshot(),
});
configureDeploymentsModule({ createBackup, checkForUpdates });
configurePublishingModule({
async start() {
const migration = migrateLegacyEbayQueue();
@@ -614,8 +609,7 @@ 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-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';
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';
return null;
}
@@ -652,25 +646,6 @@ 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.42.0 grafischer Zero-Edit-Installationsassistent
# Vendoo 1.41.2 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.42.0 Installation'
$form.Text = 'Vendoo 1.41.2 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.42.0'
$Version = '1.41.2'
$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.42.0"
Start-Process "http://localhost:$Port/?build=1.41.2"
& node bootstrap.mjs
Pop-Location
}
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
set -eu
APP_VERSION="1.42.0"
APP_VERSION="1.41.2"
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
MODE_FILE="$ROOT_DIR/.vendoo-install-mode"
ENV_FILE="$ROOT_DIR/.env"
-19
View File
@@ -1,19 +0,0 @@
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);
}
}
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""Build a byte-reproducible ZIP archive from a release directory."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
FIXED_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
def iter_files(source: Path):
for path in sorted(source.rglob("*"), key=lambda item: item.as_posix()):
if path.is_file() and not path.is_symlink():
yield path
def build_archive(source: Path, destination: Path) -> None:
source = source.resolve(strict=True)
if not source.is_dir():
raise ValueError(f"Source is not a directory: {source}")
destination = destination.resolve()
if source == destination or source in destination.parents:
raise ValueError("Destination must be outside the source directory.")
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(destination.suffix + ".tmp")
temporary.unlink(missing_ok=True)
try:
with ZipFile(temporary, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive:
for file_path in iter_files(source):
relative = file_path.relative_to(source.parent).as_posix()
info = ZipInfo(relative, FIXED_TIMESTAMP)
info.compress_type = ZIP_DEFLATED
info.create_system = 3
mode = file_path.stat().st_mode & 0o777
info.external_attr = (mode & 0xFFFF) << 16
info.flag_bits |= 0x800
archive.writestr(info, file_path.read_bytes(), compress_type=ZIP_DEFLATED, compresslevel=9)
os.replace(temporary, destination)
finally:
temporary.unlink(missing_ok=True)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
build_archive(args.source, args.destination)
print(f"Deterministic ZIP created: {args.destination}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+28 -49
View File
@@ -1,63 +1,43 @@
import {
copyFileSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { cpSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { dirname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateReleaseManifest } from './generate-release-manifest.mjs';
import { MANIFEST_FILE, readAndValidateManifest } from './release-manifest-lib.mjs';
import { createPackageManifest } from './updater-lifecycle-3.4.mjs';
import { shouldSkipStagingEntry } from './prepare-git-staging.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 normalize = value => value.split(sep).join('/');
function git(args, { allowFailure = false } = {}) {
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
if (!allowFailure && result.status !== 0) {
throw new Error(`git ${args.join(' ')} fehlgeschlagen: ${(result.stderr || result.stdout || '').trim()}`);
}
return result.status === 0 ? String(result.stdout || '').trim() : '';
}
// Das committed Manifest bleibt der reproduzierbare Quellvertrag. Das ausgelieferte
// Paket erhält zusätzlich eine unveränderliche Git-Herkunft und einen Payload-Hash.
generateReleaseManifest(root);
const manifest = readAndValidateManifest(root);
const sourceCommit = process.env.VENDOO_SOURCE_COMMIT || git(['rev-parse', 'HEAD']);
const configuredBase = process.env.VENDOO_BASE_COMMIT || '';
const discoveredBase = configuredBase || git(['merge-base', sourceCommit, 'origin/main'], { allowFailure: true });
const baseCommit = discoveredBase || sourceCommit;
const createdAt = process.env.VENDOO_SOURCE_CREATED_AT || git(['show', '-s', '--format=%cI', sourceCommit]);
const packageManifest = createPackageManifest(manifest, { sourceCommit, baseCommit, createdAt });
const packageName = `Vendoo_${pkg.version}_Updater_${manifest.updaterVersion}`;
const packageName = `Vendoo_${version}`;
const stage = join(outputRoot, packageName);
const normalize = value => value.split(sep).join('/');
rmSync(outputRoot, { recursive: true, force: true });
mkdirSync(stage, { recursive: true });
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}`);
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);
}
}
}
writeFileSync(join(stage, MANIFEST_FILE), `${JSON.stringify(packageManifest, null, 2)}\n`, 'utf8');
readAndValidateManifest(stage);
copyTree(root, stage);
const checksums = [];
function hashTree(dir) {
for (const name of readdirSync(dir).sort((a, b) => a.localeCompare(b, 'en'))) {
for (const name of readdirSync(dir).sort((a, b) => a.localeCompare(b))) {
const abs = join(dir, name);
const stat = statSync(abs);
const stat = lstatSync(abs);
if (stat.isDirectory()) hashTree(abs);
else {
const rel = normalize(relative(stage, abs));
@@ -68,8 +48,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(`Manifestbasiertes Release-Staging erstellt: ${stage}`);
console.log(`${manifest.files.length} Payload-Dateien und ${checksums.length} Paketdateien mit SHA-256 erfasst.`);
console.log(`Paket-Provenienz: source=${sourceCommit}, base=${baseCommit}, payload=${packageManifest.payloadHash}`);
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.`);
-86
View File
@@ -1,86 +0,0 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { evaluatePayloadGuard } from './updater-lifecycle-3.4.mjs';
const sha256 = value => createHash('sha256').update(value).digest('hex');
function git(repo, args, { allowFailure = false, encoding = 'utf8' } = {}) {
const result = spawnSync('git', ['-C', repo, ...args], { encoding, maxBuffer: 64 * 1024 * 1024 });
if (!allowFailure && result.status !== 0) {
throw new Error(`git ${args.join(' ')} fehlgeschlagen: ${(result.stderr || result.stdout || '').trim()}`);
}
return result;
}
function readGitFile(repo, commit, path) {
const result = git(repo, ['show', `${commit}:${path}`], { allowFailure: true, encoding: null });
if (result.status !== 0) return null;
return Buffer.from(result.stdout);
}
function normalize(path) {
return String(path || '').replaceAll('\\', '/').replace(/^\.\//, '');
}
export function inspectPayload({ packageRoot, repositoryRoot, baseCommit, currentMainCommit }) {
const source = resolve(packageRoot);
const repo = resolve(repositoryRoot);
const manifest = JSON.parse(readFileSync(join(source, 'release-manifest.json'), 'utf8'));
const baseExists = git(repo, ['cat-file', '-e', `${baseCommit}^{commit}`], { allowFailure: true }).status === 0;
const mainExists = git(repo, ['cat-file', '-e', `${currentMainCommit}^{commit}`], { allowFailure: true }).status === 0;
if (!baseExists || !mainExists) {
return {
...evaluatePayloadGuard({ baseCommit: baseExists ? baseCommit : '', currentMainCommit: mainExists ? currentMainCommit : '', baseIsAncestor: false }),
payloadChangedPaths: [],
mainChangedPaths: [],
};
}
const baseIsAncestor = git(repo, ['merge-base', '--is-ancestor', baseCommit, currentMainCommit], { allowFailure: true }).status === 0;
const mainChangedPaths = new Set(
String(git(repo, ['diff', '--name-only', `${baseCommit}..${currentMainCommit}`, '--']).stdout || '')
.split(/\r?\n/).map(normalize).filter(Boolean),
);
const payloadChangedPaths = new Set();
const desired = new Map();
for (const entry of manifest.files || []) {
const path = normalize(entry.path);
const baseBytes = readGitFile(repo, baseCommit, path);
const baseHash = baseBytes === null ? null : sha256(baseBytes);
if (baseHash !== String(entry.sha256)) payloadChangedPaths.add(path);
desired.set(path, String(entry.sha256));
}
for (const value of manifest.removedFiles || []) {
const path = normalize(value);
if (readGitFile(repo, baseCommit, path) !== null) payloadChangedPaths.add(path);
desired.set(path, null);
}
const conflictingPaths = [];
for (const path of payloadChangedPaths) {
if (!mainChangedPaths.has(path)) continue;
const currentBytes = readGitFile(repo, currentMainCommit, path);
const currentHash = currentBytes === null ? null : sha256(currentBytes);
if (currentHash !== desired.get(path)) conflictingPaths.push(path);
}
return {
...evaluatePayloadGuard({ baseCommit, currentMainCommit, baseIsAncestor, conflictingPaths }),
payloadChangedPaths: [...payloadChangedPaths].sort(),
mainChangedPaths: [...mainChangedPaths].sort(),
};
}
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replaceAll('\\', '/')}`).href) {
const [packageRoot, repositoryRoot, baseCommit, currentMainCommit] = process.argv.slice(2);
try {
if (!packageRoot || !repositoryRoot || !baseCommit || !currentMainCommit) {
throw new Error('Aufruf: node check-release-payload-3.4.mjs <packageRoot> <repositoryRoot> <baseCommit> <currentMainCommit>');
}
const result = inspectPayload({ packageRoot, repositoryRoot, baseCommit, currentMainCommit });
process.stdout.write(`${JSON.stringify(result)}\n`);
if (!result.allowed) process.exitCode = 2;
} catch (error) {
process.stderr.write(`[FEHLER] ${error.message}\n`);
process.exit(1);
}
}
-29
View File
@@ -1,29 +0,0 @@
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);
}
}
-247
View File
@@ -1,247 +0,0 @@
import {
chmodSync,
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { computePackagePayloadHash, PACKAGE_MANIFEST_SCHEMA } from './updater-lifecycle-3.4.mjs';
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.Worker34.ps1',
'tools/apply-release-manifest.mjs',
'tools/updater-lifecycle-3.4.mjs',
'tools/check-release-payload-3.4.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, PACKAGE_MANIFEST_SCHEMA].includes(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 (manifest.schema === PACKAGE_MANIFEST_SCHEMA) {
if (manifest.targetVersion !== manifest.version) throw new Error('targetVersion und version des Paketmanifests stimmen nicht überein.');
for (const field of ['sourceCommit', 'baseCommit']) {
if (!/^[a-f0-9]{40}$/.test(String(manifest[field] || ''))) throw new Error(`Ungültige Paket-Provenienz: ${field}`);
}
const createdAt = Date.parse(String(manifest.createdAt || ''));
if (!Number.isFinite(createdAt)) throw new Error('Ungültige Paket-Provenienz: createdAt');
if (!/^[a-f0-9]{64}$/.test(String(manifest.payloadHash || ''))) throw new Error('Ungültige Paket-Provenienz: payloadHash');
const expectedPayloadHash = computePackagePayloadHash(manifest);
if (manifest.payloadHash !== expectedPayloadHash) throw new Error('Payload-Hash des Paketmanifests stimmt nicht.');
}
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
@@ -1,34 +0,0 @@
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;
}
}
-200
View File
@@ -1,200 +0,0 @@
import { createHash } from 'node:crypto';
const ACTIVE = new Set(['queued', 'pending', 'in_progress', 'waiting', 'requested']);
const SUCCESS = new Set(['success', 'neutral', 'skipped']);
const FAILURE = new Set(['failure', 'timed_out', 'action_required', 'startup_failure', 'stale']);
const CANCELLED = new Set(['cancelled']);
export const UPDATER_VERSION = '3.4.0';
export const SESSION_SCHEMA = 'vendoo-updater-session-v2';
export const PACKAGE_MANIFEST_SCHEMA = 'vendoo-release-package-v2';
export const REQUIRED_CHECKS = Object.freeze([
'Syntax, Sicherheit und Release-Gates',
'Windows vollständiger Releasevertrag',
'Updater-3.4-Paket nach Abnahme',
]);
export const REQUIRED_WORKFLOWS = Object.freeze([
'Vendoo CI',
'Vendoo Release Manifest Sync',
]);
const lower = value => String(value || '').trim().toLowerCase();
const time = value => {
const parsed = Date.parse(value || '');
return Number.isFinite(parsed) ? parsed : 0;
};
const stablePayload = manifest => ({
schema: PACKAGE_MANIFEST_SCHEMA,
targetVersion: String(manifest.targetVersion || manifest.version || ''),
updaterVersion: String(manifest.updaterVersion || ''),
sourceCommit: String(manifest.sourceCommit || ''),
baseCommit: String(manifest.baseCommit || ''),
createdAt: String(manifest.createdAt || ''),
mode: String(manifest.mode || ''),
requiredFiles: [...(manifest.requiredFiles || [])],
removedFiles: [...(manifest.removedFiles || [])],
files: [...(manifest.files || [])],
});
export function computePackagePayloadHash(manifest) {
return createHash('sha256').update(JSON.stringify(stablePayload(manifest))).digest('hex');
}
export function createPackageManifest(sourceManifest, { sourceCommit, baseCommit, createdAt }) {
const manifest = {
...sourceManifest,
schema: PACKAGE_MANIFEST_SCHEMA,
targetVersion: String(sourceManifest.version || sourceManifest.targetVersion || ''),
sourceCommit: String(sourceCommit || ''),
baseCommit: String(baseCommit || ''),
createdAt: String(createdAt || ''),
};
manifest.payloadHash = computePackagePayloadHash(manifest);
return manifest;
}
export function payloadIdentity(manifest) {
const targetVersion = String(manifest?.targetVersion || manifest?.version || '');
const payloadHash = String(manifest?.payloadHash || '');
return `${targetVersion}:${payloadHash}`;
}
export function createSession({ repository, targetVersion, updaterVersion = UPDATER_VERSION, payloadHash, branch }) {
return {
schema: SESSION_SCHEMA,
repository,
targetVersion,
updaterVersion,
payloadHash,
branch,
releaseState: 'new',
prNumber: null,
prUrl: '',
finalPrHeadSha: '',
mergeCommit: '',
productionVersion: '',
completedAt: '',
updatedAt: new Date().toISOString(),
};
}
export function sessionMatches(session, { repository, targetVersion, payloadHash }) {
return Boolean(session && session.schema === SESSION_SCHEMA && session.repository === repository && session.targetVersion === targetVersion && session.payloadHash === payloadHash);
}
export function decideReleasePath({ targetVersion, mainVersion, productionReady, productionVersion, pullRequests = [], session = null }) {
const productionAtTarget = Boolean(productionReady && productionVersion === targetVersion);
if (session?.releaseState === 'completed' && productionAtTarget) return { action: 'complete', reason: 'completed-session' };
if (mainVersion === targetVersion) {
return productionAtTarget ? { action: 'complete', reason: 'main-and-production-at-target' } : { action: 'deploy', reason: 'main-at-target-production-behind' };
}
const relevant = pullRequests.filter(pr => pr && pr.targetVersion === targetVersion);
const open = relevant.filter(pr => lower(pr.state) === 'open').sort((a, b) => time(b.updatedAt) - time(a.updatedAt))[0];
if (open) return { action: 'resume-pr', reason: 'matching-open-pr', pullRequest: open };
const merged = relevant.filter(pr => Boolean(pr.mergedAt || pr.merged)).sort((a, b) => time(b.mergedAt || b.updatedAt) - time(a.mergedAt || a.updatedAt))[0];
if (merged) return { action: 'deploy', reason: 'matching-pr-already-merged', pullRequest: merged };
return { action: 'source', reason: 'source-update-required' };
}
function normalizeCheck(check) {
const status = lower(check?.status);
const conclusion = lower(check?.conclusion);
const updatedAt = check?.completedAt || check?.startedAt || check?.updatedAt || '';
let state = 'missing';
if (ACTIVE.has(status) || (!conclusion && status && status !== 'completed')) state = 'pending';
else if (SUCCESS.has(conclusion)) state = 'success';
else if (FAILURE.has(conclusion)) state = 'failure';
else if (CANCELLED.has(conclusion)) state = 'cancelled';
else if (status === 'completed' && !conclusion) state = 'pending';
return { name: String(check?.name || ''), workflowName: String(check?.workflowName || ''), state, status, conclusion, updatedAt, detailsUrl: String(check?.detailsUrl || '') };
}
function bestCheck(checks) {
return [...checks].sort((a, b) => {
const dateDelta = time(b.updatedAt) - time(a.updatedAt);
if (dateDelta) return dateDelta;
const rank = { success: 4, pending: 3, failure: 2, cancelled: 1, missing: 0 };
return (rank[b.state] || 0) - (rank[a.state] || 0);
})[0];
}
function normalizeRun(run) {
const status = lower(run?.status);
const conclusion = lower(run?.conclusion);
let state = 'missing';
if (ACTIVE.has(status)) state = 'pending';
else if (SUCCESS.has(conclusion)) state = 'success';
else if (FAILURE.has(conclusion)) state = 'failure';
else if (CANCELLED.has(conclusion)) state = 'cancelled';
return { name: String(run?.name || run?.workflowName || ''), state, status, conclusion, updatedAt: run?.updated_at || run?.updatedAt || run?.run_started_at || '', url: String(run?.html_url || run?.url || '') };
}
function bestRun(runs) {
return [...runs].sort((a, b) => {
const dateDelta = time(b.updatedAt) - time(a.updatedAt);
if (dateDelta) return dateDelta;
const rank = { success: 4, pending: 3, failure: 2, cancelled: 1, missing: 0 };
return (rank[b.state] || 0) - (rank[a.state] || 0);
})[0];
}
export function classifyChecks({ checks = [], workflowRuns = [], requiredChecks = REQUIRED_CHECKS, requiredWorkflows = REQUIRED_WORKFLOWS, elapsedSeconds = 0, gracePeriodSeconds = 90, timedOut = false }) {
const normalizedChecks = checks.map(normalizeCheck);
const normalizedRuns = workflowRuns.map(normalizeRun);
const checkRows = requiredChecks.map(name => {
const candidates = normalizedChecks.filter(check => check.name === name);
return candidates.length ? bestCheck(candidates) : { name, state: 'missing', status: '', conclusion: '', updatedAt: '', detailsUrl: '' };
});
const workflowRows = requiredWorkflows.map(name => {
const candidates = normalizedRuns.filter(run => run.name === name);
return candidates.length ? bestRun(candidates) : { name, state: 'missing', status: '', conclusion: '', updatedAt: '', url: '' };
});
const rows = [...checkRows, ...workflowRows];
const terminalFailure = rows.find(row => row.state === 'failure');
if (terminalFailure) return { state: 'failed', reason: `${terminalFailure.name}: ${terminalFailure.conclusion || terminalFailure.status}`, rows };
const terminalCancelled = rows.find(row => row.state === 'cancelled');
if (terminalCancelled) return { state: 'failed', reason: `${terminalCancelled.name}: cancelled`, rows };
if (rows.every(row => row.state === 'success')) return { state: 'success', reason: 'all-required-checks-successful', rows };
const missing = rows.filter(row => row.state === 'missing');
if (timedOut) return { state: 'timeout', reason: 'required-checks-not-terminal-before-timeout', rows };
if (missing.length && elapsedSeconds < gracePeriodSeconds) return { state: 'waiting-registration', reason: 'checks-within-registration-grace-period', rows };
if (missing.length) return { state: 'waiting-missing', reason: 'required-checks-not-yet-registered', rows };
return { state: 'pending', reason: 'required-checks-still-running', rows };
}
export function formatStatusMatrix(rows = []) {
return rows.map(row => `${row.name}: ${row.state} (${row.conclusion || row.status || 'not-reported'})`).join('\n');
}
export function evaluatePayloadGuard({ baseCommit, currentMainCommit, baseIsAncestor, conflictingPaths = [] }) {
if (!baseCommit || !currentMainCommit) return { allowed: false, reason: 'missing-commit-provenance', conflicts: [] };
if (!baseIsAncestor) return { allowed: false, reason: 'base-is-not-ancestor-of-main', conflicts: [] };
if (conflictingPaths.length) return { allowed: false, reason: 'payload-would-overwrite-newer-main', conflicts: [...conflictingPaths].sort() };
return { allowed: true, reason: baseCommit === currentMainCommit ? 'exact-base' : 'non-conflicting-main-advance', conflicts: [] };
}
function parseStdin() {
return new Promise((resolve, reject) => {
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => { try { resolve(input.trim() ? JSON.parse(input) : {}); } catch (error) { reject(error); } });
process.stdin.on('error', reject);
});
}
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replaceAll('\\', '/')}`).href) {
const command = process.argv[2];
try {
const input = await parseStdin();
let output;
if (command === 'decide') output = decideReleasePath(input);
else if (command === 'classify-checks') output = classifyChecks(input);
else if (command === 'payload-guard') output = evaluatePayloadGuard(input);
else throw new Error(`Unbekannter Lifecycle-Befehl: ${command}`);
process.stdout.write(`${JSON.stringify(output)}\n`);
} catch (error) {
process.stderr.write(`[FEHLER] ${error.message}\n`);
process.exit(1);
}
}
+25 -42
View File
@@ -1,55 +1,38 @@
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');
import { ModuleRegistry } from '../app/kernel/module-registry.mjs';
import { loadBuiltInModules } from '../app/modules/catalog.mjs';
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 {
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' });
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 === 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.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');
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');
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.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.');
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.');
@@ -1,57 +0,0 @@
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
@@ -1,139 +0,0 @@
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.43.0') failures.push(`package.json-Version ist ${pkg.version}`);
if (pkg.version !== '1.41.2') 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.43.0 fehlgeschlagen (${failures.length}):`);
console.error(`FLUX-Loop-Hotfix-Gate 1.41.2 fehlgeschlagen (${failures.length}):`);
failures.forEach(item => console.error(`- ${item}`));
process.exit(1);
}
console.log('FLUX-Loop-Hotfix-Gate 1.43.0 erfolgreich: Stop-Export, Lifecycle-Verdrahtung und CI-Verträge geprüft.');
console.log('FLUX-Loop-Hotfix-Gate 1.41.2 erfolgreich: Stop-Export, Lifecycle-Verdrahtung und CI-Verträge geprüft.');

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