#!/bin/sh # Preflight: check that this machine has what the stack needs, record where each # tool lives, and set up tools/.venv - before any `wikitool` command can run. # # tools/preflight.sh check, record, set up # tools/preflight.sh --set rg=/opt/rg/rg use a path the user named # # Exit 0: everything is in place and .wikitool-tools.json is complete. # Exit 42: the user has to act. The output says what, why, the command that fixes # it and what happens next; show it verbatim and wait (AGENTS.md ยง Tool # error contract). Never install anything on the user's behalf. # Exit 1: this script was called wrongly, or the tree next to it is incomplete. # # POSIX sh on purpose: it has to run before Python is known to exist, under dash, # bash and the Git Bash that Claude Code uses on Windows. The PowerShell # counterpart is tools/preflight.ps1; what both check is tools/prerequisites.txt. # The procedure around this script: instructions/preflight.md. # # Two variables exist for the test suite and are read nowhere else: # CHEMENU_PREFLIGHT_PLATFORM (windows|macos|linux) and # CHEMENU_PREFLIGHT_LONGPATHS (0|1) stand in for `uname` and the registry. set -u DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) ROOT=$(dirname -- "$DIR") MANIFEST="$DIR/prerequisites.txt" TOOLS_FILE="$ROOT/.wikitool-tools.json" VENV="$DIR/.venv" REQUIREMENTS="$DIR/requirements.txt" STAMP="$VENV/.chemenu-requirements.sha256" SELF="tools/preflight.sh" PYPROBE='import sys; print("%d.%d" % sys.version_info[:2]); print(sys.executable)' usage() { cat <<'EOF' usage: tools/preflight.sh [--set =]... Checks the tools this stack needs (tools/prerequisites.txt), records their paths in .wikitool-tools.json and sets up tools/.venv. Exit 0 means ready; exit 42 means the user has to act - the output says how. EOF } # --- arguments ---------------------------------------------------------------- SETS="" while [ $# -gt 0 ]; do case "$1" in --set) if [ $# -lt 2 ]; then echo "preflight: --set needs =" >&2 exit 1 fi SETS="$SETS $2" shift ;; --set=*) SETS="$SETS ${1#--set=}" ;; -h|--help) usage; exit 0 ;; *) echo "preflight: unknown argument: $1" >&2; usage >&2; exit 1 ;; esac shift done if [ ! -f "$MANIFEST" ]; then echo "preflight: $MANIFEST is missing - this script has to run from inside an unpacked stack tree." >&2 exit 1 fi # --- platform ----------------------------------------------------------------- if [ -n "${CHEMENU_PREFLIGHT_PLATFORM:-}" ]; then PLATFORM=$CHEMENU_PREFLIGHT_PLATFORM else case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;; Darwin) PLATFORM=macos ;; *) PLATFORM=linux ;; esac fi # A path as the rest of the machine spells it: under Git Bash `/c/Tools/rg.exe` # becomes `C:\Tools\rg.exe`, which is what Python and PowerShell read back. native_path() { if [ "$PLATFORM" = windows ] && command -v cygpath >/dev/null 2>&1; then cygpath -w "$1" else printf '%s\n' "$1" fi } # --- problems and the guidance block -------------------------------------------- PROBLEMS=0 GUIDE="" BAD_SET=0 # problem problem() { PROBLEMS=$((PROBLEMS + 1)) fix=$(printf '%s\n' "$3" | sed '2,$s/^/ /') GUIDE="$GUIDE $PROBLEMS) $1 Why: $2 Fix: $fix Next: Tell the agent once this is done - it runs this check again. " } stop() { if [ "$PROBLEMS" -eq 1 ]; then noun="1 thing needs" else noun="$PROBLEMS things need" fi echo "" echo "STOP - $noun your attention before this wiki can run." echo "(Agent: show this output to the user exactly as it is, then wait. Do not install" echo "anything yourself and do not work around it.)" printf '%s' "$GUIDE" exit 42 } # --- the manifest --------------------------------------------------------------- manifest_field() { # while IFS='|' read -r kind name rest; do case "$kind" in ''|'#'*) continue ;; esac if [ "$kind" = "$1" ] && [ "$name" = "$2" ]; then printf '%s|%s|%s\n' "$kind" "$name" "$rest" | cut -d'|' -f"$3" return 0 fi done < "$MANIFEST" return 1 } TOOLS="" while IFS='|' read -r kind name minimum platforms rest; do case "$kind" in ''|'#'*) continue ;; esac [ "$kind" = tool ] || continue case "$name" in *[!a-z0-9]*|'') echo "preflight: bad tool name '$name' in $MANIFEST" >&2; exit 1 ;; esac if [ "$platforms" = all ] || [ "$platforms" = "$PLATFORM" ]; then TOOLS="$TOOLS $name" fi done < "$MANIFEST" # The install command for this machine's package manager, or every known one. install_hint() { # choco=$(manifest_field tool "$1" 7) winget=$(manifest_field tool "$1" 8) brew=$(manifest_field tool "$1" 9) apt=$(manifest_field tool "$1" 10) pacman=$(manifest_field tool "$1" 11) hint="" case "$PLATFORM" in windows) if command -v choco >/dev/null 2>&1 && [ "$choco" != - ]; then hint="$choco (in a terminal opened as administrator)" elif command -v winget >/dev/null 2>&1 && [ "$winget" != - ]; then hint=$winget fi ;; macos) if command -v brew >/dev/null 2>&1 && [ "$brew" != - ]; then hint=$brew; fi ;; *) if command -v apt-get >/dev/null 2>&1 && [ "$apt" != - ]; then hint=$apt elif command -v pacman >/dev/null 2>&1 && [ "$pacman" != - ]; then hint=$pacman fi ;; esac if [ -n "$hint" ]; then printf '%s\n' "$hint" return fi printf 'Install it with the package manager this computer uses, for example:\n' for entry in "choco:$choco" "winget:$winget" "brew:$brew" "apt:$apt" "pacman:$pacman"; do case "$entry" in *:-) continue ;; esac printf ' %s\n' "${entry#*:}" done } # --- version helpers ------------------------------------------------------------ # major.minor of the first version-looking token in $1 ("git version 2.47.1" -> 2.47) version_of() { printf '%s\n' "$1" | sed -n '1{s/^[^0-9]*\([0-9][0-9]*\)\(\.[0-9][0-9]*\)\{0,1\}.*/\1\2/p;}' } version_ge() { # , both major[.minor] have=$1 want=$2 case "$have" in *.*) ;; *) have="$have.0" ;; esac case "$want" in *.*) ;; *) want="$want.0" ;; esac hm=${have%%.*} hn=${have#*.} wm=${want%%.*} wn=${want#*.} [ "$hm" -gt "$wm" ] || { [ "$hm" -eq "$wm" ] && [ "$hn" -ge "$wn" ]; } } # --- finding tools -------------------------------------------------------------- # The Microsoft Store's app-execution aliases: a python.exe that opens the Store # instead of running anything. Never executed, never recorded. is_store_alias() { case "$1" in *[Ww]indows[Aa]pps/*|*[Ww]indows[Aa]pps\\*) return 0 ;; esac return 1 } # Every executable called on PATH, in PATH order, store aliases dropped. on_path() { set -f old_ifs=$IFS IFS=: for d in $PATH; do [ -n "$d" ] || d=. for f in "$d/$1" "$d/$1.exe"; do if [ -f "$f" ] && [ -x "$f" ] && ! is_store_alias "$f"; then printf '%s\n' "$f" fi done done IFS=$old_ifs set +f } usable() { # [ -n "$1" ] && [ -f "$1" ] && [ -x "$1" ] && ! is_store_alias "$1" } # Runs a Python candidate; prints "\n". probe_python() { # [extra argument, e.g. -3 for py] if [ $# -gt 1 ]; then "$1" "$2" -c "$PYPROBE" 2>/dev/null | tr -d '\r' else "$1" -c "$PYPROBE" 2>/dev/null | tr -d '\r' fi } # The value recorded for in .wikitool-tools.json, unescaped. recorded() { [ -f "$TOOLS_FILE" ] || return 1 value=$(sed -n "s/^ *\"$1\": *\"\(.*\)\",\{0,1\} *\$/\1/p" "$TOOLS_FILE" | head -n 1) [ -n "$value" ] || return 1 printf '%s\n' "$value" | sed 's/\\"/"/g; s/\\\\/\\/g' } set_value() { # -> the path given with --set, if any printf '%s\n' "$SETS" | sed -n "s/^$1=//p" | head -n 1 } # --- --set: every named path has to work, or nothing is written ------------------- for entry in $(printf '%s\n' "$SETS" | sed -n 's/^\([^=]*\)=.*/\1/p'); do case " $TOOLS " in *" $entry "*) ;; *) echo "preflight: --set names '$entry', which is not a tool this platform needs:$TOOLS" >&2 exit 1 ;; esac done # --- python ------------------------------------------------------------------------- PY="" PY_VERSION="" PY_OLD="" PY_MIN=$(manifest_field tool python 3) try_python() { # [extra] -> sets PY/PY_VERSION on success, PY_OLD when too old out=$(probe_python "$@") || return 1 version=$(printf '%s\n' "$out" | sed -n 1p) executable=$(printf '%s\n' "$out" | sed -n 2p) [ -n "$version" ] && [ -n "$executable" ] || return 1 if version_ge "$version" "$PY_MIN"; then PY=$executable PY_VERSION=$version return 0 fi PY_OLD="$version $1" return 1 } python_extra() { # py and py.exe are the launcher: they need -3 case "${1##*/}" in py|py.exe) printf '%s\n' -3 ;; esac } given=$(set_value python) if [ -n "$given" ]; then extra=$(python_extra "$given") if ! usable "$given" || ! try_python "$given" $extra; then problem "The path given for Python does not work: $given" \ "every wikitool command runs on Python $PY_MIN or newer, and this path did not start one" \ "Check the path - it has to be the python executable itself, version $PY_MIN or newer. Then run: $SELF --set python=" BAD_SET=1 fi else previous=$(recorded python) || previous="" if [ -n "$previous" ] && usable "$previous"; then try_python "$previous" || true fi if [ -z "$PY" ]; then if [ "$PLATFORM" = windows ]; then order="python: py:-3 python3:" else order="python3: python:" fi for candidate in $order; do name=${candidate%%:*} extra=${candidate#*:} found=$(on_path "$name") [ -n "$found" ] || continue # A here-document, not a pipe: `break 2` has to leave the outer loop # of this shell, and a path may contain spaces ("Program Files"). while IFS= read -r path; do if [ -n "$extra" ]; then try_python "$path" "$extra" && break 2 else try_python "$path" && break 2 fi done < REPORT="$REPORT$(printf '%-8s %-7s %-8s %s' "$1" "$2" "$3" "$4") " } if [ -n "$PY" ]; then report OK python "$PY_VERSION" "$PY" eval "FOUND_python=\$PY" elif [ "$BAD_SET" -eq 0 ]; then label=$(manifest_field tool python 5) why=$(manifest_field tool python 6) if [ -n "$PY_OLD" ]; then report TOO_OLD python "${PY_OLD%% *}" "${PY_OLD#* }" problem "$label ${PY_OLD%% *} is too old - this stack needs $PY_MIN or newer." "$why" \ "$(install_hint python) Or, if a newer one is already installed, give its full path: $SELF --set python=" else report MISSING python - - problem "$label $PY_MIN or newer was not found." "$why" \ "$(install_hint python) Or, if it is installed somewhere this check did not look, give its full path: $SELF --set python=" fi fi for tool in $TOOLS; do [ "$tool" = python ] && continue label=$(manifest_field tool "$tool" 5) why=$(manifest_field tool "$tool" 6) minimum=$(manifest_field tool "$tool" 3) given=$(set_value "$tool") path="" if [ -n "$given" ]; then if usable "$given"; then path=$given else problem "The path given for $label does not work: $given" "$why" \ "Check the path - it has to be the $tool executable itself. Then run: $SELF --set $tool=" BAD_SET=1 continue fi else previous=$(recorded "$tool") || previous="" if [ -n "$previous" ] && usable "$previous"; then path=$previous else path=$(on_path "$tool" | head -n 1) fi fi if [ -z "$path" ]; then report MISSING "$tool" - - problem "$label was not found." "$why" \ "$(install_hint "$tool") Or, if it is installed somewhere this check did not look, give its full path: $SELF --set $tool=" continue fi version=$(version_of "$("$path" --version 2>/dev/null | tr -d '\r')") if [ "$minimum" != - ] && { [ -z "$version" ] || ! version_ge "$version" "$minimum"; }; then report TOO_OLD "$tool" "${version:-unknown}" "$path" problem "$label ${version:-of unknown version} is too old - this stack needs $minimum or newer." "$why" \ "$(install_hint "$tool")" continue fi native=$(native_path "$path") report OK "$tool" "${version:--}" "$native" eval "FOUND_$tool=\$native" done # --- install folder length (Windows, long paths off) ------------------------------ longpaths_enabled() { if [ -n "${CHEMENU_PREFLIGHT_LONGPATHS:-}" ]; then [ "$CHEMENU_PREFLIGHT_LONGPATHS" = 1 ] return fi # MSYS would rewrite the `/v` into a path without the exclusion. An unreadable # key counts as "off": the limit then protects a machine it did not have to. answer=$(MSYS2_ARG_CONV_EXCL='*' reg query 'HKLM\SYSTEM\CurrentControlSet\Control\FileSystem' \ /v LongPathsEnabled 2>/dev/null) || return 1 case "$answer" in *0x1*) return 0 ;; esac return 1 } # Length in UTF-16 code units, which is what MAX_PATH counts. utf16_length() { if command -v iconv >/dev/null 2>&1; then bytes=$(printf '%s' "$1" | iconv -f UTF-8 -t UTF-16LE 2>/dev/null | wc -c | tr -d ' ') if [ "${bytes:-0}" -gt 0 ]; then echo $((bytes / 2)) return fi fi echo ${#1} } if [ "$PLATFORM" = windows ] && ! longpaths_enabled; then limit=$(manifest_field limit install_dir_max 3) folder=$(native_path "$ROOT") length=$(utf16_length "$folder") if [ "$length" -gt "$limit" ]; then problem "The folder this wiki is installed in is too long ($length characters, at most $limit): $folder" \ "Windows on this computer only allows paths of up to 259 characters, and the wiki's own files need the rest" \ "Move the wiki to a shorter folder, for example C:\\Chemenu, and run this check there. Alternatively, someone with administrator rights can turn on long paths in Windows." fi fi # --- record what was found ---------------------------------------------------------- json_string() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } write_tools_file() { # tmp="$TOOLS_FILE.tmp.$$" { printf '{\n "schema": 1,\n "complete": %s,\n "tools": {' "$1" separator="" for tool in $TOOLS; do eval "value=\${FOUND_$tool:-}" [ -n "$value" ] || continue printf '%s\n "%s": "%s"' "$separator" "$tool" "$(json_string "$value")" separator="," done printf '\n }\n}\n' } > "$tmp" && mv -f "$tmp" "$TOOLS_FILE" } printf '%s' "$REPORT" if [ "$BAD_SET" -eq 1 ]; then stop # nothing is written for a path that does not work fi write_tools_file false if [ "$PROBLEMS" -gt 0 ]; then stop fi # --- tools/.venv ---------------------------------------------------------------------- venv_python() { if [ -x "$VENV/bin/python" ]; then printf '%s\n' "$VENV/bin/python" elif [ -f "$VENV/Scripts/python.exe" ]; then printf '%s\n' "$VENV/Scripts/python.exe" else return 1 fi } last_lines() { printf '%s\n' "$1" | tail -n 5 | sed 's/^/ /' } VPY=$(venv_python) || VPY="" if [ -z "$VPY" ] || ! "$VPY" -m pip --version >/dev/null 2>&1; then if ! output=$("$PY" -m venv --clear "$VENV" 2>&1) || ! VPY=$(venv_python); then fix="Python said: $(last_lines "$output")" if [ "$PLATFORM" = linux ] && command -v apt-get >/dev/null 2>&1; then fix="sudo apt install python3-venv $fix" fi problem "The wiki's own Python environment (tools/.venv) could not be created." \ "wikitool runs in that environment, so that nothing it installs touches the rest of the computer" \ "$fix" stop fi fi want=$("$VPY" -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' \ "$REQUIREMENTS" 2>/dev/null | tr -d '\r') have=$(cat "$STAMP" 2>/dev/null) || have="" if [ -z "$want" ] || [ "$want" != "$have" ]; then if ! output=$("$VPY" -m pip install --disable-pip-version-check --quiet -r "$REQUIREMENTS" 2>&1); then problem "The libraries wikitool needs could not be installed into tools/.venv." \ "they are downloaded once from the internet; without them no wikitool command starts" \ "Check that this computer can reach the internet (a proxy or a security program can block it; if so, ask whoever looks after this computer). pip said: $(last_lines "$output")" stop fi printf '%s\n' "$want" > "$STAMP" fi echo "OK venv - $(native_path "$VENV")" write_tools_file true echo "" echo "Preflight passed. Tool paths are recorded in .wikitool-tools.json; tools/wikitool is ready." exit 0