feat: rename cuddle_cli -> cuddle

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2023-08-12 21:41:00 +02:00
parent 53b7513ceb
commit 2cd9509fcb
14 changed files with 3 additions and 3 deletions

384
cuddle/src/cli/mod.rs Normal file
View File

@@ -0,0 +1,384 @@
mod subcommands;
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use clap::Command;
use crate::{
actions::CuddleAction,
config::{CuddleConfig, CuddleFetchPolicy},
context::{CuddleContext, CuddleTreeType},
model::*,
util::git::GitCommit,
};
use self::subcommands::render_template::RenderTemplateCommand;
#[derive(Debug, Clone)]
pub struct CuddleCli {
scripts: Vec<CuddleAction>,
variables: Vec<CuddleVariable>,
context: Option<Arc<Mutex<Vec<CuddleContext>>>>,
command: Option<Command>,
tmp_dir: Option<PathBuf>,
config: CuddleConfig,
}
impl CuddleCli {
pub fn new(
context: Option<Arc<Mutex<Vec<CuddleContext>>>>,
config: CuddleConfig,
) -> anyhow::Result<CuddleCli> {
let mut cli = CuddleCli {
scripts: vec![],
variables: vec![],
context: context.clone(),
command: None,
tmp_dir: None,
config,
};
if let Ok(provider) = std::env::var("CUDDLE_SECRETS_PROVIDER") {
let provider = provider
.split(",")
.map(|p| p.to_string())
.collect::<Vec<_>>();
tracing::trace!("secrets-provider enabled, handling for each entry");
handle_providers(provider)?;
std::thread::sleep(std::time::Duration::from_millis(100));
}
match context {
Some(_) => {
tracing::debug!("build full cli");
cli = cli
.process_variables()
.process_scripts()
.process_templates()?
.build_cli();
}
None => {
tracing::debug!("build bare cli");
cli = cli.build_bare_cli();
}
}
Ok(cli)
}
fn process_variables(mut self) -> Self {
if let Ok(context_iter) = self.context.clone().unwrap().lock() {
for ctx in context_iter.iter() {
if let Some(variables) = ctx.plan.vars.clone() {
for (name, var) in variables {
self.variables.push(CuddleVariable::new(name, var))
}
}
if let CuddleTreeType::Root = ctx.node_type {
let mut temp_path = ctx.path.clone();
temp_path.push(".cuddle/tmp");
self.variables.push(CuddleVariable::new(
"tmp".into(),
temp_path.clone().to_string_lossy().to_string(),
));
self.tmp_dir = Some(temp_path);
}
}
}
match GitCommit::new() {
Ok(commit) => self.variables.push(CuddleVariable::new(
"commit_sha".into(),
commit.commit_sha.clone(),
)),
Err(e) => {
log::debug!("{}", e);
}
}
self
}
fn process_scripts(mut self) -> Self {
if let Ok(context_iter) = self.context.clone().unwrap().lock() {
for ctx in context_iter.iter() {
if let Some(scripts) = ctx.plan.scripts.clone() {
for (name, script) in scripts {
match &script {
CuddleScript::Shell(shell_script) => {
self.scripts.push(CuddleAction::new(
script.clone(),
ctx.path.clone(),
name,
shell_script.description.clone(),
))
}
CuddleScript::Dagger(_) => todo!(),
CuddleScript::Lua(l) => self.scripts.push(CuddleAction::new(
script.clone(),
ctx.path.clone(),
name,
l.description.clone(),
)),
}
}
}
}
}
self
}
fn process_templates(self) -> anyhow::Result<Self> {
if let None = self.tmp_dir {
log::debug!("cannot process template as bare bones cli");
return Ok(self);
}
// Make sure tmp_dir exists and clean it up first
let tmp_dir = self
.tmp_dir
.clone()
.ok_or(anyhow::anyhow!("tmp_dir does not exist aborting"))?;
match self.config.get_fetch_policy()? {
CuddleFetchPolicy::Always => {
if tmp_dir.exists() && tmp_dir.ends_with("tmp") {
std::fs::remove_dir_all(tmp_dir.clone())?;
}
}
_ => {}
}
std::fs::create_dir_all(tmp_dir.clone())?;
// Handle all templating with variables and such.
// TODO: use actual templating engine, for new we just copy templates to the final folder
if let Ok(context_iter) = self.context.clone().unwrap().lock() {
for ctx in context_iter.iter() {
let mut template_path = ctx.path.clone();
template_path.push("templates");
log::trace!("template path: {}", template_path.clone().to_string_lossy());
if !template_path.exists() {
continue;
}
for file in std::fs::read_dir(template_path)?.into_iter() {
let f = file?;
let mut dest_file = tmp_dir.clone();
dest_file.push(f.file_name());
std::fs::copy(f.path(), dest_file)?;
}
}
}
Ok(self)
}
fn build_cli(mut self) -> Self {
let mut root_cmd = Command::new("cuddle")
.version("1.0")
.author("kjuulh <contact@kasperhermansen.com>")
.about("cuddle is your domain specific organization tool. It enabled widespread sharing through repositories, as well as collaborating while maintaining speed and integrity")
.subcommand_required(true)
.arg_required_else_help(true)
.propagate_version(true)
.arg(clap::Arg::new("secrets-provider").long("secrets-provider").env("CUDDLE_SECRETS_PROVIDER"));
root_cmd = subcommands::x::build_command(root_cmd, self.clone());
root_cmd = subcommands::render_template::build_command(root_cmd);
root_cmd = subcommands::init::build_command(root_cmd, self.clone());
self.command = Some(root_cmd);
self
}
fn build_bare_cli(mut self) -> Self {
let mut root_cmd = Command::new("cuddle")
.version("1.0")
.author("kjuulh <contact@kasperhermansen.com>")
.about("cuddle is your domain specific organization tool. It enabled widespread sharing through repositories, as well as collaborating while maintaining speed and integrity")
.subcommand_required(true)
.arg_required_else_help(true)
.propagate_version(true);
root_cmd = subcommands::init::build_command(root_cmd, self.clone());
self.command = Some(root_cmd);
self
}
pub fn execute(self) -> anyhow::Result<Self> {
if let Some(cli) = self.command.clone() {
let matches = cli.clone().get_matches();
let res = match matches.subcommand() {
Some(("x", exe_submatch)) => subcommands::x::execute_x(exe_submatch, self.clone()),
Some(("render_template", sub_matches)) => {
RenderTemplateCommand::from_matches(sub_matches, self.clone())
.and_then(|cmd| cmd.execute())?;
Ok(())
}
Some(("init", sub_matches)) => {
subcommands::init::execute_init(sub_matches, self.clone())
}
_ => Err(anyhow::anyhow!("could not find a match")),
};
match res {
Ok(()) => {}
Err(e) => {
return Err(e);
}
}
}
Ok(self)
}
}
pub enum SecretProvider {
OnePassword {
inject: Vec<String>,
dotenv: Option<String>,
},
}
impl TryFrom<String> for SecretProvider {
type Error = anyhow::Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.as_str() {
"1password" => {
let one_password_inject = std::env::var("CUDDLE_ONE_PASSWORD_INJECT")
.ok()
.filter(|f| f.as_str() != "");
let one_password_dot_env = std::env::var("CUDDLE_ONE_PASSWORD_DOT_ENV").ok();
let injectables = one_password_inject
.unwrap_or(String::new())
.split(",")
.filter(|s| s.contains('='))
.map(|i| i.to_string())
.collect::<Vec<_>>();
// for i in &injectables {
// if !std::path::PathBuf::from(i).exists() {
// anyhow::bail!("1pass injectable path doesn't exist: {}", i);
// }
// }
if let Some(one_password_dot_env) = &one_password_dot_env {
if let Ok(dir) = std::env::current_dir() {
tracing::trace!(
current_dir = dir.display().to_string(),
dotenv = &one_password_dot_env,
exists = PathBuf::from(&one_password_dot_env).exists(),
"1password dotenv inject"
);
}
}
Ok(Self::OnePassword {
inject: injectables,
dotenv: if let Some(one_password_dot_env) = one_password_dot_env {
if PathBuf::from(&one_password_dot_env).exists() {
Some(one_password_dot_env)
} else {
None
}
} else {
None
},
})
}
value => {
tracing::debug!(
"provided secrets manager doesn't match any allowed values {}",
value
);
Err(anyhow::anyhow!("value is not one of supported values"))
}
}
}
}
fn handle_providers(provider: Vec<String>) -> anyhow::Result<()> {
fn execute_1password(lookup: &str) -> anyhow::Result<String> {
let out = std::process::Command::new("op")
.arg("read")
.arg(lookup)
.output()?;
let secret = std::str::from_utf8(&out.stdout)?;
Ok(secret.to_string())
}
fn execute_1password_inject(file: &str) -> anyhow::Result<Vec<(String, String)>> {
let out = std::process::Command::new("op")
.arg("inject")
.arg("--in-file")
.arg(file)
.output()?;
let secrets = std::str::from_utf8(&out.stdout)?.split('\n');
let secrets_pair = secrets
.map(|secrets_pair| secrets_pair.split_once("="))
.flatten()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect::<Vec<(String, String)>>();
Ok(secrets_pair)
}
let res = provider
.into_iter()
.map(|p| SecretProvider::try_from(p))
.collect::<anyhow::Result<Vec<_>>>();
let res = res?;
let res = res
.into_iter()
.map(|p| match p {
SecretProvider::OnePassword { inject, dotenv } => {
tracing::trace!(
inject = inject.join(","),
dotenv = dotenv,
"handling 1password"
);
if let Some(dotenv) = dotenv {
let pairs = execute_1password_inject(&dotenv).unwrap();
for (key, value) in pairs {
tracing::debug!(env_name = &key, value=&value, "set var from 1password");
std::env::set_var(key, value);
}
}
for i in inject {
let (env_var_name, op_lookup) = i.split_once("=").ok_or(anyhow::anyhow!(
"CUDDLE_ONE_PASSWORD_INJECT is not a key value pair ie. key:value,key2=value2"
))?;
let secret = execute_1password(&op_lookup)?;
std::env::set_var(&env_var_name, secret);
tracing::debug!(
env_name = &env_var_name,
lookup = &op_lookup,
"set var from 1password"
);
}
Ok(())
}
})
.collect::<anyhow::Result<Vec<()>>>();
let _ = res?;
Ok(())
}

