Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ Inside `crates/tapesctl/src`:
- `api/` — the `<resource> <method>` read client.
- `cassette/` — the runtime-discovered `cassettes <name> <method>` surface:
discovery, the spec reducer, the cache, and clap synthesis.
- `upgrade/` — verified, crash-safe self-replacement: bucket resolution,
staging, digest verification, and the atomic swap.
- `install_layout.rs` — where this binary actually lives, derived from the
running executable. `upgrade` and `uninstall` both build on it.
- `rc_block.rs` — removal of the installer's sentinel block from shell rc
files. Its markers are pinned against `install.sh` by a test.
- `uninstall.rs` — the binary, the local state, and that block.
- `plugin.rs`, `capture.rs`, `logging.rs`, `error.rs` — the remaining
command entry points and cross-cutting support.
- `ports/` — search, skills, and seed.
Expand Down
34 changes: 32 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 16 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,24 @@ dirs = "5"
time = { version = "0.3", features = ["formatting", "parsing", "serde", "macros"] }
uuid = { version = "1", features = ["v4", "serde"] }

# --- test ---
# Recomputing the vendored fixture corpora's DIGEST seals, which is how a stale
# or hand-edited vendored copy fails in this repo's CI rather than silently
# testing cases nobody upstream still has. Test-only: nothing shipped hashes.
# Two callers, one of them shipped: recomputing the vendored fixture corpora's
# DIGEST seals (how a stale or hand-edited vendored copy fails in this repo's CI
# rather than silently testing cases nobody upstream still has), and verifying a
# downloaded artifact against its published `.sha256` in `tapesctl upgrade`.
sha2 = "0.10"
# Also shipped: `upgrade` and `uninstall` probe install-directory writability by
# creating a file there, which respects ACLs and read-only mounts where a
# mode-bit inspection would not.
tempfile = "3"

# Version comparison for `tapesctl upgrade`, and deliberately not `semver`:
# release labels here are compared, not validated, and `versions` accepts the
# version-like-but-not-SemVer forms a release process can produce while still
# ordering them sensibly. It also ignores build metadata for equality, which is
# what lets a stamped `v0.7.0+3f2a1b9` compare equal to the bucket's `v0.7.0`.
versions = "7"

# --- test ---
test-case = "3"
wiremock = "0.6"

Expand Down
57 changes: 54 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,26 @@ curl -sSfL https://download.tapes.dev/tapesctl/install | bash
Every published artifact carries a `.sha256` sidecar. Where `sha256sum` or
`shasum` is available, the installer verifies the download against that sidecar
before installing, and a missing sidecar is a hard failure rather than a skipped
check; with neither tool present it warns and installs unverified. Binaries land
in `/usr/local/bin` (via `sudo` only if that directory is not writable). Set
`TAPESCTL_VERSION` to install a specific release or nightly, and
check; with neither tool present it warns and installs unverified.

The binary lands in `$HOME/.local/bin` — a directory you own, so a normal
install never asks for `sudo`. That directory is not on every default `PATH`,
so the installer also writes a guarded `PATH` export into your shell's rc file
(`.bashrc`, `.zshrc`, or `config.fish`), inside a sentinel-marked block it
rewrites in place rather than duplicating on re-install. If your rc file is
read-only — managed by nix or a dotfiles manager — the install still succeeds
and prints the line to add yourself.

Set `TAPESCTL_VERSION` to install a specific release or nightly, and
`TAPESCTL_INSTALL_DIR` to install somewhere else.

Installs predating this layout put the binary in `/usr/local/bin`. Re-running
the installer migrates them: it removes the old root-owned binary with a single
announced `sudo` — the last one tapesctl will ever ask for — because a binary
left there would shadow the new one in every context that still has the default
`PATH` order. Declining is fine; the install still succeeds and prints the
removal command.

Confirm it landed:

