diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb2fc52..25eb9cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,3 +124,12 @@ jobs: else echo "scripts/smoke_run.sh missing; skipping run.sh smoke" fi + + - name: Smoke-test install.sh y/N prompts (noninteractive) + run: | + if [ -f scripts/smoke_prompts.sh ]; then + chmod +x scripts/smoke_prompts.sh + ./scripts/smoke_prompts.sh + else + echo "scripts/smoke_prompts.sh missing; skipping prompt smoke" + fi diff --git a/README.md b/README.md index 4df7d41..dd241db 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,9 @@ first look at the UI; required for chat replies and code evaluation. ```bash gh repo clone StewAlexander-com/python-tutor cd python-tutor -./install.sh # creates venv, installs deps, pulls model if Ollama is up +./install.sh # creates venv, installs deps, then prompts y/N for + # any host-level setup (install Ollama, start daemon, + # pull model, launch app) ./run.sh # serves UI + API on http://localhost:8001/ ``` @@ -89,6 +91,48 @@ Then open in your browser. You'll see the lesson list, the inline code lab (Run / Evaluate), and the floating "Ask tutor" chat panel. +### Opt-in install prompts + +`install.sh` does the Python-side setup (venv + pip) without asking — +those changes live inside the repository. Anything that touches the +host system or your home directory is **opt-in** via a y/N prompt: + +| Detected condition | Prompt | Default | +| --------------------------------------------- | -------------------------------------------------------- | ------- | +| Ollama binary missing (macOS or Linux) | "Install Ollama now? (will run the official installer)" | **No** | +| Ollama installed but daemon not on :11434 | "Start 'ollama serve' in the background now?" | **No** | +| Default model (`gemma3:4b`) missing locally | "Pull '' now? (this can take several minutes)" | **No** | +| Install finished | "Launch the tutor now (./run.sh)?" | **No** | + +Accepted "yes" answers are `y`, `Y`, `yes`, `Yes`, `YES`. Anything else +— including pressing Enter on an empty line — is treated as "no". The +upstream Ollama installer on Linux (`curl https://ollama.com/install.sh | sh`) +may itself invoke `sudo`; that is the documented upstream path. + +### Non-interactive / CI usage + +The same flow runs unattended: + +```bash +# CI: do not touch Ollama, do nothing system-level. +TUTOR_SKIP_OLLAMA=1 TUTOR_NONINTERACTIVE=1 ./install.sh + +# Unattended local install where the operator has pre-approved everything. +PYTHON_TUTOR_ASSUME_YES=1 ./install.sh +``` + +Relevant env vars: + +| Variable | Effect | +| ------------------------------ | ------------------------------------------------------------------- | +| `TUTOR_NONINTERACTIVE=1` | Never prompt; answer **no** to every install/start/pull/launch ask. | +| `PYTHON_TUTOR_NONINTERACTIVE=1`| Alias for the above. | +| `PYTHON_TUTOR_ASSUME_YES=1` | Never prompt; answer **yes** (use only if you trust the host). | +| `PYTHON_TUTOR_AUTOLAUNCH=1` | After install, `exec ./run.sh` without asking. | +| `TUTOR_SKIP_OLLAMA=1` | Skip every Ollama probe entirely. | +| `TUTOR_SKIP_MODEL_PULL=1` | Skip the `ollama pull` step (binary/daemon checks still run). | +| `TUTOR_MODEL=` | Override the default `gemma3:4b`. | + ### Expected behaviour when Ollama is not running - The web UI loads normally — you can read lessons and run code locally @@ -96,21 +140,17 @@ lesson list, the inline code lab (Run / Evaluate), and the floating - `/api/health` reports `status: "degraded"` and `ollama_reachable: false`. - `Evaluate` and the chat panel return a clear 503 — they don't hang. - As soon as you start `ollama serve`, everything works without a restart. +- `run.sh` will offer to start `ollama serve` for you if it sees the + binary but not the daemon. Decline and it continues with the + degraded-mode warning. ### What if Ollama isn't installed? -`install.sh` and `run.sh` **never** install system binaries on your -behalf. If Ollama is missing, they print exactly what to run: - -```bash -# macOS -brew install ollama && ollama serve & - -# Linux -curl -fsSL https://ollama.com/install.sh | sh && ollama serve & -``` - -Then `./install.sh` again to pull `gemma3:4b`. +`install.sh` will offer to install it for you on macOS (`brew install +ollama`) or Linux (`curl -fsSL https://ollama.com/install.sh | sh`). +Answer `y` to proceed, anything else to skip. If you skip, the same +commands are printed for you to run by hand, then re-run `./install.sh` +to pull the model. ### Troubleshooting @@ -122,6 +162,8 @@ Then `./install.sh` again to pull `gemma3:4b`. | Model missing in chat replies | `ollama pull gemma3:4b` (or set `TUTOR_MODEL` to your model) | | Service worker shows stale UI | Hard-refresh the browser (Cmd/Ctrl-Shift-R) | | `install.sh` failed mid-`pip install` | Re-run it — it's idempotent and reuses the venv | +| Prompts fire in CI / a script | Set `TUTOR_NONINTERACTIVE=1` (defaults to "no") or `PYTHON_TUTOR_ASSUME_YES=1` | +| Ollama installer prompts for sudo | That's the upstream Linux installer; decline the prompt or install via your package manager | For the design rationale behind the two-script flow (and the five flows we evaluated), see diff --git a/docs/install-runtime-workflow.md b/docs/install-runtime-workflow.md index c522beb..abbd491 100644 --- a/docs/install-runtime-workflow.md +++ b/docs/install-runtime-workflow.md @@ -154,26 +154,60 @@ flowchart TD ## Decision -We ship **Candidate D, blended with one ergonomic touch from C**. +We ship **Candidate D, blended with the consent-gated ergonomics of C**. - **From D**: two-script split (`install.sh`, `run.sh`); we *detect* - Ollama rather than installing it; we never start daemons in - `install.sh`; the run-time server still launches if Ollama is down so - the UI is usable and the failure is observable in the chat panel. -- **From C**: in `install.sh`, if Ollama *is* present, we offer to - `ollama pull` the default model on the user's behalf — gated by - `TUTOR_SKIP_MODEL_PULL` and skippable in CI. Pulling a model the user - already chose to have Ollama for is low-risk and saves a step. + Ollama and the model rather than installing them silently; the + run-time server still launches if Ollama is down so the UI is usable + and the failure is observable in the chat panel. +- **From C**: when something host-level is missing — the Ollama binary, + the `ollama serve` daemon, or the default model — `install.sh` + *offers* to handle it. The user types `y` to accept. The default + answer is **no**, so pressing Enter never installs a system binary. This blend has: - 2 commands typed (`./install.sh`, `./run.sh`) — same as B/D/E. -- Zero hidden system-level installs. -- One actionable error message if Ollama is missing (we print the - install command for the user's platform). +- Zero hidden system-level installs. Every system-touching action is + preceded by an explicit y/N confirmation. +- A single non-interactive entry-point for CI + (`TUTOR_NONINTERACTIVE=1` defaults all prompts to "no"; + `PYTHON_TUTOR_ASSUME_YES=1` defaults them to "yes" for pre-approved + unattended setup). - A web UI that loads even when the LLM is unreachable — so the learner always gets *something* to interact with. +```mermaid +flowchart TD + Clone[git clone repo] --> Install[./install.sh] + Install --> Py[Python 3.10+ check] + Py --> Venv[Create / reuse backend/.venv] + Venv --> Pip[pip install backend deps] + Pip --> HasOllama{ollama on PATH?} + HasOllama -- no --> AskInstall{Install Ollama now? y/N} + AskInstall -- y --> RunInstaller[Run upstream installer] + AskInstall -- N --> HintInstall[Print manual install hint] + RunInstaller --> Daemon{Daemon on :11434?} + HasOllama -- yes --> Daemon + HintInstall --> NextBanner + Daemon -- yes --> Model{Model present?} + Daemon -- no --> AskStart{Start 'ollama serve' now? y/N} + AskStart -- y --> Spawn[nohup ollama serve & probe up to 10s] + AskStart -- N --> HintStart[Print 'run: ollama serve' hint] + Spawn --> Model + HintStart --> NextBanner + Model -- yes --> NextBanner + Model -- no --> AskPull{"Pull model? y/N"} + AskPull -- y --> Pull[ollama pull TUTOR_MODEL] + AskPull -- N --> HintPull[Print 'ollama pull' hint] + Pull --> NextBanner + HintPull --> NextBanner + NextBanner --> AskLaunch{Launch ./run.sh? y/N} + AskLaunch -- y --> Run[./run.sh → uvicorn] + AskLaunch -- N --> Done[Print next-step banner] + Run --> Browser[Open http://localhost:8001] +``` + ## How the scripts behave ### `install.sh` @@ -181,20 +215,28 @@ This blend has: 1. Detect Python ≥3.10. If missing or too old, print install command, exit 1. 2. Create `backend/.venv` if it doesn't exist; otherwise reuse it. -3. `pip install -r backend/requirements-dev.txt` (idempotent). -4. Check `ollama` on `PATH`. If missing, print install command and exit - 0 (success — the Python side is set up). User can re-run install - later, or just run. -5. If `ollama` is present, probe `http://localhost:11434/api/tags`. If - the daemon is up, pull the default model (skippable via - `TUTOR_SKIP_MODEL_PULL=1`). If the daemon is down, print - `ollama serve &` and continue. -6. Print next-step banner: `./run.sh`. +3. `pip install -r backend/requirements-dev.txt` (idempotent; + not gated by a prompt — those changes live inside the repo). +4. Check `ollama` on `PATH`. If missing, **prompt** to install via the + OS-appropriate upstream installer (`brew install ollama` on macOS, + `curl https://ollama.com/install.sh | sh` on Linux). Decline and + the script prints the manual command and continues. +5. If `ollama` is present, probe `http://localhost:11434/api/tags`. + If the daemon is down, **prompt** to start `ollama serve` in the + background (`nohup`, logged to `/tmp/ollama-serve.log`). +6. If the daemon is up, check for the model + (`TUTOR_MODEL`, default `gemma3:4b`). If absent, **prompt** to + `ollama pull` it. +7. After setup, **prompt** "Launch the tutor now (./run.sh)?". + `PYTHON_TUTOR_AUTOLAUNCH=1` or `PYTHON_TUTOR_ASSUME_YES=1` answers + yes without asking. ### `run.sh` -1. Ensure venv exists (re-run `install.sh` if not). -2. Probe Ollama; warn if unreachable but continue. +1. Ensure venv exists (re-run `install.sh` in non-interactive, + skip-Ollama mode if not — this never installs system binaries). +2. Probe Ollama; if installed but the daemon is down, **prompt** to + start `ollama serve`. If declined, warn and continue. 3. Launch uvicorn with `TUTOR_SERVE_FRONTEND=1` so the backend serves the static frontend on the same port. 4. Print the URL: `http://localhost:8001/`. @@ -206,25 +248,34 @@ This blend has: - `TUTOR_MODEL` — Ollama model tag (default `gemma3:4b`). - `TUTOR_SKIP_OLLAMA=1` — skip every Ollama probe (CI/offline-dev). - `TUTOR_SKIP_MODEL_PULL=1` — skip `ollama pull` in install. -- `TUTOR_NONINTERACTIVE=1` — never prompt; assume defaults. +- `TUTOR_NONINTERACTIVE=1` — never prompt; auto-answer **no**. +- `PYTHON_TUTOR_NONINTERACTIVE=1` — alias for the above. +- `PYTHON_TUTOR_ASSUME_YES=1` — never prompt; auto-answer **yes**. +- `PYTHON_TUTOR_AUTOLAUNCH=1` — `exec ./run.sh` after install + without asking. ## What the user does +Default interactive flow: + ```bash gh repo clone StewAlexander-com/python-tutor cd python-tutor -./install.sh # ~2 min cold; reuses cache on re-run -./run.sh # opens at http://localhost:8001/ +./install.sh # ~2 min cold; prompts y/N for each system action + # answer 'y' to install Ollama, start the daemon, + # pull the model, and launch the app ``` -If Ollama is missing, `install.sh` will tell them exactly what to type: +If you'd rather drive it yourself, decline every prompt and the script +still finishes successfully — only the Python side is set up, with +clear hints for what to run next. + +Unattended: ```bash -# macOS -brew install ollama && ollama serve & +# Pre-approved: install Ollama, start it, pull the model, exec run.sh. +PYTHON_TUTOR_ASSUME_YES=1 ./install.sh -# Linux -curl -fsSL https://ollama.com/install.sh | sh && ollama serve & +# CI: do not touch Ollama at all. +TUTOR_SKIP_OLLAMA=1 TUTOR_NONINTERACTIVE=1 ./install.sh ``` - -Then `./install.sh && ./run.sh` again. diff --git a/install.sh b/install.sh index 190fa7a..8abc18d 100755 --- a/install.sh +++ b/install.sh @@ -5,19 +5,31 @@ # 1. Verifies Python >= 3.10. # 2. Creates backend/.venv if missing. # 3. Installs backend dependencies (dev extras included for tests). -# 4. Checks whether Ollama is installed and running, and offers to pull -# the default model. Never installs Ollama itself. +# 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. # # What this script does NOT do: -# - Start a server. -# - Install Ollama, brew, curl, or any system package. +# - 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. # # 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 defaults +# 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) set -euo pipefail repo_root="$(cd "$(dirname "$0")" && pwd)" @@ -37,7 +49,54 @@ err() { printf "%b%s%b\n" "$c_red" "[install] $*" "$c_off" >&2; } 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:-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}" + +# ----- 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}" + + if [ "$PYTHON_TUTOR_ASSUME_YES" = "1" ]; then + printf "%b%s%b %s [auto-yes]\n" "$c_blu" "[install]" "$c_off" "$question" + return 0 + fi + if [ "$TUTOR_NONINTERACTIVE" = "1" ]; then + printf "%b%s%b %s [auto-no]\n" "$c_blu" "[install]" "$c_off" "$question" + 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 + + local reply="" + printf "%b%s%b %s %s " "$c_blu" "[install]" "$c_off" "$question" "$hint" + IFS= read -r reply || reply="" + case "$reply" in + y|Y|yes|Yes|YES) return 0 ;; + n|N|no|No|NO) return 1 ;; + "") + if [ "$default_choice" = "default-yes" ]; then return 0; fi + return 1 + ;; + *) return 1 ;; + esac +} # ----- 1. Python ------------------------------------------------------------- PY="" @@ -79,54 +138,189 @@ if ! "$venv_dir/bin/python" -c "import sys" >/dev/null 2>&1; then fi # ----- 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. + +# 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 + +ollama_install_cmd_macos='brew install ollama' +ollama_install_cmd_linux='curl -fsSL https://ollama.com/install.sh | sh' + +print_ollama_manual_hint() { + warn "You can install Ollama manually any time:" + case "$os_kind" in + macos) warn " $ollama_install_cmd_macos" ;; + linux) warn " $ollama_install_cmd_linux" ;; + *) 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." +} + +install_ollama_now() { + case "$os_kind" in + macos) + if ! command -v brew >/dev/null 2>&1; then + err "Homebrew is required to install Ollama on macOS automatically." + err "Install brew from https://brew.sh, then re-run ./install.sh." + return 1 + fi + say "running: $ollama_install_cmd_macos" + if brew install ollama; then + ok "Ollama installed via Homebrew." + return 0 + fi + err "brew install ollama failed." + return 1 + ;; + linux) + if ! command -v curl >/dev/null 2>&1; then + err "curl is required to install Ollama on Linux automatically." + err "Install curl with your package manager, then re-run ./install.sh." + 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 + err "Ollama installer exited non-zero." + return 1 + ;; + *) + err "Automatic Ollama install is only supported on macOS and Linux." + err "See https://ollama.com/download for your platform." + return 1 + ;; + esac +} + +ollama_daemon_up() { + curl -fsS --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1 +} + +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)" + return 0 + fi + sleep 0.5 + done + err "ollama serve did not become reachable on :11434 within 10s." + err "Inspect /tmp/ollama-serve.log or run 'ollama serve' in another terminal." + return 1 +} + +model_present() { + curl -fsS --max-time 2 http://localhost:11434/api/tags 2>/dev/null \ + | 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" else + # 4a. Is the binary present? if ! command -v ollama >/dev/null 2>&1; then - warn "ollama is not installed." - warn " macOS: brew install ollama && ollama serve &" - warn " Linux: curl -fsSL https://ollama.com/install.sh | sh && ollama serve &" - warn "Re-run ./install.sh after installing Ollama to pull the default model." - warn "The web UI will still work — chat replies will fail until Ollama is up." - else + warn "Ollama is not installed." + case "$os_kind" in + macos|linux) + if confirm "Install Ollama now? (will run the official upstream installer)" default-no; then + if install_ollama_now; then + ok "ollama is installed ($(command -v ollama 2>/dev/null || echo 'not on PATH yet'))" + else + print_ollama_manual_hint + fi + else + print_ollama_manual_hint + fi + ;; + *) + print_ollama_manual_hint + ;; + esac + fi + + # 4b. Is the daemon reachable? + if command -v ollama >/dev/null 2>&1; then ok "ollama is installed ($(command -v ollama))" - # Probe the daemon. - if curl -fsS --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1; then + if ollama_daemon_up; then ok "ollama daemon is reachable on http://localhost:11434" - if [ "$TUTOR_SKIP_MODEL_PULL" = "1" ]; then - warn "TUTOR_SKIP_MODEL_PULL=1 — skipping model pull" + else + warn "Ollama is installed but the daemon is not running on :11434." + if confirm "Start 'ollama serve' in the background now?" default-no; then + if ! start_ollama_now; then + warn "Could not auto-start. Run 'ollama serve' in another terminal and re-run ./install.sh." + fi else - # Does the model already exist locally? - if curl -fsS --max-time 2 http://localhost:11434/api/tags 2>/dev/null \ - | grep -F -q "\"$TUTOR_MODEL\""; then - ok "model '$TUTOR_MODEL' already present" + warn "Skipping auto-start. Run 'ollama serve' yourself in another terminal." + fi + fi + fi + + # 4c. Is the 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" + elif model_present; then + ok "model '$TUTOR_MODEL' already present" + else + warn "Model '$TUTOR_MODEL' is not present locally." + if confirm "Pull '$TUTOR_MODEL' now? (this can take several minutes)" default-no; then + if ollama pull "$TUTOR_MODEL"; then + ok "model '$TUTOR_MODEL' ready" else - say "pulling model '$TUTOR_MODEL' (this can take several minutes)…" - if ollama pull "$TUTOR_MODEL"; then - ok "model '$TUTOR_MODEL' ready" - else - warn "ollama pull failed. You can retry later with: ollama pull $TUTOR_MODEL" - fi + warn "ollama pull failed. You can retry later with: ollama pull $TUTOR_MODEL" fi + else + warn "Skipping pull. Retry later with: ollama pull $TUTOR_MODEL" fi - else - warn "ollama is installed but the daemon is not running." - warn "Start it in another terminal: ollama serve" - warn "Then re-run ./install.sh to pull the default model." fi fi fi -# ----- next step ------------------------------------------------------------- +# ----- 5. Optional auto-launch ---------------------------------------------- echo ok "install complete." echo + +launch_now=0 +if [ "$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" + exec "$repo_root/run.sh" +fi + echo "Next step:" echo " ./run.sh # starts the tutor at http://localhost:8001/" echo diff --git a/run.sh b/run.sh index ced607f..a0f0fea 100755 --- a/run.sh +++ b/run.sh @@ -2,10 +2,13 @@ # 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_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 # # 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 @@ -29,10 +32,70 @@ 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}" + +# ----- prompt helper (mirrors install.sh) ----------------------------------- +confirm() { + local question="$1" + local default_choice="${2:-default-no}" + + if [ "$PYTHON_TUTOR_ASSUME_YES" = "1" ]; then + printf "%b%s%b %s [auto-yes]\n" "$c_blu" "[run]" "$c_off" "$question" + return 0 + fi + if [ "$TUTOR_NONINTERACTIVE" = "1" ]; then + printf "%b%s%b %s [auto-no]\n" "$c_blu" "[run]" "$c_off" "$question" + return 1 + fi + if [ ! -t 0 ]; then + printf "%b%s%b %s [no TTY → no]\n" "$c_yel" "[run]" "$c_off" "$question" + return 1 + fi + + local hint + if [ "$default_choice" = "default-yes" ]; then hint="[Y/n]"; else hint="[y/N]"; fi + local reply="" + printf "%b%s%b %s %s " "$c_blu" "[run]" "$c_off" "$question" "$hint" + IFS= read -r reply || reply="" + case "$reply" in + y|Y|yes|Yes|YES) return 0 ;; + n|N|no|No|NO) return 1 ;; + "") + if [ "$default_choice" = "default-yes" ]; then return 0; fi + return 1 ;; + *) return 1 ;; + esac +} + +ollama_daemon_up() { + curl -fsS --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1 +} + +start_ollama_now() { + say "starting 'ollama serve' in the background" + # shellcheck disable=SC2069 + nohup ollama serve >/tmp/ollama-serve.log 2>&1 & + local pid=$! + for _ in $(seq 1 20); do + if ollama_daemon_up; then + ok "ollama serve is up (pid $pid; log: /tmp/ollama-serve.log)" + return 0 + fi + sleep 0.5 + done + err "ollama serve did not become reachable on :11434 within 10s." + 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. + TUTOR_NONINTERACTIVE="${TUTOR_NONINTERACTIVE:-1}" \ + TUTOR_SKIP_OLLAMA=1 \ + PYTHON_TUTOR_AUTOLAUNCH=0 \ ./install.sh fi @@ -40,12 +103,18 @@ if [ "$TUTOR_SKIP_OLLAMA" = "1" ]; then 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 " macOS: brew install ollama && ollama serve &" - warn " Linux: curl -fsSL https://ollama.com/install.sh | sh && ollama serve &" -elif ! curl -fsS --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1; then + warn " Run ./install.sh and answer 'y' when asked to install Ollama, or:" + warn " macOS: brew install ollama" + warn " Linux: curl -fsSL https://ollama.com/install.sh | sh" +elif ! ollama_daemon_up; then warn "ollama is installed but the daemon is not reachable on :11434." - warn "Start it in another terminal: ollama serve" - warn "Chat replies will return 503 until Ollama is up." + if confirm "Start 'ollama serve' in the background now?" default-no; then + if ! start_ollama_now; then + warn "Could not auto-start. Chat replies will return 503 until you run 'ollama serve'." + fi + else + warn "Continuing without Ollama. Chat replies will return 503 until you run 'ollama serve'." + fi else ok "ollama daemon reachable on :11434" fi diff --git a/scripts/smoke_prompts.sh b/scripts/smoke_prompts.sh new file mode 100755 index 0000000..4a0348d --- /dev/null +++ b/scripts/smoke_prompts.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# scripts/smoke_prompts.sh — exercise the y/N prompt code paths in install.sh +# without ever installing system binaries or starting daemons. +# +# We invoke install.sh with various env combos and assert: +# - the install completes, +# - prompts auto-resolve (no hangs waiting for stdin), +# - the auto-no / auto-yes markers print as expected, +# - backend/.venv is left usable. +# +# To keep the test fast and avoid actually starting uvicorn, we shadow +# run.sh with a no-op stub when testing the ASSUME_YES auto-launch path. +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +log="$(mktemp)" +stub_dir="$(mktemp -d)" +trap 'rm -f "$log"; rm -rf "$stub_dir"' EXIT + +# Set TUTOR_SKIP_OLLAMA in every invocation so we never run apt/brew/curl. + +echo "--- Run 1: TUTOR_SKIP_OLLAMA=1 + TUTOR_NONINTERACTIVE=1 (CI baseline) ---" +TUTOR_SKIP_OLLAMA=1 TUTOR_NONINTERACTIVE=1 ./install.sh &1 | tee "$log" +grep -q "install complete." "$log" || { echo "FAIL: missing 'install complete.'" >&2; exit 1; } +test -x backend/.venv/bin/python || { echo "FAIL: venv missing" >&2; exit 1; } +grep -q "Launch the tutor now" "$log" || { echo "FAIL: launch prompt did not fire" >&2; exit 1; } +grep -q "\[auto-no\]" "$log" || { echo "FAIL: auto-no marker missing" >&2; exit 1; } +echo "ok: run 1" + +echo +echo "--- Run 2: PYTHON_TUTOR_NONINTERACTIVE alias ---" +PYTHON_TUTOR_NONINTERACTIVE=1 TUTOR_SKIP_OLLAMA=1 ./install.sh &1 | tee "$log" +grep -q "install complete." "$log" || { echo "FAIL: alias produced no successful install" >&2; exit 1; } +grep -q "\[auto-no\]" "$log" || { echo "FAIL: alias did not trigger auto-no" >&2; exit 1; } +echo "ok: run 2" + +echo +echo "--- Run 3: PYTHON_TUTOR_ASSUME_YES auto-launches (stubbed run.sh) ---" +# Replace ./run.sh with a stub for the duration of this test. We can't +# safely write to repo_root/run.sh (we'd corrupt the working copy), so we +# wrap install.sh: copy it to a temp dir, point it at a stubbed run.sh. +cat >"$stub_dir/run.sh" <<'STUB' +#!/usr/bin/env bash +echo "[stub-run] would launch uvicorn here" +exit 0 +STUB +chmod +x "$stub_dir/run.sh" +# Run install.sh from its real location but with PATH-shadowed exec target. +# install.sh execs "$repo_root/run.sh" by absolute path, so PATH shadowing +# alone won't catch it. Instead we temporarily symlink run.sh aside. +mv run.sh "$stub_dir/run.sh.real" +ln -s "$stub_dir/run.sh" run.sh +set +e +PYTHON_TUTOR_ASSUME_YES=1 TUTOR_SKIP_OLLAMA=1 ./install.sh &1 | tee "$log" +rc=${PIPESTATUS[0]} +set -e +rm -f run.sh +mv "$stub_dir/run.sh.real" run.sh +chmod +x run.sh +test "$rc" -eq 0 || { echo "FAIL: ASSUME_YES install exited $rc" >&2; exit 1; } +grep -q "\[auto-yes\]" "$log" || { echo "FAIL: auto-yes marker missing" >&2; exit 1; } +grep -q "\[stub-run\] would launch uvicorn here" "$log" || \ + { echo "FAIL: did not exec run.sh under ASSUME_YES" >&2; exit 1; } +echo "ok: run 3" + +echo +echo "prompt smoke ok"