From d85834037e0ad73fb3ce62a2a1ff8666865d4357 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Sun, 16 Aug 2026 01:59:20 +0800 Subject: feat: add self-update command for mling Add `mling update` to fetch the latest GitHub Actions artifact for the current platform, stage it as a tarball in the data directory, and apply it on the next wrapper invocation. The wrapper extracts the staged package over the installation directory while skipping the running executable, then removes the staged file. --- mingling_cli/src/bin/wrapper.rs | 92 ++++++++++- mingling_cli/src/lib.rs | 1 + mingling_cli/src/updater.rs | 1 + mingling_cli/src/updater/cmd_update.rs | 289 +++++++++++++++++++++++++++++++++ 4 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 mingling_cli/src/updater.rs create mode 100644 mingling_cli/src/updater/cmd_update.rs (limited to 'mingling_cli/src') diff --git a/mingling_cli/src/bin/wrapper.rs b/mingling_cli/src/bin/wrapper.rs index b1aecf6..90bbc53 100644 --- a/mingling_cli/src/bin/wrapper.rs +++ b/mingling_cli/src/bin/wrapper.rs @@ -1,9 +1,14 @@ use std::env; use std::ffi::OsString; -use std::path::PathBuf; +use std::fs; +use std::path::{Component, Path, PathBuf}; use std::process::{self, Command}; +use flate2::read::GzDecoder; +use tar::Archive; + fn main() { + apply_update_if_present(); exec(); } @@ -45,3 +50,88 @@ fn exec() { } } } + +/// The update package staged by `mling update` at `{data_dir}/mingling/update.tar.gz`. +fn update_package_path() -> Option { + dirs::data_dir().map(|data_dir| data_dir.join("mingling").join("update.tar.gz")) +} + +/// Apply a staged update, if any: unpack it over the installation directory +/// (never replacing the running wrapper itself), then remove the staged file. +/// This runs before forwarding to `mingling-cli`, so the new version is loaded +/// by this very invocation. +fn apply_update_if_present() { + let Some(update_path) = update_package_path() else { + return; + }; + if !update_path.is_file() { + return; + } + let Ok(current_exe) = env::current_exe() else { + return; + }; + + // The package mirrors the install layout: the wrapper lives at + // `/bin/mling` and the archive root maps onto ``, so entries + // like `bin/mingling-cli` replace the files next to this wrapper. + let Some(exe_dir) = current_exe.parent() else { + return; + }; + let Some(install_root) = exe_dir.parent() else { + return; + }; + + match unpack_update(&update_path, ¤t_exe, install_root) { + Ok(()) => { + let _ = fs::remove_file(update_path); + } + Err(e) => eprintln!("mling: failed to apply update: {e}"), + } +} + +/// Extract `update.tar.gz` into `install_root`, skipping the running wrapper. +fn unpack_update( + update_path: &Path, + current_exe: &Path, + install_root: &Path, +) -> std::io::Result<()> { + let file = fs::File::open(update_path)?; + let mut archive = Archive::new(GzDecoder::new(file)); + + for entry in archive.entries()? { + let mut entry = entry?; + let entry_type = entry.header().entry_type(); + let rel_path = sanitize_relative_path(&entry.path()?); + if rel_path.as_os_str().is_empty() { + continue; + } + let dest = install_root.join(rel_path); + // The running executable cannot (and must not) replace itself. + if dest == current_exe { + continue; + } + if entry_type.is_dir() { + fs::create_dir_all(dest)?; + continue; + } + if !entry_type.is_file() { + continue; + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + entry.unpack(dest)?; + } + Ok(()) +} + +/// Keep only normal path components so entries cannot escape `install_root`. +fn sanitize_relative_path(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + if let Component::Normal(part) = component { + out.push(part); + } + } + out +} diff --git a/mingling_cli/src/lib.rs b/mingling_cli/src/lib.rs index fdd7636..ae3b0e0 100644 --- a/mingling_cli/src/lib.rs +++ b/mingling_cli/src/lib.rs @@ -21,6 +21,7 @@ pub mod message; pub mod metadata; pub mod pkg_mgr; pub mod proj_mgr; +pub mod updater; pub mod utils; #[help] diff --git a/mingling_cli/src/updater.rs b/mingling_cli/src/updater.rs new file mode 100644 index 0000000..1c81fb3 --- /dev/null +++ b/mingling_cli/src/updater.rs @@ -0,0 +1 @@ +pub mod cmd_update; diff --git a/mingling_cli/src/updater/cmd_update.rs b/mingling_cli/src/updater/cmd_update.rs new file mode 100644 index 0000000..b75f4e9 --- /dev/null +++ b/mingling_cli/src/updater/cmd_update.rs @@ -0,0 +1,289 @@ +use std::{fs, io, path::Path, path::PathBuf}; + +use mingling::{ + Grouped, LazyRes, RenderResult, Routable, + macros::{chain, command, metadata, renderer, routeify}, + metadata::Description, +}; + +use crate::{Next, config::ResMlingConfig, eprintln_cargo, println_cargo}; + +/// Config key holding the GitHub repository that publishes the mling packages. +const CONFIG_KEY_GITHUB: &str = "mingling-github"; + +/// Default update source, used when the config key is unset. +const DEFAULT_GITHUB: &str = "https://github.com/mingling-rs/mingling"; + +/// Name of the staged update package inside `{data_dir}/mingling`. +const UPDATE_FILE_NAME: &str = "update.tar.gz"; + +/// The resolved download task: fetch the newest artifact and stage it. +#[derive(Debug, Default, Grouped)] +pub struct StateUpdateDownload { + pub owner: String, + pub repo: String, + pub update_path: PathBuf, +} + +/// The staged update package, ready to be applied by the `mling` wrapper. +#[derive(Debug, Default, Grouped)] +pub struct ResultUpdate { + pub artifact_name: String, + pub update_path: PathBuf, +} + +/// Errors produced by the download pipeline. +#[derive(Debug, Grouped)] +pub enum UpdateError { + /// The data directory could not be determined. + NoDataDirectory, + /// The configured update source is not a GitHub repository URL. + InvalidRepo(String), + /// A network or API request failed. + Network(String), + /// No matching artifact was found. + NoArtifact(String), + /// Writing the staged update package failed. + Io(String), +} + +/// A downloaded artifact with its inner `*.tar.gz` extracted. +struct Artifact { + name: String, + tar_gz: Vec, +} + +#[metadata(EntryUpdate)] +pub fn desc_update() -> Description { + "Update mling to the latest version".into() +} + +#[command(routeify)] +pub fn update(config: &mut LazyRes) -> Next { + let config = config.get_ref(); + let source = config.get_or(CONFIG_KEY_GITHUB, DEFAULT_GITHUB); + let Some(update_path) = update_package_path() else { + return UpdateError::NoDataDirectory.to_chain(); + }; + match parse_github_repo(source) { + Some((owner, repo)) => StateUpdateDownload { + owner, + repo, + update_path, + } + .to_chain(), + None => UpdateError::InvalidRepo(source.to_string()).to_chain(), + } +} + +/// Fetch the newest artifact for the current platform and stage it at +/// `{data_dir}/mingling/update.tar.gz`. +#[chain(routeify)] +pub async fn handle_state_update_download(state: StateUpdateDownload) -> Next { + match fetch_latest_artifact(&state.owner, &state.repo).await { + Ok(artifact) => { + if let Err(e) = write_update_package(&artifact.tar_gz, &state.update_path) { + return UpdateError::Io(e).to_chain(); + } + ResultUpdate { + artifact_name: artifact.name, + update_path: state.update_path, + } + .to_chain() + } + Err(e) => e.to_chain(), + } +} + +#[renderer] +pub fn render_result_update(r: ResultUpdate) -> RenderResult { + let mut result = RenderResult::new(); + println_cargo!(result, "Downloaded: {}", r.artifact_name); + println_cargo!(result, "Staged: {}", r.update_path.display()); + println_cargo!(result, "Run `mling` again to apply the update"); + result +} + +#[renderer] +pub fn render_error_update(err: UpdateError) -> RenderResult { + let mut result = RenderResult::new(); + match err { + UpdateError::NoDataDirectory => { + eprintln_cargo!(result, "failed to determine the data directory"); + } + UpdateError::InvalidRepo(source) => { + eprintln_cargo!( + result, + "invalid update source `{}`, expected a GitHub repository URL like `https://github.com/mingling-rs/mingling`", + source + ); + } + UpdateError::Network(msg) | UpdateError::NoArtifact(msg) | UpdateError::Io(msg) => { + eprintln_cargo!(result, "{}", msg); + } + } + result +} + +/// `{data_dir}/mingling/update.tar.gz`, where the wrapper looks for staged updates. +pub fn update_package_path() -> Option { + dirs::data_dir().map(|dir| dir.join("mingling").join(UPDATE_FILE_NAME)) +} + +/// Extract `owner` / `repo` from a GitHub URL such as +/// `https://github.com/mingling-rs/mingling`. Trailing slashes and `.git` +/// suffixes are tolerated, and a bare `owner/repo` is accepted as well. +fn parse_github_repo(source: &str) -> Option<(String, String)> { + let trimmed = source.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + let path = match trimmed.rfind("://") { + Some(idx) => &trimmed[idx + 3..], + None => trimmed, + }; + let mut segments = path.split('/').filter(|s| !s.is_empty()); + // The first segment is the host; take the two path segments after it. + segments.next()?; + let owner = segments.next()?; + let repo = segments.next()?.trim_end_matches(".git"); + Some((owner.to_string(), repo.to_string())) +} + +/// The platform suffix used by the CI artifact names (`mling-{os}-...`). +fn update_os_name() -> &'static str { + if cfg!(windows) { + "win" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "mac" + } else { + "unknown" + } +} + +/// Query the GitHub Actions API, pick the newest non-expired artifact for the +/// current platform, download it, and extract the inner `*.tar.gz`. +async fn fetch_latest_artifact(owner: &str, repo: &str) -> Result { + let client = reqwest::Client::builder() + .user_agent(format!("mling-updater/{}", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| UpdateError::Network(format!("failed to build HTTP client: {e}")))?; + + let list_url = + format!("https://api.github.com/repos/{owner}/{repo}/actions/artifacts?per_page=100"); + let mut request = client + .get(&list_url) + .header("Accept", "application/vnd.github+json"); + if let Ok(token) = std::env::var("GITHUB_TOKEN") + && !token.is_empty() + { + request = request.header("Authorization", format!("Bearer {token}")); + } + + let response = request.send().await.map_err(|e| { + UpdateError::Network(format!("failed to query GitHub Actions artifacts: {e}")) + })?; + if !response.status().is_success() { + return Err(UpdateError::Network(format!( + "GitHub Actions API returned {} for `{list_url}`", + response.status() + ))); + } + let json: serde_json::Value = response + .json() + .await + .map_err(|e| UpdateError::Network(format!("failed to parse GitHub response: {e}")))?; + + let os = update_os_name(); + let prefix = format!("mling-{os}-"); + let artifact = json + .get("artifacts") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter(|a| { + a.get("expired").and_then(serde_json::Value::as_bool) != Some(true) + && a.get("name") + .and_then(serde_json::Value::as_str) + .is_some_and(|name| name.starts_with(&prefix)) + }) + .max_by_key(|a| { + a.get("created_at") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + }) + .ok_or_else(|| { + UpdateError::NoArtifact(format!("no `{prefix}*` artifact found in {owner}/{repo}")) + })?; + + let name = artifact + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("mling") + .to_string(); + let download_url = artifact + .get("archive_download_url") + .and_then(serde_json::Value::as_str) + .unwrap_or(&format!( + "https://api.github.com/repos/{owner}/{repo}/actions/artifacts/{}/zip", + artifact + .get("id") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) + )) + .to_string(); + + let response = + client.get(&download_url).send().await.map_err(|e| { + UpdateError::Network(format!("failed to download artifact `{name}`: {e}")) + })?; + if !response.status().is_success() { + return Err(UpdateError::Network(format!( + "failed to download artifact `{name}`: HTTP {}", + response.status() + ))); + } + let zip_bytes = response + .bytes() + .await + .map_err(|e| UpdateError::Network(format!("failed to read artifact `{name}`: {e}")))?; + + let tar_gz = extract_tar_gz_from_zip(&zip_bytes) + .map_err(|e| UpdateError::Network(format!("invalid artifact `{name}`: {e}")))?; + Ok(Artifact { name, tar_gz }) +} + +/// The GitHub artifact is a zip containing the `mling-{os}-{sha}-{date}.tar.gz` +/// built by CI; extract that inner file. +fn extract_tar_gz_from_zip(zip_bytes: &[u8]) -> Result, String> { + let reader = io::Cursor::new(zip_bytes); + let mut archive = zip::ZipArchive::new(reader).map_err(|e| e.to_string())?; + for index in 0..archive.len() { + let mut file = archive.by_index(index).map_err(|e| e.to_string())?; + let file_name = file.name().to_string(); + if file_name.ends_with(".tar.gz") { + let mut tar_gz = Vec::with_capacity(file.size() as usize); + io::copy(&mut file, &mut tar_gz).map_err(|e| e.to_string())?; + return Ok(tar_gz); + } + } + Err("artifact contains no `*.tar.gz` file".to_string()) +} + +/// Stage the update package at the wrapper's well-known location. The bytes are +/// written to a temporary file first so a failed download never corrupts a +/// previously staged update. +fn write_update_package(tar_gz: &[u8], update_path: &Path) -> Result<(), String> { + let parent = update_path + .parent() + .ok_or_else(|| format!("no parent directory for {}", update_path.display()))?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let tmp_path = parent.join("update.tar.gz.tmp"); + fs::write(&tmp_path, tar_gz).map_err(|e| e.to_string())?; + if update_path.exists() { + fs::remove_file(update_path).map_err(|e| e.to_string())?; + } + fs::rename(&tmp_path, update_path).map_err(|e| e.to_string()) +} -- cgit