diff --git a/.github/workflows/obfuscate-main.yml b/.github/workflows/obfuscate-main.yml new file mode 100644 index 0000000..0c1b10e --- /dev/null +++ b/.github/workflows/obfuscate-main.yml @@ -0,0 +1,44 @@ +name: Obfuscate Main Build + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +jobs: + obfuscate: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run release obfuscation + run: python scripts/obfuscate_release.py --root . --hash-assets + + - name: Commit obfuscated build + run: | + git add -A + if git diff --cached --quiet; then + echo "No build changes to commit." + exit 0 + fi + git commit -m "chore(release): obfuscate and hash production assets [skip ci]" + git push diff --git a/README.md b/README.md index 0366fd7..f9fbb03 100644 --- a/README.md +++ b/README.md @@ -166,25 +166,22 @@ Für den Produktivbetrieb `public/` als Webroot konfigurieren. ### Production-Build & Veröffentlichung -Der Quellcode bleibt auf `dev`, der veröffentlichte Stand liegt auf `main` (ohne Kommentare, obfuskiertes JS). +Der Quellcode bleibt auf `dev`, der veröffentlichte Stand liegt auf `main`. -**Voraussetzungen:** Node.js 18+ (inkl. npm), PHP 8+ CLI, Git +Bei jedem Push/Merge auf `main` läuft die GitHub Action `.github/workflows/obfuscate-main.yml` automatisch und führt aus: -```powershell -# Windows -.\scripts\run-build.ps1 -.\scripts\publish-to-main.ps1 -Push -``` +- Entfernen von Kommentaren (inkl. Block-Kommentaren) in PHP/JS/CSS +- Minify + Obfuscate für JavaScript +- Minify für CSS +- Kein Source-Map-Output +- Hashing von JS/CSS-Dateinamen + automatische Referenz-Anpassung + +Lokal ausführbar: ```bash -# Linux / macOS -chmod +x scripts/*.sh -./scripts/run-build.sh -./scripts/publish-to-main.sh --push +python scripts/obfuscate_release.py --root . --hash-assets ``` -Details: `scripts/build/README.md` - ## 🔗 Backend-Integration Das Backend-Repository enthält folgende wiederverwendbare Komponenten: diff --git a/scripts/__pycache__/obfuscate_release.cpython-314.pyc b/scripts/__pycache__/obfuscate_release.cpython-314.pyc new file mode 100644 index 0000000..475c68f Binary files /dev/null and b/scripts/__pycache__/obfuscate_release.cpython-314.pyc differ diff --git a/scripts/obfuscate_release.py b/scripts/obfuscate_release.py new file mode 100644 index 0000000..9f0fa6e --- /dev/null +++ b/scripts/obfuscate_release.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + + +TEXT_EXTENSIONS = {".php", ".html", ".htm", ".xml", ".txt", ".js", ".css"} + + +def strip_comments_keep_strings(text: str) -> str: + out = [] + i = 0 + n = len(text) + in_single = False + in_double = False + in_template = False + escape = False + in_line_comment = False + in_block_comment = False + + while i < n: + ch = text[i] + nxt = text[i + 1] if i + 1 < n else "" + + if in_line_comment: + if ch == "\n": + in_line_comment = False + out.append(ch) + i += 1 + continue + + if in_block_comment: + if ch == "*" and nxt == "/": + in_block_comment = False + i += 2 + else: + if ch == "\n": + out.append("\n") + i += 1 + continue + + if in_single or in_double or in_template: + out.append(ch) + if escape: + escape = False + elif ch == "\\": + escape = True + elif in_single and ch == "'": + in_single = False + elif in_double and ch == '"': + in_double = False + elif in_template and ch == "`": + in_template = False + i += 1 + continue + + if ch == "/" and nxt == "/": + in_line_comment = True + i += 2 + continue + if ch == "/" and nxt == "*": + in_block_comment = True + i += 2 + continue + + if ch == "'": + in_single = True + out.append(ch) + i += 1 + continue + if ch == '"': + in_double = True + out.append(ch) + i += 1 + continue + if ch == "`": + in_template = True + out.append(ch) + i += 1 + continue + + out.append(ch) + i += 1 + + return "".join(out) + + +def strip_php_comments(text: str) -> str: + out = [] + i = 0 + n = len(text) + in_single = False + in_double = False + in_line_comment = False + in_block_comment = False + escape = False + + while i < n: + ch = text[i] + nxt = text[i + 1] if i + 1 < n else "" + + if in_line_comment: + if ch == "\n": + in_line_comment = False + out.append("\n") + i += 1 + continue + + if in_block_comment: + if ch == "*" and nxt == "/": + in_block_comment = False + i += 2 + else: + if ch == "\n": + out.append("\n") + i += 1 + continue + + if in_single or in_double: + out.append(ch) + if escape: + escape = False + elif ch == "\\": + escape = True + elif in_single and ch == "'": + in_single = False + elif in_double and ch == '"': + in_double = False + i += 1 + continue + + if ch == "/" and nxt == "/": + in_line_comment = True + i += 2 + continue + if ch == "#": + in_line_comment = True + i += 1 + continue + if ch == "/" and nxt == "*": + in_block_comment = True + i += 2 + continue + + if ch == "'": + in_single = True + elif ch == '"': + in_double = True + + out.append(ch) + i += 1 + + return "".join(out) + + +def minify_css_fallback(text: str) -> str: + text = strip_comments_keep_strings(text) + text = re.sub(r"\s+", " ", text) + text = re.sub(r"\s*([{}:;,>+~])\s*", r"\1", text) + return text.strip() + + +def minify_js_fallback(text: str) -> str: + text = strip_comments_keep_strings(text) + text = re.sub(r"\s+", " ", text) + text = re.sub(r"\s*([{}:;,()=+\-*/<>!&|?])\s*", r"\1", text) + return text.strip() + + +def run_cmd(command: list[str], cwd: Path, input_text: str | None = None) -> str: + proc = subprocess.run( + command, + cwd=str(cwd), + input=input_text, + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "command failed") + return proc.stdout + + +def process_js(path: Path, cwd: Path) -> None: + original = path.read_text(encoding="utf-8") + if shutil.which("npx"): + try: + minified = run_cmd( + [ + "npx", + "--yes", + "terser", + "--compress", + "--mangle", + "--comments", + "false", + ], + cwd, + input_text=original, + ) + obfuscated = run_cmd( + [ + "npx", + "--yes", + "javascript-obfuscator", + "--compact", + "true", + "--control-flow-flattening", + "true", + "--dead-code-injection", + "true", + "--string-array", + "true", + "--string-array-encoding", + "base64", + "--target", + "browser-no-eval", + "--source-map", + "false", + "--output", + "stdout", + ], + cwd, + input_text=minified, + ) + path.write_text(obfuscated.strip() + "\n", encoding="utf-8") + return + except Exception: + pass + path.write_text(minify_js_fallback(original) + "\n", encoding="utf-8") + + +def process_css(path: Path, cwd: Path) -> None: + original = path.read_text(encoding="utf-8") + if shutil.which("npx"): + try: + minified = run_cmd( + [ + "npx", + "--yes", + "clean-css-cli", + "--skip-rebase", + "-O2", + ], + cwd, + input_text=original, + ) + path.write_text(minified.strip() + "\n", encoding="utf-8") + return + except Exception: + pass + path.write_text(minify_css_fallback(original) + "\n", encoding="utf-8") + + +def process_php(path: Path) -> None: + original = path.read_text(encoding="utf-8") + stripped = strip_php_comments(original) + path.write_text(stripped, encoding="utf-8") + + +def hash_file(path: Path) -> str: + digest = hashlib.sha256(path.read_bytes()).hexdigest()[:12] + return digest + + +def replace_references(root: Path, mapping: dict[str, str]) -> None: + for candidate in root.rglob("*"): + if not candidate.is_file() or candidate.suffix.lower() not in TEXT_EXTENSIONS: + continue + try: + content = candidate.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + updated = content + for src, dst in mapping.items(): + updated = updated.replace(src, dst) + updated = updated.replace("/" + src, "/" + dst) + if updated != content: + candidate.write_text(updated, encoding="utf-8") + + +def build_hash_mapping(public_root: Path) -> dict[str, str]: + mapping: dict[str, str] = {} + asset_root = public_root / "assets" + for ext in (".js", ".css"): + for file_path in sorted(asset_root.rglob(f"*{ext}")): + if ".min." in file_path.name or ".obf." in file_path.name: + continue + digest = hash_file(file_path) + new_name = f"{file_path.stem}.{digest}{file_path.suffix}" + new_path = file_path.with_name(new_name) + file_path.rename(new_path) + rel_old = file_path.relative_to(public_root).as_posix() + rel_new = new_path.relative_to(public_root).as_posix() + mapping[rel_old] = rel_new + return mapping + + +def main() -> int: + parser = argparse.ArgumentParser(description="Release obfuscation build.") + parser.add_argument("--root", default=".", help="Repository root") + parser.add_argument("--hash-assets", action="store_true", help="Hash JS/CSS file names") + args = parser.parse_args() + + repo_root = Path(args.root).resolve() + public_root = repo_root / "public" + + if not public_root.exists(): + print("public directory not found", file=sys.stderr) + return 1 + + for js in sorted(public_root.rglob("*.js")): + process_js(js, repo_root) + for css in sorted(public_root.rglob("*.css")): + process_css(css, repo_root) + for php in sorted((repo_root / "public").rglob("*.php")): + process_php(php) + for php in sorted((repo_root / "backend").rglob("*.php")): + process_php(php) + + if args.hash_assets: + mapping = build_hash_mapping(public_root) + replace_references(repo_root, mapping) + + print("Release obfuscation complete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish-to-main.ps1 b/scripts/publish-to-main.ps1 deleted file mode 100644 index 2a472eb..0000000 --- a/scripts/publish-to-main.ps1 +++ /dev/null @@ -1,185 +0,0 @@ -#Requires -Version 5.1 -<# -.SYNOPSIS - Erstellt einen Production-Build und veroeffentlicht ihn auf den Branch main. - -.DESCRIPTION - 1. Wechselt auf main und setzt ihn auf den Stand von dev - 2. Entfernt Kommentare, minifiziert CSS, obfuskiert JavaScript - 3. Committet und pusht main (optional) - 4. Wechselt zurueck auf dev (Quellcode bleibt unveraendert) - -.PARAMETER Push - Pusht main nach origin (Standard: nur lokaler Commit) - -.PARAMETER DryRun - Fuehrt Git-Schritte nur simuliert aus (Build wird trotzdem erstellt) - -.PARAMETER AllowDirty - Erlaubt uncommittete Aenderungen - -.PARAMETER Message - Commit-Nachricht fuer den Production-Build - -.EXAMPLE - .\scripts\publish-to-main.ps1 - -.EXAMPLE - .\scripts\publish-to-main.ps1 -Push -#> -[CmdletBinding()] -param( - [switch]$Push, - [switch]$DryRun, - [switch]$AllowDirty, - [string]$Message = "" -) - -$ErrorActionPreference = "Stop" -$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path -$BuildDir = Join-Path $Root "scripts\build" -$OriginalBranch = "" - -function Write-Step([string]$Text) { - Write-Host "" - Write-Host "==> $Text" -ForegroundColor Cyan -} - -function Ensure-GitClean { - $status = git -C $Root status --porcelain - if ($status) { - throw "Uncommittete Aenderungen im Repository. Bitte zuerst committen oder stashen." - } -} - -function Resolve-NodeTool([string]$ToolName) { - $command = Get-Command $ToolName -ErrorAction SilentlyContinue - if ($command) { - return $command.Source - } - - $candidates = @( - (Join-Path $env:ProgramFiles "nodejs\$ToolName.cmd"), - (Join-Path ${env:ProgramFiles(x86)} "nodejs\$ToolName.cmd"), - (Join-Path $env:LOCALAPPDATA "Programs\nodejs\$ToolName.cmd"), - "c:\Program Files\cursor\resources\app\resources\helpers\node.exe" - ) - - foreach ($candidate in $candidates) { - if ($ToolName -eq "node" -and (Test-Path $candidate)) { - return $candidate - } - if ($ToolName -ne "node" -and (Test-Path $candidate)) { - return $candidate - } - } - - return $null -} - -function Ensure-Node { - $script:NodeExe = Resolve-NodeTool "node" - $script:NpmExe = Resolve-NodeTool "npm" - - if (-not $script:NodeExe) { - throw "Node.js ist nicht installiert. Bitte Node.js 18+ installieren: https://nodejs.org/" - } - if (-not $script:NpmExe) { - throw "npm wurde nicht gefunden. Bitte Node.js inkl. npm installieren und PATH setzen." - } -} - -try { - Set-Location $Root - Ensure-Node - if (-not $AllowDirty) { - Ensure-GitClean - } else { - Write-Warning "AllowDirty aktiv - uncommittete Aenderungen werden mit veroeffentlicht." - } - - $OriginalBranch = (git branch --show-current).Trim() - if ($OriginalBranch -ne "dev") { - Write-Warning "Empfohlen: Auf Branch 'dev' starten (aktuell: $OriginalBranch)" - } - - if ([string]::IsNullOrWhiteSpace($Message)) { - $Message = "chore(release): production build $(Get-Date -Format 'yyyy-MM-dd HH:mm')" - } - - Write-Step "Installiere Build-Abhaengigkeiten" - Set-Location $BuildDir - if (-not $DryRun) { - & $NpmExe ci --no-fund --no-audit - if ($LASTEXITCODE -ne 0) { throw "npm ci fehlgeschlagen" } - } - - Write-Step "Wechsle auf main und synchronisiere mit dev" - Set-Location $Root - if ($DryRun) { - Write-Host '[DryRun] git checkout main' - Write-Host '[DryRun] git reset --hard dev' - } else { - git checkout main - git reset --hard dev - } - - Write-Step "Production-Build (Kommentare entfernen, JS obfuscaten)" - Set-Location $BuildDir - if ($DryRun) { - Write-Host '[DryRun] npm run build:in-place' - } else { - & $NpmExe run build:in-place - if ($LASTEXITCODE -ne 0) { throw "Production-Build fehlgeschlagen" } - } - - Write-Step "Production-Build committen" - Set-Location $Root - if ($DryRun) { - Write-Host '[DryRun] git add -A' - Write-Host ('[DryRun] git commit -m "' + $Message + '"') - } else { - git add -A - $null = git diff --cached --quiet - if ($LASTEXITCODE -eq 0) { - Write-Warning "Keine Build-Aenderungen - nichts zu committen." - } else { - git commit -m $Message - } - } - - if ($Push) { - Write-Step "Push nach origin/main" - if ($DryRun) { - Write-Host '[DryRun] git push origin main' - } else { - git push origin main - } - } else { - Write-Host "Hinweis: Ohne -Push wurde nur lokal auf main gebaut." -ForegroundColor Yellow - } - - Write-Step "Zurueck auf $OriginalBranch" - if (-not $DryRun) { - if ([string]::IsNullOrWhiteSpace($OriginalBranch)) { - git checkout dev - } else { - git checkout $OriginalBranch - } - } - - Write-Host "" - Write-Host "Production-Release abgeschlossen." -ForegroundColor Green - if (-not $Push -and -not $DryRun) { - Write-Host "Zum Veroeffentlichen: git push origin main" -ForegroundColor Yellow - } -} -catch { - Write-Host "" - Write-Host ('FEHLER: ' + $_.Exception.Message) -ForegroundColor Red - Set-Location $Root - if ($OriginalBranch -and -not $DryRun) { - git checkout $OriginalBranch 2>$null - } - exit 1 -} diff --git a/scripts/publish-to-main.sh b/scripts/publish-to-main.sh deleted file mode 100644 index f2f4aaa..0000000 --- a/scripts/publish-to-main.sh +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env bash -# -# Erstellt einen Production-Build und veröffentlicht ihn auf den Branch main. -# -# 1. Wechselt auf main und setzt ihn auf den Stand von dev -# 2. Entfernt Kommentare, minifiziert CSS, obfuskiert JavaScript -# 3. Committet und pusht main (optional) -# 4. Wechselt zurück auf den ursprünglichen Branch (dev bleibt unverändert) -# -# Nutzung: -# ./scripts/publish-to-main.sh -# ./scripts/publish-to-main.sh --push -# ./scripts/publish-to-main.sh --dry-run -# ./scripts/publish-to-main.sh --allow-dirty --message "chore(release): v1.2" -# - -set -euo pipefail - -PUSH=false -DRY_RUN=false -ALLOW_DIRTY=false -MESSAGE="" - -usage() { - cat <<'EOF' -Usage: publish-to-main.sh [OPTIONS] - -Options: - --push Push nach origin/main - --dry-run Git-Schritte nur anzeigen (Build wird ausgeführt) - --allow-dirty Uncommittete Änderungen erlauben - --message TEXT Commit-Nachricht - -h, --help Hilfe anzeigen -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --push) - PUSH=true - shift - ;; - --dry-run) - DRY_RUN=true - shift - ;; - --allow-dirty) - ALLOW_DIRTY=true - shift - ;; - --message) - MESSAGE="${2:-}" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unbekannte Option: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -BUILD_DIR="$ROOT/scripts/build" -ORIGINAL_BRANCH="" - -step() { - echo "" - echo "==> $1" -} - -require_command() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "FEHLER: '$1' nicht gefunden. Bitte installieren und PATH setzen." >&2 - exit 1 - fi -} - -ensure_git_clean() { - if [[ -n "$(git -C "$ROOT" status --porcelain)" ]]; then - echo "FEHLER: Uncommittete Änderungen. Bitte zuerst committen oder stashen." >&2 - exit 1 - fi -} - -cleanup_on_error() { - echo "" - echo "FEHLER: Abgebrochen." >&2 - cd "$ROOT" || true - if [[ -n "$ORIGINAL_BRANCH" && "$DRY_RUN" == false ]]; then - git checkout "$ORIGINAL_BRANCH" 2>/dev/null || true - fi -} - -trap cleanup_on_error ERR - -require_command node -require_command npm -require_command git - -cd "$ROOT" - -if [[ "$ALLOW_DIRTY" == false ]]; then - ensure_git_clean -else - echo "WARNUNG: --allow-dirty aktiv – uncommittete Änderungen werden mit veröffentlicht." >&2 -fi - -ORIGINAL_BRANCH="$(git branch --show-current | tr -d '[:space:]')" -if [[ "$ORIGINAL_BRANCH" != "dev" ]]; then - echo "WARNUNG: Empfohlen auf Branch 'dev' zu starten (aktuell: ${ORIGINAL_BRANCH:-detached})" >&2 -fi - -if [[ -z "$MESSAGE" ]]; then - MESSAGE="chore(release): production build $(date '+%Y-%m-%d %H:%M')" -fi - -step "Installiere Build-Abhängigkeiten" -cd "$BUILD_DIR" -if [[ "$DRY_RUN" == false ]]; then - npm ci --no-fund --no-audit -fi - -step "Wechsle auf main und synchronisiere mit dev" -cd "$ROOT" -if [[ "$DRY_RUN" == true ]]; then - echo "[DryRun] git checkout main" - echo "[DryRun] git reset --hard dev" -else - git checkout main - git reset --hard dev -fi - -step "Production-Build (Kommentare entfernen, JS obfuscaten)" -cd "$BUILD_DIR" -if [[ "$DRY_RUN" == true ]]; then - echo "[DryRun] npm run build:in-place" -else - npm run build:in-place -fi - -step "Production-Build committen" -cd "$ROOT" -if [[ "$DRY_RUN" == true ]]; then - echo "[DryRun] git add -A" - echo "[DryRun] git commit -m \"$MESSAGE\"" -else - git add -A - if git diff --cached --quiet; then - echo "WARNUNG: Keine Build-Änderungen – nichts zu committen." >&2 - else - git commit -m "$MESSAGE" - fi -fi - -if [[ "$PUSH" == true ]]; then - step "Push nach origin/main" - if [[ "$DRY_RUN" == true ]]; then - echo "[DryRun] git push origin main" - else - git push origin main - fi -else - echo "Hinweis: Ohne --push wurde nur lokal auf main gebaut." -fi - -step "Zurück auf ${ORIGINAL_BRANCH:-dev}" -if [[ "$DRY_RUN" == false ]]; then - if [[ -n "$ORIGINAL_BRANCH" ]]; then - git checkout "$ORIGINAL_BRANCH" - else - git checkout dev - fi -fi - -trap - ERR - -echo "" -echo "Production-Release abgeschlossen." -if [[ "$PUSH" == false && "$DRY_RUN" == false ]]; then - echo "Zum Veröffentlichen: git push origin main" -fi diff --git a/scripts/run-build.ps1 b/scripts/run-build.ps1 deleted file mode 100644 index b60b860..0000000 --- a/scripts/run-build.ps1 +++ /dev/null @@ -1,46 +0,0 @@ -#Requires -Version 5.1 -<# -.SYNOPSIS - Erstellt ein Production-Bundle unter dist/ (ohne Branch-Wechsel). -#> -[CmdletBinding()] -param( - [switch]$InPlace -) - -$ErrorActionPreference = "Stop" -$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path -$BuildDir = Join-Path $Root "scripts\build" - -function Resolve-NodeTool([string]$ToolName) { - $command = Get-Command $ToolName -ErrorAction SilentlyContinue - if ($command) { return $command.Source } - - $candidates = @( - (Join-Path $env:ProgramFiles "nodejs\$ToolName.cmd"), - (Join-Path ${env:ProgramFiles(x86)} "nodejs\$ToolName.cmd") - ) - - foreach ($candidate in $candidates) { - if (Test-Path $candidate) { return $candidate } - } - - return $null -} - -$npm = Resolve-NodeTool "npm" -if (-not $npm) { - throw "npm nicht gefunden. Bitte Node.js installieren." -} - -Set-Location $BuildDir -& $npm ci --no-fund --no-audit -if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - -if ($InPlace) { - & $npm run build:in-place -} else { - & $npm run build -} - -exit $LASTEXITCODE diff --git a/scripts/run-build.sh b/scripts/run-build.sh deleted file mode 100644 index 7009c73..0000000 --- a/scripts/run-build.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -# -# Erstellt ein Production-Bundle unter dist/ (ohne Branch-Wechsel). -# -# Nutzung: -# ./scripts/run-build.sh -# ./scripts/run-build.sh --in-place -# - -set -euo pipefail - -IN_PLACE=false - -usage() { - cat <<'EOF' -Usage: run-build.sh [OPTIONS] - -Options: - --in-place Build direkt im Repository (statt dist/) - -h, --help Hilfe anzeigen -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --in-place) - IN_PLACE=true - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unbekannte Option: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -BUILD_DIR="$ROOT/scripts/build" - -if ! command -v npm >/dev/null 2>&1; then - echo "FEHLER: npm nicht gefunden. Bitte Node.js installieren." >&2 - exit 1 -fi - -cd "$BUILD_DIR" -npm ci --no-fund --no-audit - -if [[ "$IN_PLACE" == true ]]; then - npm run build:in-place -else - npm run build -fi