Merge pull request #100 from dan-claude-bot/feat/versioned-installs

feat: versioned installations — the box#79 layout, ported the way rig#36 ported it
This commit is contained in:
Daniel Marin 2026-07-18 23:14:54 +01:00 committed by GitHub
commit 35604ebc0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1173 additions and 35 deletions

View file

@ -24,3 +24,37 @@ jobs:
run: bash -n install.sh bin/cast scripts/*.sh
- name: labels state-machine tests
run: bash test/labels-reconcile.sh
# The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at
# this checkout, so CI proves the installer under review (the versioned
# layout, the current symlink, the PATH chain, the uninstall's absence
# assert), not a hand-built imitation of it. Box's CI installs box the
# same way. This is the one place the real npm ci + tsc build path runs
# end to end; the vitest installer tests cover the layout semantics
# offline with a shimmed npm.
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: install via install.sh, from this checkout
run: |
CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh
# assert what landed: the layout, the chain, and that it answers
readlink -f "$HOME/.local/bin/cast" | grep '/versions/'
"$HOME/.local/bin/cast" --version
"$HOME/.local/bin/cast" versions
- name: converging no-op — a re-run changes nothing and builds nothing
run: |
CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh \
| tee /tmp/rerun.log
grep -q 'already installed' /tmp/rerun.log
- name: uninstall --all — ends with the absence assert
run: |
CAST_YES=1 "$HOME/.local/bin/cast" uninstall --all
test ! -e "$HOME/.local/share/cast"
test ! -e "$HOME/.local/bin/cast"
test ! -L "$HOME/.local/bin/cast"

View file

@ -21,6 +21,23 @@ are decrypted by shelling out to it). Re-run any time to upgrade. Unlike rig —
which is pure bash so it can run on a bare box — cast runs on **your** machine:
it is an API client, and a server should never install it.
Installs are **versioned**, the same layout box and rig use: each install
lands at `~/.local/share/cast/versions/<version>` (the version is the tree's
`package.json` version), a `current` symlink names the default, and the
`cast` on your PATH points through it. Versions install side by side:
```sh
cast versions # list what is installed, marking (current) and (running)
cast use <version> # switch the default — atomic, then asserted
cast uninstall [<version>|--all] # remove one non-current version, or everything
```
Re-running the installer with an already-installed version is a converging
no-op (`CAST_REINSTALL=1` rebuilds and replaces that version's tree); a new
version installs beside the old one and becomes the default — `cast use <old>`
switches back. A pre-versioning flat install is migrated in place on the next
run. `CAST_REF=<branch>` installs from another branch instead of `main`.
The installer symlinks `cast` into `~/.local/bin` (or `/usr/local/bin` as root)
and, if that directory is not already on your `PATH`, appends it to your shell
profile — `.zshrc`, `.bashrc`/`.bash_profile`, or `config.fish`, whichever your

256
bin/cast
View file

@ -1,12 +1,262 @@
#!/usr/bin/env bash
set -euo pipefail
# Thin launcher — the CLI itself is dist/cli.js (built from src/ by tsc).
# Kept as a shim so `cast` lands on PATH the same way `rig` does, without
# requiring a global npm install.
# Launcher — the CLI itself is dist/cli.js (built from src/ by tsc), plus
# the VERSIONED-INSTALL verbs (versions / use / uninstall), which live here
# in bash because they manage the layout the node tree sits in: they must
# work even when the default version's dist/ is broken — that is exactly
# when you need them.
ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
log() { printf 'cast: %s\n' "$*"; }
warn() { printf 'cast: WARNING: %s\n' "$*" >&2; }
die() { printf 'cast: ERROR: %s\n' "$*" >&2; exit 1; }
# --- the versioned install (box#79's layout, ported the way rig#36 did) ------
# install.sh lands each version at <install-root>/versions/<v>, with a
# 'current' symlink naming the default and $BINDIR/cast pointing through it.
# $ROOT (readlink -f, above) already resolved the whole chain, so a versioned
# install always runs from .../versions/<v> — and a git checkout does not,
# which is how these verbs know to refuse instead of uninstalling somebody's
# working copy.
install_root() {
local vdir; vdir="$(dirname "$ROOT")"
[ "$(basename "$vdir")" = versions ] || return 1
dirname "$vdir"
}
# 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. install.sh 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 — cast's version lives there
# (deliberately no separate VERSION file), read via node, never by regexing
# JSON. The path travels by env var 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
}
# Flip <root>/current to versions/<v> 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. install.sh 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);'
}
# The PATH symlinks that could ride this install: the one this invocation came
# in on, CAST_BIN's, and the tier default's. Candidates only — every consumer
# checks where a link actually points before touching it, so a symlink that is
# somebody else's (another install root, a hand-rolled wrapper) is never moved.
bin_links() {
local c=()
[ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}")
[ -n "${CAST_BIN:-}" ] && c+=("$CAST_BIN/cast")
if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/cast); else c+=("$HOME/.local/bin/cast"); fi
printf '%s\n' "${c[@]}" | awk '!seen[$0]++'
}
converge_bin_links() { # $1 = install root: point our PATH symlinks through current
local ir="$1" p t
while IFS= read -r p; do
[ -L "$p" ] || continue
t="$(readlink -f "$p" 2>/dev/null || true)"
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
case "$t" in
"$ir"/*) ln -sfn "$ir/current/bin/cast" "$p" ;;
esac
done < <(bin_links)
}
cmd_versions() {
local ir cur d v mark
ir="$(install_root)" || die "this cast runs from a working tree ($ROOT), not a versioned install — nothing to list"
cur="$(readlink -f "$ir/current" 2>/dev/null || true)"
echo "VERSIONS ($ir)"
for d in "$ir/versions"/*/; do
[ -d "$d" ] || continue
v="$(basename "$d")"
mark=""
[ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)"
[ "$(readlink -f "$d")" = "$ROOT" ] && mark="$mark (running)"
printf ' %s%s\n' "$v" "$mark"
done
echo
echo "switch the default: cast use <version>"
echo "install another: re-run install.sh (versions land side by side)"
}
cmd_use() {
local v="${1:-}" ir eff expect out
if [ -z "$v" ]; then
printf 'cast: use needs a version (see: cast versions)\n' >&2
exit 2
fi
ir="$(install_root)" || die "this cast runs from a working tree ($ROOT), not a versioned install — nothing to switch"
valid_version "$v" || die "not a sane version name: '$v' (a version is a directory name under versions/ — see 'cast versions')"
[ -d "$ir/versions/$v" ] || die "no such version: $v (see 'cast versions')"
flip_current "$ir" "$v"
converge_bin_links "$ir"
# Assert the EFFECTIVE result, not the intent: current must resolve to the
# version asked for, and the chain's own binary must answer that version —
# a flip that "worked" while the operator's cast still runs the old tree is
# exactly the flakiness this verb exists to end.
eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
[ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v"
expect="$(pkg_version "$ir/versions/$v")"
if [ -n "$expect" ]; then
out="$("$ir/current/bin/cast" --version 2>&1 || true)"
case "$out" in
*"$expect"*) : ;;
*) die "current/bin/cast answers '$out', not version $expect — the symlink chain is broken" ;;
esac
fi
log "switched to $v (current -> versions/$v)"
}
# The uninstall's own confirmation: --force, or CAST_YES=1, or a TTY. CAST_YES
# is the installer-family consent contract — how automation says yes without
# a terminal; without any of the three we refuse rather than assume consent.
uninstall_confirm() { # $1 = question
[ "$force" -eq 1 ] && return 0
[ -n "${CAST_YES:-}" ] && return 0
if [ ! -t 0 ]; then
printf 'cast: refusing to %s without --force (no terminal to confirm on; CAST_YES=1 also means yes)\n' "$1" >&2
exit 2
fi
local reply
printf 'cast: %s? [y/N] ' "$1"
read -r reply
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}
# 'cast uninstall' — trees and symlinks, and it ENDS by PROVING the absence —
# the last word is a re-check, not a hope.
cmd_uninstall() {
local ir a ver="" all=0 force=0 cur p t leftover="" deduped=""
local targets=()
for a in "$@"; do
case "$a" in
--all) all=1 ;;
--force) force=1 ;;
-*)
printf 'cast: unknown option: %s\n' "$a" >&2
exit 2
;;
*)
if [ -n "$ver" ]; then
printf 'cast: uninstall takes one version, or --all\n' >&2
exit 2
fi
ver="$a"
;;
esac
done
ir="$(install_root)" || die "this cast runs from a working tree ($ROOT), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)"
[ -w "$ir" ] || die "cannot write $ir — uninstall as the user that installed it (or root: sudo cast uninstall)"
# -- one version -----------------------------------------------------------
if [ -n "$ver" ] && [ "$all" -eq 0 ]; then
valid_version "$ver" || die "not a sane version name: '$ver' (a version is a directory name under versions/ — see 'cast versions')"
[ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'cast versions')"
cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
# A broken current makes the CURRENT guard below unfireable (cur empty
# when the link is missing; cur naming a non-directory when it dangles —
# readlink -f resolves a link whose last component does not exist). Heal
# first, then decide; never delete around a broken default.
{ [ -n "$cur" ] && [ -d "$ir/versions/$cur" ]; } \
|| die "current is dangling — 'cast use <version>' to repoint the default first (refusing to remove versions while it is broken)"
[ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'cast use <other>' first, or 'cast uninstall --all' for everything"
uninstall_confirm "remove cast version $ver from $ir"
# rm's exit code is not the verdict — the absence re-check below is (a
# half-removed tree must be reported as INCOMPLETE, not as a crash).
rm -rf "${ir:?}/versions/$ver" || true
if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then
echo "cast: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2
exit 1
fi
log "removed version $ver (the default stays $cur)"
return 0
fi
if [ -n "$ver" ]; then
printf 'cast: a version and --all together is ambiguous\n' >&2
exit 2
fi
# -- everything (bare 'cast uninstall' and '--all' both mean all of it) ----
uninstall_confirm "remove the ENTIRE cast install at $ir (every version)"
# The removal set, gathered BEFORE anything is deleted, so the absence
# assert below re-checks exactly what was promised gone. PATH symlinks are
# removed only when they resolve into (or dangle at) THIS install root.
targets+=("$ir")
while IFS= read -r p; do
[ -L "$p" ] || continue
t="$(readlink -f "$p" 2>/dev/null || true)"
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
case "$t" in "$ir"/*) targets+=("$p") ;; esac
done < <(bin_links)
# De-dup, portably: macOS ships bash 3.2, which has no mapfile.
deduped="$(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++')"
targets=()
while IFS= read -r p; do targets+=("$p"); done <<<"$deduped"
# rm's exit code is not the verdict — the absence assert below is (a
# half-removed tree must be reported as INCOMPLETE by name, not as a crash).
for p in "${targets[@]}"; do rm -rf "$p" || true; done
# END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink.
# A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims
# get verified.
for p in "${targets[@]}"; do
if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi
done
if [ -n "$leftover" ]; then
echo "cast: uninstall INCOMPLETE — still present:$leftover" >&2
echo "cast: remove them by hand, and re-check each path is really gone." >&2
exit 1
fi
echo "cast: uninstalled — removed:"
for p in "${targets[@]}"; do echo "cast: · $p"; done
}
# --- dispatch: layout verbs here, everything else is dist/cli.js -------------
case "${1:-}" in
versions)
shift
cmd_versions "$@"
exit 0
;;
use)
shift
cmd_use "$@"
exit 0
;;
uninstall)
shift
cmd_uninstall "$@"
exit 0
;;
esac
command -v node >/dev/null 2>&1 || {
printf 'cast: node (>=22.12) is required but was not found.\n' >&2
exit 1

View file

@ -3,8 +3,30 @@ set -euo pipefail
# cast installer — intended for: curl -fsSL .../install.sh | bash
#
# Downloads the cast repo tarball, installs the tree under $DEST, builds it,
# and puts a `cast` symlink on PATH via $BINDIR. Re-run any time to upgrade.
# 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/<version>/ one full tree per installed version
# $DEST/current -> versions/<version> the default version
# $BINDIR/cast -> $DEST/current/bin/cast the PATH entry
#
# Versions install side by side: `cast versions` lists them, `cast use <v>`
# 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 <old>` 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=<dir-or-tarball> 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.
@ -22,8 +44,34 @@ 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 -----------------------------------------------------------
command -v curl >/dev/null 2>&1 || die "curl is required but was not found."
# 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."
@ -31,6 +79,13 @@ 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."
@ -38,47 +93,194 @@ if ! command -v age >/dev/null 2>&1; then
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 <root>/current to versions/<v> 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/<its-version> 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
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
# --- 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 "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"
log "extracting archive"
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
# GitHub names the archive's top dir <repo>-<ref> — 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?"
# GitHub archives extract to a single top-level dir like cast-<ref>/
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'cast-*' | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find extracted cast-* directory in archive"
[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $REPO@$REF 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'"
# --- build (deps + tsc), then drop the dev deps -------------------------------
log "building (npm ci && npm run build)"
( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \
|| die "build failed"
( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies"
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
}
# --- atomically replace $DEST --------------------------------------------------
log "installing into $DEST"
rm -rf "$DEST"
mkdir -p "$(dirname "$DEST")"
mv "$EXTRACTED" "$DEST"
# 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"
}
chmod +x "$DEST/bin/cast" "$DEST"/scripts/*.sh
# --- install into $DEST/versions/<version> -----------------------------------
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 '<unknown source>')"
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
# --- put cast on PATH ----------------------------------------------------------
# --- 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/<v> 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 -sf "$DEST/bin/cast" "$BINDIR/cast"
log "linked $BINDIR/cast -> $DEST/bin/cast"
ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast"
log "linked $BINDIR/cast -> $DEST/current/bin/cast"
# --- wire $BINDIR onto PATH, durably -------------------------------------------
# --- 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.
@ -133,4 +335,4 @@ else
log "this shell does not have it yet — open a new one, or: source $PROFILE"
fi
log "done — try: cast --help"
log "done ($SRCDESC, version $new_ver) — try: cast --help"

View file

@ -1,7 +1,8 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { parse as parseYaml } from "yaml";
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
@ -125,6 +126,10 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>]
cast versions # list installed versions
cast use <version> # switch the default version
cast uninstall [<version>|--all] [--force] # remove versions (CAST_YES=1 skips the prompt)
cast --version # version + install root
--state <dir> the state checkout holding environments.yaml, secrets/ and
.coolify.env (default: $CAST_STATE, else the cwd)
@ -1299,12 +1304,30 @@ async function runProject(
return { status: "applied", mutated };
}
// The version lives in package.json — the tree's single source of truth
// (deliberately no separate VERSION file; the ecosystem already has one).
// dist/cli.js sits one level below it in a source checkout and in an
// installed versions/<v> tree alike, so resolving from import.meta.url
// answers for both without caring how this tree got here. The install
// root rides along in the output (the family's shape — rig prints its
// ROOT too) because "which cast is this" and "where does it run from"
// are the same question once versions install side by side.
function formatVersion(): string {
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
const version: unknown = JSON.parse(readFileSync(pkgPath, "utf8")).version;
return `cast ${typeof version === "string" ? version : "unknown"} (${dirname(pkgPath)})`;
}
async function main(): Promise<number> {
const [command, ...rest] = process.argv.slice(2);
if (command === "-h" || command === "--help" || command === "help") {
console.log(USAGE);
return 0;
}
if (command === "-V" || command === "--version") {
console.log(formatVersion());
return 0;
}
if (command === "apply" || command === "diff") {
const { values, positionals } = parseArgs({
args: rest,

358
test/install-sh.test.ts Normal file
View file

@ -0,0 +1,358 @@
import { execFile } from "node:child_process";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
const run = promisify(execFile);
// These tests drive the REAL install.sh — not a reimplementation of its
// logic — via CAST_INSTALL_SOURCE (the offline channel the installer carries
// for exactly this, rig's RIG_INSTALL_SOURCE precedent) and an npm PATH shim
// whose `run build` drops a tiny runnable cli.js. Every assertion reads the
// resulting tree, the symlink chain, or the shim's invocation log. The real
// npm-ci-and-tsc build path runs end to end in CI's install job instead —
// here it would cost minutes per test for no extra layout coverage.
const INSTALL_SH = join(process.cwd(), "install.sh");
const REAL_BIN_CAST = join(process.cwd(), "bin", "cast");
// What `npm run build` produces in a fixture tree: enough of a cli.js that
// the launcher chain — BINDIR/cast -> current -> versions/<v>/bin/cast ->
// node dist/cli.js — can be asserted end to end, --version included (cmd_use
// verifies the flip through it).
const FAKE_CLI = `const path = require("path");
const root = path.resolve(__dirname, "..");
const pkg = require(path.join(root, "package.json"));
const [cmd] = process.argv.slice(2);
if (cmd === "--version" || cmd === "-V") {
console.log("cast " + pkg.version + " (" + root + ")");
process.exit(0);
}
console.log("fake-cast " + pkg.version);
`;
const NPM_SHIM = `#!/usr/bin/env bash
printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG"
case "\${1:-}" in
run) mkdir -p dist && cp "$CAST_TEST_FAKECLI" dist/cli.js ;;
esac
`;
type Sandbox = {
root: string;
dest: string;
bindir: string;
npmLog: string;
env: Record<string, string>;
};
function sandbox(): Sandbox {
const root = mkdtempSync(join(tmpdir(), "cast-install-"));
const stubs = join(root, "stubs");
const home = join(root, "home");
const dest = join(root, "cast-home");
const bindir = join(root, "bin");
mkdirSync(stubs);
mkdirSync(home);
const npmLog = join(root, "npm.log");
const fakeCli = join(root, "fake-cli.js");
writeFileSync(npmLog, "");
writeFileSync(fakeCli, FAKE_CLI);
writeFileSync(join(stubs, "npm"), NPM_SHIM);
chmodSync(join(stubs, "npm"), 0o755);
return {
root,
dest,
bindir,
npmLog,
env: {
PATH: `${stubs}:${process.env.PATH}`,
HOME: home,
SHELL: "/bin/bash",
CAST_HOME: dest,
CAST_BIN: bindir,
CAST_NO_MODIFY_PATH: "1",
CAST_TEST_NPM_LOG: npmLog,
CAST_TEST_FAKECLI: fakeCli,
},
};
}
// A source tree the installer can build: the REPO'S OWN bin/cast (so the
// launcher and its layout verbs are the code under review), a package.json
// carrying the version, and a src/ marker.
function sourceTree(sb: Sandbox, version: string): string {
const src = join(sb.root, `src-${version.replace(/[^A-Za-z0-9.]/g, "_")}`);
mkdirSync(join(src, "bin"), { recursive: true });
mkdirSync(join(src, "src"), { recursive: true });
copyFileSync(REAL_BIN_CAST, join(src, "bin", "cast"));
writeFileSync(
join(src, "package.json"),
`${JSON.stringify({ name: "cast", version })}\n`,
);
writeFileSync(join(src, "src", "cli.ts"), "// fixture\n");
return src;
}
async function install(sb: Sandbox, extraEnv: Record<string, string>) {
return run("bash", [INSTALL_SH], { env: { ...sb.env, ...extraEnv } });
}
function currentTarget(sb: Sandbox): string {
return realpathSync(join(sb.dest, "current"));
}
describe("install.sh — the versioned layout", () => {
it("lands versions/<v>, points current and the PATH link through it, and the chain answers", async () => {
const sb = sandbox();
const src = sourceTree(sb, "0.5.0");
const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src });
expect(stdout).toContain("done (local source");
// The layout: one tree per version, named by package.json's version.
expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true);
expect(
readFileSync(join(sb.dest, "versions/0.5.0/dist/cli.js"), "utf8"),
).toContain("pkg.version");
expect(
readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"),
).toBe(`local:${src}\n`);
// The chain: current -> versions/0.5.0, BINDIR/cast -> current/bin/cast.
expect(currentTarget(sb)).toBe(
realpathSync(join(sb.dest, "versions/0.5.0")),
);
expect(readlinkSync(join(sb.bindir, "cast"))).toBe(
join(sb.dest, "current/bin/cast"),
);
// And it ANSWERS, through the whole chain.
const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], {
env: sb.env,
});
expect(v.trim()).toBe(
`cast 0.5.0 (${realpathSync(join(sb.dest, "versions/0.5.0"))})`,
);
// The build ran: ci, build, prune — in that order.
expect(readFileSync(sb.npmLog, "utf8")).toBe(
"npm ci --silent\nnpm run build --silent\nnpm prune --omit=dev --silent\n",
);
});
it("re-running the same version is a converging no-op — nothing rebuilt, nothing touched", async () => {
const sb = sandbox();
const src = sourceTree(sb, "0.5.0");
await install(sb, { CAST_INSTALL_SOURCE: src });
const npmCallsAfterFirst = readFileSync(sb.npmLog, "utf8");
writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "survives\n");
const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src });
expect(stdout).toContain("cast 0.5.0 is already installed");
expect(stdout).toContain("nothing was built");
// No second build, and the installed tree was not replaced.
expect(readFileSync(sb.npmLog, "utf8")).toBe(npmCallsAfterFirst);
expect(readFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "utf8")).toBe(
"survives\n",
);
});
it("CAST_REINSTALL=1 replaces that version's tree — no partial overlays", async () => {
const sb = sandbox();
const src = sourceTree(sb, "0.5.0");
await install(sb, { CAST_INSTALL_SOURCE: src });
writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "stale\n");
const { stdout } = await install(sb, {
CAST_INSTALL_SOURCE: src,
CAST_REINSTALL: "1",
});
expect(stdout).toContain("replacing the installed 0.5.0 tree");
// A replaced tree, not an overlay: the stale file is gone.
expect(existsSync(join(sb.dest, "versions/0.5.0/SENTINEL"))).toBe(false);
expect(existsSync(join(sb.dest, "versions/0.5.0/dist/cli.js"))).toBe(true);
});
it("a NEW version installs beside the old one and becomes the default", async () => {
const sb = sandbox();
await install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") });
const { stdout } = await install(sb, {
CAST_INSTALL_SOURCE: sourceTree(sb, "0.6.0"),
});
expect(stdout).toContain(
"default version switched: 0.5.0 -> 0.6.0 ('cast use 0.5.0' switches back)",
);
expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true);
expect(currentTarget(sb)).toBe(
realpathSync(join(sb.dest, "versions/0.6.0")),
);
const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], {
env: sb.env,
});
expect(v).toContain("cast 0.6.0");
});
it("re-running an installed NON-default version never moves the default", async () => {
const sb = sandbox();
const old = sourceTree(sb, "0.5.0");
await install(sb, { CAST_INSTALL_SOURCE: old });
await install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.6.0") });
const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: old });
expect(stdout).toContain(
"the default stays 0.6.0 — 'cast use 0.5.0' switches.",
);
expect(currentTarget(sb)).toBe(
realpathSync(join(sb.dest, "versions/0.6.0")),
);
});
it("migrates a pre-versioning flat install in place, preserving the tree", async () => {
const sb = sandbox();
// The OLD layout: the tree sits flat at $DEST, bin/cast directly under it.
mkdirSync(join(sb.dest, "bin"), { recursive: true });
mkdirSync(join(sb.dest, "dist"), { recursive: true });
copyFileSync(REAL_BIN_CAST, join(sb.dest, "bin/cast"));
writeFileSync(
join(sb.dest, "package.json"),
`${JSON.stringify({ name: "cast", version: "0.4.0" })}\n`,
);
writeFileSync(
join(sb.dest, "SENTINEL"),
"the operator's tree, bit for bit\n",
);
const { stdout } = await install(sb, {
CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0"),
});
expect(stdout).toContain("found a pre-versioning flat install at");
expect(stdout).toContain("migrating it into the versioned layout");
// The flat tree moved — preserved, not rebuilt — and the new version
// installed beside it and took the default.
expect(readFileSync(join(sb.dest, "versions/0.4.0/SENTINEL"), "utf8")).toBe(
"the operator's tree, bit for bit\n",
);
expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true);
expect(currentTarget(sb)).toBe(
realpathSync(join(sb.dest, "versions/0.5.0")),
);
});
it("refuses a source whose package.json version is not a sane directory name", async () => {
const sb = sandbox();
const src = sourceTree(sb, "../evil");
await expect(
install(sb, { CAST_INSTALL_SOURCE: src }),
).rejects.toMatchObject({
stderr: expect.stringContaining("not a sane directory name: '../evil'"),
});
expect(existsSync(sb.dest)).toBe(false);
});
it("refuses to migrate a flat install whose version would escape versions/", async () => {
const sb = sandbox();
mkdirSync(join(sb.dest, "bin"), { recursive: true });
copyFileSync(REAL_BIN_CAST, join(sb.dest, "bin/cast"));
writeFileSync(
join(sb.dest, "package.json"),
`${JSON.stringify({ name: "cast", version: "../evil" })}\n`,
);
await expect(
install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") }),
).rejects.toMatchObject({
stderr: expect.stringContaining("not a sane directory name"),
});
// Refused BEFORE anything moved: the flat tree is untouched.
expect(existsSync(join(sb.dest, "bin/cast"))).toBe(true);
});
it("downloads refs/heads/<ref> when no local source is given", async () => {
const sb = sandbox();
// A curl shim standing in for GitHub: serves the fixture tarball and
// logs the URL it was asked for.
sourceTree(sb, "0.5.0");
await run("tar", [
"-C",
sb.root,
"-czf",
join(sb.root, "src.tgz"),
"src-0.5.0",
]);
const stubs = join(sb.root, "stubs");
const curlLog = join(sb.root, "curl.log");
writeFileSync(curlLog, "");
writeFileSync(
join(stubs, "curl"),
`#!/usr/bin/env bash
out=""; url=""
for a in "$@"; do
case "$prev" in -o) out="$a" ;; esac
case "$a" in http*) url="$a" ;; esac
prev="$a"
done
printf '%s\\n' "$url" >> "${curlLog}"
cp "${join(sb.root, "src.tgz")}" "$out"
`,
);
chmodSync(join(stubs, "curl"), 0o755);
const { stdout } = await install(sb, { CAST_REF: "dev-branch" });
expect(readFileSync(curlLog, "utf8").trim()).toBe(
"https://github.com/heavy-duty/cast/archive/refs/heads/dev-branch.tar.gz",
);
expect(stdout).toContain("installing cast (heavy-duty/cast@dev-branch)");
expect(
readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"),
).toBe("heavy-duty/cast@dev-branch\n");
});
});
describe("the shared gates cannot drift", () => {
// install.sh and bin/cast each carry valid_version and pkg_version — the
// same trust boundary enforced in two places. A byte-identical diff is the
// rig-precedent guard that an edit to one cannot quietly miss the other.
function extractFunction(file: string, name: string): string {
const text = readFileSync(file, "utf8");
const start = text.indexOf(`${name}() {`);
expect(start, `${name}() not found in ${file}`).toBeGreaterThan(-1);
const end = text.indexOf("\n}", start);
return text.slice(start, end + 2);
}
for (const fn of ["valid_version", "pkg_version", "flip_current"]) {
it(`${fn}() is byte-identical between install.sh and bin/cast`, () => {
expect(extractFunction(INSTALL_SH, fn)).toBe(
extractFunction(REAL_BIN_CAST, fn),
);
});
}
});
describe("portability — cast runs on the operator's own machine, macOS included", () => {
// box#79/rig#36 could assume GNU userland and bash 4+ because boxes run
// Linux; cast cannot. These greps pin the two spellings that bit (or
// nearly bit) for real: `mv -T` (GNU-only — BSD/macOS mv has no -T, the
// flip now rides rename(2) via node) and `mapfile` (bash 4 — macOS ships
// bash 3.2). A reintroduction fails HERE, not on the first operator Mac.
for (const file of [INSTALL_SH, REAL_BIN_CAST]) {
const name = file.split("/").pop();
it(`${name} carries no GNU mv -T and no bash-4 mapfile`, () => {
const text = readFileSync(file, "utf8");
// "mv -T" as an invocation — the comments explaining WHY it is absent
// spell it "mv says … with -T", which this must not match.
expect(text).not.toMatch(/\bmv\s+-[A-Za-z]*T/);
expect(text).not.toMatch(/^\s*mapfile\b/m);
});
}
});

229
test/layout-cli.test.ts Normal file
View file

@ -0,0 +1,229 @@
import { execFile, spawn } from "node:child_process";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
const run = promisify(execFile);
// The layout verbs — cast versions / use / uninstall — exercised on REAL
// installed sandboxes: two versions land via the real install.sh (npm
// shimmed, as in install-sh.test.ts), then every verb runs through the PATH
// chain the way an operator's would. Assertions read symlinks and trees,
// plus each refusal's exit code and message.
const INSTALL_SH = join(process.cwd(), "install.sh");
const REAL_BIN_CAST = join(process.cwd(), "bin", "cast");
const FAKE_CLI = `const path = require("path");
const root = path.resolve(__dirname, "..");
const pkg = require(path.join(root, "package.json"));
const [cmd] = process.argv.slice(2);
if (cmd === "--version" || cmd === "-V") {
console.log("cast " + pkg.version + " (" + root + ")");
process.exit(0);
}
console.log("fake-cast " + pkg.version);
`;
const NPM_SHIM = `#!/usr/bin/env bash
case "\${1:-}" in
run) mkdir -p dist && cp "$CAST_TEST_FAKECLI" dist/cli.js ;;
esac
`;
type Sandbox = {
root: string;
dest: string;
bindir: string;
env: Record<string, string>;
};
async function installedSandbox(versions: string[]): Promise<Sandbox> {
const root = mkdtempSync(join(tmpdir(), "cast-layout-"));
const stubs = join(root, "stubs");
const home = join(root, "home");
const dest = join(root, "cast-home");
const bindir = join(root, "bin");
mkdirSync(stubs);
mkdirSync(home);
const fakeCli = join(root, "fake-cli.js");
writeFileSync(fakeCli, FAKE_CLI);
writeFileSync(join(stubs, "npm"), NPM_SHIM);
chmodSync(join(stubs, "npm"), 0o755);
const env = {
PATH: `${stubs}:${process.env.PATH}`,
HOME: home,
SHELL: "/bin/bash",
CAST_HOME: dest,
CAST_BIN: bindir,
CAST_NO_MODIFY_PATH: "1",
CAST_TEST_FAKECLI: fakeCli,
};
for (const version of versions) {
const src = join(root, `src-${version}`);
mkdirSync(join(src, "bin"), { recursive: true });
copyFileSync(REAL_BIN_CAST, join(src, "bin", "cast"));
writeFileSync(
join(src, "package.json"),
`${JSON.stringify({ name: "cast", version })}\n`,
);
await run("bash", [INSTALL_SH], {
env: { ...env, CAST_INSTALL_SOURCE: src },
});
}
return { root, dest, bindir, env };
}
// Through the chain, like an operator: $BINDIR/cast -> current -> version.
async function cast(
sb: Sandbox,
args: string[],
extraEnv: Record<string, string> = {},
) {
return run(join(sb.bindir, "cast"), args, {
env: { ...sb.env, ...extraEnv },
});
}
function currentVersion(sb: Sandbox): string {
return realpathSync(join(sb.dest, "current")).split("/").pop() ?? "";
}
describe("cast versions", () => {
it("lists installed versions, marking (current) and (running)", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
const { stdout } = await cast(sb, ["versions"]);
// 0.6.0 installed last, so it is the default — and, invoked through the
// chain, also the tree answering this very command.
expect(stdout).toContain(`VERSIONS (${sb.dest}`);
expect(stdout).toMatch(/^ {2}0\.5\.0$/m);
expect(stdout).toMatch(/^ {2}0\.6\.0 \(current\) \(running\)$/m);
expect(stdout).toContain("switch the default: cast use <version>");
});
it("refuses to run from a working tree — this repo checkout is not an install", async () => {
await expect(run(REAL_BIN_CAST, ["versions"])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("not a versioned install"),
});
});
});
describe("cast use", () => {
it("switches the default atomically and asserts the chain answers the new version", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
expect(currentVersion(sb)).toBe("0.6.0");
const { stdout } = await cast(sb, ["use", "0.5.0"]);
expect(stdout).toContain("switched to 0.5.0 (current -> versions/0.5.0)");
expect(currentVersion(sb)).toBe("0.5.0");
const { stdout: v } = await cast(sb, ["--version"]);
expect(v).toContain("cast 0.5.0");
});
it("refuses a version that is not installed, an insane name, and a missing argument", async () => {
const sb = await installedSandbox(["0.5.0"]);
await expect(cast(sb, ["use", "9.9.9"])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("no such version: 9.9.9"),
});
// The gate fires BEFORE any path is built from the name.
await expect(cast(sb, ["use", "../evil"])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("not a sane version name: '../evil'"),
});
await expect(cast(sb, ["use"])).rejects.toMatchObject({
code: 2,
stderr: expect.stringContaining("use needs a version"),
});
});
});
describe("cast uninstall", () => {
it("removes one non-current version and proves the absence", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
const { stdout } = await cast(sb, ["uninstall", "0.5.0"], {
CAST_YES: "1",
});
expect(stdout).toContain("removed version 0.5.0 (the default stays 0.6.0)");
expect(existsSync(join(sb.dest, "versions/0.5.0"))).toBe(false);
expect(currentVersion(sb)).toBe("0.6.0");
});
it("refuses to remove the CURRENT version", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
await expect(
cast(sb, ["uninstall", "0.6.0"], { CAST_YES: "1" }),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("0.6.0 is the CURRENT version"),
});
expect(existsSync(join(sb.dest, "versions/0.6.0"))).toBe(true);
});
it("refuses without consent when there is no terminal to confirm on", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
// No CAST_YES, no --force, stdin is a pipe — the consent contract says
// refuse rather than assume.
const result = await new Promise<{ code: number; stderr: string }>(
(resolve) => {
const child = spawn(join(sb.bindir, "cast"), ["uninstall", "0.5.0"], {
env: sb.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (d) => {
stderr += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, stderr }));
},
);
expect(result.code).toBe(2);
expect(result.stderr).toContain("refusing to remove cast version 0.5.0");
expect(existsSync(join(sb.dest, "versions/0.5.0"))).toBe(true);
});
it("--all removes every version, current, and the PATH symlink — then re-checks", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
const { stdout } = await cast(sb, ["uninstall", "--all"], {
CAST_YES: "1",
});
expect(stdout).toContain("uninstalled — removed:");
expect(existsSync(sb.dest)).toBe(false);
// The PATH symlink resolved into this install root, so it went too —
// as a link, not just as a resolvable file.
expect(existsSync(join(sb.bindir, "cast"))).toBe(false);
const gone = await run("bash", [
"-c",
`[ ! -L '${join(sb.bindir, "cast")}' ] && echo really-gone`,
]);
expect(gone.stdout.trim()).toBe("really-gone");
});
it("a version plus --all is ambiguous, and unknown options are refused", async () => {
const sb = await installedSandbox(["0.5.0"]);
await expect(
cast(sb, ["uninstall", "0.5.0", "--all"], { CAST_YES: "1" }),
).rejects.toMatchObject({
code: 2,
stderr: expect.stringContaining("ambiguous"),
});
await expect(
cast(sb, ["uninstall", "--purge"], { CAST_YES: "1" }),
).rejects.toMatchObject({
code: 2,
stderr: expect.stringContaining("unknown option: --purge"),
});
});
});

25
test/version-cli.test.ts Normal file
View file

@ -0,0 +1,25 @@
import { execFile } from "node:child_process";
import { readFileSync } from "node:fs";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
const run = promisify(execFile);
describe("cast --version", () => {
// The real built CLI, not a fixture: what an operator's `cast --version`
// answers. The version comes from package.json — the single source of
// truth (deliberately no separate VERSION file) — and the install root
// rides along, rig-style, because "which cast" and "where from" are the
// same question once versions install side by side.
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as {
version: string;
};
for (const flag of ["--version", "-V"]) {
it(`${flag} prints the package.json version and the install root`, async () => {
const { stdout, stderr } = await run("node", ["dist/cli.js", flag]);
expect(stdout.trim()).toBe(`cast ${pkg.version} (${process.cwd()})`);
expect(stderr).toBe("");
});
}
});