aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli/src/updater
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_cli/src/updater')
-rw-r--r--mingling_cli/src/updater/cmd_update.rs289
1 files changed, 289 insertions, 0 deletions
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<u8>,
+}
+
+#[metadata(EntryUpdate)]
+pub fn desc_update() -> Description {
+ "Update mling to the latest version".into()
+}
+
+#[command(routeify)]
+pub fn update(config: &mut LazyRes<ResMlingConfig>) -> 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<PathBuf> {
+ 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<Artifact, UpdateError> {
+ 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<Vec<u8>, 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())
+}