Skip to content

Commit a984178

Browse files
authored
feat(avm): add avm self-update command and passive version-check warning (otter-sec#4338)
1 parent d432fde commit a984178

3 files changed

Lines changed: 185 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The minor version will be incremented upon a breaking change and the patch versi
1313
### Features
1414

1515
- avm: Added flags and version labels to explicitly handle pre-releases (`avm list --pre-release`, `avm update --pre-release` and `avm install latest-pre-release`). ([#4335](https://github.com/solana-foundation/anchor/pull/4335))
16+
- avm: Added `avm self-update` command and passive version check warning for out of date avm ([#4338](https://github.com/solana-foundation/anchor/pull/4338))
1617

1718
### Fixes
1819

avm/src/lib.rs

Lines changed: 164 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ use std::path::PathBuf;
1111
use std::process::{Command, Stdio};
1212
use 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
1528
pub 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
233246
pub 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

291303
fn 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.
601612
pub 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

687705
pub 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!("{}\n0", 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)]
710867
mod tests {
711868
use crate::*;

avm/src/main.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ pub enum Commands {
5454
/// Include pre-release versions when selecting the latest
5555
pre_release: bool,
5656
},
57+
#[clap(about = "Update avm itself to the latest version via cargo install")]
58+
SelfUpdate {
59+
#[clap(long)]
60+
/// Update to the latest pre-release version instead of the latest stable
61+
pre_release: bool,
62+
#[clap(long, conflicts_with = "pre_release")]
63+
/// Build and install from the latest commit on the master branch
64+
bleeding_edge: bool,
65+
},
5766
#[clap(about = "Generate shell completions for AVM")]
5867
Completions {
5968
#[clap(value_enum)]
@@ -106,6 +115,13 @@ fn resolve_use_version(version: Option<String>) -> Result<Option<Version>> {
106115
}
107116

108117
pub fn entry(opts: Cli) -> Result<()> {
118+
if !matches!(
119+
opts.command,
120+
Commands::SelfUpdate { .. } | Commands::Completions { .. }
121+
) {
122+
avm::check_avm_version_and_warn();
123+
}
124+
109125
match opts.command {
110126
Commands::Use { version } => {
111127
let resolved = resolve_use_version(version)?;
@@ -132,6 +148,10 @@ pub fn entry(opts: Cli) -> Result<()> {
132148
}
133149
Commands::List { pre_release } => avm::list_versions(pre_release),
134150
Commands::Update { pre_release } => avm::update(pre_release),
151+
Commands::SelfUpdate {
152+
pre_release,
153+
bleeding_edge,
154+
} => avm::self_update(pre_release, bleeding_edge),
135155
Commands::Completions { shell } => {
136156
clap_complete::generate(shell, &mut Cli::command(), "avm", &mut std::io::stdout());
137157
Ok(())

0 commit comments

Comments
 (0)