🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

underpost

Package Overview
Dependencies
Maintainers
1
Versions
198
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

underpost - npm Package Compare versions

Comparing version
3.2.22
to
3.2.28
+224
docker-compose.yml
# 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 &quot; but not __¬¬¬__
.replaceAll(
`data.replaceAll('\\n', ' ').replaceAll('\u201c', '\u201d')`,
`data.replaceAll('\\n', ' ').replaceAll('\u201c', '\u201d').replaceAll('__\u00ac\u00ac\u00ac__', '"')`,
)
// getResponsesTag: decodes neither &quot; 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) &gt; 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) &lt; 1">
<p class="desc">
<a href="{{web-url}}sitemap.xml" class="back-link">&#8592; 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">&#8592; 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;
+1
-1

@@ -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",

@@ -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 @@ }

## 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:

@@ -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"
}
}

@@ -19,3 +19,3 @@ <p align="center">

[![Node.js CI](https://github.com/underpostnet/engine/actions/workflows/docker-image.ci.yml/badge.svg?branch=master)](https://github.com/underpostnet/engine/actions/workflows/docker-image.ci.yml) [![Test](https://github.com/underpostnet/engine/actions/workflows/coverall.ci.yml/badge.svg?branch=master)](https://github.com/underpostnet/engine/actions/workflows/coverall.ci.yml) [![Downloads](https://img.shields.io/npm/dm/underpost.svg)](https://www.npmjs.com/package/underpost) [![](https://data.jsdelivr.com/v1/package/npm/underpost/badge)](https://www.jsdelivr.com/package/npm/underpost) [![Socket Badge](https://socket.dev/api/badge/npm/package/underpost/3.2.22)](https://socket.dev/npm/package/underpost/overview/3.2.22) [![Coverage Status](https://coveralls.io/repos/github/underpostnet/engine/badge.svg?branch=master)](https://coveralls.io/github/underpostnet/engine?branch=master) [![Version](https://img.shields.io/npm/v/underpost.svg)](https://www.npmjs.org/package/underpost) [![License](https://img.shields.io/npm/l/underpost.svg)](https://www.npmjs.com/package/underpost)
[![Node.js CI](https://github.com/underpostnet/engine/actions/workflows/docker-image.ci.yml/badge.svg?branch=master)](https://github.com/underpostnet/engine/actions/workflows/docker-image.ci.yml) [![Test](https://github.com/underpostnet/engine/actions/workflows/coverall.ci.yml/badge.svg?branch=master)](https://github.com/underpostnet/engine/actions/workflows/coverall.ci.yml) [![Downloads](https://img.shields.io/npm/dm/underpost.svg)](https://www.npmjs.com/package/underpost) [![](https://data.jsdelivr.com/v1/package/npm/underpost/badge)](https://www.jsdelivr.com/package/npm/underpost) [![Socket Badge](https://socket.dev/api/badge/npm/package/underpost/3.2.28)](https://socket.dev/npm/package/underpost/overview/3.2.28) [![Coverage Status](https://coveralls.io/repos/github/underpostnet/engine/badge.svg?branch=master)](https://coveralls.io/github/underpostnet/engine?branch=master) [![Version](https://img.shields.io/npm/v/underpost.svg)](https://www.npmjs.org/package/underpost) [![License](https://img.shields.io/npm/l/underpost.svg)](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. |

#!/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 @@ }

@@ -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,

@@ -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.',

@@ -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');

@@ -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);

@@ -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';

@@ -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;

@@ -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 &quot; but not __¬¬¬__
.replaceAll(
`data.replaceAll('\\n', ' ').replaceAll('\u201c', '\u201d')`,
`data.replaceAll('\\n', ' ').replaceAll('\u201c', '\u201d').replaceAll('__\u00ac\u00ac\u00ac__', '"')`,
)
// getResponsesTag: decodes neither &quot; 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) &gt; 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) &lt; 1">
<p class="desc">
<a href="{{web-url}}sitemap.xml" class="back-link">&#8592; 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">&#8592; 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