aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_cli/src')
-rw-r--r--mingling_cli/src/bin/wrapper.rs115
-rw-r--r--mingling_cli/src/lib.rs28
-rw-r--r--mingling_cli/src/linter/registry.rs5
-rw-r--r--mingling_cli/src/pkg_mgr/cmd_install.rs86
-rw-r--r--mingling_cli/src/updater.rs1
-rw-r--r--mingling_cli/src/updater/cmd_update.rs266
6 files changed, 492 insertions, 9 deletions
diff --git a/mingling_cli/src/bin/wrapper.rs b/mingling_cli/src/bin/wrapper.rs
index b1aecf6..a47b926 100644
--- a/mingling_cli/src/bin/wrapper.rs
+++ b/mingling_cli/src/bin/wrapper.rs
@@ -1,9 +1,16 @@
use std::env;
use std::ffi::OsString;
-use std::path::PathBuf;
+use std::fs;
+use std::io;
+use std::path::{Component, Path, PathBuf};
use std::process::{self, Command};
+use flate2::read::GzDecoder;
+use sha2::{Digest, Sha256};
+use tar::Archive;
+
fn main() {
+ apply_update_if_present();
exec();
}
@@ -45,3 +52,109 @@ fn exec() {
}
}
}
+
+/// The update package staged by `mling update` at `{data_dir}/mingling/update.tar.gz`.
+fn update_package_path() -> Option<PathBuf> {
+ 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
+ // `<root>/bin/mling` and the archive root maps onto `<root>`, 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, &current_exe, install_root) {
+ Ok(()) => {
+ // Record the applied package's checksum so `mling update` can tell
+ // that this installation is already up to date.
+ if let Some(checksum_path) = last_update_checksum_path()
+ && let Ok(checksum) = sha256_file(&update_path)
+ {
+ let _ = fs::write(checksum_path, checksum);
+ }
+ 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
+}
+
+/// `{data_dir}/mingling/last-update.sha256`, where the wrapper records the
+/// checksum of the update it applied.
+fn last_update_checksum_path() -> Option<PathBuf> {
+ dirs::data_dir().map(|data_dir| data_dir.join("mingling").join("last-update.sha256"))
+}
+
+/// The sha256 hex digest of a file.
+fn sha256_file(path: &Path) -> io::Result<String> {
+ let mut file = fs::File::open(path)?;
+ let mut hasher = Sha256::new();
+ io::copy(&mut file, &mut hasher)?;
+ Ok(format!("{:x}", hasher.finalize()))
+}
diff --git a/mingling_cli/src/lib.rs b/mingling_cli/src/lib.rs
index fdd7636..902ab32 100644
--- a/mingling_cli/src/lib.rs
+++ b/mingling_cli/src/lib.rs
@@ -1,7 +1,7 @@
use mingling::{
- ShellContext, Suggest,
+ RenderResult, ShellContext, Suggest,
consts::HELP_FLAG,
- macros::{completion, gen_program, help, suggest},
+ macros::{completion, gen_program, help, renderer, suggest},
};
use crate::{
@@ -21,11 +21,12 @@ pub mod message;
pub mod metadata;
pub mod pkg_mgr;
pub mod proj_mgr;
+pub mod updater;
pub mod utils;
#[help]
pub fn help_global(_: EntryFallback) -> String {
- include_str!("../help/help.txt").parse_color_code()
+ format!("{}\n", include_str!("../help/help.txt").parse_color_code())
}
#[completion(EntryFallback)]
@@ -41,4 +42,25 @@ pub fn complete_global(_ctx: &ShellContext) -> Suggest {
}
}
+#[renderer]
+pub fn handle_fallback(args: EntryFallback) -> RenderResult {
+ let mut r = RenderResult::new();
+ let args = args.inner;
+ if !args.is_empty() {
+ eprintln_cargo!(
+ r,
+ "{}",
+ format!("Unknown command `{}`", args.join(" ")).parse_color_code()
+ )
+ } else {
+ hprintln_cargo!(
+ r,
+ "{}",
+ "Welcome to MinglingCLI, please use `mling -h` to see available commands"
+ .parse_color_code(),
+ );
+ }
+ r
+}
+
gen_program!();
diff --git a/mingling_cli/src/linter/registry.rs b/mingling_cli/src/linter/registry.rs
index 79f3225..b9f82fa 100644
--- a/mingling_cli/src/linter/registry.rs
+++ b/mingling_cli/src/linter/registry.rs
@@ -26,8 +26,9 @@ pub struct LintMetadata {
#[program_setup]
pub fn lint_registry_setup(p: &mut Program<ThisProgram>) {
p.with_resource(ResLintRegistry::lazy_init(|| {
- let registry: ResLintRegistry = serde_json::from_str(include_str!("../../registry.json"))
- .expect("failed to parse embedded registry.json");
+ let registry: ResLintRegistry =
+ serde_json::from_str(include_str!(concat!(env!("OUT_DIR"), "/registry.json")))
+ .expect("failed to parse embedded registry.json");
registry
}));
}
diff --git a/mingling_cli/src/pkg_mgr/cmd_install.rs b/mingling_cli/src/pkg_mgr/cmd_install.rs
index ddcef7d..731ba98 100644
--- a/mingling_cli/src/pkg_mgr/cmd_install.rs
+++ b/mingling_cli/src/pkg_mgr/cmd_install.rs
@@ -2,9 +2,10 @@ use std::{env, fs, io, path::PathBuf, process::Command};
use cargo_metadata::TargetKind;
use mingling::{
- Grouped, LazyRes, RenderResult, Routable,
- macros::{chain, command, metadata, pack_err, renderer, routeify},
+ Grouped, LazyRes, RenderResult, Routable, ShellContext, Suggest,
+ macros::{arg, chain, command, completion, metadata, pack_err, renderer, routeify, suggest},
metadata::Description,
+ picker::{EntryPicker, PickerArg, value::Flag},
};
use crate::{
@@ -16,6 +17,11 @@ use crate::{
pack_err!(ErrorBuildFailed = String);
pack_err!(ErrorBinaryNotFound = String);
+pack_err!(ErrorPkgEnableFailed = String);
+
+/// Flag: `--enable` — run `mling pkg-enable` after a successful install
+/// to enable the package being installed.
+pub static ARG_ENABLE: PickerArg<Flag> = arg![enable: Flag];
/// Resolved install paths, used by the build step.
#[derive(Debug, Default, Grouped)]
@@ -24,6 +30,7 @@ pub struct StateInstallBuild {
pub install_dir: PathBuf,
pub release_dir: PathBuf,
pub exe_suffix: &'static str,
+ pub enable: bool,
}
/// State after `cargo build --release`, used by the copy step.
@@ -33,6 +40,16 @@ pub struct StateInstallCopy {
pub release_dir: PathBuf,
pub exe_suffix: &'static str,
pub installed: Vec<PathBuf>,
+ pub enable: bool,
+}
+
+/// State after the copy step when `--enable` was given: run `mling pkg-enable`.
+#[derive(Debug, Default, Grouped)]
+pub struct StateInstallEnable {
+ pub install_dir: PathBuf,
+ pub installed: Vec<PathBuf>,
+ pub name: String,
+ pub version: String,
}
#[derive(Debug, Default, Grouped)]
@@ -49,7 +66,12 @@ pub fn desc_install() -> Description {
}
#[command(routeify)]
-pub fn install(packages_dir: &ResPackagesDir, metadata: &mut LazyRes<ResMetadata>) -> Next {
+pub fn install(
+ args: EntryInstall,
+ packages_dir: &ResPackagesDir,
+ metadata: &mut LazyRes<ResMetadata>,
+) -> Next {
+ let enable = args.pick(&ARG_ENABLE).to_result()?;
let metadata = metadata.get_ref().data();
let packages_dir = &packages_dir.path;
if packages_dir.as_os_str().is_empty() {
@@ -69,6 +91,7 @@ pub fn install(packages_dir: &ResPackagesDir, metadata: &mut LazyRes<ResMetadata
.join("release")
.into_std_path_buf(),
exe_suffix: env::consts::EXE_SUFFIX,
+ enable: enable.bool(),
}
.to_chain()
}
@@ -93,6 +116,7 @@ pub fn handle_state_install_build(state: StateInstallBuild) -> Next {
release_dir: state.release_dir,
exe_suffix: state.exe_suffix,
installed: vec![],
+ enable: state.enable,
}
.to_chain()
}
@@ -153,6 +177,45 @@ pub fn handle_state_install_copy(
}
}
+ if state.enable {
+ let root_package = metadata
+ .root_package()
+ .or_else(|| metadata.workspace_packages().first().copied())
+ .ok_or(ErrorRootPackageNotFound::default())?;
+ return StateInstallEnable {
+ install_dir: state.install_dir,
+ installed: state.installed,
+ name: root_package.name.to_string(),
+ version: root_package.version.to_string(),
+ }
+ .to_chain();
+ }
+
+ ResultInstall {
+ install_dir: state.install_dir,
+ installed: state.installed,
+ }
+ .to_chain()
+}
+
+/// Step 3 (optional): enable the installed package via `mling pkg-enable`
+/// when `--enable` was given.
+#[chain(routeify)]
+pub fn handle_state_install_enable(state: StateInstallEnable) -> Next {
+ let spec = format!("{}@{}", state.name, state.version);
+ let status = Command::new("mling")
+ .args(["pkg-enable", &spec])
+ .status()
+ .map_err(|e| {
+ ErrorPkgEnableFailed::new(format!("failed to run `mling pkg-enable {spec}`: {e}"))
+ })?;
+ if !status.success() {
+ return ErrorPkgEnableFailed::new(format!(
+ "`mling pkg-enable {spec}` failed with {status}"
+ ))
+ .to_chain();
+ }
+
ResultInstall {
install_dir: state.install_dir,
installed: state.installed,
@@ -183,3 +246,20 @@ pub fn render_error_binary_not_found(err: ErrorBinaryNotFound) -> RenderResult {
eprintln_cargo!(r, "binary not found: {}", err.info);
r
}
+
+#[renderer]
+pub fn render_error_pkg_enable_failed(err: ErrorPkgEnableFailed) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[completion(EntryInstall)]
+pub fn complete_install(ctx: &ShellContext) -> Suggest {
+ if ctx.previous_word != "install" {
+ return Suggest::FileCompletion;
+ }
+ suggest! {
+ ARG_ENABLE: "Enable the package after installing (runs `mling pkg-enable`)"
+ }
+}
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..545361f
--- /dev/null
+++ b/mingling_cli/src/updater/cmd_update.rs
@@ -0,0 +1,266 @@
+use std::{fs, path::Path, path::PathBuf};
+
+use mingling::{
+ Grouped, LazyRes, RenderResult, Routable,
+ macros::{chain, command, metadata, renderer, routeify},
+ metadata::Description,
+};
+use sha2::{Digest, Sha256};
+
+use crate::{Next, config::ResMlingConfig, eprintln_cargo, println_cargo};
+
+/// Config key holding the base URL that hosts the mling release packages.
+const CONFIG_KEY_UPDATE_URL: &str = "update-url";
+
+/// Default update source, used when the config key is unset.
+const DEFAULT_UPDATE_URL: &str = "https://mingling-rs.github.io/mingling/dist";
+
+/// Name of the staged update package inside `{data_dir}/mingling`.
+const UPDATE_FILE_NAME: &str = "update.tar.gz";
+
+/// Records the sha256 of the last applied update, written by the wrapper.
+const LAST_UPDATE_FILE_NAME: &str = "last-update.sha256";
+
+/// The resolved download task: check the remote checksum and stage the package.
+#[derive(Debug, Default, Grouped)]
+pub struct StateUpdateDownload {
+ pub base_url: String,
+ pub update_path: PathBuf,
+}
+
+/// The latest package is already installed.
+#[derive(Debug, Default, Grouped)]
+pub struct ResultUpdateUpToDate;
+
+/// The latest package was downloaded, verified, and staged for the wrapper.
+#[derive(Debug, Default, Grouped)]
+pub struct ResultUpdateStaged {
+ 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 URL is not a valid `http(s)://` URL.
+ InvalidUrl(String),
+ /// A network request failed, or the remote responded with an error.
+ Network(String),
+ /// The downloaded package failed its sha256 verification.
+ ChecksumMismatch(String),
+ /// Writing the staged update package failed.
+ Io(String),
+}
+
+#[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_UPDATE_URL, DEFAULT_UPDATE_URL);
+ let Some(update_path) = update_package_path() else {
+ return UpdateError::NoDataDirectory.to_chain();
+ };
+ if !is_http_url(source) {
+ return UpdateError::InvalidUrl(source.to_string()).to_chain();
+ }
+ StateUpdateDownload {
+ base_url: source.to_string(),
+ update_path,
+ }
+ .to_chain()
+}
+
+/// Check the remote checksum against the installed version; if they differ,
+/// download the package, verify its checksum, and stage it at
+/// `{data_dir}/mingling/update.tar.gz`.
+#[chain(routeify)]
+pub async fn handle_state_update_download(state: StateUpdateDownload) -> Next {
+ match check_and_fetch(&state.base_url, &state.update_path).await {
+ Ok(FetchOutcome::UpToDate) => ResultUpdateUpToDate.to_chain(),
+ Ok(FetchOutcome::Staged) => ResultUpdateStaged {
+ update_path: state.update_path,
+ }
+ .to_chain(),
+ Err(e) => e.to_chain(),
+ }
+}
+
+#[renderer]
+pub fn render_result_update_up_to_date(_: ResultUpdateUpToDate) -> RenderResult {
+ let mut result = RenderResult::new();
+ println_cargo!(result, "mling is already up to date");
+ result
+}
+
+#[renderer]
+pub fn render_result_update_staged(r: ResultUpdateStaged) -> RenderResult {
+ let mut result = RenderResult::new();
+ println_cargo!(result, "Downloaded: {}", 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::InvalidUrl(source) => {
+ eprintln_cargo!(
+ result,
+ "invalid update URL `{}`, expected an `http(s)://` URL such as `https://mingling-rs.github.io/mingling/dist`",
+ source
+ );
+ }
+ UpdateError::Network(msg) | UpdateError::ChecksumMismatch(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))
+}
+
+/// `{data_dir}/mingling/last-update.sha256`, the checksum of the last applied update.
+fn last_update_checksum_path() -> Option<PathBuf> {
+ dirs::data_dir().map(|dir| dir.join("mingling").join(LAST_UPDATE_FILE_NAME))
+}
+
+fn is_http_url(source: &str) -> bool {
+ let source = source.trim();
+ source.starts_with("http://") || source.starts_with("https://")
+}
+
+/// The platform suffix used by the package names (`mling-{os}.tar.gz`).
+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"
+ }
+}
+
+enum FetchOutcome {
+ UpToDate,
+ Staged,
+}
+
+/// Fetch `mling-{os}.tar.gz.sha256`, skip the download when the installed
+/// version already matches, then download and verify the package before
+/// staging it.
+async fn check_and_fetch(base_url: &str, update_path: &Path) -> Result<FetchOutcome, UpdateError> {
+ let os = update_os_name();
+ let base = base_url.trim_end_matches('/');
+ let checksum_url = format!("{base}/mling-{os}.tar.gz.sha256");
+ let package_url = format!("{base}/mling-{os}.tar.gz");
+
+ 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}")))?;
+
+ // 1. Fetch the remote checksum first.
+ let response = client.get(&checksum_url).send().await.map_err(|e| {
+ UpdateError::Network(format!(
+ "failed to fetch checksum from `{checksum_url}`: {e}"
+ ))
+ })?;
+ if !response.status().is_success() {
+ return Err(UpdateError::Network(format!(
+ "failed to fetch checksum from `{checksum_url}`: HTTP {}",
+ response.status()
+ )));
+ }
+ let checksum_text = response
+ .text()
+ .await
+ .map_err(|e| UpdateError::Network(format!("failed to read checksum: {e}")))?;
+ let remote_sha = parse_sha256(&checksum_text).ok_or_else(|| {
+ UpdateError::Network(format!("invalid checksum file at `{checksum_url}`"))
+ })?;
+
+ // 2. Skip the download when the installed version already matches.
+ if let Some(local_sha) = read_last_update_checksum()
+ && local_sha == remote_sha
+ {
+ return Ok(FetchOutcome::UpToDate);
+ }
+
+ // 3. Download the package.
+ let response =
+ client.get(&package_url).send().await.map_err(|e| {
+ UpdateError::Network(format!("failed to download `{package_url}`: {e}"))
+ })?;
+ if !response.status().is_success() {
+ return Err(UpdateError::Network(format!(
+ "failed to download `{package_url}`: HTTP {}",
+ response.status()
+ )));
+ }
+ let bytes = response
+ .bytes()
+ .await
+ .map_err(|e| UpdateError::Network(format!("failed to read package body: {e}")))?;
+
+ // 4. Verify the package before staging it.
+ let actual_sha = sha256_hex(&bytes);
+ if actual_sha != remote_sha {
+ return Err(UpdateError::ChecksumMismatch(format!(
+ "checksum mismatch for `{package_url}`: expected {remote_sha}, got {actual_sha}"
+ )));
+ }
+
+ // 5. Stage it for the wrapper.
+ write_update_package(&bytes, update_path)?;
+ Ok(FetchOutcome::Staged)
+}
+
+/// Parse the sha256 hex digest from a `sha256sum`-style line (`<hash> <file>`).
+fn parse_sha256(line: &str) -> Option<String> {
+ let hash = line.split_whitespace().next()?;
+ (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
+}
+
+/// The checksum of the last update applied by the wrapper, if recorded.
+fn read_last_update_checksum() -> Option<String> {
+ let path = last_update_checksum_path()?;
+ let content = fs::read_to_string(path).ok()?;
+ let sha = content.trim();
+ (!sha.is_empty()).then(|| sha.to_string())
+}
+
+fn sha256_hex(bytes: &[u8]) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(bytes);
+ format!("{:x}", hasher.finalize())
+}
+
+/// 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(package: &[u8], update_path: &Path) -> Result<(), UpdateError> {
+ let parent = update_path.parent().ok_or_else(|| {
+ UpdateError::Io(format!("no parent directory for {}", update_path.display()))
+ })?;
+ fs::create_dir_all(parent).map_err(|e| UpdateError::Io(e.to_string()))?;
+ let tmp_path = parent.join("update.tar.gz.tmp");
+ fs::write(&tmp_path, package).map_err(|e| UpdateError::Io(e.to_string()))?;
+ if update_path.exists() {
+ fs::remove_file(update_path).map_err(|e| UpdateError::Io(e.to_string()))?;
+ }
+ fs::rename(&tmp_path, update_path).map_err(|e| UpdateError::Io(e.to_string()))
+}