#!/usr/bin/env bash # bootstrap-vm.sh — Idempotent Gitea bootstrap for a fresh (or existing) cloud VM. # # Goes from "VM running with Docker installed" → "Gitea running on :3300 # with admin + learning_ai_user accounts + npm-scoped token written to file". # # This script can run in two modes: # # 1. ON THE VM directly (recommended): SCP this file to the VM, SSH in, run. # scp scripts/gitea/bootstrap-vm.sh azureuser@:~/ # ssh azureuser@ 'bash bootstrap-vm.sh' # # 2. LOCALLY targeting a remote VM via SSH: # bash scripts/gitea/bootstrap-vm.sh --ssh-host azureuser@ # # Usage: # bash bootstrap-vm.sh [OPTIONS] # # Options: # --admin-user NAME Gitea admin username (default: gitea-admin) # --admin-email EMAIL Gitea admin email (default: admin@bytelyst.local) # --admin-pass PASS Gitea admin password (default: generated, printed) # --npm-user NAME Owner for @bytelyst packages (default: learning_ai_user) # --npm-email EMAIL Email for npm-user (default: npm@bytelyst.local) # --gitea-version VER Gitea Docker tag (default: 1.22) # --data-dir PATH Host path for Gitea data (default: /var/lib/gitea) # --output FILE Write npm token to FILE (default: print to stdout # with a fenced block for easy copy) # --ssh-host HOST Run remotely via SSH (e.g. azureuser@10.0.0.4) # --skip-docker Skip Docker install (assume present) # --dry-run Print actions without executing # --force Re-create admin/npm user even if they exist # -h, --help Show this help # # Exit codes: # 0 bootstrap succeeded # 1 bootstrap failed at some step (error printed) # 2 bad arguments # # Side effects (idempotent unless --force): # - Installs Docker (skipped if present, or with --skip-docker) # - Creates /var/lib/gitea (or --data-dir) — persistent across restarts # - Writes /etc/gitea/docker-compose.yml # - Starts/restarts `gitea` container on port 3300 # - Creates admin user (skipped if exists) # - Creates npm-user via admin API (skipped if exists) # - Mints a new npm-scoped token (always — old token NOT revoked, # just supplemented; rotate via scripts/gitea/token.sh rotate) # - Writes token to --output FILE or stdout # # Re-runnable safely. Re-running: # - Detects existing Gitea, skips install # - Detects existing users, skips creation (unless --force) # - Always mints a fresh token (each run = new token; old ones still valid # unless you revoke them in Gitea UI) set -uo pipefail # ── Defaults ──────────────────────────────────────────────────── ADMIN_USER="gitea-admin" ADMIN_EMAIL="admin@bytelyst.local" ADMIN_PASS="" NPM_USER="learning_ai_user" NPM_EMAIL="npm@bytelyst.local" GITEA_VERSION="1.22" DATA_DIR="/var/lib/gitea" OUTPUT_FILE="" SSH_HOST="" SKIP_DOCKER=false DRY_RUN=false FORCE=false GITEA_PORT="3300" GITEA_SSH_PORT="222" while [ $# -gt 0 ]; do case "$1" in --admin-user) ADMIN_USER="$2"; shift 2 ;; --admin-email) ADMIN_EMAIL="$2"; shift 2 ;; --admin-pass) ADMIN_PASS="$2"; shift 2 ;; --npm-user) NPM_USER="$2"; shift 2 ;; --npm-email) NPM_EMAIL="$2"; shift 2 ;; --gitea-version) GITEA_VERSION="$2"; shift 2 ;; --data-dir) DATA_DIR="$2"; shift 2 ;; --output) OUTPUT_FILE="$2"; shift 2 ;; --ssh-host) SSH_HOST="$2"; shift 2 ;; --skip-docker) SKIP_DOCKER=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --force) FORCE=true; shift ;; -h|--help) sed -n '2,42p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "Unknown arg: $1" >&2; exit 2 ;; esac done # ── Remote re-exec via SSH ────────────────────────────────────── if [ -n "$SSH_HOST" ]; then echo "→ Re-executing on $SSH_HOST via SSH" # Copy this script to the remote and re-run with same args minus --ssh-host ARGS=() [ "$ADMIN_USER" != "gitea-admin" ] && ARGS+=(--admin-user "$ADMIN_USER") [ "$ADMIN_EMAIL" != "admin@bytelyst.local" ] && ARGS+=(--admin-email "$ADMIN_EMAIL") [ -n "$ADMIN_PASS" ] && ARGS+=(--admin-pass "$ADMIN_PASS") [ "$NPM_USER" != "learning_ai_user" ] && ARGS+=(--npm-user "$NPM_USER") [ "$NPM_EMAIL" != "npm@bytelyst.local" ] && ARGS+=(--npm-email "$NPM_EMAIL") [ "$GITEA_VERSION" != "1.22" ] && ARGS+=(--gitea-version "$GITEA_VERSION") [ "$DATA_DIR" != "/var/lib/gitea" ] && ARGS+=(--data-dir "$DATA_DIR") $SKIP_DOCKER && ARGS+=(--skip-docker) $DRY_RUN && ARGS+=(--dry-run) $FORCE && ARGS+=(--force) scp "$0" "$SSH_HOST:/tmp/bootstrap-vm.sh" >/dev/null ssh "$SSH_HOST" "bash /tmp/bootstrap-vm.sh ${ARGS[*]}" exit $? fi # ── Helpers ───────────────────────────────────────────────────── say() { echo "→ $1"; } ok() { echo " ✓ $1"; } err() { echo " ✗ $1" >&2; exit 1; } run() { if $DRY_RUN; then echo " [dry-run] $*" else eval "$@" fi } # Generate random password if not set gen_password() { # 24 chars, alphanumeric (safe for shell + URLs) if command -v openssl >/dev/null 2>&1; then openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 24 else tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 24 fi } if [ -z "$ADMIN_PASS" ]; then ADMIN_PASS="$(gen_password)" PASS_GENERATED=true else PASS_GENERATED=false fi # Detect sudo SUDO="" if [ "$(id -u)" -ne 0 ]; then SUDO="sudo" fi # ── Step 1: Install Docker ────────────────────────────────────── step_docker() { say "Step 1/6: Docker" if $SKIP_DOCKER; then ok "Skipped (--skip-docker)" return fi if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then ok "Docker + compose plugin already installed ($(docker --version))" return fi say " Installing Docker via official convenience script…" run "curl -fsSL https://get.docker.com | $SUDO sh" run "$SUDO usermod -aG docker \"\$USER\" || true" ok "Docker installed (you may need to re-login for group membership)" } # ── Step 2: Write docker-compose.yml ──────────────────────────── step_compose() { say "Step 2/6: Gitea docker-compose" run "$SUDO mkdir -p $DATA_DIR /etc/gitea" local compose="/etc/gitea/docker-compose.yml" run "$SUDO tee $compose >/dev/null </dev/null 2>&1; then ok "Gitea reachable ($(curl -fsS "http://localhost:$GITEA_PORT/api/v1/version"))" return fi sleep 2 i=$((i+1)) done err "Gitea did not come up within 60s. Check: $SUDO docker logs gitea" } # ── Step 4: Create admin user via Gitea CLI ───────────────────── step_admin() { say "Step 4/6: Admin user '$ADMIN_USER'" if $DRY_RUN; then echo " [dry-run] $SUDO docker exec gitea gitea admin user create --admin ..." return fi # Check if admin already exists if $SUDO docker exec gitea gitea admin user list 2>/dev/null | awk 'NR>1 {print $2}' | grep -qx "$ADMIN_USER"; then if $FORCE; then say " --force: re-setting admin password" $SUDO docker exec gitea gitea admin user change-password \ --username "$ADMIN_USER" --password "$ADMIN_PASS" >/dev/null ok "Admin password reset" else ok "Admin '$ADMIN_USER' already exists — leaving password unchanged" PASS_GENERATED=false # don't claim a fresh password if we didn't set one fi else $SUDO docker exec gitea gitea admin user create \ --username "$ADMIN_USER" \ --password "$ADMIN_PASS" \ --email "$ADMIN_EMAIL" \ --admin \ --must-change-password=false >/dev/null ok "Admin '$ADMIN_USER' created" fi } # ── Step 5: Create npm-user via API ───────────────────────────── step_npm_user() { say "Step 5/6: NPM owner '$NPM_USER'" if $DRY_RUN; then echo " [dry-run] curl -u $ADMIN_USER:**** POST /api/v1/admin/users" return fi local base="http://localhost:$GITEA_PORT/api/v1" local code code=$(curl -fsS -o /tmp/gitea-user-check.json -w "%{http_code}" \ -u "$ADMIN_USER:$ADMIN_PASS" "$base/users/$NPM_USER" 2>/dev/null || echo "000") if [ "$code" = "200" ] && ! $FORCE; then ok "NPM user '$NPM_USER' already exists" else local npm_pass; npm_pass="$(gen_password)" local resp resp=$(curl -fsS -u "$ADMIN_USER:$ADMIN_PASS" \ -X POST "$base/admin/users" \ -H 'Content-Type: application/json' \ -d "{\"username\":\"$NPM_USER\",\"email\":\"$NPM_EMAIL\",\"password\":\"$npm_pass\",\"must_change_password\":false}" 2>&1) if echo "$resp" | grep -q '"id"'; then ok "NPM user '$NPM_USER' created" else err "Failed to create NPM user: $resp" fi fi } # ── Step 6: Mint npm token ────────────────────────────────────── step_token() { say "Step 6/6: Mint npm token for '$NPM_USER'" if $DRY_RUN; then echo " [dry-run] curl -u $NPM_USER POST /api/v1/users/$NPM_USER/tokens" NPM_TOKEN="DRY_RUN_TOKEN_PLACEHOLDER" return fi # We need the npm-user's password to call /tokens — but we only have it # at create time. Workaround: use admin Basic Auth to call the admin-only # `/admin/users//tokens` endpoint? Gitea does not expose this. # # Approach: temporarily reset npm-user's password via admin, mint via # the npm-user's own creds, then leave the password as-is (it's only used # for token minting; the npm flow uses the token). local npm_pass; npm_pass="$(gen_password)" $SUDO docker exec gitea gitea admin user change-password \ --username "$NPM_USER" --password "$npm_pass" >/dev/null 2>&1 || true local token_name="npm-bootstrap-$(date +%Y%m%d-%H%M%S)" local base="http://localhost:$GITEA_PORT/api/v1" local resp resp=$(curl -fsS -u "$NPM_USER:$npm_pass" \ -X POST "$base/users/$NPM_USER/tokens" \ -H 'Content-Type: application/json' \ -d "{\"name\":\"$token_name\",\"scopes\":[\"write:package\",\"read:package\"]}" 2>&1) NPM_TOKEN=$(echo "$resp" | grep -oE '"(sha1|token)":"[^"]+' | head -1 | sed 's/.*":"//') if [ -z "$NPM_TOKEN" ]; then err "Failed to mint token: $resp" fi ok "Token minted (name=$token_name, ${#NPM_TOKEN} chars)" } # ── Output token ──────────────────────────────────────────────── output_token() { if [ -n "$OUTPUT_FILE" ]; then if $DRY_RUN; then echo "[dry-run] would write token to $OUTPUT_FILE" else printf '%s' "$NPM_TOKEN" > "$OUTPUT_FILE" chmod 600 "$OUTPUT_FILE" ok "Token written to $OUTPUT_FILE (mode 600)" fi fi } # ── Final summary ─────────────────────────────────────────────── summary() { echo "" echo "════════════════════════════════════════════════════════════════" echo " ✅ Gitea bootstrap complete" echo "════════════════════════════════════════════════════════════════" echo "" echo " Gitea URL: http://$(hostname):$GITEA_PORT" echo " Admin user: $ADMIN_USER" if $PASS_GENERATED && [ -z "$OUTPUT_FILE" ]; then echo " Admin password: $ADMIN_PASS ← SAVE THIS NOW (won't be shown again)" fi echo " NPM owner: $NPM_USER" echo " Registry URL: http://$(hostname):$GITEA_PORT/api/packages/$NPM_USER/npm/" echo "" if [ -z "$OUTPUT_FILE" ]; then echo " ── NPM TOKEN (copy this NOW) ──" echo "" echo " $NPM_TOKEN" echo "" echo " Save to ~/.gitea_npm_token_home on your workstation:" echo "" echo " echo -n '$NPM_TOKEN' > ~/.gitea_npm_token_home" echo " chmod 600 ~/.gitea_npm_token_home" echo "" else echo " NPM token: written to $OUTPUT_FILE" echo "" fi echo " Verify (from workstation):" echo "" echo " GITEA_NPM_HOST=$(hostname) GITEA_NPM_OWNER=$NPM_USER \\" echo " GITEA_NPM_TOKEN=\$(cat ~/.gitea_npm_token_home) \\" echo " bash scripts/gitea/doctor.sh" echo "" } # ── Main ──────────────────────────────────────────────────────── say "Gitea VM bootstrap" say " Host: $(hostname)" say " Admin user: $ADMIN_USER" say " NPM owner: $NPM_USER" say " Gitea version: $GITEA_VERSION" say " Data dir: $DATA_DIR" say " Dry-run: $DRY_RUN" echo "" step_docker step_compose step_start step_admin step_npm_user step_token output_token summary