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:
parent
0a03fc592b
commit
5cd5968cf2
13 changed files with 1020 additions and 610 deletions
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
|
|
@ -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"
|
||||||
|
|
|
||||||
74
.github/workflows/release.yml
vendored
74
.github/workflows/release.yml
vendored
|
|
@ -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
|
|
||||||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -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.
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
24
README.md
24
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:
|
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
241
bin/cast
|
|
@ -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
|
||||||
|
|
|
||||||
327
install.sh
327
install.sh
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
17
src/cli.ts
17
src/cli.ts
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
229
test/layout-cli.test.ts
Normal 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"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
25
test/version-cli.test.ts
Normal 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("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue