forgot to commit, a lot of stuff happened

This commit is contained in:
2026-07-13 23:25:35 -05:00
parent 1da8ea923b
commit 8487a612eb
8 changed files with 623 additions and 70 deletions
+2
View File
@@ -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"
@@ -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,
+81
View File
@@ -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<std::io::Error> for ConfigError {
fn from(err: std::io::Error) -> Self {
ConfigError::Io(err)
}
}
impl From<toml::de::Error> 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<Path>) -> Result<Self, ConfigError> {
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<Path>) -> Result<HarborToml, ConfigError> {
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<String, String>) -> HashMap<String, String> {
store_vars.clone()
}
}
+2 -8
View File
@@ -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<u8>,
pub nonce: Vec<u8>,
pub created_at: NaiveDateTime,
+255 -36
View File
@@ -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<Self, Self::Err> {
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<Text, Sqlite> 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<Text, Sqlite> for Environment {
fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> deserialize::Result<Self> {
let raw = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
raw.parse::<Environment>()
.map_err(|err| Box::new(err) as Box<dyn std::error::Error + Send + Sync>)
}
}
#[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<String>,
},
Shell {},
Add {},
#[command(alias = "add")]
Set {
#[arg(short = 'e', long = "environment")]
environment: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
vars: Vec<String>,
},
Delete {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
keys: Vec<String>,
},
Setup {},
List {},
Project {
#[command(subcommand)]
command: ProjectCommands,
},
Config {
#[command(subcommand)]
command: ConfigCommands,
},
}
pub fn parse_secret_pairs(raw: &[String]) -> Result<Vec<(String, String)>, Box<dyn Error>> {
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<dyn Error>
})?;
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<dyn Error>> {
type Result<T> = std::result::Result<T, Box<dyn Error>>;
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<bool, Box<dyn Error>> {
pub fn project_exists(proj_name: &str) -> Result<bool> {
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::<Project>(&mut conn)
.optional()?;
@@ -90,21 +217,22 @@ pub mod interactions {
Ok(existing_project.is_some())
}
pub fn create_project(proj_name: &str) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<Vec<Project>> {
use super::db::schema::projects::dsl::projects;
let mut conn = establish_connection();
let results = projects
.select(Project::as_select())
.load::<Project>(&mut conn)?;
Ok(results)
}
pub fn secret_exists() -> Result<bool> {
use super::db::schema::secrets::dsl::{id, secrets};
let mut conn = establish_connection();
let existing_secret = secrets.select(id).first::<String>(&mut conn).optional()?;
Ok(existing_secret.is_some())
}
pub fn get_project_id(proj_name: &str) -> Result<String> {
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::<String>(&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<u8>,
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<crypto::Key, Box<dyn Error>> {
let entry = Entry::new("harbor", "encryption-key")?;
@@ -153,7 +368,11 @@ pub fn gen_or_get_key() -> Result<crypto::Key, Box<dyn Error>> {
}
}
pub fn get_input(message: impl std::fmt::Display, prompt: char, new_line: bool) -> Result<String, Box<dyn Error>> {
pub fn get_input(
message: impl std::fmt::Display,
prompt: char,
new_line: bool,
) -> Result<String, Box<dyn Error>> {
let mut input = String::new();
if new_line {
+249 -25
View File
@@ -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::<Environment>() {
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<String> = 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 { .. }
)
}