Skip to content

Commit 96e64a8

Browse files
authored
Merge pull request #213 from bodo-run/copilot/fix-3d7d85c3-3707-4f8f-b841-925f6329216d
Add `yek --update` command for self-updating functionality
2 parents 21a40c6 + ab0d972 commit 96e64a8

File tree

3 files changed

+340
-1
lines changed

3 files changed

+340
-1
lines changed

src/config.rs

Lines changed: 243 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use bytesize::ByteSize;
33
use clap_config_file::ClapConfigFile;
44
use sha2::{Digest, Sha256};
55
use std::io::{self, BufRead, BufReader, IsTerminal};
6-
use std::{fs, path::Path, str::FromStr, time::UNIX_EPOCH};
6+
use std::{fs, path::Path, process::Command, str::FromStr, time::UNIX_EPOCH};
77

88
use crate::{
99
defaults::{BINARY_FILE_EXTENSIONS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTPUT_TEMPLATE},
@@ -30,6 +30,10 @@ pub struct YekConfig {
3030
#[config_arg(long = "version", short = 'V')]
3131
pub version: bool,
3232

33+
/// Update yek to the latest version
34+
#[config_arg(long = "update")]
35+
pub update: bool,
36+
3337
/// Max size per chunk. e.g. "10MB" or "128K" or when using token counting mode, "100" or "128K"
3438
#[config_arg(default_value = "10MB")]
3539
pub max_size: String,
@@ -114,6 +118,7 @@ impl Default for YekConfig {
114118
Self {
115119
input_paths: Vec::new(),
116120
version: false,
121+
update: false,
117122
max_size: "10MB".to_string(),
118123
tokens: String::new(),
119124
json: false,
@@ -207,6 +212,17 @@ impl YekConfig {
207212
std::process::exit(0);
208213
}
209214

215+
// Handle update flag
216+
if cfg.update {
217+
match cfg.perform_update() {
218+
Ok(()) => std::process::exit(0),
219+
Err(e) => {
220+
eprintln!("Error updating yek: {}", e);
221+
std::process::exit(1);
222+
}
223+
}
224+
}
225+
210226
// 2) compute derived fields:
211227
cfg.token_mode = !cfg.tokens.is_empty();
212228
let force_tty = std::env::var("FORCE_TTY").is_ok();
@@ -434,4 +450,230 @@ impl YekConfig {
434450

435451
Ok(())
436452
}
453+
454+
/// Update yek to the latest version by downloading and replacing the current binary
455+
pub fn perform_update(&self) -> Result<()> {
456+
const REPO_OWNER: &str = "bodo-run";
457+
const REPO_NAME: &str = "yek";
458+
459+
println!("Checking for latest version...");
460+
461+
// Get the current executable path
462+
let current_exe = std::env::current_exe()
463+
.map_err(|e| anyhow!("Failed to get current executable path: {}", e))?;
464+
465+
if !current_exe.exists() {
466+
return Err(anyhow!("Current executable path does not exist"));
467+
}
468+
469+
// Check if the current executable is writable
470+
let metadata = fs::metadata(&current_exe)?;
471+
if metadata.permissions().readonly() {
472+
return Err(anyhow!("Cannot update: current executable is not writable. Try running with elevated permissions or install to a writable location."));
473+
}
474+
475+
// Determine target architecture
476+
let target = Self::get_target_triple()?;
477+
let asset_name = format!("yek-{}.tar.gz", target);
478+
479+
println!("Fetching release info for target: {}", target);
480+
481+
// Get latest release info from GitHub API
482+
let releases_url = format!(
483+
"https://api.github.com/repos/{}/{}/releases/latest",
484+
REPO_OWNER, REPO_NAME
485+
);
486+
let releases_output = Command::new("curl")
487+
.args(["-s", &releases_url])
488+
.output()
489+
.map_err(|e| anyhow!("Failed to execute curl command: {}. Is curl installed?", e))?;
490+
491+
if !releases_output.status.success() {
492+
let stderr = String::from_utf8_lossy(&releases_output.stderr);
493+
return Err(anyhow!("Failed to fetch release info: {}", stderr));
494+
}
495+
496+
let release_json = String::from_utf8_lossy(&releases_output.stdout);
497+
498+
// Parse JSON to find download URL (simple string parsing to avoid adding serde_json dependency)
499+
let download_url = Self::extract_download_url(&release_json, &asset_name)?;
500+
501+
// Get the new version tag
502+
let new_version = Self::extract_version_tag(&release_json)?;
503+
let current_version = env!("CARGO_PKG_VERSION");
504+
505+
println!("Current version: {}", current_version);
506+
println!("Latest version: {}", new_version);
507+
508+
if new_version == current_version {
509+
println!("You are already running the latest version!");
510+
return Ok(());
511+
}
512+
513+
println!("Downloading update from: {}", download_url);
514+
515+
// Create temp directory for download
516+
let temp_dir = std::env::temp_dir().join(format!("yek-update-{}", new_version));
517+
fs::create_dir_all(&temp_dir)?;
518+
519+
let archive_path = temp_dir.join(&asset_name);
520+
521+
// Download the archive
522+
let download_output = Command::new("curl")
523+
.args(["-L", "-o"])
524+
.arg(&archive_path)
525+
.arg(&download_url)
526+
.output()
527+
.map_err(|e| anyhow!("Failed to download update: {}", e))?;
528+
529+
if !download_output.status.success() {
530+
let stderr = String::from_utf8_lossy(&download_output.stderr);
531+
return Err(anyhow!("Failed to download update: {}", stderr));
532+
}
533+
534+
// Extract the archive
535+
println!("Extracting update...");
536+
let extract_output = Command::new("tar")
537+
.args(["xzf"])
538+
.arg(&archive_path)
539+
.current_dir(&temp_dir)
540+
.output()
541+
.map_err(|e| anyhow!("Failed to extract archive: {}. Is tar installed?", e))?;
542+
543+
if !extract_output.status.success() {
544+
let stderr = String::from_utf8_lossy(&extract_output.stderr);
545+
return Err(anyhow!("Failed to extract archive: {}", stderr));
546+
}
547+
548+
// Find the new binary
549+
let extracted_dir = temp_dir.join(format!("yek-{}", target));
550+
let new_binary = extracted_dir.join("yek");
551+
552+
if !new_binary.exists() {
553+
return Err(anyhow!("Updated binary not found in extracted archive"));
554+
}
555+
556+
// Replace the current binary
557+
println!("Installing update...");
558+
559+
// Create backup of current binary
560+
let backup_path = format!("{}.backup", current_exe.to_string_lossy());
561+
fs::copy(&current_exe, &backup_path)?;
562+
563+
// Replace with new binary
564+
match fs::copy(&new_binary, &current_exe) {
565+
Ok(_) => {
566+
// Make the new binary executable (Unix-like systems)
567+
#[cfg(unix)]
568+
{
569+
use std::os::unix::fs::PermissionsExt;
570+
let mut perms = fs::metadata(&current_exe)?.permissions();
571+
perms.set_mode(0o755);
572+
fs::set_permissions(&current_exe, perms)?;
573+
}
574+
575+
// Remove backup on success
576+
let _ = fs::remove_file(&backup_path);
577+
578+
println!(
579+
"Successfully updated yek from {} to {}!",
580+
current_version, new_version
581+
);
582+
println!("Update complete! You can now run yek with the new version.");
583+
}
584+
Err(e) => {
585+
// Restore from backup on failure
586+
let _ = fs::copy(&backup_path, &current_exe);
587+
let _ = fs::remove_file(&backup_path);
588+
return Err(anyhow!("Failed to replace binary: {}", e));
589+
}
590+
}
591+
592+
// Cleanup temp directory
593+
let _ = fs::remove_dir_all(&temp_dir);
594+
595+
Ok(())
596+
}
597+
598+
/// Determine the target triple for the current platform
599+
pub fn get_target_triple() -> Result<String> {
600+
let os = std::env::consts::OS;
601+
let arch = std::env::consts::ARCH;
602+
603+
let target = match (os, arch) {
604+
("linux", "x86_64") => {
605+
// Try to detect if we should use musl or gnu
606+
// Default to musl for better compatibility
607+
"x86_64-unknown-linux-musl"
608+
}
609+
("linux", "aarch64") => "aarch64-unknown-linux-musl",
610+
("macos", "x86_64") => "x86_64-apple-darwin",
611+
("macos", "aarch64") => "aarch64-apple-darwin",
612+
("windows", "x86_64") => "x86_64-pc-windows-msvc",
613+
("windows", "aarch64") => "aarch64-pc-windows-msvc",
614+
_ => return Err(anyhow!("Unsupported platform: {} {}", os, arch)),
615+
};
616+
617+
Ok(target.to_string())
618+
}
619+
620+
/// Extract download URL from GitHub releases API JSON response
621+
pub fn extract_download_url(json: &str, asset_name: &str) -> Result<String> {
622+
// Simple JSON parsing to find the browser_download_url for our asset
623+
let lines: Vec<&str> = json.lines().collect();
624+
let mut found_asset = false;
625+
626+
for line in lines.iter() {
627+
// Look for the asset name
628+
if line.contains(&format!("\"name\": \"{}\"", asset_name)) {
629+
found_asset = true;
630+
continue;
631+
}
632+
633+
// If we found our asset, look for the download URL in nearby lines
634+
if found_asset && line.contains("browser_download_url") {
635+
if let Some(url_start) = line.find("https://") {
636+
if let Some(url_end) = line[url_start..].find('"') {
637+
let url = &line[url_start..url_start + url_end];
638+
return Ok(url.to_string());
639+
}
640+
}
641+
}
642+
}
643+
644+
Err(anyhow!(
645+
"Could not find download URL for asset: {}",
646+
asset_name
647+
))
648+
}
649+
650+
/// Extract version tag from GitHub releases API JSON response
651+
pub fn extract_version_tag(json: &str) -> Result<String> {
652+
// Look for "tag_name": "v1.2.3"
653+
for line in json.lines() {
654+
if line.contains("\"tag_name\":") {
655+
// Find the colon after tag_name
656+
if let Some(colon_pos) = line.find(':') {
657+
let after_colon = &line[colon_pos + 1..];
658+
// Find the first quote after the colon
659+
if let Some(first_quote) = after_colon.find('"') {
660+
let value_start = first_quote + 1;
661+
// Find the closing quote
662+
if let Some(second_quote) = after_colon[value_start..].find('"') {
663+
let tag = &after_colon[value_start..value_start + second_quote];
664+
// Remove 'v' prefix if present
665+
let version = if let Some(stripped) = tag.strip_prefix('v') {
666+
stripped
667+
} else {
668+
tag
669+
};
670+
return Ok(version.to_string());
671+
}
672+
}
673+
}
674+
}
675+
}
676+
677+
Err(anyhow!("Could not extract version from release info"))
678+
}
437679
}

tests/config_test.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,3 +1297,86 @@ fn test_unignore_patterns_processing() {
12971297
.ignore_patterns
12981298
.contains(&"!important.log".to_string()));
12991299
}
1300+
1301+
#[test]
1302+
fn test_config_update_flag_default() {
1303+
let config = YekConfig::default();
1304+
assert!(!config.update, "update flag should default to false");
1305+
}
1306+
1307+
#[test]
1308+
fn test_get_target_triple() {
1309+
// Test that we can determine a target triple for the current platform
1310+
let result = YekConfig::get_target_triple();
1311+
assert!(result.is_ok(), "Should be able to determine target triple");
1312+
1313+
let target = result.unwrap();
1314+
assert!(!target.is_empty(), "Target triple should not be empty");
1315+
1316+
// Should be one of the supported platforms
1317+
let supported_targets = [
1318+
"x86_64-unknown-linux-musl",
1319+
"aarch64-unknown-linux-musl",
1320+
"x86_64-apple-darwin",
1321+
"aarch64-apple-darwin",
1322+
"x86_64-pc-windows-msvc",
1323+
"aarch64-pc-windows-msvc",
1324+
];
1325+
1326+
assert!(
1327+
supported_targets.contains(&target.as_str()),
1328+
"Target triple '{}' should be supported",
1329+
target
1330+
);
1331+
}
1332+
1333+
#[test]
1334+
fn test_extract_version_tag() {
1335+
let mock_json = r#"{
1336+
"url": "https://api.github.com/repos/bodo-run/yek/releases/123",
1337+
"tag_name": "v1.2.3",
1338+
"name": "Release v1.2.3",
1339+
"draft": false
1340+
}"#;
1341+
1342+
let result = YekConfig::extract_version_tag(mock_json);
1343+
assert!(result.is_ok(), "Should extract version successfully");
1344+
assert_eq!(result.unwrap(), "1.2.3", "Should remove 'v' prefix");
1345+
1346+
// Test without 'v' prefix
1347+
let mock_json_no_v = r#"{
1348+
"tag_name": "2.0.0",
1349+
"name": "Release 2.0.0"
1350+
}"#;
1351+
1352+
let result = YekConfig::extract_version_tag(mock_json_no_v);
1353+
assert!(result.is_ok(), "Should extract version without 'v' prefix");
1354+
assert_eq!(result.unwrap(), "2.0.0", "Should return version as-is");
1355+
}
1356+
1357+
#[test]
1358+
fn test_extract_download_url() {
1359+
let mock_json = r#"{
1360+
"assets": [
1361+
{
1362+
"name": "yek-x86_64-unknown-linux-musl.tar.gz",
1363+
"browser_download_url": "https://github.com/bodo-run/yek/releases/download/v1.2.3/yek-x86_64-unknown-linux-musl.tar.gz"
1364+
},
1365+
{
1366+
"name": "yek-aarch64-apple-darwin.tar.gz",
1367+
"browser_download_url": "https://github.com/bodo-run/yek/releases/download/v1.2.3/yek-aarch64-apple-darwin.tar.gz"
1368+
}
1369+
]
1370+
}"#;
1371+
1372+
let result = YekConfig::extract_download_url(mock_json, "yek-x86_64-unknown-linux-musl.tar.gz");
1373+
assert!(result.is_ok(), "Should extract download URL successfully");
1374+
assert_eq!(
1375+
result.unwrap(),
1376+
"https://github.com/bodo-run/yek/releases/download/v1.2.3/yek-x86_64-unknown-linux-musl.tar.gz"
1377+
);
1378+
1379+
// Test asset not found
1380+
let result = YekConfig::extract_download_url(mock_json, "nonexistent-asset.tar.gz");
1381+
assert!(result.is_err(), "Should fail when asset not found");
1382+
}

tests/main_test.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,17 @@ fn test_output_name_only_no_output_dir() {
437437
// Check that the output file was created in the temp directory (fallback behavior)
438438
// Note: when no output_dir is specified and not streaming, it should fall back to temp dir
439439
}
440+
441+
#[test]
442+
fn test_main_help_includes_update_flag() {
443+
// Verify that running the binary with '--help' includes the --update flag
444+
use predicates::prelude::*;
445+
446+
Command::cargo_bin("yek")
447+
.expect("Binary 'yek' not found")
448+
.arg("--help")
449+
.assert()
450+
.success()
451+
.stdout(predicate::str::contains("--update"))
452+
.stdout(predicate::str::contains("Update yek to the latest version"));
453+
}

0 commit comments

Comments
 (0)