```bash
Expand All @@ -38,6 +53,42 @@ tapesctl version

Supported platforms are Linux and macOS, on x86-64 and arm64.

### Upgrading

```bash
tapesctl upgrade
```

No curl, no re-running the installer. It checks the published version, says
`already up to date` and exits successfully when there is nothing to do, and
otherwise replaces this binary in place and prints `old → new`.

Nothing touches the installed binary until the download's SHA-256 matches the
published sidecar and the staged file has answered `version` sensibly — so a
corrupted download, a wrong-architecture artifact, or a lost connection all
leave the binary you had still working. A missing sidecar aborts rather than
installing something unverifiable.

`--version v0.6.0` pins an exact release, older ones included, because a bad
release needs an escape hatch. `--nightly` installs the rolling nightly build.

An install still living in `/usr/local/bin` cannot upgrade itself — the
directory is not yours to write — and says so, naming the installer as the way
to migrate.

### Uninstalling

```bash
tapesctl uninstall
```

Removes the binary, `~/.tapes`, the cassette cache, and the `PATH` block the
installer wrote — leaving every other line of your rc file byte-for-byte
intact, and listing each path before you confirm. A cache location you pinned
with `TAPESCTL_CACHE_DIR` is named rather than deleted. Capture plugins installed into a harness are left alone, because those
live in the harness's own config; `tapesctl plugin uninstall <harness>` removes
one. Pass `-y` to skip the confirmation.

## Your first capture

`start` launches a harness the way you normally would, with a capture proxy in
Expand Down
6 changes: 4 additions & 2 deletions crates/tapesctl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ name = "tapesctl"
path = "src/main.rs"

[dependencies]
libc = { workspace = true }
tapes-harnesses = { workspace = true }
tapes-capture = { workspace = true }
tapes-client = { workspace = true }
Expand All @@ -36,7 +37,9 @@ reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
sha2 = { workspace = true }
snafu = { workspace = true }
tempfile = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
toml = { workspace = true }
Expand All @@ -45,12 +48,11 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }
versions = { workspace = true }
zstd = { workspace = true }

[dev-dependencies]
sha2 = { workspace = true }
test-case = { workspace = true }
tempfile = { workspace = true }
wiremock = { workspace = true }

[lints]
Expand Down
38 changes: 37 additions & 1 deletion crates/tapesctl/src/cassette/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,52 @@ pub const REVALIDATE_AFTER: Duration = Duration::from_secs(600);
/// location in CI.
pub const CACHE_DIR_ENV: &str = "TAPESCTL_CACHE_DIR";

/// Directory name under the platform cache dir, when no override is set.
const APP_DIR_NAME: &str = "tapesctl/cassettes";

/// tapesctl's cache parameterization for one base URL.
fn config(key: &str) -> CacheConfig<'_> {
CacheConfig {
app_dir_name: "tapesctl/cassettes",
app_dir_name: APP_DIR_NAME,
env_override_var: CACHE_DIR_ENV,
revalidate_after: REVALIDATE_AFTER,
key,
}
}

/// The directory this crate *chose* for cached surfaces — the platform cache
/// directory plus [`APP_DIR_NAME`] — ignoring [`CACHE_DIR_ENV`] entirely.
/// `None` when the platform names no cache directory.
///
/// The override is deliberately not honored here, and this is the only reason
/// the function exists separately from the resolution
/// [`tapes_client::cassettes::cache`] performs internally: the one caller is
/// `uninstall`, which passes what it gets to `remove_dir_all`. An override
/// names a directory the *user* picked, which may hold anything —
/// `TAPESCTL_CACHE_DIR=$HOME` would turn an uninstall into a recursive delete
/// of the home directory. A path this crate derived itself is one it owns and
/// may remove; a path the environment supplied is not.
///
/// Callers that want the location actually in use want
/// [`tapes_client::cassettes::cache`] via [`read`]/[`write`], not this.
#[must_use]
pub fn owned_cache_dir() -> Option<std::path::PathBuf> {
Some(dirs::cache_dir()?.join(APP_DIR_NAME))
}

/// The override's value, when it is set to something non-empty.
///
/// `uninstall` names it in its report rather than deleting it: leaving a cache
/// behind is a nuisance, deleting a directory the user pointed us at is not
/// recoverable.
#[must_use]
pub fn cache_dir_override() -> Option<std::path::PathBuf> {
std::env::var(CACHE_DIR_ENV)
.ok()
.filter(|raw| !raw.trim().is_empty())
.map(std::path::PathBuf::from)
}

/// Read the cached surface for a base URL, if there is a usable one.
#[must_use]
pub fn read(base: &str) -> Option<Cached> {
Expand Down
38 changes: 38 additions & 0 deletions crates/tapesctl/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,44 @@ pub enum Command {
#[command(subcommand)]
Config(ConfigCommand),

/// Upgrade tapesctl in place: download the requested build, verify its
/// published checksum, and atomically replace this binary.
///
/// With no flags, installs the newest release; exits successfully without
/// downloading when already up to date. Nothing touches the installed
/// binary until the download's sha256 matches the published one and the
/// staged file has answered `version` sensibly, so every failure leaves the
/// binary you had still working.
///
/// Deliberately named `upgrade` with no `update` alias: `upgrade` means
/// "replace the binary with another build", and `update` is reserved for
/// possible future semantics such as refreshing configuration or a
/// discovered cassette surface.
Upgrade {
/// Release version to install (e.g. `v0.7.0` or `0.7.0`).
///
/// Defaults to the newest published release; older versions are
/// allowed, because a bad release needs an escape hatch.
#[arg(long, conflicts_with = "nightly")]
version: Option<String>,

/// Install the rolling nightly build instead of a release.
#[arg(long)]
nightly: bool,
},

/// Remove tapesctl: the binary, this tool's local state, and the PATH
/// block the installer wrote into your shell's rc file.
///
/// Harness-side capture plugins are left alone — a plugin registration
/// lives in the harness's own config file, and `tapesctl plugin uninstall`
/// is the command that speaks that contract.
Uninstall {
/// Skip the interactive confirmation prompt.
#[arg(short = 'y', long = "yes")]
assume_yes: bool,
},

/// Print version information.
Version,
}
Expand Down
22 changes: 22 additions & 0 deletions crates/tapesctl/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,28 @@ pub enum Error {
/// Underlying IO failure.
source: std::io::Error,
},

/// `tapesctl upgrade` could not replace the binary.
///
/// Every variant beneath this one means the installed binary is still the
/// pre-upgrade file: nothing destructive happens before the final atomic
/// rename.
#[snafu(display("upgrade failed"))]
Upgrade {
/// Underlying upgrade failure.
source: crate::upgrade::UpgradeCliError,
},

/// `tapesctl uninstall` could not run.
///
/// Only the steps that make the command impossible to start reach here;
/// every individual removal warns and continues, so a partial teardown is
/// a report, not an error.
#[snafu(display("uninstall failed"))]
Uninstall {
/// Underlying uninstall failure.
source: crate::uninstall::UninstallError,
},
}

/// Map the shared client's errors onto the variants this CLI surfaced when the
Expand Down
Loading
Loading