#!/usr/bin/env bash # Restore an UmbraAudio full backup and rebuild a portable runtime. set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" ARCHIVE="" TARGET="${UMBRA_RESTORE_TARGET:-$HOME/UmbraAudio-restore}" GLOBAL_TARGET="${UMBRA_GLOBAL_STATE_DIR:-$HOME/.umbra}" PYTHON_OVERRIDE="${PYTHON_BIN:-}" SKIP_DEPS=0 SKIP_SUBMODULE_UPDATE=0 SKIP_GLOBAL_STATE=0 INSTALL_SYSTEM_DEPS=0 DOWNLOAD_MODELS=0 WITH_TTS_SERVER=0 KEEP_ARCHIVED_ENV=0 RUN_TESTS=0 FORCE=0 APPLY_PORTABILITY_PATCH=1 OLD_ROOT="" usage() { cat <<'EOF' Usage: Starthere.sh [options] Restore the newest UmbraAudio backup in this directory (or use --archive), verify its SHA-256 manifest, restore the project and global voice state, and rebuild a portable Python environment. Options: --target DIR Restore destination (default: $HOME/UmbraAudio-restore) --global-state-dir DIR Restored global registry/library (default: $HOME/.umbra) --archive FILE Payload archive to restore --python BIN Explicit Python 3.11+ interpreter --skip-deps Do not rebuild Python dependencies --skip-submodule Do not initialize/update git submodules --skip-global-state Do not restore ~/.umbra voice registry/library --install-system-deps Try to install git/ffmpeg/Python with brew or apt-get --download-models Download missing Qwen model weights --with-tts-server Also install tts_server/requirements.txt --keep-archived-env Keep archived .venv/.python instead of rebuilding --no-portability-patch Do not rewrite machine-local paths in restored copy --run-tests Run pytest after setup --force Permit non-empty project/global targets -h, --help Show this help The archive may contain .env, manuscripts, models, and audio. Review it before sharing the restored directory. Network database configuration is intentionally not changed by this script. EOF } while (($#)); do case "$1" in --target) TARGET="${2:?--target requires a directory}"; shift 2 ;; --global-state-dir) GLOBAL_TARGET="${2:?--global-state-dir requires a directory}"; shift 2 ;; --archive) ARCHIVE="${2:?--archive requires a file}"; shift 2 ;; --python) PYTHON_OVERRIDE="${2:?--python requires an interpreter}"; shift 2 ;; --skip-deps) SKIP_DEPS=1; shift ;; --skip-submodule) SKIP_SUBMODULE_UPDATE=1; shift ;; --skip-global-state) SKIP_GLOBAL_STATE=1; shift ;; --install-system-deps) INSTALL_SYSTEM_DEPS=1; shift ;; --download-models) DOWNLOAD_MODELS=1; shift ;; --with-tts-server) WITH_TTS_SERVER=1; shift ;; --keep-archived-env) KEEP_ARCHIVED_ENV=1; shift ;; --no-portability-patch) APPLY_PORTABILITY_PATCH=0; shift ;; --run-tests) RUN_TESTS=1; shift ;; --force) FORCE=1; shift ;; -h|--help) usage; exit 0 ;; *) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;; esac done if [[ -z "$ARCHIVE" ]]; then ARCHIVE="$(find "$SCRIPT_DIR" -maxdepth 1 -type f -name 'UmbraAudio-*.tar' -print | sort | tail -n 1)" fi [[ -n "$ARCHIVE" && -f "$ARCHIVE" && ! -L "$ARCHIVE" ]] || { printf 'No regular payload archive found. Use --archive or place UmbraAudio-*.tar beside this script.\n' >&2 exit 1 } [[ -f "$SCRIPT_DIR/SHA256SUMS" ]] || { printf 'Missing SHA256SUMS beside Starthere.sh; refusing an unverified restore.\n' >&2 exit 1 } [[ -f "$SCRIPT_DIR/restore_support.py" ]] || { printf 'Missing restore_support.py beside Starthere.sh; use a backup made with the updated tooling.\n' >&2 exit 1 } ARCHIVE_DIR="$(cd -- "$(dirname -- "$ARCHIVE")" && pwd -P)" [[ "$ARCHIVE_DIR" == "$SCRIPT_DIR" ]] || { printf 'Archive must be located beside the selected backup metadata: %s\n' "$ARCHIVE_DIR" >&2 exit 1 } ARCHIVE_BASENAME="$(basename -- "$ARCHIVE")" if ! awk -v name="$ARCHIVE_BASENAME" '$2 == name { found = 1 } END { exit(found ? 0 : 1) }' "$SCRIPT_DIR/SHA256SUMS"; then printf 'Selected archive is not listed in SHA256SUMS: %s\n' "$ARCHIVE_BASENAME" >&2 exit 1 fi if [[ -f "$SCRIPT_DIR/BACKUP-INFO.txt" ]]; then info_archive="$(sed -n 's/^archive_name=//p' "$SCRIPT_DIR/BACKUP-INFO.txt" | head -n 1)" if [[ -n "$info_archive" && "$info_archive" != "$ARCHIVE_BASENAME" ]]; then printf 'Selected archive does not match BACKUP-INFO.txt: %s vs %s\n' "$ARCHIVE_BASENAME" "$info_archive" >&2 exit 1 fi fi for tool in tar; do command -v "$tool" >/dev/null 2>&1 || { printf 'Required tool is missing: %s\n' "$tool" >&2; exit 1; } done printf 'Backup directory: %s\n' "$SCRIPT_DIR" printf 'Payload archive: %s\n' "$ARCHIVE" printf 'Restore target: %s\n' "$TARGET" printf 'Global state: %s\n' "$GLOBAL_TARGET" if command -v shasum >/dev/null 2>&1; then (cd "$SCRIPT_DIR" && shasum -a 256 -c SHA256SUMS) elif command -v sha256sum >/dev/null 2>&1; then (cd "$SCRIPT_DIR" && sha256sum -c SHA256SUMS) else printf 'Neither shasum nor sha256sum is available.\n' >&2 exit 1 fi [[ ! -L "$TARGET" ]] || { printf 'Refusing symlink restore target: %s\n' "$TARGET" >&2; exit 1; } if [[ -e "$TARGET" && -n "$(find "$TARGET" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" && "$FORCE" -ne 1 ]]; then printf 'Restore target is not empty: %s (use --force only deliberately)\n' "$TARGET" >&2 exit 1 fi TARGET_PARENT_INPUT="$(dirname -- "$TARGET")" mkdir -p "$TARGET_PARENT_INPUT" TARGET_PARENT="$(cd -- "$TARGET_PARENT_INPUT" && pwd -P)" TARGET_NAME="$(basename -- "$TARGET")" TARGET_ABS="$TARGET_PARENT/$TARGET_NAME" GLOBAL_PARENT_INPUT="$(dirname -- "$GLOBAL_TARGET")" mkdir -p "$GLOBAL_PARENT_INPUT" GLOBAL_TARGET_ABS="$(cd -- "$GLOBAL_PARENT_INPUT" && pwd -P)/$(basename -- "$GLOBAL_TARGET")" TEMP_TARGET="$(mktemp -d "$TARGET_PARENT/.${TARGET_NAME}.restore.XXXXXX")" OLD_TARGET="" cleanup() { if [[ -d "$TEMP_TARGET" ]]; then rm -rf "$TEMP_TARGET" fi } trap cleanup EXIT printf 'Extracting into an isolated staging directory...\n' tar -xpf "$ARCHIVE" -C "$TEMP_TARGET" --strip-components 1 ROOT="$TEMP_TARGET" [[ -f "$ROOT/pyproject.toml" && -d "$ROOT/umbraaudio" ]] || { printf 'Archive did not contain the expected UmbraAudio root.\n' >&2 exit 1 } if [[ "$KEEP_ARCHIVED_ENV" -ne 1 ]]; then printf 'Discarding archived platform-specific virtualenv/toolchain...\n' rm -rf "$ROOT/.venv" "$ROOT/.python" fi install_system_dependencies() { [[ "$INSTALL_SYSTEM_DEPS" -eq 1 ]] || return 0 if command -v brew >/dev/null 2>&1; then brew install git ffmpeg brew install python@3.12 || brew install python elif command -v apt-get >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then sudo apt-get update sudo apt-get install -y git ffmpeg python3 python3-venv else printf 'Install git, ffmpeg, Python 3.11+, and venv support manually.\n' >&2 exit 1 fi } install_system_dependencies python_supports_project() { "$1" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' \ >/dev/null 2>&1 } PYTHON_BIN="" if [[ -n "$PYTHON_OVERRIDE" ]]; then python_supports_project "$PYTHON_OVERRIDE" || { printf 'Requested Python is unavailable or older than 3.11: %s\n' "$PYTHON_OVERRIDE" >&2 exit 1 } PYTHON_BIN="$PYTHON_OVERRIDE" else for candidate in python3.12 python3.11 python3.13 python3; do if command -v "$candidate" >/dev/null 2>&1 && python_supports_project "$candidate"; then PYTHON_BIN="$candidate" break fi done fi [[ -n "$PYTHON_BIN" ]] || { printf 'Python 3.11+ is required. Install it or pass --python.\n' >&2 exit 1 } printf 'Using Python: %s (%s)\n' "$PYTHON_BIN" "$("$PYTHON_BIN" --version 2>&1)" if [[ "$SKIP_SUBMODULE_UPDATE" -ne 1 && -f "$ROOT/.gitmodules" ]]; then printf 'Initializing git submodules...\n' git -C "$ROOT" submodule update --init --recursive fi if [[ "$SKIP_DEPS" -eq 1 ]]; then printf 'Skipping dependency installation as requested.\n' else UV_BIN="$(command -v uv || true)" BOOTSTRAP_ENV="" if [[ -z "$UV_BIN" ]]; then printf 'Bootstrapping uv 0.11.7 in an isolated environment...\n' BOOTSTRAP_ENV="$ROOT/.uv-bootstrap" "$PYTHON_BIN" -m venv "$BOOTSTRAP_ENV" "$BOOTSTRAP_ENV/bin/python" -m pip install --upgrade pip "$BOOTSTRAP_ENV/bin/python" -m pip install 'uv==0.11.7' UV_BIN="$BOOTSTRAP_ENV/bin/uv" fi printf 'Rebuilding the locked environment with uv...\n' UV_SYNC_ARGS=(sync --locked --python "$PYTHON_BIN") if [[ "$RUN_TESTS" -eq 1 ]]; then UV_SYNC_ARGS+=(--extra dev) fi (cd "$ROOT" && "$UV_BIN" "${UV_SYNC_ARGS[@]}") if [[ "$WITH_TTS_SERVER" -eq 1 && -f "$ROOT/tts_server/requirements.txt" ]]; then (cd "$ROOT" && "$UV_BIN" pip install --python .venv/bin/python -r tts_server/requirements.txt) fi if [[ -n "$BOOTSTRAP_ENV" ]]; then rm -rf "$BOOTSTRAP_ENV" fi fi if [[ "$DOWNLOAD_MODELS" -eq 1 && ! -f "$ROOT/models/Qwen3-TTS-12Hz-1.7B-Base/model.safetensors" ]]; then [[ -f "$ROOT/scripts/download_models.sh" ]] || { printf 'Model download script is missing.\n' >&2 exit 1 } (cd "$ROOT" && bash scripts/download_models.sh) fi if [[ -f "$SCRIPT_DIR/BACKUP-INFO.txt" ]]; then OLD_ROOT="$(sed -n 's/^source_path=//p' "$SCRIPT_DIR/BACKUP-INFO.txt" | head -n 1)" fi if [[ "$APPLY_PORTABILITY_PATCH" -eq 1 ]]; then "$PYTHON_BIN" "$SCRIPT_DIR/restore_support.py" patch-paths \ --root "$ROOT" --old-root "$OLD_ROOT" --new-root "$TARGET_ABS" \ --global-state-dir "$GLOBAL_TARGET_ABS" fi if [[ -f "$ROOT/.env" ]]; then chmod 600 "$ROOT/.env" fi printf 'Validating restored project tree and SQLite databases...\n' "$PYTHON_BIN" "$SCRIPT_DIR/restore_support.py" validate-tree \ --root "$ROOT" --old-root "$OLD_ROOT" # Commit the project tree only after extraction, dependency setup, path migration, # and validation all succeed. An existing target is retained as .old on --force. if [[ -e "$TARGET" ]]; then OLD_TARGET="$TARGET_PARENT/.${TARGET_NAME}.old.$(date +%Y%m%d%H%M%S)" mv "$TARGET" "$OLD_TARGET" fi mv "$TEMP_TARGET" "$TARGET" trap - EXIT ROOT="$TARGET_ABS" if [[ "$SKIP_GLOBAL_STATE" -ne 1 && -d "$SCRIPT_DIR/global-state" ]]; then printf 'Restoring global voice registry/library...\n' RESTORE_ARGS=(restore-global --backup-dir "$SCRIPT_DIR" --target "$GLOBAL_TARGET_ABS") [[ "$FORCE" -eq 1 ]] && RESTORE_ARGS+=(--force) if [[ -n "$OLD_ROOT" ]]; then RESTORE_ARGS+=(--old-project-root "$OLD_ROOT" --new-project-root "$ROOT") fi "$PYTHON_BIN" "$SCRIPT_DIR/restore_support.py" "${RESTORE_ARGS[@]}" fi if [[ "$RUN_TESTS" -eq 1 ]]; then [[ -x "$ROOT/.venv/bin/pytest" ]] || { printf 'pytest is not installed in the rebuilt environment.\n' >&2 exit 1 } (cd "$ROOT" && .venv/bin/pytest -q) fi printf '\nRestore complete.\n' printf 'Project root: %s\n' "$ROOT" [[ -z "$OLD_TARGET" ]] || printf 'Previous target retained at: %s\n' "$OLD_TARGET" printf 'Review .env, global voice paths, and RESTORED-SERVICE-COMMANDS.md before starting services.\n' printf 'Network database setup is intentionally not performed by Starthere.sh.\n'