View File

@@ -0,0 +1,218 @@
use std::collections::BTreeMap;
use std::fs::{create_dir_all, read, read_dir};
use std::io::Write;
use std::path::PathBuf;
use clap::{ArgMatches, Command};
use walkdir::WalkDir;
use crate::cli::CuddleCli;
pub fn build_command(root_cmd: Command, _cli: CuddleCli) -> Command {
let mut repo_url = clap::Arg::new("repo").long("repo").short('r');
if let Ok(cuddle_template_url) = std::env::var("CUDDLE_TEMPLATE_URL") {
repo_url = repo_url.default_value(cuddle_template_url);
} else {
repo_url = repo_url.required(true);
}
let execute_cmd = Command::new("init")
.about("init bootstraps a repository from a template")
.arg(repo_url)
.arg(clap::Arg::new("name").long("name"))
.arg(clap::Arg::new("path").long("path"))
.arg(clap::Arg::new("value").short('v').long("value"));
root_cmd.subcommand(execute_cmd)
}
pub fn execute_init(exe_submatch: &ArgMatches, _cli: CuddleCli) -> anyhow::Result<()> {
let repo = exe_submatch.get_one::<String>("repo").unwrap();
let name = exe_submatch.get_one::<String>("name");
let path = exe_submatch.get_one::<String>("path");
let values = exe_submatch
.get_many::<String>("value")
.unwrap_or_default()
.collect::<Vec<_>>();
tracing::info!("Downloading: {}", repo);
create_dir_all(std::env::temp_dir())?;
let tmpdir = tempfile::tempdir()?;
let tmpdir_path = tmpdir.path().canonicalize()?;
let output = std::process::Command::new("git")
.args(&["clone", repo, "."])
.current_dir(tmpdir_path)
.output()?;
std::io::stdout().write_all(&output.stdout)?;
std::io::stderr().write_all(&output.stderr)?;
let templates_path = tmpdir.path().join("cuddle-templates.json");
let template_path = tmpdir.path().join("cuddle-template.json");
let templates = if templates_path.exists() {
let templates = read(templates_path)?;
let templates: CuddleTemplates = serde_json::from_slice(&templates)?;
let mut single_templates = Vec::new();
for template_name in templates.templates.iter() {
let template = read(
tmpdir
.path()
.join(template_name)
.join("cuddle-template.json"),
)?;
let template = serde_json::from_slice::<CuddleTemplate>(&template)?;
single_templates.push((template_name, template))
}
single_templates
.into_iter()
.map(|(name, template)| (name.clone(), tmpdir.path().join(name), template))
.collect::<Vec<_>>()
} else if template_path.exists() {
let template = read(template_path)?;
let template = serde_json::from_slice::<CuddleTemplate>(&template)?;
vec![(template.clone().name, tmpdir.path().to_path_buf(), template)]
} else {
anyhow::bail!("No cuddle-template.json or cuddle-templates.json found");
};
let template = match name {
Some(name) => {
let template = read(tmpdir.path().join(name).join("cuddle-template.json"))?;
let template = serde_json::from_slice::<CuddleTemplate>(&template)?;
Ok((name.clone(), tmpdir.path().join(name), template))
}
None => {
if templates.len() > 1 {
let name = inquire::Select::new(
"template",
templates.iter().map(|t| t.0.clone()).collect(),
)
.with_help_message("name of which template to use")
.prompt()?;
let found_template = templates
.iter()
.find(|item| item.0 == name)
.ok_or(anyhow::anyhow!("could not find an item with that name"))?;
Ok(found_template.clone())
} else if templates.len() == 1 {
Ok(templates[0].clone())
} else {
Err(anyhow::anyhow!("No templates found, with any valid names"))
}
}
};
let (_name, template_dir, mut template) = template?;
let path = match path {
Some(path) => path.clone(),
None => inquire::Text::new("path")
.with_help_message("to where it should be placed")
.with_default(".")
.prompt()?,
};
create_dir_all(&path)?;
let dir = std::fs::read_dir(&path)?;
if dir.count() != 0 {
for entry in read_dir(&path)? {
let entry = entry?;
if entry.file_name() == ".git" {
continue;
} else {
anyhow::bail!("Directory {} is not empty", &path);
}
}
}
{
if let Some(ref mut prompt) = template.prompt {
'prompt: for (name, prompt) in prompt {
for value in &values {
if let Some((value_name, value_content)) = value.split_once("=") {
if value_name == name {
prompt.value = value_content.to_string();
continue 'prompt;
}
}
}
let value = inquire::Text::new(&name)
.with_help_message(&prompt.description)
.prompt()?;
prompt.value = value;
}
}
}
for entry in WalkDir::new(&template_dir).follow_links(false) {
let entry = entry?;
let entry_path = entry.path();
let new_path = PathBuf::from(&path).join(entry_path.strip_prefix(&template_dir)?);
let new_path = replace_with_variables(&new_path.to_string_lossy().to_string(), &template)?;
let new_path = PathBuf::from(new_path);
if entry_path.is_dir() {
create_dir_all(&new_path)?;
}
if entry_path.is_file() {
let name = entry.file_name();
if let Some(parent) = entry_path.parent() {
create_dir_all(parent)?;
}
if name == "cuddle-template.json" || name == "cuddle-templates.json" {
continue;
}
tracing::info!("writing to: {}", new_path.display());
let new_content =
replace_with_variables(&std::fs::read_to_string(entry_path)?, &template)?;
std::fs::write(new_path, new_content.as_bytes())?;
}
}
Ok(())
}
fn replace_with_variables(content: &str, template: &CuddleTemplate) -> anyhow::Result<String> {
let mut content = content.to_string();
if let Some(prompt) = &template.prompt {
for (name, value) in prompt {
content = content.replace(&format!("%%{}%%", name), &value.value);
}
}
Ok(content)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CuddleTemplates {
pub templates: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CuddleTemplate {
pub name: String,
pub prompt: Option<BTreeMap<String, CuddleTemplatePrompt>>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CuddleTemplatePrompt {
pub description: String,
#[serde(skip)]
pub value: String,
}

View File

@@ -0,0 +1,3 @@
pub mod init;
pub mod render_template;
pub mod x;

View File

@@ -0,0 +1,150 @@
use std::{path::PathBuf, str::FromStr};
use clap::{Arg, ArgMatches, Command};
use crate::{cli::CuddleCli, model::CuddleVariable};
pub fn build_command(root_cmd: Command) -> Command {
root_cmd.subcommand(
Command::new("render_template")
.about("renders a jinja compatible template")
.args(&[
Arg::new("template-file")
.alias("template")
.short('t')
.long("template-file")
.required(true)
.action(clap::ArgAction::Set).long_help("template-file is the input file path of the .tmpl file (or inferred) that you would like to render"),
Arg::new("destination")
.alias("dest")
.short('d')
.long("destination")
.required(true)
.action(clap::ArgAction::Set)
.long_help("destination is the output path of the template once done, but default .tmpl is stripped and the normal file extension is used. this can be overwritten if a file path is entered instead. I.e. (/some/file/name.txt)"),
Arg::new("extra-var")
.long("extra-var")
.required(false)
.action(clap::ArgAction::Set),
]))
}
pub struct RenderTemplateCommand {
variables: Vec<CuddleVariable>,
template_file: PathBuf,
destination: PathBuf,
}
impl RenderTemplateCommand {
pub fn from_matches(matches: &ArgMatches, cli: CuddleCli) -> anyhow::Result<Self> {
let template_file = matches
.get_one::<String>("template-file")
.ok_or(anyhow::anyhow!("template-file was not found"))
.and_then(get_path_buf_and_check_exists)?;
let destination = matches
.get_one::<String>("destination")
.ok_or(anyhow::anyhow!("destination was not found"))
.and_then(get_path_buf_and_check_dir_exists)
.and_then(RenderTemplateCommand::transform_extension)?;
let mut extra_vars: Vec<CuddleVariable> =
if let Some(extra_vars) = matches.get_many::<String>("extra-var") {
let mut vars = Vec::with_capacity(extra_vars.len());
for var in extra_vars.into_iter() {
let parts: Vec<&str> = var.split("=").collect();
if parts.len() != 2 {
return Err(anyhow::anyhow!("extra-var: is not set correctly: {}", var));
}
vars.push(CuddleVariable::new(parts[0].into(), parts[1].into()));
}
vars
} else {
vec![]
};
extra_vars.append(&mut cli.variables.clone());
Ok(Self {
variables: extra_vars,
template_file,
destination,
})
}
pub fn execute(self) -> anyhow::Result<()> {
// Prepare context
let mut context = tera::Context::new();
for var in self.variables {
context.insert(
var.name.to_lowercase().replace(" ", "_").replace("-", "_"),
&var.value,
)
}
// Load source template
let source = std::fs::read_to_string(self.template_file)?;
let output = tera::Tera::one_off(source.as_str(), &context, false)?;
// Put template in final destination
std::fs::write(&self.destination, output)?;
log::info!(
"finished writing template to: {}",
&self.destination.to_string_lossy()
);
Ok(())
}
fn transform_extension(template_path: PathBuf) -> anyhow::Result<PathBuf> {
if template_path.is_file() {
let ext = template_path.extension().ok_or(anyhow::anyhow!(
"destination path does not have an extension"
))?;
if ext.to_string_lossy().ends_with("tmpl") {
let template_dest = template_path
.to_str()
.and_then(|s| s.strip_suffix(".tmpl"))
.ok_or(anyhow::anyhow!("string does not end in .tmpl"))?;
return PathBuf::from_str(template_dest).map_err(|e| anyhow::anyhow!(e));
}
}
Ok(template_path)
}
}
fn get_path_buf_and_check_exists(raw_path: &String) -> anyhow::Result<PathBuf> {
match PathBuf::from_str(&raw_path) {
Ok(pb) => {
if pb.exists() {
Ok(pb)
} else {
Err(anyhow::anyhow!(
"path: {}, could not be found",
pb.to_string_lossy()
))
}
}
Err(e) => Err(anyhow::anyhow!(e)),
}
}
fn get_path_buf_and_check_dir_exists(raw_path: &String) -> anyhow::Result<PathBuf> {
match PathBuf::from_str(&raw_path) {
Ok(pb) => {
if pb.is_dir() && pb.exists() {
Ok(pb)
} else if pb.is_file() {
Ok(pb)
} else {
Ok(pb)
}
}
Err(e) => Err(anyhow::anyhow!(e)),
}
}

View File

@@ -0,0 +1,88 @@
use clap::{Arg, ArgMatches, Command};
use crate::cli::CuddleCli;
pub fn build_command(root_cmd: Command, cli: CuddleCli) -> Command {
if cli.scripts.len() > 0 {
let execute_cmd_about = "x is your entry into your domains scripts, scripts inherited from parents will also be present here";
let mut execute_cmd = Command::new("x")
.about(execute_cmd_about)
.subcommand_required(true);
execute_cmd = execute_cmd.subcommands(&build_scripts(cli));
root_cmd.subcommand(execute_cmd)
} else {
root_cmd
}
}
pub fn build_scripts(cli: CuddleCli) -> Vec<Command> {
let mut cmds = Vec::new();
for script in cli.scripts.iter() {
let mut cmd = Command::new(&script.name);
if let Some(desc) = &script.description {
cmd = cmd.about(desc)
}
match &script.script {
crate::model::CuddleScript::Shell(shell_script) => {
if let Some(args) = &shell_script.args {
for (arg_name, arg) in args {
cmd = match arg {
crate::model::CuddleShellScriptArg::Env(arg_env) => cmd.arg(
Arg::new(arg_name.clone())
.env(arg_name.to_uppercase().replace(".", "_"))
.required(true),
),
crate::model::CuddleShellScriptArg::Flag(arg_flag) => {
let mut arg_val = Arg::new(arg_name.clone())
.env(arg_name.to_uppercase().replace(".", "_"))
.long(arg_name);
if let Some(true) = arg_flag.required {
arg_val = arg_val.required(true);
}
if let Some(def) = &arg_flag.default_value {
arg_val = arg_val.default_value(def);
}
if let Some(desc) = &arg_flag.description {
arg_val = arg_val.help(&*desc.clone().leak())
}
cmd.arg(arg_val)
}
};
}
}
}
crate::model::CuddleScript::Dagger(_) => todo!(),
crate::model::CuddleScript::Lua(l) => {}
}
cmds.push(cmd)
}
cmds
}
pub fn execute_x(exe_submatch: &ArgMatches, cli: CuddleCli) -> anyhow::Result<()> {
match exe_submatch.subcommand() {
Some((name, action_matches)) => {
log::trace!(action=name; "running action; name={}", name);
match cli.scripts.iter().find(|ele| ele.name == name) {
Some(script) => {
script
.clone()
.execute(action_matches, cli.variables.clone())?;
Ok(())
}
_ => Err(anyhow::anyhow!("could not find a match")),
}
}
_ => Err(anyhow::anyhow!("could not find a match")),
}
}