From ef8deadfd878227d7854c8a3ce14462b819d9151 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Thu, 27 Feb 2025 22:02:51 +0100 Subject: [PATCH] feat: is able to call a script from a project --- Cargo.toml | 2 +- crates/forest/src/cli.rs | 126 ++++++++++++++++++------------ crates/forest/src/cli/run.rs | 45 +++++++++++ crates/forest/src/main.rs | 1 + crates/forest/src/model.rs | 49 +++++++----- crates/forest/src/script.rs | 78 ++++++++++++++++++ examples/project/forest.kdl | 7 +- examples/project/scripts/hello.sh | 5 ++ 8 files changed, 237 insertions(+), 76 deletions(-) create mode 100644 crates/forest/src/cli/run.rs create mode 100644 crates/forest/src/script.rs create mode 100755 examples/project/scripts/hello.sh diff --git a/Cargo.toml b/Cargo.toml index 2f193cb..5579220 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ anyhow = { version = "1" } tokio = { version = "1", features = ["full"] } tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3.18" } -clap = { version = "4", features = ["derive", "env"] } +clap = { version = "4", features = ["derive", "env", "cargo", "string"] } dotenvy = { version = "0.15" } serde = { version = "1", features = ["derive"] } uuid = { version = "1.7", features = ["v4"] } diff --git a/crates/forest/src/cli.rs b/crates/forest/src/cli.rs index ce675e2..b30321d 100644 --- a/crates/forest/src/cli.rs +++ b/crates/forest/src/cli.rs @@ -1,6 +1,6 @@ use std::{net::SocketAddr, path::PathBuf}; -use clap::{Parser, Subcommand}; +use clap::{FromArgMatches, Parser, Subcommand, crate_authors, crate_description, crate_version}; use colored_json::ToColoredJson; use kdl::KdlDocument; use rusty_s3::{Bucket, Credentials, S3Action}; @@ -11,29 +11,14 @@ use crate::{ state::SharedState, }; +mod run; mod template; -#[derive(Parser)] -#[command(author, version, about, long_about = None, subcommand_required = true)] -struct Command { - #[command(subcommand)] - command: Option, - #[arg( - env = "FOREST_PROJECT_PATH", - long = "project-path", - default_value = "." - )] - project_path: PathBuf, -} - #[derive(Subcommand)] enum Commands { Init {}, - Template(template::Template), - Info {}, - Serve { #[arg(env = "FOREST_HOST", long, default_value = "127.0.0.1:3000")] host: SocketAddr, @@ -55,10 +40,36 @@ enum Commands { }, } -pub async fn execute() -> anyhow::Result<()> { - let cli = Command::parse(); +fn get_root(include_run: bool) -> clap::Command { + let mut root_cmd = clap::Command::new("forest") + .subcommand_required(true) + .author(crate_authors!()) + .version(crate_version!()) + .about(crate_description!()) + .arg( + clap::Arg::new("project_path") + .long("project-path") + .env("FOREST_PROJECT_PATH") + .default_value("."), + ); - let project_path = &cli.project_path.canonicalize()?; + if include_run { + root_cmd = root_cmd + .subcommand(clap::Command::new("run").allow_external_subcommands(true)) + .ignore_errors(true); + } + + Commands::augment_subcommands(root_cmd) +} + +pub async fn execute() -> anyhow::Result<()> { + let matches = get_root(true).get_matches(); + let project_path = PathBuf::from( + &matches + .get_one::("project_path") + .expect("project path always to be set"), + ) + .canonicalize()?; let project_file_path = project_path.join("forest.kdl"); if !project_file_path.exists() { anyhow::bail!( @@ -74,7 +85,7 @@ pub async fn execute() -> anyhow::Result<()> { tracing::trace!("found a project name: {}", project.name); let plan = if let Some(plan_file_path) = PlanReconciler::new() - .reconcile(&project, project_path) + .reconcile(&project, &project_path) .await? { let plan_file = tokio::fs::read_to_string(&plan_file_path).await?; @@ -90,41 +101,54 @@ pub async fn execute() -> anyhow::Result<()> { let context = Context { project, plan }; - match cli.command.unwrap() { - Commands::Init {} => { - tracing::info!("initializing project"); - tracing::trace!("found context: {:?}", context); - } + let matches = if matches.subcommand_matches("run").is_some() { + tracing::debug!("run is called, building extra commands, rerunning the parser"); + let root = get_root(false); - Commands::Info {} => { - let output = serde_json::to_string_pretty(&context)?; - println!("{}", output.to_colored_json_auto().unwrap_or(output)); - } + let root = run::Run::augment_command(root, &context); - Commands::Template(template) => { - template.execute(project_path, &context).await?; - } + root.get_matches() + } else { + matches + }; - Commands::Serve { - s3_endpoint, - s3_bucket, - s3_region, - s3_user, - s3_password, - .. - } => { - tracing::info!("Starting server"); - let creds = Credentials::new(s3_user, s3_password); - let bucket = Bucket::new( - url::Url::parse(&s3_endpoint)?, - rusty_s3::UrlStyle::Path, + match matches.subcommand().unwrap() { + ("run", args) => { + run::Run::execute(args, &project_path, &context).await?; + } + _ => match Commands::from_arg_matches(&matches).unwrap() { + Commands::Init {} => { + tracing::info!("initializing project"); + tracing::trace!("found context: {:?}", context); + } + Commands::Info {} => { + let output = serde_json::to_string_pretty(&context)?; + println!("{}", output.to_colored_json_auto().unwrap_or(output)); + } + Commands::Template(template) => { + template.execute(&project_path, &context).await?; + } + Commands::Serve { + s3_endpoint, s3_bucket, s3_region, - )?; - let put_object = bucket.put_object(Some(&creds), "some-object"); - let _url = put_object.sign(std::time::Duration::from_secs(30)); - let _state = SharedState::new().await?; - } + s3_user, + s3_password, + .. + } => { + tracing::info!("Starting server"); + let creds = Credentials::new(s3_user, s3_password); + let bucket = Bucket::new( + url::Url::parse(&s3_endpoint)?, + rusty_s3::UrlStyle::Path, + s3_bucket, + s3_region, + )?; + let put_object = bucket.put_object(Some(&creds), "some-object"); + let _url = put_object.sign(std::time::Duration::from_secs(30)); + let _state = SharedState::new().await?; + } + }, } Ok(()) diff --git a/crates/forest/src/cli/run.rs b/crates/forest/src/cli/run.rs new file mode 100644 index 0000000..6c35a7e --- /dev/null +++ b/crates/forest/src/cli/run.rs @@ -0,0 +1,45 @@ +use std::{collections::BTreeMap, path::Path}; + +use crate::{model::Context, script::ScriptExecutor}; + +// Run is a bit special in that because the arguments dynamically render, we need to do some special magic in +// clap to avoid having to do hacks to register clap subcommands midcommand. As such instead, we opt to simply +// create a new sub command that encapsulates all the run complexities +pub struct Run {} +impl Run { + pub fn augment_command(root: clap::Command, ctx: &Context) -> clap::Command { + let mut run_cmd = clap::Command::new("run") + .subcommand_required(true) + .about("runs any kind of script from either the project or plan"); + + if let Some(scripts) = &ctx.project.scripts { + for name in scripts.items.keys() { + let cmd = clap::Command::new(name.to_string()); + run_cmd = run_cmd.subcommand(cmd); + } + } + + root.subcommand(run_cmd) + } + + pub async fn execute( + args: &clap::ArgMatches, + project_path: &Path, + ctx: &Context, + ) -> anyhow::Result<()> { + let Some((name, args)) = args.subcommand() else { + anyhow::bail!("failed to find a matching run command") + }; + + let scripts_ctx = ctx.project.scripts.as_ref().expect("to find scripts"); + let Some(script_ctx) = scripts_ctx.items.get(name) else { + anyhow::bail!("failed to find script: {}", name); + }; + + ScriptExecutor::new(project_path.into(), ctx.clone()) + .run(script_ctx, name) + .await?; + + Ok(()) + } +} diff --git a/crates/forest/src/main.rs b/crates/forest/src/main.rs index c22c7fc..57c0d77 100644 --- a/crates/forest/src/main.rs +++ b/crates/forest/src/main.rs @@ -1,6 +1,7 @@ pub mod cli; pub mod model; pub mod plan_reconciler; +pub mod script; pub mod state; #[tokio::main] diff --git a/crates/forest/src/model.rs b/crates/forest/src/model.rs index cc9fb0a..fc3b4ff 100644 --- a/crates/forest/src/model.rs +++ b/crates/forest/src/model.rs @@ -130,6 +130,7 @@ impl TryFrom<&KdlValue> for GlobalVariable { #[derive(Debug, Clone, Serialize, Default)] pub struct Global { + #[serde(flatten)] items: BTreeMap, } @@ -233,12 +234,23 @@ impl TryFrom<&KdlNode> for Templates { } #[derive(Debug, Clone, Serialize)] -pub struct Action {} +#[serde(tag = "type")] +pub enum Script { + Shell {}, +} + +impl TryFrom<&KdlNode> for Script { + type Error = anyhow::Error; + + fn try_from(value: &KdlNode) -> Result { + Ok(Self::Shell {}) + } +} #[derive(Debug, Clone, Serialize)] pub struct Scripts { - pub path: PathBuf, - pub actions: BTreeMap, + #[serde(flatten)] + pub items: BTreeMap, } impl TryFrom<&KdlNode> for Scripts { @@ -246,22 +258,21 @@ impl TryFrom<&KdlNode> for Scripts { fn try_from(value: &KdlNode) -> Result { let val = Self { - path: value - .get("path") - .and_then(|p| p.as_string()) - .map(PathBuf::from) - .unwrap_or(PathBuf::from("scripts/")), - actions: value - .children() - .and_then(|c| c.get("actions")) - .and_then(|a| a.children()) - .map(|d| { - d.nodes() - .iter() - .map(|n| (n.name().value().to_string(), Action {})) - .collect::>() - }) - .unwrap_or_default(), + items: { + let mut out = BTreeMap::default(); + if let Some(children) = value.children() { + for entry in children.nodes() { + let name = entry.name().value(); + let val = entry.try_into()?; + + out.insert(name.to_string(), val); + } + + out + } else { + out + } + }, }; Ok(val) diff --git a/crates/forest/src/script.rs b/crates/forest/src/script.rs new file mode 100644 index 0000000..8343967 --- /dev/null +++ b/crates/forest/src/script.rs @@ -0,0 +1,78 @@ +use std::path::PathBuf; + +use shell::ShellExecutor; + +use crate::model::{Context, Script}; + +pub mod shell { + use std::process::Stdio; + + use anyhow::Context; + + use super::ScriptExecutor; + + pub struct ShellExecutor { + root: ScriptExecutor, + } + + impl ShellExecutor { + pub async fn execute(&self, name: &str) -> anyhow::Result<()> { + let path = &self.root.project_path; + let script_path = path.join("scripts").join(format!("{name}.sh")); + + if !script_path.exists() { + anyhow::bail!("script was not found at: {}", script_path.display()); + } + + let mut cmd = tokio::process::Command::new(&script_path); + let cmd = cmd.current_dir(path); + cmd.stdin(Stdio::inherit()); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + + let mut proc = cmd.spawn().context(format!( + "failed to spawn process: {}", + script_path.display() + ))?; + + let exit = proc.wait().await?; + + if !exit.success() { + anyhow::bail!( + "command: {name} failed with status: {}", + exit.code().unwrap_or(-1) + ) + } + + Ok(()) + } + } + + impl From<&ScriptExecutor> for ShellExecutor { + fn from(value: &ScriptExecutor) -> Self { + Self { + root: value.clone(), + } + } + } +} + +#[derive(Clone)] +pub struct ScriptExecutor { + project_path: PathBuf, + ctx: Context, +} + +impl ScriptExecutor { + pub fn new(project_path: PathBuf, ctx: Context) -> Self { + Self { project_path, ctx } + } + + pub async fn run(&self, script_ctx: &Script, name: &str) -> anyhow::Result<()> { + match script_ctx { + Script::Shell {} => ShellExecutor::from(self).execute(name).await?, + } + + Ok(()) + } +} diff --git a/examples/project/forest.kdl b/examples/project/forest.kdl index fc8a035..12f96da 100644 --- a/examples/project/forest.kdl +++ b/examples/project/forest.kdl @@ -26,11 +26,8 @@ project { output "output/" } - scripts type=shell { - path "scripts/" - actions { - build - } + scripts { + hello type=shell {} } } diff --git a/examples/project/scripts/hello.sh b/examples/project/scripts/hello.sh new file mode 100755 index 0000000..6b22ac4 --- /dev/null +++ b/examples/project/scripts/hello.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env zsh + +set -e + +echo "hello world"