#!/usr/bin/env bash set -euo pipefail # ============================================================ # BentoPDF Air-Gapped Deployment Preparation Script # ============================================================ # Automates the creation of a self-contained deployment bundle # for air-gapped (offline) networks. # # Run this on a machine WITH internet access. The output bundle # contains everything needed to deploy BentoPDF offline. # # Usage: # bash scripts/prepare-airgap.sh --wasm-base-url https://internal.example.com/wasm # bash scripts/prepare-airgap.sh # interactive mode # # See --help for all options. # ============================================================ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # --- Output formatting --- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m' info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERR]${NC} $*" >&2; } step() { echo -e "\n${BOLD}==> $*${NC}"; } # Disable colors if NO_COLOR is set if [ -n "${NO_COLOR:-}" ]; then RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC='' fi # --- Defaults --- WASM_BASE_URL="" IMAGE_NAME="bentopdf" OUTPUT_DIR="./bentopdf-airgap-bundle" SIMPLE_MODE="" BASE_URL="" COMPRESSION_MODE="" LANGUAGE="" BRAND_NAME="" BRAND_LOGO="" FOOTER_TEXT="" DOCKERFILE="Dockerfile" SKIP_DOCKER=false SKIP_WASM=false INTERACTIVE=false # --- Usage --- usage() { cat <<'EOF' BentoPDF Air-Gapped Deployment Preparation USAGE: bash scripts/prepare-airgap.sh [OPTIONS] bash scripts/prepare-airgap.sh # interactive mode REQUIRED: --wasm-base-url Base URL where WASM files will be hosted in the air-gapped network (e.g. https://internal.example.com/wasm) OPTIONS: --image-name Docker image name (default: bentopdf) --output-dir Output bundle directory (default: ./bentopdf-airgap-bundle) --dockerfile Dockerfile to use (default: Dockerfile) --simple-mode Enable Simple Mode --base-url Subdirectory base URL (e.g. /pdf/) --compression Compression: g, b, o, all (default: all) --language Default UI language (e.g. fr, de, es) --brand-name Custom brand name --brand-logo Logo path relative to public/ --footer-text Custom footer text --skip-docker Skip Docker build and export --skip-wasm Skip WASM download (reuse existing .tgz files) --help Show this help message EXAMPLES: # Minimal (prompts for WASM URL interactively) bash scripts/prepare-airgap.sh # Full automation bash scripts/prepare-airgap.sh \ --wasm-base-url https://internal.example.com/wasm \ --brand-name "AcmePDF" \ --language fr # Skip Docker build (reuse existing image) bash scripts/prepare-airgap.sh \ --wasm-base-url https://internal.example.com/wasm \ --skip-docker EOF exit 0 } # --- Parse arguments --- while [[ $# -gt 0 ]]; do case "$1" in --wasm-base-url) WASM_BASE_URL="$2"; shift 2 ;; --image-name) IMAGE_NAME="$2"; shift 2 ;; --output-dir) OUTPUT_DIR="$2"; shift 2 ;; --simple-mode) SIMPLE_MODE="true"; shift ;; --base-url) BASE_URL="$2"; shift 2 ;; --compression) COMPRESSION_MODE="$2"; shift 2 ;; --language) LANGUAGE="$2"; shift 2 ;; --brand-name) BRAND_NAME="$2"; shift 2 ;; --brand-logo) BRAND_LOGO="$2"; shift 2 ;; --footer-text) FOOTER_TEXT="$2"; shift 2 ;; --dockerfile) DOCKERFILE="$2"; shift 2 ;; --skip-docker) SKIP_DOCKER=true; shift ;; --skip-wasm) SKIP_WASM=true; shift ;; --help|-h) usage ;; *) error "Unknown option: $1"; echo "Run with --help for usage."; exit 1 ;; esac done # --- Validate project root --- cd "$PROJECT_ROOT" if [ ! -f "package.json" ] || [ ! -f "src/js/const/cdn-version.ts" ]; then error "This script must be run from the BentoPDF project root." error "Expected to find package.json and src/js/const/cdn-version.ts" exit 1 fi # --- Check prerequisites --- check_prerequisites() { local missing=false if ! command -v npm &>/dev/null; then error "npm is required but not found. Install Node.js first." missing=true fi if [ "$SKIP_DOCKER" = false ] && ! command -v docker &>/dev/null; then error "docker is required but not found (use --skip-docker to skip)." missing=true fi if [ "$missing" = true ]; then exit 1 fi } # --- Read versions from source code --- read_versions() { PYMUPDF_VERSION=$(grep "pymupdf:" src/js/const/cdn-version.ts | grep -o "'[^']*'" | tr -d "'") GS_VERSION=$(grep "ghostscript:" src/js/const/cdn-version.ts | grep -o "'[^']*'" | tr -d "'") APP_VERSION=$(node -p "require('./package.json').version") if [ -z "$PYMUPDF_VERSION" ] || [ -z "$GS_VERSION" ]; then error "Failed to read WASM versions from src/js/const/cdn-version.ts" exit 1 fi } # --- Interactive mode --- interactive_mode() { echo "" echo -e "${BOLD}============================================================${NC}" echo -e "${BOLD} BentoPDF Air-Gapped Deployment Preparation${NC}" echo -e "${BOLD} App Version: ${APP_VERSION}${NC}" echo -e "${BOLD}============================================================${NC}" echo "" echo " Detected WASM versions from source:" echo " PyMuPDF: ${PYMUPDF_VERSION}" echo " Ghostscript: ${GS_VERSION}" echo " CoherentPDF: latest" echo "" # [1] WASM base URL (REQUIRED) echo -e "${BOLD}[1/8] WASM Base URL ${RED}(required)${NC}" echo " The URL where WASM files will be hosted inside the air-gapped network." echo " The script will append /pymupdf/, /gs/, /cpdf/ to this URL." echo "" echo " Examples:" echo " https://internal.example.com/wasm" echo " http://192.168.1.100/assets/wasm" echo " https://cdn.mycompany.local/bentopdf" echo "" while true; do read -r -p " URL: " WASM_BASE_URL if [ -z "$WASM_BASE_URL" ]; then warn "WASM base URL is required. Please enter a URL." elif [[ ! "$WASM_BASE_URL" =~ ^https?:// ]]; then warn "Must start with http:// or https://. Try again." else break fi done echo "" # [2] Docker image name (optional) echo -e "${BOLD}[2/8] Docker Image Name ${GREEN}(optional)${NC}" echo " The name used to tag the Docker image (used with 'docker run')." read -r -p " Image name [${IMAGE_NAME}]: " input IMAGE_NAME="${input:-$IMAGE_NAME}" echo "" # [3] Simple mode (optional) echo -e "${BOLD}[3/8] Simple Mode ${GREEN}(optional)${NC}" echo " Hides navigation, hero, features, FAQ — shows only PDF tools." read -r -p " Enable Simple Mode? (y/N): " input if [[ "${input:-}" =~ ^[Yy]$ ]]; then SIMPLE_MODE="true" fi echo "" # [4] Default language (optional) echo -e "${BOLD}[4/8] Default UI Language ${GREEN}(optional)${NC}" echo " Supported: en, ar, be, da, de, es, fr, id, it, nl, pt, tr, vi, zh, zh-TW" while true; do read -r -p " Language [en]: " input LANGUAGE="${input:-}" if [ -z "$LANGUAGE" ] || echo " en ar be da de es fr id it nl pt tr vi zh zh-TW " | grep -q " $LANGUAGE "; then break fi warn "Invalid language code '${LANGUAGE}'. Try again." done echo "" # [5] Custom branding (optional) echo -e "${BOLD}[5/8] Custom Branding ${GREEN}(optional)${NC}" echo " Replace the default BentoPDF name, logo, and footer text." read -r -p " Brand name [BentoPDF]: " input BRAND_NAME="${input:-}" if [ -n "$BRAND_NAME" ]; then echo " Place your logo in the public/ folder before building." read -r -p " Logo path relative to public/ [images/favicon-no-bg.svg]: " input BRAND_LOGO="${input:-}" read -r -p " Footer text [© 2026 BentoPDF. All rights reserved.]: " input FOOTER_TEXT="${input:-}" fi echo "" # [6] Base URL (optional) echo -e "${BOLD}[6/8] Base URL ${GREEN}(optional)${NC}" echo " Set this if hosting under a subdirectory (e.g. /pdf/)." read -r -p " Base URL [/]: " input BASE_URL="${input:-}" echo "" # [7] Dockerfile (optional) echo -e "${BOLD}[7/8] Dockerfile ${GREEN}(optional)${NC}" echo " Options: Dockerfile (standard) or Dockerfile.nonroot (custom PUID/PGID)" read -r -p " Dockerfile [${DOCKERFILE}]: " input DOCKERFILE="${input:-$DOCKERFILE}" echo "" # [8] Output directory (optional) echo -e "${BOLD}[8/8] Output Directory ${GREEN}(optional)${NC}" read -r -p " Path [${OUTPUT_DIR}]: " input OUTPUT_DIR="${input:-$OUTPUT_DIR}" # Confirm echo "" echo -e "${BOLD}--- Configuration Summary ---${NC}" echo "" echo " WASM Base URL: ${WASM_BASE_URL}" echo " Image Name: ${IMAGE_NAME}" echo " Dockerfile: ${DOCKERFILE}" echo " Simple Mode: ${SIMPLE_MODE:-false}" echo " Language: ${LANGUAGE:-en (default)}" echo " Brand Name: ${BRAND_NAME:-BentoPDF (default)}" [ -n "$BRAND_NAME" ] && echo " Brand Logo: ${BRAND_LOGO:-images/favicon-no-bg.svg (default)}" [ -n "$BRAND_NAME" ] && echo " Footer Text: ${FOOTER_TEXT:-(default)}" echo " Base URL: ${BASE_URL:-/ (root)}" echo " Output: ${OUTPUT_DIR}" echo "" read -r -p " Proceed? (Y/n): " input if [[ "${input:-Y}" =~ ^[Nn]$ ]]; then echo "Aborted." exit 0 fi } # --- SHA-256 checksum (cross-platform) --- sha256() { if command -v sha256sum &>/dev/null; then sha256sum "$1" | awk '{print $1}' elif command -v shasum &>/dev/null; then shasum -a 256 "$1" | awk '{print $1}' else echo "n/a" fi } # --- File size (human-readable, cross-platform) --- filesize() { if stat --version &>/dev/null 2>&1; then # GNU stat (Linux) stat --printf='%s' "$1" 2>/dev/null | awk '{ if ($1 >= 1073741824) printf "%.1f GB", $1/1073741824; else if ($1 >= 1048576) printf "%.1f MB", $1/1048576; else if ($1 >= 1024) printf "%.1f KB", $1/1024; else printf "%d B", $1; }' else # BSD stat (macOS) stat -f '%z' "$1" 2>/dev/null | awk '{ if ($1 >= 1073741824) printf "%.1f GB", $1/1073741824; else if ($1 >= 1048576) printf "%.1f MB", $1/1048576; else if ($1 >= 1024) printf "%.1f KB", $1/1024; else printf "%d B", $1; }' fi } # ============================================================ # MAIN # ============================================================ check_prerequisites read_versions # If no WASM base URL provided, go interactive if [ -z "$WASM_BASE_URL" ]; then INTERACTIVE=true interactive_mode fi # Validate language code if provided if [ -n "$LANGUAGE" ]; then VALID_LANGS="en ar be da de es fr id it nl pt tr vi zh zh-TW" if ! echo " $VALID_LANGS " | grep -q " $LANGUAGE "; then error "Invalid language code: ${LANGUAGE}" error "Supported: ${VALID_LANGS}" exit 1 fi fi # Validate WASM base URL format if [[ ! "$WASM_BASE_URL" =~ ^https?:// ]]; then error "WASM base URL must start with http:// or https://" error " Got: ${WASM_BASE_URL}" error " Example: https://internal.example.com/wasm" exit 1 fi # Strip trailing slash from WASM base URL WASM_BASE_URL="${WASM_BASE_URL%/}" # Construct WASM URLs WASM_PYMUPDF_URL="${WASM_BASE_URL}/pymupdf/" WASM_GS_URL="${WASM_BASE_URL}/gs/" WASM_CPDF_URL="${WASM_BASE_URL}/cpdf/" echo "" echo -e "${BOLD}============================================================${NC}" echo -e "${BOLD} BentoPDF Air-Gapped Bundle Preparation${NC}" echo -e "${BOLD} App: v${APP_VERSION} | PyMuPDF: ${PYMUPDF_VERSION} | GS: ${GS_VERSION}${NC}" echo -e "${BOLD}============================================================${NC}" # --- Phase 1: Prepare output directory --- step "Preparing output directory" mkdir -p "$OUTPUT_DIR" OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" # Warn if output directory already has bundle files if ls "$OUTPUT_DIR"/*.tgz "$OUTPUT_DIR"/bentopdf.tar "$OUTPUT_DIR"/setup.sh 2>/dev/null | head -1 &>/dev/null; then warn "Output directory already contains files from a previous run." warn "Existing files will be overwritten." if [ "$INTERACTIVE" = true ]; then read -r -p " Continue? (Y/n): " input if [[ "${input:-Y}" =~ ^[Nn]$ ]]; then echo "Aborted." exit 0 fi fi fi info "Output: ${OUTPUT_DIR}" # --- Phase 2: Download WASM packages --- if [ "$SKIP_WASM" = true ]; then step "Skipping WASM download (--skip-wasm)" # Verify each file exists with specific errors wasm_missing=false if ! ls "$OUTPUT_DIR"/bentopdf-pymupdf-wasm-*.tgz &>/dev/null; then error "Missing: bentopdf-pymupdf-wasm-*.tgz" wasm_missing=true fi if ! ls "$OUTPUT_DIR"/bentopdf-gs-wasm-*.tgz &>/dev/null; then error "Missing: bentopdf-gs-wasm-*.tgz" wasm_missing=true fi if ! ls "$OUTPUT_DIR"/coherentpdf-*.tgz &>/dev/null; then error "Missing: coherentpdf-*.tgz" wasm_missing=true fi if [ "$wasm_missing" = true ]; then error "Run without --skip-wasm first to download the packages." exit 1 fi success "Reusing existing WASM packages" else step "Downloading WASM packages" WASM_TMP=$(mktemp -d) trap 'rm -rf "$WASM_TMP"' EXIT info "Downloading @bentopdf/pymupdf-wasm@${PYMUPDF_VERSION}..." if ! (cd "$WASM_TMP" && npm pack "@bentopdf/pymupdf-wasm@${PYMUPDF_VERSION}" --quiet 2>&1); then error "Failed to download @bentopdf/pymupdf-wasm@${PYMUPDF_VERSION}" error "Check your internet connection and that the package exists on npm." exit 1 fi info "Downloading @bentopdf/gs-wasm@${GS_VERSION}..." if ! (cd "$WASM_TMP" && npm pack "@bentopdf/gs-wasm@${GS_VERSION}" --quiet 2>&1); then error "Failed to download @bentopdf/gs-wasm@${GS_VERSION}" error "Check your internet connection and that the package exists on npm." exit 1 fi info "Downloading coherentpdf..." if ! (cd "$WASM_TMP" && npm pack coherentpdf --quiet 2>&1); then error "Failed to download coherentpdf" error "Check your internet connection and that the package exists on npm." exit 1 fi # Move to output directory mv "$WASM_TMP"/*.tgz "$OUTPUT_DIR/" rm -rf "$WASM_TMP" trap - EXIT # Resolve CoherentPDF version from filename CPDF_TGZ=$(ls "$OUTPUT_DIR"/coherentpdf-*.tgz 2>/dev/null | head -1) CPDF_VERSION=$(basename "$CPDF_TGZ" | sed 's/coherentpdf-\(.*\)\.tgz/\1/') success "Downloaded all WASM packages" info " PyMuPDF: $(filesize "$OUTPUT_DIR"/bentopdf-pymupdf-wasm-*.tgz)" info " Ghostscript: $(filesize "$OUTPUT_DIR"/bentopdf-gs-wasm-*.tgz)" info " CoherentPDF: $(filesize "$CPDF_TGZ") (v${CPDF_VERSION})" fi # Resolve CPDF version if we skipped download if [ -z "${CPDF_VERSION:-}" ]; then CPDF_TGZ=$(ls "$OUTPUT_DIR"/coherentpdf-*.tgz 2>/dev/null | head -1) CPDF_VERSION=$(basename "$CPDF_TGZ" | sed 's/coherentpdf-\(.*\)\.tgz/\1/') fi # --- Phase 3: Build Docker image --- if [ "$SKIP_DOCKER" = true ]; then step "Skipping Docker build (--skip-docker)" # Check if image exists or tar exists if [ -f "$OUTPUT_DIR/bentopdf.tar" ]; then success "Reusing existing bentopdf.tar" elif docker image inspect "$IMAGE_NAME" &>/dev/null; then step "Exporting existing Docker image" docker save "$IMAGE_NAME" -o "$OUTPUT_DIR/bentopdf.tar" success "Exported: $(filesize "$OUTPUT_DIR/bentopdf.tar")" else warn "No Docker image '${IMAGE_NAME}' found and no bentopdf.tar in output." warn "The bundle will not include a Docker image." fi else step "Building Docker image" # Verify Dockerfile exists if [ ! -f "$DOCKERFILE" ]; then error "Dockerfile not found: ${DOCKERFILE}" error "Available Dockerfiles:" ls -1 Dockerfile* 2>/dev/null | sed 's/^/ /' || echo " (none found)" exit 1 fi # Verify Docker daemon is running if ! docker info &>/dev/null; then error "Docker daemon is not running. Start Docker and try again." exit 1 fi # Build the docker build command BUILD_ARGS=() BUILD_ARGS+=(--build-arg "VITE_WASM_PYMUPDF_URL=${WASM_PYMUPDF_URL}") BUILD_ARGS+=(--build-arg "VITE_WASM_GS_URL=${WASM_GS_URL}") BUILD_ARGS+=(--build-arg "VITE_WASM_CPDF_URL=${WASM_CPDF_URL}") [ -n "$SIMPLE_MODE" ] && BUILD_ARGS+=(--build-arg "SIMPLE_MODE=${SIMPLE_MODE}") [ -n "$BASE_URL" ] && BUILD_ARGS+=(--build-arg "BASE_URL=${BASE_URL}") [ -n "$COMPRESSION_MODE" ] && BUILD_ARGS+=(--build-arg "COMPRESSION_MODE=${COMPRESSION_MODE}") [ -n "$LANGUAGE" ] && BUILD_ARGS+=(--build-arg "VITE_DEFAULT_LANGUAGE=${LANGUAGE}") [ -n "$BRAND_NAME" ] && BUILD_ARGS+=(--build-arg "VITE_BRAND_NAME=${BRAND_NAME}") [ -n "$BRAND_LOGO" ] && BUILD_ARGS+=(--build-arg "VITE_BRAND_LOGO=${BRAND_LOGO}") [ -n "$FOOTER_TEXT" ] && BUILD_ARGS+=(--build-arg "VITE_FOOTER_TEXT=${FOOTER_TEXT}") info "Image name: ${IMAGE_NAME}" info "Dockerfile: ${DOCKERFILE}" info "WASM URLs:" info " PyMuPDF: ${WASM_PYMUPDF_URL}" info " Ghostscript: ${WASM_GS_URL}" info " CoherentPDF: ${WASM_CPDF_URL}" echo "" info "Building... this may take a few minutes (npm install + Vite build)." echo "" docker build -f "$DOCKERFILE" "${BUILD_ARGS[@]}" -t "$IMAGE_NAME" . success "Docker image '${IMAGE_NAME}' built successfully" # --- Phase 4: Export Docker image --- step "Exporting Docker image" docker save "$IMAGE_NAME" -o "$OUTPUT_DIR/bentopdf.tar" success "Exported: $(filesize "$OUTPUT_DIR/bentopdf.tar")" fi # --- Phase 5: Generate setup.sh --- step "Generating setup script" cat > "$OUTPUT_DIR/setup.sh" </dev/null; then echo "ERROR: docker is required but not found." echo "Install Docker first: https://docs.docker.com/get-docker/" exit 1 fi if ! docker info &>/dev/null; then echo "ERROR: Docker daemon is not running. Start Docker and try again." exit 1 fi # --- Configuration (baked in at generation time) --- IMAGE_NAME="${IMAGE_NAME}" WASM_BASE_URL="${WASM_BASE_URL}" DOCKER_PORT="\${1:-3000}" # Where to extract WASM files (override with WASM_EXTRACT_DIR env var) WASM_DIR="\${WASM_EXTRACT_DIR:-\${SCRIPT_DIR}/wasm}" echo "" echo "============================================================" echo " BentoPDF Air-Gapped Setup" echo " Version: ${APP_VERSION}" echo "============================================================" echo "" echo " Docker image: \${IMAGE_NAME}" echo " WASM base URL: \${WASM_BASE_URL}" echo " WASM extract: \${WASM_DIR}" echo " Port: \${DOCKER_PORT}" echo "" # --- Step 1: Load Docker Image --- echo "[1/3] Loading Docker image..." if [ -f "\${SCRIPT_DIR}/bentopdf.tar" ]; then docker load -i "\${SCRIPT_DIR}/bentopdf.tar" echo " Docker image '\${IMAGE_NAME}' loaded." else echo " WARNING: bentopdf.tar not found. Skipping Docker load." echo " Make sure the image '\${IMAGE_NAME}' is already available." fi # --- Step 2: Extract WASM Packages --- echo "" echo "[2/3] Extracting WASM packages to \${WASM_DIR}..." mkdir -p "\${WASM_DIR}/pymupdf" "\${WASM_DIR}/gs" "\${WASM_DIR}/cpdf" # PyMuPDF: package has dist/ and assets/ at root echo " Extracting PyMuPDF..." tar xzf "\${SCRIPT_DIR}"/bentopdf-pymupdf-wasm-*.tgz -C "\${WASM_DIR}/pymupdf" --strip-components=1 # Ghostscript: browser expects gs.js and gs.wasm at root echo " Extracting Ghostscript..." TEMP_GS="\$(mktemp -d)" tar xzf "\${SCRIPT_DIR}"/bentopdf-gs-wasm-*.tgz -C "\${TEMP_GS}" if [ -d "\${TEMP_GS}/package/assets" ]; then cp -r "\${TEMP_GS}/package/assets/"* "\${WASM_DIR}/gs/" else cp -r "\${TEMP_GS}/package/"* "\${WASM_DIR}/gs/" fi rm -rf "\${TEMP_GS}" # CoherentPDF: browser expects coherentpdf.browser.min.js at root echo " Extracting CoherentPDF..." TEMP_CPDF="\$(mktemp -d)" tar xzf "\${SCRIPT_DIR}"/coherentpdf-*.tgz -C "\${TEMP_CPDF}" if [ -d "\${TEMP_CPDF}/package/dist" ]; then cp -r "\${TEMP_CPDF}/package/dist/"* "\${WASM_DIR}/cpdf/" else cp -r "\${TEMP_CPDF}/package/"* "\${WASM_DIR}/cpdf/" fi rm -rf "\${TEMP_CPDF}" echo " WASM files extracted to: \${WASM_DIR}" echo "" echo " IMPORTANT: Ensure these paths are served by your internal web server:" echo " \${WASM_BASE_URL}/pymupdf/ -> \${WASM_DIR}/pymupdf/" echo " \${WASM_BASE_URL}/gs/ -> \${WASM_DIR}/gs/" echo " \${WASM_BASE_URL}/cpdf/ -> \${WASM_DIR}/cpdf/" # --- Step 3: Start BentoPDF --- echo "" echo "[3/3] Ready to start BentoPDF" echo "" echo " To start manually:" echo " docker run -d --name bentopdf -p \${DOCKER_PORT}:8080 --restart unless-stopped \${IMAGE_NAME}" echo "" echo " Then open: http://localhost:\${DOCKER_PORT}" echo "" read -r -p "Start BentoPDF now? (y/N): " REPLY if [[ "\${REPLY:-}" =~ ^[Yy]$ ]]; then docker run -d --name bentopdf -p "\${DOCKER_PORT}:8080" --restart unless-stopped "\${IMAGE_NAME}" echo "" echo " BentoPDF is running at http://localhost:\${DOCKER_PORT}" fi SETUP_EOF chmod +x "$OUTPUT_DIR/setup.sh" success "Generated setup.sh" # --- Phase 6: Generate README --- step "Generating README" cat > "$OUTPUT_DIR/README.md" <