diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b5e04..39f1fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Subconscious Code are documented here. This project uses ## [Unreleased] +### Added + +- `sc update` checks the running binary against the newest GitHub release and + reports the `subc sc install` command when an update is available. A `--json` + mode provides the same status for scripts. + ### Changed - Mouse-wheel history navigation now moves one transcript row per event, and diff --git a/Cargo.lock b/Cargo.lock index 3a2a758..4c7bd08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1708,6 +1708,7 @@ dependencies = [ "rc-tokenize", "rc-tools", "rc-tui", + "reqwest", "serde", "serde_json", "tempfile", diff --git a/README.md b/README.md index 206e7cc..084177e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,16 @@ Release archives and their checksum files include keyless Sigstore bundles. The `subc sc install` command uses the same OS/architecture mapping and installs the matching archive without requiring Cargo. +To see whether the installed binary is behind the newest release: + +```sh +sc update +``` + +Use `sc update --json` for scripts. While the repository is private, the check +uses an authenticated GitHub CLI session when available; otherwise set +`GH_TOKEN`. Install an available update with `subc sc install`. + Launch `sc`. On first use, the CLI securely prompts for your Subconscious API key and saves it to `~/.sc/key` with user-only permissions: diff --git a/crates/rc-cli/Cargo.toml b/crates/rc-cli/Cargo.toml index bb5fc6f..683b7ab 100644 --- a/crates/rc-cli/Cargo.toml +++ b/crates/rc-cli/Cargo.toml @@ -37,6 +37,7 @@ serde_json.workspace = true tracing.workspace = true tracing-subscriber.workspace = true libc.workspace = true +reqwest.workspace = true uuid = { version = "=1.20.0", features = ["v4"] } crossterm = "0.28" diff --git a/crates/rc-cli/src/main.rs b/crates/rc-cli/src/main.rs index 9c34757..1ff6ccf 100644 --- a/crates/rc-cli/src/main.rs +++ b/crates/rc-cli/src/main.rs @@ -19,6 +19,7 @@ mod doctor; mod resource_scope; +mod update; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; @@ -140,6 +141,13 @@ enum Command { #[arg(long = "body-ladder")] body_ladder: bool, }, + /// Check the installed version against the newest published release. + /// Installation remains owned by `subc sc install`. + Update { + /// Emit a machine-readable status object. + #[arg(long)] + json: bool, + }, } #[tokio::main] @@ -166,6 +174,13 @@ async fn main() -> ExitCode { } async fn run(cli: Cli) -> Result<()> { + // Version discovery is independent of the model endpoint, project, and API + // key. Keep it ahead of settings/first-run setup so `sc update` is useful + // even on a machine that has not configured the agent yet. + if let Some(Command::Update { json }) = &cli.command { + return update::run(*json).await; + } + let benchmark_mode = cli.benchmark_report.is_some() || cli.benchmark_trajectory.is_some(); let mut settings = Settings::load(&std::env::current_dir()?); let model_override = cli.model.clone(); @@ -188,12 +203,18 @@ async fn run(cli: Cli) -> Result<()> { // `sc doctor` runs before the API-key requirement so it can *report* a // missing key as a failed check instead of erroring out with no diagnostics. - if let Some(Command::Doctor { body_ladder }) = cli.command { - let ok = doctor::run(&settings, body_ladder).await?; - if !ok { - anyhow::bail!("doctor: one or more checks failed"); + if let Some(command) = cli.command { + match command { + Command::Doctor { body_ladder } => { + let ok = doctor::run(&settings, body_ladder).await?; + if !ok { + anyhow::bail!("doctor: one or more checks failed"); + } + return Ok(()); + } + // Handled before settings are loaded above. + Command::Update { .. } => unreachable!("update returned before agent setup"), } - return Ok(()); } // Mutable because `/menu` can save a new key mid-run: the reload path below @@ -768,7 +789,7 @@ fn fresh_session_id() -> String { #[cfg(test)] mod session_id_tests { - use super::{fresh_session_id, Cli}; + use super::{fresh_session_id, Cli, Command}; use clap::Parser; #[test] @@ -791,6 +812,13 @@ mod session_id_tests { assert_eq!(cli.print.as_deref(), Some("- Update the display grid")); } + + #[test] + fn update_accepts_machine_readable_output() { + let cli = Cli::try_parse_from(["sc", "update", "--json"]).unwrap(); + + assert!(matches!(cli.command, Some(Command::Update { json: true }))); + } } /// Headless `-p`: one turn, then print the final assistant text. An interactive diff --git a/crates/rc-cli/src/update.rs b/crates/rc-cli/src/update.rs new file mode 100644 index 0000000..8154487 --- /dev/null +++ b/crates/rc-cli/src/update.rs @@ -0,0 +1,252 @@ +//! Release update discovery for the `sc update` command. +//! +//! Installation belongs to the Subconscious CLI (`subc sc install`), which +//! already detects the platform, downloads the correct signed release asset, +//! and verifies its checksum. This module only answers whether the running +//! binary is behind the newest full GitHub release. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::time::Duration; +use tokio::process::Command; + +const REPOSITORY: &str = "subconscious-systems/subconscious-code"; +const RELEASES_API: &str = + "https://api.github.com/repos/subconscious-systems/subconscious-code/releases/latest"; +const INSTALL_COMMAND: &str = "subc sc install"; +const CHECK_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug, Deserialize)] +struct LatestRelease { + #[serde(alias = "tagName")] + tag_name: String, + #[serde(default, alias = "url")] + html_url: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct UpdateReport { + current_version: String, + latest_version: String, + update_available: bool, + install_command: &'static str, + release_url: String, +} + +pub(crate) async fn run(json: bool) -> Result<()> { + let release = latest_release().await?; + let current = env!("CARGO_PKG_VERSION"); + let latest = release.tag_name.trim_start_matches('v'); + let ordering = compare_versions(latest, current).with_context(|| { + format!( + "published release tag {:?} is not a semantic version", + release.tag_name + ) + })?; + let report = UpdateReport { + current_version: current.to_string(), + latest_version: latest.to_string(), + update_available: ordering == Ordering::Greater, + install_command: INSTALL_COMMAND, + release_url: release.html_url, + }; + + if json { + println!("{}", serde_json::to_string(&report)?); + return Ok(()); + } + + match ordering { + Ordering::Greater => { + println!( + "Update available: sc {} -> {}", + report.current_version, report.latest_version + ); + println!("Run: {}", report.install_command); + if !report.release_url.is_empty() { + println!("Release: {}", report.release_url); + } + } + Ordering::Equal => println!("sc {} is up to date.", report.current_version), + Ordering::Less => println!( + "sc {} is newer than the latest release ({}).", + report.current_version, report.latest_version + ), + } + Ok(()) +} + +/// Prefer `gh`, exactly like `subc sc install`: it carries the user's existing +/// GitHub authentication and can see a private release. Anonymous/token HTTP +/// is the portable fallback and will work without credentials once public. +async fn latest_release() -> Result { + if let Some(release) = latest_release_with_gh().await { + return Ok(release); + } + latest_release_with_http().await +} + +async fn latest_release_with_gh() -> Option { + let mut command = Command::new("gh"); + command.kill_on_drop(true).args([ + "release", + "view", + "--repo", + REPOSITORY, + "--json", + "tagName,url", + ]); + let output = tokio::time::timeout(CHECK_TIMEOUT, command.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + serde_json::from_slice(&output.stdout).ok() +} + +async fn latest_release_with_http() -> Result { + let client = reqwest::Client::builder() + .timeout(CHECK_TIMEOUT) + .user_agent(format!("subconscious-code/{}", env!("CARGO_PKG_VERSION"))) + .build() + .context("build update-check HTTP client")?; + let mut request = client + .get(RELEASES_API) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = github_token() { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .context("could not reach GitHub to check for updates")?; + let status = response.status(); + if !status.is_success() { + if status == reqwest::StatusCode::NOT_FOUND { + anyhow::bail!( + "GitHub could not access the latest release while the repository is private; run `gh auth login` or set GH_TOKEN, then retry" + ); + } + anyhow::bail!("GitHub update check returned HTTP {status}"); + } + response + .json() + .await + .context("GitHub returned an invalid latest-release response") +} + +fn github_token() -> Option { + ["GH_TOKEN", "GITHUB_TOKEN"].into_iter().find_map(|name| { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) +} + +#[derive(Debug, PartialEq, Eq)] +struct Version { + core: [u64; 3], + prerelease: Vec, +} + +fn compare_versions(left: &str, right: &str) -> Option { + let left = parse_version(left)?; + let right = parse_version(right)?; + Some( + left.core + .cmp(&right.core) + .then_with(|| compare_prerelease(&left.prerelease, &right.prerelease)), + ) +} + +fn parse_version(raw: &str) -> Option { + let version = raw.trim().trim_start_matches('v'); + let without_build = version.split_once('+').map_or(version, |(core, _)| core); + let (core, prerelease) = without_build + .split_once('-') + .map_or((without_build, ""), |(core, prerelease)| (core, prerelease)); + let mut parts = core.split('.'); + let core = [ + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + ]; + if parts.next().is_some() { + return None; + } + let prerelease = if prerelease.is_empty() { + Vec::new() + } else { + prerelease.split('.').map(str::to_string).collect() + }; + Some(Version { core, prerelease }) +} + +fn compare_prerelease(left: &[String], right: &[String]) -> Ordering { + match (left.is_empty(), right.is_empty()) { + (true, true) => return Ordering::Equal, + (true, false) => return Ordering::Greater, + (false, true) => return Ordering::Less, + (false, false) => {} + } + for index in 0..left.len().max(right.len()) { + let Some(left) = left.get(index) else { + return Ordering::Less; + }; + let Some(right) = right.get(index) else { + return Ordering::Greater; + }; + let ordering = match (left.parse::(), right.parse::()) { + (Ok(left), Ok(right)) => left.cmp(&right), + (Ok(_), Err(_)) => Ordering::Less, + (Err(_), Ok(_)) => Ordering::Greater, + (Err(_), Err(_)) => left.cmp(right), + }; + if ordering != Ordering::Equal { + return ordering; + } + } + Ordering::Equal +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_compare_by_semver_precedence() { + assert_eq!(compare_versions("0.1.3", "0.1.2"), Some(Ordering::Greater)); + assert_eq!(compare_versions("v1.0.0", "1.0.0"), Some(Ordering::Equal)); + assert_eq!( + compare_versions("1.0.0-rc.2", "1.0.0-rc.10"), + Some(Ordering::Less) + ); + assert_eq!( + compare_versions("1.0.0", "1.0.0-rc.10"), + Some(Ordering::Greater) + ); + assert_eq!( + compare_versions("1.0.0+build.9", "1.0.0+build.1"), + Some(Ordering::Equal) + ); + assert_eq!(compare_versions("latest", "1.0.0"), None); + } + + #[test] + fn github_payloads_from_cli_and_rest_both_parse() { + let cli: LatestRelease = serde_json::from_str( + r#"{"tagName":"v0.1.3","url":"https://github.com/example/release"}"#, + ) + .unwrap(); + let rest: LatestRelease = serde_json::from_str( + r#"{"tag_name":"v0.1.3","html_url":"https://github.com/example/release"}"#, + ) + .unwrap(); + assert_eq!(cli.tag_name, rest.tag_name); + assert_eq!(cli.html_url, rest.html_url); + } +}