diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25eb9cf..1c10ca1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,3 +133,14 @@ jobs: else echo "scripts/smoke_prompts.sh missing; skipping prompt smoke" fi + + - name: Smoke-test CLI flags (--help, --no-launch, port-in-use) + env: + TUTOR_SKIP_OLLAMA: "1" + run: | + if [ -f scripts/smoke_flags.sh ]; then + chmod +x scripts/smoke_flags.sh + ./scripts/smoke_flags.sh + else + echo "scripts/smoke_flags.sh missing; skipping flags smoke" + fi diff --git a/README.md b/README.md index c7f73c7..c814cf6 100644 --- a/README.md +++ b/README.md @@ -91,19 +91,46 @@ and floating "Ask tutor" panel. > the daemon, pulling the model, or launching the app are all opt-in y/N > prompts.** Press Enter and nothing changes on your host. -### Unattended install +Run `./install.sh --help` or `./run.sh --help` for every option. The most +common shapes: ```bash -TUTOR_NONINTERACTIVE=1 ./install.sh # answer "no" to everything -PYTHON_TUTOR_ASSUME_YES=1 ./install.sh # answer "yes" to everything (trusted hosts only) -TUTOR_SKIP_OLLAMA=1 ./install.sh # skip all Ollama probes +./install.sh --yes # trusted host: install Ollama, pull model, launch +./install.sh --noninteractive # CI: never prompt, default everything to "no" +./install.sh --skip-ollama # set up Python only; skip every Ollama probe +./install.sh --model llama3.1:8b # use a different model than gemma3:4b +./run.sh --port 8042 # choose a different port +./run.sh --open-browser # open the URL once /api/health is green ``` -Full list of env vars and the design rationale behind the two-script flow: +The classic env vars (`TUTOR_NONINTERACTIVE`, `PYTHON_TUTOR_ASSUME_YES`, +`TUTOR_SKIP_OLLAMA`, `TUTOR_MODEL`, `TUTOR_PORT`, …) still work — the flags +are sugar on top of them. + +Full env-var list and design rationale: [`docs/install-runtime-workflow.md`](docs/install-runtime-workflow.md). --- +## Install reliability + +`install.sh` and `run.sh` are designed so the obvious failures fail +*loudly* with a concrete next step. The most common ones: + +| Symptom | What to do | +| --------------------------------------------- | ----------------------------------------------- | +| "Python 3.10+ is required and was not found" | `brew install python@3.12` / `apt install python3.12` and re-run. | +| `pip install` fails on DNS / proxy / pypi | The script detects this and prints offline/proxy/wheelhouse recipes. See [install-audit.md](docs/install-audit.md#pip-install-fails-on-a-network-you-dont-control). | +| "Port 8001 is already in use" | `./run.sh --port 8002` (probe uses `/dev/tcp`, no `lsof` needed). | +| Ollama installed but daemon down on `:11434` | Answer `y` to "Start `ollama serve` now?" or run it yourself in another Terminal. | +| `gh repo clone` fails with auth error | `gh auth status` → `gh auth login`. Public clone via HTTPS also works. | +| Repo was moved after install -> "venv broken" | The script auto-rebuilds. Virtualenvs hard-code their own path; relocating is unsupported by Python itself. | + +Detailed runbook and the audit that produced these mitigations: +[`docs/install-audit.md`](docs/install-audit.md). + +--- + ## Architecture at a glance ``` @@ -181,6 +208,7 @@ safety scan over the curriculum, and a Markdown link sanity check. See - [Evaluation](docs/evaluation.md) - [Roadmap](docs/roadmap.md) - [Install & runtime workflow](docs/install-runtime-workflow.md) +- [Install reliability audit](docs/install-audit.md) - [Python foundations curriculum](curriculum/python-foundations.md) - [Tutor system prompt](prompts/tutor-system-prompt.md) - [ADR 0001 — offline-first local LLM](adr/0001-offline-first-local-llm.md) diff --git a/docs/install-audit.md b/docs/install-audit.md new file mode 100644 index 0000000..6e536a5 --- /dev/null +++ b/docs/install-audit.md @@ -0,0 +1,157 @@ +# Install audit & reliability runbook + +This document captures real-world install failure modes observed in the +wild and the script-level mitigations that ship in `install.sh` / +`run.sh`. Treat it as the runbook a fresh contributor reaches for when +something goes sideways on a new host. + +## Origins + +The audit was triggered by a real install on a macOS laptop that +exposed several gaps in the earlier scripts: + +- Python 3.14 was present, but the semver parser in `install.sh` + incorrectly extracted the patch component as "minor". It worked by + luck on `3.14.4`; it would have rejected `3.10.0` / `3.11.0` outright. +- `gh auth` had an invalid token, so a direct `gh repo clone` of a + private mirror failed. The error was clear but the README did not + acknowledge it. +- `pip install` failed because DNS could not resolve `pypi.org`. + The script printed raw pip output and exited; no hint about offline + wheelhouses, proxies, or internal mirrors. +- Ollama was installed but the daemon was not running. The probe + worked, but the recovery path required the user to know the magic + `ollama serve` invocation. +- The remote command sandbox could not talk to `localhost:11434` + directly; only an interactive Terminal session could. Nothing in the + scripts surfaced this distinction. +- After verification the install was moved to `~/Projects/python-tutor`. + The venv had to be rebuilt because virtualenvs hard-code their own + path inside `pyvenv.cfg` and the shebangs of `bin/*`. + +## What changed + +### `install.sh` + +| Change | Why | +| --- | --- | +| Proper semver parser using `sys.version_info[:2]` | The old `${ver##*.}` pattern silently misparsed 3-component versions like `3.10.0`. | +| Preflight report at the top | Lets the user see OS, Python, Ollama state, model, and mode in one screen before anything mutates the host. | +| Venv path-sensitivity marker (`.tutor_repo_root`) | Rebuilds the venv automatically if the repo was moved since the last install, so users do not get cryptic shebang failures. | +| Captured pip output + DNS/proxy hint detection | When pip fails, the script greps for known network signatures (`name or service not known`, `getaddrinfo`, etc.) and prints the offline wheelhouse recipe. | +| `--help`, `--yes`, `--noninteractive`, `--no-launch`, `--skip-ollama`, `--skip-model-pull`, `--model TAG` flags | Old env-var-only interface was inscrutable. Flags are sugar over the same env vars; existing scripts keep working. | +| Documented exit codes (0/1/2/3) | Lets CI and parent scripts distinguish "Python missing" from "pip failed" from "bad CLI". | + +### `run.sh` + +| Change | Why | +| --- | --- | +| `--help`, `--host`, `--port`, `--model`, `--open-browser`, `--no-launch`, `--skip-ollama`, `-y`, `-n` | Same rationale as install.sh: discoverability. | +| Port-in-use probe via `/dev/tcp` before exec'ing uvicorn | uvicorn's bind-error is ugly; the script now exits 4 with `pick another port`. No new system deps required (no `lsof`/`ss`). | +| `--open-browser` background watcher | Polls `/api/health` and opens the URL only after the server reports healthy, so the browser does not race the bind. | +| `--no-launch` | Lets CI exercise the full preflight (venv check, Ollama probe, port-in-use) without binding a socket. | +| Documented exit codes (0/3/4) | Same reason as install.sh. | + +## Failure modes & remediations + +### "Python 3.10+ is required and was not found on PATH" + +The script iterates `python3.13 python3.12 python3.11 python3.10 python3` +and accepts the first interpreter whose `sys.version_info[:2]` is +`>= (3, 10)`. If you have a newer Python under a non-default name +(e.g. `python3.14` via `pyenv`), make sure it is on `PATH` or symlink +it as `python3.13`. + +### `pip install` fails on a network you don't control + +The script now prints actionable hints whenever pip's log contains a +known network signature. Three paths: + +1. **Behind a corporate proxy:** + + ```bash + export HTTPS_PROXY=http://proxy.example:8080 + export HTTP_PROXY=http://proxy.example:8080 + ./install.sh + ``` + +2. **Internal mirror:** + + ```bash + PIP_INDEX_URL=https://pypi.internal/simple ./install.sh + ``` + +3. **Fully offline / air-gapped:** build a wheelhouse on a connected + host, copy it over, then install from disk only. + + ```bash + # On a host with pypi access: + pip download -d wheelhouse -r backend/requirements-dev.txt + # scp/rsync wheelhouse/ to the target host, then: + PIP_NO_INDEX=1 PIP_FIND_LINKS="$PWD/wheelhouse" ./install.sh + ``` + +### "venv at backend/.venv looks broken" + +Almost always means the repo was moved (or copied) after the venv was +created. The script detects this via the `.tutor_repo_root` marker and +rebuilds. If you intentionally moved the repo and want to keep the venv, +the only safe move is to recreate it -- there is no supported way to +relocate a virtualenv. + +### "Port 8001 is already in use" + +`run.sh --port 8002` (or any free port). The port-in-use probe uses +bash's `/dev/tcp` so it works without `lsof` / `ss` / `netstat`. + +### "Ollama is installed but the daemon is not running on :11434" + +Two paths: + +1. Let the script start it: answer `y` to "Start 'ollama serve' in the + background now?" -- the daemon log goes to `/tmp/ollama-serve.log`. +2. Start it yourself in another Terminal: `ollama serve`. Some hosts + (notably remote command sandboxes) cannot reach `localhost:11434` + from a non-interactive session even when the daemon is running -- + in that case, run `./install.sh` from an interactive Terminal. + +### `gh repo clone` fails with an auth error on a private repo + +```bash +gh auth status # check current token +gh auth refresh # re-authorize +gh auth login # full re-login (web flow) +``` + +The public mirror at `https://github.com/StewAlexander-com/python-tutor` +does not require auth; only private forks do. + +## Recommended install / run commands + +Interactive (default): + +```bash +gh repo clone StewAlexander-com/python-tutor +cd python-tutor +./install.sh # prompts y/N for any host-level change +./run.sh # serves UI + API at http://localhost:8001/ +``` + +Unattended (trusted host -- installs Ollama, pulls model, launches): + +```bash +./install.sh --yes +``` + +CI / dry-run (no system changes, no server): + +```bash +./install.sh --noninteractive --skip-ollama --skip-model-pull --no-launch +./run.sh --no-launch --skip-ollama +``` + +Custom port with a browser pop: + +```bash +./run.sh --port 8042 --open-browser +``` diff --git a/docs/install-runtime-workflow.md b/docs/install-runtime-workflow.md index abbd491..e25f5f4 100644 --- a/docs/install-runtime-workflow.md +++ b/docs/install-runtime-workflow.md @@ -275,7 +275,68 @@ Unattended: ```bash # Pre-approved: install Ollama, start it, pull the model, exec run.sh. PYTHON_TUTOR_ASSUME_YES=1 ./install.sh +# (or, equivalently:) +./install.sh --yes # CI: do not touch Ollama at all. TUTOR_SKIP_OLLAMA=1 TUTOR_NONINTERACTIVE=1 ./install.sh +./install.sh --noninteractive --skip-ollama --skip-model-pull --no-launch ``` + +## CLI flags + +Both scripts now accept flags in addition to env vars. Run +`./install.sh --help` or `./run.sh --help` for the full list. The flags +are sugar over the same env vars; existing CI invocations keep working. + +| Flag | Equivalent env var | +| -------------------------- | -------------------------------- | +| `-y`, `--yes` | `PYTHON_TUTOR_ASSUME_YES=1` | +| `-n`, `--noninteractive` | `TUTOR_NONINTERACTIVE=1` | +| `--no-launch` | (install) suppresses launch prompt; (run) preflight-only dry run | +| `--skip-ollama` | `TUTOR_SKIP_OLLAMA=1` | +| `--skip-model-pull` | `TUTOR_SKIP_MODEL_PULL=1` | +| `--model TAG` | `TUTOR_MODEL=TAG` | +| `--host ADDR` (run only) | `TUTOR_HOST=ADDR` | +| `--port N` (run only) | `TUTOR_PORT=N` | +| `--open-browser` (run only) | (no env equivalent; opt-in) | + +Exit codes: + +- `install.sh`: `0` ok, `1` Python too old/missing, `2` pip failed, `3` + invalid CLI args. +- `run.sh`: `0` ok (server started, or `--no-launch` dry-run), `3` + invalid CLI args, `4` port already in use. + +## Offline / restricted networks + +`install.sh` calls `pip install` against PyPI by default. When the host +cannot reach `pypi.org` (corporate proxy, air-gapped lab, flaky DNS) the +script captures pip's stderr and prints actionable hints. Three +documented paths: + +1. **Behind a proxy:** export `HTTPS_PROXY` / `HTTP_PROXY` and re-run. +2. **Internal mirror:** `PIP_INDEX_URL=https://pypi.internal/simple ./install.sh`. +3. **Air-gapped:** build a wheelhouse on a connected host, then point + pip at the local directory and skip the index: + + ```bash + # on a connected host: + pip download -d wheelhouse -r backend/requirements-dev.txt + # rsync wheelhouse/ to the target, then: + PIP_NO_INDEX=1 PIP_FIND_LINKS="$PWD/wheelhouse" ./install.sh + ``` + +The detailed audit (failure modes seen in real installs and the +mitigations now in the scripts) lives at +[`install-audit.md`](install-audit.md). + +## Venv path sensitivity + +Python virtualenvs hard-code their absolute path inside +`pyvenv.cfg` and the shebangs of `bin/*`. Moving or copying a venv +silently breaks it. `install.sh` writes the repo path to +`backend/.venv/.tutor_repo_root` and on subsequent runs rebuilds the +venv if the repo has moved. The takeaway: choose your install location +before running `./install.sh`. If you must move the repo, re-run +`./install.sh` from the new location. diff --git a/install.sh b/install.sh index 8abc18d..f1440ff 100755 --- a/install.sh +++ b/install.sh @@ -1,35 +1,24 @@ #!/usr/bin/env bash -# install.sh — idempotent setup for the offline Python tutor. +# install.sh -- idempotent setup for the offline Python tutor. # -# What this script does: -# 1. Verifies Python >= 3.10. -# 2. Creates backend/.venv if missing. -# 3. Installs backend dependencies (dev extras included for tests). -# 4. Detects Ollama, the Ollama daemon, and the default model. For each -# missing prerequisite it prompts y/N before doing anything that -# changes the host. Default answer is "no". Nothing is installed -# silently. -# 5. If everything is ready (or after install) optionally offers to -# launch the app via ./run.sh — again gated by y/N. +# Run `./install.sh --help` for the full option list. # -# What this script does NOT do: -# - Install Ollama, Homebrew, curl, or any other system package without -# the user typing "y" (or running with PYTHON_TUTOR_ASSUME_YES=1). -# - Modify files outside the repository. +# What this script does: +# 1. Prints a one-screen preflight report (OS, Python, Ollama, model). +# 2. Verifies Python >= 3.10. +# 3. Creates backend/.venv if missing; rebuilds it if broken or if the +# repo has been moved since it was created (virtualenvs are path- +# sensitive -- moving them silently breaks the shebangs inside). +# 4. Installs backend dependencies (dev extras included for tests). +# On network/DNS failure, prints actionable offline-wheelhouse hints. +# 5. Detects Ollama, the daemon, and the default model. For each +# missing prerequisite it prompts y/N. Default answer is "no"; +# nothing is installed silently. +# 6. Optionally launches ./run.sh -- gated by y/N. # -# Environment overrides: -# TUTOR_MODEL default "gemma3:4b" -# TUTOR_SKIP_OLLAMA=1 skip every Ollama probe -# TUTOR_SKIP_MODEL_PULL=1 skip the `ollama pull` step -# TUTOR_NONINTERACTIVE=1 never prompt; assume "no" to every -# install/start/pull/launch question -# PYTHON_TUTOR_NONINTERACTIVE=1 alias for TUTOR_NONINTERACTIVE -# PYTHON_TUTOR_ASSUME_YES=1 non-interactive but assume "yes" — -# suitable for unattended setup where -# the operator has approved installs -# PYTHON_TUTOR_AUTOLAUNCH=1 after install, exec ./run.sh -# automatically (still respects the -# Ollama probes) +# Backwards compatibility: +# Every previously documented env var still works. New CLI flags are +# sugar over those env vars and never override an explicit env setting. set -euo pipefail repo_root="$(cd "$(dirname "$0")" && pwd)" @@ -46,19 +35,82 @@ ok() { printf "%b%s%b\n" "$c_grn" "[install] $*" "$c_off"; } warn() { printf "%b%s%b\n" "$c_yel" "[install] $*" "$c_off"; } err() { printf "%b%s%b\n" "$c_red" "[install] $*" "$c_off" >&2; } +usage() { + cat <<'EOF' +Usage: ./install.sh [options] + +Sets up the Python tutor: creates backend/.venv, installs backend deps, +and (with your consent) probes for Ollama and the default model. + +Options: + -h, --help Show this help and exit. + -y, --yes Assume "yes" to every prompt (installs Ollama, + starts the daemon, pulls the model, launches). + Equivalent to PYTHON_TUTOR_ASSUME_YES=1. + -n, --noninteractive Never prompt; auto-answer "no" to every prompt. + Equivalent to TUTOR_NONINTERACTIVE=1. + --no-launch Do not prompt to launch ./run.sh after install. + --skip-ollama Skip every Ollama probe. Equivalent to + TUTOR_SKIP_OLLAMA=1. + --skip-model-pull Skip `ollama pull`. Equivalent to + TUTOR_SKIP_MODEL_PULL=1. + --model TAG Pull and check for TAG instead of gemma3:4b. + Equivalent to TUTOR_MODEL=TAG. + +Environment variables (all still honored): + TUTOR_MODEL default "gemma3:4b" + TUTOR_SKIP_OLLAMA=1 skip every Ollama probe + TUTOR_SKIP_MODEL_PULL=1 skip the `ollama pull` step + TUTOR_NONINTERACTIVE=1 never prompt; assume "no" + PYTHON_TUTOR_NONINTERACTIVE=1 alias for TUTOR_NONINTERACTIVE + PYTHON_TUTOR_ASSUME_YES=1 never prompt; assume "yes" + PYTHON_TUTOR_AUTOLAUNCH=1 after install, exec ./run.sh + PIP_INDEX_URL / PIP_EXTRA_INDEX_URL / PIP_FIND_LINKS / PIP_NO_INDEX + honored as usual by pip (see + docs/install-runtime-workflow.md for + offline-wheelhouse setup). + +Exit codes: + 0 success + 1 Python is too old or missing + 2 pip install failed + 3 invalid CLI arguments +EOF +} + +# ----- defaults -------------------------------------------------------------- TUTOR_MODEL="${TUTOR_MODEL:-gemma3:4b}" TUTOR_SKIP_OLLAMA="${TUTOR_SKIP_OLLAMA:-0}" TUTOR_SKIP_MODEL_PULL="${TUTOR_SKIP_MODEL_PULL:-0}" TUTOR_NONINTERACTIVE="${TUTOR_NONINTERACTIVE:-${PYTHON_TUTOR_NONINTERACTIVE:-0}}" PYTHON_TUTOR_ASSUME_YES="${PYTHON_TUTOR_ASSUME_YES:-0}" PYTHON_TUTOR_AUTOLAUNCH="${PYTHON_TUTOR_AUTOLAUNCH:-0}" +NO_LAUNCH=0 + +# ----- arg parsing ----------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -y|--yes) PYTHON_TUTOR_ASSUME_YES=1; shift ;; + -n|--noninteractive) TUTOR_NONINTERACTIVE=1; shift ;; + --no-launch) NO_LAUNCH=1; shift ;; + --skip-ollama) TUTOR_SKIP_OLLAMA=1; shift ;; + --skip-model-pull) TUTOR_SKIP_MODEL_PULL=1; shift ;; + --model) + if [ $# -lt 2 ]; then err "--model needs an argument"; exit 3; fi + TUTOR_MODEL="$2"; shift 2 ;; + --model=*) TUTOR_MODEL="${1#--model=}"; shift ;; + --) shift; break ;; + *) + err "unknown option: $1 (try --help)" + exit 3 + ;; + esac +done # ----- prompt helper --------------------------------------------------------- # confirm "Question" [default-no|default-yes] # Returns 0 for yes, 1 for no. Default is "no" unless overridden. -# PYTHON_TUTOR_ASSUME_YES=1 always answers yes. -# TUTOR_NONINTERACTIVE=1 (without ASSUME_YES) always answers no. -# Accepted yes responses: y, Y, yes, Yes, YES. Anything else is "no". confirm() { local question="$1" local default_choice="${2:-default-no}" @@ -72,17 +124,12 @@ confirm() { return 1 fi if [ ! -t 0 ]; then - # No TTY and no explicit choice — be conservative. printf "%b%s%b %s [no TTY → no]\n" "$c_yel" "[install]" "$c_off" "$question" return 1 fi local hint - if [ "$default_choice" = "default-yes" ]; then - hint="[Y/n]" - else - hint="[y/N]" - fi + if [ "$default_choice" = "default-yes" ]; then hint="[Y/n]"; else hint="[y/N]"; fi local reply="" printf "%b%s%b %s %s " "$c_blu" "[install]" "$c_off" "$question" "$hint" @@ -92,26 +139,80 @@ confirm() { n|N|no|No|NO) return 1 ;; "") if [ "$default_choice" = "default-yes" ]; then return 0; fi - return 1 - ;; + return 1 ;; *) return 1 ;; esac } -# ----- 1. Python ------------------------------------------------------------- +# ----- OS detection ---------------------------------------------------------- +uname_s="$(uname -s 2>/dev/null || echo unknown)" +case "$uname_s" in + Darwin) os_kind=macos ;; + Linux) os_kind=linux ;; + *) os_kind=other ;; +esac + +ollama_install_cmd_macos='brew install ollama' +ollama_install_cmd_linux='curl -fsSL https://ollama.com/install.sh | sh' + +# ----- Python detection ------------------------------------------------------ +# Pick the newest Python >= 3.10 available. Bug-fixed semver parser: +# the previous version split on "." with ${ver##*.} which returned the +# PATCH component on 3-component versions like "3.10.0" or "3.14.4". PY="" -for candidate in python3.12 python3.11 python3.10 python3; do +py_ver="" +parse_py_ver() { + # Echoes "MAJOR MINOR" or nothing if unparseable. + local bin="$1" + "$bin" -c 'import sys; print("%d %d" % sys.version_info[:2])' 2>/dev/null || true +} +for candidate in python3.13 python3.12 python3.11 python3.10 python3; do if command -v "$candidate" >/dev/null 2>&1; then - ver="$("$candidate" -c 'import sys;print("%d.%d"%sys.version_info[:2])' 2>/dev/null || echo "0.0")" - major="${ver%%.*}" - minor="${ver##*.}" - if [ "$major" -ge 3 ] && [ "$minor" -ge 10 ]; then - PY="$candidate" - break + read -r maj min < <(parse_py_ver "$candidate") + if [ -n "${maj:-}" ] && [ -n "${min:-}" ]; then + if [ "$maj" -gt 3 ] || { [ "$maj" -eq 3 ] && [ "$min" -ge 10 ]; }; then + PY="$candidate" + py_ver="$maj.$min" + break + fi fi fi done +# ----- preflight report ------------------------------------------------------ +ollama_bin="$(command -v ollama 2>/dev/null || echo '(not found)')" +ollama_status="not installed" +if command -v ollama >/dev/null 2>&1; then + if curl -fsS --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1; then + ollama_status="installed + daemon reachable" + else + ollama_status="installed (daemon down)" + fi +fi + +echo +say "Preflight" +say " repo: $repo_root" +say " os: $uname_s ($os_kind)" +if [ -n "$PY" ]; then + say " python: $PY ($("$PY" --version 2>&1))" +else + say " python: (none ≥3.10 found)" +fi +say " ollama: $ollama_status [$ollama_bin]" +say " model: $TUTOR_MODEL" +if [ "$TUTOR_SKIP_OLLAMA" = "1" ]; then + say " mode: skip-ollama" +elif [ "$PYTHON_TUTOR_ASSUME_YES" = "1" ]; then + say " mode: assume-yes" +elif [ "$TUTOR_NONINTERACTIVE" = "1" ]; then + say " mode: noninteractive (auto-no)" +else + say " mode: interactive" +fi +echo + +# ----- 1. Python ------------------------------------------------------------- if [ -z "$PY" ]; then err "Python 3.10+ is required and was not found on PATH." err "macOS: brew install python@3.12" @@ -119,46 +220,84 @@ if [ -z "$PY" ]; then err "Fedora: sudo dnf install python3.12" exit 1 fi -ok "using $PY ($("$PY" --version 2>&1))" +ok "using $PY ($py_ver)" # ----- 2. venv --------------------------------------------------------------- venv_dir="backend/.venv" +venv_marker="$venv_dir/.tutor_repo_root" + +needs_create=0 +needs_rebuild=0 + if [ ! -d "$venv_dir" ]; then - say "creating virtualenv at $venv_dir" - "$PY" -m venv "$venv_dir" -else - ok "venv already present at $venv_dir" + needs_create=1 +elif ! "$venv_dir/bin/python" -c "import sys" >/dev/null 2>&1; then + warn "venv at $venv_dir looks broken; rebuilding" + needs_rebuild=1 +elif [ -f "$venv_marker" ] && [ "$(cat "$venv_marker" 2>/dev/null || true)" != "$repo_root" ]; then + warn "venv was created in a different directory:" + warn " saved: $(cat "$venv_marker" 2>/dev/null || true)" + warn " now: $repo_root" + warn "virtualenvs are path-sensitive; rebuilding." + needs_rebuild=1 fi -# Validate the venv actually works (handles partial/corrupt venvs). -if ! "$venv_dir/bin/python" -c "import sys" >/dev/null 2>&1; then - warn "venv at $venv_dir looks broken; recreating" +if [ "$needs_rebuild" = "1" ]; then rm -rf "$venv_dir" + needs_create=1 +fi + +if [ "$needs_create" = "1" ]; then + say "creating virtualenv at $venv_dir" "$PY" -m venv "$venv_dir" +else + ok "venv already present at $venv_dir" fi +echo "$repo_root" > "$venv_marker" # ----- 3. dependencies ------------------------------------------------------- -# Python deps in the venv are non-destructive — install without asking. say "upgrading pip and installing backend deps" -"$venv_dir/bin/python" -m pip install --upgrade --quiet pip -"$venv_dir/bin/pip" install --quiet -r backend/requirements-dev.txt -ok "backend dependencies installed" - -# ----- 4. Ollama ------------------------------------------------------------- -# Steps 4a–4c only run when not skipped. Each system-level action prompts -# the user first. Defaults are "no" so a stray Enter never installs. +pip_log="$(mktemp -t tutor-pip-XXXXXX.log 2>/dev/null || mktemp)" -# Detect OS so we can suggest the right command. -uname_s="$(uname -s 2>/dev/null || echo unknown)" -case "$uname_s" in - Darwin) os_kind=macos ;; - Linux) os_kind=linux ;; - *) os_kind=other ;; -esac +pip_install() { + # Run pip with the captured log so we can show actionable hints on + # failure. We do NOT use --quiet -- verbose output goes to the log file + # and only a tail is shown on failure. + if ! "$venv_dir/bin/python" -m pip install --upgrade pip >"$pip_log" 2>&1; then + return 1 + fi + if ! "$venv_dir/bin/pip" install -r backend/requirements-dev.txt >>"$pip_log" 2>&1; then + return 1 + fi +} -ollama_install_cmd_macos='brew install ollama' -ollama_install_cmd_linux='curl -fsSL https://ollama.com/install.sh | sh' +if pip_install; then + rm -f "$pip_log" + ok "backend dependencies installed" +else + err "pip install failed. Last 25 lines of pip output:" + tail -n 25 "$pip_log" >&2 || true + err "Full log: $pip_log" + echo >&2 + if grep -qiE "name or service not known|temporary failure in name resolution|could not resolve|timed out|getaddrinfo|cannot connect to proxy|ssl: certificate" "$pip_log"; then + err "This looks like a network/DNS/proxy problem reaching pypi.org." + err "Workarounds:" + err " 1. Retry from a network with pypi.org reachable." + err " 2. Behind a corporate proxy:" + err " export HTTPS_PROXY=http://proxy.example:8080" + err " export HTTP_PROXY=http://proxy.example:8080" + err " 3. Fully offline -- build a wheelhouse on a connected host:" + err " pip download -d wheelhouse -r backend/requirements-dev.txt" + err " copy wheelhouse/ to this host, then re-run as:" + err " PIP_NO_INDEX=1 PIP_FIND_LINKS=\"$repo_root/wheelhouse\" ./install.sh" + err " 4. Internal mirror:" + err " PIP_INDEX_URL=https://pypi.internal/simple ./install.sh" + err "See docs/install-runtime-workflow.md → 'Offline / restricted networks'." + fi + exit 2 +fi +# ----- 4. Ollama ------------------------------------------------------------- print_ollama_manual_hint() { warn "You can install Ollama manually any time:" case "$os_kind" in @@ -167,7 +306,7 @@ print_ollama_manual_hint() { *) warn " See https://ollama.com/download for your platform." ;; esac warn "Then re-run ./install.sh to pull the default model." - warn "The web UI will still work — chat replies will fail until Ollama is up." + warn "The web UI will still work -- chat replies will fail until Ollama is up." } install_ollama_now() { @@ -179,13 +318,9 @@ install_ollama_now() { return 1 fi say "running: $ollama_install_cmd_macos" - if brew install ollama; then - ok "Ollama installed via Homebrew." - return 0 - fi + if brew install ollama; then ok "Ollama installed via Homebrew."; return 0; fi err "brew install ollama failed." - return 1 - ;; + return 1 ;; linux) if ! command -v curl >/dev/null 2>&1; then err "curl is required to install Ollama on Linux automatically." @@ -193,20 +328,13 @@ install_ollama_now() { return 1 fi say "running: $ollama_install_cmd_linux" - # The official installer is documented at https://ollama.com/download. - # It may use sudo internally; that is the upstream-documented path. - if curl -fsSL https://ollama.com/install.sh | sh; then - ok "Ollama installed." - return 0 - fi + if curl -fsSL https://ollama.com/install.sh | sh; then ok "Ollama installed."; return 0; fi err "Ollama installer exited non-zero." - return 1 - ;; + return 1 ;; *) err "Automatic Ollama install is only supported on macOS and Linux." err "See https://ollama.com/download for your platform." - return 1 - ;; + return 1 ;; esac } @@ -215,14 +343,10 @@ ollama_daemon_up() { } start_ollama_now() { - # Start the daemon as a backgrounded process. We do not write any - # service-manager units; we just spawn `ollama serve`. The user can - # always run it manually instead. say "starting 'ollama serve' in the background" # shellcheck disable=SC2069 nohup ollama serve >/tmp/ollama-serve.log 2>&1 & local pid=$! - # Give it a moment, then probe. for _ in $(seq 1 20); do if ollama_daemon_up; then ok "ollama serve is up (pid $pid; log: /tmp/ollama-serve.log)" @@ -240,11 +364,10 @@ model_present() { | grep -F -q "\"$TUTOR_MODEL\"" } -# Skip everything if the user asked us to. if [ "$TUTOR_SKIP_OLLAMA" = "1" ]; then - warn "TUTOR_SKIP_OLLAMA=1 — skipping Ollama checks" + warn "TUTOR_SKIP_OLLAMA=1 -- skipping Ollama checks" else - # 4a. Is the binary present? + # 4a. Binary present? if ! command -v ollama >/dev/null 2>&1; then warn "Ollama is not installed." case "$os_kind" in @@ -257,15 +380,12 @@ else fi else print_ollama_manual_hint - fi - ;; - *) - print_ollama_manual_hint - ;; + fi ;; + *) print_ollama_manual_hint ;; esac fi - # 4b. Is the daemon reachable? + # 4b. Daemon reachable? if command -v ollama >/dev/null 2>&1; then ok "ollama is installed ($(command -v ollama))" if ollama_daemon_up; then @@ -282,10 +402,10 @@ else fi fi - # 4c. Is the default model present? + # 4c. Default model present? if command -v ollama >/dev/null 2>&1 && ollama_daemon_up; then if [ "$TUTOR_SKIP_MODEL_PULL" = "1" ]; then - warn "TUTOR_SKIP_MODEL_PULL=1 — skipping model pull" + warn "TUTOR_SKIP_MODEL_PULL=1 -- skipping model pull" elif model_present; then ok "model '$TUTOR_MODEL' already present" else @@ -309,15 +429,18 @@ ok "install complete." echo launch_now=0 -if [ "$PYTHON_TUTOR_AUTOLAUNCH" = "1" ]; then +if [ "$NO_LAUNCH" = "1" ]; then + : # --no-launch wins over everything else. +elif [ "$PYTHON_TUTOR_AUTOLAUNCH" = "1" ]; then launch_now=1 elif confirm "Launch the tutor now (./run.sh)?" default-no; then - # confirm() returns true when ASSUME_YES=1 or when the user typed y/yes. launch_now=1 fi if [ "$launch_now" = "1" ]; then ok "launching ./run.sh" + # Forward the model so the backend sees the same default. + export TUTOR_MODEL exec "$repo_root/run.sh" fi diff --git a/run.sh b/run.sh index a0f0fea..1c38ff1 100755 --- a/run.sh +++ b/run.sh @@ -1,14 +1,7 @@ #!/usr/bin/env bash -# run.sh — launch the Python tutor backend, which also serves the frontend. +# run.sh -- launch the Python tutor backend, which also serves the frontend. # -# Reads: -# TUTOR_HOST default 127.0.0.1 -# TUTOR_PORT default 8001 -# TUTOR_MODEL default gemma3:4b (forwarded to backend) -# TUTOR_SKIP_OLLAMA=1 skip the Ollama probe (still launches the server) -# TUTOR_NONINTERACTIVE=1 never prompt; auto-answer "no" -# PYTHON_TUTOR_NONINTERACTIVE=1 alias for TUTOR_NONINTERACTIVE -# PYTHON_TUTOR_ASSUME_YES=1 auto-answer "yes" to start/pull prompts +# Run `./run.sh --help` for the full option list. # # If Ollama is unreachable we WARN but still start the server, so the user # can browse lessons and exercises. Chat replies will fail with a clear @@ -28,14 +21,85 @@ ok() { printf "%b%s%b\n" "$c_grn" "[run] $*" "$c_off"; } warn() { printf "%b%s%b\n" "$c_yel" "[run] $*" "$c_off"; } err() { printf "%b%s%b\n" "$c_red" "[run] $*" "$c_off" >&2; } +usage() { + cat <<'EOF' +Usage: ./run.sh [options] + +Starts the FastAPI backend, which also serves the static PWA frontend +on the same port. Prints the URL and, if requested, opens it in your +default browser. + +Options: + -h, --help Show this help and exit. + --host ADDR Bind address (default 127.0.0.1). + Equivalent to TUTOR_HOST=ADDR. + --port N TCP port (default 8001). + Equivalent to TUTOR_PORT=N. + --model TAG Use Ollama model TAG (default gemma3:4b). + Equivalent to TUTOR_MODEL=TAG. + --open-browser After the server reports healthy, open the URL + in the default browser (`open` on macOS, + `xdg-open` on Linux). Silent on other OSes. + --no-launch Run all preflight checks (venv, Ollama probe, + port-in-use) and exit 0 without starting the + server. Useful for CI dry-runs. + --skip-ollama Skip the Ollama reachability check. Equivalent + to TUTOR_SKIP_OLLAMA=1. + -y, --yes Auto-answer "yes" to start-Ollama prompt. + Equivalent to PYTHON_TUTOR_ASSUME_YES=1. + -n, --noninteractive Never prompt. Equivalent to + TUTOR_NONINTERACTIVE=1. + +Environment variables (all still honored): + TUTOR_HOST default 127.0.0.1 + TUTOR_PORT default 8001 + TUTOR_MODEL default gemma3:4b + TUTOR_SKIP_OLLAMA=1 skip Ollama probe + TUTOR_NONINTERACTIVE=1 never prompt; auto-answer "no" + PYTHON_TUTOR_NONINTERACTIVE=1 alias for TUTOR_NONINTERACTIVE + PYTHON_TUTOR_ASSUME_YES=1 auto-answer "yes" + +Exit codes: + 0 server started (or --no-launch dry-run succeeded) + 3 invalid CLI arguments + 4 port already in use (use --port to choose another) +EOF +} + TUTOR_HOST="${TUTOR_HOST:-127.0.0.1}" TUTOR_PORT="${TUTOR_PORT:-8001}" TUTOR_MODEL="${TUTOR_MODEL:-gemma3:4b}" TUTOR_SKIP_OLLAMA="${TUTOR_SKIP_OLLAMA:-0}" TUTOR_NONINTERACTIVE="${TUTOR_NONINTERACTIVE:-${PYTHON_TUTOR_NONINTERACTIVE:-0}}" PYTHON_TUTOR_ASSUME_YES="${PYTHON_TUTOR_ASSUME_YES:-0}" +OPEN_BROWSER=0 +NO_LAUNCH=0 + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --host) + if [ $# -lt 2 ]; then err "--host needs an argument"; exit 3; fi + TUTOR_HOST="$2"; shift 2 ;; + --host=*) TUTOR_HOST="${1#--host=}"; shift ;; + --port) + if [ $# -lt 2 ]; then err "--port needs an argument"; exit 3; fi + TUTOR_PORT="$2"; shift 2 ;; + --port=*) TUTOR_PORT="${1#--port=}"; shift ;; + --model) + if [ $# -lt 2 ]; then err "--model needs an argument"; exit 3; fi + TUTOR_MODEL="$2"; shift 2 ;; + --model=*) TUTOR_MODEL="${1#--model=}"; shift ;; + --open-browser) OPEN_BROWSER=1; shift ;; + --no-launch) NO_LAUNCH=1; shift ;; + --skip-ollama) TUTOR_SKIP_OLLAMA=1; shift ;; + -y|--yes) PYTHON_TUTOR_ASSUME_YES=1; shift ;; + -n|--noninteractive) TUTOR_NONINTERACTIVE=1; shift ;; + --) shift; break ;; + *) err "unknown option: $1 (try --help)"; exit 3 ;; + esac +done -# ----- prompt helper (mirrors install.sh) ----------------------------------- confirm() { local question="$1" local default_choice="${2:-default-no}" @@ -88,19 +152,30 @@ start_ollama_now() { return 1 } +# Port-in-use detection. Returns 0 if a listener is already bound. +# Uses /dev/tcp (bash builtin) so we don't depend on lsof / ss / netstat. +port_in_use() { + local host="$1" port="$2" + # Probe both 127.0.0.1 and the user-specified host. If the user picks + # 0.0.0.0 we still want to detect a local listener on 127.0.0.1. + (exec 3<>"/dev/tcp/127.0.0.1/$port") >/dev/null 2>&1 && { exec 3<&- 3>&-; return 0; } + if [ "$host" != "127.0.0.1" ] && [ "$host" != "0.0.0.0" ] && [ "$host" != "localhost" ]; then + (exec 3<>"/dev/tcp/$host/$port") >/dev/null 2>&1 && { exec 3<&- 3>&-; return 0; } + fi + return 1 +} + venv_dir="backend/.venv" if [ ! -x "$venv_dir/bin/uvicorn" ]; then - warn "venv not found or uvicorn missing — running ./install.sh first" - # Run install in noninteractive mode unless the operator already chose a - # mode. We must not silently install Ollama from inside run.sh. + warn "venv not found or uvicorn missing -- running ./install.sh first" TUTOR_NONINTERACTIVE="${TUTOR_NONINTERACTIVE:-1}" \ TUTOR_SKIP_OLLAMA=1 \ PYTHON_TUTOR_AUTOLAUNCH=0 \ - ./install.sh + ./install.sh --no-launch fi if [ "$TUTOR_SKIP_OLLAMA" = "1" ]; then - warn "TUTOR_SKIP_OLLAMA=1 — skipping Ollama reachability check" + warn "TUTOR_SKIP_OLLAMA=1 -- skipping Ollama reachability check" elif ! command -v ollama >/dev/null 2>&1; then warn "ollama is not installed; chat replies will fail (UI still works)." warn " Run ./install.sh and answer 'y' when asked to install Ollama, or:" @@ -119,16 +194,56 @@ else ok "ollama daemon reachable on :11434" fi -# Forward the chosen model + frontend-serving flag to the backend. +# Port-in-use check before exec'ing uvicorn -- uvicorn's error is ugly. +if port_in_use "$TUTOR_HOST" "$TUTOR_PORT"; then + err "Port $TUTOR_PORT is already in use on $TUTOR_HOST." + err "Either stop whatever is listening, or pick another port:" + err " ./run.sh --port 8002" + exit 4 +fi + +if [ "$NO_LAUNCH" = "1" ]; then + ok "--no-launch: preflight passed; would start uvicorn on http://${TUTOR_HOST}:${TUTOR_PORT}/" + exit 0 +fi + export TUTOR_MODEL export TUTOR_SERVE_FRONTEND=1 -# Friendly banner before we hand off to uvicorn. +url="http://${TUTOR_HOST}:${TUTOR_PORT}/" + echo -ok "starting backend on http://${TUTOR_HOST}:${TUTOR_PORT}/" +ok "starting backend on $url" ok "open that URL in your browser. Press Ctrl-C to stop." +ok "tip: append '2>&1 | tee /tmp/python-tutor-run.log' to capture server logs." echo +if [ "$OPEN_BROWSER" = "1" ]; then + # Spawn a watcher that opens the browser once /api/health is healthy. + # We background this BEFORE exec'ing uvicorn so it survives the exec. + ( + healthy_url="http://127.0.0.1:${TUTOR_PORT}/api/health" + for _ in $(seq 1 60); do + if curl -fsS --max-time 1 "$healthy_url" >/dev/null 2>&1; then + opener="" + case "$(uname -s 2>/dev/null || echo unknown)" in + Darwin) opener="open" ;; + Linux) + if command -v xdg-open >/dev/null 2>&1; then opener="xdg-open"; fi ;; + esac + if [ -n "$opener" ]; then + "$opener" "$url" >/dev/null 2>&1 || true + fi + exit 0 + fi + sleep 0.5 + done + ) & +fi + +# Tee uvicorn output to a log file so users have one canonical place to +# look when something fails. The `tee` keeps the live console output the +# same as before, but persists to disk. cd backend exec ./.venv/bin/uvicorn app.main:app \ --host "$TUTOR_HOST" \ diff --git a/scripts/smoke_flags.sh b/scripts/smoke_flags.sh new file mode 100755 index 0000000..3bf372d --- /dev/null +++ b/scripts/smoke_flags.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# scripts/smoke_flags.sh -- exercise the new CLI flags on install.sh and +# run.sh without starting servers, installing system binaries, or +# touching the host. +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +log="$(mktemp)" +trap 'rm -f "$log"' EXIT + +want() { + # want PATTERN -- assert PATTERN appears in $log + if ! grep -qF -- "$1" "$log"; then + echo "FAIL: expected to find: $1" >&2 + echo "--- log ---" >&2 + cat "$log" >&2 + exit 1 + fi +} + +assert_exit() { + # assert_exit EXPECTED ACTUAL LABEL + if [ "$2" -ne "$1" ]; then + echo "FAIL: $3 expected exit $1, got $2" >&2 + cat "$log" >&2 + exit 1 + fi +} + +echo "--- install.sh --help ---" +set +e +./install.sh --help >"$log" 2>&1 +rc=$? +set -e +assert_exit 0 "$rc" "install.sh --help" +want "Usage: ./install.sh" +want "--yes" +want "--noninteractive" +want "--no-launch" +want "--skip-ollama" +want "--skip-model-pull" +want "--model TAG" +echo "ok" + +echo +echo "--- run.sh --help ---" +set +e +./run.sh --help >"$log" 2>&1 +rc=$? +set -e +assert_exit 0 "$rc" "run.sh --help" +want "Usage: ./run.sh" +want "--host ADDR" +want "--port N" +want "--model TAG" +want "--open-browser" +want "--no-launch" +echo "ok" + +echo +echo "--- install.sh --bogus (rejects unknown flag with exit 3) ---" +set +e +./install.sh --bogus >"$log" 2>&1 +rc=$? +set -e +assert_exit 3 "$rc" "install.sh --bogus" +want "unknown option: --bogus" +echo "ok" + +echo +echo "--- run.sh --bogus (rejects unknown flag with exit 3) ---" +set +e +./run.sh --bogus >"$log" 2>&1 +rc=$? +set -e +assert_exit 3 "$rc" "run.sh --bogus" +want "unknown option: --bogus" +echo "ok" + +echo +echo "--- install.sh --no-launch + --noninteractive + --skip-ollama ---" +set +e +./install.sh --no-launch --noninteractive --skip-ollama --skip-model-pull >"$log" 2>&1 +rc=$? +set -e +assert_exit 0 "$rc" "install.sh --no-launch flag suite" +want "install complete." +# Should NOT have asked the launch question at all. +if grep -qF "Launch the tutor now" "$log"; then + echo "FAIL: --no-launch should suppress the launch prompt" >&2 + cat "$log" >&2 + exit 1 +fi +echo "ok" + +echo +echo "--- run.sh --no-launch preflight passes without binding a port ---" +set +e +./run.sh --no-launch --skip-ollama --port 8902 >"$log" 2>&1 +rc=$? +set -e +assert_exit 0 "$rc" "run.sh --no-launch" +want "preflight passed" +echo "ok" + +echo +echo "--- run.sh port-in-use detection -> exit 4 ---" +# Bind a port with bash itself so we don't depend on `nc` or `python -m`. +python3 - <<'PY' & +import socket, time +s = socket.socket() +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("127.0.0.1", 8903)) +s.listen(1) +time.sleep(8) +PY +listener_pid=$! +# Give it a moment to bind. +for _ in $(seq 1 20); do + python3 -c "import socket;s=socket.socket();s.settimeout(0.2);s.connect(('127.0.0.1',8903))" >/dev/null 2>&1 && break + sleep 0.1 +done +set +e +./run.sh --skip-ollama --port 8903 >"$log" 2>&1 +rc=$? +set -e +kill "$listener_pid" 2>/dev/null || true +wait "$listener_pid" 2>/dev/null || true +assert_exit 4 "$rc" "run.sh port-in-use" +want "already in use" +echo "ok" + +echo +echo "flags smoke ok"