From 1b223fe4f9fe2c95f1cf246a140fc6d8ea3a3b77 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 08:16:29 -0700 Subject: [PATCH] feat(cli): authforgectl subcommand structure with clap derive (Task C1) --- cli/src/bus.rs | 2 + cli/src/commands/mod.rs | 7 ++ cli/src/main.rs | 170 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 cli/src/bus.rs create mode 100644 cli/src/commands/mod.rs diff --git a/cli/src/bus.rs b/cli/src/bus.rs new file mode 100644 index 0000000..e5d75f1 --- /dev/null +++ b/cli/src/bus.rs @@ -0,0 +1,2 @@ +// Phase 3+6+7 plan: D-Bus client wrapper. Stubbed in Task C1; real methods +// land in Task C2. diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs new file mode 100644 index 0000000..a938077 --- /dev/null +++ b/cli/src/commands/mod.rs @@ -0,0 +1,7 @@ +use anyhow::Result; + +pub(crate) async fn dispatch(_json: bool, cmd: super::Cmd) -> Result<()> { + eprintln!("authforgectl: subcommand dispatcher not yet wired in this build"); + let _ = cmd; + Ok(()) +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 52837d2..669ddea 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,27 +1,173 @@ use anyhow::Result; -use clap::Parser; +use clap::{Parser, Subcommand}; + +mod bus; +mod commands; #[derive(Parser)] -#[command( - name = "authforgectl", - version, - about = "Manage authforge configuration" -)] +#[command(name = "authforgectl", version, about = "Manage authforge configuration")] struct Cli { + /// Emit machine-parseable JSON. + #[arg(long, global = true)] + json: bool, + #[command(subcommand)] cmd: Cmd, } -#[derive(clap::Subcommand)] +#[derive(Subcommand)] enum Cmd { + /// Show daemon + policy status. Status, + /// Enroll a security key for a user (interactive on the daemon side). + Enroll { + #[arg(long)] + user: Option, + #[arg(long)] + nickname: Option, + }, + /// List enrolled credentials for a user. + List { + #[arg(long)] + user: Option, + }, + /// Remove a credential by ID. + Remove { + #[arg(long)] + user: Option, + cred_id: String, + }, + /// Manage MFA policy. + Policy { + #[command(subcommand)] + cmd: PolicyCmd, + }, + /// Manage first-login pending flags. + Pending { + #[command(subcommand)] + cmd: PendingCmd, + }, + /// Manage recovery codes. + Recovery { + #[command(subcommand)] + cmd: RecoveryCmd, + }, +} + +#[derive(Subcommand)] +enum PolicyCmd { + /// Print the merged on-disk policy. + Show, + /// Set a stack's mode and methods. + Set { + stack: String, + /// disabled | optional | required + mode: String, + #[arg(long, value_delimiter = ',')] + methods: Vec, + }, + /// Re-apply the current policy. + Apply { + #[arg(long = "force-i-know-what-im-doing")] + force: bool, + }, + /// Validate the policy without applying. + Validate, +} + +#[derive(Subcommand)] +enum PendingCmd { + Set { + user: String, + #[arg(long, value_delimiter = ',')] + methods: Vec, + }, + Clear { + user: String, + }, + List, +} + +#[derive(Subcommand)] +enum RecoveryCmd { + Generate { user: String }, + List { user: String }, } #[tokio::main] async fn main() -> Result<()> { - let args = Cli::parse(); - match args.cmd { - Cmd::Status => println!("authforgectl: not yet implemented"), - } - Ok(()) + let cli = Cli::parse(); + commands::dispatch(cli.json, cli.cmd).await +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn cli_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn parse_status_subcommand() { + let c = Cli::try_parse_from(["authforgectl", "status"]).unwrap(); + assert!(matches!(c.cmd, Cmd::Status)); + } + + #[test] + fn parse_policy_set_with_methods() { + let c = Cli::try_parse_from([ + "authforgectl", + "policy", + "set", + "sudo", + "required", + "--methods", + "fido2,totp", + ]) + .unwrap(); + match c.cmd { + Cmd::Policy { + cmd: + PolicyCmd::Set { + stack, + mode, + methods, + }, + } => { + assert_eq!(stack, "sudo"); + assert_eq!(mode, "required"); + assert_eq!(methods, vec!["fido2".to_string(), "totp".to_string()]); + } + _ => panic!("wrong subcommand"), + } + } + + #[test] + fn parse_enroll_with_user_and_nickname() { + let c = Cli::try_parse_from([ + "authforgectl", + "enroll", + "--user", + "alice", + "--nickname", + "Yellow", + ]) + .unwrap(); + match c.cmd { + Cmd::Enroll { user, nickname } => { + assert_eq!(user.as_deref(), Some("alice")); + assert_eq!(nickname.as_deref(), Some("Yellow")); + } + _ => panic!("wrong subcommand"), + } + } + + #[test] + fn json_flag_is_global() { + let c = Cli::try_parse_from(["authforgectl", "--json", "status"]).unwrap(); + assert!(c.json); + } }