refactor: rescope to versioned installations — the release flow moves out

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>
This commit is contained in:
dan-claude-bot 2026-07-18 21:17:59 +00:00
parent 0a03fc592b
commit 5cd5968cf2
13 changed files with 1020 additions and 610 deletions

View file

@ -24,3 +24,37 @@ jobs:
run: bash -n install.sh bin/cast scripts/*.sh run: bash -n install.sh bin/cast scripts/*.sh
- name: labels state-machine tests - name: labels state-machine tests
run: bash test/labels-reconcile.sh 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

@ -1,74 +0,0 @@
name: release
# A release is a PR, then a tag (cast#96, the flow shared with box#83):
# the `release: X.Y.Z` PR bumps package.json and stamps the CHANGELOG's
# Unreleased section; merging and pushing the bare `X.Y.Z` tag lands here.
# This workflow is where cast differs from its siblings: box/rig are pure
# bash, so the source tarball IS the package — cast compiles, so the build
# happens ONCE, here, and the release carries a prebuilt `cast-X.Y.Z.tgz`
# the installer can drop in without npm ci or tsc on the operator's machine.
on:
push:
# Bare X.Y.Z tags (the family scheme — box's 0.6.0 set the precedent,
# no `v` prefix). The glob is loose; the assert step below is the gate.
tags: ["[0-9]*.*.*"]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: assert tag == package.json version
# Fail loudly, create nothing: a tag that contradicts package.json
# would mint a release whose `cast --version` disagrees with its
# own name. The mismatch is a ritual error — retag, don't patch.
run: |
version="$(node -p 'require("./package.json").version')"
if [ "$GITHUB_REF_NAME" != "$version" ]; then
echo "tag '$GITHUB_REF_NAME' != package.json version '$version' — refusing to release" >&2
exit 1
fi
- name: extract the release notes from CHANGELOG.md
# The release body is the curated section we wrote, never the
# auto-generated PR list. Missing/empty section fails the release —
# before the tag has minted anything.
run: bash scripts/changelog-section.sh "$GITHUB_REF_NAME" CHANGELOG.md > /tmp/release-notes.md
- name: build the package, once
# Just the build — check and tests already gated the merge commit
# this tag points at (ci.yml, with its age dependency); the release
# job's whole job is packaging that green tree.
run: |
npm ci
npm run build
- name: assemble cast-${{ github.ref_name }}.tgz
# The runnable tree and nothing else: bin/, dist/, production
# node_modules/, package.json. Top-level dir named like a GitHub
# archive's, so the installer handles both shapes identically.
run: |
npm prune --omit=dev
stage="$(mktemp -d)/cast-$GITHUB_REF_NAME"
mkdir -p "$stage"
cp -R bin dist node_modules package.json "$stage/"
tar -C "$(dirname "$stage")" -czf "cast-$GITHUB_REF_NAME.tgz" "cast-$GITHUB_REF_NAME"
tar -tzf "cast-$GITHUB_REF_NAME.tgz" | head -5
- name: create the GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" "cast-$GITHUB_REF_NAME.tgz" \
--verify-tag \
--title "cast $GITHUB_REF_NAME" \
--notes-file /tmp/release-notes.md

View file

@ -1,25 +0,0 @@
# Changelog
History before 0.1.0 lives in git. Feature PRs land their entry in
`## Unreleased` as part of the PR; a release PR stamps that section with
the version and date (see cast#96 — the release flow shared with
heavy-duty/box#83).
## Unreleased
### Added
- **Versioned installs: tagged releases with a prebuilt dist asset** (#96) —
cast now has a release surface. `cast --version` prints the version from
`package.json` (the single source of truth — no separate `VERSION` file)
plus the install root. On a bare `X.Y.Z` tag push, `release.yml` asserts
the tag matches `package.json`, builds once in CI (`npm ci`, `npm run
build`, `npm prune --omit=dev`), tars the runnable tree into
`cast-X.Y.Z.tgz`, and creates the GitHub release with that version's
changelog section as the body and the tarball attached. The installer now
defaults to the **latest release asset** — resolved via the
`releases/latest` redirect, no API, no token — so a default install
compiles nothing on the operator's machine and answers "what cast is
this?" with a version. `CAST_REF=X.Y.Z` pins (uses that tag's asset when
it exists), `CAST_REF=main` stays the dev channel: build-from-source,
exactly the old path.

View file

@ -38,21 +38,6 @@ labels tell you where everything is without opening anything.
agreement is the author's judgment, so the author makes the request. agreement is the author's judgment, so the author makes the request.
7. **Checks must be green**: `npm run check`, `npm run build`, and 7. **Checks must be green**: `npm run check`, `npm run build`, and
`npm test` locally mirror what CI runs. `npm test` locally mirror what CI runs.
8. **Feature PRs carry their changelog entry.** Add what changed to
`CHANGELOG.md`'s `## Unreleased` section as part of the PR — release
notes are written when the change lands, not reconstructed at release
time.
## Releasing
A release is a PR, then a tag (cast#96; the flow box#83 anchors for the
family). A `release: X.Y.Z` PR bumps `package.json` and stamps the
`## Unreleased` section with the version and date. Merge it, tag the merge
commit bare `X.Y.Z` (no `v` prefix), push the tag —
[release.yml](.github/workflows/release.yml) asserts the tag matches
`package.json`, builds `cast-X.Y.Z.tgz` once in CI, and creates the GitHub
release with that section as the body and the tarball attached. The
installer's default channel serves that asset.
## Labels — who sets what ## Labels — who sets what

View file

@ -21,16 +21,22 @@ 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: 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. it is an API client, and a server should never install it.
By default that installs the **latest release**: a prebuilt `cast-X.Y.Z.tgz` Installs are **versioned**, the same layout box and rig use: each install
asset built once in CI — nothing compiles on your machine, and lands at `~/.local/share/cast/versions/<version>` (the version is the tree's
`cast --version` names exactly what you got. `CAST_REF` selects the other `package.json` version), a `current` symlink names the default, and the
channels: `cast` on your PATH points through it. Versions install side by side:
| | channel | what happens | ```sh
|---|---|---| cast versions # list what is installed, marking (current) and (running)
| unset | latest release | the newest tag's prebuilt asset | cast use <version> # switch the default — atomic, then asserted
| `CAST_REF=0.1.0` | pinned | that release's prebuilt asset | cast uninstall [<version>|--all] # remove one non-current version, or everything
| `CAST_REF=main` | dev | that ref's source tarball, built here (`npm ci` + `tsc` — needs `npm`) | ```
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) 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 and, if that directory is not already on your `PATH`, appends it to your shell

241
bin/cast
View file

@ -1,12 +1,247 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Thin launcher — the CLI itself is dist/cli.js (built from src/ by tsc). # Launcher — the CLI itself is dist/cli.js (built from src/ by tsc), plus
# Kept as a shim so `cast` lands on PATH the same way `rig` does, without # the VERSIONED-INSTALL verbs (versions / use / uninstall), which live here
# requiring a global npm install. # 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)" 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
}
# 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')"
# An atomic flip, not unlink+create: ln -sfn leaves a window where current
# is missing; a rename over it does not.
ln -sfn "versions/$v" "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current"
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=""
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)
mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++')
# 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 || { command -v node >/dev/null 2>&1 || {
printf 'cast: node (>=22.12) is required but was not found.\n' >&2 printf 'cast: node (>=22.12) is required but was not found.\n' >&2
exit 1 exit 1

View file

@ -3,23 +3,36 @@ set -euo pipefail
# cast installer — intended for: curl -fsSL .../install.sh | bash # cast installer — intended for: curl -fsSL .../install.sh | bash
# #
# Three channels from one script (cast#96, the flow shared with box#83): # 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):
# #
# CAST_REF unset → the latest GitHub release's prebuilt asset # $DEST/versions/<version>/ one full tree per installed version
# (cast-X.Y.Z.tgz). No npm ci, no tsc, no # $DEST/current -> versions/<version> the default version
# devDependencies on this machine — the build # $BINDIR/cast -> $DEST/current/bin/cast the PATH entry
# happened once, in CI, on the tag.
# CAST_REF=X.Y.Z → that release's asset, when one exists — a pin.
# CAST_REF=<branch> → build from source: the repo tarball for that
# ref (tags tried before branches), npm ci + tsc
# here. CAST_REF=main is the dev channel.
# #
# Re-run any time to upgrade. Unlike rig (pure bash, runs on bare boxes), # Versions install side by side: `cast versions` lists them, `cast use <v>`
# cast runs on YOUR machine and needs node — it is an API client, never # switches the default, `cast uninstall` removes them. Re-running with an
# something a server installs. # 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}" REPO="${CAST_REPO:-heavy-duty/cast}"
REF="${CAST_REF:-}" REF="${CAST_REF:-main}"
DEST="${CAST_HOME:-$HOME/.local/share/cast}" DEST="${CAST_HOME:-$HOME/.local/share/cast}"
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
BINDIR="${CAST_BIN:-/usr/local/bin}" BINDIR="${CAST_BIN:-/usr/local/bin}"
@ -31,10 +44,37 @@ log() { printf 'cast-install: %s\n' "$*"; }
warn() { printf 'cast-install: WARNING: %s\n' "$*" >&2; } warn() { printf 'cast-install: WARNING: %s\n' "$*" >&2; }
die() { printf 'cast-install: ERROR: %s\n' "$*" >&2; exit 1; } 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 ----------------------------------------------------------- # --- 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 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 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="$(node -p 'process.versions.node.split(".")[0]')"
[ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))." [ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))."
@ -46,114 +86,189 @@ if ! command -v age >/dev/null 2>&1; then
warn " Fedora: sudo dnf install age | macOS: brew install age" warn " Fedora: sudo dnf install age | macOS: brew install age"
fi 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 ---------------------------------------------------------- # --- temp workspace ----------------------------------------------------------
TMPDIR="$(mktemp -d)" TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; } cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT trap cleanup EXIT
# Resolve the latest release tag by following the releases/latest redirect
# and reading where it landed — no API, no token, no rate-limit pain
# (box#83's trick). GitHub answers .../releases/tag/<TAG>; anything else
# (a repo with no releases redirects nowhere useful) is a loud failure.
resolve_latest_tag() {
local landed
landed="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest")" || return 1
case "$landed" in
*/releases/tag/*) printf '%s\n' "${landed##*/releases/tag/}" ;;
*) return 1 ;;
esac
}
# fetch_ok <url> <outfile> — download, true/false. -f keeps a 404 an
# error instead of saving GitHub's error page as a tarball.
fetch_ok() {
curl -fsSL "$1" -o "$2" 2>/dev/null
}
# --- acquire the tree -------------------------------------------------------- # --- acquire the tree --------------------------------------------------------
# PREBUILT=1 means the tarball is a CI-built runnable tree (bin/, dist/, if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then
# production node_modules/, package.json) — nothing to compile here. SRC="$CAST_INSTALL_SOURCE"
PREBUILT=0 INSTALLED_FROM="local:$SRC"
SRCDESC="" if [ -d "$SRC" ]; then
log "copying local tree $SRC"
if [ -z "$REF" ]; then mkdir -p "$TMPDIR/tree"
TAG="$(resolve_latest_tag)" \ # tar, not cp -a: --exclude=.git so a working checkout never carries its
|| die "could not resolve the latest release of $REPO — no releases yet, or no network. CAST_REF=main installs from source." # VCS state into the install tree, --exclude=./node_modules (top level
URL="https://github.com/$REPO/releases/download/$TAG/cast-$TAG.tgz" # only — nested ones are npm's own business) because npm ci below builds
log "installing cast $TAG (latest release of $REPO)" # dependencies fresh from the lockfile anyway.
log "downloading $URL" tar -C "$SRC" --exclude=.git --exclude=./node_modules -cf - . | tar -xf - -C "$TMPDIR/tree"
fetch_ok "$URL" "$TMPDIR/cast.tar.gz" \ EXTRACTED="$TMPDIR/tree"
|| die "failed to download the $TAG release asset: $URL" elif [ -f "$SRC" ]; then
PREBUILT=1 log "extracting local tarball $SRC"
SRCDESC="$REPO@$TAG (release asset)" tar -xzf "$SRC" -C "$TMPDIR" || die "failed to extract $SRC"
else EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
# A pinned tag that has a release asset gets the asset — same bits as the
# default channel, just older. Everything else (a branch, a tag from
# before releases carried assets) falls back to build-from-source.
ASSET_URL="https://github.com/$REPO/releases/download/$REF/cast-$REF.tgz"
if fetch_ok "$ASSET_URL" "$TMPDIR/cast.tar.gz"; then
log "installing cast $REF (pinned release asset)"
PREBUILT=1
SRCDESC="$REPO@$REF (release asset)"
else else
log "no release asset for '$REF' — building from source" die "CAST_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC"
for kind in tags heads; do
URL="https://github.com/$REPO/archive/refs/$kind/$REF.tar.gz"
if fetch_ok "$URL" "$TMPDIR/cast.tar.gz"; then
SRCDESC="$REPO@$REF (source, refs/$kind)"
break
fi
SRCDESC=""
done
[ -n "$SRCDESC" ] || die "no tag or branch named '$REF' in $REPO (tried the release asset, refs/tags and refs/heads)"
log "downloaded from refs — $SRCDESC"
fi fi
fi
log "extracting archive"
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
# Both shapes carry exactly ONE top-level directory (GitHub names its
# archives <repo>-<ref>; release.yml stages cast-<version>). Deriving that
# name is guesswork — it broke for real at box's repo rename — so take the
# single directory, whatever it is called, and judge the tree by its content.
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find the cast tree in the archive"
[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $SRCDESC correct?"
# --- build (source channel only) ---------------------------------------------
if [ "$PREBUILT" -eq 1 ]; then
# Verify the shape before touching $DEST: a prebuilt tree that cannot run
# is better refused here than discovered at `cast apply` time.
[ -f "$EXTRACTED/dist/cli.js" ] && [ -d "$EXTRACTED/node_modules" ] \
|| die "the release asset is not a runnable tree (missing dist/ or node_modules/) — broken release? CAST_REF=main installs from source"
else else
command -v npm >/dev/null 2>&1 || die "npm is required to build from source (CAST_REF=$REF) but was not found." 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)" log "building (npm ci && npm run build)"
( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \ ( cd "$1" && npm ci --silent && npm run build --silent ) \
|| die "build failed" || die "build failed"
( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies" ( 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 fi
# --- atomically replace $DEST -------------------------------------------------- # --- which version is the default? -------------------------------------------
log "installing into $DEST" # 'current' is the tracked default; flipping it is the ONLY step that changes
rm -rf "$DEST" # what an operator's `cast` runs. A fresh host (or a dangling current) is
mkdir -p "$(dirname "$DEST")" # claimed outright; an upgrade flips, because a re-run that silently left you
mv "$EXTRACTED" "$DEST" # 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
chmod +x "$DEST/bin/cast" # claims.
# Source installs carry scripts/; the release asset deliberately does not. cur="$(readlink -f "$DEST/current" 2>/dev/null || true)"
if [ -d "$DEST/scripts" ]; then want="$(readlink -f "$VDIR")"
find "$DEST/scripts" -name '*.sh' -exec chmod +x {} + 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 fi
# --- put cast on PATH ---------------------------------------------------------- # --- 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" mkdir -p "$BINDIR"
ln -sf "$DEST/bin/cast" "$BINDIR/cast" ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast"
log "linked $BINDIR/cast -> $DEST/bin/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 # `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, # 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. # once, marked. Opt out with CAST_NO_MODIFY_PATH=1 and wire it yourself.
@ -208,4 +323,4 @@ else
log "this shell does not have it yet — open a new one, or: source $PROFILE" log "this shell does not have it yet — open a new one, or: source $PROFILE"
fi fi
log "done ($SRCDESC) — try: cast --help" log "done ($SRCDESC, version $new_ver) — try: cast --help"

View file

@ -1,43 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# changelog-section.sh <version> [changelog-file]
#
# Print the body of CHANGELOG.md's `## <version>` section — everything
# between that heading and the next `## ` heading (or EOF), with the
# leading/trailing blank lines trimmed. release.yml uses this as the
# GitHub release body, so the notes are the curated prose we actually
# wrote, never an auto-generated PR list (cast#96 / box#83).
#
# Fails loudly when the section is missing or empty: a release with no
# written history is a release that skipped the changelog discipline,
# and the tag push is exactly the moment to catch that — before a
# release object exists.
version="${1:-}"
file="${2:-CHANGELOG.md}"
[ -n "$version" ] || { echo "usage: changelog-section.sh <version> [changelog-file]" >&2; exit 2; }
[ -f "$file" ] || { echo "changelog-section: no such file: $file" >&2; exit 1; }
# The heading is `## <version>` optionally followed by more (a date stamp:
# `## 0.1.0 — 2026-07-18`). Match on the version as the second word so the
# stamp's format never becomes load-bearing here.
section="$(awk -v ver="$version" '
/^## / { if (found) exit; if ($2 == ver) { found = 1; next } }
found { print }
END { exit found ? 0 : 3 }
' "$file")" || {
echo "changelog-section: no \"## $version\" section in $file" >&2
exit 1
}
# Trim leading blank lines; command substitution already ate the trailing ones.
section="$(printf '%s\n' "$section" | sed '/./,$!d')"
[ -n "$section" ] || {
echo "changelog-section: the \"## $version\" section in $file is empty" >&2
exit 1
}
printf '%s\n' "$section"

View file

@ -126,6 +126,9 @@ 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 server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>] cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>] 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 cast --version # version + install root
--state <dir> the state checkout holding environments.yaml, secrets/ and --state <dir> the state checkout holding environments.yaml, secrets/ and
@ -1302,13 +1305,13 @@ async function runProject(
} }
// The version lives in package.json — the tree's single source of truth // The version lives in package.json — the tree's single source of truth
// (cast#96, deliberately no separate VERSION file). dist/cli.js sits one // (deliberately no separate VERSION file; the ecosystem already has one).
// level below it in a source checkout and in an installed release asset // dist/cli.js sits one level below it in a source checkout and in an
// alike, so resolving from import.meta.url answers for both without // installed versions/<v> tree alike, so resolving from import.meta.url
// caring how this tree got here. The install root rides along in the // answers for both without caring how this tree got here. The install
// output (the family's shape — rig prints its ROOT too) because "which // root rides along in the output (the family's shape — rig prints its
// cast is this" and "where does it run from" are the same question when // ROOT too) because "which cast is this" and "where does it run from"
// several trees exist on one machine. // are the same question once versions install side by side.
function formatVersion(): string { function formatVersion(): string {
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url)); const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
const version: unknown = JSON.parse(readFileSync(pkgPath, "utf8")).version; const version: unknown = JSON.parse(readFileSync(pkgPath, "utf8")).version;

