Skip to content

Commit d5fc996

Browse files
authored
feat: add self-update command (#13)
- 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 d5fc996

5 files changed

Lines changed: 205 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,41 @@ let file = fs::read(&path)?; // Less helpful
284284

285285
---
286286

287+
### Code Formatting Rules
288+
289+
**Spacing for multi-line code blocks:**
290+
- Use blank line *after* multi-line code blocks (if/while/match/fn bodies spanning multiple lines)
291+
- Single-line statements don't need trailing blank line
292+
- Opening brace on same line for functions, structs, enums
293+
294+
**Example:**
295+
```rust
296+
// Good: no blank line after single-line if
297+
if condition { do_something(); }
298+
let x = 1;
299+
300+
// Good: blank line after multi-line if
301+
if condition {
302+
do_first();
303+
do_second();
304+
}
305+
306+
let y = 2;
307+
308+
// Good: no blank line within single expressions
309+
let result = match value {
310+
Some(v) => process(v),
311+
None => default(),
312+
};
313+
```
314+
315+
**Avoid:**
316+
- Excessive blank lines
317+
- Comments that restate the obvious
318+
- "What" comments instead of "why" comments
319+
320+
---
321+
287322
## Resources
288323

289324
- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/)

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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
let extract_dir = temp_dir.join("extracted");
85+
extract_archive(&download_path, &extract_dir, true)?;
86+
let new_binary = find_binary(&extract_dir, REPO_NAME)?;
87+
replace_binary(&new_binary, &current_exe)?;
88+
} else {
89+
replace_binary(&download_path, &current_exe)?;
90+
}
91+
let _ = std::fs::remove_dir_all(&temp_dir);
92+
println!("gitclaw updated successfully to {}", latest);
93+
Ok(())
94+
}
95+
96+
/// Find binary in extracted directory
97+
fn find_binary(dir: &std::path::Path, name: &str) -> Result<PathBuf> {
98+
use walkdir::WalkDir;
99+
100+
for entry in WalkDir::new(dir).max_depth(2) {
101+
let entry = entry?;
102+
if !entry.file_type().is_file() {
103+
continue;
104+
}
105+
let file_name = entry
106+
.path()
107+
.file_stem()
108+
.unwrap_or_default()
109+
.to_string_lossy();
110+
if file_name == name {
111+
return Ok(entry.path().to_path_buf());
112+
}
113+
}
114+
bail!("Binary '{}' not found in extracted archive", name)
115+
}
116+
117+
/// Replace current binary with new one
118+
#[cfg(unix)]
119+
fn replace_binary(new: &std::path::Path, current: &std::path::Path) -> Result<()> {
120+
use std::os::unix::fs::PermissionsExt;
121+
122+
// On Unix: write to temp file, then rename (atomic)
123+
let backup = current.with_extension("backup");
124+
std::fs::rename(current, &backup)?;
125+
match std::fs::copy(new, current) {
126+
Ok(_) => {
127+
let mut perms = std::fs::metadata(current)?.permissions();
128+
perms.set_mode(0o755);
129+
std::fs::set_permissions(current, perms)?;
130+
let _ = std::fs::remove_file(&backup);
131+
Ok(())
132+
}
133+
Err(e) => {
134+
let _ = std::fs::rename(&backup, current);
135+
bail!("Failed to install new binary: {}", e)
136+
}
137+
}
138+
}
139+
140+
#[cfg(windows)]
141+
fn replace_binary(new: &std::path::Path, current: &std::path::Path) -> Result<()> {
142+
// On Windows: rename current (in-use files can't be overwritten)
143+
let backup = current.with_extension("exe.backup");
144+
std::fs::rename(current, &backup)?;
145+
match std::fs::copy(new, current) {
146+
Ok(_) => {
147+
let _ = std::fs::remove_file(&backup);
148+
Ok(())
149+
}
150+
Err(e) => {
151+
let _ = std::fs::rename(&backup, current);
152+
bail!("Failed to install new binary: {}", e)
153+
}
154+
}
155+
}

0 commit comments

Comments
 (0)