Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The minor version will be incremented upon a breaking change and the patch versi
### Features

- 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))
- 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))

### Fixes

Expand Down
171 changes: 164 additions & 7 deletions avm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::LazyLock;

/// Checked at most once per hour.
const UPDATE_CHECK_INTERVAL_SECS: i64 = 60 * 60;
/// Shorter HTTP timeout so a slow or unreachable GitHub does not stall the CLI for long.
const HTTP_CLIENT_TIMEOUT_SECS: u64 = 5;

/// Shared HTTP client with a short timeout, used for all outbound requests.
static HTTP_CLIENT: LazyLock<reqwest::blocking::Client> = LazyLock::new(|| {
reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_CLIENT_TIMEOUT_SECS))
.build()
.expect("Failed to build HTTP client")
});

/// Storage directory for AVM, customizable by setting the $AVM_HOME, defaults to ~/.avm
pub static AVM_HOME: LazyLock<PathBuf> = LazyLock::new(|| {
cfg_if::cfg_if! {
Expand Down Expand Up @@ -231,8 +244,7 @@ pub fn update(include_pre_release: bool) -> Result<()> {
///
/// returns the full commit sha3 for unique versioning downstream
pub fn check_and_get_full_commit(commit: &str) -> Result<String> {
let client = reqwest::blocking::Client::new();
let response = client
let response = HTTP_CLIENT
.get(format!(
"https://api.github.com/repos/solana-foundation/anchor/commits/{commit}"
))
Expand Down Expand Up @@ -289,11 +301,10 @@ fn append_commit(version: &mut Version, commit: &str) -> Result<()> {
}

fn get_anchor_version_from_commit(commit: &str) -> Result<Version> {
let client = reqwest::blocking::Client::new();
let base = format!("https://raw.githubusercontent.com/solana-foundation/anchor/{commit}");

// Newer versions (workspace layout): version lives in [workspace.package] of the root Cargo.toml.
if let Some(text) = fetch_raw(&client, &format!("{base}/Cargo.toml"))? {
if let Some(text) = fetch_raw(&HTTP_CLIENT, &format!("{base}/Cargo.toml"))? {
if let Ok(manifest) = Manifest::from_str(&text) {
if let Some(version_str) = manifest
.workspace
Expand All @@ -309,7 +320,7 @@ fn get_anchor_version_from_commit(commit: &str) -> Result<Version> {
}

// Older versions: version lives in [package] of cli/Cargo.toml.
let text = fetch_raw(&client, &format!("{base}/cli/Cargo.toml"))?
let text = fetch_raw(&HTTP_CLIENT, &format!("{base}/cli/Cargo.toml"))?
.ok_or_else(|| anyhow!("Could not find anchor-cli version for commit {commit}"))?;
let manifest = Manifest::from_str(&text)?;
let mut version = manifest.package().version().parse::<Version>()?;
Expand Down Expand Up @@ -599,6 +610,13 @@ pub fn read_anchorversion_file() -> Result<Version> {
/// Retrieve a list of installable versions of anchor-cli using the GitHub API and tags on the Anchor
/// repository.
pub fn fetch_versions(include_pre_release: bool) -> Result<Vec<Version>, Error> {
fetch_versions_with_client(&HTTP_CLIENT, include_pre_release)
}

fn fetch_versions_with_client(
client: &reqwest::blocking::Client,
include_pre_release: bool,
) -> Result<Vec<Version>, Error> {
#[derive(Deserialize)]
struct Release {
#[serde(rename = "name", deserialize_with = "version_deserializer")]
Expand All @@ -615,7 +633,7 @@ pub fn fetch_versions(include_pre_release: bool) -> Result<Vec<Version>, Error>
Version::parse(s.trim_start_matches('v')).map_err(de::Error::custom)
}

let response = reqwest::blocking::Client::new()
let response = client
.get("https://api.github.com/repos/solana-foundation/anchor/releases")
.header(
USER_AGENT,
Expand Down Expand Up @@ -685,7 +703,14 @@ pub fn list_versions(include_pre_release: bool) -> Result<()> {
}

pub fn get_latest_version(include_pre_release: bool) -> Result<Version> {
let mut versions = fetch_versions(include_pre_release)?;
get_latest_version_with_client(&HTTP_CLIENT, include_pre_release)
}

fn get_latest_version_with_client(
client: &reqwest::blocking::Client,
include_pre_release: bool,
) -> Result<Version> {
let mut versions = fetch_versions_with_client(client, include_pre_release)?;
versions.sort();
versions
.into_iter()
Expand All @@ -706,6 +731,138 @@ pub fn read_installed_versions() -> Result<Vec<Version>> {
Ok(versions)
}

// ── AVM self-update ───────────────────────────────────────────────────────────

fn update_check_file_path() -> PathBuf {
AVM_HOME.join(".update-check")
}

/// The cache file stores one of two states:
/// Success: `{unix_ts}\n{semver}` — a successful check at `unix_ts` that found `semver`.
/// Error: `{unix_ts}\n0` — a failed check at `unix_ts` (`"0"` is not valid semver).
enum UpdateCacheState {
Success(i64, Version),
Error(i64),
Missing,
}

fn read_update_cache() -> UpdateCacheState {
let Ok(content) = fs::read_to_string(update_check_file_path()) else {
return UpdateCacheState::Missing;
};
let mut lines = content.lines();
let Some(ts) = lines.next().and_then(|l| l.parse::<i64>().ok()) else {
return UpdateCacheState::Missing;
};
match lines.next().and_then(|l| Version::parse(l).ok()) {
Some(v) => UpdateCacheState::Success(ts, v),
None => UpdateCacheState::Error(ts),
}
}

fn write_update_cache_success(version: &Version) {
let content = format!("{}\n{version}", Utc::now().timestamp());
let _ = fs::write(update_check_file_path(), content);
}

/// Writes timestamp 0 as an error sentinel so the next invocation knows the last check failed.
fn write_update_cache_error() {
let content = format!("{}\n0", Utc::now().timestamp());
let _ = fs::write(update_check_file_path(), content);
}

/// Check whether a newer AVM release is available and print a warning to stderr if so.
/// Results (including failures) are cached in `$AVM_HOME/.update-check` so the network
/// is hit at most once per hour.
pub fn check_avm_version_and_warn() {
let Ok(current) = Version::parse(env!("CARGO_PKG_VERSION")) else {
return;
};

let now = Utc::now().timestamp();

match read_update_cache() {
// Fresh successful cache: just compare and maybe warn.
UpdateCacheState::Success(ts, latest) if now - ts < UPDATE_CHECK_INTERVAL_SECS => {
if latest > current {
eprintln!(
"A new version of avm is available: {latest} (you have {current}). \
Run `avm self-update` to upgrade."
);
}
}
// Previous check failed recently: tell the user and skip.
UpdateCacheState::Error(ts) if now - ts < UPDATE_CHECK_INTERVAL_SECS => {
let next_attempt_secs = (ts + UPDATE_CHECK_INTERVAL_SECS) - now;
eprintln!("avm update check failed. Next attempt in {next_attempt_secs}s.");
}
// Cache is stale or missing: run a fresh check.
_ => match get_latest_version_with_client(&HTTP_CLIENT, false) {
Ok(latest) => {
write_update_cache_success(&latest);
if latest > current {
eprintln!(
"A new version of avm is available: {latest} (you have {current}). \
Run `avm self-update` to upgrade."
);
}
}
Err(_) => {
write_update_cache_error();
eprintln!(
"avm update check failed. Next attempt in {UPDATE_CHECK_INTERVAL_SECS}s."
);
}
},
}
}

/// Update AVM itself by re-running `cargo install`.
///
/// - Default: installs the latest stable release via `--tag`.
/// - `include_pre_release`: installs the latest release including rc/beta/alpha.
/// - `bleeding_edge`: builds from the HEAD of the `master` branch.
pub fn self_update(include_pre_release: bool, bleeding_edge: bool) -> Result<()> {
let current = Version::parse(env!("CARGO_PKG_VERSION"))
.map_err(|e| anyhow!("Failed to parse current avm version: {e}"))?;

let mut args = vec![
"install".to_string(),
"--git".to_string(),
Comment thread
tiago18c marked this conversation as resolved.
"https://github.com/solana-foundation/anchor".to_string(),
"--locked".to_string(),
];

if bleeding_edge {
println!("Updating avm to the latest commit on master...");
args.extend_from_slice(&["--branch".to_string(), "master".to_string()]);
} else {
let latest = get_latest_version(include_pre_release)?;
if latest <= current {
println!("avm is already up to date ({current})");
return Ok(());
}
println!("Updating avm from {current} to {latest}...");
args.extend_from_slice(&["--tag".to_string(), format!("v{latest}")]);
}

args.extend_from_slice(&["avm".to_string(), "--force".to_string()]);

let status = Command::new("cargo")
.args(&args)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|e| anyhow!("`cargo install` failed: {e}"))?;

if !status.success() {
bail!("Failed to update avm");
}

println!("avm successfully updated");
Ok(())
}

#[cfg(test)]
mod tests {
use crate::*;
Expand Down
20 changes: 20 additions & 0 deletions avm/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ pub enum Commands {
/// Include pre-release versions when selecting the latest
pre_release: bool,
},
#[clap(about = "Update avm itself to the latest version via cargo install")]
SelfUpdate {
#[clap(long)]
/// Update to the latest pre-release version instead of the latest stable
pre_release: bool,
#[clap(long, conflicts_with = "pre_release")]
/// Build and install from the latest commit on the master branch
bleeding_edge: bool,
},
#[clap(about = "Generate shell completions for AVM")]
Completions {
#[clap(value_enum)]
Expand Down Expand Up @@ -106,6 +115,13 @@ fn resolve_use_version(version: Option<String>) -> Result<Option<Version>> {
}

pub fn entry(opts: Cli) -> Result<()> {
if !matches!(
opts.command,
Commands::SelfUpdate { .. } | Commands::Completions { .. }
) {
avm::check_avm_version_and_warn();
}

match opts.command {
Commands::Use { version } => {
let resolved = resolve_use_version(version)?;
Expand All @@ -132,6 +148,10 @@ pub fn entry(opts: Cli) -> Result<()> {
}
Commands::List { pre_release } => avm::list_versions(pre_release),
Commands::Update { pre_release } => avm::update(pre_release),
Commands::SelfUpdate {
pre_release,
bleeding_edge,
} => avm::self_update(pre_release, bleeding_edge),
Commands::Completions { shell } => {
clap_complete::generate(shell, &mut Cli::command(), "avm", &mut std::io::stdout());
Ok(())
Expand Down
Loading