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:
parent
5cd5968cf2
commit
199cf6ecaf
3 changed files with 61 additions and 16 deletions
25
bin/cast
25
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 <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).
|
||||
|
|
|
|||
32
install.sh
32
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/<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue