diff --git a/Cargo.lock b/Cargo.lock index 45d958d0e3..96b5bd89ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -510,6 +510,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cargo-run-internal-download" +version = "0.0.0" +dependencies = [ + "cargo-run", + "flate2", + "sha2", + "tar", + "ureq", +] + [[package]] name = "cargo-run-internal-watch" version = "0.0.0" diff --git a/tools/cargo-run/internal/download/Cargo.toml b/tools/cargo-run/internal/download/Cargo.toml new file mode 100644 index 0000000000..8eccb16af2 --- /dev/null +++ b/tools/cargo-run/internal/download/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cargo-run-internal-download" +edition.workspace = true +version.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +cargo-run = { path = "../.." } +ureq = { version = "3", default-features = false, features = ["rustls"] } +sha2 = { version = "0.10" } +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +tar = { version = "0.4", default-features = false } diff --git a/tools/cargo-run/internal/download/src/main.rs b/tools/cargo-run/internal/download/src/main.rs new file mode 100644 index 0000000000..eb04ee2b46 --- /dev/null +++ b/tools/cargo-run/internal/download/src/main.rs @@ -0,0 +1,163 @@ +use cargo_run::Error; +use flate2::read::GzDecoder; +use sha2::{Digest, Sha256}; +use std::io::{self, Read}; +use std::path::{Component, Path, PathBuf}; +use std::process::ExitCode; + +pub fn usage() { + eprintln!(); + eprintln!("USAGE:"); + eprintln!(" cargo run -p cargo-run-internal-download -- \\"); + eprintln!(" [--extract] [--strip ] [--include ]..."); + eprintln!(); + eprintln!("Args:"); + eprintln!(" HTTPS source to download"); + eprintln!(" Expected SHA-256 of the response body (64 hex digits)"); + eprintln!(" Destination file (default) or directory (with --extract)"); + eprintln!(" --extract Decompress the body as tar.gz and extract into "); + eprintln!(" --strip Strip N leading path components from each entry (requires --extract)"); + eprintln!(" --include Only extract entries whose stripped path starts with PREFIX, repeatable (requires --extract)"); + eprintln!(); +} + +fn main() -> ExitCode { + if let Err(e) = parse_args().inspect_err(|_| usage()).and_then(run) { + eprintln!("Error: {e}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +struct Args { + url: String, + sha256: String, + out: PathBuf, + mode: Mode, +} + +enum Mode { + File, + ExtractTarGz { strip: usize, include: Vec }, +} + +fn run(Args { url, sha256, out, mode }: Args) -> Result<(), Error> { + eprintln!("Downloading {url}"); + let mut body = Vec::new(); + ureq::get(&url) + .call() + .map_err(|e| Error::Io(io::Error::other(e), format!("HTTP GET {url}")))? + .into_body() + .into_with_config() + .reader() + .read_to_end(&mut body) + .map_err(|e| Error::Io(io::Error::other(e), "reading response body".into()))?; + + let actual = Sha256::digest(&body).iter().map(|b| format!("{b:02x}")).collect::(); + if !actual.eq_ignore_ascii_case(&sha256) { + eprintln!("SHA-256 mismatch:"); + eprintln!(" expected: {sha256}"); + eprintln!(" actual: {actual}"); + return Err(Error::Io(io::Error::other("SHA-256 mismatch"), "verifying download".into())); + } + + match mode { + Mode::File => write_to_file(&body, &out), + Mode::ExtractTarGz { strip, include } => extract_tar_gz(&body, &out, strip, &include), + } +} + +fn write_to_file(body: &[u8], path: &Path) -> Result<(), Error> { + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(|e| Error::Io(e, format!("creating '{}'", parent.display())))?; + } + std::fs::write(path, body).map_err(|e| Error::Io(e, format!("writing '{}'", path.display())))?; + eprintln!("Wrote {}", path.display()); + Ok(()) +} + +// TODO: support other compression/archive formats +fn extract_tar_gz(body: &[u8], dir: &Path, strip: usize, include: &[String]) -> Result<(), Error> { + std::fs::create_dir_all(dir).map_err(|e| Error::Io(e, format!("creating '{}'", dir.display())))?; + let mut archive = tar::Archive::new(GzDecoder::new(body)); + archive.set_preserve_permissions(true); + for entry in archive.entries().map_err(|e| Error::Io(e, "reading tar archive".into()))? { + let mut entry = entry.map_err(|e| Error::Io(e, "reading tar entry".into()))?; + let entry_path = entry.path().map_err(|e| Error::Io(e, "reading tar entry path".into()))?.into_owned(); + let Some(stripped) = strip_components(&entry_path, strip) else { + continue; + }; + if stripped.as_os_str().is_empty() || stripped.components().any(|c| matches!(c, Component::ParentDir | Component::Prefix(_) | Component::RootDir)) { + continue; + } + if !include.is_empty() { + let s = stripped.to_string_lossy().replace('\\', "/"); + if !include.iter().any(|p| s.starts_with(p.as_str())) { + continue; + } + } + let entry_type = entry.header().entry_type(); + if entry_type.is_symlink() || entry_type.is_hard_link() { + continue; + } + let target = dir.join(&stripped); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| Error::Io(e, format!("creating '{}'", parent.display())))?; + } + entry.unpack(&target).map_err(|e| Error::Io(e, format!("unpacking '{}'", target.display())))?; + } + eprintln!("Extracted into {}", dir.display()); + Ok(()) +} + +fn strip_components(path: &Path, n: usize) -> Option { + let mut comps = path.components(); + for _ in 0..n { + comps.next()?; + } + Some(comps.as_path().to_path_buf()) +} + +fn parse_args() -> Result { + fn arg_err(msg: impl Into) -> Error { + Error::Io(io::Error::new(io::ErrorKind::InvalidInput, msg.into()), "invalid arguments".into()) + } + + let mut args = std::env::args().skip(1); + let url = args.next().ok_or_else(|| arg_err("URL is required (first positional argument)"))?; + let sha256 = args.next().ok_or_else(|| arg_err("SHA-256 is required (second positional argument)"))?; + if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(arg_err("SHA-256 must be 64 hex digits")); + } + let out = PathBuf::from(args.next().ok_or_else(|| arg_err("OUT is required (third positional argument)"))?); + + let mut extract = false; + let mut strip: usize = 0; + let mut include: Vec = Vec::new(); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--extract" => extract = true, + "--strip" => { + let v = args.next().ok_or_else(|| arg_err("'--strip' requires a value"))?; + strip = v.parse().map_err(|_| arg_err(format!("--strip must be a non-negative integer, got '{v}'")))?; + } + "--include" => include.push(args.next().ok_or_else(|| arg_err("'--include' requires a value"))?), + other => return Err(arg_err(format!("unknown flag '{other}'"))), + } + } + + let mode = if extract { + Mode::ExtractTarGz { strip, include } + } else { + if strip != 0 { + return Err(arg_err("--strip is only valid with --extract")); + } + if !include.is_empty() { + return Err(arg_err("--include is only valid with --extract")); + } + Mode::File + }; + + Ok(Args { url, sha256, out, mode }) +} diff --git a/tools/cargo-run/internal/watch/src/main.rs b/tools/cargo-run/internal/watch/src/main.rs index 0ac70b2c2b..d520c60548 100644 --- a/tools/cargo-run/internal/watch/src/main.rs +++ b/tools/cargo-run/internal/watch/src/main.rs @@ -8,7 +8,7 @@ use std::path::Path; use std::process::ExitCode; use std::time::Duration; -const EXCLUDED_DIRECTORIES: &[&str] = &["target", ".git", "frontend/node_modules", "frontend/dist", "frontend/wrapper/pkg"]; +const EXCLUDED_DIRECTORIES: &[&str] = &["target", ".git", "frontend/node_modules", "frontend/dist", "frontend/wrapper/pkg", "tools"]; const INCLUDED_EXTENSIONS: &[&str] = &["rs"]; const DEBOUNCE: Duration = Duration::from_millis(500); @@ -23,17 +23,16 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - println!("Watching for changes..."); loop { std::thread::park(); } } -pub struct WatchGuard { +struct WatchGuard { _debouncer: Debouncer, } -pub fn watch(steps: impl IntoIterator) -> Result { +fn watch(steps: impl IntoIterator) -> Result { let steps: Vec = steps.into_iter().collect(); let root = std::env::current_dir() .and_then(|p| p.canonicalize()) diff --git a/tools/cargo-run/src/cmd.rs b/tools/cargo-run/src/cmd.rs index c17436bfa1..3cd12d3294 100644 --- a/tools/cargo-run/src/cmd.rs +++ b/tools/cargo-run/src/cmd.rs @@ -84,6 +84,14 @@ pub enum TerminalColor { Magenta, Cyan, White, + BrightBlack, + BrightRed, + BrightGreen, + BrightYellow, + BrightBlue, + BrightMagenta, + BrightCyan, + BrightWhite, Reset, } impl TerminalColor { @@ -97,6 +105,14 @@ impl TerminalColor { Self::Magenta => "\x1b[35m", Self::Cyan => "\x1b[36m", Self::White => "\x1b[37m", + Self::BrightBlack => "\x1b[90m", + Self::BrightRed => "\x1b[91m", + Self::BrightGreen => "\x1b[92m", + Self::BrightYellow => "\x1b[93m", + Self::BrightBlue => "\x1b[94m", + Self::BrightMagenta => "\x1b[95m", + Self::BrightCyan => "\x1b[96m", + Self::BrightWhite => "\x1b[97m", Self::Reset => "\x1b[0m", } } diff --git a/tools/cargo-run/src/lib.rs b/tools/cargo-run/src/lib.rs index 3a92a06ee0..174c810592 100644 --- a/tools/cargo-run/src/lib.rs +++ b/tools/cargo-run/src/lib.rs @@ -77,6 +77,14 @@ pub fn target_dir() -> PathBuf { } } +pub fn install_dir() -> PathBuf { + target_dir().join("cargo-run") +} + +pub fn bin_dir() -> PathBuf { + install_dir().join("bin") +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("One or more requirements were not met")] diff --git a/tools/cargo-run/src/main.rs b/tools/cargo-run/src/main.rs index 0283f4d8d5..96e5cdc110 100644 --- a/tools/cargo-run/src/main.rs +++ b/tools/cargo-run/src/main.rs @@ -140,7 +140,7 @@ fn run_task(task: &Task) -> Result<(), Error> { } fn prepend_path() { - let mut paths = vec![target_dir().join("cargo-run").join("bin")]; + let mut paths = vec![bin_dir()]; if let Some(path) = std::env::var_os("PATH") { paths.extend(std::env::split_paths(&path)); } diff --git a/tools/cargo-run/src/requirements.rs b/tools/cargo-run/src/requirements.rs index 76a6d4ef53..ec690b8967 100644 --- a/tools/cargo-run/src/requirements.rs +++ b/tools/cargo-run/src/requirements.rs @@ -4,6 +4,8 @@ use std::io::IsTerminal; use crate::cmd::prelude::*; use crate::*; +mod wasm_opt; + #[derive(Default, Clone)] pub struct Requirement { command: &'static str, @@ -27,24 +29,17 @@ fn requirements(task: &Task) -> Vec { command: "rustc", args: &["--print", "target-libdir", "--target", "wasm32-unknown-unknown"], check: Check::Matches(&|out| std::path::Path::new(out.trim()).is_dir()), - name: "Rust Wasm Toolchain", + name: "Rust (Wasm Target)", install: "rustup target add wasm32-unknown-unknown".into(), skip: Some(&|task| matches!(task.target, Target::Cli)), ..Default::default() }, - Requirement { - command: "cargo-about", - args: &["--version"], - name: "Cargo About", - install: "cargo install cargo-about".into(), - skip: Some(&|task| matches!(task.target, Target::Cli)), - ..Default::default() - }, Requirement { command: "wasm-opt", args: &["--version"], name: "Wasm Opt", version: Some(">=130"), + install: wasm_opt::install_action(), skip: Some(&|task| { matches!(task.target, Target::Cli) || match task.profile { @@ -64,6 +59,14 @@ fn requirements(task: &Task) -> Vec { skip: Some(&|task| matches!(task.target, Target::Cli)), ..Default::default() }, + Requirement { + command: "cargo-about", + args: &["--version"], + name: "Cargo About", + install: "cargo install cargo-about".into(), + skip: Some(&|task| matches!(task.target, Target::Cli)), + ..Default::default() + }, Requirement { command: "node", args: &["--version"], @@ -75,6 +78,7 @@ fn requirements(task: &Task) -> Vec { command: "cmake", args: &["--version"], name: "CMake", + install: InstallAction::ManualInstructions("https://cmake.org/download/ or find it in your system's package manager"), skip: Some(&|task| !matches!(task.target, Target::Desktop) || cfg!(target_os = "linux")), ..Default::default() }, @@ -82,6 +86,7 @@ fn requirements(task: &Task) -> Vec { command: "ninja", args: &["--version"], name: "Ninja", + install: InstallAction::ManualInstructions("https://github.com/ninja-build/ninja/releases or find it in your system's package manager"), skip: Some(&|task| !matches!(task.target, Target::Desktop) || cfg!(target_os = "linux")), ..Default::default() }, @@ -97,7 +102,7 @@ pub fn check(task: &Task) -> Result<(), Error> { eprintln!("Checking Requirements:"); let mut installable: Vec = Vec::new(); - let mut failures: Vec = Vec::new(); + let mut manual: Vec<(Requirement, String)> = Vec::new(); for dep in requirements(task) { match cmd(dep.command, dep.args.iter().copied()).output_unchecked() { @@ -114,15 +119,15 @@ pub fn check(task: &Task) -> Result<(), Error> { Some(version) if req.matches(&version) => eprintln!(" ✓ {} ({version})", dep.name), Some(version) => { eprintln!(" ✗ {} (found {version}, requires {req_str})", dep.name); - if dep.install.is_some() { + if dep.install.is_auto_installable() { installable.push(dep); } else { - failures.push(format!("{}: version mismatch (found {version}, requires {req_str})", dep.name)); + manual.push((dep, format!("version mismatch (found {version}, requires {req_str})"))); } } None => { eprintln!(" ✗ {} (could not parse version from '{line}')", dep.name); - failures.push(format!("{}: could not parse version from '{line}'", dep.name)); + manual.push((dep, format!("could not parse version from '{line}'"))); } } } @@ -131,8 +136,10 @@ pub fn check(task: &Task) -> Result<(), Error> { Check::Matches(check) => { if !check(stdout.to_string()) { eprintln!(" ✗ {} - check failed", dep.name); - if dep.install.is_some() { + if dep.install.is_auto_installable() { installable.push(dep); + } else { + manual.push((dep, "check failed".into())); } } else { eprintln!(" ✓ {}", dep.name); @@ -143,18 +150,18 @@ pub fn check(task: &Task) -> Result<(), Error> { Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!(" ✗ {} - command failed: {}", dep.name, stderr.trim()); - if dep.install.is_some() { + if dep.install.is_auto_installable() { installable.push(dep); } else { - failures.push(format!("{}: not installed or not working", dep.name)); + manual.push((dep.clone(), format!("`{}` not installed or not working", dep.command))); } } Err(_) => { eprintln!(" ✗ {} - not found", dep.name); - if dep.install.is_some() { + if dep.install.is_auto_installable() { installable.push(dep); } else { - failures.push(format!("{}: not found in PATH", dep.name)); + manual.push((dep.clone(), format!("`{}` not found in PATH", dep.command))); } } } @@ -162,20 +169,20 @@ pub fn check(task: &Task) -> Result<(), Error> { eprintln!(); - if installable.is_empty() && failures.is_empty() { + if installable.is_empty() && manual.is_empty() { return Ok(()); } - let total = installable.len() + failures.len(); + let total = installable.len() + manual.len(); eprintln!("{total} requirement{} not met:", if total > 1 { "s" } else { "" }); for dep in &installable { eprintln!(" - {}", dep.name); } - for msg in &failures { - eprintln!(" - {msg}"); + for (dep, msg) in &manual { + eprintln!(" - {}: {msg}", dep.name); } - if !failures.is_empty() { + if !manual.is_empty() { eprintln!(); eprintln!("See: https://graphite.art/volunteer/guide/project-setup/"); } @@ -187,7 +194,7 @@ pub fn check(task: &Task) -> Result<(), Error> { return Ok(()); } - if installable.is_empty() && failures.is_empty() { + if installable.is_empty() && manual.is_empty() { return Ok(()); } @@ -195,13 +202,17 @@ pub fn check(task: &Task) -> Result<(), Error> { eprintln!(); eprintln!("The following can be installed automatically:"); for dep in &installable { - eprintln!(" {}: {}", dep.name, dep.install.description()); + match &dep.install { + InstallAction::Command(cmd) => eprintln!(" - {}: {}", dep.name, cmd), + InstallAction::Expression { description, .. } => eprintln!(" - {description}"), + InstallAction::None | InstallAction::ManualInstructions(_) => unreachable!(), + } } eprintln!(); if installable.len() == 1 { - eprint!("Install it now? [Y/n] "); + eprint!("Install it now? [y/N] "); } else { - eprint!("Install them now? [Y/n] "); + eprint!("Install them now? [y/N] "); } let mut input = String::new(); @@ -209,55 +220,54 @@ pub fn check(task: &Task) -> Result<(), Error> { let input = input.trim(); if input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") { - let mut successfully_installed = Vec::new(); - - for (i, dep) in installable.iter().enumerate() { + for dep in installable.into_iter() { eprintln!("Installing {}...", dep.name); match &dep.install { InstallAction::Command(install_cmd) => { let parts: Vec<&str> = install_cmd.split_whitespace().collect(); let expr = cmd(parts[0], parts[1..].iter().copied()).unchecked(); match Expression::run(&expr) { - Ok(output) if output.status.success() => successfully_installed.push(i), - Ok(_) => eprintln!("Failed to install {}", dep.name), + Ok(output) if !output.status.success() => { + let stderr = String::from_utf8_lossy(&output.stderr); + manual.push((dep, format!("installation command failed: {}", stderr.trim()))); + } Err(e) => return Err(Error::Command(e)), + _ => {} } } - InstallAction::Function { function, .. } => { - if let Err(e) = function() { + InstallAction::Expression { expression, .. } => { + if let Err(e) = expression.clone().run() { eprintln!("{e}"); eprintln!("Failed to install {}", dep.name); - } else { - successfully_installed.push(i); + manual.push((dep, format!("failed to install ({e})"))); } } - InstallAction::None => unreachable!(), + InstallAction::None | InstallAction::ManualInstructions(_) => unreachable!(), } } + } + } - for i in successfully_installed.into_iter().rev() { - installable.remove(i); + if !manual.is_empty() { + eprintln!(); + eprintln!("Please install the following dependenc{}:", if manual.len() == 1 { "y" } else { "ies" }); + for (dep, msg) in &manual { + match dep.install { + InstallAction::ManualInstructions(instructions) => eprintln!(" - {}: {}", dep.name, instructions), + _ => eprintln!(" - {}: {}", dep.name, msg), } } } - if !failures.is_empty() { + if (!manual.is_empty()) && is_interactive { eprintln!(); - eprintln!("The following requirements must be resolved manually:"); - for msg in &failures { - eprintln!(" - {msg}"); - } - } - - if (!installable.is_empty() || !failures.is_empty()) && is_interactive { - eprintln!(); - eprintln!("Continue without resolving these requirements? [y/N]"); + eprintln!("Attempt to continue regardless of unmet requirements? [y/N]"); let mut input = String::new(); std::io::stdin().read_line(&mut input).map_err(|e| Error::Io(e, "Failed to read from stdin".into()))?; let input = input.trim(); - if input.eq_ignore_ascii_case("n") || input.eq_ignore_ascii_case("no") { + if !input.eq_ignore_ascii_case("y") && !input.eq_ignore_ascii_case("yes") { return Err(Error::RequirementsNotMet); } } @@ -300,24 +310,16 @@ enum InstallAction { #[default] None, Command(&'static str), - #[expect(dead_code)] // TODO: Remove after followup pr landed - Function { - description: &'static str, - function: &'static dyn Fn() -> Result<(), Error>, + Expression { + description: String, + expression: Expression, }, + ManualInstructions(&'static str), } impl InstallAction { - fn is_some(&self) -> bool { - !matches!(self, InstallAction::None) - } - - fn description(&self) -> &'static str { - match self { - InstallAction::None => "", - InstallAction::Command(cmd) => cmd, - InstallAction::Function { description, .. } => description, - } + fn is_auto_installable(&self) -> bool { + !matches!(self, InstallAction::None | InstallAction::ManualInstructions(_)) } } diff --git a/tools/cargo-run/src/requirements/wasm_opt.rs b/tools/cargo-run/src/requirements/wasm_opt.rs new file mode 100644 index 0000000000..8e13b7eff1 --- /dev/null +++ b/tools/cargo-run/src/requirements/wasm_opt.rs @@ -0,0 +1,46 @@ +use super::InstallAction; +use crate::cmd::prelude::*; +use crate::{install_dir, workspace_dir}; + +/// Pinned Binaryen release used by [`install_action`]. +/// NOTICE: keep in sync with the `BINARYEN_VERSION` pinned across the CI workflows, and update [`SHA256`] below. +const VERSION: &str = "130"; +const SHA256: &[(&str, &str)] = &[ + ("x86_64-windows", "cc09c874f4332d00aa32ab72745a9b98c9a172f795762f21d03e70638a3f7f4c"), + ("arm64-windows", "b18c9cbe000562b1ee5d9cb60146616a949aca504903ad63f27fd9fd679898a7"), + ("arm64-macos", "79d3ab9f417d9e215f15f598f523d001a7d9ac1e59367e5c869fbdabd1cba72e"), + ("x86_64-macos", "d3e2d1235b70c93c54b52eabc1625ea960965152218754f1f4eeb0f873c48e03"), + ("x86_64-linux", "0a18362361ad05465118cd8eeb72edaeec89de6894bc283576ef4e07aa3babcc"), + ("aarch64-linux", "e6ae6e09ac40f4e14bc5be6f687c58e2995c84170013975fa641809dd3b480a0"), +]; + +fn url_for(platform: &str) -> String { + format!("https://github.com/WebAssembly/binaryen/releases/download/version_{VERSION}/binaryen-version_{VERSION}-{platform}.tar.gz") +} + +pub fn install_action() -> InstallAction { + let platform = match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "x86_64") => "x86_64-windows", + ("windows", "aarch64") => "arm64-windows", + ("macos", "aarch64") => "arm64-macos", + ("macos", "x86_64") => "x86_64-macos", + ("linux", "x86_64") => "x86_64-linux", + ("linux", "aarch64") => "aarch64-linux", + _ => return InstallAction::None, + }; + let url = url_for(platform); + let Some(sha256) = SHA256.iter().find_map(|(p, s)| (*p == platform).then_some(*s)) else { + return InstallAction::None; + }; + let out = install_dir().to_string_lossy().into_owned(); + let description = format!("Download wasm-opt {VERSION} from {url} (sha256 {sha256})"); + + let args = [&url, sha256, &out, "--extract", "--strip", "1", "--include", "bin/wasm-opt"]; + + #[cfg(target_os = "macos")] + let args = args.into_iter().chain(["--include", "lib/libbinaryen.dylib"]).collect::>(); + + let expression = utils::internal("download").args(args).dir(workspace_dir()); + + InstallAction::Expression { description, expression } +}