Files
UmbraAudio/backup/Starthere.sh
B.D. Murphy 0c9bea72e0 Add enterprise/infra tooling and repoint Qwen3-TTS submodule to internal fork
Two groups of changes, committed together as the follow-up to the initial push:

1. .gitmodules — submodule Qwen3-TTS now points at the internal Gitea fork
   (ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git) instead of upstream GitHub,
   so a clone actually gets the UmbraAudio branding/integration commit
   012e452 rather than vanilla upstream.

2. New infra/ tooling for the cross-machine work in spec 21: agent_bridge,
   remote_credential_broker (Keychain-backed, secrets read at runtime and
   generated via read_secret/random_secret — no hardcoded credentials), the
   enterprise bootstrap scripts, and the oldbox SSH/auth prompts. Plus
   backup/ restore scripts, two specs (18 UmbraTrans, 19 enterprise control
   plane), umbraaudio/voices/lmstudio_gate.py, and in-flight edits to the
   discovery/attribution/profiling modules, config, chapter_builder, emotion,
   pronunciation, qwen_tts and routes.

Also adds *.db to .gitignore: SQLite project databases are runtime state and no
.db file was tracked before.

Verified before staging: .env's only change is UMBRA_TTS__QWEN_TTS_HELPER_URL and
the committed copy has zero secret-looking values; the broker ships only
config.example.json.
2026-10-06 06:57:41 -07:00

291 lines
11 KiB
Bash
Executable File

#!/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'