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
|
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
|
# 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
|
# 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
|
# 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"
|
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')"
|
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')"
|
[ -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
|
flip_current "$ir" "$v"
|
||||||
# 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"
|
converge_bin_links "$ir"
|
||||||
# Assert the EFFECTIVE result, not the intent: current must resolve to the
|
# 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 —
|
# 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 —
|
# 'cast uninstall' — trees and symlinks, and it ENDS by PROVING the absence —
|
||||||
# the last word is a re-check, not a hope.
|
# the last word is a re-check, not a hope.
|
||||||
cmd_uninstall() {
|
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=()
|
local targets=()
|
||||||
for a in "$@"; do
|
for a in "$@"; do
|
||||||
case "$a" in
|
case "$a" in
|
||||||
|
|
@ -202,7 +214,10 @@ cmd_uninstall() {
|
||||||
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
|
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
|
||||||
case "$t" in "$ir"/*) targets+=("$p") ;; esac
|
case "$t" in "$ir"/*) targets+=("$p") ;; esac
|
||||||
done < <(bin_links)
|
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
|
# 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).
|
# 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="$(node -p 'process.versions.node.split(".")[0]')"
|
||||||
[ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))."
|
[ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))."
|
||||||
|
|
||||||
|
# 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.
|
# age is what decrypts the state repo's secrets — apply/diff shell out to it.
|
||||||
if ! command -v age >/dev/null 2>&1; then
|
if ! command -v age >/dev/null 2>&1; then
|
||||||
warn "age not found — 'cast apply' and 'cast diff' will fail until it is installed."
|
warn "age not found — 'cast apply' and 'cast diff' will fail until it is installed."
|
||||||
|
|
@ -92,13 +99,18 @@ else
|
||||||
SRCDESC="$REPO@$REF"
|
SRCDESC="$REPO@$REF"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Flip $DEST/current to versions/<v> atomically: build the new link beside it,
|
# Flip <root>/current to versions/<v> atomically: build the new link beside
|
||||||
# rename over. Plain ln -sfn is unlink+create — a window where current names
|
# it, rename(2) over. Plain ln -sfn is unlink+create — a window where current
|
||||||
# nothing and a concurrent 'cast' invocation dies mid-chain. bin/cast's
|
# names nothing and a concurrent 'cast' invocation dies mid-chain. The rename
|
||||||
# cmd_use flips with the same pattern.
|
# rides node's fs.renameSync because the coreutils spelling is not portable —
|
||||||
flip_current() {
|
# GNU mv says "replace, don't descend" with -T, BSD/macOS says -h — while
|
||||||
ln -sfn "versions/$1" "$DEST/current.new.$$"
|
# rename(2) itself is POSIX and node is a cast prerequisite on every
|
||||||
mv -Tf "$DEST/current.new.$$" "$DEST/current"
|
# 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 -----------------------------------
|
# --- migrate a pre-versioning flat install -----------------------------------
|
||||||
|
|
@ -119,7 +131,7 @@ if [ -e "$DEST/bin/cast" ] && [ ! -d "$DEST/versions" ]; then
|
||||||
mv "$DEST" "$staging"
|
mv "$DEST" "$staging"
|
||||||
mkdir -p "$DEST/versions"
|
mkdir -p "$DEST/versions"
|
||||||
mv "$staging" "$DEST/versions/$flat_ver"
|
mv "$staging" "$DEST/versions/$flat_ver"
|
||||||
flip_current "$flat_ver"
|
flip_current "$DEST" "$flat_ver"
|
||||||
mkdir -p "$BINDIR"
|
mkdir -p "$BINDIR"
|
||||||
ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast"
|
ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast"
|
||||||
log "migrated: it now lives at $DEST/versions/$flat_ver (still current)"
|
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)"
|
cur="$(readlink -f "$DEST/current" 2>/dev/null || true)"
|
||||||
want="$(readlink -f "$VDIR")"
|
want="$(readlink -f "$VDIR")"
|
||||||
if [ -z "$cur" ] || [ ! -d "$cur" ]; then
|
if [ -z "$cur" ] || [ ! -d "$cur" ]; then
|
||||||
flip_current "$new_ver"
|
flip_current "$DEST" "$new_ver"
|
||||||
log "default version: $new_ver"
|
log "default version: $new_ver"
|
||||||
elif [ "$cur" = "$want" ]; then
|
elif [ "$cur" = "$want" ]; then
|
||||||
: # already the default — nothing to flip
|
: # 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."
|
log "the default stays $(basename "$cur") — 'cast use $new_ver' switches."
|
||||||
else
|
else
|
||||||
old_ver="$(basename "$cur")"
|
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)"
|
log "default version switched: $old_ver -> $new_ver ('cast use $old_ver' switches back)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -330,7 +330,7 @@ describe("the shared gates cannot drift", () => {
|
||||||
return text.slice(start, end + 2);
|
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`, () => {
|
it(`${fn}() is byte-identical between install.sh and bin/cast`, () => {
|
||||||
expect(extractFunction(INSTALL_SH, fn)).toBe(
|
expect(extractFunction(INSTALL_SH, fn)).toBe(
|
||||||
extractFunction(REAL_BIN_CAST, fn),
|
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