Skip to content

Instantly share code, notes, and snippets.

@andrebrait
Last active June 1, 2026 15:52
Show Gist options
  • Select an option

  • Save andrebrait/7fb9ef01a1eef2a234c57d3f5bd94601 to your computer and use it in GitHub Desktop.

Select an option

Save andrebrait/7fb9ef01a1eef2a234c57d3f5bd94601 to your computer and use it in GitHub Desktop.
Arcane bidirectional migrator
#!/usr/bin/env bash
set -euo pipefail
# ╔══════════════════════════════════════════════════════════════════════════╗
# ║ Arcane bidirectional migrator ║
# ║ ║
# ║ ── DISCLAIMER ── ║
# ║ This script was generated by Claude (an AI assistant by Anthropic) in ║
# ║ collaboration with the user. It is NOT an official Arcane tool and is ║
# ║ not affiliated with or endorsed by the Arcane project (getarcaneapp). ║
# ║ It is provided as-is, without warranty of any kind. It performs ║
# ║ destructive operations (stopping services, removing installs). Review it ║
# ║ in full, ensure you have working backups, and test on a non-critical ║
# ║ host before relying on it. You run it at your own risk. ║
# ║ ║
# ║ ── WHAT IT DOES ── ║
# ║ Migrates an Arcane install between host-binary (systemd) and Docker ║
# ║ Compose forms. Direction auto-detected where unambiguous, else asked. ║
# ║ Live compose dir detected from the running container's compose label; ║
# ║ when the container is down, falls back to parsing the compose file's ║
# ║ /app/data bind mount. Creates the 'arcane' user/group (as the official ║
# ║ installer does) and normalizes data/projects/builds ownership to ║
# ║ arcane:arcane EARLY, before any failure-prone step. An ERR trap reports ║
# ║ the exact failing line. Data and secrets preserved; consistent tar ║
# ║ snapshot taken before changes. Old variant never auto-removed — run with ║
# ║ --teardown after verifying. ║
# ║ ║
# ║ ── USAGE ── ║
# ║ sudo bash arcane-migrate.sh # detect, choose, migrate ║
# ║ sudo bash arcane-migrate.sh --teardown # after verifying, remove old ║
# ║ ║
# ║ NOTE ON `curl ... | bash`: all interactive prompts read from /dev/tty, ║
# ║ so piping the script into bash works. Even so, downloading then running ║
# ║ (so you can review it first) is recommended for a script this powerful. ║
# ║ ║
# ║ Assumptions: Docker is installed, or installed on request if missing. ║
# ╚══════════════════════════════════════════════════════════════════════════╝
# ── ERR trap: never exit silently ─────────────────────────────────────────────
trap 'rc=$?; printf "\n\033[1;31mScript failed (exit %s) at line %s: %s\033[0m\n" \
"$rc" "$LINENO" "$BASH_COMMAND" >&2' ERR
ARCANE_DIR=/opt/arcane
HOST_ENV="$ARCANE_DIR/.env"
HOST_UNIT=/etc/systemd/system/arcane.service
BUILDS_DIR="$ARCANE_DIR/builds"
INSTALL_URL="https://getarcane.app/install.sh"
INSTALL_RAW="https://raw.githubusercontent.com/getarcaneapp/website/refs/heads/main/static/install.sh"
ARCANE_DATA_DIR=/var/lib/arcane
COMPOSE_CANDIDATES=(/opt/arcane-docker /opt/arcane-compose)
DEFAULT_COMPOSE_DIR=/opt/arcane-compose
say() { printf '\n\033[1;36m== %s\033[0m\n' "$*"; }
warn() { printf '\n\033[1;33m%s\033[0m\n' "$*"; }
die() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
getfrom() { grep -E "^$2=" "$1" 2>/dev/null | head -n1 | cut -d= -f2- || true ; }
# All interactive input comes from the controlling terminal, so the script works
# even when its body is piped in via `curl ... | bash` (stdin = the pipe).
ask() { local __var="$1" __prompt="$2"; printf '%s' "$__prompt"; read -r "$__var" </dev/tty; }
[ "$(id -u)" -eq 0 ] || die "Run as root (sudo)."
[ -e /dev/tty ] || die "No controlling terminal (/dev/tty). Run interactively, not in a non-tty pipeline."
MODE=migrate
if [ "${1:-}" = "--teardown" ]; then
MODE=teardown
elif [ -n "${1:-}" ]; then
die "Unknown arg '$1'. Use no args, or --teardown."
fi
# ── Compose-file / project-dir resolution ─────────────────────────────────────
find_compose_file() {
local d="$1" f
for f in compose.yaml compose.yml docker-compose.yml docker-compose.yaml; do
[ -f "$d/$f" ] && { echo "$d/$f"; return 0; }
done
return 1
}
detect_compose_dir() {
local wd="" d
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx arcane; then
wd=$(docker inspect arcane \
--format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' \
2>/dev/null || true)
if [ -n "$wd" ] && [ -d "$wd" ]; then echo "$wd"; return 0; fi
fi
for d in "${COMPOSE_CANDIDATES[@]}"; do
if find_compose_file "$d" >/dev/null; then echo "$d"; return 0; fi
done
return 1
}
# Echoes: "<host:yes|no> <container:yes|project|no> <compose_dir|->"
detect_variant() {
local has_host="no" has_container="no" cdir="-"
if [ -f "$HOST_UNIT" ] && [ -x "$ARCANE_DIR/arcane" ]; then has_host="yes"; fi
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx arcane; then
has_container="yes"; cdir=$(detect_compose_dir || echo "-")
elif cdir=$(detect_compose_dir 2>/dev/null); then
has_container="project"
else
cdir="-"
fi
echo "$has_host $has_container $cdir"
}
# Real host path mounted to /app/data, from the LIVE container (authoritative).
detect_data_mount_live() {
docker ps --format '{{.Names}}' 2>/dev/null | grep -qx arcane || return 1
local src
src=$(docker inspect arcane --format \
'{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Source}}{{end}}{{end}}' \
2>/dev/null || true)
[ -n "$src" ] && { echo "$src"; return 0; }
return 1
}
# Fallback: parse a compose file's volumes for the host side of a `:/app/data`
# bind mount, OR a pass-through mount of $ARCANE_DIR. Handles both the script's
# own format and hand-rolled compose files. Best-effort, no YAML parser.
detect_data_mount_from_file() {
local cf="$1" line host dest
[ -f "$cf" ] || return 1
# Look for "<host>:/app/data" (optionally with :ro/:rw and leading "- ").
line=$(grep -E '^[[:space:]]*-?[[:space:]]*[^[:space:]#]+:/app/data([:[:space:]]|$)' "$cf" 2>/dev/null | head -n1 || true)
if [ -n "$line" ]; then
host=$(echo "$line" | sed -E 's/^[[:space:]]*-?[[:space:]]*//; s#:/app/data.*##')
# Strip surrounding quotes if present.
host=${host%\"}; host=${host#\"}; host=${host%\'}; host=${host#\'}
if [ -n "$host" ] && [ "${host#/}" != "$host" ]; then echo "$host"; return 0; fi
fi
# Pass-through case: "<ARCANE_DIR>:<ARCANE_DIR>" → data lives at $ARCANE_DIR/data.
if grep -qE "^[[:space:]]*-?[[:space:]]*${ARCANE_DIR}:${ARCANE_DIR}([:[:space:]]|$)" "$cf" 2>/dev/null; then
echo "$ARCANE_DIR/data"; return 0
fi
return 1
}
# ── Shared helpers ─────────────────────────────────────────────────────────────
ensure_arcane_user() {
if ! getent group arcane >/dev/null; then
say "Creating system group 'arcane'"; groupadd --system arcane
fi
if ! id arcane >/dev/null 2>&1; then
say "Creating system user 'arcane'"
useradd --system --gid arcane --shell /bin/false \
--home-dir "$ARCANE_DATA_DIR" --create-home arcane
fi
usermod -aG docker arcane 2>/dev/null || true
mkdir -p "$ARCANE_DIR" "$ARCANE_DATA_DIR" "$ARCANE_DATA_DIR/data" "$BUILDS_DIR" /var/log/arcane
chown -R arcane:arcane "$ARCANE_DIR" "$ARCANE_DATA_DIR" /var/log/arcane 2>/dev/null || true
}
normalize_ownership() {
local uid gid p
uid=$(id -u arcane); gid=$(id -g arcane)
for p in "$@"; do
if [ -e "$p" ]; then
chown -R "$uid:$gid" "$p" 2>/dev/null \
|| warn "Could not fully chown $p to $uid:$gid — check manually."
fi
done
}
ensure_docker() {
if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then return 0; fi
warn "Docker (or the compose plugin) is missing."
local ans; ask ans 'Install Docker now via get.docker.com? [y/N] '
[[ "$ans" =~ ^[Yy]$ ]] || die "Docker is required. Install it and re-run."
say "Installing Docker via get.docker.com"
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker 2>/dev/null || true
docker compose version >/dev/null 2>&1 || die "Compose plugin still missing; install docker-compose-plugin."
}
snapshot() {
local bdir="$1"; shift
local tar="$bdir/arcane-data-projects.tar.gz" rels=() p
for p in "$@"; do [ -e "$p" ] && rels+=("${p#/}"); done
[ "${#rels[@]}" -gt 0 ] || die "Nothing to back up — refusing to continue."
tar -czf "$tar" -C / "${rels[@]}" 2>/dev/null \
|| die "Backup archive failed — aborting before any change."
echo "$tar"
}
# ══════════════════════════════════════════════════════════════════════════════
# TEARDOWN
# ══════════════════════════════════════════════════════════════════════════════
if [ "$MODE" = teardown ]; then
read -r HAS_HOST HAS_CONTAINER CDIR < <(detect_variant)
say "Teardown — host=$HAS_HOST container=$HAS_CONTAINER compose_dir=$CDIR"
if systemctl is-active --quiet arcane 2>/dev/null && [ "$HAS_CONTAINER" != yes ]; then
[ "$CDIR" != "-" ] && [ -d "$CDIR" ] || { warn "No compose project to remove."; exit 0; }
local_c=""; ask local_c "Host service is live. Remove the compose project at $CDIR? Type \"yes\": "
[ "$local_c" = yes ] || die "Aborted."
( cd "$CDIR" && docker compose down ) || warn "compose down had an issue."
rm -rf "$CDIR"
say "Removed compose project at $CDIR. Host service remains."; exit 0
elif [ "$HAS_CONTAINER" = yes ]; then
local_c=""; ask local_c 'Container is live. Remove the host-binary install (unit + binary)? Type "yes": '
[ "$local_c" = yes ] || die "Aborted."
systemctl stop arcane 2>/dev/null || true
systemctl disable arcane 2>/dev/null || true
rm -f "$HOST_UNIT"; systemctl daemon-reload
rm -f "$ARCANE_DIR/arcane" /usr/local/bin/arcane "$ARCANE_DIR"/arcane.bak.* 2>/dev/null || true
say "Removed host binary + unit. Kept data, projects, .env, and the arcane user."; exit 0
else
die "Couldn't determine a healthy live variant. Verify state manually before teardown."
fi
fi
# ══════════════════════════════════════════════════════════════════════════════
# MIGRATE — detect & choose direction
# ══════════════════════════════════════════════════════════════════════════════
read -r HAS_HOST HAS_CONTAINER CDIR < <(detect_variant)
say "Detected — host-binary: $HAS_HOST compose: $HAS_CONTAINER compose_dir: $CDIR"
# When the container is DOWN, detection of the compose dir is a directory-scan
# guess and may be ambiguous (e.g. a stale leftover project dir). Warn & confirm.
if [ "$HAS_CONTAINER" = project ]; then
warn "No running Arcane container — compose dir was guessed by scanning: $CDIR"
warn "If this is the wrong project, Ctrl-C and either remove the stale dir or"
warn "start the intended container first ('docker compose up -d') so detection"
warn "can use the authoritative compose label."
fi
DIRECTION=""
if [ "$HAS_HOST" = yes ] && [ "$HAS_CONTAINER" = no ]; then
DIRECTION="to-compose"
echo "Found a host-binary install. Will migrate: host binary → Docker Compose."
elif { [ "$HAS_CONTAINER" = yes ] || [ "$HAS_CONTAINER" = project ]; } && [ "$HAS_HOST" != yes ]; then
DIRECTION="to-host"
echo "Found a Docker Compose deployment ($CDIR). Will migrate: Docker Compose → host binary."
elif [ "$HAS_HOST" = yes ] && { [ "$HAS_CONTAINER" = yes ] || [ "$HAS_CONTAINER" = project ]; }; then
warn "Both variants appear present. You must choose."
d=""; ask d 'Migrate which way? [1] host→compose [2] compose→host : '
case "$d" in 1) DIRECTION=to-compose;; 2) DIRECTION=to-host;; *) die "Invalid choice.";; esac
else
die "No existing Arcane install detected (neither host binary nor compose project)."
fi
go=""; ask go "$(printf '\nProceed with migration "%s"? Type "yes": ' "$DIRECTION")"
[ "$go" = yes ] || die "Aborted."
# ── EARLY, IDEMPOTENT PREP (before any failure-prone extraction) ──────────────
say "Preparing: ensuring arcane user exists and normalizing ownership"
ensure_arcane_user
EARLY_DATA="$(detect_data_mount_live 2>/dev/null || true)"
if [ -z "$EARLY_DATA" ] && [ "$CDIR" != "-" ]; then
EARLY_CF="$(find_compose_file "$CDIR" 2>/dev/null || true)"
[ -n "$EARLY_CF" ] && EARLY_DATA="$(detect_data_mount_from_file "$EARLY_CF" 2>/dev/null || true)"
fi
[ -z "$EARLY_DATA" ] && EARLY_DATA="$ARCANE_DIR/data"
EARLY_PROJ=""
for ef in "$HOST_ENV" "/opt/arcane-docker/.env" "/opt/arcane-compose/.env"; do
if [ -f "$ef" ]; then EARLY_PROJ="$(getfrom "$ef" PROJECTS_DIRECTORY)"; [ -n "$EARLY_PROJ" ] && break; fi
done
[ -z "$EARLY_PROJ" ] && EARLY_PROJ="$ARCANE_DIR/projects"
normalize_ownership "$ARCANE_DIR" "$ARCANE_DATA_DIR" "$EARLY_DATA" "$EARLY_PROJ" "$BUILDS_DIR" /var/log/arcane
BACKUP_DIR="/root/arcane-migrate-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
# ──────────────────────────────────────────────────────────────────────────────
# HOST → COMPOSE
# ──────────────────────────────────────────────────────────────────────────────
if [ "$DIRECTION" = to-compose ]; then
TARGET_DIR="$DEFAULT_COMPOSE_DIR"
COMPOSE_FILE="$TARGET_DIR/docker-compose.yml"
COMPOSE_ENV="$TARGET_DIR/.env"
[ -f "$HOST_ENV" ] || die "$HOST_ENV not found — cannot recover secrets."
[ -d "$ARCANE_DIR/data" ] || die "$ARCANE_DIR/data not found."
ENCRYPTION_KEY=$(getfrom "$HOST_ENV" ENCRYPTION_KEY)
JWT_SECRET=$(getfrom "$HOST_ENV" JWT_SECRET)
APP_URL=$(getfrom "$HOST_ENV" APP_URL); APP_URL=${APP_URL:-http://localhost:3552}
PROJECTS_DIRECTORY=$(getfrom "$HOST_ENV" PROJECTS_DIRECTORY); PROJECTS_DIRECTORY=${PROJECTS_DIRECTORY:-$ARCANE_DIR/projects}
DISK_USAGE_PATH=$(getfrom "$HOST_ENV" DISK_USAGE_PATH); DISK_USAGE_PATH=${DISK_USAGE_PATH:-$PROJECTS_DIRECTORY}
[ -n "$ENCRYPTION_KEY" ] || die "ENCRYPTION_KEY missing in $HOST_ENV."
[ -n "$JWT_SECRET" ] || die "JWT_SECRET missing in $HOST_ENV."
ensure_docker
ARCANE_UID=$(id -u arcane); ARCANE_GID=$(id -g arcane)
DOCKER_GID=$(getent group docker | cut -d: -f3)
say "Normalizing data/projects/builds ownership to arcane:arcane"
normalize_ownership "$ARCANE_DIR/data" "$PROJECTS_DIRECTORY" "$BUILDS_DIR"
say "Backing up secrets, unit, and data/projects"
cp "$HOST_ENV" "$BACKUP_DIR/arcane.env"; chmod 600 "$BACKUP_DIR/arcane.env"
cp "$HOST_UNIT" "$BACKUP_DIR/" 2>/dev/null || true
systemctl stop arcane
TAR=$(snapshot "$BACKUP_DIR" "$ARCANE_DIR/data" "$PROJECTS_DIRECTORY")
echo "Snapshot: $TAR ($(du -h "$TAR" | cut -f1)). Copy $BACKUP_DIR off-box."
say "Writing compose project to $TARGET_DIR"
mkdir -p "$TARGET_DIR"
cat > "$COMPOSE_FILE" <<'YAML'
services:
arcane:
image: ghcr.io/getarcaneapp/arcane:latest
container_name: arcane
restart: unless-stopped
user: "${ARCANE_UID}:${ARCANE_GID}"
group_add:
- "${DOCKER_GID}"
ports:
- "3552:3552"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${ARCANE_DATA}:/app/data
- ${PROJECTS_DIRECTORY}:${PROJECTS_DIRECTORY}
- ${BUILDS_DIR}:/builds
environment:
- APP_URL=${APP_URL}
- PROJECTS_DIRECTORY=${PROJECTS_DIRECTORY}
- DISK_USAGE_PATH=${DISK_USAGE_PATH}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- JWT_SECRET=${JWT_SECRET}
YAML
cat > "$COMPOSE_ENV" <<EOF
ARCANE_UID=$ARCANE_UID
ARCANE_GID=$ARCANE_GID
DOCKER_GID=$DOCKER_GID
ARCANE_DATA=$ARCANE_DIR/data
PROJECTS_DIRECTORY=$PROJECTS_DIRECTORY
DISK_USAGE_PATH=$DISK_USAGE_PATH
BUILDS_DIR=$BUILDS_DIR
APP_URL=$APP_URL
ENCRYPTION_KEY=$ENCRYPTION_KEY
JWT_SECRET=$JWT_SECRET
EOF
chmod 600 "$COMPOSE_ENV"
( cd "$TARGET_DIR" && docker compose config >/dev/null ) || die "compose config invalid."
systemctl disable arcane
say "Starting container"
( cd "$TARGET_DIR" && docker compose up -d )
sleep 5
LOGS=$(cd "$TARGET_DIR" && docker compose logs --tail 200 arcane 2>&1 || true)
if echo "$LOGS" | grep -qiE 'decrypt|invalid key|encryption'; then
warn "Possible key mismatch:"; echo "$LOGS" | grep -iE 'decrypt|invalid key|encryption' || true
printf 'Roll back:\n cd %s && docker compose down\n systemctl enable --now arcane\n' "$TARGET_DIR"
printf 'Restore data if needed:\n tar -xzf %s -C /\n' "$TAR"
die "Stopping before teardown. Investigate."
fi
( cd "$TARGET_DIR" && docker compose ps --status running | grep -q arcane ) \
|| die "Container not running. Check: cd $TARGET_DIR && docker compose logs arcane"
cat <<EOF
────────────────────────────────────────────────────────────────────────────
Migrated host → compose. Verify the UI at $APP_URL (projects, users, settings).
Then remove the old host install: sudo bash $0 --teardown
Backups: $BACKUP_DIR
Rollback (pre-teardown): cd $TARGET_DIR && docker compose down ; systemctl enable --now arcane
────────────────────────────────────────────────────────────────────────────
EOF
exit 0
fi
# ──────────────────────────────────────────────────────────────────────────────
# COMPOSE → HOST
# ──────────────────────────────────────────────────────────────────────────────
if [ "$DIRECTION" = to-host ]; then
[ "$CDIR" != "-" ] && [ -d "$CDIR" ] || die "Could not resolve the live compose dir."
TARGET_DIR="$CDIR"
COMPOSE_FILE=$(find_compose_file "$TARGET_DIR") || die "No compose file in $TARGET_DIR."
COMPOSE_ENV="$TARGET_DIR/.env"
say "Using compose project: $TARGET_DIR (file: $COMPOSE_FILE)"
# Resolve the real data path: live container mount first, then compose-file parse.
ARCANE_DATA="$(detect_data_mount_live 2>/dev/null || true)"
[ -z "$ARCANE_DATA" ] && ARCANE_DATA="$(detect_data_mount_from_file "$COMPOSE_FILE" 2>/dev/null || true)"
[ -f "$COMPOSE_ENV" ] || die "No $COMPOSE_ENV — cannot recover secrets/paths."
ENCRYPTION_KEY=$(getfrom "$COMPOSE_ENV" ENCRYPTION_KEY)
JWT_SECRET=$(getfrom "$COMPOSE_ENV" JWT_SECRET)
APP_URL=$(getfrom "$COMPOSE_ENV" APP_URL); APP_URL=${APP_URL:-http://localhost:3552}
PROJECTS_DIRECTORY=$(getfrom "$COMPOSE_ENV" PROJECTS_DIRECTORY); PROJECTS_DIRECTORY=${PROJECTS_DIRECTORY:-$ARCANE_DIR/projects}
DISK_USAGE_PATH=$(getfrom "$COMPOSE_ENV" DISK_USAGE_PATH); DISK_USAGE_PATH=${DISK_USAGE_PATH:-$PROJECTS_DIRECTORY}
[ -z "$ARCANE_DATA" ] && ARCANE_DATA="$(getfrom "$COMPOSE_ENV" ARCANE_DATA)"
[ -z "$ARCANE_DATA" ] && ARCANE_DATA="$ARCANE_DIR/data"
[ -n "$ENCRYPTION_KEY" ] || die "ENCRYPTION_KEY missing in $COMPOSE_ENV."
[ -n "$JWT_SECRET" ] || die "JWT_SECRET missing in $COMPOSE_ENV."
[ -d "$ARCANE_DATA" ] || die "Data dir $ARCANE_DATA not found."
ensure_docker
say "Backing up compose project, secrets, and data/projects"
cp "$COMPOSE_ENV" "$BACKUP_DIR/compose.env"; chmod 600 "$BACKUP_DIR/compose.env"
cp "$COMPOSE_FILE" "$BACKUP_DIR/$(basename "$COMPOSE_FILE")" 2>/dev/null || true
cp "$HOST_ENV" "$BACKUP_DIR/host-arcane.env.pre-existing" 2>/dev/null || true
( cd "$TARGET_DIR" && docker compose down ) || warn "compose down had an issue."
TAR=$(snapshot "$BACKUP_DIR" "$ARCANE_DATA" "$PROJECTS_DIRECTORY")
echo "Snapshot: $TAR ($(du -h "$TAR" | cut -f1)). Copy $BACKUP_DIR off-box."
say "Ensuring $HOST_ENV exists so the installer preserves secrets"
if [ ! -f "$HOST_ENV" ]; then
cat > "$HOST_ENV" <<EOF
ENVIRONMENT=production
PORT=3552
APP_URL=$APP_URL
DATABASE_URL=file:data/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate
ENCRYPTION_KEY=$ENCRYPTION_KEY
JWT_SECRET=$JWT_SECRET
DOCKER_HOST=unix:///var/run/docker.sock
PROJECTS_DIRECTORY=$PROJECTS_DIRECTORY
DISK_USAGE_PATH=$DISK_USAGE_PATH
LOG_LEVEL=info
EOF
chown arcane:arcane "$HOST_ENV"; chmod 600 "$HOST_ENV"
else
warn "$HOST_ENV already exists; leaving it untouched (installer will preserve it)."
fi
if [ "$ARCANE_DATA" != "$ARCANE_DIR/data" ]; then
warn "Container data path ($ARCANE_DATA) != host default ($ARCANE_DIR/data)."
warn "The host binary expects data at $ARCANE_DIR/data (WorkingDirectory-relative)."
warn "If these differ, after install: stop service, move data into $ARCANE_DIR/data, restart."
fi
say "Downloading installer for review (saved; not auto-piped)"
INSTALLER="$BACKUP_DIR/install.sh"
curl -fsSL "$INSTALL_URL" -o "$INSTALLER" 2>/dev/null \
|| curl -fsSL "$INSTALL_RAW" -o "$INSTALLER" \
|| die "Could not download installer. Fetch manually to $INSTALLER and re-run."
echo "Saved to $INSTALLER. Review with: less $INSTALLER"
r=""; ask r 'Run the installer now? Type "yes": '
[ "$r" = yes ] || die "Aborted. Roll back: cd $TARGET_DIR && docker compose up -d"
say "Running installer"
bash "$INSTALLER"
say "Verifying secrets in $HOST_ENV"
if [ "$(getfrom "$HOST_ENV" ENCRYPTION_KEY)" != "$ENCRYPTION_KEY" ] || \
[ "$(getfrom "$HOST_ENV" JWT_SECRET)" != "$JWT_SECRET" ]; then
warn "Installer changed the secrets — restoring originals."
systemctl stop arcane 2>/dev/null || true
tmp="$HOST_ENV.tmp"
grep -vE '^(ENCRYPTION_KEY|JWT_SECRET)=' "$HOST_ENV" > "$tmp"
printf 'ENCRYPTION_KEY=%s\nJWT_SECRET=%s\n' "$ENCRYPTION_KEY" "$JWT_SECRET" >> "$tmp"
mv "$tmp" "$HOST_ENV"
chown arcane:arcane "$HOST_ENV"; chmod 600 "$HOST_ENV"
systemctl restart arcane
fi
say "Normalizing data/projects/builds ownership to arcane:arcane"
normalize_ownership "$ARCANE_DIR/data" "$ARCANE_DATA" "$PROJECTS_DIRECTORY" "$BUILDS_DIR"
sleep 5
systemctl is-active --quiet arcane || die "arcane.service failed. Check: journalctl -u arcane -n 100"
if [ -f /var/log/arcane/arcane-error.log ] && \
grep -qiE 'decrypt|invalid key|encryption' /var/log/arcane/arcane-error.log; then
warn "Possible key mismatch in /var/log/arcane/arcane-error.log:"
grep -iE 'decrypt|invalid key|encryption' /var/log/arcane/arcane-error.log | tail -n 20 || true
printf 'Roll back:\n systemctl stop arcane && systemctl disable arcane\n cd %s && docker compose up -d\n' "$TARGET_DIR"
printf 'Restore data if needed:\n tar -xzf %s -C /\n' "$TAR"
die "Stopping before teardown. Investigate."
fi
cat <<EOF
────────────────────────────────────────────────────────────────────────────
Migrated compose → host. Verify the UI at $APP_URL (projects, users, settings).
Then remove the unused compose project: sudo bash $0 --teardown
Backups: $BACKUP_DIR
Rollback (pre-teardown): systemctl stop arcane && systemctl disable arcane ; cd $TARGET_DIR && docker compose up -d
────────────────────────────────────────────────────────────────────────────
EOF
exit 0
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment