uploader-bot/scripts/provision_node.sh

521 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
if [[ $(id -u) -ne 0 ]]; then
echo "This script must be run as root (use sudo)." >&2
exit 1
fi
if ! command -v lsb_release >/dev/null 2>&1; then
apt-get update -y
apt-get install -y lsb-release >/dev/null
fi
UBUNTU_CODENAME=$(lsb_release -sc)
UBUNTU_MAJOR=$(lsb_release -rs | cut -d'.' -f1)
if [[ "$UBUNTU_MAJOR" != "22" ]]; then
echo "Warning: this script targets Ubuntu 22.04. Detected $(lsb_release -ds)." >&2
read -r -p "Continue anyway? [y/N]: " _cont
_cont=${_cont:-N}
if [[ ! $_cont =~ ^[Yy]$ ]]; then
exit 1
fi
fi
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
if [[ -d "$SCRIPT_DIR/../app" && -d "$SCRIPT_DIR/../scripts" ]]; then
DEFAULT_INSTALL_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
else
DEFAULT_INSTALL_ROOT="/home/my-network"
fi
read -r -p "Installation root [$DEFAULT_INSTALL_ROOT]: " INSTALL_ROOT || true
INSTALL_ROOT=${INSTALL_ROOT:-$DEFAULT_INSTALL_ROOT}
INSTALL_ROOT=${INSTALL_ROOT%/}
if [[ -z "$INSTALL_ROOT" ]]; then
INSTALL_ROOT="/home/my-network"
fi
mkdir -p "$INSTALL_ROOT"
INSTALL_ROOT=$(cd "$INSTALL_ROOT" && pwd)
echo "Using installation root: $INSTALL_ROOT"
PROJECT_ROOT="$INSTALL_ROOT"
BACKEND_ROOT="$PROJECT_ROOT/uploader-bot"
CONFIGS_DIR="$PROJECT_ROOT/configs"
FRONTEND_DIR="$PROJECT_ROOT/web2-client"
ENV_FILE="$CONFIGS_DIR/.env"
ENV_EXAMPLE="$BACKEND_ROOT/env.example"
DEFAULT_BACKEND_REMOTE="https://git.projscale.dev/my-dev/uploader-bot"
if [[ -d "$BACKEND_ROOT/.git" ]]; then
DEFAULT_BACKEND_REMOTE=$(git -C "$BACKEND_ROOT" config --get remote.origin.url 2>/dev/null || echo "$DEFAULT_BACKEND_REMOTE")
fi
DEFAULT_CONFIGS_REMOTE="https://git.projscale.dev/my-dev/configs"
if [[ -d "$CONFIGS_DIR/.git" ]]; then
DEFAULT_CONFIGS_REMOTE=$(git -C "$CONFIGS_DIR" config --get remote.origin.url 2>/dev/null || echo "$DEFAULT_CONFIGS_REMOTE")
fi
DEFAULT_WEB2_REMOTE="https://git.projscale.dev/my-dev/web2-client"
if [[ -d "$FRONTEND_DIR/.git" ]]; then
DEFAULT_WEB2_REMOTE=$(git -C "$FRONTEND_DIR" config --get remote.origin.url 2>/dev/null || echo "$DEFAULT_WEB2_REMOTE")
fi
trim() {
local val="$1"
val="${val#${val%%[![:space:]]*}}"
val="${val%${val##*[![:space:]]}}"
printf '%s' "$val"
}
strip_assignment_prefix() {
local key="$1"
local val="$2"
val=$(trim "$val")
if [[ $val == export\ * ]]; then
val=${val#export }
fi
local prefix="${key}="
if [[ $val == "$prefix"* ]]; then
val=${val#$prefix}
fi
local upper_prefix="$(echo "$key" | tr '[:lower:]' '[:upper:]')="
if [[ $val == "$upper_prefix"* ]]; then
val=${val#$upper_prefix}
fi
val="${val#\"}"
val="${val%\"}"
val="${val#\'}"
val="${val%\'}"
printf '%s' "$val"
}
ini_val() {
local key="$1"
if [[ -f "$ENV_FILE" ]]; then
awk -F'=' -v k="$key" 'BEGIN{found=0} $1==k{print substr($0,index($0,$2)); found=1; exit} END{if(!found){} }' "$ENV_FILE"
fi
}
update_env() {
local key="$1"
local value="$2"
if [[ -f "$ENV_FILE" ]]; then
if grep -qE "^${key}=" "$ENV_FILE"; then
sed -i "s|^${key}=.*$|${key}=${value}|" "$ENV_FILE"
return
fi
fi
echo "${key}=${value}" >> "$ENV_FILE"
}
RUN_USER=${SUDO_USER:-$(logname 2>/dev/null || echo root)}
if ! id -u "$RUN_USER" >/dev/null 2>&1; then
RUN_USER=root
fi
RUN_GROUP=$(id -gn "$RUN_USER" 2>/dev/null || echo $RUN_USER)
ensure_repo() {
local dir="$1"
local default_url="$2"
local label="$3"
if [[ -d "$dir/.git" ]]; then
return
fi
if [[ -d "$dir" && ! -d "$dir/.git" ]]; then
echo "Found existing $label directory at $dir without git metadata." >&2
read -r -p "Use the existing directory as-is? [y/N]: " reuse || true
reuse=${reuse:-N}
if [[ ! $reuse =~ ^[Yy]$ ]]; then
echo "Please remove or move $dir and rerun the script." >&2
exit 1
fi
return
fi
read -r -p "Git URL for $label repository [$default_url]: " repo_url || true
repo_url=${repo_url:-$default_url}
mkdir -p "$(dirname "$dir")"
if ! git clone "$repo_url" "$dir"; then
echo "Failed to clone $label repository from $repo_url" >&2
exit 1
fi
chown -R "$RUN_USER:$RUN_GROUP" "$dir" || true
}
prompt_required() {
local var="$1"
local label="$2"
local default_val="${3:-}"
local value
while true; do
if [[ -n "$default_val" ]]; then
read -r -p "$label [$default_val]: " value || true
value=${value:-$default_val}
else
read -r -p "$label: " value || true
fi
value=$(trim "$value")
value=$(strip_assignment_prefix "$var" "$value")
if [[ -n "$value" ]]; then
printf -v "$var" '%s' "$value"
return
fi
echo "Value is required."
done
}
prompt_optional() {
local var="$1"
local label="$2"
local default_val="${3:-}"
local value
if [[ -n "$default_val" ]]; then
read -r -p "$label [$default_val]: " value || true
value=${value:-$default_val}
else
read -r -p "$label: " value || true
fi
value=$(trim "$value")
value=$(strip_assignment_prefix "$var" "$value")
printf -v "$var" '%s' "$value"
}
# Install base dependencies
apt-get update -y
apt-get install -y ca-certificates curl gnupg apt-transport-https software-properties-common git make nginx certbot python3-certbot-nginx python3 jq openssl
# Ensure repository checkouts
ensure_repo "$BACKEND_ROOT" "$DEFAULT_BACKEND_REMOTE" "uploader-bot"
ensure_repo "$CONFIGS_DIR" "$DEFAULT_CONFIGS_REMOTE" "configs"
ensure_repo "$FRONTEND_DIR" "$DEFAULT_WEB2_REMOTE" "web2-client"
# Prepare environment file
if [[ ! -f "$ENV_FILE" ]]; then
echo "Creating $ENV_FILE from example template." >&2
if [[ -f "$ENV_EXAMPLE" ]]; then
cp "$ENV_EXAMPLE" "$ENV_FILE"
else
cp "$CONFIGS_DIR/.env.example" "$ENV_FILE" 2>/dev/null || true
if [[ ! -f "$ENV_FILE" ]]; then
touch "$ENV_FILE"
fi
fi
fi
chown "$RUN_USER:$RUN_GROUP" "$ENV_FILE" || true
# Docker repository setup
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor | tee /etc/apt/keyrings/docker.gpg >/dev/null
chmod a+r /etc/apt/keyrings/docker.gpg
fi
cat <<EOF_REPO >/etc/apt/sources.list.d/docker.list
deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $UBUNTU_CODENAME stable
EOF_REPO
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
if [[ -n "${SUDO_USER:-}" ]]; then
usermod -aG docker "$SUDO_USER"
fi
# Collect interactive inputs
EXISTING_DOMAIN=$(ini_val PUBLIC_HOST)
EXISTING_DOMAIN=${EXISTING_DOMAIN#https://}
EXISTING_DOMAIN=${EXISTING_DOMAIN#http://}
DEFAULT_DOMAIN=$(trim "$EXISTING_DOMAIN")
prompt_required DOMAIN "Public domain (e.g. node.example.com)" "$DEFAULT_DOMAIN"
DOMAIN=$(trim "$DOMAIN")
PUBLIC_HOST="https://$DOMAIN"
prompt_required EMAIL "Email for Let's Encrypt notifications" "$(ini_val CERTBOT_EMAIL)"
DEFAULT_SEEDS=$(trim "${DEFAULT_DOMAIN:+https://$DEFAULT_DOMAIN}")
if [[ -z "$DEFAULT_SEEDS" ]]; then
DEFAULT_SEEDS="https://my-public-node-103.projscale.dev"
fi
prompt_required BOOTSTRAP_SEEDS "Bootstrap seed URLs (comma-separated)" "$DEFAULT_SEEDS"
IFS=',' read -r -a _seed_array <<< "$BOOTSTRAP_SEEDS"
BOOTSTRAP_SEEDS=""
for entry in "${_seed_array[@]}"; do
entry=$(trim "$entry")
[[ -z "$entry" ]] && continue
if [[ $entry != http://* && $entry != https://* ]]; then
entry="https://$entry"
fi
if [[ -z "$BOOTSTRAP_SEEDS" ]]; then
BOOTSTRAP_SEEDS="$entry"
else
BOOTSTRAP_SEEDS="$BOOTSTRAP_SEEDS,$entry"
fi
done
if [[ -z "$BOOTSTRAP_SEEDS" ]]; then
echo 'At least one bootstrap seed is required.' >&2
exit 1
fi
DEFAULT_IPFS_BOOTSTRAP_RAW=$(trim "$(ini_val IPFS_PRIVATE_BOOTSTRAP)")
DEFAULT_IPFS_BOOTSTRAP_ADDR=""
if [[ "$DEFAULT_IPFS_BOOTSTRAP_RAW" =~ ^\["([^"]+)"\]$ ]]; then
DEFAULT_IPFS_BOOTSTRAP_ADDR="${BASH_REMATCH[1]}"
fi
if [[ -z "$DEFAULT_IPFS_BOOTSTRAP_ADDR" ]]; then
DEFAULT_IPFS_BOOTSTRAP_ADDR="/ip4/2.58.65.188/tcp/4001/p2p/12D3KooWDNFkGrc7oFrCSLqm68gRgmCX7mUGmpZHQjezrtHRnptd"
fi
prompt_required IPFS_BOOTSTRAP_MULTIADDR "Primary IPFS bootstrap multiaddr (/ip4/.../tcp/.../p2p/PeerID)" "$DEFAULT_IPFS_BOOTSTRAP_ADDR"
IPFS_BOOTSTRAP_MULTIADDR=$(trim "$IPFS_BOOTSTRAP_MULTIADDR")
if [[ "$IPFS_BOOTSTRAP_MULTIADDR" != *"/p2p/"* ]]; then
echo 'Bootstrap multiaddr must include /p2p/<PeerID> suffix.' >&2
exit 1
fi
IPFS_PEER_ID=${IPFS_BOOTSTRAP_MULTIADDR##*/p2p/}
BOOTSTRAP_JSON=$(printf '["%s"]' "$IPFS_BOOTSTRAP_MULTIADDR")
PEERING_JSON=$(printf '[{"ID":"%s","Addrs":["%s"]}]' "$IPFS_PEER_ID" "$IPFS_BOOTSTRAP_MULTIADDR")
update_env IPFS_PRIVATE_BOOTSTRAP "$BOOTSTRAP_JSON"
update_env IPFS_PEERING_PEERS "$PEERING_JSON"
DEFAULT_IPFS_ANNOUNCE_RAW=$(trim "$(ini_val IPFS_ANNOUNCE_ADDRESSES)")
DEFAULT_IPFS_ANNOUNCE=""
if [[ "$DEFAULT_IPFS_ANNOUNCE_RAW" =~ ^\["([^"]+)"\]$ ]]; then
DEFAULT_IPFS_ANNOUNCE="${BASH_REMATCH[1]}"
fi
if [[ -z "$DEFAULT_IPFS_ANNOUNCE" ]]; then
if command -v curl >/dev/null 2>&1; then
PUBLIC_IP=$(curl -s https://ifconfig.me 2>/dev/null || true)
fi
if [[ -z "$PUBLIC_IP" ]]; then
PUBLIC_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
fi
if [[ -n "$PUBLIC_IP" ]]; then
DEFAULT_IPFS_ANNOUNCE="/ip4/${PUBLIC_IP}/tcp/4001"
fi
fi
prompt_required IPFS_ANNOUNCE_MULTIADDR "Public IPFS announce multiaddr (/ip4/.../tcp/...)" "$DEFAULT_IPFS_ANNOUNCE"
IPFS_ANNOUNCE_MULTIADDR=$(trim "$IPFS_ANNOUNCE_MULTIADDR")
ANNOUNCE_JSON=$(printf '["%s"]' "$IPFS_ANNOUNCE_MULTIADDR")
update_env IPFS_ANNOUNCE_ADDRESSES "$ANNOUNCE_JSON"
update_env IPFS_NOANNOUNCE_ADDRESSES '["/ip4/127.0.0.1","/ip6/::1"]'
prompt_optional NODE_PRIVACY "Node privacy (public/private)" "$(ini_val NODE_PRIVACY)"
if [[ -z "$NODE_PRIVACY" ]]; then
NODE_PRIVACY="public"
fi
NODE_PRIVACY=$(echo "$NODE_PRIVACY" | tr '[:upper:]' '[:lower:]')
if [[ "$NODE_PRIVACY" != "public" && "$NODE_PRIVACY" != "private" ]]; then
echo "Invalid privacy option, defaulting to public." >&2
NODE_PRIVACY="public"
fi
DEFAULT_SANIC=$(ini_val SANIC_PORT)
DEFAULT_SANIC=${DEFAULT_SANIC:-13200}
prompt_optional SANIC_PORT "Internal backend port" "$DEFAULT_SANIC"
DEFAULT_BACKEND_PORT=$(ini_val BACKEND_HTTP_PORT)
DEFAULT_BACKEND_PORT=${DEFAULT_BACKEND_PORT:-13200}
prompt_optional BACKEND_HTTP_PORT "Public backend port" "$DEFAULT_BACKEND_PORT"
DEFAULT_FRONTEND_PORT=$(ini_val FRONTEND_HTTP_PORT)
DEFAULT_FRONTEND_PORT=${DEFAULT_FRONTEND_PORT:-13300}
prompt_optional FRONTEND_HTTP_PORT "Public frontend port" "$DEFAULT_FRONTEND_PORT"
DEFAULT_TUSD_PORT=$(ini_val TUSD_HTTP_PORT)
DEFAULT_TUSD_PORT=${DEFAULT_TUSD_PORT:-13400}
prompt_optional TUSD_HTTP_PORT "Public tusd port" "$DEFAULT_TUSD_PORT"
DEFAULT_PG_PORT=$(ini_val POSTGRES_FORWARD_PORT)
DEFAULT_PG_PORT=${DEFAULT_PG_PORT:-13580}
prompt_optional POSTGRES_FORWARD_PORT "Public Postgres port" "$DEFAULT_PG_PORT"
prompt_required TELEGRAM_API_KEY "Telegram uploader bot token" "$(ini_val TELEGRAM_API_KEY)"
prompt_required CLIENT_TELEGRAM_API_KEY "Telegram client bot token" "$(ini_val CLIENT_TELEGRAM_API_KEY)"
prompt_optional ADMIN_API_TOKEN "Admin API token" "$(ini_val ADMIN_API_TOKEN)"
if [[ -z "$ADMIN_API_TOKEN" ]]; then
ADMIN_API_TOKEN=$(openssl rand -hex 16)
echo "Generated ADMIN_API_TOKEN=$ADMIN_API_TOKEN"
fi
prompt_required TONCENTER_API_KEY "TON Center API key" "$(ini_val TONCENTER_API_KEY)"
prompt_optional TON_INIT_HOT_SEED "TON hot wallet seed hex (leave blank to auto-generate)" "$(ini_val TON_INIT_HOT_SEED)"
prompt_required CONTENT_KEY_KEK_B64 "CONTENT_KEY_KEK_B64 (32-byte base64)" "$(ini_val CONTENT_KEY_KEK_B64)"
if ! python3 - "$CONTENT_KEY_KEK_B64" <<'PY'; then
import base64, sys
val = sys.argv[1]
raw = base64.b64decode(val + '===' , validate=False)
if len(raw) != 32:
raise SystemExit(1)
PY
echo 'Invalid CONTENT_KEY_KEK_B64: must be base64-encoded 32 bytes.' >&2
exit 1
fi
prompt_required SWARM_KEY_HEX "IPFS swarm secret (32-byte hex)" ""
SWARM_KEY_HEX=$(echo "$SWARM_KEY_HEX" | tr '[:lower:]' '[:upper:]')
if ! python3 - "$SWARM_KEY_HEX" <<'PY'; then
import sys
val = sys.argv[1].strip()
bytes.fromhex(val)
if len(val) != 64:
raise SystemExit(1)
PY
echo 'Invalid IPFS swarm key: must be 64 hex chars (32 bytes).' >&2
exit 1
fi
update_env CERTBOT_EMAIL "$EMAIL"
update_env PUBLIC_HOST "$PUBLIC_HOST"
update_env PROJECT_HOST "$PUBLIC_HOST"
update_env NODE_PRIVACY "$NODE_PRIVACY"
update_env BOOTSTRAP_SEEDS "$BOOTSTRAP_SEEDS"
update_env SANIC_PORT "$SANIC_PORT"
update_env BACKEND_HTTP_PORT "$BACKEND_HTTP_PORT"
update_env FRONTEND_HTTP_PORT "$FRONTEND_HTTP_PORT"
update_env TUSD_HTTP_PORT "$TUSD_HTTP_PORT"
update_env POSTGRES_FORWARD_PORT "$POSTGRES_FORWARD_PORT"
update_env TELEGRAM_API_KEY "$TELEGRAM_API_KEY"
update_env CLIENT_TELEGRAM_API_KEY "$CLIENT_TELEGRAM_API_KEY"
update_env ADMIN_API_TOKEN "$ADMIN_API_TOKEN"
update_env TONCENTER_API_KEY "$TONCENTER_API_KEY"
if [[ -n "$TON_INIT_HOT_SEED" ]]; then
update_env TON_INIT_HOT_SEED "$TON_INIT_HOT_SEED"
fi
update_env CONTENT_KEY_KEK_B64 "$CONTENT_KEY_KEK_B64"
update_env HANDSHAKE_INTERVAL_SEC "60"
update_env BOOTSTRAP_REQUIRED "1"
update_env VITE_API_BASE_URL "${PUBLIC_HOST}/api/v1"
update_env VITE_API_BASE_STORAGE_URL "${PUBLIC_HOST}/api/v1.5/storage"
update_env VITE_TUS_ENDPOINT "${PUBLIC_HOST}/tus/files"
update_env TON_CONNECT_MANIFEST_URI "${PUBLIC_HOST}/api/tonconnect-manifest.json"
# Ensure swarm key file
mkdir -p "$CONFIGS_DIR/ipfs"
cat <<EOF_SWARM >"$CONFIGS_DIR/ipfs/swarm.key"
/key/swarm/psk/1.0.0/
/base16/
$SWARM_KEY_HEX
EOF_SWARM
chown 1000:1000 "$CONFIGS_DIR/ipfs/swarm.key" || true
chmod 600 "$CONFIGS_DIR/ipfs/swarm.key"
update_env IPFS_SWARM_KEY_FILE "$CONFIGS_DIR/ipfs/swarm.key"
# Issue TLS certificate
if [[ ! -d "/etc/letsencrypt/live/$DOMAIN" ]]; then
systemctl stop nginx || true
certbot certonly --standalone --agree-tos --non-interactive -m "$EMAIL" -d "$DOMAIN"
systemctl start nginx
fi
systemctl enable --now nginx
# Nginx configuration
cat <<EOF_NGX >/etc/nginx/sites-available/my-network.conf
upstream backend_app {
server 127.0.0.1:$SANIC_PORT;
keepalive 32;
}
upstream frontend_web {
server 127.0.0.1:$FRONTEND_HTTP_PORT;
keepalive 16;
}
upstream tusd_backend {
server 127.0.0.1:$TUSD_HTTP_PORT;
keepalive 16;
}
server {
listen 80;
server_name $DOMAIN;
return 301 https://$DOMAIN\$request_uri;
}
server {
listen 443 ssl http2;
server_name $DOMAIN;
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
client_max_body_size 10G;
proxy_read_timeout 300s;
location / {
proxy_pass http://frontend_web;
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;
}
location /assets/ {
proxy_pass http://frontend_web;
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_hide_header Cache-Control;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location /tus/ {
proxy_pass http://tusd_backend/;
proxy_request_buffering off;
proxy_buffering off;
proxy_max_temp_file_size 0;
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;
}
location /files/ {
proxy_pass http://tusd_backend;
proxy_request_buffering off;
proxy_buffering off;
proxy_max_temp_file_size 0;
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;
}
location /api/ {
proxy_pass http://backend_app;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
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;
}
location = /health {
proxy_pass http://backend_app/api/system.version;
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;
}
}
EOF_NGX
ln -sf /etc/nginx/sites-available/my-network.conf /etc/nginx/sites-enabled/my-network.conf
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
# Bootstrap docker-compose stack
make -C "$CONFIGS_DIR" bootstrap
echo "\nNode provisioning complete."
echo "- Admin panel: ${PUBLIC_HOST}/admin (use ADMIN_API_TOKEN)"
echo "- To trust this node on peers, mark it via admin API on existing node."
echo "- Docker services: run 'docker ps -a' or 'make -C $CONFIGS_DIR ps'."
echo "- Installation root: $PROJECT_ROOT"