@@ -11,6 +11,19 @@ use std::path::PathBuf;
1111use std:: process:: { Command , Stdio } ;
1212use std:: sync:: LazyLock ;
1313
14+ /// Checked at most once per hour.
15+ const UPDATE_CHECK_INTERVAL_SECS : i64 = 60 * 60 ;
16+ /// Shorter HTTP timeout so a slow or unreachable GitHub does not stall the CLI for long.
17+ const HTTP_CLIENT_TIMEOUT_SECS : u64 = 5 ;
18+
19+ /// Shared HTTP client with a short timeout, used for all outbound requests.
20+ static HTTP_CLIENT : LazyLock < reqwest:: blocking:: Client > = LazyLock :: new ( || {
21+ reqwest:: blocking:: Client :: builder ( )
22+ . timeout ( std:: time:: Duration :: from_secs ( HTTP_CLIENT_TIMEOUT_SECS ) )
23+ . build ( )
24+ . expect ( "Failed to build HTTP client" )
25+ } ) ;
26+
1427/// Storage directory for AVM, customizable by setting the $AVM_HOME, defaults to ~/.avm
1528pub static AVM_HOME : LazyLock < PathBuf > = LazyLock :: new ( || {
1629 cfg_if:: cfg_if! {
@@ -231,8 +244,7 @@ pub fn update(include_pre_release: bool) -> Result<()> {
231244///
232245/// returns the full commit sha3 for unique versioning downstream
233246pub fn check_and_get_full_commit ( commit : & str ) -> Result < String > {
234- let client = reqwest:: blocking:: Client :: new ( ) ;
235- let response = client
247+ let response = HTTP_CLIENT
236248 . get ( format ! (
237249 "https://api.github.com/repos/solana-foundation/anchor/commits/{commit}"
238250 ) )
@@ -289,11 +301,10 @@ fn append_commit(version: &mut Version, commit: &str) -> Result<()> {
289301}
290302
291303fn get_anchor_version_from_commit ( commit : & str ) -> Result < Version > {
292- let client = reqwest:: blocking:: Client :: new ( ) ;
293304 let base = format ! ( "https://raw.githubusercontent.com/solana-foundation/anchor/{commit}" ) ;
294305
295306 // Newer versions (workspace layout): version lives in [workspace.package] of the root Cargo.toml.
296- if let Some ( text) = fetch_raw ( & client , & format ! ( "{base}/Cargo.toml" ) ) ? {
307+ if let Some ( text) = fetch_raw ( & HTTP_CLIENT , & format ! ( "{base}/Cargo.toml" ) ) ? {
297308 if let Ok ( manifest) = Manifest :: from_str ( & text) {
298309 if let Some ( version_str) = manifest
299310 . workspace
@@ -309,7 +320,7 @@ fn get_anchor_version_from_commit(commit: &str) -> Result<Version> {
309320 }
310321
311322 // Older versions: version lives in [package] of cli/Cargo.toml.
312- let text = fetch_raw ( & client , & format ! ( "{base}/cli/Cargo.toml" ) ) ?
323+ let text = fetch_raw ( & HTTP_CLIENT , & format ! ( "{base}/cli/Cargo.toml" ) ) ?
313324 . ok_or_else ( || anyhow ! ( "Could not find anchor-cli version for commit {commit}" ) ) ?;
314325 let manifest = Manifest :: from_str ( & text) ?;
315326 let mut version = manifest. package ( ) . version ( ) . parse :: < Version > ( ) ?;
@@ -599,6 +610,13 @@ pub fn read_anchorversion_file() -> Result<Version> {
599610/// Retrieve a list of installable versions of anchor-cli using the GitHub API and tags on the Anchor
600611/// repository.
601612pub fn fetch_versions ( include_pre_release : bool ) -> Result < Vec < Version > , Error > {
613+ fetch_versions_with_client ( & HTTP_CLIENT , include_pre_release)
614+ }
615+
616+ fn fetch_versions_with_client (
617+ client : & reqwest:: blocking:: Client ,
618+ include_pre_release : bool ,
619+ ) -> Result < Vec < Version > , Error > {
602620 #[ derive( Deserialize ) ]
603621 struct Release {
604622 #[ serde( rename = "name" , deserialize_with = "version_deserializer" ) ]
@@ -615,7 +633,7 @@ pub fn fetch_versions(include_pre_release: bool) -> Result<Vec<Version>, Error>
615633 Version :: parse ( s. trim_start_matches ( 'v' ) ) . map_err ( de:: Error :: custom)
616634 }
617635
618- let response = reqwest :: blocking :: Client :: new ( )
636+ let response = client
619637 . get ( "https://api.github.com/repos/solana-foundation/anchor/releases" )
620638 . header (
621639 USER_AGENT ,
@@ -685,7 +703,14 @@ pub fn list_versions(include_pre_release: bool) -> Result<()> {
685703}
686704
687705pub fn get_latest_version ( include_pre_release : bool ) -> Result < Version > {
688- let mut versions = fetch_versions ( include_pre_release) ?;
706+ get_latest_version_with_client ( & HTTP_CLIENT , include_pre_release)
707+ }
708+
709+ fn get_latest_version_with_client (
710+ client : & reqwest:: blocking:: Client ,
711+ include_pre_release : bool ,
712+ ) -> Result < Version > {
713+ let mut versions = fetch_versions_with_client ( client, include_pre_release) ?;
689714 versions. sort ( ) ;
690715 versions
691716 . into_iter ( )
@@ -706,6 +731,138 @@ pub fn read_installed_versions() -> Result<Vec<Version>> {
706731 Ok ( versions)
707732}
708733
734+ // ── AVM self-update ───────────────────────────────────────────────────────────
735+
736+ fn update_check_file_path ( ) -> PathBuf {
737+ AVM_HOME . join ( ".update-check" )
738+ }
739+
740+ /// The cache file stores one of two states:
741+ /// Success: `{unix_ts}\n{semver}` — a successful check at `unix_ts` that found `semver`.
742+ /// Error: `{unix_ts}\n0` — a failed check at `unix_ts` (`"0"` is not valid semver).
743+ enum UpdateCacheState {
744+ Success ( i64 , Version ) ,
745+ Error ( i64 ) ,
746+ Missing ,
747+ }
748+
749+ fn read_update_cache ( ) -> UpdateCacheState {
750+ let Ok ( content) = fs:: read_to_string ( update_check_file_path ( ) ) else {
751+ return UpdateCacheState :: Missing ;
752+ } ;
753+ let mut lines = content. lines ( ) ;
754+ let Some ( ts) = lines. next ( ) . and_then ( |l| l. parse :: < i64 > ( ) . ok ( ) ) else {
755+ return UpdateCacheState :: Missing ;
756+ } ;
757+ match lines. next ( ) . and_then ( |l| Version :: parse ( l) . ok ( ) ) {
758+ Some ( v) => UpdateCacheState :: Success ( ts, v) ,
759+ None => UpdateCacheState :: Error ( ts) ,
760+ }
761+ }
762+
763+ fn write_update_cache_success ( version : & Version ) {
764+ let content = format ! ( "{}\n {version}" , Utc :: now( ) . timestamp( ) ) ;
765+ let _ = fs:: write ( update_check_file_path ( ) , content) ;
766+ }
767+
768+ /// Writes timestamp 0 as an error sentinel so the next invocation knows the last check failed.
769+ fn write_update_cache_error ( ) {
770+ let content = format ! ( "{}\n 0" , Utc :: now( ) . timestamp( ) ) ;
771+ let _ = fs:: write ( update_check_file_path ( ) , content) ;
772+ }
773+
774+ /// Check whether a newer AVM release is available and print a warning to stderr if so.
775+ /// Results (including failures) are cached in `$AVM_HOME/.update-check` so the network
776+ /// is hit at most once per hour.
777+ pub fn check_avm_version_and_warn ( ) {
778+ let Ok ( current) = Version :: parse ( env ! ( "CARGO_PKG_VERSION" ) ) else {
779+ return ;
780+ } ;
781+
782+ let now = Utc :: now ( ) . timestamp ( ) ;
783+
784+ match read_update_cache ( ) {
785+ // Fresh successful cache: just compare and maybe warn.
786+ UpdateCacheState :: Success ( ts, latest) if now - ts < UPDATE_CHECK_INTERVAL_SECS => {
787+ if latest > current {
788+ eprintln ! (
789+ "A new version of avm is available: {latest} (you have {current}). \
790+ Run `avm self-update` to upgrade."
791+ ) ;
792+ }
793+ }
794+ // Previous check failed recently: tell the user and skip.
795+ UpdateCacheState :: Error ( ts) if now - ts < UPDATE_CHECK_INTERVAL_SECS => {
796+ let next_attempt_secs = ( ts + UPDATE_CHECK_INTERVAL_SECS ) - now;
797+ eprintln ! ( "avm update check failed. Next attempt in {next_attempt_secs}s." ) ;
798+ }
799+ // Cache is stale or missing: run a fresh check.
800+ _ => match get_latest_version_with_client ( & HTTP_CLIENT , false ) {
801+ Ok ( latest) => {
802+ write_update_cache_success ( & latest) ;
803+ if latest > current {
804+ eprintln ! (
805+ "A new version of avm is available: {latest} (you have {current}). \
806+ Run `avm self-update` to upgrade."
807+ ) ;
808+ }
809+ }
810+ Err ( _) => {
811+ write_update_cache_error ( ) ;
812+ eprintln ! (
813+ "avm update check failed. Next attempt in {UPDATE_CHECK_INTERVAL_SECS}s."
814+ ) ;
815+ }
816+ } ,
817+ }
818+ }
819+
820+ /// Update AVM itself by re-running `cargo install`.
821+ ///
822+ /// - Default: installs the latest stable release via `--tag`.
823+ /// - `include_pre_release`: installs the latest release including rc/beta/alpha.
824+ /// - `bleeding_edge`: builds from the HEAD of the `master` branch.
825+ pub fn self_update ( include_pre_release : bool , bleeding_edge : bool ) -> Result < ( ) > {
826+ let current = Version :: parse ( env ! ( "CARGO_PKG_VERSION" ) )
827+ . map_err ( |e| anyhow ! ( "Failed to parse current avm version: {e}" ) ) ?;
828+
829+ let mut args = vec ! [
830+ "install" . to_string( ) ,
831+ "--git" . to_string( ) ,
832+ "https://github.com/solana-foundation/anchor" . to_string( ) ,
833+ "--locked" . to_string( ) ,
834+ ] ;
835+
836+ if bleeding_edge {
837+ println ! ( "Updating avm to the latest commit on master..." ) ;
838+ args. extend_from_slice ( & [ "--branch" . to_string ( ) , "master" . to_string ( ) ] ) ;
839+ } else {
840+ let latest = get_latest_version ( include_pre_release) ?;
841+ if latest <= current {
842+ println ! ( "avm is already up to date ({current})" ) ;
843+ return Ok ( ( ) ) ;
844+ }
845+ println ! ( "Updating avm from {current} to {latest}..." ) ;
846+ args. extend_from_slice ( & [ "--tag" . to_string ( ) , format ! ( "v{latest}" ) ] ) ;
847+ }
848+
849+ args. extend_from_slice ( & [ "avm" . to_string ( ) , "--force" . to_string ( ) ] ) ;
850+
851+ let status = Command :: new ( "cargo" )
852+ . args ( & args)
853+ . stdout ( Stdio :: inherit ( ) )
854+ . stderr ( Stdio :: inherit ( ) )
855+ . status ( )
856+ . map_err ( |e| anyhow ! ( "`cargo install` failed: {e}" ) ) ?;
857+
858+ if !status. success ( ) {
859+ bail ! ( "Failed to update avm" ) ;
860+ }
861+
862+ println ! ( "avm successfully updated" ) ;
863+ Ok ( ( ) )
864+ }
865+
709866#[ cfg( test) ]
710867mod tests {
711868 use crate :: * ;
0 commit comments