underpost
Advanced tools
| # Production-style Docker Compose stack mirroring the Kubernetes development | ||
| # environment defined under manifests/{mongodb,valkey,deployment/dd-default-development}. | ||
| # | ||
| # Mapping summary (K8s -> Compose): | ||
| # StatefulSet mongodb (replSet rs0, keyFile auth) -> mongodb + mongodb-keyfile-init + mongodb-rs-init | ||
| # StatefulSet valkey-service -> valkey-service | ||
| # Deployment dd-default-development-blue -> app | ||
| # HTTPProxy default.net / www.default.net (Contour) -> proxy (nginx reverse proxy) | ||
| # Service type LoadBalancer / NodePort -> published host ports via the proxy | ||
| # Secret mongodb-secret / mongodb-keyfile -> .env values + generated keyfile volume | ||
| # PVC mongodb-storage / volumeClaimTemplates -> named volumes | ||
| name: dd-default-development | ||
| services: | ||
| # --- MongoDB ------------------------------------------------------------- | ||
| # Mirrors manifests/mongodb/statefulset.yaml: replSet rs0, keyFile cluster | ||
| # auth, --auth, bind to all interfaces. Single-node replica set for local dev. | ||
| # | ||
| # Bootstrap follows src/db/mongo/MongoBootstrap.js rather than the image's | ||
| # MONGO_INITDB_ROOT_USERNAME auto-init (whose temp server + keyFile combination | ||
| # is unreliable). The generated entrypoint (docker/mongodb/entrypoint.sh) | ||
| # generates the keyfile, starts mongod, and bootstraps the replica set + root | ||
| # user over the loopback localhost exception. The healthcheck gates readiness | ||
| # on an authenticated writable primary. | ||
| mongodb: | ||
| image: ${MONGO_IMAGE:-mongo:latest} | ||
| container_name: dd-mongodb | ||
| entrypoint: ["bash", "/docker-init/entrypoint.sh"] | ||
| environment: | ||
| MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME:?set MONGO_INITDB_ROOT_USERNAME in docker/compose.env} | ||
| MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:?set MONGO_INITDB_ROOT_PASSWORD in docker/compose.env} | ||
| DB_REPLICA_SET: ${DB_REPLICA_SET:-rs0} | ||
| volumes: | ||
| - ./docker/mongodb:/docker-init:ro | ||
| - mongodb-keyfile:/opt/keyfile | ||
| - mongodb-data:/data/db | ||
| networks: | ||
| - dd-internal | ||
| expose: | ||
| - "27017" | ||
| ports: | ||
| - "${MONGO_HOST_PORT:-27017}:27017" | ||
| healthcheck: | ||
| test: | ||
| - CMD-SHELL | ||
| - > | ||
| mongosh --quiet -u "$$MONGO_INITDB_ROOT_USERNAME" -p "$$MONGO_INITDB_ROOT_PASSWORD" | ||
| --authenticationDatabase admin --eval "db.hello().isWritablePrimary" | grep -q true | ||
| interval: 10s | ||
| timeout: 5s | ||
| retries: 20 | ||
| start_period: 60s | ||
| restart: unless-stopped | ||
| # --- Valkey -------------------------------------------------------------- | ||
| # Mirrors manifests/valkey/statefulset.yaml. | ||
| valkey-service: | ||
| image: ${VALKEY_IMAGE:-valkey/valkey:latest} | ||
| container_name: dd-valkey | ||
| command: ["valkey-server", "--port", "6379", "--bind", "0.0.0.0", "--protected-mode", "no"] | ||
| volumes: | ||
| - valkey-data:/data | ||
| networks: | ||
| - dd-internal | ||
| expose: | ||
| - "6379" | ||
| # NodePort equivalent (manifests/valkey/valkey-nodeport.yaml nodePort 32079). | ||
| ports: | ||
| - "${VALKEY_NODEPORT:-32079}:6379" | ||
| healthcheck: | ||
| test: ["CMD", "valkey-cli", "-p", "6379", "ping"] | ||
| interval: 10s | ||
| timeout: 5s | ||
| retries: 10 | ||
| start_period: 10s | ||
| restart: unless-stopped | ||
| # --- Application --------------------------------------------------------- | ||
| # Mirrors manifests/deployment/dd-default-development/deployment.yaml | ||
| # (dd-default-development-blue). Connection targets use Docker service | ||
| # discovery instead of hardcoded localhost. | ||
| app: | ||
| image: ${APP_IMAGE:-underpost/underpost-engine}:${APP_TAG:-v3.2.22} | ||
| container_name: dd-app | ||
| # The start command is supplied by the generated override docker/compose.app.yml | ||
| # (see `underpost docker-compose --generate --deploy-id <id>`), keeping this | ||
| # file deployment-agnostic. | ||
| environment: | ||
| NODE_ENV: ${NODE_ENV:-development} | ||
| DB_PROVIDER: ${DB_PROVIDER:-mongoose} | ||
| DB_HOST: ${DB_HOST:-mongodb://mongodb:27017} | ||
| DB_NAME: ${DB_NAME:-default} | ||
| DB_REPLICA_SET: ${DB_REPLICA_SET:-rs0} | ||
| DB_AUTH_SOURCE: ${DB_AUTH_SOURCE:-admin} | ||
| DB_USER: ${MONGO_INITDB_ROOT_USERNAME:?} | ||
| DB_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:?} | ||
| VALKEY_HOST: ${VALKEY_HOST:-valkey-service} | ||
| VALKEY_PORT: ${VALKEY_PORT:-6379} | ||
| networks: | ||
| - dd-internal | ||
| expose: | ||
| - "4001" | ||
| - "4002" | ||
| - "4003" | ||
| - "4004" | ||
| # Direct access to the LoadBalancer-equivalent ports (manifest 4001-4004). | ||
| ports: | ||
| - "${APP_PORT_4001:-4001}:4001" | ||
| - "${APP_PORT_4002:-4002}:4002" | ||
| - "${APP_PORT_4003:-4003}:4003" | ||
| - "${APP_PORT_4004:-4004}:4004" | ||
| healthcheck: | ||
| test: ["CMD-SHELL", "timeout 2 bash -c '</dev/tcp/127.0.0.1/4001' || exit 1"] | ||
| interval: 15s | ||
| timeout: 5s | ||
| retries: 5 | ||
| start_period: 60s | ||
| depends_on: | ||
| mongodb: | ||
| condition: service_healthy | ||
| valkey-service: | ||
| condition: service_healthy | ||
| restart: unless-stopped | ||
| # --- Reverse proxy / NodePort-equivalent load balancer ------------------- | ||
| # Mirrors manifests/deployment/dd-default-development/proxy.yaml (Contour | ||
| # HTTPProxy) using nginx with websocket upgrade support. | ||
| proxy: | ||
| image: ${PROXY_IMAGE:-nginx:stable-alpine} | ||
| container_name: dd-proxy | ||
| volumes: | ||
| - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro | ||
| networks: | ||
| - dd-internal | ||
| ports: | ||
| - "${PROXY_HTTP_PORT:-80}:80" | ||
| healthcheck: | ||
| test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1/healthz"] | ||
| interval: 15s | ||
| timeout: 5s | ||
| retries: 5 | ||
| start_period: 10s | ||
| depends_on: | ||
| app: | ||
| condition: service_started | ||
| restart: unless-stopped | ||
| # --- Monitoring: Prometheus ---------------------------------------------- | ||
| # Mirrors manifests/prometheus/deployment.yaml. Scrapes the app's /metrics | ||
| # endpoint (prom-client) over the internal network. Config is generated by | ||
| # `underpost docker-compose --generate` into docker/prometheus/prometheus.yml. | ||
| prometheus: | ||
| image: ${PROMETHEUS_IMAGE:-prom/prometheus:latest} | ||
| container_name: dd-prometheus | ||
| command: | ||
| - --config.file=/etc/prometheus/prometheus.yml | ||
| - --storage.tsdb.path=/prometheus | ||
| volumes: | ||
| - ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro | ||
| - prometheus-data:/prometheus | ||
| networks: | ||
| - dd-internal | ||
| expose: | ||
| - "9090" | ||
| ports: | ||
| - "${PROMETHEUS_PORT:-9090}:9090" | ||
| healthcheck: | ||
| test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:9090/-/healthy"] | ||
| interval: 15s | ||
| timeout: 5s | ||
| retries: 5 | ||
| start_period: 15s | ||
| depends_on: | ||
| app: | ||
| condition: service_started | ||
| restart: unless-stopped | ||
| # --- Monitoring: Grafana ------------------------------------------------- | ||
| # Mirrors manifests/grafana/deployment.yaml. Prometheus datasource is | ||
| # pre-provisioned (generated) so dashboards work out of the box. | ||
| grafana: | ||
| image: ${GRAFANA_IMAGE:-grafana/grafana:latest} | ||
| container_name: dd-grafana | ||
| environment: | ||
| GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} | ||
| GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} | ||
| GF_USERS_ALLOW_SIGN_UP: "false" | ||
| volumes: | ||
| - ./docker/grafana/provisioning:/etc/grafana/provisioning:ro | ||
| - grafana-data:/var/lib/grafana | ||
| networks: | ||
| - dd-internal | ||
| expose: | ||
| - "3000" | ||
| ports: | ||
| - "${GRAFANA_PORT:-3000}:3000" | ||
| healthcheck: | ||
| test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:3000/api/health"] | ||
| interval: 15s | ||
| timeout: 5s | ||
| retries: 5 | ||
| start_period: 15s | ||
| depends_on: | ||
| prometheus: | ||
| condition: service_started | ||
| restart: unless-stopped | ||
| networks: | ||
| dd-internal: | ||
| name: dd-internal | ||
| driver: bridge | ||
| volumes: | ||
| mongodb-data: | ||
| name: dd-mongodb-data | ||
| mongodb-keyfile: | ||
| name: dd-mongodb-keyfile | ||
| valkey-data: | ||
| name: dd-valkey-data | ||
| prometheus-data: | ||
| name: dd-prometheus-data | ||
| grafana-data: | ||
| name: dd-grafana-data |
| #!/bin/bash | ||
| set -euo pipefail | ||
| # --------------------------------------------------------------------------- | ||
| # Underpost Kubeadm Node Setup (bare-metal physical node) | ||
| # | ||
| # Mirrors scripts/k3s-node-setup.sh but for kubeadm on real hardware: it runs on | ||
| # the freshly deployed OS (disk-installed Rocky) after first boot, installs NVM + | ||
| # Node.js, ensures the engine source is present, and drives the cluster bring-up | ||
| # through the local engine entrypoint `node bin cluster` (src/cli/cluster.js) — | ||
| # reusing its kubeadm + Contour + host-init logic instead of reimplementing it. | ||
| # | ||
| # Modes: | ||
| # --control Initialize a new kubeadm control-plane (default). | ||
| # --worker Join an existing cluster. | ||
| # | ||
| # Worker join (controller supplies this over SSH; no manual token paste): | ||
| # --join-command="kubeadm join <ip>:6443 --token <t> --discovery-token-ca-cert-hash <h>" | ||
| # or: --control-ip=<ip> --token=<t> --discovery-token-ca-cert-hash=<h> | ||
| # | ||
| # Engine source / options: | ||
| # --engine-root=<path> Engine source path (default /home/dd/engine). | ||
| # --engine-repo=<url> Git repo cloned if the engine source is missing. | ||
| # --engine-branch=<branch> Branch to clone (default: repo default). | ||
| # --no-contour Control mode: skip the Contour ingress install. | ||
| # --------------------------------------------------------------------------- | ||
| ROLE="control" | ||
| JOIN_COMMAND="" | ||
| CONTROL_IP="" | ||
| CONTROL_ENDPOINT_HOST="" | ||
| TOKEN="" | ||
| CA_CERT_HASH="" | ||
| JOIN_ONLY="0" | ||
| INSTALL_CONTOUR="1" | ||
| ENGINE_ROOT="/home/dd/engine" | ||
| ENGINE_REPO="https://github.com/underpostnet/engine.git" | ||
| ENGINE_BRANCH="" | ||
| # Private repo holding secrets (engine-private/conf/.../.env.production) cloned | ||
| # with a GitHub token. GITHUB_TOKEN/GITHUB_USERNAME come from the environment | ||
| # (passed over SSH by the controller) or the --github-* args. | ||
| ENGINE_PRIVATE_REPO="https://github.com/underpostnet/engine-private.git" | ||
| ENGINE_PRIVATE_BRANCH="" | ||
| GITHUB_TOKEN="${GITHUB_TOKEN:-}" | ||
| GITHUB_USERNAME="${GITHUB_USERNAME:-}" | ||
| CRI_SOCKET="unix:///var/run/crio/crio.sock" | ||
| for arg in "$@"; do | ||
| case $arg in | ||
| --control) ROLE="control" ;; | ||
| --worker) ROLE="worker" ;; | ||
| --join-command=*) JOIN_COMMAND="${arg#*=}" ;; | ||
| --control-ip=*) CONTROL_IP="${arg#*=}" ;; | ||
| --control-endpoint-host=*) CONTROL_ENDPOINT_HOST="${arg#*=}" ;; | ||
| --token=*) TOKEN="${arg#*=}" ;; | ||
| --discovery-token-ca-cert-hash=*) CA_CERT_HASH="${arg#*=}" ;; | ||
| --join-only) JOIN_ONLY="1" ;; | ||
| --engine-root=*) ENGINE_ROOT="${arg#*=}" ;; | ||
| --engine-repo=*) ENGINE_REPO="${arg#*=}" ;; | ||
| --engine-branch=*) ENGINE_BRANCH="${arg#*=}" ;; | ||
| --engine-private-repo=*) ENGINE_PRIVATE_REPO="${arg#*=}" ;; | ||
| --engine-private-branch=*) ENGINE_PRIVATE_BRANCH="${arg#*=}" ;; | ||
| --github-token=*) GITHUB_TOKEN="${arg#*=}" ;; | ||
| --github-username=*) GITHUB_USERNAME="${arg#*=}" ;; | ||
| --no-contour) INSTALL_CONTOUR="0" ;; | ||
| esac | ||
| done | ||
| log() { echo "$(date): [kubeadm-setup] $*"; } | ||
| # worker_join: lightweight, idempotent kubeadm join. Centralizes the join logic | ||
| # used by both the full worker flow and the --join-only short-circuit. Returns | ||
| # non-zero on failure so `set -e` aborts the script (no false-positive success). | ||
| worker_join() { | ||
| if [ -z "$JOIN_COMMAND" ]; then | ||
| if [ -n "$CONTROL_IP" ] && [ -n "$TOKEN" ] && [ -n "$CA_CERT_HASH" ]; then | ||
| JOIN_COMMAND="kubeadm join ${CONTROL_IP}:6443 --token ${TOKEN} --discovery-token-ca-cert-hash ${CA_CERT_HASH}" | ||
| else | ||
| echo "ERROR: worker mode needs --join-command=... OR --control-ip/--token/--discovery-token-ca-cert-hash" >&2 | ||
| return 1 | ||
| fi | ||
| fi | ||
| command -v kubeadm >/dev/null 2>&1 || { | ||
| echo "ERROR: kubeadm not found on node. Run a full (non --join-only) setup first." >&2 | ||
| return 1 | ||
| } | ||
| # Make the control-plane endpoint hostname resolve to the control IP. kubeadm | ||
| # reads the cluster-info / kubeadm-config ConfigMaps whose server URL is the | ||
| # control-plane endpoint (often 'localhost.localdomain:6443'); without this the | ||
| # worker dials [::1]:6443 and fails with "connection refused". | ||
| if [ -n "$CONTROL_ENDPOINT_HOST" ] && [ -n "$CONTROL_IP" ] && [ "$CONTROL_ENDPOINT_HOST" != "$CONTROL_IP" ]; then | ||
| log "Mapping control-plane endpoint '$CONTROL_ENDPOINT_HOST' -> $CONTROL_IP in /etc/hosts" | ||
| ESC_HOST="$(printf '%s' "$CONTROL_ENDPOINT_HOST" | sed 's/[.]/\\./g')" | ||
| # Strip the endpoint host from any existing (loopback) line so it no longer | ||
| # resolves to 127.0.0.1, then point it at the real control-plane IP. | ||
| sudo sed -i -E "s/[[:space:]]+${ESC_HOST}([[:space:]]|\$)/\1/g" /etc/hosts || true | ||
| if ! grep -qE "^${CONTROL_IP}[[:space:]].*${ESC_HOST}([[:space:]]|\$)" /etc/hosts; then | ||
| echo "${CONTROL_IP} ${CONTROL_ENDPOINT_HOST}" | sudo tee -a /etc/hosts >/dev/null | ||
| fi | ||
| fi | ||
| # Kernel/sysctl prereqs the kubeadm preflight needs (no engine/node required). | ||
| sudo swapoff -a || true | ||
| sudo sed -i '/swap/d' /etc/fstab || true | ||
| sudo modprobe br_netfilter || true | ||
| printf 'net.bridge.bridge-nf-call-iptables = 1\nnet.bridge.bridge-nf-call-ip6tables = 1\nnet.ipv4.ip_forward = 1\n' \ | ||
| | sudo tee /etc/sysctl.d/99-kubernetes.conf >/dev/null | ||
| sudo sysctl --system >/dev/null 2>&1 || true | ||
| # Replace --discovery-token-ca-cert-hash with --discovery-token-unsafe-skip-ca-verification | ||
| # so token discovery does not depend on the pinned CA hash. | ||
| UNSAFE_JOIN="$(echo "${JOIN_COMMAND}" | sed 's/--discovery-token-ca-cert-hash [^ ]*/--discovery-token-unsafe-skip-ca-verification/')" | ||
| _do_join() { | ||
| # Reset any stale state from a previous failed join so kubeadm does not | ||
| # reuse cached config that points at an unreachable endpoint. | ||
| sudo kubeadm reset -f --cri-socket="${CRI_SOCKET}" >/dev/null 2>&1 || true | ||
| sudo rm -rf /etc/kubernetes/* /var/lib/kubelet/pki 2>/dev/null || true | ||
| log "Joining cluster: ${UNSAFE_JOIN%% --token*} ..." | ||
| sudo ${UNSAFE_JOIN} --cri-socket="${CRI_SOCKET}" | ||
| } | ||
| if _do_join; then | ||
| log "Worker joined the cluster." | ||
| return 0 | ||
| fi | ||
| log "WARNING: kubeadm join attempt failed; resetting and retrying once..." | ||
| if _do_join; then | ||
| log "Worker joined the cluster." | ||
| return 0 | ||
| fi | ||
| log "FATAL: kubeadm join failed after retry" | ||
| return 1 | ||
| } | ||
| # --join-only: skip all prerequisite/engine/host setup and go straight to the | ||
| # join (used by `baremetal --resume-join` on an already-provisioned node). | ||
| if [ "$JOIN_ONLY" = "1" ]; then | ||
| [ "$ROLE" = "worker" ] || { echo "ERROR: --join-only is only valid with --worker" >&2; exit 1; } | ||
| log "--join-only: skipping prerequisites/engine setup; joining cluster directly" | ||
| worker_join | ||
| log "kubeadm worker (join-only) complete." | ||
| exit 0 | ||
| fi | ||
| # --------------------------------------------------------------------------- | ||
| # 0. Base prerequisites — a minimal @core Rocky install lacks tar/xz/git, which | ||
| # NVM needs to extract Node.js and the engine clone needs. Install them first. | ||
| # --------------------------------------------------------------------------- | ||
| log "Installing base prerequisites (tar, xz, gzip, git, curl)..." | ||
| sudo dnf install -y tar xz gzip bzip2 git curl ca-certificates which findutils 2>/dev/null \ | ||
| || dnf install -y tar xz gzip bzip2 git curl ca-certificates which findutils | ||
| # --------------------------------------------------------------------------- | ||
| # 1. NVM and Node.js — required for the `node bin ...` entrypoints. Idempotent so | ||
| # a retried run does not reinstall Node from scratch. | ||
| # --------------------------------------------------------------------------- | ||
| if command -v node >/dev/null 2>&1 && node --version 2>/dev/null | grep -q '^v24'; then | ||
| log "Node.js $(node --version) already installed; skipping NVM setup" | ||
| else | ||
| log "Installing NVM and Node.js v24.15.0..." | ||
| curl -o- https://cdn.jsdelivr.net/gh/nvm-sh/nvm@v0.40.1/install.sh | bash | ||
| export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")" | ||
| # shellcheck disable=SC1090 | ||
| [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" | ||
| nvm install 24.15.0 | ||
| nvm use 24.15.0 | ||
| nvm alias default 24.15.0 | ||
| ln -sf "$(command -v node)" /usr/local/bin/node | ||
| ln -sf "$(command -v npm)" /usr/local/bin/npm | ||
| ln -sf "$(command -v npx)" /usr/local/bin/npx | ||
| fi | ||
| echo " | ||
| ██╗░░░██╗███╗░░██╗██████╗░███████╗██████╗░██████╗░░█████╗░░██████╗████████╗ | ||
| ██║░░░██║████╗░██║██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝╚══██╔══╝ | ||
| ██║░░░██║██╔██╗██║██║░░██║█████╗░░██████╔╝██████╔╝██║░░██║╚█████╗░░░░██║░░░ | ||
| ██║░░░██║██║╚████║██║░░██║██╔══╝░░██╔══██╗██╔═══╝░██║░░██║░╚═══██╗░░░██║░░░ | ||
| ╚██████╔╝██║░╚███║██████╔╝███████╗██║░░██║██║░░░░░╚█████╔╝██████╔╝░░░██║░░░ | ||
| ░╚═════╝░╚═╝░░╚══╝╚═════╝░╚══════╝╚═╝░░╚═╝╚═╝░░░░░░╚════╝░╚═════╝░░░░╚═╝░░░ | ||
| Bringing up underpost kubeadm node (role=$ROLE) from $ENGINE_ROOT | ||
| " | ||
| # --------------------------------------------------------------------------- | ||
| # 2. Engine source — required by `node bin cluster` (manifests + CLI). | ||
| # Normalizes any (custom-named) repo into the canonical paths, mirroring | ||
| # src/server/start.js: clone into a temp dir then copy the contents into the | ||
| # target, so the working tree is always $ENGINE_ROOT regardless of repo name. | ||
| # --------------------------------------------------------------------------- | ||
| command -v git >/dev/null 2>&1 || sudo dnf install -y git | ||
| # clone_or_pull <repo-url> <branch> <target-dir> <remote-name> [mask-secret] | ||
| # If <target-dir> exists with a git repo, does a git pull (fetch+reset) to | ||
| # update it. Otherwise clones <repo-url> into a temp dir and copies contents | ||
| # to <target-dir> so a custom repo (e.g. engine-test-test) always lands at | ||
| # the canonical path. [mask-secret] is redacted from any logged output. | ||
| clone_or_pull() { | ||
| local url="${1:-}" branch="${2:-}" target="${3:-}" remote="${4:-origin}" mask="${5:-}" | ||
| if [ -d "$target" ] && git -C "$target" rev-parse --git-dir >/dev/null 2>&1; then | ||
| log "$target already present; pulling latest ..." | ||
| if git -C "$target" remote get-url "$remote" >/dev/null 2>&1; then | ||
| # Temporarily set the authenticated URL so the fetch succeeds. | ||
| # The caller strips the token from the remote URL after this returns. | ||
| local saved_url; saved_url="$(git -C "$target" remote get-url "$remote" 2>/dev/null || true)" | ||
| git -C "$target" remote set-url "$remote" "$url" 2>/dev/null || true | ||
| if [ -n "$mask" ]; then | ||
| git -C "$target" fetch --depth 1 "$remote" 2>&1 | sed "s/${mask}/***/g" || true | ||
| else | ||
| git -C "$target" fetch --depth 1 "$remote" 2>&1 || true | ||
| fi | ||
| # Restore the saved (clean) URL immediately so the token is never persisted. | ||
| if [ -n "$saved_url" ]; then | ||
| git -C "$target" remote set-url "$remote" "$saved_url" 2>/dev/null || true | ||
| fi | ||
| if [ -n "$branch" ]; then | ||
| git -C "$target" reset --hard "$remote/$branch" 2>/dev/null || true | ||
| else | ||
| # No branch specified: pull whichever branch is HEAD on remote | ||
| git -C "$target" reset --hard "$remote/$(git -C "$target" rev-parse --abbrev-ref HEAD 2>/dev/null)" 2>/dev/null || true | ||
| fi | ||
| # Clean untracked files so stale artifacts don't accumulate | ||
| git -C "$target" clean -fd 2>/dev/null || true | ||
| log "$target updated via pull" | ||
| return 0 | ||
| fi | ||
| fi | ||
| # Full clone + normalize path | ||
| log "Cloning + normalizing $url -> $target ..." | ||
| local tmp; tmp="$(mktemp -d)" | ||
| local ok=0 | ||
| if [ -n "$branch" ]; then | ||
| if [ -n "$mask" ]; then git clone --depth 1 --branch "$branch" "$url" "$tmp/repo" 2>&1 | sed "s/${mask}/***/g"; ok=${PIPESTATUS[0]}; else git clone --depth 1 --branch "$branch" "$url" "$tmp/repo"; ok=$?; fi | ||
| else | ||
| if [ -n "$mask" ]; then git clone --depth 1 "$url" "$tmp/repo" 2>&1 | sed "s/${mask}/***/g"; ok=${PIPESTATUS[0]}; else git clone --depth 1 "$url" "$tmp/repo"; ok=$?; fi | ||
| fi | ||
| if [ "$ok" -ne 0 ] || [ ! -d "$tmp/repo" ]; then | ||
| rm -rf "$tmp" | ||
| return 1 | ||
| fi | ||
| mkdir -p "$target" | ||
| cp -a "$tmp/repo/." "$target/" | ||
| rm -rf "$tmp" | ||
| } | ||
| if [ -n "$GITHUB_TOKEN" ]; then | ||
| ENGINE_REPO_URL="https://${GITHUB_USERNAME:-x-access-token}:${GITHUB_TOKEN}@${ENGINE_REPO#https://}" | ||
| # Mask the token in log output by routing through clone_or_pull with mask arg | ||
| clone_or_pull "$ENGINE_REPO_URL" "$ENGINE_BRANCH" "$ENGINE_ROOT" "origin" "$GITHUB_TOKEN" || { log "FATAL: engine clone failed"; exit 1; } | ||
| # Drop the token from the remote URL so it is not persisted on disk. | ||
| git -C "$ENGINE_ROOT" remote set-url origin "$ENGINE_REPO" 2>/dev/null || true | ||
| else | ||
| clone_or_pull "$ENGINE_REPO" "$ENGINE_BRANCH" "$ENGINE_ROOT" "origin" "" || { log "FATAL: engine clone failed"; exit 1; } | ||
| fi | ||
| cd "$ENGINE_ROOT" | ||
| # Clone + normalize the private secrets repo into $ENGINE_ROOT/engine-private | ||
| # (where `node bin run secret` reads engine-private/conf/.../.env.production), | ||
| # regardless of the private repo's name. The token is masked in logs and then | ||
| # stripped from the saved remote URL. | ||
| if [ -n "$GITHUB_TOKEN" ]; then | ||
| PRIV_URL="https://${GITHUB_USERNAME:-x-access-token}:${GITHUB_TOKEN}@${ENGINE_PRIVATE_REPO#https://}" | ||
| if clone_or_pull "$PRIV_URL" "$ENGINE_PRIVATE_BRANCH" "$ENGINE_ROOT/engine-private" "origin" "$GITHUB_TOKEN"; then | ||
| # Drop the token from the remote URL so it is not persisted on disk. | ||
| git -C "$ENGINE_ROOT/engine-private" remote set-url origin "$ENGINE_PRIVATE_REPO" 2>/dev/null || true | ||
| else | ||
| log "WARNING: engine-private clone failed — secrets will be unavailable" | ||
| fi | ||
| else | ||
| log "WARNING: GITHUB_TOKEN not provided; engine-private not cloned — secrets will be unavailable" | ||
| fi | ||
| # Install JS deps and generate secrets using the local engine entrypoint. | ||
| npm install | ||
| npm install -g underpost | ||
| node bin run secret | ||
| # --------------------------------------------------------------------------- | ||
| # 3. Host prerequisites (Docker, CRI-O, kubelet/kubeadm/kubectl, ...) via cluster.js | ||
| # --------------------------------------------------------------------------- | ||
| log "Installing Kubernetes host prerequisites (underpost cluster --init-host)..." | ||
| # CRI-O is already installed by this point (idempotent dnf). The install-crio | ||
| # runner also tries to install cri-tools (crictl) which is not in Rocky 9 repos. | ||
| # That failure is non-fatal; CRI-O itself works fine without crictl. | ||
| node bin cluster --dev --init-host || log "WARNING: init-host had minor errors (CRI-O is already installed)" | ||
| # --------------------------------------------------------------------------- | ||
| # 4. Role-specific bring-up, fully delegated to src/cli/cluster.js | ||
| # --------------------------------------------------------------------------- | ||
| if [ "$ROLE" = "control" ]; then | ||
| if [ "$INSTALL_CONTOUR" = "1" ]; then | ||
| log "Initializing kubeadm control-plane + Contour (underpost cluster --kubeadm --contour)..." | ||
| node bin cluster --dev --kubeadm --contour | ||
| else | ||
| log "Initializing kubeadm control-plane (underpost cluster --kubeadm)..." | ||
| node bin cluster --dev --kubeadm | ||
| fi | ||
| echo "" | ||
| log "Control-plane ready. Join command for workers:" | ||
| sudo kubeadm token create --print-join-command | ||
| elif [ "$ROLE" = "worker" ]; then | ||
| # Apply the same host configuration cluster.js uses (SELinux, swap, sysctl) | ||
| # before joining; worker_join then handles endpoint mapping + the join itself | ||
| # and fails the script (set -e) if the join does not succeed. | ||
| log "Applying host configuration (underpost cluster --config)..." | ||
| node bin cluster --dev --config | ||
| worker_join | ||
| fi | ||
| log "kubeadm ${ROLE} setup complete." |
| /** | ||
| * @description Docker Compose pipeline CLI for the local development stack that | ||
| * mirrors the Kubernetes manifests under `manifests/`. Manages dynamic | ||
| * generation of supporting config (nginx router, env-file) and the compose | ||
| * lifecycle (up/down/logs/...). General-purpose: any compose subcommand can be | ||
| * forwarded via `--exec`. | ||
| * @module src/cli/docker-compose.js | ||
| * @namespace UnderpostDockerCompose | ||
| */ | ||
| import fs from 'fs-extra'; | ||
| import nodePath from 'path'; | ||
| import { getRootDirectory, shellExec } from '../server/process.js'; | ||
| import { loggerFactory } from '../server/logger.js'; | ||
| import Nginx from '../runtime/nginx/Nginx.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| /** | ||
| * Reverse-proxy route table derived from | ||
| * manifests/deployment/dd-default-development/proxy.yaml (Contour HTTPProxy). | ||
| * Longer prefixes precede '/' so nginx matches them first. | ||
| * @constant PROXY_HOSTS | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| const PROXY_HOSTS = [ | ||
| { | ||
| host: 'default.net', | ||
| routes: [ | ||
| { location: '/peer', service: 'app', port: 4002 }, | ||
| { location: '/', service: 'app', port: 4001 }, | ||
| ], | ||
| }, | ||
| { | ||
| host: 'www.default.net', | ||
| routes: [{ location: '/', service: 'app', port: 4003 }], | ||
| }, | ||
| ]; | ||
| /** Fallback upstream for unmatched Host headers and the proxy healthcheck. */ | ||
| const DEFAULT_UPSTREAM = { service: 'app', port: 4001 }; | ||
| /** Prometheus scrape target: the app's /metrics endpoint on its base port. */ | ||
| const METRICS_TARGET = { service: 'app', port: 4001 }; | ||
| /** Default deployment identifier (the self-bootstrapping engine deploy). */ | ||
| const DEFAULT_DEPLOY_ID = 'dd-default'; | ||
| /** | ||
| * Generated artifact paths (relative to repo root) that `--generate` produces | ||
| * and `--reset` prunes. The working env-file (docker/compose.env) is excluded | ||
| * so a reset never destroys configured credentials. | ||
| */ | ||
| const GENERATED_ARTIFACTS = [ | ||
| 'docker/mongodb/entrypoint.sh', | ||
| 'docker/nginx/default.conf', | ||
| 'docker/prometheus/prometheus.yml', | ||
| 'docker/grafana/provisioning', | ||
| 'docker/compose.app.yml', | ||
| 'docker/compose.env.example', | ||
| ]; | ||
| /** | ||
| * @class UnderpostDockerCompose | ||
| * @description Docker Compose development pipeline. A single {@link UnderpostDockerCompose.API.callback} | ||
| * dispatches actions based on flag options, mirroring the cluster CLI pattern. | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| class UnderpostDockerCompose { | ||
| /** | ||
| * Resolves a repo-root-relative path to an absolute path. | ||
| * @param {string} relPath - Path relative to the engine root. | ||
| * @returns {string} Absolute path. | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static resolve(relPath) { | ||
| return nodePath.isAbsolute(relPath) ? relPath : nodePath.join(getRootDirectory(), relPath); | ||
| } | ||
| /** | ||
| * Builds the base `docker compose` invocation with explicit file and env-file, | ||
| * so behavior is independent of the caller's working directory. | ||
| * @param {object} options - CLI options. | ||
| * @returns {string} The base command string (without a subcommand). | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static baseCmd(options = {}) { | ||
| const composeFile = UnderpostDockerCompose.resolve(options.composeFile || 'docker-compose.yml'); | ||
| const envFile = UnderpostDockerCompose.resolve(options.envFile || 'docker/compose.env'); | ||
| const overrideFile = UnderpostDockerCompose.resolve(options.appOverride || 'docker/compose.app.yml'); | ||
| const overrideFlag = fs.existsSync(overrideFile) ? ` -f ${overrideFile}` : ''; | ||
| return `docker compose --env-file ${envFile} -f ${composeFile}${overrideFlag}`; | ||
| } | ||
| /** | ||
| * Resolves the app container start command for a deployment, mirroring | ||
| * src/cli/deploy.js. The `dd-default` deploy self-bootstraps a fresh engine | ||
| * (matching manifests/deployment/dd-default-development); any other deploy-id | ||
| * follows the standard deploy command (matching | ||
| * manifests/deployment/dd-test-development and | ||
| * UnderpostDeploy.deploymentYamlPartsFactory's default cmd). | ||
| * @param {string} deployId - Deployment identifier (conf id, e.g. `dd-test`). | ||
| * @param {string} env - Deployment environment (e.g. `development`). | ||
| * @returns {string[]} Ordered shell steps joined with `&&` at render time. | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static appCommand(deployId = DEFAULT_DEPLOY_ID, env = 'development') { | ||
| if (deployId === DEFAULT_DEPLOY_ID) | ||
| return [ | ||
| // `$$` escapes to a literal `$` so /bin/sh (not Compose) performs the | ||
| // command/variable substitution at container runtime. | ||
| 'cd $$(underpost root)/underpost', | ||
| 'node bin new --default-conf --conf-workflow-id template', | ||
| // Point the template .env.example at the Docker service-discovery hosts | ||
| // before `new engine` copies it. The generated dd-engine/.env.development | ||
| // is seeded from this file and is applied over the process env by | ||
| // loadConf (src/server/conf.js), so without this the engine would fall | ||
| // back to the .env.example localhost defaults for DB_HOST/VALKEY_HOST. | ||
| 'sed -i "s#^DB_HOST=.*#DB_HOST=$${DB_HOST}#" .env.example', | ||
| 'sed -i "s#^VALKEY_HOST=.*#VALKEY_HOST=$${VALKEY_HOST}#" .env.example', | ||
| 'mkdir -p /home/dd', | ||
| 'cd /home/dd', | ||
| 'underpost new engine', | ||
| ]; | ||
| return ['underpost secret underpost --create-from-env', `underpost start --build --run ${deployId} ${env}`]; | ||
| } | ||
| /** | ||
| * Renders a Compose override file that sets only the `app` service command | ||
| * for the selected deployment. Keeping the command in an override file makes | ||
| * the generator the single source of truth and leaves docker-compose.yml | ||
| * deployment-agnostic. | ||
| * @param {string} deployId - Deployment identifier. | ||
| * @param {string} env - Deployment environment. | ||
| * @returns {string} The override YAML document. | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static appOverrideContent(deployId = DEFAULT_DEPLOY_ID, env = 'development') { | ||
| const steps = UnderpostDockerCompose.appCommand(deployId, env).join(' &&\n '); | ||
| return `# Generated by 'underpost docker-compose --generate --deploy-id ${deployId}' — do not hand-edit. | ||
| # Overrides the app service start command for deploy '${deployId}' (${env}). | ||
| services: | ||
| app: | ||
| command: | ||
| - /bin/sh | ||
| - -c | ||
| - > | ||
| ${steps} | ||
| `; | ||
| } | ||
| /** | ||
| * Renders the dynamic env-file example content for the compose stack. | ||
| * @returns {string} The env-file template. | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static envExampleContent() { | ||
| return `# Generated by 'underpost docker-compose --generate' — copy to docker/compose.env. | ||
| # Loaded explicitly via --env-file so container service-discovery values are not | ||
| # overridden by the application's host-local (127.0.0.1) root .env. | ||
| # --- Container images ---------------------------------------------------- | ||
| MONGO_IMAGE=mongo:latest | ||
| VALKEY_IMAGE=valkey/valkey:latest | ||
| APP_IMAGE=underpost/underpost-engine | ||
| APP_TAG=v3.2.22 | ||
| PROXY_IMAGE=nginx:stable-alpine | ||
| PROMETHEUS_IMAGE=prom/prometheus:latest | ||
| GRAFANA_IMAGE=grafana/grafana:latest | ||
| # --- MongoDB (translation of Secret mongodb-secret) ---------------------- | ||
| MONGO_INITDB_ROOT_USERNAME=admin | ||
| MONGO_INITDB_ROOT_PASSWORD=changeme | ||
| # --- Application DB connection (Docker service discovery, not localhost) -- | ||
| NODE_ENV=development | ||
| DB_PROVIDER=mongoose | ||
| DB_HOST=mongodb://mongodb:27017 | ||
| DB_NAME=default | ||
| DB_REPLICA_SET=rs0 | ||
| DB_AUTH_SOURCE=admin | ||
| # --- Valkey (Docker service discovery) ----------------------------------- | ||
| VALKEY_HOST=valkey-service | ||
| VALKEY_PORT=6379 | ||
| # --- Monitoring ---------------------------------------------------------- | ||
| GRAFANA_ADMIN_USER=admin | ||
| GRAFANA_ADMIN_PASSWORD=admin | ||
| # --- Host-published ports ------------------------------------------------ | ||
| PROXY_HTTP_PORT=80 | ||
| MONGO_HOST_PORT=27017 | ||
| VALKEY_NODEPORT=32079 | ||
| PROMETHEUS_PORT=9090 | ||
| GRAFANA_PORT=3000 | ||
| APP_PORT_4001=4001 | ||
| APP_PORT_4002=4002 | ||
| APP_PORT_4003=4003 | ||
| APP_PORT_4004=4004 | ||
| `; | ||
| } | ||
| /** | ||
| * Renders the MongoDB container entrypoint. Mirrors src/db/mongo/MongoBootstrap.js: | ||
| * generates the cluster-auth keyfile, launches mongod (replSet + keyFile + auth), | ||
| * and bootstraps the replica set + root user over the loopback localhost | ||
| * exception (single-node member is the `mongodb` service name so app clients | ||
| * connecting with replicaSet=rs0 resolve a reachable host). Idempotent across | ||
| * restarts: re-runs are no-ops once auth is enforced. | ||
| * @returns {string} entrypoint.sh content. | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static mongoEntrypointContent() { | ||
| return `#!/usr/bin/env bash | ||
| # Generated by 'underpost docker-compose --generate' — do not hand-edit. | ||
| set -e | ||
| KEYFILE=/opt/keyfile/mongodb-keyfile | ||
| RS="\${DB_REPLICA_SET:-rs0}" | ||
| MEMBER_HOST="mongodb:27017" | ||
| if [ ! -s "$KEYFILE" ]; then | ||
| openssl rand -base64 756 > "$KEYFILE" | ||
| fi | ||
| chmod 400 "$KEYFILE" | ||
| chown 999:999 "$KEYFILE" | ||
| mkdir -p /data/db | ||
| chown -R 999:999 /data/db | ||
| # One-time replica-set + root-user bootstrap via the localhost exception, | ||
| # performed in the background once mongod accepts loopback connections. | ||
| ( | ||
| for i in $(seq 1 60); do | ||
| if mongosh --quiet --host 127.0.0.1 --eval 'db.adminCommand({ ping: 1 })' >/dev/null 2>&1; then | ||
| break | ||
| fi | ||
| sleep 1 | ||
| done | ||
| cat > /tmp/mongo-bootstrap.js <<JS | ||
| var RS = "$RS"; | ||
| var HOST = "$MEMBER_HOST"; | ||
| var U = "$MONGO_INITDB_ROOT_USERNAME"; | ||
| var P = "$MONGO_INITDB_ROOT_PASSWORD"; | ||
| function waitPrimary() { | ||
| for (var i = 0; i < 60; i++) { | ||
| var h = db.hello(); | ||
| if (h.isWritablePrimary) return true; | ||
| sleep(1000); | ||
| } | ||
| return false; | ||
| } | ||
| var initialized = false; | ||
| try { | ||
| var s = rs.status(); | ||
| if (s && s.ok === 1) initialized = true; | ||
| } catch (e) { | ||
| var m = String(e); | ||
| if (m.indexOf("requires authentication") >= 0 || m.indexOf("not authorized") >= 0 || m.indexOf("Unauthorized") >= 0) { | ||
| initialized = true; | ||
| } else if (m.indexOf("NotYetInitialized") < 0 && m.indexOf("no replset config") < 0) { | ||
| throw e; | ||
| } | ||
| } | ||
| if (!initialized) { | ||
| rs.initiate({ _id: RS, members: [{ _id: 0, host: HOST }] }); | ||
| waitPrimary(); | ||
| var admin = db.getSiblingDB("admin"); | ||
| try { | ||
| admin.createUser({ user: U, pwd: P, roles: [{ role: "root", db: "admin" }] }); | ||
| print("BOOTSTRAP_USER_CREATED"); | ||
| } catch (e) { | ||
| var s2 = String(e); | ||
| if (s2.indexOf("already exists") < 0 && s2.indexOf("not authorized") < 0 && s2.indexOf("requires authentication") < 0) { | ||
| throw e; | ||
| } | ||
| print("BOOTSTRAP_USER_SKIP"); | ||
| } | ||
| print("BOOTSTRAP_DONE"); | ||
| } else { | ||
| print("BOOTSTRAP_ALREADY_INITIALIZED"); | ||
| } | ||
| JS | ||
| mongosh --quiet --host 127.0.0.1 /tmp/mongo-bootstrap.js > /tmp/mongo-bootstrap.log 2>&1 || true | ||
| ) & | ||
| exec gosu mongodb mongod \\ | ||
| --replSet "$RS" --auth --clusterAuthMode keyFile --keyFile "$KEYFILE" --bind_ip_all | ||
| `; | ||
| } | ||
| /** | ||
| * Renders the Prometheus scrape config (mirrors manifests/prometheus). | ||
| * @returns {string} prometheus.yml content. | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static prometheusContent() { | ||
| return `# Generated by 'underpost docker-compose --generate' — do not hand-edit. | ||
| global: | ||
| scrape_interval: 30s | ||
| evaluation_interval: 30s | ||
| scrape_configs: | ||
| - job_name: 'underpost-app' | ||
| metrics_path: /metrics | ||
| scheme: http | ||
| static_configs: | ||
| - targets: ['${METRICS_TARGET.service}:${METRICS_TARGET.port}'] | ||
| - job_name: 'prometheus' | ||
| static_configs: | ||
| - targets: ['localhost:9090'] | ||
| `; | ||
| } | ||
| /** | ||
| * Renders the Grafana datasource provisioning file wiring the Prometheus | ||
| * service as the default datasource. | ||
| * @returns {string} datasource.yml content. | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static grafanaDatasourceContent() { | ||
| return `# Generated by 'underpost docker-compose --generate' — do not hand-edit. | ||
| apiVersion: 1 | ||
| datasources: | ||
| - name: Prometheus | ||
| type: prometheus | ||
| access: proxy | ||
| url: http://prometheus:9090 | ||
| isDefault: true | ||
| editable: true | ||
| `; | ||
| } | ||
| /** | ||
| * Renders all dynamic supporting files: the nginx reverse-proxy config (from | ||
| * PROXY_HOSTS), the monitoring configs (Prometheus + Grafana datasource), and | ||
| * the env-file example. Creates a working env-file from the example only when | ||
| * one does not already exist (never overwrites credentials). | ||
| * @param {object} options - CLI options. | ||
| * @returns {void} | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static generate(options = {}) { | ||
| Nginx.removeRouter(); | ||
| for (const { host, routes } of PROXY_HOSTS) Nginx.createApp({ host, routes }); | ||
| Nginx.createDefaultServer(DEFAULT_UPSTREAM); | ||
| Nginx.writeConf(UnderpostDockerCompose.resolve(options.nginxConf || 'docker/nginx/default.conf')); | ||
| const mongoEntrypointPath = UnderpostDockerCompose.resolve('docker/mongodb/entrypoint.sh'); | ||
| fs.mkdirpSync(nodePath.dirname(mongoEntrypointPath)); | ||
| fs.writeFileSync(mongoEntrypointPath, UnderpostDockerCompose.mongoEntrypointContent(), { mode: 0o755 }); | ||
| logger.info('mongodb entrypoint written', { path: mongoEntrypointPath }); | ||
| const promPath = UnderpostDockerCompose.resolve('docker/prometheus/prometheus.yml'); | ||
| fs.mkdirpSync(nodePath.dirname(promPath)); | ||
| fs.writeFileSync(promPath, UnderpostDockerCompose.prometheusContent(), 'utf8'); | ||
| const grafanaDsPath = UnderpostDockerCompose.resolve('docker/grafana/provisioning/datasources/datasource.yml'); | ||
| fs.mkdirpSync(nodePath.dirname(grafanaDsPath)); | ||
| fs.writeFileSync(grafanaDsPath, UnderpostDockerCompose.grafanaDatasourceContent(), 'utf8'); | ||
| logger.info('monitoring config written', { prometheus: promPath, grafanaDatasource: grafanaDsPath }); | ||
| const examplePath = UnderpostDockerCompose.resolve('docker/compose.env.example'); | ||
| fs.mkdirpSync(nodePath.dirname(examplePath)); | ||
| fs.writeFileSync(examplePath, UnderpostDockerCompose.envExampleContent(), 'utf8'); | ||
| logger.info('env example written', { path: examplePath }); | ||
| const envPath = UnderpostDockerCompose.resolve(options.envFile || 'docker/compose.env'); | ||
| if (!fs.existsSync(envPath)) { | ||
| fs.copySync(examplePath, envPath); | ||
| logger.warn('created working env-file from example — set real credentials', { path: envPath }); | ||
| } else logger.info('working env-file already present (left untouched)', { path: envPath }); | ||
| const deployId = options.deployId || DEFAULT_DEPLOY_ID; | ||
| const env = options.env || 'development'; | ||
| const overridePath = UnderpostDockerCompose.resolve(options.appOverride || 'docker/compose.app.yml'); | ||
| fs.writeFileSync(overridePath, UnderpostDockerCompose.appOverrideContent(deployId, env), 'utf8'); | ||
| logger.info('app command override written', { path: overridePath, deployId, env }); | ||
| } | ||
| /** | ||
| * Installs Docker Engine and the Compose v2 plugin on RHEL-compatible hosts | ||
| * (Rocky Linux) from the official Docker CE repository. Idempotent and | ||
| * host-safe: skips installation when `docker compose` already works (unless | ||
| * `--force`), validates the platform, enables the service, and adds the | ||
| * invoking user to the `docker` group. | ||
| * @param {object} options - CLI options. | ||
| * @param {boolean} [options.force] - Reinstall even if Compose is already present. | ||
| * @returns {void} | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static install(options = {}) { | ||
| if (process.platform !== 'linux') { | ||
| logger.warn('docker-compose --install only supports Linux (RHEL/Rocky); skipping', { | ||
| platform: process.platform, | ||
| }); | ||
| return; | ||
| } | ||
| if (!fs.existsSync('/etc/redhat-release')) { | ||
| logger.warn('Host does not look RHEL-compatible (/etc/redhat-release missing); proceeding with dnf anyway'); | ||
| } | ||
| if (!options.force) { | ||
| const probe = shellExec('docker compose version', { silent: true, silentOnError: true }); | ||
| if (probe && probe.code === 0) { | ||
| logger.info('Docker Compose already installed; skipping (use --force to reinstall)', { | ||
| version: (probe.stdout || '').trim(), | ||
| }); | ||
| return; | ||
| } | ||
| } | ||
| shellExec(`sudo dnf -y install dnf-plugins-core`); | ||
| shellExec(`sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo`); | ||
| shellExec(`sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin`); | ||
| shellExec(`sudo systemctl enable --now docker`); | ||
| const user = options.user || process.env.SUDO_USER || process.env.USER || ''; | ||
| if (user && user !== 'root') { | ||
| shellExec(`sudo groupadd docker 2>/dev/null || true`); | ||
| shellExec(`sudo usermod -aG docker ${user}`); | ||
| logger.info(`Added '${user}' to the docker group — log out/in (or run 'newgrp docker') to use docker rootless`); | ||
| } | ||
| const verify = shellExec('docker compose version', { silent: true, silentOnError: true, stdout: true }); | ||
| logger.info('Docker Compose installation complete', { version: `${verify || ''}`.trim() }); | ||
| } | ||
| /** | ||
| * Comprehensive reset of the Docker Compose stack — the compose equivalent of | ||
| * `cluster --reset` in src/cli/cluster.js. Tears down all stack containers, | ||
| * the project network, and the named volumes (destroying persisted MongoDB / | ||
| * Valkey / Prometheus / Grafana data), removes orphans, and prunes generated | ||
| * artifacts so the next `--up` is a clean slate. The working env-file | ||
| * (credentials) is preserved unless `--force` is passed. | ||
| * @param {object} options - CLI options. | ||
| * @param {boolean} [options.force] - Also remove the working env-file (docker/compose.env). | ||
| * @returns {void} | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| static reset(options = {}) { | ||
| logger.info('=== DOCKER COMPOSE RESET (destroys containers, network, and volume data) ==='); | ||
| // Phase 1: tear down containers, orphans, named volumes, and compose-built images. | ||
| shellExec(`${UnderpostDockerCompose.baseCmd(options)} down --remove-orphans --volumes --rmi local`, { | ||
| silentOnError: true, | ||
| }); | ||
| // Phase 2: prune generated artifacts (regenerated on the next --generate/--up). | ||
| const artifacts = options.force | ||
| ? [...GENERATED_ARTIFACTS, options.envFile || 'docker/compose.env'] | ||
| : GENERATED_ARTIFACTS; | ||
| for (const rel of artifacts) { | ||
| const target = UnderpostDockerCompose.resolve(rel); | ||
| if (fs.existsSync(target)) { | ||
| fs.removeSync(target); | ||
| logger.info('removed generated artifact', { path: target }); | ||
| } | ||
| } | ||
| logger.info('Docker Compose reset complete. Run `--up` to recreate the stack.'); | ||
| } | ||
| static API = { | ||
| /** | ||
| * @method callback | ||
| * @description Single CLI entrypoint for the docker-compose pipeline. The | ||
| * action is selected by flag options; multiple lifecycle flags may be | ||
| * combined (e.g. `--generate --up`). When no action flag is supplied it | ||
| * defaults to `--up`. | ||
| * @param {string} [target] - Optional service name (logs/shell/restart) or | ||
| * passthrough subcommand context. | ||
| * @param {object} [options] - CLI options. | ||
| * @param {boolean} [options.install] - Install Docker + Compose on RHEL/Rocky hosts. | ||
| * @param {boolean} [options.reset] - Comprehensive teardown of the whole stack (containers, network, volumes) + prune generated artifacts. | ||
| * @param {boolean} [options.force] - Force reinstall (--install), remove volumes (--down), or also drop the env-file (--reset). | ||
| * @param {boolean} [options.generate] - Render nginx config + env-file. | ||
| * @param {boolean} [options.up] - Start the full stack detached (implies generate). | ||
| * @param {boolean} [options.down] - Stop and remove containers. | ||
| * @param {boolean} [options.volumes] - With --down, also remove named volumes (destroys data). | ||
| * @param {boolean} [options.restart] - Restart services (optionally a single `target`). | ||
| * @param {boolean} [options.build] - With --up rebuild images; alone, `build --no-cache`. | ||
| * @param {boolean} [options.pull] - Pull upstream images. | ||
| * @param {boolean} [options.logs] - Follow logs (optionally a single `target`). | ||
| * @param {boolean} [options.status] - Show a formatted status table. | ||
| * @param {boolean} [options.shell] - Open an interactive shell in `target` (default: app). | ||
| * @param {string} [options.exec] - General-purpose passthrough docker compose subcommand. | ||
| * @param {string} [options.deployId] - Deployment to run as the app (default: dd-default). `dd-default` self-bootstraps a fresh engine; any other id runs the standard `underpost start` command. | ||
| * @param {string} [options.env] - Deployment environment for non-default deploy ids (default: development). | ||
| * @param {string} [options.composeFile] - Override compose file path. | ||
| * @param {string} [options.envFile] - Override env-file path. | ||
| * @param {string} [options.nginxConf] - Override generated nginx config path. | ||
| * @param {string} [options.appOverride] - Override generated app-command override path. | ||
| * @returns {Promise<void>} | ||
| * @memberof UnderpostDockerCompose | ||
| */ | ||
| async callback(target = '', options = {}) { | ||
| try { | ||
| if (options.install) { | ||
| UnderpostDockerCompose.install(options); | ||
| const onlyInstall = !options.up && !options.down && !options.generate && !options.exec; | ||
| if (onlyInstall) return; | ||
| } | ||
| if (options.reset) { | ||
| UnderpostDockerCompose.reset(options); | ||
| const onlyReset = !options.up && !options.generate; | ||
| if (onlyReset) return; | ||
| } | ||
| // "Bring up" is the explicit --up or the no-action default. | ||
| const isUp = | ||
| options.up || | ||
| !( | ||
| options.exec || | ||
| options.down || | ||
| options.restart || | ||
| options.build || | ||
| options.pull || | ||
| options.logs || | ||
| options.status || | ||
| options.shell || | ||
| options.generate | ||
| ); | ||
| // Generate dynamic config BEFORE composing the invocation: baseCmd() | ||
| // conditionally includes `-f compose.app.yml`, so the override must | ||
| // exist first (notably after --reset, which prunes it). | ||
| if (isUp || options.generate) UnderpostDockerCompose.generate(options); | ||
| const base = UnderpostDockerCompose.baseCmd(options); | ||
| if (options.exec) { | ||
| shellExec(`${base} ${options.exec}`); | ||
| return; | ||
| } | ||
| if (isUp) { | ||
| shellExec(`${base} up -d${options.build ? ' --build' : ''}`); | ||
| return; | ||
| } | ||
| if (options.down) { | ||
| shellExec(`${base} down --remove-orphans${options.volumes ? ' --volumes' : ''}`); | ||
| return; | ||
| } | ||
| if (options.restart) { | ||
| shellExec(`${base} restart${target ? ` ${target}` : ''}`); | ||
| return; | ||
| } | ||
| if (options.build) { | ||
| shellExec(`${base} build --no-cache${target ? ` ${target}` : ''}`); | ||
| return; | ||
| } | ||
| if (options.pull) { | ||
| shellExec(`${base} pull`); | ||
| return; | ||
| } | ||
| if (options.logs) { | ||
| shellExec(`${base} logs -f --tail=200${target ? ` ${target}` : ''}`); | ||
| return; | ||
| } | ||
| if (options.status) { | ||
| shellExec(`${base} ps --format 'table {{.Name}}\\t{{.Service}}\\t{{.Status}}\\t{{.Ports}}'`); | ||
| return; | ||
| } | ||
| if (options.shell) { | ||
| const service = target || 'app'; | ||
| shellExec(`${base} exec ${service} ${service === 'app' ? '/bin/bash' : '/bin/sh'}`); | ||
| return; | ||
| } | ||
| // Remaining case: --generate alone (config already written above). | ||
| } catch (error) { | ||
| logger.error(error); | ||
| process.exit(1); | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
| export default UnderpostDockerCompose; |
| 'use strict'; | ||
| /** | ||
| * Module for building project documentation (JSDoc, Swagger, Coverage). | ||
| * @module src/client-builder/client-build-docs.js | ||
| * @namespace clientBuildDocs | ||
| */ | ||
| import fs from 'fs-extra'; | ||
| import { shellExec } from '../server/process.js'; | ||
| import { loggerFactory } from '../server/logger.js'; | ||
| import { JSONweb } from './client-formatted.js'; | ||
| import { ssrFactory } from './ssr.js'; | ||
| /** | ||
| * Builds API documentation using Swagger | ||
| * @function buildApiDocs | ||
| * @memberof clientBuildDocs | ||
| * @param {Object} options - Documentation build options | ||
| * @param {string} options.host - The hostname for the API | ||
| * @param {string} options.path - The base path for the API | ||
| * @param {number} options.port - The port number for the API | ||
| * @param {Object} options.metadata - Metadata for the API documentation | ||
| * @param {Array<string>} options.apis - List of API modules to document | ||
| * @param {string} options.publicClientId - Client ID for the public documentation | ||
| * @param {string} options.rootClientPath - Root path for client files | ||
| * @param {Object} options.packageData - Package.json data | ||
| */ | ||
| const buildApiDocs = async ({ | ||
| host, | ||
| path, | ||
| port, | ||
| metadata = {}, | ||
| apis = [], | ||
| publicClientId, | ||
| rootClientPath, | ||
| packageData, | ||
| }) => { | ||
| const logger = loggerFactory(import.meta); | ||
| const basePath = path === '/' ? `${process.env.BASE_API}` : `/${process.env.BASE_API}`; | ||
| const doc = { | ||
| info: { | ||
| version: packageData.version, | ||
| title: metadata?.title ? `${metadata.title}` : 'REST API', | ||
| description: metadata?.description ? metadata.description : '', | ||
| }, | ||
| servers: [ | ||
| { | ||
| url: | ||
| process.env.NODE_ENV === 'development' | ||
| ? `http://localhost:${port}${path}${basePath}` | ||
| : `https://${host}${path}${basePath}`, | ||
| description: `${process.env.NODE_ENV} server`, | ||
| }, | ||
| ], | ||
| tags: [ | ||
| { | ||
| name: 'user', | ||
| description: 'User API operations', | ||
| }, | ||
| { | ||
| name: 'object-layer', | ||
| description: 'Object Layer API operations', | ||
| }, | ||
| ], | ||
| components: { | ||
| schemas: { | ||
| userRequest: { | ||
| type: 'object', | ||
| required: ['username', 'password', 'email'], | ||
| properties: { | ||
| username: { type: 'string', example: 'user123' }, | ||
| password: { type: 'string', example: 'Password123!' }, | ||
| email: { type: 'string', format: 'email', example: 'user@example.com' }, | ||
| }, | ||
| }, | ||
| userResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'success' }, | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| token: { | ||
| type: 'string', | ||
| example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7Il9pZCI6IjY2YzM3N2Y1N2Y5OWU1OTY5YjgxZG...', | ||
| }, | ||
| user: { | ||
| type: 'object', | ||
| properties: { | ||
| _id: { type: 'string', example: '66c377f57f99e5969b81de89' }, | ||
| email: { type: 'string', format: 'email', example: 'user@example.com' }, | ||
| emailConfirmed: { type: 'boolean', example: false }, | ||
| username: { type: 'string', example: 'user123' }, | ||
| role: { type: 'string', example: 'user' }, | ||
| profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| userUpdateResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'success' }, | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| _id: { type: 'string', example: '66c377f57f99e5969b81de89' }, | ||
| email: { type: 'string', format: 'email', example: 'user@example.com' }, | ||
| emailConfirmed: { type: 'boolean', example: false }, | ||
| username: { type: 'string', example: 'user123222' }, | ||
| role: { type: 'string', example: 'user' }, | ||
| profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| userGetResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'success' }, | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| _id: { type: 'string', example: '66c377f57f99e5969b81de89' }, | ||
| email: { type: 'string', format: 'email', example: 'user@example.com' }, | ||
| emailConfirmed: { type: 'boolean', example: false }, | ||
| username: { type: 'string', example: 'user123222' }, | ||
| role: { type: 'string', example: 'user' }, | ||
| profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| userLogInRequest: { | ||
| type: 'object', | ||
| required: ['email', 'password'], | ||
| properties: { | ||
| email: { type: 'string', format: 'email', example: 'user@example.com' }, | ||
| password: { type: 'string', example: 'Password123!' }, | ||
| }, | ||
| }, | ||
| userBadRequestResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'error' }, | ||
| message: { | ||
| type: 'string', | ||
| example: 'Bad request. Please check your inputs, and try again', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| objectLayerResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'success' }, | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| _id: { type: 'string', example: '66c377f57f99e5969b81de89' }, | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| stats: { | ||
| type: 'object', | ||
| properties: { | ||
| effect: { type: 'number', example: 0 }, | ||
| resistance: { type: 'number', example: 0 }, | ||
| agility: { type: 'number', example: 0 }, | ||
| range: { type: 'number', example: 0 }, | ||
| intelligence: { type: 'number', example: 0 }, | ||
| utility: { type: 'number', example: 0 }, | ||
| }, | ||
| }, | ||
| item: { | ||
| type: 'object', | ||
| properties: { | ||
| id: { type: 'string', example: 'skin-default' }, | ||
| type: { type: 'string', example: 'skin' }, | ||
| description: { type: 'string', example: 'Default skin layer' }, | ||
| activable: { type: 'boolean', example: false }, | ||
| }, | ||
| }, | ||
| ledger: { | ||
| type: 'object', | ||
| properties: { | ||
| type: { type: 'string', example: 'semi-fungible' }, | ||
| address: { type: 'string', example: '0x0000000000000000000000000000000000000000' }, | ||
| tokenId: { type: 'string', example: '' }, | ||
| }, | ||
| }, | ||
| render: { | ||
| type: 'object', | ||
| properties: { | ||
| cid: { type: 'string', example: '' }, | ||
| metadataCid: { type: 'string', example: '' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| cid: { type: 'string', example: '' }, | ||
| sha256: { type: 'string', example: 'abc123def456...' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| objectLayerBadRequestResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'error' }, | ||
| message: { | ||
| type: 'string', | ||
| example: 'Bad request. Please check your inputs, and try again', | ||
| }, | ||
| }, | ||
| }, | ||
| securitySchemes: { | ||
| bearerAuth: { | ||
| type: 'http', | ||
| scheme: 'bearer', | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| /** | ||
| * swagger-autogen has no requestBody annotation support — it only handles | ||
| * #swagger.parameters, responses, security, etc. We define the requestBody | ||
| * objects here and inject them into the generated JSON as a post-processing step. | ||
| * | ||
| * Each key is an "<method> <path>" pair matching the generated paths object. | ||
| * The value is a valid OAS 3.0 requestBody object. | ||
| */ | ||
| const requestBodies = { | ||
| 'post /user': { | ||
| description: 'User registration data', | ||
| required: true, | ||
| content: { | ||
| 'application/json': { | ||
| schema: { $ref: '#/components/schemas/userRequest' }, | ||
| }, | ||
| }, | ||
| }, | ||
| 'post /user/auth': { | ||
| description: 'User login credentials', | ||
| required: true, | ||
| content: { | ||
| 'application/json': { | ||
| schema: { $ref: '#/components/schemas/userLogInRequest' }, | ||
| }, | ||
| }, | ||
| }, | ||
| 'put /user/{id}': { | ||
| description: 'User fields to update', | ||
| required: true, | ||
| content: { | ||
| 'application/json': { | ||
| schema: { $ref: '#/components/schemas/userRequest' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| logger.warn('build swagger api docs', doc.info); | ||
| // swagger-autogen@2.9.2 bug: getProducesTag, getConsumesTag, getResponsesTag missing __¬¬¬__ decode before eval | ||
| fs.writeFileSync( | ||
| `node_modules/swagger-autogen/src/swagger-tags.js`, | ||
| fs | ||
| .readFileSync(`node_modules/swagger-autogen/src/swagger-tags.js`, 'utf8') | ||
| // getProducesTag and getConsumesTag: already decode " but not __¬¬¬__ | ||
| .replaceAll( | ||
| `data.replaceAll('\\n', ' ').replaceAll('\u201c', '\u201d')`, | ||
| `data.replaceAll('\\n', ' ').replaceAll('\u201c', '\u201d').replaceAll('__\u00ac\u00ac\u00ac__', '"')`, | ||
| ) | ||
| // getResponsesTag: decodes neither " nor __¬¬¬__ | ||
| .replaceAll( | ||
| `data.replaceAll('\\n', ' ');`, | ||
| `data.replaceAll('\\n', ' ').replaceAll('__\u00ac\u00ac\u00ac__', '"');`, | ||
| ), | ||
| 'utf8', | ||
| ); | ||
| setTimeout(async () => { | ||
| const { default: swaggerAutoGen } = await import('swagger-autogen'); | ||
| const outputFile = `./public/${host}${path === '/' ? path : `${path}/`}swagger-output.json`; | ||
| const routes = []; | ||
| for (const api of apis) { | ||
| if (['user', 'object-layer'].includes(api)) routes.push(`./src/api/${api}/${api}.router.js`); | ||
| } | ||
| await swaggerAutoGen({ openapi: '3.0.0' })(outputFile, routes, doc); | ||
| // Post-process: inject requestBody into operations — swagger-autogen silently | ||
| // ignores #swagger.requestBody annotations and has no internal OAS-3 body support. | ||
| if (fs.existsSync(outputFile)) { | ||
| const swaggerJson = JSON.parse(fs.readFileSync(outputFile, 'utf8')); | ||
| let patched = false; | ||
| for (const [key, requestBody] of Object.entries(requestBodies)) { | ||
| const [method, ...pathParts] = key.split(' '); | ||
| const opPath = pathParts.join(' '); | ||
| if (swaggerJson.paths?.[opPath]?.[method]) { | ||
| swaggerJson.paths[opPath][method].requestBody = requestBody; | ||
| // Remove any stale in:body entry from parameters (OAS 3.0 doesn't allow it) | ||
| if (Array.isArray(swaggerJson.paths[opPath][method].parameters)) { | ||
| swaggerJson.paths[opPath][method].parameters = swaggerJson.paths[opPath][method].parameters.filter( | ||
| (p) => p.in !== 'body', | ||
| ); | ||
| } | ||
| patched = true; | ||
| } | ||
| } | ||
| if (patched) { | ||
| fs.writeFileSync(outputFile, JSON.stringify(swaggerJson, null, 2), 'utf8'); | ||
| // logger.warn('swagger post-process: requestBody injected', Object.keys(requestBodies)); | ||
| } | ||
| } | ||
| }); | ||
| }; | ||
| /** | ||
| * Builds API documentation using TypeDoc (generates a modern static site from JSDoc-annotated JS). | ||
| * Config is read from the base typedoc JSON, merged with runtime values, written to a temporary | ||
| * file, and deleted after the build — the base config file is never mutated on disk. | ||
| * @function buildJsDocs | ||
| * @memberof clientBuildDocs | ||
| * @param {Object} options - TypeDoc build options | ||
| * @param {string} options.host - The hostname for the documentation | ||
| * @param {string} options.path - The base path for the documentation | ||
| * @param {Object} options.metadata - Metadata for the documentation | ||
| * @param {string} options.publicClientId - Client ID used to resolve the references directory | ||
| * @param {Object} options.docs - Documentation config from server conf | ||
| * @param {string} options.docs.jsJsonPath - Path to the base typedoc JSON config file | ||
| * @param {string} options.docsDestination - Resolved output path for the generated docs | ||
| */ | ||
| const buildJsDocs = async ({ host, path, metadata = {}, publicClientId, docs, docsDestination }) => { | ||
| const logger = loggerFactory(import.meta); | ||
| const typedocConfigPath = docs.jsJsonPath; | ||
| if (!fs.existsSync(typedocConfigPath)) { | ||
| logger.warn('typedoc config not found, skipping', typedocConfigPath); | ||
| return; | ||
| } | ||
| const baseConfig = JSON.parse(fs.readFileSync(typedocConfigPath, 'utf8')); | ||
| logger.info('using typedoc config', typedocConfigPath); | ||
| // Build runtime config in memory — never mutate the base config file | ||
| // tsconfig must be absolute so TypeDoc resolves it regardless of where the | ||
| // tmp config file is located on disk. | ||
| const runtimeConfig = { | ||
| ...baseConfig, | ||
| tsconfig: fs.realpathSync(baseConfig.tsconfig || './tsconfig.docs.json'), | ||
| out: docsDestination, | ||
| name: metadata?.title || baseConfig.name, | ||
| favicon: `./public/${host}${path === '/' ? '/' : `${path}/`}favicon.ico`, | ||
| }; | ||
| // Include extra reference documents as TypeDoc document pages | ||
| // TypeDoc 0.28+: option is `projectDocuments`, not `documents` | ||
| if (Array.isArray(docs.references) && docs.references.length > 0) { | ||
| runtimeConfig.projectDocuments = docs.references.filter((p) => fs.existsSync(p)); | ||
| if (runtimeConfig.projectDocuments.length > 0) logger.info('typedoc documents', runtimeConfig.projectDocuments); | ||
| } | ||
| const tmpConfigPath = `.typedoc.tmp.json`; | ||
| fs.writeFileSync(tmpConfigPath, JSON.stringify(runtimeConfig, null, 2), 'utf8'); | ||
| logger.warn('build typedoc view', docsDestination); | ||
| shellExec(`node_modules/.bin/typedoc --options ${tmpConfigPath}`, { silent: true }); | ||
| fs.removeSync(tmpConfigPath); | ||
| }; | ||
| /** | ||
| * Builds test coverage documentation | ||
| * @function buildCoverage | ||
| * @memberof clientBuildDocs | ||
| * @param {Object} options - Coverage build options | ||
| * @param {Object} options.docs - Documentation config from server conf | ||
| * @param {string} options.docs.coveragePath - Directory where coverage reports are generated | ||
| * @param {string} options.docsDestination - Resolved output path where docs were built | ||
| */ | ||
| const buildCoverage = async ({ docs, docsDestination }) => { | ||
| const logger = loggerFactory(import.meta); | ||
| const { coveragePath, coverageOutputDir = 'coverage' } = docs; | ||
| const coverageOutputPath = `${coveragePath}/coverage`; | ||
| if (!fs.existsSync(coverageOutputPath)) { | ||
| const pkgPath = `${coveragePath}/package.json`; | ||
| if (fs.existsSync(pkgPath)) { | ||
| const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); | ||
| if (pkg.scripts && pkg.scripts.coverage) { | ||
| logger.info('generating coverage report', coveragePath); | ||
| try { | ||
| await shellExec(`cd ${coveragePath} && npm run coverage`, { silent: true }); | ||
| } catch (err) { | ||
| logger.warn('coverage generation failed (non-fatal), skipping:', err.message); | ||
| return; | ||
| } | ||
| } else if (pkg.scripts && pkg.scripts.test) { | ||
| logger.info('generating coverage via test', coveragePath); | ||
| try { | ||
| await shellExec(`cd ${coveragePath} && npm test`, { silent: true, silentOnError: true }); | ||
| } catch (err) { | ||
| logger.warn('coverage generation failed (non-fatal), skipping:', err.message); | ||
| return; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| if (fs.existsSync(coverageOutputPath) && fs.readdirSync(coverageOutputPath).length > 0) { | ||
| const coverageBuildPath = `${docsDestination}${coverageOutputDir}`; | ||
| fs.mkdirSync(coverageBuildPath, { recursive: true }); | ||
| // Hardhat 3 outputs HTML to coverage/html/; Hardhat 2 / c8 output directly to coverage/ | ||
| const coverageHtmlSubdir = `${coverageOutputPath}/html`; | ||
| if (fs.existsSync(coverageHtmlSubdir) && fs.existsSync(`${coverageHtmlSubdir}/index.html`)) { | ||
| fs.copySync(coverageHtmlSubdir, coverageBuildPath); | ||
| } else { | ||
| fs.copySync(coverageOutputPath, coverageBuildPath); | ||
| } | ||
| logger.warn('build coverage', coverageBuildPath); | ||
| } else { | ||
| logger.warn('no coverage output found, skipping', coverageOutputPath); | ||
| } | ||
| }; | ||
| /** | ||
| * Main function to build all documentation | ||
| * @function buildDocs | ||
| * @memberof clientBuildDocs | ||
| * @param {Object} options - Documentation build options | ||
| * @param {string} options.host - The hostname | ||
| * @param {string} options.path - The base path | ||
| * @param {number} options.port - The port number | ||
| * @param {Object} options.metadata - Metadata for the documentation | ||
| * @param {Array<string>} options.apis - List of API modules to document | ||
| * @param {string} options.publicClientId - Client ID for the public documentation | ||
| * @param {string} options.rootClientPath - Root path for client files | ||
| * @param {Object} options.packageData - Package.json data | ||
| * @param {Object} options.docs - Documentation config from server conf | ||
| */ | ||
| const buildDocs = async ({ | ||
| host, | ||
| path, | ||
| port, | ||
| metadata = {}, | ||
| apis = [], | ||
| publicClientId, | ||
| rootClientPath, | ||
| packageData, | ||
| docs, | ||
| }) => { | ||
| const pathPrefix = path === '/' ? '/' : `${path}/`; | ||
| // TypeDoc output is versioned: served at /docs/engine/{version}/ | ||
| const version = (packageData?.version || '').replace(/^v/, ''); | ||
| const jsDocsDestination = `./public/${host}${pathPrefix}docs/engine/${version}/`; | ||
| // Coverage output at /docs/coverage/ (or /docs/{coverageOutputDir}/) | ||
| const coverageBaseDestination = `./public/${host}${pathPrefix}docs/`; | ||
| await buildJsDocs({ host, path, metadata, publicClientId, docs, docsDestination: jsDocsDestination }); | ||
| await buildCoverage({ docs, docsDestination: coverageBaseDestination }); | ||
| await buildApiDocs({ | ||
| host, | ||
| path, | ||
| port, | ||
| metadata, | ||
| apis, | ||
| publicClientId, | ||
| rootClientPath, | ||
| packageData, | ||
| }); | ||
| }; | ||
| /** | ||
| * Builds Swagger UI customization options by rendering the SwaggerDarkMode SSR body component. | ||
| * Returns the customCss and customJsStr strings required by swagger-ui-express to enable | ||
| * a dark/light mode toggle button with a black/gray gradient dark theme. | ||
| * @function buildSwaggerUiOptions | ||
| * @memberof clientBuildDocs | ||
| * @returns {Promise<{customCss: string, customJsStr: string}>} Swagger UI setup options | ||
| */ | ||
| const buildSwaggerUiOptions = async () => { | ||
| const swaggerDarkMode = await ssrFactory('./src/client/ssr/body/SwaggerDarkMode.js'); | ||
| const { css, js } = swaggerDarkMode(); | ||
| return { customCss: css, customJsStr: js }; | ||
| }; | ||
| export { buildDocs, buildSwaggerUiOptions }; |
| /** | ||
| * Module for live building client-side code | ||
| * @module src/client-builder/client-build-live.js | ||
| * @namespace clientLiveBuild | ||
| */ | ||
| import fs from 'fs-extra'; | ||
| import { Config, loadConf, readConfJson } from '../server/conf.js'; | ||
| import { loggerFactory } from '../server/logger.js'; | ||
| import { buildClient } from './client-build.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| /** | ||
| * @function clientLiveBuild | ||
| * @description Initiates a live build of client-side code. | ||
| * @memberof clientLiveBuild | ||
| */ | ||
| const clientLiveBuild = async () => { | ||
| if (fs.existsSync(`/tmp/client.build.json`)) { | ||
| const deployId = process.argv[2]; | ||
| const subConf = process.argv[3]; | ||
| let clientId = 'default'; | ||
| let host = 'default.net'; | ||
| let path = '/'; | ||
| let baseHost = `${host}${path === '/' ? '' : path}`; | ||
| let views; | ||
| let apiBaseHost; | ||
| let apiBaseProxyPath; | ||
| if ( | ||
| deployId && | ||
| (fs.existsSync(`./engine-private/conf/${deployId}`) || fs.existsSync(`./engine-private/replica/${deployId}`)) | ||
| ) { | ||
| loadConf(deployId, subConf); | ||
| const confClient = readConfJson(deployId, 'client'); | ||
| const confServer = readConfJson(deployId, 'server'); | ||
| host = process.argv[4]; | ||
| path = process.argv[5]; | ||
| clientId = confServer[host][path].client; | ||
| views = confClient[clientId].views; | ||
| baseHost = `${host}${path === '/' ? '' : path}`; | ||
| apiBaseHost = confServer[host][path].apiBaseHost; | ||
| apiBaseProxyPath = confServer[host][path].apiBaseProxyPath; | ||
| } else { | ||
| views = Config.default.client[clientId].views; | ||
| } | ||
| logger.info('Live build config', { | ||
| deployId, | ||
| subConf, | ||
| host, | ||
| path, | ||
| clientId, | ||
| baseHost, | ||
| views: views.length, | ||
| apiBaseHost, | ||
| apiBaseProxyPath, | ||
| }); | ||
| const updates = JSON.parse(fs.readFileSync(`/tmp/client.build.json`, 'utf8')); | ||
| const liveClientBuildPaths = []; | ||
| for (let srcPath of updates) { | ||
| srcPath = srcPath.replaceAll('/', `\\`); | ||
| const srcBuildPath = `./src${srcPath.split('src')[1].replace(/\\/g, '/')}`; | ||
| if ( | ||
| srcPath.split('src')[1].startsWith(`\\client\\components`) || | ||
| srcPath.split('src')[1].startsWith(`\\client\\services`) | ||
| ) { | ||
| const publicBuildPath = `./public/${baseHost}/${srcPath.split('src')[1].slice(8)}`.replace(/\\/g, '/'); | ||
| liveClientBuildPaths.push({ srcBuildPath, publicBuildPath }); | ||
| } else if (srcPath.split('src')[1].startsWith(`\\client\\sw`)) { | ||
| const publicBuildPath = `./public/${baseHost}/sw.js`; | ||
| liveClientBuildPaths.push({ srcBuildPath, publicBuildPath }); | ||
| } else if ( | ||
| srcPath.split('src')[1].startsWith(`\\client\\offline`) && | ||
| srcPath.split('src')[1].startsWith(`index.js`) | ||
| ) { | ||
| const publicBuildPath = `./public/${baseHost}/offline.js`; | ||
| liveClientBuildPaths.push({ srcBuildPath, publicBuildPath }); | ||
| } else if (srcPath.split('src')[1].startsWith(`\\client`) && srcPath.slice(-9) === '.index.js') { | ||
| for (const view of views) { | ||
| const publicBuildPath = `./public/${baseHost}${view.path === '/' ? '' : view.path}/${clientId}.index.js`; | ||
| liveClientBuildPaths.push({ srcBuildPath, publicBuildPath }); | ||
| } | ||
| } else if (srcPath.split('src')[1].startsWith(`\\client\\ssr`)) { | ||
| for (const view of views) { | ||
| const publicBuildPath = `./public/${baseHost}${view.path === '/' ? '' : view.path}/index.html`; | ||
| liveClientBuildPaths.push({ srcBuildPath, publicBuildPath }); | ||
| } | ||
| } | ||
| } | ||
| logger.info('liveClientBuildPaths', liveClientBuildPaths); | ||
| await buildClient({ liveClientBuildPaths, instances: [{ host, path }] }); | ||
| fs.removeSync(`/tmp/client.build.json`); | ||
| } | ||
| }; | ||
| export { clientLiveBuild }; |
| /** | ||
| * Manages the client-side build process, including full builds and incremental builds. | ||
| * @module src/client-builder/client-build.js | ||
| * @namespace clientBuild | ||
| */ | ||
| 'use strict'; | ||
| import fs from 'fs-extra'; | ||
| import { transformClientJs, JSONweb } from './client-formatted.js'; | ||
| import { loggerFactory } from '../server/logger.js'; | ||
| import { | ||
| getCapVariableName, | ||
| newInstance, | ||
| orderArrayFromAttrInt, | ||
| uniqueArray, | ||
| } from '../client/components/core/CommonJs.js'; | ||
| import { readConfJson } from '../server/conf.js'; | ||
| import { minify } from 'html-minifier-terser'; | ||
| import AdmZip from 'adm-zip'; | ||
| import * as dir from 'path'; | ||
| import { shellExec } from '../server/process.js'; | ||
| import { SitemapStream, streamToPromise } from 'sitemap'; | ||
| import { Readable } from 'stream'; | ||
| import { buildIcons } from './client-icons.js'; | ||
| import Underpost from '../index.js'; | ||
| import { buildDocs } from './client-build-docs.js'; | ||
| import { ssrFactory } from './ssr.js'; | ||
| // Static Site Generation (SSG) | ||
| /** | ||
| * Recursively copies files from source to destination, but only files that don't exist in destination. | ||
| * @function copyNonExistingFiles | ||
| * @param {string} src - Source directory path | ||
| * @param {string} dest - Destination directory path | ||
| * @returns {void} | ||
| * @memberof clientBuild | ||
| */ | ||
| const copyNonExistingFiles = (src, dest) => { | ||
| if (dir.basename(src) === '.git') return; | ||
| // Ensure source exists | ||
| if (!fs.existsSync(src)) { | ||
| throw new Error(`Source directory does not exist: ${src}`); | ||
| } | ||
| // Get stats for source | ||
| const srcStats = fs.statSync(src); | ||
| // If source is a file, copy only if it doesn't exist in destination | ||
| if (srcStats.isFile()) { | ||
| if (!fs.existsSync(dest)) { | ||
| const destDir = dir.dirname(dest); | ||
| fs.mkdirSync(destDir, { recursive: true }); | ||
| fs.copyFileSync(src, dest); | ||
| } | ||
| return; | ||
| } | ||
| // If source is a directory, create destination if it doesn't exist | ||
| if (srcStats.isDirectory()) { | ||
| if (!fs.existsSync(dest)) { | ||
| fs.mkdirSync(dest, { recursive: true }); | ||
| } | ||
| // Read all items in source directory | ||
| const items = fs.readdirSync(src); | ||
| // Recursively process each item | ||
| for (const item of items) { | ||
| const srcPath = dir.join(src, item); | ||
| const destPath = dir.join(dest, item); | ||
| copyNonExistingFiles(srcPath, destPath); | ||
| } | ||
| } | ||
| }; | ||
| const splitFileByMb = ({ filePath, partSizeMb, logger }) => { | ||
| const partSizeBytes = Math.floor(Number(partSizeMb) * 1024 * 1024); | ||
| if (!Number.isFinite(partSizeBytes) || partSizeBytes <= 0) { | ||
| throw new Error(`Invalid --split value: ${partSizeMb}`); | ||
| } | ||
| // Clean ALL stale part files (any naming variant) before writing new ones | ||
| const zipDir = dir.dirname(filePath); | ||
| const zipBase = dir.basename(filePath); | ||
| if (fs.existsSync(zipDir)) { | ||
| fs.readdirSync(zipDir) | ||
| .filter((name) => name.startsWith(`${zipBase}.part`) || name.startsWith(`${zipBase}-part`)) | ||
| .forEach((name) => fs.removeSync(dir.join(zipDir, name))); | ||
| } | ||
| const fileBuffer = fs.readFileSync(filePath); | ||
| const partPaths = []; | ||
| for (let offset = 0, partIndex = 0; offset < fileBuffer.length; offset += partSizeBytes, partIndex++) { | ||
| const partBuffer = fileBuffer.subarray(offset, offset + partSizeBytes); | ||
| const partPath = `${filePath}.part${String(partIndex + 1).padStart(3, '0')}`; | ||
| fs.writeFileSync(partPath, partBuffer); | ||
| partPaths.push(partPath); | ||
| } | ||
| logger.warn('split zip', { | ||
| filePath, | ||
| partSizeMb: Number(partSizeMb), | ||
| parts: partPaths.length, | ||
| }); | ||
| return partPaths; | ||
| }; | ||
| const getZipPartPaths = (zipPath) => { | ||
| const zipDir = dir.dirname(zipPath); | ||
| const zipBase = dir.basename(zipPath); | ||
| const partPrefixDot = `${zipBase}.part`; | ||
| const partPrefixDash = `${zipBase}-part`; | ||
| const parsePartIndex = (rawSuffix) => { | ||
| // Strip optional .zip suffix added by pull/download (e.g. '001.zip' → '001') | ||
| const digits = rawSuffix.replace(/\.zip$/i, ''); | ||
| return /^\d+$/.test(digits) ? Number(digits) : NaN; | ||
| }; | ||
| const getPartIndex = (name) => { | ||
| if (name.startsWith(partPrefixDot)) return parsePartIndex(name.slice(partPrefixDot.length)); | ||
| if (name.startsWith(partPrefixDash)) return parsePartIndex(name.slice(partPrefixDash.length)); | ||
| return NaN; | ||
| }; | ||
| return fs | ||
| .readdirSync(zipDir) | ||
| .filter((name) => Number.isFinite(getPartIndex(name))) | ||
| .sort((a, b) => getPartIndex(a) - getPartIndex(b)) | ||
| .map((name) => dir.join(zipDir, name)); | ||
| }; | ||
| const resolveClientBuildZip = (buildPrefix) => { | ||
| const normalizedPrefix = buildPrefix.replace(/\.zip(?:[.-]part\d+|[.-]part\*)?$/, '').replace(/[.-]part\*$/, ''); | ||
| const candidatePrefixes = uniqueArray([ | ||
| normalizedPrefix, | ||
| normalizedPrefix.endsWith('-') ? normalizedPrefix : `${normalizedPrefix}-`, | ||
| ]); | ||
| for (const prefix of candidatePrefixes) { | ||
| const zipPath = `${prefix}.zip`; | ||
| if (fs.existsSync(zipPath)) { | ||
| return { | ||
| buildPrefix: prefix, | ||
| zipPath, | ||
| partPaths: [], | ||
| }; | ||
| } | ||
| const partPaths = fs.existsSync(dir.dirname(zipPath)) ? getZipPartPaths(zipPath) : []; | ||
| if (partPaths.length > 0) { | ||
| return { | ||
| buildPrefix: prefix, | ||
| zipPath, | ||
| partPaths, | ||
| }; | ||
| } | ||
| } | ||
| const searchDir = dir.dirname(normalizedPrefix); | ||
| const prefixBase = dir.basename(normalizedPrefix); | ||
| if (!fs.existsSync(searchDir)) { | ||
| throw new Error(`Build directory not found: ${searchDir}`); | ||
| } | ||
| const matches = uniqueArray( | ||
| fs | ||
| .readdirSync(searchDir) | ||
| .filter((name) => name.startsWith(prefixBase) && /\.zip(?:[.-]part\d+)?$/.test(name)) | ||
| .map((name) => name.replace(/[.-]part\d+$/, '')), | ||
| ); | ||
| if (matches.length === 1) { | ||
| const zipPath = dir.join(searchDir, matches[0]); | ||
| const partPaths = getZipPartPaths(zipPath); | ||
| return { | ||
| buildPrefix: zipPath.replace(/\.zip$/, ''), | ||
| zipPath, | ||
| partPaths, | ||
| }; | ||
| } | ||
| if (matches.length > 1) { | ||
| throw new Error( | ||
| `Multiple build zip matches found for '${buildPrefix}': ${matches.join(', ')}. Use a more specific --unzip path.`, | ||
| ); | ||
| } | ||
| throw new Error(`No build zip or split parts found for: ${buildPrefix}`); | ||
| }; | ||
| /** | ||
| * Merges split ZIP parts back into a single ZIP file. | ||
| * @param {object} options | ||
| * @param {string} options.buildPrefix - The build prefix path (e.g. build/underpost.net/underpost.net-). | ||
| * @param {object} options.logger - Logger instance. | ||
| * @returns {{ zipPath: string, partPaths: string[], mergedBytes: number }} | ||
| */ | ||
| const mergeClientBuildZip = ({ buildPrefix, logger }) => { | ||
| // Normalize to get the zip path, then look for parts directly (bypassing resolveClientBuildZip | ||
| // which prefers an existing monolithic zip over parts). | ||
| const normalizedPrefix = buildPrefix.replace(/\.zip(?:[.-]part\d+)?$/, '').replace(/[-.]$/, '') + '-'; | ||
| const candidatePrefixes = uniqueArray([buildPrefix, buildPrefix.endsWith('-') ? buildPrefix : `${buildPrefix}-`]); | ||
| let zipPath; | ||
| let partPaths = []; | ||
| for (const prefix of candidatePrefixes) { | ||
| const candidate = prefix.endsWith('.zip') ? prefix : `${prefix}.zip`; | ||
| const parts = getZipPartPaths(candidate); | ||
| if (parts.length > 0) { | ||
| zipPath = candidate; | ||
| partPaths = parts; | ||
| break; | ||
| } | ||
| } | ||
| if (partPaths.length === 0) { | ||
| // Fall back to resolveClientBuildZip for the zipPath | ||
| const resolved = resolveClientBuildZip(buildPrefix); | ||
| zipPath = resolved.zipPath; | ||
| logger.warn('merge-zip: no split parts found, nothing to merge', { buildPrefix, zipPath }); | ||
| return { zipPath, partPaths, mergedBytes: 0 }; | ||
| } | ||
| // For each part, extract raw bytes: if the part file is a Cloudinary wrapper zip | ||
| // (downloaded via pull without --omit-unzip or with --omit-unzip keeping the .zip), | ||
| // extract the inner entry rather than using the wrapper bytes. | ||
| const readPartBytes = (partPath) => { | ||
| const rawBytes = fs.readFileSync(partPath); | ||
| // Check for ZIP magic bytes (PK\x03\x04) | ||
| if (rawBytes[0] === 0x50 && rawBytes[1] === 0x4b && rawBytes[2] === 0x03 && rawBytes[3] === 0x04) { | ||
| try { | ||
| const wrapperZip = new AdmZip(rawBytes); | ||
| const entries = wrapperZip.getEntries(); | ||
| // The inner entry is the original part file (without the outer .zip wrapper) | ||
| const partBase = dir.basename(partPath).replace(/\.zip$/i, ''); | ||
| const entry = entries.find((e) => e.entryName === partBase || e.entryName.endsWith('/' + partBase)); | ||
| if (entry) { | ||
| return entry.getData(); | ||
| } | ||
| // Fallback: single-entry archive | ||
| if (entries.length === 1) { | ||
| return entries[0].getData(); | ||
| } | ||
| } catch (_) { | ||
| // Not a valid zip or extraction failed — use raw bytes | ||
| } | ||
| } | ||
| return rawBytes; | ||
| }; | ||
| const mergedBuffer = Buffer.concat(partPaths.map(readPartBytes)); | ||
| fs.writeFileSync(zipPath, mergedBuffer); | ||
| logger.warn('merge-zip: merged split parts into zip', { | ||
| zipPath, | ||
| parts: partPaths.length, | ||
| mergedBytes: mergedBuffer.length, | ||
| }); | ||
| return { zipPath, partPaths, mergedBytes: mergedBuffer.length }; | ||
| }; | ||
| const unzipClientBuild = ({ buildPrefix, logger }) => { | ||
| const { zipPath, partPaths, buildPrefix: resolvedBuildPrefix } = resolveClientBuildZip(buildPrefix); | ||
| const outputPath = resolvedBuildPrefix.replace(/-$/, ''); | ||
| fs.removeSync(outputPath); | ||
| fs.mkdirSync(outputPath, { recursive: true }); | ||
| const zip = | ||
| partPaths.length > 0 | ||
| ? new AdmZip(Buffer.concat(partPaths.map((partPath) => fs.readFileSync(partPath)))) | ||
| : new AdmZip(zipPath); | ||
| zip.extractAllTo(outputPath, true); | ||
| logger.warn('unzip build', { | ||
| source: partPaths.length > 0 ? partPaths : [zipPath], | ||
| outputPath, | ||
| splitParts: partPaths.length, | ||
| }); | ||
| return { | ||
| outputPath, | ||
| zipPath, | ||
| partPaths, | ||
| }; | ||
| }; | ||
| /** @type {string} Default XSL sitemap template used when no `sitemap` source file exists in the public directory. */ | ||
| const defaultSitemapXsl = `<?xml version="1.0" encoding="UTF-8"?> | ||
| <xsl:stylesheet version="1.0" | ||
| xmlns:html="http://www.w3.org/TR/REC-html40" | ||
| xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" | ||
| xmlns:sitemap="http://www.sitemaps.org/schemas/sitemap/0.9" | ||
| xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> | ||
| <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" /> | ||
| <xsl:template match="/"> | ||
| <html xmlns="http://www.w3.org/1999/xhtml"> | ||
| <head> | ||
| <title>XML Sitemap</title> | ||
| <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | ||
| <style type="text/css"> | ||
| body { | ||
| font-family: sans-serif; | ||
| font-size: 16px; | ||
| color: #242628; | ||
| } | ||
| a { | ||
| color: #000; | ||
| text-decoration: none; | ||
| } | ||
| a:hover { | ||
| text-decoration: underline; | ||
| } | ||
| table { | ||
| border: none; | ||
| border-collapse: collapse; | ||
| width: 100% | ||
| } | ||
| th { | ||
| text-align: left; | ||
| padding-right: 30px; | ||
| font-size: 11px; | ||
| } | ||
| thead th { | ||
| border-bottom: 1px solid #7d878a; | ||
| cursor: pointer; | ||
| } | ||
| td { | ||
| font-size:11px; | ||
| padding: 5px; | ||
| } | ||
| tr:nth-child(odd) td { | ||
| background-color: rgba(0,0,0,0.04); | ||
| } | ||
| tr:hover td { | ||
| background-color: #e2edf2; | ||
| } | ||
| #content { | ||
| margin: 0 auto; | ||
| padding: 2% 5%; | ||
| max-width: 800px; | ||
| } | ||
| .desc { | ||
| margin: 18px 3px; | ||
| line-height: 1.2em; | ||
| } | ||
| .desc a { | ||
| color: #5ba4e5; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="content"> | ||
| <h1>XML Sitemap</h1> | ||
| <p class="desc"> This is a sitemap generated by <a | ||
| href="{{web-url}}">{{web-url}}</a> | ||
| </p> | ||
| <xsl:if test="count(sitemap:sitemapindex/sitemap:sitemap) > 0"> | ||
| <table id="sitemap" cellpadding="3"> | ||
| <thead> | ||
| <tr> | ||
| <th width="75%">Sitemap</th> | ||
| <th width="25%">Last Modified</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <xsl:for-each select="sitemap:sitemapindex/sitemap:sitemap"> | ||
| <xsl:variable name="sitemapURL"> | ||
| <xsl:value-of select="sitemap:loc" /> | ||
| </xsl:variable> | ||
| <tr> | ||
| <td> | ||
| <a href="{$sitemapURL}"> | ||
| <xsl:value-of select="sitemap:loc" /> | ||
| </a> | ||
| </td> | ||
| <td> | ||
| <xsl:value-of | ||
| select="concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)))" /> | ||
| </td> | ||
| </tr> | ||
| </xsl:for-each> | ||
| </tbody> | ||
| </table> | ||
| </xsl:if> | ||
| <xsl:if test="count(sitemap:sitemapindex/sitemap:sitemap) < 1"> | ||
| <p class="desc"> | ||
| <a href="{{web-url}}sitemap.xml" class="back-link">← Back to index</a> | ||
| </p> | ||
| <table | ||
| id="sitemap" cellpadding="3"> | ||
| <thead> | ||
| <tr> | ||
| <th width="70%">URL (<xsl:value-of | ||
| select="count(sitemap:urlset/sitemap:url)" /> total)</th> | ||
| <th width="15%">Images</th> | ||
| <th title="Last Modification Time" width="15%">Last Modified</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <xsl:variable name="lower" select="'abcdefghijklmnopqrstuvwxyz'" /> | ||
| <xsl:variable name="upper" select="'ABCDEFGHIJKLMNOPQRSTUVWXYZ'" /> | ||
| <xsl:for-each select="sitemap:urlset/sitemap:url"> | ||
| <tr> | ||
| <td> | ||
| <xsl:variable name="itemURL"> | ||
| <xsl:value-of select="sitemap:loc" /> | ||
| </xsl:variable> | ||
| <a href="{$itemURL}"> | ||
| <xsl:value-of select="sitemap:loc" /> | ||
| </a> | ||
| </td> | ||
| <td> | ||
| <xsl:value-of select="count(image:image)" /> | ||
| </td> | ||
| <td> | ||
| <xsl:value-of | ||
| select="concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)))" /> | ||
| </td> | ||
| </tr> | ||
| </xsl:for-each> | ||
| </tbody> | ||
| </table> | ||
| <p | ||
| class="desc"> | ||
| <a href="{{web-url}}sitemap.xml" class="back-link">← Back to index</a> | ||
| </p> | ||
| </xsl:if> | ||
| </div> | ||
| </body> | ||
| </html> | ||
| </xsl:template> | ||
| </xsl:stylesheet>`; | ||
| /** | ||
| * @async | ||
| * @function buildClient | ||
| * @memberof clientBuild | ||
| * @param {Object} options - Options for the build process. | ||
| * @param {string} options.deployId - The deployment ID for which to build the client. | ||
| * @param {Array} options.liveClientBuildPaths - List of paths to build incrementally. | ||
| * @param {Array} options.instances - List of instances to build. | ||
| * @param {boolean} options.buildZip - Whether to create zip files of the builds. | ||
| * @param {string|number} options.split - Optional zip split size in MB. | ||
| * @param {boolean} options.fullBuild - Whether to perform a full build. | ||
| * @param {boolean} options.iconsBuild - Whether to build icons. | ||
| * @returns {Promise<void>} - Promise that resolves when the build is complete. | ||
| * @throws {Error} - If the build fails. | ||
| * @memberof clientBuild | ||
| */ | ||
| const buildClient = async ( | ||
| options = { | ||
| deployId: '', | ||
| liveClientBuildPaths: [], | ||
| instances: [], | ||
| buildZip: false, | ||
| split: '', | ||
| fullBuild: false, | ||
| iconsBuild: false, | ||
| }, | ||
| ) => { | ||
| const logger = loggerFactory(import.meta); | ||
| const deployId = options.deployId || process.env.DEPLOY_ID; | ||
| const confClient = readConfJson(deployId, 'client'); | ||
| const confServer = readConfJson(deployId, 'server', { loadReplicas: true }); | ||
| const confSSR = readConfJson(deployId, 'ssr'); | ||
| const packageData = JSON.parse(fs.readFileSync(`./package.json`, 'utf8')); | ||
| const acmeChallengePath = `/.well-known/acme-challenge`; | ||
| const publicPath = `./public`; | ||
| /** | ||
| * @async | ||
| * @function buildAcmeChallengePath | ||
| * @memberof clientBuild | ||
| * @param {string} acmeChallengeFullPath - Full path to the acme-challenge directory. | ||
| * @returns {void} | ||
| * @throws {Error} - If the directory cannot be created. | ||
| * @memberof clientBuild | ||
| */ | ||
| const buildAcmeChallengePath = (acmeChallengeFullPath = '') => { | ||
| fs.mkdirSync(acmeChallengeFullPath, { | ||
| recursive: true, | ||
| }); | ||
| fs.writeFileSync(`${acmeChallengeFullPath}/.gitkeep`, '', 'utf8'); | ||
| }; | ||
| /** | ||
| * @async | ||
| * @function fullBuild | ||
| * @memberof clientBuild | ||
| * @param {Object} options - Options for the full build process. | ||
| * @param {string} options.path - Path to the client directory. | ||
| * @param {Object} options.logger - Logger instance. | ||
| * @param {string} options.client - Client name. | ||
| * @param {Object} options.db - Database configuration. | ||
| * @param {Array} options.dists - List of distributions to build. | ||
| * @param {string} options.rootClientPath - Full path to the client directory. | ||
| * @param {string} options.acmeChallengeFullPath - Full path to the acme-challenge directory. | ||
| * @param {string} options.publicClientId - Public client ID. | ||
| * @param {boolean} options.iconsBuild - Whether to build icons. | ||
| * @param {Object} options.metadata - Metadata for the client. | ||
| * @param {boolean} options.publicCopyNonExistingFiles - Whether to copy non-existing files from public directory. | ||
| * @returns {Promise<void>} - Promise that resolves when the full build is complete. | ||
| * @throws {Error} - If the full build fails. | ||
| * @memberof clientBuild | ||
| */ | ||
| const fullBuild = async ({ | ||
| path, | ||
| logger, | ||
| client, | ||
| db, | ||
| dists, | ||
| rootClientPath, | ||
| acmeChallengeFullPath, | ||
| publicClientId, | ||
| iconsBuild, | ||
| metadata, | ||
| publicCopyNonExistingFiles, | ||
| }) => { | ||
| logger.warn('Full build', rootClientPath); | ||
| buildAcmeChallengePath(acmeChallengeFullPath); | ||
| fs.removeSync(rootClientPath); | ||
| if (fs.existsSync(`./src/client/public/${publicClientId}`)) { | ||
| if (iconsBuild === true) await buildIcons({ publicClientId, metadata }); | ||
| fs.copySync(`./src/client/public/${publicClientId}`, rootClientPath, { | ||
| filter: (sourcePath) => !sourcePath.split(dir.sep).includes('.git'), | ||
| }); | ||
| } else if (fs.existsSync(`./engine-private/src/client/public/${publicClientId}`)) { | ||
| fs.copySync(`./engine-private/src/client/public/${publicClientId}`, rootClientPath, { | ||
| filter: (sourcePath) => !sourcePath.split(dir.sep).includes('.git'), | ||
| }); | ||
| } | ||
| if (dists) | ||
| for (const dist of dists) { | ||
| if ('folder' in dist) { | ||
| if (fs.statSync(dist.folder).isDirectory()) { | ||
| fs.mkdirSync(`${rootClientPath}${dist.public_folder}`, { recursive: true }); | ||
| fs.copySync(dist.folder, `${rootClientPath}${dist.public_folder}`); | ||
| } else { | ||
| const folder = dist.public_folder.split('/'); | ||
| folder.pop(); | ||
| fs.mkdirSync(`${rootClientPath}${folder.join('/')}`, { recursive: true }); | ||
| fs.copyFileSync(dist.folder, `${rootClientPath}${dist.public_folder}`); | ||
| } | ||
| } | ||
| if ('styles' in dist) { | ||
| fs.mkdirSync(`${rootClientPath}${dist.public_styles_folder}`, { recursive: true }); | ||
| fs.copySync(dist.styles, `${rootClientPath}${dist.public_styles_folder}`); | ||
| } | ||
| } | ||
| if (publicCopyNonExistingFiles) | ||
| copyNonExistingFiles(`./src/client/public/${publicCopyNonExistingFiles}`, rootClientPath); | ||
| }; | ||
| // { srcBuildPath, publicBuildPath } | ||
| const enableLiveRebuild = | ||
| options && options.liveClientBuildPaths && options.liveClientBuildPaths.length > 0 ? true : false; | ||
| const isDevelopment = process.env.NODE_ENV === 'development'; | ||
| let currentPort = parseInt(process.env.PORT) + 1; | ||
| for (const host of Object.keys(confServer)) { | ||
| const paths = orderArrayFromAttrInt(Object.keys(confServer[host]), 'length', 'asc'); | ||
| for (const path of paths) { | ||
| if ( | ||
| options && | ||
| options.instances && | ||
| options.instances.length > 0 && | ||
| !options.instances.find((i) => i.path === path && i.host === host) | ||
| ) | ||
| continue; | ||
| const { | ||
| runtime, | ||
| client, | ||
| directory, | ||
| disabledRebuild, | ||
| db, | ||
| redirect, | ||
| apis, | ||
| apiBaseProxyPath, | ||
| apiBaseHost, | ||
| ttiLoadTimeLimit, | ||
| singleReplica, | ||
| docs, | ||
| } = confServer[host][path]; | ||
| if (singleReplica) continue; | ||
| if (!confClient[client]) confClient[client] = {}; | ||
| const { components, dists, views, services, metadata, publicRef, publicCopyNonExistingFiles } = | ||
| confClient[client]; | ||
| let backgroundImage; | ||
| if (metadata) { | ||
| backgroundImage = metadata.backgroundImage; | ||
| if (metadata.thumbnail) metadata.thumbnail = `${path === '/' ? path : `${path}/`}${metadata.thumbnail}`; | ||
| } | ||
| const rootClientPath = directory ? directory : `${publicPath}/${host}${path}`; | ||
| const port = newInstance(currentPort); | ||
| const publicClientId = publicRef ? publicRef : client; | ||
| const fullBuildEnabled = options.fullBuild && !enableLiveRebuild; | ||
| // const baseHost = process.env.NODE_ENV === 'production' ? `https://${host}` : `http://localhost:${port}`; | ||
| const baseHost = process.env.NODE_ENV === 'production' ? `https://${host}` : ``; | ||
| const minifyBuild = process.env.NODE_ENV === 'production'; | ||
| // ''; // process.env.NODE_ENV === 'production' ? `https://${host}` : ``; | ||
| currentPort++; | ||
| const acmeChallengeFullPath = directory | ||
| ? `${directory}${acmeChallengePath}` | ||
| : `${publicPath}/${host}${acmeChallengePath}`; | ||
| if (!enableLiveRebuild) buildAcmeChallengePath(acmeChallengeFullPath); | ||
| if (redirect || disabledRebuild) continue; | ||
| if (fullBuildEnabled) | ||
| await fullBuild({ | ||
| path, | ||
| logger, | ||
| client, | ||
| db, | ||
| dists, | ||
| rootClientPath, | ||
| acmeChallengeFullPath, | ||
| publicClientId, | ||
| iconsBuild: options.iconsBuild, | ||
| metadata, | ||
| publicCopyNonExistingFiles, | ||
| }); | ||
| if (components) | ||
| for (const module of Object.keys(components)) { | ||
| if (!fs.existsSync(`${rootClientPath}/components/${module}`)) | ||
| fs.mkdirSync(`${rootClientPath}/components/${module}`, { recursive: true }); | ||
| for (const component of components[module]) { | ||
| const jsSrcPath = `./src/client/components/${module}/${component}.js`; | ||
| const jsPublicPath = `${rootClientPath}/components/${module}/${component}.js`; | ||
| if (enableLiveRebuild && !options.liveClientBuildPaths.find((p) => p.srcBuildPath === jsSrcPath)) continue; | ||
| const jsSrc = await transformClientJs(jsSrcPath, { | ||
| dists, | ||
| proxyPath: path, | ||
| basePath: 'components', | ||
| module, | ||
| baseHost, | ||
| minify: minifyBuild, | ||
| }); | ||
| fs.writeFileSync(jsPublicPath, jsSrc, 'utf8'); | ||
| } | ||
| } | ||
| if (services) { | ||
| for (const module of services) { | ||
| if (!fs.existsSync(`${rootClientPath}/services/${module}`)) | ||
| fs.mkdirSync(`${rootClientPath}/services/${module}`, { recursive: true }); | ||
| const moduleDir = `./src/client/services/${module}`; | ||
| if (!fs.existsSync(moduleDir)) continue; | ||
| const serviceFiles = fs | ||
| .readdirSync(moduleDir) | ||
| .filter((name) => name.endsWith('.service.js') || name.endsWith('.management.js')) | ||
| .sort(); | ||
| for (const serviceFile of serviceFiles) { | ||
| const jsSrcPath = `${moduleDir}/${serviceFile}`; | ||
| const jsPublicPath = `${rootClientPath}/services/${module}/${serviceFile}`; | ||
| if (enableLiveRebuild && !options.liveClientBuildPaths.find((p) => p.srcBuildPath === jsSrcPath)) continue; | ||
| const jsSrc = await transformClientJs(jsSrcPath, { | ||
| dists, | ||
| proxyPath: path, | ||
| basePath: 'services', | ||
| module, | ||
| baseHost, | ||
| minify: minifyBuild, | ||
| }); | ||
| fs.writeFileSync(jsPublicPath, jsSrc, 'utf8'); | ||
| } | ||
| // Auto-build guest module files when user module is processed | ||
| if (module === 'user') { | ||
| const guestModuleDir = './src/client/services/user'; | ||
| const guestServicePath = `${guestModuleDir}/guest.service.js`; | ||
| if (fs.existsSync(guestServicePath)) { | ||
| if (!fs.existsSync(`${rootClientPath}/services/user`)) | ||
| fs.mkdirSync(`${rootClientPath}/services/user`, { recursive: true }); | ||
| const guestJsPublicPath = `${rootClientPath}/services/user/guest.service.js`; | ||
| if (!enableLiveRebuild || options.liveClientBuildPaths.find((p) => p.srcBuildPath === guestServicePath)) { | ||
| const guestJsSrc = await transformClientJs(guestServicePath, { | ||
| dists, | ||
| proxyPath: path, | ||
| basePath: 'services', | ||
| module: 'user', | ||
| baseHost, | ||
| minify: minifyBuild, | ||
| }); | ||
| fs.writeFileSync(guestJsPublicPath, guestJsSrc, 'utf8'); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| const buildId = `${client}.index`; | ||
| const siteMapLinks = []; | ||
| const ssrPath = path === '/' ? path : `${path}/`; | ||
| const Render = await ssrFactory(); | ||
| const swSrcPath = `./src/client/sw/core.sw.js`; | ||
| const swPublicPath = `${rootClientPath}/sw.js`; | ||
| const swShouldRebuild = | ||
| views && !(enableLiveRebuild && !options.liveClientBuildPaths.find((p) => p.srcBuildPath === swSrcPath)); | ||
| // Transformed SW JS is held in memory; it gets prepended with renderPayload | ||
| // and written once below, after PRE_CACHED_RESOURCES are known. | ||
| let swTransformedJs = ''; | ||
| if (swShouldRebuild) { | ||
| swTransformedJs = await transformClientJs(swSrcPath, { | ||
| dists, | ||
| proxyPath: path, | ||
| baseHost, | ||
| minify: minifyBuild, | ||
| externalizeBareImports: false, | ||
| }); | ||
| } | ||
| if (views) { | ||
| if ( | ||
| !( | ||
| enableLiveRebuild && | ||
| !options.liveClientBuildPaths.find( | ||
| (p) => p.srcBuildPath.startsWith(`./src/client/ssr`) || p.srcBuildPath.slice(-9) === '.index.js', | ||
| ) | ||
| ) | ||
| ) | ||
| for (const view of views) { | ||
| const buildPath = `${ | ||
| rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath | ||
| }${view.path === '/' ? view.path : `${view.path}/`}`; | ||
| if (!fs.existsSync(buildPath)) fs.mkdirSync(buildPath, { recursive: true }); | ||
| logger.info('View build', buildPath); | ||
| const jsSrc = await transformClientJs(`./src/client/${view.client}.index.js`, { | ||
| dists, | ||
| proxyPath: path, | ||
| baseHost, | ||
| minify: minifyBuild, | ||
| }); | ||
| fs.writeFileSync(`${buildPath}${buildId}.js`, jsSrc, 'utf8'); | ||
| const title = metadata.title ? metadata.title : title; | ||
| const canonicalURL = `https://${host}${path}${ | ||
| view.path === '/' ? (path === '/' ? '' : '/') : path === '/' ? `${view.path.slice(1)}/` : `${view.path}/` | ||
| }`; | ||
| let ssrHeadComponents = ``; | ||
| let ssrBodyComponents = ``; | ||
| if ('ssr' in view) { | ||
| // https://metatags.io/ | ||
| if (process.env.NODE_ENV === 'production' && !confSSR[view.ssr].head.includes('Production')) | ||
| confSSR[view.ssr].head.unshift('Production'); | ||
| for (const ssrHeadComponent of confSSR[view.ssr].head) { | ||
| const SrrComponent = await ssrFactory(`./src/client/ssr/head/${ssrHeadComponent}.js`); | ||
| switch (ssrHeadComponent) { | ||
| case 'Pwa': | ||
| const validPwaBuild = | ||
| metadata && | ||
| fs.existsSync(`./src/client/public/${publicClientId}/browserconfig.xml`) && | ||
| fs.existsSync(`./src/client/public/${publicClientId}/site.webmanifest`); | ||
| if (validPwaBuild) { | ||
| // build webmanifest | ||
| const webmanifestJson = JSON.parse( | ||
| fs.readFileSync(`./src/client/public/${publicClientId}/site.webmanifest`, 'utf8'), | ||
| ); | ||
| if (metadata.title) { | ||
| webmanifestJson.name = metadata.title; | ||
| webmanifestJson.short_name = metadata.title; | ||
| } | ||
| if (metadata.description) { | ||
| webmanifestJson.description = metadata.description; | ||
| } | ||
| if (metadata.themeColor) { | ||
| webmanifestJson.theme_color = metadata.themeColor; | ||
| webmanifestJson.background_color = metadata.themeColor; | ||
| } | ||
| fs.writeFileSync( | ||
| `${buildPath}site.webmanifest`, | ||
| JSON.stringify(webmanifestJson, null, 4).replaceAll(`: "/`, `: "${ssrPath}`), | ||
| 'utf8', | ||
| ); | ||
| // build browserconfig | ||
| fs.writeFileSync( | ||
| `${buildPath}browserconfig.xml`, | ||
| fs | ||
| .readFileSync(`./src/client/public/${publicClientId}/browserconfig.xml`, 'utf8') | ||
| .replaceAll( | ||
| `<TileColor></TileColor>`, | ||
| metadata.themeColor | ||
| ? `<TileColor>${metadata.themeColor}</TileColor>` | ||
| : `<TileColor>#e0e0e0</TileColor>`, | ||
| ) | ||
| .replaceAll(`src="/`, `src="${ssrPath}`), | ||
| 'utf8', | ||
| ); | ||
| // Android play store example: | ||
| // | ||
| // "related_applications": [ | ||
| // { | ||
| // "platform": "play", | ||
| // "url": "https://play.google.com/store/apps/details?id=cheeaun.hackerweb" | ||
| // } | ||
| // ], | ||
| // "prefer_related_applications": true | ||
| } | ||
| if (validPwaBuild) ssrHeadComponents += SrrComponent({ title, ssrPath, canonicalURL, ...metadata }); | ||
| break; | ||
| case 'Seo': | ||
| if (metadata) { | ||
| ssrHeadComponents += SrrComponent({ title, ssrPath, canonicalURL, ...metadata }); | ||
| } | ||
| break; | ||
| case 'Microdata': | ||
| if ( | ||
| fs.existsSync(`./src/client/public/${publicClientId}/microdata.json`) // && | ||
| // path === '/' && | ||
| // view.path === '/' | ||
| ) { | ||
| const microdata = JSON.parse( | ||
| fs.readFileSync(`./src/client/public/${publicClientId}/microdata.json`, 'utf8'), | ||
| ); | ||
| ssrHeadComponents += SrrComponent({ microdata }); | ||
| } | ||
| break; | ||
| default: | ||
| ssrHeadComponents += SrrComponent({ ssrPath, host, path }); | ||
| break; | ||
| } | ||
| } | ||
| for (const ssrBodyComponent of confSSR[view.ssr].body) { | ||
| const SrrComponent = await ssrFactory(`./src/client/ssr/body/${ssrBodyComponent}.js`); | ||
| ssrBodyComponents += SrrComponent({ | ||
| ...metadata, | ||
| ssrPath, | ||
| host, | ||
| path, | ||
| ttiLoadTimeLimit, | ||
| version: Underpost.version, | ||
| backgroundImage: backgroundImage ? (path === '/' ? path : `${path}/`) + backgroundImage : undefined, | ||
| }); | ||
| } | ||
| } | ||
| /** @type {import('sitemap').SitemapItem} */ | ||
| const siteMapLink = { | ||
| url: `${path === '/' ? '' : path}${view.path}`, | ||
| changefreq: 'daily', | ||
| priority: 0.8, | ||
| }; | ||
| siteMapLinks.push(siteMapLink); | ||
| const htmlSrc = Render({ | ||
| title, | ||
| buildId, | ||
| ssrPath, | ||
| ssrHeadComponents, | ||
| ssrBodyComponents, | ||
| renderPayload: { | ||
| apiBaseProxyPath, | ||
| apiBaseHost, | ||
| apiBasePath: process.env.BASE_API, | ||
| version: Underpost.version, | ||
| ...(isDevelopment ? { dev: true } : undefined), | ||
| }, | ||
| renderApi: { | ||
| JSONweb, | ||
| }, | ||
| }); | ||
| fs.writeFileSync( | ||
| `${buildPath}index.html`, | ||
| minifyBuild | ||
| ? await minify(htmlSrc, { | ||
| minifyCSS: true, | ||
| minifyJS: true, | ||
| collapseBooleanAttributes: true, | ||
| collapseInlineTagWhitespace: true, | ||
| collapseWhitespace: true, | ||
| }) | ||
| : htmlSrc, | ||
| 'utf8', | ||
| ); | ||
| } | ||
| } | ||
| if (!enableLiveRebuild && siteMapLinks.length > 0) { | ||
| const hasSitemapTemplate = fs.existsSync(`${rootClientPath}/sitemap`); | ||
| const sitemapBaseUrl = `https://${host}${path === '/' ? '' : path}`; | ||
| // Create a stream to write to — omit xslUrl so we can inject a relative href below | ||
| /** @type {import('sitemap').SitemapStreamOptions} */ | ||
| const sitemapOptions = { hostname: `https://${host}` }; | ||
| const siteMapStream = new SitemapStream(sitemapOptions); | ||
| let siteMapSrc = await new Promise((resolve) => | ||
| streamToPromise(Readable.from(siteMapLinks).pipe(siteMapStream)).then((data) => resolve(data.toString())), | ||
| ); | ||
| // Inject a relative xml-stylesheet PI so the XSL loads from the same origin | ||
| // (works on both http://localhost:<port> and https://production-host) | ||
| siteMapSrc = siteMapSrc.replace( | ||
| '<?xml version="1.0" encoding="UTF-8"?>', | ||
| '<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="sitemap.xsl"?>', | ||
| ); | ||
| // Return a promise that resolves with your XML string | ||
| fs.writeFileSync(`${rootClientPath}/sitemap.xml`, siteMapSrc, 'utf8'); | ||
| // Generate XSL stylesheet from source template or default fallback | ||
| const xslTemplate = hasSitemapTemplate | ||
| ? fs.readFileSync(`${rootClientPath}/sitemap`, 'utf8') | ||
| : defaultSitemapXsl; | ||
| const webUrl = `https://${host}${path === '/' ? '/' : `${path}/`}`; | ||
| fs.writeFileSync(`${rootClientPath}/sitemap.xsl`, xslTemplate.replaceAll('{{web-url}}', webUrl), 'utf8'); | ||
| fs.writeFileSync( | ||
| `${rootClientPath}/robots.txt`, | ||
| `User-agent: * | ||
| Sitemap: ${sitemapBaseUrl}/sitemap.xml`, | ||
| 'utf8', | ||
| ); | ||
| } | ||
| if (fullBuildEnabled && docs) { | ||
| await buildDocs({ | ||
| host, | ||
| path, | ||
| port, | ||
| metadata, | ||
| apis, | ||
| publicClientId, | ||
| rootClientPath, | ||
| packageData, | ||
| docs, | ||
| }); | ||
| } | ||
| if (client) { | ||
| const proxyPrefix = path === '/' ? '' : path; | ||
| const buildIndexUrl = (routePath) => `${proxyPrefix}${routePath === '/' ? '' : routePath}/index.html`; | ||
| // SSR views: a single declarative array. The role of each view (regular | ||
| // page vs. offline/maintenance fallback) is expressed by per-entry flags; | ||
| // fallback-flagged views are also precached so the SW can serve them | ||
| // when the network is unreachable. | ||
| const ssrClientConf = confSSR[getCapVariableName(client)] || {}; | ||
| const ssrViews = Array.isArray(ssrClientConf.views) ? ssrClientConf.views : []; | ||
| const PRE_CACHED_RESOURCES = []; | ||
| let offlineFallbackUrl = null; | ||
| let maintenanceFallbackUrl = null; | ||
| for (const view of ssrViews) { | ||
| const SsrComponent = await ssrFactory(`./src/client/ssr/views/${view.client}.js`); | ||
| const htmlSrc = Render({ | ||
| title: view.title, | ||
| ssrPath, | ||
| ssrHeadComponents: '<base target="_top">', | ||
| ssrBodyComponents: SsrComponent(), | ||
| renderPayload: { | ||
| apiBaseProxyPath, | ||
| apiBaseHost, | ||
| apiBasePath: process.env.BASE_API, | ||
| version: Underpost.version, | ||
| ...(isDevelopment ? { dev: true } : undefined), | ||
| }, | ||
| renderApi: { JSONweb }, | ||
| }); | ||
| const buildPath = `${ | ||
| rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath | ||
| }${view.path === '/' ? view.path : `${view.path}/`}`; | ||
| const indexUrl = buildIndexUrl(view.path); | ||
| if (view.offlineDefault) { | ||
| offlineFallbackUrl = indexUrl; | ||
| PRE_CACHED_RESOURCES.push(indexUrl); | ||
| } | ||
| if (view.maintenanceDefault) { | ||
| maintenanceFallbackUrl = indexUrl; | ||
| PRE_CACHED_RESOURCES.push(indexUrl); | ||
| } | ||
| if (!fs.existsSync(buildPath)) fs.mkdirSync(buildPath, { recursive: true }); | ||
| const buildHtmlPath = `${buildPath}index.html`; | ||
| logger.info('ssr view build', buildHtmlPath); | ||
| fs.writeFileSync( | ||
| buildHtmlPath, | ||
| minifyBuild | ||
| ? await minify(htmlSrc, { | ||
| minifyCSS: true, | ||
| minifyJS: true, | ||
| collapseBooleanAttributes: true, | ||
| collapseInlineTagWhitespace: true, | ||
| collapseWhitespace: true, | ||
| }) | ||
| : htmlSrc, | ||
| 'utf8', | ||
| ); | ||
| } | ||
| if (swShouldRebuild) { | ||
| const cacheScope = path === '/' ? 'root' : path.replaceAll('/', '_'); | ||
| const renderPayload = { | ||
| PRE_CACHED_RESOURCES: uniqueArray(PRE_CACHED_RESOURCES), | ||
| PROXY_PATH: path, | ||
| CACHE_PREFIX: `engine-core-${cacheScope}`, | ||
| OFFLINE_URL: offlineFallbackUrl || buildIndexUrl('/offline'), | ||
| MAINTENANCE_URL: maintenanceFallbackUrl || buildIndexUrl('/maintenance'), | ||
| }; | ||
| // Single write: prepend the payload prelude to the transformed SW JS. | ||
| fs.writeFileSync( | ||
| swPublicPath, | ||
| `self.renderPayload = ${JSONweb(renderPayload)}; | ||
| self.__WB_DISABLE_DEV_LOGS = true; | ||
| ${swTransformedJs}`, | ||
| 'utf8', | ||
| ); | ||
| } | ||
| } | ||
| if (!enableLiveRebuild && options.buildZip) { | ||
| logger.warn('build zip', rootClientPath); | ||
| if (!fs.existsSync('./build')) fs.mkdirSync('./build'); | ||
| const zip = new AdmZip(); | ||
| const files = await fs.readdir(rootClientPath, { recursive: true }); | ||
| for (const relativePath of files) { | ||
| const filePath = dir.resolve(`${rootClientPath}/${relativePath}`); | ||
| if (!fs.lstatSync(filePath).isDirectory()) { | ||
| const folder = dir.relative(`public/${host}${path}`, dir.dirname(filePath)); | ||
| zip.addLocalFile(filePath, folder); | ||
| } | ||
| } | ||
| const buildId = `${host}-${path.replaceAll('/', '')}`; | ||
| const zipPath = `./build/${buildId}.zip`; | ||
| logger.warn('write zip', zipPath); | ||
| zip.writeZip(zipPath); | ||
| if (options.split) { | ||
| splitFileByMb({ | ||
| filePath: zipPath, | ||
| partSizeMb: options.split, | ||
| logger, | ||
| }); | ||
| fs.removeSync(zipPath); | ||
| logger.warn('removed original zip after split', { zipPath }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| export { buildClient, copyNonExistingFiles, unzipClientBuild, mergeClientBuildZip }; |
| /** | ||
| * Module for creating a client-side development server | ||
| * @module src/client-builder/client-dev-server.js | ||
| * @namespace clientDevServer | ||
| */ | ||
| import fs from 'fs-extra'; | ||
| import nodemon from 'nodemon'; | ||
| import dotenv from 'dotenv'; | ||
| import { shellExec } from '../server/process.js'; | ||
| import { loggerFactory } from '../server/logger.js'; | ||
| import Underpost from '../index.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| /** | ||
| * @function createClientDevServer | ||
| * @description Creates a client-side development server. | ||
| * @memberof clientDevServer | ||
| * @param {string} deployId - The deployment ID. | ||
| * @param {string} subConf - The sub-configuration. | ||
| * @param {string} host - The host. | ||
| * @param {string} path - The path. | ||
| * @returns {void} | ||
| * @memberof clientDevServer | ||
| */ | ||
| const createClientDevServer = async ( | ||
| deployId = process.argv[2] || 'dd-default', | ||
| subConf = process.argv[3] || '', | ||
| host = process.argv[4] || 'default.net', | ||
| path = process.argv[5] || '/', | ||
| ) => { | ||
| const devClientEnvPath = `./engine-private/conf/${deployId}/.env.${process.env.NODE_ENV}.${subConf}-dev-client`; | ||
| if (fs.existsSync(devClientEnvPath)) dotenv.config({ path: devClientEnvPath, override: true }); | ||
| await Underpost.repo.client(deployId, `${subConf}-dev-client`.trim(), host, path); | ||
| shellExec(`node src/server ${deployId} ${subConf}-dev-client`.trim(), { | ||
| async: true, | ||
| }); | ||
| // https://github.com/remy/nodemon/blob/main/doc/events.md | ||
| // States | ||
| // start - child process has started | ||
| // crash - child process has crashed (nodemon will not emit exit) | ||
| // exit - child process has cleanly exited (ie. no crash) | ||
| // restart([ array of files triggering the restart ]) - child process has restarted | ||
| // config:update - nodemon's config has changed | ||
| if (fs.existsSync(`/tmp/client.build.json`)) fs.removeSync(`/tmp/client.build.json`); | ||
| let buildPathScope = []; | ||
| const nodemonOptions = { | ||
| script: './src/client.build', | ||
| args: [`${deployId}`, `${subConf}-dev-client`, `${host}`, `${path}`], | ||
| watch: 'src/client', | ||
| }; | ||
| logger.info('nodemon option', { nodemonOptions }); | ||
| nodemon(nodemonOptions) | ||
| .on('start', function (...args) { | ||
| logger.info(args, 'nodemon started'); | ||
| }) | ||
| .on('restart', function (...args) { | ||
| logger.info(args, 'nodemon restart'); | ||
| const eventPath = args[0][0]; | ||
| const indexPath = buildPathScope.findIndex((buildObjScope) => buildObjScope.path === eventPath); | ||
| const buildObj = { | ||
| timestamp: new Date().getTime(), | ||
| path: eventPath, | ||
| }; | ||
| if (indexPath > -1) { | ||
| buildPathScope[indexPath].timestamp = buildObj.timestamp; | ||
| } else buildPathScope.push(buildObj); | ||
| setTimeout(() => { | ||
| buildPathScope = buildPathScope.filter((buildObjScope) => buildObjScope.timestamp !== buildObj.timestamp); | ||
| }, 2500); | ||
| const buildPathScopeBuild = buildPathScope.map((o) => o.path); | ||
| logger.info('buildPathScopeBuild', buildPathScopeBuild); | ||
| fs.writeFileSync(`/tmp/client.build.json`, JSON.stringify(buildPathScopeBuild, null, 4)); | ||
| }) | ||
| .on('crash', function (error) { | ||
| if (error) logger.error(error, error.message || 'nodemon crash'); | ||
| else logger.error('nodemon process crashed'); | ||
| }); | ||
| }; | ||
| export { createClientDevServer }; |
| /** | ||
| * Module for formatting client-side code using esbuild for import rewriting and minification. | ||
| * @module src/server/client-formatted.js | ||
| * @namespace clientFormatted | ||
| */ | ||
| 'use strict'; | ||
| import * as esbuild from 'esbuild'; | ||
| import fs from 'fs-extra'; | ||
| import * as path from 'path'; | ||
| /** | ||
| * Escapes a string for safe use inside a RegExp. | ||
| * @param {string} s - The string to escape. | ||
| * @returns {string} The escaped string. | ||
| * @memberof clientFormatted | ||
| */ | ||
| const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| /** | ||
| * Formats a source code string by removing 'html`' and 'css`' tagged template prefixes. | ||
| * Used for SSR VM execution where the full esbuild pipeline is not needed. | ||
| * @param {string} src - The source code string. | ||
| * @returns {string} The formatted source code. | ||
| * @memberof clientFormatted | ||
| */ | ||
| const srcFormatted = (src) => src.replace(/(?<=[\s({[,;=+!?:^])(html|css)`/g, '`'); | ||
| const resolveBrowserImportPath = (basePrefix, relativePath) => { | ||
| if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(basePrefix)) { | ||
| return new URL(relativePath, basePrefix.endsWith('/') ? basePrefix : `${basePrefix}/`).toString(); | ||
| } | ||
| return path.posix.normalize(`${basePrefix}${relativePath}`); | ||
| }; | ||
| /** | ||
| * Converts a JavaScript object into a string that can be embedded in client-side code | ||
| * and parsed back into an object (e.g., 'JSON.parse(`{...}`)'). | ||
| * Escapes backticks and template expression markers for safe template literal embedding. | ||
| * @param {*} data - The data to be stringified. | ||
| * @returns {string} A string representing the code to parse the JSON data. | ||
| * @memberof clientFormatted | ||
| */ | ||
| const JSONweb = (data) => { | ||
| const json = JSON.stringify(data).replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); | ||
| return 'JSON.parse(`' + json + '`)'; | ||
| }; | ||
| /** | ||
| * Creates an esbuild plugin that rewrites import paths for browser consumption. | ||
| * Handles dist library imports, relative imports, and marks all remaining imports as external. | ||
| * @param {object} options | ||
| * @param {Array<object>} [options.dists=[]] - Distribution objects with import_name and import_name_build. | ||
| * @param {string} options.proxyPath - The proxy path for the application. | ||
| * @param {string} [options.basePath=''] - The base path for the module type (e.g., 'components', 'services'). | ||
| * @param {string} [options.module=''] - The module/component name for relative import resolution. | ||
| * @param {string} [options.baseHost=''] - The base host URL. | ||
| * @returns {import('esbuild').Plugin} | ||
| * @memberof clientFormatted | ||
| */ | ||
| const importRewritePlugin = ({ | ||
| dists = [], | ||
| proxyPath, | ||
| basePath = '', | ||
| module = '', | ||
| baseHost = '', | ||
| externalizeBareImports = true, | ||
| }) => ({ | ||
| name: 'import-rewrite', | ||
| setup(build) { | ||
| const prefix = `${baseHost}${proxyPath !== '/' ? `${proxyPath}/` : '/'}`; | ||
| // Rewrite dist library imports (e.g., '@neodrag/vanilla' → '/proxyPath/dist/@neodrag-vanilla/index.js') | ||
| if (dists) { | ||
| for (const dist of dists) { | ||
| if (!dist.import_name) continue; | ||
| const filter = new RegExp(`^${escapeRegExp(dist.import_name)}$`); | ||
| build.onResolve({ filter }, () => ({ | ||
| path: `${baseHost}${proxyPath !== '/' ? proxyPath : ''}${dist.import_name_build}`, | ||
| external: true, | ||
| })); | ||
| } | ||
| } | ||
| // Rewrite app-relative imports to absolute paths based on proxy path and module. | ||
| // Do not touch node_modules relative imports so esbuild can bundle package internals. | ||
| build.onResolve({ filter: /^\.\.?\// }, (args) => { | ||
| const normalizedImporter = (args.importer || '').replace(/\\/g, '/'); | ||
| if (!normalizedImporter.includes('/src/client/')) { | ||
| return; | ||
| } | ||
| // Extract the path relative to /src/client/ | ||
| // Handle cases where the path might have duplicates or be in various formats | ||
| const srcClientIndex = normalizedImporter.lastIndexOf('/src/client/'); | ||
| if (srcClientIndex === -1) { | ||
| return; | ||
| } | ||
| const importerFromClientRoot = normalizedImporter.substring(srcClientIndex + '/src/client/'.length); | ||
| const importerDir = path.posix.dirname(importerFromClientRoot); | ||
| const resolvedFromClientRoot = path.posix.normalize(path.posix.join(importerDir, args.path)); | ||
| const result = resolveBrowserImportPath(prefix, resolvedFromClientRoot); | ||
| return { | ||
| path: result, | ||
| external: true, | ||
| }; | ||
| }); | ||
| // For client app modules we externalize bare imports; for SW builds we let esbuild bundle them. | ||
| build.onResolve({ filter: /.*/ }, (args) => { | ||
| if (args.kind === 'entry-point') return; | ||
| if (!externalizeBareImports) return; | ||
| return { path: args.path, external: true }; | ||
| }); | ||
| }, | ||
| }); | ||
| /** | ||
| * Transforms a JavaScript source file using esbuild with import path rewriting, | ||
| * tagged template stripping, and optional minification. | ||
| * Replaces the previous srcFormatted + componentFormatted/viewFormatted + UglifyJS pipeline. | ||
| * @param {string} srcPath - Path to the source file. | ||
| * @param {object} options | ||
| * @param {Array<object>} [options.dists=[]] - Distribution objects with import names. | ||
| * @param {string} options.proxyPath - The proxy path for the application. | ||
| * @param {string} [options.basePath=''] - Base path for the module type (e.g., 'components', 'services'). | ||
| * @param {string} [options.module=''] - Module name for relative import resolution. | ||
| * @param {string} [options.baseHost=''] - Base host URL. | ||
| * @param {boolean} [options.minify=false] - Whether to minify the output. | ||
| * @returns {Promise<string>} The transformed source code. | ||
| * @memberof clientFormatted | ||
| */ | ||
| const transformClientJs = async ( | ||
| srcPath, | ||
| { | ||
| dists = [], | ||
| proxyPath, | ||
| basePath = '', | ||
| module = '', | ||
| baseHost = '', | ||
| minify: shouldMinify = false, | ||
| externalizeBareImports = true, | ||
| } = {}, | ||
| ) => { | ||
| const src = fs.readFileSync(srcPath, 'utf8'); | ||
| const stripped = srcFormatted(src); | ||
| const result = await esbuild.build({ | ||
| stdin: { | ||
| contents: stripped, | ||
| loader: 'js', | ||
| resolveDir: path.dirname(path.resolve(srcPath)), | ||
| sourcefile: srcPath, | ||
| }, | ||
| bundle: true, | ||
| write: false, | ||
| format: 'esm', | ||
| platform: 'browser', | ||
| target: 'esnext', | ||
| minify: shouldMinify, | ||
| logLevel: 'warning', | ||
| plugins: [importRewritePlugin({ dists, proxyPath, basePath, module, baseHost, externalizeBareImports })], | ||
| }); | ||
| return result.outputFiles[0].text; | ||
| }; | ||
| export { srcFormatted, JSONweb, transformClientJs }; |
| /** | ||
| * Module for building client-side icons | ||
| * @module src/client-builder/client-icons.js | ||
| * @namespace clientIcons | ||
| */ | ||
| import { favicons } from 'favicons'; | ||
| import { loggerFactory } from '../server/logger.js'; | ||
| import fs from 'fs-extra'; | ||
| import { getCapVariableName } from '../client/components/core/CommonJs.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| /** | ||
| * @function buildIcons | ||
| * @description Builds icons for a client-side application. | ||
| * @memberof clientIcons | ||
| * @param {Object} metadata - The metadata for the client-side application. | ||
| * @param {string} metadata.title - The title of the client-side application. | ||
| * @param {string} metadata.description - The description of the client-side application. | ||
| * @param {string} metadata.keywords - The keywords for the client-side application. | ||
| * @param {string} metadata.author - The author of the client-side application. | ||
| * @param {string} metadata.thumbnail - The thumbnail of the client-side application. | ||
| * @param {string} metadata.themeColor - The theme color of the client-side application. | ||
| * @param {string} metadata.baseBuildIconReference - The base build icon reference for the client-side application. | ||
| * @returns {Promise<void>} | ||
| */ | ||
| const buildIcons = async ({ | ||
| publicClientId, | ||
| metadata: { title, description, keywords, author, thumbnail, themeColor, baseBuildIconReference }, | ||
| }) => { | ||
| const source = baseBuildIconReference | ||
| ? baseBuildIconReference | ||
| : `src/client/public/${publicClientId}/assets/logo/base-icon.png`; // Source image(s). `string`, `buffer` or array of `string` | ||
| const configuration = { | ||
| path: '/', // Path for overriding default icons path. `string` | ||
| appName: title ? title : null, // Your application's name. `string` | ||
| appShortName: title ? title : null, // Your application's short_name. `string`. Optional. If not set, appName will be used | ||
| appDescription: description ? description : null, // Your application's description. `string` | ||
| developerName: author ? author : null, // Your (or your developer's) name. `string` | ||
| developerURL: author ? author : null, // Your (or your developer's) URL. `string` | ||
| cacheBustingQueryParam: null, // Query parameter added to all URLs that acts as a cache busting system. `string | null` | ||
| dir: 'auto', // Primary text direction for name, short_name, and description | ||
| lang: 'en-US', // Primary language for name and short_name | ||
| background: themeColor ? themeColor : '#fff', // Background colour for flattened icons. `string` | ||
| theme_color: themeColor ? themeColor : '#fff', // Theme color user for example in Android's task switcher. `string` | ||
| appleStatusBarStyle: 'black-translucent', // Style for Apple status bar: "black-translucent", "default", "black". `string` | ||
| display: 'standalone', // Preferred display mode: "fullscreen", "standalone", "minimal-ui" or "browser". `string` | ||
| orientation: 'any', // Default orientation: "any", "natural", "portrait" or "landscape". `string` | ||
| scope: '/', // set of URLs that the browser considers within your app | ||
| start_url: '/?homescreen=1', // Start URL when launching the application from a device. `string` | ||
| preferRelatedApplications: false, // Should the browser prompt the user to install the native companion app. `boolean` | ||
| relatedApplications: undefined, // Information about the native companion apps. This will only be used if `preferRelatedApplications` is `true`. `Array<{ id: string, url: string, platform: string }>` | ||
| version: '1.0', // Your application's version string. `string` | ||
| pixel_art: false, // Keeps pixels "sharp" when scaling up, for pixel art. Only supported in offline mode. | ||
| loadManifestWithCredentials: true, // Browsers don't send cookies when fetching a manifest, enable this to fix that. `boolean` | ||
| manifestMaskable: true, // Maskable source image(s) for manifest.json. "true" to use default source. More information at https://web.dev/maskable-icon/. `boolean`, `string`, `buffer` or array of `string` | ||
| icons: { | ||
| // Platform Options: | ||
| // - offset - offset in percentage | ||
| // - background: | ||
| // * false - use default | ||
| // * true - force use default, e.g. set background for Android icons | ||
| // * color - set background for the specified icons | ||
| // | ||
| android: true, // Create Android homescreen icon. `boolean` or `{ offset, background }` or an array of sources | ||
| appleIcon: true, // Create Apple touch icons. `boolean` or `{ offset, background }` or an array of sources | ||
| appleStartup: true, // Create Apple startup images. `boolean` or `{ offset, background }` or an array of sources | ||
| favicons: true, // Create regular favicons. `boolean` or `{ offset, background }` or an array of sources | ||
| windows: true, // Create Windows 8 tile icons. `boolean` or `{ offset, background }` or an array of sources | ||
| yandex: true, // Create Yandex browser icon. `boolean` or `{ offset, background }` or an array of sources | ||
| }, | ||
| shortcuts: [ | ||
| // Your applications's Shortcuts (see: https://developer.mozilla.org/docs/Web/Manifest/shortcuts) | ||
| // Array of shortcut objects: | ||
| // { | ||
| // name: 'View your Inbox', // The name of the shortcut. `string` | ||
| // short_name: 'inbox', // optionally, falls back to name. `string` | ||
| // description: 'View your inbox messages', // optionally, not used in any implemention yet. `string` | ||
| // url: '/inbox', // The URL this shortcut should lead to. `string` | ||
| // icon: 'test/inbox_shortcut.png', // source image(s) for that shortcut. `string`, `buffer` or array of `string` | ||
| // }, | ||
| // more shortcuts objects | ||
| ], | ||
| }; | ||
| try { | ||
| const response = await favicons(source, configuration); | ||
| // console.log(response.images); // Array of { name: string, contents: <buffer> } | ||
| // console.log(response.files); // Array of { name: string, contents: <string> } | ||
| // console.log(response.html); // Array of strings (html elements) | ||
| for (const image of response.images) | ||
| fs.writeFileSync(`./src/client/public/${publicClientId}/${image.name}`, image.contents); | ||
| for (const file of response.files) | ||
| fs.writeFileSync(`./src/client/public/${publicClientId}/${file.name}`, file.contents, 'utf8'); | ||
| const ssrPath = `./src/client/ssr/head/Pwa${getCapVariableName(publicClientId)}.js`; | ||
| if (!fs.existsSync(ssrPath)) | ||
| fs.writeFileSync(ssrPath, 'SrrComponent = () => html`' + response.html.join(`\n`) + '`;', 'utf8'); | ||
| } catch (error) { | ||
| logger.error(error.message); // Error description e.g. "An unknown error has occurred" | ||
| } | ||
| }; | ||
| export { buildIcons }; |
| /** | ||
| * Module for managing server side rendering | ||
| * @module src/client-builder/ssr.js | ||
| * @namespace ServerSideRendering | ||
| */ | ||
| import fs from 'fs-extra'; | ||
| import vm from 'node:vm'; | ||
| import Underpost from '../index.js'; | ||
| import { srcFormatted, JSONweb } from './client-formatted.js'; | ||
| import { loggerFactory } from '../server/logger.js'; | ||
| import { getRootDirectory } from '../server/process.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| /** | ||
| * Creates a server-side rendering component function from a given file path. | ||
| * It reads the component file, formats it, and executes it in a sandboxed Node.js VM context to extract the component. | ||
| * @param {string} [componentPath='./src/client/ssr/RootDocument.js'] - The path to the SSR component file. | ||
| * @returns {Promise<Function>} A promise that resolves to the SSR component function. | ||
| * @memberof ServerSideRendering | ||
| */ | ||
| const ssrFactory = async (componentPath = `./src/client/ssr/RootDocument.js`) => { | ||
| const context = { SrrComponent: () => {}, npm_package_version: Underpost.version }; | ||
| vm.createContext(context); | ||
| vm.runInContext(await srcFormatted(fs.readFileSync(componentPath, 'utf8')), context); | ||
| return context.SrrComponent; | ||
| }; | ||
| /** | ||
| * Sanitizes an HTML string by adding a nonce to all script and style tags for Content Security Policy (CSP). | ||
| * The nonce is retrieved from `res.locals.nonce`. | ||
| * @param {object} res - The Express response object. | ||
| * @param {object} req - The Express request object. | ||
| * @param {string} html - The HTML string to sanitize. | ||
| * @returns {string} The sanitized HTML string with nonces. | ||
| * @memberof ServerSideRendering | ||
| */ | ||
| const sanitizeHtml = (res, req, html) => { | ||
| const nonce = res.locals.nonce; | ||
| return html | ||
| .replace(/<script(?=\s|>)/gi, `<script nonce="${nonce}"`) | ||
| .replace(/<style(?=\s|>)/gi, `<style nonce="${nonce}"`); | ||
| }; | ||
| /** | ||
| * Factory function to create Express middleware for handling 404 and 500 errors. | ||
| * It generates server-side rendered HTML for these error pages. If static error pages exist, it redirects to them. | ||
| * @param {object} options - The options for creating the middleware. | ||
| * @param {object} options.app - The Express app instance. | ||
| * @param {string} options.directory - The directory for the instance's static files. | ||
| * @param {string} options.rootHostPath - The root path for the host's public files. | ||
| * @param {string} options.path - The base path for the instance. | ||
| * @returns {Promise<{error500: Function, error400: Function}>} A promise that resolves to an object containing the 500 and 404 error handling middleware. | ||
| * @memberof ServerSideRendering | ||
| */ | ||
| const ssrMiddlewareFactory = async ({ app, directory, rootHostPath, path }) => { | ||
| const Render = await ssrFactory(); | ||
| const ssrPath = path === '/' ? path : `${path}/`; | ||
| // Build default html src for 404 and 500 | ||
| const defaultHtmlSrc404 = Render({ | ||
| title: '404 Not Found', | ||
| ssrPath, | ||
| ssrHeadComponents: '', | ||
| ssrBodyComponents: (await ssrFactory(`./src/client/ssr/body/404.js`))(), | ||
| renderPayload: { | ||
| apiBasePath: process.env.BASE_API, | ||
| version: Underpost.version, | ||
| }, | ||
| renderApi: { | ||
| JSONweb, | ||
| }, | ||
| }); | ||
| const path404 = `${directory ? directory : `${getRootDirectory()}${rootHostPath}`}/404/index.html`; | ||
| const page404 = fs.existsSync(path404) ? `${path === '/' ? '' : path}/404` : undefined; | ||
| const defaultHtmlSrc500 = Render({ | ||
| title: '500 Server Error', | ||
| ssrPath, | ||
| ssrHeadComponents: '', | ||
| ssrBodyComponents: (await ssrFactory(`./src/client/ssr/body/500.js`))(), | ||
| renderPayload: { | ||
| apiBasePath: process.env.BASE_API, | ||
| version: Underpost.version, | ||
| }, | ||
| renderApi: { | ||
| JSONweb, | ||
| }, | ||
| }); | ||
| const path500 = `${directory ? directory : `${getRootDirectory()}${rootHostPath}`}/500/index.html`; | ||
| const page500 = fs.existsSync(path500) ? `${path === '/' ? '' : path}/500` : undefined; | ||
| return { | ||
| error500: function (err, req, res, next) { | ||
| logger.error(err, err.stack); | ||
| if (page500) return res.status(500).redirect(page500); | ||
| else { | ||
| res.set('Content-Type', 'text/html'); | ||
| return res.status(500).send(sanitizeHtml(res, req, defaultHtmlSrc500)); | ||
| } | ||
| }, | ||
| error400: function (req, res, next) { | ||
| // if /<path>/home redirect to /<path> | ||
| const homeRedirectPath = `${path === '/' ? '' : path}/home`; | ||
| if (req.url.startsWith(homeRedirectPath)) { | ||
| const redirectUrl = req.url.replace('/home', ''); | ||
| return res.redirect(redirectUrl.startsWith('/') ? redirectUrl : `/${redirectUrl}`); | ||
| } | ||
| if (page404) return res.status(404).redirect(page404); | ||
| else { | ||
| res.set('Content-Type', 'text/html'); | ||
| return res.status(404).send(sanitizeHtml(res, req, defaultHtmlSrc404)); | ||
| } | ||
| }, | ||
| }; | ||
| }; | ||
| export { ssrMiddlewareFactory, ssrFactory, sanitizeHtml }; |
| SrrComponent = ({ title, ssrPath, buildId, ssrHeadComponents, ssrBodyComponents, renderPayload, renderApi }) => html` | ||
| <!DOCTYPE html> | ||
| <html dir="ltr" lang="en"> | ||
| <head> | ||
| <title>${title}</title> | ||
| <link rel="icon" type="image/x-icon" href="${ssrPath}favicon.ico" /> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> | ||
| <script> | ||
| window.renderPayload = ${renderApi.JSONweb(renderPayload)}; | ||
| </script> | ||
| ${ssrHeadComponents} | ||
| </head> | ||
| <body> | ||
| <style> | ||
| html { | ||
| scroll-behavior: smooth; | ||
| } | ||
| body { | ||
| /* overscroll-behavior: contain; */ | ||
| /* box-sizing: border-box; */ | ||
| padding: 0px; | ||
| margin: 0px; | ||
| } | ||
| .fl { | ||
| position: relative; | ||
| display: flow-root; | ||
| } | ||
| .abs, | ||
| .in { | ||
| display: block; | ||
| } | ||
| .fll { | ||
| float: left; | ||
| } | ||
| .flr { | ||
| float: right; | ||
| } | ||
| .abs { | ||
| position: absolute; | ||
| } | ||
| .in, | ||
| .inl { | ||
| position: relative; | ||
| } | ||
| .inl { | ||
| display: inline-table; | ||
| display: -webkit-inline-table; | ||
| display: -moz-inline-table; | ||
| display: -ms-inline-table; | ||
| display: -o-inline-table; | ||
| } | ||
| .fix { | ||
| position: fixed; | ||
| display: block; | ||
| } | ||
| .stq { | ||
| position: sticky; | ||
| /* require defined at least top, bottom, left o right */ | ||
| } | ||
| .wfa { | ||
| width: available; | ||
| width: -webkit-available; | ||
| width: -moz-available; | ||
| width: -ms-available; | ||
| width: -o-available; | ||
| width: fill-available; | ||
| width: -webkit-fill-available; | ||
| width: -moz-fill-available; | ||
| width: -ms-fill-available; | ||
| width: -o-fill-available; | ||
| } | ||
| .wft { | ||
| width: fit-content; | ||
| width: -webkit-fit-content; | ||
| width: -moz-fit-content; | ||
| width: -ms-fit-content; | ||
| width: -o-fit-content; | ||
| } | ||
| .wfm { | ||
| width: max-content; | ||
| width: -webkit-max-content; | ||
| width: -moz-max-content; | ||
| width: -ms-max-content; | ||
| width: -o-max-content; | ||
| } | ||
| .negative-color { | ||
| filter: invert(1); | ||
| -webkit-filter: invert(1); | ||
| -moz-filter: invert(1); | ||
| -ms-filter: invert(1); | ||
| -o-filter: invert(1); | ||
| } | ||
| .no-drag { | ||
| user-drag: none; | ||
| -webkit-user-drag: none; | ||
| -moz-user-drag: none; | ||
| -ms-user-drag: none; | ||
| -o-user-drag: none; | ||
| user-select: none; | ||
| -webkit-user-select: none; | ||
| -moz-user-select: none; | ||
| -ms-user-select: none; | ||
| -o-user-select: none; | ||
| } | ||
| .center { | ||
| transform: translate(-50%, -50%); | ||
| top: 50%; | ||
| left: 50%; | ||
| width: 100%; | ||
| text-align: center; | ||
| } | ||
| input { | ||
| outline: none !important; | ||
| border: none; | ||
| padding-block: 0; | ||
| padding-inline: 0; | ||
| height: 30px; | ||
| line-height: 30px; | ||
| } | ||
| input::file-selector-button { | ||
| outline: none !important; | ||
| border: none; | ||
| } | ||
| .hide { | ||
| display: none !important; | ||
| } | ||
| /* | ||
| placeholder | ||
| */ | ||
| ::placeholder { | ||
| color: black; | ||
| opacity: 1; | ||
| /* Firefox */ | ||
| background: none; | ||
| } | ||
| :-ms-input-placeholder { | ||
| /* Internet Explorer 10-11 */ | ||
| color: black; | ||
| background: none; | ||
| } | ||
| ::-ms-input-placeholder { | ||
| /* Microsoft Edge */ | ||
| color: black; | ||
| background: none; | ||
| } | ||
| /* | ||
| selection | ||
| */ | ||
| ::-moz-selection { | ||
| /* Code for Firefox */ | ||
| color: black; | ||
| background: rgb(208, 208, 208); | ||
| } | ||
| ::selection { | ||
| color: black; | ||
| background: rgb(208, 208, 208); | ||
| } | ||
| .lowercase { | ||
| text-transform: lowercase; | ||
| } | ||
| .uppercase { | ||
| text-transform: uppercase; | ||
| } | ||
| .capitalize { | ||
| text-transform: capitalize; | ||
| } | ||
| .bold { | ||
| font-weight: bold; | ||
| } | ||
| .m { | ||
| font-family: monospace; | ||
| } | ||
| .gray { | ||
| filter: grayscale(1); | ||
| } | ||
| </style> | ||
| <div class="session"> | ||
| <style> | ||
| .session-in-log-out { | ||
| display: block; | ||
| } | ||
| .session-inl-log-out { | ||
| display: inline-table; | ||
| } | ||
| .session-fl-log-out { | ||
| display: flow-root; | ||
| } | ||
| .session-in-log-in { | ||
| display: none; | ||
| } | ||
| .session-inl-log-in { | ||
| display: none; | ||
| } | ||
| .session-fl-log-in { | ||
| display: none; | ||
| } | ||
| </style> | ||
| </div> | ||
| <div class="theme"></div> | ||
| ${ssrBodyComponents} ${buildId ? html`<script async type="module" src="./${buildId}.js"></script>` : ''} | ||
| </body> | ||
| </html> | ||
| `; |
| /** | ||
| * Underpost platform content catalog — the base `pwa-microservices-template`. | ||
| * | ||
| * @module src/projects/underpost/catalog-underpost.js | ||
| * @namespace UnderpostCatalog | ||
| */ | ||
| /** | ||
| * Workflow + service files re-added to the template after the engine-only strip. | ||
| * @constant {string[]} | ||
| * @memberof UnderpostCatalog | ||
| */ | ||
| const TEMPLATE_RESTORE_PATHS = [ | ||
| `./.github/workflows/pwa-microservices-template-page.cd.yml`, | ||
| `./.github/workflows/pwa-microservices-template-test.ci.yml`, | ||
| `./.github/workflows/npmpkg.ci.yml`, | ||
| `./.github/workflows/ghpkg.ci.yml`, | ||
| `./.github/workflows/gitlab.ci.yml`, | ||
| `./.github/workflows/publish.ci.yml`, | ||
| `./.github/workflows/release.cd.yml`, | ||
| `./src/client/services/user/guest.service.js`, | ||
| './src/api/user/guest.service.js', | ||
| './src/ws/IoInterface.js', | ||
| './src/ws/IoServer.js', | ||
| './manifests/deployment/dd-default-development', | ||
| ]; | ||
| /** | ||
| * npm keywords for the standalone Underpost platform / template package. | ||
| * @constant {string[]} | ||
| * @memberof UnderpostCatalog | ||
| */ | ||
| const TEMPLATE_KEYWORDS = [ | ||
| 'underpost', | ||
| 'underpost-platform', | ||
| 'cli', | ||
| 'toolchain', | ||
| 'ci-cd', | ||
| 'devops', | ||
| 'kubernetes', | ||
| 'k3s', | ||
| 'kubeadm', | ||
| 'lxd', | ||
| 'baremetal', | ||
| 'container-orchestration', | ||
| 'image-management', | ||
| 'pwa', | ||
| 'workbox', | ||
| 'microservices', | ||
| ]; | ||
| /** | ||
| * npm description for the standalone Underpost platform / template package. | ||
| * @constant {string} | ||
| * @memberof UnderpostCatalog | ||
| */ | ||
| const TEMPLATE_DESCRIPTION = | ||
| 'Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orchestration. Extensible via downstream CLIs.'; | ||
| export { TEMPLATE_RESTORE_PATHS, TEMPLATE_KEYWORDS, TEMPLATE_DESCRIPTION }; |
| /** | ||
| * Exported singleton instance of the NginxService class. | ||
| * Manages dynamic generation of nginx reverse-proxy router configuration used | ||
| * by the Docker Compose development stack. Mirrors the conventions of | ||
| * {@link module:src/runtime/lampp/Lampp.js LamppService}. | ||
| * @module src/runtime/nginx/Nginx.js | ||
| * @namespace NginxService | ||
| */ | ||
| import fs from 'fs-extra'; | ||
| import path from 'path'; | ||
| import { loggerFactory } from '../../server/logger.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| /** | ||
| * @class NginxService | ||
| * @description Builds nginx `server` blocks (the router) for fronting upstream | ||
| * application services and writes the rendered configuration to disk. The | ||
| * router is accumulated in memory via {@link NginxService#createApp} and | ||
| * flushed with {@link NginxService#writeConf}, keeping config generation | ||
| * decoupled from the filesystem location it is written to. | ||
| * @memberof NginxService | ||
| */ | ||
| class NginxService { | ||
| /** | ||
| * @type {string} | ||
| * @description Accumulated nginx `server { ... }` blocks (the router definition). | ||
| * @memberof NginxService | ||
| */ | ||
| router = ''; | ||
| /** | ||
| * @type {Set<string>} | ||
| * @description Upstream blocks keyed by upstream name to avoid duplicates. | ||
| * @memberof NginxService | ||
| */ | ||
| upstreams; | ||
| /** | ||
| * @type {boolean} | ||
| * @description Whether a default_server catch-all block has been emitted. | ||
| * @memberof NginxService | ||
| */ | ||
| hasDefaultServer; | ||
| constructor() { | ||
| this.reset(); | ||
| } | ||
| /** | ||
| * Resets the in-memory router, upstreams, and default-server flag. | ||
| * @method reset | ||
| * @returns {void} | ||
| * @memberof NginxService | ||
| */ | ||
| reset() { | ||
| this.router = ''; | ||
| this.upstreams = new Map(); | ||
| this.hasDefaultServer = false; | ||
| } | ||
| /** | ||
| * Appends a raw render fragment to the router string. | ||
| * @method appendRouter | ||
| * @param {string} render - The configuration fragment to append. | ||
| * @returns {string} The complete, updated router configuration string. | ||
| * @memberof NginxService | ||
| */ | ||
| appendRouter(render) { | ||
| if (!this.router) return (this.router = render); | ||
| return (this.router += render); | ||
| } | ||
| /** | ||
| * Clears the in-memory router configuration. | ||
| * @method removeRouter | ||
| * @returns {void} | ||
| * @memberof NginxService | ||
| */ | ||
| removeRouter() { | ||
| this.reset(); | ||
| } | ||
| /** | ||
| * Registers a named upstream pointing at a container service:port. | ||
| * Idempotent: repeated names with the same target are collapsed. | ||
| * @method addUpstream | ||
| * @param {string} name - The upstream identifier. | ||
| * @param {string} service - The Docker service name (resolved via service discovery). | ||
| * @param {number} port - The upstream container port. | ||
| * @returns {string} The upstream name. | ||
| * @memberof NginxService | ||
| */ | ||
| addUpstream(name, service, port) { | ||
| this.upstreams.set(name, `upstream ${name} { server ${service}:${port}; }`); | ||
| return name; | ||
| } | ||
| /** | ||
| * Renders the standard proxy directive block shared by all locations, | ||
| * including websocket upgrade headers and forwarded headers. | ||
| * @method proxyLocation | ||
| * @param {string} location - The location path (e.g. '/', '/peer'). | ||
| * @param {string} upstream - The upstream name to proxy to. | ||
| * @returns {string} The rendered `location { ... }` block. | ||
| * @memberof NginxService | ||
| */ | ||
| proxyLocation(location, upstream) { | ||
| return ` | ||
| location ${location} { | ||
| proxy_pass http://${upstream}; | ||
| proxy_set_header Host $host; | ||
| proxy_set_header X-Real-IP $remote_addr; | ||
| proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||
| proxy_set_header X-Forwarded-Proto $scheme; | ||
| proxy_set_header Upgrade $http_upgrade; | ||
| proxy_set_header Connection $connection_upgrade; | ||
| proxy_read_timeout 3600s; | ||
| } | ||
| `; | ||
| } | ||
| /** | ||
| * Renders the `/healthz` location used by the proxy container healthcheck. | ||
| * @method healthLocation | ||
| * @returns {string} The rendered health-check location block. | ||
| * @memberof NginxService | ||
| */ | ||
| healthLocation() { | ||
| return ` | ||
| location = /healthz { | ||
| access_log off; | ||
| return 200 "ok\\n"; | ||
| add_header Content-Type text/plain; | ||
| } | ||
| `; | ||
| } | ||
| /** | ||
| * Creates an nginx virtual-host (`server`) entry for a host and appends it to | ||
| * the router. Each route maps a URL prefix to an upstream service:port with | ||
| * websocket support, mirroring the Contour HTTPProxy route model. | ||
| * | ||
| * @method createApp | ||
| * @param {object} options - Virtual host options. | ||
| * @param {string} options.host - The `server_name` (e.g. 'default.net'). | ||
| * @param {number} [options.listen=80] - The listen port. | ||
| * @param {Array<{location: string, service: string, port: number}>} options.routes | ||
| * - Route table. Longer prefixes should precede '/' for correct matching. | ||
| * @param {boolean} [options.resetRouter] - Clear the router before appending. | ||
| * @returns {string} The complete, updated router configuration string. | ||
| * @memberof NginxService | ||
| */ | ||
| createApp({ host, listen = 80, routes = [], resetRouter = false }) { | ||
| if (resetRouter) this.removeRouter(); | ||
| const safeHost = host.replace(/[^a-zA-Z0-9]/g, '_'); | ||
| const locationBlocks = routes | ||
| .map(({ location, service, port }) => { | ||
| const upstreamName = this.addUpstream(`up_${safeHost}_${port}`, service, port); | ||
| return this.proxyLocation(location, upstreamName); | ||
| }) | ||
| .join(''); | ||
| this.appendRouter(` | ||
| server { | ||
| listen ${listen}; | ||
| server_name ${host}; | ||
| ${this.healthLocation()}${locationBlocks}} | ||
| `); | ||
| return this.router; | ||
| } | ||
| /** | ||
| * Emits a `default_server` catch-all that forwards to the given upstream and | ||
| * answers the health probe for unmatched Host headers. Safe to call once. | ||
| * | ||
| * @method createDefaultServer | ||
| * @param {object} options - Default server options. | ||
| * @param {string} options.service - The Docker service name to fall back to. | ||
| * @param {number} options.port - The upstream container port. | ||
| * @param {number} [options.listen=80] - The listen port. | ||
| * @returns {string} The complete, updated router configuration string. | ||
| * @memberof NginxService | ||
| */ | ||
| createDefaultServer({ service, port, listen = 80 }) { | ||
| if (this.hasDefaultServer) return this.router; | ||
| this.hasDefaultServer = true; | ||
| const upstreamName = this.addUpstream('up_default', service, port); | ||
| this.appendRouter(` | ||
| server { | ||
| listen ${listen} default_server; | ||
| server_name _; | ||
| ${this.healthLocation()}${this.proxyLocation('/', upstreamName)}} | ||
| `); | ||
| return this.router; | ||
| } | ||
| /** | ||
| * Renders the full nginx config document: the websocket connection map, all | ||
| * upstream blocks, the global `proxy_http_version`, and the accumulated router. | ||
| * @method render | ||
| * @returns {string} The complete nginx configuration file content. | ||
| * @memberof NginxService | ||
| */ | ||
| render() { | ||
| const upstreamBlocks = Array.from(this.upstreams.values()).join('\n'); | ||
| return `# Generated by src/runtime/nginx/Nginx.js — do not hand-edit. | ||
| # Reverse proxy derived from manifests/deployment/*/proxy.yaml (Contour HTTPProxy). | ||
| # Upstreams resolve container services via the Docker internal network. | ||
| map $http_upgrade $connection_upgrade { | ||
| default upgrade; | ||
| '' close; | ||
| } | ||
| ${upstreamBlocks} | ||
| proxy_http_version 1.1; | ||
| ${this.router}`; | ||
| } | ||
| /** | ||
| * Writes the rendered configuration to the given path, creating parent | ||
| * directories as needed. Idempotent and safe to rerun. | ||
| * @method writeConf | ||
| * @param {string} confPath - Absolute or relative destination path. | ||
| * @returns {string} The path written. | ||
| * @memberof NginxService | ||
| */ | ||
| writeConf(confPath) { | ||
| const target = path.resolve(confPath); | ||
| fs.mkdirpSync(path.dirname(target)); | ||
| fs.writeFileSync(target, this.render(), 'utf8'); | ||
| logger.info(`nginx config written`, { path: target, upstreams: this.upstreams.size }); | ||
| return target; | ||
| } | ||
| } | ||
| /** | ||
| * @description Exported singleton instance of the NginxService class. | ||
| * @type {NginxService} | ||
| * @memberof NginxService | ||
| */ | ||
| const Nginx = new NginxService(); | ||
| export { Nginx, NginxService }; | ||
| export default Nginx; |
@@ -23,3 +23,3 @@ name: CI | Publish github repository package | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| - uses: actions/checkout@v7 | ||
@@ -26,0 +26,0 @@ - name: Install required packages |
@@ -9,3 +9,3 @@ name: CI | Publish gitlab repository package | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| - uses: actions/checkout@v7 | ||
| with: | ||
@@ -12,0 +12,0 @@ fetch-depth: 0 |
@@ -42,3 +42,3 @@ name: CI | Publish npm repository package | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| - uses: actions/checkout@v7 | ||
@@ -45,0 +45,0 @@ - name: Install required packages |
@@ -20,3 +20,3 @@ name: CI | Publish npm package | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| - uses: actions/checkout@v7 | ||
@@ -58,3 +58,3 @@ - name: Install required packages | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| - uses: actions/checkout@v7 | ||
@@ -61,0 +61,0 @@ - uses: actions/setup-node@v6 |
@@ -36,3 +36,3 @@ # Simple workflow for deploying static content to GitHub Pages | ||
| - name: Checkout | ||
| uses: actions/checkout@v6 | ||
| uses: actions/checkout@v7 | ||
| # with: | ||
@@ -39,0 +39,0 @@ # lfs: true |
@@ -14,3 +14,3 @@ name: CI | Gihub page | PWA Microservices Template Test | ||
| - name: Clone repository | ||
| uses: actions/checkout@v6 | ||
| uses: actions/checkout@v7 | ||
@@ -17,0 +17,0 @@ - name: Install required packages |
@@ -177,2 +177,3 @@ { | ||
| "architecture": "amd64", | ||
| "infraSetup": "underpost-kubeadm-contour", | ||
| "isoUrl": "https://download.rockylinux.org/pub/rocky/9/isos/x86_64/Rocky-9-latest-x86_64-boot.iso", | ||
@@ -179,0 +180,0 @@ "tftpPrefix": "envy-rocky9", |
+1
-0
@@ -143,2 +143,3 @@ #! /usr/bin/env node | ||
| default: | ||
| for (const path of catalog.templatePaths) fs.copySync(`.${path}`, `${basePath}${path}`); | ||
| break; | ||
@@ -145,0 +146,0 @@ } |
+56
-2
| ## Underpost CLI | ||
| > underpost ci/cd cli v3.2.22 | ||
| > underpost ci/cd cli v3.2.28 | ||
@@ -43,2 +43,3 @@ **Usage:** `underpost [options] [command]` | ||
| | [`run`](#underpost-run) | Runs specified scripts using various runners. | | ||
| | [`docker-compose`](#underpost-docker-compose) | General-purpose Docker Compose development pipeline (mirrors the Kubernetes dev stack). | | ||
| | [`lxd`](#underpost-lxd) | Manages LXD virtual machines as K3s nodes (control plane or workers). | | ||
@@ -819,3 +820,3 @@ | [`baremetal`](#underpost-baremetal) | Manages baremetal server operations, including installation, database setup, commissioning, and user management. | | ||
| | --- | --- | | ||
| | `runner-id` | The runner ID to run. Options: dev-cluster,etc-hosts,ipfs-expose,metadata,svc-ls,svc-rm,ssh-deploy-info,dev-hosts-expose,dev-hosts-restore,cluster-build,template-deploy,template-deploy-local,docker-image,clean,pull,release-deploy,ssh-deploy,ide,crypto-policy,sync,stop,ssh-deploy-stop,ssh-deploy-db-rollback,ssh-deploy-db,ssh-deploy-db-status,tz,get-proxy,instance-promote,instance,instance-build-manifest,ls-deployments,host-update,install-crio,dd-container,ip-info,db-client,git-conf,promote,metrics,cluster,deploy,disk-clean,disk-devices,disk-usage,dev,service,sh,log,ps,pid-info,background,ports,deploy-test,tf-vae-test,spark-template,pull-rocky-image,rmi,kill,generate-pass,secret,underpost-config,gpu-env,tf-gpu-test,deploy-job,push-bundle,pull-bundle,build-cluster-deployment-manifests,monitor-ui,shared-dir. | | ||
| | `runner-id` | The runner ID to run. Options: dev-cluster,etc-hosts,ipfs-expose,metadata,svc-ls,svc-rm,ssh-deploy-info,node-move,dev-hosts-expose,dev-hosts-restore,cluster-build,template-deploy,template-deploy-local,docker-image,clean,pull,release-deploy,ssh-deploy,ide,crypto-policy,sync,stop,ssh-deploy-stop,ssh-deploy-db-rollback,ssh-deploy-db,ssh-deploy-db-status,tz,get-proxy,instance-promote,instance,instance-build-manifest,ls-deployments,host-update,install-crio,dd-container,ip-info,db-client,git-conf,promote,metrics,cluster,deploy,disk-clean,disk-devices,disk-usage,dev,service,sh,log,ps,pid-info,background,ports,deploy-test,tf-vae-test,spark-template,pull-rocky-image,rmi,kill,generate-pass,secret,underpost-config,gpu-env,tf-gpu-test,deploy-job,push-bundle,pull-bundle,build-cluster-deployment-manifests,monitor-ui,shared-dir,shared-dir-add-user. | | ||
| | `path` | The input value, identifier, or path for the operation. | | ||
@@ -899,2 +900,41 @@ | ||
| ### underpost docker-compose | ||
| General-purpose Docker Compose development pipeline (mirrors the Kubernetes dev stack). | ||
| **Usage:** `underpost docker-compose [options] [target]` | ||
| #### Arguments | ||
| | Argument | Description | | ||
| | --- | --- | | ||
| | `target` | Optional service name for --logs, --shell, --restart, or --build. | | ||
| #### Options | ||
| | Option | Description | | ||
| | --- | --- | | ||
| | `--install` | Install Docker Engine and the Compose v2 plugin on RHEL/Rocky hosts. | | ||
| | `--reset` | Comprehensive teardown (equivalent to cluster --reset): removes all stack containers, the network, named volumes (destroys data), orphans, and generated artifacts. | | ||
| | `--force` | Force reinstall (--install), remove volumes (--down), or also drop the env-file (--reset). | | ||
| | `--deploy-id <deploy-id>` | Deployment to run as the app container (default: dd-default). 'dd-default' self-bootstraps a fresh engine; any other id runs the standard 'underpost start' command (mirrors src/cli/deploy.js). | | ||
| | `--env <env>` | Deployment environment for non-default deploy ids (default: development). | | ||
| | `--generate` | Render dynamic supporting files (nginx router config, env-file, app-command override). | | ||
| | `--up` | Start the full stack detached (regenerates config first). | | ||
| | `--down` | Stop and remove containers (and orphans). | | ||
| | `--volumes` | With --down, also remove named volumes (destroys persisted data). | | ||
| | `--restart` | Restart services (optionally a single [target]). | | ||
| | `--build` | With --up rebuild images; alone, rebuilds images with --no-cache. | | ||
| | `--pull` | Pull upstream images for all services. | | ||
| | `--logs` | Follow logs for all services (optionally a single [target]). | | ||
| | `--status` | Show a formatted status table of services. | | ||
| | `--shell` | Open an interactive shell in [target] (default: app). | | ||
| | `--exec <subcommand>` | General-purpose passthrough docker compose subcommand. | | ||
| | `--compose-file <path>` | Path to the compose file (default: docker-compose.yml). | | ||
| | `--env-file <path>` | Path to the compose env-file (default: docker/compose.env). | | ||
| | `--nginx-conf <path>` | Path to the generated nginx config (default: docker/nginx/default.conf). | | ||
| | `-h, --help` | display help for command | | ||
| --- | ||
| ### underpost lxd | ||
@@ -982,2 +1022,14 @@ | ||
| | `--commission` | Init workflow for commissioning a physical machine. | | ||
| | `--install-disk [device]` | Explicit target install disk for Rocky deployment (e.g. /dev/nvme0n1). Omit or leave empty to auto-detect the internal disk. | | ||
| | `--no-auto-install` | Disables the ephemeral runtime AUTO_INSTALL fallback (controller must trigger install). | | ||
| | `--no-remote-install` | Skips the controller-side remote install orchestration over SSH. | | ||
| | `--worker` | Post-install infra role: join the deployed node as a Kubernetes worker (requires --control <ip>). Without this flag the node is set up as a control-plane. | | ||
| | `--control <ip>` | Control-plane IP the worker node joins (used with --worker for kubeadm infra setup). | | ||
| | `--ssh-key-dir <dir>` | Directory holding the SSH key pair used for commissioning/orchestration (expects <dir>/id_rsa and <dir>/id_rsa.pub). Overrides the workflow "sshKeyDir"; defaults to engine-private/deploy. Supports a leading ~. | | ||
| | `--deploy-id <deploy-id>` | Deployment ID whose user key pair is used for SSH (key from engine-private/conf/<deploy-id>/users/<user>/id_rsa). Same user↔deployId↔key convention as the ssh command. | | ||
| | `--user <user>` | SSH user paired with --deploy-id for key resolution and the login user on an existing control-plane (defaults to root). Mirrors the ssh command --user. | | ||
| | `--engine-repo <url>` | Custom engine repo cloned + normalized to /home/dd/engine on the node (default: <GITHUB_USERNAME>/engine). | | ||
| | `--engine-branch <branch>` | Branch of the engine repo to clone on the node. | | ||
| | `--engine-private-repo <url>` | Custom private repo cloned + normalized to /home/dd/engine/engine-private on the node (default: <GITHUB_USERNAME>/engine-<id>-private). | | ||
| | `--engine-private-branch <branch>` | Branch of the engine-private repo to clone on the node. | | ||
| | `--bootstrap-http-server-run` | Runs a temporary bootstrap HTTP server for generic purposes such as serving iPXE scripts or ISO images during commissioning. | | ||
@@ -1004,2 +1056,4 @@ | `--bootstrap-http-server-path <path>` | Sets a custom bootstrap HTTP server path for baremetal commissioning. | | ||
| | `--ls` | Lists available boot resources and machines. | | ||
| | `--resume-infra-setup` | Skip commissioning, OS install, and all bootstrapping; resume only the SSH-based infra setup (kubeadm join/init) on a node that already has the OS installed and is reachable via SSH. | | ||
| | `--resume-join` | Skip everything except the kubeadm join command. Assumes engine, Node.js, CRI-O, kubelet, and kubeadm are already installed. Only retrieves a fresh join token from the control-plane and runs kubeadm join. | | ||
| | `-h, --help` | display help for command | | ||
@@ -1006,0 +1060,0 @@ |
@@ -26,3 +26,3 @@ apiVersion: batch/v1 | ||
| - name: dd-cron-backup | ||
| image: underpost/underpost-engine:v3.2.22 | ||
| image: underpost/underpost-engine:v3.2.28 | ||
| command: | ||
@@ -29,0 +29,0 @@ - /bin/sh |
@@ -26,3 +26,3 @@ apiVersion: batch/v1 | ||
| - name: dd-cron-dns | ||
| image: underpost/underpost-engine:v3.2.22 | ||
| image: underpost/underpost-engine:v3.2.28 | ||
| command: | ||
@@ -29,0 +29,0 @@ - /bin/sh |
@@ -20,3 +20,3 @@ --- | ||
| - name: dd-default-development-blue | ||
| image: underpost/underpost-engine:v3.2.22 | ||
| image: underpost/underpost-engine:v3.2.28 | ||
| # resources: | ||
@@ -102,3 +102,3 @@ # requests: | ||
| - name: dd-default-development-green | ||
| image: underpost/underpost-engine:v3.2.22 | ||
| image: underpost/underpost-engine:v3.2.28 | ||
| # resources: | ||
@@ -105,0 +105,0 @@ # requests: |
+27
-15
@@ -5,3 +5,3 @@ { | ||
| "name": "underpost", | ||
| "version": "3.2.22", | ||
| "version": "3.2.28", | ||
| "description": "Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orchestration. Extensible via downstream CLIs.", | ||
@@ -30,3 +30,14 @@ "scripts": { | ||
| "security": "npm run security:secrets:ci && npm run security:deps", | ||
| "clean": "node bin env clean && node bin run clean" | ||
| "clean": "node bin env clean && node bin run clean", | ||
| "docker:generate": "node bin docker-compose --generate", | ||
| "docker:up": "node bin docker-compose --up", | ||
| "docker:up:build": "node bin docker-compose --up --build", | ||
| "docker:down": "node bin docker-compose --down", | ||
| "docker:down:volumes": "node bin docker-compose --down --volumes", | ||
| "docker:restart": "node bin docker-compose --restart", | ||
| "docker:build": "node bin docker-compose --build", | ||
| "docker:pull": "node bin docker-compose --pull", | ||
| "docker:logs": "node bin docker-compose --logs", | ||
| "docker:status": "node bin docker-compose --status", | ||
| "docker:shell": "node bin docker-compose --shell" | ||
| }, | ||
@@ -66,3 +77,3 @@ "bin": { | ||
| "@fortawesome/fontawesome-free": "^7.2.0", | ||
| "@fullcalendar/rrule": "^6.1.20", | ||
| "@fullcalendar/rrule": "^6.1.21", | ||
| "@grpc/grpc-js": "^1.14.4", | ||
@@ -72,4 +83,4 @@ "@grpc/proto-loader": "^0.8.1", | ||
| "adm-zip": "^0.5.17", | ||
| "ag-grid-community": "^35.3.0", | ||
| "axios": "^1.16.1", | ||
| "ag-grid-community": "^35.3.1", | ||
| "axios": "^1.18.0", | ||
| "bumpp": "^11.1.0", | ||
@@ -85,6 +96,6 @@ "chai": "^6.2.2", | ||
| "d3": "^7.9.0", | ||
| "dexie": "^4.4.3", | ||
| "dexie": "^4.4.4", | ||
| "dotenv": "^17.4.2", | ||
| "easymde": "^2.21.0", | ||
| "esbuild": "^0.28.0", | ||
| "esbuild": "^0.28.1", | ||
| "escape-string-regexp": "^5.0.0", | ||
@@ -98,6 +109,6 @@ "express": "^5.2.1", | ||
| "fs-extra": "^11.3.5", | ||
| "fullcalendar": "^6.1.15", | ||
| "fullcalendar": "^6.1.21", | ||
| "helmet": "^8.2.0", | ||
| "html-minifier-terser": "^7.2.0", | ||
| "http-proxy-middleware": "^4.0.0", | ||
| "http-proxy-middleware": "^4.1.1", | ||
| "ignore-walk": "^9.0.0", | ||
@@ -107,8 +118,8 @@ "iovalkey": "^0.3.3", | ||
| "jsonwebtoken": "^9.0.3", | ||
| "mariadb": "^3.2.2", | ||
| "mariadb": "^3.5.3", | ||
| "marked": "^18.0.5", | ||
| "mocha": "^11.7.6", | ||
| "marked": "^18.0.4", | ||
| "mongoose": "^9.6.3", | ||
| "morgan": "^1.10.0", | ||
| "nodemailer": "^8.0.10", | ||
| "mongoose": "^9.7.1", | ||
| "morgan": "^1.11.0", | ||
| "nodemailer": "^9.0.0", | ||
| "nodemon": "^3.0.1", | ||
@@ -155,4 +166,5 @@ "peer": "^1.0.2", | ||
| "path-to-regexp": ">=8.4.0" | ||
| } | ||
| }, | ||
| "ws": "^8.20.2" | ||
| } | ||
| } |
+3
-2
@@ -19,3 +19,3 @@ <p align="center"> | ||
| [](https://github.com/underpostnet/engine/actions/workflows/docker-image.ci.yml) [](https://github.com/underpostnet/engine/actions/workflows/coverall.ci.yml) [](https://www.npmjs.com/package/underpost) [](https://www.jsdelivr.com/package/npm/underpost) [](https://socket.dev/npm/package/underpost/overview/3.2.22) [](https://coveralls.io/github/underpostnet/engine?branch=master) [](https://www.npmjs.org/package/underpost) [](https://www.npmjs.com/package/underpost) | ||
| [](https://github.com/underpostnet/engine/actions/workflows/docker-image.ci.yml) [](https://github.com/underpostnet/engine/actions/workflows/coverall.ci.yml) [](https://www.npmjs.com/package/underpost) [](https://www.jsdelivr.com/package/npm/underpost) [](https://socket.dev/npm/package/underpost/overview/3.2.28) [](https://coveralls.io/github/underpostnet/engine?branch=master) [](https://www.npmjs.org/package/underpost) [](https://www.npmjs.com/package/underpost) | ||
@@ -92,3 +92,3 @@ </div> | ||
| > underpost ci/cd cli v3.2.22 | ||
| > underpost ci/cd cli v3.2.28 | ||
@@ -133,2 +133,3 @@ **Usage:** `underpost [options] [command]` | ||
| | [`run`](CLI-HELP.md#underpost-run) | Runs specified scripts using various runners. | | ||
| | [`docker-compose`](CLI-HELP.md#underpost-docker-compose) | General-purpose Docker Compose development pipeline (mirrors the Kubernetes dev stack). | | ||
| | [`lxd`](CLI-HELP.md#underpost-lxd) | Manages LXD virtual machines as K3s nodes (control plane or workers). | | ||
@@ -135,0 +136,0 @@ | [`baremetal`](CLI-HELP.md#underpost-baremetal) | Manages baremetal server operations, including installation, database setup, commissioning, and user management. | |
+877
-185
| #!/bin/bash | ||
| # Rocky Linux 9 - Anaconda %pre Ephemeral Commissioning Script | ||
| # Variables ROOT_PASS, AUTHORIZED_KEYS, ADMIN_USER must be set before this script runs. | ||
| # Rocky Linux 9 - Anaconda %pre Ephemeral Commissioning + Disk Install Script | ||
| # | ||
| # This script runs inside the ephemeral Anaconda live environment (booted via | ||
| # iPXE/HTTP with inst.sshd). It drives the full unattended lifecycle: | ||
| # | ||
| # 1. ephemeral boot up (Anaconda live, this %pre script starts) | ||
| # 2. ssh ready (key-only sshd up and verified listening) | ||
| # 3. metadata posted (async POST stage=ssh-ready to bootstrap server) | ||
| # 4. remote install executed (controller SSHes in and runs the installer, | ||
| # or AUTO_INSTALL fallback self-triggers it) | ||
| # 5. disk installation (POST stage=installing; Rocky written to disk) | ||
| # 6. install completed (POST stage=completed) | ||
| # 7. reboot (boot order points at the freshly written disk) | ||
| # 8. boot into deployed OS | ||
| # | ||
| # Variables injected by the kickstart %pre header (kickstart.js): | ||
| # ROOT_PASS, AUTHORIZED_KEYS, ADMIN_USER, | ||
| # BOOTSTRAP_URL, WORKFLOW_ID, SYSTEM_ID, TARGET_HOSTNAME, | ||
| # SSH_PORT, INSTALL_DISK_HINT, AUTO_INSTALL | ||
| set +e | ||
| # 1. Set root password | ||
| SSH_PORT="${SSH_PORT:-22}" | ||
| AUTO_INSTALL="${AUTO_INSTALL:-1}" | ||
| # Fallback: if no remote install trigger arrives within this window, the | ||
| # ephemeral runtime self-installs so a single missed handshake never bricks the | ||
| # flow. The controller normally triggers far sooner over SSH. | ||
| AUTO_INSTALL_FALLBACK_SECONDS="${AUTO_INSTALL_FALLBACK_SECONDS:-600}" | ||
| KS_LOG=/tmp/ks-pre.log | ||
| READY_FLAG=/tmp/.underpost-ssh-ready-posted | ||
| TRIGGER_FILE=/tmp/.underpost-install-trigger | ||
| INSTALL_LOCK=/tmp/.underpost-install-running | ||
| INSTALL_DONE=/tmp/.underpost-install-done | ||
| INSTALLER=/usr/local/bin/underpost-install.sh | ||
| TARGET_MNT=/mnt/sysimage-underpost | ||
| log() { echo "$(date): $*" | tee -a "$KS_LOG"; } | ||
| # Reference feedback on the physical/serial console. Use printf with \r\n so | ||
| # lines render correctly on serial and physical terminals. Best-effort: never | ||
| # fails if /dev/console is unavailable. | ||
| console_log() { printf "[underpost] %s\r\n" "$*" > /dev/console 2>/dev/null || true; } | ||
| # --------------------------------------------------------------------------- | ||
| # 1. Root password (emergency console fallback only; SSH stays key-only) | ||
| # --------------------------------------------------------------------------- | ||
| if [ -n "$ROOT_PASS" ]; then | ||
| echo "root:$ROOT_PASS" | chpasswd 2>/dev/null || \ | ||
| echo "root:$ROOT_PASS" | chpasswd 2>/dev/null || \ | ||
| echo "$ROOT_PASS" | passwd --stdin root 2>/dev/null || { | ||
| HASH=$(python3 -c "import crypt; print(crypt.crypt('$ROOT_PASS', crypt.mksalt(crypt.METHOD_SHA512)))" 2>/dev/null || \ | ||
| openssl passwd -6 "$ROOT_PASS" 2>/dev/null) | ||
| [ -n "$HASH" ] && sed -i "s|^root:[^:]*:|root:$HASH:|" /etc/shadow 2>/dev/null | ||
| HASH=$(python3 -c "import crypt; print(crypt.crypt('$ROOT_PASS', crypt.mksalt(crypt.METHOD_SHA512)))" 2>/dev/null || \ | ||
| openssl passwd -6 "$ROOT_PASS" 2>/dev/null) | ||
| [ -n "$HASH" ] && sed -i "s|^root:[^:]*:|root:$HASH:|" /etc/shadow 2>/dev/null | ||
| } | ||
| fi | ||
| # 2. SSH authorized_keys for root | ||
| if [ -n "$AUTHORIZED_KEYS" ]; then | ||
| mkdir -p /root/.ssh && chmod 700 /root/.ssh | ||
| echo "$AUTHORIZED_KEYS" > /root/.ssh/authorized_keys | ||
| chmod 600 /root/.ssh/authorized_keys | ||
| mkdir -p /root/.ssh && chmod 700 /root/.ssh | ||
| echo "$AUTHORIZED_KEYS" > /root/.ssh/authorized_keys | ||
| chmod 600 /root/.ssh/authorized_keys | ||
| fi | ||
| # 3. Create admin user | ||
| # --------------------------------------------------------------------------- | ||
| # 2. Admin user (parity with previous behavior) | ||
| # --------------------------------------------------------------------------- | ||
| if command -v useradd >/dev/null 2>&1; then | ||
| useradd -m -G wheel "$ADMIN_USER" 2>/dev/null || true | ||
| useradd -m -G wheel "$ADMIN_USER" 2>/dev/null || true | ||
| else | ||
| NEXT_UID=$(awk -F: 'BEGIN{max=999} $3>max && $3<60000{max=$3} END{print max+1}' /etc/passwd 2>/dev/null || echo 1001) | ||
| if ! grep -q "^$ADMIN_USER:" /etc/passwd 2>/dev/null; then | ||
| echo "$ADMIN_USER:x:$NEXT_UID:$NEXT_UID:$ADMIN_USER:/home/$ADMIN_USER:/bin/bash" >> /etc/passwd | ||
| echo "$ADMIN_USER:x:$NEXT_UID:" >> /etc/group 2>/dev/null | ||
| echo "$ADMIN_USER:!:19000:0:99999:7:::" >> /etc/shadow 2>/dev/null | ||
| mkdir -p /home/$ADMIN_USER | ||
| fi | ||
| NEXT_UID=$(awk -F: 'BEGIN{max=999} $3>max && $3<60000{max=$3} END{print max+1}' /etc/passwd 2>/dev/null || echo 1001) | ||
| if ! grep -q "^$ADMIN_USER:" /etc/passwd 2>/dev/null; then | ||
| echo "$ADMIN_USER:x:$NEXT_UID:$NEXT_UID:$ADMIN_USER:/home/$ADMIN_USER:/bin/bash" >> /etc/passwd | ||
| echo "$ADMIN_USER:x:$NEXT_UID:" >> /etc/group 2>/dev/null | ||
| echo "$ADMIN_USER:!:19000:0:99999:7:::" >> /etc/shadow 2>/dev/null | ||
| mkdir -p /home/$ADMIN_USER | ||
| fi | ||
| fi | ||
| if [ -n "$ROOT_PASS" ]; then | ||
| echo "$ADMIN_USER:$ROOT_PASS" | chpasswd 2>/dev/null || \ | ||
| echo "$ROOT_PASS" | passwd --stdin "$ADMIN_USER" 2>/dev/null || { | ||
| HASH=$(python3 -c "import crypt; print(crypt.crypt('$ROOT_PASS', crypt.mksalt(crypt.METHOD_SHA512)))" 2>/dev/null || \ | ||
| openssl passwd -6 "$ROOT_PASS" 2>/dev/null) | ||
| [ -n "$HASH" ] && sed -i "s|^$ADMIN_USER:[^:]*:|$ADMIN_USER:$HASH:|" /etc/shadow 2>/dev/null | ||
| if [ -n "$ADMIN_PASS" ]; then | ||
| echo "$ADMIN_USER:$ADMIN_PASS" | chpasswd 2>/dev/null || \ | ||
| echo "$ADMIN_PASS" | passwd --stdin "$ADMIN_USER" 2>/dev/null || { | ||
| HASH=$(python3 -c "import crypt; print(crypt.crypt('$ADMIN_PASS', crypt.mksalt(crypt.METHOD_SHA512)))" 2>/dev/null || \ | ||
| openssl passwd -6 "$ADMIN_PASS" 2>/dev/null) | ||
| [ -n "$HASH" ] && sed -i "s|^$ADMIN_USER:[^:]*:|$ADMIN_USER:$HASH:|" /etc/shadow 2>/dev/null | ||
| } | ||
@@ -47,59 +89,165 @@ fi | ||
| if [ -n "$AUTHORIZED_KEYS" ]; then | ||
| mkdir -p /home/$ADMIN_USER/.ssh && chmod 700 /home/$ADMIN_USER/.ssh | ||
| echo "$AUTHORIZED_KEYS" > /home/$ADMIN_USER/.ssh/authorized_keys | ||
| chmod 600 /home/$ADMIN_USER/.ssh/authorized_keys | ||
| chown -R $ADMIN_USER:$ADMIN_USER /home/$ADMIN_USER/.ssh 2>/dev/null || true | ||
| mkdir -p /home/$ADMIN_USER/.ssh && chmod 700 /home/$ADMIN_USER/.ssh | ||
| echo "$AUTHORIZED_KEYS" > /home/$ADMIN_USER/.ssh/authorized_keys | ||
| chmod 600 /home/$ADMIN_USER/.ssh/authorized_keys | ||
| chown -R $ADMIN_USER:$ADMIN_USER /home/$ADMIN_USER/.ssh 2>/dev/null || true | ||
| fi | ||
| if [ -d /etc/sudoers.d ]; then | ||
| echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$ADMIN_USER | ||
| chmod 0440 /etc/sudoers.d/$ADMIN_USER | ||
| elif [ -f /etc/sudoers ]; then | ||
| echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers | ||
| echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$ADMIN_USER | ||
| chmod 0440 /etc/sudoers.d/$ADMIN_USER | ||
| elif [ -f /etc/sudoers ]; then | ||
| echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers | ||
| fi | ||
| # 4. Configure sshd | ||
| if [ -f /etc/ssh/sshd_config ]; then | ||
| sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config | ||
| sed -i 's/^#*PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config | ||
| sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config | ||
| sed -i 's/^#*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication yes/' /etc/ssh/sshd_config | ||
| sed -i 's/^#*UsePAM.*/UsePAM yes/' /etc/ssh/sshd_config | ||
| sed -i 's/^#*KbdInteractiveAuthentication.*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config | ||
| grep -q "^PasswordAuthentication" /etc/ssh/sshd_config || echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config | ||
| grep -q "^PermitRootLogin" /etc/ssh/sshd_config || echo "PermitRootLogin yes" >> /etc/ssh/sshd_config | ||
| grep -q "^PubkeyAuthentication" /etc/ssh/sshd_config || echo "PubkeyAuthentication yes" >> /etc/ssh/sshd_config | ||
| # --------------------------------------------------------------------------- | ||
| # 3. Configure sshd for key-only automation | ||
| # --------------------------------------------------------------------------- | ||
| log "[sshd] Starting SSH configuration..." | ||
| mkdir -p /etc/ssh /root/.ssh /var/lib/anaconda/ssh /run/install/ssh | ||
| chmod 755 /etc/ssh | ||
| chmod 700 /root/.ssh | ||
| chmod 700 /var/lib/anaconda/ssh 2>/dev/null || true | ||
| chmod 700 /run/install/ssh 2>/dev/null || true | ||
| for KEY_DEST in \ | ||
| /root/.ssh/authorized_keys \ | ||
| /var/lib/anaconda/ssh/authorized_keys \ | ||
| /run/install/ssh/authorized_keys; do | ||
| mkdir -p "$(dirname "$KEY_DEST")" | ||
| chmod 700 "$(dirname "$KEY_DEST")" 2>/dev/null || true | ||
| if [ -n "$AUTHORIZED_KEYS" ]; then | ||
| echo "$AUTHORIZED_KEYS" > "$KEY_DEST" | ||
| chmod 600 "$KEY_DEST" | ||
| chown root:root "$KEY_DEST" 2>/dev/null || true | ||
| log "[sshd] Wrote authorized_keys to $KEY_DEST" | ||
| fi | ||
| done | ||
| if [ -n "$AUTHORIZED_KEYS" ] && [ -n "$ADMIN_USER" ]; then | ||
| mkdir -p "/home/$ADMIN_USER/.ssh" | ||
| chmod 700 "/home/$ADMIN_USER/.ssh" | ||
| echo "$AUTHORIZED_KEYS" > "/home/$ADMIN_USER/.ssh/authorized_keys" | ||
| chmod 600 "/home/$ADMIN_USER/.ssh/authorized_keys" | ||
| chown -R "$ADMIN_USER:$ADMIN_USER" "/home/$ADMIN_USER/.ssh" 2>/dev/null || true | ||
| log "[sshd] Wrote authorized_keys to /home/$ADMIN_USER/.ssh/authorized_keys" | ||
| fi | ||
| # Key-only when keys are present; fall back to password auth only if no key was | ||
| # injected (so automation is never fully locked out). Root login is permitted | ||
| # strictly via authorized keys (prohibit-password) for non-interactive batch | ||
| # command execution from the controller. | ||
| if [ -n "$AUTHORIZED_KEYS" ]; then | ||
| SSHD_PASSWORD_AUTH="no" | ||
| SSHD_PERMIT_ROOT="prohibit-password" | ||
| SSHD_KBD_INTERACTIVE="no" | ||
| log "[sshd] authorized_keys present -> key-only mode (PasswordAuthentication no, PermitRootLogin prohibit-password)" | ||
| else | ||
| mkdir -p /etc/ssh | ||
| cat > /etc/ssh/sshd_config << 'SSHEOF' | ||
| Port 22 | ||
| PermitRootLogin yes | ||
| SSHD_PASSWORD_AUTH="yes" | ||
| SSHD_PERMIT_ROOT="yes" | ||
| SSHD_KBD_INTERACTIVE="yes" | ||
| log "[sshd] WARNING: no authorized_keys injected -> falling back to password auth" | ||
| fi | ||
| cat > /etc/ssh/sshd_config << SSHEOF | ||
| Port ${SSH_PORT} | ||
| Protocol 2 | ||
| HostKey /etc/ssh/ssh_host_rsa_key | ||
| HostKey /etc/ssh/ssh_host_ecdsa_key | ||
| HostKey /etc/ssh/ssh_host_ed25519_key | ||
| SyslogFacility AUTH | ||
| LogLevel INFO | ||
| LoginGraceTime 120 | ||
| PermitRootLogin ${SSHD_PERMIT_ROOT} | ||
| StrictModes no | ||
| PubkeyAuthentication yes | ||
| AuthorizedKeysFile .ssh/authorized_keys | ||
| PasswordAuthentication yes | ||
| ChallengeResponseAuthentication yes | ||
| KbdInteractiveAuthentication yes | ||
| UsePAM yes | ||
| AuthorizedKeysFile /root/.ssh/authorized_keys /var/lib/anaconda/ssh/authorized_keys /run/install/ssh/authorized_keys .ssh/authorized_keys | ||
| IgnoreRhosts yes | ||
| HostbasedAuthentication no | ||
| PermitEmptyPasswords no | ||
| PasswordAuthentication ${SSHD_PASSWORD_AUTH} | ||
| ChallengeResponseAuthentication ${SSHD_KBD_INTERACTIVE} | ||
| KbdInteractiveAuthentication ${SSHD_KBD_INTERACTIVE} | ||
| UsePAM no | ||
| X11Forwarding no | ||
| PrintMotd no | ||
| TCPKeepAlive yes | ||
| AcceptEnv LANG LC_* | ||
| Subsystem sftp /usr/libexec/openssh/sftp-server | ||
| UseDNS no | ||
| AllowTcpForwarding yes | ||
| ClientAliveInterval 30 | ||
| ClientAliveCountMax 3 | ||
| SSHEOF | ||
| fi | ||
| if [ -d /etc/ssh/sshd_config.d ]; then | ||
| cat > /etc/ssh/sshd_config.d/00-underpost.conf << 'SSHCONF' | ||
| PermitRootLogin yes | ||
| PasswordAuthentication yes | ||
| PubkeyAuthentication yes | ||
| KbdInteractiveAuthentication yes | ||
| SSHCONF | ||
| rm -f /etc/ssh/sshd_config.d/*.conf 2>/dev/null || true | ||
| fi | ||
| if [ ! -f /etc/ssh/ssh_host_rsa_key ]; then | ||
| ssh-keygen -A 2>/dev/null || { | ||
| ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key -N "" 2>/dev/null | ||
| ssh-keygen -t ecdsa -f /etc/ssh/ssh_host_ecdsa_key -N "" 2>/dev/null | ||
| ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N "" 2>/dev/null | ||
| } | ||
| log "[sshd] Checking/Generating SSH host keys..." | ||
| _generate_host_key() { | ||
| local TYPE=$1 | ||
| local FILE=$2 | ||
| local BITS=$3 | ||
| if [ -f "$FILE" ]; then | ||
| log "[sshd] Host key $FILE already exists, skipping." | ||
| return 0 | ||
| fi | ||
| log "[sshd] Generating $TYPE host key: $FILE ..." | ||
| if command -v ssh-keygen >/dev/null 2>&1; then | ||
| ssh-keygen -t "$TYPE" -f "$FILE" -N "" >> "$KS_LOG" 2>&1 | ||
| [ -f "$FILE" ] && { log "[sshd] ssh-keygen succeeded for $TYPE"; return 0; } | ||
| fi | ||
| if command -v openssl >/dev/null 2>&1; then | ||
| case "$TYPE" in | ||
| rsa) openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:${BITS:-2048} -out "$FILE" >> "$KS_LOG" 2>&1 ;; | ||
| ecdsa) openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out "$FILE" >> "$KS_LOG" 2>&1 ;; | ||
| ed25519) openssl genpkey -algorithm ED25519 -out "$FILE" >> "$KS_LOG" 2>&1 ;; | ||
| esac | ||
| openssl pkey -in "$FILE" -pubout > "${FILE}.pub" 2>> "$KS_LOG" | ||
| [ -f "$FILE" ] && { log "[sshd] openssl succeeded for $TYPE"; return 0; } | ||
| fi | ||
| log "[sshd] WARNING: key generation fallback for $TYPE (placeholder)" | ||
| dd if=/dev/urandom bs=1024 count=4 of="$FILE" 2>/dev/null | ||
| echo "placeholder public key" > "${FILE}.pub" | ||
| [ -f "$FILE" ] | ||
| } | ||
| _generate_host_key rsa /etc/ssh/ssh_host_rsa_key 2048 | ||
| _generate_host_key ecdsa /etc/ssh/ssh_host_ecdsa_key 256 | ||
| _generate_host_key ed25519 /etc/ssh/ssh_host_ed25519_key 256 | ||
| chmod 600 /etc/ssh/ssh_host_*_key 2>/dev/null || true | ||
| chmod 644 /etc/ssh/ssh_host_*_key.pub 2>/dev/null || true | ||
| log "[sshd] Restarting sshd to apply key-only config..." | ||
| if command -v systemctl >/dev/null 2>&1; then | ||
| systemctl restart sshd 2>/dev/null || systemctl restart sshd.service 2>/dev/null || true | ||
| fi | ||
| if ! pidof sshd >/dev/null 2>&1; then | ||
| SSH_OLD_PID=$(pidof sshd 2>/dev/null | awk '{print $1}') | ||
| [ -n "$SSH_OLD_PID" ] && { kill "$SSH_OLD_PID" 2>/dev/null || true; sleep 1; } | ||
| /usr/sbin/sshd >> "$KS_LOG" 2>&1 || sshd >> "$KS_LOG" 2>&1 || true | ||
| fi | ||
| pidof sshd >/dev/null 2>&1 && log "[sshd] sshd running (pid: $(pidof sshd))" || log "[sshd] WARNING: sshd not running" | ||
| # 5. Initialize rpmdb and dnf repos | ||
| # Disable firewall so the controller can reach sshd / the install can fetch packages. | ||
| if command -v systemctl >/dev/null 2>&1; then | ||
| systemctl stop firewalld 2>/dev/null || systemctl stop iptables 2>/dev/null || true | ||
| systemctl disable firewalld 2>/dev/null || systemctl disable iptables 2>/dev/null || true | ||
| fi | ||
| firewall-cmd --set-default-zone=trusted 2>/dev/null || true | ||
| iptables -F 2>/dev/null || true | ||
| ip6tables -F 2>/dev/null || true | ||
| # --------------------------------------------------------------------------- | ||
| # 4. dnf repos (used by the live environment for the installer's package pull) | ||
| # --------------------------------------------------------------------------- | ||
| mkdir -p /var/lib/rpm /var/cache/dnf /var/log/dnf /etc/yum.repos.d /etc/pki/rpm-gpg /etc/dnf/vars | ||
@@ -124,41 +272,7 @@ echo "9" > /etc/dnf/vars/releasever | ||
| rpm --initdb 2>/dev/null || rpmdb --initdb 2>/dev/null || { | ||
| if command -v python3 >/dev/null 2>&1; then | ||
| python3 -c " | ||
| import sqlite3, os | ||
| db_path = '/var/lib/rpm/rpmdb.sqlite' | ||
| if not os.path.exists(db_path): | ||
| conn = sqlite3.connect(db_path) | ||
| conn.execute('CREATE TABLE IF NOT EXISTS Packages (key BLOB NOT NULL, data BLOB NOT NULL)') | ||
| conn.execute('CREATE TABLE IF NOT EXISTS Name (key BLOB NOT NULL, data BLOB NOT NULL)') | ||
| conn.execute('CREATE TABLE IF NOT EXISTS Basenames (key BLOB NOT NULL, data BLOB NOT NULL)') | ||
| conn.execute('CREATE TABLE IF NOT EXISTS Installtid (key BLOB NOT NULL, data BLOB NOT NULL)') | ||
| conn.execute('CREATE TABLE IF NOT EXISTS Providename (key BLOB NOT NULL, data BLOB NOT NULL)') | ||
| conn.execute('CREATE TABLE IF NOT EXISTS Requirename (key BLOB NOT NULL, data BLOB NOT NULL)') | ||
| conn.execute('CREATE TABLE IF NOT EXISTS Dirnames (key BLOB NOT NULL, data BLOB NOT NULL)') | ||
| conn.execute('CREATE TABLE IF NOT EXISTS Sha1header (key BLOB NOT NULL, data BLOB NOT NULL)') | ||
| conn.execute('CREATE TABLE IF NOT EXISTS Sigmd5 (key BLOB NOT NULL, data BLOB NOT NULL)') | ||
| conn.commit() | ||
| conn.close() | ||
| " 2>/dev/null | ||
| fi | ||
| } | ||
| chmod -R 755 /var/lib/rpm 2>/dev/null | ||
| REPO_ARCH=$(uname -m) | ||
| rpm --import https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9 2>/dev/null || \ | ||
| curl -fsSL https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9 -o /etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9 2>/dev/null || true | ||
| curl -fsSL https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9 -o /etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9 2>/dev/null || true | ||
| cat > /etc/dnf/dnf.conf << 'DNFCONF' | ||
| [main] | ||
| gpgcheck=1 | ||
| installonly_limit=3 | ||
| clean_requirements_on_remove=True | ||
| best=True | ||
| skip_if_unavailable=True | ||
| install_weak_deps=False | ||
| tsflags=nodocs | ||
| DNFCONF | ||
| cat > /etc/yum.repos.d/rocky-baseos.repo << REPOEOF | ||
@@ -186,73 +300,556 @@ [baseos] | ||
| cat > /etc/yum.repos.d/rocky-extras.repo << REPOEOF3 | ||
| [extras] | ||
| name=Rocky Linux 9 - Extras | ||
| baseurl=http://dl.rockylinux.org/pub/rocky/9/extras/$REPO_ARCH/os/ | ||
| gpgcheck=1 | ||
| enabled=1 | ||
| gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9 | ||
| https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9 | ||
| skip_if_unavailable=1 | ||
| REPOEOF3 | ||
| # --------------------------------------------------------------------------- | ||
| # 5. Bootstrap status POST helper (async, idempotent per stage, retry+backoff) | ||
| # --------------------------------------------------------------------------- | ||
| detect_ip() { | ||
| local ip="" | ||
| ip=$(ip addr show 2>/dev/null | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d/ -f1 | head -1) | ||
| [ -z "$ip" ] && ip=$(hostname -I 2>/dev/null | awk '{print $1}') | ||
| [ -z "$ip" ] && ip=$(grep -oP '32 host \K[0-9.]+' /proc/net/fib_trie 2>/dev/null | grep -v '127.0.0.1' | head -1) | ||
| [ -z "$ip" ] && ip="UNKNOWN" | ||
| echo "$ip" | ||
| } | ||
| cat > /etc/yum.repos.d/rocky-crb.repo << REPOEOF4 | ||
| [crb] | ||
| name=Rocky Linux 9 - CRB | ||
| baseurl=http://dl.rockylinux.org/pub/rocky/9/CRB/$REPO_ARCH/os/ | ||
| gpgcheck=1 | ||
| enabled=0 | ||
| gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9 | ||
| https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9 | ||
| skip_if_unavailable=1 | ||
| REPOEOF4 | ||
| detect_mac() { | ||
| local iface | ||
| iface=$(ip -o link show up 2>/dev/null | awk -F': ' '$2!="lo"{print $2; exit}') | ||
| [ -n "$iface" ] && cat "/sys/class/net/$iface/address" 2>/dev/null || echo "UNKNOWN" | ||
| } | ||
| cat > /etc/yum.repos.d/epel.repo << REPOEOF5 | ||
| [epel] | ||
| name=Extra Packages for Enterprise Linux 9 | ||
| metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-9&arch=$REPO_ARCH | ||
| gpgcheck=1 | ||
| enabled=1 | ||
| gpgkey=https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-9 | ||
| skip_if_unavailable=1 | ||
| REPOEOF5 | ||
| detect_vendor_model() { | ||
| local vendor product | ||
| vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null | tr -d '\n') | ||
| product=$(cat /sys/class/dmi/id/product_name 2>/dev/null | tr -d '\n') | ||
| echo "${vendor} ${product}" | sed 's/^ *//;s/ *$//' | ||
| } | ||
| rpm --import https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-9 2>/dev/null || \ | ||
| curl -fsSL https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-9 -o /etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-9 2>/dev/null || true | ||
| # Pure-bash HTTP POST over /dev/tcp. Last-resort transport when neither curl nor | ||
| # wget is present in the minimal Anaconda %pre environment. | ||
| _raw_http_post() { | ||
| local url="$1" body="$2" | ||
| local rest host port path | ||
| rest="${url#http://}" | ||
| path="/${rest#*/}" | ||
| rest="${rest%%/*}" | ||
| host="${rest%%:*}" | ||
| port="${rest##*:}" | ||
| [ "$host" = "$port" ] && port=80 | ||
| exec 3<>"/dev/tcp/${host}/${port}" 2>/dev/null || return 1 | ||
| printf 'POST %s HTTP/1.0\r\nHost: %s\r\nContent-Type: application/json\r\nContent-Length: %s\r\nConnection: close\r\n\r\n%s' \ | ||
| "$path" "$host" "${#body}" "$body" >&3 2>/dev/null || { exec 3>&- 2>/dev/null; return 1; } | ||
| # Read (and discard) the response so the server flushes; ignore failures. | ||
| timeout 8 cat <&3 >/dev/null 2>&1 | ||
| exec 3>&- 2>/dev/null | ||
| return 0 | ||
| } | ||
| dnf makecache --releasever=9 --quiet 2>/dev/null || dnf makecache --releasever=9 2>/dev/null || true | ||
| # post_status <stage> [extra_json_fields] | ||
| # Posts a lifecycle event to ${BOOTSTRAP_URL}/status. Tries curl, then wget, then | ||
| # a raw /dev/tcp HTTP POST so a missing http client never silently drops events. | ||
| # Retries with backoff but never blocks installation forever (capped attempts). | ||
| post_status() { | ||
| local stage="$1"; shift | ||
| local extra="$1" | ||
| if [ -z "$BOOTSTRAP_URL" ]; then | ||
| log "[post] BOOTSTRAP_URL unset, skipping stage=$stage" | ||
| return 0 | ||
| fi | ||
| # Install sudo | ||
| if ! command -v sudo >/dev/null 2>&1; then | ||
| dnf install -y --releasever=9 --nogpgcheck sudo 2>/dev/null || \ | ||
| dnf install -y --releasever=9 --nogpgcheck --disableplugin='*' sudo 2>/dev/null || true | ||
| local ip mac model ts payload url | ||
| ip=$(detect_ip) | ||
| mac=$(detect_mac) | ||
| model=$(detect_vendor_model) | ||
| ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) | ||
| payload="{\"stage\":\"${stage}\",\"workflowId\":\"${WORKFLOW_ID}\",\"hostname\":\"${TARGET_HOSTNAME}\",\"systemId\":\"${SYSTEM_ID}\",\"ip\":\"${ip}\",\"mac\":\"${mac}\",\"sshPort\":${SSH_PORT},\"hardware\":\"${model}\",\"timestamp\":\"${ts}\"${extra:+,${extra}}}" | ||
| url="${BOOTSTRAP_URL%/}/status" | ||
| # Reference feedback on the physical console for every lifecycle stage. | ||
| console_log "stage=${stage} ip=${ip}${extra:+ ${extra}}" | ||
| log "[post] stage=$stage -> $url (curl=$(command -v curl >/dev/null 2>&1 && echo y || echo n) wget=$(command -v wget >/dev/null 2>&1 && echo y || echo n))" | ||
| log "[post] payload=$payload" | ||
| local attempt=1 max=4 delay=3 | ||
| while [ "$attempt" -le "$max" ]; do | ||
| if command -v curl >/dev/null 2>&1 && \ | ||
| curl -fsS -m 10 -X POST -H 'Content-Type: application/json' -d "$payload" "$url" >> "$KS_LOG" 2>&1; then | ||
| log "[post] stage=$stage delivered via curl (attempt $attempt)" | ||
| return 0 | ||
| fi | ||
| if command -v wget >/dev/null 2>&1 && \ | ||
| wget -q -T 10 -O - --header='Content-Type: application/json' --post-data="$payload" "$url" >> "$KS_LOG" 2>&1; then | ||
| log "[post] stage=$stage delivered via wget (attempt $attempt)" | ||
| return 0 | ||
| fi | ||
| if _raw_http_post "$url" "$payload"; then | ||
| log "[post] stage=$stage delivered via /dev/tcp (attempt $attempt)" | ||
| return 0 | ||
| fi | ||
| log "[post] stage=$stage attempt $attempt/$max failed; retrying in ${delay}s" | ||
| sleep "$delay" | ||
| delay=$((delay * 2)) | ||
| attempt=$((attempt + 1)) | ||
| done | ||
| log "[post] stage=$stage giving up after $max attempts (non-fatal)" | ||
| return 1 | ||
| } | ||
| # Early heartbeat: announce the ephemeral runtime is up and that %pre reached the | ||
| # networking/POST stage. If even this never reaches the controller, the problem | ||
| # is transport/network — not disk detection or sshd. | ||
| post_status "ephemeral-boot" "\"detail\":\"%pre reached network/post stage\"" | ||
| # --------------------------------------------------------------------------- | ||
| # 6. Safe install-disk detection for a real physical machine | ||
| # --------------------------------------------------------------------------- | ||
| # Rules (deterministic): | ||
| # - honor INSTALL_DISK_HINT if it is a valid block device | ||
| # - only whole disks (TYPE=disk), writable (RO=0), non-removable (RM=0) | ||
| # - skip USB-attached media (TRAN=usb) and zram/loop/ram devices | ||
| # - skip the device backing the live/ephemeral root, if any is disk-backed | ||
| # - among the remaining, prefer internal bus order (nvme > sata/sas) then | ||
| # largest size, and pick the smallest stable /dev/disk/by-id name for it | ||
| detect_install_disk() { | ||
| if [ -n "$INSTALL_DISK_HINT" ] && [ -b "$INSTALL_DISK_HINT" ]; then | ||
| echo "$INSTALL_DISK_HINT" | ||
| return 0 | ||
| fi | ||
| # Device currently backing the live root filesystem (avoid wiping ourselves). | ||
| local live_src live_disk="" | ||
| live_src=$(findmnt -n -o SOURCE / 2>/dev/null) | ||
| if [ -n "$live_src" ] && [ -b "$live_src" ]; then | ||
| live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null | head -1) | ||
| [ -z "$live_disk" ] && live_disk=$(basename "$live_src") | ||
| fi | ||
| # Emit "score name size" per candidate, sort, take the top. | ||
| local best="" | ||
| best=$(lsblk -dn -o NAME,TYPE,SIZE,RM,RO,TRAN -b 2>/dev/null | while read -r name type size rm ro tran; do | ||
| [ "$type" = "disk" ] || continue | ||
| [ "$ro" = "0" ] || continue | ||
| [ "$rm" = "0" ] || continue | ||
| case "$name" in | ||
| loop*|ram*|zram*|sr*|fd*) continue ;; | ||
| esac | ||
| [ "$tran" = "usb" ] && continue | ||
| [ -n "$live_disk" ] && [ "$name" = "$live_disk" ] && continue | ||
| # Bus preference weight (higher = preferred), then size as tiebreaker. | ||
| local weight=0 | ||
| case "$tran" in | ||
| nvme) weight=3 ;; | ||
| sata|sas|ata) weight=2 ;; | ||
| *) weight=1 ;; | ||
| esac | ||
| # score = weight * 1e15 + size ; keeps weight dominant, size as tiebreak | ||
| printf '%d %s\n' "$(( weight * 1000000000000000 + ${size:-0} ))" "/dev/$name" | ||
| done | sort -rn | head -1 | awk '{print $2}') | ||
| [ -z "$best" ] && return 1 | ||
| # Prefer a stable /dev/disk/by-id symlink for the chosen device. | ||
| local byid | ||
| byid=$(for l in /dev/disk/by-id/*; do | ||
| [ -e "$l" ] || continue | ||
| case "$l" in *-part*) continue ;; esac | ||
| if [ "$(readlink -f "$l")" = "$(readlink -f "$best")" ]; then | ||
| echo "$l" | ||
| fi | ||
| done 2>/dev/null | grep -v '/wwn-' | head -1) | ||
| [ -n "$byid" ] && echo "$byid" || echo "$best" | ||
| } | ||
| # --------------------------------------------------------------------------- | ||
| # 7. Write the unattended installer (run by the controller over SSH, or by the | ||
| # AUTO_INSTALL fallback). It detects the disk, installs Rocky 9, posts | ||
| # completion, and reboots into the deployed OS. | ||
| # --------------------------------------------------------------------------- | ||
| mkdir -p /usr/local/bin | ||
| cat > "$INSTALLER" << INSTALLEOF | ||
| #!/bin/bash | ||
| # Underpost unattended Rocky Linux 9 installer (runs inside the ephemeral env). | ||
| set +e | ||
| KS_LOG="$KS_LOG" | ||
| INSTALL_LOCK="$INSTALL_LOCK" | ||
| INSTALL_DONE="$INSTALL_DONE" | ||
| TARGET_MNT="$TARGET_MNT" | ||
| AUTHORIZED_KEYS='$AUTHORIZED_KEYS' | ||
| ADMIN_USER='$ADMIN_USER' | ||
| DEPLOY_USER='$DEPLOY_USER' | ||
| TARGET_HOSTNAME='$TARGET_HOSTNAME' | ||
| NET_IP='$NET_IP' | ||
| NET_PREFIX='$NET_PREFIX' | ||
| NET_GATEWAY='$NET_GATEWAY' | ||
| NET_DNS='$NET_DNS' | ||
| TIMEZONE='$TIMEZONE' | ||
| KEYBOARD_LAYOUT='$KEYBOARD_LAYOUT' | ||
| CHRONY_CONF_PATH='$CHRONY_CONF_PATH' | ||
| # Passwords are carried base64-encoded so any character survives intact. Decoding | ||
| # tries base64 then python3 so a minimal environment never yields empty passwords. | ||
| ks_b64d() { printf %s "\$1" | base64 -d 2>/dev/null || printf %s "\$1" | python3 -c 'import sys,base64;sys.stdout.buffer.write(base64.b64decode(sys.stdin.read().strip()))' 2>/dev/null; } | ||
| ROOT_PASS_B64='$ROOT_PASS_B64' | ||
| ADMIN_PASS_B64='$ADMIN_PASS_B64' | ||
| DEPLOY_PASS_B64='$DEPLOY_PASS_B64' | ||
| ROOT_PASS="\$(ks_b64d "\$ROOT_PASS_B64")" | ||
| ADMIN_PASS="\$(ks_b64d "\$ADMIN_PASS_B64")" | ||
| DEPLOY_PASS="\$(ks_b64d "\$DEPLOY_PASS_B64")" | ||
| [ -z "\$ADMIN_PASS" ] && ADMIN_PASS="\$ROOT_PASS" | ||
| [ -z "\$DEPLOY_PASS" ] && DEPLOY_PASS="\$ROOT_PASS" | ||
| ilog() { | ||
| echo "\$(date): [install] \$*" | tee -a "\$KS_LOG" | ||
| printf "[underpost-install] %s\r\n" "\$*" > /dev/console 2>/dev/null || true | ||
| } | ||
| # Prove the installer actually launched (distinguishes "never started" from | ||
| # "started and exited early"). Posted before any guard so it always fires once. | ||
| ilog "installer process started (pid \$\$)" | ||
| post_status "install-start" "\"detail\":\"installer launched\"" | ||
| # Single-flight guard. | ||
| if [ -f "\$INSTALL_LOCK" ]; then | ||
| ilog "installer already running (lock present), exiting" | ||
| exit 0 | ||
| fi | ||
| if [ -f "\$INSTALL_DONE" ]; then | ||
| ilog "install already completed, exiting" | ||
| exit 0 | ||
| fi | ||
| touch "\$INSTALL_LOCK" | ||
| if command -v sudo >/dev/null 2>&1; then | ||
| mkdir -p /etc/sudoers.d | ||
| echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$ADMIN_USER | ||
| chmod 0440 /etc/sudoers.d/$ADMIN_USER | ||
| post_status "installing" "\"detail\":\"partitioning\"" | ||
| DISK="\$(detect_install_disk)" | ||
| if [ -z "\$DISK" ] || [ ! -b "\$DISK" ]; then | ||
| ilog "FATAL: no valid install disk detected" | ||
| post_status "failed" "\"detail\":\"no install disk detected\"" | ||
| rm -f "\$INSTALL_LOCK" | ||
| exit 1 | ||
| fi | ||
| REAL_DISK="\$(readlink -f "\$DISK")" | ||
| ilog "Selected install disk: \$DISK (\$REAL_DISK)" | ||
| post_status "installing" "\"detail\":\"target \$REAL_DISK\",\"disk\":\"\$REAL_DISK\"" | ||
| cat > /home/$ADMIN_USER/.bash_profile 2>/dev/null << PROFILEEOF | ||
| if ! command -v sudo >/dev/null 2>&1; then | ||
| echo "" | ||
| echo "NOTE: sudo is not available. Use 'su -' to switch to root." | ||
| echo "" | ||
| # Detect firmware mode. | ||
| if [ -d /sys/firmware/efi ]; then | ||
| FW="uefi" | ||
| else | ||
| FW="bios" | ||
| fi | ||
| PROFILEEOF | ||
| chown $ADMIN_USER:$ADMIN_USER /home/$ADMIN_USER/.bash_profile 2>/dev/null || true | ||
| ilog "Firmware mode: \$FW" | ||
| # Restart sshd | ||
| if command -v systemctl >/dev/null 2>&1; then | ||
| systemctl restart sshd 2>/dev/null || systemctl restart sshd.service 2>/dev/null | ||
| # Partition naming helper (nvme0n1p1 vs sda1). | ||
| part() { | ||
| case "\$REAL_DISK" in | ||
| *[0-9]) echo "\${REAL_DISK}p\$1" ;; | ||
| *) echo "\${REAL_DISK}\$1" ;; | ||
| esac | ||
| } | ||
| ilog "Wiping and partitioning \$REAL_DISK ..." | ||
| umount -R "\$TARGET_MNT" 2>/dev/null || true | ||
| swapoff -a 2>/dev/null || true | ||
| wipefs -a "\$REAL_DISK" >> "\$KS_LOG" 2>&1 | ||
| sgdisk --zap-all "\$REAL_DISK" >> "\$KS_LOG" 2>&1 || dd if=/dev/zero of="\$REAL_DISK" bs=1M count=10 2>/dev/null | ||
| if [ "\$FW" = "uefi" ]; then | ||
| sgdisk -n 1:0:+600M -t 1:ef00 -c 1:"EFI System" "\$REAL_DISK" >> "\$KS_LOG" 2>&1 | ||
| sgdisk -n 2:0:+1024M -t 2:8300 -c 2:"boot" "\$REAL_DISK" >> "\$KS_LOG" 2>&1 | ||
| sgdisk -n 3:0:0 -t 3:8300 -c 3:"root" "\$REAL_DISK" >> "\$KS_LOG" 2>&1 | ||
| EFI_PART="\$(part 1)"; BOOT_PART="\$(part 2)"; ROOT_PART="\$(part 3)" | ||
| else | ||
| sgdisk -n 1:0:+2M -t 1:ef02 -c 1:"BIOS boot" "\$REAL_DISK" >> "\$KS_LOG" 2>&1 | ||
| sgdisk -n 2:0:+1024M -t 2:8300 -c 2:"boot" "\$REAL_DISK" >> "\$KS_LOG" 2>&1 | ||
| sgdisk -n 3:0:0 -t 3:8300 -c 3:"root" "\$REAL_DISK" >> "\$KS_LOG" 2>&1 | ||
| BOOT_PART="\$(part 2)"; ROOT_PART="\$(part 3)" | ||
| fi | ||
| SSHD_PID=$(cat /var/run/sshd.pid 2>/dev/null || pidof sshd 2>/dev/null | awk '{print $1}') | ||
| [ -n "$SSHD_PID" ] && kill -HUP "$SSHD_PID" 2>/dev/null | ||
| if ! pidof sshd >/dev/null 2>&1; then | ||
| /usr/sbin/sshd 2>/dev/null || sshd 2>/dev/null | ||
| partprobe "\$REAL_DISK" >> "\$KS_LOG" 2>&1 || true | ||
| udevadm settle 2>/dev/null || sleep 3 | ||
| ilog "Creating filesystems ..." | ||
| [ "\$FW" = "uefi" ] && mkfs.vfat -F32 "\$EFI_PART" >> "\$KS_LOG" 2>&1 | ||
| mkfs.xfs -f "\$BOOT_PART" >> "\$KS_LOG" 2>&1 | ||
| mkfs.xfs -f "\$ROOT_PART" >> "\$KS_LOG" 2>&1 | ||
| ilog "Mounting target ..." | ||
| mkdir -p "\$TARGET_MNT" | ||
| mount "\$ROOT_PART" "\$TARGET_MNT" || { ilog "FATAL: mount root failed"; post_status "failed" "\"detail\":\"mount root failed\""; rm -f "\$INSTALL_LOCK"; exit 1; } | ||
| mkdir -p "\$TARGET_MNT/boot" | ||
| mount "\$BOOT_PART" "\$TARGET_MNT/boot" | ||
| if [ "\$FW" = "uefi" ]; then | ||
| mkdir -p "\$TARGET_MNT/boot/efi" | ||
| mount "\$EFI_PART" "\$TARGET_MNT/boot/efi" | ||
| fi | ||
| # 6. Status report | ||
| DETECTED_IP=$(ip -4 addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d/ -f1 | head -1) | ||
| post_status "installing" "\"detail\":\"installing packages\",\"disk\":\"\$REAL_DISK\"" | ||
| ilog "Installing base packages with dnf --installroot ..." | ||
| PKGS="@core kernel grub2-tools openssh-server NetworkManager chrony dracut-config-generic rocky-release" | ||
| if [ "\$FW" = "uefi" ]; then | ||
| PKGS="\$PKGS grub2-efi-x64 shim-x64 efibootmgr" | ||
| else | ||
| PKGS="\$PKGS grub2-pc" | ||
| fi | ||
| dnf -y --installroot="\$TARGET_MNT" --releasever=9 \ | ||
| --setopt=install_weak_deps=False --nogpgcheck \ | ||
| install \$PKGS >> "\$KS_LOG" 2>&1 | ||
| DNF_RC=\$? | ||
| if [ "\$DNF_RC" -ne 0 ]; then | ||
| ilog "FATAL: dnf install failed (rc=\$DNF_RC)" | ||
| post_status "failed" "\"detail\":\"dnf install rc=\$DNF_RC\"" | ||
| rm -f "\$INSTALL_LOCK" | ||
| exit 1 | ||
| fi | ||
| ilog "Generating fstab ..." | ||
| ROOT_UUID=\$(blkid -s UUID -o value "\$ROOT_PART") | ||
| BOOT_UUID=\$(blkid -s UUID -o value "\$BOOT_PART") | ||
| { | ||
| echo "UUID=\$ROOT_UUID / xfs defaults 0 0" | ||
| echo "UUID=\$BOOT_UUID /boot xfs defaults 0 0" | ||
| if [ "\$FW" = "uefi" ]; then | ||
| EFI_UUID=\$(blkid -s UUID -o value "\$EFI_PART") | ||
| echo "UUID=\$EFI_UUID /boot/efi vfat umask=0077,shortname=winnt 0 2" | ||
| fi | ||
| } > "\$TARGET_MNT/etc/fstab" | ||
| # Bind mounts for chroot. | ||
| for d in dev proc sys run; do mount --bind /\$d "\$TARGET_MNT/\$d"; done | ||
| ilog "Configuring installed system (hostname, network, ssh, users) ..." | ||
| echo "\${TARGET_HOSTNAME:-rocky9}" > "\$TARGET_MNT/etc/hostname" | ||
| chroot "\$TARGET_MNT" /bin/bash -s << CHROOTEOF >> "\$KS_LOG" 2>&1 | ||
| set +e | ||
| systemctl enable sshd NetworkManager chronyd 2>/dev/null | ||
| # SELinux -> permissive in the DEPLOYED OS. Critical: with SELinux enforcing on | ||
| # first boot, files written from this installer chroot (authorized_keys, home | ||
| # dirs) carry the wrong security context, so sshd drops sessions ("Connection | ||
| # reset by peer") and PAM can block console logins. Permissive ignores contexts | ||
| # (no relabel reboot needed) and matches what kubeadm/cluster.js expect. | ||
| sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config 2>/dev/null || true | ||
| # Ensure sshd host keys exist so sshd actually starts on first boot. | ||
| ssh-keygen -A 2>/dev/null || true | ||
| # Disable firewalld so the controller can reach sshd on first boot. | ||
| systemctl disable firewalld 2>/dev/null || true | ||
| systemctl mask firewalld 2>/dev/null || true | ||
| # Static networking bound to the primary wired NIC by MAC so the deployed OS is | ||
| # reachable at the same IP the commission used (NetworkManager keyfile). | ||
| if [ -n "\${NET_IP}" ]; then | ||
| PRIMARY_IF=\\\$(ls /sys/class/net | grep -E '^(en|eth)' | head -1) | ||
| PRIMARY_MAC=\\\$(cat /sys/class/net/\\\$PRIMARY_IF/address 2>/dev/null) | ||
| mkdir -p /etc/NetworkManager/system-connections | ||
| NMFILE=/etc/NetworkManager/system-connections/underpost.nmconnection | ||
| { | ||
| echo "[connection]" | ||
| echo "id=underpost" | ||
| echo "type=ethernet" | ||
| echo "autoconnect=true" | ||
| echo "autoconnect-priority=999" | ||
| echo "[ethernet]" | ||
| [ -n "\\\$PRIMARY_MAC" ] && echo "mac-address=\\\$PRIMARY_MAC" | ||
| echo "[ipv4]" | ||
| echo "method=manual" | ||
| echo "addresses=\${NET_IP}/\${NET_PREFIX}" | ||
| echo "gateway=\${NET_GATEWAY}" | ||
| echo "dns=\${NET_DNS};" | ||
| echo "[ipv6]" | ||
| echo "method=ignore" | ||
| } > "\\\$NMFILE" | ||
| chmod 600 "\\\$NMFILE" | ||
| fi | ||
| # Timezone + NTP (chrony) for the deployed OS. Mirrors the Rocky branch of | ||
| # src/cli/system.js (rocky.timezone): localtime symlink, /etc/timezone, a chrony | ||
| # config with a local + public NTP pool, and chronyd enabled on boot. | ||
| if [ -n "\${TIMEZONE}" ]; then | ||
| ln -sf /usr/share/zoneinfo/\${TIMEZONE} /etc/localtime | ||
| echo "\${TIMEZONE}" > /etc/timezone | ||
| timedatectl set-timezone \${TIMEZONE} 2>/dev/null || true | ||
| { | ||
| echo "# Underpost-managed chrony configuration" | ||
| [ -n "\${NET_GATEWAY}" ] && echo "server \${NET_GATEWAY} iburst prefer" | ||
| echo "server 0.pool.ntp.org iburst" | ||
| echo "server 1.pool.ntp.org iburst" | ||
| echo "server 2.pool.ntp.org iburst" | ||
| echo "server 3.pool.ntp.org iburst" | ||
| echo "driftfile /var/lib/chrony/drift" | ||
| echo "makestep 1.0 3" | ||
| echo "rtcsync" | ||
| echo "logdir /var/log/chrony" | ||
| } > "\${CHRONY_CONF_PATH:-/etc/chrony.conf}" | ||
| systemctl enable chronyd 2>/dev/null || true | ||
| fi | ||
| # Keyboard layout + locale for the deployed OS. Mirrors the Rocky branch of | ||
| # src/cli/system.js (rocky.keyboard): vconsole.conf, locale.conf, X11 keymap. | ||
| if [ -n "\${KEYBOARD_LAYOUT}" ]; then | ||
| echo "KEYMAP=\${KEYBOARD_LAYOUT}" > /etc/vconsole.conf | ||
| echo "FONT=latarcyrheb-sun16" >> /etc/vconsole.conf | ||
| echo "LANG=en_US.UTF-8" > /etc/locale.conf | ||
| mkdir -p /etc/X11/xorg.conf.d | ||
| { | ||
| echo 'Section "InputClass"' | ||
| echo ' Identifier "system-keyboard"' | ||
| echo ' MatchIsKeyboard "on"' | ||
| echo ' Option "XkbLayout" "\${KEYBOARD_LAYOUT}"' | ||
| echo 'EndSection' | ||
| } > /etc/X11/xorg.conf.d/00-keyboard.conf | ||
| localectl set-keymap \${KEYBOARD_LAYOUT} 2>/dev/null || true | ||
| localectl set-x11-keymap \${KEYBOARD_LAYOUT} 2>/dev/null || true | ||
| fi | ||
| # Key-only SSH on the installed system. | ||
| mkdir -p /etc/ssh/sshd_config.d | ||
| cat > /etc/ssh/sshd_config.d/00-underpost.conf <<SSHDEOF | ||
| PermitRootLogin prohibit-password | ||
| PubkeyAuthentication yes | ||
| PasswordAuthentication no | ||
| SSHDEOF | ||
| # Create the admin login accounts. create_user <name> writes the authorized key, | ||
| # wheel membership and passwordless sudo. Passwords are applied after the chroot. | ||
| create_user() { | ||
| [ -z "\\\$1" ] && return 0 | ||
| id "\\\$1" >/dev/null 2>&1 || useradd -m -G wheel "\\\$1" | ||
| usermod -aG wheel "\\\$1" 2>/dev/null || true | ||
| mkdir -p /home/\\\$1/.ssh && chmod 700 /home/\\\$1/.ssh | ||
| cat > /home/\\\$1/.ssh/authorized_keys <<KEYEOF | ||
| \${AUTHORIZED_KEYS} | ||
| KEYEOF | ||
| chmod 600 /home/\\\$1/.ssh/authorized_keys | ||
| chown -R "\\\$1:\\\$1" /home/\\\$1/.ssh | ||
| echo "\\\$1 ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/\\\$1 | ||
| chmod 0440 /etc/sudoers.d/\\\$1 | ||
| } | ||
| mkdir -p /root/.ssh && chmod 700 /root/.ssh | ||
| cat > /root/.ssh/authorized_keys <<KEYEOF3 | ||
| \${AUTHORIZED_KEYS} | ||
| KEYEOF3 | ||
| chmod 600 /root/.ssh/authorized_keys | ||
| create_user "\${ADMIN_USER}" | ||
| create_user "\${DEPLOY_USER}" | ||
| # Build initramfs for the installed kernel. | ||
| KVER=\\\$(ls /lib/modules | head -1) | ||
| [ -n "\\\$KVER" ] && dracut -f /boot/initramfs-\\\$KVER.img "\\\$KVER" | ||
| CHROOTEOF | ||
| # Set console passwords AFTER the chroot heredoc, from decoded installer vars. | ||
| # Primary path: pipe 'user:password' into 'chroot chpasswd' (keeps password | ||
| # characters out of any heredoc). Fallback: hash with openssl and apply via | ||
| # 'usermod -p' so a chpasswd/PAM quirk in the installroot never leaves an | ||
| # account locked. Verifies the shadow field is a real hash afterwards. | ||
| set_password() { | ||
| local u="\$1" p="\$2" | ||
| [ -z "\$u" ] || [ -z "\$p" ] && return 0 | ||
| printf '%s:%s\n' "\$u" "\$p" | chroot "\$TARGET_MNT" chpasswd 2>>"\$KS_LOG" | ||
| local field | ||
| field=\$(chroot "\$TARGET_MNT" getent shadow "\$u" 2>/dev/null | cut -d: -f2) | ||
| case "\$field" in | ||
| '\$'*) ilog "\$u password set (chpasswd)"; return 0 ;; | ||
| esac | ||
| local hash | ||
| hash=\$(printf %s "\$p" | openssl passwd -6 -stdin 2>/dev/null) | ||
| if [ -n "\$hash" ]; then | ||
| chroot "\$TARGET_MNT" usermod -p "\$hash" "\$u" 2>>"\$KS_LOG" && ilog "\$u password set (openssl hash fallback)" | ||
| else | ||
| ilog "WARNING: failed to set password for \$u" | ||
| fi | ||
| } | ||
| set_password root "\$ROOT_PASS" | ||
| set_password "\$ADMIN_USER" "\$ADMIN_PASS" | ||
| set_password "\$DEPLOY_USER" "\$DEPLOY_PASS" | ||
| post_status "installing" "\"detail\":\"installing bootloader\",\"disk\":\"\$REAL_DISK\"" | ||
| ilog "Installing bootloader (\$FW) on \$REAL_DISK ..." | ||
| if [ "\$FW" = "uefi" ]; then | ||
| chroot "\$TARGET_MNT" grub2-mkconfig -o /boot/grub2/grub.cfg >> "\$KS_LOG" 2>&1 | ||
| chroot "\$TARGET_MNT" mkdir -p /boot/efi/EFI/rocky | ||
| chroot "\$TARGET_MNT" grub2-mkconfig -o /boot/efi/EFI/rocky/grub.cfg >> "\$KS_LOG" 2>&1 | ||
| # Drop any stale Rocky Linux UEFI entries to avoid duplicates. | ||
| for b in \$(chroot "\$TARGET_MNT" efibootmgr 2>/dev/null | awk '/Rocky Linux/{print substr(\$1,5,4)}'); do | ||
| chroot "\$TARGET_MNT" efibootmgr -b "\$b" -B >> "\$KS_LOG" 2>&1 | ||
| done | ||
| # Create the boot entry (also prepends it to BootOrder). | ||
| chroot "\$TARGET_MNT" efibootmgr -c -d "\$REAL_DISK" -p 1 -L "Rocky Linux" -l "\\\\EFI\\\\rocky\\\\shimx64.efi" >> "\$KS_LOG" 2>&1 | ||
| BOOT_RC=\$? | ||
| # Set BootNext so the immediate post-install reboot boots the DISK, not the | ||
| # USB iPXE installer — this overrides a USB-first firmware order for one boot, | ||
| # so no manual BIOS change is needed to land in the freshly installed OS. | ||
| NEW_BOOT=\$(chroot "\$TARGET_MNT" efibootmgr 2>/dev/null | awk '/Rocky Linux/{print substr(\$1,5,4); exit}') | ||
| if [ -n "\$NEW_BOOT" ]; then | ||
| chroot "\$TARGET_MNT" efibootmgr -n "\$NEW_BOOT" >> "\$KS_LOG" 2>&1 | ||
| ilog "Set UEFI BootNext=\$NEW_BOOT (Rocky Linux on \$REAL_DISK); remove USB after this boot to stay on disk" | ||
| fi | ||
| else | ||
| chroot "\$TARGET_MNT" /bin/bash -c "grub2-install --target=i386-pc \$REAL_DISK && \ | ||
| grub2-mkconfig -o /boot/grub2/grub.cfg" >> "\$KS_LOG" 2>&1 | ||
| BOOT_RC=\$? | ||
| fi | ||
| # Teardown mounts. | ||
| for d in dev proc sys run; do umount -l "\$TARGET_MNT/\$d" 2>/dev/null; done | ||
| umount -R "\$TARGET_MNT" 2>/dev/null || true | ||
| if [ "\$BOOT_RC" -ne 0 ]; then | ||
| ilog "FATAL: bootloader install failed (rc=\$BOOT_RC)" | ||
| post_status "failed" "\"detail\":\"bootloader rc=\$BOOT_RC\"" | ||
| rm -f "\$INSTALL_LOCK" | ||
| exit 1 | ||
| fi | ||
| touch "\$INSTALL_DONE" | ||
| ilog "Install completed on \$REAL_DISK. Rebooting into deployed OS (BootNext set to disk)." | ||
| ilog "If it boots the USB again, remove the USB stick now — the OS is on \$REAL_DISK." | ||
| ilog "Console login: 'root', '\${ADMIN_USER}'\$([ -n "\$DEPLOY_USER" ] && echo " or '\$DEPLOY_USER'") (password \$([ -n "\$ROOT_PASS" ] && echo set || echo NOT-set)). SSH is key-only at \${NET_IP:-dhcp}." | ||
| post_status "completed" "\"detail\":\"install ok, rebooting into disk\",\"disk\":\"\$REAL_DISK\",\"loginUser\":\"\${ADMIN_USER}\",\"deployUser\":\"\${DEPLOY_USER}\",\"passwordSet\":\$([ -n "\$ROOT_PASS" ] && echo true || echo false),\"staticIp\":\"\${NET_IP}\",\"bootNext\":\"\${NEW_BOOT:-}\"" | ||
| rm -f "\$INSTALL_LOCK" | ||
| sync | ||
| sleep 3 | ||
| reboot -f || systemctl reboot || echo b > /proc/sysrq-trigger | ||
| INSTALLEOF | ||
| chmod +x "$INSTALLER" | ||
| log "[install] Wrote unattended installer to $INSTALLER" | ||
| # Export helpers used by the installer (it sources nothing; functions are | ||
| # re-declared above). The installer relies on post_status/detect_install_disk | ||
| # being defined in its own process, so embed minimal copies inline. | ||
| # To keep one source of truth, re-export them via a sourced fragment. | ||
| cat > /usr/local/bin/underpost-install-helpers.sh << HELPEREOF | ||
| $(declare -f log) | ||
| $(declare -f console_log) | ||
| $(declare -f detect_ip) | ||
| $(declare -f detect_mac) | ||
| $(declare -f detect_vendor_model) | ||
| $(declare -f post_status) | ||
| $(declare -f detect_install_disk) | ||
| BOOTSTRAP_URL='$BOOTSTRAP_URL' | ||
| WORKFLOW_ID='$WORKFLOW_ID' | ||
| SYSTEM_ID='$SYSTEM_ID' | ||
| TARGET_HOSTNAME='$TARGET_HOSTNAME' | ||
| SSH_PORT='$SSH_PORT' | ||
| INSTALL_DISK_HINT='$INSTALL_DISK_HINT' | ||
| HELPEREOF | ||
| # Prepend a source of the helpers into the installer so its post_status / | ||
| # detect_install_disk calls resolve. | ||
| sed -i "2i source /usr/local/bin/underpost-install-helpers.sh" "$INSTALLER" | ||
| # --------------------------------------------------------------------------- | ||
| # 8. Physical console banner + SSH-ready handshake | ||
| # --------------------------------------------------------------------------- | ||
| DETECTED_IP=$(detect_ip) | ||
| echo "" | ||
@@ -262,37 +859,132 @@ echo "==============================================" | ||
| echo "==============================================" | ||
| echo "Root login: root / (password set: $([ -n "$ROOT_PASS" ] && echo 'yes' || echo 'no'))" | ||
| echo "Admin user: $ADMIN_USER / (password set: $([ -n "$ROOT_PASS" ] && echo 'yes' || echo 'no'))" | ||
| echo "Root login: root (key-only: $([ -n "$AUTHORIZED_KEYS" ] && echo yes || echo no))" | ||
| echo "Admin user: $ADMIN_USER" | ||
| echo "SSH keys: $([ -n "$AUTHORIZED_KEYS" ] && echo 'configured' || echo 'NOT configured')" | ||
| echo "sshd status: $(pidof sshd >/dev/null 2>&1 && echo "running (pid $(pidof sshd))" || echo 'NOT running')" | ||
| echo "sudo: $(command -v sudo >/dev/null 2>&1 && echo 'installed' || echo 'NOT available (use su -)')" | ||
| echo "IP address: $DETECTED_IP" | ||
| echo "Installer: $INSTALLER" | ||
| echo "==============================================" | ||
| # Physical console display (printf with \r\n for proper line breaks on serial/physical consoles) | ||
| { | ||
| printf "\r\n" | ||
| printf "██╗░░░██╗███╗░░██╗██████╗░███████╗██████╗░██████╗░░█████╗░░██████╗████████╗\r\n" | ||
| printf "██║░░░██║████╗░██║██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝╚══██╔══╝\r\n" | ||
| printf "██║░░░██║██╔██╗██║██║░░██║█████╗░░██████╔╝██████╔╝██║░░██║╚█████╗░░░░██║░░░\r\n" | ||
| printf "██║░░░██║██║╚████║██║░░██║██╔══╝░░██╔══██╗██╔═══╝░██║░░██║░╚═══██╗░░░██║░░░\r\n" | ||
| printf "╚██████╔╝██║░╚███║██████╔╝███████╗██║░░██║██║░░░░░╚█████╔╝██████╔╝░░░██║░░░\r\n" | ||
| printf "░╚═════╝░╚═╝░░╚══╝╚═════╝░╚══════╝╚═╝░░╚═╝╚═╝░░░░░░╚════╝░╚═════╝░░░░╚═╝░░░\r\n" | ||
| printf "==============================================\r\n" | ||
| printf " Underpost Ephemeral Commissioning Active\r\n" | ||
| printf "==============================================\r\n" | ||
| printf " SSH as: root@%s\r\n" "$DETECTED_IP" | ||
| printf " or: %s@%s\r\n" "$ADMIN_USER" "$DETECTED_IP" | ||
| printf " Key: %s\r\n" "$([ -n "$AUTHORIZED_KEYS" ] && echo 'pubkey auth enabled' || echo 'password only')" | ||
| printf " dnf: ready (BaseOS, AppStream, Extras, EPEL)\r\n" | ||
| printf "==============================================\r\n" | ||
| printf "\r\n" | ||
| printf "\r\n" | ||
| printf "██╗░░░██╗███╗░░██╗██████╗░███████╗██████╗░██████╗░░█████╗░░██████╗████████╗\r\n" | ||
| printf "██║░░░██║████╗░██║██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝╚══██╔══╝\r\n" | ||
| printf "██║░░░██║██╔██╗██║██║░░██║█████╗░░██████╔╝██████╔╝██║░░██║╚█████╗░░░░██║░░░\r\n" | ||
| printf "██║░░░██║██║╚████║██║░░██║██╔══╝░░██╔══██╗██╔═══╝░██║░░██║░╚═══██╗░░░██║░░░\r\n" | ||
| printf "╚██████╔╝██║░╚███║██████╔╝███████╗██║░░██║██║░░░░░╚█████╔╝██████╔╝░░░██║░░░\r\n" | ||
| printf "░╚═════╝░╚═╝░░╚══╝╚═════╝░╚══════╝╚═╝░░╚═╝╚═╝░░░░░░╚════╝░╚═════╝░░░░╚═╝░░░\r\n" | ||
| printf "==============================================\r\n" | ||
| printf " Underpost Network Ephemeral Commissioning Active\r\n" | ||
| printf "==============================================\r\n" | ||
| printf " SSH as: root@%s -p %s (key-only)\r\n" "$DETECTED_IP" "$SSH_PORT" | ||
| printf " Stage: awaiting remote install trigger\r\n" | ||
| printf " Disk: auto-detect (hint: %s)\r\n" "${INSTALL_DISK_HINT:-none}" | ||
| printf "==============================================\r\n" | ||
| printf "\r\n" | ||
| } > /dev/console 2>/dev/null || true | ||
| # 7. Infinite wait loop - keep Anaconda live environment active | ||
| # Best-effort check that sshd is accepting connections on the configured port. | ||
| # Reported as metadata in the ssh-ready event but does NOT gate the POST: the | ||
| # controller needs the announcement regardless, and inst.sshd may name/track the | ||
| # daemon in ways pidof misses. | ||
| ssh_is_ready() { | ||
| if command -v ss >/dev/null 2>&1; then | ||
| ss -lnt 2>/dev/null | grep -q ":${SSH_PORT} " && return 0 | ||
| fi | ||
| if command -v netstat >/dev/null 2>&1; then | ||
| netstat -lnt 2>/dev/null | grep -q ":${SSH_PORT} " && return 0 | ||
| fi | ||
| timeout 3 bash -c "</dev/tcp/127.0.0.1/${SSH_PORT}" >/dev/null 2>&1 | ||
| } | ||
| # Give sshd a brief window to come up, but never block the announcement. | ||
| for _ in $(seq 1 15); do | ||
| ssh_is_ready && break | ||
| sleep 2 | ||
| done | ||
| SSHD_LISTENING=$(ssh_is_ready && echo true || echo false) | ||
| SSHD_PID=$(pidof sshd 2>/dev/null | awk '{print $1}') | ||
| log "[ready] sshd_listening=${SSHD_LISTENING} sshd_pid=${SSHD_PID:-none} port=${SSH_PORT}" | ||
| # Idempotent: only POST ssh-ready once per boot/session. Posted unconditionally | ||
| # (best effort) so a strict readiness probe never swallows the handshake. | ||
| if [ ! -f "$READY_FLAG" ]; then | ||
| if post_status "ssh-ready" "\"detail\":\"ephemeral runtime ready\",\"sshdListening\":${SSHD_LISTENING},\"sshdPid\":\"${SSHD_PID:-}\",\"installer\":\"${INSTALLER}\""; then | ||
| touch "$READY_FLAG" | ||
| log "[ready] ssh-ready handshake posted" | ||
| else | ||
| log "[ready] ssh-ready POST failed; will retry in lifecycle loop" | ||
| fi | ||
| fi | ||
| # --------------------------------------------------------------------------- | ||
| # 9. Lifecycle wait loop | ||
| # - keep sshd alive (inst.sshd watchdog should also handle this) | ||
| # - run the installer when the controller drops a trigger file over SSH, | ||
| # e.g. ssh root@host 'touch /tmp/.underpost-install-trigger' | ||
| # or directly: ssh root@host '/usr/local/bin/underpost-install.sh' | ||
| # - AUTO_INSTALL fallback self-triggers after AUTO_INSTALL_FALLBACK_SECONDS | ||
| # --------------------------------------------------------------------------- | ||
| INSTALL_LOG=/tmp/underpost-install.log | ||
| LAUNCHED_FLAG=/tmp/.underpost-install-launched | ||
| # Launch the installer fully detached from this loop process via setsid + closed | ||
| # stdio, so it keeps running independently of any sshd session or signal. Guarded | ||
| # by LAUNCHED_FLAG so trigger + fallback never double-launch. | ||
| launch_installer() { | ||
| [ -f "$LAUNCHED_FLAG" ] && return 0 | ||
| touch "$LAUNCHED_FLAG" | ||
| log "[loop] launching installer ($INSTALLER), log -> $INSTALL_LOG" | ||
| console_log "remote command received: starting disk install ($INSTALLER)" | ||
| if command -v setsid >/dev/null 2>&1; then | ||
| setsid bash "$INSTALLER" >> "$INSTALL_LOG" 2>&1 < /dev/null & | ||
| else | ||
| nohup bash "$INSTALLER" >> "$INSTALL_LOG" 2>&1 < /dev/null & | ||
| fi | ||
| } | ||
| START_TS=$(date +%s) | ||
| LOOP_N=0 | ||
| while true; do | ||
| sleep 60 | ||
| if ! pidof sshd >/dev/null 2>&1; then | ||
| echo "$(date): sshd not running, restarting..." >> /tmp/ks-pre.log | ||
| /usr/sbin/sshd 2>/dev/null || sshd 2>/dev/null | ||
| fi | ||
| [ -f "$INSTALL_DONE" ] && { log "[loop] install done; idle"; sleep 60; continue; } | ||
| LOOP_N=$((LOOP_N + 1)) | ||
| # Keep retrying the ssh-ready handshake until it is acknowledged so a flaky | ||
| # first POST never strands the controller. | ||
| if [ ! -f "$READY_FLAG" ]; then | ||
| if post_status "ssh-ready" "\"detail\":\"retry from lifecycle loop\",\"installer\":\"${INSTALLER}\""; then | ||
| touch "$READY_FLAG" | ||
| log "[loop] ssh-ready handshake posted (retry)" | ||
| fi | ||
| fi | ||
| # Periodic heartbeat so the controller log shows the runtime is alive while | ||
| # it waits for a trigger. | ||
| if [ "$((LOOP_N % 4))" -eq 0 ]; then | ||
| post_status "heartbeat" "\"detail\":\"awaiting install trigger\",\"uptimeSec\":$(( $(date +%s) - START_TS ))" >/dev/null 2>&1 || true | ||
| fi | ||
| if ! pidof sshd >/dev/null 2>&1; then | ||
| log "[loop] sshd not running, restarting..." | ||
| /usr/sbin/sshd 2>/dev/null || sshd 2>/dev/null | ||
| fi | ||
| if [ -f "$TRIGGER_FILE" ] && [ ! -f "$INSTALL_DONE" ]; then | ||
| log "[loop] install trigger detected" | ||
| console_log "ssh install trigger detected" | ||
| rm -f "$TRIGGER_FILE" | ||
| launch_installer | ||
| fi | ||
| if [ "$AUTO_INSTALL" = "1" ] && [ ! -f "$LAUNCHED_FLAG" ] && [ ! -f "$INSTALL_DONE" ]; then | ||
| NOW=$(date +%s) | ||
| if [ "$((NOW - START_TS))" -ge "$AUTO_INSTALL_FALLBACK_SECONDS" ]; then | ||
| log "[loop] AUTO_INSTALL fallback timeout reached; self-triggering installer" | ||
| launch_installer | ||
| fi | ||
| fi | ||
| sleep 15 | ||
| done |
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
| # Purpose: enable required repos (CRB, EPEL, RPM Fusion) and attempt to install ffmpeg on Rocky/Alma/RHEL-9 compatible systems. | ||
| echo "1) Ensure dnf-plugins-core is installed" | ||
| dnf -y install dnf-plugins-core | ||
| if [ "${EUID:-$(id -u)}" -ne 0 ]; then | ||
| echo "This script must be run as root or with sudo. Exiting." | ||
| exit 1 | ||
| fi | ||
| echo "2) Enable CRB" | ||
| dnf config-manager --set-enabled crb || true | ||
| echo "1) Ensure dnf-plugins-core is available (for config-manager)" | ||
| dnf -y install dnf-plugins-core | ||
| echo "3) Install EPEL" | ||
| dnf -y install epel-release \ | ||
| || dnf -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm | ||
| echo "2) Enable CodeReady / CRB (needed for some deps, e.g. ladspa)" | ||
| # On RHEL you'd use subscription-manager; on CentOS/Rocky/Alma use config-manager | ||
| dnf config-manager --set-enabled crb | ||
| echo "4) Install RPM Fusion repositories" | ||
| dnf -y install \ | ||
| https://download1.rpmfusion.org/free/el/rpmfusion-free-release-9.noarch.rpm \ | ||
| https://download1.rpmfusion.org/nonfree/el/rpmfusion-nonfree-release-9.noarch.rpm | ||
| echo "3) Install EPEL release (required by some ffmpeg deps)" | ||
| dnf -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm | ||
| echo "4) Add RPM Fusion (free + nonfree) repositories" | ||
| # Using mirrors.rpmfusion.org recommended links | ||
| dnf -y install https://mirrors.rpmfusion.org/free/el/rpmfusion-free-release-9.noarch.rpm \ | ||
| https://mirrors.rpmfusion.org/nonfree/el/rpmfusion-nonfree-release-9.noarch.rpm | ||
| echo "5) Install libwebp-tools (for ffmpeg to support WebP)" | ||
| dnf -y install libwebp-tools | ||
| echo "5) Refresh metadata and update system" | ||
| dnf -y makecache | ||
| # Optional: update system packages (comment out if you don't want a full update) | ||
| # dnf -y update | ||
| echo "6) Refresh metadata" | ||
| dnf clean all | ||
| dnf makecache --refresh | ||
| echo "6) Try to install audio helper packages that sometimes block ffmpeg (ladspa, rubberband)" | ||
| # These may be provided by CRB/EPEL or other compatible repos | ||
| dnf -y install ladspa || echo "ladspa not available from enabled repos (will try later)" | ||
| dnf -y install rubberband || echo "rubberband not available from enabled repos (will try later)" | ||
| dnf -y install libwebp-tools || echo "libwebp-tools not available from enabled repos (will try later)" | ||
| echo "7) Install ffmpeg" | ||
| dnf -y install ffmpeg ffmpeg-devel --allowerasing | ||
| echo "7) Try installing ffmpeg (several fallbacks tried)" | ||
| if dnf -y install ffmpeg ffmpeg-devel --allowerasing; then | ||
| echo "ffmpeg installed successfully (used --allowerasing)." | ||
| elif dnf -y install ffmpeg ffmpeg-devel --nobest; then | ||
| echo "ffmpeg installed successfully (used --nobest)." | ||
| elif dnf -y install ffmpeg ffmpeg-devel --skip-broken; then | ||
| echo "ffmpeg installed (skip-broken). Some optional packages may have been skipped." | ||
| else | ||
| echo "Automatic install failed." | ||
| echo "Helpful troubleshooting steps:" | ||
| echo " - Check which repo provides ladspa: dnf repoquery --whatprovides 'ladspa'" | ||
| echo " - Check which package provides librubberband: dnf repoquery --whatprovides 'librubberband.so.2'" | ||
| echo " - Try enabling CRB and EPEL (we already attempted that). If ladspa/rubberband are still missing you can fetch their EL9 rpm from a trusted mirror or build them." | ||
| echo " - Example manual install (ONLY if you trust the source): sudo dnf install /path/to/ladspa-*.el9.rpm /path/to/rubberband-*.el9.rpm" | ||
| echo " - After satisfying ladspa/rubberband, rerun: sudo dnf install ffmpeg ffmpeg-devel" | ||
| exit 2 | ||
| fi | ||
| echo "\nInstallation finished. Verify with: ffmpeg -version" | ||
| exit 0 | ||
| echo | ||
| echo "Done." | ||
| ffmpeg -version | head -n 1 |
@@ -694,3 +694,5 @@ /** | ||
| // ); | ||
| shellExec(`sudo service docker restart`); // Restart docker after containerd config changes | ||
| // Restart docker after containerd config changes. Rocky 9 uses systemctl, | ||
| // not the legacy service command. | ||
| shellExec(`sudo systemctl restart docker || sudo service docker restart || true`); | ||
| shellExec(`sudo systemctl enable --now containerd.service`); | ||
@@ -697,0 +699,0 @@ shellExec(`sudo systemctl restart containerd`); // Restart containerd to apply changes |
@@ -972,4 +972,8 @@ /** | ||
| !options.selfSigned | ||
| ) | ||
| shellExec(`sudo kubectl apply -f ./${manifestsPath}/secret.yaml -n ${namespace}`); | ||
| ) { | ||
| const secretPath = `./${manifestsPath}/secret.yaml`; | ||
| if (fs.existsSync(secretPath) && fs.readFileSync(secretPath, 'utf8').trim()) { | ||
| shellExec(`sudo kubectl apply -f ${secretPath} -n ${namespace}`); | ||
| } else logger.info('Skipping secret.yaml apply (no objects yet; applied by the --cert step)'); | ||
| } | ||
| } | ||
@@ -976,0 +980,0 @@ } |
+35
-11
@@ -98,2 +98,13 @@ /** | ||
| // ── Single-file handling: when path is a file (not a directory), use parent dir | ||
| // as the git working directory and process just that one file. ────────────── | ||
| let isSingleFile = false; | ||
| let parentDir = path; | ||
| let singleFileName = ''; | ||
| if (fs.existsSync(path) && !fs.statSync(path).isDirectory()) { | ||
| isSingleFile = true; | ||
| parentDir = dir.dirname(path); | ||
| singleFileName = path.split('/').pop(); | ||
| } | ||
| // In recursive remove mode, delete every tracked storage key under the requested path, | ||
@@ -141,6 +152,11 @@ // even when local files/directories are already missing. | ||
| const deleteFiles = options.pull === true ? [] : Underpost.repo.getDeleteFiles(path); | ||
| // For single files, run getDeleteFiles against the parent directory to avoid | ||
| // trying to `cd` into a file. | ||
| const gitContextPath = isSingleFile ? parentDir : path; | ||
| const deleteFiles = options.pull === true ? [] : Underpost.repo.getDeleteFiles(gitContextPath); | ||
| // When processing a single file, only consider it for deletion | ||
| for (const relativePath of deleteFiles) { | ||
| const _path = path + '/' + relativePath; | ||
| if (_path in storage) { | ||
| const _path = isSingleFile ? (relativePath === singleFileName ? path : null) : path + '/' + relativePath; | ||
| if (_path && _path in storage) { | ||
| await Underpost.fs.delete(_path); | ||
@@ -162,4 +178,4 @@ delete storage[_path]; | ||
| if (options.git === true) { | ||
| Underpost.repo.initLocalRepo({ path }); | ||
| shellExec(`cd ${path} && git add . && git commit -m "Base pull state"`, { | ||
| Underpost.repo.initLocalRepo({ path: gitContextPath }); | ||
| shellExec(`cd ${gitContextPath} && git add . && git commit -m "Base pull state"`, { | ||
| silentOnError: true, | ||
@@ -169,7 +185,15 @@ }); | ||
| } else { | ||
| const files = | ||
| options.git === true ? Underpost.repo.getChangedFiles(path) : await fs.readdir(path, { recursive: true }); | ||
| let files; | ||
| if (isSingleFile) { | ||
| // Single file: treat the file itself as the sole item to process | ||
| files = [singleFileName]; | ||
| } else { | ||
| files = | ||
| options.git === true | ||
| ? Underpost.repo.getChangedFiles(gitContextPath) | ||
| : await fs.readdir(path, { recursive: true }); | ||
| } | ||
| for (const relativePath of files) { | ||
| const _path = path + '/' + relativePath; | ||
| if (fs.statSync(_path).isDirectory()) { | ||
| const _path = isSingleFile ? path : path + '/' + relativePath; | ||
| if (fs.existsSync(_path) && fs.statSync(_path).isDirectory()) { | ||
| if (options.pull === true && !fs.existsSync(_path)) fs.mkdirSync(_path, { recursive: true }); | ||
@@ -185,4 +209,4 @@ continue; | ||
| if (options.git === true) { | ||
| shellExec(`cd ${path} && git add .`); | ||
| shellExec(`underpost cmt ${path} feat`, { | ||
| shellExec(`cd ${gitContextPath} && git add .`); | ||
| shellExec(`underpost cmt ${gitContextPath} feat`, { | ||
| silentOnError: true, | ||
@@ -189,0 +213,0 @@ silent: true, |
+75
-0
@@ -742,2 +742,33 @@ import dotenv from 'dotenv'; | ||
| program | ||
| .command('docker-compose') | ||
| .argument('[target]', 'Optional service name for --logs, --shell, --restart, or --build.') | ||
| .option('--install', 'Install Docker Engine and the Compose v2 plugin on RHEL/Rocky hosts.') | ||
| .option( | ||
| '--reset', | ||
| 'Comprehensive teardown (equivalent to cluster --reset): removes all stack containers, the network, named volumes (destroys data), orphans, and generated artifacts.', | ||
| ) | ||
| .option('--force', 'Force reinstall (--install), remove volumes (--down), or also drop the env-file (--reset).') | ||
| .option( | ||
| '--deploy-id <deploy-id>', | ||
| "Deployment to run as the app container (default: dd-default). 'dd-default' self-bootstraps a fresh engine; any other id runs the standard 'underpost start' command (mirrors src/cli/deploy.js).", | ||
| ) | ||
| .option('--env <env>', 'Deployment environment for non-default deploy ids (default: development).') | ||
| .option('--generate', 'Render dynamic supporting files (nginx router config, env-file, app-command override).') | ||
| .option('--up', 'Start the full stack detached (regenerates config first).') | ||
| .option('--down', 'Stop and remove containers (and orphans).') | ||
| .option('--volumes', 'With --down, also remove named volumes (destroys persisted data).') | ||
| .option('--restart', 'Restart services (optionally a single [target]).') | ||
| .option('--build', 'With --up rebuild images; alone, rebuilds images with --no-cache.') | ||
| .option('--pull', 'Pull upstream images for all services.') | ||
| .option('--logs', 'Follow logs for all services (optionally a single [target]).') | ||
| .option('--status', 'Show a formatted status table of services.') | ||
| .option('--shell', 'Open an interactive shell in [target] (default: app).') | ||
| .option('--exec <subcommand>', 'General-purpose passthrough docker compose subcommand.') | ||
| .option('--compose-file <path>', 'Path to the compose file (default: docker-compose.yml).') | ||
| .option('--env-file <path>', 'Path to the compose env-file (default: docker/compose.env).') | ||
| .option('--nginx-conf <path>', 'Path to the generated nginx config (default: docker/nginx/default.conf).') | ||
| .description('General-purpose Docker Compose development pipeline (mirrors the Kubernetes dev stack).') | ||
| .action(Underpost.dockerCompose.callback); | ||
| program | ||
| .command('lxd') | ||
@@ -861,2 +892,38 @@ .argument( | ||
| .option( | ||
| '--install-disk [device]', | ||
| 'Explicit target install disk for Rocky deployment (e.g. /dev/nvme0n1). Omit or leave empty to auto-detect the internal disk.', | ||
| ) | ||
| .option( | ||
| '--no-auto-install', | ||
| 'Disables the ephemeral runtime AUTO_INSTALL fallback (controller must trigger install).', | ||
| ) | ||
| .option('--no-remote-install', 'Skips the controller-side remote install orchestration over SSH.') | ||
| .option( | ||
| '--worker', | ||
| 'Post-install infra role: join the deployed node as a Kubernetes worker (requires --control <ip>). Without this flag the node is set up as a control-plane.', | ||
| ) | ||
| .option('--control <ip>', 'Control-plane IP the worker node joins (used with --worker for kubeadm infra setup).') | ||
| .option( | ||
| '--ssh-key-dir <dir>', | ||
| 'Directory holding the SSH key pair used for commissioning/orchestration (expects <dir>/id_rsa and <dir>/id_rsa.pub). Overrides the workflow "sshKeyDir"; defaults to engine-private/deploy. Supports a leading ~.', | ||
| ) | ||
| .option( | ||
| '--deploy-id <deploy-id>', | ||
| 'Deployment ID whose user key pair is used for SSH (key from engine-private/conf/<deploy-id>/users/<user>/id_rsa). Same user↔deployId↔key convention as the ssh command.', | ||
| ) | ||
| .option( | ||
| '--user <user>', | ||
| 'SSH user paired with --deploy-id for key resolution and the login user on an existing control-plane (defaults to root). Mirrors the ssh command --user.', | ||
| ) | ||
| .option( | ||
| '--engine-repo <url>', | ||
| 'Custom engine repo cloned + normalized to /home/dd/engine on the node (default: <GITHUB_USERNAME>/engine).', | ||
| ) | ||
| .option('--engine-branch <branch>', 'Branch of the engine repo to clone on the node.') | ||
| .option( | ||
| '--engine-private-repo <url>', | ||
| 'Custom private repo cloned + normalized to /home/dd/engine/engine-private on the node (default: <GITHUB_USERNAME>/engine-<id>-private).', | ||
| ) | ||
| .option('--engine-private-branch <branch>', 'Branch of the engine-private repo to clone on the node.') | ||
| .option( | ||
| '--bootstrap-http-server-run', | ||
@@ -901,2 +968,10 @@ 'Runs a temporary bootstrap HTTP server for generic purposes such as serving iPXE scripts or ISO images during commissioning.', | ||
| .option('--ls', 'Lists available boot resources and machines.') | ||
| .option( | ||
| '--resume-infra-setup', | ||
| 'Skip commissioning, OS install, and all bootstrapping; resume only the SSH-based infra setup (kubeadm join/init) on a node that already has the OS installed and is reachable via SSH.', | ||
| ) | ||
| .option( | ||
| '--resume-join', | ||
| 'Skip everything except the kubeadm join command. Assumes engine, Node.js, CRI-O, kubelet, and kubeadm are already installed. Only retrieves a fresh join token from the control-plane and runs kubeadm join.', | ||
| ) | ||
| .description( | ||
@@ -903,0 +978,0 @@ 'Manages baremetal server operations, including installation, database setup, commissioning, and user management.', |
+142
-20
@@ -46,16 +46,85 @@ /** | ||
| * @method kickstartPreVariables | ||
| * @description Generates the variable assignments block for the %pre script. | ||
| * @param {object} options | ||
| * @param {string} [options.rootPassword] | ||
| * @param {string} [options.authorizedKeys] | ||
| * @param {string} [options.adminUsername] | ||
| * @description Generates the shell variable-assignment block injected at the top of the | ||
| * kickstart `%pre` script. These variables drive both the ephemeral commissioning runtime | ||
| * and the unattended disk installer in `scripts/rocky-kickstart.sh`. Passwords are emitted | ||
| * base64-encoded (`*_B64`) and decoded by an in-script `ks_b64d` helper; all other values | ||
| * are single-quote escaped so arbitrary content survives the shell/heredoc layers. | ||
| * @param {object} options - Variable values to bake into the `%pre` block. | ||
| * @param {string} [options.rootPassword=''] - root console password (base64-encoded). | ||
| * @param {string} [options.authorizedKeys=''] - SSH public key(s) installed as authorized_keys. | ||
| * @param {string} [options.adminUsername=''] - Primary (MAAS) admin user (defaults to MAAS_ADMIN_USERNAME). | ||
| * @param {string} [options.adminPassword=''] - Primary admin console password (defaults to rootPassword). | ||
| * @param {string} [options.deployUsername=''] - Optional second (deploy) admin user (e.g. admin). | ||
| * @param {string} [options.deployPassword=''] - Deploy user console password (defaults to rootPassword). | ||
| * @param {string} [options.netIp=''] - Static IPv4 for the deployed OS; empty = DHCP. | ||
| * @param {number} [options.netPrefix=24] - IPv4 prefix length for the static address. | ||
| * @param {string} [options.netGateway=''] - Default gateway for the deployed OS. | ||
| * @param {string} [options.netDns=''] - DNS server for the deployed OS. | ||
| * @param {string} [options.timezone=''] - IANA timezone configured in the deployed OS (e.g. America/Santiago). | ||
| * @param {string} [options.keyboardLayout=''] - Console/X11 keyboard layout for the deployed OS (e.g. es). | ||
| * @param {string} [options.chronyConfPath='/etc/chrony.conf'] - chrony config path written in the deployed OS. | ||
| * @param {string} [options.bootstrapUrl=''] - Base URL of the bootstrap HTTP server for this host (status POSTs). | ||
| * @param {string} [options.workflowId=''] - Workflow identifier reported in status metadata. | ||
| * @param {string} [options.systemId=''] - MAAS system id reported in status metadata (if known). | ||
| * @param {string} [options.targetHostname=''] - Hostname reported in status metadata and set on the deployed OS. | ||
| * @param {number} [options.sshPort=22] - SSH port the ephemeral runtime listens on. | ||
| * @param {string} [options.installDiskHint=''] - Optional explicit target disk (e.g. /dev/nvme0n1); empty = auto-detect. | ||
| * @param {boolean} [options.autoInstall=true] - When true, the ephemeral runtime self-installs after a fallback timeout if no remote trigger arrives. | ||
| * @memberof UnderpostKickStart | ||
| * @returns {string} | ||
| * @returns {string} Newline-joined bash variable assignments. | ||
| */ | ||
| kickstartPreVariables: ({ rootPassword = '', authorizedKeys = '', adminUsername = '' }) => { | ||
| kickstartPreVariables: ({ | ||
| rootPassword = '', | ||
| authorizedKeys = '', | ||
| adminUsername = '', | ||
| adminPassword = '', | ||
| deployUsername = '', | ||
| deployPassword = '', | ||
| netIp = '', | ||
| netPrefix = 24, | ||
| netGateway = '', | ||
| netDns = '', | ||
| timezone = '', | ||
| keyboardLayout = '', | ||
| chronyConfPath = '/etc/chrony.conf', | ||
| bootstrapUrl = '', | ||
| workflowId = '', | ||
| systemId = '', | ||
| targetHostname = '', | ||
| sshPort = 22, | ||
| installDiskHint = '', | ||
| autoInstall = true, | ||
| }) => { | ||
| const sanitizedKeys = (authorizedKeys || '').trim(); | ||
| // Passwords are passed base64-encoded so arbitrary characters (quotes, | ||
| // spaces, $, etc.) survive every shell/heredoc layer intact. base64 output | ||
| // never contains single quotes. Decoding tries `base64` then falls back to | ||
| // python3 so a minimal Anaconda environment can never yield empty passwords. | ||
| const b64 = (v) => Buffer.from(String(v || ''), 'utf8').toString('base64'); | ||
| const sq = (v) => `'${String(v || '').replace(/'/g, "'\\''")}'`; | ||
| return [ | ||
| `ROOT_PASS='${rootPassword || ''}'`, | ||
| `AUTHORIZED_KEYS='${sanitizedKeys}'`, | ||
| `ADMIN_USER='${adminUsername || process.env.MAAS_ADMIN_USERNAME || 'maas'}'`, | ||
| `ks_b64d() { printf %s "$1" | base64 -d 2>/dev/null || printf %s "$1" | python3 -c 'import sys,base64;sys.stdout.buffer.write(base64.b64decode(sys.stdin.read().strip()))' 2>/dev/null; }`, | ||
| `ROOT_PASS_B64='${b64(rootPassword)}'`, | ||
| `ADMIN_PASS_B64='${b64(adminPassword || rootPassword)}'`, | ||
| `DEPLOY_PASS_B64='${b64(deployPassword)}'`, | ||
| `ROOT_PASS="$(ks_b64d "$ROOT_PASS_B64")"`, | ||
| `ADMIN_PASS="$(ks_b64d "$ADMIN_PASS_B64")"`, | ||
| `DEPLOY_PASS="$(ks_b64d "$DEPLOY_PASS_B64")"`, | ||
| `AUTHORIZED_KEYS=${sq(sanitizedKeys)}`, | ||
| `ADMIN_USER=${sq(adminUsername || process.env.MAAS_ADMIN_USERNAME || 'maas')}`, | ||
| `DEPLOY_USER=${sq(deployUsername)}`, | ||
| `NET_IP=${sq(netIp)}`, | ||
| `NET_PREFIX='${parseInt(netPrefix, 10) || 24}'`, | ||
| `NET_GATEWAY=${sq(netGateway)}`, | ||
| `NET_DNS=${sq(netDns)}`, | ||
| `TIMEZONE=${sq(timezone)}`, | ||
| `KEYBOARD_LAYOUT=${sq(keyboardLayout)}`, | ||
| `CHRONY_CONF_PATH=${sq(chronyConfPath || '/etc/chrony.conf')}`, | ||
| `BOOTSTRAP_URL=${sq(bootstrapUrl)}`, | ||
| `WORKFLOW_ID=${sq(workflowId)}`, | ||
| `SYSTEM_ID=${sq(systemId)}`, | ||
| `TARGET_HOSTNAME=${sq(targetHostname)}`, | ||
| `SSH_PORT='${sshPort || 22}'`, | ||
| `INSTALL_DISK_HINT=${sq(installDiskHint)}`, | ||
| `AUTO_INSTALL='${autoInstall ? '1' : '0'}'`, | ||
| ].join('\n'); | ||
@@ -67,11 +136,27 @@ }, | ||
| * @description Generates a complete kickstart configuration by combining the header, | ||
| * variable assignments, and the rocky-kickstart.sh script body. | ||
| * @param {object} options | ||
| * @param {string} [options.lang='en_US.UTF-8'] | ||
| * @param {string} [options.keyboard='us'] | ||
| * @param {string} [options.timezone='America/New_York'] | ||
| * @param {string} [options.rootPassword] | ||
| * @param {string} [options.authorizedKeys] | ||
| * the `%pre` variable-assignment block, and the `scripts/rocky-kickstart.sh` body. | ||
| * @param {object} options - Kickstart generation options. | ||
| * @param {string} [options.lang='en_US.UTF-8'] - System language for the ephemeral runtime. | ||
| * @param {string} [options.keyboard='us'] - Keyboard layout for the ephemeral runtime AND the deployed OS. | ||
| * @param {string} [options.timezone='America/New_York'] - Timezone for the ephemeral runtime AND the deployed OS. | ||
| * @param {string} [options.chronyConfPath='/etc/chrony.conf'] - chrony config path written in the deployed OS. | ||
| * @param {string} [options.rootPassword=process.env.MAAS_ADMIN_PASS] - root console password. | ||
| * @param {string} [options.adminUsername=''] - Primary (MAAS) admin user created in the deployed OS. | ||
| * @param {string} [options.adminPassword=''] - Primary admin console password (defaults to rootPassword). | ||
| * @param {string} [options.deployUsername=''] - Optional second (deploy) admin user (e.g. admin). | ||
| * @param {string} [options.deployPassword=''] - Deploy user console password (defaults to rootPassword). | ||
| * @param {string} [options.netIp=''] - Static IPv4 for the deployed OS; empty = DHCP. | ||
| * @param {number} [options.netPrefix=24] - IPv4 prefix length for the static address. | ||
| * @param {string} [options.netGateway=''] - Default gateway for the deployed OS. | ||
| * @param {string} [options.netDns=''] - DNS server for the deployed OS. | ||
| * @param {string} [options.authorizedKeys=''] - SSH public key(s) installed as authorized_keys. | ||
| * @param {string} [options.bootstrapUrl=''] - Base URL of the bootstrap HTTP server (status POSTs). | ||
| * @param {string} [options.workflowId=''] - Workflow identifier reported in status metadata. | ||
| * @param {string} [options.systemId=''] - MAAS system id reported in status metadata (if known). | ||
| * @param {string} [options.targetHostname=''] - Hostname reported in status metadata and set on the deployed OS. | ||
| * @param {number} [options.sshPort=22] - SSH port the ephemeral runtime listens on. | ||
| * @param {string} [options.installDiskHint=''] - Optional explicit target disk; empty = auto-detect. | ||
| * @param {boolean} [options.autoInstall=true] - When true, the runtime self-installs after a fallback timeout. | ||
| * @memberof UnderpostKickStart | ||
| * @returns {string} | ||
| * @returns {string} The full kickstart (ks.cfg) source. | ||
| */ | ||
@@ -82,8 +167,45 @@ kickstartFactory: ({ | ||
| timezone = 'America/New_York', | ||
| chronyConfPath = '/etc/chrony.conf', | ||
| rootPassword = process.env.MAAS_ADMIN_PASS, | ||
| adminUsername = '', | ||
| adminPassword = '', | ||
| deployUsername = '', | ||
| deployPassword = '', | ||
| netIp = '', | ||
| netPrefix = 24, | ||
| netGateway = '', | ||
| netDns = '', | ||
| authorizedKeys = '', | ||
| bootstrapUrl = '', | ||
| workflowId = '', | ||
| systemId = '', | ||
| targetHostname = '', | ||
| sshPort = 22, | ||
| installDiskHint = '', | ||
| autoInstall = true, | ||
| }) => { | ||
| const adminUsername = process.env.MAAS_ADMIN_USERNAME || 'maas'; | ||
| const resolvedAdminUsername = adminUsername || process.env.MAAS_ADMIN_USERNAME || 'maas'; | ||
| const header = UnderpostKickStart.API.kickstartHeader({ lang, keyboard, timezone, rootPassword }); | ||
| const variables = UnderpostKickStart.API.kickstartPreVariables({ rootPassword, authorizedKeys, adminUsername }); | ||
| const variables = UnderpostKickStart.API.kickstartPreVariables({ | ||
| rootPassword, | ||
| authorizedKeys, | ||
| adminUsername: resolvedAdminUsername, | ||
| adminPassword, | ||
| deployUsername, | ||
| deployPassword, | ||
| netIp, | ||
| netPrefix, | ||
| netGateway, | ||
| netDns, | ||
| timezone, | ||
| keyboardLayout: keyboard, | ||
| chronyConfPath, | ||
| bootstrapUrl, | ||
| workflowId, | ||
| systemId, | ||
| targetHostname, | ||
| sshPort, | ||
| installDiskHint, | ||
| autoInstall, | ||
| }); | ||
@@ -90,0 +212,0 @@ const scriptPath = path.resolve(__dirname, '../../scripts/rocky-kickstart.sh'); |
+190
-0
@@ -549,2 +549,192 @@ /** | ||
| /** | ||
| * Waits until a TCP SSH port becomes reachable on a host. | ||
| * @async | ||
| * @function waitForSshPort | ||
| * @memberof UnderpostSSH | ||
| * @param {object} params | ||
| * @param {string} params.host - Target host/IP. | ||
| * @param {number} [params.port=22] - SSH port. | ||
| * @param {number} [params.timeoutMs=600000] - Maximum wait window. | ||
| * @param {number} [params.intervalMs=3000] - Poll interval. | ||
| * @returns {Promise<boolean>} True once the port accepts connections, false on timeout. | ||
| */ | ||
| waitForSshPort: async ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) => { | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (Date.now() < deadline) { | ||
| const probe = shellExec( | ||
| `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`, | ||
| { silent: true, stdout: true, silentOnError: true, disableLog: true }, | ||
| ); | ||
| if (`${probe}`.trim() === 'open') return true; | ||
| await new Promise((r) => setTimeout(r, intervalMs)); | ||
| } | ||
| logger.warn(`SSH port ${host}:${port} not reachable within timeout`); | ||
| return false; | ||
| }, | ||
| /** | ||
| * Waits until a host's SSH port stops accepting connections (e.g. while it | ||
| * reboots). Used to detect a reboot edge before waiting for the port to come | ||
| * back up, so callers don't latch onto the pre-reboot (ephemeral) sshd. | ||
| * @async | ||
| * @function waitForSshPortClosed | ||
| * @memberof UnderpostSSH | ||
| * @param {object} params | ||
| * @param {string} params.host - Target host/IP. | ||
| * @param {number} [params.port=22] - SSH port. | ||
| * @param {number} [params.timeoutMs=180000] - Maximum wait window. | ||
| * @param {number} [params.intervalMs=3000] - Poll interval. | ||
| * @returns {Promise<boolean>} True once the port is closed, false on timeout. | ||
| */ | ||
| waitForSshPortClosed: async ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) => { | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (Date.now() < deadline) { | ||
| const probe = shellExec( | ||
| `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`, | ||
| { silent: true, stdout: true, silentOnError: true, disableLog: true }, | ||
| ); | ||
| if (`${probe}`.trim() === 'closed') return true; | ||
| await new Promise((r) => setTimeout(r, intervalMs)); | ||
| } | ||
| return false; | ||
| }, | ||
| /** | ||
| * Orchestrates a non-interactive, key-only SSH session against a freshly | ||
| * provisioned host: waits for the port, attempts key-based auth, runs a | ||
| * remote command batch, and returns a structured result. Used by the | ||
| * commissioning flow once the ephemeral runtime reports SSH readiness. | ||
| * @async | ||
| * @function sshExecBatch | ||
| * @memberof UnderpostSSH | ||
| * @param {object} params | ||
| * @param {string} params.host - Target host/IP. | ||
| * @param {string} params.command - Remote command batch to execute. | ||
| * @param {number} [params.port=22] - SSH port. | ||
| * @param {string} [params.user='root'] - SSH user (key-only). | ||
| * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key). | ||
| * @param {number} [params.connectTimeoutSec=15] - Per-attempt SSH connect timeout. | ||
| * @param {number} [params.retries=3] - Auth/exec retry attempts. | ||
| * @param {number} [params.retryDelayMs=5000] - Base backoff between retries. | ||
| * @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first. | ||
| * @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>} | ||
| */ | ||
| sshExecBatch: async ({ | ||
| host, | ||
| command, | ||
| port = 22, | ||
| user = 'root', | ||
| keyPath = './engine-private/deploy/id_rsa', | ||
| connectTimeoutSec = 15, | ||
| retries = 3, | ||
| retryDelayMs = 5000, | ||
| waitForPortMs = 0, | ||
| }) => { | ||
| if (!host) throw new Error('sshExecBatch requires a host'); | ||
| if (!command) throw new Error('sshExecBatch requires a command'); | ||
| if (waitForPortMs > 0) { | ||
| const reachable = await Underpost.ssh.waitForSshPort({ host, port, timeoutMs: waitForPortMs }); | ||
| if (!reachable) return { ok: false, code: 255, stdout: '', stderr: 'ssh port unreachable', attempts: 0 }; | ||
| } | ||
| shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true }); | ||
| const sshOpts = [ | ||
| `-i ${keyPath}`, | ||
| `-o BatchMode=yes`, | ||
| `-o PreferredAuthentications=publickey`, | ||
| `-o PubkeyAuthentication=yes`, | ||
| `-o PasswordAuthentication=no`, | ||
| `-o StrictHostKeyChecking=no`, | ||
| `-o UserKnownHostsFile=/dev/null`, | ||
| `-o ConnectTimeout=${connectTimeoutSec}`, | ||
| // Tolerate a freshly-booted node whose network briefly flaps (e.g. while | ||
| // NetworkManager applies a static profile): retry the TCP connect and | ||
| // keep the session alive across short stalls. | ||
| `-o ConnectionAttempts=3`, | ||
| `-o ServerAliveInterval=10`, | ||
| `-o ServerAliveCountMax=6`, | ||
| `-p ${port}`, | ||
| ].join(' '); | ||
| let last = { ok: false, code: 255, stdout: '', stderr: '', attempts: 0 }; | ||
| for (let attempt = 1; attempt <= retries; attempt++) { | ||
| const result = shellExec(`ssh ${sshOpts} ${user}@${host} bash -s <<'UNDERPOST_SSH_BATCH_EOF'\n${command}\nUNDERPOST_SSH_BATCH_EOF`, { | ||
| stdout: false, | ||
| silentOnError: true, | ||
| disableLog: true, | ||
| }); | ||
| last = { | ||
| ok: result.code === 0, | ||
| code: result.code, | ||
| stdout: result.stdout || '', | ||
| stderr: result.stderr || '', | ||
| attempts: attempt, | ||
| }; | ||
| if (last.ok) { | ||
| logger.info(`sshExecBatch succeeded on ${user}@${host}:${port} (attempt ${attempt})`); | ||
| return last; | ||
| } | ||
| logger.warn(`sshExecBatch attempt ${attempt}/${retries} failed on ${user}@${host}:${port}`, { | ||
| code: last.code, | ||
| stderr: last.stderr.slice(-400), | ||
| }); | ||
| if (attempt < retries) await new Promise((r) => setTimeout(r, retryDelayMs * attempt)); | ||
| } | ||
| return last; | ||
| }, | ||
| /** | ||
| * Transfers a local script to a remote host and runs it over key-only SSH. | ||
| * The script is base64-encoded so no shell-quoting/escaping is needed, then | ||
| * decoded, made executable, and executed with the given arguments. Reuses | ||
| * sshExecBatch for the actual transport, retries, and structured result. | ||
| * @async | ||
| * @function sshRunScript | ||
| * @memberof UnderpostSSH | ||
| * @param {object} params | ||
| * @param {string} params.host - Target host/IP. | ||
| * @param {string} params.scriptPath - Local path to the script to run. | ||
| * @param {string} [params.args=''] - Arguments appended to the remote invocation. | ||
| * @param {object} [params.env={}] - Environment variables exported for the remote run (e.g. secrets). Passed inline to the command, never echoed. | ||
| * @param {string} [params.remotePath='/tmp/underpost-remote-script.sh'] - Remote path to write the script. | ||
| * @param {number} [params.port=22] - SSH port. | ||
| * @param {string} [params.user='root'] - SSH user (key-only). | ||
| * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key). | ||
| * @param {number} [params.retries=3] - Retry attempts. | ||
| * @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first. | ||
| * @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>} | ||
| */ | ||
| sshRunScript: async ({ | ||
| host, | ||
| scriptPath, | ||
| args = '', | ||
| env = {}, | ||
| remotePath = '/tmp/underpost-remote-script.sh', | ||
| port = 22, | ||
| user = 'root', | ||
| keyPath = './engine-private/deploy/id_rsa', | ||
| retries = 3, | ||
| waitForPortMs = 0, | ||
| }) => { | ||
| if (!fs.existsSync(scriptPath)) throw new Error(`sshRunScript: script not found: ${scriptPath}`); | ||
| const b64 = Buffer.from(fs.readFileSync(scriptPath, 'utf8'), 'utf8').toString('base64'); | ||
| // Inline env assignments (single-quote escaped) so secrets are exported for | ||
| // the remote run without appearing as logged CLI args. | ||
| const sq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`; | ||
| const envPrefix = Object.entries(env) | ||
| .filter(([, v]) => v !== undefined && v !== null && `${v}` !== '') | ||
| .map(([k, v]) => `${k}=${sq(v)}`) | ||
| .join(' '); | ||
| const command = [ | ||
| 'set -e', | ||
| `echo '${b64}' | base64 -d > ${remotePath}`, | ||
| `chmod +x ${remotePath}`, | ||
| `${envPrefix ? `${envPrefix} ` : ''}bash ${remotePath} ${args}`, | ||
| ].join('\n'); | ||
| return Underpost.ssh.sshExecBatch({ host, port, user, keyPath, retries, waitForPortMs, command }); | ||
| }, | ||
| /** | ||
| * Loads saved SSH credentials from config and sets them in the UnderpostRootEnv API. | ||
@@ -551,0 +741,0 @@ * @async |
@@ -9,6 +9,6 @@ /** | ||
| import express from 'express'; | ||
| import { ssrFactory } from '../server/ssr.js'; | ||
| import { ssrFactory } from '../client-builder/ssr.js'; | ||
| import { shellExec } from '../server/process.js'; | ||
| import Underpost from '../index.js'; | ||
| import { JSONweb } from '../server/client-formatted.js'; | ||
| import { JSONweb } from '../client-builder/client-formatted.js'; | ||
| import { loggerFactory, loggerMiddleware } from '../server/logger.js'; | ||
@@ -15,0 +15,0 @@ const logger = loggerFactory(import.meta); |
@@ -9,3 +9,3 @@ 'use strict'; | ||
| import { ProcessController } from './server/process.js'; | ||
| import { clientLiveBuild } from './server/client-build-live.js'; | ||
| import { clientLiveBuild } from './client-builder/client-build-live.js'; | ||
@@ -12,0 +12,0 @@ await Config.build(); |
@@ -9,3 +9,3 @@ 'use strict'; | ||
| import { Config, buildClientStaticConf } from './server/conf.js'; | ||
| import { createClientDevServer } from './server/client-dev-server.js'; | ||
| import { createClientDevServer } from './client-builder/client-dev-server.js'; | ||
@@ -12,0 +12,0 @@ const logger = loggerFactory(import.meta); |
+12
-1
@@ -13,2 +13,3 @@ /** | ||
| import UnderpostDeploy from './cli/deploy.js'; | ||
| import UnderpostDockerCompose from './cli/docker-compose.js'; | ||
| import UnderpostKubectl from './cli/kubectl.js'; | ||
@@ -48,3 +49,3 @@ import UnderpostRootEnv from './cli/env.js'; | ||
| */ | ||
| static version = 'v3.2.22'; | ||
| static version = 'v3.2.28'; | ||
@@ -152,2 +153,11 @@ /** | ||
| /** | ||
| * Docker Compose pipeline cli API | ||
| * @static | ||
| * @type {UnderpostDockerCompose.API} | ||
| * @memberof Underpost | ||
| */ | ||
| static get dockerCompose() { | ||
| return UnderpostDockerCompose.API; | ||
| } | ||
| /** | ||
| * File Storage cli API | ||
@@ -327,2 +337,3 @@ * @static | ||
| UnderpostDeploy, | ||
| UnderpostDockerCompose, | ||
| UnderpostKubectl, | ||
@@ -329,0 +340,0 @@ UnderpostRootEnv, |
@@ -1,2 +0,2 @@ | ||
| import { ssrFactory } from '../server/ssr.js'; | ||
| import { ssrFactory } from '../client-builder/ssr.js'; | ||
@@ -3,0 +3,0 @@ /** |
@@ -22,4 +22,4 @@ /** | ||
| import { applySecurity, authMiddlewareFactory } from '../../server/auth.js'; | ||
| import { ssrMiddlewareFactory } from '../../server/ssr.js'; | ||
| import { buildSwaggerUiOptions } from '../../server/client-build-docs.js'; | ||
| import { ssrMiddlewareFactory } from '../../client-builder/ssr.js'; | ||
| import { buildSwaggerUiOptions } from '../../client-builder/client-build-docs.js'; | ||
@@ -26,0 +26,0 @@ import { shellExec } from '../../server/process.js'; |
+1
-1
@@ -7,3 +7,3 @@ 'use strict'; | ||
| import { loggerFactory } from './server/logger.js'; | ||
| import { buildClient } from './server/client-build.js'; | ||
| import { buildClient } from './client-builder/client-build.js'; | ||
| import { buildRuntime } from './server/runtime.js'; | ||
@@ -10,0 +10,0 @@ import { ProcessController } from './server/process.js'; |
@@ -21,4 +21,2 @@ /** | ||
| const catalogDir = path.dirname(fileURLToPath(import.meta.url)); | ||
| /** Empty product catalog returned for deploy ids without a dedicated module. */ | ||
@@ -47,8 +45,7 @@ const EMPTY_CATALOG = { | ||
| if (!suffix) return EMPTY_CATALOG; | ||
| try { | ||
| const mod = await import(`./catalog-${suffix}.js`); | ||
| if (fs.existsSync(`./src/projects/${suffix}/catalog-${suffix}.js`)) { | ||
| const mod = await import(`../projects/${suffix}/catalog-${suffix}.js`); | ||
| return { ...EMPTY_CATALOG, ...(mod.default ?? {}) }; | ||
| } catch { | ||
| return EMPTY_CATALOG; | ||
| } | ||
| return EMPTY_CATALOG; | ||
| }; | ||
@@ -67,10 +64,6 @@ | ||
| const catalogs = []; | ||
| for (const file of fs.readdirSync(catalogDir)) { | ||
| if (!/^catalog-.+\.js$/.test(file) || file === 'catalog-underpost.js') continue; | ||
| try { | ||
| const mod = await import(`./${file}`); | ||
| if (mod.default) catalogs.push({ ...EMPTY_CATALOG, ...mod.default }); | ||
| } catch { | ||
| /* a malformed/removed product catalog must not break the base build */ | ||
| } | ||
| for (const file of await fs.readdir('./src/projects')) { | ||
| if (file === 'underpost') continue; | ||
| const mod = await import(`../projects/${file}/catalog-${file}.js`); | ||
| if (mod.default) catalogs.push({ ...EMPTY_CATALOG, ...mod.default }); | ||
| } | ||
@@ -77,0 +70,0 @@ return catalogs; |
+3
-1
@@ -11,3 +11,5 @@ { | ||
| "./src/mailer", | ||
| "./src/runtime" | ||
| "./src/runtime", | ||
| "./src/client-builder", | ||
| "./src/projects/underpost" | ||
| ], | ||
@@ -14,0 +16,0 @@ "entryPointStrategy": "expand", |
| SrrComponent = ({ title, ssrPath, buildId, ssrHeadComponents, ssrBodyComponents, renderPayload, renderApi }) => html` | ||
| <!DOCTYPE html> | ||
| <html dir="ltr" lang="en"> | ||
| <head> | ||
| <title>${title}</title> | ||
| <link rel="icon" type="image/x-icon" href="${ssrPath}favicon.ico" /> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> | ||
| <script> | ||
| window.renderPayload = ${renderApi.JSONweb(renderPayload)}; | ||
| </script> | ||
| ${ssrHeadComponents} | ||
| </head> | ||
| <body> | ||
| <style> | ||
| html { | ||
| scroll-behavior: smooth; | ||
| } | ||
| body { | ||
| /* overscroll-behavior: contain; */ | ||
| /* box-sizing: border-box; */ | ||
| padding: 0px; | ||
| margin: 0px; | ||
| } | ||
| .fl { | ||
| position: relative; | ||
| display: flow-root; | ||
| } | ||
| .abs, | ||
| .in { | ||
| display: block; | ||
| } | ||
| .fll { | ||
| float: left; | ||
| } | ||
| .flr { | ||
| float: right; | ||
| } | ||
| .abs { | ||
| position: absolute; | ||
| } | ||
| .in, | ||
| .inl { | ||
| position: relative; | ||
| } | ||
| .inl { | ||
| display: inline-table; | ||
| display: -webkit-inline-table; | ||
| display: -moz-inline-table; | ||
| display: -ms-inline-table; | ||
| display: -o-inline-table; | ||
| } | ||
| .fix { | ||
| position: fixed; | ||
| display: block; | ||
| } | ||
| .stq { | ||
| position: sticky; | ||
| /* require defined at least top, bottom, left o right */ | ||
| } | ||
| .wfa { | ||
| width: available; | ||
| width: -webkit-available; | ||
| width: -moz-available; | ||
| width: -ms-available; | ||
| width: -o-available; | ||
| width: fill-available; | ||
| width: -webkit-fill-available; | ||
| width: -moz-fill-available; | ||
| width: -ms-fill-available; | ||
| width: -o-fill-available; | ||
| } | ||
| .wft { | ||
| width: fit-content; | ||
| width: -webkit-fit-content; | ||
| width: -moz-fit-content; | ||
| width: -ms-fit-content; | ||
| width: -o-fit-content; | ||
| } | ||
| .wfm { | ||
| width: max-content; | ||
| width: -webkit-max-content; | ||
| width: -moz-max-content; | ||
| width: -ms-max-content; | ||
| width: -o-max-content; | ||
| } | ||
| .negative-color { | ||
| filter: invert(1); | ||
| -webkit-filter: invert(1); | ||
| -moz-filter: invert(1); | ||
| -ms-filter: invert(1); | ||
| -o-filter: invert(1); | ||
| } | ||
| .no-drag { | ||
| user-drag: none; | ||
| -webkit-user-drag: none; | ||
| -moz-user-drag: none; | ||
| -ms-user-drag: none; | ||
| -o-user-drag: none; | ||
| user-select: none; | ||
| -webkit-user-select: none; | ||
| -moz-user-select: none; | ||
| -ms-user-select: none; | ||
| -o-user-select: none; | ||
| } | ||
| .center { | ||
| transform: translate(-50%, -50%); | ||
| top: 50%; | ||
| left: 50%; | ||
| width: 100%; | ||
| text-align: center; | ||
| } | ||
| input { | ||
| outline: none !important; | ||
| border: none; | ||
| padding-block: 0; | ||
| padding-inline: 0; | ||
| height: 30px; | ||
| line-height: 30px; | ||
| } | ||
| input::file-selector-button { | ||
| outline: none !important; | ||
| border: none; | ||
| } | ||
| .hide { | ||
| display: none !important; | ||
| } | ||
| /* | ||
| placeholder | ||
| */ | ||
| ::placeholder { | ||
| color: black; | ||
| opacity: 1; | ||
| /* Firefox */ | ||
| background: none; | ||
| } | ||
| :-ms-input-placeholder { | ||
| /* Internet Explorer 10-11 */ | ||
| color: black; | ||
| background: none; | ||
| } | ||
| ::-ms-input-placeholder { | ||
| /* Microsoft Edge */ | ||
| color: black; | ||
| background: none; | ||
| } | ||
| /* | ||
| selection | ||
| */ | ||
| ::-moz-selection { | ||
| /* Code for Firefox */ | ||
| color: black; | ||
| background: rgb(208, 208, 208); | ||
| } | ||
| ::selection { | ||
| color: black; | ||
| background: rgb(208, 208, 208); | ||
| } | ||
| .lowercase { | ||
| text-transform: lowercase; | ||
| } | ||
| .uppercase { | ||
| text-transform: uppercase; | ||
| } | ||
| .capitalize { | ||
| text-transform: capitalize; | ||
| } | ||
| .bold { | ||
| font-weight: bold; | ||
| } | ||
| .m { | ||
| font-family: monospace; | ||
| } | ||
| .gray { | ||
| filter: grayscale(1); | ||
| } | ||
| </style> | ||
| <div class="session"> | ||
| <style> | ||
| .session-in-log-out { | ||
| display: block; | ||
| } | ||
| .session-inl-log-out { | ||
| display: inline-table; | ||
| } | ||
| .session-fl-log-out { | ||
| display: flow-root; | ||
| } | ||
| .session-in-log-in { | ||
| display: none; | ||
| } | ||
| .session-inl-log-in { | ||
| display: none; | ||
| } | ||
| .session-fl-log-in { | ||
| display: none; | ||
| } | ||
| </style> | ||
| </div> | ||
| <div class="theme"></div> | ||
| ${ssrBodyComponents} ${buildId ? html`<script async type="module" src="./${buildId}.js"></script>` : ''} | ||
| </body> | ||
| </html> | ||
| `; |
| /** | ||
| * Underpost platform content catalog — the base `pwa-microservices-template`. | ||
| * | ||
| * @module src/server/catalog-underpost.js | ||
| * @namespace UnderpostCatalog | ||
| */ | ||
| /** | ||
| * Workflow + service files re-added to the template after the engine-only strip. | ||
| * @constant {string[]} | ||
| * @memberof UnderpostCatalog | ||
| */ | ||
| const TEMPLATE_RESTORE_PATHS = [ | ||
| `./src/server/catalog-underpost.js`, | ||
| `./.github/workflows/pwa-microservices-template-page.cd.yml`, | ||
| `./.github/workflows/pwa-microservices-template-test.ci.yml`, | ||
| `./.github/workflows/npmpkg.ci.yml`, | ||
| `./.github/workflows/ghpkg.ci.yml`, | ||
| `./.github/workflows/gitlab.ci.yml`, | ||
| `./.github/workflows/publish.ci.yml`, | ||
| `./.github/workflows/release.cd.yml`, | ||
| `./src/client/services/user/guest.service.js`, | ||
| './src/api/user/guest.service.js', | ||
| './src/ws/IoInterface.js', | ||
| './src/ws/IoServer.js', | ||
| './manifests/deployment/dd-default-development', | ||
| ]; | ||
| /** | ||
| * npm keywords for the standalone Underpost platform / template package. | ||
| * @constant {string[]} | ||
| * @memberof UnderpostCatalog | ||
| */ | ||
| const TEMPLATE_KEYWORDS = [ | ||
| 'underpost', | ||
| 'underpost-platform', | ||
| 'cli', | ||
| 'toolchain', | ||
| 'ci-cd', | ||
| 'devops', | ||
| 'kubernetes', | ||
| 'k3s', | ||
| 'kubeadm', | ||
| 'lxd', | ||
| 'baremetal', | ||
| 'container-orchestration', | ||
| 'image-management', | ||
| 'pwa', | ||
| 'workbox', | ||
| 'microservices', | ||
| ]; | ||
| /** | ||
| * npm description for the standalone Underpost platform / template package. | ||
| * @constant {string} | ||
| * @memberof UnderpostCatalog | ||
| */ | ||
| const TEMPLATE_DESCRIPTION = | ||
| 'Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orchestration. Extensible via downstream CLIs.'; | ||
| export { TEMPLATE_RESTORE_PATHS, TEMPLATE_KEYWORDS, TEMPLATE_DESCRIPTION }; |
| 'use strict'; | ||
| /** | ||
| * Module for building project documentation (JSDoc, Swagger, Coverage). | ||
| * @module src/server/client-build-docs.js | ||
| * @namespace clientBuildDocs | ||
| */ | ||
| import fs from 'fs-extra'; | ||
| import { shellExec } from './process.js'; | ||
| import { loggerFactory } from './logger.js'; | ||
| import { JSONweb } from './client-formatted.js'; | ||
| import { ssrFactory } from './ssr.js'; | ||
| /** | ||
| * Builds API documentation using Swagger | ||
| * @function buildApiDocs | ||
| * @memberof clientBuildDocs | ||
| * @param {Object} options - Documentation build options | ||
| * @param {string} options.host - The hostname for the API | ||
| * @param {string} options.path - The base path for the API | ||
| * @param {number} options.port - The port number for the API | ||
| * @param {Object} options.metadata - Metadata for the API documentation | ||
| * @param {Array<string>} options.apis - List of API modules to document | ||
| * @param {string} options.publicClientId - Client ID for the public documentation | ||
| * @param {string} options.rootClientPath - Root path for client files | ||
| * @param {Object} options.packageData - Package.json data | ||
| */ | ||
| const buildApiDocs = async ({ | ||
| host, | ||
| path, | ||
| port, | ||
| metadata = {}, | ||
| apis = [], | ||
| publicClientId, | ||
| rootClientPath, | ||
| packageData, | ||
| }) => { | ||
| const logger = loggerFactory(import.meta); | ||
| const basePath = path === '/' ? `${process.env.BASE_API}` : `/${process.env.BASE_API}`; | ||
| const doc = { | ||
| info: { | ||
| version: packageData.version, | ||
| title: metadata?.title ? `${metadata.title}` : 'REST API', | ||
| description: metadata?.description ? metadata.description : '', | ||
| }, | ||
| servers: [ | ||
| { | ||
| url: | ||
| process.env.NODE_ENV === 'development' | ||
| ? `http://localhost:${port}${path}${basePath}` | ||
| : `https://${host}${path}${basePath}`, | ||
| description: `${process.env.NODE_ENV} server`, | ||
| }, | ||
| ], | ||
| tags: [ | ||
| { | ||
| name: 'user', | ||
| description: 'User API operations', | ||
| }, | ||
| { | ||
| name: 'object-layer', | ||
| description: 'Object Layer API operations', | ||
| }, | ||
| ], | ||
| components: { | ||
| schemas: { | ||
| userRequest: { | ||
| type: 'object', | ||
| required: ['username', 'password', 'email'], | ||
| properties: { | ||
| username: { type: 'string', example: 'user123' }, | ||
| password: { type: 'string', example: 'Password123!' }, | ||
| email: { type: 'string', format: 'email', example: 'user@example.com' }, | ||
| }, | ||
| }, | ||
| userResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'success' }, | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| token: { | ||
| type: 'string', | ||
| example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7Il9pZCI6IjY2YzM3N2Y1N2Y5OWU1OTY5YjgxZG...', | ||
| }, | ||
| user: { | ||
| type: 'object', | ||
| properties: { | ||
| _id: { type: 'string', example: '66c377f57f99e5969b81de89' }, | ||
| email: { type: 'string', format: 'email', example: 'user@example.com' }, | ||
| emailConfirmed: { type: 'boolean', example: false }, | ||
| username: { type: 'string', example: 'user123' }, | ||
| role: { type: 'string', example: 'user' }, | ||
| profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| userUpdateResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'success' }, | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| _id: { type: 'string', example: '66c377f57f99e5969b81de89' }, | ||
| email: { type: 'string', format: 'email', example: 'user@example.com' }, | ||
| emailConfirmed: { type: 'boolean', example: false }, | ||
| username: { type: 'string', example: 'user123222' }, | ||
| role: { type: 'string', example: 'user' }, | ||
| profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| userGetResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'success' }, | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| _id: { type: 'string', example: '66c377f57f99e5969b81de89' }, | ||
| email: { type: 'string', format: 'email', example: 'user@example.com' }, | ||
| emailConfirmed: { type: 'boolean', example: false }, | ||
| username: { type: 'string', example: 'user123222' }, | ||
| role: { type: 'string', example: 'user' }, | ||
| profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| userLogInRequest: { | ||
| type: 'object', | ||
| required: ['email', 'password'], | ||
| properties: { | ||
| email: { type: 'string', format: 'email', example: 'user@example.com' }, | ||
| password: { type: 'string', example: 'Password123!' }, | ||
| }, | ||
| }, | ||
| userBadRequestResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'error' }, | ||
| message: { | ||
| type: 'string', | ||
| example: 'Bad request. Please check your inputs, and try again', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| objectLayerResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'success' }, | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| _id: { type: 'string', example: '66c377f57f99e5969b81de89' }, | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| stats: { | ||
| type: 'object', | ||
| properties: { | ||
| effect: { type: 'number', example: 0 }, | ||
| resistance: { type: 'number', example: 0 }, | ||
| agility: { type: 'number', example: 0 }, | ||
| range: { type: 'number', example: 0 }, | ||
| intelligence: { type: 'number', example: 0 }, | ||
| utility: { type: 'number', example: 0 }, | ||
| }, | ||
| }, | ||
| item: { | ||
| type: 'object', | ||
| properties: { | ||
| id: { type: 'string', example: 'skin-default' }, | ||
| type: { type: 'string', example: 'skin' }, | ||
| description: { type: 'string', example: 'Default skin layer' }, | ||
| activable: { type: 'boolean', example: false }, | ||
| }, | ||
| }, | ||
| ledger: { | ||
| type: 'object', | ||
| properties: { | ||
| type: { type: 'string', example: 'semi-fungible' }, | ||
| address: { type: 'string', example: '0x0000000000000000000000000000000000000000' }, | ||
| tokenId: { type: 'string', example: '' }, | ||
| }, | ||
| }, | ||
| render: { | ||
| type: 'object', | ||
| properties: { | ||
| cid: { type: 'string', example: '' }, | ||
| metadataCid: { type: 'string', example: '' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| cid: { type: 'string', example: '' }, | ||
| sha256: { type: 'string', example: 'abc123def456...' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| objectLayerBadRequestResponse: { | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', example: 'error' }, | ||
| message: { | ||
| type: 'string', | ||
| example: 'Bad request. Please check your inputs, and try again', | ||
| }, | ||
| }, | ||
| }, | ||
| securitySchemes: { | ||
| bearerAuth: { | ||
| type: 'http', | ||
| scheme: 'bearer', | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| /** | ||
| * swagger-autogen has no requestBody annotation support — it only handles | ||
| * #swagger.parameters, responses, security, etc. We define the requestBody | ||
| * objects here and inject them into the generated JSON as a post-processing step. | ||
| * | ||
| * Each key is an "<method> <path>" pair matching the generated paths object. | ||
| * The value is a valid OAS 3.0 requestBody object. | ||
| */ | ||
| const requestBodies = { | ||
| 'post /user': { | ||
| description: 'User registration data', | ||
| required: true, | ||
| content: { | ||
| 'application/json': { | ||
| schema: { $ref: '#/components/schemas/userRequest' }, | ||
| }, | ||
| }, | ||
| }, | ||
| 'post /user/auth': { | ||
| description: 'User login credentials', | ||
| required: true, | ||
| content: { | ||
| 'application/json': { | ||
| schema: { $ref: '#/components/schemas/userLogInRequest' }, | ||
| }, | ||
| }, | ||
| }, | ||
| 'put /user/{id}': { | ||
| description: 'User fields to update', | ||
| required: true, | ||
| content: { | ||
| 'application/json': { | ||
| schema: { $ref: '#/components/schemas/userRequest' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| logger.warn('build swagger api docs', doc.info); | ||
| // swagger-autogen@2.9.2 bug: getProducesTag, getConsumesTag, getResponsesTag missing __¬¬¬__ decode before eval | ||
| fs.writeFileSync( | ||
| `node_modules/swagger-autogen/src/swagger-tags.js`, | ||
| fs | ||
| .readFileSync(`node_modules/swagger-autogen/src/swagger-tags.js`, 'utf8') | ||
| // getProducesTag and getConsumesTag: already decode " but not __¬¬¬__ | ||
| .replaceAll( | ||
| `data.replaceAll('\\n', ' ').replaceAll('\u201c', '\u201d')`, | ||
| `data.replaceAll('\\n', ' ').replaceAll('\u201c', '\u201d').replaceAll('__\u00ac\u00ac\u00ac__', '"')`, | ||
| ) | ||
| // getResponsesTag: decodes neither " nor __¬¬¬__ | ||
| .replaceAll( | ||
| `data.replaceAll('\\n', ' ');`, | ||
| `data.replaceAll('\\n', ' ').replaceAll('__\u00ac\u00ac\u00ac__', '"');`, | ||
| ), | ||
| 'utf8', | ||
| ); | ||
| setTimeout(async () => { | ||
| const { default: swaggerAutoGen } = await import('swagger-autogen'); | ||
| const outputFile = `./public/${host}${path === '/' ? path : `${path}/`}swagger-output.json`; | ||
| const routes = []; | ||
| for (const api of apis) { | ||
| if (['user', 'object-layer'].includes(api)) routes.push(`./src/api/${api}/${api}.router.js`); | ||
| } | ||
| await swaggerAutoGen({ openapi: '3.0.0' })(outputFile, routes, doc); | ||
| // Post-process: inject requestBody into operations — swagger-autogen silently | ||
| // ignores #swagger.requestBody annotations and has no internal OAS-3 body support. | ||
| if (fs.existsSync(outputFile)) { | ||
| const swaggerJson = JSON.parse(fs.readFileSync(outputFile, 'utf8')); | ||
| let patched = false; | ||
| for (const [key, requestBody] of Object.entries(requestBodies)) { | ||
| const [method, ...pathParts] = key.split(' '); | ||
| const opPath = pathParts.join(' '); | ||
| if (swaggerJson.paths?.[opPath]?.[method]) { | ||
| swaggerJson.paths[opPath][method].requestBody = requestBody; | ||
| // Remove any stale in:body entry from parameters (OAS 3.0 doesn't allow it) | ||
| if (Array.isArray(swaggerJson.paths[opPath][method].parameters)) { | ||
| swaggerJson.paths[opPath][method].parameters = swaggerJson.paths[opPath][method].parameters.filter( | ||
| (p) => p.in !== 'body', | ||
| ); | ||
| } | ||
| patched = true; | ||
| } | ||
| } | ||
| if (patched) { | ||
| fs.writeFileSync(outputFile, JSON.stringify(swaggerJson, null, 2), 'utf8'); | ||
| // logger.warn('swagger post-process: requestBody injected', Object.keys(requestBodies)); | ||
| } | ||
| } | ||
| }); | ||
| }; | ||
| /** | ||
| * Builds API documentation using TypeDoc (generates a modern static site from JSDoc-annotated JS). | ||
| * Config is read from the base typedoc JSON, merged with runtime values, written to a temporary | ||
| * file, and deleted after the build — the base config file is never mutated on disk. | ||
| * @function buildJsDocs | ||
| * @memberof clientBuildDocs | ||
| * @param {Object} options - TypeDoc build options | ||
| * @param {string} options.host - The hostname for the documentation | ||
| * @param {string} options.path - The base path for the documentation | ||
| * @param {Object} options.metadata - Metadata for the documentation | ||
| * @param {string} options.publicClientId - Client ID used to resolve the references directory | ||
| * @param {Object} options.docs - Documentation config from server conf | ||
| * @param {string} options.docs.jsJsonPath - Path to the base typedoc JSON config file | ||
| * @param {string} options.docsDestination - Resolved output path for the generated docs | ||
| */ | ||
| const buildJsDocs = async ({ host, path, metadata = {}, publicClientId, docs, docsDestination }) => { | ||
| const logger = loggerFactory(import.meta); | ||
| const typedocConfigPath = docs.jsJsonPath; | ||
| if (!fs.existsSync(typedocConfigPath)) { | ||
| logger.warn('typedoc config not found, skipping', typedocConfigPath); | ||
| return; | ||
| } | ||
| const baseConfig = JSON.parse(fs.readFileSync(typedocConfigPath, 'utf8')); | ||
| logger.info('using typedoc config', typedocConfigPath); | ||
| // Build runtime config in memory — never mutate the base config file | ||
| // tsconfig must be absolute so TypeDoc resolves it regardless of where the | ||
| // tmp config file is located on disk. | ||
| const runtimeConfig = { | ||
| ...baseConfig, | ||
| tsconfig: fs.realpathSync(baseConfig.tsconfig || './tsconfig.docs.json'), | ||
| out: docsDestination, | ||
| name: metadata?.title || baseConfig.name, | ||
| favicon: `./public/${host}${path === '/' ? '/' : `${path}/`}favicon.ico`, | ||
| }; | ||
| // Include extra reference documents as TypeDoc document pages | ||
| // TypeDoc 0.28+: option is `projectDocuments`, not `documents` | ||
| if (Array.isArray(docs.references) && docs.references.length > 0) { | ||
| runtimeConfig.projectDocuments = docs.references.filter((p) => fs.existsSync(p)); | ||
| if (runtimeConfig.projectDocuments.length > 0) logger.info('typedoc documents', runtimeConfig.projectDocuments); | ||
| } | ||
| const tmpConfigPath = `.typedoc.tmp.json`; | ||
| fs.writeFileSync(tmpConfigPath, JSON.stringify(runtimeConfig, null, 2), 'utf8'); | ||
| logger.warn('build typedoc view', docsDestination); | ||
| shellExec(`node_modules/.bin/typedoc --options ${tmpConfigPath}`, { silent: true }); | ||
| fs.removeSync(tmpConfigPath); | ||
| }; | ||
| /** | ||
| * Builds test coverage documentation | ||
| * @function buildCoverage | ||
| * @memberof clientBuildDocs | ||
| * @param {Object} options - Coverage build options | ||
| * @param {Object} options.docs - Documentation config from server conf | ||
| * @param {string} options.docs.coveragePath - Directory where coverage reports are generated | ||
| * @param {string} options.docsDestination - Resolved output path where docs were built | ||
| */ | ||
| const buildCoverage = async ({ docs, docsDestination }) => { | ||
| const logger = loggerFactory(import.meta); | ||
| const { coveragePath, coverageOutputDir = 'coverage' } = docs; | ||
| const coverageOutputPath = `${coveragePath}/coverage`; | ||
| if (!fs.existsSync(coverageOutputPath)) { | ||
| const pkgPath = `${coveragePath}/package.json`; | ||
| if (fs.existsSync(pkgPath)) { | ||
| const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); | ||
| if (pkg.scripts && pkg.scripts.coverage) { | ||
| logger.info('generating coverage report', coveragePath); | ||
| shellExec(`cd ${coveragePath} && npm run coverage`, { silent: true }); | ||
| } else if (pkg.scripts && pkg.scripts.test) { | ||
| logger.info('generating coverage via test', coveragePath); | ||
| shellExec(`cd ${coveragePath} && npm test`, { silent: true, silentOnError: true }); | ||
| } | ||
| } | ||
| } | ||
| if (fs.existsSync(coverageOutputPath) && fs.readdirSync(coverageOutputPath).length > 0) { | ||
| const coverageBuildPath = `${docsDestination}${coverageOutputDir}`; | ||
| fs.mkdirSync(coverageBuildPath, { recursive: true }); | ||
| // Hardhat 3 outputs HTML to coverage/html/; Hardhat 2 / c8 output directly to coverage/ | ||
| const coverageHtmlSubdir = `${coverageOutputPath}/html`; | ||
| if (fs.existsSync(coverageHtmlSubdir) && fs.existsSync(`${coverageHtmlSubdir}/index.html`)) { | ||
| fs.copySync(coverageHtmlSubdir, coverageBuildPath); | ||
| } else { | ||
| fs.copySync(coverageOutputPath, coverageBuildPath); | ||
| } | ||
| logger.warn('build coverage', coverageBuildPath); | ||
| } else { | ||
| logger.warn('no coverage output found, skipping', coverageOutputPath); | ||
| } | ||
| }; | ||
| /** | ||
| * Main function to build all documentation | ||
| * @function buildDocs | ||
| * @memberof clientBuildDocs | ||
| * @param {Object} options - Documentation build options | ||
| * @param {string} options.host - The hostname | ||
| * @param {string} options.path - The base path | ||
| * @param {number} options.port - The port number | ||
| * @param {Object} options.metadata - Metadata for the documentation | ||
| * @param {Array<string>} options.apis - List of API modules to document | ||
| * @param {string} options.publicClientId - Client ID for the public documentation | ||
| * @param {string} options.rootClientPath - Root path for client files | ||
| * @param {Object} options.packageData - Package.json data | ||
| * @param {Object} options.docs - Documentation config from server conf | ||
| */ | ||
| const buildDocs = async ({ | ||
| host, | ||
| path, | ||
| port, | ||
| metadata = {}, | ||
| apis = [], | ||
| publicClientId, | ||
| rootClientPath, | ||
| packageData, | ||
| docs, | ||
| }) => { | ||
| const pathPrefix = path === '/' ? '/' : `${path}/`; | ||
| // TypeDoc output is versioned: served at /docs/engine/{version}/ | ||
| const version = (packageData?.version || '').replace(/^v/, ''); | ||
| const jsDocsDestination = `./public/${host}${pathPrefix}docs/engine/${version}/`; | ||
| // Coverage output at /docs/coverage/ (or /docs/{coverageOutputDir}/) | ||
| const coverageBaseDestination = `./public/${host}${pathPrefix}docs/`; | ||
| await buildJsDocs({ host, path, metadata, publicClientId, docs, docsDestination: jsDocsDestination }); | ||
| await buildCoverage({ docs, docsDestination: coverageBaseDestination }); | ||
| await buildApiDocs({ | ||
| host, | ||
| path, | ||
| port, | ||
| metadata, | ||
| apis, | ||
| publicClientId, | ||
| rootClientPath, | ||
| packageData, | ||
| }); | ||
| }; | ||
| /** | ||
| * Builds Swagger UI customization options by rendering the SwaggerDarkMode SSR body component. | ||
| * Returns the customCss and customJsStr strings required by swagger-ui-express to enable | ||
| * a dark/light mode toggle button with a black/gray gradient dark theme. | ||
| * @function buildSwaggerUiOptions | ||
| * @memberof clientBuildDocs | ||
| * @returns {Promise<{customCss: string, customJsStr: string}>} Swagger UI setup options | ||
| */ | ||
| const buildSwaggerUiOptions = async () => { | ||
| const swaggerDarkMode = await ssrFactory('./src/client/ssr/body/SwaggerDarkMode.js'); | ||
| const { css, js } = swaggerDarkMode(); | ||
| return { customCss: css, customJsStr: js }; | ||
| }; | ||
| export { buildDocs, buildSwaggerUiOptions }; |
| /** | ||
| * Module for live building client-side code | ||
| * @module src/server/client-build-live.js | ||
| * @namespace clientLiveBuild | ||
| */ | ||
| import fs from 'fs-extra'; | ||
| import { Config, loadConf, readConfJson } from './conf.js'; | ||
| import { loggerFactory } from './logger.js'; | ||
| import { buildClient } from './client-build.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| /** | ||
| * @function clientLiveBuild | ||
| * @description Initiates a live build of client-side code. | ||
| * @memberof clientLiveBuild | ||
| */ | ||
| const clientLiveBuild = async () => { | ||
| if (fs.existsSync(`/tmp/client.build.json`)) { | ||
| const deployId = process.argv[2]; | ||
| const subConf = process.argv[3]; | ||
| let clientId = 'default'; | ||
| let host = 'default.net'; | ||
| let path = '/'; | ||
| let baseHost = `${host}${path === '/' ? '' : path}`; | ||
| let views; | ||
| let apiBaseHost; | ||
| let apiBaseProxyPath; | ||
| if ( | ||
| deployId && | ||
| (fs.existsSync(`./engine-private/conf/${deployId}`) || fs.existsSync(`./engine-private/replica/${deployId}`)) | ||
| ) { | ||
| loadConf(deployId, subConf); | ||
| const confClient = readConfJson(deployId, 'client'); | ||
| const confServer = readConfJson(deployId, 'server'); | ||
| host = process.argv[4]; | ||
| path = process.argv[5]; | ||
| clientId = confServer[host][path].client; | ||
| views = confClient[clientId].views; | ||
| baseHost = `${host}${path === '/' ? '' : path}`; | ||
| apiBaseHost = confServer[host][path].apiBaseHost; | ||
| apiBaseProxyPath = confServer[host][path].apiBaseProxyPath; | ||
| } else { | ||
| views = Config.default.client[clientId].views; | ||
| } | ||
| logger.info('Live build config', { | ||
| deployId, | ||
| subConf, | ||
| host, | ||
| path, | ||
| clientId, | ||
| baseHost, | ||
| views: views.length, | ||
| apiBaseHost, | ||
| apiBaseProxyPath, | ||
| }); | ||
| const updates = JSON.parse(fs.readFileSync(`/tmp/client.build.json`, 'utf8')); | ||
| const liveClientBuildPaths = []; | ||
| for (let srcPath of updates) { | ||
| srcPath = srcPath.replaceAll('/', `\\`); | ||
| const srcBuildPath = `./src${srcPath.split('src')[1].replace(/\\/g, '/')}`; | ||
| if ( | ||
| srcPath.split('src')[1].startsWith(`\\client\\components`) || | ||
| srcPath.split('src')[1].startsWith(`\\client\\services`) | ||
| ) { | ||
| const publicBuildPath = `./public/${baseHost}/${srcPath.split('src')[1].slice(8)}`.replace(/\\/g, '/'); | ||
| liveClientBuildPaths.push({ srcBuildPath, publicBuildPath }); | ||
| } else if (srcPath.split('src')[1].startsWith(`\\client\\sw`)) { | ||
| const publicBuildPath = `./public/${baseHost}/sw.js`; | ||
| liveClientBuildPaths.push({ srcBuildPath, publicBuildPath }); | ||
| } else if ( | ||
| srcPath.split('src')[1].startsWith(`\\client\\offline`) && | ||
| srcPath.split('src')[1].startsWith(`index.js`) | ||
| ) { | ||
| const publicBuildPath = `./public/${baseHost}/offline.js`; | ||
| liveClientBuildPaths.push({ srcBuildPath, publicBuildPath }); | ||
| } else if (srcPath.split('src')[1].startsWith(`\\client`) && srcPath.slice(-9) === '.index.js') { | ||
| for (const view of views) { | ||
| const publicBuildPath = `./public/${baseHost}${view.path === '/' ? '' : view.path}/${clientId}.index.js`; | ||
| liveClientBuildPaths.push({ srcBuildPath, publicBuildPath }); | ||
| } | ||
| } else if (srcPath.split('src')[1].startsWith(`\\client\\ssr`)) { | ||
| for (const view of views) { | ||
| const publicBuildPath = `./public/${baseHost}${view.path === '/' ? '' : view.path}/index.html`; | ||
| liveClientBuildPaths.push({ srcBuildPath, publicBuildPath }); | ||
| } | ||
| } | ||
| } | ||
| logger.info('liveClientBuildPaths', liveClientBuildPaths); | ||
| await buildClient({ liveClientBuildPaths, instances: [{ host, path }] }); | ||
| fs.removeSync(`/tmp/client.build.json`); | ||
| } | ||
| }; | ||
| export { clientLiveBuild }; |
| /** | ||
| * Manages the client-side build process, including full builds and incremental builds. | ||
| * @module server/client-build.js | ||
| * @namespace clientBuild | ||
| */ | ||
| 'use strict'; | ||
| import fs from 'fs-extra'; | ||
| import { transformClientJs, JSONweb } from './client-formatted.js'; | ||
| import { loggerFactory } from './logger.js'; | ||
| import { | ||
| getCapVariableName, | ||
| newInstance, | ||
| orderArrayFromAttrInt, | ||
| uniqueArray, | ||
| } from '../client/components/core/CommonJs.js'; | ||
| import { readConfJson } from './conf.js'; | ||
| import { minify } from 'html-minifier-terser'; | ||
| import AdmZip from 'adm-zip'; | ||
| import * as dir from 'path'; | ||
| import { shellExec } from './process.js'; | ||
| import { SitemapStream, streamToPromise } from 'sitemap'; | ||
| import { Readable } from 'stream'; | ||
| import { buildIcons } from './client-icons.js'; | ||
| import Underpost from '../index.js'; | ||
| import { buildDocs } from './client-build-docs.js'; | ||
| import { ssrFactory } from './ssr.js'; | ||
| // Static Site Generation (SSG) | ||
| /** | ||
| * Recursively copies files from source to destination, but only files that don't exist in destination. | ||
| * @function copyNonExistingFiles | ||
| * @param {string} src - Source directory path | ||
| * @param {string} dest - Destination directory path | ||
| * @returns {void} | ||
| * @memberof clientBuild | ||
| */ | ||
| const copyNonExistingFiles = (src, dest) => { | ||
| if (dir.basename(src) === '.git') return; | ||
| // Ensure source exists | ||
| if (!fs.existsSync(src)) { | ||
| throw new Error(`Source directory does not exist: ${src}`); | ||
| } | ||
| // Get stats for source | ||
| const srcStats = fs.statSync(src); | ||
| // If source is a file, copy only if it doesn't exist in destination | ||
| if (srcStats.isFile()) { | ||
| if (!fs.existsSync(dest)) { | ||
| const destDir = dir.dirname(dest); | ||
| fs.mkdirSync(destDir, { recursive: true }); | ||
| fs.copyFileSync(src, dest); | ||
| } | ||
| return; | ||
| } | ||
| // If source is a directory, create destination if it doesn't exist | ||
| if (srcStats.isDirectory()) { | ||
| if (!fs.existsSync(dest)) { | ||
| fs.mkdirSync(dest, { recursive: true }); | ||
| } | ||
| // Read all items in source directory | ||
| const items = fs.readdirSync(src); | ||
| // Recursively process each item | ||
| for (const item of items) { | ||
| const srcPath = dir.join(src, item); | ||
| const destPath = dir.join(dest, item); | ||
| copyNonExistingFiles(srcPath, destPath); | ||
| } | ||
| } | ||
| }; | ||
| const splitFileByMb = ({ filePath, partSizeMb, logger }) => { | ||
| const partSizeBytes = Math.floor(Number(partSizeMb) * 1024 * 1024); | ||
| if (!Number.isFinite(partSizeBytes) || partSizeBytes <= 0) { | ||
| throw new Error(`Invalid --split value: ${partSizeMb}`); | ||
| } | ||
| // Clean ALL stale part files (any naming variant) before writing new ones | ||
| const zipDir = dir.dirname(filePath); | ||
| const zipBase = dir.basename(filePath); | ||
| if (fs.existsSync(zipDir)) { | ||
| fs.readdirSync(zipDir) | ||
| .filter((name) => name.startsWith(`${zipBase}.part`) || name.startsWith(`${zipBase}-part`)) | ||
| .forEach((name) => fs.removeSync(dir.join(zipDir, name))); | ||
| } | ||
| const fileBuffer = fs.readFileSync(filePath); | ||
| const partPaths = []; | ||
| for (let offset = 0, partIndex = 0; offset < fileBuffer.length; offset += partSizeBytes, partIndex++) { | ||
| const partBuffer = fileBuffer.subarray(offset, offset + partSizeBytes); | ||
| const partPath = `${filePath}.part${String(partIndex + 1).padStart(3, '0')}`; | ||
| fs.writeFileSync(partPath, partBuffer); | ||
| partPaths.push(partPath); | ||
| } | ||
| logger.warn('split zip', { | ||
| filePath, | ||
| partSizeMb: Number(partSizeMb), | ||
| parts: partPaths.length, | ||
| }); | ||
| return partPaths; | ||
| }; | ||
| const getZipPartPaths = (zipPath) => { | ||
| const zipDir = dir.dirname(zipPath); | ||
| const zipBase = dir.basename(zipPath); | ||
| const partPrefixDot = `${zipBase}.part`; | ||
| const partPrefixDash = `${zipBase}-part`; | ||
| const parsePartIndex = (rawSuffix) => { | ||
| // Strip optional .zip suffix added by pull/download (e.g. '001.zip' → '001') | ||
| const digits = rawSuffix.replace(/\.zip$/i, ''); | ||
| return /^\d+$/.test(digits) ? Number(digits) : NaN; | ||
| }; | ||
| const getPartIndex = (name) => { | ||
| if (name.startsWith(partPrefixDot)) return parsePartIndex(name.slice(partPrefixDot.length)); | ||
| if (name.startsWith(partPrefixDash)) return parsePartIndex(name.slice(partPrefixDash.length)); | ||
| return NaN; | ||
| }; | ||
| return fs | ||
| .readdirSync(zipDir) | ||
| .filter((name) => Number.isFinite(getPartIndex(name))) | ||
| .sort((a, b) => getPartIndex(a) - getPartIndex(b)) | ||
| .map((name) => dir.join(zipDir, name)); | ||
| }; | ||
| const resolveClientBuildZip = (buildPrefix) => { | ||
| const normalizedPrefix = buildPrefix.replace(/\.zip(?:[.-]part\d+|[.-]part\*)?$/, '').replace(/[.-]part\*$/, ''); | ||
| const candidatePrefixes = uniqueArray([ | ||
| normalizedPrefix, | ||
| normalizedPrefix.endsWith('-') ? normalizedPrefix : `${normalizedPrefix}-`, | ||
| ]); | ||
| for (const prefix of candidatePrefixes) { | ||
| const zipPath = `${prefix}.zip`; | ||
| if (fs.existsSync(zipPath)) { | ||
| return { | ||
| buildPrefix: prefix, | ||
| zipPath, | ||
| partPaths: [], | ||
| }; | ||
| } | ||
| const partPaths = fs.existsSync(dir.dirname(zipPath)) ? getZipPartPaths(zipPath) : []; | ||
| if (partPaths.length > 0) { | ||
| return { | ||
| buildPrefix: prefix, | ||
| zipPath, | ||
| partPaths, | ||
| }; | ||
| } | ||
| } | ||
| const searchDir = dir.dirname(normalizedPrefix); | ||
| const prefixBase = dir.basename(normalizedPrefix); | ||
| if (!fs.existsSync(searchDir)) { | ||
| throw new Error(`Build directory not found: ${searchDir}`); | ||
| } | ||
| const matches = uniqueArray( | ||
| fs | ||
| .readdirSync(searchDir) | ||
| .filter((name) => name.startsWith(prefixBase) && /\.zip(?:[.-]part\d+)?$/.test(name)) | ||
| .map((name) => name.replace(/[.-]part\d+$/, '')), | ||
| ); | ||
| if (matches.length === 1) { | ||
| const zipPath = dir.join(searchDir, matches[0]); | ||
| const partPaths = getZipPartPaths(zipPath); | ||
| return { | ||
| buildPrefix: zipPath.replace(/\.zip$/, ''), | ||
| zipPath, | ||
| partPaths, | ||
| }; | ||
| } | ||
| if (matches.length > 1) { | ||
| throw new Error( | ||
| `Multiple build zip matches found for '${buildPrefix}': ${matches.join(', ')}. Use a more specific --unzip path.`, | ||
| ); | ||
| } | ||
| throw new Error(`No build zip or split parts found for: ${buildPrefix}`); | ||
| }; | ||
| /** | ||
| * Merges split ZIP parts back into a single ZIP file. | ||
| * @param {object} options | ||
| * @param {string} options.buildPrefix - The build prefix path (e.g. build/underpost.net/underpost.net-). | ||
| * @param {object} options.logger - Logger instance. | ||
| * @returns {{ zipPath: string, partPaths: string[], mergedBytes: number }} | ||
| */ | ||
| const mergeClientBuildZip = ({ buildPrefix, logger }) => { | ||
| // Normalize to get the zip path, then look for parts directly (bypassing resolveClientBuildZip | ||
| // which prefers an existing monolithic zip over parts). | ||
| const normalizedPrefix = buildPrefix.replace(/\.zip(?:[.-]part\d+)?$/, '').replace(/[-.]$/, '') + '-'; | ||
| const candidatePrefixes = uniqueArray([buildPrefix, buildPrefix.endsWith('-') ? buildPrefix : `${buildPrefix}-`]); | ||
| let zipPath; | ||
| let partPaths = []; | ||
| for (const prefix of candidatePrefixes) { | ||
| const candidate = prefix.endsWith('.zip') ? prefix : `${prefix}.zip`; | ||
| const parts = getZipPartPaths(candidate); | ||
| if (parts.length > 0) { | ||
| zipPath = candidate; | ||
| partPaths = parts; | ||
| break; | ||
| } | ||
| } | ||
| if (partPaths.length === 0) { | ||
| // Fall back to resolveClientBuildZip for the zipPath | ||
| const resolved = resolveClientBuildZip(buildPrefix); | ||
| zipPath = resolved.zipPath; | ||
| logger.warn('merge-zip: no split parts found, nothing to merge', { buildPrefix, zipPath }); | ||
| return { zipPath, partPaths, mergedBytes: 0 }; | ||
| } | ||
| // For each part, extract raw bytes: if the part file is a Cloudinary wrapper zip | ||
| // (downloaded via pull without --omit-unzip or with --omit-unzip keeping the .zip), | ||
| // extract the inner entry rather than using the wrapper bytes. | ||
| const readPartBytes = (partPath) => { | ||
| const rawBytes = fs.readFileSync(partPath); | ||
| // Check for ZIP magic bytes (PK\x03\x04) | ||
| if (rawBytes[0] === 0x50 && rawBytes[1] === 0x4b && rawBytes[2] === 0x03 && rawBytes[3] === 0x04) { | ||
| try { | ||
| const wrapperZip = new AdmZip(rawBytes); | ||
| const entries = wrapperZip.getEntries(); | ||
| // The inner entry is the original part file (without the outer .zip wrapper) | ||
| const partBase = dir.basename(partPath).replace(/\.zip$/i, ''); | ||
| const entry = entries.find((e) => e.entryName === partBase || e.entryName.endsWith('/' + partBase)); | ||
| if (entry) { | ||
| return entry.getData(); | ||
| } | ||
| // Fallback: single-entry archive | ||
| if (entries.length === 1) { | ||
| return entries[0].getData(); | ||
| } | ||
| } catch (_) { | ||
| // Not a valid zip or extraction failed — use raw bytes | ||
| } | ||
| } | ||
| return rawBytes; | ||
| }; | ||
| const mergedBuffer = Buffer.concat(partPaths.map(readPartBytes)); | ||
| fs.writeFileSync(zipPath, mergedBuffer); | ||
| logger.warn('merge-zip: merged split parts into zip', { | ||
| zipPath, | ||
| parts: partPaths.length, | ||
| mergedBytes: mergedBuffer.length, | ||
| }); | ||
| return { zipPath, partPaths, mergedBytes: mergedBuffer.length }; | ||
| }; | ||
| const unzipClientBuild = ({ buildPrefix, logger }) => { | ||
| const { zipPath, partPaths, buildPrefix: resolvedBuildPrefix } = resolveClientBuildZip(buildPrefix); | ||
| const outputPath = resolvedBuildPrefix.replace(/-$/, ''); | ||
| fs.removeSync(outputPath); | ||
| fs.mkdirSync(outputPath, { recursive: true }); | ||
| const zip = | ||
| partPaths.length > 0 | ||
| ? new AdmZip(Buffer.concat(partPaths.map((partPath) => fs.readFileSync(partPath)))) | ||
| : new AdmZip(zipPath); | ||
| zip.extractAllTo(outputPath, true); | ||
| logger.warn('unzip build', { | ||
| source: partPaths.length > 0 ? partPaths : [zipPath], | ||
| outputPath, | ||
| splitParts: partPaths.length, | ||
| }); | ||
| return { | ||
| outputPath, | ||
| zipPath, | ||
| partPaths, | ||
| }; | ||
| }; | ||
| /** @type {string} Default XSL sitemap template used when no `sitemap` source file exists in the public directory. */ | ||
| const defaultSitemapXsl = `<?xml version="1.0" encoding="UTF-8"?> | ||
| <xsl:stylesheet version="1.0" | ||
| xmlns:html="http://www.w3.org/TR/REC-html40" | ||
| xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" | ||
| xmlns:sitemap="http://www.sitemaps.org/schemas/sitemap/0.9" | ||
| xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> | ||
| <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" /> | ||
| <xsl:template match="/"> | ||
| <html xmlns="http://www.w3.org/1999/xhtml"> | ||
| <head> | ||
| <title>XML Sitemap</title> | ||
| <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | ||
| <style type="text/css"> | ||
| body { | ||
| font-family: sans-serif; | ||
| font-size: 16px; | ||
| color: #242628; | ||
| } | ||
| a { | ||
| color: #000; | ||
| text-decoration: none; | ||
| } | ||
| a:hover { | ||
| text-decoration: underline; | ||
| } | ||
| table { | ||
| border: none; | ||
| border-collapse: collapse; | ||
| width: 100% | ||
| } | ||
| th { | ||
| text-align: left; | ||
| padding-right: 30px; | ||
| font-size: 11px; | ||
| } | ||
| thead th { | ||
| border-bottom: 1px solid #7d878a; | ||
| cursor: pointer; | ||
| } | ||
| td { | ||
| font-size:11px; | ||
| padding: 5px; | ||
| } | ||
| tr:nth-child(odd) td { | ||
| background-color: rgba(0,0,0,0.04); | ||
| } | ||
| tr:hover td { | ||
| background-color: #e2edf2; | ||
| } | ||
| #content { | ||
| margin: 0 auto; | ||
| padding: 2% 5%; | ||
| max-width: 800px; | ||
| } | ||
| .desc { | ||
| margin: 18px 3px; | ||
| line-height: 1.2em; | ||
| } | ||
| .desc a { | ||
| color: #5ba4e5; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="content"> | ||
| <h1>XML Sitemap</h1> | ||
| <p class="desc"> This is a sitemap generated by <a | ||
| href="{{web-url}}">{{web-url}}</a> | ||
| </p> | ||
| <xsl:if test="count(sitemap:sitemapindex/sitemap:sitemap) > 0"> | ||
| <table id="sitemap" cellpadding="3"> | ||
| <thead> | ||
| <tr> | ||
| <th width="75%">Sitemap</th> | ||
| <th width="25%">Last Modified</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <xsl:for-each select="sitemap:sitemapindex/sitemap:sitemap"> | ||
| <xsl:variable name="sitemapURL"> | ||
| <xsl:value-of select="sitemap:loc" /> | ||
| </xsl:variable> | ||
| <tr> | ||
| <td> | ||
| <a href="{$sitemapURL}"> | ||
| <xsl:value-of select="sitemap:loc" /> | ||
| </a> | ||
| </td> | ||
| <td> | ||
| <xsl:value-of | ||
| select="concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)))" /> | ||
| </td> | ||
| </tr> | ||
| </xsl:for-each> | ||
| </tbody> | ||
| </table> | ||
| </xsl:if> | ||
| <xsl:if test="count(sitemap:sitemapindex/sitemap:sitemap) < 1"> | ||
| <p class="desc"> | ||
| <a href="{{web-url}}sitemap.xml" class="back-link">← Back to index</a> | ||
| </p> | ||
| <table | ||
| id="sitemap" cellpadding="3"> | ||
| <thead> | ||
| <tr> | ||
| <th width="70%">URL (<xsl:value-of | ||
| select="count(sitemap:urlset/sitemap:url)" /> total)</th> | ||
| <th width="15%">Images</th> | ||
| <th title="Last Modification Time" width="15%">Last Modified</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <xsl:variable name="lower" select="'abcdefghijklmnopqrstuvwxyz'" /> | ||
| <xsl:variable name="upper" select="'ABCDEFGHIJKLMNOPQRSTUVWXYZ'" /> | ||
| <xsl:for-each select="sitemap:urlset/sitemap:url"> | ||
| <tr> | ||
| <td> | ||
| <xsl:variable name="itemURL"> | ||
| <xsl:value-of select="sitemap:loc" /> | ||
| </xsl:variable> | ||
| <a href="{$itemURL}"> | ||
| <xsl:value-of select="sitemap:loc" /> | ||
| </a> | ||
| </td> | ||
| <td> | ||
| <xsl:value-of select="count(image:image)" /> | ||
| </td> | ||
| <td> | ||
| <xsl:value-of | ||
| select="concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)))" /> | ||
| </td> | ||
| </tr> | ||
| </xsl:for-each> | ||
| </tbody> | ||
| </table> | ||
| <p | ||
| class="desc"> | ||
| <a href="{{web-url}}sitemap.xml" class="back-link">← Back to index</a> | ||
| </p> | ||
| </xsl:if> | ||
| </div> | ||
| </body> | ||
| </html> | ||
| </xsl:template> | ||
| </xsl:stylesheet>`; | ||
| /** | ||
| * @async | ||
| * @function buildClient | ||
| * @memberof clientBuild | ||
| * @param {Object} options - Options for the build process. | ||
| * @param {string} options.deployId - The deployment ID for which to build the client. | ||
| * @param {Array} options.liveClientBuildPaths - List of paths to build incrementally. | ||
| * @param {Array} options.instances - List of instances to build. | ||
| * @param {boolean} options.buildZip - Whether to create zip files of the builds. | ||
| * @param {string|number} options.split - Optional zip split size in MB. | ||
| * @param {boolean} options.fullBuild - Whether to perform a full build. | ||
| * @param {boolean} options.iconsBuild - Whether to build icons. | ||
| * @returns {Promise<void>} - Promise that resolves when the build is complete. | ||
| * @throws {Error} - If the build fails. | ||
| * @memberof clientBuild | ||
| */ | ||
| const buildClient = async ( | ||
| options = { | ||
| deployId: '', | ||
| liveClientBuildPaths: [], | ||
| instances: [], | ||
| buildZip: false, | ||
| split: '', | ||
| fullBuild: false, | ||
| iconsBuild: false, | ||
| }, | ||
| ) => { | ||
| const logger = loggerFactory(import.meta); | ||
| const deployId = options.deployId || process.env.DEPLOY_ID; | ||
| const confClient = readConfJson(deployId, 'client'); | ||
| const confServer = readConfJson(deployId, 'server', { loadReplicas: true }); | ||
| const confSSR = readConfJson(deployId, 'ssr'); | ||
| const packageData = JSON.parse(fs.readFileSync(`./package.json`, 'utf8')); | ||
| const acmeChallengePath = `/.well-known/acme-challenge`; | ||
| const publicPath = `./public`; | ||
| /** | ||
| * @async | ||
| * @function buildAcmeChallengePath | ||
| * @memberof clientBuild | ||
| * @param {string} acmeChallengeFullPath - Full path to the acme-challenge directory. | ||
| * @returns {void} | ||
| * @throws {Error} - If the directory cannot be created. | ||
| * @memberof clientBuild | ||
| */ | ||
| const buildAcmeChallengePath = (acmeChallengeFullPath = '') => { | ||
| fs.mkdirSync(acmeChallengeFullPath, { | ||
| recursive: true, | ||
| }); | ||
| fs.writeFileSync(`${acmeChallengeFullPath}/.gitkeep`, '', 'utf8'); | ||
| }; | ||
| /** | ||
| * @async | ||
| * @function fullBuild | ||
| * @memberof clientBuild | ||
| * @param {Object} options - Options for the full build process. | ||
| * @param {string} options.path - Path to the client directory. | ||
| * @param {Object} options.logger - Logger instance. | ||
| * @param {string} options.client - Client name. | ||
| * @param {Object} options.db - Database configuration. | ||
| * @param {Array} options.dists - List of distributions to build. | ||
| * @param {string} options.rootClientPath - Full path to the client directory. | ||
| * @param {string} options.acmeChallengeFullPath - Full path to the acme-challenge directory. | ||
| * @param {string} options.publicClientId - Public client ID. | ||
| * @param {boolean} options.iconsBuild - Whether to build icons. | ||
| * @param {Object} options.metadata - Metadata for the client. | ||
| * @param {boolean} options.publicCopyNonExistingFiles - Whether to copy non-existing files from public directory. | ||
| * @returns {Promise<void>} - Promise that resolves when the full build is complete. | ||
| * @throws {Error} - If the full build fails. | ||
| * @memberof clientBuild | ||
| */ | ||
| const fullBuild = async ({ | ||
| path, | ||
| logger, | ||
| client, | ||
| db, | ||
| dists, | ||
| rootClientPath, | ||
| acmeChallengeFullPath, | ||
| publicClientId, | ||
| iconsBuild, | ||
| metadata, | ||
| publicCopyNonExistingFiles, | ||
| }) => { | ||
| logger.warn('Full build', rootClientPath); | ||
| buildAcmeChallengePath(acmeChallengeFullPath); | ||
| fs.removeSync(rootClientPath); | ||
| if (fs.existsSync(`./src/client/public/${publicClientId}`)) { | ||
| if (iconsBuild === true) await buildIcons({ publicClientId, metadata }); | ||
| fs.copySync(`./src/client/public/${publicClientId}`, rootClientPath, { | ||
| filter: (sourcePath) => !sourcePath.split(dir.sep).includes('.git'), | ||
| }); | ||
| } else if (fs.existsSync(`./engine-private/src/client/public/${publicClientId}`)) { | ||
| fs.copySync(`./engine-private/src/client/public/${publicClientId}`, rootClientPath, { | ||
| filter: (sourcePath) => !sourcePath.split(dir.sep).includes('.git'), | ||
| }); | ||
| } | ||
| if (dists) | ||
| for (const dist of dists) { | ||
| if ('folder' in dist) { | ||
| if (fs.statSync(dist.folder).isDirectory()) { | ||
| fs.mkdirSync(`${rootClientPath}${dist.public_folder}`, { recursive: true }); | ||
| fs.copySync(dist.folder, `${rootClientPath}${dist.public_folder}`); | ||
| } else { | ||
| const folder = dist.public_folder.split('/'); | ||
| folder.pop(); | ||
| fs.mkdirSync(`${rootClientPath}${folder.join('/')}`, { recursive: true }); | ||
| fs.copyFileSync(dist.folder, `${rootClientPath}${dist.public_folder}`); | ||
| } | ||
| } | ||
| if ('styles' in dist) { | ||
| fs.mkdirSync(`${rootClientPath}${dist.public_styles_folder}`, { recursive: true }); | ||
| fs.copySync(dist.styles, `${rootClientPath}${dist.public_styles_folder}`); | ||
| } | ||
| } | ||
| if (publicCopyNonExistingFiles) | ||
| copyNonExistingFiles(`./src/client/public/${publicCopyNonExistingFiles}`, rootClientPath); | ||
| }; | ||
| // { srcBuildPath, publicBuildPath } | ||
| const enableLiveRebuild = | ||
| options && options.liveClientBuildPaths && options.liveClientBuildPaths.length > 0 ? true : false; | ||
| const isDevelopment = process.env.NODE_ENV === 'development'; | ||
| let currentPort = parseInt(process.env.PORT) + 1; | ||
| for (const host of Object.keys(confServer)) { | ||
| const paths = orderArrayFromAttrInt(Object.keys(confServer[host]), 'length', 'asc'); | ||
| for (const path of paths) { | ||
| if ( | ||
| options && | ||
| options.instances && | ||
| options.instances.length > 0 && | ||
| !options.instances.find((i) => i.path === path && i.host === host) | ||
| ) | ||
| continue; | ||
| const { | ||
| runtime, | ||
| client, | ||
| directory, | ||
| disabledRebuild, | ||
| db, | ||
| redirect, | ||
| apis, | ||
| apiBaseProxyPath, | ||
| apiBaseHost, | ||
| ttiLoadTimeLimit, | ||
| singleReplica, | ||
| docs, | ||
| } = confServer[host][path]; | ||
| if (singleReplica) continue; | ||
| if (!confClient[client]) confClient[client] = {}; | ||
| const { components, dists, views, services, metadata, publicRef, publicCopyNonExistingFiles } = | ||
| confClient[client]; | ||
| let backgroundImage; | ||
| if (metadata) { | ||
| backgroundImage = metadata.backgroundImage; | ||
| if (metadata.thumbnail) metadata.thumbnail = `${path === '/' ? path : `${path}/`}${metadata.thumbnail}`; | ||
| } | ||
| const rootClientPath = directory ? directory : `${publicPath}/${host}${path}`; | ||
| const port = newInstance(currentPort); | ||
| const publicClientId = publicRef ? publicRef : client; | ||
| const fullBuildEnabled = options.fullBuild && !enableLiveRebuild; | ||
| // const baseHost = process.env.NODE_ENV === 'production' ? `https://${host}` : `http://localhost:${port}`; | ||
| const baseHost = process.env.NODE_ENV === 'production' ? `https://${host}` : ``; | ||
| const minifyBuild = process.env.NODE_ENV === 'production'; | ||
| // ''; // process.env.NODE_ENV === 'production' ? `https://${host}` : ``; | ||
| currentPort++; | ||
| const acmeChallengeFullPath = directory | ||
| ? `${directory}${acmeChallengePath}` | ||
| : `${publicPath}/${host}${acmeChallengePath}`; | ||
| if (!enableLiveRebuild) buildAcmeChallengePath(acmeChallengeFullPath); | ||
| if (redirect || disabledRebuild) continue; | ||
| if (fullBuildEnabled) | ||
| await fullBuild({ | ||
| path, | ||
| logger, | ||
| client, | ||
| db, | ||
| dists, | ||
| rootClientPath, | ||
| acmeChallengeFullPath, | ||
| publicClientId, | ||
| iconsBuild: options.iconsBuild, | ||
| metadata, | ||
| publicCopyNonExistingFiles, | ||
| }); | ||
| if (components) | ||
| for (const module of Object.keys(components)) { | ||
| if (!fs.existsSync(`${rootClientPath}/components/${module}`)) | ||
| fs.mkdirSync(`${rootClientPath}/components/${module}`, { recursive: true }); | ||
| for (const component of components[module]) { | ||
| const jsSrcPath = `./src/client/components/${module}/${component}.js`; | ||
| const jsPublicPath = `${rootClientPath}/components/${module}/${component}.js`; | ||
| if (enableLiveRebuild && !options.liveClientBuildPaths.find((p) => p.srcBuildPath === jsSrcPath)) continue; | ||
| const jsSrc = await transformClientJs(jsSrcPath, { | ||
| dists, | ||
| proxyPath: path, | ||
| basePath: 'components', | ||
| module, | ||
| baseHost, | ||
| minify: minifyBuild, | ||
| }); | ||
| fs.writeFileSync(jsPublicPath, jsSrc, 'utf8'); | ||
| } | ||
| } | ||
| if (services) { | ||
| for (const module of services) { | ||
| if (!fs.existsSync(`${rootClientPath}/services/${module}`)) | ||
| fs.mkdirSync(`${rootClientPath}/services/${module}`, { recursive: true }); | ||
| const moduleDir = `./src/client/services/${module}`; | ||
| if (!fs.existsSync(moduleDir)) continue; | ||
| const serviceFiles = fs | ||
| .readdirSync(moduleDir) | ||
| .filter((name) => name.endsWith('.service.js') || name.endsWith('.management.js')) | ||
| .sort(); | ||
| for (const serviceFile of serviceFiles) { | ||
| const jsSrcPath = `${moduleDir}/${serviceFile}`; | ||
| const jsPublicPath = `${rootClientPath}/services/${module}/${serviceFile}`; | ||
| if (enableLiveRebuild && !options.liveClientBuildPaths.find((p) => p.srcBuildPath === jsSrcPath)) continue; | ||
| const jsSrc = await transformClientJs(jsSrcPath, { | ||
| dists, | ||
| proxyPath: path, | ||
| basePath: 'services', | ||
| module, | ||
| baseHost, | ||
| minify: minifyBuild, | ||
| }); | ||
| fs.writeFileSync(jsPublicPath, jsSrc, 'utf8'); | ||
| } | ||
| // Auto-build guest module files when user module is processed | ||
| if (module === 'user') { | ||
| const guestModuleDir = './src/client/services/user'; | ||
| const guestServicePath = `${guestModuleDir}/guest.service.js`; | ||
| if (fs.existsSync(guestServicePath)) { | ||
| if (!fs.existsSync(`${rootClientPath}/services/user`)) | ||
| fs.mkdirSync(`${rootClientPath}/services/user`, { recursive: true }); | ||
| const guestJsPublicPath = `${rootClientPath}/services/user/guest.service.js`; | ||
| if (!enableLiveRebuild || options.liveClientBuildPaths.find((p) => p.srcBuildPath === guestServicePath)) { | ||
| const guestJsSrc = await transformClientJs(guestServicePath, { | ||
| dists, | ||
| proxyPath: path, | ||
| basePath: 'services', | ||
| module: 'user', | ||
| baseHost, | ||
| minify: minifyBuild, | ||
| }); | ||
| fs.writeFileSync(guestJsPublicPath, guestJsSrc, 'utf8'); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| const buildId = `${client}.index`; | ||
| const siteMapLinks = []; | ||
| const ssrPath = path === '/' ? path : `${path}/`; | ||
| const Render = await ssrFactory(); | ||
| const swSrcPath = `./src/client/sw/core.sw.js`; | ||
| const swPublicPath = `${rootClientPath}/sw.js`; | ||
| const swShouldRebuild = | ||
| views && !(enableLiveRebuild && !options.liveClientBuildPaths.find((p) => p.srcBuildPath === swSrcPath)); | ||
| // Transformed SW JS is held in memory; it gets prepended with renderPayload | ||
| // and written once below, after PRE_CACHED_RESOURCES are known. | ||
| let swTransformedJs = ''; | ||
| if (swShouldRebuild) { | ||
| swTransformedJs = await transformClientJs(swSrcPath, { | ||
| dists, | ||
| proxyPath: path, | ||
| baseHost, | ||
| minify: minifyBuild, | ||
| externalizeBareImports: false, | ||
| }); | ||
| } | ||
| if (views) { | ||
| if ( | ||
| !( | ||
| enableLiveRebuild && | ||
| !options.liveClientBuildPaths.find( | ||
| (p) => p.srcBuildPath.startsWith(`./src/client/ssr`) || p.srcBuildPath.slice(-9) === '.index.js', | ||
| ) | ||
| ) | ||
| ) | ||
| for (const view of views) { | ||
| const buildPath = `${rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath | ||
| }${view.path === '/' ? view.path : `${view.path}/`}`; | ||
| if (!fs.existsSync(buildPath)) fs.mkdirSync(buildPath, { recursive: true }); | ||
| logger.info('View build', buildPath); | ||
| const jsSrc = await transformClientJs(`./src/client/${view.client}.index.js`, { | ||
| dists, | ||
| proxyPath: path, | ||
| baseHost, | ||
| minify: minifyBuild, | ||
| }); | ||
| fs.writeFileSync(`${buildPath}${buildId}.js`, jsSrc, 'utf8'); | ||
| const title = metadata.title ? metadata.title : title; | ||
| const canonicalURL = `https://${host}${path}${view.path === '/' ? (path === '/' ? '' : '/') : path === '/' ? `${view.path.slice(1)}/` : `${view.path}/` | ||
| }`; | ||
| let ssrHeadComponents = ``; | ||
| let ssrBodyComponents = ``; | ||
| if ('ssr' in view) { | ||
| // https://metatags.io/ | ||
| if (process.env.NODE_ENV === 'production' && !confSSR[view.ssr].head.includes('Production')) | ||
| confSSR[view.ssr].head.unshift('Production'); | ||
| for (const ssrHeadComponent of confSSR[view.ssr].head) { | ||
| const SrrComponent = await ssrFactory(`./src/client/ssr/head/${ssrHeadComponent}.js`); | ||
| switch (ssrHeadComponent) { | ||
| case 'Pwa': | ||
| const validPwaBuild = | ||
| metadata && | ||
| fs.existsSync(`./src/client/public/${publicClientId}/browserconfig.xml`) && | ||
| fs.existsSync(`./src/client/public/${publicClientId}/site.webmanifest`); | ||
| if (validPwaBuild) { | ||
| // build webmanifest | ||
| const webmanifestJson = JSON.parse( | ||
| fs.readFileSync(`./src/client/public/${publicClientId}/site.webmanifest`, 'utf8'), | ||
| ); | ||
| if (metadata.title) { | ||
| webmanifestJson.name = metadata.title; | ||
| webmanifestJson.short_name = metadata.title; | ||
| } | ||
| if (metadata.description) { | ||
| webmanifestJson.description = metadata.description; | ||
| } | ||
| if (metadata.themeColor) { | ||
| webmanifestJson.theme_color = metadata.themeColor; | ||
| webmanifestJson.background_color = metadata.themeColor; | ||
| } | ||
| fs.writeFileSync( | ||
| `${buildPath}site.webmanifest`, | ||
| JSON.stringify(webmanifestJson, null, 4).replaceAll(`: "/`, `: "${ssrPath}`), | ||
| 'utf8', | ||
| ); | ||
| // build browserconfig | ||
| fs.writeFileSync( | ||
| `${buildPath}browserconfig.xml`, | ||
| fs | ||
| .readFileSync(`./src/client/public/${publicClientId}/browserconfig.xml`, 'utf8') | ||
| .replaceAll( | ||
| `<TileColor></TileColor>`, | ||
| metadata.themeColor | ||
| ? `<TileColor>${metadata.themeColor}</TileColor>` | ||
| : `<TileColor>#e0e0e0</TileColor>`, | ||
| ) | ||
| .replaceAll(`src="/`, `src="${ssrPath}`), | ||
| 'utf8', | ||
| ); | ||
| // Android play store example: | ||
| // | ||
| // "related_applications": [ | ||
| // { | ||
| // "platform": "play", | ||
| // "url": "https://play.google.com/store/apps/details?id=cheeaun.hackerweb" | ||
| // } | ||
| // ], | ||
| // "prefer_related_applications": true | ||
| } | ||
| if (validPwaBuild) ssrHeadComponents += SrrComponent({ title, ssrPath, canonicalURL, ...metadata }); | ||
| break; | ||
| case 'Seo': | ||
| if (metadata) { | ||
| ssrHeadComponents += SrrComponent({ title, ssrPath, canonicalURL, ...metadata }); | ||
| } | ||
| break; | ||
| case 'Microdata': | ||
| if ( | ||
| fs.existsSync(`./src/client/public/${publicClientId}/microdata.json`) // && | ||
| // path === '/' && | ||
| // view.path === '/' | ||
| ) { | ||
| const microdata = JSON.parse( | ||
| fs.readFileSync(`./src/client/public/${publicClientId}/microdata.json`, 'utf8'), | ||
| ); | ||
| ssrHeadComponents += SrrComponent({ microdata }); | ||
| } | ||
| break; | ||
| default: | ||
| ssrHeadComponents += SrrComponent({ ssrPath, host, path }); | ||
| break; | ||
| } | ||
| } | ||
| for (const ssrBodyComponent of confSSR[view.ssr].body) { | ||
| const SrrComponent = await ssrFactory(`./src/client/ssr/body/${ssrBodyComponent}.js`); | ||
| ssrBodyComponents += SrrComponent({ | ||
| ...metadata, | ||
| ssrPath, | ||
| host, | ||
| path, | ||
| ttiLoadTimeLimit, | ||
| version: Underpost.version, | ||
| backgroundImage: backgroundImage ? (path === '/' ? path : `${path}/`) + backgroundImage : undefined, | ||
| }); | ||
| } | ||
| } | ||
| /** @type {import('sitemap').SitemapItem} */ | ||
| const siteMapLink = { | ||
| url: `${path === '/' ? '' : path}${view.path}`, | ||
| changefreq: 'daily', | ||
| priority: 0.8, | ||
| }; | ||
| siteMapLinks.push(siteMapLink); | ||
| const htmlSrc = Render({ | ||
| title, | ||
| buildId, | ||
| ssrPath, | ||
| ssrHeadComponents, | ||
| ssrBodyComponents, | ||
| renderPayload: { | ||
| apiBaseProxyPath, | ||
| apiBaseHost, | ||
| apiBasePath: process.env.BASE_API, | ||
| version: Underpost.version, | ||
| ...(isDevelopment ? { dev: true } : undefined), | ||
| }, | ||
| renderApi: { | ||
| JSONweb, | ||
| }, | ||
| }); | ||
| fs.writeFileSync( | ||
| `${buildPath}index.html`, | ||
| minifyBuild | ||
| ? await minify(htmlSrc, { | ||
| minifyCSS: true, | ||
| minifyJS: true, | ||
| collapseBooleanAttributes: true, | ||
| collapseInlineTagWhitespace: true, | ||
| collapseWhitespace: true, | ||
| }) | ||
| : htmlSrc, | ||
| 'utf8', | ||
| ); | ||
| } | ||
| } | ||
| if (!enableLiveRebuild && siteMapLinks.length > 0) { | ||
| const hasSitemapTemplate = fs.existsSync(`${rootClientPath}/sitemap`); | ||
| const sitemapBaseUrl = `https://${host}${path === '/' ? '' : path}`; | ||
| // Create a stream to write to — omit xslUrl so we can inject a relative href below | ||
| /** @type {import('sitemap').SitemapStreamOptions} */ | ||
| const sitemapOptions = { hostname: `https://${host}` }; | ||
| const siteMapStream = new SitemapStream(sitemapOptions); | ||
| let siteMapSrc = await new Promise((resolve) => | ||
| streamToPromise(Readable.from(siteMapLinks).pipe(siteMapStream)).then((data) => resolve(data.toString())), | ||
| ); | ||
| // Inject a relative xml-stylesheet PI so the XSL loads from the same origin | ||
| // (works on both http://localhost:<port> and https://production-host) | ||
| siteMapSrc = siteMapSrc.replace( | ||
| '<?xml version="1.0" encoding="UTF-8"?>', | ||
| '<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="sitemap.xsl"?>', | ||
| ); | ||
| // Return a promise that resolves with your XML string | ||
| fs.writeFileSync(`${rootClientPath}/sitemap.xml`, siteMapSrc, 'utf8'); | ||
| // Generate XSL stylesheet from source template or default fallback | ||
| const xslTemplate = hasSitemapTemplate | ||
| ? fs.readFileSync(`${rootClientPath}/sitemap`, 'utf8') | ||
| : defaultSitemapXsl; | ||
| const webUrl = `https://${host}${path === '/' ? '/' : `${path}/`}`; | ||
| fs.writeFileSync(`${rootClientPath}/sitemap.xsl`, xslTemplate.replaceAll('{{web-url}}', webUrl), 'utf8'); | ||
| fs.writeFileSync( | ||
| `${rootClientPath}/robots.txt`, | ||
| `User-agent: * | ||
| Sitemap: ${sitemapBaseUrl}/sitemap.xml`, | ||
| 'utf8', | ||
| ); | ||
| } | ||
| if (fullBuildEnabled && docs) { | ||
| await buildDocs({ | ||
| host, | ||
| path, | ||
| port, | ||
| metadata, | ||
| apis, | ||
| publicClientId, | ||
| rootClientPath, | ||
| packageData, | ||
| docs, | ||
| }); | ||
| } | ||
| if (client) { | ||
| const proxyPrefix = path === '/' ? '' : path; | ||
| const buildIndexUrl = (routePath) => `${proxyPrefix}${routePath === '/' ? '' : routePath}/index.html`; | ||
| // SSR views: a single declarative array. The role of each view (regular | ||
| // page vs. offline/maintenance fallback) is expressed by per-entry flags; | ||
| // fallback-flagged views are also precached so the SW can serve them | ||
| // when the network is unreachable. | ||
| const ssrClientConf = confSSR[getCapVariableName(client)] || {}; | ||
| const ssrViews = Array.isArray(ssrClientConf.views) ? ssrClientConf.views : []; | ||
| const PRE_CACHED_RESOURCES = []; | ||
| let offlineFallbackUrl = null; | ||
| let maintenanceFallbackUrl = null; | ||
| for (const view of ssrViews) { | ||
| const SsrComponent = await ssrFactory(`./src/client/ssr/views/${view.client}.js`); | ||
| const htmlSrc = Render({ | ||
| title: view.title, | ||
| ssrPath, | ||
| ssrHeadComponents: '<base target="_top">', | ||
| ssrBodyComponents: SsrComponent(), | ||
| renderPayload: { | ||
| apiBaseProxyPath, | ||
| apiBaseHost, | ||
| apiBasePath: process.env.BASE_API, | ||
| version: Underpost.version, | ||
| ...(isDevelopment ? { dev: true } : undefined), | ||
| }, | ||
| renderApi: { JSONweb }, | ||
| }); | ||
| const buildPath = `${rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath | ||
| }${view.path === '/' ? view.path : `${view.path}/`}`; | ||
| const indexUrl = buildIndexUrl(view.path); | ||
| if (view.offlineDefault) { | ||
| offlineFallbackUrl = indexUrl; | ||
| PRE_CACHED_RESOURCES.push(indexUrl); | ||
| } | ||
| if (view.maintenanceDefault) { | ||
| maintenanceFallbackUrl = indexUrl; | ||
| PRE_CACHED_RESOURCES.push(indexUrl); | ||
| } | ||
| if (!fs.existsSync(buildPath)) fs.mkdirSync(buildPath, { recursive: true }); | ||
| const buildHtmlPath = `${buildPath}index.html`; | ||
| logger.info('ssr view build', buildHtmlPath); | ||
| fs.writeFileSync( | ||
| buildHtmlPath, | ||
| minifyBuild | ||
| ? await minify(htmlSrc, { | ||
| minifyCSS: true, | ||
| minifyJS: true, | ||
| collapseBooleanAttributes: true, | ||
| collapseInlineTagWhitespace: true, | ||
| collapseWhitespace: true, | ||
| }) | ||
| : htmlSrc, | ||
| 'utf8', | ||
| ); | ||
| } | ||
| if (swShouldRebuild) { | ||
| const cacheScope = path === '/' ? 'root' : path.replaceAll('/', '_'); | ||
| const renderPayload = { | ||
| PRE_CACHED_RESOURCES: uniqueArray(PRE_CACHED_RESOURCES), | ||
| PROXY_PATH: path, | ||
| CACHE_PREFIX: `engine-core-${cacheScope}`, | ||
| OFFLINE_URL: offlineFallbackUrl || buildIndexUrl('/offline'), | ||
| MAINTENANCE_URL: maintenanceFallbackUrl || buildIndexUrl('/maintenance'), | ||
| }; | ||
| // Single write: prepend the payload prelude to the transformed SW JS. | ||
| fs.writeFileSync( | ||
| swPublicPath, | ||
| `self.renderPayload = ${JSONweb(renderPayload)}; | ||
| self.__WB_DISABLE_DEV_LOGS = true; | ||
| ${swTransformedJs}`, | ||
| 'utf8', | ||
| ); | ||
| } | ||
| } | ||
| if (!enableLiveRebuild && options.buildZip) { | ||
| logger.warn('build zip', rootClientPath); | ||
| if (!fs.existsSync('./build')) fs.mkdirSync('./build'); | ||
| const zip = new AdmZip(); | ||
| const files = await fs.readdir(rootClientPath, { recursive: true }); | ||
| for (const relativePath of files) { | ||
| const filePath = dir.resolve(`${rootClientPath}/${relativePath}`); | ||
| if (!fs.lstatSync(filePath).isDirectory()) { | ||
| const folder = dir.relative(`public/${host}${path}`, dir.dirname(filePath)); | ||
| zip.addLocalFile(filePath, folder); | ||
| } | ||
| } | ||
| const buildId = `${host}-${path.replaceAll('/', '')}`; | ||
| const zipPath = `./build/${buildId}.zip`; | ||
| logger.warn('write zip', zipPath); | ||
| zip.writeZip(zipPath); | ||
| if (options.split) { | ||
| splitFileByMb({ | ||
| filePath: zipPath, | ||
| partSizeMb: options.split, | ||
| logger, | ||
| }); | ||
| fs.removeSync(zipPath); | ||
| logger.warn('removed original zip after split', { zipPath }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| export { buildClient, copyNonExistingFiles, unzipClientBuild, mergeClientBuildZip }; |
| /** | ||
| * Module for creating a client-side development server | ||
| * @module src/server/client-dev-server.js | ||
| * @namespace clientDevServer | ||
| */ | ||
| import fs from 'fs-extra'; | ||
| import nodemon from 'nodemon'; | ||
| import dotenv from 'dotenv'; | ||
| import { shellExec } from './process.js'; | ||
| import { loggerFactory } from './logger.js'; | ||
| import Underpost from '../index.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| /** | ||
| * @function createClientDevServer | ||
| * @description Creates a client-side development server. | ||
| * @memberof clientDevServer | ||
| * @param {string} deployId - The deployment ID. | ||
| * @param {string} subConf - The sub-configuration. | ||
| * @param {string} host - The host. | ||
| * @param {string} path - The path. | ||
| * @returns {void} | ||
| * @memberof clientDevServer | ||
| */ | ||
| const createClientDevServer = async ( | ||
| deployId = process.argv[2] || 'dd-default', | ||
| subConf = process.argv[3] || '', | ||
| host = process.argv[4] || 'default.net', | ||
| path = process.argv[5] || '/', | ||
| ) => { | ||
| const devClientEnvPath = `./engine-private/conf/${deployId}/.env.${process.env.NODE_ENV}.${subConf}-dev-client`; | ||
| if (fs.existsSync(devClientEnvPath)) dotenv.config({ path: devClientEnvPath, override: true }); | ||
| await Underpost.repo.client(deployId, `${subConf}-dev-client`.trim(), host, path); | ||
| shellExec(`node src/server ${deployId} ${subConf}-dev-client`.trim(), { | ||
| async: true, | ||
| }); | ||
| // https://github.com/remy/nodemon/blob/main/doc/events.md | ||
| // States | ||
| // start - child process has started | ||
| // crash - child process has crashed (nodemon will not emit exit) | ||
| // exit - child process has cleanly exited (ie. no crash) | ||
| // restart([ array of files triggering the restart ]) - child process has restarted | ||
| // config:update - nodemon's config has changed | ||
| if (fs.existsSync(`/tmp/client.build.json`)) fs.removeSync(`/tmp/client.build.json`); | ||
| let buildPathScope = []; | ||
| const nodemonOptions = { | ||
| script: './src/client.build', | ||
| args: [`${deployId}`, `${subConf}-dev-client`, `${host}`, `${path}`], | ||
| watch: 'src/client', | ||
| }; | ||
| logger.info('nodemon option', { nodemonOptions }); | ||
| nodemon(nodemonOptions) | ||
| .on('start', function (...args) { | ||
| logger.info(args, 'nodemon started'); | ||
| }) | ||
| .on('restart', function (...args) { | ||
| logger.info(args, 'nodemon restart'); | ||
| const eventPath = args[0][0]; | ||
| const indexPath = buildPathScope.findIndex((buildObjScope) => buildObjScope.path === eventPath); | ||
| const buildObj = { | ||
| timestamp: new Date().getTime(), | ||
| path: eventPath, | ||
| }; | ||
| if (indexPath > -1) { | ||
| buildPathScope[indexPath].timestamp = buildObj.timestamp; | ||
| } else buildPathScope.push(buildObj); | ||
| setTimeout(() => { | ||
| buildPathScope = buildPathScope.filter((buildObjScope) => buildObjScope.timestamp !== buildObj.timestamp); | ||
| }, 2500); | ||
| const buildPathScopeBuild = buildPathScope.map((o) => o.path); | ||
| logger.info('buildPathScopeBuild', buildPathScopeBuild); | ||
| fs.writeFileSync(`/tmp/client.build.json`, JSON.stringify(buildPathScopeBuild, null, 4)); | ||
| }) | ||
| .on('crash', function (error) { | ||
| if (error) logger.error(error, error.message || 'nodemon crash'); | ||
| else logger.error('nodemon process crashed'); | ||
| }); | ||
| }; | ||
| export { createClientDevServer }; |
| /** | ||
| * Module for formatting client-side code using esbuild for import rewriting and minification. | ||
| * @module src/server/client-formatted.js | ||
| * @namespace clientFormatted | ||
| */ | ||
| 'use strict'; | ||
| import * as esbuild from 'esbuild'; | ||
| import fs from 'fs-extra'; | ||
| import * as path from 'path'; | ||
| /** | ||
| * Escapes a string for safe use inside a RegExp. | ||
| * @param {string} s - The string to escape. | ||
| * @returns {string} The escaped string. | ||
| * @memberof clientFormatted | ||
| */ | ||
| const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| /** | ||
| * Formats a source code string by removing 'html`' and 'css`' tagged template prefixes. | ||
| * Used for SSR VM execution where the full esbuild pipeline is not needed. | ||
| * @param {string} src - The source code string. | ||
| * @returns {string} The formatted source code. | ||
| * @memberof clientFormatted | ||
| */ | ||
| const srcFormatted = (src) => src.replace(/(?<=[\s({[,;=+!?:^])(html|css)`/g, '`'); | ||
| const resolveBrowserImportPath = (basePrefix, relativePath) => { | ||
| if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(basePrefix)) { | ||
| return new URL(relativePath, basePrefix.endsWith('/') ? basePrefix : `${basePrefix}/`).toString(); | ||
| } | ||
| return path.posix.normalize(`${basePrefix}${relativePath}`); | ||
| }; | ||
| /** | ||
| * Converts a JavaScript object into a string that can be embedded in client-side code | ||
| * and parsed back into an object (e.g., 'JSON.parse(`{...}`)'). | ||
| * Escapes backticks and template expression markers for safe template literal embedding. | ||
| * @param {*} data - The data to be stringified. | ||
| * @returns {string} A string representing the code to parse the JSON data. | ||
| * @memberof clientFormatted | ||
| */ | ||
| const JSONweb = (data) => { | ||
| const json = JSON.stringify(data).replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); | ||
| return 'JSON.parse(`' + json + '`)'; | ||
| }; | ||
| /** | ||
| * Creates an esbuild plugin that rewrites import paths for browser consumption. | ||
| * Handles dist library imports, relative imports, and marks all remaining imports as external. | ||
| * @param {object} options | ||
| * @param {Array<object>} [options.dists=[]] - Distribution objects with import_name and import_name_build. | ||
| * @param {string} options.proxyPath - The proxy path for the application. | ||
| * @param {string} [options.basePath=''] - The base path for the module type (e.g., 'components', 'services'). | ||
| * @param {string} [options.module=''] - The module/component name for relative import resolution. | ||
| * @param {string} [options.baseHost=''] - The base host URL. | ||
| * @returns {import('esbuild').Plugin} | ||
| * @memberof clientFormatted | ||
| */ | ||
| const importRewritePlugin = ({ | ||
| dists = [], | ||
| proxyPath, | ||
| basePath = '', | ||
| module = '', | ||
| baseHost = '', | ||
| externalizeBareImports = true, | ||
| }) => ({ | ||
| name: 'import-rewrite', | ||
| setup(build) { | ||
| const prefix = `${baseHost}${proxyPath !== '/' ? `${proxyPath}/` : '/'}`; | ||
| // Rewrite dist library imports (e.g., '@neodrag/vanilla' → '/proxyPath/dist/@neodrag-vanilla/index.js') | ||
| if (dists) { | ||
| for (const dist of dists) { | ||
| if (!dist.import_name) continue; | ||
| const filter = new RegExp(`^${escapeRegExp(dist.import_name)}$`); | ||
| build.onResolve({ filter }, () => ({ | ||
| path: `${baseHost}${proxyPath !== '/' ? proxyPath : ''}${dist.import_name_build}`, | ||
| external: true, | ||
| })); | ||
| } | ||
| } | ||
| // Rewrite app-relative imports to absolute paths based on proxy path and module. | ||
| // Do not touch node_modules relative imports so esbuild can bundle package internals. | ||
| build.onResolve({ filter: /^\.\.?\// }, (args) => { | ||
| const normalizedImporter = (args.importer || '').replace(/\\/g, '/'); | ||
| if (!normalizedImporter.includes('/src/client/')) { | ||
| return; | ||
| } | ||
| // Extract the path relative to /src/client/ | ||
| // Handle cases where the path might have duplicates or be in various formats | ||
| const srcClientIndex = normalizedImporter.lastIndexOf('/src/client/'); | ||
| if (srcClientIndex === -1) { | ||
| return; | ||
| } | ||
| const importerFromClientRoot = normalizedImporter.substring(srcClientIndex + '/src/client/'.length); | ||
| const importerDir = path.posix.dirname(importerFromClientRoot); | ||
| const resolvedFromClientRoot = path.posix.normalize(path.posix.join(importerDir, args.path)); | ||
| const result = resolveBrowserImportPath(prefix, resolvedFromClientRoot); | ||
| return { | ||
| path: result, | ||
| external: true, | ||
| }; | ||
| }); | ||
| // For client app modules we externalize bare imports; for SW builds we let esbuild bundle them. | ||
| build.onResolve({ filter: /.*/ }, (args) => { | ||
| if (args.kind === 'entry-point') return; | ||
| if (!externalizeBareImports) return; | ||
| return { path: args.path, external: true }; | ||
| }); | ||
| }, | ||
| }); | ||
| /** | ||
| * Transforms a JavaScript source file using esbuild with import path rewriting, | ||
| * tagged template stripping, and optional minification. | ||
| * Replaces the previous srcFormatted + componentFormatted/viewFormatted + UglifyJS pipeline. | ||
| * @param {string} srcPath - Path to the source file. | ||
| * @param {object} options | ||
| * @param {Array<object>} [options.dists=[]] - Distribution objects with import names. | ||
| * @param {string} options.proxyPath - The proxy path for the application. | ||
| * @param {string} [options.basePath=''] - Base path for the module type (e.g., 'components', 'services'). | ||
| * @param {string} [options.module=''] - Module name for relative import resolution. | ||
| * @param {string} [options.baseHost=''] - Base host URL. | ||
| * @param {boolean} [options.minify=false] - Whether to minify the output. | ||
| * @returns {Promise<string>} The transformed source code. | ||
| * @memberof clientFormatted | ||
| */ | ||
| const transformClientJs = async ( | ||
| srcPath, | ||
| { | ||
| dists = [], | ||
| proxyPath, | ||
| basePath = '', | ||
| module = '', | ||
| baseHost = '', | ||
| minify: shouldMinify = false, | ||
| externalizeBareImports = true, | ||
| } = {}, | ||
| ) => { | ||
| const src = fs.readFileSync(srcPath, 'utf8'); | ||
| const stripped = srcFormatted(src); | ||
| const result = await esbuild.build({ | ||
| stdin: { | ||
| contents: stripped, | ||
| loader: 'js', | ||
| resolveDir: path.dirname(path.resolve(srcPath)), | ||
| sourcefile: srcPath, | ||
| }, | ||
| bundle: true, | ||
| write: false, | ||
| format: 'esm', | ||
| platform: 'browser', | ||
| target: 'esnext', | ||
| minify: shouldMinify, | ||
| logLevel: 'warning', | ||
| plugins: [importRewritePlugin({ dists, proxyPath, basePath, module, baseHost, externalizeBareImports })], | ||
| }); | ||
| return result.outputFiles[0].text; | ||
| }; | ||
| export { srcFormatted, JSONweb, transformClientJs }; |
| /** | ||
| * Module for building client-side icons | ||
| * @module src/server/client-icons.js | ||
| * @namespace clientIcons | ||
| */ | ||
| import { favicons } from 'favicons'; | ||
| import { loggerFactory } from './logger.js'; | ||
| import fs from 'fs-extra'; | ||
| import { getCapVariableName } from '../client/components/core/CommonJs.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| /** | ||
| * @function buildIcons | ||
| * @description Builds icons for a client-side application. | ||
| * @memberof clientIcons | ||
| * @param {Object} metadata - The metadata for the client-side application. | ||
| * @param {string} metadata.title - The title of the client-side application. | ||
| * @param {string} metadata.description - The description of the client-side application. | ||
| * @param {string} metadata.keywords - The keywords for the client-side application. | ||
| * @param {string} metadata.author - The author of the client-side application. | ||
| * @param {string} metadata.thumbnail - The thumbnail of the client-side application. | ||
| * @param {string} metadata.themeColor - The theme color of the client-side application. | ||
| * @param {string} metadata.baseBuildIconReference - The base build icon reference for the client-side application. | ||
| * @returns {Promise<void>} | ||
| */ | ||
| const buildIcons = async ({ | ||
| publicClientId, | ||
| metadata: { title, description, keywords, author, thumbnail, themeColor, baseBuildIconReference }, | ||
| }) => { | ||
| const source = baseBuildIconReference | ||
| ? baseBuildIconReference | ||
| : `src/client/public/${publicClientId}/assets/logo/base-icon.png`; // Source image(s). `string`, `buffer` or array of `string` | ||
| const configuration = { | ||
| path: '/', // Path for overriding default icons path. `string` | ||
| appName: title ? title : null, // Your application's name. `string` | ||
| appShortName: title ? title : null, // Your application's short_name. `string`. Optional. If not set, appName will be used | ||
| appDescription: description ? description : null, // Your application's description. `string` | ||
| developerName: author ? author : null, // Your (or your developer's) name. `string` | ||
| developerURL: author ? author : null, // Your (or your developer's) URL. `string` | ||
| cacheBustingQueryParam: null, // Query parameter added to all URLs that acts as a cache busting system. `string | null` | ||
| dir: 'auto', // Primary text direction for name, short_name, and description | ||
| lang: 'en-US', // Primary language for name and short_name | ||
| background: themeColor ? themeColor : '#fff', // Background colour for flattened icons. `string` | ||
| theme_color: themeColor ? themeColor : '#fff', // Theme color user for example in Android's task switcher. `string` | ||
| appleStatusBarStyle: 'black-translucent', // Style for Apple status bar: "black-translucent", "default", "black". `string` | ||
| display: 'standalone', // Preferred display mode: "fullscreen", "standalone", "minimal-ui" or "browser". `string` | ||
| orientation: 'any', // Default orientation: "any", "natural", "portrait" or "landscape". `string` | ||
| scope: '/', // set of URLs that the browser considers within your app | ||
| start_url: '/?homescreen=1', // Start URL when launching the application from a device. `string` | ||
| preferRelatedApplications: false, // Should the browser prompt the user to install the native companion app. `boolean` | ||
| relatedApplications: undefined, // Information about the native companion apps. This will only be used if `preferRelatedApplications` is `true`. `Array<{ id: string, url: string, platform: string }>` | ||
| version: '1.0', // Your application's version string. `string` | ||
| pixel_art: false, // Keeps pixels "sharp" when scaling up, for pixel art. Only supported in offline mode. | ||
| loadManifestWithCredentials: true, // Browsers don't send cookies when fetching a manifest, enable this to fix that. `boolean` | ||
| manifestMaskable: true, // Maskable source image(s) for manifest.json. "true" to use default source. More information at https://web.dev/maskable-icon/. `boolean`, `string`, `buffer` or array of `string` | ||
| icons: { | ||
| // Platform Options: | ||
| // - offset - offset in percentage | ||
| // - background: | ||
| // * false - use default | ||
| // * true - force use default, e.g. set background for Android icons | ||
| // * color - set background for the specified icons | ||
| // | ||
| android: true, // Create Android homescreen icon. `boolean` or `{ offset, background }` or an array of sources | ||
| appleIcon: true, // Create Apple touch icons. `boolean` or `{ offset, background }` or an array of sources | ||
| appleStartup: true, // Create Apple startup images. `boolean` or `{ offset, background }` or an array of sources | ||
| favicons: true, // Create regular favicons. `boolean` or `{ offset, background }` or an array of sources | ||
| windows: true, // Create Windows 8 tile icons. `boolean` or `{ offset, background }` or an array of sources | ||
| yandex: true, // Create Yandex browser icon. `boolean` or `{ offset, background }` or an array of sources | ||
| }, | ||
| shortcuts: [ | ||
| // Your applications's Shortcuts (see: https://developer.mozilla.org/docs/Web/Manifest/shortcuts) | ||
| // Array of shortcut objects: | ||
| // { | ||
| // name: 'View your Inbox', // The name of the shortcut. `string` | ||
| // short_name: 'inbox', // optionally, falls back to name. `string` | ||
| // description: 'View your inbox messages', // optionally, not used in any implemention yet. `string` | ||
| // url: '/inbox', // The URL this shortcut should lead to. `string` | ||
| // icon: 'test/inbox_shortcut.png', // source image(s) for that shortcut. `string`, `buffer` or array of `string` | ||
| // }, | ||
| // more shortcuts objects | ||
| ], | ||
| }; | ||
| try { | ||
| const response = await favicons(source, configuration); | ||
| // console.log(response.images); // Array of { name: string, contents: <buffer> } | ||
| // console.log(response.files); // Array of { name: string, contents: <string> } | ||
| // console.log(response.html); // Array of strings (html elements) | ||
| for (const image of response.images) | ||
| fs.writeFileSync(`./src/client/public/${publicClientId}/${image.name}`, image.contents); | ||
| for (const file of response.files) | ||
| fs.writeFileSync(`./src/client/public/${publicClientId}/${file.name}`, file.contents, 'utf8'); | ||
| const ssrPath = `./src/client/ssr/head/Pwa${getCapVariableName(publicClientId)}.js`; | ||
| if (!fs.existsSync(ssrPath)) | ||
| fs.writeFileSync(ssrPath, 'SrrComponent = () => html`' + response.html.join(`\n`) + '`;', 'utf8'); | ||
| } catch (error) { | ||
| logger.error(error.message); // Error description e.g. "An unknown error has occurred" | ||
| } | ||
| }; | ||
| export { buildIcons }; |
| /** | ||
| * Lightweight IPFS HTTP client for communicating with a Kubo (go-ipfs) node | ||
| * and an IPFS Cluster daemon running side-by-side in the same StatefulSet. | ||
| * | ||
| * Kubo API (port 5001) – add / pin / cat content. | ||
| * Cluster API (port 9094) – replicate pins across the cluster. | ||
| * | ||
| * Uses native `FormData` + `Blob` (Node ≥ 18) for reliable multipart encoding. | ||
| * | ||
| * @module src/server/ipfs-client.js | ||
| * @namespace IpfsClient | ||
| */ | ||
| import stringify from 'fast-json-stable-stringify'; | ||
| import { loggerFactory } from './logger.js'; | ||
| import Underpost from '../index.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| const DEFAULT_IPFS_HTTP_TIMEOUT_MS = Number(process.env.IPFS_HTTP_TIMEOUT_MS || 10000); | ||
| const getRequestTimeoutMs = (kind = 'kubo') => { | ||
| if (kind === 'cluster') { | ||
| return Number(process.env.IPFS_CLUSTER_TIMEOUT_MS || DEFAULT_IPFS_HTTP_TIMEOUT_MS); | ||
| } | ||
| if (kind === 'gateway') { | ||
| return Number(process.env.IPFS_GATEWAY_TIMEOUT_MS || DEFAULT_IPFS_HTTP_TIMEOUT_MS); | ||
| } | ||
| return Number(process.env.IPFS_KUBO_TIMEOUT_MS || DEFAULT_IPFS_HTTP_TIMEOUT_MS); | ||
| }; | ||
| const fetchWithTimeout = async (url, options = {}, { kind = 'kubo', label = url } = {}) => { | ||
| const controller = new AbortController(); | ||
| const timeoutMs = getRequestTimeoutMs(kind); | ||
| const timeoutId = setTimeout(() => controller.abort(), timeoutMs); | ||
| try { | ||
| return await fetch(url, { ...options, signal: controller.signal }); | ||
| } catch (err) { | ||
| if (err.name === 'AbortError') { | ||
| throw new Error(`${label} timed out after ${timeoutMs}ms`); | ||
| } | ||
| throw err; | ||
| } finally { | ||
| clearTimeout(timeoutId); | ||
| } | ||
| }; | ||
| // ───────────────────────────────────────────────────────── | ||
| // URL helpers | ||
| // ───────────────────────────────────────────────────────── | ||
| /** | ||
| * Base URL of the Kubo RPC API (port 5001). | ||
| * @returns {string} | ||
| */ | ||
| const getIpfsApiUrl = () => | ||
| process.env.IPFS_API_URL || | ||
| `http://${process.env.NODE_ENV === 'development' && !Underpost.env.isInsideContainer() ? 'localhost' : 'ipfs-cluster'}:5001`; | ||
| /** | ||
| * Base URL of the IPFS Cluster REST API (port 9094). | ||
| * @returns {string} | ||
| */ | ||
| const getClusterApiUrl = () => | ||
| process.env.IPFS_CLUSTER_API_URL || | ||
| `http://${process.env.NODE_ENV === 'development' && !Underpost.env.isInsideContainer() ? 'localhost' : 'ipfs-cluster'}:9094`; | ||
| /** | ||
| * Base URL of the IPFS HTTP Gateway (port 8080). | ||
| * @returns {string} | ||
| */ | ||
| const getGatewayUrl = () => | ||
| process.env.IPFS_GATEWAY_URL || | ||
| `http://${process.env.NODE_ENV === 'development' && !Underpost.env.isInsideContainer() ? 'localhost' : 'ipfs-cluster'}:8080`; | ||
| // ───────────────────────────────────────────────────────── | ||
| // Core: add content | ||
| // ───────────────────────────────────────────────────────── | ||
| /** | ||
| * @typedef {Object} IpfsAddResult | ||
| * @property {string} cid – CID (Content Identifier) returned by the node. | ||
| * @property {number} size – Cumulative DAG size reported by the node. | ||
| */ | ||
| /** | ||
| * Add arbitrary bytes to the Kubo node AND pin them on the IPFS Cluster. | ||
| * | ||
| * 1. `POST /api/v0/add?pin=true` to Kubo (5001) – stores + locally pins. | ||
| * 2. `POST /pins/<CID>` to the Cluster REST API (9094) – replicates the pin | ||
| * across every peer so `GET /pins` on the cluster shows the content. | ||
| * 3. Copies into MFS so the Web UI "Files" section shows the file. | ||
| * | ||
| * @param {Buffer|string} content – raw bytes or a UTF-8 string to store. | ||
| * @param {string} [filename='data'] – logical filename for the upload. | ||
| * @param {string} [mfsPath] – optional full MFS path. | ||
| * When omitted defaults to `/pinned/<filename>`. | ||
| * @returns {Promise<IpfsAddResult|null>} `null` when the node is unreachable. | ||
| */ | ||
| const addToIpfs = async (content, filename = 'data', mfsPath) => { | ||
| const kuboUrl = getIpfsApiUrl(); | ||
| const clusterUrl = getClusterApiUrl(); | ||
| // Build multipart body using native FormData + Blob (Node ≥ 18). | ||
| const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'); | ||
| const formData = new FormData(); | ||
| formData.append('file', new Blob([buf]), filename); | ||
| // ── Step 1: add to Kubo ────────────────────────────── | ||
| let cid; | ||
| let size; | ||
| try { | ||
| const res = await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/add?pin=true&cid-version=1`, | ||
| { | ||
| method: 'POST', | ||
| body: formData, | ||
| }, | ||
| { kind: 'kubo', label: `IPFS Kubo add ${filename}` }, | ||
| ); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| logger.error(`IPFS Kubo add failed (${res.status}): ${text}`); | ||
| return null; | ||
| } | ||
| const json = await res.json(); | ||
| cid = json.Hash; | ||
| size = Number(json.Size); | ||
| logger.info(`IPFS Kubo add OK – CID: ${cid}, size: ${size}`); | ||
| } catch (err) { | ||
| logger.warn(`IPFS Kubo node unreachable at ${kuboUrl}: ${err.message}`); | ||
| return null; | ||
| } | ||
| // ── Step 2: pin to the Cluster ─────────────────────── | ||
| try { | ||
| const clusterRes = await fetchWithTimeout( | ||
| `${clusterUrl}/pins/${encodeURIComponent(cid)}`, | ||
| { | ||
| method: 'POST', | ||
| }, | ||
| { kind: 'cluster', label: `IPFS Cluster pin ${cid}` }, | ||
| ); | ||
| if (!clusterRes.ok) { | ||
| const text = await clusterRes.text(); | ||
| logger.warn(`IPFS Cluster pin failed (${clusterRes.status}): ${text}`); | ||
| } else { | ||
| logger.info(`IPFS Cluster pin OK – CID: ${cid}`); | ||
| } | ||
| } catch (err) { | ||
| logger.warn(`IPFS Cluster unreachable at ${clusterUrl}: ${err.message}`); | ||
| } | ||
| // ── Step 3: copy into MFS so the Web UI "Files" section shows it ─ | ||
| const destPath = mfsPath || `/pinned/${filename}`; | ||
| const destDir = destPath.substring(0, destPath.lastIndexOf('/')) || '/'; | ||
| try { | ||
| // Ensure parent directory exists in MFS | ||
| await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/files/mkdir?arg=${encodeURIComponent(destDir)}&parents=true`, | ||
| { method: 'POST' }, | ||
| { kind: 'kubo', label: `IPFS MFS mkdir ${destDir}` }, | ||
| ); | ||
| // Remove existing entry if present (cp fails on duplicates) | ||
| await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/files/rm?arg=${encodeURIComponent(destPath)}&force=true`, | ||
| { | ||
| method: 'POST', | ||
| }, | ||
| { kind: 'kubo', label: `IPFS MFS rm ${destPath}` }, | ||
| ); | ||
| // Copy the CID into MFS | ||
| const cpRes = await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/files/cp?arg=/ipfs/${encodeURIComponent(cid)}&arg=${encodeURIComponent(destPath)}`, | ||
| { method: 'POST' }, | ||
| { kind: 'kubo', label: `IPFS MFS cp ${destPath}` }, | ||
| ); | ||
| if (!cpRes.ok) { | ||
| const text = await cpRes.text(); | ||
| logger.warn(`IPFS MFS cp failed (${cpRes.status}): ${text}`); | ||
| } else { | ||
| logger.info(`IPFS MFS cp OK – ${destPath} → ${cid}`); | ||
| } | ||
| } catch (err) { | ||
| logger.warn(`IPFS MFS cp unreachable: ${err.message}`); | ||
| } | ||
| return { cid, size }; | ||
| }; | ||
| // ───────────────────────────────────────────────────────── | ||
| // Convenience wrappers | ||
| // ───────────────────────────────────────────────────────── | ||
| /** | ||
| * Add a JSON-serialisable object to IPFS. | ||
| * | ||
| * @param {any} obj – value to serialise. | ||
| * @param {string} [filename='data.json'] | ||
| * @param {string} [mfsPath] – optional full MFS destination path. | ||
| * @returns {Promise<IpfsAddResult|null>} | ||
| */ | ||
| const addJsonToIpfs = async (obj, filename = 'data.json', mfsPath) => { | ||
| const payload = stringify(obj); | ||
| return addToIpfs(Buffer.from(payload, 'utf-8'), filename, mfsPath); | ||
| }; | ||
| /** | ||
| * Compute the CID that Kubo would assign to a payload without pinning or copying it into MFS. | ||
| * Useful when building canonical backup manifests from the actual bytes that will be restored later. | ||
| * | ||
| * @param {Buffer|string} content | ||
| * @param {string} [filename='data'] | ||
| * @returns {Promise<IpfsAddResult|null>} | ||
| */ | ||
| const hashContentForIpfs = async (content, filename = 'data') => { | ||
| const kuboUrl = getIpfsApiUrl(); | ||
| const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'); | ||
| const formData = new FormData(); | ||
| formData.append('file', new Blob([buf]), filename); | ||
| try { | ||
| const res = await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/add?only-hash=true&pin=false&cid-version=1`, | ||
| { | ||
| method: 'POST', | ||
| body: formData, | ||
| }, | ||
| { kind: 'kubo', label: `IPFS Kubo only-hash ${filename}` }, | ||
| ); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| logger.error(`IPFS Kubo only-hash failed (${res.status}): ${text}`); | ||
| return null; | ||
| } | ||
| const json = await res.json(); | ||
| return { cid: json.Hash, size: Number(json.Size) }; | ||
| } catch (err) { | ||
| logger.warn(`IPFS Kubo only-hash unreachable at ${kuboUrl}: ${err.message}`); | ||
| return null; | ||
| } | ||
| }; | ||
| /** | ||
| * Compute the CID for a JSON-serialisable object using the same stable stringification | ||
| * that the regular addJsonToIpfs path uses. | ||
| * | ||
| * @param {any} obj | ||
| * @param {string} [filename='data.json'] | ||
| * @returns {Promise<IpfsAddResult|null>} | ||
| */ | ||
| const hashJsonForIpfs = async (obj, filename = 'data.json') => { | ||
| const payload = stringify(obj); | ||
| return hashContentForIpfs(Buffer.from(payload, 'utf-8'), filename); | ||
| }; | ||
| /** | ||
| * Add a binary buffer (e.g. a PNG image) to IPFS. | ||
| * | ||
| * @param {Buffer} buffer – raw image / file bytes. | ||
| * @param {string} filename – e.g. `"atlas.png"`. | ||
| * @param {string} [mfsPath] – optional full MFS destination path. | ||
| * @returns {Promise<IpfsAddResult|null>} | ||
| */ | ||
| const addBufferToIpfs = async (buffer, filename, mfsPath) => { | ||
| return addToIpfs(buffer, filename, mfsPath); | ||
| }; | ||
| /** | ||
| * Compute the CID for a binary buffer without pinning it. | ||
| * | ||
| * @param {Buffer} buffer | ||
| * @param {string} filename | ||
| * @returns {Promise<IpfsAddResult|null>} | ||
| */ | ||
| const hashBufferForIpfs = async (buffer, filename) => { | ||
| return hashContentForIpfs(buffer, filename); | ||
| }; | ||
| // ───────────────────────────────────────────────────────── | ||
| // Pin management | ||
| // ───────────────────────────────────────────────────────── | ||
| /** | ||
| * Explicitly pin an existing CID on both the Kubo node and the Cluster. | ||
| * | ||
| * @param {string} cid | ||
| * @param {string} [type='recursive'] – `'recursive'` | `'direct'` | ||
| * @returns {Promise<boolean>} `true` when at least the Kubo pin succeeded. | ||
| */ | ||
| const pinCid = async (cid, type = 'recursive') => { | ||
| const kuboUrl = getIpfsApiUrl(); | ||
| const clusterUrl = getClusterApiUrl(); | ||
| let kuboOk = false; | ||
| // Kubo pin | ||
| try { | ||
| const res = await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/pin/add?arg=${encodeURIComponent(cid)}&type=${type}`, | ||
| { | ||
| method: 'POST', | ||
| }, | ||
| { kind: 'kubo', label: `IPFS Kubo pin/add ${cid}` }, | ||
| ); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| logger.error(`IPFS Kubo pin/add failed (${res.status}): ${text}`); | ||
| } else { | ||
| kuboOk = true; | ||
| logger.info(`IPFS Kubo pin OK – CID: ${cid} (${type})`); | ||
| } | ||
| } catch (err) { | ||
| logger.warn(`IPFS Kubo pin unreachable: ${err.message}`); | ||
| } | ||
| // Cluster pin | ||
| try { | ||
| const clusterRes = await fetchWithTimeout( | ||
| `${clusterUrl}/pins/${encodeURIComponent(cid)}`, | ||
| { | ||
| method: 'POST', | ||
| }, | ||
| { kind: 'cluster', label: `IPFS Cluster pin ${cid}` }, | ||
| ); | ||
| if (!clusterRes.ok) { | ||
| const text = await clusterRes.text(); | ||
| logger.warn(`IPFS Cluster pin failed (${clusterRes.status}): ${text}`); | ||
| } else { | ||
| logger.info(`IPFS Cluster pin OK – CID: ${cid}`); | ||
| } | ||
| } catch (err) { | ||
| logger.warn(`IPFS Cluster pin unreachable: ${err.message}`); | ||
| } | ||
| return kuboOk; | ||
| }; | ||
| /** | ||
| * Unpin a CID from both the Kubo node and the Cluster. | ||
| * | ||
| * @param {string} cid | ||
| * @returns {Promise<boolean>} | ||
| */ | ||
| const unpinCid = async (cid) => { | ||
| const kuboUrl = getIpfsApiUrl(); | ||
| const clusterUrl = getClusterApiUrl(); | ||
| let kuboOk = false; | ||
| // Cluster unpin | ||
| try { | ||
| const clusterRes = await fetchWithTimeout( | ||
| `${clusterUrl}/pins/${encodeURIComponent(cid)}`, | ||
| { | ||
| method: 'DELETE', | ||
| }, | ||
| { kind: 'cluster', label: `IPFS Cluster unpin ${cid}` }, | ||
| ); | ||
| if (!clusterRes.ok) { | ||
| const text = await clusterRes.text(); | ||
| if (clusterRes.status === 404) { | ||
| logger.info(`IPFS Cluster unpin – CID already not pinned: ${cid}`); | ||
| } else { | ||
| logger.warn(`IPFS Cluster unpin failed (${clusterRes.status}): ${text}`); | ||
| } | ||
| } else { | ||
| logger.info(`IPFS Cluster unpin OK – CID: ${cid}`); | ||
| } | ||
| } catch (err) { | ||
| logger.warn(`IPFS Cluster unpin unreachable: ${err.message}`); | ||
| } | ||
| // Kubo unpin | ||
| try { | ||
| const res = await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`, | ||
| { | ||
| method: 'POST', | ||
| }, | ||
| { kind: 'kubo', label: `IPFS Kubo pin/rm ${cid}` }, | ||
| ); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| // "not pinned or pinned indirectly" means the CID is already unpinned – treat as success | ||
| if (text.includes('not pinned')) { | ||
| kuboOk = true; | ||
| logger.info(`IPFS Kubo unpin – CID already not pinned: ${cid}`); | ||
| } else { | ||
| logger.warn(`IPFS Kubo pin/rm failed (${res.status}): ${text}`); | ||
| } | ||
| } else { | ||
| kuboOk = true; | ||
| logger.info(`IPFS Kubo unpin OK – CID: ${cid}`); | ||
| } | ||
| } catch (err) { | ||
| logger.warn(`IPFS Kubo unpin unreachable: ${err.message}`); | ||
| } | ||
| return kuboOk; | ||
| }; | ||
| // ───────────────────────────────────────────────────────── | ||
| // Retrieval | ||
| // ───────────────────────────────────────────────────────── | ||
| /** | ||
| * Retrieve raw bytes for a CID from the IPFS HTTP Gateway (port 8080). | ||
| * | ||
| * @param {string} cid | ||
| * @returns {Promise<Buffer|null>} | ||
| */ | ||
| const getFromIpfs = async (cid) => { | ||
| const url = getGatewayUrl(); | ||
| try { | ||
| const res = await fetchWithTimeout( | ||
| `${url}/ipfs/${encodeURIComponent(cid)}`, | ||
| {}, | ||
| { | ||
| kind: 'gateway', | ||
| label: `IPFS gateway GET ${cid}`, | ||
| }, | ||
| ); | ||
| if (!res.ok) { | ||
| logger.error(`IPFS gateway GET failed (${res.status}) for ${cid}`); | ||
| return null; | ||
| } | ||
| const arrayBuffer = await res.arrayBuffer(); | ||
| return Buffer.from(arrayBuffer); | ||
| } catch (err) { | ||
| logger.warn(`IPFS gateway unreachable at ${url}: ${err.message}`); | ||
| return null; | ||
| } | ||
| }; | ||
| // ───────────────────────────────────────────────────────── | ||
| // Diagnostics | ||
| // ───────────────────────────────────────────────────────── | ||
| /** | ||
| * List all pins tracked by the IPFS Cluster (port 9094). | ||
| * Each line in the response is a JSON object with at least a `cid` field. | ||
| * | ||
| * @returns {Promise<Array<{ cid: string, name: string, peer_map: object }>>} | ||
| */ | ||
| const listClusterPins = async () => { | ||
| const clusterUrl = getClusterApiUrl(); | ||
| try { | ||
| const res = await fetchWithTimeout( | ||
| `${clusterUrl}/pins`, | ||
| {}, | ||
| { | ||
| kind: 'cluster', | ||
| label: 'IPFS Cluster list pins', | ||
| }, | ||
| ); | ||
| if (res.status === 204) { | ||
| // 204 No Content → the cluster has no pins at all. | ||
| return []; | ||
| } | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| logger.error(`IPFS Cluster list pins failed (${res.status}): ${text}`); | ||
| return []; | ||
| } | ||
| const text = await res.text(); | ||
| // The cluster streams one JSON object per line (NDJSON). | ||
| return text | ||
| .split('\n') | ||
| .filter((line) => line.trim()) | ||
| .map((line) => { | ||
| try { | ||
| return JSON.parse(line); | ||
| } catch { | ||
| return null; | ||
| } | ||
| }) | ||
| .filter(Boolean); | ||
| } catch (err) { | ||
| logger.warn(`IPFS Cluster unreachable at ${clusterUrl}: ${err.message}`); | ||
| return []; | ||
| } | ||
| }; | ||
| /** | ||
| * List pins tracked by the local Kubo node (port 5001). | ||
| * | ||
| * @param {string} [type='recursive'] – `'all'` | `'recursive'` | `'direct'` | `'indirect'` | ||
| * @returns {Promise<Object<string, { Type: string }>>} Map of CID → pin info. | ||
| */ | ||
| const listKuboPins = async (type = 'recursive') => { | ||
| const kuboUrl = getIpfsApiUrl(); | ||
| try { | ||
| const res = await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/pin/ls?type=${type}`, | ||
| { | ||
| method: 'POST', | ||
| }, | ||
| { kind: 'kubo', label: `IPFS Kubo pin/ls type=${type}` }, | ||
| ); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| logger.error(`IPFS Kubo pin/ls failed (${res.status}): ${text}`); | ||
| return {}; | ||
| } | ||
| const json = await res.json(); | ||
| return json.Keys || {}; | ||
| } catch (err) { | ||
| logger.warn(`IPFS Kubo pin/ls unreachable: ${err.message}`); | ||
| return {}; | ||
| } | ||
| }; | ||
| // ───────────────────────────────────────────────────────── | ||
| // MFS management | ||
| // ───────────────────────────────────────────────────────── | ||
| /** | ||
| * Remove a file or directory from the Kubo MFS (Mutable File System). | ||
| * This cleans up entries visible in the IPFS Web UI "Files" section. | ||
| * | ||
| * @param {string} mfsPath – Full MFS path to remove, e.g. `/pinned/myfile.json` | ||
| * or `/object-layer/itemId`. | ||
| * @param {boolean} [recursive=true] – When `true`, removes directories recursively. | ||
| * @returns {Promise<boolean>} `true` when the removal succeeded or the path didn't exist. | ||
| */ | ||
| const removeMfsPath = async (mfsPath, recursive = true) => { | ||
| const kuboUrl = getIpfsApiUrl(); | ||
| try { | ||
| // First check if the path exists via stat; if it doesn't we can return early. | ||
| const statRes = await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/files/stat?arg=${encodeURIComponent(mfsPath)}`, | ||
| { method: 'POST' }, | ||
| { kind: 'kubo', label: `IPFS MFS stat ${mfsPath}` }, | ||
| ); | ||
| if (!statRes.ok) { | ||
| // Path doesn't exist – nothing to remove. | ||
| logger.info(`IPFS MFS rm – path does not exist, skipping: ${mfsPath}`); | ||
| return true; | ||
| } | ||
| const rmRes = await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/files/rm?arg=${encodeURIComponent(mfsPath)}&force=true${recursive ? '&recursive=true' : ''}`, | ||
| { method: 'POST' }, | ||
| { kind: 'kubo', label: `IPFS MFS rm ${mfsPath}` }, | ||
| ); | ||
| if (!rmRes.ok) { | ||
| const text = await rmRes.text(); | ||
| logger.warn(`IPFS MFS rm failed (${rmRes.status}): ${text}`); | ||
| return false; | ||
| } | ||
| logger.info(`IPFS MFS rm OK – ${mfsPath}`); | ||
| return true; | ||
| } catch (err) { | ||
| logger.warn(`IPFS MFS rm unreachable: ${err.message}`); | ||
| return false; | ||
| } | ||
| }; | ||
| /** | ||
| * Restore a CID into the Kubo MFS at a specific path (e.g. when re-importing a backup). | ||
| * Creates the parent directory if needed, removes any existing entry, then copies the CID. | ||
| * | ||
| * @param {string} cid – IPFS CID to copy into MFS. | ||
| * @param {string} mfsPath – Full destination MFS path, e.g. `/object-layer/sword/sword_data.json`. | ||
| * @returns {Promise<boolean>} `true` when the MFS entry was created successfully. | ||
| */ | ||
| const restoreMfsPath = async (cid, mfsPath) => { | ||
| const kuboUrl = getIpfsApiUrl(); | ||
| const destDir = mfsPath.substring(0, mfsPath.lastIndexOf('/')) || '/'; | ||
| try { | ||
| await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/files/mkdir?arg=${encodeURIComponent(destDir)}&parents=true`, | ||
| { method: 'POST' }, | ||
| { kind: 'kubo', label: `IPFS MFS mkdir ${destDir}` }, | ||
| ); | ||
| await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/files/rm?arg=${encodeURIComponent(mfsPath)}&force=true`, | ||
| { method: 'POST' }, | ||
| { kind: 'kubo', label: `IPFS MFS rm ${mfsPath}` }, | ||
| ); | ||
| const cpRes = await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/files/cp?arg=/ipfs/${encodeURIComponent(cid)}&arg=${encodeURIComponent(mfsPath)}`, | ||
| { method: 'POST' }, | ||
| { kind: 'kubo', label: `IPFS MFS restore ${mfsPath}` }, | ||
| ); | ||
| if (!cpRes.ok) { | ||
| const text = await cpRes.text(); | ||
| logger.warn(`IPFS MFS restore failed (${cpRes.status}): ${text} – ${mfsPath}`); | ||
| return false; | ||
| } | ||
| logger.info(`IPFS MFS restore OK – ${mfsPath} → ${cid}`); | ||
| return true; | ||
| } catch (err) { | ||
| logger.warn(`IPFS MFS restore unreachable: ${err.message}`); | ||
| return false; | ||
| } | ||
| }; | ||
| // ───────────────────────────────────────────────────────── | ||
| // Export | ||
| // ───────────────────────────────────────────────────────── | ||
| class IpfsClient { | ||
| static getIpfsApiUrl = getIpfsApiUrl; | ||
| static getClusterApiUrl = getClusterApiUrl; | ||
| static getGatewayUrl = getGatewayUrl; | ||
| static addToIpfs = addToIpfs; | ||
| static addJsonToIpfs = addJsonToIpfs; | ||
| static addBufferToIpfs = addBufferToIpfs; | ||
| static hashContentForIpfs = hashContentForIpfs; | ||
| static hashJsonForIpfs = hashJsonForIpfs; | ||
| static hashBufferForIpfs = hashBufferForIpfs; | ||
| static pinCid = pinCid; | ||
| static unpinCid = unpinCid; | ||
| static getFromIpfs = getFromIpfs; | ||
| static listClusterPins = listClusterPins; | ||
| static listKuboPins = listKuboPins; | ||
| static removeMfsPath = removeMfsPath; | ||
| static restoreMfsPath = restoreMfsPath; | ||
| /** | ||
| * Check whether a single CID is currently pinned on the local Kubo node. | ||
| * Uses the pin/ls?arg=<cid> endpoint which returns only that one pin | ||
| * (much cheaper than fetching the full list). | ||
| * | ||
| * @param {string} cid - IPFS Content Identifier to check. | ||
| * @returns {Promise<boolean>} true when the CID is pinned. | ||
| */ | ||
| static isCidPinned = async (cid) => { | ||
| const kuboUrl = getIpfsApiUrl(); | ||
| try { | ||
| const res = await fetchWithTimeout( | ||
| `${kuboUrl}/api/v0/pin/ls?arg=${encodeURIComponent(cid)}&type=all`, | ||
| { method: 'POST' }, | ||
| { kind: 'kubo', label: `IPFS Kubo pin/ls ${cid}` }, | ||
| ); | ||
| if (!res.ok) return false; | ||
| const json = await res.json(); | ||
| return !!(json.Keys && json.Keys[cid]); | ||
| } catch { | ||
| return false; | ||
| } | ||
| }; | ||
| } | ||
| export { IpfsClient }; |
| /** | ||
| * Module for managing server side rendering | ||
| * @module src/server/ssr.js | ||
| * @namespace ServerSideRendering | ||
| */ | ||
| import fs from 'fs-extra'; | ||
| import vm from 'node:vm'; | ||
| import Underpost from '../index.js'; | ||
| import { srcFormatted, JSONweb } from './client-formatted.js'; | ||
| import { loggerFactory } from './logger.js'; | ||
| import { getRootDirectory } from './process.js'; | ||
| const logger = loggerFactory(import.meta); | ||
| /** | ||
| * Creates a server-side rendering component function from a given file path. | ||
| * It reads the component file, formats it, and executes it in a sandboxed Node.js VM context to extract the component. | ||
| * @param {string} [componentPath='./src/client/ssr/Render.js'] - The path to the SSR component file. | ||
| * @returns {Promise<Function>} A promise that resolves to the SSR component function. | ||
| * @memberof ServerSideRendering | ||
| */ | ||
| const ssrFactory = async (componentPath = `./src/client/ssr/Render.js`) => { | ||
| const context = { SrrComponent: () => {}, npm_package_version: Underpost.version }; | ||
| vm.createContext(context); | ||
| vm.runInContext(await srcFormatted(fs.readFileSync(componentPath, 'utf8')), context); | ||
| return context.SrrComponent; | ||
| }; | ||
| /** | ||
| * Sanitizes an HTML string by adding a nonce to all script and style tags for Content Security Policy (CSP). | ||
| * The nonce is retrieved from `res.locals.nonce`. | ||
| * @param {object} res - The Express response object. | ||
| * @param {object} req - The Express request object. | ||
| * @param {string} html - The HTML string to sanitize. | ||
| * @returns {string} The sanitized HTML string with nonces. | ||
| * @memberof ServerSideRendering | ||
| */ | ||
| const sanitizeHtml = (res, req, html) => { | ||
| const nonce = res.locals.nonce; | ||
| return html | ||
| .replace(/<script(?=\s|>)/gi, `<script nonce="${nonce}"`) | ||
| .replace(/<style(?=\s|>)/gi, `<style nonce="${nonce}"`); | ||
| }; | ||
| /** | ||
| * Factory function to create Express middleware for handling 404 and 500 errors. | ||
| * It generates server-side rendered HTML for these error pages. If static error pages exist, it redirects to them. | ||
| * @param {object} options - The options for creating the middleware. | ||
| * @param {object} options.app - The Express app instance. | ||
| * @param {string} options.directory - The directory for the instance's static files. | ||
| * @param {string} options.rootHostPath - The root path for the host's public files. | ||
| * @param {string} options.path - The base path for the instance. | ||
| * @returns {Promise<{error500: Function, error400: Function}>} A promise that resolves to an object containing the 500 and 404 error handling middleware. | ||
| * @memberof ServerSideRendering | ||
| */ | ||
| const ssrMiddlewareFactory = async ({ app, directory, rootHostPath, path }) => { | ||
| const Render = await ssrFactory(); | ||
| const ssrPath = path === '/' ? path : `${path}/`; | ||
| // Build default html src for 404 and 500 | ||
| const defaultHtmlSrc404 = Render({ | ||
| title: '404 Not Found', | ||
| ssrPath, | ||
| ssrHeadComponents: '', | ||
| ssrBodyComponents: (await ssrFactory(`./src/client/ssr/body/404.js`))(), | ||
| renderPayload: { | ||
| apiBasePath: process.env.BASE_API, | ||
| version: Underpost.version, | ||
| }, | ||
| renderApi: { | ||
| JSONweb, | ||
| }, | ||
| }); | ||
| const path404 = `${directory ? directory : `${getRootDirectory()}${rootHostPath}`}/404/index.html`; | ||
| const page404 = fs.existsSync(path404) ? `${path === '/' ? '' : path}/404` : undefined; | ||
| const defaultHtmlSrc500 = Render({ | ||
| title: '500 Server Error', | ||
| ssrPath, | ||
| ssrHeadComponents: '', | ||
| ssrBodyComponents: (await ssrFactory(`./src/client/ssr/body/500.js`))(), | ||
| renderPayload: { | ||
| apiBasePath: process.env.BASE_API, | ||
| version: Underpost.version, | ||
| }, | ||
| renderApi: { | ||
| JSONweb, | ||
| }, | ||
| }); | ||
| const path500 = `${directory ? directory : `${getRootDirectory()}${rootHostPath}`}/500/index.html`; | ||
| const page500 = fs.existsSync(path500) ? `${path === '/' ? '' : path}/500` : undefined; | ||
| return { | ||
| error500: function (err, req, res, next) { | ||
| logger.error(err, err.stack); | ||
| if (page500) return res.status(500).redirect(page500); | ||
| else { | ||
| res.set('Content-Type', 'text/html'); | ||
| return res.status(500).send(sanitizeHtml(res, req, defaultHtmlSrc500)); | ||
| } | ||
| }, | ||
| error400: function (req, res, next) { | ||
| // if /<path>/home redirect to /<path> | ||
| const homeRedirectPath = `${path === '/' ? '' : path}/home`; | ||
| if (req.url.startsWith(homeRedirectPath)) { | ||
| const redirectUrl = req.url.replace('/home', ''); | ||
| return res.redirect(redirectUrl.startsWith('/') ? redirectUrl : `/${redirectUrl}`); | ||
| } | ||
| if (page404) return res.status(404).redirect(page404); | ||
| else { | ||
| res.set('Content-Type', 'text/html'); | ||
| return res.status(404).send(sanitizeHtml(res, req, defaultHtmlSrc404)); | ||
| } | ||
| }, | ||
| }; | ||
| }; | ||
| export { ssrMiddlewareFactory, ssrFactory, sanitizeHtml }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 7 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
6923365
2.21%459
0.66%64236
2.45%139
0.72%346
-0.29%44
-2.22%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated