Repository: cargo-generate/cargo-generate
Pull request #598 (2022-05-19 11:26:42)

Allow system commands
An attempt at #596.

I'm unsure if the command in Rhai should even try to return anything or if it should just "call and forget". But it might be useful to have the stdout (maybe even also stderr or whatever).

v-n-suadicani-issuu: Updated to fail template generation on non-zero status from system commands.
v-n-suadicani-issuu: @sassman Please have a look when you have time :)

Patch:
diff --git a/src/args.rs b/src/args.rs
index edf4845..575c6cc 100644
--- a/src/args.rs
+++ b/src/args.rs
@@ -125,6 +125,12 @@ pub struct Args {
     /// Will enforce a fresh git init on the generated project
     #[structopt(long)]
     pub force_git_init: bool,
+
+    /// Allows running system commands without being prompted.
+    /// Warning: Setting this flag will enable the template to run arbitrary system commands without user confirmation.
+    /// Use at your own risk and be sure to review the template code beforehand.
+    #[structopt(short, long)]
+    pub allow_commands: bool,
 }
 
 #[derive(Debug, StructOpt, Clone, Copy)]
diff --git a/src/hooks/file_mod.rs b/src/hooks/file_mod.rs
index cf0172f..d82a04d 100644
--- a/src/hooks/file_mod.rs
+++ b/src/hooks/file_mod.rs
@@ -1,10 +1,10 @@
 use console::style;
 use path_absolutize::Absolutize;
-use rhai::{Array, EvalAltResult, Module};
+use rhai::{Array, Module};
 use std::io::Write;
 use std::path::{Path, PathBuf};
 
