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
805 lines
31 KiB
Bash
Executable File
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
|