fix: the flip and the uninstall must run on macOS — rename(2) via node, no mapfile

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 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-18 21:39:29 +00:00
parent 5cd5968cf2
commit 199cf6ecaf
3 changed files with 61 additions and 16 deletions

View file

@ -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 <root>/current to versions/<v> 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).

View file

@ -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/<v> atomically: build the new link beside it,
# rename over. Plain ln -sfn is unlink+create — a window where current names
# nothing and a concurrent 'cast' invocation dies mid-chain. bin/cast's
# cmd_use flips with the same pattern.
flip_current() {
ln -sfn "versions/$1" "$DEST/current.new.$$"
mv -Tf "$DEST/current.new.$$" "$DEST/current"
# Flip <root>/current to versions/<v> 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

View file

@ -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);
});
}
});