Files
chemenu/tools/preflight.sh
T
torbenandClaude Opus 5.5 5c9be38aa8
CI / verify (push) Successful in 6m14s
CI / pwsh (push) Successful in 2m23s
Release / release (push) Successful in 35s
fix(preflight): asset mode retries a move that fails, names the cause and rolls back (#197)
A part of the stack that will not move from the unpack folder into place is
retried for about 30 seconds; each retry removes what reached the target and
unpacks the entry afresh from the archive, so the entry ends up there once and
whole. If it still fails, both scripts take back everything they moved and exit 1
with the system's message and "nothing was installed". New test hook
CHEMENU_PREFLIGHT_MOVE_FAIL=<entry>:<n>[:partial].

Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- instructions/preflight.md
- tools/CONTRACT.md
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_preflight.py
- tools/chemenu/tests/test_preflight_pwsh.py
- tools/preflight.ps1
- tools/preflight.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-07 12:23:13 +02:00

805 lines
31 KiB
Bash
Executable File

#!/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
#
# As the release asset `preflight.sh` - a copy with no tools/prerequisites.txt next
# to it - the script is the first install step instead: it downloads the stack
# release named in RELEASE_ARCHIVE_URL / RELEASE_CHECKSUM_URL, checks the sha256,
# unpacks it into the folder it lies in (or --into <path>), removes itself there, and
# runs the copy of this script inside the unpacked tree, which does everything above.
# That folder has to be empty apart from this script and a .git (an empty clone of the
# instance's own repository).
#
# preflight.sh [--into <path>] [--archive <tarball>] [--set <tool>=<path>]...
#
# --archive uses a local tarball instead of a download; <tarball>.sha256 has to lie
# next to it. Release builds fill the two URL lines below; a tree copy leaves them empty.
#
# 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, a download or unpack failed (nothing is
# unpacked then - or, should even that fail, the message names what is
# left), 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.
#
# Three 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;
# CHEMENU_PREFLIGHT_MOVE_FAIL (<entry>:<n>[:partial]) stands in for a file some other
# program holds open: moving the unpacked <entry> up fails the first <n> times (`always`:
# every time), with :partial after moving a part of it, and the pauses between attempts
# are skipped.
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"
# Filled in by the release build, in the copy attached to the release; empty in a tree.
RELEASE_ARCHIVE_URL=''
RELEASE_CHECKSUM_URL=''
PYPROBE='import sys; print("%d.%d" % sys.version_info[:2]); print(sys.executable)'
usage() {
cat <<'EOF'
usage: tools/preflight.sh [--set <tool>=<path>]...
preflight.sh [--into <path>] [--archive <tarball>] [--set <tool>=<path>]...
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.
The second form is the release asset: it downloads the release (or takes
--archive), checks its sha256, unpacks it into its own folder or --into (empty,
or holding only .git), and runs the preflight inside it.
EOF
}
# --- arguments ----------------------------------------------------------------
SETS=""
INTO="" ARCHIVE=""
while [ $# -gt 0 ]; do
case "$1" in
--into|--archive)
if [ $# -lt 2 ] || [ -z "$2" ]; then
echo "preflight: $1 needs a path" >&2
exit 1
fi
if [ "$1" = --into ]; then INTO=$2; else ARCHIVE=$2; fi
shift
;;
--set)
if [ $# -lt 2 ]; then
echo "preflight: --set needs <tool>=<path>" >&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
ASSET=0
if [ ! -f "$MANIFEST" ]; then
ASSET=1
elif [ -n "$INTO$ARCHIVE" ]; then
echo "preflight: --into and --archive belong to the release asset; inside a stack tree there is nothing to unpack." >&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 <what> <why> <fix, may span lines>
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
}
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}
}
# --- asset mode: the release asset unpacks the stack, then hands over ------------------
#
# Only when no tools/prerequisites.txt lies next to this script. Nothing here reads
# the tree; the folder limit comes out of the archive itself. The tools the tree copy
# checks (Python, git, rg) are not needed until that copy runs.
WORK=""
cleanup() {
[ -z "$WORK" ] || rm -rf "$WORK"
[ -z "${UNPACK:-}" ] || rm -rf "$UNPACK"
}
asset_fail() { # <message>
echo "preflight: $1" >&2
exit 1
}
sha256_of() { # <file>
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
else
shasum -a 256 "$1" | cut -d' ' -f1
fi
}
# This script and a .git are the two things an empty install folder may already carry:
# the script was downloaded into it, and the user may have cloned the instance's own,
# still empty repository there. Neither counts as occupying the folder, and neither is
# ever removed from it.
is_protected() { # <name>
[ "$1" = .git ] && return 0
[ "$1" = "${0##*/}" ] && [ "$(CDPATH='' cd -- "$TARGET" && pwd -P)" = "$DIR" ]
}
# The names in the install folder besides those two, one per line.
occupants() {
[ -d "$TARGET" ] || return 0
for occupant in "$TARGET"/* "$TARGET"/.[!.]* "$TARGET"/..?*; do
[ -e "$occupant" ] || [ -L "$occupant" ] || continue
is_protected "${occupant##*/}" || printf '%s\n' "${occupant##*/}"
done
}
# How long an entry that will not move up is retried: the pauses between attempts, in
# seconds - 30 in all. On Windows a folder cannot be renamed while any file in it is
# held open, and a virus scanner or indexer looking at a freshly unpacked tree holds one
# for a moment; waiting is what gets past it.
MOVE_PAUSES='1 2 3 4 5 6 9'
# One attempt at moving the unpacked entry <name> up into the target; on failure the
# system's message is in MOVE_ERROR. A failed attempt can leave part of the entry on
# either side - a move that falls back to copying and deleting stops wherever the lock
# is - so every attempt after the first starts over: what reached the target is removed,
# and the entry is unpacked afresh from the archive. Only then is the entry in the
# target, once and whole, after the attempt that works.
move_entry() { # <name> <attempt>
from="$UNPACK/$top/$1"
landed="$TARGET/$1"
if [ "$2" -gt 1 ]; then
if { [ -e "$landed" ] || [ -L "$landed" ]; } && ! is_protected "$1"; then
MOVE_ERROR=$(rm -rf -- "$landed" 2>&1) || return 1
fi
MOVE_ERROR=$(tar -xzf "$archive" -C "$UNPACK" "$top/$1" 2>&1) \
|| { MOVE_ERROR="unpacking it again failed: $MOVE_ERROR"; return 1; }
fi
hook=${CHEMENU_PREFLIGHT_MOVE_FAIL:-}
times=${hook#*:}
case "$hook" in
"$1":always*|"$1":[0-9]*)
if [ "${times%%:*}" = always ] || [ "$2" -le "${times%%:*}" ]; then
case "$hook" in
*:partial)
if [ -d "$from" ]; then
mkdir -p "$landed"
for part in "$from"/*; do
[ -e "$part" ] && mv -- "$part" "$landed/"
break
done
else
: > "$landed"
fi ;;
esac
MOVE_ERROR="mv: cannot move '$from': Permission denied (CHEMENU_PREFLIGHT_MOVE_FAIL, attempt $2)"
return 1
fi ;;
esac
MOVE_ERROR=$(mv -- "$from" "$TARGET/" 2>&1)
}
# An entry would not move even after waiting: take back everything this run moved into
# the target, so that it holds what it held before and running this again is all it
# takes. Only names that came out of the archive are removed, never a protected one.
undo_move() { # <name of the entry that would not move>
while IFS= read -r undone; do
[ -n "$undone" ] || continue
is_protected "$undone" || rm -rf -- "${TARGET:?}/$undone" 2>/dev/null
done <<EOT
$MOVED
$1
EOT
rm -rf -- "$UNPACK" 2>/dev/null && UNPACK=""
left=$(occupants | tr '\n' ' ')
if [ -z "$left" ]; then
asset_fail "could not move $1 into $(native_path "$TARGET") ($MOVE_ERROR) - nothing was installed, the folder is as it was before. Run this again."
fi
asset_fail "could not move $1 into $(native_path "$TARGET") ($MOVE_ERROR), and could not take back what was already there: ${left% } - empty the folder (keep .git), then run this again."
}
asset_mode() {
# GNU tar reads `C:` as a host name; Git Bash has to hand it `/c/...`.
if [ "$PLATFORM" = windows ] && command -v cygpath >/dev/null 2>&1; then
[ -z "$ARCHIVE" ] || ARCHIVE=$(cygpath -u "$ARCHIVE")
[ -z "$INTO" ] || INTO=$(cygpath -u "$INTO")
fi
if [ -n "$INTO" ]; then
case "$INTO" in /*) TARGET=$INTO ;; *) TARGET="$(pwd)/$INTO" ;; esac
else
TARGET=$DIR
fi
TARGET=${TARGET%/}
if { [ -e "$TARGET" ] || [ -L "$TARGET" ]; } && [ ! -d "$TARGET" ]; then
asset_fail "$(native_path "$TARGET") exists and is not a folder - nothing was unpacked."
fi
if [ -n "$(occupants)" ]; then
asset_fail "$(native_path "$TARGET") is not empty - nothing was unpacked. The wiki installs into an empty folder (an empty git clone is fine): put this script into one and run it there, or pass --into <path>."
fi
if [ -z "$ARCHIVE" ] && { [ -z "$RELEASE_ARCHIVE_URL" ] || [ -z "$RELEASE_CHECKSUM_URL" ]; }; then
asset_fail "this copy of the script does not come from a release (it has no download address). Download preflight.sh from the release page, or pass --archive <tarball>."
fi
if [ -n "$ARCHIVE" ]; then
[ -f "$ARCHIVE" ] || asset_fail "--archive: $ARCHIVE is not a file."
[ -f "$ARCHIVE.sha256" ] || asset_fail "--archive: $ARCHIVE.sha256 is missing - the checksum file has to lie next to the tarball."
fi
# What has to be here for the download, tested before anything is fetched.
missing=""
if [ -z "$ARCHIVE" ] && ! command -v curl >/dev/null 2>&1; then missing="$missing curl"; fi
command -v tar >/dev/null 2>&1 || missing="$missing tar"
command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || missing="$missing sha256sum"
if [ -n "$missing" ]; then
case "$PLATFORM" in
macos) hint="brew install$missing" ;;
windows) hint="Git for Windows (https://gitforwindows.org) provides all of them in Git Bash." ;;
*) hint="sudo apt install$(printf '%s' "$missing" | sed 's/ sha256sum/ coreutils/')" ;;
esac
problem "Tools needed to fetch and unpack the stack could not be found:$missing" \
"the first step downloads the release, checks its checksum and unpacks it" \
"$hint"
stop
fi
trap cleanup EXIT
WORK=$(mktemp -d "${TMPDIR:-/tmp}/chemenu-preflight.XXXXXX") || asset_fail "could not create a temporary folder."
if [ -n "$ARCHIVE" ]; then
archive=$ARCHIVE
checksum=$ARCHIVE.sha256
else
archive="$WORK/${RELEASE_ARCHIVE_URL##*/}"
checksum="$WORK/${RELEASE_CHECKSUM_URL##*/}"
echo "Downloading $RELEASE_ARCHIVE_URL"
curl -fsSL -o "$archive" "$RELEASE_ARCHIVE_URL" || asset_fail "the download failed: $RELEASE_ARCHIVE_URL (check the internet connection, then run this again)."
curl -fsSL -o "$checksum" "$RELEASE_CHECKSUM_URL" || asset_fail "the download failed: $RELEASE_CHECKSUM_URL (check the internet connection, then run this again)."
fi
want=$(sed -n '1s/^\([0-9A-Fa-f]\{64\}\).*/\1/p' "$checksum" | tr 'A-F' 'a-f')
[ -n "$want" ] || asset_fail "the checksum file holds no sha256 in its first line - nothing was unpacked."
have=$(sha256_of "$archive" | tr 'A-F' 'a-f')
if [ "$want" != "$have" ]; then
asset_fail "the sha256 of the archive does not match its checksum file (expected $want, got $have) - nothing was unpacked. Delete the download and run this again."
fi
echo "sha256 OK"
listing=$(tar -tzf "$archive" 2>/dev/null) || asset_fail "the archive could not be read - nothing was unpacked."
tops=$(printf '%s\n' "$listing" | sed 's|^\./||; s|/.*||; /^\.\{0,1\}$/d')
top=$(printf '%s\n' "$tops" | head -n 1)
other=$(printf '%s\n' "$tops" | grep -v -x -F -- "$top" | head -n 1)
if [ -z "$top" ] || [ -n "$other" ]; then
asset_fail "the archive does not hold exactly one top-level folder - nothing was unpacked."
fi
manifest=$(tar -xOzf "$archive" "$top/tools/prerequisites.txt" 2>/dev/null) \
|| asset_fail "the archive holds no $top/tools/prerequisites.txt - nothing was unpacked."
limit=$(printf '%s\n' "$manifest" | sed -n 's/^limit|install_dir_max|\([0-9][0-9]*\).*/\1/p' | head -n 1)
if [ "$PLATFORM" = windows ] && [ -n "$limit" ] && ! longpaths_enabled; then
folder=$(native_path "$TARGET")
length=$(utf16_length "$folder")
if [ "$length" -gt "$limit" ]; then
problem "The folder this wiki would be 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" \
"Use a shorter folder such as C:\\Chemenu - put this script there and run it again,
or pass --into C:\\Chemenu.
Alternatively, someone with administrator rights can turn on long paths in Windows."
stop
fi
fi
# Unpacked inside the target rather than beside it: the folder above may be a drive
# root nobody can write to. Only then are the entries moved up, one by one.
mkdir -p "$TARGET" || asset_fail "could not create $(native_path "$TARGET")."
UNPACK="$TARGET/.chemenu-unpack.$$"
mkdir "$UNPACK" || asset_fail "could not create a temporary folder in the target."
tar -xzf "$archive" -C "$UNPACK" || asset_fail "unpacking failed - nothing was installed."
[ -d "$UNPACK/$top" ] || asset_fail "unpacking produced no $top folder - nothing was installed."
MOVED=""
for entry in "$UNPACK/$top"/* "$UNPACK/$top"/.[!.]* "$UNPACK/$top"/..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
attempt=1
until move_entry "${entry##*/}" "$attempt"; do
pause=$(printf '%s\n' $MOVE_PAUSES | sed -n "${attempt}p")
[ -n "$pause" ] || undo_move "${entry##*/}"
[ -n "${CHEMENU_PREFLIGHT_MOVE_FAIL:-}" ] || sleep "$pause"
attempt=$((attempt + 1))
done
MOVED="$MOVED
${entry##*/}"
done
rm -rf "$UNPACK"
UNPACK=""
# The release asset is not part of the stack; left in the folder, it would end up in
# the instance's first commit beside the tree's own tools/preflight.sh.
if [ "$(CDPATH='' cd -- "$TARGET" && pwd -P)" = "$DIR" ]; then
rm -f -- "$DIR/${0##*/}"
fi
[ -f "$TARGET/tools/preflight.sh" ] || asset_fail "the unpacked stack has no tools/preflight.sh."
echo "Unpacked into $(native_path "$TARGET")."
echo "If a later step stops, run tools/preflight.sh from inside that folder."
echo ""
set --
while IFS= read -r entry; do
[ -n "$entry" ] || continue
set -- "$@" --set "$entry"
done <<EOT
$SETS
EOT
trap - EXIT
cleanup
exec /bin/sh "$TARGET/tools/preflight.sh" "$@"
}
[ "$ASSET" -eq 0 ] || asset_mode
# --- the manifest ---------------------------------------------------------------
manifest_field() { # <kind> <name> <field number, 1-based>
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() { # <tool>
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() { # <have> <want>, 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 <name> 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() { # <path>
[ -n "$1" ] && [ -f "$1" ] && [ -x "$1" ] && ! is_store_alias "$1"
}
# Runs a Python candidate; prints "<major.minor>\n<sys.executable>".
probe_python() { # <path> [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 <name> 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() { # <name> -> 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() { # <path> [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=<full path to 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 <<EOF
$found
EOF
done
fi
fi
# --- the other tools --------------------------------------------------------------
REPORT=""
report() { # <status> <name> <version> <path>
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=<full path to 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=<full path to 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=<full path to $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=<full path to $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) ------------------------------
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() { # <true|false>
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