#!/usr/bin/env bash## Bootstrap a Lattice development environment from scratch.## TARGET: a fresh Debian/Ubuntu machine (headless container or VM). It installs# with apt and downloads Linux release archives. It will not work on macOS.## macOS contributors: do NOT run this. Follow CONTRIBUTING.md instead — Homebrew# and the Tauri prerequisites cover what apt does here, and `mise install` gets# you the same Node and pnpm.## What it does, in order:# 1. apt-installs the Tauri and LaTeX build prerequisites;# 2. installs mise (pinned, checksum-verified) and runs `mise install`, which# provisions Node and pnpm at the versions mise.toml pins — the same# versions `pnpm check` runs under, since `pnpm check` is `mise run check`;# 3. installs the stable Rust toolchain with clippy, rustfmt, and the# aarch64-apple-darwin target;# 4. installs a pinned, checksum-verified `uv` (optional — see that step);# 5. installs locked dependencies for the app, literature-worker and the# Open Slide runtime, and registers the catalog merge driver;# 6. fetches the pinned Synara source into SYNARA_SOURCE_DIR (defaulting to# the `sourceDirectory` in scripts/synara-runtime.json) and installs it# with bun — only needed to run the real agent sidecar;# 7. writes the runtime stub that lets cargo compile Tauri's resource# manifest without a staged Synara bundle;# 8. warms the cargo registry.## It only writes inside the repository (including its git config), ~/.local, ~/.cargo and the Synara source# directory. It does not edit your shell profile: the last thing it prints is# the PATH line to add yourself.## It is idempotent: every step checks before it acts, so re-running is cheap.set -euo pipefailrepo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"cd "$repo_root"case "$(uname -m)" in x86_64) machine_arch="x64"; rust_triple_arch="x86_64" ;; aarch64|arm64) machine_arch="arm64"; rust_triple_arch="aarch64" ;; *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;esac# Everything this script installs lands here, so it is the one directory that# has to be on PATH afterwards.local_bin="$HOME/.local/bin"mise_shims="$HOME/.local/share/mise/shims"path_line="export PATH=\"\$HOME/.local/bin:\$HOME/.local/share/mise/shims:\$HOME/.cargo/bin:\$PATH\""export PATH="$local_bin:$mise_shims:$HOME/.cargo/bin:$PATH"step_started=0start_step() { step_started=$SECONDS printf '\n==> %s\n' "$1"}finish_step() { printf ' completed in %ss\n' "$((SECONDS - step_started))"}start_step "Installing system packages required by Tauri and LaTeX"packages=( build-essential curl file git latexmk libayatana-appindicator3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev pkg-config wget xz-utils zip)missing_packages=()for package in "${packages[@]}"; do if ! dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -Fq 'install ok installed'; then missing_packages+=("$package") fidoneif ((${#missing_packages[@]})); then if ((EUID == 0)); then apt_prefix=() else apt_prefix=(sudo) fi "${apt_prefix[@]}" apt-get update "${apt_prefix[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing_packages[@]}"fifinish_stepstart_step "Installing mise, then Node.js and pnpm at the pinned versions"# mise.toml is the single source of truth for the Node and pnpm versions, and# `pnpm check` is literally `mise run check`, so mise is not optional: without# it this box cannot run the gate. Installing Node by hand here instead is how# this script used to drift a whole major version away from mise.toml.## Pinned and verified against the SHASUMS256.txt published with the same# release, like the uv install below. `curl https://mise.run | sh` would run# whatever the server returned today, unverified.mise_version="${MISE_VERSION:-v2026.8.8}"mise_bin="$local_bin/mise"if ! "$mise_bin" --version 2>/dev/null | grep -Fq "${mise_version#v}"; then mise_archive="mise-$mise_version-linux-$machine_arch.tar.gz" mise_base="https://github.com/jdx/mise/releases/download/$mise_version" tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT curl -fsSLo "$tmp_dir/$mise_archive" "$mise_base/$mise_archive" curl -fsSLo "$tmp_dir/SHASUMS256.txt" "$mise_base/SHASUMS256.txt" # mise's checksum file lists paths as `./mise-…`, so match on that and strip # the prefix before handing the line to sha256sum. ( cd "$tmp_dir" grep -F " ./$mise_archive" SHASUMS256.txt | sed 's|\./||' | sha256sum --check --status ) tar -xzf "$tmp_dir/$mise_archive" -C "$tmp_dir" mkdir -p "$local_bin" install -m 0755 "$tmp_dir/mise/bin/mise" "$mise_bin" rm -rf "$tmp_dir" trap - EXITfi# `mise install` refuses to act on an untrusted config file when it cannot ask."$mise_bin" trust "$repo_root/mise.toml""$mise_bin" install# Shims are what put the pinned `node` and `pnpm` on PATH for the rest of this# script (and for any later shell that picks up the PATH line printed at the# end) without needing `mise activate` in a profile."$mise_bin" reshimfinish_stepstart_step "Installing the pinned Rust toolchain"if [[ ! -x "$HOME/.cargo/bin/rustup" ]]; then curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-pathfi# With no arguments it installs rust-toolchain.toml's release, components and target.rustup toolchain installfinish_stepstart_step "Installing uv (optional; set SKIP_UV=1 to skip)"# uv provisions the two Python literature CLIs the app shells out to# (`bibcite-cli` and `arxiv2md`). Note what it is NOT for: the shipped macOS app# never uses a `uv` from PATH. It downloads and checksum-verifies its own copy# under ~/Library/Application Support and refuses to fall back to a PATH install# (see MANAGED_UV_VERSION and managed_uv_tool_status in# src-tauri/src/commands.rs). This copy is only so you can drive those tools by# hand on a Linux dev box, which is why the step is skippable.## It is pinned to the version the app manages, and the archive digest is# recorded here rather than fetched, so a compromised release page cannot# silently swap the binary. Bump both together when MANAGED_UV_VERSION moves.uv_version="0.12.3"uv_target="$rust_triple_arch-unknown-linux-gnu"case "$rust_triple_arch" in x86_64) uv_sha256="600cf9a742aca00d292673b16b5acffaa7b8c269a364ad0c2e79498dcb1fe101" ;; aarch64) uv_sha256="bb66cb52e7b1823aed1183630d8d8e5c958840d584a4c55ec10a4cfc168dcca2" ;;esacif [[ "${SKIP_UV:-0}" == "1" ]]; then printf ' skipped (SKIP_UV=1)\n'elif "$local_bin/uv" --version 2>/dev/null | grep -Fq "$uv_version"; then printf ' already at %s\n' "$uv_version"else uv_archive="uv-$uv_target.tar.gz" tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT curl -fsSLo "$tmp_dir/$uv_archive" \ "https://github.com/astral-sh/uv/releases/download/$uv_version/$uv_archive" printf '%s %s\n' "$uv_sha256" "$tmp_dir/$uv_archive" | sha256sum --check --status tar -xzf "$tmp_dir/$uv_archive" -C "$tmp_dir" mkdir -p "$local_bin" install -m 0755 "$tmp_dir/uv-$uv_target/uv" "$local_bin/uv" install -m 0755 "$tmp_dir/uv-$uv_target/uvx" "$local_bin/uvx" rm -rf "$tmp_dir" trap - EXITfifinish_stepstart_step "Installing locked Lattice dependencies"pnpm install --frozen-lockfilepnpm --dir literature-worker install --frozen-lockfilepnpm --dir tools/open-slide-runtime install --frozen-lockfilebun_bin="$repo_root/node_modules/.bin/bun"if ! "$bun_bin" --version >/dev/null 2>&1; then node node_modules/bun/install.jsfi# The merge driver .gitattributes names for the Lingui catalogs, so branches# that each add a string stop conflicting on the generated messages.po files.node scripts/merge-po.mjs --installfinish_stepstart_step "Installing pinned Synara dependencies"synara_repository="$(node -p "require('./scripts/synara-runtime.json').repository")"synara_revision="$(node -p "require('./scripts/synara-runtime.json').revision")"synara_source="${SYNARA_SOURCE_DIR:-$(node -p "require('./scripts/synara-runtime.json').sourceDirectory")}"if [[ "$synara_source" != /* ]]; then synara_source="$repo_root/$synara_source"fiif [[ ! -d "$synara_source/.git" ]]; then if [[ -e "$synara_source" ]]; then if [[ ! -d "$synara_source" || -n "$(ls -A "$synara_source")" ]]; then echo "Synara source path exists but is not an empty directory: $synara_source" >&2 exit 1 fi fi mkdir -p "$synara_source" git init -q "$synara_source" git -C "$synara_source" remote add origin "$synara_repository" git -C "$synara_source" fetch --depth=1 origin "$synara_revision" git -C "$synara_source" -c advice.detachedHead=false checkout --detach FETCH_HEADelse if ! git -C "$synara_source" remote get-url origin >/dev/null 2>&1; then git -C "$synara_source" remote add origin "$synara_repository" fi synara_head="$(git -C "$synara_source" rev-parse HEAD 2>/dev/null || true)" if [[ "$synara_head" != "$synara_revision" ]]; then if [[ -n "$(git -C "$synara_source" status --short)" ]]; then echo "Synara has local changes; leaving it at $synara_head instead of checking out $synara_revision." else git -C "$synara_source" fetch --depth=1 origin "$synara_revision" git -C "$synara_source" -c advice.detachedHead=false checkout --detach FETCH_HEAD fi fifi( cd "$synara_source" "$bun_bin" install --frozen-lockfile)# Cargo compiles Tauri's resource manifest even when tests never launch the# bundled runtimes. Desktop packaging replaces these stubs with the real# pinned resources through its beforeBuildCommand hook.if [[ ! -e src-tauri/synara-runtime/manifest.json ]]; then mkdir -p src-tauri/synara-runtime printf 'stub\n' > src-tauri/synara-runtime/placeholder.txtfifinish_stepstart_step "Fetching Rust crates"cargo fetch --manifest-path src-tauri/Cargo.tomlfinish_stepprintf '\nSetup complete in %ss.\n' "$SECONDS"printf '\nThis script did not touch your shell profile. Add this line to it\n'printf 'yourself (or run it in each shell) so the toolchains are on PATH:\n\n'printf ' %s\n\n' "$path_line"
#!/usr/bin/env bash
#
# Bootstrap a Lattice development environment from scratch.
#
# TARGET: a fresh Debian/Ubuntu machine (headless container or VM). It installs
# with apt and downloads Linux release archives. It will not work on macOS.
#
# macOS contributors: do NOT run this. Follow CONTRIBUTING.md instead — Homebrew
# and the Tauri prerequisites cover what apt does here, and `mise install` gets
# you the same Node and pnpm.
#
# What it does, in order:
# 1. apt-installs the Tauri and LaTeX build prerequisites;
# 2. installs mise (pinned, checksum-verified) and runs `mise install`, which
# provisions Node and pnpm at the versions mise.toml pins — the same
# versions `pnpm check` runs under, since `pnpm check` is `mise run check`;
# 3. installs the stable Rust toolchain with clippy, rustfmt, and the
# aarch64-apple-darwin target;
# 4. installs a pinned, checksum-verified `uv` (optional — see that step);
# 5. installs locked dependencies for the app, literature-worker and the
# Open Slide runtime, and registers the catalog merge driver;
# 6. fetches the pinned Synara source into SYNARA_SOURCE_DIR (defaulting to
# the `sourceDirectory` in scripts/synara-runtime.json) and installs it
# with bun — only needed to run the real agent sidecar;
# 7. writes the runtime stub that lets cargo compile Tauri's resource
# manifest without a staged Synara bundle;
# 8. warms the cargo registry.
#
# It only writes inside the repository (including its git config), ~/.local, ~/.cargo and the Synara source
# directory. It does not edit your shell profile: the last thing it prints is
# the PATH line to add yourself.
#
# It is idempotent: every step checks before it acts, so re-running is cheap.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
case "$(uname -m)" in
x86_64) machine_arch="x64"; rust_triple_arch="x86_64" ;;
aarch64|arm64) machine_arch="arm64"; rust_triple_arch="aarch64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
# Everything this script installs lands here, so it is the one directory that
# has to be on PATH afterwards.
local_bin="$HOME/.local/bin"
mise_shims="$HOME/.local/share/mise/shims"
path_line="export PATH=\"\$HOME/.local/bin:\$HOME/.local/share/mise/shims:\$HOME/.cargo/bin:\$PATH\""
export PATH="$local_bin:$mise_shims:$HOME/.cargo/bin:$PATH"
step_started=0
start_step() {
step_started=$SECONDS
printf '\n==> %s\n' "$1"
}
finish_step() {
printf ' completed in %ss\n' "$((SECONDS - step_started))"
}
start_step "Installing system packages required by Tauri and LaTeX"
packages=(
build-essential
curl
file
git
latexmk
libayatana-appindicator3-dev
librsvg2-dev
libssl-dev
libwebkit2gtk-4.1-dev
libxdo-dev
pkg-config
wget
xz-utils
zip
)
missing_packages=()
for package in "${packages[@]}"; do
if ! dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -Fq 'install ok installed'; then
missing_packages+=("$package")
fi
done
if ((${#missing_packages[@]})); then
if ((EUID == 0)); then
apt_prefix=()
else
apt_prefix=(sudo)
fi
"${apt_prefix[@]}" apt-get update
"${apt_prefix[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing_packages[@]}"
fi
finish_step
start_step "Installing mise, then Node.js and pnpm at the pinned versions"
# mise.toml is the single source of truth for the Node and pnpm versions, and
# `pnpm check` is literally `mise run check`, so mise is not optional: without
# it this box cannot run the gate. Installing Node by hand here instead is how
# this script used to drift a whole major version away from mise.toml.
#
# Pinned and verified against the SHASUMS256.txt published with the same
# release, like the uv install below. `curl https://mise.run | sh` would run
# whatever the server returned today, unverified.
mise_version="${MISE_VERSION:-v2026.8.8}"
mise_bin="$local_bin/mise"
if ! "$mise_bin" --version 2>/dev/null | grep -Fq "${mise_version#v}"; then
mise_archive="mise-$mise_version-linux-$machine_arch.tar.gz"
mise_base="https://github.com/jdx/mise/releases/download/$mise_version"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
curl -fsSLo "$tmp_dir/$mise_archive" "$mise_base/$mise_archive"
curl -fsSLo "$tmp_dir/SHASUMS256.txt" "$mise_base/SHASUMS256.txt"
# mise's checksum file lists paths as `./mise-…`, so match on that and strip
# the prefix before handing the line to sha256sum.
(
cd "$tmp_dir"
grep -F " ./$mise_archive" SHASUMS256.txt | sed 's|\./||' | sha256sum --check --status
)
tar -xzf "$tmp_dir/$mise_archive" -C "$tmp_dir"
mkdir -p "$local_bin"
install -m 0755 "$tmp_dir/mise/bin/mise" "$mise_bin"
rm -rf "$tmp_dir"
trap - EXIT
fi
# `mise install` refuses to act on an untrusted config file when it cannot ask.
"$mise_bin" trust "$repo_root/mise.toml"
"$mise_bin" install
# Shims are what put the pinned `node` and `pnpm` on PATH for the rest of this
# script (and for any later shell that picks up the PATH line printed at the
# end) without needing `mise activate` in a profile.
"$mise_bin" reshim
finish_step
start_step "Installing the pinned Rust toolchain"
if [[ ! -x "$HOME/.cargo/bin/rustup" ]]; then
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path
fi
# With no arguments it installs rust-toolchain.toml's release, components and target.
rustup toolchain install
finish_step
start_step "Installing uv (optional; set SKIP_UV=1 to skip)"
# uv provisions the two Python literature CLIs the app shells out to
# (`bibcite-cli` and `arxiv2md`). Note what it is NOT for: the shipped macOS app
# never uses a `uv` from PATH. It downloads and checksum-verifies its own copy
# under ~/Library/Application Support and refuses to fall back to a PATH install
# (see MANAGED_UV_VERSION and managed_uv_tool_status in
# src-tauri/src/commands.rs). This copy is only so you can drive those tools by
# hand on a Linux dev box, which is why the step is skippable.
#
# It is pinned to the version the app manages, and the archive digest is
# recorded here rather than fetched, so a compromised release page cannot
# silently swap the binary. Bump both together when MANAGED_UV_VERSION moves.
uv_version="0.12.3"
uv_target="$rust_triple_arch-unknown-linux-gnu"
case "$rust_triple_arch" in
x86_64) uv_sha256="600cf9a742aca00d292673b16b5acffaa7b8c269a364ad0c2e79498dcb1fe101" ;;
aarch64) uv_sha256="bb66cb52e7b1823aed1183630d8d8e5c958840d584a4c55ec10a4cfc168dcca2" ;;
esac
if [[ "${SKIP_UV:-0}" == "1" ]]; then
printf ' skipped (SKIP_UV=1)\n'
elif "$local_bin/uv" --version 2>/dev/null | grep -Fq "$uv_version"; then
printf ' already at %s\n' "$uv_version"
else
uv_archive="uv-$uv_target.tar.gz"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
curl -fsSLo "$tmp_dir/$uv_archive" \
"https://github.com/astral-sh/uv/releases/download/$uv_version/$uv_archive"
printf '%s %s\n' "$uv_sha256" "$tmp_dir/$uv_archive" | sha256sum --check --status
tar -xzf "$tmp_dir/$uv_archive" -C "$tmp_dir"
mkdir -p "$local_bin"
install -m 0755 "$tmp_dir/uv-$uv_target/uv" "$local_bin/uv"
install -m 0755 "$tmp_dir/uv-$uv_target/uvx" "$local_bin/uvx"
rm -rf "$tmp_dir"
trap - EXIT
fi
finish_step
start_step "Installing locked Lattice dependencies"
pnpm install --frozen-lockfile
pnpm --dir literature-worker install --frozen-lockfile
pnpm --dir tools/open-slide-runtime install --frozen-lockfile
bun_bin="$repo_root/node_modules/.bin/bun"
if ! "$bun_bin" --version >/dev/null 2>&1; then
node node_modules/bun/install.js
fi
# The merge driver .gitattributes names for the Lingui catalogs, so branches
# that each add a string stop conflicting on the generated messages.po files.
node scripts/merge-po.mjs --install
finish_step
start_step "Installing pinned Synara dependencies"
synara_repository="$(node -p "require('./scripts/synara-runtime.json').repository")"
synara_revision="$(node -p "require('./scripts/synara-runtime.json').revision")"
synara_source="${SYNARA_SOURCE_DIR:-$(node -p "require('./scripts/synara-runtime.json').sourceDirectory")}"
if [[ "$synara_source" != /* ]]; then
synara_source="$repo_root/$synara_source"
fi
if [[ ! -d "$synara_source/.git" ]]; then
if [[ -e "$synara_source" ]]; then
if [[ ! -d "$synara_source" || -n "$(ls -A "$synara_source")" ]]; then
echo "Synara source path exists but is not an empty directory: $synara_source" >&2
exit 1
fi
fi
mkdir -p "$synara_source"
git init -q "$synara_source"
git -C "$synara_source" remote add origin "$synara_repository"
git -C "$synara_source" fetch --depth=1 origin "$synara_revision"
git -C "$synara_source" -c advice.detachedHead=false checkout --detach FETCH_HEAD
else
if ! git -C "$synara_source" remote get-url origin >/dev/null 2>&1; then
git -C "$synara_source" remote add origin "$synara_repository"
fi
synara_head="$(git -C "$synara_source" rev-parse HEAD 2>/dev/null || true)"
if [[ "$synara_head" != "$synara_revision" ]]; then
if [[ -n "$(git -C "$synara_source" status --short)" ]]; then
echo "Synara has local changes; leaving it at $synara_head instead of checking out $synara_revision."
else
git -C "$synara_source" fetch --depth=1 origin "$synara_revision"
git -C "$synara_source" -c advice.detachedHead=false checkout --detach FETCH_HEAD
fi
fi
fi
(
cd "$synara_source"
"$bun_bin" install --frozen-lockfile
)
# Cargo compiles Tauri's resource manifest even when tests never launch the
# bundled runtimes. Desktop packaging replaces these stubs with the real
# pinned resources through its beforeBuildCommand hook.
if [[ ! -e src-tauri/synara-runtime/manifest.json ]]; then
mkdir -p src-tauri/synara-runtime
printf 'stub\n' > src-tauri/synara-runtime/placeholder.txt
fi
finish_step
start_step "Fetching Rust crates"
cargo fetch --manifest-path src-tauri/Cargo.toml
finish_step
printf '\nSetup complete in %ss.\n' "$SECONDS"
printf '\nThis script did not touch your shell profile. Add this line to it\n'
printf 'yourself (or run it in each shell) so the toolchains are on PATH:\n\n'
printf ' %s\n\n' "$path_line"