Skip to content

Commit eff6343

Browse files
committed
feat: add self-update command
- Add 'self-update' command with --check flag - Query GitHub for latest gitclaw release - Download and replace current binary atomically - Support for all platforms (Linux, macOS, Windows) - Backup current binary before update (rollback on failure) Part of v0.3.0 phase 2 (self-update).
1 parent 9c86789 commit eff6343

4 files changed

Lines changed: 196 additions & 0 deletions

File tree

src/cli.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,10 @@ pub enum Commands {
3939
#[arg(value_enum)]
4040
shell: Shell,
4141
},
42+
/// Update gitclaw itself
43+
SelfUpdate {
44+
/// Only check for updates, don't install
45+
#[arg(long)]
46+
check: bool,
47+
},
4248
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ pub mod github;
55
pub mod install;
66
pub mod platform;
77
pub mod registry;
8+
pub mod self_update;
89
pub mod util;

src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod github;
77
mod install;
88
mod platform;
99
mod registry;
10+
mod self_update;
1011
mod util;
1112

1213
// Extract module as a directory
@@ -76,6 +77,13 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> {
7677
let name = cmd.get_name().to_string();
7778
generate(shell, &mut cmd, name, &mut std::io::stdout());
7879
}
80+
Commands::SelfUpdate { check } => {
81+
if check {
82+
self_update::check_for_update(&config).await?
83+
} else {
84+
self_update::perform_update(&config).await?
85+
}
86+
}
7987
}
8088

8189
Ok(())

