#!/bin/sh # ancilla's bootstrap installer (#347). # # It knows three things: which node to use, how to get a release tarball, and # how to unpack and npm-install one into /versions//. Then it # hands over to the binary it just installed: # # exec node /versions//dist/cli/index.js install --activate # # Everything after that line is TypeScript (src/cli/install.ts), tested by # vitest, and upgraded with the binary. This script cannot be upgraded after # the fact, so it stays as small as it can. # # Usage: # curl -fsSL https://install.ancilla.sh | sh latest release # curl -fsSL https://install.ancilla.sh | sh -s -- --version 0.35.0 # sh install.sh latest release # sh install.sh --version 0.35.0 that release # sh install.sh --from ancilla-0.35.0.tgz # sh install.sh --from ~/src/ancilla a checkout with dist/ built # # Options: --prefix (default $ANCILLA_PREFIX, else $ANCILLA_HOME, else # ~/.ancilla), --node (default $ANCILLA_NODE, else node on PATH), # --force (reinstall a version that is already there), --prune (drop all but # the last two versions after activating), --with-bridges [,...] # (also install those bridges from the same release into # /bridges/; from a checkout, its built bridges/), # --with-bridge (the same as --with-bridges matrix), --bridge (install # that bridge tarball, named by its package; repeatable; with it, no bridge is # looked up by name). # # Releases are public at $ANCILLA_RELEASE_URL (default # https://update.ancilla.sh): releases/latest.json names the latest, and # releases/v/ holds release.json and the tarballs, no token needed. # When that cannot be read, a read-only GitHub token ($ANCILLA_GITHUB_TOKEN, # else ~/.ancilla/github-token, mode 600) reads the private repository's # releases instead. --from needs no token and no network. set -eu umask 022 # Everything runs from main, called on the last line: piped into sh, the whole # script is read before anything runs, so no child can read the rest of it # from stdin and a cut-off download runs nothing. main() { # Must equal package.json's engines.node; src/cli/install.test.ts checks. NODE_RANGE='^24.15.0 || >=26.0.0' REPO="${ANCILLA_RELEASE_REPO:-sarangsystems/ancilla}" API="${ANCILLA_GITHUB_API:-https://api.github.com}" PUBLIC="${ANCILLA_RELEASE_URL:-https://update.ancilla.sh}" PUBLIC=${PUBLIC%/} say() { printf 'ancilla install: %s\n' "$*"; } die() { printf 'ancilla install: %s\n' "$*" >&2; exit 1; } from="" version="" prefix="" node="${ANCILLA_NODE:-}" force=0 prune="" bridges="" with="" root_lock="" nbridge=0 while [ $# -gt 0 ]; do case "$1" in --from) [ $# -ge 2 ] || die "--from needs a tarball or a checkout"; from=$2; shift 2 ;; --version) [ $# -ge 2 ] || die "--version needs a version"; version=${2#v}; shift 2 ;; --prefix) [ $# -ge 2 ] || die "--prefix needs a directory"; prefix=$2; shift 2 ;; --node) [ $# -ge 2 ] || die "--node needs a path"; node=$2; shift 2 ;; --force) force=1; shift ;; --prune) prune=--prune; shift ;; --bridge) [ $# -ge 2 ] || die "--bridge needs a tarball"; bridges=1; nbridge=$((nbridge + 1)); eval "bridge_$nbridge=\$2"; shift 2 ;; --with-bridge) with="$with matrix"; shift ;; --with-bridges) [ $# -ge 2 ] || die "--with-bridges needs bridge names"; with="$with $(printf '%s' "$2" | tr ',' ' ')"; shift 2 ;; -h|--help) case "$0" in *install.sh) sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' ;; *) printf 'usage: curl -fsSL https://install.ancilla.sh | sh -s -- [--version ] [--from ] [--prefix ] ...\nEvery option is at the top of https://install.ancilla.sh/install.sh\n' ;; esac exit 0 ;; *) die "unknown argument '$1' (see --help)" ;; esac done # --- node --------------------------------------------------------------------- if [ -z "$node" ]; then node=$(command -v node 2>/dev/null) || die "no node on PATH. ancilla needs node $NODE_RANGE (https://nodejs.org/), or set ANCILLA_NODE. Nothing was changed." fi case "$node" in /*) ;; */*) node="$(cd "$(dirname "$node")" && pwd)/$(basename "$node")" ;; *) node=$(command -v "$node") || die "no '$node' on PATH" ;; esac [ -x "$node" ] || die "$node is not executable" node_version=$("$node" -p 'process.versions.node') || die "$node did not run" node_ok() { major=${1%%.*} rest=${1#*.} minor=${rest%%.*} [ "$major" -ge 26 ] || { [ "$major" -eq 24 ] && [ "$minor" -ge 15 ]; } } node_ok "$node_version" || die "node $node_version ($node) is outside $NODE_RANGE. Install a matching node (https://nodejs.org/) or point ANCILLA_NODE at one. Nothing was changed." # npm must run under the same node, and its own shebang says `env node`. PATH="$(dirname "$node"):$PATH" export PATH command -v npm >/dev/null 2>&1 || die "no npm next to $node or on PATH" home="${ANCILLA_HOME:-$HOME/.ancilla}" [ -n "$prefix" ] || prefix="${ANCILLA_PREFIX:-$home}" case "$prefix" in /*) ;; *) prefix="$(pwd)/$prefix" ;; esac work=$(mktemp -d "${TMPDIR:-/tmp}/ancilla-install.XXXXXX") stage="" cleanup() { rm -rf "$work" [ -z "$stage" ] || rm -rf "$stage" } trap cleanup EXIT trap 'exit 1' INT TERM HUP # --- the tarball ---------------------------------------------------------------- asset_url() { "$node" -e ' const [meta, name] = process.argv.slice(1); const a = (JSON.parse(require("fs").readFileSync(meta, "utf8")).assets || []).find((x) => x.name === name); if (!a) { console.error("ancilla install: the release has no asset named " + name); process.exit(1); } console.log(a.url); ' "$1" "$2" } # verify_sha verify_sha() { "$node" -e ' const [manifest, file, name] = process.argv.slice(1); const want = (require(manifest).sha256s || {})[name]; const got = require("crypto").createHash("sha256").update(require("fs").readFileSync(file)).digest("hex"); if (want !== got) { console.error(`ancilla install: ${name} sha256 is ${got}, release.json says ${want}. Nothing was changed.`); process.exit(1); } ' "$1" "$2" "$3" } # fetch_asset : download a release asset into $work/, from # $PUBLIC/releases/v/ or through the GitHub API. fetch_asset() { if [ "$source" = public ]; then curl -fsSL -o "$work/$1" "$PUBLIC/releases/v$version/$1" || die "could not download $PUBLIC/releases/v$version/$1. Nothing was changed." else url=$(asset_url "$work/meta.json" "$1") || exit 1 curl -fsSL -H @"$work/auth" -H 'Accept: application/octet-stream' -o "$work/$1" "$url" \ || die "could not download $1" fi } # take_bridge []: unpack a bridge tarball into $work/b/, # from its package name, ancilla--bridge. take_bridge() { t="$work/b/.unpack" rm -rf "$t" mkdir -p "$t" tar -xzf "$1" -C "$t" --strip-components=1 || die "could not unpack $1" pname=$("$node" -p 'require(process.argv[1]).name' "$t/package.json" 2>/dev/null) || die "$1 is not an ancilla bridge (no package.json)" name=${pname#ancilla-} name=${name%-bridge} case "$pname" in ancilla-*-bridge) ;; *) die "$1 is not an ancilla bridge (its package is $pname)" ;; esac case "$name" in "" | *[!a-z0-9-]* | [!a-z]*) die "$1 is not an ancilla bridge (its package is $pname)" ;; esac [ ! -d "$work/b/$name" ] || die "two tarballs for the $name bridge" # The release packs the bridge's lockfile as release-lock.json (npm pack # never ships a package-lock.json): install exactly what was tested. if [ -f "$t/release-lock.json" ]; then mv "$t/release-lock.json" "$t/package-lock.json" elif [ -n "${2:-}" ]; then cp "$2" "$t/package-lock.json" fi mv "$t" "$work/b/$name" } i=1 while [ "$i" -le "$nbridge" ]; do eval "b=\$bridge_$i" case "$b" in /*) ;; *) b="$(pwd)/$b" ;; esac [ -f "$b" ] || die "--bridge $b is not a file" take_bridge "$b" i=$((i + 1)) done # The bridges asked for by name, once each; none when --bridge gave tarballs. wanted="" if [ -z "$bridges" ]; then for name in $with; do case "$name" in "" | *[!a-z0-9-]* | [!a-z]*) die "'$name' is not a bridge name" ;; esac case " $wanted " in *" $name "*) ;; *) wanted="$wanted $name" ;; esac done fi if [ -n "$from" ]; then if [ -d "$from" ]; then [ -f "$from/dist/cli/index.js" ] || die "$from has no dist/cli/index.js. Run 'npm ci && npm run build' there first." (cd "$from" && npm pack --silent --pack-destination "$work" >/dev/null) || die "npm pack failed in $from" set -- "$work"/*.tgz [ $# -eq 1 ] && [ -f "$1" ] || die "npm pack in $from did not produce one tarball" tgz=$1 [ -f "$from/package-lock.json" ] && root_lock="$from/package-lock.json" for name in $wanted; do bd="$from/bridges/$name" [ -f "$bd/package.json" ] || die "$from has no bridge named $name (no bridges/$name/package.json)" entry=$("$node" -p '(require(process.argv[1]).bin || {})[process.argv[2]] || ""' "$bd/package.json" "ancilla-$name") [ -n "$entry" ] || die "$bd/package.json has no bin named ancilla-$name" [ -f "$bd/$entry" ] || die "$bd has no $entry. Run 'npm ci && npm run build' there first." mkdir -p "$work/p/$name" (cd "$bd" && npm pack --silent --pack-destination "$work/p/$name" >/dev/null) || die "npm pack failed in $bd" set -- "$work/p/$name"/*.tgz [ $# -eq 1 ] && [ -f "$1" ] || die "npm pack in $bd did not produce one tarball" lock="" [ -f "$bd/package-lock.json" ] && lock="$bd/package-lock.json" take_bridge "$1" "$lock" done elif [ -f "$from" ]; then tgz=$from [ -z "$wanted" ] || die "--with-bridge with a tarball needs --bridge " else die "--from $from is neither a tarball nor a checkout" fi else command -v curl >/dev/null 2>&1 || die "curl is needed to download a release (or pass --from)" token="${ANCILLA_GITHUB_TOKEN:-}" if [ -z "$token" ] && [ -r "$home/github-token" ]; then token=$(tr -d ' \t\r\n' < "$home/github-token") fi # $ANCILLA_GITHUB_API alone (tests, staging) means GitHub only. source=public if [ -n "${ANCILLA_GITHUB_API:-}" ] && [ -z "${ANCILLA_RELEASE_URL:-}" ]; then source=github elif [ -n "$version" ]; then first="$PUBLIC/releases/v$version/release.json" curl -fsSL -o "$work/release.json" "$first" 2>/dev/null || source="" else first="$PUBLIC/releases/latest.json" if curl -fsSL -o "$work/latest.json" "$first" 2>/dev/null; then version=$("$node" -p 'const l = require(process.argv[1]); String(l.version || l.tag || "").replace(/^v/, "")' "$work/latest.json" 2>/dev/null) || version="" case "$version" in "" | *[!0-9A-Za-z.+-]*) die "$first names no usable version. Nothing was changed." ;; esac curl -fsSL -o "$work/release.json" "$PUBLIC/releases/v$version/release.json" \ || die "could not download $PUBLIC/releases/v$version/release.json. Nothing was changed." else source="" fi fi if [ -z "$source" ]; then [ -n "$token" ] || die "could not read $first, and there is no GitHub token to fall back on. Try again later, or pass --from . Nothing was changed." printf 'ancilla install: could not read %s; downloading from GitHub (%s) instead\n' "$first" "$REPO" >&2 source=github fi if [ "$source" = github ]; then [ -n "$token" ] || die "no GitHub token. Put a read-only token for $REPO in $home/github-token (chmod 600), or pass --from . Nothing was changed." # A header file keeps the token out of `ps`. (umask 077 && printf 'Authorization: Bearer %s\n' "$token" > "$work/auth") if [ -n "$version" ]; then rel="$API/repos/$REPO/releases/tags/v$version" else rel="$API/repos/$REPO/releases/latest" fi curl -fsSL -H @"$work/auth" -H 'Accept: application/vnd.github+json' -o "$work/meta.json" "$rel" \ || die "could not read $rel (is the token valid for $REPO?)" url=$(asset_url "$work/meta.json" release.json) || exit 1 curl -fsSL -H @"$work/auth" -H 'Accept: application/octet-stream' -o "$work/release.json" "$url" \ || die "could not download release.json" fi name=$("$node" -p 'require(process.argv[1]).root_tgz' "$work/release.json") case "$name" in */*|"") die "release.json names no usable root_tgz" ;; esac say "downloading $name" fetch_asset "$name" verify_sha "$work/release.json" "$work/$name" "$name" || exit 1 tgz="$work/$name" for name in $wanted; do # release.json's `bridges` names each bridge's tarball; releases before # it name only the Matrix bridge's, as bridge_tgz. bname=$("$node" -p 'const m = require(process.argv[1]); (m.bridges || {})[process.argv[2]] || (process.argv[2] === "matrix" && m.bridge_tgz) || ""' "$work/release.json" "$name") case "$bname" in */*|"") die "release.json names no usable tarball for the $name bridge" ;; esac say "downloading $bname" fetch_asset "$bname" verify_sha "$work/release.json" "$work/$bname" "$bname" || exit 1 take_bridge "$work/$bname" done fi # --- unpack and install ----------------------------------------------------------- mkdir -p "$prefix/versions" prefix=$(cd "$prefix" && pwd -P) stage="$prefix/versions/.incoming.$$" rm -rf "$stage" mkdir "$stage" tar -xzf "$tgz" -C "$stage" --strip-components=1 || die "could not unpack $tgz" [ "$("$node" -p 'require(process.argv[1]).name' "$stage/package.json")" = ancilla ] || die "$tgz is not an ancilla package" v=$("$node" -p 'require(process.argv[1]).version' "$stage/package.json") dest="$prefix/versions/$v" if [ -d "$dest" ] && [ "$force" -ne 1 ]; then for d in "$work/b"/*; do [ -d "$d" ] || continue name=$(basename "$d") [ -d "$dest/bridges/$name" ] || die "$v is already installed at $dest without the $name bridge; add --force to reinstall it with the bridge" done say "$v is already installed at $dest" rm -rf "$stage" stage="" else say "installing $v into $dest" # The release packs the lockfile as release-lock.json (npm pack never ships # a package-lock.json): install exactly the tree the release was tested with. if [ -f "$stage/release-lock.json" ]; then mv "$stage/release-lock.json" "$stage/package-lock.json" elif [ -n "$root_lock" ]; then cp "$root_lock" "$stage/package-lock.json" fi # No install scripts, no dev or optional dependencies: better-sqlite3 13 # ships its prebuilds inside the package, and @napi-rs/canvas is optional # (src/convert/canvas.ts stubs what pdfjs needs from it). if [ -f "$stage/package-lock.json" ]; then (cd "$stage" && npm ci --omit=dev --omit=optional --ignore-scripts --no-audit --no-fund --loglevel=error) \ || die "npm ci failed in $stage" else (cd "$stage" && npm install --omit=dev --omit=optional --ignore-scripts --no-audit --no-fund --loglevel=error) \ || die "npm install failed in $stage" fi for d in "$work/b"/*; do [ -d "$d" ] || continue # Each bridge (#534), from the same release, beside the daemon. Its # install scripts run: the Matrix bridge's native crypto library is # downloaded by one. name=$(basename "$d") b="$stage/bridges/$name" mkdir -p "$stage/bridges" mv "$d" "$b" bv=$("$node" -p 'require(process.argv[1]).version' "$b/package.json") [ "$bv" = "$v" ] || die "the $name bridge is $bv and ancilla is $v; a release carries both at one version. Nothing was changed." say "installing the $name bridge $bv into $dest/bridges/$name" if [ -f "$b/package-lock.json" ]; then (cd "$b" && npm ci --omit=dev --no-audit --no-fund --loglevel=error) || die "npm ci failed in $b" else (cd "$b" && npm install --omit=dev --no-audit --no-fund --loglevel=error) || die "npm install failed in $b" fi if [ "$name" = matrix ]; then set -- "$b"/node_modules/@matrix-org/matrix-sdk-crypto-nodejs/*.node [ -f "$1" ] || die "the bridge's native crypto library was not downloaded into $b/node_modules/@matrix-org/matrix-sdk-crypto-nodejs (its postinstall fetches it from GitHub). Nothing was changed." fi done if [ -d "$dest" ]; then mv "$dest" "$prefix/versions/.replaced.$$" mv "$stage" "$dest" rm -rf "$prefix/versions/.replaced.$$" else mv "$stage" "$dest" fi stage="" fi cleanup trap - EXIT exec "$node" "$dest/dist/cli/index.js" install --activate --node "$node" $prune } main "$@"