Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
848 changes: 808 additions & 40 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ version = "0.1.0"
edition = "2024"

[features]
default = ["gui"]
dev-store = []
gui = ["dep:xilem"]
tui = ["dep:ratatui", "dep:crossterm"]

[dependencies]
anyhow = "1"
Expand All @@ -19,4 +22,7 @@ serde-envfile = "0.3"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
zeroize = { version = "1", features = ["derive"] }

xilem = { git = "https://github.com/linebender/xilem", package = "xilem" }
xilem = { git = "https://github.com/linebender/xilem", package = "xilem", optional = true }

ratatui = { version = "0.30", optional = true }
crossterm = { version = "0.29", optional = true }
44 changes: 44 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.PHONY: all check fmt clippy test build build-gui build-tui build-all run run-tui clean

all: check fmt clippy test

check:
cargo check --no-default-features --features gui
cargo check --no-default-features --features tui
cargo check --no-default-features --features "gui tui"

fmt:
cargo fmt --all --check

clippy:
cargo clippy --no-default-features --features gui -- -D warnings
cargo clippy --no-default-features --features tui -- -D warnings
cargo clippy --no-default-features --features "gui tui" -- -D warnings

test:
cargo test --no-default-features --features gui
cargo test --no-default-features --features tui
cargo test --no-default-features --features "gui tui"

# Default build: GUI (xilem). Same as `make build-gui`.
build: build-gui

build-gui:
cargo build --release --no-default-features --features gui

# TUI-only build: no xilem, no fontconfig, no wgpu.
build-tui:
cargo build --release --no-default-features --features tui

# Both editors in one binary.
build-all:
cargo build --release --no-default-features --features "gui tui"

run:
cargo run --release --no-default-features --features gui --

run-tui:
cargo run --release --no-default-features --features tui -- --editor tui

clean:
cargo clean
21 changes: 19 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
use clap::{Parser, Subcommand};
use clap::Parser;
use clap::Subcommand;
use clap::ValueEnum;

#[derive(Parser)]
#[command(name = "credctl", about = "Minimal secret manager", disable_help_subcommand = true)]
#[command(
name = "credctl",
about = "Minimal secret manager",
disable_help_subcommand = true
)]
pub struct Cli {
/// Override the active context for this invocation
#[arg(long, global = true)]
pub context: Option<String>,

/// Editor UI to use (default: gui). Requires --features tui for the tui option.
#[arg(long, global = true, value_enum, default_value_t = EditorKind::Gui)]
pub editor: EditorKind,

#[command(subcommand)]
pub command: Commands,
}

#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
pub enum EditorKind {
Gui,
Tui,
}

#[derive(Subcommand)]
pub enum Commands {
/// List all secrets
Expand Down Expand Up @@ -114,6 +130,7 @@ COMMANDS:

GLOBAL FLAGS:
--context <name> Override the active context for this invocation
--editor <gui|tui> Editor UI (default: gui; tui requires --features tui)

EXAMPLES:
credctl create my-secret
Expand Down
36 changes: 29 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,22 @@ pub fn save_config(config: &Config) -> Result<()> {
Ok(())
}

pub fn resolve_context<'a>(config: &'a Config, override_name: Option<&'a str>) -> Result<(&'a str, &'a StoreType)> {
pub fn resolve_context<'a>(
config: &'a Config,
override_name: Option<&'a str>,
) -> Result<(&'a str, &'a StoreType)> {
let name = override_name.unwrap_or(&config.current_context);
let store_type = config.contexts.get(name)
let store_type = config
.contexts
.get(name)
.ok_or_else(|| anyhow::anyhow!("context '{}' not found", name))?;
Ok((name, store_type))
}

