#!/usr/bin/env bash set -euo pipefail # cast installer — intended for: curl -fsSL .../install.sh | bash # # Downloads the cast repo tarball, builds it, and installs it into the # VERSIONED layout under $DEST (box#79's layout, ported the way rig#36 # ported it): # # $DEST/versions// one full tree per installed version # $DEST/current -> versions/ the default version # $BINDIR/cast -> $DEST/current/bin/cast the PATH entry # # Versions install side by side: `cast versions` lists them, `cast use ` # switches the default, `cast uninstall` removes them. Re-running with an # already-installed version is a converging no-op (CAST_REINSTALL=1 replaces # that version's tree); a NEW version installs beside the old one and becomes # the default. cast neither refuses nor warns where box refuses and rig # warns: box protects live boxes and rig a converged host, but cast is an # API client — flipping its version strands nothing on this machine, and # `cast use ` is always one command away. A pre-versioning flat tree is # migrated in place, so upgrading is seamless. # # The version IS the tree's package.json version — cast's single source of # truth (deliberately no separate VERSION file). # # CAST_INSTALL_SOURCE= installs from a local tree instead of # downloading — for CI and the test suite, so what lands is the code under # review. # # Unlike rig (pure bash, runs on bare boxes), cast runs on YOUR machine and # needs node — it is an API client, never something a server installs. REPO="${CAST_REPO:-heavy-duty/cast}" REF="${CAST_REF:-main}" DEST="${CAST_HOME:-$HOME/.local/share/cast}" if [ "$(id -u)" -eq 0 ]; then BINDIR="${CAST_BIN:-/usr/local/bin}" else BINDIR="${CAST_BIN:-$HOME/.local/bin}" fi log() { printf 'cast-install: %s\n' "$*"; } warn() { printf 'cast-install: WARNING: %s\n' "$*" >&2; } die() { printf 'cast-install: ERROR: %s\n' "$*" >&2; exit 1; } # A version is a DIRECTORY NAME under versions/ — nothing else. One strict # gate for every caller that builds a path from one (the installer's new_ver, # migration's flat_ver, and bin/cast's 'use'/single-version uninstall): only # [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes, # spaces and option-lookalikes by construction — a crafted version dies HERE, # never in an rm -rf or an ln. bin/cast carries a byte-identical copy; # test/install-sh.test.ts diffs the two so the gates cannot drift. valid_version() { case "$1" in ''|.*|-*) return 1 ;; *[!A-Za-z0-9._+-]*) return 1 ;; esac return 0 } # The tree's package.json version, or empty. Read via node (a prerequisite # anyway) — never by regexing JSON. The path travels by env var, not by # splicing it into the expression, so no filename can break the quoting. pkg_version() { CAST_PKG_PATH="$1/package.json" node -p 'require(process.env.CAST_PKG_PATH).version' 2>/dev/null || true } # --- prerequisites ----------------------------------------------------------- # curl only when something must be downloaded — a local CAST_INSTALL_SOURCE # needs none, which is what lets the test suite drive REAL installs offline. if [ -z "${CAST_INSTALL_SOURCE:-}" ]; then command -v curl >/dev/null 2>&1 || die "curl is required but was not found." fi command -v tar >/dev/null 2>&1 || die "tar is required but was not found." command -v node >/dev/null 2>&1 || die "node >=22.12 is required but was not found." command -v npm >/dev/null 2>&1 || die "npm is required but was not found." NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" [ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))." # readlink -f is load-bearing across the layout (the launcher and every verb # resolve the symlink chain with it). GNU always has it; Apple's readlink # grew -f in macOS 12.3 (March 2022). Probe once and refuse loudly on the # museum pieces, instead of failing weirdly mid-flip later. readlink -f / >/dev/null 2>&1 \ || die "this system's readlink does not support -f (macOS older than 12.3?) — upgrade, or 'brew install coreutils'." # age is what decrypts the state repo's secrets — apply/diff shell out to it. if ! command -v age >/dev/null 2>&1; then warn "age not found — 'cast apply' and 'cast diff' will fail until it is installed." warn " Debian/Ubuntu: sudo apt install age | Arch: sudo pacman -S age" warn " Fedora: sudo dnf install age | macOS: brew install age" fi if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then SRCDESC="local source $CAST_INSTALL_SOURCE" else SRCDESC="$REPO@$REF" fi # Flip /current to versions/ atomically: build the new link beside # it, rename(2) over. Plain ln -sfn is unlink+create — a window where current # names nothing and a concurrent 'cast' invocation dies mid-chain. The rename # rides node's fs.renameSync because the coreutils spelling is not portable — # GNU mv says "replace, don't descend" with -T, BSD/macOS says -h — while # rename(2) itself is POSIX and node is a cast prerequisite on every # platform. bin/cast carries a byte-identical copy; test/install-sh.test.ts # diffs the two so they cannot drift. flip_current() { # $1 = install root, $2 = version ln -sfn "versions/$2" "$1/current.new.$$" CAST_FLIP_NEW="$1/current.new.$$" CAST_FLIP_CUR="$1/current" \ node -e 'const fs = require("node:fs"); fs.renameSync(process.env.CAST_FLIP_NEW, process.env.CAST_FLIP_CUR);' } # --- migrate a pre-versioning flat install ----------------------------------- # The old installer put the tree FLAT at $DEST (bin/cast directly under it). # Move such a tree to versions/ BEFORE anything else, so the # upgrade is seamless and the version comparison below sees the truth. The # move is two renames inside one parent directory — no copying, no window with # no install — and the operator's tree is preserved bit for bit. if [ -e "$DEST/bin/cast" ] && [ ! -d "$DEST/versions" ]; then flat_ver="$(pkg_version "$DEST")" [ -n "$flat_ver" ] || flat_ver="0.0.0-unknown" # The flat tree's version is data from disk, not from this installer — the # same trust boundary as the new_ver check, so the same gate: a corrupted # (or hostile) package.json must not steer the mv/ln below out of versions/. valid_version "$flat_ver" || die "the flat install's package.json version is not a sane directory name: '$flat_ver' — fix $DEST/package.json, then re-run" log "found a pre-versioning flat install at $DEST (version $flat_ver) — migrating it into the versioned layout" staging="$DEST.migrating.$$" mv "$DEST" "$staging" mkdir -p "$DEST/versions" mv "$staging" "$DEST/versions/$flat_ver" flip_current "$DEST" "$flat_ver" mkdir -p "$BINDIR" ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast" log "migrated: it now lives at $DEST/versions/$flat_ver (still current)" fi # --- temp workspace ---------------------------------------------------------- TMPDIR="$(mktemp -d)" cleanup() { rm -rf "$TMPDIR"; } trap cleanup EXIT # --- acquire the tree -------------------------------------------------------- if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then SRC="$CAST_INSTALL_SOURCE" INSTALLED_FROM="local:$SRC" if [ -d "$SRC" ]; then log "copying local tree $SRC" mkdir -p "$TMPDIR/tree" # tar, not cp -a: --exclude=.git so a working checkout never carries its # VCS state into the install tree, --exclude=./node_modules (top level # only — nested ones are npm's own business) because npm ci below builds # dependencies fresh from the lockfile anyway. tar -C "$SRC" --exclude=.git --exclude=./node_modules -cf - . | tar -xf - -C "$TMPDIR/tree" EXTRACTED="$TMPDIR/tree" elif [ -f "$SRC" ]; then log "extracting local tarball $SRC" tar -xzf "$SRC" -C "$TMPDIR" || die "failed to extract $SRC" EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" else die "CAST_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC" fi else INSTALLED_FROM="$REPO@$REF" URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" log "installing cast ($REPO@$REF)" log "downloading $URL" curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz" \ || die "failed to download $URL" log "extracting archive" tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \ || die "failed to extract archive" # GitHub names the archive's top dir - — deriving that name is # guesswork (it broke for real at box's repo rename). The tarball has # exactly ONE top-level directory: take the directory, whatever it is # called, and let the bin/cast check below judge whether it is the right # tree. EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" fi [ -n "${EXTRACTED:-}" ] || die "could not find the source tree in $SRCDESC" [ -f "$EXTRACTED/bin/cast" ] || die "source does not contain bin/cast — is $SRCDESC correct?" # The tree's own package.json names the directory it lands in — the version # IS the identity of what is being installed, and 'cast versions' lists # these names. new_ver="$(pkg_version "$EXTRACTED")" [ -n "$new_ver" ] || die "source has no package.json version — cannot install it as a version" valid_version "$new_ver" || die "the source's package.json version is not a sane directory name: '$new_ver'" set_exec() { # $1 = a cast tree: the executable bits install.sh owns chmod +x "$1/bin/cast" if [ -d "$1/scripts" ]; then find "$1/scripts" -name '*.sh' -exec chmod +x {} + fi } # Build the tree IN PLACE, in the temp workspace — deps + tsc, then drop the # dev deps. Landing in versions/ happens after, by rename: a half-built tree # never sits where the version chain can resolve to it. build_tree() { # $1 = the tree to build log "building (npm ci && npm run build)" ( cd "$1" && npm ci --silent && npm run build --silent ) \ || die "build failed" ( cd "$1" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies" } # --- install into $DEST/versions/ ----------------------------------- VDIR="$DEST/versions/$new_ver" newly_installed=0 if [ -d "$VDIR" ]; then if [ -n "${CAST_REINSTALL:-}" ]; then # Replace THIS version's tree, as atomically as two renames allow — never # a partial overlay of new files onto an old tree. log "CAST_REINSTALL=1 — replacing the installed $new_ver tree" build_tree "$EXTRACTED" stage="$VDIR.new.$$"; old="$VDIR.old.$$" rm -rf "$stage" "$old" set_exec "$EXTRACTED" mv "$EXTRACTED" "$stage" # Swap by renames, delete LAST: rm-then-move leaves a hole the whole # length of the delete where current -> this version resolves to nothing. mv "$VDIR" "$old" mv "$stage" "$VDIR" rm -rf "$old" printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM" log "reinstalled $new_ver" else cur_from="$(cat "$VDIR/INSTALLED_FROM" 2>/dev/null || echo '')" log "cast $new_ver is already installed ($cur_from) — nothing to do, and nothing was built." log "(CAST_REINSTALL=1 replaces this version's tree; 'cast versions' lists what is installed.)" fi else build_tree "$EXTRACTED" log "installing $new_ver into $VDIR" mkdir -p "$DEST/versions" set_exec "$EXTRACTED" mv "$EXTRACTED" "$VDIR" newly_installed=1 # Record WHAT was installed, so a caller can assert it got what it asked # for — an installer invoked with stale env vars silently falls back to the # defaults, and INSTALLED_FROM is how that lie gets caught. printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM" fi # --- which version is the default? ------------------------------------------- # 'current' is the tracked default; flipping it is the ONLY step that changes # what an operator's `cast` runs. A fresh host (or a dangling current) is # claimed outright; an upgrade flips, because a re-run that silently left you # on the old version would make "re-run any time to upgrade" a lie. Judged # from versions/ itself (readlink -f), never from what a wedged current # claims. cur="$(readlink -f "$DEST/current" 2>/dev/null || true)" want="$(readlink -f "$VDIR")" if [ -z "$cur" ] || [ ! -d "$cur" ]; then flip_current "$DEST" "$new_ver" log "default version: $new_ver" elif [ "$cur" = "$want" ]; then : # already the default — nothing to flip elif [ "$newly_installed" -eq 0 ]; then # A converge/no-op (or CAST_REINSTALL) of a version that is NOT the default # never moves the default — a re-run must change nothing; switching is # 'cast use', a deliberate act. log "the default stays $(basename "$cur") — 'cast use $new_ver' switches." else old_ver="$(basename "$cur")" flip_current "$DEST" "$new_ver" log "default version switched: $old_ver -> $new_ver ('cast use $old_ver' switches back)" fi # --- put cast on PATH -------------------------------------------------------- # Through the current chain, and converging — that includes HEALING: a stale # or dangling $BINDIR/cast (say, its tree half-removed by hand) must never # block or wedge an install — it gets repointed at the current chain, # whatever it said before. mkdir -p "$BINDIR" ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast" log "linked $BINDIR/cast -> $DEST/current/bin/cast" # --- wire $BINDIR onto PATH, durably ----------------------------------------- # `curl | bash` runs in a subshell, so exporting PATH here would die with this # process. The only durable place is the user's shell profile — so append there, # once, marked. Opt out with CAST_NO_MODIFY_PATH=1 and wire it yourself. MARKER='# added by cast-install' on_path() { case ":$PATH:" in *":$1:"*) return 0 ;; *) return 1 ;; esac } # Which file a *login/interactive* shell of this flavour actually reads. profile_for_shell() { case "${SHELL##*/}" in zsh) printf '%s\n' "${ZDOTDIR:-$HOME}/.zshrc" ;; bash) # macOS terminals start login shells (.bash_profile); Linux does not. if [ "$(uname -s)" = "Darwin" ]; then printf '%s\n' "$HOME/.bash_profile" else printf '%s\n' "$HOME/.bashrc" fi ;; fish) printf '%s\n' "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" ;; *) printf '%s\n' "$HOME/.profile" ;; esac } path_line_for() { case "$1" in */config.fish) printf 'fish_add_path %s\n' "$BINDIR" ;; *) printf 'export PATH="%s:$PATH"\n' "$BINDIR" ;; esac } if on_path "$BINDIR"; then : # already reachable — nothing to wire elif [ -n "${CAST_NO_MODIFY_PATH:-}" ]; then warn "$BINDIR is not on your PATH (CAST_NO_MODIFY_PATH set — leaving your profile alone)." warn " add: $(path_line_for "$(profile_for_shell)")" else PROFILE="$(profile_for_shell)" if [ -f "$PROFILE" ] && grep -qF "$MARKER" "$PROFILE"; then log "$PROFILE already puts $BINDIR on PATH — this shell just predates it." else mkdir -p "$(dirname "$PROFILE")" { printf '\n%s\n' "$MARKER"; path_line_for "$PROFILE"; } >>"$PROFILE" \ || die "could not write $PROFILE — add this line yourself: $(path_line_for "$PROFILE")" log "wired $BINDIR onto PATH in $PROFILE" fi log "this shell does not have it yet — open a new one, or: source $PROFILE" fi log "done ($SRCDESC, version $new_ver) — try: cast --help"