#![allow(clippy::module_name_repetitions)]
use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::{anyhow, bail, Context, Result};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use secretenv_core::{
resolve_manifest, resolve_registry, run as runner_run, Backend, BackendRegistry, BackendUri,
Config, HistoryEntry, Manifest, RegistryCache, RegistrySelection,
};
#[derive(Debug, Parser)]
#[command(
name = "secretenv",
version,
about = "Run commands with secrets injected from any backend"
)]
pub struct Cli {
#[arg(long, global = true)]
pub config: Option<PathBuf>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Run(RunArgs),
#[command(subcommand)]
Registry(RegistryCommand),
#[command(subcommand)]
Profile(ProfileCommand),
Resolve(ResolveArgs),
Get(GetArgs),
Doctor(DoctorArgs),
Setup(SetupArgs),
Completions(CompletionsArgs),
}
#[derive(Debug, Subcommand)]
pub enum ProfileCommand {
Install {
name: String,
#[arg(long)]
url: Option<String>,
},
List {
#[arg(long)]
json: bool,
},
Update {
name: Option<String>,
},
Uninstall { name: String },
}
#[derive(Debug, Args)]
pub struct RunArgs {
#[arg(long)]
pub registry: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub verbose: bool,
#[arg(trailing_var_arg = true, required = true)]
pub command: Vec<String>,
}
#[derive(Debug, Subcommand)]
pub enum RegistryCommand {
List {
#[arg(long)]
registry: Option<String>,
},
Get {
alias: String,
#[arg(long)]
registry: Option<String>,
},
Set {
alias: String,
uri: String,
#[arg(long)]
registry: Option<String>,
},
Unset {
alias: String,
#[arg(long)]
registry: Option<String>,
},
History {
alias: String,
#[arg(long)]
registry: Option<String>,
#[arg(long)]
json: bool,
},
Invite {
#[arg(long)]
registry: Option<String>,
#[arg(long)]
invitee: Option<String>,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Args)]
pub struct ResolveArgs {
pub alias: String,
#[arg(long)]
pub registry: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct GetArgs {
pub alias: String,
#[arg(long)]
pub registry: Option<String>,
#[arg(long, short)]
pub yes: bool,
}
#[derive(Debug, Args)]
pub struct DoctorArgs {
#[arg(long)]
pub json: bool,
#[arg(long)]
pub fix: bool,
#[arg(long)]
pub extensive: bool,
}
#[derive(Debug, Args)]
pub struct SetupArgs {
pub registry_uri: String,
#[arg(long)]
pub region: Option<String>,
#[arg(long)]
pub profile: Option<String>,
#[arg(long)]
pub account: Option<String>,
#[arg(long)]
pub vault_address: Option<String>,
#[arg(long)]
pub vault_namespace: Option<String>,
#[arg(long)]
pub gcp_project: Option<String>,
#[arg(long)]
pub gcp_impersonate_service_account: Option<String>,
#[arg(long)]
pub azure_vault_url: Option<String>,
#[arg(long)]
pub azure_tenant: Option<String>,
#[arg(long)]
pub azure_subscription: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub skip_doctor: bool,
}
#[derive(Debug, Args)]
pub struct CompletionsArgs {
pub shell: Shell,
#[arg(long)]
pub output: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Shell {
Bash,
Zsh,
Fish,
}
impl Cli {
pub async fn run(&self, config: &Config, backends: &BackendRegistry) -> Result<()> {
match &self.command {
Command::Run(args) => cmd_run(args, config, backends).await,
Command::Registry(rc) => cmd_registry(rc, config, backends).await,
Command::Resolve(args) => cmd_resolve(args, config, backends).await,
Command::Get(args) => cmd_get(args, config, backends).await,
Command::Doctor(args) => {
crate::doctor::run_doctor(
config,
backends,
crate::doctor::DoctorOpts {
json: args.json,
fix: args.fix,
extensive: args.extensive,
},
)
.await
}
Command::Profile(pc) => cmd_profile(pc, self.config.as_deref()).await,
Command::Setup(args) => cmd_setup(args, self.config.as_deref()).await,
Command::Completions(args) => cmd_completions(args),
}
}
}
pub fn resolve_selection(
explicit: Option<&str>,
env_registry: Option<&str>,
config: &Config,
) -> Result<RegistrySelection> {
if let Some(s) = explicit {
return s.parse().context("parsing --registry value");
}
if let Some(env) = env_registry {
if !env.is_empty() {
return env.parse().context("parsing $SECRETENV_REGISTRY");
}
}
if config.registries.contains_key("default") {
return Ok(RegistrySelection::Name("default".to_owned()));
}
Err(anyhow!(
"no registry selected — pass --registry <name-or-uri>, set \
$SECRETENV_REGISTRY, or add a [registries.default] block to config.toml"
))
}
fn resolve_selection_from_env(
explicit: Option<&str>,
config: &Config,
) -> Result<RegistrySelection> {
let env = std::env::var("SECRETENV_REGISTRY").ok();
resolve_selection(explicit, env.as_deref(), config)
}
async fn cmd_run(args: &RunArgs, config: &Config, backends: &BackendRegistry) -> Result<()> {
let starting_dir = std::env::current_dir().context("determining current directory")?;
let manifest = Manifest::load(&starting_dir)
.context("loading secretenv.toml (walked upward from $CWD)")?;
let selection = resolve_selection_from_env(args.registry.as_deref(), config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let resolved = resolve_manifest(&manifest, &aliases)?;
runner_run(&resolved, backends, &args.command, args.dry_run, args.verbose).await
}
async fn cmd_resolve(
args: &ResolveArgs,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
use secretenv_core::{Manifest, DEFAULT_CHECK_TIMEOUT};
let selection = resolve_selection_from_env(args.registry.as_deref(), config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let (target, source) = aliases.get(&args.alias).ok_or_else(|| {
anyhow!(
"alias '{}' not found in registry cascade [{}]",
args.alias,
format_sources(&aliases)
)
})?;
let target = target.clone();
let source = source.clone();
let layer_index = aliases.sources().position(|u| u.raw == source.raw).unwrap_or(0);
let env_var = std::env::current_dir()
.ok()
.and_then(|cwd| Manifest::load(&cwd).ok())
.and_then(|m| manifest_env_var_for_alias(&m, &args.alias));
let backend_check = match backends.get(&target.scheme) {
Some(b) => {
let op_label = format!("{}::check", b.backend_type());
let check_future = async { Ok(b.check().await) };
match secretenv_core::with_timeout(DEFAULT_CHECK_TIMEOUT, &op_label, check_future).await
{
Ok(status) => ResolveBackendCheck::Checked {
backend_type: b.backend_type().to_owned(),
status,
},
Err(err) => ResolveBackendCheck::CheckFailed {
backend_type: b.backend_type().to_owned(),
message: format!("{err:#}"),
},
}
}
None => ResolveBackendCheck::UnregisteredScheme,
};
let report = ResolveReport {
alias: args.alias.clone(),
env_var,
resolved: target.raw.clone(),
source_uri: source.raw.clone(),
layer_index,
backend_scheme: target.scheme.clone(),
check: backend_check,
};
if args.json {
println!("{}", serde_json::to_string_pretty(&report.to_json())?);
} else {
print!("{}", report.render_human());
}
Ok(())
}
fn manifest_env_var_for_alias(manifest: &secretenv_core::Manifest, alias: &str) -> Option<String> {
for (env_var, decl) in &manifest.secrets {
if let secretenv_core::SecretDecl::Alias { from } = decl {
let Ok(parsed) = secretenv_core::BackendUri::parse(from) else {
continue;
};
if parsed.is_alias() {
let found = parsed.path.trim_start_matches('/');
if found == alias {
return Some(env_var.clone());
}
}
}
}
None
}
enum ResolveBackendCheck {
Checked { backend_type: String, status: secretenv_core::BackendStatus },
CheckFailed { backend_type: String, message: String },
UnregisteredScheme,
}
struct ResolveReport {
alias: String,
env_var: Option<String>,
resolved: String,
source_uri: String,
layer_index: usize,
backend_scheme: String,
check: ResolveBackendCheck,
}
impl ResolveReport {
fn render_human(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
writeln!(out, "alias: {}", self.alias).ok();
writeln!(out, "env var: {}", self.env_var.as_deref().unwrap_or("(none)")).ok();
writeln!(out, "resolved: {}", self.resolved).ok();
writeln!(out, "source: {} (cascade layer {})", self.source_uri, self.layer_index).ok();
writeln!(out, "backend: {}", self.render_backend_line()).ok();
out
}
fn render_backend_line(&self) -> String {
use secretenv_core::BackendStatus;
match &self.check {
ResolveBackendCheck::Checked { backend_type, status } => {
let (state, detail) = match status {
BackendStatus::Ok { cli_version: _, identity } => {
("authenticated".to_owned(), format!("({identity})"))
}
BackendStatus::NotAuthenticated { hint } => {
("NOT authenticated".to_owned(), format!("(hint: {hint})"))
}
BackendStatus::CliMissing { cli_name, install_hint } => {
(format!("CLI '{cli_name}' missing"), format!("(install: {install_hint})"))
}
BackendStatus::Error { message } => {
("error".to_owned(), format!("({message})"))
}
};
format!("{backend_type} instance '{}' — {state} {detail}", self.backend_scheme)
}
ResolveBackendCheck::CheckFailed { backend_type, message } => {
format!(
"{backend_type} instance '{}' — check failed ({message})",
self.backend_scheme
)
}
ResolveBackendCheck::UnregisteredScheme => {
format!(
"instance '{}' is not registered in config.toml (resolve succeeded; fetch would fail)",
self.backend_scheme
)
}
}
}
fn to_json(&self) -> serde_json::Value {
use secretenv_core::BackendStatus;
let check = match &self.check {
ResolveBackendCheck::Checked { backend_type, status } => {
let (status_key, detail) = match status {
BackendStatus::Ok { cli_version, identity } => (
"ok",
serde_json::json!({
"cli_version": cli_version,
"identity": identity,
}),
),
BackendStatus::NotAuthenticated { hint } => {
("not_authenticated", serde_json::json!({ "hint": hint }))
}
BackendStatus::CliMissing { cli_name, install_hint } => (
"cli_missing",
serde_json::json!({
"cli_name": cli_name,
"install_hint": install_hint,
}),
),
BackendStatus::Error { message } => {
("error", serde_json::json!({ "message": message }))
}
};
serde_json::json!({
"backend_type": backend_type,
"instance": self.backend_scheme,
"status": status_key,
"detail": detail,
})
}
ResolveBackendCheck::CheckFailed { backend_type, message } => serde_json::json!({
"backend_type": backend_type,
"instance": self.backend_scheme,
"status": "check_failed",
"detail": { "message": message },
}),
ResolveBackendCheck::UnregisteredScheme => serde_json::json!({
"instance": self.backend_scheme,
"status": "unregistered_scheme",
"detail": {},
}),
};
serde_json::json!({
"alias": self.alias,
"env_var": self.env_var,
"resolved": self.resolved,
"source": {
"uri": self.source_uri,
"layer": self.layer_index,
},
"backend": check,
})
}
}
async fn cmd_get(args: &GetArgs, config: &Config, backends: &BackendRegistry) -> Result<()> {
let selection = resolve_selection_from_env(args.registry.as_deref(), config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let target = aliases
.get(&args.alias)
.ok_or_else(|| {
anyhow!(
"alias '{}' not found in registry cascade [{}]",
args.alias,
format_sources(&aliases)
)
})?
.0
.clone();
if !args.yes && !confirm_print_secret(&args.alias)? {
bail!("aborted by user");
}
let backend = backends
.get(&target.scheme)
.ok_or_else(|| anyhow!("no backend instance '{}' is configured", target.scheme))?;
let value = backend.get(&target).await?;
println!("{value}");
Ok(())
}
fn confirm_print_secret(alias: &str) -> Result<bool> {
eprint!("about to print the secret value for '{alias}' to stdout. continue? [y/N] ");
io::stderr().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input).context("reading confirmation from stdin")?;
Ok(matches!(input.trim().to_lowercase().as_str(), "y" | "yes"))
}
async fn cmd_registry(
rc: &RegistryCommand,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
match rc {
RegistryCommand::List { registry } => {
registry_list(registry.as_deref(), config, backends).await
}
RegistryCommand::Get { alias, registry } => {
registry_get(alias, registry.as_deref(), config, backends).await
}
RegistryCommand::Set { alias, uri, registry } => {
registry_set(alias, uri, registry.as_deref(), config, backends).await
}
RegistryCommand::Unset { alias, registry } => {
registry_unset(alias, registry.as_deref(), config, backends).await
}
RegistryCommand::History { alias, registry, json } => {
registry_history(alias, registry.as_deref(), *json, config, backends).await
}
RegistryCommand::Invite { registry, invitee, json } => {
registry_invite(registry.as_deref(), invitee.as_deref(), *json, config)
}
}
}
fn registry_invite(
registry: Option<&str>,
invitee: Option<&str>,
json: bool,
config: &Config,
) -> Result<()> {
let selection = resolve_selection_from_env(registry, config)?;
let invitation = crate::invite::build_invitation(config, &selection, invitee)?;
if json {
println!("{}", crate::invite::render_json(&invitation)?);
} else {
print!("{}", crate::invite::render_human(&invitation));
}
Ok(())
}
async fn registry_list(
registry: Option<&str>,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let selection = resolve_selection_from_env(registry, config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let mut entries: Vec<_> =
aliases.iter().map(|(a, target, _source)| (a.clone(), target.raw.clone())).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (alias, uri) in entries {
println!("{alias} = {uri}");
}
Ok(())
}
async fn registry_get(
alias: &str,
registry: Option<&str>,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let selection = resolve_selection_from_env(registry, config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let (target, _source) = aliases.get(alias).ok_or_else(|| {
anyhow!("alias '{alias}' not found in registry cascade [{}]", format_sources(&aliases))
})?;
println!("{}", target.raw);
Ok(())
}
async fn registry_set(
alias: &str,
target_uri: &str,
registry: Option<&str>,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let target = BackendUri::parse(target_uri)
.with_context(|| format!("target '{target_uri}' is not a valid URI"))?;
if target.is_alias() {
bail!("target must be a direct backend URI, not a secretenv:// alias");
}
if backends.get(&target.scheme).is_none() {
bail!(
"target '{target_uri}' references backend instance '{}' which is not configured",
target.scheme
);
}
let (source_uri, backend) = pick_registry_source(registry, config, backends)?;
let current = backend
.list(&source_uri)
.await
.with_context(|| format!("reading registry document at '{}'", source_uri.raw))?;
let mut map: BTreeMap<String, String> = current.into_iter().collect();
map.insert(alias.to_owned(), target_uri.to_owned());
let serialized = serialize_registry(backend.backend_type(), &map)?;
backend
.set(&source_uri, &serialized)
.await
.with_context(|| format!("writing updated registry document to '{}'", source_uri.raw))?;
eprintln!("set {alias} → {target_uri} in registry at '{}'", source_uri.raw);
Ok(())
}
async fn registry_history(
alias: &str,
registry: Option<&str>,
json: bool,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let selection = resolve_selection_from_env(registry, config)?;
let mut cache = RegistryCache::new();
let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;
let (target, _source) = aliases.get(alias).ok_or_else(|| {
anyhow!("alias '{alias}' not found in registry cascade [{}]", format_sources(&aliases))
})?;
let target = target.clone();
let backend = backends
.get(&target.scheme)
.ok_or_else(|| anyhow!("no backend instance '{}' is configured", target.scheme))?;
let entries = backend.history(&target).await?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&render_history_json(alias, &target.raw, &entries))?
);
} else {
print!("{}", render_history_human(alias, &target.raw, &entries));
}
Ok(())
}
#[allow(clippy::write_literal)] fn render_history_human(alias: &str, uri: &str, entries: &[HistoryEntry]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "alias: {alias}");
let _ = writeln!(out, "resolved: {uri}");
let _ = writeln!(out);
if entries.is_empty() {
let _ = writeln!(out, "(no versions reported by the backend)");
return out;
}
let v_w = entries.iter().map(|e| e.version.len()).max().unwrap_or(7).max(7);
let t_w = entries.iter().map(|e| e.timestamp.len()).max().unwrap_or(20).max(20);
let a_w =
entries.iter().map(|e| e.actor.as_deref().unwrap_or("-").len()).max().unwrap_or(6).max(6);
let _ = writeln!(
out,
"{:<v_w$} {:<t_w$} {:<a_w$} {}",
"VERSION",
"TIMESTAMP",
"ACTOR",
"DESCRIPTION",
v_w = v_w,
t_w = t_w,
a_w = a_w
);
for e in entries {
let _ = writeln!(
out,
"{:<v_w$} {:<t_w$} {:<a_w$} {}",
e.version,
e.timestamp,
e.actor.as_deref().unwrap_or("-"),
e.description.as_deref().unwrap_or(""),
v_w = v_w,
t_w = t_w,
a_w = a_w
);
}
out
}
fn render_history_json(alias: &str, uri: &str, entries: &[HistoryEntry]) -> serde_json::Value {
serde_json::json!({
"alias": alias,
"resolved": uri,
"versions": entries
.iter()
.map(|e| serde_json::json!({
"version": e.version,
"timestamp": e.timestamp,
"actor": e.actor,
"description": e.description,
}))
.collect::<Vec<_>>(),
})
}
async fn registry_unset(
alias: &str,
registry: Option<&str>,
config: &Config,
backends: &BackendRegistry,
) -> Result<()> {
let (source_uri, backend) = pick_registry_source(registry, config, backends)?;
let current = backend
.list(&source_uri)
.await
.with_context(|| format!("reading registry document at '{}'", source_uri.raw))?;
let mut map: BTreeMap<String, String> = current.into_iter().collect();
if map.remove(alias).is_none() {
bail!("alias '{alias}' not found in registry at '{}'", source_uri.raw);
}
let serialized = serialize_registry(backend.backend_type(), &map)?;
backend
.set(&source_uri, &serialized)
.await
.with_context(|| format!("writing updated registry document to '{}'", source_uri.raw))?;
eprintln!("unset {alias} in registry at '{}'", source_uri.raw);
Ok(())
}
fn pick_registry_source<'a>(
registry: Option<&str>,
config: &Config,
backends: &'a BackendRegistry,
) -> Result<(BackendUri, &'a dyn Backend)> {
let selection = resolve_selection_from_env(registry, config)?;
let source_uri: BackendUri = match selection {
RegistrySelection::Uri(u) => u,
RegistrySelection::Name(name) => {
let reg = config
.registries
.get(&name)
.ok_or_else(|| anyhow!("no registry named '{name}' in config.toml"))?;
let first =
reg.sources.first().ok_or_else(|| anyhow!("registry '{name}' has no sources"))?;
BackendUri::parse(first).with_context(|| {
format!("registry '{name}' sources[0] = '{first}' is not a valid URI")
})?
}
};
let backend = backends.get(&source_uri.scheme).ok_or_else(|| {
anyhow!(
"registry source '{}' targets backend '{}' which is not configured",
source_uri.raw,
source_uri.scheme
)
})?;
Ok((source_uri, backend))
}
fn serialize_registry(backend_type: &str, map: &BTreeMap<String, String>) -> Result<String> {
match backend_type {
"local" | "1password" => toml::to_string(map).with_context(|| {
format!("serializing registry as TOML for backend type '{backend_type}'")
}),
"aws-ssm" | "vault" | "aws-secrets" | "gcp" | "azure" | "openbao" => {
serde_json::to_string(map).with_context(|| {
format!("serializing registry as JSON for backend type '{backend_type}'")
})
}
other => Err(anyhow!(
"writing registry documents through backend type '{other}' is not supported"
)),
}
}
fn format_sources(aliases: &secretenv_core::AliasMap) -> String {
aliases.sources().map(|u| u.raw.as_str()).collect::<Vec<_>>().join(", ")
}
async fn cmd_setup(args: &SetupArgs, target_config: Option<&std::path::Path>) -> Result<()> {
let opts = crate::setup::SetupOpts {
registry_uri: args.registry_uri.clone(),
region: args.region.clone(),
profile: args.profile.clone(),
account: args.account.clone(),
vault_address: args.vault_address.clone(),
vault_namespace: args.vault_namespace.clone(),
gcp_project: args.gcp_project.clone(),
gcp_impersonate_service_account: args.gcp_impersonate_service_account.clone(),
azure_vault_url: args.azure_vault_url.clone(),
azure_tenant: args.azure_tenant.clone(),
azure_subscription: args.azure_subscription.clone(),
force: args.force,
skip_doctor: args.skip_doctor,
target: target_config.map(std::path::Path::to_path_buf),
};
crate::setup::run_setup(&opts).await
}
async fn cmd_profile(pc: &ProfileCommand, target_config: Option<&std::path::Path>) -> Result<()> {
let config_path: std::path::PathBuf = match target_config {
Some(p) => p.to_path_buf(),
None => secretenv_core::default_config_path_xdg()?,
};
let opts = crate::profile::ProfileOpts {
profiles_dir: secretenv_core::profiles_dir_for(&config_path),
};
match pc {
ProfileCommand::Install { name, url } => {
crate::profile::install(name, url.as_deref(), &opts).await
}
ProfileCommand::List { json } => {
let installed = crate::profile::list(&opts)?;
render_profile_list(&installed, *json)
}
ProfileCommand::Update { name } => {
if let Some(n) = name {
let outcome = crate::profile::update_one(n, &opts).await?;
match outcome {
crate::profile::UpdateOutcome::UpToDate => {
eprintln!("Profile '{n}' is already up to date.");
}
crate::profile::UpdateOutcome::Refreshed => {
eprintln!("Profile '{n}' refreshed.");
}
}
Ok(())
} else {
let reports = crate::profile::update_all(&opts).await?;
render_profile_update_reports(&reports)
}
}
ProfileCommand::Uninstall { name } => crate::profile::uninstall(name, &opts),
}
}
fn render_profile_list(installed: &[crate::profile::InstalledProfile], json: bool) -> Result<()> {
if json {
let json =
serde_json::to_string_pretty(&installed).context("serializing profile list to JSON")?;
println!("{json}");
return Ok(());
}
if installed.is_empty() {
println!("No profiles installed.");
return Ok(());
}
println!("{:<24} {:<20} SOURCE", "NAME", "INSTALLED");
for p in installed {
println!("{:<24} {:<20} {}", p.name, p.installed_at, p.source_url);
}
Ok(())
}
fn render_profile_update_reports(reports: &[crate::profile::UpdateReport]) -> Result<()> {
if reports.is_empty() {
println!("No profiles installed.");
return Ok(());
}
let mut had_error = false;
for r in reports {
match &r.outcome {
Ok(crate::profile::UpdateOutcome::UpToDate) => {
println!("{:<24} up to date", r.name);
}
Ok(crate::profile::UpdateOutcome::Refreshed) => {
println!("{:<24} refreshed", r.name);
}
Err(e) => {
had_error = true;
println!("{:<24} ERROR: {e:#}", r.name);
}
}
}
if had_error {
anyhow::bail!("one or more profile updates failed");
}
Ok(())
}
fn cmd_completions(args: &CompletionsArgs) -> Result<()> {
use std::io::IsTerminal as _;
let mut cmd = Cli::command();
let bin = "secretenv";
let mut buf: Vec<u8> = Vec::new();
match args.shell {
Shell::Bash => {
clap_complete::generate(clap_complete::shells::Bash, &mut cmd, bin, &mut buf);
}
Shell::Zsh => {
clap_complete::generate(clap_complete::shells::Zsh, &mut cmd, bin, &mut buf);
}
Shell::Fish => {
clap_complete::generate(clap_complete::shells::Fish, &mut cmd, bin, &mut buf);
}
}
if let Some(path) = &args.output {
std::fs::write(path, &buf)
.with_context(|| format!("writing completion script to '{}'", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).with_context(
|| format!("chmod 0o644 on completion script '{}'", path.display()),
)?;
}
eprintln!("wrote {} completion script to '{}'", args.shell.name(), path.display());
} else {
std::io::Write::write_all(&mut std::io::stdout(), &buf)
.context("writing completion script to stdout")?;
if std::io::stdout().is_terminal() {
eprintln!();
eprintln!("{}", args.shell.install_hint());
}
}
Ok(())
}
impl Shell {
const fn name(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
Self::Fish => "fish",
}
}
const fn install_hint(self) -> &'static str {
match self {
Self::Bash => {
"# install: add to ~/.bashrc (or /etc/bash_completion.d/):\n\
# source <(secretenv completions bash)"
}
Self::Zsh => {
"# install (replace PATH with a directory in your fpath):\n\
# secretenv completions zsh > \"$HOME/.zsh/completions/_secretenv\"\n\
# then ensure your ~/.zshrc has:\n\
# fpath=(~/.zsh/completions $fpath)\n\
# autoload -U compinit && compinit"
}
Self::Fish => {
"# install:\n\
# secretenv completions fish > \"$HOME/.config/fish/completions/secretenv.fish\""
}
}
}
}
const _: fn() = || {
let _ = <RegistrySelection as FromStr>::from_str;
};
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::collections::HashMap;
use secretenv_core::{BackendConfig, RegistryConfig};
use super::*;
fn config_with_default() -> Config {
Config {
registries: HashMap::from([(
"default".to_owned(),
RegistryConfig { sources: vec!["local:///tmp/r.toml".to_owned()] },
)]),
backends: HashMap::from([(
"local".to_owned(),
BackendConfig { backend_type: "local".into(), raw_fields: HashMap::new() },
)]),
}
}
#[test]
fn selection_prefers_explicit_flag() {
let cfg = config_with_default();
let sel = resolve_selection(Some("prod"), None, &cfg).unwrap();
match sel {
RegistrySelection::Name(n) => assert_eq!(n, "prod"),
RegistrySelection::Uri(_) => panic!("expected Name"),
}
}
#[test]
fn selection_uses_env_when_flag_absent() {
let cfg = config_with_default();
let sel = resolve_selection(None, Some("shared"), &cfg).unwrap();
match sel {
RegistrySelection::Name(n) => assert_eq!(n, "shared"),
RegistrySelection::Uri(_) => panic!("expected Name"),
}
}
#[test]
fn selection_falls_back_to_default_when_no_flag_or_env() {
let cfg = config_with_default();
let sel = resolve_selection(None, None, &cfg).unwrap();
match sel {
RegistrySelection::Name(n) => assert_eq!(n, "default"),
RegistrySelection::Uri(_) => panic!("expected Name"),
}
}
#[test]
fn selection_errors_when_nothing_configured() {
let cfg = Config::default();
let err = resolve_selection(None, None, &cfg).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no registry selected"), "clear error: {msg}");
}
#[test]
fn selection_interprets_triple_slash_as_uri() {
let cfg = Config::default();
let sel = resolve_selection(Some("local:///tmp/r.toml"), None, &cfg).unwrap();
match sel {
RegistrySelection::Uri(u) => assert_eq!(u.scheme, "local"),
RegistrySelection::Name(_) => panic!("expected Uri"),
}
}
#[test]
fn selection_treats_empty_env_as_absent() {
let cfg = config_with_default();
let sel = resolve_selection(None, Some(""), &cfg).unwrap();
match sel {
RegistrySelection::Name(n) => assert_eq!(n, "default"),
RegistrySelection::Uri(_) => panic!("expected Name"),
}
}
#[test]
fn serialize_registry_produces_toml_for_local() {
let mut m = BTreeMap::new();
m.insert("k".to_owned(), "aws-ssm:///v".to_owned());
let s = serialize_registry("local", &m).unwrap();
assert!(s.contains("k = \"aws-ssm:///v\""), "TOML shape: {s}");
}
#[test]
fn serialize_registry_produces_json_for_aws_ssm() {
let mut m = BTreeMap::new();
m.insert("k".to_owned(), "aws-ssm:///v".to_owned());
let s = serialize_registry("aws-ssm", &m).unwrap();
assert!(s.starts_with('{'), "JSON shape: {s}");
assert!(s.contains("\"k\""));
}
#[test]
fn serialize_registry_rejects_unknown_type() {
let m = BTreeMap::new();
let err = serialize_registry("unknown-backend", &m).unwrap_err();
assert!(format!("{err:#}").contains("not supported"));
}
#[test]
fn serialize_registry_is_alphabetical_regardless_of_insertion_order() {
let mut m = BTreeMap::new();
m.insert("zeta".to_owned(), "local:///z".to_owned());
m.insert("alpha".to_owned(), "local:///a".to_owned());
m.insert("mu".to_owned(), "local:///m".to_owned());
let toml_out = serialize_registry("local", &m).unwrap();
let alpha = toml_out.find("alpha").unwrap();
let mu = toml_out.find("mu").unwrap();
let zeta = toml_out.find("zeta").unwrap();
assert!(alpha < mu && mu < zeta, "TOML not alphabetical: {toml_out}");
let json_out = serialize_registry("aws-ssm", &m).unwrap();
let j_alpha = json_out.find("alpha").unwrap();
let j_mu = json_out.find("mu").unwrap();
let j_zeta = json_out.find("zeta").unwrap();
assert!(j_alpha < j_mu && j_mu < j_zeta, "JSON not alphabetical: {json_out}");
}
}