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.
291 lines
11 KiB
Bash
Executable File
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'
|