fix(install): use requires-python range; bootstrap shims are Python-free (#476, #484) (#492)

Consolidates two reports that overlap in scope:

- #476 (@senki): install.sh hardcoded `--python 3.11` but
  pyproject.toml declares `requires-python = ">=3.10,<3.14"`. The
  installer should track the project's allowed range, not pin a
  conservative-three-years-ago version.
- #484 (@sanjayravit): install.sh crashed on hosts with no
  python3/python on PATH because `mark_done`, `beacon`, and
  `get_anon_id` all called $PY_CMD via inline Python heredocs.

This PR closes both gaps with five surgical edits to install.sh
(all behavior-preserving for the existing happy path; the bats
tests prove it):

1. get_anon_id — replace `python3 -c uuid` with POSIX
   `/dev/urandom + od` + bash substring expansion. Same UUID v4
   shape; works on Python-less hosts.

2. beacon — replace the 40-line Python heredoc with curl + a
   shell-built JSON payload. All inputs are from controlled
   sources (event ∈ fixed vocabulary, stage from stage_label(),
   numeric ids/codes from validated arithmetic, anon_id from a
   fresh UUID) so no general-purpose JSON escaping is needed.
   `|| true` is load-bearing — PostHog 5xx must never abort an
   install via the ERR trap.

3. mark_done — replace `python3 -c json.load+update+dump` with
   awk that regenerates the file from scratch. Idempotent: if
   the key is already marked, return early. Robust against
   prior format drift. wsl key is always rewritten last so a
   later FORCE_WSL=1 re-run correctly updates it.

4. parse_requires_python — new helper. Greps the project's
   requires-python field, handles inclusive (`<=3.13`) and
   exclusive (`<3.14`) upper bounds correctly, falls back to
   3.11 if pyproject can't be parsed (the previous hardcoded
   value — safe under the existing 3.10-3.13 range).

