use std::io::IsTerminal;
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
use crate::sops::FileType;
#[derive(Debug, Parser)]
#[command(name = "sopsy")]
#[command(version)]
#[command(about = "The missing developer experience for SOPS")]
#[command(propagate_version = true)]
#[command(max_term_width = 80)]
pub struct Cli {
#[command(flatten)]
pub global: GlobalArgs,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Args, Clone)]
pub struct GlobalArgs {
#[arg(long, short = 'y', visible_alias = "yes", global = true)]
pub non_interactive: bool,
#[arg(long, global = true)]
pub no_color: bool,
#[arg(long, short = 'v', global = true)]
pub verbose: bool,
}
impl GlobalArgs {
pub fn resolve_interactive(&self) -> bool {
!self.non_interactive && std::io::stdout().is_terminal()
}
pub fn resolve_color(&self) -> bool {
!self.no_color
}
}
#[derive(Debug, Subcommand)]
pub enum Command {
Init(InitArgs),
Doctor,
Edit(EditArgs),
#[command(visible_alias = "request-access")]
Join(JoinArgs),
Approve(ApproveArgs),
#[command(subcommand)]
Recipient(RecipientCommand),
#[command(subcommand)]
Secrets(SecretsCommand),
ListSupportedTypes,
Check,
Deps(DepsArgs),
Completion(CompletionArgs),
}
#[derive(Debug, Args)]
pub struct InitArgs {
#[arg(long)]
pub recipient_name: Option<String>,
#[arg(long)]
pub username: Option<String>,
#[arg(long)]
pub public_key: Option<String>,
#[arg(long)]
pub no_generate: bool,
#[arg(long, conflicts_with = "no_break_glass")]
pub break_glass: bool,
#[arg(long)]
pub no_break_glass: bool,
#[arg(long)]
pub force: bool,
}
#[derive(Debug, Args)]
pub struct EditArgs {
pub file: PathBuf,
#[arg(long)]
pub editor: Option<String>,
#[arg(last = true)]
pub sops_args: Vec<String>,
}
#[derive(Debug, Args)]
pub struct JoinArgs {
pub name: String,
#[arg(long)]
pub sopsy_file: Option<PathBuf>,
#[arg(long)]
pub public_key: Option<String>,
#[arg(last = true)]
pub age_args: Vec<String>,
}
#[derive(Debug, Args)]
pub struct ApproveArgs {
#[arg(num_args = 0..)]
pub names: Vec<String>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub no_updatekeys: bool,
}
#[derive(Debug, Subcommand)]
pub enum SecretsCommand {
Encrypt(SecretsEncryptArgs),
Decrypt(SecretsDecryptArgs),
}
#[derive(Debug, Args)]
pub struct SecretsEncryptArgs {
pub file: PathBuf,
#[arg(short = 'o', long = "output")]
pub output: Option<PathBuf>,
#[arg(long = "type", value_enum)]
pub file_type: Option<FileType>,
}
#[derive(Debug, Args)]
pub struct SecretsDecryptArgs {
pub file: PathBuf,
#[arg(short = 'o', long = "output")]
pub output: Option<PathBuf>,
#[arg(long = "type", value_enum)]
pub file_type: Option<FileType>,
}
#[derive(Debug, Args)]
pub struct DepsArgs {
#[arg(long)]
pub check: bool,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Args)]
pub struct CompletionArgs {
#[arg(value_enum)]
pub shell: clap_complete::Shell,
}
#[derive(Debug, Subcommand)]
pub enum RecipientCommand {
Add(RecipientAddArgs),
Remove(RecipientRemoveArgs),
List,
Keygen(RecipientKeygenArgs),
BreakGlass(RecipientBreakGlassArgs),
}
#[derive(Debug, Args)]
pub struct RecipientKeygenArgs {
#[arg(last = true)]
pub age_args: Vec<String>,
}
#[derive(Debug, Args)]
pub struct RecipientBreakGlassArgs {
#[arg(short = 'o', long = "output")]
pub output: PathBuf,
#[arg(long = "name")]
pub name: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub no_updatekeys: bool,
}
#[derive(Debug, Args)]
pub struct RecipientAddArgs {
pub name_pos: Option<String>,
#[arg(long = "name")]
pub name: Option<String>,
#[arg(long)]
pub public_key: Option<String>,
#[arg(long)]
pub break_glass: bool,
#[arg(long)]
pub no_updatekeys: bool,
}
impl RecipientAddArgs {
pub fn resolved_name(&self) -> Option<&str> {
self.name.as_deref().or(self.name_pos.as_deref())
}
}
#[derive(Debug, Args)]
pub struct RecipientRemoveArgs {
pub name_pos: Option<String>,
#[arg(long = "name")]
pub name: Option<String>,
#[arg(long)]
pub no_updatekeys: bool,
}
impl RecipientRemoveArgs {
pub fn resolved_name(&self) -> Option<&str> {
self.name.as_deref().or(self.name_pos.as_deref())
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn parses_recipient_add_with_flags() {
let cli = Cli::try_parse_from([
"sopsy",
"--non-interactive",
"recipient",
"add",
"--name",
"alice",
"--public-key",
"age1alice",
])
.unwrap();
assert!(cli.global.non_interactive);
match cli.command {
Command::Recipient(RecipientCommand::Add(args)) => {
assert_eq!(args.resolved_name(), Some("alice"));
assert_eq!(args.public_key.as_deref(), Some("age1alice"));
}
_ => panic!("expected recipient add"),
}
}
#[test]
fn yes_alias_enables_non_interactive() {
let cli = Cli::try_parse_from(["sopsy", "-y", "doctor"]).unwrap();
assert!(cli.global.non_interactive);
}
#[test]
fn request_access_is_an_alias_for_join() {
let cli = Cli::try_parse_from(["sopsy", "request-access", "annie"]).unwrap();
assert!(matches!(cli.command, Command::Join(args) if args.name == "annie"));
}
#[test]
fn approve_accepts_multiple_names() {
let cli = Cli::try_parse_from(["sopsy", "approve", "annie", "colin"]).unwrap();
assert!(matches!(cli.command, Command::Approve(args) if args.names == ["annie", "colin"]));
}
#[test]
fn approve_accepts_no_names_for_interactive_mode() {
let cli = Cli::try_parse_from(["sopsy", "approve"]).unwrap();
assert!(matches!(cli.command, Command::Approve(args) if args.names.is_empty()));
}
#[test]
fn approve_keeps_multiword_names_intact() {
let cli =
Cli::try_parse_from(["sopsy", "approve", "Konstantin Gredeskoul", "Colin Powell"])
.unwrap();
assert!(matches!(
cli.command,
Command::Approve(args) if args.names == ["Konstantin Gredeskoul", "Colin Powell"]
));
}
}