@@ -3,7 +3,7 @@ use bytesize::ByteSize;
33use clap_config_file:: ClapConfigFile ;
44use sha2:: { Digest , Sha256 } ;
55use 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
88use 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}
0 commit comments