#!/usr/bin/env bash # install.sh — install the dune-admin binary on an Ubuntu host # # This script ONLY installs the binary. It does not configure it. To configure # dune-admin after installing, run the built-in setup wizard: # # /opt/dune-admin/dune-admin -setup # # The wizard asks you to pick a control plane (kubectl / docker / local / amp) # and writes ~/.dune-admin/config.yaml. # # Prerequisites (this script does NOT install these for you): # - Ubuntu 22.04 or 24.04 with passwordless sudo # - Whatever your control plane needs already running: # * amp → AMP installed with a running Dune instance # (verify with: sudo -u amp ampinstmgr -l) # * kubectl → kubeconfig pointing at a k3s/k8s cluster with the # dune workloads deployed # * docker → docker daemon + named containers for dune services # * local → game-server processes reachable on localhost # - PostgreSQL reachable (typically 127.0.0.1:15432 with the AMP module) # - Outbound internet (to fetch Go, Node, pnpm, the source repo) # # What this script does: # 1. Installs build toolchain: Go 1.26.3, Node 22 LTS, pnpm 10.28.1, build-essential # 2. Clones dune-admin source into $SOURCE_DIR (default: ~/src/dune-admin) # 3. Builds the Linux binary + frontend assets # 4. Copies the binary, SPA, and data files into $INSTALL_DIR (default: /opt/dune-admin) # 5. Writes the systemd unit (Restart=always) — but does not enable/start it # 6. Prints next steps: setup wizard, sudoers entry, service enable/start # # What this script does NOT do: # - Install AMP, k3s, Docker, or set up game services — those are prerequisites # - Run the setup wizard (interactive; you do that, see above) # - Apply sudoers grants (security-sensitive; you review and apply) # - Enable or start the systemd unit (it writes the unit, but you enable/start # it after running the setup wizard) # # Re-running this script is safe and idempotent. If a toolchain version is # already correct, it's skipped. If source is already cloned, it's fetched # and reset to the target branch. If artifacts already exist in $INSTALL_DIR, # they are replaced atomically with a `.prev` backup left in place. # # Local patches: # If a "patches" directory exists next to this script (or one is specified # with --patches-dir), every *.patch file in it is applied with `git apply` # after the source checkout. Use this to layer in unmerged fixes or local # modifications without forking the repo. Pass --no-patches to skip. # # Usage: # ./install.sh # main branch, /opt/dune-admin, current user # ./install.sh --branch chore/phase-0-bug-fixes # ./install.sh --install-dir /opt/dune-admin --service-user dune-admin # ./install.sh --patches-dir ./my-patches # ./install.sh --no-patches # ./install.sh --help # Re-exec under bash when started by another shell (`sh install.sh`, `sh -c …`, # `zsh install.sh`) so the bash-only features below — arrays, [[ … ]], # `set -o pipefail`, ${BASH_SOURCE} — work regardless of the caller's shell (#76). # POSIX-safe test, and placed after the comment header so it stays out of the # `sed -n '2,30p'` range that usage() prints. The README's `curl … | bash` # already runs under bash, so the guard is a no-op there. if [ -z "${BASH_VERSION:-}" ]; then exec bash "$0" "$@" fi set -euo pipefail # ── Defaults (override via flags) ───────────────────────────────────────────── REPO_URL="https://github.com/Icehunter/dune-admin.git" BRANCH="main" SOURCE_DIR="${HOME}/src/dune-admin" INSTALL_DIR="/opt/dune-admin" SERVICE_USER="${USER}" SKIP_TOOLCHAIN=0 # Patches directory: defaults to ./patches alongside this script. Empty/missing # is fine — just means no patches will be applied. SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" PATCHES_DIR="${SCRIPT_DIR}/patches" APPLY_PATCHES=1 GO_VERSION="1.26.3" NODE_MAJOR="22" PNPM_VERSION="10.28.1" # ── Helpers ────────────────────────────────────────────────────────────────── log() { printf '\033[1;34m[install]\033[0m %s\n' "$*"; } ok() { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[warn ]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[fail ]\033[0m %s\n' "$*" >&2; exit 1; } usage() { sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' exit 0 } # ── Argument parsing ───────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in --repo) REPO_URL="$2"; shift 2 ;; --branch) BRANCH="$2"; shift 2 ;; --source-dir) SOURCE_DIR="$2"; shift 2 ;; --install-dir) INSTALL_DIR="$2"; shift 2 ;; --service-user) SERVICE_USER="$2"; shift 2 ;; --skip-toolchain) SKIP_TOOLCHAIN=1; shift ;; --patches-dir) PATCHES_DIR="$2"; shift 2 ;; --no-patches) APPLY_PATCHES=0; shift ;; -h|--help) usage ;; *) die "unknown flag: $1 (try --help)" ;; esac done log "config:" log " repo: $REPO_URL" log " branch: $BRANCH" log " source dir: $SOURCE_DIR" log " install dir: $INSTALL_DIR" log " service user: $SERVICE_USER" log "" # ── Sanity checks ──────────────────────────────────────────────────────────── [[ "$(id -u)" -eq 0 ]] && die "run this as a normal user with sudo, not as root directly" sudo -n true 2>/dev/null || die "this user needs passwordless sudo (or you need to authenticate sudo first)" id "$SERVICE_USER" >/dev/null 2>&1 || die "service user '$SERVICE_USER' does not exist" command -v sudo >/dev/null || die "sudo is required" # Don't try to migrate a running service silently — make the operator stop it first. if systemctl is-active --quiet dune-admin 2>/dev/null; then warn "dune-admin.service is currently active. Stop it before re-running this script:" warn " sudo systemctl stop dune-admin" die "refusing to swap binary under a running service" fi # ── 1. Toolchain ───────────────────────────────────────────────────────────── if [[ "$SKIP_TOOLCHAIN" -eq 0 ]]; then log "installing build toolchain (apt: build-essential, git, curl, ca-certificates)…" sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ build-essential git curl ca-certificates # ── Go ───────────────────────────────────────────────────────────────────── if /usr/local/go/bin/go version 2>/dev/null | grep -q "go${GO_VERSION}"; then ok "go ${GO_VERSION} already installed" else log "installing go ${GO_VERSION} to /usr/local/go…" tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" -o "$tmp/go.tgz" sudo rm -rf /usr/local/go sudo tar -C /usr/local -xzf "$tmp/go.tgz" ok "go $(/usr/local/go/bin/go version | awk '{print $3}') installed" fi export PATH="/usr/local/go/bin:${PATH}" # ── Node.js ──────────────────────────────────────────────────────────────── if command -v node >/dev/null && node -v | grep -q "^v${NODE_MAJOR}\."; then ok "node $(node -v) already installed" else log "installing Node ${NODE_MAJOR} LTS from NodeSource…" curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | sudo -E bash - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs ok "node $(node -v) installed" fi # ── pnpm ─────────────────────────────────────────────────────────────────── if command -v pnpm >/dev/null && pnpm -v 2>/dev/null | grep -q "^${PNPM_VERSION}$"; then ok "pnpm ${PNPM_VERSION} already installed" else log "installing pnpm ${PNPM_VERSION}…" sudo npm install -g "pnpm@${PNPM_VERSION}" ok "pnpm $(pnpm -v) installed" fi else log "skipping toolchain installation (--skip-toolchain)" export PATH="/usr/local/go/bin:${PATH}" fi log "" # ── 2. Source ──────────────────────────────────────────────────────────────── log "syncing source into $SOURCE_DIR @ branch $BRANCH…" mkdir -p "$(dirname "$SOURCE_DIR")" if [[ -d "$SOURCE_DIR/.git" ]]; then git -C "$SOURCE_DIR" fetch --quiet origin else git clone --quiet "$REPO_URL" "$SOURCE_DIR" fi git -C "$SOURCE_DIR" checkout --quiet "$BRANCH" 2>/dev/null \ || git -C "$SOURCE_DIR" checkout --quiet -B "$BRANCH" "origin/$BRANCH" git -C "$SOURCE_DIR" reset --hard --quiet "origin/$BRANCH" ok "checked out $(git -C "$SOURCE_DIR" rev-parse --short HEAD) ($(git -C "$SOURCE_DIR" log -1 --format=%s))" log "" # ── 2b. Local patches ──────────────────────────────────────────────────────── # Apply *.patch files (in lexical order) from $PATCHES_DIR. This lets you # layer in unmerged fixes (e.g. our spaHandler restoration while the upstream # PR is in review) without maintaining a fork. if [[ "$APPLY_PATCHES" -eq 1 && -d "$PATCHES_DIR" ]]; then shopt -s nullglob patches=( "$PATCHES_DIR"/*.patch ) shopt -u nullglob if [[ ${#patches[@]} -gt 0 ]]; then log "applying ${#patches[@]} local patch(es) from $PATCHES_DIR…" for p in "${patches[@]}"; do if git -C "$SOURCE_DIR" apply --check "$p" 2>/dev/null; then git -C "$SOURCE_DIR" apply "$p" ok " applied $(basename "$p")" else # Already applied? Skip silently if reverse-apply works (idempotent re-runs). if git -C "$SOURCE_DIR" apply --reverse --check "$p" 2>/dev/null; then ok " $(basename "$p") already applied (skipped)" else die " $(basename "$p") does not apply cleanly. Inspect the patch and the target file:\n git -C $SOURCE_DIR apply --check $p" fi fi done else log "no *.patch files in $PATCHES_DIR (skipping)" fi elif [[ "$APPLY_PATCHES" -eq 0 ]]; then log "patch application disabled (--no-patches)" else log "no patches dir at $PATCHES_DIR (skipping)" fi log "" # ── 3. Build ───────────────────────────────────────────────────────────────── log "building frontend (pnpm)…" # Workaround for pnpm + rolldown native bindings on Linux/Windows: hoist # node_modules so @rolldown/binding-* is resolvable from the top level. echo 'node-linker=hoisted' > "$SOURCE_DIR/web/.npmrc" (cd "$SOURCE_DIR/web" && pnpm install --frozen-lockfile && pnpm build) >/dev/null [[ -f "$SOURCE_DIR/web/dist/index.html" ]] || die "frontend build produced no dist/index.html" ok "frontend built ($(du -sh "$SOURCE_DIR/web/dist" | awk '{print $1}'))" log "building backend (go)…" (cd "$SOURCE_DIR" && make linux) >/dev/null [[ -f "$SOURCE_DIR/dune-admin-linux" ]] || die "backend build produced no dune-admin-linux" ok "backend built ($(du -sh "$SOURCE_DIR/dune-admin-linux" | awk '{print $1}'))" log "" # ── 4. Install into $INSTALL_DIR ───────────────────────────────────────────── log "installing into $INSTALL_DIR (as service user '$SERVICE_USER')…" sudo mkdir -p "$INSTALL_DIR" sudo chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" # Backup existing binary (move to .prev for one-step rollback). if [[ -f "$INSTALL_DIR/dune-admin" ]]; then sudo cp -f "$INSTALL_DIR/dune-admin" "$INSTALL_DIR/dune-admin.prev" fi sudo install -m 0755 -o "$SERVICE_USER" -g "$SERVICE_USER" \ "$SOURCE_DIR/dune-admin-linux" "$INSTALL_DIR/dune-admin" # Backup existing dist (move to dist.prev for one-step rollback). if [[ -d "$INSTALL_DIR/dist" ]]; then sudo rm -rf "$INSTALL_DIR/dist.prev" sudo mv "$INSTALL_DIR/dist" "$INSTALL_DIR/dist.prev" fi sudo cp -r "$SOURCE_DIR/web/dist" "$INSTALL_DIR/dist" sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR/dist" # Data files (only copy if newer or missing — these change less often). for f in item-data.json quality-data.json tags-data.json; do if [[ -f "$SOURCE_DIR/$f" ]]; then sudo install -m 0644 -o "$SERVICE_USER" -g "$SERVICE_USER" \ "$SOURCE_DIR/$f" "$INSTALL_DIR/$f" fi done ok "installed: $(ls -la "$INSTALL_DIR/dune-admin" | awk '{print $NF, $5, "bytes"}')" log "" # ── 4b. systemd unit ───────────────────────────────────────────────────────── # Write (or repair) the unit with Restart=always. This is REQUIRED for in-app # self-update: after swapping the binary the process re-execs/exits, and only # Restart=always reliably brings it back on a clean exit (a hand-made unit with # Restart=on-failure leaves the service down after an update). We deliberately # do NOT enable/start here — the service needs config.yaml from the setup # wizard first — but we DO restart it if it is already enabled (re-install). UNIT_PATH="/etc/systemd/system/dune-admin.service" log "writing systemd unit $UNIT_PATH (Restart=always)…" sudo tee "$UNIT_PATH" >/dev/null </dev/null; then log "existing service detected — restarting onto the new binary…" sudo systemctl restart dune-admin || warn "restart failed; check: sudo journalctl -u dune-admin -e" fi ok "systemd unit installed (Restart=always)" log "" # ── 5. Next steps ──────────────────────────────────────────────────────────── cat <:9090 (or whatever listen_addr you chose). NOTE: this installer writes the unit with Restart=always, which is required for in-app self-update (Settings → Check for Updates) to restart cleanly. ROLLBACK (if something is wrong): sudo systemctl stop dune-admin sudo cp $INSTALL_DIR/dune-admin.prev $INSTALL_DIR/dune-admin sudo rm -rf $INSTALL_DIR/dist && sudo mv $INSTALL_DIR/dist.prev $INSTALL_DIR/dist sudo systemctl start dune-admin ────────────────────────────────────────────────────────────────────────────── EOF