Replace npm build script with new cargo run tool (#3832)

* move nix flake to root

* cargo run tool

* use thiserror in third-party-licenses tool

* prefere panic over exit

* Add automatic dependency check to cargo run tool

* Skip dependecies that are not needed for the current task

* Fixup

* Fixup

* fix windows

* Fixup

* improve usage text

* Fix linux bundle

* add graphen-cli

* fix build profile

* fix

* release profile should not include debug infos

* Review

* remove profiling profile

was redundent with release

* rename to cargo-run tool

* improve consistency

* rename deps to requirements

* fix

* return success when showing usage
This commit is contained in:
Timon
2026-03-07 13:26:19 +00:00
committed by GitHub
parent 50ef6e15cb
commit 5d22292072
39 changed files with 664 additions and 404 deletions
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "cargo-run"
edition.workspace = true
version.workspace = true
license.workspace = true
authors.workspace = true
default-run = "cargo-run"
[dependencies]
thiserror = { workspace = true }
+104
View File
@@ -0,0 +1,104 @@
use std::path::PathBuf;
use std::process;
pub mod requirements;
pub enum Action {
Run,
Build,
}
pub enum Target {
Web,
Desktop,
Cli,
}
pub enum Profile {
Default,
Release,
Debug,
}
pub struct Task {
pub action: Action,
pub target: Target,
pub profile: Profile,
pub args: Vec<String>,
}
impl Task {
pub fn parse(args: &[&str]) -> Option<Self> {
let split = args.iter().position(|a| *a == "--").unwrap_or(args.len());
let passthru_args = args[split..].iter().skip(1).map(|s| s.to_string()).collect();
let args = &args[..split];
let (action, args) = match args.first() {
Some(&"build") => (Action::Build, &args[1..]),
Some(&"run") => (Action::Run, &args[1..]),
Some(&"help") => return None,
_ => (Action::Run, args),
};
let (target, args) = match args.first() {
Some(&"desktop") => (Target::Desktop, &args[1..]),
Some(&"web") => (Target::Web, &args[1..]),
Some(&"cli") => (Target::Cli, &args[1..]),
_ => (Target::Web, args),
};
let profile = match args.first() {
Some(&"release") => Profile::Release,
Some(&"debug") => Profile::Debug,
None => Profile::Default,
_ => return None,
};
Some(Task {
target,
action,
profile,
args: passthru_args,
})
}
}
pub fn run(command: &str) -> Result<(), Error> {
run_from(command, None)
}
pub fn npm_run_in_frontend_dir(args: &str) -> Result<(), Error> {
let workspace_dir = std::path::PathBuf::from(env!("CARGO_WORKSPACE_DIR"));
let frontend_dir = workspace_dir.join("frontend");
let npm = if cfg!(target_os = "windows") { "npm.cmd" } else { "npm" };
run_from(&format!("{npm} run {args}"), Some(&frontend_dir))
}
fn run_from(command: &str, dir: Option<&PathBuf>) -> Result<(), Error> {
let command = command.split_whitespace().collect::<Vec<_>>();
let mut cmd = process::Command::new(command[0]);
if command.len() > 1 {
cmd.args(&command[1..]);
}
if let Some(dir) = dir {
cmd.current_dir(dir);
}
let exit_code = cmd
.spawn()
.map_err(|e| Error::Io(e, format!("Failed to spawn command '{}'", command.join(" "))))?
.wait()
.map_err(|e| Error::Io(e, format!("Failed to wait for command '{}'", command.join(" "))))?;
if !exit_code.success() {
return Err(Error::Command(command.join(" "), exit_code));
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{1}: {0}")]
Io(#[source] std::io::Error, String),
#[error("Command '{0}' exited with code {1}")]
Command(String, process::ExitStatus),
}
+99
View File
@@ -0,0 +1,99 @@
use std::process::ExitCode;
use cargo_run::*;
fn usage() {
println!();
println!("USAGE:");
println!(" cargo run [<command>] [<target>] [<profile>] [-- [args]...]");
println!();
println!("COMMON USAGE:");
println!(" cargo run Run the web app");
println!(" cargo run desktop Run the desktop app");
println!();
println!("OPTIONS:");
println!("<command>:");
println!(" [run] Run the selected target (default)");
println!(" build Build the selected target");
println!(" help Show this message");
println!("<target>:");
println!(" [web] Web app (default)");
println!(" desktop Desktop app");
println!(" cli Graphene CLI");
println!("<profile>:");
println!(" [debug] Optimizations disabled (default for run)");
println!(" [release] Optimizations enabled (default for build)");
println!();
println!("MORE EXAMPLES:");
println!(" cargo run build desktop");
println!(" cargo run desktop release");
println!(" cargo run cli -- --help");
println!()
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
let args: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
let task = match Task::parse(&args) {
Some(run) => run,
None => {
usage();
return ExitCode::SUCCESS;
}
};
if let Err(e) = run_task(&task) {
eprintln!("Error: {e}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
fn run_task(task: &Task) -> Result<(), Error> {
requirements::check(task)?;
match (&task.action, &task.target, &task.profile) {
(Action::Run, Target::Web, Profile::Debug | Profile::Default) => npm_run_in_frontend_dir("start")?,
(Action::Run, Target::Web, Profile::Release) => npm_run_in_frontend_dir("production")?,
(Action::Build, Target::Web, Profile::Debug) => npm_run_in_frontend_dir("build-dev")?,
(Action::Build, Target::Web, Profile::Release | Profile::Default) => npm_run_in_frontend_dir("build")?,
(action, Target::Desktop, mut profile) => {
if matches!(profile, Profile::Default) {
profile = match action {
Action::Run => &Profile::Debug,
Action::Build => &Profile::Release,
}
}
if matches!(profile, Profile::Release) {
npm_run_in_frontend_dir("build-native")?;
} else {
npm_run_in_frontend_dir("build-native-dev")?;
};
run("cargo run -p third-party-licenses --features desktop")?;
let cargo_profile = match profile {
Profile::Debug => "dev",
Profile::Release => "release",
Profile::Default => unreachable!(),
};
let args = if matches!(action, Action::Run) {
format!(" -- open {}", task.args.join(" "))
} else {
"".to_string()
};
run(&format!("cargo run --profile {cargo_profile} -p graphite-desktop-bundle{args}"))?;
}
(Action::Run, Target::Cli, Profile::Debug | Profile::Default) => run(&format!("cargo run -p graphene-cli -- {}", task.args.join(" ")))?,
(Action::Run, Target::Cli, Profile::Release) => run(&format!("cargo run -r -p graphene-cli -- {}", task.args.join(" ")))?,
(Action::Build, Target::Cli, Profile::Debug) => run("cargo build -p graphene-cli")?,
(Action::Build, Target::Cli, Profile::Release | Profile::Default) => run("cargo build -r -p graphene-cli")?,
}
Ok(())
}
+196
View File
@@ -0,0 +1,196 @@
use std::io::IsTerminal;
use std::process::Command;
use crate::*;
#[derive(Default, Clone)]
struct Requirement {
command: &'static str,
args: &'static [&'static str],
name: &'static str,
version: Option<&'static str>,
install: Option<&'static str>,
skip: Option<&'static dyn Fn(&Task) -> bool>,
}
fn requirements(task: &Task) -> Vec<Requirement> {
[
Requirement {
command: "rustc",
args: &["--version"],
name: "Rust",
..Default::default()
},
Requirement {
command: "cargo-about",
args: &["--version"],
name: "cargo-about",
install: Some("cargo install cargo-about"),
skip: Some(&|task| matches!(task.target, Target::Cli)),
..Default::default()
},
Requirement {
command: "cargo-watch",
args: &["--version"],
name: "cargo-watch",
install: Some("cargo install cargo-watch"),
skip: Some(&|task| {
!matches!(
task,
Task {
target: Target::Web,
action: Action::Run,
..
}
)
}),
..Default::default()
},
Requirement {
command: "wasm-bindgen",
args: &["--version"],
name: "wasm-bindgen-cli",
version: Some("0.2.100"),
install: Some("cargo install -f wasm-bindgen-cli@0.2.100"),
skip: Some(&|task| matches!(task.target, Target::Cli)),
},
Requirement {
command: "wasm-pack",
args: &["--version"],
name: "wasm-pack",
install: Some("cargo install wasm-pack"),
skip: Some(&|task| matches!(task.target, Target::Cli)),
..Default::default()
},
Requirement {
command: "node",
args: &["--version"],
name: "Node.js",
skip: Some(&|task| matches!(task.target, Target::Cli)),
..Default::default()
},
Requirement {
command: "cmake",
args: &["--version"],
name: "CMake",
skip: Some(&|task| !matches!(task.target, Target::Desktop) || cfg!(target_os = "linux")),
..Default::default()
},
Requirement {
command: "ninja",
args: &["--version"],
name: "Ninja",
skip: Some(&|task| !matches!(task.target, Target::Desktop) || cfg!(target_os = "linux")),
..Default::default()
},
]
.iter()
.filter(|d| if let Some(skip) = d.skip { !skip(task) } else { true })
.cloned()
.collect()
}
pub fn check(task: &Task) -> Result<(), Error> {
eprintln!();
eprintln!("Checking Requirements:");
let mut installable: Vec<Requirement> = Vec::new();
let mut failures: Vec<String> = Vec::new();
for dep in requirements(task) {
match Command::new(dep.command).args(dep.args).output() {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout);
let version = version.lines().next().unwrap_or_default().trim();
if let Some(expected) = dep.version {
if version.contains(expected) {
eprintln!("{} ({})", dep.name, version);
} else {
eprintln!("{} (found {}, expected {})", dep.name, version, expected);
if dep.install.is_some() {
installable.push(dep);
} else {
failures.push(format!("{}: version mismatch (found {version}, expected {expected})", dep.name));
}
}
} else {
eprintln!("{} ({})", dep.name, version);
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("{} - command failed: {}", dep.name, stderr.trim());
if dep.install.is_some() {
installable.push(dep);
} else {
failures.push(format!("{}: not installed or not working", dep.name));
}
}
Err(_) => {
eprintln!("{} - not found", dep.name);
if dep.install.is_some() {
installable.push(dep);
} else {
failures.push(format!("{}: not found in PATH", dep.name));
}
}
}
}
eprintln!();
if installable.is_empty() && failures.is_empty() {
return Ok(());
}
let total = installable.len() + failures.len();
eprintln!("{total} requirement{} not met:", if total > 1 { "s" } else { "" });
for dep in &installable {
eprintln!(" - {}: {}", dep.name, dep.install.unwrap());
}
for msg in &failures {
eprintln!(" - {msg}");
}
if !failures.is_empty() {
eprintln!();
eprintln!("See: https://graphite.art/volunteer/guide/project-setup/");
}
// Don't prompt for automatic installation if we're not interactive session
if !std::io::stdout().is_terminal() || !std::io::stderr().is_terminal() || !std::io::stdin().is_terminal() {
return Ok(());
}
if installable.is_empty() {
return Ok(());
}
eprintln!();
eprintln!("The following can be installed automatically:");
for dep in &installable {
eprintln!(" {}", dep.install.unwrap());
}
eprintln!();
eprint!("Install them now? [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.is_empty() || input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") {
for dep in &installable {
let parts: Vec<&str> = dep.install.unwrap().split_whitespace().collect();
eprintln!("Running: {}...", dep.install.unwrap());
let status = Command::new(parts[0])
.args(&parts[1..])
.status()
.map_err(|e| Error::Io(e, format!("Failed to run '{}'", dep.install.unwrap())))?;
if !status.success() {
eprintln!("Failed to install {}", dep.name);
}
}
}
Ok(())
}