diff --git a/README.md b/README.md index 2ad6e96..ec30bbf 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ cleanup = [ # Hooks can also be run conditionally depending on a variable value. # If a hook is meant to fail, make sure to exit with a non 0 error code. # The files need to be executable, no restrictions otherwise. It can be python, bash, bat etc. +# Hooks are automatically ignored, no need to add them to the ignore array # pre-gen hooks are run after all the questions have been answered. This can be used for example to do more complex # validations @@ -232,6 +233,7 @@ You can use these like any other filter, e.g. `{{variable_name | camel_case}}`. - Templates with a `directory` field will now no longer include that directory name in the output - `copy_without_render` elements are now templated and refer to the template relative path if specified - Avoid path traversals in cleanup +- Add pre-gen and post-gen hooks ### 0.4.0 (2023-08-02) diff --git a/examples/hooks/do_stuff.py b/examples/hooks/do_stuff.py new file mode 100644 index 0000000..b16bf84 --- /dev/null +++ b/examples/hooks/do_stuff.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 + +print("This is a pre-gen hook where we don't have variables yet") \ No newline at end of file diff --git a/examples/hooks/greet.sh b/examples/hooks/greet.sh new file mode 100755 index 0000000..9e565c7 --- /dev/null +++ b/examples/hooks/greet.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +echo "Hello {{greeting_recipient}}!" \ No newline at end of file diff --git a/examples/hooks/template.toml b/examples/hooks/template.toml new file mode 100644 index 0000000..f8ecb5d --- /dev/null +++ b/examples/hooks/template.toml @@ -0,0 +1,26 @@ +name = "Super basic" +description = "A very simple template" +kickstart_version = 1 + +pre_gen_hooks = [ + { name = "can run python scripts", path = "do_stuff.py" }, +] + +post_gen_hooks = [ + { name = "greeting", path = "greet.sh" }, +] + +[[variables]] +name = "directory_name" +default = "Hello" +prompt = "Directory name?" + +[[variables]] +name = "file_name" +default = "Howdy" +prompt = "File name?" + +[[variables]] +name = "greeting_recipient" +default = "Vincent" +prompt = "Who am I greeting?" diff --git a/examples/hooks/{{directory_name}}/{{file_name}}.py b/examples/hooks/{{directory_name}}/{{file_name}}.py new file mode 100644 index 0000000..99b9220 --- /dev/null +++ b/examples/hooks/{{directory_name}}/{{file_name}}.py @@ -0,0 +1 @@ +print("Hello, {{greeting_recipient}}!") diff --git a/src/bin/kickstart.rs b/src/bin/kickstart.rs index 5303d49..fd9b006 100644 --- a/src/bin/kickstart.rs +++ b/src/bin/kickstart.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::error::Error; use std::path::PathBuf; +use std::process::Command as StdCommand; use clap::{Parser, Subcommand}; use tera::Context; @@ -35,6 +36,10 @@ pub struct Cli { #[clap(long, default_value_t = false)] pub no_input: bool, + /// Whether to run the hooks + #[clap(long, default_value_t = true)] + pub run_hooks: bool, + #[clap(subcommand)] pub command: Option, } @@ -143,7 +148,9 @@ macro_rules! bail_if_err { } fn execute_hook(hook: &HookFile) -> Result<()> { - todo!("write me") + terminal::bold(&format!(" - {}\n", hook.name())); + StdCommand::new(hook.path()).status().expect("sh command failed to start"); + Ok(()) } fn main() { @@ -164,24 +171,37 @@ fn main() { } } None => { - let template = bail_if_err!(Template::from_input( + let mut template = bail_if_err!(Template::from_input( &cli.template.unwrap(), cli.directory.as_deref() )); // 1. ask questions let variables = bail_if_err!(ask_questions(&template.definition, cli.no_input)); + template.set_variables(variables); // 2. run pre-gen hooks let pre_gen_hooks = bail_if_err!(template.get_pre_gen_hooks()); - if !pre_gen_hooks.is_empty() {} + if cli.run_hooks && !pre_gen_hooks.is_empty() { + terminal::bold("Running pre-gen hooks...\n"); + for hook in &pre_gen_hooks { + execute_hook(hook).expect("todo handle error") + } + println!(); + } // 3. generate - bail_if_err!(template.generate(&cli.output_dir, &variables)); + bail_if_err!(template.generate(&cli.output_dir)); // 4. run post-gen hooks let post_gen_hooks = bail_if_err!(template.get_post_gen_hooks()); - if !post_gen_hooks.is_empty() {} + if cli.run_hooks && !post_gen_hooks.is_empty() { + terminal::bold("Running post-gen hooks...\n"); + for hook in &post_gen_hooks { + execute_hook(hook).expect("todo handle error") + } + println!(); + } terminal::success("\nEverything done, ready to go!\n"); } diff --git a/src/definition.rs b/src/definition.rs index 22aa06c..4356ab1 100644 --- a/src/definition.rs +++ b/src/definition.rs @@ -95,6 +95,14 @@ pub struct TemplateDefinition { } impl TemplateDefinition { + pub(crate) fn all_hooks_paths(&self) -> Vec { + self.pre_gen_hooks + .iter() + .chain(self.post_gen_hooks.iter()) + .map(|h| format!("{}", h.path.display())) + .collect() + } + /// Returns the default values for all the variables that have one while following conditions /// TODO: probably remove that fn? see how to test things pub fn default_values(&self) -> Result> { diff --git a/src/generation.rs b/src/generation.rs index 99d8d77..437c2b8 100644 --- a/src/generation.rs +++ b/src/generation.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::env; use std::fs::{self, File}; use std::io::{Read, Write}; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; use std::str; @@ -22,19 +23,23 @@ use crate::utils::{ /// to the templated version #[derive(Debug)] pub struct HookFile { - original_path: PathBuf, + hook: Hook, file: NamedTempFile, } impl HookFile { + pub fn name(&self) -> &str { + &self.hook.name + } /// The hook original path in the template folder pub fn original_path(&self) -> &Path { - self.original_path.as_path() + &self.hook.path } - /// The rendered hook file, this is what you want to execute. - pub fn path(&self) -> &Path { - self.file.path() + /// The rendered hook canonicalized file path, this is what you want to execute. + pub fn path(&self) -> PathBuf { + // We _just_ created this file so it should exist and be fine + self.file.path().canonicalize().expect("should be able to canonicalize") } } @@ -123,13 +128,15 @@ impl Template { } // Then we will read the content of the file and run it through Tera - let content = read_file(&hook.path)?; + let content = read_file(&self.path.join(&hook.path))?; let rendered = render_one_off_template(&content, &context, Some(hook.path.clone()))?; // Then we save it in a temporary file let mut file = NamedTempFile::new()?; write!(file, "{}", rendered)?; - hooks_files.push(HookFile { file, original_path: hook.path.clone() }); + // TODO: how to make it work for windows + fs::set_permissions(file.path(), fs::Permissions::from_mode(0o755))?; + hooks_files.push(HookFile { file, hook: hook.clone() }); } Ok(hooks_files) @@ -150,16 +157,16 @@ impl Template { } /// Generate the template at the given output directory - pub fn generate(&self, output_dir: &Path, variables: &HashMap) -> Result<()> { - let output_dir = output_dir.canonicalize()?; + pub fn generate(&self, output_dir: &Path) -> Result<()> { let mut context = Context::new(); - for (key, val) in variables { + for (key, val) in &self.variables { context.insert(key, val); } if !output_dir.exists() { - create_directory(&output_dir)?; + create_directory(output_dir)?; } + let output_dir = output_dir.canonicalize()?; // Create the glob patterns of files to copy without rendering first, only once let mut patterns = Vec::with_capacity(self.definition.copy_without_render.len()); @@ -198,6 +205,8 @@ impl Template { }) .filter_map(|e| e.ok()); + let hooks_paths = self.definition.all_hooks_paths(); + 'outer: for entry in walker { // Skip root folder and the template.toml if entry.path() == self.path || entry.path() == self.path.join("template.toml") { @@ -215,6 +224,11 @@ impl Template { } } + // We automatically ignore hooks file + if hooks_paths.contains(&path_str) { + continue 'outer; + } + let path_str = path_str.replace("$$", "|"); let tpl = render_one_off_template(&path_str, &context, None)?; let real_path = output_dir.join(Path::new(&tpl)); @@ -248,7 +262,7 @@ impl Template { } for cleanup in &self.definition.cleanup { - if let Some(val) = variables.get(&cleanup.name) { + if let Some(val) = self.variables.get(&cleanup.name) { if *val == cleanup.value { for p in &cleanup.paths { let actual_path = render_one_off_template(p, &context, None)?; @@ -280,9 +294,9 @@ mod tests { #[test] fn can_generate_from_local_path() { let dir = tempdir().unwrap(); - let tpl = Template::from_input("examples/complex", None).unwrap(); - let res = - tpl.generate(&dir.path().to_path_buf(), &tpl.definition.default_values().unwrap()); + let mut tpl = Template::from_input("examples/complex", None).unwrap(); + tpl.set_variables(tpl.definition.default_values().unwrap()); + let res = tpl.generate(&dir.path().to_path_buf()); assert!(res.is_ok()); assert!(!dir.path().join("some-project").join("template.toml").exists()); @@ -292,9 +306,9 @@ mod tests { #[test] fn can_generate_from_local_path_with_directory() { let dir = tempdir().unwrap(); - let tpl = Template::from_input("examples/with-directory", None).unwrap(); - let res = - tpl.generate(&dir.path().to_path_buf(), &tpl.definition.default_values().unwrap()); + let mut tpl = Template::from_input("examples/with-directory", None).unwrap(); + tpl.set_variables(tpl.definition.default_values().unwrap()); + let res = tpl.generate(&dir.path().to_path_buf()); assert!(res.is_ok()); assert!(dir.path().join("template_root").join("Howdy.py").exists()); } @@ -302,9 +316,9 @@ mod tests { #[test] fn can_generate_from_local_path_with_directory_param() { let dir = tempdir().unwrap(); - let tpl = Template::from_input("./", Some("examples/complex")).unwrap(); - let res = - tpl.generate(&dir.path().to_path_buf(), &tpl.definition.default_values().unwrap()); + let mut tpl = Template::from_input("./", Some("examples/complex")).unwrap(); + tpl.set_variables(tpl.definition.default_values().unwrap()); + let res = tpl.generate(&dir.path().to_path_buf()); assert!(res.is_ok()); assert!(!dir.path().join("some-project").join("template.toml").exists()); assert!(dir.path().join("some-project").join("logo.png").exists()); @@ -313,9 +327,10 @@ mod tests { #[test] fn can_generate_from_remote_repo() { let dir = tempdir().unwrap(); - let tpl = Template::from_input("https://github.com/Keats/rust-cli-template", None).unwrap(); - let res = - tpl.generate(&dir.path().to_path_buf(), &tpl.definition.default_values().unwrap()); + let mut tpl = + Template::from_input("https://github.com/Keats/rust-cli-template", None).unwrap(); + tpl.set_variables(tpl.definition.default_values().unwrap()); + let res = tpl.generate(&dir.path().to_path_buf()); assert!(res.is_ok()); assert!(!dir.path().join("My-CLI").join("template.toml").exists()); @@ -325,11 +340,11 @@ mod tests { #[test] fn can_generate_from_remote_repo_with_directory() { let dir = tempdir().unwrap(); - let tpl = + let mut tpl = Template::from_input("https://github.com/Keats/kickstart", Some("examples/complex")) .unwrap(); - let res = - tpl.generate(&dir.path().to_path_buf(), &tpl.definition.default_values().unwrap()); + tpl.set_variables(tpl.definition.default_values().unwrap()); + let res = tpl.generate(&dir.path().to_path_buf()); assert!(res.is_ok()); assert!(!dir.path().join("some-project").join("template.toml").exists()); @@ -339,9 +354,9 @@ mod tests { #[test] fn can_generate_handling_slugify() { let dir = tempdir().unwrap(); - let tpl = Template::from_input("examples/slugify", None).unwrap(); - let res = - tpl.generate(&dir.path().to_path_buf(), &tpl.definition.default_values().unwrap()); + let mut tpl = Template::from_input("examples/slugify", None).unwrap(); + tpl.set_variables(tpl.definition.default_values().unwrap()); + let res = tpl.generate(&dir.path().to_path_buf()); assert!(res.is_ok()); assert!(!dir.path().join("template.toml").exists()); assert!(dir.path().join("hello.md").exists()); diff --git a/src/terminal.rs b/src/terminal.rs index 2d90d56..3bb77c4 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -8,6 +8,7 @@ pub fn error(message: &str) { Ok(_) => { write!(t, "{}", message).unwrap(); t.reset().unwrap(); + t.flush().unwrap(); } Err(_) => writeln!(t, "{}", message).unwrap(), }; @@ -23,6 +24,7 @@ pub fn success(message: &str) { Ok(_) => { write!(t, "{}", message).unwrap(); t.reset().unwrap(); + t.flush().unwrap(); } Err(_) => writeln!(t, "{}", message).unwrap(), }; @@ -38,6 +40,7 @@ pub fn bold(message: &str) { Ok(_) => { write!(t, "{}", message).unwrap(); t.reset().unwrap(); + t.flush().unwrap(); } Err(_) => write!(t, "{}", message).unwrap(), }; @@ -69,6 +72,7 @@ pub fn basic_question(prompt: &str, default: &T, validation: &O write!(t, "[default: {}]: ", default).unwrap(); } t.reset().unwrap(); + t.flush().unwrap(); } else { eprint!("{} [default: {}]: ", prompt, default); } @@ -94,6 +98,7 @@ pub fn bool_question(prompt: &str, default: bool) { write!(t, "[y/N]: ").unwrap() } t.reset().unwrap(); + t.flush().unwrap(); } else { eprint!("{} {}: ", prompt, default_str); }