-type Result<T> = anyhow::Result<T, Box<EvalAltResult>>;
+use super::HookResult;
 
 pub fn create_module(dir: &Path) -> Module {
     let dir = dir.to_owned();
@@ -13,7 +13,7 @@ pub fn create_module(dir: &Path) -> Module {
     module.set_native_fn("rename", {
         let dir = dir.clone();
 
-        move |from: &str, to: &str| -> Result<()> {
+        move |from: &str, to: &str| -> HookResult<()> {
             let from = to_absolute_path(&dir, from)?;
             let to = to_absolute_path(&dir, to)?;
             std::fs::rename(from, to).map_err(|e| e.to_string())?;
@@ -24,7 +24,7 @@ pub fn create_module(dir: &Path) -> Module {
     module.set_native_fn("delete", {
         let dir = dir.clone();
 
-        move |file: &str| -> Result<()> {
+        move |file: &str| -> HookResult<()> {
             let file = to_absolute_path(&dir, file)?;
             if file.is_file() {
                 std::fs::remove_file(file).map_err(|e| e.to_string())?;
@@ -38,7 +38,7 @@ pub fn create_module(dir: &Path) -> Module {
     module.set_native_fn("write", {
         let dir = dir.clone();
 
-        move |file: &str, content: &str| -> Result<()> {
+        move |file: &str, content: &str| -> HookResult<()> {
             let file = to_absolute_path(&dir, file)?;
             std::fs::write(file, content).map_err(|e| e.to_string())?;
             Ok(())
@@ -46,7 +46,7 @@ pub fn create_module(dir: &Path) -> Module {
     });
 
     module.set_native_fn("write", {
-        move |file: &str, content: Array| -> Result<()> {
+        move |file: &str, content: Array| -> HookResult<()> {
             let file = to_absolute_path(&dir, file)?;
             let mut file = std::fs::File::create(file).map_err(|e| e.to_string())?;
             for v in content.iter() {
@@ -60,7 +60,7 @@ pub fn create_module(dir: &Path) -> Module {
     module
 }
 
-fn to_absolute_path(base_dir: &Path, relative_path: &str) -> Result<PathBuf> {
+fn to_absolute_path(base_dir: &Path, relative_path: &str) -> HookResult<PathBuf> {
     let joined = base_dir.join(relative_path);
     Ok(joined
         .absolutize_virtually(base_dir)
diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs
index 8901d6b..c9e3726 100644
--- a/src/hooks/mod.rs
+++ b/src/hooks/mod.rs
@@ -9,8 +9,11 @@ use crate::config;
 use crate::emoji;
 
 mod file_mod;
+mod system_mod;
 mod variable_mod;
 
+type HookResult<T> = std::result::Result<T, Box<EvalAltResult>>;
+
 struct CleanupJob<F: FnOnce()>(Option<F>);
 
 impl<F: FnOnce()> CleanupJob<F> {
@@ -29,8 +32,10 @@ pub fn execute_pre_hooks(
     dir: &Path,
     liquid_object: Rc<RefCell<liquid::Object>>,
     template_cfg: &mut config::Config,
+    allow_commands: bool,
+    silent: bool,
 ) -> Result<()> {
-    let engine = create_rhai_engine(dir, liquid_object);
+    let engine = create_rhai_engine(dir, liquid_object, allow_commands, silent);
     evaluate_scripts(dir, &template_cfg.get_pre_hooks(), engine)
 }
 
@@ -38,8 +43,10 @@ pub fn execute_post_hooks(
     dir: &Path,
     liquid_object: Rc<RefCell<liquid::Object>>,
     template_cfg: &config::Config,
+    allow_commands: bool,
+    silent: bool,
 ) -> Result<()> {
-    let engine = create_rhai_engine(dir, liquid_object);
+    let engine = create_rhai_engine(dir, liquid_object, allow_commands, silent);
     evaluate_scripts(dir, &template_cfg.get_post_hooks(), engine)
 }
 
@@ -65,7 +72,12 @@ fn evaluate_scripts(dir: &Path, scripts: &[String], engine: rhai::Engine) -> Res
     Ok(())
 }
 
-fn create_rhai_engine(dir: &Path, liquid_object: Rc<RefCell<liquid::Object>>) -> rhai::Engine {
+fn create_rhai_engine(
+    dir: &Path,
+    liquid_object: Rc<RefCell<liquid::Object>>,
+    allow_commands: bool,
+    silent: bool,
+) -> rhai::Engine {
     let mut engine = rhai::Engine::new();
 
     let module = variable_mod::create_module(liquid_object);
@@ -74,10 +86,12 @@ fn create_rhai_engine(dir: &Path, liquid_object: Rc<RefCell<liquid::Object>>) ->
     let module = file_mod::create_module(dir);
     engine.register_static_module("file", module.into());
 
-    engine.register_result_fn(
-        "abort",
-        |error: &str| -> Result<String, Box<EvalAltResult>> { Err(error.into()) },
-    );
+    let module = system_mod::create_module(allow_commands, silent);
+    engine.register_static_module("system", module.into());
+
+    engine.register_result_fn("abort", |error: &str| -> HookResult<String> {
+        Err(error.into())
+    });
 
     engine
 }
diff --git a/src/hooks/system_mod.rs b/src/hooks/system_mod.rs
new file mode 100644
index 0000000..5da7232
--- /dev/null
+++ b/src/hooks/system_mod.rs
@@ -0,0 +1,87 @@
+use rhai::{Dynamic, Module};
+use std::process::Command;
+
+use crate::{
+    interactive::prompt_for_variable,
+    project_variables::{StringEntry, TemplateSlots, VarInfo},
+};
+
+use super::HookResult;
+
+/// Creates the system module, containing the `command` function,
+/// which allows you to run system command.
+pub fn create_module(allow_commands: bool, silent: bool) -> Module {
+    let mut module = Module::new();
+
+    module.set_native_fn("command", move |name: &str, commands_args: rhai::Array| {
+        run_command(name, commands_args, allow_commands, silent)
+    });
+
+    module
+}
+
+fn run_command(
+    name: &str,
+    args: rhai::Array,
+    allow_commands: bool,
+    silent: bool,
+) -> HookResult<Dynamic> {
+    let args: Vec<String> = args.into_iter().map(|arg| arg.to_string()).collect();
+
+    // If --allow-commands is false, we need to prompt. But we shouldn't if we're in silent mode.
+    if !allow_commands && silent {
+        return Err("Cannot prompt for system command confirmation in silent mode. Use --allow-commands if you want to allow the template to run system commands in silent mode.".into());
+    }
+
+    let full_command = if args.is_empty() {
+        name.into()
+    } else {
+        format!("{name} {}", args.join(" "))
+    };
+
+    // If the user specified the --allow-commands flag, don't prompt.
+    let should_run = allow_commands
+        || {
+            let prompt = format!("The template is requesting to run the following command. Do you agree?\n{full_command}");
+
+            // Prompt the user for whether they actually want to run the command.
+            let value = prompt_for_variable(&TemplateSlots {
+                prompt,
+                var_name: "".into(),
+                var_info: VarInfo::String {
+                    entry: Box::new(StringEntry {
+                        default: Some("no".into()),
+                        choices: Some(vec!["yes".into(), "no".into()]),
+                        regex: None,
+                    }),
+                },
+            });
+
+            // Only accept clearly positive affirmations.
+            matches!(
+                value.map(|s| s.trim().to_ascii_lowercase()).as_deref(),
+                Ok("yes" | "y")
+            )
+        };
+
+    if !should_run {
+        return Err(format!("User denied execution of system command `{full_command}`.").into());
+    }
+
+    let output = Command::new(name).args(args).output();
+
+    match output {
+        Ok(output) => {
+            if output.status.success() {
+                Ok(Dynamic::UNIT)
+            } else {
+                Err(format!(
+                    "System command `{full_command}` returned non-zero status: {}",
+                    output.status
+                )
+                .into())
+            }
+        }
+        Err(e) => Err(format!("System command `{full_command}` failed to execute: {e}").into()),
+    }
+}
diff --git a/src/hooks/variable_mod.rs b/src/hooks/variable_mod.rs
index 5c5ab3f..d5b8265 100644
--- a/src/hooks/variable_mod.rs
+++ b/src/hooks/variable_mod.rs
@@ -1,21 +1,21 @@
 use liquid::{Object, ValueView};
 use liquid_core::Value;
 use regex::Regex;
-use rhai::{Array, Dynamic, EvalAltResult, Module};
+use rhai::{Array, Dynamic, Module};
 use std::cell::RefCell;
 use std::rc::Rc;
 
 use crate::interactive::prompt_for_variable;
 use crate::project_variables::{StringEntry, TemplateSlots, VarInfo};
 
-type Result<T> = anyhow::Result<T, Box<EvalAltResult>>;
+use super::HookResult;
 
 pub fn create_module(liquid_object: Rc<RefCell<Object>>) -> Module {
     let mut module = Module::new();
 
     module.set_native_fn("is_set", {
         let liquid_object = liquid_object.clone();
-        move |name: &str| -> Result<bool> {
+        move |name: &str| -> HookResult<bool> {
             match liquid_object.get_value(name) {
                 NamedValue::NonExistant => Ok(false),
                 _ => Ok(true),
@@ -25,7 +25,7 @@ pub fn create_module(liquid_object: Rc<RefCell<Object>>) -> Module {
 
     module.set_native_fn("get", {
         let liquid_object = liquid_object.clone();
-        move |name: &str| -> Result<Dynamic> {
+        move |name: &str| -> HookResult<Dynamic> {
             match liquid_object.get_value(name) {
                 NamedValue::NonExistant => Ok(Dynamic::from(String::from(""))),
                 NamedValue::Bool(v) => Ok(Dynamic::from(v)),
@@ -37,7 +37,7 @@ pub fn create_module(liquid_object: Rc<RefCell<Object>>) -> Module {
     module.set_native_fn("set", {
         let liquid_object = liquid_object.clone();
 
-        move |name: &str, value: &str| -> Result<()> {
+        move |name: &str, value: &str| -> HookResult<()> {
             match liquid_object.get_value(name) {
                 NamedValue::NonExistant | NamedValue::String(_) => {
                     liquid_object.borrow_mut().insert(
@@ -54,7 +54,7 @@ pub fn create_module(liquid_object: Rc<RefCell<Object>>) -> Module {
     module.set_native_fn("set", {
         let liquid_object = liquid_object.clone();
 
-        move |name: &str, value: bool| -> Result<()> {
+        move |name: &str, value: bool| -> HookResult<()> {
             match liquid_object.get_value(name) {
                 NamedValue::NonExistant | NamedValue::Bool(_) => {
                     liquid_object
@@ -68,7 +68,7 @@ pub fn create_module(liquid_object: Rc<RefCell<Object>>) -> Module {
     });
 
     module.set_native_fn("set", {
-        move |name: &str, value: Array| -> Result<()> {
+        move |name: &str, value: Array| -> HookResult<()> {
             match liquid_object.get_value(name) {
                 NamedValue::NonExistant => {
                     let val = rhai_to_liquid_value(Dynamic::from(value))?;
@@ -83,7 +83,7 @@ pub fn create_module(liquid_object: Rc<RefCell<Object>>) -> Module {
     });
 
     module.set_native_fn("prompt", {
-        move |prompt: &str, default_value: bool| -> Result<bool> {
+        move |prompt: &str, default_value: bool| -> HookResult<bool> {
             let value = prompt_for_variable(&TemplateSlots {
                 prompt: prompt.into(),
                 var_name: "".into(),
@@ -100,7 +100,7 @@ pub fn create_module(liquid_object: Rc<RefCell<Object>>) -> Module {
     });
 
     module.set_native_fn("prompt", {
-        move |prompt: &str| -> Result<String> {
+        move |prompt: &str| -> HookResult<String> {
             let value = prompt_for_variable(&TemplateSlots {
                 prompt: prompt.into(),
                 var_name: "".into(),
@@ -121,7 +121,7 @@ pub fn create_module(liquid_object: Rc<RefCell<Object>>) -> Module {
     });
 
     module.set_native_fn("prompt", {
-        move |prompt: &str, default_value: &str| -> Result<String> {
+        move |prompt: &str, default_value: &str| -> HookResult<String> {
             let value = prompt_for_variable(&TemplateSlots {
                 prompt: prompt.into(),
                 var_name: "".into(),
@@ -142,7 +142,7 @@ pub fn create_module(liquid_object: Rc<RefCell<Object>>) -> Module {
     });
 
     module.set_native_fn("prompt", {
-        move |prompt: &str, default_value: &str, regex: &str| -> Result<String> {
+        move |prompt: &str, default_value: &str, regex: &str| -> HookResult<String> {
             let value = prompt_for_variable(&TemplateSlots {
                 prompt: prompt.into(),
                 var_name: "".into(),
@@ -163,7 +163,7 @@ pub fn create_module(liquid_object: Rc<RefCell<Object>>) -> Module {
     });
 
     module.set_native_fn("prompt", {
-        move |prompt: &str, default_value: &str, choices: rhai::Array| -> Result<String> {
+        move |prompt: &str, default_value: &str, choices: rhai::Array| -> HookResult<String> {
             let value = prompt_for_variable(&TemplateSlots {
                 prompt: prompt.into(),
                 var_name: "".into(),
@@ -221,7 +221,7 @@ impl GetNamedValue for Rc<RefCell<Object>> {
     }
 }
 
-fn rhai_to_liquid_value(val: Dynamic) -> Result<Value> {
+fn rhai_to_liquid_value(val: Dynamic) -> HookResult<Value> {
     val.as_bool()
         .map(Into::into)
         .map(Value::Scalar)
@@ -239,7 +239,7 @@ fn rhai_to_liquid_value(val: Dynamic) -> Result<Value> {
                 .and_then(|arr| {
                     arr.into_iter()
                         .map(rhai_to_liquid_value)
-                        .collect::<Result<_>>()
+                        .collect::<HookResult<_>>()
                         .map(Value::Array)
                 })
         })
diff --git a/src/lib.rs b/src/lib.rs
index a55068b..d8b0e6f 100755
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -457,7 +457,13 @@ fn expand_template(
 
     let mut liquid_object = Rc::new(RefCell::new(liquid_object));
 
-    execute_pre_hooks(dir, Rc::clone(&liquid_object), &mut template_config)?;
+    execute_pre_hooks(
+        dir,
+        Rc::clone(&liquid_object),
+        &mut template_config,
+        args.allow_commands,
+        args.silent,
+    )?;
     ignore_me::remove_unneeded_files(dir, &template_cfg.ignore, args.verbose)?;
     let mut pbar = progressbar::new();
 
@@ -473,7 +479,13 @@ fn expand_template(
         &mut pbar,
     )?;
 
-    execute_post_hooks(dir, Rc::clone(&liquid_object), &template_config)?;
+    execute_post_hooks(
+        dir,
+        Rc::clone(&liquid_object),
+        &template_config,
+        args.allow_commands,
+        args.silent,
+    )?;
     remove_dir_files(all_hook_files, false);
 
     pbar.join().unwrap();


Test patch:
diff --git a/tests/integration/hooks.rs b/tests/integration/hooks.rs
index a3c2d00..492a279 100644
--- a/tests/integration/hooks.rs
+++ b/tests/integration/hooks.rs
@@ -6,7 +6,7 @@ use predicates::str::PredicateStrExt;
 use crate::helpers::{project::binary, project_builder::tmp_dir};
 
 #[test]
-fn it_runs_scripts() {
+fn it_runs_all_hook_types() {
     let template = tmp_dir()
         .file(
             "pre-script.rhai",
@@ -20,6 +20,12 @@ fn it_runs_scripts() {
             file::rename("POST-TEST", "POST");
         "#},
         )
+        .file(
+            "system-script.rhai",
+            indoc! {r#"
+                let output = system::command("touch", ["touched_file"]);
+            "#},
+        )
         .file(
             "PRE-TEST",
             indoc! {r#"
@@ -40,7 +46,7 @@ fn it_runs_scripts() {
 
             [hooks]
             pre = ["pre-script.rhai"]
-            post = ["post-script.rhai"]
+            post = ["post-script.rhai", "system-script.rhai"]
             "#},
         )
         .init_git()
@@ -58,6 +64,7 @@ fn it_runs_scripts() {
         .arg("pre=hello")
         .arg("-d")
         .arg("post=world")
+        .arg("--allow-commands")
         .current_dir(&dir.path())
         .assert()
         .success()
@@ -66,6 +73,157 @@ fn it_runs_scripts() {
     assert!(dir.exists("script-project/PRE"));
     assert!(dir.exists("script-project/POST"));
 
+    assert!(dir.exists("script-project/touched_file"));
+
     assert!(dir.read("script-project/PRE").contains("hello"));
     assert!(dir.read("script-project/POST").contains("world"));
 }
+
+#[test]
+fn it_runs_system_commands() {
+    let template = tmp_dir()
+        .file(
+            "system-script.rhai",
+            indoc! {r#"
+                let output = system::command("touch", ["touched_file"]);
+            "#},
+        )
+        .file(
+            "cargo-generate.toml",
+            indoc! {r#"
+            [hooks]
+            post = ["system-script.rhai"]
+            "#},
+        )
+        .init_git()
+        .build();
+
+    let dir = tmp_dir().build();
+
+    binary()
+        .arg("gen")
+        .arg("--git")
+        .arg(template.path())
+        .arg("-n")
+        .arg("script-project")
+        .arg("--allow-commands")
+        .current_dir(&dir.path())
+        .assert()
+        .success()
+        .stdout(predicates::str::contains("Done!").from_utf8());
+
+    assert!(dir.exists("script-project/touched_file"));
+}
+
+#[test]
+fn it_fails_to_prompt_for_system_commands_in_silent_mode() {
+    let template = tmp_dir()
+        .file(
+            "system-script.rhai",
+            indoc! {r#"
+                let output = system::command("touch", ["touched_file"]);
+            "#},
+        )
+        .file(
+            "cargo-generate.toml",
+            indoc! {r#"
+            [hooks]
+            post = ["system-script.rhai"]
+            "#},
+        )
+        .init_git()
+        .build();
+
+    let dir = tmp_dir().build();
+
+    binary()
+        .arg("gen")
+        .arg("--git")
+        .arg(template.path())
+        .arg("-n")
+        .arg("script-project")
+        .arg("--silent")
+        .current_dir(&dir.path())
+        .assert()
+        .failure()
+        // The error message should instruct the user on how to proceed with silent mode (i.e. by setting the allow flag).
+        .stderr(predicates::str::contains("--allow-commands").from_utf8());
+}
+
+#[test]
+fn it_fails_when_a_system_command_returns_non_zero_exit_code() {
+    let template = tmp_dir()
+        .file(
+            "system-script.rhai",
+            indoc! {r#"
+                let output = system::command("mkdir", ["invalid_/.dir_name"]);
+            "#},
+        )
+        .file(
+            "cargo-generate.toml",
+            indoc! {r#"
+            [hooks]
+            post = ["system-script.rhai"]
+            "#},
+        )
+        .init_git()
+        .build();
+
+    let dir = tmp_dir().build();
+
+    binary()
+        .arg("gen")
+        .arg("--git")
+        .arg(template.path())
+        .arg("-n")
+        .arg("script-project")
+        .arg("--allow-commands")
+        .current_dir(&dir.path())
+        .assert()
+        .failure()
+        .stderr(
+            predicates::str::contains(
+                "System command `mkdir invalid_/.dir_name` returned non-zero status",
+            )
+            .from_utf8(),
+        );
+}
+
+#[test]
+fn it_fails_when_it_cant_execute_system_command() {
+    let template = tmp_dir()
+        .file(
+            "system-script.rhai",
+            indoc! {r#"
+                let output = system::command("dummy_command_that_doesn't_exist", ["dummy_arg"]);
+            "#},
+        )
+        .file(
+            "cargo-generate.toml",
+            indoc! {r#"
+            [hooks]
+            post = ["system-script.rhai"]
+            "#},
+        )
+        .init_git()
+        .build();
+
+    let dir = tmp_dir().build();
+
+    binary()
+        .arg("gen")
+        .arg("--git")
+        .arg(template.path())
+        .arg("-n")
+        .arg("script-project")
+        .arg("--allow-commands")
+        .current_dir(&dir.path())
+        .assert()
+        .failure()
+        .stderr(
+            predicates::str::contains(
+                "System command `dummy_command_that_doesn't_exist dummy_arg` failed to execute",
+            )
+            .from_utf8(),
+        );
+}
diff --git a/tests/integration/library.rs b/tests/integration/library.rs
index 78f848d..5b197ae 100644
--- a/tests/integration/library.rs
+++ b/tests/integration/library.rs
@@ -37,6 +37,7 @@ version = "0.1.0"
         define: vec![],
         init: false,
         force_git_init: false,
+        allow_commands: false,
     };
     // need to cd to the dir as we aren't running in the cargo shell.
     assert!(std::env::set_current_dir(&dir.root).is_ok());