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