feat: record how and where install.sh installed, for rook update - #15
Conversation
`rook update` (LambdatestIncPrivate/rook#554) has to know how the running copy was installed before it can update it. It cannot work that out from the binary's own path: the tarball's launcher resolves its symlink chain and execs the deep entry, so by the time any code runs, the directory this script chose is gone. An npm --prefix can also point at ~/.local, so the inference fails in both directions. So install.sh records what only install.sh knows. `install-manifest.json`, written into the versioned install tree: {"v":1,"channel":"curl","installDir":"/Users/x/.local/bin","version":"0.1.0"} installDir is the point. This script links $INSTALL_DIR/rook, and --dir moves it. Re-running the installer without the recorded directory would write a SECOND symlink into the default ~/.local/bin while the user's real one still pointed at the old version — an update that reports success and changes nothing on PATH. Three details that are load-bearing rather than incidental: - The path is resolved AFTER mkdir, not at argument-parse time. The target need not exist when --dir is parsed, and ~/.local/bin does not exist on a fresh machine, so `cd`/realpath/readlink -f would all fail there and `set -e` would turn a working install into a non-zero exit that installs nothing. Resolving at parse time passes every existing harness, because all of them pre-create the directory and all of them pass --dir. - It is written LAST, after ln -sf. Its presence means an install that actually reached PATH; written first it would survive a failed symlink and describe an install nobody can run. A failure writing it warns rather than aborting — the install is already complete and usable, and a missing manifest degrades `rook update` to printing the command instead of running it, which is the safe direction. - Control characters in the path are refused up front, and backslash and double-quote are escaped on the way out. This is bash with no JSON encoder, and a legal `--dir '/tmp/a"b'` would otherwise write a document that will not parse — which the reader treats as absent, leaving that install silently never updating. scripts/test-install-manifest.sh runs the real, unmodified install.sh against a synthetic release, and differs from test-install-fixture.sh in two deliberate ways: it does not always pass --dir and does not pre-create the install directory (case A is a fresh `curl ... | bash`), and its fixture tarball carries @lambdatestincprivate/rook, the scope a real public tarball has — @testmuai is the published package's name and is the wrong side of the discriminator `rook update` relies on. Stash-verified: git stash push install.sh → 14 of the harness's assertions fail; restored → all 20 pass. All 7 scripts/test-*.sh pass and shellcheck is clean on both files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ReviewRan an independent review of this diff ( Findings1. [HIGH] The control-character guard runs before
2. [HIGH] Exits 0, writes a manifest with a raw tab in the version field — 3. [MED] Confirmed regression: reusing the resolved physical path for the (unchanged) 4. [MED] Case F's ordering check doesn't structurally verify what it claims. 5. [LOW] No UTF-8 validation — a third unguarded byte class. 6. [LOW] Non-atomic manifest write. 7. [LOW] Sourcing 8. [LOW, doc accuracy] Comment overclaim. Test-harness quality notes (not correctness bugs)
Findings 1, 2, and 3 were empirically reproduced against this branch in a scratch worktree; happy to share the exact repro commands/output if useful. |
- control characters are now rejected on --dir's resolved physical path too, not just the raw argument — resolution (pwd -P) can fold one in from the CWD or a symlink target that the pre-resolution check never saw - --version gets the same control-character (and now UTF-8) guard --dir already had; it was previously unvalidated despite flowing into the same hand-rolled JSON - the "add to your PATH" check now compares against the raw, pre-resolution --dir, not the resolved physical path — using the resolved path caused a false positive for a directory that IS on PATH via its unresolved form (e.g. /tmp/... on macOS) - install-manifest.json is now written via temp-file-then-rename so a crash mid-write can't leave a truncated manifest on disk - arg parsing and both control-character/UTF-8 guards moved inside main(), so sourcing install.sh for detect_platform() (as three test harnesses already do) no longer runs them against the ambient environment - a stale doc comment claiming the manifest's presence means PATH was reached is corrected — that's a separate, later check that can be false on a fully successful install scripts/test-install-manifest.sh: fixed Case F's grep, which matched the warning-echo line instead of the actual write (now matches the temp-file rename specifically); added Cases G/H/I covering the three new/extended guards, including one that only reproduces post-fix (a control character introduced by path resolution, not present in the raw --dir). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed
Also added Cases G/H/I to Verified: |
- guard the test do marker assertions on existence: bottle do's sha256 and version are unchanged by this PR, so brew install keeps pouring the pre-marker 0.1.0 bottle until a rebuild lands one that has it — unguarded, any brew-smoke run dispatched against 0.1.0 in that window hits an uncaught Errno::ENOENT instead of a clean pass - write the marker via temp-file-then-rename so a crash mid-write can't leave a truncated file on disk (same reasoning as install.sh's manifest write, #15) - derive formula: name instead of hardcoding "rook" at both the write and test sites - test do independently re-derives the "marker sits five levels above pkg_dir" layout claim instead of only reading back through prefix, which def install also wrote through Two findings from the same review are out of scope for this PR and tracked separately, since the fix lives in the private CLI repo: readKegRecord() never validates the marker's version field (LambdatestIncPrivate/rook#635), and there's no contract test between this Formula's output and that reader (LambdatestIncPrivate/rook#636). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ord (#16) * feat: Formula writes rook-keg-marker.json, the keg's own identity record `rook update` (LambdatestIncPrivate/rook#576) must establish that the keg its running copy sits in is rook's own before it prints `brew upgrade` for it. Homebrew's INSTALL_RECEIPT.json cannot carry that fact: it has no formula-name field, and for an API-loaded formula source.path is the shared formula.jws.json cache — so without a record of our own, the only implementation is inferring from the Cellar path, which the reader refuses to do. def install now writes {v: 1, formula: "rook", version} to <keg>/rook-keg-marker.json, unconditionally (the opoo fallback still installs rook), with no absolute paths inside so bottles pour and relocate cleanly under :any_skip_relocation. test do asserts all three fields, and brew-smoke inherits the assertion through the brew test call it already makes. Verified by a real from-source install via a throwaway local tap: the marker lands at the keg root beside INSTALL_RECEIPT.json with exactly {"v":1,"formula":"rook","version":"0.1.0"}, and all three test-do assertions pass executed against the installed keg. Without this change the file does not exist (the shipped 0.1.0 bottle demonstrates it) and the test's read raises ENOENT. brew style reports only the pre-existing, deliberate ComponentsOrder offense; the update-formula.yml sed anchors (url/sha256/version) are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: close keg-marker gaps found in review - guard the test do marker assertions on existence: bottle do's sha256 and version are unchanged by this PR, so brew install keeps pouring the pre-marker 0.1.0 bottle until a rebuild lands one that has it — unguarded, any brew-smoke run dispatched against 0.1.0 in that window hits an uncaught Errno::ENOENT instead of a clean pass - write the marker via temp-file-then-rename so a crash mid-write can't leave a truncated file on disk (same reasoning as install.sh's manifest write, #15) - derive formula: name instead of hardcoding "rook" at both the write and test sites - test do independently re-derives the "marker sits five levels above pkg_dir" layout claim instead of only reading back through prefix, which def install also wrote through Two findings from the same review are out of scope for this PR and tracked separately, since the fix lives in the private CLI repo: readKegRecord() never validates the marker's version field (LambdatestIncPrivate/rook#635), and there's no contract test between this Formula's output and that reader (LambdatestIncPrivate/rook#636). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
What
install.shrecords how and where it installed, ininstall-manifest.jsoninside the versioned install tree:{ "v": 1, "channel": "curl", "installDir": "/Users/x/.local/bin", "version": "0.1.0" }Plus
scripts/test-install-manifest.sh, which runs the real, unmodifiedinstall.shagainst a synthetic release and asserts the record.Nothing about installing changes. This is one file write at the end of a
successful install.
Why
rook update(LambdatestIncPrivate/rook#554) has to know how the running copywas installed before it can update it, and it cannot work that out from the
binary's own path. The tarball's launcher resolves its own symlink chain and
execs the deep entry, so by the time any code runs, the directory thisscript chose is already gone. An npm
--prefixcan point at~/.localtoo,so the inference fails in both directions.
installDiris the point, more than the channel name. This script links$INSTALL_DIR/rook(install.sh:232), and--dirmoves it. Re-running theinstaller without the recorded directory writes a second symlink into the
default
~/.local/binwhile the user's real one still points at the oldversion — an update that reports success and changes nothing on
PATH. Onlythis script knows where it put the link, so only this script can record it.
Three details that are load-bearing
The path is resolved after
mkdir, not at argument-parse time. The targetneed not exist when
--diris parsed, and~/.local/bindoes not exist on afresh machine — so
cd,realpathandreadlink -fwould all fail there andset -euo pipefailwould turn a working install into a non-zero exit thatinstalls nothing.
This is worth calling out because resolving at parse time passes every
existing harness. All of them pre-create the install directory and all of
them pass
--dir(test-install-fixture.sh:168,222,308,335,379). A freshcurl … | bashis exactly the case none of them cover, and it is thedocumented install path. Case A of the new harness is that install.
It is written last, after
ln -sf. Its presence then means an install thatactually reached
PATH; written first, it would survive a failed symlink anddescribe an install nobody can run. A failure writing it warns rather than
aborting — the tree and the symlink are already in place and working, and a
missing manifest degrades
rook updateto printing the command instead ofrunning it, which is the safe direction.
Control characters are refused, backslash and double-quote escaped. This is
bash with no JSON encoder. A legal
--dir '/tmp/a"b'would otherwise write adocument that will not parse, which the reader treats as absent — leaving that
install silently never updating, with no error anywhere.
The harness, and how it differs from
test-install-fixture.shSame seam (a stub
curlearlier onPATHserves fixture files by basename,so the real download → verify → extract → symlink pipeline runs), two
deliberate differences:
--dir, and does not pre-create the directory —see above.
lib/node_modules/@lambdatestincprivate/rook,the scope a real public tarball has.
test-install-fixture.shuses@testmuai/rook, which is fine for what that file asserts, but@testmuaiis the published package's name — the npm rename applies only to the
published copy, not to the tarball — and it is the wrong side of the
discriminator
rook updateuses to tell a curl install from an npm one.It also writes
VERSIONat the tarball root, which the real one has andwhich
rook updatereads to confirm an update landed.Six cases, 20 assertions: default
--diron a machine where it does not exist,explicit
--dirnot pre-created, a relative--dirrecorded absolute, adouble quote in the path, a control character refused, and the write ordered
after the symlink.
Evidence
All seven
scripts/test-*.shpass, including the existingtest-install-fixture.shagainst the modifiedinstall.sh.shellcheckisclean on both files.
One thing the harness found rather than me: expectations have to compare
against the physical path (
pwd -P), because macOS puts$TMPDIRbehind/private. Comparing against the unresolved form is green on Linux CI and redon a maintainer's laptop.
Not in this PR
rook updateitself, which lives in the private CLI repository.~/.testmuai/rook-<version>trees — pre-existing, filed asinstall.sh never prunes older ~/.testmuai/rook-<version> trees #14. An automated update path makes it faster rather than causing it.