Skip to content

Commit 21662fd

Browse files
homanpcursoragent
andcommitted
feat: add sus upgrade command for self-update
Adds a self-upgrade command that checks GitHub releases for the latest version and downloads/installs it. Includes proper semver comparison to prevent accidental downgrades. Supports --force to reinstall current version. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cfa4e24 commit 21662fd

File tree

4 files changed

+304
-0
lines changed

4 files changed

+304
-0
lines changed

crates/cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ tracing = { workspace = true }
2323
tracing-subscriber = { workspace = true }
2424
chrono = { workspace = true }
2525
dotenvy = { workspace = true }
26+
flate2 = { workspace = true }
27+
tar = { workspace = true }
2628

2729
[dev-dependencies]
2830
wiremock = "0.6"

crates/cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ pub mod remove;
77
pub mod scan;
88
pub mod uninstall;
99
pub mod update;
10+
pub mod upgrade;
1011
pub mod why;

crates/cli/src/commands/upgrade.rs

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
//! Upgrade command - update sus to the latest version
2+
3+
use anyhow::{anyhow, Result};
4+
use colored::Colorize;
5+
use flate2::read::GzDecoder;
6+
use serde::Deserialize;
7+
use std::fs::{self, File};
8+
use std::io::Write;
9+
use std::path::PathBuf;
10+
use tar::Archive;
11+
12+
const GITHUB_REPO: &str = "superagent-ai/sus";
13+
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
14+
15+
#[derive(Deserialize)]
16+
struct GitHubRelease {
17+
tag_name: String,
18+
}
19+
20+
/// Run the upgrade command
21+
pub async fn run(force: bool) -> Result<()> {
22+
println!();
23+
println!("🔄 Checking for updates...");
24+
println!();
25+
26+
let current = CURRENT_VERSION;
27+
let latest = get_latest_version().await?;
28+
29+
// Strip 'v' prefix for comparison
30+
let latest_clean = latest.strip_prefix('v').unwrap_or(&latest);
31+
let current_clean = current.strip_prefix('v').unwrap_or(current);
32+
33+
println!(
34+
" Current version: {}",
35+
format!("v{}", current_clean).cyan()
36+
);
37+
println!(
38+
" Latest version: {}",
39+
format!("v{}", latest_clean).cyan()
40+
);
41+
println!();
42+
43+
// Compare versions using semver
44+
let is_newer = is_version_newer(latest_clean, current_clean);
45+
46+
if !is_newer && !force {
47+
if current_clean == latest_clean {
48+
println!(" {} Already on the latest version.", "✓".green());
49+
} else {
50+
println!(
51+
" {} Local version is newer than latest release.",
52+
"✓".green()
53+
);
54+
}
55+
println!();
56+
return Ok(());
57+
}
58+
59+
if !is_newer && force {
60+
println!(
61+
" {} Forcing reinstall (will replace with v{})...",
62+
"⚡".yellow(),
63+
latest_clean
64+
);
65+
println!();
66+
}
67+
68+
// Detect platform
69+
let (os, arch) = detect_platform()?;
70+
let tarball_name = format!("sus-{}-{}.tar.gz", os, arch);
71+
72+
println!(" Downloading {}...", tarball_name.cyan());
73+
74+
// Download and install
75+
download_and_install(&latest, &os, &arch).await?;
76+
77+
println!(" {} Upgraded to v{}", "✓".green(), latest_clean);
78+
println!();
79+
println!(
80+
" {} Restart your terminal or run '{}' to verify.",
81+
"note:".yellow(),
82+
"sus --version".cyan()
83+
);
84+
println!();
85+
86+
Ok(())
87+
}
88+
89+
/// Fetch the latest release version from GitHub
90+
async fn get_latest_version() -> Result<String> {
91+
let url = format!(
92+
"https://api.github.com/repos/{}/releases/latest",
93+
GITHUB_REPO
94+
);
95+
96+
let client = reqwest::Client::new();
97+
let response = client
98+
.get(&url)
99+
.header("User-Agent", "sus-cli")
100+
.send()
101+
.await?;
102+
103+
if !response.status().is_success() {
104+
return Err(anyhow!(
105+
"Failed to fetch latest version: HTTP {}",
106+
response.status()
107+
));
108+
}
109+
110+
let release: GitHubRelease = response.json().await?;
111+
Ok(release.tag_name)
112+
}
113+
114+
/// Detect the current platform (OS and architecture)
115+
fn detect_platform() -> Result<(String, String)> {
116+
let os = if cfg!(target_os = "macos") {
117+
"darwin"
118+
} else if cfg!(target_os = "linux") {
119+
"linux"
120+
} else {
121+
return Err(anyhow!("Unsupported OS"));
122+
};
123+
124+
let arch = if cfg!(target_arch = "x86_64") {
125+
"x86_64"
126+
} else if cfg!(target_arch = "aarch64") {
127+
"aarch64"
128+
} else {
129+
return Err(anyhow!("Unsupported architecture"));
130+
};
131+
132+
Ok((os.to_string(), arch.to_string()))
133+
}
134+
135+
/// Download the release tarball and install it
136+
async fn download_and_install(version: &str, os: &str, arch: &str) -> Result<()> {
137+
let tarball_name = format!("sus-{}-{}.tar.gz", os, arch);
138+
let download_url = format!(
139+
"https://github.com/{}/releases/download/{}/{}",
140+
GITHUB_REPO, version, tarball_name
141+
);
142+
143+
// Download to temp file
144+
let client = reqwest::Client::new();
145+
let response = client
146+
.get(&download_url)
147+
.header("User-Agent", "sus-cli")
148+
.send()
149+
.await?;
150+
151+
if !response.status().is_success() {
152+
return Err(anyhow!(
153+
"Failed to download release: HTTP {} - Check if {} exists for {}-{}",
154+
response.status(),
155+
version,
156+
os,
157+
arch
158+
));
159+
}
160+
161+
let bytes = response.bytes().await?;
162+
163+
// Create temp directory
164+
let temp_dir = std::env::temp_dir().join("sus-upgrade");
165+
fs::create_dir_all(&temp_dir)?;
166+
167+
let tarball_path = temp_dir.join(&tarball_name);
168+
let mut file = File::create(&tarball_path)?;
169+
file.write_all(&bytes)?;
170+
drop(file);
171+
172+
// Extract tarball
173+
let tar_gz = File::open(&tarball_path)?;
174+
let tar = GzDecoder::new(tar_gz);
175+
let mut archive = Archive::new(tar);
176+
archive.unpack(&temp_dir)?;
177+
178+
// Find the extracted binary
179+
let extracted_binary = temp_dir.join("sus");
180+
if !extracted_binary.exists() {
181+
return Err(anyhow!("Binary not found in archive"));
182+
}
183+
184+
// Get current executable path
185+
let current_exe = std::env::current_exe()?;
186+
187+
// Replace the binary
188+
replace_binary(&extracted_binary, &current_exe)?;
189+
190+
// Cleanup
191+
let _ = fs::remove_dir_all(&temp_dir);
192+
193+
Ok(())
194+
}
195+
196+
/// Compare two version strings (semver-like)
197+
/// Returns true if `new_version` is newer than `current_version`
198+
fn is_version_newer(new_version: &str, current_version: &str) -> bool {
199+
let parse_version =
200+
|v: &str| -> Vec<u32> { v.split('.').filter_map(|s| s.parse::<u32>().ok()).collect() };
201+
202+
let new_parts = parse_version(new_version);
203+
let current_parts = parse_version(current_version);
204+
205+
for i in 0..3 {
206+
let new_val = new_parts.get(i).copied().unwrap_or(0);
207+
let cur_val = current_parts.get(i).copied().unwrap_or(0);
208+
209+
if new_val > cur_val {
210+
return true;
211+
}
212+
if new_val < cur_val {
213+
return false;
214+
}
215+
}
216+
217+
false // versions are equal
218+
}
219+
220+
/// Replace the current binary with the new one
221+
fn replace_binary(new_binary: &PathBuf, current_exe: &PathBuf) -> Result<()> {
222+
#[cfg(unix)]
223+
{
224+
use std::os::unix::fs::PermissionsExt;
225+
226+
// On Unix, we can copy over the running binary
227+
// The old binary stays in memory until the process exits
228+
fs::copy(new_binary, current_exe)?;
229+
230+
// Ensure executable permissions
231+
let mut perms = fs::metadata(current_exe)?.permissions();
232+
perms.set_mode(0o755);
233+
fs::set_permissions(current_exe, perms)?;
234+
}
235+
236+
#[cfg(windows)]
237+
{
238+
// On Windows, rename the old binary and copy new one
239+
let backup_path = current_exe.with_extension("old");
240+
let _ = fs::remove_file(&backup_path); // Remove any existing backup
241+
fs::rename(current_exe, &backup_path)?;
242+
fs::copy(new_binary, current_exe)?;
243+
}
244+
245+
Ok(())
246+
}
247+
248+
#[cfg(test)]
249+
mod tests {
250+
use super::*;
251+
252+
#[test]
253+
fn test_detect_platform() {
254+
let result = detect_platform();
255+
assert!(result.is_ok());
256+
257+
let (os, arch) = result.unwrap();
258+
259+
#[cfg(target_os = "macos")]
260+
assert_eq!(os, "darwin");
261+
262+
#[cfg(target_os = "linux")]
263+
assert_eq!(os, "linux");
264+
265+
#[cfg(target_arch = "x86_64")]
266+
assert_eq!(arch, "x86_64");
267+
268+
#[cfg(target_arch = "aarch64")]
269+
assert_eq!(arch, "aarch64");
270+
}
271+
272+
#[test]
273+
fn test_current_version() {
274+
// Verify version is a valid semver-like string
275+
assert!(CURRENT_VERSION.contains('.'));
276+
}
277+
278+
#[test]
279+
fn test_is_version_newer() {
280+
// Newer versions
281+
assert!(is_version_newer("0.1.6", "0.1.5"));
282+
assert!(is_version_newer("0.2.0", "0.1.9"));
283+
assert!(is_version_newer("1.0.0", "0.9.9"));
284+
285+
// Same version
286+
assert!(!is_version_newer("0.1.5", "0.1.5"));
287+
288+
// Older versions
289+
assert!(!is_version_newer("0.1.4", "0.1.5"));
290+
assert!(!is_version_newer("0.1.0", "0.2.0"));
291+
}
292+
}

crates/cli/src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ enum Commands {
9595
#[arg(long)]
9696
all: bool,
9797
},
98+
99+
/// Upgrade sus to the latest version
100+
Upgrade {
101+
/// Force upgrade even if already on latest version
102+
#[arg(long)]
103+
force: bool,
104+
},
98105
}
99106

100107
#[tokio::main]
@@ -133,5 +140,7 @@ async fn main() -> anyhow::Result<()> {
133140
Commands::Why { package } => commands::why::run(&package).await,
134141

135142
Commands::Uninstall { yes, all } => commands::uninstall::run(yes, all).await,
143+
144+
Commands::Upgrade { force } => commands::upgrade::run(force).await,
136145
}
137146
}

0 commit comments

Comments
 (0)