View file

@ -1,11 +1,13 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { import {
chmodSync, chmodSync,
copyFileSync,
existsSync, existsSync,
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
readFileSync, readFileSync,
readlinkSync, readlinkSync,
realpathSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@ -15,82 +17,48 @@ import { describe, expect, it } from "vitest";
const run = promisify(execFile); const run = promisify(execFile);
// These tests run the REAL install.sh — not a reimplementation of its // These tests drive the REAL install.sh — not a reimplementation of its
// logic — with curl and npm replaced by PATH shims, so every channel // logic — via CAST_INSTALL_SOURCE (the offline channel the installer carries
// (latest asset, pinned asset, build-from-source) is exercised offline. // for exactly this, rig's RIG_INSTALL_SOURCE precedent) and an npm PATH shim
// The shims record what was requested; the assertions read the wire log // whose `run build` drops a tiny runnable cli.js. Every assertion reads the
// and the resulting tree, the same way rig's cli.sh proves its installer. // 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 INSTALL_SH = join(process.cwd(), "install.sh");
const REAL_BIN_CAST = join(process.cwd(), "bin", "cast");
// curl shim: answers from CAST_TEST_* env vars, appends every URL to // What `npm run build` produces in a fixture tree: enough of a cli.js that
// CAST_TEST_CURL_LOG. Exit 22 is curl's own "-f saw an HTTP error". // the launcher chain — BINDIR/cast -> current -> versions/<v>/bin/cast ->
const CURL_SHIM = `#!/usr/bin/env bash // node dist/cli.js — can be asserted end to end, --version included (cmd_use
set -euo pipefail // verifies the flip through it).
out=""; url="" const FAKE_CLI = `const path = require("path");
args=("$@") const root = path.resolve(__dirname, "..");
i=0 const pkg = require(path.join(root, "package.json"));
while [ $i -lt \${#args[@]} ]; do const [cmd] = process.argv.slice(2);
a="\${args[$i]}" if (cmd === "--version" || cmd === "-V") {
case "$a" in console.log("cast " + pkg.version + " (" + root + ")");
-o) i=$((i+1)); out="\${args[$i]}" ;; process.exit(0);
-w) i=$((i+1)) ;; }
http*) url="$a" ;; console.log("fake-cast " + pkg.version);
esac
i=$((i+1))
done
printf '%s\\n' "$url" >> "$CAST_TEST_CURL_LOG"
case "$url" in
*/releases/latest)
[ -n "\${CAST_TEST_LATEST:-}" ] || exit 22
printf '%s' "$CAST_TEST_LATEST"
;;
*/releases/download/*)
if [ -n "\${CAST_TEST_ASSET_FILE:-}" ] && [ "$url" = "\${CAST_TEST_ASSET_URL:-}" ]; then
cp "$CAST_TEST_ASSET_FILE" "$out"
else
exit 22
fi
;;
*/archive/refs/tags/*)
if [ -n "\${CAST_TEST_TAGS_TARBALL:-}" ]; then cp "$CAST_TEST_TAGS_TARBALL" "$out"; else exit 22; fi
;;
*/archive/refs/heads/*)
if [ -n "\${CAST_TEST_HEADS_TARBALL:-}" ]; then cp "$CAST_TEST_HEADS_TARBALL" "$out"; else exit 22; fi
;;
*) exit 22 ;;
esac
`; `;
// npm shim: logs every invocation; 'run build' produces dist/cli.js so a
// source install ends up runnable. The prebuilt channels get a POISONED
// npm instead — if the installer touches npm at all on an asset install,
// the install fails and so does the test.
const NPM_SHIM = `#!/usr/bin/env bash const NPM_SHIM = `#!/usr/bin/env bash
printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG" printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG"
case "\${1:-}" in case "\${1:-}" in
ci) exit 0 ;; run) mkdir -p dist && cp "$CAST_TEST_FAKECLI" dist/cli.js ;;
run) mkdir -p dist && printf '// built by npm shim\\n' > dist/cli.js ;;
prune) exit 0 ;;
esac esac
`; `;
const POISONED_NPM = `#!/usr/bin/env bash
printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG"
exit 97
`;
type Sandbox = { type Sandbox = {
root: string; root: string;
stubs: string;
dest: string; dest: string;
bindir: string; bindir: string;
curlLog: string;
npmLog: string; npmLog: string;
env: Record<string, string>; env: Record<string, string>;
}; };
function sandbox(opts: { poisonNpm: boolean }): Sandbox { function sandbox(): Sandbox {
const root = mkdtempSync(join(tmpdir(), "cast-install-")); const root = mkdtempSync(join(tmpdir(), "cast-install-"));
const stubs = join(root, "stubs"); const stubs = join(root, "stubs");
const home = join(root, "home"); const home = join(root, "home");
@ -98,20 +66,16 @@ function sandbox(opts: { poisonNpm: boolean }): Sandbox {
const bindir = join(root, "bin"); const bindir = join(root, "bin");
mkdirSync(stubs); mkdirSync(stubs);
mkdirSync(home); mkdirSync(home);
const curlLog = join(root, "curl.log");
const npmLog = join(root, "npm.log"); const npmLog = join(root, "npm.log");
writeFileSync(curlLog, ""); const fakeCli = join(root, "fake-cli.js");
writeFileSync(npmLog, ""); writeFileSync(npmLog, "");
writeFileSync(join(stubs, "curl"), CURL_SHIM); writeFileSync(fakeCli, FAKE_CLI);
writeFileSync(join(stubs, "npm"), opts.poisonNpm ? POISONED_NPM : NPM_SHIM); writeFileSync(join(stubs, "npm"), NPM_SHIM);
chmodSync(join(stubs, "curl"), 0o755);
chmodSync(join(stubs, "npm"), 0o755); chmodSync(join(stubs, "npm"), 0o755);
return { return {
root, root,
stubs,
dest, dest,
bindir, bindir,
curlLog,
npmLog, npmLog,
env: { env: {
PATH: `${stubs}:${process.env.PATH}`, PATH: `${stubs}:${process.env.PATH}`,
@ -120,180 +84,257 @@ function sandbox(opts: { poisonNpm: boolean }): Sandbox {
CAST_HOME: dest, CAST_HOME: dest,
CAST_BIN: bindir, CAST_BIN: bindir,
CAST_NO_MODIFY_PATH: "1", CAST_NO_MODIFY_PATH: "1",
CAST_TEST_CURL_LOG: curlLog,
CAST_TEST_NPM_LOG: npmLog, CAST_TEST_NPM_LOG: npmLog,
CAST_TEST_FAKECLI: fakeCli,
}, },
}; };
} }
// Build a .tgz fixture with a single top-level dir, like both real shapes. // A source tree the installer can build: the REPO'S OWN bin/cast (so the
async function makeTarball( // launcher and its layout verbs are the code under review), a package.json
root: string, // carrying the version, and a src/ marker.
topdir: string, function sourceTree(sb: Sandbox, version: string): string {
files: Record<string, string>, const src = join(sb.root, `src-${version.replace(/[^A-Za-z0-9.]/g, "_")}`);
): Promise<string> { mkdirSync(join(src, "bin"), { recursive: true });
const stage = join(root, "fixtures", topdir); mkdirSync(join(src, "src"), { recursive: true });
for (const [rel, content] of Object.entries(files)) { copyFileSync(REAL_BIN_CAST, join(src, "bin", "cast"));
const abs = join(stage, rel); writeFileSync(
mkdirSync(join(abs, ".."), { recursive: true }); join(src, "package.json"),
writeFileSync(abs, content); `${JSON.stringify({ name: "cast", version })}\n`,
} );
const tgz = join(root, "fixtures", `${topdir}.tgz`); writeFileSync(join(src, "src", "cli.ts"), "// fixture\n");
await run("tar", ["-C", join(root, "fixtures"), "-czf", tgz, topdir]); return src;
return tgz;
} }
const PREBUILT_FILES = { async function install(sb: Sandbox, extraEnv: Record<string, string>) {
"bin/cast": "#!/usr/bin/env bash\necho fake-cast\n", return run("bash", [INSTALL_SH], { env: { ...sb.env, ...extraEnv } });
"dist/cli.js": "// prebuilt in CI\n", }
"node_modules/yaml/package.json": "{}",
"package.json": '{ "name": "cast", "version": "0.2.0" }\n',
};
const SOURCE_FILES = { function currentTarget(sb: Sandbox): string {
"bin/cast": "#!/usr/bin/env bash\necho fake-cast\n", return realpathSync(join(sb.dest, "current"));
"package.json": '{ "name": "cast", "version": "0.3.0-dev" }\n', }
"src/cli.ts": "// source only — dist/ does not exist until npm run build\n",
};
async function runInstaller(sb: Sandbox, extraEnv: Record<string, string>) { describe("install.sh — the versioned layout", () => {
return run("bash", [INSTALL_SH], { it("lands versions/<v>, points current and the PATH link through it, and the chain answers", async () => {
env: { ...sb.env, ...extraEnv }, 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",
);
}); });
}
describe("install.sh — default channel (latest release asset)", () => { it("re-running the same version is a converging no-op — nothing rebuilt, nothing touched", async () => {
it("resolves the latest tag via the redirect and installs the prebuilt tree without npm", async () => { const sb = sandbox();
const sb = sandbox({ poisonNpm: true }); const src = sourceTree(sb, "0.5.0");
const asset = await makeTarball(sb.root, "cast-0.2.0", PREBUILT_FILES); await install(sb, { CAST_INSTALL_SOURCE: src });
const { stdout } = await runInstaller(sb, { const npmCallsAfterFirst = readFileSync(sb.npmLog, "utf8");
CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases/tag/0.2.0", writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "survives\n");
CAST_TEST_ASSET_URL:
"https://github.com/heavy-duty/cast/releases/download/0.2.0/cast-0.2.0.tgz", const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src });
CAST_TEST_ASSET_FILE: asset, 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( expect(stdout).toContain(
"installing cast 0.2.0 (latest release of heavy-duty/cast)", "default version switched: 0.5.0 -> 0.6.0 ('cast use 0.5.0' switches back)",
); );
// The tree landed, prebuilt: dist/ came from the tarball, not a build. expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true);
expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain( expect(currentTarget(sb)).toBe(
"prebuilt in CI", realpathSync(join(sb.dest, "versions/0.6.0")),
); );
expect(existsSync(join(sb.dest, "node_modules/yaml/package.json"))).toBe( const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], {
true, env: sb.env,
); });
expect(readlinkSync(join(sb.bindir, "cast"))).toBe( expect(v).toContain("cast 0.6.0");
join(sb.dest, "bin/cast"),
);
// npm is poisoned — a single invocation would have failed the install.
expect(readFileSync(sb.npmLog, "utf8")).toBe("");
}); });
it("dies loudly when there is no release to resolve, pointing at CAST_REF=main", async () => { it("re-running an installed NON-default version never moves the default", async () => {
const sb = sandbox({ poisonNpm: true }); const sb = sandbox();
await expect(runInstaller(sb, {})).rejects.toMatchObject({ const old = sourceTree(sb, "0.5.0");
stderr: expect.stringContaining("CAST_REF=main installs from source"), 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); expect(existsSync(sb.dest)).toBe(false);
}); });
it("dies when the redirect lands somewhere that is not a tag page", async () => { it("refuses to migrate a flat install whose version would escape versions/", async () => {
const sb = sandbox({ poisonNpm: true }); const sb = sandbox();
await expect(
runInstaller(sb, {
CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases",
}),
).rejects.toMatchObject({
stderr: expect.stringContaining("could not resolve the latest release"),
});
});
});
describe("install.sh — pinned channel (CAST_REF=X.Y.Z)", () => {
it("uses that tag's release asset and never falls through to a source build", async () => {
const sb = sandbox({ poisonNpm: true });
const asset = await makeTarball(sb.root, "cast-0.1.0", {
...PREBUILT_FILES,
"package.json": '{ "name": "cast", "version": "0.1.0" }\n',
});
const { stdout } = await runInstaller(sb, {
CAST_REF: "0.1.0",
CAST_TEST_ASSET_URL:
"https://github.com/heavy-duty/cast/releases/download/0.1.0/cast-0.1.0.tgz",
CAST_TEST_ASSET_FILE: asset,
});
expect(stdout).toContain("installing cast 0.1.0 (pinned release asset)");
const urls = readFileSync(sb.curlLog, "utf8");
// No latest-resolution, no archive fallbacks — the pin answered.
expect(urls).not.toContain("/releases/latest");
expect(urls).not.toContain("/archive/refs/");
expect(readFileSync(sb.npmLog, "utf8")).toBe("");
});
it("refuses a prebuilt asset that is not a runnable tree, leaving the old install alone", async () => {
const sb = sandbox({ poisonNpm: true });
// An asset missing dist/ — a broken release.
const asset = await makeTarball(sb.root, "cast-0.4.0", {
"bin/cast": "#!/usr/bin/env bash\n",
"package.json": "{}",
});
// A previous install that must survive the refused upgrade.
mkdirSync(join(sb.dest, "bin"), { recursive: true }); mkdirSync(join(sb.dest, "bin"), { recursive: true });
writeFileSync(join(sb.dest, "bin/cast"), "#!/usr/bin/env bash\necho old\n"); copyFileSync(REAL_BIN_CAST, join(sb.dest, "bin/cast"));
writeFileSync(
join(sb.dest, "package.json"),
`${JSON.stringify({ name: "cast", version: "../evil" })}\n`,
);
await expect( await expect(
runInstaller(sb, { install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") }),
CAST_REF: "0.4.0",
CAST_TEST_ASSET_URL:
"https://github.com/heavy-duty/cast/releases/download/0.4.0/cast-0.4.0.tgz",
CAST_TEST_ASSET_FILE: asset,
}),
).rejects.toMatchObject({ ).rejects.toMatchObject({
stderr: expect.stringContaining("not a runnable tree"), stderr: expect.stringContaining("not a sane directory name"),
}); });
// The shape check fired BEFORE rm -rf $DEST — the old tree survives. // Refused BEFORE anything moved: the flat tree is untouched.
expect(readFileSync(join(sb.dest, "bin/cast"), "utf8")).toContain( expect(existsSync(join(sb.dest, "bin/cast"))).toBe(true);
"echo old",
);
}); });
});
describe("install.sh — dev channel (CAST_REF=<branch>)", () => { it("downloads refs/heads/<ref> when no local source is given", async () => {
it("falls back asset → refs/tags → refs/heads and builds from source", async () => { const sb = sandbox();
const sb = sandbox({ poisonNpm: false }); // A curl shim standing in for GitHub: serves the fixture tarball and
const src = await makeTarball(sb.root, "cast-main", SOURCE_FILES); // logs the URL it was asked for.
const { stdout } = await runInstaller(sb, { sourceTree(sb, "0.5.0");
CAST_REF: "main", await run("tar", [
CAST_TEST_HEADS_TARBALL: src, "-C",
}); sb.root,
"-czf",
expect(stdout).toContain( join(sb.root, "src.tgz"),
"no release asset for 'main' — building from source", "src-0.5.0",
);
const urls = readFileSync(sb.curlLog, "utf8").trim().split("\n");
expect(urls).toEqual([
"https://github.com/heavy-duty/cast/releases/download/main/cast-main.tgz",
"https://github.com/heavy-duty/cast/archive/refs/tags/main.tar.gz",
"https://github.com/heavy-duty/cast/archive/refs/heads/main.tar.gz",
]); ]);
// The build ran here — ci, build, prune — and produced the dist tree. const stubs = join(sb.root, "stubs");
const npm = readFileSync(sb.npmLog, "utf8"); const curlLog = join(sb.root, "curl.log");
expect(npm).toContain("npm ci"); writeFileSync(curlLog, "");
expect(npm).toContain("npm run build"); writeFileSync(
expect(npm).toContain("npm prune --omit=dev"); join(stubs, "curl"),
expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain( `#!/usr/bin/env bash
"built by npm shim", 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);
it("dies when the ref exists nowhere (asset, tags, heads all miss)", async () => { const { stdout } = await install(sb, { CAST_REF: "dev-branch" });
const sb = sandbox({ poisonNpm: true }); expect(readFileSync(curlLog, "utf8").trim()).toBe(
await expect( "https://github.com/heavy-duty/cast/archive/refs/heads/dev-branch.tar.gz",
runInstaller(sb, { CAST_REF: "no-such-ref" }), );
).rejects.toMatchObject({ expect(stdout).toContain("installing cast (heavy-duty/cast@dev-branch)");
stderr: expect.stringContaining("no tag or branch named 'no-such-ref'"), 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"]) {
it(`${fn}() is byte-identical between install.sh and bin/cast`, () => {
expect(extractFunction(INSTALL_SH, fn)).toBe(
extractFunction(REAL_BIN_CAST, fn),
);
});
}
});

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"),
});
});
});

View file

@ -1,121 +0,0 @@
import { execFile } from "node:child_process";
import { mkdtempSync, readFileSync, 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 release surface has two moving parts this file can prove without a
// tag push: `cast --version` (what an operator asks an installed tree) and
// scripts/changelog-section.sh (what release.yml publishes as the release
// body). Both are exercised for real — the built CLI, the actual script —
// not reimplemented in the test.
describe("cast --version", () => {
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 } = await run("node", ["dist/cli.js", flag]);
// The version comes from package.json — the single source of truth
// (cast#96) — and the root rides along, rig-style, because "which
// cast" and "where from" are the same question.
expect(stdout.trim()).toBe(`cast ${pkg.version} (${process.cwd()})`);
});
}
it("exits 0 and prints nothing to stderr", async () => {
const { stderr } = await run("node", ["dist/cli.js", "--version"]);
expect(stderr).toBe("");
});
});
describe("scripts/changelog-section.sh", () => {
const SCRIPT = join(process.cwd(), "scripts", "changelog-section.sh");
const CHANGELOG = `# Changelog
Intro prose that must never leak into a release body.
## Unreleased
### Added
- something still cooking
## 0.2.0 2026-07-18
### Added
- **the second thing** (#96) with detail.
### Fixed
- a fix note
## 0.1.0 2026-07-01
- the first thing
`;
function withChangelog(content: string): string {
const dir = mkdtempSync(join(tmpdir(), "cast-changelog-"));
const file = join(dir, "CHANGELOG.md");
writeFileSync(file, content);
return file;
}
it("prints exactly one version's section, trimmed", async () => {
const file = withChangelog(CHANGELOG);
const { stdout } = await run("bash", [SCRIPT, "0.2.0", file]);
expect(stdout).toBe(
"### Added\n\n- **the second thing** (#96) — with detail.\n\n### Fixed\n\n- a fix note\n",
);
});
it("does not bleed into the next section for the last version either", async () => {
const file = withChangelog(CHANGELOG);
const { stdout } = await run("bash", [SCRIPT, "0.1.0", file]);
expect(stdout).toBe("- the first thing\n");
});
it("never serves Unreleased content for a version that is absent", async () => {
const file = withChangelog(CHANGELOG);
// 0.3.0 has no section — the release must fail loudly, not ship the
// Unreleased notes (or an empty body) under a version's name.
await expect(run("bash", [SCRIPT, "0.3.0", file])).rejects.toMatchObject({
code: 1,
});
});
it("refuses an empty section", async () => {
const file = withChangelog(
"# Changelog\n\n## 0.9.0 — 2026-01-01\n\n## 0.8.0\n\n- old\n",
);
await expect(run("bash", [SCRIPT, "0.9.0", file])).rejects.toMatchObject({
code: 1,
});
});
it("refuses a missing file and a missing argument", async () => {
await expect(
run("bash", [SCRIPT, "0.1.0", "/nonexistent/CHANGELOG.md"]),
).rejects.toMatchObject({ code: 1 });
await expect(run("bash", [SCRIPT])).rejects.toMatchObject({ code: 2 });
});
it("finds the real CHANGELOG's Unreleased section (the format stays parseable)", async () => {
// Guard against the repo's own changelog drifting away from the shape
// this script parses — that drift would only surface on a tag push.
const { stdout } = await run("bash", [
SCRIPT,
"Unreleased",
"CHANGELOG.md",
]);
expect(stdout.length).toBeGreaterThan(0);
});
});

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("");
});
}
});