feat: implement selfupdate command
Release / create-release (push) Failing after 31s
Release / build-linux-x86_64 (push) Has been skipped
Release / build-linux-aarch64 (push) Has been skipped
Release / build-macos (aarch64-apple-darwin) (push) Has been skipped
Release / build-macos (x86_64-apple-darwin) (push) Has been skipped
Release / build-windows (push) Has been skipped
Release / create-release (push) Failing after 31s
Release / build-linux-x86_64 (push) Has been skipped
Release / build-linux-aarch64 (push) Has been skipped
Release / build-macos (aarch64-apple-darwin) (push) Has been skipped
Release / build-macos (x86_64-apple-darwin) (push) Has been skipped
Release / build-windows (push) Has been skipped
- Fetch latest release from Gitea API using existing reqwest client - Match release asset by BUILD_TARGET triple (supports .tar.gz and .zip) - Compare versions; show confirmation prompt (skippable with -y/--yes) - Download archive, extract binary, atomically replace self via self-replace - Support private repositories via GITEA_TOKEN environment variable - Expose BUILD_TARGET in build.rs for compile-time target triple detection - Add .gitea/workflows/release.yml for multi-platform release builds on tag push Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -18,3 +18,4 @@ pub mod shared;
|
||||
pub mod upload;
|
||||
pub mod version;
|
||||
pub mod whoami;
|
||||
pub mod selfupdate;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
use anyhow::{anyhow, bail};
|
||||
use reqwest::header::{AUTHORIZATION, USER_AGENT};
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
const GITEA_HOST: &str = "https://git.ni.riken.jp";
|
||||
const REPO_OWNER: &str = "niu";
|
||||
const REPO_NAME: &str = "mdrs-client-rust";
|
||||
|
||||
/// Current build target triple, captured at compile time via build.rs.
|
||||
const BUILD_TARGET: &str = env!("BUILD_TARGET");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GiteaRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GiteaAsset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GiteaAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
/// Returns true if `latest` is strictly greater than `current` (semver-like comparison).
|
||||
fn is_newer(current: &str, latest: &str) -> bool {
|
||||
let parse = |s: &str| -> Vec<u64> {
|
||||
s.trim_start_matches('v')
|
||||
.split('.')
|
||||
.map(|p| p.parse::<u64>().unwrap_or(0))
|
||||
.collect()
|
||||
};
|
||||
let cur = parse(current);
|
||||
let lat = parse(latest);
|
||||
let len = cur.len().max(lat.len());
|
||||
for i in 0..len {
|
||||
let c = cur.get(i).copied().unwrap_or(0);
|
||||
let l = lat.get(i).copied().unwrap_or(0);
|
||||
if l > c {
|
||||
return true;
|
||||
}
|
||||
if l < c {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract the binary named `bin_name` from a `.tar.gz` archive at `archive_path`
|
||||
/// and write it to `dest_path`.
|
||||
fn extract_from_tar_gz(archive_path: &Path, bin_name: &str, dest_path: &Path) -> anyhow::Result<()> {
|
||||
use flate2::read::GzDecoder;
|
||||
use tar::Archive;
|
||||
|
||||
let file = std::fs::File::open(archive_path)?;
|
||||
let gz = GzDecoder::new(file);
|
||||
let mut archive = Archive::new(gz);
|
||||
|
||||
for entry in archive.entries()? {
|
||||
let mut entry = entry?;
|
||||
let path = entry.path()?;
|
||||
// Match by file name only (ignore directory prefix in archive).
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) {
|
||||
entry.unpack(dest_path)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
bail!("Binary '{}' not found in archive", bin_name)
|
||||
}
|
||||
|
||||
/// Extract the binary named `bin_name` from a `.zip` archive at `archive_path`
|
||||
/// and write it to `dest_path`.
|
||||
fn extract_from_zip(archive_path: &Path, bin_name: &str, dest_path: &Path) -> anyhow::Result<()> {
|
||||
use std::io::Read;
|
||||
|
||||
let file = std::fs::File::open(archive_path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive.by_index(i)?;
|
||||
let entry_name = entry.name().to_owned();
|
||||
let file_name = Path::new(&entry_name)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
if file_name == bin_name {
|
||||
let mut buf = Vec::new();
|
||||
entry.read_to_end(&mut buf)?;
|
||||
std::fs::write(dest_path, &buf)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
bail!("Binary '{}' not found in archive", bin_name)
|
||||
}
|
||||
|
||||
pub async fn selfupdate(yes: bool) -> anyhow::Result<()> {
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
println!("Checking for updates (current version: {current_version}, target: {BUILD_TARGET})...");
|
||||
|
||||
let api_url = format!(
|
||||
"{GITEA_HOST}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases?limit=1"
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = client
|
||||
.get(&api_url)
|
||||
.header(USER_AGENT, format!("mdrs/{current_version}"));
|
||||
|
||||
if let Ok(token) = env::var("GITEA_TOKEN") {
|
||||
req = req.header(AUTHORIZATION, format!("Bearer {token}"));
|
||||
}
|
||||
|
||||
let resp = req.send().await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("Failed to fetch release info: HTTP {}", resp.status());
|
||||
}
|
||||
|
||||
let releases: Vec<GiteaRelease> = resp.json().await?;
|
||||
let release = releases
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("No releases found"))?;
|
||||
|
||||
let latest_version = release.tag_name.trim_start_matches('v');
|
||||
|
||||
if !is_newer(current_version, latest_version) {
|
||||
println!("Already up-to-date ({current_version}).");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("New version available: {latest_version}");
|
||||
|
||||
// Find the asset matching the current build target.
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.contains(BUILD_TARGET))
|
||||
.ok_or_else(|| {
|
||||
let names: Vec<&str> = release.assets.iter().map(|a| a.name.as_str()).collect();
|
||||
anyhow!(
|
||||
"No release asset found for target '{BUILD_TARGET}'. \
|
||||
Available assets: {}",
|
||||
names.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
println!("Asset: {}", asset.name);
|
||||
|
||||
if !yes {
|
||||
print!("Update to version {latest_version}? [y/N] ");
|
||||
io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
if !input.trim().eq_ignore_ascii_case("y") {
|
||||
println!("Update cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Download the asset to a temporary directory.
|
||||
let tmp_dir = tempfile::Builder::new()
|
||||
.prefix("mdrs-selfupdate-")
|
||||
.tempdir()?;
|
||||
let archive_path = tmp_dir.path().join(&asset.name);
|
||||
|
||||
println!("Downloading {}...", asset.browser_download_url);
|
||||
|
||||
let mut download_req = client
|
||||
.get(&asset.browser_download_url)
|
||||
.header(USER_AGENT, format!("mdrs/{current_version}"));
|
||||
|
||||
if let Ok(token) = env::var("GITEA_TOKEN") {
|
||||
download_req = download_req.header(AUTHORIZATION, format!("Bearer {token}"));
|
||||
}
|
||||
|
||||
let download_resp = download_req.send().await?;
|
||||
if !download_resp.status().is_success() {
|
||||
bail!(
|
||||
"Failed to download asset: HTTP {}",
|
||||
download_resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
let bytes = download_resp.bytes().await?;
|
||||
std::fs::write(&archive_path, &bytes)?;
|
||||
|
||||
// Extract the binary from the archive.
|
||||
let bin_name = if cfg!(windows) { "mdrs.exe" } else { "mdrs" };
|
||||
let new_bin = tmp_dir.path().join(bin_name);
|
||||
let name = asset.name.as_str();
|
||||
|
||||
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
|
||||
extract_from_tar_gz(&archive_path, bin_name, &new_bin)?;
|
||||
} else if name.ends_with(".zip") {
|
||||
extract_from_zip(&archive_path, bin_name, &new_bin)?;
|
||||
} else {
|
||||
bail!("Unsupported archive format: {}", asset.name);
|
||||
}
|
||||
|
||||
// Make the extracted binary executable on Unix.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&new_bin)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&new_bin, perms)?;
|
||||
}
|
||||
|
||||
// Atomically replace the current executable.
|
||||
self_replace::self_replace(&new_bin)?;
|
||||
|
||||
println!("Successfully updated to version {latest_version}.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+13
@@ -111,6 +111,13 @@ enum Commands {
|
||||
},
|
||||
/// Show the version of this tool
|
||||
Version,
|
||||
/// Update this binary to the latest release
|
||||
#[command(name = "selfupdate")]
|
||||
SelfUpdate {
|
||||
/// Skip the confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Print the error message in Python-compatible format and exit with code 2.
|
||||
@@ -349,5 +356,11 @@ fn main() {
|
||||
Commands::Version => {
|
||||
commands::version::version();
|
||||
}
|
||||
Commands::SelfUpdate { yes } => {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
if let Err(e) = rt.block_on(commands::selfupdate::selfupdate(*yes)) {
|
||||
handle_error(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user