diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index bc464a0..6c08f0b 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -125,9 +125,18 @@ pub enum Commands { vars: Vec, }, Delete { + #[arg(short = 'e', long = "environment")] + environment: Option, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] keys: Vec, }, + Show { + #[arg(short = 'e', long = "environment")] + environment: Option, + + key: String, + }, Setup {}, List {}, Project { @@ -212,6 +221,10 @@ pub mod interactions { type Result = std::result::Result>; + fn db_url() -> String { + std::env::var("DATABASE_URL").unwrap_or_else(|_| DB_URL.to_string()) + } + fn construct_error(message: &str) -> Result<()> { Err(Box::new(std::io::Error::new( std::io::ErrorKind::AlreadyExists, @@ -221,7 +234,7 @@ pub mod interactions { pub fn project_exists(proj_name: &str) -> Result { use super::db::schema::projects::dsl::{name, projects}; - let mut conn = establish_connection(DB_URL.to_string()); + let mut conn = establish_connection(db_url()); let existing_project = projects .filter(name.eq(proj_name)) @@ -234,7 +247,7 @@ pub mod interactions { pub fn create_project(proj_name: &str) -> Result<()> { use super::db::schema::projects::dsl::{created_at, id, name, projects}; - let mut conn = establish_connection(DB_URL.to_string()); + let mut conn = establish_connection(db_url()); if project_exists(proj_name)? { return construct_error("Project already exists"); @@ -260,7 +273,7 @@ pub mod interactions { pub fn delete_project(proj_name: &str) -> Result<()> { use super::db::schema::projects::dsl::{name, projects}; - let mut conn = establish_connection(DB_URL.to_string()); + let mut conn = establish_connection(db_url()); if !project_exists(proj_name)? { return construct_error("Project does not exist"); @@ -273,7 +286,7 @@ pub mod interactions { pub fn get_projects() -> Result> { use super::db::schema::projects::dsl::projects; - let mut conn = establish_connection(DB_URL.to_string()); + let mut conn = establish_connection(db_url()); let results = projects .select(Project::as_select()) @@ -288,7 +301,7 @@ pub mod interactions { environment: Environment, ) -> Result { use super::db::schema::secrets::dsl::{config, id, name, project_id, secrets}; - let mut conn = establish_connection(DB_URL.to_string()); + let mut conn = establish_connection(db_url()); let existing_secret = secrets .filter(project_id.eq(proj_id)) @@ -303,7 +316,7 @@ pub mod interactions { pub fn get_project_id(proj_name: &str) -> Result { use super::db::schema::projects::dsl::{id, name, projects}; - let mut conn = establish_connection(DB_URL.to_string()); + let mut conn = establish_connection(db_url()); let project_id = projects .filter(name.eq(proj_name)) @@ -330,26 +343,30 @@ pub mod interactions { use super::db::schema::secrets::dsl::{ config, created_at, id, name, nonce, project_id, secret, secrets, }; - let mut conn = establish_connection(DB_URL.to_string()); + let mut conn = establish_connection(db_url()); if secret_exists(proj_id, secret_name, conf)? { - return Err(Box::new(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "Secret already exists", - ))); - } - - insert_into(secrets) - .values(( - id.eq(Uuid::new_v4().to_string()), - name.eq(secret_name), - secret.eq(secret_value), - project_id.eq(proj_id), - config.eq(conf), - nonce.eq(non.to_vec()), - created_at.eq(chrono::Utc::now().naive_utc()), - )) + update( + secrets + .filter(project_id.eq(proj_id)) + .filter(name.eq(secret_name)) + .filter(config.eq(conf)), + ) + .set((secret.eq(secret_value), nonce.eq(non.to_vec()))) .execute(&mut conn)?; + } else { + insert_into(secrets) + .values(( + id.eq(Uuid::new_v4().to_string()), + name.eq(secret_name), + secret.eq(secret_value), + project_id.eq(proj_id), + config.eq(conf), + nonce.eq(non.to_vec()), + created_at.eq(chrono::Utc::now().naive_utc()), + )) + .execute(&mut conn)?; + } Ok(()) } @@ -359,7 +376,7 @@ pub mod interactions { environment: Environment, ) -> Result, Vec)>> { use super::db::schema::secrets::dsl::{config, name, nonce, project_id, secret, secrets}; - let mut conn = establish_connection(DB_URL.to_string()); + let mut conn = establish_connection(db_url()); let results = secrets .filter(project_id.eq(proj_id)) @@ -370,12 +387,14 @@ pub mod interactions { Ok(results) } - pub fn delete_secret(secret_name: &str) -> Result<()> { - use super::db::schema::secrets::dsl::{id, name, secrets}; - let mut conn = establish_connection(DB_URL.to_string()); + pub fn delete_secret(proj_id: &str, secret_name: &str, environment: Environment) -> Result<()> { + use super::db::schema::secrets::dsl::{config, id, name, project_id, secrets}; + let mut conn = establish_connection(db_url()); let existing_secret = secrets + .filter(project_id.eq(proj_id)) .filter(name.eq(secret_name)) + .filter(config.eq(environment)) .select(id) .first::(&mut conn) .optional()?; @@ -387,7 +406,13 @@ pub mod interactions { ))); } - diesel::delete(secrets.filter(name.eq(secret_name))).execute(&mut conn)?; + diesel::delete( + secrets + .filter(project_id.eq(proj_id)) + .filter(name.eq(secret_name)) + .filter(config.eq(environment)), + ) + .execute(&mut conn)?; Ok(()) } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 1397ef3..68feb9e 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -12,6 +12,7 @@ use cli::interactions::delete_secret; use cli::interactions::get_project_id; use cli::interactions::get_project_secrets; use cli::interactions::get_projects; +use cli::interactions::secret_exists; use cli::interactions::set_secret; use colored::*; use crypto::encrypt; @@ -55,6 +56,80 @@ fn main() { print_error("Missing .harbor.toml. Run `harbor config create` first."); process::exit(1); } + Commands::Show { environment, key } => { + let config = require_config(&root); + let environment: Environment = match environment { + Some(env) => match env.parse::() { + Ok(parsed) => parsed, + Err(_) => { + print_error(format!("Invalid environment '{}'.", env)); + process::exit(1); + } + }, + None => config.default_env.into(), + }; + + let project = match get_project_id(&config.name) { + Ok(p) => p, + Err(e) => { + print_error(format!( + "Unable to get project ID for '{}': {}", + config.name, e + )); + process::exit(1); + } + }; + + let secrets = match get_project_secrets(&project, environment) { + Ok(s) => s, + Err(e) => { + print_error(format!("Error getting secrets: {}", e)); + process::exit(1); + } + }; + + let secret = secrets.into_iter().find(|(name, _, _)| name == &key); + + match secret { + Some((_, ciphertext, nonce)) => { + let key = match gen_or_get_key() { + Ok(k) => k, + Err(_) => { + print_error("Error generating or getting key"); + process::exit(1); + } + }; + let nonce = crypto::Nonce::from_slice(&nonce); + let decrypted = match crypto::decrypt(&key, ciphertext, nonce) { + Ok(d) => d, + Err(e) => { + print_error(format!("Error decrypting secret: {}", e)); + process::exit(1); + } + }; + + let decrypted_str = match String::from_utf8(decrypted) { + Ok(s) => s, + Err(e) => { + print_error(format!( + "Error converting decrypted secret to string: {}", + e + )); + process::exit(1); + } + }; + + println!("{}", decrypted_str); + } + None => { + print_error(format!( + "Secret with key '{}' not found in project '{}'.", + key, config.name + )); + process::exit(1); + } + } + } Commands::Set { environment, vars } => { let config = require_config(&root); let environment: Environment = match environment { @@ -93,6 +168,47 @@ fn main() { }; for pair in pairs { + let existed = match secret_exists(&project, &pair.0, environment) { + Ok(exists) => exists, + Err(e) => { + print_error(format!( + "Error checking existing secret for key '{}': {}", + pair.0, e + )); + process::exit(1); + } + }; + if existed { + let confirmation = match get_input( + format!( + "Secret '{}' already exists. Overwrite? (y/N)", + pair.0 + ) + .yellow(), + ':', + false, + ) { + Ok(input) => input, + Err(e) => { + print_error(format!("Error getting confirmation: {}", e)); + process::exit(1); + } + }; + + if confirmation.to_lowercase() != "y" { + println!( + "{}", + format!( + "Skipped secret '{}' in {}.", + pair.0, + environment.as_str() + ) + .dimmed() + ); + continue; + } + } + let key = match gen_or_get_key() { Ok(k) => k, Err(_) => { @@ -111,7 +227,20 @@ fn main() { } }; match set_secret(&project, &pair.0, encrypted, environment, nonce) { - Ok(()) => {} + Ok(()) => { + let verb = if existed { "Updated" } else { "Set" }; + println!( + "{}", + format!( + "{} secret '{}' in {}.", + verb, + pair.0, + environment.as_str() + ) + .bright_green() + .bold() + ); + } Err(e) => { print_error(format!( "Error setting secret for key '{}': {}", @@ -122,7 +251,28 @@ fn main() { } } } - Commands::Delete { keys } => { + Commands::Delete { environment, keys } => { + let config = require_config(&root); + let environment: Environment = match environment { + Some(env) => match env.parse::() { + Ok(parsed) => parsed, + Err(_) => { + print_error(format!("Invalid environment '{}'.", env)); + process::exit(1); + } + }, + None => config.default_env.into(), + }; + let proj_id = match get_project_id(&config.name) { + Ok(p) => p, + Err(e) => { + print_error(format!( + "Unable to get project ID for '{}': {}", + config.name, e + )); + process::exit(1); + } + }; let mut cleaned = Vec::new(); for key in keys { @@ -141,7 +291,7 @@ fn main() { } for key in cleaned { - match delete_secret(&key) { + match delete_secret(&proj_id, &key, environment) { Ok(()) => { println!("Deleted secret for key '{}'", key); } @@ -300,7 +450,9 @@ fn main() { }; match cli::interactions::create_project(&project_name) { - Ok(()) => println!("{}", "Project created successfully.".cyan()), + Ok(()) => { + println!("{}", "Project created successfully.".bright_green().bold()) + } Err(e) => { print_error(format!("Error creating project: {}", e)); process::exit(1); @@ -342,7 +494,10 @@ fn main() { if confirmation.to_lowercase() == "y" { match cli::interactions::delete_project(&name) { Ok(()) => { - println!("{}", "Project deleted successfully.".cyan()) + println!( + "{}", + "Project deleted successfully.".bright_green().bold() + ) } Err(e) => { print_error(format!("Error deleting project: {}", e)); @@ -530,7 +685,12 @@ config = "dev""#, let config_path = root.join(".harbor.toml"); match std::fs::write(&config_path, new_config) { Ok(_) => { - println!("Created .harbor.toml for project '{}'.", project); + println!( + "{}", + format!("Created .harbor.toml for project '{}'.", project) + .bright_green() + .bold() + ); } Err(e) => { print_error(format!( @@ -571,6 +731,7 @@ fn requires_config(command: &cli::Commands) -> bool { matches!( command, cli::Commands::Inject { .. } + | cli::Commands::Show { .. } | cli::Commands::Set { .. } | cli::Commands::Delete { .. } | cli::Commands::Shell { .. } diff --git a/crates/cli/tests/lib_tests.rs b/crates/cli/tests/lib_tests.rs index cabdf55..26c4401 100644 --- a/crates/cli/tests/lib_tests.rs +++ b/crates/cli/tests/lib_tests.rs @@ -1,15 +1,8 @@ use cli::Environment; use cli::config::Config; use cli::interactions::{ - create_project, - delete_project, - delete_secret, - get_project_id, - get_project_secrets, - get_projects, - project_exists, - secret_exists, - set_secret, + create_project, delete_project, delete_secret, get_project_id, get_project_secrets, + get_projects, project_exists, secret_exists, set_secret, }; use cli::{format_doppler_set_command, parse_secret_pairs}; use diesel::connection::SimpleConnection; @@ -70,7 +63,10 @@ fn environment_parsing_and_display() { assert_eq!(format!("{}", Environment::Prod), "prod"); let err = "unknown".parse::().unwrap_err(); - assert_eq!(err.to_string(), "Environment must be one of: dev, prod, staging"); + assert_eq!( + err.to_string(), + "Environment must be one of: dev, prod, staging" + ); } #[test] @@ -108,10 +104,7 @@ fn format_doppler_set_command_outputs_expected_format() { ("B".to_string(), "2".to_string()), ]); - assert_eq!( - many, - "doppler secrets set \\\n A=\"1\" \\\n B=\"2\"" - ); + assert_eq!(many, "doppler secrets set \\\n A=\"1\" \\\n B=\"2\""); } #[test] @@ -158,7 +151,7 @@ fn project_and_secret_lifecycle() { assert_eq!(projects.len(), 1); assert_eq!(projects[0].name, "alpha"); - assert!(!secret_exists().expect("secret exists")); + assert!(!secret_exists(&project_id, "API_KEY", Environment::Dev).expect("secret exists")); let nonce = crypto::helper::gen_nonce(); set_secret( @@ -170,7 +163,7 @@ fn project_and_secret_lifecycle() { ) .expect("set secret"); - assert!(secret_exists().expect("secret exists")); + assert!(secret_exists(&project_id, "API_KEY", Environment::Dev).expect("secret exists")); let secrets = get_project_secrets(&project_id, Environment::Dev).expect("get secrets"); assert_eq!(secrets.len(), 1); @@ -191,8 +184,8 @@ fn project_and_secret_lifecycle() { assert_eq!(updated.len(), 1); assert_eq!(updated[0].1, b"second".to_vec()); - delete_secret("API_KEY").expect("delete secret"); - assert!(!secret_exists().expect("secret exists")); + delete_secret(&project_id, "API_KEY", Environment::Dev).expect("delete secret"); + assert!(!secret_exists(&project_id, "API_KEY", Environment::Dev).expect("secret exists")); delete_project("alpha").expect("delete project"); assert!(!project_exists("alpha").expect("project exists")); @@ -211,7 +204,8 @@ fn errors_for_missing_project_or_secret() { let err = get_project_id("missing").unwrap_err(); assert_eq!(err.to_string(), "Project not found"); - let err = delete_secret("missing").unwrap_err(); + let missing_project_id = Uuid::new_v4().to_string(); + let err = delete_secret(&missing_project_id, "missing", Environment::Dev).unwrap_err(); assert_eq!(err.to_string(), "Secret does not exist"); cleanup_test_db(db_path);