From d992f1833d60ca62c03ab64bd5d1c80211de041c Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 20:51:15 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20versioned=20installs=20=E2=80=94=20?= =?UTF-8?q?tagged=20releases=20with=20a=20prebuilt=20dist=20asset=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cast gets the family's release flow (box#83's shape), plus the piece unique to cast: because cast compiles, the source tarball is not the package — so release.yml builds ONCE in CI and attaches cast-X.Y.Z.tgz, and the installer's default channel extracts that asset instead of running npm ci + tsc on the operator's machine. - cast --version: package.json is the single source of truth (no VERSION file); prints the install root too, rig-style. - CHANGELOG.md with Unreleased; release notes are the curated section (scripts/changelog-section.sh), never the auto-generated PR list. - release.yml on a bare X.Y.Z tag: assert tag == package.json version, check + build + test, prune, tar the runnable tree, gh release create. - install.sh channels: unset → latest release asset (resolved via the releases/latest redirect — no API, no token); CAST_REF=X.Y.Z → that tag's asset; CAST_REF= → build-from-source, the old path. - Tests drive the REAL install.sh offline via curl/npm PATH shims (all three channels, plus the broken-asset and no-release refusals), and the real changelog-section.sh against fixture changelogs. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 74 +++++++++ CHANGELOG.md | 25 +++ CONTRIBUTING.md | 15 ++ README.md | 11 ++ install.sh | 119 +++++++++++--- scripts/changelog-section.sh | 43 +++++ src/cli.ts | 22 ++- test/install-sh.test.ts | 299 ++++++++++++++++++++++++++++++++++ test/release-tooling.test.ts | 121 ++++++++++++++ 9 files changed, 706 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 scripts/changelog-section.sh create mode 100644 test/install-sh.test.ts create mode 100644 test/release-tooling.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..eb28601 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +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 + run: | + npm ci + npm run check + npm run build + npm test + + - name: assemble cast-${{ github.ref_name }}.tgz + # The runnable tree and nothing else: bin/, dist/, production + # node_modules/, package.json. Pruned AFTER the tests so what ships + # is the tree that passed. Top-level dir named like a GitHub + # archive's, so the installer handles both shapes identically. + run: | + npm prune --omit=dev + stage="$(mktemp -d)/cast-$GITHUB_REF_NAME" + mkdir -p "$stage" + cp -R bin dist node_modules package.json "$stage/" + tar -C "$(dirname "$stage")" -czf "cast-$GITHUB_REF_NAME.tgz" "cast-$GITHUB_REF_NAME" + tar -tzf "cast-$GITHUB_REF_NAME.tgz" | head -5 + + - name: create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" "cast-$GITHUB_REF_NAME.tgz" \ + --verify-tag \ + --title "cast $GITHUB_REF_NAME" \ + --notes-file /tmp/release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ffda61a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +History before versioning 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 test`, `npm prune --omit=dev`), tars the runnable tree into + `cast-X.Y.Z.tgz`, and creates the GitHub release with that version's + changelog section as the body and the tarball attached. The installer now + defaults to the **latest release asset** — resolved via the + `releases/latest` redirect, no API, no token — so a default install + compiles nothing on the operator's machine and answers "what cast is + this?" with a version. `CAST_REF=X.Y.Z` pins (uses that tag's asset when + it exists), `CAST_REF=main` stays the dev channel: build-from-source, + exactly the old path. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c95550..6852819 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,21 @@ labels tell you where everything is without opening anything. agreement is the author's judgment, so the author makes the request. 7. **Checks must be green**: `npm run check`, `npm run build`, and `npm test` locally mirror what CI runs. +8. **Feature PRs carry their changelog entry.** Add what changed to + `CHANGELOG.md`'s `## Unreleased` section as part of the PR — release + notes are written when the change lands, not reconstructed at release + time. + +## Releasing + +A release is a PR, then a tag (cast#96; the flow box#83 anchors for the +family). A `release: X.Y.Z` PR bumps `package.json` and stamps the +`## Unreleased` section with the version and date. Merge it, tag the merge +commit bare `X.Y.Z` (no `v` prefix), push the tag — +[release.yml](.github/workflows/release.yml) asserts the tag matches +`package.json`, builds `cast-X.Y.Z.tgz` once in CI, and creates the GitHub +release with that section as the body and the tarball attached. The +installer's default channel serves that asset. ## Labels — who sets what diff --git a/README.md b/README.md index 3b8565e..ab88fde 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,17 @@ are decrypted by shelling out to it). Re-run any time to upgrade. Unlike rig — which is pure bash so it can run on a bare box — cast runs on **your** machine: it is an API client, and a server should never install it. +By default that installs the **latest release**: a prebuilt `cast-X.Y.Z.tgz` +asset built once in CI — nothing compiles on your machine, and +`cast --version` names exactly what you got. `CAST_REF` selects the other +channels: + +| | channel | what happens | +|---|---|---| +| unset | latest release | the newest tag's prebuilt asset | +| `CAST_REF=0.1.0` | pinned | that release's prebuilt asset | +| `CAST_REF=main` | dev | that ref's source tarball, built here (`npm ci` + `tsc` — needs `npm`) | + The installer symlinks `cast` into `~/.local/bin` (or `/usr/local/bin` as root) and, if that directory is not already on your `PATH`, appends it to your shell profile — `.zshrc`, `.bashrc`/`.bash_profile`, or `config.fish`, whichever your diff --git a/install.sh b/install.sh index 7ff8a7e..bb532c5 100644 --- a/install.sh +++ b/install.sh @@ -3,14 +3,23 @@ set -euo pipefail # cast installer — intended for: curl -fsSL .../install.sh | bash # -# Downloads the cast repo tarball, installs the tree under $DEST, builds it, -# and puts a `cast` symlink on PATH via $BINDIR. Re-run any time to upgrade. +# Three channels from one script (cast#96, the flow shared with box#83): # -# 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. +# CAST_REF unset → the latest GitHub release's prebuilt asset +# (cast-X.Y.Z.tgz). No npm ci, no tsc, no +# devDependencies on this machine — the build +# happened once, in CI, on the tag. +# CAST_REF=X.Y.Z → that release's asset, when one exists — a pin. +# CAST_REF= → build from source: the repo tarball for that +# ref (tags tried before branches), npm ci + tsc +# here. CAST_REF=main is the dev channel. +# +# Re-run any time to upgrade. Unlike rig (pure bash, runs on bare boxes), +# cast runs on YOUR machine and needs node — it is an API client, never +# something a server installs. REPO="${CAST_REPO:-heavy-duty/cast}" -REF="${CAST_REF:-main}" +REF="${CAST_REF:-}" DEST="${CAST_HOME:-$HOME/.local/share/cast}" if [ "$(id -u)" -eq 0 ]; then BINDIR="${CAST_BIN:-/usr/local/bin}" @@ -26,7 +35,6 @@ die() { printf 'cast-install: ERROR: %s\n' "$*" >&2; exit 1; } command -v curl >/dev/null 2>&1 || die "curl 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 npm >/dev/null 2>&1 || die "npm is required but was not found." NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" [ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))." @@ -43,27 +51,90 @@ TMPDIR="$(mktemp -d)" cleanup() { rm -rf "$TMPDIR"; } trap cleanup EXIT -URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" +# Resolve the latest release tag by following the releases/latest redirect +# and reading where it landed — no API, no token, no rate-limit pain +# (box#83's trick). GitHub answers .../releases/tag/; anything else +# (a repo with no releases redirects nowhere useful) is a loud failure. +resolve_latest_tag() { + local landed + landed="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest")" || return 1 + case "$landed" in + */releases/tag/*) printf '%s\n' "${landed##*/releases/tag/}" ;; + *) return 1 ;; + esac +} -log "installing cast ($REPO@$REF)" -log "downloading $URL" -curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz" \ - || die "failed to download $URL" +# fetch_ok — download, true/false. -f keeps a 404 an +# error instead of saving GitHub's error page as a tarball. +fetch_ok() { + curl -fsSL "$1" -o "$2" 2>/dev/null +} + +# --- acquire the tree -------------------------------------------------------- +# PREBUILT=1 means the tarball is a CI-built runnable tree (bin/, dist/, +# production node_modules/, package.json) — nothing to compile here. +PREBUILT=0 +SRCDESC="" + +if [ -z "$REF" ]; then + TAG="$(resolve_latest_tag)" \ + || die "could not resolve the latest release of $REPO — no releases yet, or no network. CAST_REF=main installs from source." + URL="https://github.com/$REPO/releases/download/$TAG/cast-$TAG.tgz" + log "installing cast $TAG (latest release of $REPO)" + log "downloading $URL" + fetch_ok "$URL" "$TMPDIR/cast.tar.gz" \ + || die "failed to download the $TAG release asset: $URL" + PREBUILT=1 + SRCDESC="$REPO@$TAG (release asset)" +else + # A pinned tag that has a release asset gets the asset — same bits as the + # default channel, just older. Everything else (a branch, a tag from + # before releases carried assets) falls back to build-from-source. + ASSET_URL="https://github.com/$REPO/releases/download/$REF/cast-$REF.tgz" + if fetch_ok "$ASSET_URL" "$TMPDIR/cast.tar.gz"; then + log "installing cast $REF (pinned release asset)" + PREBUILT=1 + SRCDESC="$REPO@$REF (release asset)" + else + log "no release asset for '$REF' — building from source" + for kind in tags heads; do + URL="https://github.com/$REPO/archive/refs/$kind/$REF.tar.gz" + if fetch_ok "$URL" "$TMPDIR/cast.tar.gz"; then + SRCDESC="$REPO@$REF (source, refs/$kind)" + break + fi + SRCDESC="" + done + [ -n "$SRCDESC" ] || die "no tag or branch named '$REF' in $REPO (tried the release asset, refs/tags and refs/heads)" + log "downloaded from refs — $SRCDESC" + fi +fi log "extracting archive" tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \ || die "failed to extract archive" -# GitHub archives extract to a single top-level dir like cast-/ -EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'cast-*' | head -n1)" -[ -n "$EXTRACTED" ] || die "could not find extracted cast-* directory in archive" -[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $REPO@$REF correct?" +# Both shapes carry exactly ONE top-level directory (GitHub names its +# archives -; release.yml stages cast-). Deriving that +# name is guesswork — it broke for real at box's repo rename — so take the +# single directory, whatever it is called, and judge the tree by its content. +EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" +[ -n "$EXTRACTED" ] || die "could not find the cast tree in the archive" +[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $SRCDESC correct?" -# --- build (deps + tsc), then drop the dev deps ------------------------------- -log "building (npm ci && npm run build)" -( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \ - || die "build failed" -( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies" +# --- build (source channel only) --------------------------------------------- +if [ "$PREBUILT" -eq 1 ]; then + # Verify the shape before touching $DEST: a prebuilt tree that cannot run + # is better refused here than discovered at `cast apply` time. + [ -f "$EXTRACTED/dist/cli.js" ] && [ -d "$EXTRACTED/node_modules" ] \ + || die "the release asset is not a runnable tree (missing dist/ or node_modules/) — broken release? CAST_REF=main installs from source" +else + command -v npm >/dev/null 2>&1 || die "npm is required to build from source (CAST_REF=$REF) but was not found." + log "building (npm ci && npm run build)" + ( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \ + || die "build failed" + ( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies" +fi # --- atomically replace $DEST -------------------------------------------------- log "installing into $DEST" @@ -71,7 +142,11 @@ rm -rf "$DEST" mkdir -p "$(dirname "$DEST")" mv "$EXTRACTED" "$DEST" -chmod +x "$DEST/bin/cast" "$DEST"/scripts/*.sh +chmod +x "$DEST/bin/cast" +# Source installs carry scripts/; the release asset deliberately does not. +if [ -d "$DEST/scripts" ]; then + find "$DEST/scripts" -name '*.sh' -exec chmod +x {} + +fi # --- put cast on PATH ---------------------------------------------------------- mkdir -p "$BINDIR" @@ -133,4 +208,4 @@ else log "this shell does not have it yet — open a new one, or: source $PROFILE" fi -log "done — try: cast --help" +log "done ($SRCDESC) — try: cast --help" diff --git a/scripts/changelog-section.sh b/scripts/changelog-section.sh new file mode 100644 index 0000000..21d1e97 --- /dev/null +++ b/scripts/changelog-section.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +# changelog-section.sh [changelog-file] +# +# Print the body of CHANGELOG.md's `## ` section — everything +# between that heading and the next `## ` heading (or EOF), with the +# leading/trailing blank lines trimmed. release.yml uses this as the +# GitHub release body, so the notes are the curated prose we actually +# wrote, never an auto-generated PR list (cast#96 / box#83). +# +# Fails loudly when the section is missing or empty: a release with no +# written history is a release that skipped the changelog discipline, +# and the tag push is exactly the moment to catch that — before a +# release object exists. + +version="${1:-}" +file="${2:-CHANGELOG.md}" + +[ -n "$version" ] || { echo "usage: changelog-section.sh [changelog-file]" >&2; exit 2; } +[ -f "$file" ] || { echo "changelog-section: no such file: $file" >&2; exit 1; } + +# The heading is `## ` optionally followed by more (a date stamp: +# `## 0.1.0 — 2026-07-18`). Match on the version as the second word so the +# stamp's format never becomes load-bearing here. +section="$(awk -v ver="$version" ' + /^## / { if (found) exit; if ($2 == ver) { found = 1; next } } + found { print } + END { exit found ? 0 : 3 } +' "$file")" || { + echo "changelog-section: no \"## $version\" section in $file" >&2 + exit 1 +} + +# Trim leading blank lines; command substitution already ate the trailing ones. +section="$(printf '%s\n' "$section" | sed '/./,$!d')" + +[ -n "$section" ] || { + echo "changelog-section: the \"## $version\" section in $file is empty" >&2 + exit 1 +} + +printf '%s\n' "$section" diff --git a/src/cli.ts b/src/cli.ts index 2a04a02..5c7a122 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { createInterface } from "node:readline/promises"; +import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { parse as parseYaml } from "yaml"; import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js"; @@ -125,6 +126,7 @@ const USAGE = `usage: cast apply / --env [--path ] [-- cast server add --ip --key --env [--user root] [--port 22] cast smoke / --env [--project ] [--environment ] cast team [--env ] + cast --version # version + install root --state the state checkout holding environments.yaml, secrets/ and .coolify.env (default: $CAST_STATE, else the cwd) @@ -1299,12 +1301,30 @@ async function runProject( return { status: "applied", mutated }; } +// The version lives in package.json — the tree's single source of truth +// (cast#96, deliberately no separate VERSION file). dist/cli.js sits one +// level below it in a source checkout and in an installed release asset +// alike, so resolving from import.meta.url answers for both without +// caring how this tree got here. The install root rides along in the +// output (the family's shape — rig prints its ROOT too) because "which +// cast is this" and "where does it run from" are the same question when +// several trees exist on one machine. +function formatVersion(): string { + const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url)); + const version: unknown = JSON.parse(readFileSync(pkgPath, "utf8")).version; + return `cast ${typeof version === "string" ? version : "unknown"} (${dirname(pkgPath)})`; +} + async function main(): Promise { const [command, ...rest] = process.argv.slice(2); if (command === "-h" || command === "--help" || command === "help") { console.log(USAGE); return 0; } + if (command === "-V" || command === "--version") { + console.log(formatVersion()); + return 0; + } if (command === "apply" || command === "diff") { const { values, positionals } = parseArgs({ args: rest, diff --git a/test/install-sh.test.ts b/test/install-sh.test.ts new file mode 100644 index 0000000..a87e0f0 --- /dev/null +++ b/test/install-sh.test.ts @@ -0,0 +1,299 @@ +import { execFile } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const run = promisify(execFile); + +// These tests run the REAL install.sh — not a reimplementation of its +// logic — with curl and npm replaced by PATH shims, so every channel +// (latest asset, pinned asset, build-from-source) is exercised offline. +// The shims record what was requested; the assertions read the wire log +// and the resulting tree, the same way rig's cli.sh proves its installer. + +const INSTALL_SH = join(process.cwd(), "install.sh"); + +// curl shim: answers from CAST_TEST_* env vars, appends every URL to +// CAST_TEST_CURL_LOG. Exit 22 is curl's own "-f saw an HTTP error". +const CURL_SHIM = `#!/usr/bin/env bash +set -euo pipefail +out=""; url="" +args=("$@") +i=0 +while [ $i -lt \${#args[@]} ]; do + a="\${args[$i]}" + case "$a" in + -o) i=$((i+1)); out="\${args[$i]}" ;; + -w) i=$((i+1)) ;; + http*) url="$a" ;; + esac + i=$((i+1)) +done +printf '%s\\n' "$url" >> "$CAST_TEST_CURL_LOG" +case "$url" in + */releases/latest) + [ -n "\${CAST_TEST_LATEST:-}" ] || exit 22 + printf '%s' "$CAST_TEST_LATEST" + ;; + */releases/download/*) + if [ -n "\${CAST_TEST_ASSET_FILE:-}" ] && [ "$url" = "\${CAST_TEST_ASSET_URL:-}" ]; then + cp "$CAST_TEST_ASSET_FILE" "$out" + else + exit 22 + fi + ;; + */archive/refs/tags/*) + if [ -n "\${CAST_TEST_TAGS_TARBALL:-}" ]; then cp "$CAST_TEST_TAGS_TARBALL" "$out"; else exit 22; fi + ;; + */archive/refs/heads/*) + if [ -n "\${CAST_TEST_HEADS_TARBALL:-}" ]; then cp "$CAST_TEST_HEADS_TARBALL" "$out"; else exit 22; fi + ;; + *) exit 22 ;; +esac +`; + +// npm shim: logs every invocation; 'run build' produces dist/cli.js so a +// source install ends up runnable. The prebuilt channels get a POISONED +// npm instead — if the installer touches npm at all on an asset install, +// the install fails and so does the test. +const NPM_SHIM = `#!/usr/bin/env bash +printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG" +case "\${1:-}" in + ci) exit 0 ;; + run) mkdir -p dist && printf '// built by npm shim\\n' > dist/cli.js ;; + prune) exit 0 ;; +esac +`; + +const POISONED_NPM = `#!/usr/bin/env bash +printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG" +exit 97 +`; + +type Sandbox = { + root: string; + stubs: string; + dest: string; + bindir: string; + curlLog: string; + npmLog: string; + env: Record; +}; + +function sandbox(opts: { poisonNpm: boolean }): Sandbox { + const root = mkdtempSync(join(tmpdir(), "cast-install-")); + const stubs = join(root, "stubs"); + const home = join(root, "home"); + const dest = join(root, "cast-home"); + const bindir = join(root, "bin"); + mkdirSync(stubs); + mkdirSync(home); + const curlLog = join(root, "curl.log"); + const npmLog = join(root, "npm.log"); + writeFileSync(curlLog, ""); + writeFileSync(npmLog, ""); + writeFileSync(join(stubs, "curl"), CURL_SHIM); + writeFileSync(join(stubs, "npm"), opts.poisonNpm ? POISONED_NPM : NPM_SHIM); + chmodSync(join(stubs, "curl"), 0o755); + chmodSync(join(stubs, "npm"), 0o755); + return { + root, + stubs, + dest, + bindir, + curlLog, + npmLog, + env: { + PATH: `${stubs}:${process.env.PATH}`, + HOME: home, + SHELL: "/bin/bash", + CAST_HOME: dest, + CAST_BIN: bindir, + CAST_NO_MODIFY_PATH: "1", + CAST_TEST_CURL_LOG: curlLog, + CAST_TEST_NPM_LOG: npmLog, + }, + }; +} + +// Build a .tgz fixture with a single top-level dir, like both real shapes. +async function makeTarball( + root: string, + topdir: string, + files: Record, +): Promise { + const stage = join(root, "fixtures", topdir); + for (const [rel, content] of Object.entries(files)) { + const abs = join(stage, rel); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, content); + } + const tgz = join(root, "fixtures", `${topdir}.tgz`); + await run("tar", ["-C", join(root, "fixtures"), "-czf", tgz, topdir]); + return tgz; +} + +const PREBUILT_FILES = { + "bin/cast": "#!/usr/bin/env bash\necho fake-cast\n", + "dist/cli.js": "// prebuilt in CI\n", + "node_modules/yaml/package.json": "{}", + "package.json": '{ "name": "cast", "version": "0.2.0" }\n', +}; + +const SOURCE_FILES = { + "bin/cast": "#!/usr/bin/env bash\necho fake-cast\n", + "package.json": '{ "name": "cast", "version": "0.3.0-dev" }\n', + "src/cli.ts": "// source only — dist/ does not exist until npm run build\n", +}; + +async function runInstaller(sb: Sandbox, extraEnv: Record) { + return run("bash", [INSTALL_SH], { + env: { ...sb.env, ...extraEnv }, + }); +} + +describe("install.sh — default channel (latest release asset)", () => { + it("resolves the latest tag via the redirect and installs the prebuilt tree without npm", async () => { + const sb = sandbox({ poisonNpm: true }); + const asset = await makeTarball(sb.root, "cast-0.2.0", PREBUILT_FILES); + const { stdout } = await runInstaller(sb, { + CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases/tag/0.2.0", + CAST_TEST_ASSET_URL: + "https://github.com/heavy-duty/cast/releases/download/0.2.0/cast-0.2.0.tgz", + CAST_TEST_ASSET_FILE: asset, + }); + + expect(stdout).toContain( + "installing cast 0.2.0 (latest release of heavy-duty/cast)", + ); + // The tree landed, prebuilt: dist/ came from the tarball, not a build. + expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain( + "prebuilt in CI", + ); + expect(existsSync(join(sb.dest, "node_modules/yaml/package.json"))).toBe( + true, + ); + expect(readlinkSync(join(sb.bindir, "cast"))).toBe( + join(sb.dest, "bin/cast"), + ); + // npm is poisoned — a single invocation would have failed the install. + expect(readFileSync(sb.npmLog, "utf8")).toBe(""); + }); + + it("dies loudly when there is no release to resolve, pointing at CAST_REF=main", async () => { + const sb = sandbox({ poisonNpm: true }); + await expect(runInstaller(sb, {})).rejects.toMatchObject({ + stderr: expect.stringContaining("CAST_REF=main installs from source"), + }); + expect(existsSync(sb.dest)).toBe(false); + }); + + it("dies when the redirect lands somewhere that is not a tag page", async () => { + const sb = sandbox({ poisonNpm: true }); + await expect( + runInstaller(sb, { + CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases", + }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining("could not resolve the latest release"), + }); + }); +}); + +describe("install.sh — pinned channel (CAST_REF=X.Y.Z)", () => { + it("uses that tag's release asset and never falls through to a source build", async () => { + const sb = sandbox({ poisonNpm: true }); + const asset = await makeTarball(sb.root, "cast-0.1.0", { + ...PREBUILT_FILES, + "package.json": '{ "name": "cast", "version": "0.1.0" }\n', + }); + const { stdout } = await runInstaller(sb, { + CAST_REF: "0.1.0", + CAST_TEST_ASSET_URL: + "https://github.com/heavy-duty/cast/releases/download/0.1.0/cast-0.1.0.tgz", + CAST_TEST_ASSET_FILE: asset, + }); + + expect(stdout).toContain("installing cast 0.1.0 (pinned release asset)"); + const urls = readFileSync(sb.curlLog, "utf8"); + // No latest-resolution, no archive fallbacks — the pin answered. + expect(urls).not.toContain("/releases/latest"); + expect(urls).not.toContain("/archive/refs/"); + expect(readFileSync(sb.npmLog, "utf8")).toBe(""); + }); + + it("refuses a prebuilt asset that is not a runnable tree, leaving the old install alone", async () => { + const sb = sandbox({ poisonNpm: true }); + // An asset missing dist/ — a broken release. + const asset = await makeTarball(sb.root, "cast-0.4.0", { + "bin/cast": "#!/usr/bin/env bash\n", + "package.json": "{}", + }); + // A previous install that must survive the refused upgrade. + mkdirSync(join(sb.dest, "bin"), { recursive: true }); + writeFileSync(join(sb.dest, "bin/cast"), "#!/usr/bin/env bash\necho old\n"); + + await expect( + runInstaller(sb, { + CAST_REF: "0.4.0", + CAST_TEST_ASSET_URL: + "https://github.com/heavy-duty/cast/releases/download/0.4.0/cast-0.4.0.tgz", + CAST_TEST_ASSET_FILE: asset, + }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining("not a runnable tree"), + }); + // The shape check fired BEFORE rm -rf $DEST — the old tree survives. + expect(readFileSync(join(sb.dest, "bin/cast"), "utf8")).toContain( + "echo old", + ); + }); +}); + +describe("install.sh — dev channel (CAST_REF=)", () => { + it("falls back asset → refs/tags → refs/heads and builds from source", async () => { + const sb = sandbox({ poisonNpm: false }); + const src = await makeTarball(sb.root, "cast-main", SOURCE_FILES); + const { stdout } = await runInstaller(sb, { + CAST_REF: "main", + CAST_TEST_HEADS_TARBALL: src, + }); + + expect(stdout).toContain( + "no release asset for 'main' — building from source", + ); + const urls = readFileSync(sb.curlLog, "utf8").trim().split("\n"); + expect(urls).toEqual([ + "https://github.com/heavy-duty/cast/releases/download/main/cast-main.tgz", + "https://github.com/heavy-duty/cast/archive/refs/tags/main.tar.gz", + "https://github.com/heavy-duty/cast/archive/refs/heads/main.tar.gz", + ]); + // The build ran here — ci, build, prune — and produced the dist tree. + const npm = readFileSync(sb.npmLog, "utf8"); + expect(npm).toContain("npm ci"); + expect(npm).toContain("npm run build"); + expect(npm).toContain("npm prune --omit=dev"); + expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain( + "built by npm shim", + ); + }); + + it("dies when the ref exists nowhere (asset, tags, heads all miss)", async () => { + const sb = sandbox({ poisonNpm: true }); + await expect( + runInstaller(sb, { CAST_REF: "no-such-ref" }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining("no tag or branch named 'no-such-ref'"), + }); + }); +}); diff --git a/test/release-tooling.test.ts b/test/release-tooling.test.ts new file mode 100644 index 0000000..5738b6f --- /dev/null +++ b/test/release-tooling.test.ts @@ -0,0 +1,121 @@ +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); + }); +}); -- 2.45.2 From 0a03fc592bf93c8fdb096da112b90260a09407a2 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 21:00:25 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(release):=20drop=20test-at-tag=20from?= =?UTF-8?q?=20release.yml=20=E2=80=94=20ci.yml=20is=20the=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release job ran `npm run check` and `npm test` on a bare runner with no `age` installed — ci.yml apt-installs it because the secrets tests round-trip a real age identity, so the first real tag push would have died at `npm test` and minted no release. The job now does exactly what the flow (cast#96 / box#83) assigns it: `npm ci && npm run build && npm prune --omit=dev`, tar, `gh release create` — check and tests already gated the merge commit the tag points at. Also aligns CHANGELOG.md with the family preamble ("History before 0.1.0 lives in git") and with the workflow's actual build steps. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 8 ++++---- CHANGELOG.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb28601..0620091 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,16 +45,16 @@ jobs: 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 check npm run build - npm test - name: assemble cast-${{ github.ref_name }}.tgz # The runnable tree and nothing else: bin/, dist/, production - # node_modules/, package.json. Pruned AFTER the tests so what ships - # is the tree that passed. Top-level dir named like a GitHub + # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ffda61a..d298f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -History before versioning lives in git. Feature PRs land their entry in +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). @@ -14,7 +14,7 @@ heavy-duty/box#83). `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 test`, `npm prune --omit=dev`), tars the runnable tree into + 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 -- 2.45.2 From 5cd5968cf29814d4fd7257518d2eb48c6d0e24cb Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 21:17:59 +0000 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20rescope=20to=20versioned=20inst?= =?UTF-8?q?allations=20=E2=80=94=20the=20release=20flow=20moves=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE= installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use ' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 34 +++ .github/workflows/release.yml | 74 ------ CHANGELOG.md | 25 -- CONTRIBUTING.md | 15 -- README.md | 24 +- bin/cast | 241 +++++++++++++++++- install.sh | 327 ++++++++++++++++-------- scripts/changelog-section.sh | 43 ---- src/cli.ts | 17 +- test/install-sh.test.ts | 455 ++++++++++++++++++---------------- test/layout-cli.test.ts | 229 +++++++++++++++++ test/release-tooling.test.ts | 121 --------- test/version-cli.test.ts | 25 ++ 13 files changed, 1020 insertions(+), 610 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 CHANGELOG.md delete mode 100644 scripts/changelog-section.sh create mode 100644 test/layout-cli.test.ts delete mode 100644 test/release-tooling.test.ts create mode 100644 test/version-cli.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fab140..3199403 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,37 @@ jobs: run: bash -n install.sh bin/cast scripts/*.sh - name: labels state-machine tests run: bash test/labels-reconcile.sh + + # The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at + # this checkout, so CI proves the installer under review (the versioned + # layout, the current symlink, the PATH chain, the uninstall's absence + # assert), not a hand-built imitation of it. Box's CI installs box the + # same way. This is the one place the real npm ci + tsc build path runs + # end to end; the vitest installer tests cover the layout semantics + # offline with a shimmed npm. + install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: install via install.sh, from this checkout + run: | + CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh + # assert what landed: the layout, the chain, and that it answers + readlink -f "$HOME/.local/bin/cast" | grep '/versions/' + "$HOME/.local/bin/cast" --version + "$HOME/.local/bin/cast" versions + - name: converging no-op — a re-run changes nothing and builds nothing + run: | + CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh \ + | tee /tmp/rerun.log + grep -q 'already installed' /tmp/rerun.log + - name: uninstall --all — ends with the absence assert + run: | + CAST_YES=1 "$HOME/.local/bin/cast" uninstall --all + test ! -e "$HOME/.local/share/cast" + test ! -e "$HOME/.local/bin/cast" + test ! -L "$HOME/.local/bin/cast" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 0620091..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: release - -# A release is a PR, then a tag (cast#96, the flow shared with box#83): -# the `release: X.Y.Z` PR bumps package.json and stamps the CHANGELOG's -# Unreleased section; merging and pushing the bare `X.Y.Z` tag lands here. -# This workflow is where cast differs from its siblings: box/rig are pure -# bash, so the source tarball IS the package — cast compiles, so the build -# happens ONCE, here, and the release carries a prebuilt `cast-X.Y.Z.tgz` -# the installer can drop in without npm ci or tsc on the operator's machine. - -on: - push: - # Bare X.Y.Z tags (the family scheme — box's 0.6.0 set the precedent, - # no `v` prefix). The glob is loose; the assert step below is the gate. - tags: ["[0-9]*.*.*"] - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - - - name: assert tag == package.json version - # Fail loudly, create nothing: a tag that contradicts package.json - # would mint a release whose `cast --version` disagrees with its - # own name. The mismatch is a ritual error — retag, don't patch. - run: | - version="$(node -p 'require("./package.json").version')" - if [ "$GITHUB_REF_NAME" != "$version" ]; then - echo "tag '$GITHUB_REF_NAME' != package.json version '$version' — refusing to release" >&2 - exit 1 - fi - - - name: extract the release notes from CHANGELOG.md - # The release body is the curated section we wrote, never the - # auto-generated PR list. Missing/empty section fails the release — - # before the tag has minted anything. - run: bash scripts/changelog-section.sh "$GITHUB_REF_NAME" CHANGELOG.md > /tmp/release-notes.md - - - name: build the package, once - # Just the build — check and tests already gated the merge commit - # this tag points at (ci.yml, with its age dependency); the release - # job's whole job is packaging that green tree. - run: | - npm ci - npm run build - - - name: assemble cast-${{ github.ref_name }}.tgz - # The runnable tree and nothing else: bin/, dist/, production - # node_modules/, package.json. Top-level dir named like a GitHub - # archive's, so the installer handles both shapes identically. - run: | - npm prune --omit=dev - stage="$(mktemp -d)/cast-$GITHUB_REF_NAME" - mkdir -p "$stage" - cp -R bin dist node_modules package.json "$stage/" - tar -C "$(dirname "$stage")" -czf "cast-$GITHUB_REF_NAME.tgz" "cast-$GITHUB_REF_NAME" - tar -tzf "cast-$GITHUB_REF_NAME.tgz" | head -5 - - - name: create the GitHub release - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create "$GITHUB_REF_NAME" "cast-$GITHUB_REF_NAME.tgz" \ - --verify-tag \ - --title "cast $GITHUB_REF_NAME" \ - --notes-file /tmp/release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index d298f2f..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,25 +0,0 @@ -# Changelog - -History before 0.1.0 lives in git. Feature PRs land their entry in -`## Unreleased` as part of the PR; a release PR stamps that section with -the version and date (see cast#96 — the release flow shared with -heavy-duty/box#83). - -## Unreleased - -### Added - -- **Versioned installs: tagged releases with a prebuilt dist asset** (#96) — - cast now has a release surface. `cast --version` prints the version from - `package.json` (the single source of truth — no separate `VERSION` file) - plus the install root. On a bare `X.Y.Z` tag push, `release.yml` asserts - the tag matches `package.json`, builds once in CI (`npm ci`, `npm run - build`, `npm prune --omit=dev`), tars the runnable tree into - `cast-X.Y.Z.tgz`, and creates the GitHub release with that version's - changelog section as the body and the tarball attached. The installer now - defaults to the **latest release asset** — resolved via the - `releases/latest` redirect, no API, no token — so a default install - compiles nothing on the operator's machine and answers "what cast is - this?" with a version. `CAST_REF=X.Y.Z` pins (uses that tag's asset when - it exists), `CAST_REF=main` stays the dev channel: build-from-source, - exactly the old path. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6852819..8c95550 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,21 +38,6 @@ labels tell you where everything is without opening anything. agreement is the author's judgment, so the author makes the request. 7. **Checks must be green**: `npm run check`, `npm run build`, and `npm test` locally mirror what CI runs. -8. **Feature PRs carry their changelog entry.** Add what changed to - `CHANGELOG.md`'s `## Unreleased` section as part of the PR — release - notes are written when the change lands, not reconstructed at release - time. - -## Releasing - -A release is a PR, then a tag (cast#96; the flow box#83 anchors for the -family). A `release: X.Y.Z` PR bumps `package.json` and stamps the -`## Unreleased` section with the version and date. Merge it, tag the merge -commit bare `X.Y.Z` (no `v` prefix), push the tag — -[release.yml](.github/workflows/release.yml) asserts the tag matches -`package.json`, builds `cast-X.Y.Z.tgz` once in CI, and creates the GitHub -release with that section as the body and the tarball attached. The -installer's default channel serves that asset. ## Labels — who sets what diff --git a/README.md b/README.md index ab88fde..ba422ea 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,22 @@ are decrypted by shelling out to it). Re-run any time to upgrade. Unlike rig — which is pure bash so it can run on a bare box — cast runs on **your** machine: it is an API client, and a server should never install it. -By default that installs the **latest release**: a prebuilt `cast-X.Y.Z.tgz` -asset built once in CI — nothing compiles on your machine, and -`cast --version` names exactly what you got. `CAST_REF` selects the other -channels: +Installs are **versioned**, the same layout box and rig use: each install +lands at `~/.local/share/cast/versions/` (the version is the tree's +`package.json` version), a `current` symlink names the default, and the +`cast` on your PATH points through it. Versions install side by side: -| | channel | what happens | -|---|---|---| -| unset | latest release | the newest tag's prebuilt asset | -| `CAST_REF=0.1.0` | pinned | that release's prebuilt asset | -| `CAST_REF=main` | dev | that ref's source tarball, built here (`npm ci` + `tsc` — needs `npm`) | +```sh +cast versions # list what is installed, marking (current) and (running) +cast use # switch the default — atomic, then asserted +cast uninstall [|--all] # remove one non-current version, or everything +``` + +Re-running the installer with an already-installed version is a converging +no-op (`CAST_REINSTALL=1` rebuilds and replaces that version's tree); a new +version installs beside the old one and becomes the default — `cast use ` +switches back. A pre-versioning flat install is migrated in place on the next +run. `CAST_REF=` installs from another branch instead of `main`. The installer symlinks `cast` into `~/.local/bin` (or `/usr/local/bin` as root) and, if that directory is not already on your `PATH`, appends it to your shell diff --git a/bin/cast b/bin/cast index feefc2a..b0922b2 100755 --- a/bin/cast +++ b/bin/cast @@ -1,12 +1,247 @@ #!/usr/bin/env bash set -euo pipefail -# Thin launcher — the CLI itself is dist/cli.js (built from src/ by tsc). -# Kept as a shim so `cast` lands on PATH the same way `rig` does, without -# requiring a global npm install. +# Launcher — the CLI itself is dist/cli.js (built from src/ by tsc), plus +# the VERSIONED-INSTALL verbs (versions / use / uninstall), which live here +# in bash because they manage the layout the node tree sits in: they must +# work even when the default version's dist/ is broken — that is exactly +# when you need them. ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" +log() { printf 'cast: %s\n' "$*"; } +warn() { printf 'cast: WARNING: %s\n' "$*" >&2; } +die() { printf 'cast: ERROR: %s\n' "$*" >&2; exit 1; } + +# --- the versioned install (box#79's layout, ported the way rig#36 did) ------ +# install.sh lands each version at /versions/, with a +# 'current' symlink naming the default and $BINDIR/cast pointing through it. +# $ROOT (readlink -f, above) already resolved the whole chain, so a versioned +# install always runs from .../versions/ — and a git checkout does not, +# which is how these verbs know to refuse instead of uninstalling somebody's +# working copy. +install_root() { + local vdir; vdir="$(dirname "$ROOT")" + [ "$(basename "$vdir")" = versions ] || return 1 + dirname "$vdir" +} + +# A version is a DIRECTORY NAME under versions/ — nothing else. One strict +# gate for every caller that builds a path from one (the installer's new_ver, +# migration's flat_ver, and bin/cast's 'use'/single-version uninstall): only +# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes, +# spaces and option-lookalikes by construction — a crafted version dies HERE, +# never in an rm -rf or an ln. install.sh carries a byte-identical copy; +# test/install-sh.test.ts diffs the two so the gates cannot drift. +valid_version() { + case "$1" in + ''|.*|-*) return 1 ;; + *[!A-Za-z0-9._+-]*) return 1 ;; + esac + return 0 +} + +# The tree's package.json version, or empty — cast's version lives there +# (deliberately no separate VERSION file), read via node, never by regexing +# JSON. The path travels by env var so no filename can break the quoting. +pkg_version() { + CAST_PKG_PATH="$1/package.json" node -p 'require(process.env.CAST_PKG_PATH).version' 2>/dev/null || true +} + +# The PATH symlinks that could ride this install: the one this invocation came +# in on, CAST_BIN's, and the tier default's. Candidates only — every consumer +# checks where a link actually points before touching it, so a symlink that is +# somebody else's (another install root, a hand-rolled wrapper) is never moved. +bin_links() { + local c=() + [ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}") + [ -n "${CAST_BIN:-}" ] && c+=("$CAST_BIN/cast") + if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/cast); else c+=("$HOME/.local/bin/cast"); fi + printf '%s\n' "${c[@]}" | awk '!seen[$0]++' +} + +converge_bin_links() { # $1 = install root: point our PATH symlinks through current + local ir="$1" p t + while IFS= read -r p; do + [ -L "$p" ] || continue + t="$(readlink -f "$p" 2>/dev/null || true)" + [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)" + case "$t" in + "$ir"/*) ln -sfn "$ir/current/bin/cast" "$p" ;; + esac + done < <(bin_links) +} + +cmd_versions() { + local ir cur d v mark + ir="$(install_root)" || die "this cast runs from a working tree ($ROOT), not a versioned install — nothing to list" + cur="$(readlink -f "$ir/current" 2>/dev/null || true)" + echo "VERSIONS ($ir)" + for d in "$ir/versions"/*/; do + [ -d "$d" ] || continue + v="$(basename "$d")" + mark="" + [ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)" + [ "$(readlink -f "$d")" = "$ROOT" ] && mark="$mark (running)" + printf ' %s%s\n' "$v" "$mark" + done + echo + echo "switch the default: cast use " + echo "install another: re-run install.sh (versions land side by side)" +} + +cmd_use() { + local v="${1:-}" ir eff expect out + if [ -z "$v" ]; then + printf 'cast: use needs a version (see: cast versions)\n' >&2 + exit 2 + fi + ir="$(install_root)" || die "this cast runs from a working tree ($ROOT), not a versioned install — nothing to switch" + valid_version "$v" || die "not a sane version name: '$v' (a version is a directory name under versions/ — see 'cast versions')" + [ -d "$ir/versions/$v" ] || die "no such version: $v (see 'cast versions')" + # An atomic flip, not unlink+create: ln -sfn leaves a window where current + # is missing; a rename over it does not. + ln -sfn "versions/$v" "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current" + converge_bin_links "$ir" + # Assert the EFFECTIVE result, not the intent: current must resolve to the + # version asked for, and the chain's own binary must answer that version — + # a flip that "worked" while the operator's cast still runs the old tree is + # exactly the flakiness this verb exists to end. + eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")" + [ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v" + expect="$(pkg_version "$ir/versions/$v")" + if [ -n "$expect" ]; then + out="$("$ir/current/bin/cast" --version 2>&1 || true)" + case "$out" in + *"$expect"*) : ;; + *) die "current/bin/cast answers '$out', not version $expect — the symlink chain is broken" ;; + esac + fi + log "switched to $v (current -> versions/$v)" +} + +# The uninstall's own confirmation: --force, or CAST_YES=1, or a TTY. CAST_YES +# is the installer-family consent contract — how automation says yes without +# a terminal; without any of the three we refuse rather than assume consent. +uninstall_confirm() { # $1 = question + [ "$force" -eq 1 ] && return 0 + [ -n "${CAST_YES:-}" ] && return 0 + if [ ! -t 0 ]; then + printf 'cast: refusing to %s without --force (no terminal to confirm on; CAST_YES=1 also means yes)\n' "$1" >&2 + exit 2 + fi + local reply + printf 'cast: %s? [y/N] ' "$1" + read -r reply + case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac +} + +# 'cast uninstall' — trees and symlinks, and it ENDS by PROVING the absence — +# the last word is a re-check, not a hope. +cmd_uninstall() { + local ir a ver="" all=0 force=0 cur p t leftover="" + local targets=() + for a in "$@"; do + case "$a" in + --all) all=1 ;; + --force) force=1 ;; + -*) + printf 'cast: unknown option: %s\n' "$a" >&2 + exit 2 + ;; + *) + if [ -n "$ver" ]; then + printf 'cast: uninstall takes one version, or --all\n' >&2 + exit 2 + fi + ver="$a" + ;; + esac + done + ir="$(install_root)" || die "this cast runs from a working tree ($ROOT), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)" + [ -w "$ir" ] || die "cannot write $ir — uninstall as the user that installed it (or root: sudo cast uninstall)" + + # -- one version ----------------------------------------------------------- + if [ -n "$ver" ] && [ "$all" -eq 0 ]; then + valid_version "$ver" || die "not a sane version name: '$ver' (a version is a directory name under versions/ — see 'cast versions')" + [ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'cast versions')" + cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")" + # A broken current makes the CURRENT guard below unfireable (cur empty + # when the link is missing; cur naming a non-directory when it dangles — + # readlink -f resolves a link whose last component does not exist). Heal + # first, then decide; never delete around a broken default. + { [ -n "$cur" ] && [ -d "$ir/versions/$cur" ]; } \ + || die "current is dangling — 'cast use ' to repoint the default first (refusing to remove versions while it is broken)" + [ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'cast use ' first, or 'cast uninstall --all' for everything" + uninstall_confirm "remove cast version $ver from $ir" + # rm's exit code is not the verdict — the absence re-check below is (a + # half-removed tree must be reported as INCOMPLETE, not as a crash). + rm -rf "${ir:?}/versions/$ver" || true + if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then + echo "cast: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2 + exit 1 + fi + log "removed version $ver (the default stays $cur)" + return 0 + fi + if [ -n "$ver" ]; then + printf 'cast: a version and --all together is ambiguous\n' >&2 + exit 2 + fi + + # -- everything (bare 'cast uninstall' and '--all' both mean all of it) ---- + uninstall_confirm "remove the ENTIRE cast install at $ir (every version)" + + # The removal set, gathered BEFORE anything is deleted, so the absence + # assert below re-checks exactly what was promised gone. PATH symlinks are + # removed only when they resolve into (or dangle at) THIS install root. + targets+=("$ir") + while IFS= read -r p; do + [ -L "$p" ] || continue + t="$(readlink -f "$p" 2>/dev/null || true)" + [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)" + case "$t" in "$ir"/*) targets+=("$p") ;; esac + done < <(bin_links) + mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++') + + # rm's exit code is not the verdict — the absence assert below is (a + # half-removed tree must be reported as INCOMPLETE by name, not as a crash). + for p in "${targets[@]}"; do rm -rf "$p" || true; done + + # END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink. + # A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims + # get verified. + for p in "${targets[@]}"; do + if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi + done + if [ -n "$leftover" ]; then + echo "cast: uninstall INCOMPLETE — still present:$leftover" >&2 + echo "cast: remove them by hand, and re-check each path is really gone." >&2 + exit 1 + fi + echo "cast: uninstalled — removed:" + for p in "${targets[@]}"; do echo "cast: · $p"; done +} + +# --- dispatch: layout verbs here, everything else is dist/cli.js ------------- +case "${1:-}" in + versions) + shift + cmd_versions "$@" + exit 0 + ;; + use) + shift + cmd_use "$@" + exit 0 + ;; + uninstall) + shift + cmd_uninstall "$@" + exit 0 + ;; +esac + command -v node >/dev/null 2>&1 || { printf 'cast: node (>=22.12) is required but was not found.\n' >&2 exit 1 diff --git a/install.sh b/install.sh index bb532c5..b710490 100644 --- a/install.sh +++ b/install.sh @@ -3,23 +3,36 @@ set -euo pipefail # cast installer — intended for: curl -fsSL .../install.sh | bash # -# Three channels from one script (cast#96, the flow shared with box#83): +# Downloads the cast repo tarball, builds it, and installs it into the +# VERSIONED layout under $DEST (box#79's layout, ported the way rig#36 +# ported it): # -# CAST_REF unset → the latest GitHub release's prebuilt asset -# (cast-X.Y.Z.tgz). No npm ci, no tsc, no -# devDependencies on this machine — the build -# happened once, in CI, on the tag. -# CAST_REF=X.Y.Z → that release's asset, when one exists — a pin. -# CAST_REF= → build from source: the repo tarball for that -# ref (tags tried before branches), npm ci + tsc -# here. CAST_REF=main is the dev channel. +# $DEST/versions// one full tree per installed version +# $DEST/current -> versions/ the default version +# $BINDIR/cast -> $DEST/current/bin/cast the PATH entry # -# Re-run any time to upgrade. Unlike rig (pure bash, runs on bare boxes), -# cast runs on YOUR machine and needs node — it is an API client, never -# something a server installs. +# Versions install side by side: `cast versions` lists them, `cast use ` +# switches the default, `cast uninstall` removes them. Re-running with an +# already-installed version is a converging no-op (CAST_REINSTALL=1 replaces +# that version's tree); a NEW version installs beside the old one and becomes +# the default. cast neither refuses nor warns where box refuses and rig +# warns: box protects live boxes and rig a converged host, but cast is an +# API client — flipping its version strands nothing on this machine, and +# `cast use ` is always one command away. A pre-versioning flat tree is +# migrated in place, so upgrading is seamless. +# +# The version IS the tree's package.json version — cast's single source of +# truth (deliberately no separate VERSION file). +# +# CAST_INSTALL_SOURCE= installs from a local tree instead of +# downloading — for CI and the test suite, so what lands is the code under +# review. +# +# Unlike rig (pure bash, runs on bare boxes), cast runs on YOUR machine and +# needs node — it is an API client, never something a server installs. REPO="${CAST_REPO:-heavy-duty/cast}" -REF="${CAST_REF:-}" +REF="${CAST_REF:-main}" DEST="${CAST_HOME:-$HOME/.local/share/cast}" if [ "$(id -u)" -eq 0 ]; then BINDIR="${CAST_BIN:-/usr/local/bin}" @@ -31,10 +44,37 @@ log() { printf 'cast-install: %s\n' "$*"; } warn() { printf 'cast-install: WARNING: %s\n' "$*" >&2; } die() { printf 'cast-install: ERROR: %s\n' "$*" >&2; exit 1; } +# A version is a DIRECTORY NAME under versions/ — nothing else. One strict +# gate for every caller that builds a path from one (the installer's new_ver, +# migration's flat_ver, and bin/cast's 'use'/single-version uninstall): only +# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes, +# spaces and option-lookalikes by construction — a crafted version dies HERE, +# never in an rm -rf or an ln. bin/cast carries a byte-identical copy; +# test/install-sh.test.ts diffs the two so the gates cannot drift. +valid_version() { + case "$1" in + ''|.*|-*) return 1 ;; + *[!A-Za-z0-9._+-]*) return 1 ;; + esac + return 0 +} + +# The tree's package.json version, or empty. Read via node (a prerequisite +# anyway) — never by regexing JSON. The path travels by env var, not by +# splicing it into the expression, so no filename can break the quoting. +pkg_version() { + CAST_PKG_PATH="$1/package.json" node -p 'require(process.env.CAST_PKG_PATH).version' 2>/dev/null || true +} + # --- prerequisites ----------------------------------------------------------- -command -v curl >/dev/null 2>&1 || die "curl is required but was not found." +# curl only when something must be downloaded — a local CAST_INSTALL_SOURCE +# needs none, which is what lets the test suite drive REAL installs offline. +if [ -z "${CAST_INSTALL_SOURCE:-}" ]; then + command -v curl >/dev/null 2>&1 || die "curl is required but was not found." +fi command -v tar >/dev/null 2>&1 || die "tar is required but was not found." command -v node >/dev/null 2>&1 || die "node >=22.12 is required but was not found." +command -v npm >/dev/null 2>&1 || die "npm is required but was not found." NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" [ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))." @@ -46,114 +86,189 @@ if ! command -v age >/dev/null 2>&1; then warn " Fedora: sudo dnf install age | macOS: brew install age" fi +if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then + SRCDESC="local source $CAST_INSTALL_SOURCE" +else + SRCDESC="$REPO@$REF" +fi + +# Flip $DEST/current to versions/ atomically: build the new link beside it, +# rename over. Plain ln -sfn is unlink+create — a window where current names +# nothing and a concurrent 'cast' invocation dies mid-chain. bin/cast's +# cmd_use flips with the same pattern. +flip_current() { + ln -sfn "versions/$1" "$DEST/current.new.$$" + mv -Tf "$DEST/current.new.$$" "$DEST/current" +} + +# --- migrate a pre-versioning flat install ----------------------------------- +# The old installer put the tree FLAT at $DEST (bin/cast directly under it). +# Move such a tree to versions/ BEFORE anything else, so the +# upgrade is seamless and the version comparison below sees the truth. The +# move is two renames inside one parent directory — no copying, no window with +# no install — and the operator's tree is preserved bit for bit. +if [ -e "$DEST/bin/cast" ] && [ ! -d "$DEST/versions" ]; then + flat_ver="$(pkg_version "$DEST")" + [ -n "$flat_ver" ] || flat_ver="0.0.0-unknown" + # The flat tree's version is data from disk, not from this installer — the + # same trust boundary as the new_ver check, so the same gate: a corrupted + # (or hostile) package.json must not steer the mv/ln below out of versions/. + valid_version "$flat_ver" || die "the flat install's package.json version is not a sane directory name: '$flat_ver' — fix $DEST/package.json, then re-run" + log "found a pre-versioning flat install at $DEST (version $flat_ver) — migrating it into the versioned layout" + staging="$DEST.migrating.$$" + mv "$DEST" "$staging" + mkdir -p "$DEST/versions" + mv "$staging" "$DEST/versions/$flat_ver" + flip_current "$flat_ver" + mkdir -p "$BINDIR" + ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast" + log "migrated: it now lives at $DEST/versions/$flat_ver (still current)" +fi + # --- temp workspace ---------------------------------------------------------- TMPDIR="$(mktemp -d)" cleanup() { rm -rf "$TMPDIR"; } trap cleanup EXIT -# Resolve the latest release tag by following the releases/latest redirect -# and reading where it landed — no API, no token, no rate-limit pain -# (box#83's trick). GitHub answers .../releases/tag/; anything else -# (a repo with no releases redirects nowhere useful) is a loud failure. -resolve_latest_tag() { - local landed - landed="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest")" || return 1 - case "$landed" in - */releases/tag/*) printf '%s\n' "${landed##*/releases/tag/}" ;; - *) return 1 ;; - esac -} - -# fetch_ok — download, true/false. -f keeps a 404 an -# error instead of saving GitHub's error page as a tarball. -fetch_ok() { - curl -fsSL "$1" -o "$2" 2>/dev/null -} - # --- acquire the tree -------------------------------------------------------- -# PREBUILT=1 means the tarball is a CI-built runnable tree (bin/, dist/, -# production node_modules/, package.json) — nothing to compile here. -PREBUILT=0 -SRCDESC="" - -if [ -z "$REF" ]; then - TAG="$(resolve_latest_tag)" \ - || die "could not resolve the latest release of $REPO — no releases yet, or no network. CAST_REF=main installs from source." - URL="https://github.com/$REPO/releases/download/$TAG/cast-$TAG.tgz" - log "installing cast $TAG (latest release of $REPO)" - log "downloading $URL" - fetch_ok "$URL" "$TMPDIR/cast.tar.gz" \ - || die "failed to download the $TAG release asset: $URL" - PREBUILT=1 - SRCDESC="$REPO@$TAG (release asset)" -else - # A pinned tag that has a release asset gets the asset — same bits as the - # default channel, just older. Everything else (a branch, a tag from - # before releases carried assets) falls back to build-from-source. - ASSET_URL="https://github.com/$REPO/releases/download/$REF/cast-$REF.tgz" - if fetch_ok "$ASSET_URL" "$TMPDIR/cast.tar.gz"; then - log "installing cast $REF (pinned release asset)" - PREBUILT=1 - SRCDESC="$REPO@$REF (release asset)" +if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then + SRC="$CAST_INSTALL_SOURCE" + INSTALLED_FROM="local:$SRC" + if [ -d "$SRC" ]; then + log "copying local tree $SRC" + mkdir -p "$TMPDIR/tree" + # tar, not cp -a: --exclude=.git so a working checkout never carries its + # VCS state into the install tree, --exclude=./node_modules (top level + # only — nested ones are npm's own business) because npm ci below builds + # dependencies fresh from the lockfile anyway. + tar -C "$SRC" --exclude=.git --exclude=./node_modules -cf - . | tar -xf - -C "$TMPDIR/tree" + EXTRACTED="$TMPDIR/tree" + elif [ -f "$SRC" ]; then + log "extracting local tarball $SRC" + tar -xzf "$SRC" -C "$TMPDIR" || die "failed to extract $SRC" + EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" else - log "no release asset for '$REF' — building from source" - for kind in tags heads; do - URL="https://github.com/$REPO/archive/refs/$kind/$REF.tar.gz" - if fetch_ok "$URL" "$TMPDIR/cast.tar.gz"; then - SRCDESC="$REPO@$REF (source, refs/$kind)" - break - fi - SRCDESC="" - done - [ -n "$SRCDESC" ] || die "no tag or branch named '$REF' in $REPO (tried the release asset, refs/tags and refs/heads)" - log "downloaded from refs — $SRCDESC" + die "CAST_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC" fi -fi - -log "extracting archive" -tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \ - || die "failed to extract archive" - -# Both shapes carry exactly ONE top-level directory (GitHub names its -# archives -; release.yml stages cast-). Deriving that -# name is guesswork — it broke for real at box's repo rename — so take the -# single directory, whatever it is called, and judge the tree by its content. -EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" -[ -n "$EXTRACTED" ] || die "could not find the cast tree in the archive" -[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $SRCDESC correct?" - -# --- build (source channel only) --------------------------------------------- -if [ "$PREBUILT" -eq 1 ]; then - # Verify the shape before touching $DEST: a prebuilt tree that cannot run - # is better refused here than discovered at `cast apply` time. - [ -f "$EXTRACTED/dist/cli.js" ] && [ -d "$EXTRACTED/node_modules" ] \ - || die "the release asset is not a runnable tree (missing dist/ or node_modules/) — broken release? CAST_REF=main installs from source" else - command -v npm >/dev/null 2>&1 || die "npm is required to build from source (CAST_REF=$REF) but was not found." + INSTALLED_FROM="$REPO@$REF" + URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" + log "installing cast ($REPO@$REF)" + log "downloading $URL" + curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz" \ + || die "failed to download $URL" + + log "extracting archive" + tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \ + || die "failed to extract archive" + + # GitHub names the archive's top dir - — deriving that name is + # guesswork (it broke for real at box's repo rename). The tarball has + # exactly ONE top-level directory: take the directory, whatever it is + # called, and let the bin/cast check below judge whether it is the right + # tree. + EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" +fi +[ -n "${EXTRACTED:-}" ] || die "could not find the source tree in $SRCDESC" +[ -f "$EXTRACTED/bin/cast" ] || die "source does not contain bin/cast — is $SRCDESC correct?" + +# The tree's own package.json names the directory it lands in — the version +# IS the identity of what is being installed, and 'cast versions' lists +# these names. +new_ver="$(pkg_version "$EXTRACTED")" +[ -n "$new_ver" ] || die "source has no package.json version — cannot install it as a version" +valid_version "$new_ver" || die "the source's package.json version is not a sane directory name: '$new_ver'" + +set_exec() { # $1 = a cast tree: the executable bits install.sh owns + chmod +x "$1/bin/cast" + if [ -d "$1/scripts" ]; then + find "$1/scripts" -name '*.sh' -exec chmod +x {} + + fi +} + +# Build the tree IN PLACE, in the temp workspace — deps + tsc, then drop the +# dev deps. Landing in versions/ happens after, by rename: a half-built tree +# never sits where the version chain can resolve to it. +build_tree() { # $1 = the tree to build log "building (npm ci && npm run build)" - ( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \ + ( cd "$1" && npm ci --silent && npm run build --silent ) \ || die "build failed" - ( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies" + ( cd "$1" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies" +} + +# --- install into $DEST/versions/ ----------------------------------- +VDIR="$DEST/versions/$new_ver" +newly_installed=0 +if [ -d "$VDIR" ]; then + if [ -n "${CAST_REINSTALL:-}" ]; then + # Replace THIS version's tree, as atomically as two renames allow — never + # a partial overlay of new files onto an old tree. + log "CAST_REINSTALL=1 — replacing the installed $new_ver tree" + build_tree "$EXTRACTED" + stage="$VDIR.new.$$"; old="$VDIR.old.$$" + rm -rf "$stage" "$old" + set_exec "$EXTRACTED" + mv "$EXTRACTED" "$stage" + # Swap by renames, delete LAST: rm-then-move leaves a hole the whole + # length of the delete where current -> this version resolves to nothing. + mv "$VDIR" "$old" + mv "$stage" "$VDIR" + rm -rf "$old" + printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM" + log "reinstalled $new_ver" + else + cur_from="$(cat "$VDIR/INSTALLED_FROM" 2>/dev/null || echo '')" + log "cast $new_ver is already installed ($cur_from) — nothing to do, and nothing was built." + log "(CAST_REINSTALL=1 replaces this version's tree; 'cast versions' lists what is installed.)" + fi +else + build_tree "$EXTRACTED" + log "installing $new_ver into $VDIR" + mkdir -p "$DEST/versions" + set_exec "$EXTRACTED" + mv "$EXTRACTED" "$VDIR" + newly_installed=1 + # Record WHAT was installed, so a caller can assert it got what it asked + # for — an installer invoked with stale env vars silently falls back to the + # defaults, and INSTALLED_FROM is how that lie gets caught. + printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM" fi -# --- atomically replace $DEST -------------------------------------------------- -log "installing into $DEST" -rm -rf "$DEST" -mkdir -p "$(dirname "$DEST")" -mv "$EXTRACTED" "$DEST" - -chmod +x "$DEST/bin/cast" -# Source installs carry scripts/; the release asset deliberately does not. -if [ -d "$DEST/scripts" ]; then - find "$DEST/scripts" -name '*.sh' -exec chmod +x {} + +# --- which version is the default? ------------------------------------------- +# 'current' is the tracked default; flipping it is the ONLY step that changes +# what an operator's `cast` runs. A fresh host (or a dangling current) is +# claimed outright; an upgrade flips, because a re-run that silently left you +# on the old version would make "re-run any time to upgrade" a lie. Judged +# from versions/ itself (readlink -f), never from what a wedged current +# claims. +cur="$(readlink -f "$DEST/current" 2>/dev/null || true)" +want="$(readlink -f "$VDIR")" +if [ -z "$cur" ] || [ ! -d "$cur" ]; then + flip_current "$new_ver" + log "default version: $new_ver" +elif [ "$cur" = "$want" ]; then + : # already the default — nothing to flip +elif [ "$newly_installed" -eq 0 ]; then + # A converge/no-op (or CAST_REINSTALL) of a version that is NOT the default + # never moves the default — a re-run must change nothing; switching is + # 'cast use', a deliberate act. + log "the default stays $(basename "$cur") — 'cast use $new_ver' switches." +else + old_ver="$(basename "$cur")" + flip_current "$new_ver" + log "default version switched: $old_ver -> $new_ver ('cast use $old_ver' switches back)" fi -# --- put cast on PATH ---------------------------------------------------------- +# --- put cast on PATH -------------------------------------------------------- +# Through the current chain, and converging — that includes HEALING: a stale +# or dangling $BINDIR/cast (say, its tree half-removed by hand) must never +# block or wedge an install — it gets repointed at the current chain, +# whatever it said before. mkdir -p "$BINDIR" -ln -sf "$DEST/bin/cast" "$BINDIR/cast" -log "linked $BINDIR/cast -> $DEST/bin/cast" +ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast" +log "linked $BINDIR/cast -> $DEST/current/bin/cast" -# --- wire $BINDIR onto PATH, durably ------------------------------------------- +# --- wire $BINDIR onto PATH, durably ----------------------------------------- # `curl | bash` runs in a subshell, so exporting PATH here would die with this # process. The only durable place is the user's shell profile — so append there, # once, marked. Opt out with CAST_NO_MODIFY_PATH=1 and wire it yourself. @@ -208,4 +323,4 @@ else log "this shell does not have it yet — open a new one, or: source $PROFILE" fi -log "done ($SRCDESC) — try: cast --help" +log "done ($SRCDESC, version $new_ver) — try: cast --help" diff --git a/scripts/changelog-section.sh b/scripts/changelog-section.sh deleted file mode 100644 index 21d1e97..0000000 --- a/scripts/changelog-section.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# changelog-section.sh [changelog-file] -# -# Print the body of CHANGELOG.md's `## ` section — everything -# between that heading and the next `## ` heading (or EOF), with the -# leading/trailing blank lines trimmed. release.yml uses this as the -# GitHub release body, so the notes are the curated prose we actually -# wrote, never an auto-generated PR list (cast#96 / box#83). -# -# Fails loudly when the section is missing or empty: a release with no -# written history is a release that skipped the changelog discipline, -# and the tag push is exactly the moment to catch that — before a -# release object exists. - -version="${1:-}" -file="${2:-CHANGELOG.md}" - -[ -n "$version" ] || { echo "usage: changelog-section.sh [changelog-file]" >&2; exit 2; } -[ -f "$file" ] || { echo "changelog-section: no such file: $file" >&2; exit 1; } - -# The heading is `## ` optionally followed by more (a date stamp: -# `## 0.1.0 — 2026-07-18`). Match on the version as the second word so the -# stamp's format never becomes load-bearing here. -section="$(awk -v ver="$version" ' - /^## / { if (found) exit; if ($2 == ver) { found = 1; next } } - found { print } - END { exit found ? 0 : 3 } -' "$file")" || { - echo "changelog-section: no \"## $version\" section in $file" >&2 - exit 1 -} - -# Trim leading blank lines; command substitution already ate the trailing ones. -section="$(printf '%s\n' "$section" | sed '/./,$!d')" - -[ -n "$section" ] || { - echo "changelog-section: the \"## $version\" section in $file is empty" >&2 - exit 1 -} - -printf '%s\n' "$section" diff --git a/src/cli.ts b/src/cli.ts index 5c7a122..dcff19d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -126,6 +126,9 @@ const USAGE = `usage: cast apply / --env [--path ] [-- cast server add --ip --key --env [--user root] [--port 22] cast smoke / --env [--project ] [--environment ] cast team [--env ] + cast versions # list installed versions + cast use # switch the default version + cast uninstall [|--all] [--force] # remove versions (CAST_YES=1 skips the prompt) cast --version # version + install root --state the state checkout holding environments.yaml, secrets/ and @@ -1302,13 +1305,13 @@ async function runProject( } // The version lives in package.json — the tree's single source of truth -// (cast#96, deliberately no separate VERSION file). dist/cli.js sits one -// level below it in a source checkout and in an installed release asset -// alike, so resolving from import.meta.url answers for both without -// caring how this tree got here. The install root rides along in the -// output (the family's shape — rig prints its ROOT too) because "which -// cast is this" and "where does it run from" are the same question when -// several trees exist on one machine. +// (deliberately no separate VERSION file; the ecosystem already has one). +// dist/cli.js sits one level below it in a source checkout and in an +// installed versions/ tree alike, so resolving from import.meta.url +// answers for both without caring how this tree got here. The install +// root rides along in the output (the family's shape — rig prints its +// ROOT too) because "which cast is this" and "where does it run from" +// are the same question once versions install side by side. function formatVersion(): string { const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url)); const version: unknown = JSON.parse(readFileSync(pkgPath, "utf8")).version; diff --git a/test/install-sh.test.ts b/test/install-sh.test.ts index a87e0f0..c6fb83e 100644 --- a/test/install-sh.test.ts +++ b/test/install-sh.test.ts @@ -1,11 +1,13 @@ import { execFile } from "node:child_process"; import { chmodSync, + copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, + realpathSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -15,82 +17,48 @@ import { describe, expect, it } from "vitest"; const run = promisify(execFile); -// These tests run the REAL install.sh — not a reimplementation of its -// logic — with curl and npm replaced by PATH shims, so every channel -// (latest asset, pinned asset, build-from-source) is exercised offline. -// The shims record what was requested; the assertions read the wire log -// and the resulting tree, the same way rig's cli.sh proves its installer. +// These tests drive the REAL install.sh — not a reimplementation of its +// logic — via CAST_INSTALL_SOURCE (the offline channel the installer carries +// for exactly this, rig's RIG_INSTALL_SOURCE precedent) and an npm PATH shim +// whose `run build` drops a tiny runnable cli.js. Every assertion reads the +// resulting tree, the symlink chain, or the shim's invocation log. The real +// npm-ci-and-tsc build path runs end to end in CI's install job instead — +// here it would cost minutes per test for no extra layout coverage. const INSTALL_SH = join(process.cwd(), "install.sh"); +const REAL_BIN_CAST = join(process.cwd(), "bin", "cast"); -// curl shim: answers from CAST_TEST_* env vars, appends every URL to -// CAST_TEST_CURL_LOG. Exit 22 is curl's own "-f saw an HTTP error". -const CURL_SHIM = `#!/usr/bin/env bash -set -euo pipefail -out=""; url="" -args=("$@") -i=0 -while [ $i -lt \${#args[@]} ]; do - a="\${args[$i]}" - case "$a" in - -o) i=$((i+1)); out="\${args[$i]}" ;; - -w) i=$((i+1)) ;; - http*) url="$a" ;; - esac - i=$((i+1)) -done -printf '%s\\n' "$url" >> "$CAST_TEST_CURL_LOG" -case "$url" in - */releases/latest) - [ -n "\${CAST_TEST_LATEST:-}" ] || exit 22 - printf '%s' "$CAST_TEST_LATEST" - ;; - */releases/download/*) - if [ -n "\${CAST_TEST_ASSET_FILE:-}" ] && [ "$url" = "\${CAST_TEST_ASSET_URL:-}" ]; then - cp "$CAST_TEST_ASSET_FILE" "$out" - else - exit 22 - fi - ;; - */archive/refs/tags/*) - if [ -n "\${CAST_TEST_TAGS_TARBALL:-}" ]; then cp "$CAST_TEST_TAGS_TARBALL" "$out"; else exit 22; fi - ;; - */archive/refs/heads/*) - if [ -n "\${CAST_TEST_HEADS_TARBALL:-}" ]; then cp "$CAST_TEST_HEADS_TARBALL" "$out"; else exit 22; fi - ;; - *) exit 22 ;; -esac +// What `npm run build` produces in a fixture tree: enough of a cli.js that +// the launcher chain — BINDIR/cast -> current -> versions//bin/cast -> +// node dist/cli.js — can be asserted end to end, --version included (cmd_use +// verifies the flip through it). +const FAKE_CLI = `const path = require("path"); +const root = path.resolve(__dirname, ".."); +const pkg = require(path.join(root, "package.json")); +const [cmd] = process.argv.slice(2); +if (cmd === "--version" || cmd === "-V") { + console.log("cast " + pkg.version + " (" + root + ")"); + process.exit(0); +} +console.log("fake-cast " + pkg.version); `; -// npm shim: logs every invocation; 'run build' produces dist/cli.js so a -// source install ends up runnable. The prebuilt channels get a POISONED -// npm instead — if the installer touches npm at all on an asset install, -// the install fails and so does the test. const NPM_SHIM = `#!/usr/bin/env bash printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG" case "\${1:-}" in - ci) exit 0 ;; - run) mkdir -p dist && printf '// built by npm shim\\n' > dist/cli.js ;; - prune) exit 0 ;; + run) mkdir -p dist && cp "$CAST_TEST_FAKECLI" dist/cli.js ;; esac `; -const POISONED_NPM = `#!/usr/bin/env bash -printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG" -exit 97 -`; - type Sandbox = { root: string; - stubs: string; dest: string; bindir: string; - curlLog: string; npmLog: string; env: Record; }; -function sandbox(opts: { poisonNpm: boolean }): Sandbox { +function sandbox(): Sandbox { const root = mkdtempSync(join(tmpdir(), "cast-install-")); const stubs = join(root, "stubs"); const home = join(root, "home"); @@ -98,20 +66,16 @@ function sandbox(opts: { poisonNpm: boolean }): Sandbox { const bindir = join(root, "bin"); mkdirSync(stubs); mkdirSync(home); - const curlLog = join(root, "curl.log"); const npmLog = join(root, "npm.log"); - writeFileSync(curlLog, ""); + const fakeCli = join(root, "fake-cli.js"); writeFileSync(npmLog, ""); - writeFileSync(join(stubs, "curl"), CURL_SHIM); - writeFileSync(join(stubs, "npm"), opts.poisonNpm ? POISONED_NPM : NPM_SHIM); - chmodSync(join(stubs, "curl"), 0o755); + writeFileSync(fakeCli, FAKE_CLI); + writeFileSync(join(stubs, "npm"), NPM_SHIM); chmodSync(join(stubs, "npm"), 0o755); return { root, - stubs, dest, bindir, - curlLog, npmLog, env: { PATH: `${stubs}:${process.env.PATH}`, @@ -120,180 +84,257 @@ function sandbox(opts: { poisonNpm: boolean }): Sandbox { CAST_HOME: dest, CAST_BIN: bindir, CAST_NO_MODIFY_PATH: "1", - CAST_TEST_CURL_LOG: curlLog, CAST_TEST_NPM_LOG: npmLog, + CAST_TEST_FAKECLI: fakeCli, }, }; } -// Build a .tgz fixture with a single top-level dir, like both real shapes. -async function makeTarball( - root: string, - topdir: string, - files: Record, -): Promise { - const stage = join(root, "fixtures", topdir); - for (const [rel, content] of Object.entries(files)) { - const abs = join(stage, rel); - mkdirSync(join(abs, ".."), { recursive: true }); - writeFileSync(abs, content); - } - const tgz = join(root, "fixtures", `${topdir}.tgz`); - await run("tar", ["-C", join(root, "fixtures"), "-czf", tgz, topdir]); - return tgz; +// A source tree the installer can build: the REPO'S OWN bin/cast (so the +// launcher and its layout verbs are the code under review), a package.json +// carrying the version, and a src/ marker. +function sourceTree(sb: Sandbox, version: string): string { + const src = join(sb.root, `src-${version.replace(/[^A-Za-z0-9.]/g, "_")}`); + mkdirSync(join(src, "bin"), { recursive: true }); + mkdirSync(join(src, "src"), { recursive: true }); + copyFileSync(REAL_BIN_CAST, join(src, "bin", "cast")); + writeFileSync( + join(src, "package.json"), + `${JSON.stringify({ name: "cast", version })}\n`, + ); + writeFileSync(join(src, "src", "cli.ts"), "// fixture\n"); + return src; } -const PREBUILT_FILES = { - "bin/cast": "#!/usr/bin/env bash\necho fake-cast\n", - "dist/cli.js": "// prebuilt in CI\n", - "node_modules/yaml/package.json": "{}", - "package.json": '{ "name": "cast", "version": "0.2.0" }\n', -}; +async function install(sb: Sandbox, extraEnv: Record) { + return run("bash", [INSTALL_SH], { env: { ...sb.env, ...extraEnv } }); +} -const SOURCE_FILES = { - "bin/cast": "#!/usr/bin/env bash\necho fake-cast\n", - "package.json": '{ "name": "cast", "version": "0.3.0-dev" }\n', - "src/cli.ts": "// source only — dist/ does not exist until npm run build\n", -}; +function currentTarget(sb: Sandbox): string { + return realpathSync(join(sb.dest, "current")); +} -async function runInstaller(sb: Sandbox, extraEnv: Record) { - return run("bash", [INSTALL_SH], { - env: { ...sb.env, ...extraEnv }, +describe("install.sh — the versioned layout", () => { + it("lands versions/, points current and the PATH link through it, and the chain answers", async () => { + const sb = sandbox(); + const src = sourceTree(sb, "0.5.0"); + const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src }); + + expect(stdout).toContain("done (local source"); + // The layout: one tree per version, named by package.json's version. + expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true); + expect( + readFileSync(join(sb.dest, "versions/0.5.0/dist/cli.js"), "utf8"), + ).toContain("pkg.version"); + expect( + readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"), + ).toBe(`local:${src}\n`); + // The chain: current -> versions/0.5.0, BINDIR/cast -> current/bin/cast. + expect(currentTarget(sb)).toBe( + realpathSync(join(sb.dest, "versions/0.5.0")), + ); + expect(readlinkSync(join(sb.bindir, "cast"))).toBe( + join(sb.dest, "current/bin/cast"), + ); + // And it ANSWERS, through the whole chain. + const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], { + env: sb.env, + }); + expect(v.trim()).toBe( + `cast 0.5.0 (${realpathSync(join(sb.dest, "versions/0.5.0"))})`, + ); + // The build ran: ci, build, prune — in that order. + expect(readFileSync(sb.npmLog, "utf8")).toBe( + "npm ci --silent\nnpm run build --silent\nnpm prune --omit=dev --silent\n", + ); }); -} -describe("install.sh — default channel (latest release asset)", () => { - it("resolves the latest tag via the redirect and installs the prebuilt tree without npm", async () => { - const sb = sandbox({ poisonNpm: true }); - const asset = await makeTarball(sb.root, "cast-0.2.0", PREBUILT_FILES); - const { stdout } = await runInstaller(sb, { - CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases/tag/0.2.0", - CAST_TEST_ASSET_URL: - "https://github.com/heavy-duty/cast/releases/download/0.2.0/cast-0.2.0.tgz", - CAST_TEST_ASSET_FILE: asset, + it("re-running the same version is a converging no-op — nothing rebuilt, nothing touched", async () => { + const sb = sandbox(); + const src = sourceTree(sb, "0.5.0"); + await install(sb, { CAST_INSTALL_SOURCE: src }); + const npmCallsAfterFirst = readFileSync(sb.npmLog, "utf8"); + writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "survives\n"); + + const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src }); + expect(stdout).toContain("cast 0.5.0 is already installed"); + expect(stdout).toContain("nothing was built"); + // No second build, and the installed tree was not replaced. + expect(readFileSync(sb.npmLog, "utf8")).toBe(npmCallsAfterFirst); + expect(readFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "utf8")).toBe( + "survives\n", + ); + }); + + it("CAST_REINSTALL=1 replaces that version's tree — no partial overlays", async () => { + const sb = sandbox(); + const src = sourceTree(sb, "0.5.0"); + await install(sb, { CAST_INSTALL_SOURCE: src }); + writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "stale\n"); + + const { stdout } = await install(sb, { + CAST_INSTALL_SOURCE: src, + CAST_REINSTALL: "1", + }); + expect(stdout).toContain("replacing the installed 0.5.0 tree"); + // A replaced tree, not an overlay: the stale file is gone. + expect(existsSync(join(sb.dest, "versions/0.5.0/SENTINEL"))).toBe(false); + expect(existsSync(join(sb.dest, "versions/0.5.0/dist/cli.js"))).toBe(true); + }); + + it("a NEW version installs beside the old one and becomes the default", async () => { + const sb = sandbox(); + await install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") }); + const { stdout } = await install(sb, { + CAST_INSTALL_SOURCE: sourceTree(sb, "0.6.0"), }); expect(stdout).toContain( - "installing cast 0.2.0 (latest release of heavy-duty/cast)", + "default version switched: 0.5.0 -> 0.6.0 ('cast use 0.5.0' switches back)", ); - // The tree landed, prebuilt: dist/ came from the tarball, not a build. - expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain( - "prebuilt in CI", + expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true); + expect(currentTarget(sb)).toBe( + realpathSync(join(sb.dest, "versions/0.6.0")), ); - expect(existsSync(join(sb.dest, "node_modules/yaml/package.json"))).toBe( - true, - ); - expect(readlinkSync(join(sb.bindir, "cast"))).toBe( - join(sb.dest, "bin/cast"), - ); - // npm is poisoned — a single invocation would have failed the install. - expect(readFileSync(sb.npmLog, "utf8")).toBe(""); + const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], { + env: sb.env, + }); + expect(v).toContain("cast 0.6.0"); }); - it("dies loudly when there is no release to resolve, pointing at CAST_REF=main", async () => { - const sb = sandbox({ poisonNpm: true }); - await expect(runInstaller(sb, {})).rejects.toMatchObject({ - stderr: expect.stringContaining("CAST_REF=main installs from source"), + it("re-running an installed NON-default version never moves the default", async () => { + const sb = sandbox(); + const old = sourceTree(sb, "0.5.0"); + await install(sb, { CAST_INSTALL_SOURCE: old }); + await install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.6.0") }); + + const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: old }); + expect(stdout).toContain( + "the default stays 0.6.0 — 'cast use 0.5.0' switches.", + ); + expect(currentTarget(sb)).toBe( + realpathSync(join(sb.dest, "versions/0.6.0")), + ); + }); + + it("migrates a pre-versioning flat install in place, preserving the tree", async () => { + const sb = sandbox(); + // The OLD layout: the tree sits flat at $DEST, bin/cast directly under it. + mkdirSync(join(sb.dest, "bin"), { recursive: true }); + mkdirSync(join(sb.dest, "dist"), { recursive: true }); + copyFileSync(REAL_BIN_CAST, join(sb.dest, "bin/cast")); + writeFileSync( + join(sb.dest, "package.json"), + `${JSON.stringify({ name: "cast", version: "0.4.0" })}\n`, + ); + writeFileSync( + join(sb.dest, "SENTINEL"), + "the operator's tree, bit for bit\n", + ); + + const { stdout } = await install(sb, { + CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0"), + }); + expect(stdout).toContain("found a pre-versioning flat install at"); + expect(stdout).toContain("migrating it into the versioned layout"); + // The flat tree moved — preserved, not rebuilt — and the new version + // installed beside it and took the default. + expect(readFileSync(join(sb.dest, "versions/0.4.0/SENTINEL"), "utf8")).toBe( + "the operator's tree, bit for bit\n", + ); + expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true); + expect(currentTarget(sb)).toBe( + realpathSync(join(sb.dest, "versions/0.5.0")), + ); + }); + + it("refuses a source whose package.json version is not a sane directory name", async () => { + const sb = sandbox(); + const src = sourceTree(sb, "../evil"); + await expect( + install(sb, { CAST_INSTALL_SOURCE: src }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining("not a sane directory name: '../evil'"), }); expect(existsSync(sb.dest)).toBe(false); }); - it("dies when the redirect lands somewhere that is not a tag page", async () => { - const sb = sandbox({ poisonNpm: true }); - await expect( - runInstaller(sb, { - CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases", - }), - ).rejects.toMatchObject({ - stderr: expect.stringContaining("could not resolve the latest release"), - }); - }); -}); - -describe("install.sh — pinned channel (CAST_REF=X.Y.Z)", () => { - it("uses that tag's release asset and never falls through to a source build", async () => { - const sb = sandbox({ poisonNpm: true }); - const asset = await makeTarball(sb.root, "cast-0.1.0", { - ...PREBUILT_FILES, - "package.json": '{ "name": "cast", "version": "0.1.0" }\n', - }); - const { stdout } = await runInstaller(sb, { - CAST_REF: "0.1.0", - CAST_TEST_ASSET_URL: - "https://github.com/heavy-duty/cast/releases/download/0.1.0/cast-0.1.0.tgz", - CAST_TEST_ASSET_FILE: asset, - }); - - expect(stdout).toContain("installing cast 0.1.0 (pinned release asset)"); - const urls = readFileSync(sb.curlLog, "utf8"); - // No latest-resolution, no archive fallbacks — the pin answered. - expect(urls).not.toContain("/releases/latest"); - expect(urls).not.toContain("/archive/refs/"); - expect(readFileSync(sb.npmLog, "utf8")).toBe(""); - }); - - it("refuses a prebuilt asset that is not a runnable tree, leaving the old install alone", async () => { - const sb = sandbox({ poisonNpm: true }); - // An asset missing dist/ — a broken release. - const asset = await makeTarball(sb.root, "cast-0.4.0", { - "bin/cast": "#!/usr/bin/env bash\n", - "package.json": "{}", - }); - // A previous install that must survive the refused upgrade. + it("refuses to migrate a flat install whose version would escape versions/", async () => { + const sb = sandbox(); mkdirSync(join(sb.dest, "bin"), { recursive: true }); - writeFileSync(join(sb.dest, "bin/cast"), "#!/usr/bin/env bash\necho old\n"); + copyFileSync(REAL_BIN_CAST, join(sb.dest, "bin/cast")); + writeFileSync( + join(sb.dest, "package.json"), + `${JSON.stringify({ name: "cast", version: "../evil" })}\n`, + ); await expect( - runInstaller(sb, { - CAST_REF: "0.4.0", - CAST_TEST_ASSET_URL: - "https://github.com/heavy-duty/cast/releases/download/0.4.0/cast-0.4.0.tgz", - CAST_TEST_ASSET_FILE: asset, - }), + install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") }), ).rejects.toMatchObject({ - stderr: expect.stringContaining("not a runnable tree"), + stderr: expect.stringContaining("not a sane directory name"), }); - // The shape check fired BEFORE rm -rf $DEST — the old tree survives. - expect(readFileSync(join(sb.dest, "bin/cast"), "utf8")).toContain( - "echo old", - ); + // Refused BEFORE anything moved: the flat tree is untouched. + expect(existsSync(join(sb.dest, "bin/cast"))).toBe(true); }); -}); -describe("install.sh — dev channel (CAST_REF=)", () => { - it("falls back asset → refs/tags → refs/heads and builds from source", async () => { - const sb = sandbox({ poisonNpm: false }); - const src = await makeTarball(sb.root, "cast-main", SOURCE_FILES); - const { stdout } = await runInstaller(sb, { - CAST_REF: "main", - CAST_TEST_HEADS_TARBALL: src, - }); - - expect(stdout).toContain( - "no release asset for 'main' — building from source", - ); - const urls = readFileSync(sb.curlLog, "utf8").trim().split("\n"); - expect(urls).toEqual([ - "https://github.com/heavy-duty/cast/releases/download/main/cast-main.tgz", - "https://github.com/heavy-duty/cast/archive/refs/tags/main.tar.gz", - "https://github.com/heavy-duty/cast/archive/refs/heads/main.tar.gz", + it("downloads refs/heads/ when no local source is given", async () => { + const sb = sandbox(); + // A curl shim standing in for GitHub: serves the fixture tarball and + // logs the URL it was asked for. + sourceTree(sb, "0.5.0"); + await run("tar", [ + "-C", + sb.root, + "-czf", + join(sb.root, "src.tgz"), + "src-0.5.0", ]); - // The build ran here — ci, build, prune — and produced the dist tree. - const npm = readFileSync(sb.npmLog, "utf8"); - expect(npm).toContain("npm ci"); - expect(npm).toContain("npm run build"); - expect(npm).toContain("npm prune --omit=dev"); - expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain( - "built by npm shim", + const stubs = join(sb.root, "stubs"); + const curlLog = join(sb.root, "curl.log"); + writeFileSync(curlLog, ""); + writeFileSync( + join(stubs, "curl"), + `#!/usr/bin/env bash +out=""; url="" +for a in "$@"; do + case "$prev" in -o) out="$a" ;; esac + case "$a" in http*) url="$a" ;; esac + prev="$a" +done +printf '%s\\n' "$url" >> "${curlLog}" +cp "${join(sb.root, "src.tgz")}" "$out" +`, ); - }); + chmodSync(join(stubs, "curl"), 0o755); - it("dies when the ref exists nowhere (asset, tags, heads all miss)", async () => { - const sb = sandbox({ poisonNpm: true }); - await expect( - runInstaller(sb, { CAST_REF: "no-such-ref" }), - ).rejects.toMatchObject({ - stderr: expect.stringContaining("no tag or branch named 'no-such-ref'"), - }); + const { stdout } = await install(sb, { CAST_REF: "dev-branch" }); + expect(readFileSync(curlLog, "utf8").trim()).toBe( + "https://github.com/heavy-duty/cast/archive/refs/heads/dev-branch.tar.gz", + ); + expect(stdout).toContain("installing cast (heavy-duty/cast@dev-branch)"); + expect( + readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"), + ).toBe("heavy-duty/cast@dev-branch\n"); }); }); + +describe("the shared gates cannot drift", () => { + // install.sh and bin/cast each carry valid_version and pkg_version — the + // same trust boundary enforced in two places. A byte-identical diff is the + // rig-precedent guard that an edit to one cannot quietly miss the other. + function extractFunction(file: string, name: string): string { + const text = readFileSync(file, "utf8"); + const start = text.indexOf(`${name}() {`); + expect(start, `${name}() not found in ${file}`).toBeGreaterThan(-1); + const end = text.indexOf("\n}", start); + return text.slice(start, end + 2); + } + + for (const fn of ["valid_version", "pkg_version"]) { + it(`${fn}() is byte-identical between install.sh and bin/cast`, () => { + expect(extractFunction(INSTALL_SH, fn)).toBe( + extractFunction(REAL_BIN_CAST, fn), + ); + }); + } +}); diff --git a/test/layout-cli.test.ts b/test/layout-cli.test.ts new file mode 100644 index 0000000..35b84cc --- /dev/null +++ b/test/layout-cli.test.ts @@ -0,0 +1,229 @@ +import { execFile, spawn } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const run = promisify(execFile); + +// The layout verbs — cast versions / use / uninstall — exercised on REAL +// installed sandboxes: two versions land via the real install.sh (npm +// shimmed, as in install-sh.test.ts), then every verb runs through the PATH +// chain the way an operator's would. Assertions read symlinks and trees, +// plus each refusal's exit code and message. + +const INSTALL_SH = join(process.cwd(), "install.sh"); +const REAL_BIN_CAST = join(process.cwd(), "bin", "cast"); + +const FAKE_CLI = `const path = require("path"); +const root = path.resolve(__dirname, ".."); +const pkg = require(path.join(root, "package.json")); +const [cmd] = process.argv.slice(2); +if (cmd === "--version" || cmd === "-V") { + console.log("cast " + pkg.version + " (" + root + ")"); + process.exit(0); +} +console.log("fake-cast " + pkg.version); +`; + +const NPM_SHIM = `#!/usr/bin/env bash +case "\${1:-}" in + run) mkdir -p dist && cp "$CAST_TEST_FAKECLI" dist/cli.js ;; +esac +`; + +type Sandbox = { + root: string; + dest: string; + bindir: string; + env: Record; +}; + +async function installedSandbox(versions: string[]): Promise { + const root = mkdtempSync(join(tmpdir(), "cast-layout-")); + const stubs = join(root, "stubs"); + const home = join(root, "home"); + const dest = join(root, "cast-home"); + const bindir = join(root, "bin"); + mkdirSync(stubs); + mkdirSync(home); + const fakeCli = join(root, "fake-cli.js"); + writeFileSync(fakeCli, FAKE_CLI); + writeFileSync(join(stubs, "npm"), NPM_SHIM); + chmodSync(join(stubs, "npm"), 0o755); + const env = { + PATH: `${stubs}:${process.env.PATH}`, + HOME: home, + SHELL: "/bin/bash", + CAST_HOME: dest, + CAST_BIN: bindir, + CAST_NO_MODIFY_PATH: "1", + CAST_TEST_FAKECLI: fakeCli, + }; + for (const version of versions) { + const src = join(root, `src-${version}`); + mkdirSync(join(src, "bin"), { recursive: true }); + copyFileSync(REAL_BIN_CAST, join(src, "bin", "cast")); + writeFileSync( + join(src, "package.json"), + `${JSON.stringify({ name: "cast", version })}\n`, + ); + await run("bash", [INSTALL_SH], { + env: { ...env, CAST_INSTALL_SOURCE: src }, + }); + } + return { root, dest, bindir, env }; +} + +// Through the chain, like an operator: $BINDIR/cast -> current -> version. +async function cast( + sb: Sandbox, + args: string[], + extraEnv: Record = {}, +) { + return run(join(sb.bindir, "cast"), args, { + env: { ...sb.env, ...extraEnv }, + }); +} + +function currentVersion(sb: Sandbox): string { + return realpathSync(join(sb.dest, "current")).split("/").pop() ?? ""; +} + +describe("cast versions", () => { + it("lists installed versions, marking (current) and (running)", async () => { + const sb = await installedSandbox(["0.5.0", "0.6.0"]); + const { stdout } = await cast(sb, ["versions"]); + // 0.6.0 installed last, so it is the default — and, invoked through the + // chain, also the tree answering this very command. + expect(stdout).toContain(`VERSIONS (${sb.dest}`); + expect(stdout).toMatch(/^ {2}0\.5\.0$/m); + expect(stdout).toMatch(/^ {2}0\.6\.0 \(current\) \(running\)$/m); + expect(stdout).toContain("switch the default: cast use "); + }); + + it("refuses to run from a working tree — this repo checkout is not an install", async () => { + await expect(run(REAL_BIN_CAST, ["versions"])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("not a versioned install"), + }); + }); +}); + +describe("cast use", () => { + it("switches the default atomically and asserts the chain answers the new version", async () => { + const sb = await installedSandbox(["0.5.0", "0.6.0"]); + expect(currentVersion(sb)).toBe("0.6.0"); + + const { stdout } = await cast(sb, ["use", "0.5.0"]); + expect(stdout).toContain("switched to 0.5.0 (current -> versions/0.5.0)"); + expect(currentVersion(sb)).toBe("0.5.0"); + const { stdout: v } = await cast(sb, ["--version"]); + expect(v).toContain("cast 0.5.0"); + }); + + it("refuses a version that is not installed, an insane name, and a missing argument", async () => { + const sb = await installedSandbox(["0.5.0"]); + await expect(cast(sb, ["use", "9.9.9"])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("no such version: 9.9.9"), + }); + // The gate fires BEFORE any path is built from the name. + await expect(cast(sb, ["use", "../evil"])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("not a sane version name: '../evil'"), + }); + await expect(cast(sb, ["use"])).rejects.toMatchObject({ + code: 2, + stderr: expect.stringContaining("use needs a version"), + }); + }); +}); + +describe("cast uninstall", () => { + it("removes one non-current version and proves the absence", async () => { + const sb = await installedSandbox(["0.5.0", "0.6.0"]); + const { stdout } = await cast(sb, ["uninstall", "0.5.0"], { + CAST_YES: "1", + }); + expect(stdout).toContain("removed version 0.5.0 (the default stays 0.6.0)"); + expect(existsSync(join(sb.dest, "versions/0.5.0"))).toBe(false); + expect(currentVersion(sb)).toBe("0.6.0"); + }); + + it("refuses to remove the CURRENT version", async () => { + const sb = await installedSandbox(["0.5.0", "0.6.0"]); + await expect( + cast(sb, ["uninstall", "0.6.0"], { CAST_YES: "1" }), + ).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("0.6.0 is the CURRENT version"), + }); + expect(existsSync(join(sb.dest, "versions/0.6.0"))).toBe(true); + }); + + it("refuses without consent when there is no terminal to confirm on", async () => { + const sb = await installedSandbox(["0.5.0", "0.6.0"]); + // No CAST_YES, no --force, stdin is a pipe — the consent contract says + // refuse rather than assume. + const result = await new Promise<{ code: number; stderr: string }>( + (resolve) => { + const child = spawn(join(sb.bindir, "cast"), ["uninstall", "0.5.0"], { + env: sb.env, + stdio: ["pipe", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (d) => { + stderr += String(d); + }); + child.on("close", (code) => resolve({ code: code ?? 0, stderr })); + }, + ); + expect(result.code).toBe(2); + expect(result.stderr).toContain("refusing to remove cast version 0.5.0"); + expect(existsSync(join(sb.dest, "versions/0.5.0"))).toBe(true); + }); + + it("--all removes every version, current, and the PATH symlink — then re-checks", async () => { + const sb = await installedSandbox(["0.5.0", "0.6.0"]); + const { stdout } = await cast(sb, ["uninstall", "--all"], { + CAST_YES: "1", + }); + expect(stdout).toContain("uninstalled — removed:"); + expect(existsSync(sb.dest)).toBe(false); + // The PATH symlink resolved into this install root, so it went too — + // as a link, not just as a resolvable file. + expect(existsSync(join(sb.bindir, "cast"))).toBe(false); + const gone = await run("bash", [ + "-c", + `[ ! -L '${join(sb.bindir, "cast")}' ] && echo really-gone`, + ]); + expect(gone.stdout.trim()).toBe("really-gone"); + }); + + it("a version plus --all is ambiguous, and unknown options are refused", async () => { + const sb = await installedSandbox(["0.5.0"]); + await expect( + cast(sb, ["uninstall", "0.5.0", "--all"], { CAST_YES: "1" }), + ).rejects.toMatchObject({ + code: 2, + stderr: expect.stringContaining("ambiguous"), + }); + await expect( + cast(sb, ["uninstall", "--purge"], { CAST_YES: "1" }), + ).rejects.toMatchObject({ + code: 2, + stderr: expect.stringContaining("unknown option: --purge"), + }); + }); +}); diff --git a/test/release-tooling.test.ts b/test/release-tooling.test.ts deleted file mode 100644 index 5738b6f..0000000 --- a/test/release-tooling.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { execFile } from "node:child_process"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; - -const run = promisify(execFile); - -// The release surface has two moving parts this file can prove without a -// tag push: `cast --version` (what an operator asks an installed tree) and -// scripts/changelog-section.sh (what release.yml publishes as the release -// body). Both are exercised for real — the built CLI, the actual script — -// not reimplemented in the test. - -describe("cast --version", () => { - const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { - version: string; - }; - - for (const flag of ["--version", "-V"]) { - it(`${flag} prints the package.json version and the install root`, async () => { - const { stdout } = await run("node", ["dist/cli.js", flag]); - // The version comes from package.json — the single source of truth - // (cast#96) — and the root rides along, rig-style, because "which - // cast" and "where from" are the same question. - expect(stdout.trim()).toBe(`cast ${pkg.version} (${process.cwd()})`); - }); - } - - it("exits 0 and prints nothing to stderr", async () => { - const { stderr } = await run("node", ["dist/cli.js", "--version"]); - expect(stderr).toBe(""); - }); -}); - -describe("scripts/changelog-section.sh", () => { - const SCRIPT = join(process.cwd(), "scripts", "changelog-section.sh"); - - const CHANGELOG = `# Changelog - -Intro prose that must never leak into a release body. - -## Unreleased - -### Added - -- something still cooking - -## 0.2.0 — 2026-07-18 - -### Added - -- **the second thing** (#96) — with detail. - -### Fixed - -- a fix note - -## 0.1.0 — 2026-07-01 - -- the first thing -`; - - function withChangelog(content: string): string { - const dir = mkdtempSync(join(tmpdir(), "cast-changelog-")); - const file = join(dir, "CHANGELOG.md"); - writeFileSync(file, content); - return file; - } - - it("prints exactly one version's section, trimmed", async () => { - const file = withChangelog(CHANGELOG); - const { stdout } = await run("bash", [SCRIPT, "0.2.0", file]); - expect(stdout).toBe( - "### Added\n\n- **the second thing** (#96) — with detail.\n\n### Fixed\n\n- a fix note\n", - ); - }); - - it("does not bleed into the next section for the last version either", async () => { - const file = withChangelog(CHANGELOG); - const { stdout } = await run("bash", [SCRIPT, "0.1.0", file]); - expect(stdout).toBe("- the first thing\n"); - }); - - it("never serves Unreleased content for a version that is absent", async () => { - const file = withChangelog(CHANGELOG); - // 0.3.0 has no section — the release must fail loudly, not ship the - // Unreleased notes (or an empty body) under a version's name. - await expect(run("bash", [SCRIPT, "0.3.0", file])).rejects.toMatchObject({ - code: 1, - }); - }); - - it("refuses an empty section", async () => { - const file = withChangelog( - "# Changelog\n\n## 0.9.0 — 2026-01-01\n\n## 0.8.0\n\n- old\n", - ); - await expect(run("bash", [SCRIPT, "0.9.0", file])).rejects.toMatchObject({ - code: 1, - }); - }); - - it("refuses a missing file and a missing argument", async () => { - await expect( - run("bash", [SCRIPT, "0.1.0", "/nonexistent/CHANGELOG.md"]), - ).rejects.toMatchObject({ code: 1 }); - await expect(run("bash", [SCRIPT])).rejects.toMatchObject({ code: 2 }); - }); - - it("finds the real CHANGELOG's Unreleased section (the format stays parseable)", async () => { - // Guard against the repo's own changelog drifting away from the shape - // this script parses — that drift would only surface on a tag push. - const { stdout } = await run("bash", [ - SCRIPT, - "Unreleased", - "CHANGELOG.md", - ]); - expect(stdout.length).toBeGreaterThan(0); - }); -}); diff --git a/test/version-cli.test.ts b/test/version-cli.test.ts new file mode 100644 index 0000000..e301ce7 --- /dev/null +++ b/test/version-cli.test.ts @@ -0,0 +1,25 @@ +import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const run = promisify(execFile); + +describe("cast --version", () => { + // The real built CLI, not a fixture: what an operator's `cast --version` + // answers. The version comes from package.json — the single source of + // truth (deliberately no separate VERSION file) — and the install root + // rides along, rig-style, because "which cast" and "where from" are the + // same question once versions install side by side. + const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { + version: string; + }; + + for (const flag of ["--version", "-V"]) { + it(`${flag} prints the package.json version and the install root`, async () => { + const { stdout, stderr } = await run("node", ["dist/cli.js", flag]); + expect(stdout.trim()).toBe(`cast ${pkg.version} (${process.cwd()})`); + expect(stderr).toBe(""); + }); + } +}); -- 2.45.2 From 199cf6ecaf479f0f6c8af8f8e251d245aadccc75 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 21:39:29 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20the=20flip=20and=20the=20uninstall?= =?UTF-8?q?=20must=20run=20on=20macOS=20=E2=80=94=20rename(2)=20via=20node?= =?UTF-8?q?,=20no=20mapfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-bot's round-2 catch: cast is the sibling that runs on the operator's own machine, and the layout port carried two Linux assumptions in with it. - The atomic current flip spelled 'replace, don't descend' the GNU way (mv -Tf); BSD/macOS mv has no -T and dies. The flip now rides node's fs.renameSync — rename(2) is POSIX, node is a cast prerequisite on every platform — as one flip_current(), byte-identical in install.sh and bin/cast, added to the anti-drift diff. - cmd_uninstall's de-dup used mapfile — bash 4, and macOS ships bash 3.2. Now a portable while-read append. - readlink -f: Apple's readlink grew -f in macOS 12.3 (March 2022); the installer now probes it once among the prerequisites and refuses loudly on older systems instead of failing weirdly mid-flip. - A portability test pins both spellings out of the two scripts, so a reintroduction fails in CI, not on the first operator Mac. Co-Authored-By: Claude Fable 5 --- bin/cast | 25 ++++++++++++++++++++----- install.sh | 32 ++++++++++++++++++++++---------- test/install-sh.test.ts | 20 +++++++++++++++++++- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/bin/cast b/bin/cast index b0922b2..bb69c71 100755 --- a/bin/cast +++ b/bin/cast @@ -48,6 +48,20 @@ pkg_version() { CAST_PKG_PATH="$1/package.json" node -p 'require(process.env.CAST_PKG_PATH).version' 2>/dev/null || true } +# Flip /current to versions/ atomically: build the new link beside +# it, rename(2) over. Plain ln -sfn is unlink+create — a window where current +# names nothing and a concurrent 'cast' invocation dies mid-chain. The rename +# rides node's fs.renameSync because the coreutils spelling is not portable — +# GNU mv says "replace, don't descend" with -T, BSD/macOS says -h — while +# rename(2) itself is POSIX and node is a cast prerequisite on every +# platform. install.sh carries a byte-identical copy; test/install-sh.test.ts +# diffs the two so they cannot drift. +flip_current() { # $1 = install root, $2 = version + ln -sfn "versions/$2" "$1/current.new.$$" + CAST_FLIP_NEW="$1/current.new.$$" CAST_FLIP_CUR="$1/current" \ + node -e 'const fs = require("node:fs"); fs.renameSync(process.env.CAST_FLIP_NEW, process.env.CAST_FLIP_CUR);' +} + # The PATH symlinks that could ride this install: the one this invocation came # in on, CAST_BIN's, and the tier default's. Candidates only — every consumer # checks where a link actually points before touching it, so a symlink that is @@ -99,9 +113,7 @@ cmd_use() { 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" + flip_current "$ir" "$v" converge_bin_links "$ir" # Assert the EFFECTIVE result, not the intent: current must resolve to the # version asked for, and the chain's own binary must answer that version — @@ -139,7 +151,7 @@ uninstall_confirm() { # $1 = question # '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 ir a ver="" all=0 force=0 cur p t leftover="" deduped="" local targets=() for a in "$@"; do case "$a" in @@ -202,7 +214,10 @@ cmd_uninstall() { [ -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]++') + # De-dup, portably: macOS ships bash 3.2, which has no mapfile. + deduped="$(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++')" + targets=() + while IFS= read -r p; do targets+=("$p"); done <<<"$deduped" # rm's exit code is not the verdict — the absence assert below is (a # half-removed tree must be reported as INCOMPLETE by name, not as a crash). diff --git a/install.sh b/install.sh index b710490..d4b5c6f 100644 --- a/install.sh +++ b/install.sh @@ -79,6 +79,13 @@ command -v npm >/dev/null 2>&1 || die "npm is required but was not found." NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" [ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))." +# readlink -f is load-bearing across the layout (the launcher and every verb +# resolve the symlink chain with it). GNU always has it; Apple's readlink +# grew -f in macOS 12.3 (March 2022). Probe once and refuse loudly on the +# museum pieces, instead of failing weirdly mid-flip later. +readlink -f / >/dev/null 2>&1 \ + || die "this system's readlink does not support -f (macOS older than 12.3?) — upgrade, or 'brew install coreutils'." + # age is what decrypts the state repo's secrets — apply/diff shell out to it. if ! command -v age >/dev/null 2>&1; then warn "age not found — 'cast apply' and 'cast diff' will fail until it is installed." @@ -92,13 +99,18 @@ else SRCDESC="$REPO@$REF" fi -# Flip $DEST/current to versions/ atomically: build the new link beside it, -# rename over. Plain ln -sfn is unlink+create — a window where current names -# nothing and a concurrent 'cast' invocation dies mid-chain. bin/cast's -# cmd_use flips with the same pattern. -flip_current() { - ln -sfn "versions/$1" "$DEST/current.new.$$" - mv -Tf "$DEST/current.new.$$" "$DEST/current" +# Flip /current to versions/ atomically: build the new link beside +# it, rename(2) over. Plain ln -sfn is unlink+create — a window where current +# names nothing and a concurrent 'cast' invocation dies mid-chain. The rename +# rides node's fs.renameSync because the coreutils spelling is not portable — +# GNU mv says "replace, don't descend" with -T, BSD/macOS says -h — while +# rename(2) itself is POSIX and node is a cast prerequisite on every +# platform. bin/cast carries a byte-identical copy; test/install-sh.test.ts +# diffs the two so they cannot drift. +flip_current() { # $1 = install root, $2 = version + ln -sfn "versions/$2" "$1/current.new.$$" + CAST_FLIP_NEW="$1/current.new.$$" CAST_FLIP_CUR="$1/current" \ + node -e 'const fs = require("node:fs"); fs.renameSync(process.env.CAST_FLIP_NEW, process.env.CAST_FLIP_CUR);' } # --- migrate a pre-versioning flat install ----------------------------------- @@ -119,7 +131,7 @@ if [ -e "$DEST/bin/cast" ] && [ ! -d "$DEST/versions" ]; then mv "$DEST" "$staging" mkdir -p "$DEST/versions" mv "$staging" "$DEST/versions/$flat_ver" - flip_current "$flat_ver" + flip_current "$DEST" "$flat_ver" mkdir -p "$BINDIR" ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast" log "migrated: it now lives at $DEST/versions/$flat_ver (still current)" @@ -244,7 +256,7 @@ fi cur="$(readlink -f "$DEST/current" 2>/dev/null || true)" want="$(readlink -f "$VDIR")" if [ -z "$cur" ] || [ ! -d "$cur" ]; then - flip_current "$new_ver" + flip_current "$DEST" "$new_ver" log "default version: $new_ver" elif [ "$cur" = "$want" ]; then : # already the default — nothing to flip @@ -255,7 +267,7 @@ elif [ "$newly_installed" -eq 0 ]; then log "the default stays $(basename "$cur") — 'cast use $new_ver' switches." else old_ver="$(basename "$cur")" - flip_current "$new_ver" + flip_current "$DEST" "$new_ver" log "default version switched: $old_ver -> $new_ver ('cast use $old_ver' switches back)" fi diff --git a/test/install-sh.test.ts b/test/install-sh.test.ts index c6fb83e..4397795 100644 --- a/test/install-sh.test.ts +++ b/test/install-sh.test.ts @@ -330,7 +330,7 @@ describe("the shared gates cannot drift", () => { return text.slice(start, end + 2); } - for (const fn of ["valid_version", "pkg_version"]) { + for (const fn of ["valid_version", "pkg_version", "flip_current"]) { it(`${fn}() is byte-identical between install.sh and bin/cast`, () => { expect(extractFunction(INSTALL_SH, fn)).toBe( extractFunction(REAL_BIN_CAST, fn), @@ -338,3 +338,21 @@ describe("the shared gates cannot drift", () => { }); } }); + +describe("portability — cast runs on the operator's own machine, macOS included", () => { + // box#79/rig#36 could assume GNU userland and bash 4+ because boxes run + // Linux; cast cannot. These greps pin the two spellings that bit (or + // nearly bit) for real: `mv -T` (GNU-only — BSD/macOS mv has no -T, the + // flip now rides rename(2) via node) and `mapfile` (bash 4 — macOS ships + // bash 3.2). A reintroduction fails HERE, not on the first operator Mac. + for (const file of [INSTALL_SH, REAL_BIN_CAST]) { + const name = file.split("/").pop(); + it(`${name} carries no GNU mv -T and no bash-4 mapfile`, () => { + const text = readFileSync(file, "utf8"); + // "mv -T" as an invocation — the comments explaining WHY it is absent + // spell it "mv says … with -T", which this must not match. + expect(text).not.toMatch(/\bmv\s+-[A-Za-z]*T/); + expect(text).not.toMatch(/^\s*mapfile\b/m); + }); + } +}); -- 2.45.2