From 5cd5968cf29814d4fd7257518d2eb48c6d0e24cb Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 21:17:59 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20rescope=20to=20versioned=20installa?= =?UTF-8?q?tions=20=E2=80=94=20the=20release=20flow=20moves=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, '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= 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 ' 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 --- .github/workflows/ci.yml | 34 +++ .github/workflows/release.yml | 74 ------ CHANGELOG.md | 25 -- CONTRIBUTING.md | 15 -- README.md | 24 +- bin/cast | 241 +++++++++++++++++- install.sh | 327 ++++++++++++++++-------- scripts/changelog-section.sh | 43 ---- src/cli.ts | 17 +- test/install-sh.test.ts | 455 ++++++++++++++++++---------------- test/layout-cli.test.ts | 229 +++++++++++++++++ test/release-tooling.test.ts | 121 --------- test/version-cli.test.ts | 25 ++ 13 files changed, 1020 insertions(+), 610 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 CHANGELOG.md delete mode 100644 scripts/changelog-section.sh create mode 100644 test/layout-cli.test.ts delete mode 100644 test/release-tooling.test.ts create mode 100644 test/version-cli.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fab140..3199403 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,37 @@ jobs: run: bash -n install.sh bin/cast scripts/*.sh - name: labels state-machine tests run: bash test/labels-reconcile.sh + + # The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at + # this checkout, so CI proves the installer under review (the versioned + # layout, the current symlink, the PATH chain, the uninstall's absence + # assert), not a hand-built imitation of it. Box's CI installs box the + # same way. This is the one place the real npm ci + tsc build path runs + # end to end; the vitest installer tests cover the layout semantics + # offline with a shimmed npm. + install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: install via install.sh, from this checkout + run: | + CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh + # assert what landed: the layout, the chain, and that it answers + readlink -f "$HOME/.local/bin/cast" | grep '/versions/' + "$HOME/.local/bin/cast" --version + "$HOME/.local/bin/cast" versions + - name: converging no-op — a re-run changes nothing and builds nothing + run: | + CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh \ + | tee /tmp/rerun.log + grep -q 'already installed' /tmp/rerun.log + - name: uninstall --all — ends with the absence assert + run: | + CAST_YES=1 "$HOME/.local/bin/cast" uninstall --all + test ! -e "$HOME/.local/share/cast" + test ! -e "$HOME/.local/bin/cast" + test ! -L "$HOME/.local/bin/cast" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 0620091..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index d298f2f..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6852819..8c95550 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. 7. **Checks must be green**: `npm run check`, `npm run build`, and `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 diff --git a/README.md b/README.md index ab88fde..ba422ea 100644 --- a/README.md +++ b/README.md @@ -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: 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` -asset built once in CI — nothing compiles on your machine, and -`cast --version` names exactly what you got. `CAST_REF` selects the other -channels: +Installs are **versioned**, the same layout box and rig use: each install +lands at `~/.local/share/cast/versions/` (the version is the tree's +`package.json` version), a `current` symlink names the default, and the +`cast` on your PATH points through it. Versions install side by side: -| | channel | what happens | -|---|---|---| -| unset | latest release | the newest tag's prebuilt asset | -| `CAST_REF=0.1.0` | pinned | that release's prebuilt asset | -| `CAST_REF=main` | dev | that ref's source tarball, built here (`npm ci` + `tsc` — needs `npm`) | +```sh +cast versions # list what is installed, marking (current) and (running) +cast use # switch the default — atomic, then asserted +cast uninstall [|--all] # remove one non-current version, or everything +``` + +Re-running the installer with an already-installed version is a converging +no-op (`CAST_REINSTALL=1` rebuilds and replaces that version's tree); a new +version installs beside the old one and becomes the default — `cast use ` +switches back. A pre-versioning flat install is migrated in place on the next +run. `CAST_REF=` installs from another branch instead of `main`. The installer symlinks `cast` into `~/.local/bin` (or `/usr/local/bin` as root) and, if that directory is not already on your `PATH`, appends it to your shell diff --git a/bin/cast b/bin/cast index feefc2a..b0922b2 100755 --- a/bin/cast +++ b/bin/cast @@ -1,12 +1,247 @@ #!/usr/bin/env bash set -euo pipefail -# Thin launcher — the CLI itself is dist/cli.js (built from src/ by tsc). -# Kept as a shim so `cast` lands on PATH the same way `rig` does, without -# requiring a global npm install. +# Launcher — the CLI itself is dist/cli.js (built from src/ by tsc), plus +# the VERSIONED-INSTALL verbs (versions / use / uninstall), which live here +# in bash because they manage the layout the node tree sits in: they must +# work even when the default version's dist/ is broken — that is exactly +# when you need them. ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" +log() { printf 'cast: %s\n' "$*"; } +warn() { printf 'cast: WARNING: %s\n' "$*" >&2; } +die() { printf 'cast: ERROR: %s\n' "$*" >&2; exit 1; } + +# --- the versioned install (box#79's layout, ported the way rig#36 did) ------ +# install.sh lands each version at /versions/, 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/ — 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 " + 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 ' to repoint the default first (refusing to remove versions while it is broken)" + [ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'cast use ' 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 || { printf 'cast: node (>=22.12) is required but was not found.\n' >&2 exit 1 diff --git a/install.sh b/install.sh index bb532c5..b710490 100644 --- a/install.sh +++ b/install.sh @@ -3,23 +3,36 @@ set -euo pipefail # 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 -# (cast-X.Y.Z.tgz). No npm ci, no tsc, no -# devDependencies on this machine — the build -# happened once, in CI, on the tag. -# CAST_REF=X.Y.Z → that release's asset, when one exists — a pin. -# CAST_REF= → build from source: the repo tarball for that -# ref (tags tried before branches), npm ci + tsc -# here. CAST_REF=main is the dev channel. +# $DEST/versions// one full tree per installed version +# $DEST/current -> versions/ the default version +# $BINDIR/cast -> $DEST/current/bin/cast the PATH entry # -# Re-run any time to upgrade. 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. +# Versions install side by side: `cast versions` lists them, `cast use ` +# switches the default, `cast uninstall` removes them. Re-running with an +# already-installed version is a converging no-op (CAST_REINSTALL=1 replaces +# that version's tree); a NEW version installs beside the old one and becomes +# the default. cast neither refuses nor warns where box refuses and rig +# warns: box protects live boxes and rig a converged host, but cast is an +# API client — flipping its version strands nothing on this machine, and +# `cast use ` is always one command away. A pre-versioning flat tree is +# migrated in place, so upgrading is seamless. +# +# The version IS the tree's package.json version — cast's single source of +# truth (deliberately no separate VERSION file). +# +# CAST_INSTALL_SOURCE= installs from a local tree instead of +# downloading — for CI and the test suite, so what lands is the code under +# review. +# +# Unlike rig (pure bash, runs on bare boxes), cast runs on YOUR machine and +# needs node — it is an API client, never something a server installs. REPO="${CAST_REPO:-heavy-duty/cast}" -REF="${CAST_REF:-}" +REF="${CAST_REF:-main}" DEST="${CAST_HOME:-$HOME/.local/share/cast}" if [ "$(id -u)" -eq 0 ]; then BINDIR="${CAST_BIN:-/usr/local/bin}" @@ -31,10 +44,37 @@ log() { printf 'cast-install: %s\n' "$*"; } warn() { printf 'cast-install: WARNING: %s\n' "$*" >&2; } die() { printf 'cast-install: ERROR: %s\n' "$*" >&2; exit 1; } +# A version is a DIRECTORY NAME under versions/ — nothing else. One strict +# gate for every caller that builds a path from one (the installer's new_ver, +# migration's flat_ver, and bin/cast's 'use'/single-version uninstall): only +# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes, +# spaces and option-lookalikes by construction — a crafted version dies HERE, +# never in an rm -rf or an ln. bin/cast carries a byte-identical copy; +# test/install-sh.test.ts diffs the two so the gates cannot drift. +valid_version() { + case "$1" in + ''|.*|-*) return 1 ;; + *[!A-Za-z0-9._+-]*) return 1 ;; + esac + return 0 +} + +# The tree's package.json version, or empty. Read via node (a prerequisite +# anyway) — never by regexing JSON. The path travels by env var, not by +# splicing it into the expression, so no filename can break the quoting. +pkg_version() { + CAST_PKG_PATH="$1/package.json" node -p 'require(process.env.CAST_PKG_PATH).version' 2>/dev/null || true +} + # --- prerequisites ----------------------------------------------------------- -command -v curl >/dev/null 2>&1 || die "curl is required but was not found." +# curl only when something must be downloaded — a local CAST_INSTALL_SOURCE +# needs none, which is what lets the test suite drive REAL installs offline. +if [ -z "${CAST_INSTALL_SOURCE:-}" ]; then + command -v curl >/dev/null 2>&1 || die "curl is required but was not found." +fi command -v tar >/dev/null 2>&1 || die "tar is required but was not found." command -v node >/dev/null 2>&1 || die "node >=22.12 is required but was not found." +command -v npm >/dev/null 2>&1 || die "npm is required but was not found." NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" [ "$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" fi +if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then + SRCDESC="local source $CAST_INSTALL_SOURCE" +else + SRCDESC="$REPO@$REF" +fi + +# Flip $DEST/current to versions/ 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/ 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 -# 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/; 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 — 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 -------------------------------------------------------- -# PREBUILT=1 means the tarball is a CI-built runnable tree (bin/, dist/, -# production node_modules/, package.json) — nothing to compile here. -PREBUILT=0 -SRCDESC="" - -if [ -z "$REF" ]; then - TAG="$(resolve_latest_tag)" \ - || die "could not resolve the latest release of $REPO — no releases yet, or no network. CAST_REF=main installs from source." - URL="https://github.com/$REPO/releases/download/$TAG/cast-$TAG.tgz" - log "installing cast $TAG (latest release of $REPO)" - log "downloading $URL" - fetch_ok "$URL" "$TMPDIR/cast.tar.gz" \ - || die "failed to download the $TAG release asset: $URL" - PREBUILT=1 - SRCDESC="$REPO@$TAG (release asset)" -else - # 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)" +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 - log "no release asset for '$REF' — building from source" - 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" + die "CAST_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC" 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 -; release.yml stages cast-). 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 - 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 - — 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 "$EXTRACTED" && npm ci --silent && npm run build --silent ) \ + ( cd "$1" && npm ci --silent && npm run build --silent ) \ || 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/ ----------------------------------- +VDIR="$DEST/versions/$new_ver" +newly_installed=0 +if [ -d "$VDIR" ]; then + if [ -n "${CAST_REINSTALL:-}" ]; then + # Replace THIS version's tree, as atomically as two renames allow — never + # a partial overlay of new files onto an old tree. + log "CAST_REINSTALL=1 — replacing the installed $new_ver tree" + build_tree "$EXTRACTED" + stage="$VDIR.new.$$"; old="$VDIR.old.$$" + rm -rf "$stage" "$old" + set_exec "$EXTRACTED" + mv "$EXTRACTED" "$stage" + # Swap by renames, delete LAST: rm-then-move leaves a hole the whole + # length of the delete where current -> this version resolves to nothing. + mv "$VDIR" "$old" + mv "$stage" "$VDIR" + rm -rf "$old" + printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM" + log "reinstalled $new_ver" + else + cur_from="$(cat "$VDIR/INSTALLED_FROM" 2>/dev/null || echo '')" + log "cast $new_ver is already installed ($cur_from) — nothing to do, and nothing was built." + log "(CAST_REINSTALL=1 replaces this version's tree; 'cast versions' lists what is installed.)" + fi +else + build_tree "$EXTRACTED" + log "installing $new_ver into $VDIR" + mkdir -p "$DEST/versions" + set_exec "$EXTRACTED" + mv "$EXTRACTED" "$VDIR" + newly_installed=1 + # Record WHAT was installed, so a caller can assert it got what it asked + # for — an installer invoked with stale env vars silently falls back to the + # defaults, and INSTALLED_FROM is how that lie gets caught. + printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM" fi -# --- atomically replace $DEST -------------------------------------------------- -log "installing into $DEST" -rm -rf "$DEST" -mkdir -p "$(dirname "$DEST")" -mv "$EXTRACTED" "$DEST" - -chmod +x "$DEST/bin/cast" -# Source installs carry scripts/; the release asset deliberately does not. -if [ -d "$DEST/scripts" ]; then - find "$DEST/scripts" -name '*.sh' -exec chmod +x {} + +# --- which version is the default? ------------------------------------------- +# 'current' is the tracked default; flipping it is the ONLY step that changes +# what an operator's `cast` runs. A fresh host (or a dangling current) is +# claimed outright; an upgrade flips, because a re-run that silently left you +# on the old version would make "re-run any time to upgrade" a lie. Judged +# from versions/ itself (readlink -f), never from what a wedged current +# claims. +cur="$(readlink -f "$DEST/current" 2>/dev/null || true)" +want="$(readlink -f "$VDIR")" +if [ -z "$cur" ] || [ ! -d "$cur" ]; then + flip_current "$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 ---------------------------------------------------------- +# --- put cast on PATH -------------------------------------------------------- +# Through the current chain, and converging — that includes HEALING: a stale +# or dangling $BINDIR/cast (say, its tree half-removed by hand) must never +# block or wedge an install — it gets repointed at the current chain, +# whatever it said before. mkdir -p "$BINDIR" -ln -sf "$DEST/bin/cast" "$BINDIR/cast" -log "linked $BINDIR/cast -> $DEST/bin/cast" +ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast" +log "linked $BINDIR/cast -> $DEST/current/bin/cast" -# --- wire $BINDIR onto PATH, durably ------------------------------------------- +# --- wire $BINDIR onto PATH, durably ----------------------------------------- # `curl | bash` runs in a subshell, so exporting PATH here would die with this # process. The only durable place is the user's shell profile — so append there, # once, marked. Opt out with CAST_NO_MODIFY_PATH=1 and wire it yourself. @@ -208,4 +323,4 @@ else log "this shell does not have it yet — open a new one, or: source $PROFILE" fi -log "done ($SRCDESC) — try: cast --help" +log "done ($SRCDESC, version $new_ver) — try: cast --help" diff --git a/scripts/changelog-section.sh b/scripts/changelog-section.sh deleted file mode 100644 index 21d1e97..0000000 --- a/scripts/changelog-section.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# changelog-section.sh [changelog-file] -# -# Print the body of CHANGELOG.md's `## ` 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 [changelog-file]" >&2; exit 2; } -[ -f "$file" ] || { echo "changelog-section: no such file: $file" >&2; exit 1; } - -# The heading is `## ` 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" diff --git a/src/cli.ts b/src/cli.ts index 5c7a122..dcff19d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -126,6 +126,9 @@ const USAGE = `usage: cast apply / --env [--path ] [-- cast server add --ip --key --env [--user root] [--port 22] cast smoke / --env [--project ] [--environment ] cast team [--env ] + cast versions # list installed versions + cast use # switch the default version + cast uninstall [|--all] [--force] # remove versions (CAST_YES=1 skips the prompt) cast --version # version + install root --state 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 -// (cast#96, deliberately no separate VERSION file). dist/cli.js sits one -// level below it in a source checkout and in an installed release asset -// alike, so resolving from import.meta.url answers for both without -// caring how this tree got here. The install root rides along in the -// output (the family's shape — rig prints its ROOT too) because "which -// cast is this" and "where does it run from" are the same question when -// several trees exist on one machine. +// (deliberately no separate VERSION file; the ecosystem already has one). +// dist/cli.js sits one level below it in a source checkout and in an +// installed versions/ tree alike, so resolving from import.meta.url +// answers for both without caring how this tree got here. The install +// root rides along in the output (the family's shape — rig prints its +// ROOT too) because "which cast is this" and "where does it run from" +// are the same question once versions install side by side. function formatVersion(): string { const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url)); const version: unknown = JSON.parse(readFileSync(pkgPath, "utf8")).version; diff --git a/test/install-sh.test.ts b/test/install-sh.test.ts index a87e0f0..c6fb83e 100644 --- a/test/install-sh.test.ts +++ b/test/install-sh.test.ts @@ -1,11 +1,13 @@ import { execFile } from "node:child_process"; import { chmodSync, + copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, + realpathSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -15,82 +17,48 @@ import { describe, expect, it } from "vitest"; const run = promisify(execFile); -// These tests run the REAL install.sh — not a reimplementation of its -// logic — with curl and npm replaced by PATH shims, so every channel -// (latest asset, pinned asset, build-from-source) is exercised offline. -// The shims record what was requested; the assertions read the wire log -// and the resulting tree, the same way rig's cli.sh proves its installer. +// These tests drive the REAL install.sh — not a reimplementation of its +// logic — via CAST_INSTALL_SOURCE (the offline channel the installer carries +// for exactly this, rig's RIG_INSTALL_SOURCE precedent) and an npm PATH shim +// whose `run build` drops a tiny runnable cli.js. Every assertion reads the +// resulting tree, the symlink chain, or the shim's invocation log. The real +// npm-ci-and-tsc build path runs end to end in CI's install job instead — +// here it would cost minutes per test for no extra layout coverage. const INSTALL_SH = join(process.cwd(), "install.sh"); +const REAL_BIN_CAST = join(process.cwd(), "bin", "cast"); -// curl shim: answers from CAST_TEST_* env vars, appends every URL to -// CAST_TEST_CURL_LOG. Exit 22 is curl's own "-f saw an HTTP error". -const CURL_SHIM = `#!/usr/bin/env bash -set -euo pipefail -out=""; url="" -args=("$@") -i=0 -while [ $i -lt \${#args[@]} ]; do - a="\${args[$i]}" - case "$a" in - -o) i=$((i+1)); out="\${args[$i]}" ;; - -w) i=$((i+1)) ;; - http*) url="$a" ;; - 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 +// What `npm run build` produces in a fixture tree: enough of a cli.js that +// the launcher chain — BINDIR/cast -> current -> versions//bin/cast -> +// node dist/cli.js — can be asserted end to end, --version included (cmd_use +// verifies the flip through it). +const FAKE_CLI = `const path = require("path"); +const root = path.resolve(__dirname, ".."); +const pkg = require(path.join(root, "package.json")); +const [cmd] = process.argv.slice(2); +if (cmd === "--version" || cmd === "-V") { + console.log("cast " + pkg.version + " (" + root + ")"); + process.exit(0); +} +console.log("fake-cast " + pkg.version); `; -// 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 printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG" case "\${1:-}" in - ci) exit 0 ;; - run) mkdir -p dist && printf '// built by npm shim\\n' > dist/cli.js ;; - prune) exit 0 ;; + run) mkdir -p dist && cp "$CAST_TEST_FAKECLI" dist/cli.js ;; esac `; -const POISONED_NPM = `#!/usr/bin/env bash -printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG" -exit 97 -`; - type Sandbox = { root: string; - stubs: string; dest: string; bindir: string; - curlLog: string; npmLog: string; env: Record; }; -function sandbox(opts: { poisonNpm: boolean }): Sandbox { +function sandbox(): Sandbox { const root = mkdtempSync(join(tmpdir(), "cast-install-")); const stubs = join(root, "stubs"); const home = join(root, "home"); @@ -98,20 +66,16 @@ function sandbox(opts: { poisonNpm: boolean }): Sandbox { const bindir = join(root, "bin"); mkdirSync(stubs); mkdirSync(home); - const curlLog = join(root, "curl.log"); const npmLog = join(root, "npm.log"); - writeFileSync(curlLog, ""); + const fakeCli = join(root, "fake-cli.js"); writeFileSync(npmLog, ""); - writeFileSync(join(stubs, "curl"), CURL_SHIM); - writeFileSync(join(stubs, "npm"), opts.poisonNpm ? POISONED_NPM : NPM_SHIM); - chmodSync(join(stubs, "curl"), 0o755); + writeFileSync(fakeCli, FAKE_CLI); + writeFileSync(join(stubs, "npm"), NPM_SHIM); chmodSync(join(stubs, "npm"), 0o755); return { root, - stubs, dest, bindir, - curlLog, npmLog, env: { PATH: `${stubs}:${process.env.PATH}`, @@ -120,180 +84,257 @@ function sandbox(opts: { poisonNpm: boolean }): Sandbox { CAST_HOME: dest, CAST_BIN: bindir, CAST_NO_MODIFY_PATH: "1", - CAST_TEST_CURL_LOG: curlLog, CAST_TEST_NPM_LOG: npmLog, + CAST_TEST_FAKECLI: fakeCli, }, }; } -// Build a .tgz fixture with a single top-level dir, like both real shapes. -async function makeTarball( - root: string, - topdir: string, - files: Record, -): Promise { - const stage = join(root, "fixtures", topdir); - for (const [rel, content] of Object.entries(files)) { - const abs = join(stage, rel); - mkdirSync(join(abs, ".."), { recursive: true }); - writeFileSync(abs, content); - } - const tgz = join(root, "fixtures", `${topdir}.tgz`); - await run("tar", ["-C", join(root, "fixtures"), "-czf", tgz, topdir]); - return tgz; +// A source tree the installer can build: the REPO'S OWN bin/cast (so the +// launcher and its layout verbs are the code under review), a package.json +// carrying the version, and a src/ marker. +function sourceTree(sb: Sandbox, version: string): string { + const src = join(sb.root, `src-${version.replace(/[^A-Za-z0-9.]/g, "_")}`); + mkdirSync(join(src, "bin"), { recursive: true }); + mkdirSync(join(src, "src"), { recursive: true }); + copyFileSync(REAL_BIN_CAST, join(src, "bin", "cast")); + writeFileSync( + join(src, "package.json"), + `${JSON.stringify({ name: "cast", version })}\n`, + ); + writeFileSync(join(src, "src", "cli.ts"), "// fixture\n"); + return src; } -const PREBUILT_FILES = { - "bin/cast": "#!/usr/bin/env bash\necho fake-cast\n", - "dist/cli.js": "// prebuilt in CI\n", - "node_modules/yaml/package.json": "{}", - "package.json": '{ "name": "cast", "version": "0.2.0" }\n', -}; +async function install(sb: Sandbox, extraEnv: Record) { + return run("bash", [INSTALL_SH], { env: { ...sb.env, ...extraEnv } }); +} -const SOURCE_FILES = { - "bin/cast": "#!/usr/bin/env bash\necho fake-cast\n", - "package.json": '{ "name": "cast", "version": "0.3.0-dev" }\n', - "src/cli.ts": "// source only — dist/ does not exist until npm run build\n", -}; +function currentTarget(sb: Sandbox): string { + return realpathSync(join(sb.dest, "current")); +} -async function runInstaller(sb: Sandbox, extraEnv: Record) { - return run("bash", [INSTALL_SH], { - env: { ...sb.env, ...extraEnv }, +describe("install.sh — the versioned layout", () => { + it("lands versions/, points current and the PATH link through it, and the chain answers", async () => { + const sb = sandbox(); + const src = sourceTree(sb, "0.5.0"); + const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src }); + + expect(stdout).toContain("done (local source"); + // The layout: one tree per version, named by package.json's version. + expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true); + expect( + readFileSync(join(sb.dest, "versions/0.5.0/dist/cli.js"), "utf8"), + ).toContain("pkg.version"); + expect( + readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"), + ).toBe(`local:${src}\n`); + // The chain: current -> versions/0.5.0, BINDIR/cast -> current/bin/cast. + expect(currentTarget(sb)).toBe( + realpathSync(join(sb.dest, "versions/0.5.0")), + ); + expect(readlinkSync(join(sb.bindir, "cast"))).toBe( + join(sb.dest, "current/bin/cast"), + ); + // And it ANSWERS, through the whole chain. + const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], { + env: sb.env, + }); + expect(v.trim()).toBe( + `cast 0.5.0 (${realpathSync(join(sb.dest, "versions/0.5.0"))})`, + ); + // The build ran: ci, build, prune — in that order. + expect(readFileSync(sb.npmLog, "utf8")).toBe( + "npm ci --silent\nnpm run build --silent\nnpm prune --omit=dev --silent\n", + ); }); -} -describe("install.sh — default channel (latest release asset)", () => { - it("resolves the latest tag via the redirect and installs the prebuilt tree without npm", async () => { - const sb = sandbox({ poisonNpm: true }); - const asset = await makeTarball(sb.root, "cast-0.2.0", PREBUILT_FILES); - const { stdout } = await runInstaller(sb, { - CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases/tag/0.2.0", - CAST_TEST_ASSET_URL: - "https://github.com/heavy-duty/cast/releases/download/0.2.0/cast-0.2.0.tgz", - CAST_TEST_ASSET_FILE: asset, + it("re-running the same version is a converging no-op — nothing rebuilt, nothing touched", async () => { + const sb = sandbox(); + const src = sourceTree(sb, "0.5.0"); + await install(sb, { CAST_INSTALL_SOURCE: src }); + const npmCallsAfterFirst = readFileSync(sb.npmLog, "utf8"); + writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "survives\n"); + + const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src }); + expect(stdout).toContain("cast 0.5.0 is already installed"); + expect(stdout).toContain("nothing was built"); + // No second build, and the installed tree was not replaced. + expect(readFileSync(sb.npmLog, "utf8")).toBe(npmCallsAfterFirst); + expect(readFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "utf8")).toBe( + "survives\n", + ); + }); + + it("CAST_REINSTALL=1 replaces that version's tree — no partial overlays", async () => { + const sb = sandbox(); + const src = sourceTree(sb, "0.5.0"); + await install(sb, { CAST_INSTALL_SOURCE: src }); + writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "stale\n"); + + const { stdout } = await install(sb, { + CAST_INSTALL_SOURCE: src, + CAST_REINSTALL: "1", + }); + expect(stdout).toContain("replacing the installed 0.5.0 tree"); + // A replaced tree, not an overlay: the stale file is gone. + expect(existsSync(join(sb.dest, "versions/0.5.0/SENTINEL"))).toBe(false); + expect(existsSync(join(sb.dest, "versions/0.5.0/dist/cli.js"))).toBe(true); + }); + + it("a NEW version installs beside the old one and becomes the default", async () => { + const sb = sandbox(); + await install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") }); + const { stdout } = await install(sb, { + CAST_INSTALL_SOURCE: sourceTree(sb, "0.6.0"), }); expect(stdout).toContain( - "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(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain( - "prebuilt in CI", + expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true); + expect(currentTarget(sb)).toBe( + realpathSync(join(sb.dest, "versions/0.6.0")), ); - expect(existsSync(join(sb.dest, "node_modules/yaml/package.json"))).toBe( - true, - ); - expect(readlinkSync(join(sb.bindir, "cast"))).toBe( - join(sb.dest, "bin/cast"), - ); - // npm is poisoned — a single invocation would have failed the install. - expect(readFileSync(sb.npmLog, "utf8")).toBe(""); + const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], { + env: sb.env, + }); + expect(v).toContain("cast 0.6.0"); }); - it("dies loudly when there is no release to resolve, pointing at CAST_REF=main", async () => { - const sb = sandbox({ poisonNpm: true }); - await expect(runInstaller(sb, {})).rejects.toMatchObject({ - stderr: expect.stringContaining("CAST_REF=main installs from source"), + it("re-running an installed NON-default version never moves the default", async () => { + const sb = sandbox(); + const old = sourceTree(sb, "0.5.0"); + await install(sb, { CAST_INSTALL_SOURCE: old }); + await install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.6.0") }); + + const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: old }); + expect(stdout).toContain( + "the default stays 0.6.0 — 'cast use 0.5.0' switches.", + ); + expect(currentTarget(sb)).toBe( + realpathSync(join(sb.dest, "versions/0.6.0")), + ); + }); + + it("migrates a pre-versioning flat install in place, preserving the tree", async () => { + const sb = sandbox(); + // The OLD layout: the tree sits flat at $DEST, bin/cast directly under it. + mkdirSync(join(sb.dest, "bin"), { recursive: true }); + mkdirSync(join(sb.dest, "dist"), { recursive: true }); + copyFileSync(REAL_BIN_CAST, join(sb.dest, "bin/cast")); + writeFileSync( + join(sb.dest, "package.json"), + `${JSON.stringify({ name: "cast", version: "0.4.0" })}\n`, + ); + writeFileSync( + join(sb.dest, "SENTINEL"), + "the operator's tree, bit for bit\n", + ); + + const { stdout } = await install(sb, { + CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0"), + }); + expect(stdout).toContain("found a pre-versioning flat install at"); + expect(stdout).toContain("migrating it into the versioned layout"); + // The flat tree moved — preserved, not rebuilt — and the new version + // installed beside it and took the default. + expect(readFileSync(join(sb.dest, "versions/0.4.0/SENTINEL"), "utf8")).toBe( + "the operator's tree, bit for bit\n", + ); + expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true); + expect(currentTarget(sb)).toBe( + realpathSync(join(sb.dest, "versions/0.5.0")), + ); + }); + + it("refuses a source whose package.json version is not a sane directory name", async () => { + const sb = sandbox(); + const src = sourceTree(sb, "../evil"); + await expect( + install(sb, { CAST_INSTALL_SOURCE: src }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining("not a sane directory name: '../evil'"), }); expect(existsSync(sb.dest)).toBe(false); }); - it("dies when the redirect lands somewhere that is not a tag page", async () => { - const sb = sandbox({ poisonNpm: true }); - 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. + it("refuses to migrate a flat install whose version would escape versions/", async () => { + const sb = sandbox(); 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( - runInstaller(sb, { - 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, - }), + install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") }), ).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. - expect(readFileSync(join(sb.dest, "bin/cast"), "utf8")).toContain( - "echo old", - ); + // Refused BEFORE anything moved: the flat tree is untouched. + expect(existsSync(join(sb.dest, "bin/cast"))).toBe(true); }); -}); -describe("install.sh — dev channel (CAST_REF=)", () => { - it("falls back asset → refs/tags → refs/heads and builds from source", async () => { - const sb = sandbox({ poisonNpm: false }); - const src = await makeTarball(sb.root, "cast-main", SOURCE_FILES); - const { stdout } = await runInstaller(sb, { - CAST_REF: "main", - CAST_TEST_HEADS_TARBALL: src, - }); - - expect(stdout).toContain( - "no release asset for 'main' — building from source", - ); - 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", + it("downloads refs/heads/ when no local source is given", async () => { + const sb = sandbox(); + // A curl shim standing in for GitHub: serves the fixture tarball and + // logs the URL it was asked for. + sourceTree(sb, "0.5.0"); + await run("tar", [ + "-C", + sb.root, + "-czf", + join(sb.root, "src.tgz"), + "src-0.5.0", ]); - // The build ran here — ci, build, prune — and produced the dist tree. - const npm = readFileSync(sb.npmLog, "utf8"); - expect(npm).toContain("npm ci"); - expect(npm).toContain("npm run build"); - expect(npm).toContain("npm prune --omit=dev"); - expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain( - "built by npm shim", + const stubs = join(sb.root, "stubs"); + const curlLog = join(sb.root, "curl.log"); + writeFileSync(curlLog, ""); + writeFileSync( + join(stubs, "curl"), + `#!/usr/bin/env bash +out=""; url="" +for a in "$@"; do + case "$prev" in -o) out="$a" ;; esac + case "$a" in http*) url="$a" ;; esac + prev="$a" +done +printf '%s\\n' "$url" >> "${curlLog}" +cp "${join(sb.root, "src.tgz")}" "$out" +`, ); - }); + chmodSync(join(stubs, "curl"), 0o755); - it("dies when the ref exists nowhere (asset, tags, heads all miss)", async () => { - const sb = sandbox({ poisonNpm: true }); - await expect( - runInstaller(sb, { CAST_REF: "no-such-ref" }), - ).rejects.toMatchObject({ - stderr: expect.stringContaining("no tag or branch named 'no-such-ref'"), - }); + const { stdout } = await install(sb, { CAST_REF: "dev-branch" }); + expect(readFileSync(curlLog, "utf8").trim()).toBe( + "https://github.com/heavy-duty/cast/archive/refs/heads/dev-branch.tar.gz", + ); + expect(stdout).toContain("installing cast (heavy-duty/cast@dev-branch)"); + expect( + readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"), + ).toBe("heavy-duty/cast@dev-branch\n"); }); }); + +describe("the shared gates cannot drift", () => { + // install.sh and bin/cast each carry valid_version and pkg_version — the + // same trust boundary enforced in two places. A byte-identical diff is the + // rig-precedent guard that an edit to one cannot quietly miss the other. + function extractFunction(file: string, name: string): string { + const text = readFileSync(file, "utf8"); + const start = text.indexOf(`${name}() {`); + expect(start, `${name}() not found in ${file}`).toBeGreaterThan(-1); + const end = text.indexOf("\n}", start); + return text.slice(start, end + 2); + } + + for (const fn of ["valid_version", "pkg_version"]) { + it(`${fn}() is byte-identical between install.sh and bin/cast`, () => { + expect(extractFunction(INSTALL_SH, fn)).toBe( + extractFunction(REAL_BIN_CAST, fn), + ); + }); + } +}); diff --git a/test/layout-cli.test.ts b/test/layout-cli.test.ts new file mode 100644 index 0000000..35b84cc --- /dev/null +++ b/test/layout-cli.test.ts @@ -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; +}; + +async function installedSandbox(versions: string[]): Promise { + 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 = {}, +) { + 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 "); + }); + + 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"), + }); + }); +}); diff --git a/test/release-tooling.test.ts b/test/release-tooling.test.ts deleted file mode 100644 index 5738b6f..0000000 --- a/test/release-tooling.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/test/version-cli.test.ts b/test/version-cli.test.ts new file mode 100644 index 0000000..e301ce7 --- /dev/null +++ b/test/version-cli.test.ts @@ -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(""); + }); + } +});