diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml new file mode 100644 index 0000000..28c578e --- /dev/null +++ b/.github/workflows/install-smoke.yml @@ -0,0 +1,54 @@ +name: Installer smoke + +# The terminal installer's acceptance evidence (#479): "works on macOS and Linux". +# +# This workflow RUNS scripts/install.sh for real -- public repo, real network, real +# release wheels -- on both platforms it claims, then asserts the installed venv's own +# `keel versions` exits 0. The installer already runs that verification internally +# before printing success; the explicit second run is the CI-visible assertion, so a +# red leg names the installer and not just a step inside it. +# +# Triggers: `workflow_dispatch` for on-demand runs, and `pull_request` filtered to the +# script's path ONLY -- a paths filter any broader would spend two runners per PR on a +# script that did not change, while no filter at all would spend them on every push to +# every file. The full suite (which pins the script's properties as text, in +# tests/test_install_script.py) runs in CI on every PR regardless. +on: + workflow_dispatch: + pull_request: + paths: ["scripts/install.sh"] + +concurrency: + group: installer-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + # shellcheck is preinstalled on ubuntu runners and NOT on macOS runners; rather + # than add a setup action, the Linux leg carries the lint for both -- the script + # is the same bytes on every platform it runs on. + - name: Shellcheck + if: runner.os == 'Linux' + run: shellcheck scripts/install.sh + + # A throwaway HOME keeps the runner's real home clean and proves the script needs + # nothing outside it: the install lands wholly under $HOME/.keel. + - name: Run the installer for real, into an isolated HOME + run: | + export HOME="$RUNNER_TEMP/keel-home" + mkdir -p "$HOME" + bash scripts/install.sh + + - name: Assert the installed keel verifies + run: | + export HOME="$RUNNER_TEMP/keel-home" + cd "$HOME/.keel" + ./.venv/bin/keel versions diff --git a/README.md b/README.md index 4c2c47f..718800f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,11 @@ disclaimers below. ## Try it in five minutes +No terminal? The macOS/Windows app is on the [releases page](https://github.com/CodeGateSoftware/keel/releases) +(with [`docs/desktop-install.md`](docs/desktop-install.md) for the first launch). Terminal +person, no cloning? The installer does this from the published wheels instead: +`curl -fsSL https://raw.githubusercontent.com/CodeGateSoftware/keel/main/scripts/install.sh | bash`. + Everything here is read-only and paper-side: no funds, and nothing in this path can place an order. Verified on a clean clone. You need [uv](https://docs.astral.sh/uv/) and a **free, read-only Coinbase Developer Platform (CDP) API key** — candle history is fetched through the diff --git a/docs/desktop-install.md b/docs/desktop-install.md index a645b05..7c80409 100644 --- a/docs/desktop-install.md +++ b/docs/desktop-install.md @@ -5,18 +5,23 @@ broken. This page explains exactly what happened, why, and what to do about it. ## Would you rather not deal with this at all? -There is a path with no warning on any platform, because nothing is downloaded as an application: -the install-from-source route in the README's **"Try it in five minutes"**. `pip` and `uv` fetch -the published wheels directly, and no operating system objects to that. +There is a path with no warning on any platform, because nothing is downloaded as an +application: the terminal installer. It fetches the published wheels directly into a per-user +venv at `~/.keel`, and no operating system objects to that. ``` -pip install --find-links . ./keel_trader--py3-none-any.whl -keel versions +curl -fsSL https://raw.githubusercontent.com/CodeGateSoftware/keel/main/scripts/install.sh | bash ``` -It needs a terminal and Python 3.11 or later — which is exactly the friction the desktop app -exists to remove, so this is not the recommendation for everyone. But if you already have both, it -is the shorter road and the rest of this page does not apply to you. +You do not have to trust that line blind: the script is +[`scripts/install.sh`](../scripts/install.sh) in this repository, written to be read — every step +prints what it is about to do before it does it, it runs no privileged commands, and it verifies +itself with `keel versions` before claiming success. It needs a terminal and Python 3.11 or +later. To build from a source checkout instead, see the README's **"Try it in five minutes"**. + +Both are exactly the friction the desktop app exists to remove, so this is not the recommendation +for everyone. But if you already have a terminal, either is the shorter road and the rest of this +page does not apply to you. ## The short version diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..59fe7cc --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# +# keel's terminal installer -- the no-warning path (issue #479). +# +# WHAT THIS IS: installs keel on macOS or Linux from the latest GitHub release's Python +# wheels, into a per-user venv at ~/.keel/.venv. Nothing is downloaded as an +# *application*, so no OS trust dialog ("developer cannot be verified", SmartScreen) is +# ever involved -- that path stays the .dmg/.zip walkthrough in docs/desktop-install.md, +# and code signing stays #438 until a certificate is affordable. +# +# HOW TO READ IT: every step prints what it is about to do, and why, BEFORE it runs. +# `set -euo pipefail` (below) stops the script on the first failing command or pipe, so +# a broken step can never be followed by a success message. The script runs no +# privileged commands, writes nothing outside the invoking user's home, and fetches +# exactly the five production wheels by allowlisted name -- the release also carries +# dev-only and stub venue wheels that a deployment must not have, and selection here is +# by exact name, never `*.whl`, for the same reason as `keel update`'s selector +# (keel/commands/update.py, PRODUCTION_WHEEL_PREFIXES). +# +# UPDATES compose with this installer (issue #439, option A: per-release download, no +# self-update): re-running this script moves ~/.keel to the latest release, and +# `keel update` -- run from ~/.keel -- also handles the venv deployment this creates. +# An existing config.yaml or database under ~/.keel is never touched. +set -euo pipefail + +[ -n "${BASH_VERSION:-}" ] || { printf 'installer: FAIL: run me under bash\n' >&2; exit 1; } + +# -- constants: what, where, and the one allowlist ------------------------------------------------ + +#: The repository we install from, and its public unauthenticated endpoints. No auth and +#: no tokens: a bootstrap script must never grow a credential. +REPO="CodeGateSoftware/keel" +LATEST_API="https://api.github.com/repos/${REPO}/releases/latest" + +#: The five PRODUCTION wheel name prefixes -- the same allowlist, in the same order, as +#: keel's own updater (keel/commands/update.py, PRODUCTION_WHEEL_PREFIXES). A release +#: also ships other venue wheels a deployment must not have; selection below is by exact +#: `--` name, so nothing outside this line can ride along. +WHEEL_PREFIXES="keel_core keel_broker_api keel_broker_coinbase keel_broker_alpaca keel_trader" + +#: Where the deployment lives: one per-user folder holding the venv, config.yaml and -- +#: once keel runs -- the database and .env. A folder you can look inside is keel's +#: deployment model. The venv is named `.venv` deliberately: that is the layout `keel +#: update` recognises, so the updater can serve what this script built. +KEEL_DIR="${HOME}/.keel" +VENV_DIR="${KEEL_DIR}/.venv" + +say() { printf '==> %s\n' "$*"; } +die() { printf 'installer: FAIL: %s\n' "$*" >&2; exit 1; } + +# -- step 1/7: the platform ---------------------------------------------------------------------- + +say "step 1/7: checking the platform" +case "$(uname -s)" in + Darwin | Linux) say " ok: $(uname -s)" ;; + *) + die "unsupported platform '$(uname -s)': this installer is for macOS (Darwin) and Linux. + Windows users: download the release .zip and follow docs/desktop-install.md." + ;; +esac +command -v curl >/dev/null 2>&1 || die "curl is required but was not found on PATH" + +# -- step 2/7: Python, with the floor stated ------------------------------------------------------ + +# keel requires Python 3.11+ (tests/test_python_floor.py); the check is the interpreter's +# own version_info, not a parsed string, and the failure names the floor and what to do. +say "step 2/7: finding Python >= 3.11" +PY="" +for candidate in python3 python3.14 python3.13 python3.12 python3.11; do + command -v "$candidate" >/dev/null 2>&1 || continue + if "$candidate" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then + PY="$(command -v "$candidate")" + break + fi +done +[ -n "$PY" ] || die "no Python >= 3.11 found on PATH -- keel requires 3.11 or later. Check + 'python3 --version', install a newer Python, and re-run this script." +say " ok: ${PY} ($("$PY" -c 'import platform; print(platform.python_version())'))" + +# -- step 3/7: the latest release ---------------------------------------------------------------- + +say "step 3/7: resolving the latest release from the GitHub API" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/keel-install.XXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT +RELEASE_JSON="${TMP_DIR}/release.json" +printf ' GET %s\n' "$LATEST_API" +curl -fsSL "$LATEST_API" -o "$RELEASE_JSON" || die "could not reach the GitHub releases API" +MANIFEST="${TMP_DIR}/manifest.tsv" + +# Parsing uses the Python we just found: macOS does not ship jq, and we already require +# Python, so we add no dependency. This mirrors keel's own selector -- exact +# `--...whl` names, one asset per prefix, and a loud refusal naming +# every prefix the release does not carry (a release missing a wheel cannot be deployed). +TAG="$("$PY" -c ' +import json, sys +try: + release = json.loads(open(sys.argv[1]).read()) +except ValueError as exc: + sys.stderr.write("release payload is not JSON: %s\n" % exc) + sys.exit(1) +tag = release.get("tag_name") or "" +version = tag[1:] if tag.startswith("v") else tag +if not version: + sys.stderr.write("release payload has no tag_name\n") + sys.exit(1) +assets = {a["name"]: a["browser_download_url"] for a in release.get("assets", [])} +lines, missing = [], [] +for prefix in sys.argv[3].split(): + matches = sorted( + name for name in assets + if name.startswith("%s-%s-" % (prefix, version)) and name.endswith(".whl") + ) + if len(matches) == 1: + lines.append(matches[0] + " " + assets[matches[0]]) + else: + missing.append(prefix) +if missing: + sys.stderr.write("release %s does not carry every production wheel (missing: %s)\n" + % (tag, ", ".join(missing))) + sys.exit(1) +open(sys.argv[2], "w").write("\n".join(lines) + "\n") +print(tag) +' "$RELEASE_JSON" "$MANIFEST" "$WHEEL_PREFIXES")" || die "the latest release is not installable (see above)" +say " latest release: ${TAG}" + +# -- step 4/7: download, printing every URL ------------------------------------------------------- + +say "step 4/7: downloading the five production wheels and config.yaml to ${TMP_DIR}" +WHEEL_PATHS=() +while read -r name url; do + printf ' GET %s\n' "$url" + curl -fsSL "$url" -o "${TMP_DIR}/${name}" || die "could not download ${name}" + WHEEL_PATHS+=("${TMP_DIR}/${name}") +done < "$MANIFEST" +CONFIG_URL="https://github.com/${REPO}/releases/download/${TAG}/config.yaml" +printf ' GET %s\n' "$CONFIG_URL" +curl -fsSL "$CONFIG_URL" -o "${TMP_DIR}/config.yaml" || die "could not download config.yaml" + +# -- step 5/7: checksums, stated honestly --------------------------------------------------------- + +# The release publishes SHA256SUMS files for the DESKTOP artifacts only; there are no +# published checksums for the wheels, and this script does not pretend to verify what was +# never published. What it does instead is print each wheel's sha256 as computed LOCALLY, +# so the run leaves an auditable record of exactly what was installed. +say "step 5/7: recording the sha256 of each wheel as downloaded (no published wheel checksums exist to compare against)" +for path in "${WHEEL_PATHS[@]}"; do + printf ' %s %s\n' \ + "$("$PY" -c 'import hashlib, sys +print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$path")" \ + "$(basename "$path")" +done + +# -- step 6/7: venv, install by exact path, config guard ------------------------------------------ + +say "step 6/7: installing into ${KEEL_DIR} (per-user; no elevated commands)" +mkdir -p "$KEEL_DIR" +# Discovery asks uv to ANSWER ITS OWN VERSION, not merely to be on PATH: a broken or +# half-installed uv shim must not abort the install -- a uv that cannot run is honestly +# treated as absent, and the pip path below needs no uv. +HAVE_UV=0 +if command -v uv >/dev/null 2>&1 && uv --version >/dev/null 2>&1; then HAVE_UV=1; fi +if [ ! -x "${VENV_DIR}/bin/python" ]; then + if [ "$HAVE_UV" -eq 1 ]; then + say " uv found: creating the venv with 'uv venv --python '" + uv venv --python "$PY" "$VENV_DIR" || die "uv venv failed" + else + say " uv not found: creating the venv with '${PY} -m venv'" + "$PY" -m venv "$VENV_DIR" || die "python -m venv failed (on Debian/Ubuntu the python3-venv + package is the usual missing piece -- install it and re-run)" + fi +else + say " ${VENV_DIR} already exists: reusing it (re-running this script upgrades in place)" +fi +VENV_PY="${VENV_DIR}/bin/python" + +# Installation is BY EXACT WHEEL PATH, the same form `keel update` uses -- never by +# package name, never from an index for the keel distributions themselves. The +# `--find-links` directory lets the wheels' pinned keel dependencies resolve from the +# downloaded release rather than a public index; third-party dependencies (click, numpy, +# ...) come from PyPI as usual. There is an unrelated "keel" project on PyPI; installing +# keel by name from an index is the one mistake this section exists to make impossible. +if [ "$HAVE_UV" -eq 1 ]; then + say " running: uv pip install --python --find-links " + uv pip install --python "$VENV_PY" --find-links "$TMP_DIR" "${WHEEL_PATHS[@]}" \ + || die "uv pip install failed" +else + # A venv without pip gets one honest bootstrap attempt via ensurepip, then a clear + # failure -- never a silent skip or an install pretending to have run. + if ! "$VENV_PY" -m pip --version >/dev/null 2>&1; then + say " pip is not bootstrapped in the new venv: running 'ensurepip --upgrade'" + "$VENV_PY" -m ensurepip --upgrade || die "ensurepip failed: this venv has no pip and it + could not be bootstrapped" + fi + say " running: ${VENV_PY} -m pip install --no-input --find-links " + "$VENV_PY" -m pip install --no-input --find-links "$TMP_DIR" "${WHEEL_PATHS[@]}" \ + || die "pip install failed" +fi + +# config.yaml is the user's once installed: NEVER overwritten -- it may hold their edits. +# The release's copy lands beside the venv only when no config is there yet, and an +# existing deployment's database is likewise never touched: upgrading code must not mean +# touching data. +if [ -e "${KEEL_DIR}/config.yaml" ]; then + say " ${KEEL_DIR}/config.yaml already exists: keeping it (not overwritten)" +else + say " installing the release's default config.yaml beside the venv (the paper profile)" + cp "${TMP_DIR}/config.yaml" "${KEEL_DIR}/config.yaml" +fi +for db in "${KEEL_DIR}"/keel*.db; do + if [ -e "$db" ]; then say " existing database $(basename "$db"): not touched"; fi +done + +# -- step 7/7: verify BEFORE declaring success ---------------------------------------------------- + +# `keel versions` is the one check that can actually fail -- it reports every keel +# distribution the venv resolves and whether they agree on the release version. It runs +# from the deployment folder so keel resolves its state there, and a failure fails this +# script: success is never declared unverified. +say "step 7/7: verifying the install with 'keel versions' (run from ${KEEL_DIR})" +( cd "$KEEL_DIR" && "${VENV_DIR}/bin/keel" versions ) \ + || die "'keel versions' failed: the install is NOT complete -- see its output above" + +# -- success + next steps (only reachable past the verify) ---------------------------------------- + +say "installed keel ${TAG} (verified)" +printf '\nNext steps:\n' +printf ' run it: cd ~/.keel && ./.venv/bin/keel versions\n' +printf ' (or: source ~/.keel/.venv/bin/activate, then: keel versions)\n' +printf ' paper: config.yaml beside the venv is the default paper profile -- nothing in\n' +printf ' it can place a live order. To fetch candles you will want a free,\n' +printf ' read-only Coinbase Developer Platform (CDP) API key in ~/.keel/.env.\n' +printf ' guide: https://keeltrading.com\n' +printf ' updates: re-run this installer to move to a later release (#439, option A), or\n' +printf ' run: keel update (from ~/.keel -- it serves this venv layout).\n' +printf ' Desktop app bundles update by re-downloading (docs/desktop-install.md).\n' +printf ' this folder: %s holds the venv, config.yaml and (once keel runs) the database.\n' "$KEEL_DIR" diff --git a/tests/test_install_script.py b/tests/test_install_script.py new file mode 100644 index 0000000..a8dc894 --- /dev/null +++ b/tests/test_install_script.py @@ -0,0 +1,207 @@ +"""The terminal installer: what the script must always be, pinned over its text. + +`scripts/install.sh` is the one file users are asked to pipe straight into bash (#479), +which makes every line of it a security surface. It cannot be executed here -- it needs +the network and a fresh machine -- so what is pinned is the same discipline +`tests/test_desktop_packaging.py` applies to the shell and workflow artifacts it guards: +the set of properties that would be expensive to discover were false. Each of these is a +way the script could be quietly made unauditable by a refactor: a dropped `pipefail`, a +helpful `sudo`, an install that names a package instead of a downloaded file (there is an +unrelated "keel" on PyPI, which is why the by-path rule exists), or a success banner that +survived the removal of the verification that earned it. +""" + +from __future__ import annotations + +import re +import stat +from pathlib import Path + +import pytest + +from keel.commands.update import PRODUCTION_WHEEL_PREFIXES + +_ROOT = Path(__file__).resolve().parents[1] +_SCRIPT = _ROOT / "scripts" / "install.sh" + + +@pytest.fixture(scope="module") +def script() -> str: + return _SCRIPT.read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def code(script: str) -> list[str]: + """The commands only: non-blank, non-comment lines. + + A comment may EXPLAIN a forbidden thing (why there is no signing, why wheel checksums + are absent); only code can DO one -- so the absence tests below run against code + lines, the same split `tests/test_desktop_packaging.py` makes for the macOS packer. + """ + return [ + line + for line in script.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + +# -- the floor the whole script stands on ---------------------------------------------------------- + + +def test_fail_fast_is_on(script: str) -> None: + """`set -euo pipefail`: any failing command or pipe stops the script, so a broken + step can never be followed by a success message.""" + assert "set -euo pipefail" in script + + +def test_the_script_is_executable() -> None: + assert _SCRIPT.exists() + assert _SCRIPT.stat().st_mode & stat.S_IXUSR + + +def test_the_platform_is_checked_and_refuses_the_unsupported(script: str) -> None: + """macOS and Linux are the supported pair (the CI smoke runs exactly those); any + other `uname` must be refused loudly, with the Windows route named.""" + assert "uname" in script + assert "Darwin" in script and "Linux" in script + assert "desktop-install.md" in script # where the refused platforms are sent + + +def test_every_curl_is_strict(code: list[str]) -> None: + """`-fsSL`: fail on HTTP errors, no progress noise, follow the release redirect to + the asset host. A curl that tolerates a 404 body would install whatever HTML came + back. Only actual invocations count -- `command -v curl` is a PATH probe, not a + download.""" + curls = [line for line in code if re.search(r"\bcurl\s+-", line)] + assert curls, "the installer never curls anything -- this test proves nothing" + for line in curls: + assert "-fsSL" in line, f"a curl without -fsSL: {line}" + + +# -- no privilege, no secrets, no nested pipe-to-shell --------------------------------------------- + + +def test_no_privileged_commands(code: list[str]) -> None: + """A per-user install under $HOME needs none; the moment `sudo` appears, the + no-warning path becomes a system modification the user was not promised.""" + offenders = [line for line in code if "sudo" in line] + assert not offenders, f"sudo is invoked: {offenders}" + + +def test_no_shell_piping_of_downloaded_code(script: str) -> None: + """The user may pipe THIS script into bash; the script itself must never pipe + anything it fetched into a shell, and must not `eval` -- those are the two ways a + bootstrap script turns into a loader for arbitrary code.""" + assert not re.search(r"\|\s*(ba|z|da|k)?sh\b", script), "a pipe into a shell" + offenders = [line for line in script.splitlines() if re.search(r"\beval\b", line)] + assert not offenders, f"eval is used: {offenders}" + + +def test_no_credentials_or_auth(script: str) -> None: + """The public releases API is read unauthenticated, and a bootstrap script must + never grow a credential -- nothing to leak, nothing to phish for.""" + for secret in ("Authorization", "TOKEN", "PASSWORD", "SECRET"): + assert secret not in script, f"{secret} appears in the installer" + + +# -- Python: the floor is checked, and stated when it fails ---------------------------------------- + + +def test_the_python_floor_is_checked_and_stated(script: str) -> None: + """The interpreter's own `version_info` must be compared against (3, 11) -- the + floor every distribution declares (tests/test_python_floor.py) -- and the failure + must NAME the floor, because a bare 'wrong python' sends the user nowhere.""" + assert re.search(r"sys\.version_info >= \(3, ?11\)", script), ( + "the >= 3.11 check via sys.version_info is gone -- the installer would happily " + "build a venv keel refuses to run in" + ) + assert "3.11" in script, "the failure message must state the floor" + + +# -- the wheels: exactly the five, never by name from an index ----------------------------------- + + +def test_the_allowlist_is_keel_owns_production_prefixes(script: str) -> None: + """The script's allowlist must EQUAL `PRODUCTION_WHEEL_PREFIXES` -- the same five + distributions, in the same order, as the updater's selector. Selection is by exact + `--` name, so a drift here (a sixth wheel, a renamed one) is the + difference between a deployment and a different machine.""" + match = re.search(r'^WHEEL_PREFIXES="([^"]+)"', script, re.MULTILINE) + assert match, "the WHEEL_PREFIXES allowlist line is gone from scripts/install.sh" + assert tuple(match.group(1).split()) == PRODUCTION_WHEEL_PREFIXES + + +def test_the_venue_wheels_a_deployment_must_not_have_are_absent(script: str) -> None: + """Not merely unselected -- absent. The release carries a dev-only fake venue, an + optional venue and a stub venue that a deployment must never ride; if their names + appear anywhere in the script (even as a comment saying 'not this one'), the + allowlist above is no longer the thing that keeps them out.""" + for banned in ("fake", "robinhood", "kraken"): + assert banned not in script, f"{banned!r} appears in the installer" + + +def test_installs_by_exact_wheel_path_never_by_name(script: str, code: list[str]) -> None: + """keel is installed BY PATH from the downloaded release wheels -- the hard repo + rule, because `pip install keel` would fetch an UNRELATED PyPI project. Every + install invocation must carry `--find-links` (so the keel wheels' pinned keel + dependencies resolve from the download, not an index) and the wheel-path array; a + bare package name must not appear anywhere.""" + assert not re.search(r"pip install keel\b", script), ( + "an install names a keel package rather than a downloaded wheel path" + ) + joined = "\n".join(code) + assert '-m pip install --no-input --find-links "$TMP_DIR" "${WHEEL_PATHS[@]}"' in joined, ( + "the pip path's exact wheel-path install form is gone" + ) + assert 'uv pip install --python "$VENV_PY" --find-links "$TMP_DIR" "${WHEEL_PATHS[@]}"' in ( + joined + ), "the uv path's exact wheel-path install form is gone" + + +def test_the_pipless_venv_gets_one_honest_bootstrap(script: str) -> None: + """`python -m venv` can produce a venv without pip; the fallback must attempt + `ensurepip` and then fail clearly -- never skip the install silently.""" + assert "ensurepip" in script + assert "--no-input" in script + + +# -- the user's data: guarded ---------------------------------------------------------------------- + + +def test_an_existing_config_is_never_overwritten(script: str, code: list[str]) -> None: + """config.yaml is the user's the moment it lands: it may hold their edits. The copy + from the release must sit behind an existence check that SAYS it kept the existing + one -- a re-run that upgrades keel must not reset configuration.""" + assert '[ -e "${KEEL_DIR}/config.yaml" ]' in "\n".join(code) + assert "not overwritten" in script + assert 'cp "${TMP_DIR}/config.yaml" "${KEEL_DIR}/config.yaml"' in script + + +def test_an_existing_database_is_never_touched(script: str) -> None: + """Upgrading code must not mean touching data; a `keel*.db` under the install + directory is acknowledged and left alone.""" + assert "keel*.db" in script + assert "not touched" in script + + +def test_destructive_cleanup_targets_only_the_temp_dir(code: list[str]) -> None: + for line in code: + if "rm -rf" in line: + assert "TMP_DIR" in line, f"rm -rf outside the temp dir: {line}" + + +# -- verified before success ---------------------------------------------------------------------- + + +def test_versions_runs_before_any_success_message(code: list[str]) -> None: + """`keel versions` -- the one check that can actually fail, because it reports every + keel distribution the venv resolves and whether they agree -- must run BEFORE the + success banner. A success message that survives the removal of its verification is + the desktop milestone's silent-failure lesson applied to the installer.""" + verify = [i for i, line in enumerate(code) if 'bin/keel" versions' in line] + assert verify, "the installer no longer runs 'keel versions' from the venv" + success = [i for i, line in enumerate(code) if "installed keel" in line] + assert success, "no success line found -- this test proves nothing" + assert min(verify) < min(success), ( + "the success banner is printed before 'keel versions' verified the install" + )