pub fn handle_context_command(config: &mut Config, action: &crate::cli::ContextAction) -> Result<()> {
pub fn handle_context_command(
config: &mut Config,
action: &crate::cli::ContextAction,
) -> Result<()> {
match action {
crate::cli::ContextAction::List => {
if config.contexts.is_empty() {
Expand All @@ -99,7 +107,11 @@ pub fn handle_context_command(config: &mut Config, action: &crate::cli::ContextA
let mut names: Vec<_> = config.contexts.keys().collect();
names.sort();
for name in names {
let marker = if name == &config.current_context { " *" } else { "" };
let marker = if name == &config.current_context {
" *"
} else {
""
};
let store = &config.contexts[name];
println!("{:<20} {}{}", name, store, marker);
}
Expand All @@ -119,11 +131,18 @@ pub fn handle_context_command(config: &mut Config, action: &crate::cli::ContextA
println!("switched to context '{}'", name);
}

crate::cli::ContextAction::Set { name, store, path, profile } => {
crate::cli::ContextAction::Set {
name,
store,
path,
profile,
} => {
let store_type = match store.as_str() {
"file" => StoreType::File { path: path.clone() },
"env" => StoreType::Env { path: path.clone() },
"aws" => StoreType::Aws { profile: profile.clone() },
"aws" => StoreType::Aws {
profile: profile.clone(),
},
other => bail!("unknown store type '{}' (expected: file, env, aws)", other),
};
config.contexts.insert(name.clone(), store_type);
Expand All @@ -133,7 +152,10 @@ pub fn handle_context_command(config: &mut Config, action: &crate::cli::ContextA

crate::cli::ContextAction::Delete { name } => {
if name == &config.current_context {
bail!("cannot delete the current context '{}'. Switch first.", name);
bail!(
"cannot delete the current context '{}'. Switch first.",
name
);
}
if config.contexts.remove(name).is_none() {
bail!("context '{}' not found", name);
Expand Down
50 changes: 39 additions & 11 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#[cfg(not(any(feature = "gui", feature = "tui")))]
compile_error!("credctl requires at least one editor feature: `gui` and/or `tui`");

mod cli;
mod config;
mod store;
Expand All @@ -7,12 +10,13 @@ use std::io::{BufRead, Write};

use anyhow::Result;
use clap::Parser;
use cli::{Cli, Commands};
use cli::{Cli, Commands, EditorKind};
use config::StoreType;

#[tokio::main]
async fn main() -> Result<()> {
// Suppress xilem/wgpu log noise unless the user explicitly sets RUST_LOG.
#[cfg(feature = "gui")]
if std::env::var_os("RUST_LOG").is_none() {
// SAFETY: called before tokio spawns any threads.
unsafe { std::env::set_var("RUST_LOG", "error") };
Expand Down Expand Up @@ -41,7 +45,7 @@ async fn main() -> Result<()> {
StoreType::File { path } => {
let dir = path.unwrap_or_else(|| ".credctl/secrets".to_string());
let s = store::file::FileStore::new(dir);
run_with_store(&s, &ui::XilemEditor, cli.command).await
dispatch_editor(&s, cli.editor, cli.command).await
}
#[cfg(not(feature = "dev-store"))]
StoreType::File { .. } => {
Expand All @@ -50,23 +54,44 @@ async fn main() -> Result<()> {
StoreType::Env { path } => {
let p = path.unwrap_or_else(|| ".env".to_string());
let s = store::env::EnvStore::new(p);
run_with_store(&s, &ui::XilemEditor, cli.command).await
dispatch_editor(&s, cli.editor, cli.command).await
}
StoreType::Aws { profile } => {
let s = store::aws::AwsStore::new(profile).await?;
run_with_store(&s, &ui::XilemEditor, cli.command).await
dispatch_editor(&s, cli.editor, cli.command).await
}
}
}

async fn dispatch_editor(
store: &impl store::SecretStore,
editor: EditorKind,
cmd: Commands,
) -> Result<()> {
match editor {
#[cfg(feature = "gui")]
EditorKind::Gui => run_with_store(store, &ui::XilemEditor, cmd).await,
#[cfg(not(feature = "gui"))]
EditorKind::Gui => {
anyhow::bail!("gui editor requires building with --features gui")
}
#[cfg(feature = "tui")]
EditorKind::Tui => run_with_store(store, &ui::TuiEditor, cmd).await,
#[cfg(not(feature = "tui"))]
EditorKind::Tui => {
anyhow::bail!("tui editor requires building with --features tui")
}
}
}

/// If the value is a JSON object with all-string values, treat it as structured.
fn detect_kind(value: &str) -> store::SecretKind {
if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(value) {
if map.values().all(|v| v.is_string()) {
let mut keys: Vec<String> = map.keys().cloned().collect();
keys.sort();
return store::SecretKind::Structured { keys };
}
if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(value)
&& map.values().all(|v| v.is_string())
{
let mut keys: Vec<String> = map.keys().cloned().collect();
keys.sort();
return store::SecretKind::Structured { keys };
}
store::SecretKind::PlainText
}
Expand Down Expand Up @@ -181,7 +206,10 @@ async fn run_with_store(
let name = resolve(name)?;
print!("Type '{}' to confirm deletion: ", name);
std::io::stdout().flush()?;
let line = std::io::stdin().lock().lines().next()
let line = std::io::stdin()
.lock()
.lines()
.next()
.unwrap_or_else(|| Ok(String::new()))?;
if line.trim() != name {
anyhow::bail!("confirmation did not match, aborting");
Expand Down
Loading