ludic/install.sh
Orkuncakilkaya 470971bf70
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 22s
ci / build-and-test (push) Successful in 3m0s
commit-lint / conventional-commits (push) Successful in 2s
docs / build-and-deploy (push) Successful in 33s
fix(install): keep the package store across an upgrade
The install root is not only the artifact: `ludic add` caches fetched packages
in <root>/store, keyed by content hash. install_staged moved the whole root
aside and replaced it, so re-running the installer — which is exactly what
`ludic upgrade` does — deleted the cache and forced every project to refetch.

The store is moved into the staged tree before the swap. Nothing else in the
root is preserved, because everything else is the toolchain and should be
replaced.

Verified by staging an install, planting a store entry, upgrading, and reading
the entry back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:40:05 +03:00

393 lines
15 KiB
Bash
Executable file

#!/bin/sh
# install.sh — install the Ludic toolchain.
#
# curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh
#
# That URL is the documentation site, which publishes this file alongside the
# pages it is quoted on (see cmd_docs_gen). Everything host-shaped here is a
# variable with an environment override, so moving the project — a new domain,
# a new forge, a new name for the language — is an edit to the block below and
# to the prose that quotes it (CONTRIBUTING.md has the checklist).
#
# It puts a complete toolchain — the compiler, the `ludic` CLI, the formatter,
# the language server, the engine runtime and the bundled ludic.* packages —
# under ~/.ludic, and puts ~/.ludic/bin on your PATH. Nothing else on the system
# is touched, and uninstalling is `rm -rf ~/.ludic`.
#
# Where a prebuilt toolchain exists for the platform it is downloaded and
# checksum-verified. Where it does not, the source is fetched and bootstrapped
# from the compiler's own checked-in LLVM IR seed, which needs only clang and
# takes a few seconds — the same path a contributor uses.
#
# Options (also usable through `ludic upgrade`):
# --version X.Y.Z install that release instead of the latest
# --dir PATH install root (default: $HOME/.ludic)
# --no-modify-path do not touch any shell profile
# --from-source skip the prebuilt artifacts and bootstrap from source
# --help
#
# POSIX sh on purpose: this is the first thing a new user runs, and it has to
# work on a stock macOS /bin/sh and on a minimal Linux image alike.
set -eu
REPO_API="${LUDIC_FORGEJO_API:-https://git.workshopsoft.io/api/v1/repos/workshopsoft/ludic}"
REPO_URL="${LUDIC_REPO_URL:-https://git.workshopsoft.io/workshopsoft/ludic}"
INSTALL_URL="${LUDIC_INSTALL_URL:-https://workshopsoft.pages.workshopsoft.io/ludic/install.sh}"
INSTALL_DIR="${LUDIC_INSTALL_DIR:-${HOME}/.ludic}"
VERSION=""
MODIFY_PATH=1
FROM_SOURCE=0
# ---- output -----------------------------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); R=$(printf '\033[0m')
GREEN=$(printf '\033[32m'); RED=$(printf '\033[31m')
else
B=''; DIM=''; R=''; GREEN=''; RED=''
fi
say() { printf '%s\n' "$*"; }
step() { printf '%s==>%s %s\n' "$B" "$R" "$*"; }
warn() { printf '%swarning:%s %s\n' "$RED" "$R" "$*" >&2; }
die() { printf '%serror:%s %s\n' "$RED" "$R" "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
usage() {
cat <<EOF
install.sh — install the Ludic toolchain.
curl -fsSL ${INSTALL_URL} | sh
--version X.Y.Z install that release instead of the latest
--dir PATH install root (default: $HOME/.ludic)
--no-modify-path do not touch any shell profile
--from-source skip the prebuilt artifacts and bootstrap from source
--help this message
EOF
exit 0
}
# ---- arguments --------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="${2:-}"; [ -n "$VERSION" ] || die "--version needs a value"; shift 2 ;;
--version=*) VERSION="${1#*=}"; shift ;;
--dir) INSTALL_DIR="${2:-}"; [ -n "$INSTALL_DIR" ] || die "--dir needs a value"; shift 2 ;;
--dir=*) INSTALL_DIR="${1#*=}"; shift ;;
--no-modify-path) MODIFY_PATH=0; shift ;;
--from-source) FROM_SOURCE=1; shift ;;
-h|--help) usage ;;
*) die "unknown option: $1 (try --help)" ;;
esac
done
VERSION="${VERSION#v}"
# ---- platform ---------------------------------------------------------------
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$ARCH" in
aarch64) ARCH=arm64 ;;
amd64) ARCH=x86_64 ;;
esac
case "$OS" in
darwin|linux) ;;
*) die "unsupported platform: $OS ($ARCH). Ludic builds on macOS and Linux." ;;
esac
# clang assembles and links the IR the compiler emits, so it is required to
# build any Ludic program — not only to install from source.
CC_HINT=''
if ! have clang && ! have cc; then
if [ "$OS" = darwin ]; then
CC_HINT='install the Command Line Tools: xcode-select --install'
else
CC_HINT='install clang, e.g. apt-get install clang / dnf install clang'
fi
fi
have curl || die "this installer needs curl"
# ---- downloading ------------------------------------------------------------
fetch() { curl -fsSL "$1" -o "$2"; }
# The newest published release, e.g. "0.4.0".
#
# /releases/latest excludes pre-releases, and every 0.x release is marked one —
# so on a project that has not reached 1.0 that endpoint 404s and the installer
# would refuse to install anything at all. Fall back to the full list, which is
# newest-first and includes pre-releases.
latest_version() {
_v=$(curl -fsSL "${REPO_API}/releases/latest" 2>/dev/null | first_tag)
[ -n "$_v" ] || _v=$(curl -fsSL "${REPO_API}/releases?limit=1" 2>/dev/null | first_tag)
printf '%s\n' "$_v"
}
first_tag() {
sed -n 's/.*"tag_name" *: *"v\{0,1\}\([^"]*\)".*/\1/p' | head -1
}
sha256_of() {
if have sha256sum; then sha256sum "$1" | cut -d' ' -f1
elif have shasum; then shasum -a 256 "$1" | cut -d' ' -f1
else return 1
fi
}
# download the toolchain tarball for this platform into $TMP, verify it against
# the published checksum, and echo its path. Silent failure (returns 1) means
# "no prebuilt for this platform" — the caller falls back to source.
download_prebuilt() {
_name="ludic-${VERSION}-${OS}-${ARCH}.tar.gz"
_url="${REPO_URL}/releases/download/v${VERSION}/${_name}"
_out="${TMP}/${_name}"
fetch "$_url" "$_out" 2>/dev/null || return 1
if fetch "${_url}.sha256" "${_out}.sha256" 2>/dev/null; then
_want=$(cut -d' ' -f1 < "${_out}.sha256")
_got=$(sha256_of "$_out" || true)
if [ -n "$_got" ] && [ "$_want" != "$_got" ]; then
die "checksum mismatch for ${_name} (expected ${_want}, got ${_got})"
fi
[ -n "$_got" ] || warn "no sha256 tool found; the download was not verified"
else
warn "no published checksum for ${_name}; the download was not verified"
fi
printf '%s\n' "$_out"
}
# ---- installing -------------------------------------------------------------
# Replace the install root with the staged tree. The swap is done at the end so
# an interrupted download never leaves a half-installed toolchain behind, and a
# working install is only removed once the new one is ready to take its place.
# Is this directory a usable install root? A toolchain is not just binaries: a
# game links against the engine runtime and resolves the bundled packages from
# here, so an artifact missing them installs something that cannot build
# anything. Older releases predate this layout, which is exactly the case that
# must degrade to a source build rather than to a broken ~/.ludic.
valid_root() {
[ -x "${1}/bin/ludic" ] && [ -x "${1}/bin/ludicc" ] \
&& [ -d "${1}/runtime/native" ] && [ -f "${1}/VERSION" ]
}
install_staged() {
_staged="$1"
valid_root "$_staged" || die "the staged toolchain is incomplete"
mkdir -p "$(dirname "$INSTALL_DIR")"
if [ -d "$INSTALL_DIR" ]; then
rm -rf "${INSTALL_DIR}.old"
mv "$INSTALL_DIR" "${INSTALL_DIR}.old"
# The install root is not only the artifact: `ludic add` caches packages in
# <root>/store, which is the user's data and takes a network round trip to
# rebuild. Carry it across rather than replacing the directory out from under
# it. (Anything else here is part of the toolchain and should be replaced.)
if [ -d "${INSTALL_DIR}.old/store" ]; then
mv "${INSTALL_DIR}.old/store" "${_staged}/store" || warn "could not carry the package store across"
fi
fi
mv "$_staged" "$INSTALL_DIR"
rm -rf "${INSTALL_DIR}.old"
}
# Build the toolchain from the source of a tag: clang assembles the compiler's
# own IR seed, that compiler builds the CLI, and the CLI builds the rest. This
# is the only step Ludic cannot do for itself, and it is also the whole story of
# how the language bootstraps.
install_from_source() {
have git || die "installing from source needs git"
have clang || have cc || die "installing from source needs clang. ${CC_HINT}"
step "building from source (no prebuilt toolchain for ${OS}-${ARCH})"
_src="${TMP}/src"
git clone --quiet --depth 1 --branch "v${VERSION}" "${REPO_URL}.git" "$_src" 2>/dev/null \
|| git clone --quiet --depth 1 "${REPO_URL}.git" "$_src" \
|| die "could not clone ${REPO_URL}"
# Which entry point builds the toolchain depends on the age of the tag: the
# contributor tool was split out of the CLI after 0.5.2, so an older release
# still bootstraps through main.ludic. A release older than either predates the
# CLI entirely, and saying so beats failing deep inside clang.
if [ -f "${_src}/tools/ludic-cli/dev.ludic" ]; then
_boot='bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev && bin/ludic-dev build'
elif [ -f "${_src}/tools/ludic-cli/main.ludic" ]; then
_boot='bin/ludicc tools/ludic-cli/main.ludic -o bin/ludic && bin/ludic dev build'
else
die "release v${VERSION} predates the ludic CLI; install a newer one with --version X.Y.Z"
fi
CC=clang; have clang || CC=cc
( cd "$_src" \
&& mkdir -p bin \
&& "$CC" selfhost/ludicc.seed.ll -o bin/ludicc 2>/dev/null \
&& eval "$_boot" >/dev/null ) || die "the source build failed"
_stage="${TMP}/stage"
mkdir -p "${_stage}/bin"
cp "${_src}"/bin/ludic "${_src}"/bin/ludicc "${_src}"/bin/ludic-fmt "${_src}"/bin/ludic-lsp "${_stage}/bin/"
cp -R "${_src}/runtime" "${_stage}/runtime"
cp -R "${_src}/packages" "${_stage}/packages"
cp "${_src}/VERSION" "${_src}/LICENSE" "${_src}/README.md" "${_stage}/"
install_staged "$_stage"
}
# ---- PATH -------------------------------------------------------------------
#
# "Installed but `ludic` is not a command" is the most common way an installer
# fails, so this is deliberate about it.
#
# The PATH edit itself lives in one file, <install>/env, and each shell profile
# gets a single line that sources it. That keeps the change easy to find, easy to
# undo (delete the line, delete ~/.ludic), and means a later install does not
# accumulate a second export.
#
# Which profiles: enough to cover every shell the user actually opens, not just
# the one $SHELL happens to name.
#
# ~/.profile sh, dash, and bash login shells that have no .bash_profile.
# Created when missing — it is the portable catch-all.
# ~/.bash_profile bash login shells (what macOS Terminal starts). Only touched
# ~/.bashrc when they already exist: creating .bash_profile would stop
# bash from reading ~/.profile and silently break other tools.
# ~/.zshenv zsh reads this for *every* invocation, interactive or not,
# and never reads ~/.profile. Created when missing.
# fish config only when fish is installed.
# write <install>/env and <install>/env.fish — the one place the PATH is set
write_env_files() {
cat > "${INSTALL_DIR}/env" <<EOF
# Adds the Ludic toolchain to PATH. Sourced from your shell profile; safe to
# source more than once. Written by install.sh — edit ~/.ludic instead.
case ":\${PATH}:" in
*":${INSTALL_DIR}/bin:"*) ;;
*) PATH="${INSTALL_DIR}/bin:\${PATH}"; export PATH ;;
esac
EOF
cat > "${INSTALL_DIR}/env.fish" <<EOF
# Adds the Ludic toolchain to PATH (fish). Written by install.sh.
if not contains "${INSTALL_DIR}/bin" \$PATH
set -gx PATH "${INSTALL_DIR}/bin" \$PATH
end
EOF
}
# append the source line to one profile, unless it is already there
add_line() {
_file="$1"
_line="$2"
if [ -f "$_file" ] && grep -qF "$_line" "$_file" 2>/dev/null; then
ALREADY="${ALREADY} ${_file}"
return 0
fi
mkdir -p "$(dirname "$_file")" 2>/dev/null || true
printf '\n# ludic\n%s\n' "$_line" >> "$_file" 2>/dev/null || {
warn "could not write ${_file}"
return 0
}
TOUCHED="${TOUCHED} ${_file}"
}
add_to_path() {
[ "$MODIFY_PATH" -eq 1 ] || return 0
write_env_files
_posix=". \"${INSTALL_DIR}/env\""
add_line "${HOME}/.profile" "$_posix"
add_line "${ZDOTDIR:-$HOME}/.zshenv" "$_posix"
# bash reads ~/.bashrc for a non-login interactive shell — what most Linux
# terminal emulators start — and it is safe to create, since its presence
# changes nothing else. ~/.bash_profile is only appended to when it already
# exists: creating that one would stop bash reading ~/.profile.
[ -f "${HOME}/.bash_profile" ] && add_line "${HOME}/.bash_profile" "$_posix"
if [ -f "${HOME}/.bashrc" ] || have bash; then
add_line "${HOME}/.bashrc" "$_posix"
fi
if have fish || [ -f "${HOME}/.config/fish/config.fish" ]; then
add_line "${HOME}/.config/fish/config.fish" "source \"${INSTALL_DIR}/env.fish\""
fi
return 0
}
# ---- go ---------------------------------------------------------------------
TMP=$(mktemp -d "${TMPDIR:-/tmp}/ludic-install.XXXXXX")
trap 'rm -rf "$TMP"' EXIT INT TERM
if [ -z "$VERSION" ]; then
step "looking up the latest release"
VERSION=$(latest_version || true)
[ -n "$VERSION" ] || die "could not determine the latest version (set one with --version X.Y.Z)"
fi
say " ludic ${B}${VERSION}${R} ${DIM}${OS}-${ARCH} -> ${INSTALL_DIR}${R}"
if [ "$FROM_SOURCE" -eq 1 ]; then
install_from_source
else
step "downloading the toolchain"
if TARBALL=$(download_prebuilt); then
STAGE="${TMP}/stage"
mkdir -p "$STAGE"
tar -xzf "$TARBALL" -C "$STAGE"
# A current artifact unpacks to one top-level directory that is the install
# root; an older one unpacks its contents directly. Try both, and if neither
# is a usable root the release predates this layout — build from source
# instead of installing something that cannot compile a game.
ROOTDIR=""
if valid_root "$STAGE"; then
ROOTDIR="$STAGE"
else
for _d in "$STAGE"/*; do
if [ -d "$_d" ] && valid_root "$_d"; then ROOTDIR="$_d"; break; fi
done
fi
if [ -n "$ROOTDIR" ]; then
install_staged "$ROOTDIR"
else
warn "the ${VERSION} artifact for ${OS}-${ARCH} is not a complete toolchain; building from source"
install_from_source
fi
else
install_from_source
fi
fi
TOUCHED=''
ALREADY=''
add_to_path
# ---- report -----------------------------------------------------------------
INSTALLED=$("${INSTALL_DIR}/bin/ludic" version 2>/dev/null || echo "ludic ${VERSION}")
say ""
say "${GREEN}installed${R} ${INSTALLED} -> ${INSTALL_DIR}"
if [ -n "$CC_HINT" ]; then
say ""
warn "no clang found. Ludic emits LLVM IR and needs clang to assemble and link it."
say " ${CC_HINT}"
fi
if [ "$OS" != darwin ]; then
say ""
say " ${DIM}note: windowing is macOS-only today. On ${OS}, programs build and run"
say " headless (ludic build --headless); a window will not open yet.${R}"
fi
say ""
if [ "$MODIFY_PATH" -eq 1 ]; then
[ -n "${TOUCHED# }" ] && say " ${DIM}added ${INSTALL_DIR}/bin to your PATH in:${R}${TOUCHED# }"
[ -n "${ALREADY# }" ] && say " ${DIM}already on your PATH in:${R}${ALREADY# }"
fi
case ":${PATH}:" in
*":${INSTALL_DIR}/bin:"*) ;;
*)
say " ${DIM}this shell was started before that, so for right now:${R}"
say " export PATH=\"${INSTALL_DIR}/bin:\$PATH\""
say " ${DIM}(new terminals pick it up on their own)${R}"
;;
esac
say ""
say "next:"
say " ${B}ludic new mygame${R} create a project"
say " ${B}cd mygame && ludic run${R} build it and play"
say " ${B}ludic help${R} everything else"