diff --git a/Cargo.lock b/Cargo.lock index 7c35a09..cb9f734 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -980,16 +980,21 @@ dependencies = [ "compass-semantic", "compass-semantic-diff", "ctrlc", + "flate2", "glob", "mimalloc", "regex", + "self-replace", + "semver", "serde", "serde_json", "sha2 0.10.9", + "tar", "tempfile", "time", "tokio", "toml 1.1.3+spec-1.1.0", + "ureq", "url", ] @@ -3898,6 +3903,17 @@ dependencies = [ "libc", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "1.0.28" diff --git a/Cargo.toml b/Cargo.toml index 1222bbb..38914c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,8 @@ rayon = "1" rand = "0.8.7" realfft = "3.5.0" regex = "1" +semver = "1" +self-replace = "1.5" rmcp = { version = "2.2.0", default-features = false, features = ["server", "transport-io", "transport-streamable-http-server"] } rubato = "0.16.2" flate2 = "1.1.9" @@ -91,6 +93,7 @@ unicode-normalization = "0.1" unicode-casefold = "0.2" unicode-properties = { version = "0.1.4", features = ["general-category"] } tempfile = "3" +tar = "0.4" atomicwrites = "=0.4.4" tiktoken-rs = "0.7.0" symphonia = { version = "0.6.0", default-features = false, features = ["all"] } diff --git a/README.md b/README.md index b5d717d..4b9ea98 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,16 @@ You can also install directly through Cargo: cargo install --locked --path crates/compass-cli --bin compass ``` +Upgrade any installed Compass executable to the latest stable release with: + +```bash +compass upgrade +``` + +Compass verifies the official release checksum before replacing the executable. +If no newer release is available, it exits successfully and reports that the +installed version is already the latest. + ### 2. Initialize and build a local graph ```bash diff --git a/crates/compass-cli/Cargo.toml b/crates/compass-cli/Cargo.toml index 06bab3d..ccb75d5 100644 --- a/crates/compass-cli/Cargo.toml +++ b/crates/compass-cli/Cargo.toml @@ -20,16 +20,21 @@ path = "src/bin/compass.rs" [dependencies] ctrlc.workspace = true +flate2.workspace = true glob.workspace = true mimalloc.workspace = true regex.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true +self-replace.workspace = true sha2.workspace = true +tar.workspace = true tempfile.workspace = true time.workspace = true tokio.workspace = true toml.workspace = true +ureq.workspace = true compass-core = { path = "../compass-core", version = "0.1.4" } compass-analysis = { path = "../compass-analysis", version = "0.1.4" } compass-ir = { path = "../compass-ir", version = "0.1.4" } diff --git a/crates/compass-cli/assets/compass-skill/references/command-reference.md b/crates/compass-cli/assets/compass-skill/references/command-reference.md index c7e86eb..77f4508 100644 --- a/crates/compass-cli/assets/compass-skill/references/command-reference.md +++ b/crates/compass-cli/assets/compass-skill/references/command-reference.md @@ -83,6 +83,7 @@ tools and must not replace normal extraction without a reason. - `compass install`: install the canonical skill and platform integration. - `compass uninstall`: remove managed integrations; `--purge` additionally removes Compass output and requires explicit user intent. +- `compass upgrade`: verify and install the latest stable Compass release. - `compass hook`: install, inspect, or uninstall repository refresh hooks. - `compass hook-check`: no-op probe owned by installed hook configuration. - `compass hook-guard`: adapter owned by installed search/read/Gemini guards. diff --git a/crates/compass-cli/build.rs b/crates/compass-cli/build.rs index d67388e..edd1be9 100644 --- a/crates/compass-cli/build.rs +++ b/crates/compass-cli/build.rs @@ -26,6 +26,10 @@ fn collect(root: &Path, directory: &Path, files: &mut Vec) -> io::Resul } fn main() -> Result<(), Box> { + println!( + "cargo:rustc-env=COMPASS_BUILD_TARGET={}", + env::var("TARGET")? + ); println!("cargo:rerun-if-changed=assets"); println!("cargo:rerun-if-changed=src/lib.rs"); println!("cargo:rerun-if-changed=src/help.rs"); diff --git a/crates/compass-cli/src/help.rs b/crates/compass-cli/src/help.rs index b5e93ce..c73fd4c 100644 --- a/crates/compass-cli/src/help.rs +++ b/crates/compass-cli/src/help.rs @@ -115,6 +115,7 @@ const GROUPS: &[Group] = &[ "hook", "install", "uninstall", + "upgrade", "provider", "save-result", "reflect", @@ -479,6 +480,12 @@ const PAGES: &[Page] = &[ ["compass uninstall [PLATFORM] [OPTIONS]"], "Arguments:\n [PLATFORM] Remove one assistant platform\n\nOptions:\n --platform Select one assistant platform\n --project Remove project-scoped files\n --purge Remove all installed Compass guidance\n\nExamples:\n compass uninstall --platform codex\n compass uninstall --project --purge" ), + page!( + "upgrade", + "Upgrade Compass to the latest stable release", + ["compass upgrade"], + "Examples:\n compass upgrade\n\nNotes:\n Compass verifies the official release checksum before replacing the running executable.\n If no newer stable release is available, the command exits successfully without changes." + ), page!( "provider", "Manage custom semantic model providers", @@ -998,7 +1005,7 @@ mod tests { #[test] fn catalog_has_unique_complete_public_roots() { let roots = root_commands(); - assert_eq!(roots.len(), 38); + assert_eq!(roots.len(), 39); for root in roots { let matches = PAGES.iter().filter(|page| page.path == root).count(); assert_eq!(matches, 1, "{root}"); diff --git a/crates/compass-cli/src/lib.rs b/crates/compass-cli/src/lib.rs index 07ce90a..8e308d6 100644 --- a/crates/compass-cli/src/lib.rs +++ b/crates/compass-cli/src/lib.rs @@ -22,6 +22,7 @@ mod result_commands; mod semantic_commands; mod semantic_diff_commands; mod semantic_diff_render; +mod upgrade_commands; use std::collections::HashMap; use std::ffi::OsString; @@ -370,6 +371,7 @@ pub fn run(frontend: Frontend, arguments: impl IntoIterator) -> "hook-refresh" => command_hook_refresh(frontend, &args), "install" => install_commands::command_install(frontend, &args), "uninstall" => install_commands::command_uninstall(frontend, &args), + "upgrade" => upgrade_commands::command_upgrade(&args), platform if install_commands::is_direct_command(platform) => { install_commands::command_platform(frontend, platform, &args) } diff --git a/crates/compass-cli/src/upgrade_commands.rs b/crates/compass-cli/src/upgrade_commands.rs new file mode 100644 index 0000000..47b9c29 --- /dev/null +++ b/crates/compass-cli/src/upgrade_commands.rs @@ -0,0 +1,499 @@ +use std::fs::File; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use flate2::read::GzDecoder; +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::Outcome; + +const RELEASE_API_URL: &str = "https://api.github.com/repos/crabbuild/compass/releases/latest"; +const USER_AGENT: &str = concat!("compass/", env!("CARGO_PKG_VERSION")); +const METADATA_LIMIT: usize = 1024 * 1024; +const CHECKSUM_LIMIT: usize = 4096; +const ARCHIVE_LIMIT: usize = 512 * 1024 * 1024; +const BINARY_LIMIT: u64 = 512 * 1024 * 1024; + +#[derive(Debug, Deserialize)] +struct Release { + tag_name: String, + draft: bool, + prerelease: bool, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct ReleaseAsset { + name: String, + browser_download_url: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VersionDecision { + Upgrade, + Current, + Newer, +} + +pub(crate) fn command_upgrade(arguments: &[String]) -> Outcome { + if let Some(argument) = arguments.first() { + return Outcome::failure_with_code(format!("error: unexpected argument '{argument}'"), 2); + } + + match upgrade() { + Ok(message) => Outcome::success(message), + Err(error) => Outcome::failure(format!("error: {error}")), + } +} + +fn upgrade() -> Result { + let target = current_target()?; + let current = Version::parse(env!("CARGO_PKG_VERSION")) + .map_err(|error| format!("invalid installed Compass version: {error}"))?; + let agent = http_agent(); + let release: Release = serde_json::from_slice(&fetch(&agent, RELEASE_API_URL, METADATA_LIMIT)?) + .map_err(|error| format!("invalid GitHub release metadata: {error}"))?; + let latest = release_version(&release)?; + + match version_decision(¤t, &latest) { + VersionDecision::Current => { + return Ok(format!("Compass {current} is already the latest version.")); + } + VersionDecision::Newer => { + return Ok(format!( + "Compass {current} is newer than the latest release ({latest}); no downgrade was performed." + )); + } + VersionDecision::Upgrade => {} + } + + let archive_name = format!("compass-{target}.tar.gz"); + let checksum_name = format!("{archive_name}.sha256"); + let archive_asset = release_asset(&release, &archive_name)?; + let checksum_asset = release_asset(&release, &checksum_name)?; + let temporary = tempfile::tempdir() + .map_err(|error| format!("could not create upgrade directory: {error}"))?; + let archive_path = temporary.path().join(&archive_name); + + download( + &agent, + &archive_asset.browser_download_url, + &archive_path, + ARCHIVE_LIMIT, + ) + .map_err(|error| format!("could not download {archive_name}: {error}"))?; + let checksum = fetch(&agent, &checksum_asset.browser_download_url, CHECKSUM_LIMIT) + .map_err(|error| format!("could not download {checksum_name}: {error}"))?; + verify_checksum(&archive_path, &archive_name, &checksum)?; + + let executable_name = if target.contains("windows") { + "compass.exe" + } else { + "compass" + }; + let packaged_path = PathBuf::from(format!("compass-{target}")).join(executable_name); + let staged_path = temporary.path().join(format!("staged-{executable_name}")); + extract_executable(&archive_path, &packaged_path, &staged_path)?; + validate_executable(&staged_path, &latest)?; + self_replace::self_replace(&staged_path).map_err(|error| { + let installed = std::env::current_exe() + .map_or_else(|_| "the running executable".to_owned(), |path| path.display().to_string()); + format!( + "could not replace {installed}: {error}. Check that the executable is writable by the current user" + ) + })?; + + Ok(format!("Upgraded Compass from {current} to {latest}.")) +} + +fn http_agent() -> ureq::Agent { + let config = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(5 * 60))) + .max_redirects(5) + .build(); + config.into() +} + +fn fetch(agent: &ureq::Agent, url: &str, max_bytes: usize) -> Result, String> { + let response = agent + .get(url) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", USER_AGENT) + .call() + .map_err(|error| error.to_string())?; + let limit = u64::try_from(max_bytes) + .map_err(|_| "download size limit is invalid".to_owned())? + .checked_add(1) + .ok_or_else(|| "download size limit overflowed".to_owned())?; + let mut reader = response + .into_body() + .into_with_config() + .limit(limit) + .reader(); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if bytes.len() > max_bytes { + return Err(format!("response exceeded {max_bytes} bytes")); + } + Ok(bytes) +} + +fn download( + agent: &ureq::Agent, + url: &str, + destination: &Path, + max_bytes: usize, +) -> Result<(), String> { + let response = agent + .get(url) + .header("Accept", "application/octet-stream") + .header("User-Agent", USER_AGENT) + .call() + .map_err(|error| error.to_string())?; + let limit = u64::try_from(max_bytes) + .map_err(|_| "download size limit is invalid".to_owned())? + .checked_add(1) + .ok_or_else(|| "download size limit overflowed".to_owned())?; + let mut reader = response + .into_body() + .into_with_config() + .limit(limit) + .reader(); + let mut output = File::create(destination).map_err(|error| error.to_string())?; + let written = io::copy(&mut reader, &mut output).map_err(|error| error.to_string())?; + if written > u64::try_from(max_bytes).map_err(|_| "download size limit is invalid")? { + return Err(format!("response exceeded {max_bytes} bytes")); + } + output.sync_all().map_err(|error| error.to_string()) +} + +fn release_version(release: &Release) -> Result { + if release.draft || release.prerelease { + return Err("GitHub latest release is not a stable published release".to_owned()); + } + let raw = release + .tag_name + .strip_prefix("compass-v") + .ok_or_else(|| format!("invalid Compass release tag '{}'", release.tag_name))?; + let version = + Version::parse(raw).map_err(|error| format!("invalid Compass release tag: {error}"))?; + if !version.pre.is_empty() { + return Err(format!( + "latest Compass release '{}' is a prerelease", + release.tag_name + )); + } + Ok(version) +} + +fn release_asset<'a>(release: &'a Release, name: &str) -> Result<&'a ReleaseAsset, String> { + let mut matches = release.assets.iter().filter(|asset| asset.name == name); + let asset = matches + .next() + .ok_or_else(|| format!("latest Compass release is missing asset {name}"))?; + if matches.next().is_some() { + return Err(format!( + "latest Compass release contains duplicate asset {name}" + )); + } + Ok(asset) +} + +fn version_decision(current: &Version, latest: &Version) -> VersionDecision { + match current.cmp(latest) { + std::cmp::Ordering::Less => VersionDecision::Upgrade, + std::cmp::Ordering::Equal => VersionDecision::Current, + std::cmp::Ordering::Greater => VersionDecision::Newer, + } +} + +fn supported_target(target: &str) -> Option<&'static str> { + match target { + "x86_64-apple-darwin" => Some("x86_64-apple-darwin"), + "aarch64-apple-darwin" => Some("aarch64-apple-darwin"), + "x86_64-unknown-linux-gnu" => Some("x86_64-unknown-linux-gnu"), + "aarch64-unknown-linux-gnu" => Some("aarch64-unknown-linux-gnu"), + "x86_64-pc-windows-msvc" => Some("x86_64-pc-windows-msvc"), + "aarch64-pc-windows-msvc" => Some("aarch64-pc-windows-msvc"), + _ => None, + } +} + +fn current_target() -> Result<&'static str, String> { + let target = env!("COMPASS_BUILD_TARGET"); + supported_target(target).ok_or_else(|| format!("unsupported upgrade target: {target}")) +} + +fn verify_checksum(archive: &Path, archive_name: &str, checksum: &[u8]) -> Result<(), String> { + let checksum = + std::str::from_utf8(checksum).map_err(|_| "release checksum is not UTF-8".to_owned())?; + let mut fields = checksum.split_whitespace(); + let expected = fields + .next() + .filter(|value| value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .ok_or_else(|| "release checksum does not contain a valid SHA-256 digest".to_owned())?; + let filename = fields + .next() + .ok_or_else(|| "release checksum does not name its archive".to_owned())?; + if fields.next().is_some() || filename.trim_start_matches('*') != archive_name { + return Err(format!( + "release checksum does not match archive {archive_name}" + )); + } + + let mut file = File::open(archive) + .map_err(|error| format!("could not read downloaded archive: {error}"))?; + let mut digest = Sha256::new(); + io::copy(&mut file, &mut digest) + .map_err(|error| format!("could not hash downloaded archive: {error}"))?; + let actual = format!("{:x}", digest.finalize()); + if !actual.eq_ignore_ascii_case(expected) { + return Err("release archive failed SHA-256 verification".to_owned()); + } + Ok(()) +} + +fn extract_executable( + archive_path: &Path, + packaged_path: &Path, + destination: &Path, +) -> Result<(), String> { + let file = File::open(archive_path) + .map_err(|error| format!("could not open verified release archive: {error}"))?; + let decoder = GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + let mut found = false; + + for entry in archive + .entries() + .map_err(|error| format!("invalid release archive: {error}"))? + { + let mut entry = entry.map_err(|error| format!("invalid release archive: {error}"))?; + let path = entry + .path() + .map_err(|error| format!("invalid release archive path: {error}"))?; + if path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) { + return Err(format!( + "release archive contains unsafe path {}", + path.display() + )); + } + if path.as_ref() != packaged_path { + continue; + } + if found || !entry.header().entry_type().is_file() { + return Err(format!( + "release archive contains an invalid {} entry", + packaged_path.display() + )); + } + let mut output = File::create(destination) + .map_err(|error| format!("could not stage Compass executable: {error}"))?; + let written = io::copy(&mut entry.by_ref().take(BINARY_LIMIT + 1), &mut output) + .map_err(|error| format!("could not extract Compass executable: {error}"))?; + if written > BINARY_LIMIT { + return Err("release executable exceeded the size limit".to_owned()); + } + output + .sync_all() + .map_err(|error| format!("could not sync staged Compass executable: {error}"))?; + found = true; + } + + if !found { + return Err(format!( + "release archive is missing {}", + packaged_path.display() + )); + } + make_executable(destination)?; + Ok(()) +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = std::fs::metadata(path) + .map_err(|error| format!("could not inspect staged Compass executable: {error}"))? + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions) + .map_err(|error| format!("could not make staged Compass executable runnable: {error}")) +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) -> Result<(), String> { + Ok(()) +} + +fn validate_executable(path: &Path, expected: &Version) -> Result<(), String> { + let output = Command::new(path) + .arg("--version") + .output() + .map_err(|error| format!("could not run staged Compass executable: {error}"))?; + if !output.status.success() { + return Err("staged Compass executable failed its version check".to_owned()); + } + let stdout = String::from_utf8(output.stdout) + .map_err(|_| "staged Compass version output is not UTF-8".to_owned())?; + let expected_output = format!("compass {expected}"); + if stdout.trim() != expected_output { + return Err(format!( + "staged Compass executable reported '{}', expected '{expected_output}'", + stdout.trim() + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn release(tag: &str) -> Release { + Release { + tag_name: tag.to_owned(), + draft: false, + prerelease: false, + assets: Vec::new(), + } + } + + #[test] + fn version_policy_upgrades_only_to_a_newer_stable_release() { + let current = Version::new(1, 2, 3); + assert_eq!( + version_decision(¤t, &Version::new(1, 2, 4)), + VersionDecision::Upgrade + ); + assert_eq!( + version_decision(¤t, &Version::new(1, 2, 3)), + VersionDecision::Current + ); + assert_eq!( + version_decision(¤t, &Version::new(1, 2, 2)), + VersionDecision::Newer + ); + + assert_eq!(release_version(&release("compass-v1.2.3")), Ok(current)); + assert!(release_version(&release("v1.2.3")).is_err()); + assert!(release_version(&release("compass-v1.2.4-beta.1")).is_err()); + let mut prerelease = release("compass-v1.2.4"); + prerelease.prerelease = true; + assert!(release_version(&prerelease).is_err()); + } + + #[test] + fn published_targets_are_selected_exactly() { + for target in [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + ] { + assert_eq!(supported_target(target), Some(target)); + } + assert_eq!(supported_target("x86_64-unknown-linux-musl"), None); + assert_eq!(supported_target("x86_64-pc-windows-gnu"), None); + } + + #[test] + fn release_assets_must_be_present_once() { + let mut release = release("compass-v1.2.3"); + release.assets.push(ReleaseAsset { + name: "compass-a.tar.gz".to_owned(), + browser_download_url: "https://example.test/a".to_owned(), + }); + assert!(release_asset(&release, "compass-a.tar.gz").is_ok()); + assert!(release_asset(&release, "missing").is_err()); + release.assets.push(ReleaseAsset { + name: "compass-a.tar.gz".to_owned(), + browser_download_url: "https://example.test/b".to_owned(), + }); + assert!(release_asset(&release, "compass-a.tar.gz").is_err()); + } + + #[test] + fn checksum_verification_requires_the_expected_name_and_digest() + -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let archive = directory.path().join("compass-test.tar.gz"); + std::fs::write(&archive, b"verified archive")?; + let digest = format!("{:x}", Sha256::digest(b"verified archive")); + let checksum = format!("{digest} compass-test.tar.gz\n"); + assert!(verify_checksum(&archive, "compass-test.tar.gz", checksum.as_bytes()).is_ok()); + assert!(verify_checksum(&archive, "other.tar.gz", checksum.as_bytes()).is_err()); + assert!( + verify_checksum( + &archive, + "compass-test.tar.gz", + b"0000000000000000000000000000000000000000000000000000000000000000 compass-test.tar.gz" + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn extraction_selects_only_the_packaged_compass_binary() + -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let archive_path = directory.path().join("release.tar.gz"); + let archive_file = File::create(&archive_path)?; + let encoder = flate2::write::GzEncoder::new(archive_file, flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + let contents = b"compass executable"; + let mut header = tar::Header::new_gnu(); + header.set_size(u64::try_from(contents.len())?); + header.set_mode(0o755); + header.set_cksum(); + archive.append_data( + &mut header, + "compass-aarch64-apple-darwin/compass", + &contents[..], + )?; + let encoder = archive.into_inner()?; + encoder.finish()?; + + let destination = directory.path().join("staged-compass"); + extract_executable( + &archive_path, + Path::new("compass-aarch64-apple-darwin/compass"), + &destination, + ) + .map_err(io::Error::other)?; + assert_eq!(std::fs::read(destination)?, contents); + assert!( + extract_executable( + &archive_path, + Path::new("compass-x86_64-apple-darwin/compass"), + &directory.path().join("missing") + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn unexpected_arguments_fail_before_upgrade_work() { + let outcome = command_upgrade(&["--force".to_owned()]); + assert_eq!(outcome.code, 2); + assert!(outcome.stderr.contains("unexpected argument '--force'")); + } +} diff --git a/crates/compass-cli/tests/help_cli.rs b/crates/compass-cli/tests/help_cli.rs index 0e0c5d0..9f7a03b 100644 --- a/crates/compass-cli/tests/help_cli.rs +++ b/crates/compass-cli/tests/help_cli.rs @@ -52,6 +52,7 @@ fn root_help_groups_every_public_command_with_descriptions() { "hook", "install", "uninstall", + "upgrade", "provider", "save-result", "reflect", @@ -79,6 +80,7 @@ fn command_and_nested_help_explain_options_and_examples() { &["init", "--help"][..], &["update", "--help"], &["query", "--help"], + &["upgrade", "--help"], &["history", "build", "--help"], &["export", "neo4j", "--help"], &["provider", "add", "--help"], diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 0346040..6313f2b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -466,6 +466,17 @@ compass uninstall Review targets before `--purge`. +### `upgrade` + +```text +compass upgrade +``` + +Downloads the latest stable Compass release for the current platform, verifies +its SHA-256 checksum and reported version, then replaces the running executable. +If the installed version is current or newer, the command exits successfully +without changing it. + ### `hook` ```text diff --git a/docs/superpowers/specs/2026-07-27-compass-upgrade-command-design.md b/docs/superpowers/specs/2026-07-27-compass-upgrade-command-design.md new file mode 100644 index 0000000..1674ee7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-compass-upgrade-command-design.md @@ -0,0 +1,216 @@ +# Compass Upgrade Command Design + +**Date:** 2026-07-27 + +**Status:** Approved for implementation planning + +## Goal + +Add a public `compass upgrade` command that upgrades the running Compass +executable to the newest stable GitHub release. When the installed version is +current, the command exits successfully and clearly tells the user that it is +already the latest version. + +## User Experience + +The command has no required arguments: + +```text +compass upgrade +``` + +When an update is available, Compass reports the version transition, downloads +and verifies the matching release, replaces the running executable, and prints: + +```text +Upgraded Compass from 0.1.8 to 0.1.9. +``` + +When the running version equals the latest stable release, Compass makes no +filesystem changes, exits with code 0, and prints: + +```text +Compass 0.1.9 is already the latest version. +``` + +If the running version is newer than the latest stable release, Compass does +not downgrade it. It exits successfully and reports that the running version +is already newer than the latest release. + +`compass upgrade --help` is a dedicated public help page. Unexpected arguments +are rejected before any network or filesystem activity. + +## Supported Installations and Platforms + +The upgrade operates on the currently running executable, regardless of +whether it was originally installed by the shell installer, extracted +manually, or installed with Cargo. The executable must be writable by the +current user; otherwise the command fails with a permissions-oriented error and +installation guidance. + +The command supports every target currently published by the Compass release +workflow: + +- `x86_64-apple-darwin` +- `aarch64-apple-darwin` +- `x86_64-unknown-linux-gnu` +- `aarch64-unknown-linux-gnu` +- `x86_64-pc-windows-msvc` +- `aarch64-pc-windows-msvc` + +Other targets fail before downloading an archive and identify the unsupported +target. + +## Release Discovery and Version Policy + +Compass queries the GitHub latest-release endpoint for +`crabbuild/compass`. The response must identify a non-draft, non-prerelease +release whose tag has the exact form `compass-v`. + +Only a stable release newer than `CARGO_PKG_VERSION` is eligible. Prereleases, +malformed tags, and downgrades are never installed. Version comparison uses +semantic-version ordering rather than string ordering. + +The release must contain both assets for the current target: + +```text +compass-.tar.gz +compass-.tar.gz.sha256 +``` + +Missing or duplicate matching assets are treated as release errors. + +## Components + +The CLI dispatch remains in `compass-cli`, following the existing +hand-written command architecture. + +### Command adapter + +`upgrade_commands` owns argument validation, human-readable outcomes, and the +top-level upgrade sequence. `lib.rs` dispatches `upgrade` to this module, while +`help.rs` registers the command in the public help catalog. + +### Release client + +The release client fetches and parses only the fields needed from GitHub: +release tag, draft/prerelease flags, asset names, and asset download URLs. It +uses bounded response handling, explicit HTTP status checks, and a Compass +user-agent. + +### Artifact verifier + +The verifier downloads the archive and checksum into a temporary directory, +requires a strict SHA-256 checksum entry for the selected archive, computes the +archive digest, and rejects any mismatch before extraction. + +The archive must contain the expected packaged path: + +```text +compass-/compass +compass-/compass.exe +``` + +depending on the platform. Path traversal, unexpected executable paths, and +missing executables are rejected. + +### Binary validator and replacer + +Before replacement, Compass runs the staged executable with `--version` and +requires it to report exactly the selected release version. The staged binary +is then passed to a cross-platform self-replacement boundary backed by +`self-replace`. + +Replacement is the only operation that touches the installed executable. On +Unix the executable is atomically swapped. On Windows the replacement boundary +handles the running executable lock and deferred cleanup. Failures before the +swap leave the installed executable unchanged. + +The release client, target resolver, artifact staging, process validation, and +replacement operation use narrow interfaces so they can be tested +independently without replacing the test runner. + +## Data Flow + +1. Parse `compass upgrade` and reject unsupported arguments. +2. Resolve the compile target to one of the six published targets. +3. Fetch and validate the latest stable release metadata. +4. Compare the latest version with the running package version. +5. Return the no-op message immediately when no upgrade is needed. +6. Resolve exactly one archive and checksum asset for the target. +7. Download both assets into a temporary directory. +8. Verify the archive SHA-256 checksum. +9. Safely extract the expected Compass executable. +10. Run the staged executable with `--version` and validate the result. +11. Replace the running executable. +12. Report the completed version transition. + +## Error Handling + +Errors are concise and actionable: + +- Network and HTTP failures say that release metadata or an asset could not be + downloaded. +- Invalid release metadata identifies the malformed tag or missing asset. +- Checksum mismatches explicitly say verification failed and do not extract or + replace anything. +- Invalid archives and staged-version mismatches fail before replacement. +- Unsupported targets name the current target and list the supported families. +- Permission or replacement failures identify the executable path and suggest + rerunning through the installation mechanism with sufficient permissions. + +Temporary downloads are removed on both success and failure. Error messages do +not include response bodies, tokens, or unrelated environment values. + +## Testing + +Implementation follows test-driven development. + +Unit tests cover: + +- semantic-version comparison for older, equal, and newer running versions; +- exact `compass-v` tag parsing and prerelease rejection; +- target selection for all six published targets and unsupported targets; +- exact asset selection, including missing and duplicate assets; +- strict checksum parsing and mismatch rejection; +- archive path validation and traversal rejection; +- staged executable version validation; +- replacement not being invoked on any validation failure; +- replacement being invoked exactly once after all validations succeed; +- successful no-op output for an already-current version; +- successful no-downgrade output for a newer development build. + +CLI tests cover: + +- root help lists `upgrade` with a description; +- `compass upgrade --help` has usage, examples, and standard help options; +- unexpected arguments fail without invoking upgrade dependencies. + +An end-to-end updater test uses a local HTTP fixture with synthetic release +metadata, archives, and checksums. Replacement is redirected to a temporary +executable path through the test boundary, so tests never access GitHub or +replace the test binary. + +The release workflow continues to build and package all six targets. CI builds +the new dependencies and runs the Compass CLI test suite on macOS, Linux, and +Windows, exercising the platform-specific replacement implementation at +compile time. + +## Documentation + +The README command reference will list `compass upgrade`, describe the +latest-version no-op, and state that the command installs official stable +GitHub release binaries in place. The release asset naming and checksum +contract remain unchanged. + +## Non-Goals + +This feature does not: + +- check for updates automatically during unrelated commands; +- install prerelease, nightly, or arbitrary versions; +- manage the Compass VS Code extension; +- modify shell `PATH`; +- preserve multiple installed versions or provide rollback; +- delegate upgrading to Homebrew, Cargo, or another package manager; +- change the existing release archive layout.