diff --git a/Cargo.lock b/Cargo.lock index 4d44729..bdddaa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -91,6 +97,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -211,7 +226,7 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cassady" -version = "0.2.6" +version = "0.2.7" dependencies = [ "anyhow", "async-trait", @@ -219,6 +234,7 @@ dependencies = [ "clap", "crossterm", "dirs", + "flate2", "futures-util", "ignore", "include_dir", @@ -227,13 +243,17 @@ dependencies = [ "ratatui", "regex", "reqwest", + "semver", "serde", "serde_json", + "sha2", + "tar", "tempfile", "thiserror 1.0.69", "tokio", "unicode-width 0.1.14", "wiremock", + "zip", ] [[package]] @@ -365,6 +385,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -501,6 +530,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -638,6 +678,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -656,6 +706,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1376,6 +1436,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -2231,6 +2301,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "siphasher" version = "1.0.3" @@ -2346,6 +2422,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2976,7 +3063,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -3301,6 +3388,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -3404,8 +3501,37 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 0478aa0..4355739 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cassady" -version = "0.2.6" +version = "0.2.7" edition = "2021" description = "Cassady/Cass minimal terminal coding agent" license = "MIT" @@ -33,11 +33,16 @@ ratatui = { version = "0.30", default-features = false, features = ["crossterm", regex = "1" pulldown-cmark = "0.12" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" +flate2 = "1" +tar = "0.4" thiserror = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "process", "io-util"] } unicode-width = "0.1" +zip = { version = "2", default-features = false, features = ["deflate"] } [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index df776fe..f9f4225 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The project installs two equivalent commands, `cass` and `cassady`; examples use - v0.2.6 adds an experimental Rust embedding API for headless sessions; it is useful for early integrations but not yet a stable long-term library contract. - Config and conversation state live under `~/.cass`. - Windows binaries are built for releases, but deeper Windows terminal, path, shell, and filesystem polish is planned for a later release. -- Cassady is not an installer, updater, or package manager. +- `cass update` can update release-archive installs from official GitHub releases; external package managers should still be updated through their own tools. ## Install from source @@ -39,6 +39,7 @@ If Cassady cannot resolve a usable provider, model, or API key, it offers to run ```sh cass setup cass check +cass update --check cass ``` @@ -66,9 +67,10 @@ cass --resume cass --resume cass check cass setup +cass update ``` -`cass --resume` without an id lists saved chats for the current directory. When Cassady exits a chat, it prints a resume command for that session. +`cass --resume` without an id lists saved chats for the current directory. `cass update` checks official GitHub releases and can update both `cass` and `cassady` in the current install directory. When Cassady exits a chat, it prints a resume command for that session. Common in-chat commands: diff --git a/ROADMAP.md b/ROADMAP.md index 2d81155..3cb6809 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,38 @@ # Cassady (Cass) Roadmap +## v0.2.7 — Self-Update Command + +This release focuses on making Cassady easy to keep current after installation. The goal is to let users run one clean command, `cass update`, to check GitHub releases, choose the recommended prebuilt binary or a source-build fallback, verify what will be installed, and update both `cass` and `cassady` safely. See `plans/V0_2_7_SELF_UPDATE_COMMAND_PLAN.md`. + +### Update Command Experience + +- [x] **Add a polished `cass update` command.** Check the official Cassady GitHub releases, compare the running version to the selected release, and guide the user through an interactive update flow. + - Keep update independent of provider/model setup so it works even when config is missing or invalid. + - Support script-friendly checks with flags such as `--check`, `--dry-run`, and `--yes`. + +- [x] **Select the right update path.** Prefer a matching prebuilt release archive when available, with explicit `--prebuilt` and `--source` modes for users who want to choose. + - Support the same macOS, Linux, and Windows targets used by Cassady releases. + - Offer source builds for unsupported targets or users who prefer building locally. + +### Safe Installation + +- [x] **Verify and stage prebuilt artifacts before replacing binaries.** Download archives and SHA-256 files from the release, verify checksums, extract safely, and validate staged `cass`/`cassady` binaries. + - Reject checksum mismatches, unsafe archive paths, missing binaries, and unexpected versions. + - Show clear progress and failure messages without dumping raw implementation details. + +- [x] **Replace installed binaries cleanly.** Update the current binary and same-directory companion binary when possible, using backups and rollback on failure. + - Do not auto-run `sudo` or administrator prompts. + - Handle Windows executable replacement with a staged helper or documented manual fallback if necessary. + +### Source Build Fallback and Documentation + +- [x] **Build from release source when requested.** Download the selected release source, validate its version, check for Rust tooling, run a locked release build, and install the resulting local binaries. + - Keep source mode tied to release tags rather than arbitrary branches. + - Do not attempt cross-compilation or Rust toolchain installation in this release. + +- [x] **Document and test update behavior.** Update README and bundled docs for `cass update`, platform notes, troubleshooting, and package-manager caveats. + - Add tests for release parsing, target mapping, asset selection, checksum validation, archive extraction safety, install planning, and mocked update flows. + ## v0.2.6 — Rust Embedding API ✅ Completed This release focuses on adding the first intentional Rust library surface for embedding Cassady in other Rust projects. The goal is to provide the bones for programmatic, headless agent sessions: configure a workspace, start or resume an agent session, send turns, stream typed events, and handle approvals without launching the TUI. See `plans/V0_2_6_RUST_EMBEDDING_API_PLAN.md`. diff --git a/docs/README.md b/docs/README.md index 632073e..66095c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,12 +6,12 @@ Cassady tools may list, search, and read this directory. Mutating tools are bloc ## Contents -- [Commands](commands.md): CLI forms, global flags, in-chat commands, and keys. +- [Commands](commands.md): CLI forms, global flags, `cass update`, in-chat commands, and keys. - [Configuration](configuration.md): `~/.cass` files, setup, precedence, schema examples, and validation. - [Providers and models](providers.md): built-in OpenAI-compatible providers, custom endpoints, model discovery, and reasoning metadata. - [Access modes and tool safety](access-modes.md): what tools can read, write, edit, and run in each mode. - [Experimental Rust embedding API](embedding.md): import Cassady from Rust, start headless sessions, stream events, and handle approvals. - [Workflows](workflows.md): common ways to inspect code, apply edits, run checks, switch models, and resume chats. - [Troubleshooting](troubleshooting.md): symptoms, likely causes, fixes, and verification commands. -- [Platform notes](platforms.md): macOS, Linux, and Windows environment/path notes. +- [Platform notes](platforms.md): macOS, Linux, Windows, release artifact, and update notes. - [Glossary](glossary.md): short definitions for Cassady terms. diff --git a/docs/commands.md b/docs/commands.md index 193c427..fd8df24 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -9,10 +9,11 @@ cass [OPTIONS] cassady [OPTIONS] cass check [OPTIONS] cass setup [OPTIONS] +cass update [OPTIONS] cass --resume [CHAT_ID] ``` -Run `cass --help`, `cass check --help`, or `cass setup --help` for the help generated by the current binary. +Run `cass --help`, `cass check --help`, `cass setup --help`, or `cass update --help` for the help generated by the current binary. ## Startup behavior @@ -61,6 +62,32 @@ Missing API keys for inactive providers are warnings. A missing active API key i Runs the interactive setup wizard in a terminal. It configures OpenAI-compatible providers, API key environment-variable references, and first models. It updates `config.json`, `providers.json`, and `models.json` while preserving unrelated entries where possible. +### `cass update` + +Checks official GitHub releases for Cassady, including Cassady prereleases, and updates the current install directory. The updater runs before provider/model config is loaded, so it can be used even if `~/.cass` is missing or invalid. + +By default, Cassady selects the matching prebuilt archive for the current platform, downloads the archive and `.sha256` file, verifies SHA-256, stages both `cass` and `cassady`, and replaces same-directory binaries with rollback backups. If no prebuilt archive is available, it can build from the selected release source when you choose source mode. + +Useful options: + +- `--check`: check the selected release without installing. +- `--dry-run`: show the selected release, mode, asset, and install plan without downloading or installing. +- `--yes` / `-y`: accept default prompts for non-interactive use. +- `--prebuilt`: require a matching prebuilt archive. +- `--source`: build from release source even when a prebuilt archive exists. +- `--to TAG`: use a specific release tag such as `v0.2.7`. + +Examples: + +```sh +cass update --check +cass update --dry-run +cass update +cass update --source +``` + +The updater does not invoke `sudo` or administrator prompts. If the install directory is not writable, rerun the update from an install location you own or update through the same package manager or manual process you originally used. + ## In-chat commands Type `/` to open command autocomplete. diff --git a/docs/platforms.md b/docs/platforms.md index 7152e8e..6f42c47 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -52,9 +52,22 @@ That directory contains `config.json`, `providers.json`, `models.json`, `global. ## Non-interactive contexts - `cass check` is suitable for scripts and CI because it prints text and exits non-zero on errors. +- `cass update --check` and `cass update --dry-run` are suitable for scripts that only need release status or an install plan. +- `cass update --yes` accepts default prompts for scripted updates, but still fails instead of escalating privileges when the install directory is not writable. - `cass setup` requires an interactive terminal. - `cass` chat is an interactive terminal UI. -## Release artifacts +## Release artifacts and updates -When using release archives, each archive contains both `cass` and `cassady`. Put the extracted binaries somewhere on your `PATH` or run them by explicit path. Cassady itself does not install, update, or manage PATH entries. +When using release archives, each archive contains both `cass` and `cassady`. Put the extracted binaries somewhere on your `PATH` or run them by explicit path. + +`cass update` can update release-archive installs from official GitHub releases. It supports the same prebuilt targets as the release process: + +- macOS Apple Silicon: `aarch64-apple-darwin` +- Linux x86_64: `x86_64-unknown-linux-gnu` +- Linux ARM64: `aarch64-unknown-linux-gnu` +- Windows x86_64: `x86_64-pc-windows-gnu` + +On macOS and Linux, the updater replaces same-directory `cass` and `cassady` binaries with backups and rollback on failure. On Windows, replacing a running `.exe` is more constrained; if automatic replacement is unavailable, Cassady leaves staged files in place and reports manual copy guidance instead of partially modifying the install. + +If Cassady is installed through a package manager in the future, prefer that package manager's update command instead of `cass update`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e1ad176..8280571 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -143,6 +143,29 @@ Likely cause: Windows CRLF line endings or invisible whitespace differences. Fix: re-read the exact target region and preserve the line endings in `old_text`, or use a smaller unique snippet. +## Update command problems + +Symptom: `cass update` cannot complete. + +Likely causes and fixes: + +- Network or GitHub API failure: retry later or verify proxy/firewall settings. +- No matching prebuilt archive: use `cass update --source` if you have Rust installed, or download the release archive manually for a supported target. +- SHA-256 mismatch: do not install the archive. Retry the update; if it repeats, check the GitHub release page before proceeding. +- Missing Rust toolchain in source mode: install Rust/Cargo yourself, then rerun `cass update --source`. Cassady does not install Rust automatically. +- Non-writable install directory: update through the original install method, move Cassady to a directory you own, or manually replace the binaries. Cassady does not run `sudo` for you. +- PATH conflict: `cass update` updates the current executable directory. Run `which cass` / `which cassady` on macOS/Linux or `Get-Command cass` in PowerShell to confirm which binary your shell starts. +- Windows replacement limitation: if Cassady reports that automatic replacement is unavailable, use the staged file paths it prints and copy them after the running process exits. + +Useful checks: + +```sh +cass update --check +cass update --dry-run +cass --version +cassady --version +``` + ## Terminal rendering problems Symptom: the UI appears garbled or keys do not behave as expected. diff --git a/plans/V0_2_7_SELF_UPDATE_COMMAND_PLAN.md b/plans/V0_2_7_SELF_UPDATE_COMMAND_PLAN.md new file mode 100644 index 0000000..5398bec --- /dev/null +++ b/plans/V0_2_7_SELF_UPDATE_COMMAND_PLAN.md @@ -0,0 +1,390 @@ +# v0.2.7 Self-Update Command Implementation Plan + +## Goal + +v0.2.7 adds a polished `cass update` command that can update Cassady from official GitHub releases without requiring users to manually download archives, verify checksums, unpack binaries, or rebuild from source. + +Success statement: + +> A user can run `cass update`, see the available release, choose the recommended prebuilt binary or a source build fallback, and finish with updated `cass` and `cassady` commands in the same install location. + +## Scope + +### In scope + +- Add a `cass update` / `cassady update` subcommand. +- Query official Cassady GitHub releases from `owenqwenstarsky/cassady`. +- Compare the current binary version with the latest stable release. +- Download and install the matching prebuilt archive when available. +- Verify prebuilt archives with the shipped `.sha256` files before installing. +- Offer a source-build path that downloads release source for the selected tag and builds local binaries. +- Update both shipped binaries, `cass` and `cassady`, when possible. +- Use interactive prompts by default with clear summaries, confirmations, progress, success, and recovery messages. +- Provide non-interactive flags for check-only and yes-to-prompts usage. +- Keep `cass update` independent of model/provider setup so updates work even when `~/.cass` is missing or broken. +- Add tests for release parsing, target detection, asset selection, checksum validation, archive extraction safety, and install planning. +- Update README and bundled docs. + +### Out of scope + +- Publishing through Homebrew, apt, winget, Scoop, npm, or other package managers. +- Automatic background updates or prompts during normal chat startup. +- Updating Cassady when it was installed by an external package manager that should own the install directory. +- Privilege escalation, `sudo` automation, or administrator prompts. +- Code signing, notarization, or signature verification beyond existing SHA-256 files. +- Downgrading by default. Installing an older tag should require an explicit flag if supported. +- Cross-compiling in source mode. Source builds target the current host platform only. + +## Context and Current State + +Relevant files: + +- `Cargo.toml`: package version and two binaries, `cass` and `cassady`. +- `src/cli.rs`: Clap command definitions currently include `check` and `setup`. +- `src/app.rs`: top-level command dispatch; update should run before setup/config loading. +- `src/main.rs` and `src/bin/cassady.rs`: both call `cassady::run()`. +- `README.md` and `docs/commands.md`: command documentation. +- `docs/platforms.md` and `docs/troubleshooting.md`: platform and recovery guidance. +- `AGENTS.md`: release artifacts use these names: + - `cassady-vX.Y.Z-aarch64-apple-darwin.tar.gz` + - `cassady-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz` + - `cassady-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz` + - `cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip` + +Current releases include both `cass` and `cassady` in each archive plus one `.sha256` file per archive. The update command should reuse that release contract instead of inventing a new distribution format. + +## Design Principles + +1. **Boring and recoverable.** Updating should be explicit, easy to understand, and safe to interrupt before installation starts. +2. **Use official release artifacts first.** Prefer prebuilt archives with SHA-256 verification; fall back to source builds when the user asks or no asset matches. +3. **No surprise setup coupling.** Users should not need a configured provider, model, or API key to update the CLI. +4. **Respect install ownership.** Do not auto-escalate privileges or overwrite package-manager-owned paths without clear user confirmation. +5. **Both command names stay aligned.** If the user has both `cass` and `cassady` in the install directory, update them together. +6. **Interactive by default, scriptable when requested.** The normal path should be friendly; flags should support CI/check scripts. +7. **Fail closed on integrity.** Missing or mismatched checksums for prebuilt artifacts must stop installation. + +## User Experience + +### Default interactive flow + +```text +$ cass update +Cassady update + +Current version: v0.2.6 +Latest release: v0.2.7 +Install path: /usr/local/bin +Recommended: prebuilt aarch64-apple-darwin archive + +Update Cassady to v0.2.7? [Y/n] +``` + +If the user accepts, Cassady should show concise phases: + +```text +Downloading cassady-v0.2.7-aarch64-apple-darwin.tar.gz ... 8.4 MB +Downloading cassady-v0.2.7-aarch64-apple-darwin.tar.gz.sha256 ... done +Verifying SHA-256 ... ok +Preparing cass and cassady ... ok +Installing to /usr/local/bin ... ok +Verifying installed version ... cass 0.2.7 + +Cassady is up to date. +``` + +If the current version is already latest: + +```text +Cassady is already up to date. +Current version: v0.2.7 +Latest release: v0.2.7 +``` + +### Prebuilt or source selection + +The default `auto` mode should choose the prebuilt release asset when a supported target is detected. If no matching prebuilt exists, prompt for source mode: + +```text +No prebuilt archive is available for this platform. +Build Cassady v0.2.7 from source instead? [Y/n] +``` + +If both paths are available and the user asks for source mode: + +```sh +cass update --source +``` + +Cassady should confirm prerequisites before building: + +```text +Source build requires cargo, rustc, and a working C toolchain. +Build Cassady v0.2.7 from release source now? [Y/n] +``` + +### Useful flags + +Add a command shape like: + +```sh +cass update [OPTIONS] +``` + +Suggested options: + +- `--check`: check GitHub for the latest release and print status without installing. +- `--yes`: accept default prompts for non-interactive use. +- `--prebuilt`: require a matching prebuilt archive; fail instead of falling back to source. +- `--source`: build from release source even when a prebuilt archive exists. +- `--to TAG`: install a specific release tag such as `v0.2.7`. +- `--dry-run`: resolve the release, target, assets, and install path without downloading or installing. + +Optional later flags, only if implementation needs them: + +- `--stable-only`: ignore prerelease tags during latest-release selection if Cassady later publishes both stable and prerelease channels. +- `--install-dir PATH`: install into an explicit directory. This should be advanced and carefully documented because it can conflict with PATH order. + +Avoid adding a public `--repo` override unless needed for testing; tests can inject a mock client instead. + +## Design + +### Module layout + +Add a focused update module: + +```rust +pub mod update; +``` + +Suggested internal types: + +```rust +pub struct UpdateOptions { ... } +pub enum UpdateMode { Auto, Prebuilt, Source } +pub struct ReleaseInfo { ... } +pub struct ReleaseAsset { ... } +pub struct PlatformTarget { ... } +pub struct UpdatePlan { ... } +pub enum InstallAction { Replace, AddCompanion, SkipMissingCompanion } +``` + +`src/cli.rs` should add an `Update` subcommand with parsed flags. `src/app.rs` should dispatch it before setup/config loading: + +```rust +if let Some(Command::Update(args)) = cli.command { + return crate::update::run(args).await; +} +``` + +This keeps update usable even when `Config::load()` would fail. + +### GitHub release discovery + +Use the GitHub Releases API with an explicit user agent: + +- Latest release: `GET https://api.github.com/repos/owenqwenstarsky/cassady/releases?per_page=30` and choose the highest semver non-draft tag, including prereleases because Cassady's current release process marks releases as prereleases. +- Specific tag: `GET https://api.github.com/repos/owenqwenstarsky/cassady/releases/tags/{tag}` + +Parse: + +- `tag_name` +- `name` +- `draft` +- `prerelease` +- `assets[].name` +- `assets[].browser_download_url` +- `assets[].size` +- `tarball_url` or `zipball_url` for source mode + +Use `semver` to compare `env!("CARGO_PKG_VERSION")` with release tags after stripping a leading `v`. Draft releases should never be selected. Prereleases should be eligible by default while Cassady's official releases are marked as prereleases. + +### Platform target mapping + +Map the running platform to release asset targets: + +| OS | Arch | Target | Archive | +| --- | --- | --- | --- | +| macOS | `aarch64` | `aarch64-apple-darwin` | `.tar.gz` | +| Linux | `x86_64` | `x86_64-unknown-linux-gnu` | `.tar.gz` | +| Linux | `aarch64` | `aarch64-unknown-linux-gnu` | `.tar.gz` | +| Windows | `x86_64` | `x86_64-pc-windows-gnu` | `.zip` | + +Unsupported platforms should produce a clean message and offer source mode when possible. + +### Prebuilt update path + +For tag `vX.Y.Z` and target `TARGET`, find: + +```text +cassady-vX.Y.Z-TARGET.tar.gz +cassady-vX.Y.Z-TARGET.tar.gz.sha256 +``` + +or on Windows: + +```text +cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip +cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip.sha256 +``` + +Flow: + +1. Download archive and checksum into a temporary staging directory. +2. Parse the `.sha256` file and verify that the checksum filename matches the downloaded archive name. +3. Compute SHA-256 of the archive and compare exactly. +4. Extract into staging using path traversal checks. +5. Require the expected binaries: + - Unix: `cass`, `cassady` + - Windows: `cass.exe`, `cassady.exe` +6. Run the staged `cass --version` or `cassady --version` when possible and confirm the expected version. +7. Build an install plan for the current executable directory. +8. Confirm the final plan with the user unless `--yes` was supplied. +9. Replace binaries with backups and rollback on failure. +10. Verify installed version after replacement when possible. + +Archive extraction must reject absolute paths, `..` components, symlinks that escape staging, and unexpected top-level layouts. + +### Source-build update path + +Source mode should still be tied to a GitHub release tag, not an arbitrary branch. + +Flow: + +1. Resolve the selected release tag. +2. Download release source from `tarball_url` or `zipball_url` into staging. +3. Extract with the same path traversal protections as prebuilt archives. +4. Verify `Cargo.toml` version matches the selected tag. +5. Run: + + ```sh + cargo build --release --locked --bins + ``` + + from the extracted source tree. +6. Locate built binaries under `target/release/`. +7. Run staged `--version` checks. +8. Install using the same installer path as prebuilt updates. + +Before source mode starts, check for `cargo` and `rustc` on PATH and show a clear error if they are missing. Do not attempt to install Rust automatically. + +### Install planning and replacement + +Determine the current executable path with `std::env::current_exe()`, then derive the install directory. The install plan should include: + +- current binary path +- sibling `cass` path +- sibling `cassady` path +- which binaries currently exist +- which binaries are writable +- whether companion binaries will be updated, added, skipped, or blocked + +Recommended behavior: + +- Always update the currently running binary name. +- If the sibling binary exists in the same directory, update it too. +- If the sibling binary is missing and the directory is writable, ask whether to install it. +- If a target path is not writable, stop with an actionable message. Do not invoke `sudo` or administrator prompts automatically. +- Use backups such as `.cass-update-backup-v0.2.6-` during replacement. +- If any replacement fails, restore backups before returning an error. + +Unix can generally replace a running executable via atomic rename. Windows cannot reliably overwrite the running `.exe`; implement one of these approaches during coding: + +1. Preferred: stage replacements and spawn a small PowerShell or `cmd` helper that waits for the current process to exit, moves files into place, and writes a log. +2. Fallback: stage replacements and print exact manual copy commands if helper launch is unavailable. + +Document any Windows limitation honestly in `docs/platforms.md` and `docs/troubleshooting.md`. + +### Output and error style + +Keep output concise and user-facing: + +- Show current version, target version, install directory, selected mode, and asset/source name before changing files. +- Show clear phase lines for download, verify, build, install, and final verification. +- On failure, say whether anything was changed and where staging/backups are located. +- If update cannot proceed because the install path is not writable, tell the user which path failed and suggest reinstalling through the same method they originally used. + +Avoid dumping raw GitHub JSON, backtraces, or Cargo logs unless the source build fails; in that case, preserve the final relevant Cargo output and staging path. + +## Dependencies + +Likely additions to `Cargo.toml`: + +- `semver` for version comparison. +- `sha2` for SHA-256 verification. +- `tar` and `flate2` for `.tar.gz` extraction. +- `zip` for Windows release archives and GitHub source zips if used. + +Prefer small, well-maintained crates. Reuse existing `reqwest`, `tokio`, `serde`, and `serde_json`. + +## Implementation Steps + +1. Add CLI parsing for `cass update` and dispatch it before setup/config loading. +2. Add `src/update.rs` with release API types, version comparison, and target detection. +3. Implement GitHub release fetching with a testable client abstraction or injectable base URL for tests. +4. Implement asset selection for current platform and update mode. +5. Implement download, progress reporting, and checksum verification for prebuilt archives. +6. Implement safe archive extraction and staged binary validation. +7. Implement install planning from `current_exe()` and companion binary detection. +8. Implement Unix replacement with backups and rollback. +9. Implement Windows staged-helper replacement or a clearly documented manual fallback. +10. Implement source mode: source download, version validation, prerequisite checks, `cargo build --release --locked --bins`, and staged binary validation. +11. Polish interactive prompts and `--check`, `--dry-run`, `--yes`, `--prebuilt`, `--source`, and `--to` behavior. +12. Update docs and release notes template expectations if needed. +13. Add tests and run full verification. + +## Tests + +Add focused unit tests for: + +- parsing `vX.Y.Z` tags and comparing against the current version shape +- ignoring drafts and prereleases where applicable +- mapping supported and unsupported platform targets +- matching asset and checksum filenames +- parsing `.sha256` lines generated by the release process +- rejecting checksum filename mismatches and digest mismatches +- rejecting archive path traversal entries +- planning installation when only `cass`, only `cassady`, or both binaries exist +- refusing non-writable install targets in planning or dry-run mode +- source mode validating that `Cargo.toml` version matches the selected tag + +Add integration-style tests with a mock HTTP server for: + +- already-up-to-date response +- latest prebuilt update plan +- missing prebuilt with source fallback prompt path, where build execution can be mocked +- download checksum mismatch failure +- successful staged install into a temporary directory using fake binaries + +Manual checks: + +```sh +cargo fmt +cargo test --locked --all-targets +cargo run -- update --check +cargo run -- update --dry-run --to v0.2.7 +``` + +For a real release candidate, test from a temporary install directory before using `cass update` on the developer's normal binary. + +## Documentation + +Update: + +- `README.md`: mention `cass update` in install/update and everyday command sections. +- `docs/commands.md`: full command reference, flags, interactivity, examples, and exit behavior. +- `docs/platforms.md`: platform-specific update support and Windows replacement notes. +- `docs/troubleshooting.md`: network failures, checksum mismatch, no matching prebuilt, missing Rust toolchain, non-writable install directory, PATH conflicts, and rollback recovery. +- `docs/README.md`: add any new update-related links or summaries. + +Document that users should prefer the package manager's update mechanism if Cassady was installed through a package manager in the future. + +## Acceptance Criteria + +- `cass update --check` reports the current/latest release without reading provider config. +- `cass update --dry-run` shows the selected release, mode, asset/source, and install plan without modifying files. +- On supported release targets, `cass update` can download the matching official archive, verify SHA-256, stage both binaries, and update the current install directory. +- `cass update --source` can download release source, build with `cargo build --release --locked --bins`, and install the resulting local binaries. +- Checksum mismatch, missing assets, unsupported platforms, missing Rust toolchain, and non-writable install paths fail with clear messages and no partial install. +- Existing `cass` and `cassady` sibling binaries remain version-aligned after a successful update. +- README and bundled docs explain the command accurately. +- `cargo fmt` and `cargo test --locked --all-targets` pass. diff --git a/src/app.rs b/src/app.rs index a5d3f02..ff727f8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -21,6 +21,10 @@ const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by user."; pub async fn run() -> Result<()> { let mut cli = cli::parse(); + if let Some(Command::Update(args)) = cli.command.clone() { + return crate::update::run(args).await; + } + if matches!(cli.command, Some(Command::Check)) { let report = crate::check::run(&cli)?; print!("{}", report.render()); diff --git a/src/cli.rs b/src/cli.rs index e8a86b4..8c5d4db 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; use std::path::PathBuf; #[derive(Debug, Parser, Clone)] @@ -46,6 +46,35 @@ pub enum Command { Check, /// Configure an OpenAI-compatible provider and first model. Setup, + /// Update Cassady from official GitHub releases. + Update(UpdateArgs), +} + +#[derive(Debug, Args, Clone, PartialEq, Eq)] +pub struct UpdateArgs { + /// Check the latest release without installing. + #[arg(long)] + pub check: bool, + + /// Show what would be updated without downloading or installing. + #[arg(long)] + pub dry_run: bool, + + /// Accept default prompts for non-interactive use. + #[arg(long, short = 'y')] + pub yes: bool, + + /// Require a matching prebuilt archive and do not fall back to source. + #[arg(long, conflicts_with = "source")] + pub prebuilt: bool, + + /// Build from release source even when a prebuilt archive exists. + #[arg(long, conflicts_with = "prebuilt")] + pub source: bool, + + /// Install a specific release tag, such as v0.2.7. + #[arg(long, value_name = "TAG")] + pub to: Option, } pub fn parse() -> Cli { diff --git a/src/lib.rs b/src/lib.rs index 99ec8b2..b7edb08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod security; pub mod setup; pub mod tools; pub mod ui; +pub mod update; pub async fn run() -> anyhow::Result<()> { app::run().await diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..7b32d1e --- /dev/null +++ b/src/update.rs @@ -0,0 +1,1255 @@ +use crate::cli::UpdateArgs; +use anyhow::{anyhow, bail, Context, Result}; +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::ffi::OsStr; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::Stdio; +use std::time::{SystemTime, UNIX_EPOCH}; + +const OWNER: &str = "owenqwenstarsky"; +const REPO: &str = "cassady"; +const GITHUB_API: &str = "https://api.github.com"; +const USER_AGENT: &str = concat!("cassady-updater/", env!("CARGO_PKG_VERSION")); +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UpdateMode { + Auto, + Prebuilt, + Source, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ArchiveKind { + TarGz, + Zip, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlatformTarget { + triple: &'static str, + archive: ArchiveKind, + binary_suffix: &'static str, +} + +#[derive(Debug, Clone, Deserialize)] +struct GitHubRelease { + tag_name: String, + name: Option, + draft: bool, + prerelease: bool, + assets: Vec, + tarball_url: Option, + zipball_url: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, + size: u64, +} + +#[derive(Debug, Clone)] +struct SelectedAsset { + archive: GitHubAsset, + checksum: GitHubAsset, + target: PlatformTarget, +} + +#[derive(Debug, Clone)] +struct UpdateSelection { + mode: UpdateMode, + prebuilt: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InstallActionKind { + Replace, + AddCompanion, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InstallAction { + name: String, + staged_path: PathBuf, + target_path: PathBuf, + kind: InstallActionKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InstallPlan { + current_exe: PathBuf, + install_dir: PathBuf, + actions: Vec, +} + +#[derive(Debug, Clone)] +struct StagedBinaries { + cass: PathBuf, + cassady: PathBuf, +} + +pub async fn run(args: UpdateArgs) -> Result<()> { + let mode = mode_from_args(&args); + let current = current_version()?; + let client = reqwest::Client::builder().user_agent(USER_AGENT).build()?; + + println!("Cassady update\n"); + + let Some(release) = fetch_release(&client, args.to.as_deref()).await? else { + println!("No published GitHub releases were found for {OWNER}/{REPO}."); + return Ok(()); + }; + validate_release(&release)?; + let target_version = parse_tag_version(&release.tag_name)?; + + println!("Current version: v{}", current); + let prerelease_label = if release.prerelease { + " (prerelease)" + } else { + "" + }; + if let Some(name) = release.name.as_deref() { + println!( + "Latest release: {}{} ({name})", + release.tag_name, prerelease_label + ); + } else { + println!("Latest release: {}{}", release.tag_name, prerelease_label); + } + + if target_version < current { + if args.to.is_some() { + bail!( + "selected release {} is older than the current version v{}; downgrades are not supported by cass update", + release.tag_name, + current + ); + } + println!("\nCurrent version is newer than the latest published release."); + return Ok(()); + } + + if target_version == current { + println!("\nCassady is already up to date."); + return Ok(()); + } + + if args.check { + println!("\nUpdate available: {}", release.tag_name); + return Ok(()); + } + + let selection = select_update_path(&release, mode, args.yes, args.dry_run)?; + let install_plan = plan_current_install(None, None)?; + + print_selection_summary(&release, &selection, &install_plan); + + if args.dry_run { + println!("\nDry run only; no files were changed."); + return Ok(()); + } + + confirm_or_bail( + &format!("Update Cassady to {}?", release.tag_name), + args.yes, + )?; + + match selection.mode { + UpdateMode::Prebuilt | UpdateMode::Auto => { + let selected = selection + .prebuilt + .as_ref() + .ok_or_else(|| anyhow!("no prebuilt update was selected"))?; + run_prebuilt_update(&client, &release, selected, &install_plan).await?; + } + UpdateMode::Source => { + run_source_update(&client, &release, &install_plan, args.yes).await?; + } + } + + println!("\nCassady is up to date."); + Ok(()) +} + +fn mode_from_args(args: &UpdateArgs) -> UpdateMode { + if args.prebuilt { + UpdateMode::Prebuilt + } else if args.source { + UpdateMode::Source + } else { + UpdateMode::Auto + } +} + +async fn fetch_release( + client: &reqwest::Client, + tag: Option<&str>, +) -> Result> { + if let Some(tag) = tag { + let url = format!("{GITHUB_API}/repos/{OWNER}/{REPO}/releases/tags/{tag}"); + let response = client + .get(&url) + .send() + .await + .with_context(|| format!("fetching release metadata from {url}"))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if status == reqwest::StatusCode::NOT_FOUND { + bail!("GitHub release tag {tag} was not found for {OWNER}/{REPO}"); + } + bail!( + "GitHub release request failed with {status}: {}", + trim_for_error(&body) + ); + } + return response + .json::() + .await + .context("parsing GitHub release metadata") + .map(Some); + } + + // GitHub's /releases/latest endpoint ignores prereleases and returns 404 when + // a project only publishes prereleases. Cassady's current release process uses + // prereleases, so list releases and choose the highest semver non-draft tag. + let url = format!("{GITHUB_API}/repos/{OWNER}/{REPO}/releases?per_page=30"); + let response = client + .get(&url) + .send() + .await + .with_context(|| format!("fetching release metadata from {url}"))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + bail!( + "GitHub release request failed with {status}: {}", + trim_for_error(&body) + ); + } + let releases = response + .json::>() + .await + .context("parsing GitHub release list")?; + Ok(select_latest_release(releases)) +} + +fn select_latest_release(releases: Vec) -> Option { + releases + .into_iter() + .filter(|release| !release.draft) + .filter_map(|release| { + parse_tag_version(&release.tag_name) + .ok() + .map(|version| (version, release)) + }) + .max_by(|(left, _), (right, _)| left.cmp(right)) + .map(|(_, release)| release) +} + +fn validate_release(release: &GitHubRelease) -> Result<()> { + if release.draft { + bail!("release {} is still a draft", release.tag_name); + } + Ok(()) +} + +fn current_version() -> Result { + Version::parse(CURRENT_VERSION).context("parsing current Cassady version") +} + +fn parse_tag_version(tag: &str) -> Result { + let raw = tag.strip_prefix('v').unwrap_or(tag); + Version::parse(raw).with_context(|| format!("parsing release tag {tag}")) +} + +fn select_update_path( + release: &GitHubRelease, + mode: UpdateMode, + assume_yes: bool, + dry_run: bool, +) -> Result { + let platform = current_platform(); + let prebuilt = platform + .as_ref() + .and_then(|target| select_prebuilt_asset(release, target).transpose()) + .transpose()?; + + match mode { + UpdateMode::Prebuilt => { + let prebuilt = prebuilt.ok_or_else(|| { + anyhow!( + "no matching prebuilt archive is available for this platform ({}/{})", + std::env::consts::OS, + std::env::consts::ARCH + ) + })?; + Ok(UpdateSelection { + mode: UpdateMode::Prebuilt, + prebuilt: Some(prebuilt), + }) + } + UpdateMode::Source => Ok(UpdateSelection { + mode: UpdateMode::Source, + prebuilt, + }), + UpdateMode::Auto => { + if let Some(prebuilt) = prebuilt { + Ok(UpdateSelection { + mode: UpdateMode::Prebuilt, + prebuilt: Some(prebuilt), + }) + } else { + println!( + "\nNo prebuilt archive is available for this platform ({}/{}).", + std::env::consts::OS, + std::env::consts::ARCH + ); + if !dry_run { + confirm_or_bail("Build Cassady from release source instead?", assume_yes)?; + } + Ok(UpdateSelection { + mode: UpdateMode::Source, + prebuilt: None, + }) + } + } + } +} + +fn current_platform() -> Option { + platform_target(std::env::consts::OS, std::env::consts::ARCH) +} + +fn platform_target(os: &str, arch: &str) -> Option { + match (os, arch) { + ("macos", "aarch64") => Some(PlatformTarget { + triple: "aarch64-apple-darwin", + archive: ArchiveKind::TarGz, + binary_suffix: "", + }), + ("linux", "x86_64") => Some(PlatformTarget { + triple: "x86_64-unknown-linux-gnu", + archive: ArchiveKind::TarGz, + binary_suffix: "", + }), + ("linux", "aarch64") => Some(PlatformTarget { + triple: "aarch64-unknown-linux-gnu", + archive: ArchiveKind::TarGz, + binary_suffix: "", + }), + ("windows", "x86_64") => Some(PlatformTarget { + triple: "x86_64-pc-windows-gnu", + archive: ArchiveKind::Zip, + binary_suffix: ".exe", + }), + _ => None, + } +} + +fn select_prebuilt_asset( + release: &GitHubRelease, + target: &PlatformTarget, +) -> Result> { + let ext = match target.archive { + ArchiveKind::TarGz => ".tar.gz", + ArchiveKind::Zip => ".zip", + }; + let archive_name = format!("cassady-{}-{}{}", release.tag_name, target.triple, ext); + let checksum_name = format!("{archive_name}.sha256"); + + let archive = release + .assets + .iter() + .find(|asset| asset.name == archive_name); + let checksum = release + .assets + .iter() + .find(|asset| asset.name == checksum_name); + + match (archive, checksum) { + (Some(archive), Some(checksum)) => Ok(Some(SelectedAsset { + archive: archive.clone(), + checksum: checksum.clone(), + target: target.clone(), + })), + (None, _) => Ok(None), + (Some(_), None) => { + bail!("release asset {archive_name} is missing checksum file {checksum_name}") + } + } +} + +fn print_selection_summary( + release: &GitHubRelease, + selection: &UpdateSelection, + plan: &InstallPlan, +) { + println!("Install path: {}", plan.install_dir.display()); + match selection.mode { + UpdateMode::Source => println!("Recommended: source build from {}", release.tag_name), + UpdateMode::Auto | UpdateMode::Prebuilt => { + if let Some(prebuilt) = &selection.prebuilt { + println!( + "Recommended: prebuilt {} archive", + prebuilt.target.triple + ); + println!("Asset: {}", prebuilt.archive.name); + } + } + } + + println!("\nInstall plan:"); + for action in &plan.actions { + let label = match action.kind { + InstallActionKind::Replace => "replace", + InstallActionKind::AddCompanion => "add", + }; + println!( + "- {label} {} at {}", + action.name, + action.target_path.display() + ); + } +} + +async fn run_prebuilt_update( + client: &reqwest::Client, + release: &GitHubRelease, + selected: &SelectedAsset, + plan: &InstallPlan, +) -> Result<()> { + let staging = create_staging_dir(&release.tag_name)?; + println!("\nStaging update in {}", staging.display()); + + let archive_path = staging.join(&selected.archive.name); + let checksum_path = staging.join(&selected.checksum.name); + + download_asset(client, &selected.archive, &archive_path).await?; + download_asset(client, &selected.checksum, &checksum_path).await?; + + print!("Verifying SHA-256 ... "); + io::stdout().flush().ok(); + verify_checksum_file(&archive_path, &checksum_path)?; + println!("ok"); + + let extract_dir = staging.join("extract"); + fs::create_dir_all(&extract_dir)?; + print!("Preparing cass and cassady ... "); + io::stdout().flush().ok(); + match selected.target.archive { + ArchiveKind::TarGz => safe_extract_tgz(&archive_path, &extract_dir)?, + ArchiveKind::Zip => safe_extract_zip(&archive_path, &extract_dir)?, + } + let staged = find_staged_binaries(&extract_dir, selected.target.binary_suffix)?; + validate_staged_version(&staged, &release.tag_name)?; + println!("ok"); + + let install_plan = plan_current_install(Some(&staged), Some(&plan.current_exe))?; + install_staged_binaries(&install_plan)?; + verify_installed_version(&install_plan, &release.tag_name)?; + Ok(()) +} + +async fn run_source_update( + client: &reqwest::Client, + release: &GitHubRelease, + plan: &InstallPlan, + assume_yes: bool, +) -> Result<()> { + confirm_or_bail( + &format!( + "Source build requires cargo, rustc, and a working C toolchain. Build Cassady {} from source now?", + release.tag_name + ), + assume_yes, + )?; + ensure_command_available("cargo")?; + ensure_command_available("rustc")?; + + let source_url = release + .tarball_url + .as_ref() + .or(release.zipball_url.as_ref()) + .ok_or_else(|| { + anyhow!( + "release {} does not include a source archive URL", + release.tag_name + ) + })?; + let is_zip = source_url.ends_with(".zip") || release.tarball_url.is_none(); + + let staging = create_staging_dir(&release.tag_name)?; + println!("\nStaging source build in {}", staging.display()); + let source_archive = staging.join(if is_zip { + "source.zip" + } else { + "source.tar.gz" + }); + download_url(client, source_url, &source_archive, "release source").await?; + + let source_extract = staging.join("source"); + fs::create_dir_all(&source_extract)?; + print!("Extracting release source ... "); + io::stdout().flush().ok(); + if is_zip { + safe_extract_zip(&source_archive, &source_extract)?; + } else { + safe_extract_tgz(&source_archive, &source_extract)?; + } + println!("ok"); + + let source_root = find_source_root(&source_extract)?; + verify_source_version(&source_root.join("Cargo.toml"), &release.tag_name)?; + + println!("Building Cassady {} from source ...", release.tag_name); + let output = tokio::process::Command::new("cargo") + .arg("build") + .arg("--release") + .arg("--locked") + .arg("--bins") + .current_dir(&source_root) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .context("running cargo build --release --locked --bins")?; + if !output.status.success() { + bail!( + "source build failed; staging directory kept at {}\n{}", + staging.display(), + trim_for_error(&String::from_utf8_lossy(&output.stderr)) + ); + } + + let suffix = if cfg!(windows) { ".exe" } else { "" }; + let built = StagedBinaries { + cass: source_root + .join("target") + .join("release") + .join(format!("cass{suffix}")), + cassady: source_root + .join("target") + .join("release") + .join(format!("cassady{suffix}")), + }; + if !built.cass.is_file() || !built.cassady.is_file() { + bail!("source build did not produce both cass and cassady binaries"); + } + validate_staged_version(&built, &release.tag_name)?; + + let install_plan = plan_current_install(Some(&built), Some(&plan.current_exe))?; + install_staged_binaries(&install_plan)?; + verify_installed_version(&install_plan, &release.tag_name)?; + Ok(()) +} + +async fn download_asset(client: &reqwest::Client, asset: &GitHubAsset, dest: &Path) -> Result<()> { + let label = if asset.size > 0 { + format!("{} ({})", asset.name, human_bytes(asset.size)) + } else { + asset.name.clone() + }; + download_url(client, &asset.browser_download_url, dest, &label).await +} + +async fn download_url(client: &reqwest::Client, url: &str, dest: &Path, label: &str) -> Result<()> { + print!("Downloading {label} ... "); + io::stdout().flush().ok(); + let response = client + .get(url) + .send() + .await + .with_context(|| format!("downloading {label}"))?; + if !response.status().is_success() { + bail!("download failed for {label}: HTTP {}", response.status()); + } + let bytes = response.bytes().await?; + fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?; + println!("{}", human_bytes(bytes.len() as u64)); + Ok(()) +} + +fn create_staging_dir(tag: &str) -> Result { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let dir = + std::env::temp_dir().join(format!("cass-update-{tag}-{stamp}-{}", std::process::id())); + fs::create_dir_all(&dir) + .with_context(|| format!("creating staging directory {}", dir.display()))?; + Ok(dir) +} + +fn parse_checksum_file(contents: &str) -> Result<(String, String)> { + let line = contents + .lines() + .find(|line| !line.trim().is_empty()) + .ok_or_else(|| anyhow!("checksum file is empty"))?; + let mut parts = line.split_whitespace(); + let digest = parts + .next() + .ok_or_else(|| anyhow!("checksum file is missing digest"))?; + let filename = parts + .next() + .ok_or_else(|| anyhow!("checksum file is missing archive filename"))?; + if digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("checksum file contains an invalid SHA-256 digest"); + } + Ok((digest.to_ascii_lowercase(), filename.to_string())) +} + +fn verify_checksum_file(archive_path: &Path, checksum_path: &Path) -> Result<()> { + let contents = fs::read_to_string(checksum_path) + .with_context(|| format!("reading checksum file {}", checksum_path.display()))?; + let (expected_digest, expected_filename) = parse_checksum_file(&contents)?; + let archive_name = archive_path + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| anyhow!("archive path has no filename"))?; + if expected_filename != archive_name { + bail!("checksum file is for {expected_filename}, but downloaded archive is {archive_name}"); + } + let actual_digest = sha256_file(archive_path)?; + if actual_digest != expected_digest { + bail!("SHA-256 mismatch for {archive_name}"); + } + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buf = [0_u8; 64 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn safe_extract_tgz(archive: &Path, dest: &Path) -> Result<()> { + let file = File::open(archive).with_context(|| format!("opening {}", archive.display()))?; + let decoder = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + for entry in archive.entries()? { + let mut entry = entry?; + let kind = entry.header().entry_type(); + if kind.is_symlink() || kind.is_hard_link() { + bail!("archive contains a link entry, which is not allowed"); + } + if !(kind.is_file() || kind.is_dir()) { + continue; + } + let entry_path = entry.path()?.into_owned(); + let out = safe_join(dest, &entry_path)?; + if kind.is_dir() { + fs::create_dir_all(&out)?; + } else { + if let Some(parent) = out.parent() { + fs::create_dir_all(parent)?; + } + entry.unpack(&out)?; + } + } + Ok(()) +} + +fn safe_extract_zip(archive: &Path, dest: &Path) -> Result<()> { + let file = File::open(archive).with_context(|| format!("opening {}", archive.display()))?; + let mut archive = zip::ZipArchive::new(file)?; + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let enclosed = file + .enclosed_name() + .ok_or_else(|| anyhow!("zip archive contains an unsafe path: {}", file.name()))? + .to_path_buf(); + let out = safe_join(dest, &enclosed)?; + if file.is_dir() { + fs::create_dir_all(&out)?; + } else { + if let Some(parent) = out.parent() { + fs::create_dir_all(parent)?; + } + let mut out_file = File::create(&out)?; + io::copy(&mut file, &mut out_file)?; + #[cfg(unix)] + if let Some(mode) = file.unix_mode() { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&out, fs::Permissions::from_mode(mode))?; + } + } + } + Ok(()) +} + +fn safe_join(root: &Path, relative: &Path) -> Result { + if relative.is_absolute() { + bail!("archive contains an absolute path: {}", relative.display()); + } + let mut out = root.to_path_buf(); + for component in relative.components() { + match component { + Component::Normal(part) => out.push(part), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + bail!("archive path escapes destination: {}", relative.display()) + } + } + } + Ok(out) +} + +fn find_staged_binaries(root: &Path, suffix: &str) -> Result { + let cass_name = format!("cass{suffix}"); + let cassady_name = format!("cassady{suffix}"); + let cass = find_file_named(root, &cass_name)? + .ok_or_else(|| anyhow!("archive does not contain {cass_name}"))?; + let cassady = find_file_named(root, &cassady_name)? + .ok_or_else(|| anyhow!("archive does not contain {cassady_name}"))?; + Ok(StagedBinaries { cass, cassady }) +} + +fn find_file_named(root: &Path, name: &str) -> Result> { + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + stack.push(path); + } else if file_type.is_file() && entry.file_name() == OsStr::new(name) { + return Ok(Some(path)); + } + } + } + Ok(None) +} + +fn validate_staged_version(staged: &StagedBinaries, tag: &str) -> Result<()> { + let expected = parse_tag_version(tag)?; + let output = std::process::Command::new(&staged.cass) + .arg("--version") + .output() + .with_context(|| format!("running staged binary {} --version", staged.cass.display()))?; + if !output.status.success() { + bail!("staged cass binary failed --version check"); + } + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.contains(&expected.to_string()) { + bail!( + "staged cass binary reports unexpected version; expected {}, got {}", + expected, + stdout.trim() + ); + } + Ok(()) +} + +fn ensure_command_available(command: &str) -> Result<()> { + let output = std::process::Command::new(command) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("checking for {command} on PATH"))?; + if !output.success() { + bail!("{command} is installed but did not run successfully"); + } + Ok(()) +} + +fn find_source_root(root: &Path) -> Result { + let cargo = find_file_named(root, "Cargo.toml")? + .ok_or_else(|| anyhow!("source archive does not contain Cargo.toml"))?; + cargo + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| anyhow!("Cargo.toml has no parent directory")) +} + +fn verify_source_version(cargo_toml: &Path, tag: &str) -> Result<()> { + let expected = parse_tag_version(tag)?; + let contents = fs::read_to_string(cargo_toml) + .with_context(|| format!("reading {}", cargo_toml.display()))?; + let actual = parse_package_version_from_cargo_toml(&contents)?; + if actual != expected { + bail!( + "source Cargo.toml version {} does not match release {}", + actual, + tag + ); + } + Ok(()) +} + +fn parse_package_version_from_cargo_toml(contents: &str) -> Result { + let mut in_package = false; + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_package = trimmed == "[package]"; + continue; + } + if in_package && trimmed.starts_with("version") { + let Some((_, value)) = trimmed.split_once('=') else { + continue; + }; + let value = value.trim().trim_matches('"'); + return Version::parse(value).context("parsing package version from Cargo.toml"); + } + } + bail!("Cargo.toml is missing [package] version") +} + +fn plan_current_install( + staged: Option<&StagedBinaries>, + current_exe_override: Option<&Path>, +) -> Result { + let current_exe = current_exe_override + .map(Path::to_path_buf) + .unwrap_or(std::env::current_exe().context("resolving current executable path")?); + let install_dir = current_exe + .parent() + .ok_or_else(|| anyhow!("current executable has no parent directory"))? + .to_path_buf(); + build_install_plan(&install_dir, ¤t_exe, staged) +} + +fn build_install_plan( + install_dir: &Path, + current_exe: &Path, + staged: Option<&StagedBinaries>, +) -> Result { + let suffix = if cfg!(windows) { ".exe" } else { "" }; + let cass_target = install_dir.join(format!("cass{suffix}")); + let cassady_target = install_dir.join(format!("cassady{suffix}")); + let current_name = current_exe + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| anyhow!("current executable path has no filename"))?; + + let mut actions = Vec::new(); + for (name, target, staged_path) in [ + ("cass", cass_target, staged.map(|s| s.cass.clone())), + ("cassady", cassady_target, staged.map(|s| s.cassady.clone())), + ] { + let is_current = current_name + == target + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default(); + let exists = target.exists(); + if !is_current && !exists { + if staged_path.is_none() { + continue; + } + } + if !is_writable_target(&target)? { + bail!("install target is not writable: {}", target.display()); + } + let staged_path = staged_path.unwrap_or_else(|| PathBuf::from(format!(""))); + let kind = if exists { + InstallActionKind::Replace + } else { + InstallActionKind::AddCompanion + }; + actions.push(InstallAction { + name: name.to_string(), + staged_path, + target_path: target, + kind, + }); + } + + if actions.is_empty() { + bail!("nothing to install for {}", current_exe.display()); + } + + Ok(InstallPlan { + current_exe: current_exe.to_path_buf(), + install_dir: install_dir.to_path_buf(), + actions, + }) +} + +fn is_writable_target(path: &Path) -> Result { + if path.exists() { + match OpenOptions::new().write(true).open(path) { + Ok(_) => Ok(true), + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => Ok(false), + Err(err) => { + Err(err).with_context(|| format!("checking writability of {}", path.display())) + } + } + } else { + let Some(parent) = path.parent() else { + return Ok(false); + }; + Ok(!fs::metadata(parent)?.permissions().readonly()) + } +} + +#[cfg_attr(windows, allow(unused_variables))] +fn install_staged_binaries(plan: &InstallPlan) -> Result<()> { + #[cfg(windows)] + { + bail!( + "automatic replacement of the running Windows executable is not available yet; staged files are ready but must be copied manually" + ); + } + + #[cfg(not(windows))] + { + println!("Installing to {} ...", plan.install_dir.display()); + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut backups: Vec<(PathBuf, PathBuf)> = Vec::new(); + let mut installed: Vec = Vec::new(); + + let result = (|| -> Result<()> { + for action in &plan.actions { + if !action.staged_path.is_file() { + bail!("staged binary is missing: {}", action.staged_path.display()); + } + if action.target_path.exists() { + let backup = action + .target_path + .with_extension(format!("cass-update-backup-v{CURRENT_VERSION}-{stamp}")); + fs::rename(&action.target_path, &backup).with_context(|| { + format!( + "backing up {} to {}", + action.target_path.display(), + backup.display() + ) + })?; + backups.push((action.target_path.clone(), backup)); + } + fs::copy(&action.staged_path, &action.target_path).with_context(|| { + format!( + "copying {} to {}", + action.staged_path.display(), + action.target_path.display() + ) + })?; + installed.push(action.target_path.clone()); + } + Ok(()) + })(); + + if let Err(err) = result { + for path in installed.iter().rev() { + let _ = fs::remove_file(path); + } + for (target, backup) in backups.into_iter().rev() { + let _ = fs::rename(backup, target); + } + return Err(err.context("install failed; attempted to restore backups")); + } + println!("Installing to {} ... ok", plan.install_dir.display()); + Ok(()) + } +} + +fn verify_installed_version(plan: &InstallPlan, tag: &str) -> Result<()> { + let expected = parse_tag_version(tag)?; + let first = plan + .actions + .first() + .ok_or_else(|| anyhow!("install plan had no actions"))?; + let output = std::process::Command::new(&first.target_path) + .arg("--version") + .output() + .with_context(|| format!("running {} --version", first.target_path.display()))?; + if !output.status.success() { + bail!("installed binary failed --version check"); + } + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.contains(&expected.to_string()) { + bail!( + "installed binary reports unexpected version; expected {}, got {}", + expected, + stdout.trim() + ); + } + println!("Verifying installed version ... {}", stdout.trim()); + Ok(()) +} + +fn confirm_or_bail(prompt: &str, assume_yes: bool) -> Result<()> { + if assume_yes { + println!("{prompt} yes"); + return Ok(()); + } + use std::io::IsTerminal; + if !io::stdin().is_terminal() { + bail!("{prompt} rerun with --yes to accept the default in a non-interactive context"); + } + print!("{prompt} [Y/n] "); + io::stdout().flush().ok(); + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + let answer = line.trim().to_ascii_lowercase(); + if answer.is_empty() || answer == "y" || answer == "yes" { + Ok(()) + } else { + bail!("update cancelled") + } +} + +fn trim_for_error(text: &str) -> String { + let text = text.trim(); + const LIMIT: usize = 1200; + if text.len() <= LIMIT { + text.to_string() + } else { + format!("{}...", &text[..LIMIT]) + } +} + +fn human_bytes(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + let bytes_f = bytes as f64; + if bytes_f >= MB { + format!("{:.1} MB", bytes_f / MB) + } else if bytes_f >= KB { + format!("{:.1} KB", bytes_f / KB) + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::Digest; + + fn release_with_assets(tag: &str, names: &[&str]) -> GitHubRelease { + GitHubRelease { + tag_name: tag.to_string(), + name: Some(tag.to_string()), + draft: false, + prerelease: false, + assets: names + .iter() + .map(|name| GitHubAsset { + name: name.to_string(), + browser_download_url: format!("https://example.test/{name}"), + size: 1, + }) + .collect(), + tarball_url: Some("https://example.test/source.tar.gz".to_string()), + zipball_url: None, + } + } + + #[test] + fn parses_tag_versions() { + assert_eq!( + parse_tag_version("v0.2.7").unwrap(), + Version::parse("0.2.7").unwrap() + ); + assert_eq!( + parse_tag_version("0.2.7").unwrap(), + Version::parse("0.2.7").unwrap() + ); + } + + #[test] + fn validates_release_flags() { + let mut release = release_with_assets("v0.2.7", &[]); + release.draft = true; + assert!(validate_release(&release).is_err()); + release.draft = false; + release.prerelease = true; + assert!(validate_release(&release).is_ok()); + } + + #[test] + fn selects_latest_non_draft_release_including_prereleases() { + let mut v026 = release_with_assets("v0.2.6", &[]); + v026.prerelease = true; + let mut v027 = release_with_assets("v0.2.7", &[]); + v027.prerelease = true; + let mut v999 = release_with_assets("v9.9.9", &[]); + v999.draft = true; + let selected = select_latest_release(vec![v026, v999, v027]).unwrap(); + assert_eq!(selected.tag_name, "v0.2.7"); + assert!(selected.prerelease); + } + + #[test] + fn maps_supported_platform_targets() { + assert_eq!( + platform_target("macos", "aarch64").unwrap().triple, + "aarch64-apple-darwin" + ); + assert_eq!( + platform_target("linux", "x86_64").unwrap().triple, + "x86_64-unknown-linux-gnu" + ); + assert_eq!( + platform_target("linux", "aarch64").unwrap().triple, + "aarch64-unknown-linux-gnu" + ); + assert_eq!( + platform_target("windows", "x86_64").unwrap().triple, + "x86_64-pc-windows-gnu" + ); + assert!(platform_target("macos", "x86_64").is_none()); + } + + #[test] + fn selects_prebuilt_assets_with_checksums() { + let release = release_with_assets( + "v0.2.7", + &[ + "cassady-v0.2.7-x86_64-unknown-linux-gnu.tar.gz", + "cassady-v0.2.7-x86_64-unknown-linux-gnu.tar.gz.sha256", + ], + ); + let target = platform_target("linux", "x86_64").unwrap(); + let selected = select_prebuilt_asset(&release, &target).unwrap().unwrap(); + assert_eq!( + selected.archive.name, + "cassady-v0.2.7-x86_64-unknown-linux-gnu.tar.gz" + ); + } + + #[test] + fn errors_when_prebuilt_checksum_missing() { + let release = release_with_assets( + "v0.2.7", + &["cassady-v0.2.7-x86_64-unknown-linux-gnu.tar.gz"], + ); + let target = platform_target("linux", "x86_64").unwrap(); + assert!(select_prebuilt_asset(&release, &target).is_err()); + } + + #[test] + fn parses_sha256_files() { + let (digest, filename) = parse_checksum_file( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef cassady.tar.gz\n", + ) + .unwrap(); + assert_eq!( + digest, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + assert_eq!(filename, "cassady.tar.gz"); + } + + #[test] + fn verifies_sha256_file_and_filename() { + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("cassady.tar.gz"); + fs::write(&archive, b"hello").unwrap(); + let digest = format!("{:x}", Sha256::digest(b"hello")); + let checksum = dir.path().join("cassady.tar.gz.sha256"); + fs::write(&checksum, format!("{digest} cassady.tar.gz\n")).unwrap(); + verify_checksum_file(&archive, &checksum).unwrap(); + fs::write(&checksum, format!("{digest} other.tar.gz\n")).unwrap(); + assert!(verify_checksum_file(&archive, &checksum).is_err()); + } + + #[test] + fn safe_join_rejects_traversal() { + let root = Path::new("/tmp/root"); + assert!(safe_join(root, Path::new("cassady/cass")).is_ok()); + assert!(safe_join(root, Path::new("../cass")).is_err()); + assert!(safe_join(root, Path::new("/tmp/cass")).is_err()); + } + + #[test] + fn tgz_extraction_rejects_link_entries() { + let dir = tempfile::tempdir().unwrap(); + let archive_path = dir.path().join("bad.tar.gz"); + { + let file = File::create(&archive_path).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut tar = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_link_name("/tmp/escape").unwrap(); + header.set_cksum(); + tar.append_data(&mut header, "link", &b""[..]).unwrap(); + tar.finish().unwrap(); + } + let out = dir.path().join("out"); + fs::create_dir_all(&out).unwrap(); + assert!(safe_extract_tgz(&archive_path, &out).is_err()); + } + + #[test] + fn parses_package_version_from_cargo_toml() { + let version = parse_package_version_from_cargo_toml( + "[package]\nname = \"cassady\"\nversion = \"0.2.7\"\n\n[dependencies]\n", + ) + .unwrap(); + assert_eq!(version, Version::parse("0.2.7").unwrap()); + } + + #[test] + fn plans_install_for_existing_and_missing_companion() { + let dir = tempfile::tempdir().unwrap(); + let cass = dir + .path() + .join(if cfg!(windows) { "cass.exe" } else { "cass" }); + fs::write(&cass, b"old").unwrap(); + let staged_dir = tempfile::tempdir().unwrap(); + let staged = StagedBinaries { + cass: staged_dir + .path() + .join(if cfg!(windows) { "cass.exe" } else { "cass" }), + cassady: staged_dir.path().join(if cfg!(windows) { + "cassady.exe" + } else { + "cassady" + }), + }; + fs::write(&staged.cass, b"new").unwrap(); + fs::write(&staged.cassady, b"new").unwrap(); + let plan = build_install_plan(dir.path(), &cass, Some(&staged)).unwrap(); + assert_eq!(plan.actions.len(), 2); + assert_eq!(plan.actions[0].kind, InstallActionKind::Replace); + assert_eq!(plan.actions[1].kind, InstallActionKind::AddCompanion); + } + + #[test] + #[cfg(not(windows))] + fn installs_staged_binaries_with_backup() { + let dir = tempfile::tempdir().unwrap(); + let cass = dir.path().join("cass"); + fs::write(&cass, b"old cass").unwrap(); + let cassady = dir.path().join("cassady"); + fs::write(&cassady, b"old cassady").unwrap(); + + let staged_dir = tempfile::tempdir().unwrap(); + let staged = StagedBinaries { + cass: staged_dir.path().join("cass"), + cassady: staged_dir.path().join("cassady"), + }; + fs::write(&staged.cass, b"new cass").unwrap(); + fs::write(&staged.cassady, b"new cassady").unwrap(); + + let plan = build_install_plan(dir.path(), &cass, Some(&staged)).unwrap(); + install_staged_binaries(&plan).unwrap(); + assert_eq!(fs::read(&cass).unwrap(), b"new cass"); + assert_eq!(fs::read(&cassady).unwrap(), b"new cassady"); + } +}