✨ feat: sudo-free install, uninstall, and self-upgrade - #76
Merged
Merged
Conversation
|
| Filename | Overview |
|---|---|
| crates/tapesctl/src/upgrade/artifact.rs | Implements artifact staging, digest verification, bounded process-group probing, upgrade locking, and atomic replacement; the previously reported timeout issues are addressed. |
| crates/tapesctl/src/upgrade/mod.rs | Orchestrates the serialized download, verification, probe, version-validation, and atomic-commit pipeline. |
| crates/tapesctl/src/uninstall.rs | Adds ordered removal of local state, installer-managed rc blocks, and the running binary while preserving retryability. |
| crates/tapesctl/src/rc_block.rs | Adds guarded removal of installer sentinel blocks while refusing malformed marker layouts. |
| crates/tapesctl/src/install_layout.rs | Centralizes paths and writability checks used by upgrade and uninstall operations. |
| install.sh | Moves default installation to a user-owned directory, manages PATH configuration, and supports migration from the legacy location. |
| crates/tapesctl/tests/self_upgrade.rs | Exercises the real self-replacement pipeline and verifies that abort paths preserve the installed binary. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
I[Installed tapesctl] --> L[Acquire exclusive upgrade lock]
L --> R[Resolve target release]
R --> D[Download staging artifact]
D --> H[Verify published SHA-256]
H --> X[Mark staged file executable]
X --> P[Run bounded version probe]
P --> V[Validate reported version]
V --> A[Atomically rename over installed binary]
H -->|failure| K[Keep existing binary]
P -->|failure or timeout| K
V -->|failure| K
Reviews (4): Last reviewed commit: "📚 docs: the install layout, upgrade, an..." | Re-trigger Greptile
yeazelm
force-pushed
the
matt/pcc-1279-sudo-free-install-uninstall-upgrade
branch
from
August 24, 2026 21:17
ef5eda7 to
cf17cc4
Compare
yeazelm
force-pushed
the
matt/pcc-1279-sudo-free-install-uninstall-upgrade
branch
from
August 24, 2026 21:38
cf17cc4 to
f243694
Compare
The install location moves to ~/.local/bin, and with it the last routine sudo leaves the tool. A fresh install is now ordinary user file I/O end to end: mkdir, download, install. What /usr/local/bin bought — presence on every default PATH — is replaced by a guarded export written into a sentinel-marked block in the user's rc file: guarded on PATH membership so nested shells don't stack duplicates, guarded on the binary existing so a block that outlives an uninstall is silent, and idempotent across re-installs because the block is rewritten, never duplicated. Without the export an install into ~/.local/bin is unreachable, so this is not optional polish; it is what makes the install an install. The marker text names tapesctl while keeping the shared glyph. Both this script and the Rust remover that follows compare whole lines, so a paperctl block in the same rc file is neither matched nor disturbed — one dotfile can carry both without either installer touching the other's block. Existing users migrate by re-running the installer. When it finds the old root-owned binary it says plainly that this is the last time tapesctl will ever ask for sudo, and removes it — leaving it would fork behavior by context forever, with scripts, CI, and GUI-launched tools silently running the stale binary that default PATHs still find first. Declining is fine: the new install still lands, the manual removal command is printed, and the install exits successfully. The same-directory guard compares canonicalized paths, so a trailing slash or a symlinked spelling of the install dir cannot turn the migration into a delete of the binary this very run installed. A read-only rc file — nix, home-manager, a dotfiles manager — must not fail the install: the binary is already on disk, it just is not on PATH yet. That case prints the line to add by hand, the way Homebrew does, and keeps going. The rc rewrite classifies a file the same three ways the Rust remover does — no block, well-formed, malformed — and refuses to rewrite a malformed one: dropping everything below an orphaned begin marker is worse than leaving the block. Classification is whole-line and byte-exact, so a marker differing by a trailing space or CRLF reads as malformed rather than slipping past a substring guard the stripper cannot match. An absent HOME with an explicit TAPESCTL_INSTALL_DIR degrades to printed PATH instructions — the binary is installed; only the rc convenience is impossible. Absent both is refused before anything is downloaded. The script's whole executable body runs through a main() invoked on the last line. bash executes a piped script incrementally as bytes arrive; without the wrapper, a transfer dying between the old binary's rm and the new one's install left no tapesctl at all. A truncated stream now fails to parse and executes nothing.
The installer now writes into two places the user did not choose by hand: a directory on their PATH and a sentinel block in their shell rc file. Neither should be something only a hand-edit can take back, so this is the command that takes it back. An install layout is derived, never assumed: canonicalize the running executable (so a PATH invocation, an absolute one, and one through a symlink all resolve to the same place), take its directory, and derive the binary and staging paths from that. A writability probe on the directory is the gate every mutating operation checks first — directory write permission is what unlink and rename actually require on Unix, and file permissions are irrelevant to both. The probe creates a file rather than reading mode bits, so ACLs and read-only mounts are respected too. When the directory is not user-writable — the old root-owned layout — the binary refuses with the installer command and the manual rm. It never escalates. rc files get a surgical primitive: remove exactly the lines between the sentinel markers, byte-preserving everything outside them, and refuse to rewrite at all when a begin marker has no matching end — dropping everything after an orphaned marker is worse than leaving the block. Working on bytes rather than str keeps a latin-1 comment or a stray byte elsewhere in the file undamaged. The markers are the contract that makes touching user dotfiles defensible, so the Rust constants are pinned against the installer by a test that reads it: marker drift is a red build, not a block nothing can ever remove again. Whole-line comparison is also what lets a paperctl block share the file untouched. Uninstall runs state, then the rc block, then its own binary, then exits. Destruction is sequenced so every failure leaves a tool that can retry — the self-unlink is dead last, and it is safe because the inode outlives the running process. Each step warns and continues; a read-only rc file is a warning line, not an abort. Harness-side plugin registrations are deliberately left alone: those live in a config file the harness owns, and `plugin uninstall` is the command that speaks that contract, so the report names it rather than reaching into it. A prompt that reaches EOF counts as a decline. An uninstall that proceeds because nobody was there to say no is the one outcome the prompt exists to prevent. State removal is contained to the cache path this crate derives for itself. An overridden TAPESCTL_CACHE_DIR names a directory the user chose — routinely one holding more than this cache — so it is reported and left in place rather than handed to remove_dir_all. An executable path that will not resolve degrades rather than aborts: the rest of the uninstall proceeds and the report names the manual step. The prompt writes to stderr and enumerates every path it is about to remove, two of which are recursive deletes. The unwritable-directory tests probe their own precondition and skip when mode bits cannot construct it — root writes through 0o555, and containerized CI runs as root. Upgrade residue goes with the binary: the staging file an interrupted upgrade can leave behind and the pipeline lock file are removed best-effort — their sweep otherwise runs only on the next upgrade, and after an uninstall there is not one.
tapesctl upgrade checks the published version, reports "already up to date" with a clean exit when there is nothing to do, and otherwise downloads, verifies, and atomically replaces this binary, printing old → new. --version pins an exact release (older included — a bad release needs an escape hatch) and --nightly always fetches, because nightly builds are deliberately outside version ordering. The core is a pipeline whose failure modes all converge on "the binary you had still works." The new artifact is downloaded to a dotfile staged in the install directory itself — same directory means same filesystem, which is what makes the final rename atomic; a staging file in /tmp would cross devices and degrade to copy-plus-delete, reopening exactly the corruption window this exists to close. The dotted name keeps a crashed run from leaving a PATH-visible half-binary, and any stale staging file is swept at the start of the next attempt, so repeated failures converge instead of accumulating. Nothing touches the live binary until the staged bytes have earned it, in a fixed order: the published sha256 is fetched and compared first — before the staged file is made executable, before it is run. Executing an unverified download would make the updater an arbitrary-code- execution primitive against itself, so the sanity probe runs strictly after the digest passes; it catches what a correct digest cannot, a faithfully published wrong-arch artifact. The probe's answer is then checked against the resolved version, which catches what neither can: a prefix serving the wrong build. Only then does rename swap it over the running executable — safe on Unix because the current process keeps its inode. A missing .sha256 aborts rather than degrading to an unverified install. Checksum mismatch, truncated download, failed probe, unwritable directory: each is a typed error that aborts with the original untouched and the staging file cleaned. The unwritable case names the installer — an unmigrated root-owned install cannot self-upgrade by design, and the message routes it to the script that migrates it, before a byte is downloaded that it could never apply. Unlike paperctl's, the pipeline ends at the swap: tapesctl runs no daemon and installs no symlink, so there is nothing to re-point or bounce. A capture already in flight keeps running on its old inode. The comparison has one wrinkle paperctl does not: a tapesctl build stamps its commit as semver build metadata, so `nightly` arrives as `nightly+3f2a1b9` and never matches a bare sentinel. The name is split from its metadata before that check, rather than leaving the answer to depend on whether the version parser happens to reject the whole string. main now prints the source chain rather than the outermost message alone. Every error here is a typed wrapper around the one beneath it and the outermost is deliberately the least specific — "upgrade failed" is a category, while the cause a user acts on lives one or two links down. Printing only the top discarded exactly the half that says what to do. A hidden env override for the download base URL exists so the end-to-end test can point the real compiled binary at a localhost bucket and watch it replace itself on disk — including the abort paths, where the assertion is that the installed bytes are identical afterwards. The staging file is created create_new with mode 0600: no truncate of an existing path, no symlink follow, and no executable window before the digest passes. That is what makes the stale-staging sweep load-bearing rather than incidental — disabling it fails a test. The probe is time-bounded with captured output capped, because it is the one step that executes code the bucket supplied. The swap inherits the mode of the binary being replaced, execute bits forced on, so a deliberately private install stays private across upgrades. Redirects are pinned to https, the download carries a size ceiling in place of an overall timeout, and a sidecar behind any non-success status is treated as the absent checksum it is. The unwritable-directory test probes its own precondition and skips when mode bits cannot construct it — root writes through 0o555, and containerized CI runs as root. A probe that outlives its timeout is killed, not abandoned — and killed as a process group, not a single pid: the probe leads its own group, so descendants a forking artifact leaves behind go with it. The child handle is still held when the timeout fires, which is what makes the group id trustworthy: an unreaped child's pid cannot be recycled. Output is read to a cap and the rest drained, so a chatty artifact can neither exhaust the updater's memory nor deadlock the probe on a full pipe. One pipeline at a time: an exclusive flock on a lock file in the install directory serializes concurrent upgrades — the kernel drops it however the process ends. Two runs sharing one staging path could otherwise unlink each other's verified bytes and commit a partial download as the live binary while printing success. The probe is a sanity check, not a sandbox. Its containment of the staged binary is best-effort by design: an adversarial bucket able to serve hostile verified bytes defeats the pipeline at the swap, not at the probe — the security boundary is the digest gate.
The install section described a `/usr/local/bin` install that no longer happens, and the reference had no entry for either new command. Both are now documented where a user looks: the README for the tour, docs/commands.md for the reference, docs/introduction.md for the first five minutes. The `version` entry was also stale in a way that now actively contradicts `upgrade`. It said the number is not a release identifier because the crate version has never been bumped — true when it was written, false since the build began stamping the release tag, and directly at odds with a command whose "already up to date" answer is that number compared against the bucket's. It now describes what the three build kinds actually report and why the commit rides along as build metadata that comparison ignores. The exit-code and error-family entries said every runtime error is one line. They are a line plus a `caused by:` line per underlying cause now, and the note says to read the chain from the bottom, because the outermost message is deliberately the least specific. The uninstall entry names what is deliberately not deleted: an overridden TAPESCTL_CACHE_DIR is reported and left in place rather than recursively deleted, because the variable names a directory the user chose. The checksum error carries the status it saw, since any non-success answer — not only a 404 — counts as an absent sidecar.
yeazelm
force-pushed
the
matt/pcc-1279-sudo-free-install-uninstall-upgrade
branch
from
August 24, 2026 23:25
f243694 to
9172a0f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖
Summary
~/.local/bin: plain user file I/O end to end, PATH wired via a sentinel-marked rc block; re-running the installer migrates an old root-owned install (declining is fine and still succeeds).tapesctl uninstalltakes back everything the installer wrote — binary, rc block, local state — sequenced so every failure leaves a tool that can retry; harness-side plugin registrations are deliberately left toplugin uninstall.tapesctl upgradedownloads, checksum-verifies, probes, and atomically swaps the running binary; every failure mode converges on "the binary you had still works".--versionpins (downgrades included),--nightlyalways fetches.flockheld for the pipeline's lifetime — an adversarial-audit repro showed two overlapping runs could otherwise commit a partial download over the live binary with a false success. The loser now refuses before any network I/O;uninstallsweeps the lock and staging residue.none/ok/malformedon both the shell and Rust sides — an orphaned begin marker is refused rather than eating the rest of the file (a hazard inherited from paper's installer, fixed here).Test plan
dagger call lint+dagger call test(CI-identical) green locallycurl | bashinstall on a clean machine/usr/local/bininstallFixes PCC-1279