5. create_venv — call parse_requires_python instead of
   hardcoding 3.11. The existing uv-managed-Python fallback
   (from #444) still kicks in if the host doesn't have the
   target version installed.

Adversarial review caught two real bugs before commit:

- HIGH: parse_requires_python's exclusive-bound regex would
  also match the digits after `<=` (inclusive bound) and then
  incorrectly subtract 1 — producing 3.12 from `<=3.13`. Fixed
  by checking the inclusive form first.
- MEDIUM: the new "no Python on PATH" bats test silently skips
  symlinking `pgrep` on hosts where it isn't at /usr/bin or
  /bin (some minimal BusyBox configurations). Added an explicit
  "no matching process" fallback so start_ollama's check works.

New bats coverage:

- `install succeeds with no system Python on PATH (#484)` —
  builds a PATH that excludes python3/python and exercises the
  full install. Asserts state file is written and contains
  greppable step keys.
- `mark_done is idempotent — second mark of same key doesn't
  duplicate` — re-runs the install and verifies install_uv
  appears exactly once in install-state.json.
- `create_venv picks newest in requires-python range, not
  hardcoded 3.11 (#476)` — asserts uv was called with
  `--python 3.13` (the upper minor of `>=3.10,<3.14`).

The git stub now includes `requires-python = ">=3.10,<3.14"`
in its fake pyproject.toml so create_venv has something
realistic to parse.

@sanjayravit — your PR #484 motivated this consolidation;
closing that one as superseded with credit.

Co-authored-by: krypticmouse <herumbshandilya123@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-06-03 16:59:29 -07:00
committed by GitHub
co-authored by krypticmouse Claude Opus 4.7
parent 58e822ff91
commit de3e86c544
3 changed files with 202 additions and 69 deletions
+143 -69
View File
@@ -262,15 +262,19 @@ detect_arch() {
}
get_anon_id() {
# POSIX shell UUID v4 — no Python required so this works on hosts
# where python3/python aren't on PATH yet (#484). The script must
# not crash on those hosts; analytics are best-effort.
if [[ -f "$ANON_ID_FILE" ]]; then
cat "$ANON_ID_FILE"
return
fi
local new_id
new_id="$("$PY_CMD" -c 'import uuid; print(uuid.uuid4())' 2>/dev/null || echo "")"
if [[ -z "$new_id" ]]; then
return
local raw new_id
raw="$(od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n')" || true
if [[ ${#raw} -ne 32 ]]; then
return 0 # /dev/urandom unreadable — skip analytics silently
fi
new_id="${raw:0:8}-${raw:8:4}-${raw:12:4}-${raw:16:4}-${raw:20:12}"
echo "$new_id" > "$ANON_ID_FILE"
echo "$new_id"
}
@@ -291,6 +295,13 @@ stage_label() {
beacon() {
# Args: event_name stage_label elapsed_ms exit_code
#
# No Python required (#484) — uses curl + shell-built JSON. All
# inputs are from controlled sources: $event is a fixed-vocabulary
# string (install_started / install_stage_completed / install_failed
# / install_completed), $stage comes from stage_label(), the numeric
# args are validated by the arithmetic that produced them, and
# $anon_id is a fresh UUID. No general-purpose JSON escaping needed.
local event="$1"
local stage="${2:-}"
local elapsed_ms="${3:-0}"
@@ -308,50 +319,35 @@ beacon() {
os="$(detect_os)"
arch="$(detect_arch)"
"$PY_CMD" - "$ANALYTICS_HOST" "$ANALYTICS_KEY" "$event" "$anon_id" \
"$os" "$arch" "$stage" "$elapsed_ms" "$exit_code" \
>/dev/null 2>&1 <<'PYEOF' || true
import json
import sys
import urllib.request
local props
props='"os":"'"$os"'","arch":"'"$arch"'","installer_version":"0.1.1"'
if [[ -n "$stage" ]]; then
props="${props},\"stage\":\"$stage\""
fi
# Map elapsed_ms → total_elapsed_ms for install_completed events
# (matches the old Python beacon's behavior).
if [[ "$event" = "install_completed" ]]; then
props="${props},\"total_elapsed_ms\":${elapsed_ms}"
elif [[ "$elapsed_ms" != "0" ]]; then
props="${props},\"elapsed_ms\":${elapsed_ms}"
fi
if [[ "$exit_code" != "0" ]]; then
props="${props},\"exit_code\":${exit_code}"
fi
host, key, event, distinct_id, os_val, arch, stage, elapsed_ms, exit_code = sys.argv[1:10]
props = {
"os": os_val,
"arch": arch,
"installer_version": "0.1.1",
}
if stage:
props["stage"] = stage
if elapsed_ms and elapsed_ms != "0":
try:
props["elapsed_ms"] = int(elapsed_ms)
except ValueError:
pass
if exit_code and exit_code != "0":
try:
props["exit_code"] = int(exit_code)
except ValueError:
pass
if event == "install_completed":
props["total_elapsed_ms"] = props.pop("elapsed_ms", 0)
payload = {
"api_key": key,
"event": event,
"distinct_id": distinct_id,
"properties": props,
}
req = urllib.request.Request(
f"{host}/i/v0/e/",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
urllib.request.urlopen(req, timeout=5)
except Exception:
pass
PYEOF
local payload
payload="{\"api_key\":\"$ANALYTICS_KEY\",\"event\":\"$event\",\"distinct_id\":\"$anon_id\",\"properties\":{$props}}"
# Fire and forget. `|| true` is load-bearing: the script runs under
# `set -e` via the ERR trap, and we never want a flaky PostHog post
# to abort an install. The trap is already installed; without the
# explicit `|| true` a 5xx or DNS failure would tickle it.
curl -s -X POST \
-H 'Content-Type: application/json' \
-d "$payload" \
--max-time 5 \
"${ANALYTICS_HOST}/i/v0/e/" \
>/dev/null 2>&1 || true
}
_on_install_error() {
@@ -367,19 +363,47 @@ state_done() {
}
mark_done() {
if [[ ! -f "$STATE_FILE" ]]; then
# Shell-only state-file update so the install never crashes when no
# python3/python is on PATH (#484). awk regenerates the file from
# scratch each call, which is robust against any prior format drift.
#
# Format: a flat JSON object of `"<step_name>": true` lines plus a
# `"wsl": true|false` trailer. state_done() matches against this
# with a grep — the content only has to be greppable, but we keep
# it valid JSON for any tooling that wants to parse it.
local key="$1"
if [[ ! -f "$STATE_FILE" ]] || [[ ! -s "$STATE_FILE" ]]; then
echo '{}' > "$STATE_FILE"
fi
"$PY_CMD" - "$STATE_FILE" "$1" "$WSL" <<'PYEOF'
import json, sys
path, key, wsl = sys.argv[1], sys.argv[2], sys.argv[3]
with open(path) as f:
data = json.load(f)
data[key] = True
data["wsl"] = bool(int(wsl))
with open(path, "w") as f:
json.dump(data, f, indent=2)
PYEOF
# Already marked? Nothing to do.
if grep -q "\"$key\":[[:space:]]*true" "$STATE_FILE"; then
return 0
fi
local wsl_bool
wsl_bool="$([[ $WSL -eq 1 ]] && echo true || echo false)"
local tmp="${STATE_FILE}.tmp.$$"
awk -v new_key="$key" -v wsl="$wsl_bool" '
/"[^"]+":[[:space:]]*true/ {
match($0, /"[^"]+"/)
k = substr($0, RSTART + 1, RLENGTH - 2)
# wsl is rewritten on every call — skip the existing entry so
# we do not emit it twice with potentially different values.
if (k != "wsl") keys[++n] = k
}
END {
keys[++n] = new_key
print "{"
for (i = 1; i <= n; i++) {
printf " \"%s\": true,\n", keys[i]
}
printf " \"wsl\": %s\n", wsl
print "}"
}
' "$STATE_FILE" > "$tmp"
mv "$tmp" "$STATE_FILE"
}
step() {
@@ -424,21 +448,71 @@ copy_scripts() {
chmod +x "$SCRIPTS_DIR"/*.sh
}
# Parse pyproject.toml's requires-python and return the newest minor
# version usable. E.g. ">=3.10,<3.14" → "3.13". Falls back to the
# previous hardcoded "3.11" if pyproject can't be read or the spec
# can't be parsed (#476 — installer should track the project's allowed
# range instead of hardcoding 3.11).
parse_requires_python() {
local pyproject="$1"
local fallback="3.11"
if [[ ! -f "$pyproject" ]]; then
echo "$fallback"
return 0
fi
local spec
spec="$(grep '^requires-python' "$pyproject" | head -1)"
if [[ -z "$spec" ]]; then
echo "$fallback"
return 0
fi
# Inclusive upper bound first ("<=3.13" allows 3.13 itself). Must
# match before the exclusive-bound branch, since "<=" contains "<"
# and the exclusive regex would otherwise extract "3.13" and
# subtract 1 (returning 3.12) — masking the inclusive intent.
local max_incl
max_incl="$(echo "$spec" | sed -n 's/.*<=\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1)"
if [[ -n "$max_incl" ]]; then
echo "$max_incl"
return 0
fi
# Exclusive upper bound ("<3.14" means 3.13 is the highest allowed).
local max
max="$(echo "$spec" | sed -n 's/.*<\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1)"
if [[ -z "$max" ]]; then
echo "$fallback"
return 0
fi
local major minor
major="${max%.*}"
minor="${max#*.}"
minor=$((minor - 1))
if (( minor < 10 )); then
echo "$fallback"
else
echo "${major}.${minor}"
fi
}
create_venv() {
# `uv venv --python 3.11` fails on hosts that don't have Python 3.11
# installed at all (fresh macOS without xcode-select, fresh Ubuntu
# 24.04 which only ships 3.12, etc). uv can bootstrap its own
# interpreter — use that path so the user never has to pre-install
# Python.
# Read pyproject.toml's requires-python upper bound and target the
# highest version in range (#476). Falls back to 3.11 if pyproject
# can't be parsed. uv bootstraps a managed Python if the host
# doesn't have the requested version (fresh macOS without
# xcode-select, fresh Ubuntu 24.04 which only ships 3.12, etc).
#
# We capture stderr to a log file instead of /dev/null so a genuine
# stderr from the first attempt is captured to a log so a genuine
# failure (disk full, broken venv dir, permission denied) is still
# surfaceable when the bootstrap fallback ultimately fails.
# surfaceable when the bootstrap fallback also fails.
local py_version
py_version="$(parse_requires_python "$SRC_DIR/pyproject.toml")"
echo " Target Python: $py_version (from pyproject.toml requires-python)"
local err_log="$STATE_DIR/venv-create.err"
if ! uv venv --python 3.11 "$VENV_DIR" 2>"$err_log"; then
echo " No system Python 3.11 — uv will download a managed one..."
uv python install 3.11
if ! uv venv --python 3.11 "$VENV_DIR"; then
if ! uv venv --python "$py_version" "$VENV_DIR" 2>"$err_log"; then
echo " No system Python $py_version — uv will download a managed one..."
uv python install "$py_version"
if ! uv venv --python "$py_version" "$VENV_DIR"; then
echo " venv creation failed. First attempt's stderr:"
sed 's/^/ /' "$err_log" >&2
return 1
+4
View File
@@ -41,10 +41,14 @@ EOF2
# Make it look like a git repo so subsequent .git checks pass.
mkdir -p "$target/.git"
# Also create a pyproject.toml so editable install would work in real life.
# Includes requires-python so parse_requires_python in create_venv
# has something to match against — install.sh tracks the project's
# allowed range (#476).
cat > "$target/pyproject.toml" <<EOF2
[project]
name = "OpenJarvis"
version = "0.1.1"
requires-python = ">=3.10,<3.14"
EOF2
;;
pull)
+55
View File
@@ -173,3 +173,58 @@ IDEOF
! grep -q "^pull " "$OLLAMA_STUB_LOG"
fi
}
@test "install succeeds with no system Python on PATH (#484)" {
# Build a stubs dir with NO python3 / python — verifies the
# bootstrap shims (get_anon_id, beacon, mark_done) are Python-free.
local no_py_stubs="$TEST_TMPDIR/no_py_stubs"
mkdir -p "$no_py_stubs"
for f in git curl uv ollama cargo rustup; do
cp "$PER_TEST_STUBS/$f" "$no_py_stubs/"
chmod +x "$no_py_stubs/$f"
done
# Provide minimal core utilities the shims need (od, sed, awk, tr, grep).
for util in id cat bash sh head tail awk sed grep od tr cut mv rm mkdir cp chmod ln test pgrep date sleep ps; do
if [[ -x "/usr/bin/$util" ]]; then
ln -sf "/usr/bin/$util" "$no_py_stubs/$util"
elif [[ -x "/bin/$util" ]]; then
ln -sf "/bin/$util" "$no_py_stubs/$util"
fi
done
# pgrep may be absent on minimal BusyBox/Alpine hosts; stub it as
# "no matching process" so start_ollama's existing-daemon check
# falls through to the spawn path (which the ollama stub handles).
if [[ ! -x "$no_py_stubs/pgrep" ]]; then
printf '#!/bin/sh\nexit 1\n' > "$no_py_stubs/pgrep"
chmod +x "$no_py_stubs/pgrep"
fi
PATH="$no_py_stubs" run bash "$SCRIPT" --no-bg-orchestrator --minimal
[ "$status" -eq 0 ]
# State file was still written (mark_done is Python-free).
[ -f "$OPENJARVIS_HOME/.state/install-state.json" ]
# And it still has valid-looking JSON for state_done's grep.
grep -q '"install_uv":' "$OPENJARVIS_HOME/.state/install-state.json"
}
@test "mark_done is idempotent — second mark of same key doesn't duplicate" {
run bash "$SCRIPT" --no-bg-orchestrator
[ "$status" -eq 0 ]
# Capture first state file
local first_count
first_count="$(grep -c '"install_uv":' "$OPENJARVIS_HOME/.state/install-state.json")"
[ "$first_count" -eq 1 ]
# Second run reuses state — keys should not duplicate even if some path re-marks.
run bash "$SCRIPT" --no-bg-orchestrator
[ "$status" -eq 0 ]
local second_count
second_count="$(grep -c '"install_uv":' "$OPENJARVIS_HOME/.state/install-state.json")"
[ "$second_count" -eq 1 ]
}
@test "create_venv picks newest in requires-python range, not hardcoded 3.11 (#476)" {
run bash "$SCRIPT" --no-bg-orchestrator --minimal
[ "$status" -eq 0 ]
# uv stub logs every invocation. We should see `venv --python 3.13 ...`
# (the upper bound of >=3.10,<3.14 is 3.14, so the highest usable is 3.13).
grep -q '^venv --python 3.13' "$UV_STUB_LOG"
}