diff --git a/Cargo.lock b/Cargo.lock index f0b28ba..b057abe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,7 +451,9 @@ dependencies = [ "dotenvy", "hex", "keyring", + "serde", "tokio", + "toml", "uuid", ] @@ -1467,6 +1469,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1642,6 +1653,21 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -1672,6 +1698,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tracing" version = "0.1.44" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index a376599..e2e5f20 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -22,6 +22,8 @@ chrono = "0.4.45" dotenvy = "0.15" uuid = { version = "1.23.5", features = ["v4"] } hex = "0.4" +serde = { version = "1.0.228", features = ["derive"] } +toml = "1.1.2" [[bin]] name = "harbor" diff --git a/crates/cli/migrations/2026-07-13-221551-0000_create_tables/up.sql b/crates/cli/migrations/2026-07-13-221551-0000_create_tables/up.sql index a62df2b..9036ca2 100644 --- a/crates/cli/migrations/2026-07-13-221551-0000_create_tables/up.sql +++ b/crates/cli/migrations/2026-07-13-221551-0000_create_tables/up.sql @@ -10,7 +10,8 @@ CREATE TABLE secrets ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, project_id TEXT NOT NULL, - config TEXT NOT NULL, + config TEXT NOT NULL + CHECK (config IN ('dev', 'prod', 'staging')), secret BLOB NOT NULL, nonce BLOB NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs new file mode 100644 index 0000000..38701a2 --- /dev/null +++ b/crates/cli/src/config.rs @@ -0,0 +1,81 @@ +use crate::Environment; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +const MAIN_CONFIG_FILE: &str = ".harbor.toml"; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + pub name: String, + pub version: String, + pub default_env: Environment, +} + +#[derive(Debug)] +pub enum ConfigError { + Io(std::io::Error), + Toml(toml::de::Error), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigError::Io(err) => write!(f, "IO error: {}", err), + ConfigError::Toml(err) => write!(f, "TOML error: {}", err), + } + } +} + +impl std::error::Error for ConfigError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ConfigError::Io(err) => Some(err), + ConfigError::Toml(err) => Some(err), + _ => None, + } + } +} + +impl From for ConfigError { + fn from(err: std::io::Error) -> Self { + ConfigError::Io(err) + } +} + +impl From for ConfigError { + fn from(err: toml::de::Error) -> Self { + ConfigError::Toml(err) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct HarborToml { + #[serde(alias = "project")] + name: String, + version: String, + config: Environment, +} + +impl Config { + pub fn from_repo_root(root: impl AsRef) -> Result { + let root = root.as_ref(); + let main = Self::read_main_file(root.join(MAIN_CONFIG_FILE))?; + + Ok(Config { + name: main.name, + version: main.version, + default_env: main.config, + }) + } + + pub fn read_main_file(path: impl AsRef) -> Result { + let contents = fs::read_to_string(path)?; + let parsed: HarborToml = toml::from_str(&contents)?; + Ok(parsed) + } + + pub fn merged_env_vars(&self, store_vars: &HashMap) -> HashMap { + store_vars.clone() + } +} diff --git a/crates/cli/src/db/models.rs b/crates/cli/src/db/models.rs index 405f55f..00da89e 100644 --- a/crates/cli/src/db/models.rs +++ b/crates/cli/src/db/models.rs @@ -1,3 +1,4 @@ +use crate::Environment; use crate::db::schema::{projects, secrets}; use chrono::NaiveDateTime; use diesel::prelude::*; @@ -11,13 +12,6 @@ pub struct Project { pub created_at: NaiveDateTime, } -#[derive(Insertable)] -#[diesel(table_name = projects)] -pub struct NewProject<'a> { - pub id: &'a str, - pub name: &'a str, -} - #[derive(Debug, Queryable, Selectable)] #[diesel(table_name = secrets)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] @@ -25,7 +19,7 @@ pub struct Secret { pub id: String, pub name: String, pub project_id: String, - pub config: String, + pub config: Environment, pub secret: Vec, pub nonce: Vec, pub created_at: NaiveDateTime, diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 677389c..80c4635 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,13 +1,87 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use clap::{Parser, Subcommand}; -use colored::Colorize; use crypto::helper::gen_key; +use diesel::backend::Backend; +use diesel::deserialize::{self, FromSql}; +use diesel::serialize::{self, IsNull, Output, ToSql}; +use diesel::sql_types::Text; +use diesel::sqlite::Sqlite; use keyring::Entry; +use serde::Deserialize; use std::error::Error; use std::io::{self, Write}; +pub mod config; mod db; mod store; +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Deserialize, diesel::AsExpression, diesel::FromSqlRow, +)] +#[diesel(sql_type = Text)] +#[serde(rename_all = "kebab-case")] +pub enum Environment { + Dev, + Prod, + Staging, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnvironmentParseError(String); + +impl Environment { + pub const fn as_str(self) -> &'static str { + match self { + Environment::Dev => "dev", + Environment::Prod => "prod", + Environment::Staging => "staging", + } + } +} + +impl std::fmt::Display for Environment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for Environment { + type Err = EnvironmentParseError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "dev" => Ok(Environment::Dev), + "prod" => Ok(Environment::Prod), + "staging" => Ok(Environment::Staging), + _ => Err(EnvironmentParseError( + "Environment must be one of: dev, prod, staging".to_string(), + )), + } + } +} + +impl std::fmt::Display for EnvironmentParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for EnvironmentParseError {} + +impl ToSql for Environment { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result { + out.set_value(self.as_str()); + Ok(IsNull::No) + } +} + +impl FromSql for Environment { + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + let raw = >::from_sql(bytes)?; + raw.parse::() + .map_err(|err| Box::new(err) as Box) + } +} + #[derive(Parser)] #[clap( name = "Harbor", @@ -26,24 +100,82 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { Inject { - #[arg(short, long)] - verbose: bool, - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] after: Vec, }, Shell {}, - Add {}, + #[command(alias = "add")] + Set { + #[arg(short = 'e', long = "environment")] + environment: Option, + + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + vars: Vec, + }, + Delete { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + keys: Vec, + }, Setup {}, List {}, Project { #[command(subcommand)] command: ProjectCommands, }, - Config { - #[command(subcommand)] - command: ConfigCommands, - }, +} + +pub fn parse_secret_pairs(raw: &[String]) -> Result, Box> { + let mut pairs = Vec::new(); + + for item in raw { + let trimmed = item.trim(); + + if trimmed.is_empty() { + continue; + } + + let (key, value) = trimmed.split_once('=').ok_or_else(|| { + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Invalid secret format: {}", item), + )) as Box + })?; + + if key.trim().is_empty() { + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Secret key cannot be empty", + ))); + } + + pairs.push((key.to_string(), value.to_string())); + } + + Ok(pairs) +} + +pub fn format_doppler_set_command(secrets: &[(String, String)]) -> String { + let mut command = String::from("doppler secrets set"); + + if secrets.is_empty() { + return command; + } + + command.push_str(" \\"); + + for (index, (key, value)) in secrets.iter().enumerate() { + command.push_str("\n "); + command.push_str(key); + command.push_str("=\""); + command.push_str(value); + command.push('"'); + + if index + 1 != secrets.len() { + command.push_str(" \\"); + } + } + + command } #[derive(Subcommand)] @@ -53,36 +185,31 @@ pub enum ProjectCommands { Delete { name: String }, } -#[derive(Subcommand)] -pub enum ConfigCommands { - List {}, - Create { project: String }, - Delete { project: String, name: String }, -} - pub mod interactions { - use super::db::models::{NewProject, Project}; - use super::db::{ - establish_connection, - schema::projects::dsl::{name as project_name, projects}, - }; + use super::db::establish_connection; + use super::db::models::Project; + use crate::Environment; + use diesel::dsl::{insert_into, update}; use diesel::result::{DatabaseErrorKind, Error as DieselError}; use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper}; use std::error::Error; use uuid::Uuid; - fn construct_error(message: &str) -> Result<(), Box> { + type Result = std::result::Result>; + + fn construct_error(message: &str) -> Result<()> { Err(Box::new(std::io::Error::new( std::io::ErrorKind::AlreadyExists, message.to_string(), ))) } - pub fn project_exists(proj_name: &str) -> Result> { + pub fn project_exists(proj_name: &str) -> Result { + use super::db::schema::projects::dsl::{name, projects}; let mut conn = establish_connection(); let existing_project = projects - .filter(project_name.eq(proj_name)) + .filter(name.eq(proj_name)) .select(Project::as_select()) .first::(&mut conn) .optional()?; @@ -90,21 +217,22 @@ pub mod interactions { Ok(existing_project.is_some()) } - pub fn create_project(proj_name: &str) -> Result<(), Box> { + pub fn create_project(proj_name: &str) -> Result<()> { + use super::db::schema::projects::dsl::{created_at, id, name, projects}; let mut conn = establish_connection(); if project_exists(proj_name)? { return construct_error("Project already exists"); } - let project_id = Uuid::new_v4().to_string(); - let new_project = NewProject { - id: &project_id, - name: proj_name, - }; + let proj_id = Uuid::new_v4().to_string(); - match diesel::insert_into(projects) - .values(&new_project) + match insert_into(projects) + .values(( + id.eq(proj_id), + name.eq(proj_name), + created_at.eq(chrono::Utc::now().naive_utc()), + )) .execute(&mut conn) { Ok(_) => Ok(()), @@ -115,19 +243,106 @@ pub mod interactions { } } - pub fn delete_project(proj_name: &str) -> Result<(), Box> { + pub fn delete_project(proj_name: &str) -> Result<()> { + use super::db::schema::projects::dsl::{name, projects}; let mut conn = establish_connection(); if !project_exists(proj_name)? { return construct_error("Project does not exist"); } - diesel::delete(projects.filter(project_name.eq(proj_name))).execute(&mut conn)?; + diesel::delete(projects.filter(name.eq(proj_name))).execute(&mut conn)?; + + Ok(()) + } + + pub fn get_projects() -> Result> { + use super::db::schema::projects::dsl::projects; + let mut conn = establish_connection(); + + let results = projects + .select(Project::as_select()) + .load::(&mut conn)?; + + Ok(results) + } + + pub fn secret_exists() -> Result { + use super::db::schema::secrets::dsl::{id, secrets}; + let mut conn = establish_connection(); + + let existing_secret = secrets.select(id).first::(&mut conn).optional()?; + + Ok(existing_secret.is_some()) + } + + pub fn get_project_id(proj_name: &str) -> Result { + use super::db::schema::projects::dsl::{id, name, projects}; + let mut conn = establish_connection(); + + let project_id = projects + .filter(name.eq(proj_name)) + .select(id) + .first::(&mut conn) + .optional()?; + + match project_id { + Some(pid) => Ok(pid), + None => Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Project not found", + ))), + } + } + + pub fn set_secret( + proj_id: &str, + secret_name: &str, + secret_value: Vec, + conf: Environment, + non: crypto::Nonce, + ) -> Result<()> { + use super::db::schema::secrets::dsl::{ + config, created_at, name, nonce, project_id, secret, secrets, + }; + let mut conn = establish_connection(); + + if secret_exists()? { + update(secrets.filter(name.eq(secret_name))) + .set(secret.eq(secret_value)) + .execute(&mut conn)?; + } else { + insert_into(secrets) + .values(( + 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(()) + } + + pub fn delete_secret(secret_name: &str) -> Result<()> { + use super::db::schema::secrets::dsl::{name, secrets}; + let mut conn = establish_connection(); + + if !secret_exists()? { + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Secret does not exist", + ))); + } + + diesel::delete(secrets.filter(name.eq(secret_name))).execute(&mut conn)?; Ok(()) } } - pub fn gen_or_get_key() -> Result> { let entry = Entry::new("harbor", "encryption-key")?; @@ -153,7 +368,11 @@ pub fn gen_or_get_key() -> Result> { } } -pub fn get_input(message: impl std::fmt::Display, prompt: char, new_line: bool) -> Result> { +pub fn get_input( + message: impl std::fmt::Display, + prompt: char, + new_line: bool, +) -> Result> { let mut input = String::new(); if new_line { diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c2e56ab..1efef66 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -3,17 +3,27 @@ use std::process; use clap::Parser; use clap::crate_version; use cli::Cli; +use cli::Environment; +use cli::config::{Config, ConfigError}; +use cli::gen_or_get_key; use cli::get_input; +use cli::interactions::delete_secret; +use cli::interactions::get_project_id; +use cli::interactions::get_projects; +use cli::interactions::set_secret; use colored::*; +use crypto::encrypt; const GIT_VERSION: &str = env!("GIT_VERSION"); fn main() { - use cli::{Commands, ConfigCommands, ProjectCommands}; + use cli::{Commands, ProjectCommands}; let cli = Cli::parse(); if cli.version { - let top_msg = format!("Harbor version {}", crate_version!().green()).bright_cyan(); + let top_msg = format!("Harbor version {}", crate_version!().green()) + .bright_cyan() + .bold(); let bottom_msg = format!("An open source secrets management and distribution platform.").blue(); let commit_msg = format!("Git commit {}", GIT_VERSION).dimmed(); @@ -22,39 +32,146 @@ fn main() { process::exit(0); } + let root = match std::env::current_dir() { + Ok(path) => path, + Err(err) => { + eprintln!("Error resolving current directory: {}", err); + process::exit(1); + } + }; + + let config_path = root.join(".harbor.toml"); + let has_config = config_path.exists(); + + if !has_config { + eprintln!( + "{}", + "Warning: .harbor.toml not found. Some commands are disabled.".yellow() + ); + } + match cli.command { Some(c) => match c { - Commands::Add {} => { - println!("Add command executed"); - } - Commands::Config { command } => match command { - ConfigCommands::List {} => { - println!("Config list command executed"); - } - ConfigCommands::Create { project } => { - println!("Config create command executed for project: {}", project); - } - ConfigCommands::Delete { project, name } => { - println!( - "Config delete command executed for project: {}, name: {}", - project, name - ); - } - }, - Commands::Inject { verbose, after } => { - println!( - "Inject command executed with verbose: {}, after: {:?}", - verbose, after + _ if requires_config(&c) && !has_config => { + eprintln!( + "{}", + "Missing .harbor.toml. Run `harbor config create` first.".yellow() ); + process::exit(1); + } + Commands::Set { environment, vars } => { + let config = require_config(&root); + let environment: Environment = match environment { + Some(env) => match env.parse::() { + Ok(parsed) => parsed, + Err(_) => { + eprintln!("Invalid environment '{}'.", env); + process::exit(1); + } + }, + None => config.default_env.into(), + }; + + let pairs = match cli::parse_secret_pairs(&vars) { + Ok(pairs) => pairs, + Err(e) => { + eprintln!("Error parsing secrets: {}", e); + process::exit(1); + } + }; + + if pairs.is_empty() { + eprintln!("No secrets provided. Use KEY=VALUE pairs."); + process::exit(1); + } + + let project = match get_project_id(&config.name) { + Ok(p) => p, + Err(e) => { + eprintln!("Unable to get project ID for '{}': {}", config.name, e); + process::exit(1); + } + }; + + for pair in pairs { + let key = match gen_or_get_key() { + Ok(k) => k, + Err(_) => { + eprintln!("Error generating or getting key"); + process::exit(1); + } + }; + let (nonce, encrypted) = match encrypt(&key, pair.1.as_bytes().to_vec()) { + Ok(result) => result, + Err(e) => { + eprintln!("Error encrypting secret for key '{}': {}", pair.0, e); + process::exit(1); + } + }; + match set_secret(&project, &pair.0, encrypted, environment, nonce) { + Ok(()) => {} + Err(e) => { + eprintln!("Error setting secret for key '{}': {}", pair.0, e); + process::exit(1); + } + } + } + } + Commands::Delete { keys } => { + let mut cleaned = Vec::new(); + + for key in keys { + let trimmed = key.trim(); + if !trimmed.is_empty() { + cleaned.push(trimmed.to_string()); + } + } + + // Redefining as immutable + let cleaned = cleaned; + + if cleaned.is_empty() { + eprintln!("No secret keys provided. Use one or more keys."); + process::exit(1); + } + + for key in cleaned { + match delete_secret(&key) { + Ok(()) => { + println!("Deleted secret for key '{}'", key); + } + Err(e) => { + eprintln!("Error deleting secret for key '{}': {}", key, e); + process::exit(1); + } + }; + } + } + Commands::Inject { after } => { + let _config = require_config(&root); + // for harbor inject -- bun dev // after = ["bun", "dev"] + + } Commands::List {} => { + let _config = require_config(&root); + println!("List command executed"); } Commands::Project { command } => match command { ProjectCommands::List {} => { - println!("Project list command executed"); + let projects = match get_projects() { + Ok(projects) => projects, + Err(e) => { + eprintln!("Error getting projects: {}", e); + process::exit(1); + } + }; + for project in projects { + println!(" - {}", project.name.blue().bold()); + } } ProjectCommands::Create {} => { let project_name = match get_input("Project name", ':', false) { @@ -124,7 +241,86 @@ fn main() { println!("Shell command executed"); } Commands::Setup {} => { - println!("Setup command executed"); + use std::process; + // Setup will get all projects and prompt the user to select one, + // then create a .harbor.toml file in the current directory with the selected project name. + let projects = match get_projects() { + Ok(projects) => projects, + Err(e) => { + eprintln!("Error getting projects: {}", e); + process::exit(1); + } + }; + + if projects.is_empty() { + eprintln!("No projects found. Please create a project first."); + process::exit(1); + } + + // We'll just use fzf, and pipe the project names to it, and get the selected project name back. + let project_names: Vec = projects.iter().map(|p| p.name.clone()).collect(); + + let mut fzf = match process::Command::new("fzf") + .stdin(process::Stdio::piped()) + .stdout(process::Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + eprintln!("Error spawning fzf: {}", e); + process::exit(1); + } + }; + + { + let mut stdin = fzf.stdin.take().expect("Failed to open stdin"); + for name in &project_names { + use std::io::Write; + writeln!(stdin, "{}", name).expect("Failed to write to stdin"); + } + drop(stdin); + } + + let output = match fzf.wait_with_output() { + Ok(o) => o, + Err(e) => { + eprintln!("Error waiting for fzf: {}", e); + process::exit(1); + } + }; + + if !output.status.success() { + eprintln!("fzf exited with non-zero status"); + process::exit(1); + } + + let project = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if project.is_empty() { + eprintln!("No project selected."); + process::exit(1); + } + + let new_config = format!( + r#"version = "1" +name = "{}" +config = "dev""#, + project + ); + + let config_path = root.join(".harbor.toml"); + match std::fs::write(&config_path, new_config) { + Ok(_) => { + println!("Created .harbor.toml for project '{}'.", project); + } + Err(e) => { + eprintln!( + "Error creating .harbor.toml for project '{}': {}", + project, e + ); + process::exit(1); + } + } } }, None => { @@ -137,3 +333,31 @@ fn main() { } } } + +fn require_config(root: &std::path::Path) -> Config { + match Config::from_repo_root(root) { + Ok(config) => config, + Err(ConfigError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { + eprintln!( + "{}", + "Missing .harbor.toml. Run `harbor config create` first.".yellow() + ); + process::exit(1); + } + Err(err) => { + eprintln!("Error reading config: {}", err); + process::exit(1); + } + } +} + +fn requires_config(command: &cli::Commands) -> bool { + matches!( + command, + cli::Commands::Inject { .. } + | cli::Commands::Set { .. } + | cli::Commands::Delete { .. } + | cli::Commands::Shell { .. } + | cli::Commands::List { .. } + ) +} diff --git a/harbor.toml b/harbor.toml new file mode 100644 index 0000000..e69de29