Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
326 lines
14 KiB
Bash
326 lines
14 KiB
Bash
#!/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/<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.
|
|
|
|
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))."
|
|
|
|
# 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 $DEST/current to versions/<v> atomically: build the new link beside it,
|
|
# rename over. Plain ln -sfn is unlink+create — a window where current names
|
|
# nothing and a concurrent 'cast' invocation dies mid-chain. bin/cast's
|
|
# cmd_use flips with the same pattern.
|
|
flip_current() {
|
|
ln -sfn "versions/$1" "$DEST/current.new.$$"
|
|
mv -Tf "$DEST/current.new.$$" "$DEST/current"
|
|
}
|
|
|
|
# --- 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 "$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 <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?"
|
|
|
|
# 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/<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
|
|
|
|
# --- 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 "$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 "$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"
|