src/self_update.rs

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
use anyhow::{anyhow, bail, Context, Result};
2+
use std::env;
3+
use std::path::PathBuf;
4+
5+
use crate::config::Config;
6+
use crate::extract::extract_archive;
7+
use crate::github::{find_matching_asset, GithubClient, Platform};
8+
9+
const REPO_OWNER: &str = "clawdeeo";
10+
const REPO_NAME: &str = "gitclaw";
11+
12+
/// Get the current executable path
13+
fn current_executable() -> Result<PathBuf> {
14+
env::current_exe().context("Failed to get current executable path")
15+
}
16+
17+
/// Get the current version from Cargo.toml
18+
fn current_version() -> String {
19+
env!("CARGO_PKG_VERSION").to_string()
20+
}
21+
22+
/// Check for updates without installing
23+
pub async fn check_for_update(config: &Config) -> Result<()> {
24+
let client = GithubClient::new(config.github_token.clone())?;
25+
let release = client.get_release(REPO_OWNER, REPO_NAME, "latest").await?;
26+
27+
let current = current_version();
28+
let latest = release.tag_name.trim_start_matches('v').to_string();
29+
30+
println!("Current version: {}", current);
31+
println!("Latest version: {}", latest);
32+
33+
if latest == current {
34+
println!("gitclaw is up to date!");
35+
} else {
36+
println!("Update available: {} -> {}", current, latest);
37+
println!("Run 'gitclaw self-update' to install");
38+
}
39+
40+
Ok(())
41+
}
42+
43+
/// Perform self-update
44+
pub async fn perform_update(config: &Config) -> Result<()> {
45+
let client = GithubClient::new(config.github_token.clone())?;
46+
let release = client.get_release(REPO_OWNER, REPO_NAME, "latest").await?;
47+
48+
let current = current_version();
49+
let latest = release.tag_name.trim_start_matches('v').to_string();
50+
51+
if latest == current {
52+
println!("gitclaw is already at the latest version ({})", current);
53+
return Ok(());
54+
}
55+
56+
println!("Updating gitclaw: {} -> {}", current, latest);
57+
58+
// Find matching asset for current platform
59+
let platform = Platform::current()?;
60+
let asset = find_matching_asset(&release, platform)
61+
.map_err(|_| anyhow!("No suitable asset found for platform: {}", platform))?;
62+
63+
if !config.output.quiet {
64+
println!("Downloading: {}", asset.name);
65+
}
66+
67+
// Download to temp location
68+
let temp_dir = std::env::temp_dir().join("gitclaw-self-update");
69+
std::fs::create_dir_all(&temp_dir)?;
70+
let download_path = temp_dir.join(&asset.name);
71+
72+
client
73+
.download_asset(asset, &download_path, config.download.show_progress)
74+
.await?;
75+
76+
// Get current executable path
77+
let current_exe = current_executable()?;
78+
79+
// Handle based on archive type vs direct binary
80+
if asset.name.ends_with(".tar.gz")
81+
|| asset.name.ends_with(".zip")
82+
|| asset.name.ends_with(".tar.xz")
83+
{
84+
// Extract and find binary
85+
let extract_dir = temp_dir.join("extracted");
86+
extract_archive(&download_path, &extract_dir, true)?;
87+
88+
let new_binary = find_binary(&extract_dir, REPO_NAME)?;
89+
90+
// Replace current binary
91+
replace_binary(&new_binary, &current_exe)?;
92+
} else {
93+
// Direct binary download
94+
replace_binary(&download_path, &current_exe)?;
95+
}
96+
97+
// Cleanup
98+
let _ = std::fs::remove_dir_all(&temp_dir);
99+
100+
println!("gitclaw updated successfully to {}", latest);
101+
Ok(())
102+
}
103+
104+
/// Find binary in extracted directory
105+
fn find_binary(dir: &std::path::Path, name: &str) -> Result<PathBuf> {
106+
use walkdir::WalkDir;
107+
108+
for entry in WalkDir::new(dir).max_depth(2) {
109+
let entry = entry?;
110+
if !entry.file_type().is_file() {
111+
continue;
112+
}
113+
114+
let file_name = entry
115+
.path()
116+
.file_stem()
117+
.unwrap_or_default()
118+
.to_string_lossy();
119+
120+
if file_name == name {
121+
return Ok(entry.path().to_path_buf());
122+
}
123+
}
124+
125+
bail!("Binary '{}' not found in extracted archive", name)
126+
}
127+
128+
/// Replace current binary with new one
129+
#[cfg(unix)]
130+
fn replace_binary(new: &std::path::Path, current: &std::path::Path) -> Result<()> {
131+
use std::os::unix::fs::PermissionsExt;
132+
133+
// On Unix: write to temp file, then rename (atomic)
134+
let backup = current.with_extension("backup");
135+
136+
// Rename current to backup
137+
std::fs::rename(current, &backup)?;
138+
139+
// Copy new to current location
140+
match std::fs::copy(new, current) {
141+
Ok(_) => {
142+
// Set executable permissions
143+
let mut perms = std::fs::metadata(current)?.permissions();
144+
perms.set_mode(0o755);
145+
std::fs::set_permissions(current, perms)?;
146+
147+
// Remove backup on success
148+
let _ = std::fs::remove_file(&backup);
149+
Ok(())
150+
}
151+
Err(e) => {
152+
// Restore backup on failure
153+
let _ = std::fs::rename(&backup, current);
154+
bail!("Failed to install new binary: {}", e)
155+
}
156+
}
157+
}
158+
159+
#[cfg(windows)]
160+
fn replace_binary(new: &std::path::Path, current: &std::path::Path) -> Result<()> {
161+
// On Windows: rename current (in-use files can't be overwritten)
162+
// Then copy new file
163+
let backup = current.with_extension("exe.backup");
164+
165+
// Rename current to backup
166+
std::fs::rename(current, &backup)?;
167+
168+
// Copy new to current location
169+
match std::fs::copy(new, current) {
170+
Ok(_) => {
171+
// Remove backup on success
172+
let _ = std::fs::remove_file(&backup);
173+
Ok(())
174+
}
175+
Err(e) => {
176+
// Try to restore backup
177+
let _ = std::fs::rename(&backup, current);
178+
bail!("Failed to install new binary: {}", e)
179+
}
180+
}
181+
}

0 commit comments

Comments
 (0)