diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 9be73ed..9886d89 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -15,6 +15,16 @@ bash /tmp/vl.sh The installer detects a running instance, updates in place, preserves configuration and models, and pulls new dependencies (including neural VAD when available). +On an existing install (a virtual environment plus `~/.config/vocalinux/config.json`), the installer reports the saved configuration and defaults to **Upgrade (keep current settings)**: the configured engine stays selected, `config.json` is never rewritten, the virtual environment is reused, downloaded models are kept, and pywhispercpp is rebuilt only when this release pins a new version or GPU support is missing. Pick the interactive or automatic modes instead if you want to reconfigure the install. + +For a scripted, non-interactive upgrade: + +```bash +bash /tmp/vl.sh --upgrade +``` + +`--upgrade` on a machine with no existing install falls back to a normal automatic installation. + ### Installed from source ```bash diff --git a/install.d/interactive.sh b/install.d/interactive.sh index 0afb11a..36dfb3f 100644 --- a/install.d/interactive.sh +++ b/install.d/interactive.sh @@ -34,6 +34,31 @@ EOF echo "Optional Remote API is off unless you choose it; then audio goes only to the server you configure." echo "" + # An existing install is an upgrade by default: keep the saved configuration + # and update in place instead of redoing first-time setup. Choosing the full + # guided setup continues below and can re-point the engine deliberately. + if [[ "${EXISTING_INSTALL:-no}" == "yes" ]]; then + echo "An existing Vocalinux installation was detected:" + echo " Configuration: $CONFIG_DIR/config.json (kept unchanged by an upgrade)" + echo " Virtual env: $VENV_DIR (reused by an upgrade)" + echo "" + echo " 1. Upgrade (keep current settings) - update Vocalinux only" + echo " 2. Full guided setup - choose engine, backend, and model options" + echo "" + + read_prompt -p "Choose [1-2] (default: 1): " UPGRADE_CHOICE + UPGRADE_CHOICE=${UPGRADE_CHOICE:-1} + + if [[ "$UPGRADE_CHOICE" != "2" ]]; then + UPGRADE_MODE="yes" + INTERACTIVE_MODE="no" + NON_INTERACTIVE="yes" + echo "" + return 0 + fi + echo "" + fi + # Step 1: Detect and display system info print_header "Step 1: Your System" echo "Detected: $DISTRO_NAME $DISTRO_VERSION" diff --git a/install.sh b/install.sh index e5b5006..0831b8c 100755 --- a/install.sh +++ b/install.sh @@ -280,6 +280,7 @@ SKIP_SYSTEM_DEPS="no" NON_INTERACTIVE="no" INTERACTIVE_MODE="yes" # Default to interactive mode AUTO_MODE="no" +UPGRADE_MODE="no" REBUILD_WHISPERCPP="ask" # Pinned pywhispercpp release (sdist, built from source). Keep in sync with # uv.lock and the requirements/ exports. @@ -364,6 +365,13 @@ while [[ $# -gt 0 ]]; do NON_INTERACTIVE="yes" shift ;; + --upgrade) + UPGRADE_MODE="yes" + AUTO_MODE="yes" + INTERACTIVE_MODE="no" + NON_INTERACTIVE="yes" + shift + ;; --help) echo "Vocalinux Installer" echo "" @@ -372,11 +380,15 @@ while [[ $# -gt 0 ]]; do echo "Installation Modes:" echo " (no flags) Interactive mode - guided setup with recommendations" echo " --auto Automatic mode - install with defaults (whisper.cpp)" + echo " --upgrade Upgrade mode - update an existing install, keep its settings" echo " --auto --engine=whisper Auto mode with specific engine" echo "" echo "Options:" echo " --interactive, -i Force interactive mode (default)" echo " --auto Non-interactive automatic installation" + echo " --upgrade Non-interactive upgrade of an existing install: reuse the" + echo " venv, update the configured engine's dependencies, never" + echo " modify config.json" echo " --engine=NAME Speech engine: whisper_cpp (default), whisper, vosk, parakeet, faster_whisper, remote_api" echo " --dev Install in development mode with all dev dependencies" echo " --test Run tests after installation" @@ -391,6 +403,7 @@ while [[ $# -gt 0 ]]; do echo "" echo "Examples:" echo " $0 # Interactive mode (recommended)" + echo " $0 --upgrade # Update an existing install, keep settings" echo " $0 --auto # Auto-install with whisper.cpp" echo " $0 --auto --engine=vosk # Auto-install VOSK only" echo " $0 --auto --engine=faster_whisper # Auto-install faster-whisper" @@ -1286,28 +1299,132 @@ case "$DISTRO_FAMILY" in ;; esac -# Handle installation mode selection -if [[ "$INTERACTIVE_MODE" == "ask" ]]; then - # Running via curl pipe but we have a terminal - ask user preference +# Define XDG directories early: existing-install detection and the mode menu +# need CONFIG_DIR before system dependency installation asks anything. +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vocalinux" +DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux" +DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps" + +# An existing install is a usable venv plus a saved configuration: re-running +# the installer on one is an upgrade, not a first-time setup. A leftover +# config.json without a venv (from an aborted install) is not an install. +detect_existing_install() { + [ -f "$VENV_DIR/bin/activate" ] && [ -f "$CONFIG_DIR/config.json" ] +} + +# Running via curl pipe but with a terminal: ask which mode to run. An existing +# install gets an upgrade entry as the default so re-running the installer is a +# non-event instead of a guided redo of the first-time setup. +choose_installation_mode() { + [[ "$INTERACTIVE_MODE" == "ask" ]] || return 0 + echo "" echo "Installation Mode:" - echo " 1. Interactive (recommended) - guided setup with recommendations" - echo " 2. Automatic - quick install with defaults (whisper.cpp)" - echo "" - read_prompt -p "Choose mode [1-2] (default: 1): " MODE_CHOICE - MODE_CHOICE=${MODE_CHOICE:-1} + if [[ "${EXISTING_INSTALL:-no}" == "yes" ]]; then + echo " 1. Upgrade (keep current settings) - update Vocalinux, keep your setup" + echo " 2. Interactive - guided setup with recommendations" + echo " 3. Automatic - quick install with defaults (whisper.cpp)" + echo "" + read_prompt -p "Choose mode [1-3] (default: 1): " MODE_CHOICE + MODE_CHOICE=${MODE_CHOICE:-1} - if [[ "$MODE_CHOICE" == "2" ]]; then - AUTO_MODE="yes" - INTERACTIVE_MODE="no" - NON_INTERACTIVE="yes" + case "$MODE_CHOICE" in + 2) + INTERACTIVE_MODE="yes" + NON_INTERACTIVE="no" + ;; + 3) + AUTO_MODE="yes" + INTERACTIVE_MODE="no" + NON_INTERACTIVE="yes" + ;; + *) + UPGRADE_MODE="yes" + AUTO_MODE="yes" + INTERACTIVE_MODE="no" + NON_INTERACTIVE="yes" + ;; + esac else - INTERACTIVE_MODE="yes" - NON_INTERACTIVE="no" + echo " 1. Interactive (recommended) - guided setup with recommendations" + echo " 2. Automatic - quick install with defaults (whisper.cpp)" + echo "" + read_prompt -p "Choose mode [1-2] (default: 1): " MODE_CHOICE + MODE_CHOICE=${MODE_CHOICE:-1} + + if [[ "$MODE_CHOICE" == "2" ]]; then + AUTO_MODE="yes" + INTERACTIVE_MODE="no" + NON_INTERACTIVE="yes" + else + INTERACTIVE_MODE="yes" + NON_INTERACTIVE="no" + fi fi echo "" +} + +# --upgrade on a machine with nothing installed cannot keep settings that do +# not exist: warn and proceed as a normal automatic install instead of failing. +resolve_upgrade_request() { + if [[ "${UPGRADE_MODE:-no}" == "yes" && "${EXISTING_INSTALL:-no}" != "yes" ]]; then + print_warning "--upgrade was given, but no existing install was found" + print_warning "(expected a venv in $VENV_DIR plus $CONFIG_DIR/config.json)." + print_info "Continuing with a normal automatic installation." + UPGRADE_MODE="no" + fi +} + +# Print speech_recognition.engine from an existing config.json. Empty output +# when the file or the key is missing or unreadable. +read_configured_engine() { + local config_file="$1" python="" + [ -f "$config_file" ] || return 1 + if [ -x "$VENV_DIR/bin/python" ]; then + python="$VENV_DIR/bin/python" + elif command_exists python3; then + python="python3" + else + return 1 + fi + "$python" - "$config_file" <<'PY' 2>/dev/null +import json +import sys + +try: + with open(sys.argv[1]) as handle: + print(json.load(handle).get("speech_recognition", {}).get("engine", "")) +except Exception: + pass +PY +} + +# Upgrade mode installs dependencies for the engine the existing configuration +# already selected instead of forcing the automatic default. +apply_upgrade_engine() { + [[ "${UPGRADE_MODE:-no}" == "yes" && -z "${SELECTED_ENGINE:-}" ]] || return 0 + + SELECTED_ENGINE=$(read_configured_engine "$CONFIG_DIR/config.json" || true) + if [ -n "$SELECTED_ENGINE" ]; then + print_info "Upgrade mode: keeping configured engine '$SELECTED_ENGINE'." + else + print_warning "No engine readable in $CONFIG_DIR/config.json; using the default (whisper.cpp)." + SELECTED_ENGINE="whisper_cpp" + fi +} + +EXISTING_INSTALL="no" +if detect_existing_install; then + EXISTING_INSTALL="yes" + print_success "Existing configuration found at $CONFIG_DIR/config.json — it will be kept." fi +resolve_upgrade_request + +# Handle installation mode selection +choose_installation_mode + # Run interactive installation if selected if [[ "$INTERACTIVE_MODE" == "yes" ]]; then # Check if we have a TTY (required for interactive mode) @@ -1322,6 +1439,8 @@ if [[ "$INTERACTIVE_MODE" == "yes" ]]; then run_interactive_install fi +apply_upgrade_engine + # Set default engine for auto/non-interactive mode if [[ "$NON_INTERACTIVE" == "yes" ]] && [[ -z "$SELECTED_ENGINE" ]]; then SELECTED_ENGINE="whisper_cpp" @@ -1338,12 +1457,6 @@ else install_system_dependencies fi -# Define XDG directories -CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vocalinux" -DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux" -DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" -ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps" - # Install text input tools based on session type install_text_input_tools @@ -1828,6 +1941,12 @@ should_rebuild_whispercpp() { esac if [[ "$NON_INTERACTIVE" == "yes" ]]; then + # An upgrade should pick up the pinned pywhispercpp: a version drift is + # the one case where reusing the existing build is not an update. + if [ -n "$INSTALLED_VERSION" ] && [ "$INSTALLED_VERSION" != "$PYWHISPERCPP_VERSION" ]; then + print_info "pywhispercpp $INSTALLED_VERSION installed; this release pins $PYWHISPERCPP_VERSION. Rebuilding..." + return 0 + fi if [[ "$HAS_NVIDIA_GPU" == "yes" || "$HAS_VULKAN" == "yes" ]] && ! is_pywhispercpp_gpu_capable; then print_info "Non-interactive mode: GPU detected but pywhispercpp lacks GPU support. Rebuilding..." return 0 @@ -1914,6 +2033,16 @@ with open(path, "w") as handle: PY } +# config.json is user data. Upgrade mode never modifies it: the write becomes +# a logged no-op so the configured engine survives the update byte-for-byte. +write_configured_engine() { + if [[ "${UPGRADE_MODE:-no}" == "yes" ]]; then + print_info "Upgrade mode: $1 is kept unchanged." + return 0 + fi + set_configured_engine "$1" "$2" +} + install_whispercpp_with_gpu_support() { local PIP_LOG_FILE="$1" @@ -2083,12 +2212,13 @@ install_whispercpp_with_gpu_support() { local FALLBACK_VOSK_CONFIG="$CONFIG_DIR/config.json" if [ -f "$FALLBACK_VOSK_CONFIG" ]; then - if set_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"; then - print_success "Switched $FALLBACK_VOSK_CONFIG to the vosk engine." + if write_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"; then + [[ "${UPGRADE_MODE:-no}" != "yes" ]] && + print_success "Switched $FALLBACK_VOSK_CONFIG to the vosk engine." else print_warning "Could not rewrite $FALLBACK_VOSK_CONFIG to vosk." fi - else + elif [[ "${UPGRADE_MODE:-no}" != "yes" ]]; then mkdir -p "$CONFIG_DIR" cat > "$FALLBACK_VOSK_CONFIG" << 'FALLBACK_VOSK_CONFIG' { @@ -2464,7 +2594,7 @@ VOSK_CONFIG } PARAKEET_CONFIG fi - set_configured_engine "$CONFIG_DIR/config.json" parakeet || + write_configured_engine "$CONFIG_DIR/config.json" parakeet || print_warning "Could not point $CONFIG_DIR/config.json at the parakeet engine." ;; @@ -2554,7 +2684,7 @@ REMOTE_CONFIG } FASTER_WHISPER_CONFIG fi - set_configured_engine "$CONFIG_DIR/config.json" faster_whisper || + write_configured_engine "$CONFIG_DIR/config.json" faster_whisper || print_warning "Could not point $CONFIG_DIR/config.json at the faster_whisper engine." else print_error "Failed to install the faster-whisper engine" @@ -2775,6 +2905,14 @@ PY print_warning "$CONFIG_FILE selects the $CONFIGURED_ENGINE engine, but it is not importable in $VENV_DIR." + if [[ "${UPGRADE_MODE:-no}" == "yes" ]]; then + # Upgrade mode never rewrites config.json: report instead of repairing. + print_error "Vocalinux cannot start with this configuration and the engine is not installed." + print_error "Install the engine: $VENV_DIR/bin/pip install $(engine_pip_name "$CONFIGURED_ENGINE")" + print_error "or edit $CONFIG_FILE and set speech_recognition.engine to an installed engine." + return 1 + fi + # Leaving it means the app dies at startup with ModuleNotFoundError. Prefer # SELECTED_ENGINE (set by the whisper.cpp -> vosk fallback), then any extra # the venv can actually import. diff --git a/tests/test_installer_engine_config.py b/tests/test_installer_engine_config.py index 7dee9f0..5ff2a67 100644 --- a/tests/test_installer_engine_config.py +++ b/tests/test_installer_engine_config.py @@ -221,7 +221,8 @@ def test_selected_engine_tracks_a_parakeet_config_repair(tmp_path: Path) -> None def test_vosk_fallback_rewrites_an_existing_config_file() -> None: """The fallback used to write vosk only when config.json was missing.""" text = INSTALL_SH.read_text(encoding="utf-8") - assert 'set_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"' in text + # write_configured_engine wraps the rewrite with the upgrade-mode no-op. + assert 'write_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"' in text assert '[ ! -f "$FALLBACK_VOSK_CONFIG" ]' not in text diff --git a/tests/test_installer_upgrade.py b/tests/test_installer_upgrade.py new file mode 100644 index 0000000..296a36c --- /dev/null +++ b/tests/test_installer_upgrade.py @@ -0,0 +1,464 @@ +"""Behavioral tests for the installer's existing-install upgrade path. + +Re-running install.sh used to replay first-time setup with no way to keep the +existing configuration (issue #1009): the mode menu offered only interactive or +automatic, automatic mode forced whisper.cpp regardless of the configured +engine, and nothing said config.json would be kept. The upgrade path detects a +venv + config.json pair, offers "Upgrade (keep current settings)" as the +default, installs dependencies for the configured engine, and never rewrites +the file. +""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +INSTALL_SH = REPO_ROOT / "install.sh" +INTERACTIVE_SH = REPO_ROOT / "install.d" / "interactive.sh" + +PRELUDE = """ +set -uo pipefail + +print_info() { echo "INFO: $*"; } +print_warning() { echo "WARNING: $*"; } +print_error() { echo "ERROR: $*"; } +print_success() { echo "SUCCESS: $*"; } +command_exists() { command -v "$1" >/dev/null 2>&1; } +""" + +#: read_prompt writes its answer into the variable named by its last argument, +#: but some calls ("Press Enter to continue...") take no variable name at all: +#: only assign when the last arg is a legal identifier. Answers come from +#: PROMPT_QUEUE so each prompt can be answered differently. +STUBS = """ +read_prompt() { + local last="${@: -1}" ans="" + if [ ${#PROMPT_QUEUE[@]} -gt 0 ]; then + ans="${PROMPT_QUEUE[0]}" + PROMPT_QUEUE=("${PROMPT_QUEUE[@]:1}") + fi + if [[ "$last" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + printf -v "$last" '%s' "$ans" + fi +} +PROMPT_QUEUE=() +""" + + +def _run(script: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", "-c", PRELUDE + script], + capture_output=True, + text=True, + timeout=30, + env={**os.environ, **(env or {})}, + ) + + +def _source(*functions: str) -> str: + """Source the named function definitions out of install.sh.""" + return "\n".join( + f"source <(sed -n '/^{name}() {{$/,/^}}$/p' {shlex.quote(str(INSTALL_SH))})" + for name in functions + ) + + +def _make_install(tmp_path: Path, engine: str = "vosk") -> tuple[Path, Path]: + """Seed the pair detect_existing_install looks for: venv + config.json.""" + venv = tmp_path / "venv" + (venv / "bin").mkdir(parents=True) + (venv / "bin" / "activate").touch() + # set_configured_engine invokes "$VENV_DIR/bin/python"; point it at a real + # interpreter so the non-upgrade write path can run. + (venv / "bin" / "python").symlink_to(shutil.which("python3")) + config_dir = tmp_path / "config" + config_dir.mkdir(parents=True) + (config_dir / "config.json").write_text(json.dumps({"speech_recognition": {"engine": engine}})) + return venv, config_dir + + +@pytest.mark.parametrize( + ("with_venv", "with_config", "expected"), + [(True, True, "0"), (True, False, "1"), (False, True, "1"), (False, False, "1")], +) +def test_existing_install_requires_venv_and_config( + tmp_path: Path, with_venv: bool, with_config: bool, expected: str +) -> None: + venv = tmp_path / "venv" + config_dir = tmp_path / "config" + if with_venv: + (venv / "bin").mkdir(parents=True) + (venv / "bin" / "activate").touch() + if with_config: + config_dir.mkdir(parents=True) + (config_dir / "config.json").write_text("{}") + + result = _run(f""" +VENV_DIR={shlex.quote(str(venv))} +CONFIG_DIR={shlex.quote(str(config_dir))} +{_source("detect_existing_install")} +detect_existing_install +echo "EXIT=$?" +""") + + assert f"EXIT={expected}" in result.stdout + + +def test_upgrade_flag_parses_to_noninteractive_upgrade() -> None: + """--upgrade selects upgrade semantics without any prompts.""" + text = INSTALL_SH.read_text(encoding="utf-8") + start = text.index("while [[ $# -gt 0 ]]; do") + end = text.index("\ndone", start) + len("\ndone") + arg_parse = text[start:end] + result = _run(f""" +AUTO_MODE="no"; INTERACTIVE_MODE="yes"; NON_INTERACTIVE="no"; UPGRADE_MODE="no" +INSTALLER_ARGS=() +set -- --upgrade +{arg_parse} +echo "U=$UPGRADE_MODE A=$AUTO_MODE I=$INTERACTIVE_MODE N=$NON_INTERACTIVE" +""") + + assert "U=yes A=yes I=no N=yes" in result.stdout + + +def test_help_documents_the_upgrade_flag() -> None: + result = subprocess.run( + ["bash", str(INSTALL_SH), "--help"], + capture_output=True, + text=True, + timeout=15, + ) + + assert result.returncode == 0 + assert "--upgrade" in result.stdout + + +@pytest.mark.parametrize( + ("answer", "expected"), + [ + ("", "U=yes A=yes I=no N=yes"), # default: Upgrade + ("1", "U=yes A=yes I=no N=yes"), + ("2", "U=no A=no I=yes N=no"), # Interactive + ("3", "U=no A=yes I=no N=yes"), # Automatic + ], +) +def test_mode_menu_defaults_to_upgrade_on_existing_install(answer: str, expected: str) -> None: + result = _run(STUBS + f""" +INTERACTIVE_MODE="ask"; AUTO_MODE="no"; NON_INTERACTIVE="no"; UPGRADE_MODE="no" +EXISTING_INSTALL="yes" +{_source("choose_installation_mode")} +PROMPT_QUEUE=("{answer}") +choose_installation_mode +echo "U=$UPGRADE_MODE A=$AUTO_MODE I=$INTERACTIVE_MODE N=$NON_INTERACTIVE" +""") + + assert "Upgrade (keep current settings)" in result.stdout + assert expected in result.stdout + + +@pytest.mark.parametrize( + ("answer", "expected"), + [("", "U=no A=no I=yes N=no"), ("2", "U=no A=yes I=no N=yes")], +) +def test_mode_menu_is_unchanged_without_an_install(answer: str, expected: str) -> None: + result = _run(STUBS + f""" +INTERACTIVE_MODE="ask"; AUTO_MODE="no"; NON_INTERACTIVE="no"; UPGRADE_MODE="no" +EXISTING_INSTALL="no" +{_source("choose_installation_mode")} +PROMPT_QUEUE=("{answer}") +choose_installation_mode +echo "U=$UPGRADE_MODE A=$AUTO_MODE I=$INTERACTIVE_MODE N=$NON_INTERACTIVE" +""") + + assert "Upgrade (keep current settings)" not in result.stdout + assert expected in result.stdout + + +def test_upgrade_flag_without_an_install_falls_back_to_auto(tmp_path: Path) -> None: + result = _run(f""" +UPGRADE_MODE="yes" +EXISTING_INSTALL="no" +VENV_DIR={shlex.quote(str(tmp_path / 'venv'))} +CONFIG_DIR={shlex.quote(str(tmp_path / 'config'))} +{_source("resolve_upgrade_request")} +resolve_upgrade_request +echo "U=$UPGRADE_MODE" +""") + + assert "no existing install was found" in result.stdout + assert "U=no" in result.stdout + + +@pytest.mark.parametrize("engine", ["vosk", "parakeet", "faster_whisper", "remote_api"]) +def test_upgrade_keeps_the_engine_from_config(tmp_path: Path, engine: str) -> None: + """The configured engine wins over the automatic default (whisper.cpp).""" + venv, config_dir = _make_install(tmp_path, engine=engine) + result = _run(f""" +UPGRADE_MODE="yes"; SELECTED_ENGINE="" +VENV_DIR={shlex.quote(str(venv))} +CONFIG_DIR={shlex.quote(str(config_dir))} +{_source("read_configured_engine", "apply_upgrade_engine")} +apply_upgrade_engine +echo "SELECTED=$SELECTED_ENGINE" +""") + + assert f"SELECTED={engine}" in result.stdout + assert f"keeping configured engine '{engine}'" in result.stdout + + +def test_upgrade_falls_back_to_default_when_engine_is_unreadable(tmp_path: Path) -> None: + venv, config_dir = _make_install(tmp_path) + (config_dir / "config.json").write_text("{not json") + result = _run(f""" +UPGRADE_MODE="yes"; SELECTED_ENGINE="" +VENV_DIR={shlex.quote(str(venv))} +CONFIG_DIR={shlex.quote(str(config_dir))} +{_source("read_configured_engine", "apply_upgrade_engine")} +apply_upgrade_engine +echo "SELECTED=$SELECTED_ENGINE" +""") + + assert "SELECTED=whisper_cpp" in result.stdout + + +def test_explicit_engine_flag_wins_over_the_configured_one(tmp_path: Path) -> None: + venv, config_dir = _make_install(tmp_path, engine="vosk") + result = _run(f""" +UPGRADE_MODE="yes"; SELECTED_ENGINE="whisper" +VENV_DIR={shlex.quote(str(venv))} +CONFIG_DIR={shlex.quote(str(config_dir))} +{_source("read_configured_engine", "apply_upgrade_engine")} +apply_upgrade_engine +echo "SELECTED=$SELECTED_ENGINE" +""") + + assert "SELECTED=whisper" in result.stdout + + +def test_upgrade_never_rewrites_config_json(tmp_path: Path) -> None: + """write_configured_engine must leave the file byte-for-byte identical.""" + venv, config_dir = _make_install(tmp_path, engine="whisper") + config = config_dir / "config.json" + original = config.read_bytes() + result = _run(f""" +UPGRADE_MODE="yes" +VENV_DIR={shlex.quote(str(venv))} +{_source("set_configured_engine", "write_configured_engine")} +write_configured_engine {shlex.quote(str(config))} vosk +""") + + assert result.returncode == 0, result.stderr + assert "kept unchanged" in result.stdout + assert config.read_bytes() == original + + +def test_write_configured_engine_still_writes_outside_upgrade(tmp_path: Path) -> None: + venv, config_dir = _make_install(tmp_path, engine="whisper") + config = config_dir / "config.json" + result = _run(f""" +UPGRADE_MODE="no" +VENV_DIR={shlex.quote(str(venv))} +{_source("set_configured_engine", "write_configured_engine")} +write_configured_engine {shlex.quote(str(config))} vosk +""") + + assert result.returncode == 0, result.stderr + assert json.loads(config.read_text())["speech_recognition"]["engine"] == "vosk" + + +def _verify(config_dir: Path, venv_dir: Path, upgrade: bool) -> subprocess.CompletedProcess[str]: + script = f""" +CONFIG_DIR={shlex.quote(str(config_dir))} +VENV_DIR={shlex.quote(str(venv_dir))} +UPGRADE_MODE="{'yes' if upgrade else 'no'}" +SELECTED_ENGINE="" +{_source( + "engine_import_module", + "engine_pip_name", + "venv_can_import", + "set_configured_engine", + "write_configured_engine", + "verify_configured_engine", + )} +verify_configured_engine +echo "EXIT=$?" +""" + return _run(script) + + +def _stub_venv_python(venv_dir: Path, importable: set[str]) -> None: + """Real python3 whose chosen engine imports succeed; the rest fail.""" + modules = {"vosk", "whisper", "pywhispercpp.model", "sherpa_onnx", "faster_whisper"} + python = venv_dir / "bin" / "python" + python.parent.mkdir(parents=True, exist_ok=True) + branches = "".join( + 'if [[ "$*" == *"import {module}"* ]]; then\n' + " if [ {code} -eq 0 ]; then exit 0; fi\n" + " echo \"ModuleNotFoundError: No module named '{module}'\" >&2\n" + " exit 1\n" + "fi\n".format(module=module, code=0 if module in importable else 1) + for module in sorted(modules, key=len, reverse=True) + ) + python.write_text("#!/bin/bash\n" + branches + 'exec python3 "$@"\n') + python.chmod(0o755) + + +def test_upgrade_reports_an_unimportable_engine_instead_of_repairing( + tmp_path: Path, +) -> None: + """The repair path rewrites config.json; upgrade mode must not take it.""" + venv = tmp_path / "venv" + _stub_venv_python(venv, importable=set()) + config_dir = tmp_path / "config" + config_dir.mkdir() + config = config_dir / "config.json" + config.write_text(json.dumps({"speech_recognition": {"engine": "vosk"}})) + + result = _verify(config_dir, venv, upgrade=True) + + assert "EXIT=1" in result.stdout + assert "pip install vosk" in result.stdout + assert json.loads(config.read_text())["speech_recognition"]["engine"] == "vosk" + + +def test_upgrade_leaves_a_working_configured_engine_alone(tmp_path: Path) -> None: + venv = tmp_path / "venv" + _stub_venv_python(venv, importable={"vosk"}) + config_dir = tmp_path / "config" + config_dir.mkdir() + config = config_dir / "config.json" + config.write_text(json.dumps({"speech_recognition": {"engine": "vosk"}})) + + result = _verify(config_dir, venv, upgrade=True) + + assert "EXIT=0" in result.stdout + assert "WARNING" not in result.stdout + assert json.loads(config.read_text())["speech_recognition"]["engine"] == "vosk" + + +def test_guided_setup_offers_upgrade_first_on_existing_install() -> None: + """run_interactive_install returns before Step 1 when upgrade is chosen.""" + result = _run(STUBS + f""" +EXISTING_INSTALL="yes" +UPGRADE_MODE="no"; INTERACTIVE_MODE="yes"; NON_INTERACTIVE="no" +CONFIG_DIR=/tmp/unused-config; VENV_DIR=/tmp/unused-venv +clear_screen() {{ :; }} +print_header() {{ echo "HEADER: $*"; }} +get_engine_recommendation() {{ echo "REACHED_GUIDED_SETUP"; }} +source {shlex.quote(str(INTERACTIVE_SH))} +PROMPT_QUEUE=("") +run_interactive_install +echo "U=$UPGRADE_MODE N=$NON_INTERACTIVE I=$INTERACTIVE_MODE" +""") + + assert "existing Vocalinux installation was detected" in result.stdout + assert "REACHED_GUIDED_SETUP" not in result.stdout + assert "U=yes N=yes I=no" in result.stdout + + +def test_guided_setup_still_runs_when_the_user_declines_upgrade() -> None: + result = _run(STUBS + f""" +EXISTING_INSTALL="yes" +UPGRADE_MODE="no"; INTERACTIVE_MODE="yes"; NON_INTERACTIVE="no" +SELECTED_ENGINE="" +CONFIG_DIR=/tmp/unused-config; VENV_DIR=/tmp/unused-venv +DISTRO_NAME="Test"; DISTRO_VERSION="1" +clear_screen() {{ :; }} +print_header() {{ echo "HEADER: $*"; }} +get_engine_recommendation() {{ printf 'whisper_cpp:star:reason'; }} +detect_whispercpp_backends() {{ printf 'cpu:reason:false:no:no:false:false:na:na'; }} +source {shlex.quote(str(INTERACTIVE_SH))} +# "2" declines the upgrade offer; every later prompt gets the default. +PROMPT_QUEUE=("2") +run_interactive_install +echo "U=$UPGRADE_MODE SELECTED=$SELECTED_ENGINE" +""") + + assert "Step 2: Choose Speech Recognition Engine" in result.stdout + assert "U=no" in result.stdout + assert "SELECTED=whisper_cpp" in result.stdout + + +SCRIPT = shutil.which("script") +needs_pty = pytest.mark.skipif( + SCRIPT is None, + reason="script(1) is needed to give a piped installer a controlling terminal", +) + + +@needs_pty +@pytest.mark.timeout(90) +def test_piped_installer_defaults_to_upgrade_on_existing_install(tmp_path: Path) -> None: + """End to end: `cat install.sh | bash` on an existing install picks upgrade. + + The venv and config.json are seeded under a scratch HOME; --venv-dir points + the installer at them and --tag skips the release lookup. A bare Enter at + the mode menu must land on "Upgrade (keep current settings)" and then name + the configured engine, not the automatic default. + """ + venv = tmp_path / "venv" + (venv / "bin").mkdir(parents=True) + (venv / "bin" / "activate").touch() + config_dir = tmp_path / ".config" / "vocalinux" + config_dir.mkdir(parents=True) + (config_dir / "config.json").write_text(json.dumps({"speech_recognition": {"engine": "vosk"}})) + + transcript = tmp_path / "upgrade.typescript" + feeder = tmp_path / "feed-input.sh" + feeder.write_text( + "#!/bin/sh\n" + # Queued until the "Choose mode" prompt reads /dev/tty; a bare Enter + # exercises the default, which must be the upgrade entry. + "sleep 1; printf '\\n'\n" + # Interrupt only once the upgrade marker is on screen: a blind delay + # loses to a slow runner and Ctrl+C's a healthy installer mid-prompt. + "for _ in $(seq 1 60); do\n" + f" grep -q 'keeping configured engine' {shlex.quote(str(transcript))} 2>/dev/null && break\n" + " sleep 1\n" + "done\n" + "printf '\\003'\n", + encoding="utf-8", + ) + feeder.chmod(0o755) + + env = os.environ.copy() + env["HOME"] = str(tmp_path) + # conftest points XDG_CONFIG_HOME at an isolation dir; the installer reads + # config.json from $XDG_CONFIG_HOME/vocalinux, not straight from $HOME. + env["XDG_CONFIG_HOME"] = str(tmp_path / ".config") + env["XDG_STATE_HOME"] = str(tmp_path / ".local" / "state") + env["TMPDIR"] = str(tmp_path) + + inner = ( + f"cat {shlex.quote(str(INSTALL_SH))} | bash -s --" + f" --tag=v0.0.0-test --venv-dir={shlex.quote(str(venv))}" + # The marker sits just ahead of the host-mutating steps; skipping them + # means the timed Ctrl+C can never lose a race against sudo or the + # package manager on a runner that happens to have either. + " --skip-system-deps --skip-models" + ) + subprocess.run( + f"{shlex.quote(str(feeder))} | {shlex.quote(str(SCRIPT))} -ec " + + shlex.quote(f"bash -c {shlex.quote(inner)}") + + f" {shlex.quote(str(transcript))}", + shell=True, + stdin=subprocess.DEVNULL, + capture_output=True, + timeout=75, + check=False, + cwd=REPO_ROOT, + env=env, + ) + output = transcript.read_text(encoding="utf-8", errors="replace") + assert "Existing configuration found at" in output + assert "it will be kept" in output + assert "Upgrade (keep current settings)" in output + assert "keeping configured engine 'vosk'" in output